mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 10:56:02 +00:00
* feat(frontend): render slash-skill activations as inline chips Show an explicit `/skill` activation as a compact inline chip in both the composer and the chat transcript instead of raw slash text. - Composer: selecting a skill suggestion stores it as a removable chip aligned inline with the textarea; the leading `/skill ` prefix is reattached only at submit time, so the backend activation protocol is unchanged. Backspace on an empty input or the chip's close button clears it; history navigation is disabled while a chip is active. - Transcript: human messages that begin with `/skill` render the skill as a read-only chip followed by the task text. - Add a shared `core/skills/slash.ts` (`parseSlashSkillReference` + `resolveSlashSkillDisplay`) mirroring the backend `slash.py` gate, so the transcript only shows a chip when the skill actually exists and is enabled. This removes a duplicated regex/reserved-name list and keeps display semantics consistent with backend activation. Add unit tests for the shared slash parser and extend the chat e2e to assert the composer still submits `/skill <task>` after showing a chip. * chore(frontend): format chat e2e test * refactor(skills): address slash-skill chip review feedback Follow-up to the inline slash-skill chip PR, resolving three second-order review findings: - Drive the reserved-command set and skill-name grammar from a shared contracts/slash_skill_contract.json instead of a hand-copied "keep in sync" pair. slash.ts and slash.py now reference the fixture, and contract tests on both sides fail CI if either drifts. - Extract a shared SlashSkillChip so the composer and transcript chips stay in lockstep, and normalize the off-scale /8 and /12 opacity steps to the standard /10 and /20 tokens. - Split HumanMessageText into a pure parse gate plus a slash-only subtree that owns the useSkills() lookup, so a skill-enabled toggle no longer re-renders every plain-text human turn. Verified: frontend eslint + tsc clean, pnpm test 572 pass (incl. new slash-contract test); backend slash contract + slash-skills tests 31 pass. * style(tests): sort slash skill contract imports * fix(composer): inline the slash-skill text so the chip aligns with input Address the "composer body layout change" review on #3981 by rendering the active skill as an inline chip in the same text flow as the prompt, rather than a separate flex row that drifted the box model across states. - Render the chip + prompt inside one leading-6 wrapper and edit the prompt through a `contentEditable` span, so the chip sits inline with the first line and long/multi-line input wraps naturally back to the container edge. - Align the chip with `align-top`: its h-6 (24px) height matches the text line height, so chip and first-line centers coincide exactly (measured delta 0), fixing the chip being raised above the baseline. - Restore the placeholder in chip mode via a `data-empty` CSS `::before`, which also gives the empty editable span width so it is no longer treated as hidden. - Widen the IME helper to `HTMLElement` and route the span's keydown/paste through the shared skill-suggestion, prompt-history, backspace-to-clear, and IME-composition handlers so contentEditable behaves like the textarea. - Extend chat.spec.ts to drive the inline skill editor instead of the textarea after a chip is shown. * style(frontend): fix composer class order formatting * fix(composer): break long unbroken input inside the slash-skill row The inline slash-skill editor wrapped with `break-words` (overflow-wrap: break-word), which only moves an over-long token to the next line before breaking it. A long unbroken string therefore started on the line below the chip, and when the string contained a break opportunity such as a hyphen the browser wrapped there and pushed the remaining run to the next line, leaving a wide gap on the right. Switch to `break-all` (word-break: break-all) so the text fills each line from the chip and packs tightly regardless of hyphens or CJK.
75 lines
2.5 KiB
Python
75 lines
2.5 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
|
|
from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH
|
|
from deerflow.skills.types import Skill
|
|
|
|
#: Composer control commands that own the leading slash and must never be
|
|
#: treated as ``/skill`` activations. These values plus :data:`_SLASH_SKILL_RE`
|
|
#: are mirrored by the frontend display parser in
|
|
#: ``frontend/src/core/skills/slash.ts``; both sides are pinned to the shared
|
|
#: fixture at ``contracts/slash_skill_contract.json`` by contract tests
|
|
#: (``tests/test_slash_skill_contract.py`` here, ``slash-contract.test.ts`` on
|
|
#: the frontend), so a reserved command or grammar change in only one language
|
|
#: fails CI.
|
|
RESERVED_SLASH_SKILL_NAMES = frozenset({"bootstrap", "goal", "help", "memory", "models", "new", "status"})
|
|
_SLASH_SKILL_RE = re.compile(r"^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SlashSkillReference:
|
|
"""Parsed slash-skill command with the skill name and remaining task text."""
|
|
|
|
name: str
|
|
remaining_text: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ResolvedSlashSkill:
|
|
"""Slash-skill activation resolved against enabled runtime-visible skills."""
|
|
|
|
skill: Skill
|
|
remaining_text: str
|
|
container_file_path: str
|
|
|
|
|
|
def parse_slash_skill_reference(text: str) -> SlashSkillReference | None:
|
|
"""Parse strict `/skill-name task` syntax, ignoring reserved control commands."""
|
|
match = _SLASH_SKILL_RE.match(text)
|
|
if not match:
|
|
return None
|
|
name = match.group(1)
|
|
if name in RESERVED_SLASH_SKILL_NAMES:
|
|
return None
|
|
return SlashSkillReference(
|
|
name=name,
|
|
remaining_text=text[match.end() :].lstrip(),
|
|
)
|
|
|
|
|
|
def resolve_slash_skill(
|
|
text: str,
|
|
skills: list[Skill],
|
|
*,
|
|
available_skills: set[str] | None = None,
|
|
container_base_path: str = DEFAULT_SKILLS_CONTAINER_PATH,
|
|
) -> ResolvedSlashSkill | None:
|
|
"""Resolve text into an enabled, whitelisted skill activation if possible."""
|
|
reference = parse_slash_skill_reference(text)
|
|
if reference is None:
|
|
return None
|
|
if available_skills is not None and reference.name not in available_skills:
|
|
return None
|
|
|
|
skill = next((candidate for candidate in skills if candidate.name == reference.name and candidate.enabled), None)
|
|
if skill is None:
|
|
return None
|
|
|
|
return ResolvedSlashSkill(
|
|
skill=skill,
|
|
remaining_text=reference.remaining_text,
|
|
container_file_path=skill.get_container_file_path(container_base_path),
|
|
)
|