From a07539d4587c283c6234fd13295ea5a2065ca87a Mon Sep 17 00:00:00 2001 From: QAyong Date: Sat, 19 Sep 2026 03:31:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20Agent=20=E9=9D=A2=E6=9D=BF=20-?= =?UTF-8?q?=20=E6=94=AF=E6=8C=81=E5=9B=9E=E8=BD=A6=E5=8F=91=E9=80=81?= =?UTF-8?q?=E5=B9=B6=E8=AE=A9=E6=8A=80=E8=83=BD=E8=8F=9C=E5=8D=95=E6=8E=A5?= =?UTF-8?q?=E5=8F=97=E4=B8=AD=E6=96=87=E9=A1=BF=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 画布 Agent 面板的输入框此前有两个中文用户的体验问题:Enter 不发送(只换行), 且技能候选菜单只认 "/" 触发,中文标点状态下按 "/" 打出的是 "、",必须先切回英文。 回车发送: - canvas-resource-mention-textarea.tsx:sendOnEnter 由 boolean 扩展为 boolean | "both", 新增 shouldSubmitOnEnter() 统一判定;原来 contentEditable 与 textarea 两处各写一份表达式。 false 仅 ⌘/Ctrl+Enter 提交(原行为),"both" 两者都提交,Shift+Enter 始终换行。默认仍为 true, 首页智能创作、批量创作表、短剧工作台三个调用方键位不变。 - canvas-cloud-agent-chat-ui.tsx:改传 sendOnEnter={canSubmit ? "both" : false}, 运行中 Enter 保持换行、不误发;提示文案改为「Enter 发送 · Shift+Enter 换行」,发送按钮 tooltip 同步。 - canvas-cloud-agent.css:快捷键提示原本在 @container (max-width: 520px) 下整条 display: none, 而默认面板宽度 448px 正落在该区间,等于开箱即用时看不到任何发送键说明; 改为 .agent-composer-send-hint-full / -compact 两段,窄态降级为短文案「Enter 发送」。 中文顿号触发技能菜单: - canvas-cloud-agent-chat-ui.tsx:handlePromptChange 的检测正则由 /(^|\s)\/([^\s/]*)$/ 改为 /(^|\s)[/、]([^\s/、]*)$/。保留「行首或紧跟空白」前置条件, 中文正文里的顿号(如「苹果、香蕉」)不会误触发;applySlashSkill 的替换长度仍为 1,两个触发符都是单字符。 - canvas-cloud-agent-panel.tsx:placeholder 改为「用 / 或 、 引用 Skills」,让两种触发方式可发现。 - canvas-resource-mention-editor.test.ts:同步源码断言(键位、两段提示类名、触发正则、placeholder)。 验证: - cd web && bun run typecheck 通过。 - bun run lint(改动文件)通过;改动文件按 prettier 校验通过(其余四个文件基线即未格式化,CI 会跳过)。 - bun test test/canvas-resource-mention-editor.test.ts:10 通过 / 1 失败。失败项是同文件既有断言 expect(css).not.toContain(".agent-tool-row:hover"),而 main 上 canvas-cloud-agent.css 本就含该选择器, 属于基线既有失败,与本次改动无关(未在本 PR 内顺手修改,避免扩大范围)。 - 触发正则边界用独立脚本实测:、夜景 / 、夜景(前置空白)/ abc /夜 / a\n、x / " @节点 /夜景" 均正确给出 start 与 query;苹果、香蕉 / abc、夜景 / 、a、 不触发。 - 未启动开发服务,浏览器键位与实际菜单行为待人工验收。 --- .../canvas/canvas-cloud-agent-chat-ui.tsx | 16 +++++++++++----- .../canvas/canvas-cloud-agent-panel.tsx | 2 +- web/src/components/canvas/canvas-cloud-agent.css | 12 ++++++++++-- .../canvas/canvas-resource-mention-textarea.tsx | 15 ++++++++++++--- web/test/canvas-resource-mention-editor.test.ts | 8 ++++++-- 5 files changed, 40 insertions(+), 13 deletions(-) diff --git a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx index 99722cd0b..249456895 100644 --- a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx @@ -538,10 +538,10 @@ export function AgentChatComposer({ setPromptHeight(clampAgentPromptHeight(promptHeight + (event.key === "ArrowUp" ? 20 : -20))); }; - // 在输入值末尾检测「/关键词」打开技能候选;选中后写入稳定 token,编辑器再把它渲染为技能 chip。 + // 在输入值末尾检测「/ 或 、+ 关键词」打开技能候选:中文输入法下 "/" 会打成 "、",两者等价,且都必须紧跟行首或空白,避免中文顿号误触发。选中后写入稳定 token,编辑器再把它渲染为技能 chip。 const handlePromptChange = (value: string) => { onPromptChange(value); - const match = /(^|\s)\/([^\s/]*)$/.exec(value); + const match = /(^|\s)[/、]([^\s/、]*)$/.exec(value); if (match && availableSlashSkills.length) { const next = { start: match.index + match[1].length, query: match[2] }; setSlash((current) => (current && current.start === next.start && current.query === next.query ? current : next)); @@ -553,6 +553,7 @@ export function AgentChatComposer({ const applySlashSkill = (skill: Skill) => { const token = `@[skill:${skill.skillId}] `; + // 触发符 "/" 与 "、" 都是单字符,替换长度固定为 1。 const next = slash ? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + 1 + slash.query.length)}` : prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token; setSlash(null); setSlashIndex(0); @@ -673,7 +674,7 @@ export function AgentChatComposer({ value={prompt} references={composerReferences} includeAssetLibrary={includeAssetLibrary} - sendOnEnter={false} + sendOnEnter={canSubmit ? "both" : false} disabled={disabled} onChange={handlePromptChange} onSubmit={() => { if (canSubmit) onSubmit(); }} @@ -740,7 +741,12 @@ export function AgentChatComposer({ {left}
- {canStop ? "运行中:发送即插话,下一步生效" : "Enter 换行 · ⌘/Ctrl+Enter 发送"} + {disabled ? null : ( + + {canStop ? "运行中:发送即插话,下一步生效" : "Enter 发送 · Shift+Enter 换行"} + {canStop ? "运行中可插话" : "Enter 发送"} + + )} {canStop ? onSubmit()} whileHover={canSubmit && !reducedMotion ? { scale: 1.06, y: -1 } : undefined} whileTap={canSubmit && !reducedMotion ? { scale: 0.9, y: 1 } : undefined} diff --git a/web/src/components/canvas/canvas-cloud-agent-panel.tsx b/web/src/components/canvas/canvas-cloud-agent-panel.tsx index 8962e1ba1..af8d86bcf 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel.tsx @@ -733,7 +733,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re disabled={Boolean(run && connectionStatus !== "connected") || !historyHydrated || !pendingHydrated} sending={busy} running={running} - placeholder={running ? "运行中可直接插话,会在它下一步生效" : "输入操作指导;用 @ 引用画布节点,用 / 引用 Skills"} + placeholder={running ? "运行中可直接插话,会在它下一步生效" : "输入操作指导;用 @ 引用画布节点,用 / 或 、 引用 Skills"} theme={theme} onPromptChange={setPrompt} onSubmit={() => void submit()} diff --git a/web/src/components/canvas/canvas-cloud-agent.css b/web/src/components/canvas/canvas-cloud-agent.css index 645f48331..d6afb4377 100644 --- a/web/src/components/canvas/canvas-cloud-agent.css +++ b/web/src/components/canvas/canvas-cloud-agent.css @@ -373,11 +373,19 @@ white-space: nowrap; } -/* 面板宽度包含内边距;默认 448px 面板的输入区已不足以同时容纳控制区和快捷键提示。 */ +/* 面板宽度包含内边距;默认 448px 面板放不下完整快捷键说明,窄态降级为短文案而不是隐藏提示本身。 */ +.agent-composer-send-hint .agent-composer-send-hint-compact { + display: none; +} + @container (max-width: 520px) { - .agent-composer-send-hint { + .agent-composer-send-hint .agent-composer-send-hint-full { display: none; } + + .agent-composer-send-hint .agent-composer-send-hint-compact { + display: inline; + } } .agent-tool-status { diff --git a/web/src/components/canvas/canvas-resource-mention-textarea.tsx b/web/src/components/canvas/canvas-resource-mention-textarea.tsx index 857701848..fdb4ef269 100644 --- a/web/src/components/canvas/canvas-resource-mention-textarea.tsx +++ b/web/src/components/canvas/canvas-resource-mention-textarea.tsx @@ -42,7 +42,7 @@ type Props = Omit, "onChange" | "val containerClassName?: string; highlightLabels?: boolean; mentionMenuWidth?: number; - sendOnEnter?: boolean; + sendOnEnter?: boolean | "both"; onContentSizeChange?: (height: number) => void; includeAssetLibrary?: boolean; activeDropReferenceId?: string | null; @@ -50,6 +50,15 @@ type Props = Omit, "onChange" | "val autoLinkEnabled?: boolean; }; +// 回车提交语义由调用方决定:false 只在 ⌘/Ctrl+Enter 提交,"both" 两种都提交;Shift+Enter 始终换行。 +function shouldSubmitOnEnter(event: { key: string; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }, sendOnEnter: boolean | "both") { + if (event.key !== "Enter" || event.shiftKey) return false; + const modifier = event.ctrlKey || event.metaKey; + if (sendOnEnter === false) return modifier; + if (sendOnEnter === "both") return true; + return !modifier; +} + export const CanvasResourceMentionTextarea = forwardRef(function CanvasResourceMentionTextarea( { value, references, onSelectReference, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, mentionMenuWidth = 320, sendOnEnter = true, onContentSizeChange, includeAssetLibrary = false, activeDropReferenceId, onReferenceFilesDrop, autoLinkEnabled = false, ...props }, forwardedRef, @@ -433,7 +442,7 @@ if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.curr } if (event.key === "Enter") { event.preventDefault(); - const shouldSubmit = sendOnEnter ? !event.ctrlKey && !event.metaKey && !event.shiftKey : (event.ctrlKey || event.metaKey) && !event.shiftKey; + const shouldSubmit = shouldSubmitOnEnter(event, sendOnEnter); if (onSubmit && shouldSubmit) { onSubmit(); return; @@ -547,7 +556,7 @@ if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.curr return; } } - const shouldSubmit = event.key === "Enter" && (sendOnEnter ? !event.ctrlKey && !event.metaKey && !event.shiftKey : (event.ctrlKey || event.metaKey) && !event.shiftKey); + const shouldSubmit = shouldSubmitOnEnter(event, sendOnEnter); if (shouldSubmit && onSubmit) { event.preventDefault(); onSubmit(); diff --git a/web/test/canvas-resource-mention-editor.test.ts b/web/test/canvas-resource-mention-editor.test.ts index 2d9c2934d..d1760f545 100644 --- a/web/test/canvas-resource-mention-editor.test.ts +++ b/web/test/canvas-resource-mention-editor.test.ts @@ -121,6 +121,8 @@ describe("canvas resource mention editor", () => { expect(component).toContain("const token = `@[skill:${skill.skillId}] `"); expect(component).toContain("slash.start + 1 + slash.query.length"); expect(component).toContain("buildSkillMentionReferences(availableSlashSkills)"); + expect(component).toContain("[/、]([^\\s/、]*)$"); + expect(source("../src/components/canvas/canvas-cloud-agent-panel.tsx")).toContain("用 / 或 、 引用 Skills"); }); test("skill chips use one colored icon instead of exposing the serialized token", () => { @@ -132,9 +134,11 @@ describe("canvas resource mention editor", () => { expect(component).toContain('prefix.textContent = reference.kind === "skill" ? "✦" : "@"'); expect(component).toContain('if (reference.kind !== "skill") chip.appendChild(createInlinePreview(reference));'); expect(component).toContain('chip.style.setProperty("--canvas-skill-mention-color", skillMentionColor(reference))'); - expect(chat).toContain("sendOnEnter={false}"); + expect(chat).toContain('sendOnEnter={canSubmit ? "both" : false}'); expect(chat).toContain("agent-composer-resize-handle"); - expect(chat).toContain("Enter 换行 · ⌘/Ctrl+Enter 发送"); + expect(chat).toContain("Enter 发送 · Shift+Enter 换行"); + expect(css).toContain(".agent-composer-send-hint-full"); + expect(css).toContain(".agent-composer-send-hint-compact"); expect(css).toContain(".agent-composer-prompt-scroll"); expect(css).not.toContain(".agent-tool-row:hover"); });