From 1156aa572ffba8f8829c201097c05d5f0145523e Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:52:50 +0800 Subject: [PATCH 01/17] fix(app): let conversation agents interpret free text Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- apps/presentation/dashboard/package.json | 2 +- .../smoke/personal-workspace-router-smoke.ts | 72 ---- .../smoke/todo-resume-condition-smoke.ts | 9 + .../personal-workspace/context-drawer.tsx | 4 +- .../src/features/personal-workspace/i18n.tsx | 32 +- .../personal-workspace-contract.test.mjs | 29 +- .../personal-workspace-page.tsx | 324 +----------------- .../personal-workspace-router.ts | 145 -------- .../personal-workspace/personal-workspace.css | 14 + .../todo-resume-condition.ts | 8 + .../workspace-action-form.tsx | 95 +++++ .../dashboard/src/views/dashboard-page.tsx | 102 +----- examples/personal-workspace-browser-smoke.mjs | 4 +- .../conversation-input.mjs | 95 +++++ .../typed-actions.mjs | 121 +++---- 15 files changed, 301 insertions(+), 755 deletions(-) delete mode 100644 apps/presentation/dashboard/smoke/personal-workspace-router-smoke.ts create mode 100644 apps/presentation/dashboard/smoke/todo-resume-condition-smoke.ts delete mode 100644 apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-router.ts create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/todo-resume-condition.ts create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/workspace-action-form.tsx create mode 100644 examples/personal-workspace-browser/conversation-input.mjs diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index d4bf0fa8f0..6068dc570f 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -33,7 +33,7 @@ "smoke:personal-workspace": "npm run smoke:goal-order && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:workspace-locale": "LOOPX_PERSONAL_WORKSPACE_SCENARIO=workspace-locale node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:personal-workspace-packaged": "npm run smoke:goal-order && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", - "smoke:personal-workspace-router": "rm -rf /tmp/loopx-personal-workspace-router-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-personal-workspace-router-smoke smoke/personal-workspace-router-smoke.ts src/features/personal-workspace/personal-workspace-router.ts && node /tmp/loopx-personal-workspace-router-smoke/smoke/personal-workspace-router-smoke.js", + "smoke:todo-resume-condition": "rm -rf /tmp/loopx-todo-resume-condition-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-todo-resume-condition-smoke smoke/todo-resume-condition-smoke.ts src/features/personal-workspace/todo-resume-condition.ts && node /tmp/loopx-todo-resume-condition-smoke/smoke/todo-resume-condition-smoke.js", "smoke:presentation-surface-schema": "rm -rf /tmp/loopx-presentation-surface-schema-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-presentation-surface-schema-smoke smoke/presentation-surface-schema-smoke.ts src/data/status.ts src/data/decision-research.ts src/data/goal-channel-frontstage.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-presentation-surface-schema-smoke/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.js", "smoke:projection-localization": "rm -rf /tmp/loopx-projection-localization-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --outDir /tmp/loopx-projection-localization-smoke smoke/projection-localization-smoke.ts src/features/personal-workspace/projection-localization.ts && node /tmp/loopx-projection-localization-smoke/smoke/projection-localization-smoke.js", "smoke:pwa-bundle": "python3 ../../../examples/dashboard-pwa-bundle-smoke.py", diff --git a/apps/presentation/dashboard/smoke/personal-workspace-router-smoke.ts b/apps/presentation/dashboard/smoke/personal-workspace-router-smoke.ts deleted file mode 100644 index 50688c84da..0000000000 --- a/apps/presentation/dashboard/smoke/personal-workspace-router-smoke.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { routeWorkspaceInput } from "../src/features/personal-workspace/personal-workspace-router.js"; - -function equal(actual: unknown, expected: unknown, label: string) { - if (actual !== expected) throw new Error(`${label}: expected ${String(expected)}, received ${String(actual)}`); -} - -function ok(value: unknown, label: string) { - if (!value) throw new Error(label); -} - -const goalContext = { - agents: [{ agentId: "codex", label: "Codex" }], - goalId: "demo-goal", - todos: [{ text: "整理验收材料", todoId: "todo-1" }], -}; - -equal(routeWorkspaceInput("我现在该做什么?只读回答,不要修改状态", { ...goalContext, goalId: null }).route, "projection", "manager projection"); -equal(routeWorkspaceInput("不要设置 Heartbeat,只回答当前进度", goalContext).route, "agent_chat", "negated heartbeat"); -equal(routeWorkspaceInput("每天推进这个 Goal,设置 heartbeat", goalContext).actionKind, "heartbeat.bind", "heartbeat outranks generic daily monitor"); -equal(routeWorkspaceInput("Set up a Heartbeat for this Goal with daily progress", goalContext).actionKind, "heartbeat.bind", "English heartbeat"); -equal(routeWorkspaceInput("Turn Heartbeat off", goalContext).route, "agent_chat", "explicit English heartbeat disable stays in chat"); -equal(routeWorkspaceInput("Add a scheduled check every 2 hours", goalContext).actionKind, "monitor.create", "English monitor"); -equal(routeWorkspaceInput("Add a monitor for off hours", goalContext).actionKind, "monitor.create", "off hours is a valid monitor target"); -equal(routeWorkspaceInput("Create a scheduled check for off-hours deployment alerts", goalContext).actionKind, "monitor.create", "hyphenated off-hours is a valid scheduled-check target"); -equal(routeWorkspaceInput("Turn monitor off", goalContext).route, "agent_chat", "explicit English monitor disable stays in chat"); -equal(routeWorkspaceInput("创建一个 Todo:整理发布说明", goalContext).actionKind, "todo.create", "todo create"); -equal( - routeWorkspaceInput("做一次只读分析:判断刚刚新增的 Todo 是否与当前 Goal 一致。不要修改状态。", goalContext).route, - "agent_chat", - "existing todo read-only analysis", -); -equal(routeWorkspaceInput("把 todo-1 标记完成", goalContext).actionKind, "todo.update", "todo update"); -const deferMissingCondition = routeWorkspaceInput("把 todo-1 暂缓", goalContext); -equal(deferMissingCondition.route, "clarify", "todo defer without condition clarifies"); -equal(deferMissingCondition.missingFields.join(","), "resume_when", "todo defer names missing resume condition"); -const deferUntilPr = routeWorkspaceInput("把 todo-1 暂缓到pr_merged:huangruiteng/loopx#3399", goalContext); -equal(deferUntilPr.route, "typed_action", "todo defer with condition routes to typed action"); -equal(deferUntilPr.normalizedParameters.resume_when, "pr_merged:huangruiteng/loopx#3399", "todo defer preserves supported condition"); -const deferUntilDate = routeWorkspaceInput("把 todo-1 暂缓到 resume_at:2026-09-14T09:00:00+08:00", goalContext); -equal(deferUntilDate.route, "typed_action", "todo defer accepts a timezone-aware resume_at condition"); -equal(deferUntilDate.normalizedParameters.resume_when, "resume_at:2026-09-14t09:00:00+08:00", "todo defer preserves an explicit timezone"); -equal(routeWorkspaceInput("帮我修复 MR 冲突,跑测试,然后 push", goalContext).actionKind, "todo.create", "execution task"); -equal(routeWorkspaceInput("创建任务并设置 Heartbeat", goalContext).route, "clarify", "compound intent"); -equal(routeWorkspaceInput("创建任务并设置 Heartbeat", goalContext).missingFields.join(","), "single_intent", "compound missing field"); -equal(routeWorkspaceInput("Create a task and set up a Heartbeat", goalContext).route, "clarify", "English compound intent"); -equal(routeWorkspaceInput("解释一下现在的状态", goalContext).route, "agent_chat", "goal chat"); -equal(routeWorkspaceInput("请问怎么解决一下这个问题?", goalContext).route, "agent_chat", "advice question stays in chat"); -equal(routeWorkspaceInput("请不要部署到生产环境", goalContext).route, "agent_chat", "negated deployment does not trigger gate"); -equal(routeWorkspaceInput("请只回复:合并后真实回复已收到", goalContext).route, "agent_chat", "descriptive merge phrase stays in chat"); -equal(routeWorkspaceInput("请分析:合并 PR #123 后会有什么风险", goalContext).route, "agent_chat", "protected action discussion stays in chat"); -equal(routeWorkspaceInput("请合并 PR #123", goalContext).route, "agent_chat", "protected merge intent is interpreted by the Agent"); -equal(routeWorkspaceInput("请合并", goalContext).route, "agent_chat", "targetless protected intent is clarified by the Agent"); -equal(routeWorkspaceInput("请把 PR #456 合并", goalContext).route, "agent_chat", "object-first merge reaches semantic interpretation"); -equal(routeWorkspaceInput("请发布 v1.2.3", goalContext).route, "agent_chat", "release intent reaches semantic interpretation"); -equal(routeWorkspaceInput("现在部署到生产环境", goalContext).route, "agent_chat", "deployment intent reaches semantic interpretation"); -equal(routeWorkspaceInput("请删除 todo_demo_123", goalContext).route, "agent_chat", "delete intent reaches semantic interpretation"); -equal(routeWorkspaceInput("批准付款 100 元", goalContext).route, "agent_chat", "payment intent reaches semantic interpretation"); -equal(routeWorkspaceInput("请部署服务", goalContext).route, "agent_chat", "generic deployment target is clarified by the Agent"); -equal(routeWorkspaceInput("发布完成后请只回复结果", goalContext).route, "agent_chat", "descriptive release phrase stays in chat"); -equal(routeWorkspaceInput("整理验收材料还没有完成,不要关闭", goalContext).route, "agent_chat", "negated complete does not update todo"); - -const createGoal = routeWorkspaceInput("创建 Goal:整理每周复盘", { ...goalContext, goalId: null }); -equal(createGoal.route, "typed_action", "goal route"); -equal(createGoal.actionKind, "goal.create", "goal action"); -ok(createGoal.confidence >= 0.9, "goal confidence"); -const createEnglishGoal = routeWorkspaceInput("Create a long-term Goal: prepare my weekly review", { ...goalContext, goalId: null }); -equal(createEnglishGoal.route, "typed_action", "English goal route"); -equal(createEnglishGoal.actionKind, "goal.create", "English goal action"); -equal(routeWorkspaceInput("Create a Goal: off track delivery recovery", { ...goalContext, goalId: null }).actionKind, "goal.create", "off track is not a Goal-disable command"); -equal(routeWorkspaceInput("Create a Goal without Heartbeat", { ...goalContext, goalId: null }).normalizedParameters.heartbeat_enabled, false, "English Goal can explicitly omit Heartbeat"); - -console.log("personal workspace router smoke passed"); diff --git a/apps/presentation/dashboard/smoke/todo-resume-condition-smoke.ts b/apps/presentation/dashboard/smoke/todo-resume-condition-smoke.ts new file mode 100644 index 0000000000..5b9f6940e0 --- /dev/null +++ b/apps/presentation/dashboard/smoke/todo-resume-condition-smoke.ts @@ -0,0 +1,9 @@ +import { parseTodoResumeCondition } from "../src/features/personal-workspace/todo-resume-condition.js"; + +for (const condition of ["todo_done:todo_release", "pr_merged:example/project#42", "capacity_available:worker", "resume_at:2026-10-01T09:00:00Z"]) { + if (parseTodoResumeCondition(condition) !== condition.toLowerCase()) throw new Error(`Supported resume condition rejected: ${condition}`); +} +for (const value of ["tomorrow", "owner_resume", "pr_merged:#0", "todo_done:", "resume_at:2026-10-01"]) { + if (parseTodoResumeCondition(value) !== null) throw new Error(`Unsupported resume condition admitted: ${value}`); +} +console.log("todo-resume-condition-smoke: ok"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index d6e34ac46a..be65d40685 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -41,7 +41,7 @@ import type { LarkGoalConnection } from "../../data/chat"; import { localizedAttentionAge, localizedGoalState, localizedSessionStatus, useWorkspaceI18n } from "./i18n"; import { formatCostUsd, formatDurationMs, formatTokenCount, formatUsageValue } from "./personal-workspace-model"; import { TeamPlanResult } from "./team-plan-result"; -import { todoResumeWhenFromMessage } from "./personal-workspace-router"; +import { parseTodoResumeCondition } from "./todo-resume-condition"; function subagentModelRequest(include: boolean, model: string, effort: string) { if (!include) return {}; @@ -298,7 +298,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention || Boolean(selection.item.outputs?.length) ); const attentionAge = selection.kind === "attention" ? localizedAttentionAge(selection.item.updatedAt, t) : null; - const normalizedTodoResumeWhen = todoResumeWhenFromMessage(todoResumeWhen); + const normalizedTodoResumeWhen = parseTodoResumeCondition(todoResumeWhen); async function sendCorrection() { if (selection.kind !== "run" || !correction.trim()) return; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index d30f3ea61a..331353f660 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -69,14 +69,8 @@ const en = { "composer.blockers": "Ask what blocks", "composer.agentProgressPrompt": "Give me a progress report for this Goal: completed, running, blocked, and next steps.", "composer.attachImageHint": "Choose, paste, or drag an image", - "composer.clarifyDefer": "Deferring a Todo requires a deterministic resume condition. Add todo_done:, pr_merged:[owner/repo]#, capacity_available:, or resume_at:.", - "composer.clarifySingleAction": "This message contains multiple operations that may change state. Describe one operation at a time so each confirmation preview can be reviewed separately.", "composer.createGoal": "Create Goal", - "composer.createGoalDraft": "Goal draft", - "composer.createGoalDraftDescription": "Complete the draft and send it. LoopX will show a confirmation preview first.", - "composer.createGoalDraftLead": "Create a long-term Goal:", - "composer.createGoalTemplate": "Create a long-term Goal:\nObjective:\nCompletion criteria:\nExecution boundary (optional):\nRelated repository (optional):\nNotification method (optional):", - "composer.createGoalHint": "Insert a Goal template to review before creation", + "composer.createGoalHint": "Configure and review a new Goal", "composer.evidence": "Check what is proven", "composer.globalProgress": "Summarize all Goal progress", "composer.globalProgressPrompt": "Summarize the latest progress and blockers for all active Goals.", @@ -96,13 +90,6 @@ const en = { "composer.managerMessageHint": "Your message goes to the LoopX Manager across Goals for global questions or Goal creation.", "composer.managerPlaceholder": "Ask the LoopX Manager, or describe a new Goal…", "composer.monitor": "Configure scheduled check", - "composer.monitorGoalQuestion": "Which Goal should receive the scheduled check?", - "composer.monitorTemplate": "Add a scheduled check for the current Goal:\nCheck target:\nFrequency (supports 30 minutes / 2 hours / daily): Every 2 hours\nStop condition: Goal completes", - "composer.monitorShortcutTemplate": "Add a scheduled check for the current Goal:\nCheck target: {target}\nFrequency: Every 2 hours\nStop condition: Goal completes", - "composer.monitorTemplateWithoutGoal": "Configure a scheduled check:\nGoal:\nCheck target:\nFrequency: Every 2 hours\nStop condition: Goal completes", - "composer.heartbeatGoalQuestion": "Which Goal should receive the Heartbeat?", - "composer.heartbeatTemplate": "Set a Heartbeat for the current Goal:\nFrequency: Daily\nStop condition: Goal completes\nNotification: Only notify me when needed", - "composer.heartbeatTemplateWithoutGoal": "Set up a Heartbeat:\nGoal:\nFrequency: Daily\nStop condition: Goal completes\nNotification: Only notify me when needed", "composer.nextAction": "Ask what’s next", "composer.nextActionPrompt": "What should I do next?", "composer.send": "Send", @@ -785,7 +772,6 @@ const en = { "schedule.paused": "Paused", "schedule.summary": "Continuously checked under LoopX scheduling constraints", "schedule.defaultTarget": "Check the current Goal for blockers, progress, and new outputs", - "schedule.unsupportedCalendar": "Scheduled checks do not currently support an exact weekday or time. Use a fixed interval such as “Every 30 minutes,” “Every 2 hours,” or “Daily.” The draft was preserved and no confirmation preview was created.", "source.add": "Add source", "source.addConfigured": "Add read-only source", "source.addMethod": "Source setup method", @@ -1183,15 +1169,9 @@ const zhCN: Record = { "composer.agentProgressPrompt": "请给我一份当前 Goal 的进度报告:已完成、执行中、阻塞和下一步。", "composer.blockers": "看阻塞", "composer.attachImageHint": "选择、粘贴或拖入图片", - "composer.clarifyDefer": "暂缓 Todo 需要可自动判断的恢复条件。请补充 todo_done:、pr_merged:[owner/repo]#、capacity_available: 或 resume_at:<带时区 RFC3339 时间>。", - "composer.clarifySingleAction": "这句话包含多个可能修改状态的操作。请一次描述一项操作,我会逐项展示确认预览。", "composer.evidence": "查证据", "composer.createGoal": "创建新 Goal", - "composer.createGoalDraft": "Goal 草稿", - "composer.createGoalDraftDescription": "补充内容后发送,LoopX 会先展示待确认操作。", - "composer.createGoalDraftLead": "我想创建一个长期 Goal:", - "composer.createGoalTemplate": "我想创建一个长期 Goal:\n目标:\n完成标准:\n执行边界(可选):\n关联仓库(可选):\n通知方式(可选):", - "composer.createGoalHint": "填入 Goal 模板草稿,检查后再创建", + "composer.createGoalHint": "填写 Goal 配置,检查后再创建", "composer.globalProgress": "汇总所有 Goal 进展", "composer.globalProgressPrompt": "请帮我汇总所有活跃 Goal 的最新进展与阻塞。", "composer.globalTasks": "询问全局待办", @@ -1210,13 +1190,6 @@ const zhCN: Record = { "composer.managerMessageHint": "你的消息由 LoopX 管家跨 Goal 接收,支持全局询问与创建 Goal", "composer.managerPlaceholder": "问问 LoopX 管家,或描述一个新 Goal…", "composer.monitor": "配置定时检查", - "composer.monitorGoalQuestion": "为哪个 Goal 添加定时检查?", - "composer.monitorTemplate": "为当前 Goal 添加定时检查:\n检查内容:\n频率(支持 30 分钟 / 2 小时 / 每天):每 2 小时\n停止条件:Goal 完成", - "composer.monitorShortcutTemplate": "为当前 Goal 添加定时检查:\n检查内容:{target}\n频率:每 2 小时\n停止条件:Goal 完成", - "composer.monitorTemplateWithoutGoal": "配置定时检查:\nGoal:\n检查内容:\n频率:每 2 小时\n停止条件:Goal 完成", - "composer.heartbeatGoalQuestion": "为哪个 Goal 设置 Heartbeat?", - "composer.heartbeatTemplate": "为当前 Goal 设置 Heartbeat:\n频率:每天\n停止条件:Goal 完成\n通知:仅在需要我时", - "composer.heartbeatTemplateWithoutGoal": "设置 Heartbeat:\nGoal:\n频率:每天\n停止条件:Goal 完成\n通知:仅在需要我时", "composer.nextAction": "询问下一步", "composer.nextActionPrompt": "我现在该做什么?", "composer.send": "发送", @@ -1899,7 +1872,6 @@ const zhCN: Record = { "schedule.paused": "已暂停", "schedule.summary": "按 LoopX 调度约束持续检查", "schedule.defaultTarget": "检查当前 Goal 的阻塞、进度与新产出", - "schedule.unsupportedCalendar": "当前定时检查不支持精确到星期或时刻的日历计划。请改用固定间隔,例如“每 30 分钟”“每 2 小时”或“每天”;草稿已保留,没有生成待确认操作。", "source.add": "添加来源", "source.addConfigured": "添加只读来源", "source.addMethod": "来源添加方式", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index f3f2bd04fa..ee0987dbed 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -7,8 +7,9 @@ const model = source("./personal-workspace-model.ts"); const drawer = source("./context-drawer.tsx"); const header = source("./channel-header.tsx"); const sidebar = source("./goal-sidebar.tsx"); +const actionForm = source("./workspace-action-form.tsx"); const page = source("./personal-workspace-page.tsx"); -const router = source("./personal-workspace-router.ts"); + const shell = source("./workspace-shell.tsx"); const timeline = source("./channel-timeline.tsx"); const returnDelivery = source("./return-delivery-status.tsx"); @@ -89,12 +90,6 @@ assert.doesNotMatch( /onCorrectRun:[\s\S]{0,240}sendManagerQuestion/, "Run correction does not fall back to the read-only manager Chat", ); -assert.match(page, /routeWorkspaceInput\(message,/, "Every free-text send enters the unified Router contract"); -assert.match(router, /route: "projection" \| "typed_action" \| "agent_chat" \| "clarify"/, "Router exposes the constrained route contract"); -assert.match(router, /function executionIntent/, "Execution intent stays inside the Router implementation"); -assert.match(router, /function negates/, "Router can honor explicit negation"); -assert.doesNotMatch(router, /protectedActionIntent|protectedActionRules/, "Free-text protected operations are not interpreted by browser keyword rules"); -assert.doesNotMatch(router, /"goal\.update"/, "The browser Router type cannot emit a protected Goal action"); assert.doesNotMatch(page, /intentRoute\.actionKind === "goal\.update"|workspace-protected-/, "Free-text send has no legacy protected-action preview branch"); assert.match(chatData, /protected_action: protectedActionProposalSchema/, "Chat accepts one narrow semantic protected-action proposal"); assert.match(chatData, /"operation\.execute"/, "Dashboard accepts canonical operation proposals"); @@ -151,10 +146,8 @@ for (const inputKind of ["boolean", "number", "select", "string_list"]) { assert.match(capabilityFields, new RegExp(`input_kind === "${inputKind}"`), `Shared capability fields render ${inputKind}`); } assert.match(tasks, /t\("tasks\.convertToTask"\)/, "Tasks offer an explicit preview-first bridge from a reply to task management"); -assert.match(page, /function todoTextFromMessage[\s\S]*标题[\s\S]*内容/, "Todo parsing preserves structured title and content fields"); assert.match(page, /t\("home\.taskCount"/, "Home cards expose durable activity when a new Goal has Todos but no run timestamp yet"); assert.match(page, /t\("proposal\.primary\.goalCreate"\)/, "Goal creation names the localized immediate first-turn effect"); -assert.match(router, /const asksForMutation/, "Execution routing remains explicit inside the Router contract"); assert.match(timeline, /t\("timeline\.waitingConfirmation"\)/, "Historical gated proposals are grouped into a compact summary"); assert.match(timeline, /gatedItems\.length/, "The compact Gate summary exposes the pending count"); assert.match(timeline, /Boolean\(item\.run\.sessionId\)/, "Running count requires a discovered execution Session"); @@ -171,9 +164,6 @@ assert.match(dashboard, /attachments: route\?\.attachments/, "Image attachments assert.match(page, /sendMessage\(t\("composer\.agentProgressPrompt"\)\)/, "Progress report shortcut sends a scoped read-only request immediately"); assert.match(page, /t\("composer\.agentProgress"\)/, "Progress report shortcut makes its immediate-send behavior explicit"); assert.match(page, /t\("composer\.nextAction"\)[\s\S]*sendMessage\(t\("composer\.nextActionPrompt"\)\)/, "Advice shortcut sends its scoped question immediately"); -assert.match(page, /t\("composer\.monitor"\)[\s\S]*sendMessage\(t\("composer\.monitorShortcutTemplate", \{ target: t\("schedule\.defaultTarget"\) \}\)\)/, "Monitor shortcut sends its bounded template with a named check target immediately"); -assert.match(page, /goalDraftActive[\s\S]*t\("composer\.createGoalDraft"\)[\s\S]*t\("composer\.createGoal"/, "Create Goal mode is visibly distinct from a normal chat draft"); -assert.match(page, /setComposerDraft\(`manager:\$\{selectedAgentId\}`,\s*t\("composer\.createGoalTemplate"\)\)/, "Create Goal writes the localized template to the manager draft even when invoked from a Goal"); assert.match(page, /personal-action-feedback/, "Typed actions surface a persistent visible receipt"); assert.match(page, /visibleTimelineItems[\s\S]*item\.run\.runId === activeSessionRun\.runId/, "Session record mode filters unrelated Goal activity"); assert.match(page, /function openGoalConversation\(\)[\s\S]*setActiveSessionRun\(null\)[\s\S]*scrollIntoView\(\{ block: "start" \}\)/, "Opening a Goal reply exits a nested Session and reveals the latest answer"); @@ -184,13 +174,6 @@ assert.match(drawer, /t\("drawer\.proposalApplyFailed"\)/, "Failed preview commu assert.match(drawer, /onClick=\{onClose\} type="button">\{t\("drawer\.proposalClose"\)\}/, "Closing a proposal is a pure UI action with zero state transition"); assert.match(drawer, /drawer\.copyRepositoryDone[\s\S]*drawer\.copyRepositorySuccess/, "Repository copy action exposes a visible receipt"); assert.doesNotMatch(drawer, />打开 Goal/, "Every action preview field retains a stable semantic key beside its localized label"); assert.match(page, /\.map\(\(\[key, value\]\) => \(\{[\s\S]*key,[\s\S]*label: fieldLabels\[key\]/, "Typed action projection preserves semantic parameter keys while localizing labels"); assert.match(page, /field\.key === "cadence"[\s\S]*field\.key === "stop_condition"[\s\S]*field\.key === "timezone"/, "Applied Heartbeat readback consumes stable semantic keys"); @@ -298,8 +281,6 @@ assert.doesNotMatch(header, /切换到野兽主题|切换到默认主题/, "Work assert.match(workspaceTheme, /workspaceThemeStorageKey = "loopx-pw-theme"/, "Theme preference persists across reloads"); assert.doesNotMatch(dashboard, /isManagerProjectionQuestion/, "Ordinary manager questions do not silently bypass the selected model by matching phrases"); assert.match(dashboard, /if \(selectedRoute\.agentId === "status-only" \|\| \(!targetGoal && targetContextId !== "manager"\)\)/, "Projection answers require the explicit status-only route or a missing Goal fallback"); -assert.match(dashboard, /const asksForNextAction[\s\S]*if \(asksForNextAction\)[\s\S]*personalManagerMatches\(question, \["状态"/, "A next-step question outranks a read-only boundary that mentions state"); -assert.match(dashboard, /先处理「\$\{personalGoalTitle\(nextTodo\.goalId\)\}」:\$\{nextTodo\.text\}/, "The compact manager answer names the Goal and concrete blocking action"); assert.match(drawer, /t\("drawer\.decisionReview"\)/, "Blocked items preview their decision boundary before any write"); assert.match(drawer, /const hasProjectedRunActivity = selection\.kind === "run"[\s\S]*selection\.item\.completedSteps > 0/, "Session empty-state copy distinguishes projected progress from a truly idle run"); assert.match(drawer, /t\("drawer\.runRecordProjected"/, "A projected run does not claim that the Agent never started"); @@ -367,12 +348,10 @@ assert.match(page, /proposal\.status === "applied"/, "Unconfirmed Heartbeat prev assert.match(drawer, /actionKind === "goal\.create" \? t\("drawer\.proposalEnterGoal"\) : t\(selection\.item\.actionKind === "team\.plan" \? "proposal\.teamPlan\.openGoal" : "drawer\.proposalViewGoal"\)/, "Applied actions offer scoped refreshed navigation labels"); assert.doesNotMatch(sidebar, /Agent 设置/, "The sidebar omits the read-only Agent settings dead end"); assert.doesNotMatch(sidebar, /野兽主题|默认主题/, "The sidebar keeps one owner-reviewed visual theme"); -for (const key of ["composer.createGoalTemplate", "composer.monitorTemplate", "composer.heartbeatTemplate", "proposal.primary.goalCreate", "proposal.impact.goalCreate"]) { +for (const key of ["proposal.primary.goalCreate", "proposal.impact.goalCreate"]) { assert.match(i18n, new RegExp(`"${key.replaceAll(".", "\\.")}"`), `${key} has a typed locale resource`); } -assert.match(i18n, /Create a long-term Goal:[\s\S]*Completion criteria:[\s\S]*Related repository \(optional\):[\s\S]*Notification method \(optional\):/, "English Create Goal starts with a useful objective form instead of a host gate"); -assert.match(i18n, /我想创建一个长期 Goal:[\s\S]*完成标准:[\s\S]*关联仓库(可选):[\s\S]*通知方式(可选):/, "Chinese Create Goal keeps its useful objective form"); -assert.match(page, /workspace_ref:\s*"current"/, "Create Goal does not leak another Goal id as its execution workspace"); +assert.match(actionForm, /workspace_ref:\s*"current"/, "Create Goal does not leak another Goal id as its execution workspace"); assert.match(page, /t\("proposal\.workspace\.current"\)/, "Create Goal localizes its execution workspace explanation"); assert.match(i18n, /Current local workspace \(no Repository bound\)/, "English workspace copy explains that no repository is bound"); assert.match(i18n, /当前本地工作区(未绑定 Repository)/, "Chinese workspace copy explains that no repository is bound"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index f4eb0cd147..ce5bca178f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -60,29 +60,13 @@ import type { WorkspaceTodo, } from "./personal-workspace-model"; import { goalHasExecutionSummary, goalTitleFor, workspaceHomeLaneForGoal } from "./personal-workspace-model"; -import { routeWorkspaceInput } from "./personal-workspace-router"; +import { WorkspaceActionForm, type WorkspaceActionDraft } from "./workspace-action-form"; import { WorkspaceSettingsPage } from "./workspace-settings-page"; import { readWorkspaceTheme, writeWorkspaceTheme, type WorkspaceTheme } from "./workspace-theme"; import { WorkspaceShell } from "./workspace-shell"; import type { StatusSourceControl } from "./status-source-switcher"; import "./personal-workspace.css"; -export function sanitizeTaskDraftFromReply(reply: string): string { - const nextStepMatch = reply.match(/(?:下一步|建议|行动项|待办)[::\s]*([^\n]+)/u); - let candidate = nextStepMatch ? nextStepMatch[1] : reply; - candidate = candidate - .replace(/```[\s\S]*?```/g, "") - .replace(/`([^`]+)`/g, "$1") - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") - .replace(/[#*~_>]/g, "") - .replace(/^[-*•\d+.\s]+/u, "") - .replace(/^(好的|没问题|收到|建议如下|任务如下|分析如下|结论[::])[\s,,::]*/u, ""); - const lines = candidate.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - const firstLine = lines[0] || candidate; - const normalized = firstLine.replace(/\s+/gu, " ").trim(); - return Array.from(normalized).slice(0, 120).join(""); -} - function dedupeProposals(proposals: WorkspaceActionPreview[]): WorkspaceActionPreview[] { const latest = new Map(); proposals.forEach((proposal) => { @@ -701,109 +685,6 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) }; } -function compactGoalSlug(value: string) { - const ascii = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 42); - if (ascii) return ascii; - let hash = 2_166_136_261; - for (const character of value) { - hash ^= character.codePointAt(0) ?? 0; - hash = Math.imul(hash, 16_777_619); - } - return `goal-${(hash >>> 0).toString(36)}`; -} - -function goalTitleFromMessage(message: string, t: WorkspaceTranslate) { - const quoted = message.match(/[「“"]([^」”"]{2,80})[」”"]/u)?.[1]; - if (quoted) return quoted.trim(); - return message - .replace(/^(请|帮我|我想|给我|创建|新建|设置|please|i want to|create|set up)+/iu, "") - .replace(/(一个|新的)?\s*(goal|目标)/giu, "") - .replace(/[,。!?].*$/u, "") - .trim() - .slice(0, 80) || t("goal.defaultTitle"); -} - -function structuredFieldFromMessage(message: string, labels: string[]) { - for (const line of message.split(/\r?\n/u)) { - const trimmed = line.trim(); - for (const label of labels) { - const match = trimmed.match(new RegExp(`^${label.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}\\s*[::]\\s*(.*)$`, "iu")); - if (match?.[1]?.trim()) return match[1].trim(); - } - } - return ""; -} - -function structuredGoalIntentFromMessage(message: string, t: WorkspaceTranslate) { - const target = structuredFieldFromMessage(message, ["目标", "Objective"]); - const completion = structuredFieldFromMessage(message, ["完成标准", "Completion criteria"]); - const boundary = structuredFieldFromMessage(message, ["执行边界(可选)", "执行边界", "边界", "Execution boundary (optional)", "Execution boundary", "Boundary"]); - const title = (target || goalTitleFromMessage(message, t)).split(/[。;;\n]/u)[0].trim().slice(0, 80) || t("goal.defaultTitle"); - const objective = [target || title, completion ? t("goal.objectiveCompletion", { criteria: completion }) : "", boundary ? t("goal.objectiveBoundary", { boundary }) : ""] - .filter(Boolean) - .join("\n"); - const readOnly = /(只读|不调用外部工具|不修改(?:仓库|代码|状态)|read.?only|do not (?:call|use) external tools|do not modify (?:repositories|repository|code|state))/iu.test(boundary || message); - return { - completionCriteria: completion, - executionBoundary: boundary, - initialTodos: completion ? [t("goal.initialTodo", { criteria: completion })] : [], - objective, - permission: readOnly ? "read_only" : "workspace_write_on_confirmation", - title, - }; -} - -function cadenceFromMessage(message: string) { - const minutes = message.match(/(?:每|every)\s*(\d{1,3})\s*(?:分钟|minutes?)/iu)?.[1]; - if (minutes) return `${minutes}m`; - const hours = message.match(/(?:每|every)\s*(\d{1,2})\s*(?:小时|hours?)/iu)?.[1]; - if (hours) return `${hours}h`; - if (/每小时|every hour|hourly/iu.test(message)) return "1h"; - if (/每天|每日|早上|上午|daily|every day/iu.test(message)) return "1d"; - return "1d"; -} - -function unsupportedCalendarScheduleReason(message: string, t: WorkspaceTranslate) { - if (/(每周|星期|周[一二三四五六日天]|weekly|every\s+(?:mon|tues|wednes|thurs|fri|satur|sun)day|\d{1,2}\s*[::]\s*\d{2})/iu.test(message)) { - return t("schedule.unsupportedCalendar"); - } - return null; -} - -function monitorTargetFromMessage(message: string, t: WorkspaceTranslate) { - return structuredFieldFromMessage(message, ["检查内容", "监控内容", "目标", "Check target", "Monitor target", "Target"]) - || message.replace(/^(?:为当前 Goal |for the current Goal )?(?:添加|配置|创建|add|configure|create)?\s*(?:定时检查|监控|scheduled check|monitor)[::]?/iu, "").split(/\r?\n/u)[0].trim() - || t("schedule.defaultTarget"); -} - -function stopConditionFromMessage(message: string) { - if (/(mr|pr).{0,8}(合并|merge)/iu.test(message)) return "pr_merged"; - if (/发布完成|上线完成|release (?:is )?complete|deployment (?:is )?complete/iu.test(message)) return "release_complete"; - return "goal_complete"; -} - -function mentionedAgent(message: string, agents: WorkspaceAgentOption[]) { - const normalized = message.toLowerCase(); - return agents.find((agent) => - normalized.includes(agent.agentId.toLowerCase()) || normalized.includes(agent.label.toLowerCase())); -} - -function todoTextFromMessage(message: string) { - const titled = structuredFieldFromMessage(message, ["标题", "任务标题", "Todo 标题"]); - const content = structuredFieldFromMessage(message, ["内容", "任务内容", "Todo 内容"]); - if (titled) return [titled, content].filter(Boolean).join(":").slice(0, 400); - const quoted = message.match(/[「“"]([^」”"]{2,200})[」”"]/u)?.[1]; - if (quoted) return quoted.trim(); - return message - .replace(/^(请|帮我|给我|为当前 Goal |新增|新建|创建|添加|加上|加一个|记一个)+/u, "") - .replace(/^(一个\s*)?(普通\s*)?(todo|待办|任务)(?:\s*到\s*Tasks?)?[::\s]*/iu, "") - .replace(/[。;;,,]\s*(?:不要|不需要|无需|禁止|别|暂不).{0,80}(?:heartbeat|心跳|定时|监控|执行).*$/iu, "") - .replace(/[,,]\s*(并且|然后|再)?\s*(交给|分配给|让).+$/u, "") - .replace(/\s*(交给|分配给|让)\s+.+$/u, "") - .trim() - .slice(0, 400) || "推进当前 Goal 的下一项工作"; -} - const acceptedImageTypes = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); const maxImageAttachmentBytes = 5 * 1024 * 1024; const maxImageAttachmentCount = 4; @@ -874,6 +755,7 @@ export function PersonalWorkspacePage({ } }); const [sending, setSending] = useState(false); + const [actionDraft, setActionDraft] = useState(null); const [loopxMode, setLoopxMode] = useState(null); const [loopxDelivery, setLoopxDelivery] = useState<"queue" | "inbox" | "steer">("queue"); const [loopxMessageReceipt, setLoopxMessageReceipt] = useState(""); @@ -1231,9 +1113,7 @@ export function PersonalWorkspacePage({ } function requestGoalCreate() { - selectGoal(null); - setComposerDraft(`manager:${selectedAgentId}`, t("composer.createGoalTemplate")); - window.requestAnimationFrame(() => composerRef.current?.focus()); + setActionDraft({ kind: "goal", goalId: null, goalTitle: "", agentId: selectedAgentId }); } async function requestGoalLifecycle(goal: WorkspaceGoal, operation: GoalLifecycleOperation) { @@ -1332,55 +1212,8 @@ export function PersonalWorkspacePage({ } function prepareScheduleDraft(kind: "heartbeat" | "monitor", goalId: string | null) { - if (!goalId) { - setComposer(kind === "heartbeat" - ? t("composer.heartbeatTemplateWithoutGoal") - : t("composer.monitorTemplateWithoutGoal")); - } else { - setComposer(kind === "heartbeat" - ? t("composer.heartbeatTemplate") - : t("composer.monitorTemplate")); - } + setActionDraft({ kind, goalId, goalTitle: workspaceGoals.find((goal) => goal.goalId === goalId)?.title ?? "", agentId: selectedAgentId }); setSelection(null); - window.requestAnimationFrame(() => composerRef.current?.focus()); - } - - async function requestSchedule(kind: "heartbeat" | "monitor", goalId: string | null, intent = "") { - const delegated = await callbacks.onRequestScheduleConfig?.(kind, goalId); - if (delegated) { - setSessionProposalIds((current) => current.includes(delegated.previewId) ? current : [...current, delegated.previewId]); - setProposals((current) => ({ ...current, [delegated.previewId]: delegated })); - setSelection({ item: delegated, kind: "proposal" }); - return; - } - if (!goalId) { - setComposer(kind === "heartbeat" ? t("composer.heartbeatGoalQuestion") : t("composer.monitorGoalQuestion")); - return; - } - const timestamp = Date.now().toString(36); - await createPreview({ - actionKind: kind === "heartbeat" ? "heartbeat.bind" : "monitor.create", - context: { kind: "schedule", goal_id: goalId }, - idempotencyKey: `workspace-${kind}-${goalId}-${timestamp}`, - normalizedParameters: kind === "heartbeat" ? { - agent_id: selectedAgentId, - cadence: cadenceFromMessage(intent), - goal_id: goalId, - stop_condition: stopConditionFromMessage(intent), - timezone: "Asia/Shanghai", - } : { - agent_id: selectedAgentId, - cadence: cadenceFromMessage(intent), - goal_id: goalId, - stop_condition: stopConditionFromMessage(intent), - target: monitorTargetFromMessage(intent, t), - target_key: `goal-${goalId}`, - timezone: "Asia/Shanghai", - }, - summary: kind === "heartbeat" - ? t("proposal.summary.heartbeat") - : t("proposal.summary.monitor", { target: monitorTargetFromMessage(intent, t) }), - }); } async function requestQuickTodoCompletion(todo: WorkspaceTodo) { @@ -1710,137 +1543,9 @@ export function PersonalWorkspacePage({ setImageAttachmentError(null); setSending(true); try { - if (pendingImages.length) { - if (!selectedGoalId) setManagerConversationReceiptVisible(true); - else if (selectedGoalTab !== "chat") setGoalConversationReceiptVisible(true); - const semanticPreview = await callbacks.onSendMessage?.(message, selectedAgentId, selectedGoalId, pendingImages); - if (semanticPreview) await createPreview(semanticPreview); - return; - } - const intentRoute = routeWorkspaceInput(message, { - agents: agents.map((agent) => ({ agentId: agent.agentId, label: agent.label })), - goalId: selectedGoalId, - todos: (selectedGoal?.agentTodos ?? []).map((todo) => ({ text: todo.text, todoId: todo.todoId })), - }); - if (intentRoute.route === "clarify") { - setComposer(message); - let clarification = t("composer.clarifySingleAction"); - if (intentRoute.missingFields.includes("resume_when")) clarification = t("composer.clarifyDefer"); - setActionFeedback(clarification); - return; - } - if (intentRoute.actionKind === "goal.create") { - const intent = structuredGoalIntentFromMessage(message, t); - const goalId = compactGoalSlug(intent.title); - await createPreview({ - actionKind: "goal.create", - context: { kind: "manager", goal_id: null, natural_language: message }, - idempotencyKey: `workspace-goal-intent-${goalId}-${Date.now().toString(36)}`, - normalizedParameters: { - agent_id: selectedAgentId, - completion_criteria: intent.completionCriteria, - execution_boundary: intent.executionBoundary, - goal_id: goalId, - heartbeat: { - cadence: cadenceFromMessage(message), - enabled: intentRoute.normalizedParameters.heartbeat_enabled === true, - timezone: "Asia/Shanghai", - }, - initial_todos: intent.initialTodos, - objective: intent.objective, - permission: intent.permission, - stop_condition: stopConditionFromMessage(message), - title: intent.title, - workspace_ref: "current", - }, - summary: t("proposal.summary.goalCreate", { title: intent.title }), - }); - return; - } - if (selectedGoalId && intentRoute.actionKind === "heartbeat.bind") { - await requestSchedule("heartbeat", selectedGoalId, message); - return; - } - if (selectedGoalId && intentRoute.actionKind === "monitor.create") { - const scheduleError = unsupportedCalendarScheduleReason(message, t); - if (scheduleError) { - setComposer(message); - setActionFeedback(scheduleError); - return; - } - await requestSchedule("monitor", selectedGoalId, message); - return; - } - const requestedAgent = mentionedAgent(message, agents); - if (selectedGoalId && requestedAgent && intentRoute.actionKind === "agent.bind") { - await createPreview({ - actionKind: "agent.bind", - context: { kind: "goal", goal_id: selectedGoalId, natural_language: message }, - idempotencyKey: `workspace-agent-bind-${selectedGoalId}-${requestedAgent.agentId}-${Date.now().toString(36)}`, - normalizedParameters: { agent_id: requestedAgent.agentId, goal_id: selectedGoalId }, - summary: `将 ${requestedAgent.label} 绑定到 ${selectedGoal?.title ?? selectedGoalId}`, - }); - return; - } - if (selectedGoalId && intentRoute.actionKind === "todo.create") { - if (intentRoute.normalizedParameters.start_execution === true) { - await createPreview({ - actionKind: "todo.create", - context: { kind: "goal", goal_id: selectedGoalId, natural_language: message }, - idempotencyKey: `workspace-task-start-${selectedGoalId}-${Date.now().toString(36)}`, - normalizedParameters: { - endpoint_id: requestedAgent?.agentId ?? selectedAgentId, - goal_id: selectedGoalId, - start_execution: true, - text: message, - }, - summary: `交给 Agent 执行:${message.slice(0, 120)}`, - }); - return; - } - const assignedAgentId = requestedAgent?.agentId - ?? (/(交给|分配给|让).{0,24}(agent|codex|claude|kiro|kimi)/iu.test(message) ? selectedAgentId : null); - await createPreview({ - actionKind: "todo.create", - context: { kind: "goal", goal_id: selectedGoalId, natural_language: message }, - idempotencyKey: `workspace-todo-create-${selectedGoalId}-${Date.now().toString(36)}`, - normalizedParameters: { - ...(assignedAgentId ? { endpoint_id: assignedAgentId } : {}), - goal_id: selectedGoalId, - text: todoTextFromMessage(message), - }, - summary: `创建 Todo:${todoTextFromMessage(message)}`, - }); - return; - } - const matchedTodo = selectedGoal?.agentTodos.find((todo) => - message.includes(todo.todoId) || message.includes(todo.text)); - const todoOperation = typeof intentRoute.normalizedParameters.operation === "string" - ? intentRoute.normalizedParameters.operation - : null; - if (selectedGoalId && matchedTodo && intentRoute.actionKind === "todo.update" && todoOperation) { - await createPreview({ - actionKind: "todo.update", - context: { kind: "todo", goal_id: selectedGoalId, todo_id: matchedTodo.todoId, natural_language: message }, - idempotencyKey: `workspace-todo-update-${matchedTodo.todoId}-${todoOperation}-${Date.now().toString(36)}`, - normalizedParameters: { - agent_id: selectedAgentId, - ...(todoOperation === "reassign" && requestedAgent ? { endpoint_id: requestedAgent.agentId } : {}), - ...(todoOperation === "block" ? { note: message } : {}), - ...(todoOperation === "defer" && typeof intentRoute.normalizedParameters.resume_when === "string" - ? { resume_when: intentRoute.normalizedParameters.resume_when } - : {}), - goal_id: selectedGoalId, - operation: todoOperation, - todo_id: matchedTodo.todoId, - }, - summary: `更新 Todo:${matchedTodo.text}`, - }); - return; - } if (!selectedGoalId) setManagerConversationReceiptVisible(true); else if (selectedGoalTab !== "chat") setGoalConversationReceiptVisible(true); - const semanticPreview = await callbacks.onSendMessage?.(message, selectedAgentId, selectedGoalId); + const semanticPreview = await callbacks.onSendMessage?.(message, selectedAgentId, selectedGoalId, pendingImages.length ? pendingImages : undefined); if (semanticPreview) await createPreview(semanticPreview); } catch (error) { if (!messageOverride) { @@ -1855,7 +1560,6 @@ export function PersonalWorkspacePage({ } const selectedAgentLabel = agents.find((agent) => agent.agentId === selectedAgentId)?.label ?? selectedAgentId; - const goalDraftActive = !selectedGoal && composer.startsWith(t("composer.createGoalDraftLead")); async function selectImages(files: FileList | readonly File[] | null) { @@ -2033,10 +1737,8 @@ export function PersonalWorkspacePage({ goal={selectedGoal} items={items} onDraftTaskFromMessage={readOnly ? undefined : (reply) => { - const taskDraft = sanitizeTaskDraftFromReply(reply); - setComposer(`创建一个 Task:${taskDraft}`); - setActionFeedback(t("feedback.taskDraftCreated")); - window.requestAnimationFrame(() => composerRef.current?.focus()); + const taskDraft = reply.trim(); + setActionDraft({ kind: "todo", text: taskDraft, goalId: selectedGoalId, goalTitle: selectedGoal.title, agentId: selectedAgentId }); }} onOpenChat={openGoalConversation} onQuickComplete={readOnly ? undefined : requestQuickTodoCompletion} @@ -2104,10 +1806,8 @@ export function PersonalWorkspacePage({ messages={goalMessages} onClose={() => setGoalConversationReceiptVisible(false)} onDraftTask={selectedGoalTab === "tasks" ? (reply) => { - const taskDraft = sanitizeTaskDraftFromReply(reply); - setComposer(`创建一个 Task:${taskDraft}`); - setActionFeedback(t("feedback.taskDraftCreated")); - window.requestAnimationFrame(() => composerRef.current?.focus()); + const taskDraft = reply.trim(); + setActionDraft({ kind: "todo", text: taskDraft, goalId: selectedGoalId, goalTitle: selectedGoal.title, agentId: selectedAgentId }); } : undefined} onOpenConversation={openGoalConversation} title={`${selectedGoal.title} · ${selectedAgentLabel}`} @@ -2125,7 +1825,7 @@ export function PersonalWorkspacePage({
- +
@@ -2137,7 +1837,7 @@ export function PersonalWorkspacePage({ )} - {goalDraftActive ?
{t("composer.createGoalDraft")}{t("composer.createGoalDraftDescription")}
: null} + {actionDraft ? setActionDraft(null)} onPreview={(request) => createPreview(request)} /> : null} {imageAttachments.length ?
{imageAttachments.map((attachment) => (
{attachment.name} @@ -2185,7 +1885,7 @@ export function PersonalWorkspacePage({ rows={1} value={composer} /> - +
} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-router.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-router.ts deleted file mode 100644 index 457734d7b3..0000000000 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-router.ts +++ /dev/null @@ -1,145 +0,0 @@ -export type WorkspaceRouterActionKind = - | "goal.create" - | "todo.create" - | "todo.update" - | "agent.bind" - | "heartbeat.bind" - | "monitor.create"; - -export type WorkspaceRouterResult = { - actionKind: WorkspaceRouterActionKind | null; - confidence: number; - missingFields: string[]; - normalizedParameters: Record; - route: "projection" | "typed_action" | "agent_chat" | "clarify"; -}; - -export type WorkspaceRouterContext = { - agents: Array<{ agentId: string; label: string }>; - goalId: string | null; - todos: Array<{ text: string; todoId: string }>; -}; - -function normalizedMessage(message: string) { - return message.replace(/\s+/gu, " ").trim(); -} - -function negates(message: string, subject: RegExp) { - const beforeSubject = new RegExp( - `(?:不要|不需要|无需|禁止|别|暂不|do not\\b|don't\\b|without\\b).{0,10}(?:${subject.source})`, - "iu", - ); - const chineseAfter = new RegExp(`(?:${subject.source}).{0,10}(?:不要|不需要|无需|禁止|关闭)`, "iu"); - const explicitEnglishDisable = new RegExp( - `disable\\b.{0,10}(?:${subject.source})|(?:turn|switch|set)\\b.{0,10}(?:${subject.source}).{0,10}\\boff\\b|(?:${subject.source})\\s+(?:is\\s+)?disabled\\b`, - "iu", - ); - return beforeSubject.test(message) - || chineseAfter.test(message) - || explicitEnglishDisable.test(message); -} - -function managerProjectionIntent(message: string) { - return /(我现在该做什么|下一步|哪些\s*Goal\s*在等我|需要我|谁在等我|Agent\s*在做什么|当前进度|总结(?:今天)?进展)/iu.test(message); -} - -function executionIntent(message: string) { - const asksForAdvice = /(怎么|如何|为什么|给.*建议|分析一下|解释|只读)/u.test(message); - const asksForMutation = /(解决一下|修复一下|处理一下|执行一下|改一下|跑(?:一下)?测试|rebase|push|提交|推送)/iu.test(message); - if (asksForAdvice) return false; - return asksForMutation - && /(帮我|请|给我|直接|现在|开始|bytedcli|codebase|git|rebase|push|提交|推送)/iu.test(message); -} - -export function todoResumeWhenFromMessage(rawMessage: string) { - const message = normalizedMessage(rawMessage).toLowerCase(); - const match = message.match( - /(?:^|[\s,,;;:((:]|到|至)(?todo_done:todo_[a-z0-9_-]{3,64}|pr_merged:(?:(?:[a-z0-9_.-]{1,80})\/(?:[a-z0-9_.-]{1,100}))?#[1-9][0-9]{0,8}|capacity_available:[a-z][a-z0-9_:-]{0,63}|resume_at:[1-9][0-9]{3}-[0-9]{2}-[0-9]{2}t[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,3})?(?:z|[+-][0-9]{2}:[0-9]{2}))(?=$|[\s,,。;;))])/iu, - ); - return match?.groups?.condition ?? null; -} - -export function routeWorkspaceInput(rawMessage: string, context: WorkspaceRouterContext): WorkspaceRouterResult { - const message = normalizedMessage(rawMessage); - const candidates: Array<{ actionKind: WorkspaceRouterActionKind; confidence: number; normalizedParameters: Record }> = []; - const agent = context.agents.find((candidate) => { - const lower = message.toLowerCase(); - return lower.includes(candidate.agentId.toLowerCase()) || lower.includes(candidate.label.toLowerCase()); - }); - const todo = context.todos.find((candidate) => message.includes(candidate.todoId) || message.includes(candidate.text)); - const todoSubject = "todo|待办|任务"; - const referencesExistingTodo = /(刚刚|已经|已)(?:经)?\s*(新增|创建|添加)(?:的)?\s*(todo|待办|任务)/iu.test(message); - const requestsHeartbeat = Boolean(context.goalId) - && !negates(message, /heartbeat|心跳/iu) - && /(heartbeat|心跳|每天推进|持续推进|daily progress)/iu.test(message); - - if (!context.goalId && !negates(message, /goal|目标/iu) && /(创建|新建|设置|create|start|set up).{0,24}(goal|目标)/iu.test(message)) { - candidates.push({ - actionKind: "goal.create", - confidence: 0.97, - normalizedParameters: { - heartbeat_enabled: !negates(message, /heartbeat|心跳/iu) && /(heartbeat|心跳|每天推进|持续推进|daily progress)/iu.test(message), - }, - }); - } - if (context.goalId && requestsHeartbeat) { - candidates.push({ actionKind: "heartbeat.bind", confidence: 0.96, normalizedParameters: { goal_id: context.goalId } }); - } - if (context.goalId - && !requestsHeartbeat - && !negates(message, /定时|监控|监测|持续观察|scheduled check|monitor/iu) - && /(定时|监控|监测|每.{0,8}(分钟|小时|天)|持续观察|scheduled check|monitor|every.{0,12}(minute|hour|day)|daily)/iu.test(message)) { - candidates.push({ actionKind: "monitor.create", confidence: 0.94, normalizedParameters: { goal_id: context.goalId } }); - } - if (context.goalId && agent && !negates(message, /绑定|负责|接管|管理/iu) && /((让|交给).{0,20}(管理|负责|接管).{0,8}(goal|目标)|(绑定).{0,12}(goal|目标)|(goal|目标).{0,12}(交给|绑定|负责|接管))/iu.test(message)) { - candidates.push({ actionKind: "agent.bind", confidence: 0.96, normalizedParameters: { agent_id: agent.agentId, goal_id: context.goalId } }); - } - if (context.goalId && !referencesExistingTodo && !negates(message, new RegExp(todoSubject, "iu")) - && /(创建|新建|新增|添加|加一个|记一个).{0,16}(todo|待办|任务)|(todo|待办|任务).{0,12}(创建|新建|新增|添加)|(?:create|add)(?:\s+(?:a|an|new))?\s+(?:todo|task)|(?:todo|task).{0,12}(?:create|add)/iu.test(message)) { - candidates.push({ actionKind: "todo.create", confidence: 0.94, normalizedParameters: { goal_id: context.goalId } }); - } - if (context.goalId && executionIntent(message)) { - candidates.push({ actionKind: "todo.create", confidence: 0.9, normalizedParameters: { goal_id: context.goalId, start_execution: true } }); - } - if (context.goalId && todo) { - const operation = !negates(message, /完成|做完|关闭/u) && /完成|做完|关闭/u.test(message) ? "complete" - : !negates(message, /阻塞|卡住/u) && /阻塞|卡住/u.test(message) ? "block" - : !negates(message, /暂缓|稍后|推迟/u) && /暂缓|稍后|推迟/u.test(message) ? "defer" - : agent && !negates(message, /交给|分配给|改派/u) && /交给|分配给|改派/u.test(message) ? "reassign" - : null; - if (operation) { - const resumeWhen = operation === "defer" ? todoResumeWhenFromMessage(message) : null; - if (operation === "defer" && !resumeWhen) { - return { - actionKind: "todo.update", - confidence: 0.97, - missingFields: ["resume_when"], - normalizedParameters: { goal_id: context.goalId, operation, todo_id: todo.todoId }, - route: "clarify", - }; - } - candidates.push({ - actionKind: "todo.update", - confidence: 0.97, - normalizedParameters: { - goal_id: context.goalId, - operation, - ...(resumeWhen ? { resume_when: resumeWhen } : {}), - todo_id: todo.todoId, - }, - }); - } - } - const distinct = [...new Map(candidates.map((candidate) => [candidate.actionKind, candidate])).values()]; - if (distinct.length > 1) { - return { actionKind: null, confidence: 0.4, missingFields: ["single_intent"], normalizedParameters: {}, route: "clarify" }; - } - if (distinct.length === 1) { - const candidate = distinct[0]; - return { ...candidate, missingFields: [], route: "typed_action" }; - } - if (!context.goalId && managerProjectionIntent(message)) { - return { actionKind: null, confidence: 0.98, missingFields: [], normalizedParameters: {}, route: "projection" }; - } - return { actionKind: null, confidence: 0.75, missingFields: [], normalizedParameters: {}, route: "agent_chat" }; -} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index fb6602f238..0427d91989 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -1824,3 +1824,17 @@ .personal-usage-statistics > p { padding-left: 0; overflow-wrap: anywhere; } .personal-usage-statistics button { margin-block: 8px; } .personal-usage-statistics pre { max-height: 180px; overflow: auto; font-size: 12px; } +.personal-action-form { margin: auto; width: min(520px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); overflow: auto; padding: 24px; border: 1px solid var(--pw-line-strong); border-radius: 16px; color: var(--pw-text); background: var(--pw-card); } +.personal-action-form::backdrop { background: rgb(0 0 0 / 35%); } +.personal-action-form form, .personal-action-form fieldset, .personal-action-form label { display: grid; gap: 12px; } +.personal-action-form fieldset { padding: 0; border: 0; min-width: 0; } +.personal-action-form h2 { margin: 0; font-size: 20px; } +.personal-action-form header p { color: var(--pw-muted); } +.personal-action-form label { gap: 6px; font-size: 14px; } +.personal-action-form input, .personal-action-form select, .personal-action-form textarea { width: 100%; min-width: 0; padding: 9px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: var(--pw-bg); color: var(--pw-text); font: inherit; } +.personal-action-form textarea { min-height: 64px; resize: vertical; } +.personal-action-form footer { display: flex; justify-content: flex-end; gap: 12px; margin-top: 8px; } +.personal-action-form button { padding: 8px 14px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); cursor: pointer; } +.personal-action-form button[type="submit"] { background: var(--pw-text); color: var(--pw-card); } +.personal-action-form button:disabled { opacity: .5; cursor: default; } +.personal-action-form [role="alert"] { color: var(--pw-red); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/todo-resume-condition.ts b/apps/presentation/dashboard/src/features/personal-workspace/todo-resume-condition.ts new file mode 100644 index 0000000000..186dde5587 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/todo-resume-condition.ts @@ -0,0 +1,8 @@ +// Parses the explicit resume-condition field in the Todo editor. Not a chat router. +export function parseTodoResumeCondition(rawMessage: string) { + const message = rawMessage.replace(/\s+/gu, " ").trim().toLowerCase(); + const match = message.match( + /(?:^|[\s,,;;:((:]|到|至)(?todo_done:todo_[a-z0-9_-]{3,64}|pr_merged:(?:(?:[a-z0-9_.-]{1,80})\/(?:[a-z0-9_.-]{1,100}))?#[1-9][0-9]{0,8}|capacity_available:[a-z][a-z0-9_:-]{0,63}|resume_at:[1-9][0-9]{3}-[0-9]{2}-[0-9]{2}t[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,3})?(?:z|[+-][0-9]{2}:[0-9]{2}))(?=$|[\s,,。;;))])/iu, + ); + return match?.groups?.condition ?? null; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/workspace-action-form.tsx b/apps/presentation/dashboard/src/features/personal-workspace/workspace-action-form.tsx new file mode 100644 index 0000000000..ea83fceb25 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/workspace-action-form.tsx @@ -0,0 +1,95 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { useWorkspaceI18n } from "./i18n"; +import type { WorkspaceActionPreviewRequest } from "./personal-workspace-model"; + +// Action identity comes from an explicit control, never from the text being edited. +export type WorkspaceActionDraft = { + kind: "goal" | "todo" | "heartbeat" | "monitor"; + goalId: string | null; + goalTitle: string; + agentId: string; + text?: string; +}; + +export function WorkspaceActionForm({ draft, onClose, onPreview }: { + draft: WorkspaceActionDraft; + onClose: () => void; + onPreview: (request: WorkspaceActionPreviewRequest) => Promise; +}) { + const { locale, t } = useWorkspaceI18n(); + const zh = locale === "zh-CN"; + const dialog = useRef(null); + const headingId = useId(); + const submitting = useRef(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [objective, setObjective] = useState(draft.text ?? ""); + const [completion, setCompletion] = useState(""); + const [boundary, setBoundary] = useState(""); + const [permission, setPermission] = useState("workspace_write_on_confirmation"); + const [interval, setInterval] = useState(draft.kind === "heartbeat" ? "1" : "2"); + const [unit, setUnit] = useState(draft.kind === "heartbeat" ? "d" : "h"); + const [stop, setStop] = useState("goal_complete"); + const [target, setTarget] = useState(t("schedule.defaultTarget")); + const scheduled = draft.kind === "heartbeat" || draft.kind === "monitor"; + const title = draft.kind === "goal" ? t("composer.createGoal") : draft.kind === "todo" ? (zh ? "创建任务" : "Create task") : draft.kind === "heartbeat" ? t("schedule.heartbeat") : t("composer.monitor"); + useEffect(() => { + const node = dialog.current; + node?.showModal(); + return () => node?.close(); + }, []); + + async function preview() { + if (submitting.current) return; + submitting.current = true; + setBusy(true); + setError(""); + try { + const goalId = draft.kind === "goal" ? `goal-${crypto.randomUUID()}` : draft.goalId; + if (!goalId) throw new Error(zh ? "请先选择 Goal。" : "Select a Goal first."); + const actionKind = draft.kind === "goal" ? "goal.create" : draft.kind === "todo" ? "todo.create" : draft.kind === "heartbeat" ? "heartbeat.bind" : "monitor.create"; + const parameters = draft.kind === "goal" ? { + goal_id: goalId, agent_id: draft.agentId, title: objective.trim().slice(0, 80), + objective: [objective.trim(), t("goal.objectiveCompletion", { criteria: completion.trim() }), boundary.trim() ? t("goal.objectiveBoundary", { boundary: boundary.trim() }) : ""].filter(Boolean).join("\n"), + completion_criteria: completion.trim(), execution_boundary: boundary.trim(), + initial_todos: [t("goal.initialTodo", { criteria: completion.trim() })], permission, + workspace_ref: "current", heartbeat: { enabled: false, cadence: "1d", timezone: "Asia/Shanghai" }, stop_condition: "goal_complete", + } : draft.kind === "todo" ? { goal_id: goalId, text: objective.trim() } : { + goal_id: goalId, agent_id: draft.agentId, cadence: `${interval}${unit}`, stop_condition: stop, + timezone: "Asia/Shanghai", + ...(draft.kind === "monitor" ? { target: target.trim(), target_key: `goal-${goalId}` } : {}), + }; + await onPreview({ actionKind, context: { kind: draft.kind === "goal" ? "manager" : "goal", goal_id: draft.goalId }, + idempotencyKey: `workspace-${actionKind}-${crypto.randomUUID()}`, normalizedParameters: parameters, + summary: draft.kind === "goal" ? t("proposal.summary.goalCreate", { title: objective.trim().slice(0, 80) }) : title }); + onClose(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)); + } finally { + submitting.current = false; + setBusy(false); + } + } + return { event.preventDefault(); if (!busy) onClose(); }}> +
{ event.preventDefault(); void preview(); }}> +

{title}

{draft.goalId ?

{draft.goalTitle}

: null}
+
+ {scheduled ? <> + {draft.kind === "monitor" ?