From 07259e485e11af7969f7607132a9e34e18472367 Mon Sep 17 00:00:00 2001 From: chujieHong <2280628443@qq.com> Date: Sun, 16 Aug 2026 15:04:46 +0800 Subject: [PATCH 01/33] feat(skills): allow Unicode skill names with filesystem-safe validation The create-form name pattern rejected CJK and other non-ASCII input, while source mode (and the recording workflow, which derives names from user titles) accepted them - the backend slug validator only rejects path-hostile names. Octop targets non-technical users, so the name field should not force ASCII slug conventions. - Replace the ASCII whitelist with a blacklist: reject only filesystem-hostile characters (/ \ : * ? " < > |, control chars), a leading dot, empty names, and names over 64 chars - Apply the same check to source-mode creation (frontmatter name), aligning both editor modes - Skip the check on edit: updateSkill keeps the existing slug, so legacy skills with odd names stay editable - Update zh/en namePattern copy and the placeholder examples --- dashboard/src/locales/en.json | 4 +-- dashboard/src/locales/zh.json | 4 +-- .../Skills/components/SkillDrawer.test.ts | 18 ++++++++++++ .../Agent/Skills/components/SkillDrawer.tsx | 29 +++++++++++++++++-- 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index be84f34d..76cd1b1d 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -1298,7 +1298,7 @@ "skillContent": "Skill Content", "pleaseInputName": "Please input skill name", "pleaseInputContent": "Please input skill content", - "skillNamePlaceholder": "e.g., weather_query", + "skillNamePlaceholder": "e.g., weather-analysis / 天气查询", "contentPlaceholder": "---\nname: (required)\ndescription: (required)\nmetadata: { \"octop\": { \"emoji\": \"🔧\" } }\n---\n\nSkill implementation content...\n\n# Example:\n# ---\n# name: cron\n# description: Manage cron jobs via octop commands - create, query, pause, resume, delete tasks\n# metadata: { \"octop\": { \"emoji\": \"⏰\" } }\n# ---", "createSuccess": "Skill created successfully", "importSuccess": "Skill imported successfully", @@ -1365,7 +1365,7 @@ "disableBeforeDelete": "Disable the skill before deleting it", "applyNow": "Apply now", "nameLabel": "Name", - "namePattern": "Only letters / digits / . _ - are allowed", + "namePattern": "Unicode names (e.g. Chinese) are allowed, but not / \\ : * ? \" < > |, a leading dot, or over 64 characters", "sourceLabel": "Source", "pathLabel": "Path", "metadataLabel": "Metadata", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 97ea00e9..a1c84a9a 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -1296,7 +1296,7 @@ "skillContent": "技能内容", "pleaseInputName": "请输入技能名称", "pleaseInputContent": "请输入技能内容", - "skillNamePlaceholder": "例如:weather_query", + "skillNamePlaceholder": "例如:天气查询 / weather-query", "contentPlaceholder": "---\nname: <技能名称>(必填)\ndescription: <技能功能描述>(必填)\nmetadata: { \"octop\": { \"emoji\": \"🔧\" } }\n---\n\n技能实现内容...\n\n# 示例:\n# ---\n# name: cron\n# description: 通过 octop 命令管理定时任务 - 创建、查询、暂停、恢复、删除任务\n# metadata: { \"octop\": { \"emoji\": \"⏰\" } }\n# ---", "createSuccess": "技能创建成功", "importSuccess": "技能导入成功", @@ -1363,7 +1363,7 @@ "disableBeforeDelete": "请先禁用该技能后再删除", "applyNow": "立即应用", "nameLabel": "名称", - "namePattern": "仅支持字母 / 数字 / . _ -", + "namePattern": "名称支持中文等 Unicode 字符,但不能包含 / \\ : * ? \" < > | 等特殊字符,且不能以 . 开头(最长 64 字符)", "sourceLabel": "来源", "pathLabel": "路径", "metadataLabel": "Metadata", diff --git a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.test.ts b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.test.ts index 47610dec..35768f7e 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.test.ts +++ b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.test.ts @@ -1,10 +1,28 @@ import { describe, expect, it } from "vitest"; import { buildSkillMarkdown, + isValidSkillName, OCTOP_EMOJI_META_KEY, parseSkillEmojiAndMetadata, } from "./SkillDrawer"; +describe("isValidSkillName", () => { + it("accepts CJK, letters, digits and . _ -", () => { + expect(isValidSkillName("天气查询")).toBe(true); + expect(isValidSkillName("weather-analysis")).toBe(true); + expect(isValidSkillName("weather_query.v2")).toBe(true); + }); + + it("rejects filesystem-hostile characters and leading dot", () => { + expect(isValidSkillName(".hidden")).toBe(false); + expect(isValidSkillName("a/b")).toBe(false); + expect(isValidSkillName("a\\b")).toBe(false); + expect(isValidSkillName('a:b*c?d"eg|h')).toBe(false); + expect(isValidSkillName("")).toBe(false); + expect(isValidSkillName("x".repeat(65))).toBe(false); + }); +}); + describe("SkillDrawer emoji metadata", () => { it("writes octop.emoji into frontmatter from the emoji field", () => { const md = buildSkillMarkdown({ diff --git a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx index bd9aa1c3..219e9c2b 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx @@ -44,6 +44,17 @@ export interface SkillFormValues { export const OCTOP_EMOJI_META_KEY = "octop.emoji"; +/** + * The skill name doubles as the workspace directory slug, so only + * filesystem-hostile characters are rejected - CJK and other Unicode + * letters are allowed. + */ +const SKILL_NAME_PATTERN = /^(?!\.)[^\\/:*?"<>|\x00-\x1f]{1,64}$/; + +export function isValidSkillName(name: string): boolean { + return SKILL_NAME_PATTERN.test(name.trim()); +} + function yamlQuote(value: string): string { if (!value) return '""'; if (/[:#\n"'{}[\],&*?|>!%@`]/.test(value) || value.trim() !== value) { @@ -401,9 +412,16 @@ export function SkillDrawer({ } const { body } = splitMarkdownFrontmatter(content); const fm = splitMarkdownFrontmatter(content).raw ?? ""; + const name = yamlTopLevel(fm, "name") || values.name; + // Edits keep the existing slug (updateSkill ignores the name), so only + // creation needs the slug check - legacy skills with odd names stay editable. + if (isCreate && !isValidSkillName(name)) { + message.warning(t("skills.namePattern")); + return; + } onSubmit({ ...values, - name: yamlTopLevel(fm, "name") || values.name, + name, description: yamlTopLevel(fm, "description") || values.description, body, content, @@ -431,8 +449,13 @@ export function SkillDrawer({ ? [ { required: true, message: t("skills.pleaseInputName") }, { - pattern: /^[a-zA-Z0-9._-]+$/, - message: t("skills.namePattern"), + validator: (_: unknown, value: string) => { + // Empty is reported by the required rule above. + if (!String(value ?? "").trim()) return Promise.resolve(); + return isValidSkillName(String(value)) + ? Promise.resolve() + : Promise.reject(new Error(t("skills.namePattern"))); + }, }, ] : undefined From 0f8e7b4f3555845e15ee17506848bb1a918139e0 Mon Sep 17 00:00:00 2001 From: Georgyhongbo <2280628443@qq.com> Date: Fri, 14 Aug 2026 21:43:01 +0800 Subject: [PATCH 02/33] fix(chat): strip media-offload placeholder text from image history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MediaOffloadMiddleware writes a '[image offloaded: sha=… path=… size=…B mime=…; use read_file to retrieve bytes]' placeholder text block into LangGraph checkpoint state on every turn after the first one. That text is meant for the LLM to read_file the bytes back, not for the dashboard UI — after leaving and re-entering a chat, the user's image showed this internal text underneath it. On history serialization, strip the offload placeholder and the LLM-only 'User sent an image.' sentinel for user messages that carry an image in octop_inbound_attachments (the original image is rendered from there). The user's own caption is preserved. Pure-image user messages keep their entry (with empty content) so the dashboard still renders the attachment. --- src/octop/api/routers/chat/serialize.py | 105 ++++++++++++- tests/unit/api/test_chat_polish.py | 188 ++++++++++++++++++++++++ 2 files changed, 288 insertions(+), 5 deletions(-) diff --git a/src/octop/api/routers/chat/serialize.py b/src/octop/api/routers/chat/serialize.py index f78ecbfc..c1da2f44 100644 --- a/src/octop/api/routers/chat/serialize.py +++ b/src/octop/api/routers/chat/serialize.py @@ -11,11 +11,13 @@ from typing import Any from octop.api.common.agent_workspace import resolve_agent_workspace_dir +from octop.i18n.domains.attachment import attachment_empty_image from octop.infra.gateway.process.message_keys import ( COMPOSER_CTX_KEY, INBOUND_ATTACHMENTS_KEY, ) from octop.infra.utils.llm_text import strip_thinking as _strip_thinking +from octop.infra.utils.locale import normalize_locale logger = logging.getLogger(__name__) @@ -24,6 +26,21 @@ re.IGNORECASE, ) +# Matches the lightweight placeholder that ``MediaOffloadMiddleware`` writes +# into LangGraph state for already-offloaded inline images / audio. Format +# (see harness_agent.middleware.media_offload._placeholder_text_block): +# [ offloaded: sha= path= size=B mime=; +# use read_file to retrieve bytes] +# We strip these on history serialization because the original bytes are +# still available via ``inbound_attachments`` and the dashboard renders the +# thumbnail from there — leaving the placeholder visible made the chat show +# a "[image offloaded: sha=… path=…]" line under every user image after the +# second turn (when the middleware first re-encountered the block). +_OFFLOAD_PLACEHOLDER_RE = re.compile( + r"^\s*\[\s*(?:image|audio)\s+offloaded\s*:", + re.IGNORECASE, +) + # Must match harness_agent.agent.CHECKPOINT_TS_KEY (epoch-ms in additional_kwargs). CHECKPOINT_TS_KEY = "checkpoint_ts" @@ -35,6 +52,73 @@ def _clamp_history_limit(limit: int) -> int: return max(1, min(limit, HISTORY_MAX_LIMIT)) +def _is_offload_placeholder_block(block: Any) -> bool: + """True when *block* is a ``MediaOffloadMiddleware`` placeholder. + + The middleware rewrites an inline image/audio block into a single text + block of the form ``[image offloaded: sha=… path=… size=…B mime=…; use + read_file to retrieve bytes]`` on every turn after the first one. We + must not surface that text in the dashboard: the original attachment + is still available via ``inbound_attachments`` and the UI renders the + image from there. Showing the placeholder underneath is a UX bug. + """ + if not isinstance(block, dict): + return False + if str(block.get("type") or "").lower() != "text": + return False + text = str(block.get("text") or "") + return bool(_OFFLOAD_PLACEHOLDER_RE.match(text)) + + +def _user_message_has_image_attachment(additional_kwargs: Any) -> bool: + """True if the persisted ``INBOUND_ATTACHMENTS_KEY`` carries any image.""" + if not isinstance(additional_kwargs, dict): + return False + raw = additional_kwargs.get(INBOUND_ATTACHMENTS_KEY) + if not isinstance(raw, list): + return False + for item in raw: + if not isinstance(item, dict): + continue + kind = str(item.get("kind") or "").lower() + media_type = str(item.get("media_type") or item.get("mediaType") or "") + if kind == "image" or media_type.lower().startswith("image/"): + return True + return False + + +def _strip_image_only_text_blocks( + blocks: list[dict[str, Any]], + *, + locale: str, +) -> list[dict[str, Any]]: + """Drop placeholders + the LLM-facing "User sent an image." sentinel. + + Only safe when the original image is also being delivered to the + dashboard via ``inbound_attachments``; if not, removing the text + would make a pure-image turn look empty in the UI. + """ + empty_image = attachment_empty_image(normalize_locale(locale)).strip() + out: list[dict[str, Any]] = [] + for block in blocks: + if _is_offload_placeholder_block(block): + continue + if ( + empty_image + and isinstance(block, dict) + and str(block.get("type") or "").lower() == "text" + and str(block.get("text") or "").strip() == empty_image + ): + continue + out.append(block) + return out + + +def _user_locale(user: Any) -> str: + raw = getattr(user, "locale", None) if user is not None else None + return normalize_locale(str(raw) if raw else None) + + def _slice_message_page( raw: list[Any], *, @@ -112,7 +196,7 @@ async def _load_thread_messages( offset, ) for m in raw_messages: - entry = _serialize_history_message(m) + entry = _serialize_history_message(m, user=user) if entry is not None: out.append(entry) except Exception: @@ -474,7 +558,7 @@ def _split_string_thinking(text: str) -> list[dict[str, Any]]: return blocks -def _serialize_history_message(msg: Any) -> dict[str, Any] | None: +def _serialize_history_message(msg: Any, *, user: Any = None) -> dict[str, Any] | None: """Project a LangGraph checkpoint message into dashboard history shape.""" role = _message_role(msg) if role in ("system", ""): @@ -512,7 +596,19 @@ def _serialize_history_message(msg: Any) -> dict[str, Any] | None: if role == "assistant": blocks.extend(_tool_use_blocks(_msg_attr(msg, "tool_calls"))) - if not blocks: + raw_att = ( + additional_kwargs.get(INBOUND_ATTACHMENTS_KEY) + if isinstance(additional_kwargs, dict) + else None + ) + has_user_attachments = isinstance(raw_att, list) and bool(raw_att) + if role == "user" and _user_message_has_image_attachment(additional_kwargs): + # The original image is being delivered through inbound_attachments; + # the image/audio offload placeholders and the LLM-only "User sent + # an image." sentinel are redundant noise on the dashboard. + blocks = _strip_image_only_text_blocks(blocks, locale=_user_locale(user)) + + if not blocks and not (role == "user" and has_user_attachments): return None entry = {"role": role, "content": blocks} @@ -524,8 +620,7 @@ def _serialize_history_message(msg: Any) -> dict[str, Any] | None: raw_ctx = additional_kwargs.get(COMPOSER_CTX_KEY) if isinstance(raw_ctx, dict) and raw_ctx: entry["composer_context"] = raw_ctx - raw_att = additional_kwargs.get(INBOUND_ATTACHMENTS_KEY) - if isinstance(raw_att, list) and raw_att: + if has_user_attachments: entry["inbound_attachments"] = raw_att ts_ms = _extract_message_timestamp_ms(msg) if ts_ms is not None: diff --git a/tests/unit/api/test_chat_polish.py b/tests/unit/api/test_chat_polish.py index 7d9838f9..076881ab 100644 --- a/tests/unit/api/test_chat_polish.py +++ b/tests/unit/api/test_chat_polish.py @@ -2,6 +2,7 @@ from __future__ import annotations +from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -10,8 +11,10 @@ from octop.api.routers.chat.serialize import ( _entry_matches_thread, + _is_offload_placeholder_block, _merge_adjacent_messages, _serialize_history_message, + _strip_image_only_text_blocks, _strip_thinking, _ts_to_ms, ) @@ -181,6 +184,191 @@ def test_ts_to_ms_converts_seconds() -> None: assert _ts_to_ms(1_700_000_000.5) == 1_700_000_000_500 +# --------------------------------------------------------------------------- +# Image-offload placeholder filtering on history serialization +# --------------------------------------------------------------------------- + + +_PLACEHOLDER_TEXT = ( + "[image offloaded: sha=06bdd82d592a " + "path=C:\\Users\\me\\.octop\\agents\\DMQ318\\.media-cache/" + "06bdd82d592a5cd6371ccdb2ed49d347a4ffb0fca4b80ea57e991f51522e178e.png " + "size=173004B mime=image/png; use read_file to retrieve bytes]" +) + + +def test_is_offload_placeholder_block_matches_image_format() -> None: + assert _is_offload_placeholder_block({"type": "text", "text": _PLACEHOLDER_TEXT}) + # Same shape, but a 12-char short sha and trailing "]". + assert _is_offload_placeholder_block( + { + "type": "text", + "text": " [audio offloaded: sha=abcdef012345 path=/x.y size=1B mime=audio/mpeg; use read_file to retrieve bytes] ", + } + ) + + +def test_is_offload_placeholder_block_rejects_unrelated_text() -> None: + assert not _is_offload_placeholder_block({"type": "text", "text": "hello"}) + assert not _is_offload_placeholder_block( + {"type": "text", "text": "[image] some other bracket text"} + ) + # Image block (not text) is not a placeholder. + assert not _is_offload_placeholder_block( + {"type": "image_url", "image_url": {"url": "data:..."}} + ) + assert not _is_offload_placeholder_block("not a dict") + + +def test_serialize_history_message_drops_image_offload_placeholder_for_image_user() -> None: + msg = HumanMessage( + content=[ + {"type": "text", "text": "用户发送了图片。"}, + {"type": "text", "text": _PLACEHOLDER_TEXT}, + ], + additional_kwargs={ + "octop_inbound_attachments": [ + { + "filename": "image.png", + "media_type": "image/png", + "kind": "image", + "workspace_path": "inbound/123_image.png", + } + ], + }, + ) + user = SimpleNamespace(locale="zh") + entry = _serialize_history_message(msg, user=user) + assert entry is not None + # Both the localized "User sent an image." sentinel and the offload + # placeholder must be stripped — only the image (rendered from + # inbound_attachments) is meaningful on the dashboard. + assert entry["content"] == [] + # Attachments are still propagated for the frontend to render the image. + assert entry["inbound_attachments"][0]["workspace_path"] == "inbound/123_image.png" + + +def test_serialize_history_message_keeps_user_caption_alongside_image_placeholder() -> None: + msg = HumanMessage( + content=[ + {"type": "text", "text": "请帮我看看这张图"}, + {"type": "text", "text": _PLACEHOLDER_TEXT}, + ], + additional_kwargs={ + "octop_inbound_attachments": [ + { + "filename": "image.png", + "media_type": "image/png", + "kind": "image", + "workspace_path": "inbound/123_image.png", + } + ], + }, + ) + user = SimpleNamespace(locale="zh") + entry = _serialize_history_message(msg, user=user) + assert entry is not None + # Only the offload placeholder is dropped; the user-written caption + # is preserved verbatim so the dashboard still shows it. + assert entry["content"] == [{"type": "text", "text": "请帮我看看这张图"}] + + +def test_serialize_history_message_keeps_placeholder_when_no_image_attachment() -> None: + """Without inbound_attachments, the placeholder is the only sign of media.""" + msg = HumanMessage( + content=[{"type": "text", "text": _PLACEHOLDER_TEXT}], + ) + # No additional_kwargs → no inbound_attachments → no filtering. + entry = _serialize_history_message(msg, user=SimpleNamespace(locale="en")) + assert entry is not None + assert entry["content"] == [{"type": "text", "text": _PLACEHOLDER_TEXT}] + + +def test_serialize_history_message_drops_placeholder_for_en_locale() -> None: + msg = HumanMessage( + content=[ + {"type": "text", "text": "User sent an image."}, + {"type": "text", "text": _PLACEHOLDER_TEXT}, + ], + additional_kwargs={ + "octop_inbound_attachments": [ + { + "filename": "image.png", + "media_type": "image/png", + "kind": "image", + "workspace_path": "inbound/123_image.png", + } + ], + }, + ) + entry = _serialize_history_message(msg, user=SimpleNamespace(locale="en")) + assert entry is not None + assert entry["content"] == [] + + +def test_strip_image_only_text_blocks_without_user_skips_zh_default() -> None: + """Locale falls back to ``zh`` when no user is supplied.""" + msg = HumanMessage( + content=[ + {"type": "text", "text": "用户发送了图片。"}, + {"type": "text", "text": _PLACEHOLDER_TEXT}, + ], + additional_kwargs={ + "octop_inbound_attachments": [ + { + "filename": "image.png", + "media_type": "image/png", + "kind": "image", + "workspace_path": "inbound/x.png", + } + ], + }, + ) + # Pass no user — caller signature is ``user=None`` default. + entry = _serialize_history_message(msg) + assert entry is not None + assert entry["content"] == [] + + +def test_strip_image_only_text_blocks_keeps_voice_caption() -> None: + """A non-image attachment must not trigger placeholder stripping.""" + msg = HumanMessage( + content=[ + {"type": "text", "text": "请听这段录音"}, + {"type": "text", "text": _PLACEHOLDER_TEXT}, # NOT real, but tests shape + ], + additional_kwargs={ + "octop_inbound_attachments": [ + { + "filename": "voice.m4a", + "media_type": "audio/mp4", + "kind": "file", + "workspace_path": "inbound/voice.m4a", + } + ], + }, + ) + entry = _serialize_history_message(msg, user=SimpleNamespace(locale="zh")) + assert entry is not None + # The audio file is rendered from inbound_attachments, not as a + # placeholder text block, so the on-disk placeholder is the only + # signal — leave it alone. + assert entry["content"] == [ + {"type": "text", "text": "请听这段录音"}, + {"type": "text", "text": _PLACEHOLDER_TEXT}, + ] + + +def test_strip_image_only_text_blocks_directly() -> None: + blocks = [ + {"type": "text", "text": "用户发送了图片。"}, + {"type": "text", "text": _PLACEHOLDER_TEXT}, + {"type": "text", "text": "你好"}, + ] + out = _strip_image_only_text_blocks(blocks, locale="zh") + assert out == [{"type": "text", "text": "你好"}] + + @pytest.mark.asyncio async def test_load_checkpoint_messages_falls_back_to_graph_state() -> None: from octop.api.routers.chat.serialize import _load_checkpoint_messages From 420d5e26ff1e5f9e463c4fca350754cb4b1aac47 Mon Sep 17 00:00:00 2001 From: chujieHong <31946519+chujieHong@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:09:42 +0800 Subject: [PATCH 03/33] fix(dashboard): prevent AGENT_NOT_RUNNING race when saving page config (#293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing an expert's 页面配置 (welcome message + quick-start cards) and clicking 保存 surfaced "此专家未启用" (AGENT_NOT_RUNNING) on every save after the first. Two failures combined to cause it: 1. PATCH /agents/{aid} schedules a background harness reload (arebuild_agent = aremove_agent + slow acreate_agent compile, often 2-5s on Windows). During that window the agent is absent from the harness registry, but the DB row still says 'running'. 2. The manifest write was placed BEFORE the PATCH, so the first save landed before the reload started and worked. But every subsequent save (re-opening the drawer, expanding 页面配置, editing, saving again) hit the reload window from the previous PATCH — and require_running_workspace raised AGENT_NOT_RUNNING. The 'manifest before PATCH' ordering alone only protects the within-save race, not the cross-save one. Reproduction (live server, ENPKA2, 10 rapid saves with the WIP order): 2/10 manifest writes succeeded; 8/10 failed with AGENT_NOT_RUNNING. First save after a 3s wait: 10/10 succeeded — confirms the reload window is the cause. Fix: - Add writeManifestWithRetry (10 × 500ms = up to 5s, retries only on AGENT_NOT_RUNNING). Re-running for 5s is well within typical reload windows; the manifest is best-effort. - Wrap the manifest write in its own try/catch. On failure show a warning toast (yellow, with i18n key experts.manifestWriteFailed) so the user knows, but never block the PATCH — the agent's main config must always save. The drawer still closes and the success toast still fires after the PATCH. Scope of the change: - New: WelcomeConfig.tsx (the 页面配置 editor, used by EditAgentDrawer). - New: index.module.less styles for WelcomeConfig. - Modified: EditAgentDrawer.tsx (render the new collapse section, save the manifest with retry, fall back to a warning). - New i18n keys: experts.pageConfigTitle / welcomeMessageTitle / welcomeMessagePlaceholder / quickPromptsTitle / addQuickPrompt / quickPromptTitle(Placeholder) / quickPromptDescription(Placeholder) / quickPromptContent(Placeholder) / quickPromptColor / quickPromptIcon / quickPromptPreview / noQuickPrompts / noIcon / manifestWriteFailed in both zh.json and en.json; also backfilled experts.patchFailed in zh.json (was missing). Out of scope (pre-existing, surfaced during review): - WelcomeConfig's useImperativeHandle has no deps array — works correctly via closure but recreates the handle each render. - loadConfig() runs on mount; a click on 保存 during the load would write the empty initial state to manifest.json. Not addressed here. Verified: end-to-end against the live server, agent ENPKA2 and S35JZD, after a 3s settle the 2nd save succeeds on retry attempt 2 (~25ms after the first failed attempt), and 5 rapid back-to-back saves all land without surfacing a red error to the user. Co-authored-by: Georgyhongbo --- dashboard/src/locales/en.json | 17 + dashboard/src/locales/zh.json | 18 + .../Experts/components/EditAgentDrawer.tsx | 99 ++++- .../Experts/components/WelcomeConfig.tsx | 345 ++++++++++++++++++ dashboard/src/pages/Experts/index.module.less | 265 ++++++++++++++ 5 files changed, 740 insertions(+), 4 deletions(-) create mode 100644 dashboard/src/pages/Experts/components/WelcomeConfig.tsx diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 76cd1b1d..81940d9b 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -722,6 +722,23 @@ "skillPackagesLabel": "Skill Packages", "skillPackagesHint": "Mount selected packages when the agent is created. Currently supports only “Full local environment (filesystem + shell)” or “Local filesystem (no shell commands)”, and the storage root must be /.", "skillPackagesPlaceholder": "Select skill packages", + "manifestWriteFailed": "Page configuration could not be saved (the expert is reloading). It will be retried on the next save.", + "pageConfigTitle": "Page Configuration", + "welcomeMessageTitle": "Title", + "welcomeMessagePlaceholder": "Enter title shown for new chats", + "quickPromptsTitle": "Quick Start Cards", + "addQuickPrompt": "Add Card", + "quickPromptTitle": "Title", + "quickPromptTitlePlaceholder": "Enter card title", + "quickPromptDescription": "Description", + "quickPromptDescriptionPlaceholder": "Enter card description", + "quickPromptContent": "Prompt Content", + "quickPromptContentPlaceholder": "Enter prompt sent when card is clicked", + "quickPromptColor": "Color", + "quickPromptIcon": "Icon", + "quickPromptPreview": "Preview", + "noQuickPrompts": "No quick start cards yet, click the button above to add", + "noIcon": "No Icon", "iconLabels": { "sparkles": "Sparkles", "globe": "Globe", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index a1c84a9a..11af5bdd 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -722,6 +722,24 @@ "skillPackagesLabel": "技能包", "skillPackagesHint": "创建专家时挂载选中的技能包。目前仅支持「本地完整环境(文件系统+指令执行)」或「本地文件系统(不能执行指令)」,且存储根目录必须为 /。", "skillPackagesPlaceholder": "选择技能包", + "patchFailed": "保存失败", + "manifestWriteFailed": "页面配置未能保存(专家正在重新加载),将在下次保存时重试。", + "pageConfigTitle": "页面配置", + "welcomeMessageTitle": "标题语", + "welcomeMessagePlaceholder": "输入新建聊天时显示的标题语", + "quickPromptsTitle": "快速启动卡片", + "addQuickPrompt": "添加卡片", + "quickPromptTitle": "标题", + "quickPromptTitlePlaceholder": "输入卡片标题", + "quickPromptDescription": "描述", + "quickPromptDescriptionPlaceholder": "输入卡片描述", + "quickPromptContent": "提示内容", + "quickPromptContentPlaceholder": "输入点击卡片后发送的提示词", + "quickPromptColor": "颜色", + "quickPromptIcon": "图标", + "quickPromptPreview": "预览", + "noQuickPrompts": "暂无快速启动卡片,点击上方按钮添加", + "noIcon": "无图标", "iconLabels": { "sparkles": "闪光", "globe": "地球", diff --git a/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx b/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx index c1814bd3..5841c739 100644 --- a/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx +++ b/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx @@ -20,7 +20,7 @@ import { request } from "../../../api/request"; import { AgentAdvancedConfigFields } from "../../../components/AgentAdvancedConfigFields"; import ExpertColorPicker from "../../../components/ExpertColorPicker"; import { workspaceApi } from "../../../api/modules/workspace"; -import { apiErrorMessage } from "../../../utils/apiError"; +import { apiErrorMessage, parseApiError } from "../../../utils/apiError"; import { isAgentChatReady } from "../../../utils/agentError"; import { useAgentFormResources } from "../../../hooks/useAgentFormResources"; import type { OctopAgent } from "../../../context/AgentContext"; @@ -43,6 +43,7 @@ import { } from "../../../utils/agentRuntimeConfig"; import { useSkillDisplayName } from "../../Agent/Skills/skillDisplayNames"; import FileEditModal from "./FileEditModal"; +import WelcomeConfig, { type WelcomeConfigRef } from "./WelcomeConfig"; import { fetchConfigMdFiles } from "./expertFileGroups"; import { buildBackendSpec, @@ -139,6 +140,44 @@ interface EditAgentDrawerBodyProps { onSavingChange: (saving: boolean) => void; } +/** + * Write a workspace file, retrying briefly when the agent's harness is mid-reload. + * + * The PATCH /agents/{aid} endpoint schedules a background ``arebuild_agent`` + * that briefly removes the agent from the registry and then re-creates it + * (slow graph compile, often 2-5s on Windows). Workspace writes go through + * ``require_running_workspace`` which raises ``AGENT_NOT_RUNNING`` during + * that absence window. The very first save in a session usually lands + * before the reload starts, but a follow-up save (the user re-opens the + * drawer, edits 页面配置, and clicks save again) hits the reload window. + * Backing off 500ms up to 10 times (~5s) is enough for typical agents; the + * manifest is best-effort so we let the caller's catch surface a warning + * rather than block the save. + */ +async function writeManifestWithRetry( + agentId: string, + path: string, + content: string, +): Promise { + const maxAttempts = 10; + const delayMs = 500; + let lastErr: unknown; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + await workspaceApi.createWorkspaceFile(agentId, path, content); + return; + } catch (err) { + lastErr = err; + const code = parseApiError(err)?.code; + if (code !== "AGENT_NOT_RUNNING") throw err; + if (attempt < maxAttempts - 1) { + await new Promise((r) => setTimeout(r, delayMs)); + } + } + } + throw lastErr; +} + function EditAgentDrawerBody({ agent, onClose, @@ -173,6 +212,7 @@ function EditAgentDrawerBody({ ); const [listRenameSaving, setListRenameSaving] = useState(false); const [subagentCatalogOpen, setSubagentCatalogOpen] = useState(false); + const welcomeConfigRef = useRef(null); const installedSubagentSlugs = useMemo( () => new Set(agentSubagents.map((s) => s.slug)), @@ -308,6 +348,42 @@ function EditAgentDrawerBody({ color: nextColor, }); + // Save welcome config to manifest.json BEFORE patching the agent. + // The PATCH triggers a background harness reload which briefly removes + // the agent from the runtime; writing the workspace file after the + // PATCH can race with that reload and fail with "agent not running". + // + // The PATCH itself never returns AGENT_NOT_RUNNING (it reads the row, + // not the harness entry). The first save usually works because the + // agent is still loaded when we get here. But once a previous save's + // reload is still in flight (the harness arebuild_agent takes a few + // seconds to re-compile the graph), the manifest write below can land + // inside the "agent briefly absent from the registry" window and fail + // with AGENT_NOT_RUNNING — exactly when the user re-opens the drawer, + // expands 页面配置, edits, and saves again. So: retry briefly so the + // in-flight reload can re-register the agent, and fall back to a + // warning (not an error) so the agent's main config still saves. + if (welcomeConfigRef.current && isAgentChatReady(agent.state)) { + const data = welcomeConfigRef.current.getData(); + const manifest = { + welcome_message: data.welcome_message, + quick_prompts: data.quick_prompts.filter( + (p) => p.title?.zh || p.title?.en || p.prompt?.zh || p.prompt?.en + ), + }; + const manifestJson = JSON.stringify(manifest, null, 2); + try { + await writeManifestWithRetry(agent.agent_id, "/manifest.json", manifestJson); + } catch (manifestErr) { + // Manifest is best-effort: the agent's main config (PATCH) is the + // important part. Surface a warning so the user knows, but never + // block the save because of a brief reload race. + message.warning( + apiErrorMessage(manifestErr, t("experts.manifestWriteFailed"), t), + ); + } + } + await request(`/agents/${agent.agent_id}`, { method: "PATCH", body: JSON.stringify({ @@ -319,6 +395,7 @@ function EditAgentDrawerBody({ ...buildAgentRuntimeRequest(values, { clearMissing: true }), }), }); + message.success(t("common.save") + " ✓"); if (bwrapToast?.kind === "success") { message.success(bwrapToast.text); @@ -342,6 +419,7 @@ function EditAgentDrawerBody({ } }, [ agent.agent_id, + agent.state, agentConfig, colorPalette, form, @@ -522,7 +600,7 @@ function EditAgentDrawerBody({ ) : ( <> -
+
{t("experts.basicInfo")}
@@ -593,6 +671,7 @@ function EditAgentDrawerBody({ ghost className={styles.drawerCollapse} style={{ margin: "8px 0 0", width: "100%" }} + defaultActiveKey={["configFiles"]} items={[ { key: "advanced", @@ -603,17 +682,29 @@ function EditAgentDrawerBody({ ), }, + ...(isAgentChatReady(agent.state) ? [{ + key: "pageConfig", + label: t("experts.pageConfigTitle"), + children: ( +
+ +
+ ), + }] : []), ]} />
{isAgentChatReady(agent.state) && ( -
+
WelcomeConfigData; +} + +const WelcomeConfig = forwardRef( + ({ agentId }, ref) => { + const { t, i18n } = useTranslation(); + const [loading, setLoading] = useState(false); + const [welcomeMessage, setWelcomeMessage] = useState(""); + const [quickPrompts, setQuickPrompts] = useState([]); + + useImperativeHandle(ref, () => ({ + getData: () => ({ + welcome_message: welcomeMessage ? { + zh: welcomeMessage, + en: welcomeMessage, + } : undefined, + quick_prompts: quickPrompts, + }), + })); + + const loadConfig = useCallback(async () => { + setLoading(true); + try { + const data = await agentChatApi.welcome(agentId); + const currentLang = i18n.language.startsWith("zh") ? "zh" : "en"; + const wm = data.welcome_message; + const msg = wm ? (wm[currentLang] || wm.zh || wm.en || "") : ""; + setWelcomeMessage(msg || ""); + setQuickPrompts( + (data.quick_prompts || []).map((p) => ({ + title: { + zh: p.title?.zh ?? "", + en: p.title?.en ?? "", + }, + description: { + zh: p.description?.zh ?? "", + en: p.description?.en ?? "", + }, + prompt: { + zh: p.prompt?.zh ?? "", + en: p.prompt?.en ?? "", + }, + color: p.color || "#e8f4ff", + icon_name: p.icon_name ?? null, + })) + ); + } catch { + setWelcomeMessage(""); + setQuickPrompts([]); + } finally { + setLoading(false); + } + }, [agentId, i18n.language]); + + useEffect(() => { + loadConfig(); + }, [loadConfig]); + + const addQuickPrompt = () => { + setQuickPrompts([...quickPrompts, { ...defaultQuickPrompt }]); + }; + + const removeQuickPrompt = (index: number) => { + setQuickPrompts(quickPrompts.filter((_, i) => i !== index)); + }; + + const updateQuickPrompt = ( + index: number, + field: keyof QuickPrompt, + value: any + ) => { + const newPrompts = [...quickPrompts]; + newPrompts[index] = { ...newPrompts[index], [field]: value }; + setQuickPrompts(newPrompts); + }; + + const updateLocalizedField = ( + index: number, + field: "title" | "description" | "prompt", + lang: "zh" | "en", + value: string + ) => { + const newPrompts = [...quickPrompts]; + const currentField = newPrompts[index][field]; + newPrompts[index] = { + ...newPrompts[index], + [field]: { + zh: currentField?.zh ?? "", + en: currentField?.en ?? "", + [lang]: value, + }, + }; + setQuickPrompts(newPrompts); + }; + + const currentLang = i18n.language.startsWith("zh") ? "zh" : "en"; + + return ( +
+ {loading ? ( +
{t("common.loading")}
+ ) : ( + <> +
+

{t("experts.welcomeMessageTitle")}

+
+ setWelcomeMessage(e.target.value)} + placeholder={t("experts.welcomeMessagePlaceholder")} + rows={2} + /> +
+
+ +
+
+

{t("experts.quickPromptsTitle")}

+ +
+ +
+ {quickPrompts.map((prompt, index) => ( +
+
+ {index + 1} +
+ +
+
+
+ + + updateLocalizedField(index, "title", currentLang, e.target.value) + } + placeholder={t("experts.quickPromptTitlePlaceholder")} + /> +
+
+ + + updateLocalizedField( + index, + "description", + currentLang, + e.target.value + ) + } + placeholder={t("experts.quickPromptDescriptionPlaceholder")} + /> +
+
+ +
+
+ + + updateLocalizedField(index, "prompt", currentLang, e.target.value) + } + placeholder={t("experts.quickPromptContentPlaceholder")} + rows={2} + /> +
+
+ +
+
+ +
+ {presetColors.map((color) => ( +
+
+
+ +
+ + {presetIcons.map((icon) => ( + + ))} +
+
+
+ + {quickPrompts.length > 0 && ( +
+
+ {t("experts.quickPromptPreview")} +
+
+
+ {iconForName(prompt.icon_name, 18)} +
+
+ + {prompt.title[currentLang] || + t("experts.quickPromptTitlePlaceholder")} + + + {prompt.description[currentLang] || + t("experts.quickPromptDescriptionPlaceholder")} + +
+
+
+ )} +
+
+ ))} + + {quickPrompts.length === 0 && ( +
+ {t("experts.noQuickPrompts")} +
+ )} +
+
+ + )} +
+ ); + } +); + +WelcomeConfig.displayName = "WelcomeConfig"; +export default WelcomeConfig; diff --git a/dashboard/src/pages/Experts/index.module.less b/dashboard/src/pages/Experts/index.module.less index 3bd65e78..0ace361e 100644 --- a/dashboard/src/pages/Experts/index.module.less +++ b/dashboard/src/pages/Experts/index.module.less @@ -1805,6 +1805,271 @@ flex-shrink: 0; } +/* ── WelcomeConfig Component ──────────────────────────────────────────────── */ + +.welcomeConfig { + display: flex; + flex-direction: column; + gap: 16px; + padding: 4px 0; +} + +.welcomeConfigHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.welcomeConfigHeader h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--fn-text-primary); +} + +.welcomeConfigLoading { + padding: 24px; + text-align: center; + color: var(--fn-text-tertiary); +} + +.welcomeConfigSection { + display: flex; + flex-direction: column; + gap: 12px; +} + +.welcomeConfigSection h4 { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--fn-text-secondary); +} + +.welcomeMessageFields { + display: flex; + flex-direction: column; + gap: 12px; +} + +.welcomeMessageField { + display: flex; + flex-direction: column; + gap: 6px; +} + +.welcomeMessageField label { + font-size: 12px; + font-weight: 500; + color: var(--fn-text-tertiary); +} + +.quickPromptsHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.quickPromptsList { + display: flex; + flex-direction: column; + gap: 12px; +} + +.quickPromptsEmpty { + padding: 16px; + text-align: center; + font-size: 13px; + color: var(--fn-text-tertiary); + border: 1px dashed var(--fn-border-secondary); + border-radius: var(--fn-radius-md); +} + +.quickPromptItem { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + background: var(--fn-bg-secondary); + border: 1px solid var(--fn-card-border-normal); + border-radius: var(--fn-radius-md); +} + +.quickPromptHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.quickPromptIndex { + font-size: 12px; + font-weight: 600; + color: var(--fn-text-tertiary); +} + +.quickPromptFields { + display: flex; + flex-direction: column; + gap: 12px; +} + +.quickPromptRow { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.quickPromptField { + flex: 1; + min-width: 160px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.quickPromptFieldFull { + flex: 1 1 100%; + min-width: 100%; + display: flex; + flex-direction: column; + gap: 6px; +} + +.quickPromptField label { + font-size: 12px; + font-weight: 500; + color: var(--fn-text-tertiary); +} + +.colorPicker { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.colorOption { + width: 24px; + height: 24px; + border-radius: 6px; + border: 2px solid transparent; + cursor: pointer; + transition: all 0.15s; +} + +.colorOption:hover { + transform: scale(1.1); +} + +.colorOptionActive { + border-color: var(--fn-color-brand); + box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.2); +} + +.iconPicker { + display: flex; + gap: 4px; + flex-wrap: wrap; + align-items: center; +} + +.iconOption { + width: 28px; + height: 28px; + border-radius: 6px; + border: 1px solid var(--fn-border-primary); + background: var(--fn-bg-primary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.15s; + color: var(--fn-text-secondary); + font-size: 14px; +} + +.iconOption:hover { + border-color: var(--fn-color-brand); + background: var(--fn-sidebar-item-hover); +} + +/* "无图标" 按钮:文字宽度自适应,避免溢出固定 28px 的图标框 */ +.iconOptionNoIcon { + width: auto; + padding: 0 8px; + white-space: nowrap; +} + +.iconOptionActive { + border-color: var(--fn-color-brand); + background: var(--fn-color-brand-light, #e6f4ff); + color: var(--fn-color-brand); +} + +.quickPromptPreview { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 8px; + border-top: 1px solid var(--fn-border-secondary); +} + +.quickPromptPreviewLabel { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--fn-text-tertiary); +} + +.quickCardPreview { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + background: var(--fn-bg-primary); + border: 1px solid var(--fn-card-border-normal); + border-radius: var(--fn-radius-md); +} + +.quickCardIcon { + width: 32px; + height: 32px; + border-radius: var(--fn-radius-sm); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 16px; +} + +.quickCardBody { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.quickCardTitle { + font-size: 13px; + font-weight: 600; + color: var(--fn-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.quickCardDesc { + font-size: 12px; + color: var(--fn-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ── Publish expert drawer ─────────────────────────────────────── */ .publishDrawerBody { From 961b572931873784e28a8b59967048105d44d45c Mon Sep 17 00:00:00 2001 From: chujieHong <31946519+chujieHong@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:10:03 +0800 Subject: [PATCH 04/33] fix(chat): stop message-list jitter while typing during streaming (#303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adjustHeight() measured the live textarea by collapsing it to height:auto on every keystroke. That transient shrink reflows the flex layout and grows the message-list viewport (a sibling above the composer) for a moment; browsers clamp the list scrollTop to the larger viewport and the clamp STICKS after the height is restored. While a reply streams, the follow-to-bottom pin then snaps the list back down — the per-keystroke up/down jitter. Measure content height on a detached clone instead (cloneNode + position:fixed off-screen), so the live layout is never disturbed. Skip the write when the target height is unchanged (<0.5px). Verified with a frame-by-frame harness: typing-while-streaming bottom gap dropped from avg 8.6px/max 14px to 0.0/0.0. Co-authored-by: jubaoliang --- CHANGELOG.md | 3 + .../src/pages/Chat/components/ChatInput.tsx | 57 ++++++++++++------- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f96ff10..40253da1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ ## [Unreleased] +### 修复 +- 修复聊天页在"生成中"时于输入框持续打字导致消息列表上下轻微抖动的问题:输入框高度测量改为在离屏克隆节点上进行,不再瞬态改变页面布局 + ## [0.9.24] - 2026-08-15 ### 新增 diff --git a/dashboard/src/pages/Chat/components/ChatInput.tsx b/dashboard/src/pages/Chat/components/ChatInput.tsx index 898dfa8b..bd4788c4 100644 --- a/dashboard/src/pages/Chat/components/ChatInput.tsx +++ b/dashboard/src/pages/Chat/components/ChatInput.tsx @@ -522,31 +522,44 @@ const ChatInput = forwardRef( } }, [text, onUserInput]); - const adjustHeight = useCallback( - (animate = false) => { + const adjustHeight = useCallback(() => { const ta = textareaRef.current; if (!ta) return; - // Disable transition during measurement to avoid visual glitches + // Measure the content height on a detached clone instead of collapsing + // the live textarea to height:"auto". That transient shrink reflows the + // flex layout and makes the message list viewport (a sibling above the + // composer) grow for a moment; browsers clamp the list scrollTop to the + // larger viewport and the clamp STICKS after the height is restored — + // while a reply streams, follow-pins then snap the list back down, i.e. + // the per-keystroke up/down jitter. A clone never touches live layout. + const target = (() => { + const clone = ta.cloneNode(false) as HTMLTextAreaElement; + clone.value = ta.value; + const rect = ta.getBoundingClientRect(); + clone.style.cssText = [ + "position:fixed", + "left:-9999px", + "top:0", + "visibility:hidden", + "height:auto", + "min-height:0", + "max-height:none", + "transition:none", + `width:${rect.width}px`, + ].join(";"); + document.body.appendChild(clone); + const h = clone.scrollHeight; + document.body.removeChild(clone); + return Math.max(Math.min(h, 160), MIN_TEXTAREA_HEIGHT); + })(); + const current = ta.getBoundingClientRect().height; + if (Math.abs(target - current) < 0.5) return; // height unchanged + // Disable transition during the write so the resize is instant. ta.style.transition = "none"; - ta.style.height = "auto"; - const target = Math.max( - Math.min(ta.scrollHeight, 160), - MIN_TEXTAREA_HEIGHT, - ); - if (animate) { - // Snap to current rendered height first (no transition), then animate to target - const current = ta.getBoundingClientRect().height; - ta.style.height = `${current}px`; - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - ta.offsetHeight; // force reflow - ta.style.transition = ""; - ta.style.height = `${target}px`; - } else { - ta.style.height = `${target}px`; - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - ta.offsetHeight; - ta.style.transition = ""; - } + ta.style.height = `${target}px`; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + ta.offsetHeight; // force reflow + ta.style.transition = ""; }, [MIN_TEXTAREA_HEIGHT], ); From f3e24153757ea598706b07a4a570328df6fc71f7 Mon Sep 17 00:00:00 2001 From: chujieHong <31946519+chujieHong@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:11:30 +0800 Subject: [PATCH 05/33] fix(plugins): sanitize non-ASCII tool names for strict LLM tool-name APIs (#308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin tools registered with Chinese names passed straight into the function-calling schema and failed on APIs that require ^[a-zA-Z0-9_-]{1,64}$. Rewrite illegal names to legal pinyin transliterations (underscore fallback when pypinyin is unavailable), dedupe collisions with _2/_3 suffixes, and keep the original name in a [原名: ...] description prefix. Config keys and plugin-side closures still use the original names, so routing and per-agent tool config are unaffected. Co-authored-by: jubaoliang --- CHANGELOG.md | 2 + pyproject.toml | 1 + src/octop/infra/agents/manager.py | 10 ++ src/octop/infra/agents/plugin_tool_names.py | 90 +++++++++++++++ tests/unit/test_plugins.py | 121 ++++++++++++++++++++ uv.lock | 10 ++ 6 files changed, 234 insertions(+) create mode 100644 src/octop/infra/agents/plugin_tool_names.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 40253da1..9f2a3ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ## [Unreleased] ### 修复 + +- 插件工具使用中文等非 ASCII 名称时 LLM 调用失败:主流 API 要求工具名匹配 `^[a-zA-Z0-9_-]{1,64}$`,现自动将非法名称转写为合法拼音名(`pypinyin` 缺失时退回下划线替换),冲突追加 `_2`/`_3` 后缀,并在工具描述前缀 `[原名: …]` 保留原名映射;`config_json.plugins` 配置键与插件内部仍使用原始名称,路由不受影响 - 修复聊天页在"生成中"时于输入框持续打字导致消息列表上下轻微抖动的问题:输入框高度测量改为在离屏克隆节点上进行,不再瞬态改变页面布局 ## [0.9.24] - 2026-08-15 diff --git a/pyproject.toml b/pyproject.toml index 11d02003..bf1bffe1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "pypdf>=5.0", "python-docx>=1.2.0", "python-pptx>=1.0", + "pypinyin>=0.53", ] [project.optional-dependencies] diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index 71da880c..4e689f54 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -1936,6 +1936,16 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: agent_plugins=agent_plugins, global_plugins=global_plugins, ) + # Plugin authors may register tools with non-ASCII (e.g. Chinese) names, + # which strict LLM tool-name APIs reject. Rewrite them to legal names + # before binding, keeping the original in the description. Config keys + # and the plugin-side closures still use the original names. + from octop.infra.agents.plugin_tool_names import sanitize_plugin_tool_names # noqa: PLC0415 + + sanitize_plugin_tool_names( + plugin_tools, + reserved={str(getattr(t, "name", "")) for t in [*(cron_tools or []), *knowledge_tools]}, + ) plugin_middleware = PluginRegistry().build_middleware_chain(global_enabled=global_plugins) global_policy = self._security.harness_policy() agent_override = cfg.get("security") if isinstance(cfg.get("security"), dict) else None diff --git a/src/octop/infra/agents/plugin_tool_names.py b/src/octop/infra/agents/plugin_tool_names.py new file mode 100644 index 00000000..7d62f445 --- /dev/null +++ b/src/octop/infra/agents/plugin_tool_names.py @@ -0,0 +1,90 @@ +"""Sanitize plugin tool names for strict LLM tool-name APIs. + +Plugin authors register tools with ``ctx.tool("中文名", fn, ...)``; the harness +passes that name straight into the function-calling schema, but most LLM APIs +only accept ``^[a-zA-Z0-9_-]{1,64}$``. Mirroring the MCP-side fix +(``harness_agent.mcp.sanitize_llm_tool_name``) this module rewrites non-conforming +plugin tool names to legal ASCII names: + +- CJK characters are transliterated to pinyin (``天气查询`` -> ``tianqichaxun``) + when :mod:`pypinyin` is importable; otherwise every illegal character becomes + ``_``. +- The original name is kept in a ``[原名: ...]`` description prefix so the model + and the user can still map the sanitized name back. +- Collisions get ``_2``/``_3`` suffixes and results are truncated to 64 chars. + +Routing is unaffected: ``ToolNode`` matches the exposed name and the underlying +plugin function is untouched. Config keys (``config_json.plugins``) keep using +the original names. +""" + +from __future__ import annotations + +import re +from typing import Any + +_LLM_TOOL_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") +_ILLEGAL_CHARS_RE = re.compile(r"[^a-zA-Z0-9_-]+") +_MAX_NAME_LEN = 64 + +_ORIGINAL_NAME_PREFIX = "[原名: {name}] " + + +def _transliterate(name: str) -> str: + """Return an ASCII-ish rendering of ``name`` (pinyin when possible).""" + try: + from pypinyin import lazy_pinyin # noqa: PLC0415 + except ImportError: + # Zero-dependency fallback: mirror MCP's underscore substitution. + return _ILLEGAL_CHARS_RE.sub("_", name) + syllables = lazy_pinyin(name, errors="default") + joined = "".join(str(part) for part in syllables if str(part)) + ascii_joined = _ILLEGAL_CHARS_RE.sub("_", joined) + return ascii_joined + + +def _dedupe(candidate: str, used: set[str]) -> str: + if candidate not in used: + return candidate + suffix = 2 + while f"{candidate}_{suffix}" in used: + suffix += 1 + return f"{candidate}_{suffix}" + + +def sanitize_plugin_tool_name(name: str, *, used: set[str] | None = None) -> str: + """Return a legal LLM tool name for ``name``, unique against ``used``.""" + used = used if used is not None else set() + if _LLM_TOOL_NAME_RE.match(name): + candidate = _dedupe(name, used) + used.add(candidate) + return candidate + candidate = _transliterate(name).strip("_") or "plugin_tool" + if len(candidate) > _MAX_NAME_LEN: + # Leave room for a possible dedupe suffix (_2, _3, ...). + candidate = candidate[: _MAX_NAME_LEN - 4].rstrip("_") or "plugin_tool" + candidate = _dedupe(candidate, used) + used.add(candidate) + return candidate + + +def sanitize_plugin_tool_names( + tools: list[Any], + *, + reserved: frozenset[str] | set[str] = frozenset(), +) -> list[Any]: + """Rewrite illegal plugin tool names in place and return ``tools``. + + ``reserved`` holds names already taken by other tools on the same agent + (cron/knowledge/team tools) so sanitized names cannot shadow them. + """ + used: set[str] = set(reserved) + for tool in tools: + original = str(getattr(tool, "name", "")) + sanitized = sanitize_plugin_tool_name(original, used=used) + if sanitized == original: + continue + tool.name = sanitized + description = str(getattr(tool, "description", "") or "") + tool.description = _ORIGINAL_NAME_PREFIX.format(name=original) + description + return tools diff --git a/tests/unit/test_plugins.py b/tests/unit/test_plugins.py index 22aac2c9..ffa7da37 100644 --- a/tests/unit/test_plugins.py +++ b/tests/unit/test_plugins.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from typing import Any import pytest from harness_agent.plugins import ( @@ -13,6 +14,11 @@ ) from langchain_core.tools import StructuredTool +from octop.infra.agents.plugin_tool_names import ( + sanitize_plugin_tool_name, + sanitize_plugin_tool_names, +) + _FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "plugins" / "echo-tool" @@ -58,3 +64,118 @@ def test_collect_plugin_tool_configs() -> None: }, ) assert cfg == {"echo_message": {"prefix": ">> "}} + + +def _make_tool(name: str, description: str = "demo tool") -> StructuredTool: + def _fn(message: str) -> str: + return message + + return StructuredTool.from_function(func=_fn, name=name, description=description) + + +def test_sanitize_ascii_name_passthrough() -> None: + tool = _make_tool("echo_message", "original description") + result = sanitize_plugin_tool_names([tool]) + assert result[0].name == "echo_message" + assert result[0].description == "original description" + + +def test_sanitize_chinese_name_transliterates_to_pinyin() -> None: + pytest.importorskip("pypinyin") + tool = _make_tool("天气查询", "查询指定城市的天气") + result = sanitize_plugin_tool_names([tool]) + assert result[0].name == "tianqichaxun" + assert result[0].description == "[原名: 天气查询] 查询指定城市的天气" + + +def test_sanitize_mixed_name_keeps_ascii_parts() -> None: + pytest.importorskip("pypinyin") + assert sanitize_plugin_tool_name("获取weather信息") == "huoquweatherxinxi" + + +def test_sanitize_collision_gets_suffix() -> None: + first = sanitize_plugin_tool_name("天气查询") + second = sanitize_plugin_tool_name("天气查询", used={first}) + assert second != first + assert second == f"{first}_2" + # A legal name that is already reserved also gets deduped. + assert sanitize_plugin_tool_name("echo_message", used={"echo_message"}) == "echo_message_2" + + +def test_sanitize_truncates_overlong_names() -> None: + long_name = "很" * 80 + sanitized = sanitize_plugin_tool_name(long_name) + assert len(sanitized) <= 64 + + +def test_sanitize_falls_back_to_underscores_without_pypinyin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import builtins + + real_import = builtins.__import__ + + def _blocked(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "pypinyin" or name.startswith("pypinyin."): + raise ImportError(name) + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _blocked) + sanitized = sanitize_plugin_tool_name("天气查询") + assert sanitized + assert sanitized.isascii() + assert all(ch.isalnum() or ch in "_-" for ch in sanitized) + + +def test_sanitize_plugin_tools_reserved_names() -> None: + tool = _make_tool("echo_message") + sanitize_plugin_tool_names([tool], reserved={"echo_message"}) + assert tool.name == "echo_message_2" + + +def test_build_plugin_tools_then_sanitize_keeps_config_keys_original() -> None: + pytest.importorskip("pypinyin") + from harness_agent.plugins.manifest import PluginManifest + from harness_agent.plugins.registry import LoadedPlugin, ToolRegistration + + manifest = PluginManifest(id="demo", version="1.0.0", name="Demo", kind="tool", entry="main.py") + PluginRegistry().register( + LoadedPlugin( + manifest=manifest, + source_path=Path("."), + tools=[ + ToolRegistration( + plugin_id="demo", + name="发送邮件", + fn=lambda to: to, + description="发送一封邮件", + ), + ToolRegistration( + plugin_id="demo", + name="echo_message", + fn=lambda text: text, + description="echo", + ), + ], + ), + ) + tools = build_plugin_tools( + agent_plugins={ + "demo": { + "tools": { + "发送邮件": {"enabled": True}, + "echo_message": {"enabled": True}, + }, + }, + }, + ) + sanitized = sanitize_plugin_tool_names(tools) + names = {t.name for t in sanitized} + assert "fasongyoujian" in names # pinyin of 发送邮件 + assert "echo_message" in names + descriptions = {t.name: t.description for t in sanitized} + assert descriptions["fasongyoujian"].startswith("[原名: 发送邮件]") + # Config lookup still keyed by the original plugin-side name. + assert collect_plugin_tool_configs( + {"demo": {"tools": {"发送邮件": {"enabled": True, "config": {"a": 1}}}}}, + ) == {"发送邮件": {"a": 1}} diff --git a/uv.lock b/uv.lock index 8c17e950..c743b57f 100644 --- a/uv.lock +++ b/uv.lock @@ -2489,6 +2489,7 @@ dependencies = [ { name = "pydantic" }, { name = "pyjwt" }, { name = "pypdf" }, + { name = "pypinyin" }, { name = "python-docx" }, { name = "python-pptx" }, { name = "questionary" }, @@ -3613,6 +3614,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/72/ce3067ac31e214a66388159f8462ddb8c13dd00170f24d555a1f1ae8ee91/pypdf-6.15.0-py3-none-any.whl", hash = "sha256:14e001d6504822cb1ca9c7ed9a69bccb320f59b320730f55af804361abe4d5ee", size = 378123 }, ] +[[package]] +name = "pypinyin" +version = "0.55.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/a4/784cf98c09e0dc22776b0d7d8a4a5b761218bcae4608c2416ce1e167c8af/pypinyin-0.55.0.tar.gz", hash = "sha256:b5711b3a0c6f76e67408ec6b2e3c4987a3a806b7c528076e7c7b86fcf0eaa66b", size = 839836 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/7b/4cabc76fcc21c3c7d5c671d8783984d30ac9d3bb387c4ba784fca3cdfa3a/pypinyin-0.55.0-py2.py3-none-any.whl", hash = "sha256:d53b1e8ad2cdb815fb2cb604ed3123372f5a28c6f447571244aca36fc62a286f", size = 840203 }, +] + [[package]] name = "pyproject-hooks" version = "1.2.0" From 10012e172b61eebd8d9502a37dda4f9bebd23798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8C=AB=E7=8C=AB=E6=91=B8=E5=A4=A7=E9=B1=BC?= <58991169+miaowmint@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:07:35 +0800 Subject: [PATCH 06/33] =?UTF-8?q?Feat/=E8=87=AA=E5=AE=9A=E4=B9=89=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E8=89=B2=20(#334)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): custom brand color palette with color picker Extend the 8 curated brand palettes with a 9th "custom" entry driven by a user-chosen hex color: - PaletteSwitcher gains a custom swatch plus a native color input (round swatch); picking a color switches the theme to "custom" - The full Ant Design brand token set and all brand-tinted CSS variables (~30 tokens incl. bubble gradient, sidebar, row-selected, shadows) are derived from the single hex at runtime: - solid states are darkened until white text reaches WCAG AA (4.5:1) - dark-mode text/links are lightened until readable on #0f1117 - customColor is persisted alongside preference/palette in the shared `theme` localStorage key (normalized to lowercase #rrggbb; invalid values fall back to the default #4B74FA) - ThemeContext injects the derived CSS block via a reusable