mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
feat: modernize studio interface and session recovery
This commit is contained in:
1 parent
1b2365fff2
commit
8a6a4f20d6
82 files changed
+5901
-931
No files matched your search
@@ -120,6 +120,23 @@
|
|||||||
.bmark .bl, .bline { animation-duration: .01ms; animation-delay: 0s }
|
.bmark .bl, .bline { animation-duration: .01ms; animation-delay: 0s }
|
||||||
.boot[data-done] { transform: none }
|
.boot[data-done] { transform: none }
|
||||||
}
|
}
|
||||||
|
/* The product shell uses a restrained, neutral first paint. The startup
|
||||||
|
screen confirms the app is opening; it is not a second brand film. */
|
||||||
|
:root {
|
||||||
|
--boot-bg: #f6f6f4; --boot-ink: #292b29; --boot-dim: #7b7e77;
|
||||||
|
--boot-gilt: #292b29; --boot-grain-a: .012;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root { --boot-bg: #171817; --boot-ink: #e5e7e1; --boot-dim: #777a73; --boot-gilt: #e5e7e1; --boot-grain-a: .018 }
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] { --boot-bg: #171817; --boot-ink: #e5e7e1; --boot-dim: #777a73; --boot-gilt: #e5e7e1; --boot-grain-a: .018 }
|
||||||
|
:root[data-theme="light"] { --boot-bg: #f6f6f4; --boot-ink: #292b29; --boot-dim: #7b7e77; --boot-gilt: #292b29; --boot-grain-a: .012 }
|
||||||
|
.bsky { display: none }
|
||||||
|
.bmid { gap: 18px; margin-top: -2vh }
|
||||||
|
.bmark { width: 154px }
|
||||||
|
.bmark .bl { animation-duration: .42s; animation-delay: calc(var(--i) * 35ms + 80ms) }
|
||||||
|
.bline { letter-spacing: .02em; animation-delay: 360ms }
|
||||||
|
@keyframes bootletter { from { opacity: 0 } to { opacity: 1 } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -49,18 +49,34 @@ export interface UIAction {
|
|||||||
|
|
||||||
export const ACTIONS: UIAction[] = [
|
export const ACTIONS: UIAction[] = [
|
||||||
// ── The window's own switches, and the way into the turn's policy ────────
|
// ── The window's own switches, and the way into the turn's policy ────────
|
||||||
// chrome.preset keeps the id it was registered under when the preset still
|
// Completion verification is adaptive now, rather than a user-facing preset.
|
||||||
// lived in the title bar: where a control is drawn is not its identity,
|
// The legacy endpoint remains in the port for older clients and sessions,
|
||||||
// which is why the settings sheet has always rendered it too. The composer's
|
// but it is intentionally not advertised as an interface action.
|
||||||
// panel renders it beside reasoning.effort and tool-approval.mode, which the
|
|
||||||
// settings sheet already owned — one question, three ids that already exist.
|
|
||||||
{ id: "chrome.preset", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
|
||||||
{ id: "chrome.policy", kind: "view", target: "none", proof: "interaction" },
|
{ id: "chrome.policy", kind: "view", target: "none", proof: "interaction" },
|
||||||
{ id: "chrome.focus", kind: "view", target: "none", proof: "browser" },
|
{ id: "chrome.rail", kind: "view", target: "none", proof: "browser" },
|
||||||
|
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
|
||||||
|
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
|
||||||
|
{ id: "workspace.switch", kind: "view", target: "none", proof: "interaction" },
|
||||||
|
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
|
||||||
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
|
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
|
||||||
|
{ id: "browser.open", kind: "view", target: "none", proof: "interaction" },
|
||||||
|
{ id: "browser.close", kind: "view", target: "none", proof: "interaction" },
|
||||||
|
{ id: "browser.back", kind: "navigation", target: "none", proof: "interaction" },
|
||||||
|
{ id: "browser.reload", kind: "repeatable", target: "none", proof: "interaction" },
|
||||||
|
{ id: "browser.navigate", kind: "navigation", target: "none", proof: "interaction" },
|
||||||
|
{ id: "browser.address", kind: "view", target: "none", proof: "static" },
|
||||||
|
{ id: "browser.external", kind: "shell-native", target: "none", proof: "interaction" },
|
||||||
|
{ id: "tool.copy-output", kind: "shell-native", target: "none", proof: "interaction" },
|
||||||
// The way out of a window whose assets an update replaced underneath it.
|
// The way out of a window whose assets an update replaced underneath it.
|
||||||
{ id: "chrome.reload", kind: "navigation", target: "none", proof: "interaction" },
|
{ id: "chrome.reload", kind: "navigation", target: "none", proof: "interaction" },
|
||||||
{ id: "chrome.account", kind: "navigation", target: "none", proof: "interaction" },
|
{ id: "chrome.account", kind: "navigation", target: "none", proof: "interaction" },
|
||||||
|
{ id: "metrics.details", kind: "view", target: "optional", proof: "interaction" },
|
||||||
|
{ id: "session.menu", kind: "view", target: "entity", proof: "interaction" },
|
||||||
|
{ id: "session.pin", kind: "view", target: "entity", proof: "interaction" },
|
||||||
|
{ id: "session.pause", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||||
|
{ id: "session.archive", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||||
|
{ id: "session.export", kind: "shell-native", target: "entity", proof: "browser" },
|
||||||
|
{ id: "session.delete", kind: "destructive", target: "entity", proof: "authority-effect" },
|
||||||
|
|
||||||
// ── The turn ─────────────────────────────────────────────────────────────
|
// ── The turn ─────────────────────────────────────────────────────────────
|
||||||
// Send and stop remain two actions even when they sit together: during a
|
// Send and stop remain two actions even when they sit together: during a
|
||||||
@@ -242,6 +258,7 @@ export const ACTIONS: UIAction[] = [
|
|||||||
{ id: "reasoning.effort", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
{ id: "reasoning.effort", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||||
{ id: "tool-approval.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
{ id: "tool-approval.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||||
{ id: "storage.move", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
{ id: "storage.move", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||||
|
{ id: "session.import-legacy", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||||
{ id: "network.diagnose", kind: "repeatable", target: "none", proof: "interaction" },
|
{ id: "network.diagnose", kind: "repeatable", target: "none", proof: "interaction" },
|
||||||
|
|
||||||
// ── Permissions, the sandbox, and the endpoints a session can reach.
|
// ── Permissions, the sandbox, and the endpoints a session can reach.
|
||||||
@@ -261,7 +278,6 @@ export const ACTIONS: UIAction[] = [
|
|||||||
// ── The two side panels. Reached from the keyboard here, and from each
|
// ── The two side panels. Reached from the keyboard here, and from each
|
||||||
// gutter's own grip, which this pass has not annotated yet.
|
// gutter's own grip, which this pass has not annotated yet.
|
||||||
{ id: "rail.toggle", kind: "view", target: "none", proof: "interaction" },
|
{ id: "rail.toggle", kind: "view", target: "none", proof: "interaction" },
|
||||||
{ id: "inspector.toggle", kind: "view", target: "none", proof: "interaction" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// No action in Studio is reachable from the keyboard alone: every shortcut in
|
// No action in Studio is reachable from the keyboard alone: every shortcut in
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const EN: Record<string, string> = {
|
|||||||
"运行中": "Running",
|
"运行中": "Running",
|
||||||
"思考中": "Thinking",
|
"思考中": "Thinking",
|
||||||
"正在回答": "Answering",
|
"正在回答": "Answering",
|
||||||
|
"正在处理…": "Working…",
|
||||||
"已完成": "Done",
|
"已完成": "Done",
|
||||||
"已中断": "Interrupted",
|
"已中断": "Interrupted",
|
||||||
"请求未能送达内核(HTTP {status})": "That request never reached the kernel (HTTP {status})",
|
"请求未能送达内核(HTTP {status})": "That request never reached the kernel (HTTP {status})",
|
||||||
@@ -86,7 +87,21 @@ export const EN: Record<string, string> = {
|
|||||||
"第 {n} 句": "Message {n}",
|
"第 {n} 句": "Message {n}",
|
||||||
"{n} 个文件": "{n} files",
|
"{n} 个文件": "{n} files",
|
||||||
"工作区": "Workspaces",
|
"工作区": "Workspaces",
|
||||||
|
"工作空间": "Workspace",
|
||||||
|
"搜索与快捷操作": "Search and commands",
|
||||||
|
"添加工作区": "Add workspace",
|
||||||
|
"切换工作区": "Switch workspace",
|
||||||
|
"工具与集成": "Tools and integrations",
|
||||||
|
"钱包与用量": "Wallet and usage",
|
||||||
"收起工作区栏": "Collapse the workspace rail",
|
"收起工作区栏": "Collapse the workspace rail",
|
||||||
|
"展开工作区栏": "Show the workspace rail",
|
||||||
|
"打开执行中心": "Open execution center",
|
||||||
|
"执行中心": "Execution center",
|
||||||
|
"切换浅色主题": "Switch to light theme",
|
||||||
|
"切换深色主题": "Switch to dark theme",
|
||||||
|
"沙盒": "Sandbox",
|
||||||
|
"沙盒与运行边界": "Sandbox and runtime boundary",
|
||||||
|
"更多与设置": "More and settings",
|
||||||
"收起度量栏": "Collapse the metrics rail",
|
"收起度量栏": "Collapse the metrics rail",
|
||||||
"展开度量栏": "Show the metrics rail",
|
"展开度量栏": "Show the metrics rail",
|
||||||
"调整工作区栏宽度": "Resize the workspace rail",
|
"调整工作区栏宽度": "Resize the workspace rail",
|
||||||
@@ -472,6 +487,52 @@ export const EN: Record<string, string> = {
|
|||||||
"输出不完整,部分内容未进入上下文": "The output is incomplete; some content did not enter context",
|
"输出不完整,部分内容未进入上下文": "The output is incomplete; some content did not enter context",
|
||||||
"仅显示前 400 个字符": "Showing only the first 400 characters",
|
"仅显示前 400 个字符": "Showing only the first 400 characters",
|
||||||
"读了 {n} 个文件": "Read {n} files",
|
"读了 {n} 个文件": "Read {n} files",
|
||||||
|
"读取了 {n} 个文件": "Read {n} files",
|
||||||
|
"完成了 {n} 次搜索": "Completed {n} searches",
|
||||||
|
"查阅了 {n} 项内容": "Inspected {n} items",
|
||||||
|
"新增 {added} 行,删除 {removed} 行": "{added} lines added, {removed} removed",
|
||||||
|
"网页搜索": "Web search",
|
||||||
|
"获取网页": "Fetch page",
|
||||||
|
"委派任务": "Delegate task",
|
||||||
|
"运行命令": "Run command",
|
||||||
|
"命令输出": "Command output",
|
||||||
|
"停止命令": "Stop command",
|
||||||
|
"等待任务": "Wait",
|
||||||
|
"读取文件": "Read file",
|
||||||
|
"搜索代码": "Search code",
|
||||||
|
"查找文件": "Find files",
|
||||||
|
"列出目录": "List directory",
|
||||||
|
"编辑文件": "Edit file",
|
||||||
|
"写入文件": "Write file",
|
||||||
|
"编辑多个文件": "Edit files",
|
||||||
|
"更新计划": "Update plan",
|
||||||
|
"保存记忆": "Save memory",
|
||||||
|
"调用 MCP": "Call MCP",
|
||||||
|
"建立索引": "Build index",
|
||||||
|
"完成步骤": "Complete step",
|
||||||
|
"审阅结果": "Review results",
|
||||||
|
"更新目标": "Update goal",
|
||||||
|
"安全检查": "Safety check",
|
||||||
|
"压缩上下文": "Compact context",
|
||||||
|
"删除内容": "Delete content",
|
||||||
|
"删除符号": "Delete symbol",
|
||||||
|
"移动文件": "Move file",
|
||||||
|
"编辑笔记本": "Edit notebook",
|
||||||
|
"提交计划": "Submit plan",
|
||||||
|
"询问用户": "Ask user",
|
||||||
|
"并行任务": "Parallel tasks",
|
||||||
|
"只读任务": "Read-only task",
|
||||||
|
"读取子任务结果": "Read subtask result",
|
||||||
|
"完成子任务": "Complete subtask",
|
||||||
|
"代码诊断": "Code diagnostics",
|
||||||
|
"读取 · {n} 行": "Read · {n} lines",
|
||||||
|
"搜索": "Search",
|
||||||
|
"{n} 处": "{n} matches",
|
||||||
|
"{n} 项": "{n} items",
|
||||||
|
"读取文件 ×{n}": "Read files ×{n}",
|
||||||
|
"搜索命中 {n} 处": "Search · {n} matches",
|
||||||
|
"查找文件 ×{n}": "Find files ×{n}",
|
||||||
|
"列出目录 ×{n}": "List directories ×{n}",
|
||||||
"{n} 项失败": "{n} failed",
|
"{n} 项失败": "{n} failed",
|
||||||
"推荐": "Recommended",
|
"推荐": "Recommended",
|
||||||
"其他 —— 自行填写": "Something else — I will write it",
|
"其他 —— 自行填写": "Something else — I will write it",
|
||||||
@@ -766,6 +827,98 @@ export const EN: Record<string, string> = {
|
|||||||
"重新读取余额": "Read the balance again",
|
"重新读取余额": "Read the balance again",
|
||||||
"已停用": "Suspended",
|
"已停用": "Suspended",
|
||||||
"两种币种不合计": "Two currencies, not summed",
|
"两种币种不合计": "Two currencies, not summed",
|
||||||
|
"当前工作区 · 本地": "Current workspace · Local",
|
||||||
|
"体验并行任务": "Try parallel tasks",
|
||||||
|
"置顶": "Pinned",
|
||||||
|
"归档": "Archived",
|
||||||
|
"归档会话": "Archive session",
|
||||||
|
"取消归档": "Unarchive",
|
||||||
|
"操作执行前由你确认": "Ask before an operation runs",
|
||||||
|
"只查看内容,不允许写入": "View content without allowing writes",
|
||||||
|
"当前内核暂不支持会话级只读模式": "The current runtime does not support session-level read-only mode yet",
|
||||||
|
"低风险操作自动执行,写入仍需确认": "Run low-risk operations automatically; writes still require confirmation",
|
||||||
|
"在设置中查看当前隔离与网络权限": "View current isolation and network permissions in Settings",
|
||||||
|
"使用模型默认或自适应策略,按任务复杂度调整": "Use the model default or adapt to task complexity",
|
||||||
|
"不发送推理强度参数,使用服务端默认设置": "Do not send an effort parameter; use the server default",
|
||||||
|
"轻量思考,适合改写、提取和明确的小任务": "Light reasoning for rewriting, extraction, and clear small tasks",
|
||||||
|
"兼顾响应速度与可靠性,适合大多数任务": "Balance speed and reliability for most tasks",
|
||||||
|
"投入更多时间分析复杂上下文与执行方案": "Spend more time analyzing complex context and execution plans",
|
||||||
|
"用于最复杂的问题,等待时间与消耗最高": "For the hardest problems, with the longest wait and highest usage",
|
||||||
|
"仅显示当前模型实际支持的档位。": "Only levels actually supported by this model are shown.",
|
||||||
|
"按模型生效": "Per model",
|
||||||
|
"查看上下文与压缩": "View context and compaction",
|
||||||
|
"查看本轮费用": "View turn cost",
|
||||||
|
"查看钱包余额": "View wallet balance",
|
||||||
|
"收起运行统计": "Collapse run statistics",
|
||||||
|
"展开运行统计": "Expand run statistics",
|
||||||
|
"钱包余额": "Wallet balance",
|
||||||
|
"上下文详情": "Context details",
|
||||||
|
"查看上下文、工具与 MCP 使用情况": "View context, tool, and MCP usage",
|
||||||
|
"Browser 与工具集成": "Browser and tool integrations",
|
||||||
|
"当前会话操作": "Current session actions",
|
||||||
|
"已使用": "Used",
|
||||||
|
"暂时没有上下文数据": "No context data yet",
|
||||||
|
"个已连接": "connected",
|
||||||
|
"个工具": "tools",
|
||||||
|
"尚未启用 MCP 服务": "No MCP services enabled",
|
||||||
|
"当前会话发送给模型的内容": "Content sent to the model in this session",
|
||||||
|
"工具定义计入上方的工具占用": "Tool definitions are included in the tool usage above",
|
||||||
|
"{servers} 个已连接 · {tools} 个工具": "{servers} connected · {tools} tools",
|
||||||
|
"{n} 个工具": "{n} tools",
|
||||||
|
"连接失败": "Connection failed",
|
||||||
|
"等待连接": "Waiting to connect",
|
||||||
|
"另有 {n} 个服务": "{n} more services",
|
||||||
|
"当前工作区未启用 MCP 服务": "No MCP services are enabled for this workspace",
|
||||||
|
"当前内核提供工具总占用,不提供按 MCP 服务拆分的 Token 估算。": "The runtime reports total tool usage, not token estimates split by MCP service.",
|
||||||
|
"管理 MCP 与工具": "Manage MCP and tools",
|
||||||
|
"生成中": "Generating",
|
||||||
|
"本轮均速": "Average speed this turn",
|
||||||
|
"尚无缓存数据": "No cache data yet",
|
||||||
|
"命中 {hit} · 未命中 {miss}": "Hit {hit} · Miss {miss}",
|
||||||
|
"个人工作空间": "Personal workspace",
|
||||||
|
"本地工作空间": "Local workspace",
|
||||||
|
"工作模式": "Work mode",
|
||||||
|
"当前任务": "Current task",
|
||||||
|
"执行任务并使用已启用工具": "Run tasks with enabled tools",
|
||||||
|
"先整理步骤,不写入文件": "Plan steps before changing files",
|
||||||
|
"直接回答,不调用工具": "Answer directly without tools",
|
||||||
|
"当前 Git 分支": "Current Git branch",
|
||||||
|
"后续任务会继续使用这个分支": "Later tasks will continue on this branch",
|
||||||
|
"由模型自动选择": "Let the model choose",
|
||||||
|
"管理模型与连接": "Manage models and connections",
|
||||||
|
"服务商、API 与可用模型": "Providers, API access, and available models",
|
||||||
|
"更多操作": "More actions",
|
||||||
|
"执行权限": "Execution permissions",
|
||||||
|
"禁止审批": "Deny approvals",
|
||||||
|
"自动继续": "Continue automatically",
|
||||||
|
"审批决定何时询问;沙盒限定实际访问范围。": "Approvals decide when to ask; the sandbox limits actual access.",
|
||||||
|
"高级执行设置": "Advanced execution settings",
|
||||||
|
"会话操作:{title}": "Session actions: {title}",
|
||||||
|
"会话操作": "Session actions",
|
||||||
|
"执行中": "Running",
|
||||||
|
"执行过程": "Activity",
|
||||||
|
"{count} 项操作": "{count} actions",
|
||||||
|
"读取": "Read",
|
||||||
|
"本地工作区": "Local workspace",
|
||||||
|
"暂停执行": "Pause run",
|
||||||
|
"导出会话": "Export session",
|
||||||
|
"取消置顶": "Unpin",
|
||||||
|
"置顶会话": "Pin session",
|
||||||
|
"删除会话": "Delete session",
|
||||||
|
"会话范围": "Session scope",
|
||||||
|
"按名称筛选会话": "Filter sessions by name",
|
||||||
|
"运行统计": "Run metrics",
|
||||||
|
"本轮": "This turn",
|
||||||
|
"详情": "Details",
|
||||||
|
"分离状态": "detached",
|
||||||
|
"当前 Git 分支:{branch}": "Current Git branch: {branch}",
|
||||||
|
"复制回复": "Copy response",
|
||||||
|
"逐项确认": "Ask each time",
|
||||||
|
"自动": "Auto",
|
||||||
|
"快速": "Fast",
|
||||||
|
"平衡": "Balanced",
|
||||||
|
"深入": "Deep",
|
||||||
|
"极致": "Maximum",
|
||||||
"含赠金 {amount}": "incl. {amount} credit",
|
"含赠金 {amount}": "incl. {amount} credit",
|
||||||
"刚刚": "just now",
|
"刚刚": "just now",
|
||||||
"{n} 分钟前": "{n} min ago",
|
"{n} 分钟前": "{n} min ago",
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export const EN_COMPOSER: Record<string, string> = {
|
|||||||
"{n} 字 · {lines} 行": "{n} characters · {lines} lines",
|
"{n} 字 · {lines} 行": "{n} characters · {lines} lines",
|
||||||
"知道了": "Got it",
|
"知道了": "Got it",
|
||||||
"本轮执行策略": "How this turn runs",
|
"本轮执行策略": "How this turn runs",
|
||||||
|
"工作策略": "Work strategy",
|
||||||
|
"当前分支 · {branch}": "Current branch · {branch}",
|
||||||
|
"当前工作区 · 后续任务继续使用此分支": "Current workspace · future tasks continue on this branch",
|
||||||
|
"仅作状态提示,无需点击": "Status only — no click needed",
|
||||||
"执行": "Run",
|
"执行": "Run",
|
||||||
"执行方式": "Execution",
|
"执行方式": "Execution",
|
||||||
"思考强度": "Effort",
|
"思考强度": "Effort",
|
||||||
@@ -32,6 +36,7 @@ export const EN_COMPOSER: Record<string, string> = {
|
|||||||
"粘贴的文本": "pasted text",
|
"粘贴的文本": "pasted text",
|
||||||
"{n} 行 · 展开到输入框": "{n} lines · put it back in the box",
|
"{n} 行 · 展开到输入框": "{n} lines · put it back in the box",
|
||||||
"添加附件": "Add an attachment",
|
"添加附件": "Add an attachment",
|
||||||
|
"也可直接拖入或粘贴": "You can also drop or paste files",
|
||||||
"添加附件 也可直接拖入或粘贴": "Add an attachment or drop or paste one in",
|
"添加附件 也可直接拖入或粘贴": "Add an attachment or drop or paste one in",
|
||||||
"正在添加…": "Adding…",
|
"正在添加…": "Adding…",
|
||||||
"正在添加附件…": "Adding attachments…",
|
"正在添加附件…": "Adding attachments…",
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export const EN_KERNEL: Record<string, string> = {
|
|||||||
"缺少「{field}」": "Still missing “{field}”",
|
"缺少「{field}」": "Still missing “{field}”",
|
||||||
"找不到名为「{name}」的{kind}": "No {kind} called “{name}”",
|
"找不到名为「{name}」的{kind}": "No {kind} called “{name}”",
|
||||||
"该项目未在当前窗口中打开": "That project is not open in this window",
|
"该项目未在当前窗口中打开": "That project is not open in this window",
|
||||||
|
"该工作区未在当前窗口中打开": "That workspace is not open in this window",
|
||||||
"该会话正被其他位置占用:{detail}": "That conversation is held elsewhere: {detail}",
|
"该会话正被其他位置占用:{detail}": "That conversation is held elsewhere: {detail}",
|
||||||
"该会话正在运行,此消息已排入队列": "That conversation is running; those words are queued behind it",
|
"该会话正在运行,此消息已排入队列": "That conversation is running; those words are queued behind it",
|
||||||
"这是当前打开的会话,请先切换后再删除": "That is the conversation you have open — switch away before deleting it",
|
"这是当前打开的会话,请先切换后再删除": "That is the conversation you have open — switch away before deleting it",
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
// alone — one catalogue is still the model, and this file is read as its
|
// alone — one catalogue is still the model, and this file is read as its
|
||||||
// continuation: same keying by Chinese source text, same grouping by screen.
|
// continuation: same keying by Chinese source text, same grouping by screen.
|
||||||
export const EN_SETTINGS: Record<string, string> = {
|
export const EN_SETTINGS: Record<string, string> = {
|
||||||
|
"完成标准": "Completion standard",
|
||||||
|
"系统根据任务风险自动决定需要的验证程度,不再要求手动选择均衡或交付。": "Reasonix chooses the needed verification level from task risk; there is no longer a Balanced or Delivery choice.",
|
||||||
|
"日常问答保持轻量;涉及文件修改、代码执行或明确交付物时自动增加验证与复核。": "Everyday questions stay lightweight; file changes, code execution, and explicit deliverables automatically receive more verification and review.",
|
||||||
"设置没能打开": "Settings did not open",
|
"设置没能打开": "Settings did not open",
|
||||||
"重新载入": "Reload",
|
"重新载入": "Reload",
|
||||||
"这一页是单独取的,这次没取到": "This screen is fetched on its own, and this time it did not arrive",
|
"这一页是单独取的,这次没取到": "This screen is fetched on its own, and this time it did not arrive",
|
||||||
@@ -301,6 +304,16 @@ export const EN_SETTINGS: Record<string, string> = {
|
|||||||
"折叠了 {n} 条消息": "Folded {n} messages",
|
"折叠了 {n} 条消息": "Folded {n} messages",
|
||||||
|
|
||||||
// ── 首次连接 ─────────────────────────────────────────────────────
|
// ── 首次连接 ─────────────────────────────────────────────────────
|
||||||
|
"开始设置": "Set up Reasonix",
|
||||||
|
"连接模型服务": "Connect a model service",
|
||||||
|
"地址、协议与凭据": "Address, protocol, and credentials",
|
||||||
|
"确认模型与能力": "Confirm the model and its capabilities",
|
||||||
|
"打开工作区": "Open a workspace",
|
||||||
|
"开始第一个任务": "Start your first task",
|
||||||
|
"之后可以在设置中随时修改,不会锁定当前选择。":
|
||||||
|
"You can change any of this later in Settings; the choices are not locked in.",
|
||||||
|
"本地优先 · 凭据由系统安全存储 · 连接前不会发送请求":
|
||||||
|
"Local first · credentials stay in secure system storage · nothing is sent before you connect",
|
||||||
"首先连接模型服务": "Connect a model service first",
|
"首先连接模型服务": "Connect a model service first",
|
||||||
"填写地址与 key,其余由系统探测 —— 协议、模型清单、是否支持读图均可自动获取。":
|
"填写地址与 key,其余由系统探测 —— 协议、模型清单、是否支持读图均可自动获取。":
|
||||||
"Give it an address and a key; the rest is asked of the endpoint — protocol, model list, whether it reads images.",
|
"Give it an address and a key; the rest is asked of the endpoint — protocol, model list, whether it reads images.",
|
||||||
|
|||||||
@@ -2,6 +2,16 @@
|
|||||||
// it. Split out of en.ts because that file is one screen's worth of catalogue
|
// it. Split out of en.ts because that file is one screen's worth of catalogue
|
||||||
// per section and had grown past its ceiling — the grouping is the same.
|
// per section and had grown past its ceiling — the grouping is the same.
|
||||||
export const EN_STORAGE: Record<string, string> = {
|
export const EN_STORAGE: Record<string, string> = {
|
||||||
|
"找回旧版会话": "Recover older sessions",
|
||||||
|
"从旧版 Reasonix 导入": "Import from an older Reasonix installation",
|
||||||
|
"选择旧版的数据文件夹。扫描只会复制会话,不会移动或删除原文件;找不到原工作区的会话会归入当前工作区。":
|
||||||
|
"Choose the older data folder. The scan only copies sessions and never moves or deletes the originals; sessions whose workspace no longer exists are placed in the current workspace.",
|
||||||
|
"选择旧版数据目录…": "Choose older data folder…",
|
||||||
|
"正在扫描…": "Scanning…",
|
||||||
|
"已找回 {n} 个会话。": "Recovered {n} sessions.",
|
||||||
|
"没有发现尚未导入的旧会话。": "No sessions remain to be imported.",
|
||||||
|
"有 {n} 项无法读取。": "{n} items could not be read.",
|
||||||
|
"未能扫描这个文件夹。请确认它是旧版 Reasonix 的数据目录。": "This folder could not be scanned. Make sure it is an older Reasonix data folder.",
|
||||||
存储: "Storage",
|
存储: "Storage",
|
||||||
"数据的存储位置与占用空间。会话和索引会持续增长,配置和凭据不会,因此只有前者可以迁移。迁移在重启后生效。":
|
"数据的存储位置与占用空间。会话和索引会持续增长,配置和凭据不会,因此只有前者可以迁移。迁移在重启后生效。":
|
||||||
"Where the data is written and how much space it uses. Sessions and indexes keep growing while configuration and credentials do not, so only the former can be moved. A move takes effect after a restart.",
|
"Where the data is written and how much space it uses. Sessions and indexes keep growing while configuration and credentials do not, so only the former can be moved. A move takes effect after a restart.",
|
||||||
|
|||||||
@@ -73,4 +73,26 @@ export const EN_WINDOW: Record<string, string> = {
|
|||||||
"外部请求": "External requests",
|
"外部请求": "External requests",
|
||||||
"失败步数": "Failed",
|
"失败步数": "Failed",
|
||||||
"待确认": "Awaiting you",
|
"待确认": "Awaiting you",
|
||||||
|
"内置 Browser": "Built-in Browser",
|
||||||
|
"内置浏览": "Built in",
|
||||||
|
"关闭 Browser": "Close Browser",
|
||||||
|
"后退": "Back",
|
||||||
|
"网页地址": "Web address",
|
||||||
|
"打开网页": "Open page",
|
||||||
|
"打开": "Open",
|
||||||
|
"在外部浏览器打开": "Open in external browser",
|
||||||
|
"请输入有效的 http 或 https 地址": "Enter a valid http or https address",
|
||||||
|
"Reasonix 内置 Browser": "Reasonix built-in Browser",
|
||||||
|
"部分网站可能禁止嵌入;此时可在外部浏览器打开。": "Some sites block embedding; open them in your external browser instead.",
|
||||||
|
"正在打开网页…": "Opening page…",
|
||||||
|
"页面空白通常表示网站禁止嵌入,可使用右上角按钮在外部浏览器打开。": "A blank page usually means the site blocks embedding. Use the button at the top right to open it in your external browser.",
|
||||||
|
"关闭内置 Browser": "Close the built-in Browser",
|
||||||
|
"打开内置 Browser": "Open the built-in Browser",
|
||||||
|
"在上方地址栏输入网址,即可在当前任务旁浏览资料。": "Enter an address above to browse beside the current task.",
|
||||||
|
"浏览内容不会替换或中断当前会话。部分网站禁止嵌入时,可使用右上角的外部打开。": "Browsing will not replace or interrupt the current task. If a site blocks embedding, use Open externally in the toolbar.",
|
||||||
|
"点击步骤可原位查看详情、结果或文件差异": "Select a step to inspect its details, result, or file diff in place",
|
||||||
|
"调用 {id}": "Call {id}",
|
||||||
|
"输入": "Input",
|
||||||
|
"结果": "Result",
|
||||||
|
"复制": "Copy",
|
||||||
};
|
};
|
||||||
@@ -38,6 +38,7 @@ const SAID: Record<string, string> = {
|
|||||||
"request.missing_field": "缺少「{field}」",
|
"request.missing_field": "缺少「{field}」",
|
||||||
"request.not_found": "找不到名为「{name}」的{kind}",
|
"request.not_found": "找不到名为「{name}」的{kind}",
|
||||||
"project.unknown": "该项目未在当前窗口中打开",
|
"project.unknown": "该项目未在当前窗口中打开",
|
||||||
|
"workspace.unknown": "该工作区未在当前窗口中打开",
|
||||||
"busy.session_in_use": "该会话正被其他位置占用:{detail}",
|
"busy.session_in_use": "该会话正被其他位置占用:{detail}",
|
||||||
"busy.session_running": "该会话正在运行,此消息已排入队列",
|
"busy.session_running": "该会话正在运行,此消息已排入队列",
|
||||||
"busy.session_active": "这是当前打开的会话,请先切换后再删除",
|
"busy.session_active": "这是当前打开的会话,请先切换后再删除",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { reason } from "./i18n/kernel";
|
|||||||
import { track as trackWidth } from "./ui/viewport";
|
import { track as trackWidth } from "./ui/viewport";
|
||||||
import "./styles/tokens.css";
|
import "./styles/tokens.css";
|
||||||
import "./styles/app.css";
|
import "./styles/app.css";
|
||||||
|
import "./styles/studio.css";
|
||||||
import { App } from "./ui/App";
|
import { App } from "./ui/App";
|
||||||
import { SseHub } from "./port/hub";
|
import { SseHub } from "./port/hub";
|
||||||
import type { HubPort } from "./port/hub";
|
import type { HubPort } from "./port/hub";
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface TreeSession {
|
|||||||
// Set when a pane already drives this transcript: the sidebar focuses that
|
// Set when a pane already drives this transcript: the sidebar focuses that
|
||||||
// pane rather than opening a second writer for one file.
|
// pane rather than opening a second writer for one file.
|
||||||
runtimeId?: string;
|
runtimeId?: string;
|
||||||
|
archived?: boolean;
|
||||||
// Conflict-recovery copies of this same conversation. A save that keeps
|
// Conflict-recovery copies of this same conversation. A save that keeps
|
||||||
// conflicting writes one per turn, all under one title.
|
// conflicting writes one per turn, all under one title.
|
||||||
copies?: TreeSession[];
|
copies?: TreeSession[];
|
||||||
@@ -66,7 +67,10 @@ export interface HubPort {
|
|||||||
addWorkspace(path: string): Promise<TreeWorkspace>;
|
addWorkspace(path: string): Promise<TreeWorkspace>;
|
||||||
removeWorkspace(path: string): Promise<void>;
|
removeWorkspace(path: string): Promise<void>;
|
||||||
removeSession(path: string): Promise<void>;
|
removeSession(path: string): Promise<void>;
|
||||||
|
archiveSession(path: string, archived: boolean): Promise<void>;
|
||||||
renameSession(path: string, title: string): Promise<void>;
|
renameSession(path: string, title: string): Promise<void>;
|
||||||
|
exportSession(path: string): Promise<{ name: string; content: string }>;
|
||||||
|
importLegacySessions(path: string, workspace: string): Promise<{ summary: string; imported: number; warnings: number }>;
|
||||||
// The host book with each link's state, or null where this kernel refuses
|
// The host book with each link's state, or null where this kernel refuses
|
||||||
// remote panes outright — a page served to a browser, rather than the window.
|
// remote panes outright — a page served to a browser, rather than the window.
|
||||||
// Null is what lets the sidebar leave the whole section out instead of
|
// Null is what lets the sidebar leave the whole section out instead of
|
||||||
@@ -210,10 +214,22 @@ export class SseHub implements HubPort {
|
|||||||
await this.post<void>("/tree/sessions/remove", { path });
|
await this.post<void>("/tree/sessions/remove", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async archiveSession(path: string, archived: boolean) {
|
||||||
|
await this.post<void>("/tree/sessions/archive", { path, archived });
|
||||||
|
}
|
||||||
|
|
||||||
async renameSession(path: string, title: string) {
|
async renameSession(path: string, title: string) {
|
||||||
await this.post<void>("/tree/sessions/rename", { path, title });
|
await this.post<void>("/tree/sessions/rename", { path, title });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exportSession(path: string) {
|
||||||
|
return this.post<{ name: string; content: string }>("/tree/sessions/export", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
importLegacySessions(path: string, workspace: string) {
|
||||||
|
return this.post<{ summary: string; imported: number; warnings: number }>("/tree/sessions/import-legacy", { path, workspace });
|
||||||
|
}
|
||||||
|
|
||||||
async remoteHosts() {
|
async remoteHosts() {
|
||||||
const res = await fetch("/remotes", { credentials: "same-origin" });
|
const res = await fetch("/remotes", { credentials: "same-origin" });
|
||||||
// Not an error to report: this kernel simply does not do remote panes.
|
// Not an error to report: this kernel simply does not do remote panes.
|
||||||
|
|||||||
@@ -194,10 +194,23 @@ export class MockHub implements HubPort {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
archiveSession(_path: string, _archived: boolean) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
renameSession(_path: string, _title: string) {
|
renameSession(_path: string, _title: string) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exportSession(path: string) {
|
||||||
|
const name = path.split(/[/\\]/).at(-1)?.replace(/\.jsonl$/i, "") || "session";
|
||||||
|
return Promise.resolve({ name, content: '{"role":"user","content":"mock session"}\n' });
|
||||||
|
}
|
||||||
|
|
||||||
|
importLegacySessions(_path: string, _workspace: string) {
|
||||||
|
return Promise.resolve({ summary: "mock migration complete", imported: 0, warnings: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
pickFolder() {
|
pickFolder() {
|
||||||
return Promise.resolve<string | null>("~/projects/mock-workspace");
|
return Promise.resolve<string | null>("~/projects/mock-workspace");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,4 +31,13 @@ describe("a line the host composed is not a thing the user said", () => {
|
|||||||
const items = users([{ role: "user", content: "完全不像任何模板的一句话", msgIndex: 1, hostAuthored: true }]);
|
const items = users([{ role: "user", content: "完全不像任何模板的一句话", msgIndex: 1, hostAuthored: true }]);
|
||||||
expect(items).toEqual([]);
|
expect(items).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides the legacy unavailable-tool repair prompt from older sessions", () => {
|
||||||
|
const items = users([{
|
||||||
|
role: "user",
|
||||||
|
content: "The following tools are unavailable in the current workflow phase: update_goal. Do not call them again. Respond to the user's request with visible answer text now; call a different tool only if it is still needed to complete the request.",
|
||||||
|
msgIndex: 1,
|
||||||
|
}]);
|
||||||
|
expect(items).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -191,6 +191,11 @@ describe("a notice about the runtime rather than the conversation", () => {
|
|||||||
expect(notices(s)).toEqual(["undid last rewind"]);
|
expect(notices(s)).toEqual(["undid last rewind"]);
|
||||||
expect(s.runtime).toEqual([]);
|
expect(s.runtime).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps legacy capability proxy audits out of the transcript", () => {
|
||||||
|
const s = run([notice(undefined, "info", "capability proxy: use_capability → web_fetch")]);
|
||||||
|
expect(notices(s)).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("a line that is still queued", () => {
|
describe("a line that is still queued", () => {
|
||||||
|
|||||||
@@ -476,6 +476,12 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
|||||||
|
|
||||||
case "notice": {
|
case "notice": {
|
||||||
const level = ev.level ?? "info";
|
const level = ev.level ?? "info";
|
||||||
|
// Older journals stored the capability resolver's audit line as an
|
||||||
|
// ordinary conversation notice. The resolved tool card already carries
|
||||||
|
// the target and capability id, so rendering both produces two cards for
|
||||||
|
// one action. Current kernels mark this operator-only; keep the prefix
|
||||||
|
// guard so reopening an old session gets the same quiet transcript.
|
||||||
|
if (level === "info" && ev.text?.startsWith("capability proxy:")) return s;
|
||||||
// A notice about the runtime is not a turn in the conversation, so it
|
// A notice about the runtime is not a turn in the conversation, so it
|
||||||
// does not join the record of one. What it says is still kept: every
|
// does not join the record of one. What it says is still kept: every
|
||||||
// frame reaches the trajectory either way, which is where a question
|
// frame reaches the trajectory either way, which is where a question
|
||||||
@@ -671,7 +677,11 @@ export function fromHistory(msgs: HistoryMessage[]): { items: Item[]; plan: Plan
|
|||||||
// turn that was nothing but a dropped file still has text here. What is
|
// turn that was nothing but a dropped file still has text here. What is
|
||||||
// left with none is host chrome, and so is a line the host composed —
|
// left with none is host chrome, and so is a line the host composed —
|
||||||
// the kernel says which, and drawing it would put words in your mouth.
|
// the kernel says which, and drawing it would put words in your mouth.
|
||||||
const text = m.hostAuthored ? "" : stripControl(m.content);
|
// Older sessions predate hostAuthored on this repair prompt. It is kernel
|
||||||
|
// guidance, not something the person said, so keep those transcripts clean
|
||||||
|
// after an upgrade as well as for newly written history.
|
||||||
|
const legacyHostRepair = m.content.startsWith("The following tools are unavailable in the current workflow phase:");
|
||||||
|
const text = m.hostAuthored || legacyHostRepair ? "" : stripControl(m.content);
|
||||||
if (text) out.push({ t: "user", id: nextId(), text, msgIndex: m.msgIndex });
|
if (text) out.push({ t: "user", id: nextId(), text, msgIndex: m.msgIndex });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -6,12 +6,11 @@ import type { HubPort, RuntimeView, TreeWorkspace } from "../port/hub";
|
|||||||
import { Chrome } from "./Chrome";
|
import { Chrome } from "./Chrome";
|
||||||
import { Nav } from "./Nav";
|
import { Nav } from "./Nav";
|
||||||
import { AccountRow } from "./AccountRow";
|
import { AccountRow } from "./AccountRow";
|
||||||
import { swapping } from "./swap";
|
|
||||||
import { apply as applyThemePack } from "./theme";
|
import { apply as applyThemePack } from "./theme";
|
||||||
import { apply as applyLook } from "./look";
|
import { apply as applyLook } from "./look";
|
||||||
import { adopt as adoptLang } from "../i18n";
|
import { adopt as adoptLang } from "../i18n";
|
||||||
import { Pane, type PaneReport } from "./Pane";
|
import { Pane, type PaneReport } from "./Pane";
|
||||||
import { Gutter, RAIL, SIDE, widthOf } from "./Gutter";
|
import { Gutter, RAIL, widthOf } from "./Gutter";
|
||||||
import { folded as roomGaveUp, onFolds } from "./viewport";
|
import { folded as roomGaveUp, onFolds } from "./viewport";
|
||||||
import { trailing } from "./trailing";
|
import { trailing } from "./trailing";
|
||||||
import { RemoteAsk } from "./RemoteAsk";
|
import { RemoteAsk } from "./RemoteAsk";
|
||||||
@@ -27,14 +26,48 @@ import { useAddWorkspace } from "./addws";
|
|||||||
import { PaneTabs } from "./PaneTabs";
|
import { PaneTabs } from "./PaneTabs";
|
||||||
import { Onboarding } from "./Onboarding";
|
import { Onboarding } from "./Onboarding";
|
||||||
import { Welcome } from "./Welcome";
|
import { Welcome } from "./Welcome";
|
||||||
|
import { StudioIcon } from "./StudioIcon";
|
||||||
|
import { BrowserPanel } from "./BrowserPanel";
|
||||||
|
|
||||||
const Settings = lazy(async () => ({ default: (await import("./Settings")).Settings }));
|
// Start fetching the settings chunk with the shell instead of waiting for the
|
||||||
|
// first click. It remains a separate chunk (and keeps its failure boundary),
|
||||||
|
// but opening settings no longer produces a veil while the module catches up.
|
||||||
|
const settingsModule = import("./Settings").then(
|
||||||
|
(module) => ({ module, error: null as unknown }),
|
||||||
|
(error: unknown) => ({ module: null, error }),
|
||||||
|
);
|
||||||
|
const Settings = lazy(async () => {
|
||||||
|
const loaded = await settingsModule;
|
||||||
|
if (loaded.error) throw loaded.error;
|
||||||
|
return { default: loaded.module!.Settings };
|
||||||
|
});
|
||||||
|
|
||||||
const NO_REPORT: PaneReport = { status: null, title: "", steer: 0, run: "idle", live: false, cost: "" };
|
const NO_REPORT: PaneReport = {
|
||||||
|
status: null,
|
||||||
|
title: "",
|
||||||
|
steer: 0,
|
||||||
|
run: "idle",
|
||||||
|
live: false,
|
||||||
|
cost: "",
|
||||||
|
contextPercent: null,
|
||||||
|
context: null,
|
||||||
|
mcp: [],
|
||||||
|
wallet: "",
|
||||||
|
};
|
||||||
|
const PINNED_SESSIONS_KEY = "reasonix:pinned-sessions";
|
||||||
|
// Keep a small warm set for instant back-and-forth switching. Older settled
|
||||||
|
// panes are cheap to restore from disk and expensive to leave mounted: every
|
||||||
|
// hidden pane retains a transcript, observers and markdown tree.
|
||||||
|
const WARM_PANES = 4;
|
||||||
|
|
||||||
// 度量栏默认展开,收起是用户的选择 —— 那个选择跟主题一样留在盘上,不然拖一下
|
function savedPins(): Set<string> {
|
||||||
// 窗口或者重开一次就被顶回展开。
|
try {
|
||||||
const sideWanted = () => localStorage.getItem("rx-side") !== "0";
|
const raw = JSON.parse(localStorage.getItem(PINNED_SESSIONS_KEY) ?? "[]");
|
||||||
|
return new Set(Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : []);
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 两栏共用一条规则:窄到放不下就收起,缝和把手留在原处。宽档下的那个选择要留
|
// 两栏共用一条规则:窄到放不下就收起,缝和把手留在原处。宽档下的那个选择要留
|
||||||
// 着 —— 拖窄一下再拖回来,不该把用户自己收起或展开的决定抹掉。
|
// 着 —— 拖窄一下再拖回来,不该把用户自己收起或展开的决定抹掉。
|
||||||
@@ -78,25 +111,20 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
// 窄到放不下工作区栏时它是收起的,而不是消失的:栏一旦从 DOM 里拿掉,把手也
|
// 窄到放不下工作区栏时它是收起的,而不是消失的:栏一旦从 DOM 里拿掉,把手也
|
||||||
// 跟着没了,剩下的入口只有一个没人知道的快捷键。
|
// 跟着没了,剩下的入口只有一个没人知道的快捷键。
|
||||||
const [rail, setRail] = useState(() => !roomGaveUp("rail"));
|
const [rail, setRail] = useState(() => !roomGaveUp("rail"));
|
||||||
const [side, setSide] = useState(() => sideWanted() && !roomGaveUp("side"));
|
const [railScope, setRailScope] = useState<"all" | "live" | "pinned" | "archived">("all");
|
||||||
const chooseSide = useCallback((v: boolean | ((p: boolean) => boolean)) => {
|
const [pinnedSessions, setPinnedSessions] = useState<Set<string>>(savedPins);
|
||||||
setSide((cur) => {
|
const [showProjects, setShowProjects] = useState(false);
|
||||||
const next = typeof v === "function" ? v(cur) : v;
|
|
||||||
localStorage.setItem("rx-side", next ? "1" : "0");
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
// 三层所有权,只在用的地方相乘:wanted(用户的选择,rail 在 useFoldAway 的
|
// 三层所有权,只在用的地方相乘:wanted(用户的选择,rail 在 useFoldAway 的
|
||||||
// 记忆里、side 还在盘上)、allowed(当前视口,useFoldAway 管)、focus(临时
|
// 记忆里、side 还在盘上)、allowed(当前视口,useFoldAway 管)、focus(临时
|
||||||
// 观看状态)。focus 绝不调 setRail/chooseSide —— 那会把临时状态写回偏好,退
|
// 观看状态)。focus 绝不调 setRail/chooseSide —— 那会把临时状态写回偏好,退
|
||||||
// 出后就恢复不了了。它也不存盘:重开一次窗口不该还在专注里。
|
// 出后就恢复不了了。它也不存盘:重开一次窗口不该还在专注里。
|
||||||
const [focus, setFocus] = useState(false);
|
const [focus, setFocus] = useState(false);
|
||||||
const [railW, setRailW] = useState(() => widthOf(RAIL));
|
const [railW, setRailW] = useState(() => widthOf(RAIL));
|
||||||
const [sideW, setSideW] = useState(() => widthOf(SIDE));
|
|
||||||
const [report, setReport] = useState<PaneReport>(NO_REPORT);
|
const [report, setReport] = useState<PaneReport>(NO_REPORT);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
// false = closed, true = open at its last section, a string = open there.
|
// false = closed, true = open at its last section, a string = open there.
|
||||||
const [settings, setSettings] = useState<string | boolean>(false);
|
const [settings, setSettings] = useState<string | boolean>(false);
|
||||||
|
const [browser, setBrowser] = useState(false);
|
||||||
const [theme, setTheme] = useState(() => localStorage.getItem("rx-theme") ?? "auto");
|
const [theme, setTheme] = useState(() => localStorage.getItem("rx-theme") ?? "auto");
|
||||||
// "" means never chosen, which is what lets the system's own contrast setting
|
// "" means never chosen, which is what lets the system's own contrast setting
|
||||||
// decide. Any explicit pick wins over it from then on.
|
// decide. Any explicit pick wins over it from then on.
|
||||||
@@ -110,9 +138,6 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
const [accountUnread, setAccountUnread] = useState("");
|
const [accountUnread, setAccountUnread] = useState("");
|
||||||
const [pack, setPack] = useState<ThemePack | null>(null);
|
const [pack, setPack] = useState<ThemePack | null>(null);
|
||||||
const [look, setLook] = useState<Look>({});
|
const [look, setLook] = useState<Look>({});
|
||||||
// The metrics column is the window's, but its contents belong to the focused
|
|
||||||
// pane, which renders into it through a portal.
|
|
||||||
const [sideHost, setSideHost] = useState<HTMLElement | null>(null);
|
|
||||||
// Bumped when a pane is rebound to another transcript. It rides the Pane key,
|
// Bumped when a pane is rebound to another transcript. It rides the Pane key,
|
||||||
// so the takeover remounts it: every bit of what is on screen belonged to the
|
// so the takeover remounts it: every bit of what is on screen belonged to the
|
||||||
// conversation it just left.
|
// conversation it just left.
|
||||||
@@ -127,7 +152,6 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
runsRef.current = runs;
|
runsRef.current = runs;
|
||||||
|
|
||||||
const fail = useCallback((e: unknown) => setError(reason(e)), []);
|
const fail = useCallback((e: unknown) => setError(reason(e)), []);
|
||||||
|
|
||||||
// Asked at the moment a confirmation opens, never subscribed to: runs moves
|
// Asked at the moment a confirmation opens, never subscribed to: runs moves
|
||||||
// on every usage round, and handing the sidebar that would rebuild a tree of
|
// on every usage round, and handing the sidebar that would rebuild a tree of
|
||||||
// a few hundred sessions each frame — which is what its memo is there for.
|
// a few hundred sessions each frame — which is what its memo is there for.
|
||||||
@@ -307,10 +331,14 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
const paint = () => {
|
const paint = () => {
|
||||||
const scheme = theme === "auto" ? (mq.matches ? "dark" : "light") : theme;
|
const scheme = theme === "auto" ? (mq.matches ? "dark" : "light") : theme;
|
||||||
document.documentElement.dataset.theme = scheme;
|
document.documentElement.dataset.theme = scheme;
|
||||||
applyThemePack(pack, scheme as "light" | "dark", running);
|
// Studio now has one authored visual system shared with the approved
|
||||||
|
// prototype. Legacy theme packs may still be managed in settings, but
|
||||||
|
// they must not repaint this shell or reintroduce the old background,
|
||||||
|
// glow and translucency rules over the product UI.
|
||||||
|
applyThemePack(null, scheme as "light" | "dark", running);
|
||||||
// After the pack, never before: size and type are the reader's, and a
|
// After the pack, never before: size and type are the reader's, and a
|
||||||
// palette someone else authored does not get to overrule them.
|
// palette someone else authored does not get to overrule them.
|
||||||
applyLook(look, running);
|
applyLook({ ...look, wallpaper: undefined }, running);
|
||||||
};
|
};
|
||||||
paint();
|
paint();
|
||||||
mq.addEventListener("change", paint);
|
mq.addEventListener("change", paint);
|
||||||
@@ -330,16 +358,19 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
// A pane with no session file has never been written to — the empty one every
|
// A pane with no session file has never been written to — the empty one every
|
||||||
// window opens with. Opening a conversation takes it over instead of parking
|
// window opens with. Opening a conversation takes it over instead of parking
|
||||||
// a blank column next to it.
|
// a blank column next to it.
|
||||||
const focusPane = useCallback((id: string) => swapping(() => setActive(id), "pane"), []);
|
// A transcript can be thousands of pixels tall. Capturing it into a View
|
||||||
|
// Transition made a simple sidebar click pay for a full-page texture before
|
||||||
|
// the active id changed. Selection feedback should be immediate.
|
||||||
|
const focusPane = useCallback((id: string) => setActive(id), []);
|
||||||
// Settings is the next layer over the whole screen and had entry but no exit: it
|
// Settings is the next layer over the whole screen and had entry but no exit: it
|
||||||
// simply vanished on unmount. A view transition can animate out an element that
|
// simply vanished on unmount. A view transition can animate out an element that
|
||||||
// is absent from the new state, so the tree need not stay mounted.
|
// is absent from the new state, so the tree need not stay mounted.
|
||||||
const showPrefs = useCallback((sec?: string) => swapping(() => setSettings(sec ?? true), "prefs"), []);
|
const showPrefs = useCallback((sec?: string) => setSettings(sec ?? true), []);
|
||||||
// The host book is edited in settings and read by the sidebar, and nothing
|
// The host book is edited in settings and read by the sidebar, and nothing
|
||||||
// else would tell it a machine was added: an idle host reports no change to
|
// else would tell it a machine was added: an idle host reports no change to
|
||||||
// poll for, so a folder added there stayed invisible until the next launch.
|
// poll for, so a folder added there stayed invisible until the next launch.
|
||||||
const hidePrefs = useCallback(() => {
|
const hidePrefs = useCallback(() => {
|
||||||
swapping(() => setSettings(false), "prefs");
|
setSettings(false);
|
||||||
void reloadRemotes();
|
void reloadRemotes();
|
||||||
}, [reloadRemotes]);
|
}, [reloadRemotes]);
|
||||||
|
|
||||||
@@ -359,18 +390,43 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
if (blank && req.sessionPath && blank.root === req.root) {
|
if (blank && req.sessionPath && blank.root === req.root) {
|
||||||
await panePorts.get(blank.id)?.resume(req.sessionPath);
|
await panePorts.get(blank.id)?.resume(req.sessionPath);
|
||||||
setTakeover((prev) => ({ ...prev, [blank.id]: (prev[blank.id] ?? 0) + 1 }));
|
setTakeover((prev) => ({ ...prev, [blank.id]: (prev[blank.id] ?? 0) + 1 }));
|
||||||
await reloadPanes();
|
|
||||||
focusPane(blank.id);
|
focusPane(blank.id);
|
||||||
|
void reloadPanes();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// One visible pane plus live background work is the product model. Keep a
|
||||||
|
// few settled panes warm for quick backtracking, then reuse the oldest
|
||||||
|
// idle pane in the same workspace instead of accumulating full hidden
|
||||||
|
// transcripts until the kernel refuses another open.
|
||||||
|
const idle = runtimes.filter((rt) => rt.id !== active && !runsRef.current[rt.id]?.live);
|
||||||
|
const atCapacity = runtimes.length >= Math.min(WARM_PANES, hub.maxPanes());
|
||||||
|
const reusable = atCapacity && req.sessionPath
|
||||||
|
? idle.find((rt) => rt.root === req.root && panePorts.has(rt.id))
|
||||||
|
: undefined;
|
||||||
|
if (reusable && req.sessionPath) {
|
||||||
|
await panePorts.get(reusable.id)?.resume(req.sessionPath);
|
||||||
|
setTakeover((prev) => ({ ...prev, [reusable.id]: (prev[reusable.id] ?? 0) + 1 }));
|
||||||
|
focusPane(reusable.id);
|
||||||
|
void reloadPanes();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A different workspace cannot be resumed into the same runtime. Retire
|
||||||
|
// one settled background pane before opening so the row never reaches the
|
||||||
|
// old "nothing happens" max-pane failure.
|
||||||
|
let retired = "";
|
||||||
|
if (runtimes.length >= hub.maxPanes() && idle[0]) {
|
||||||
|
retired = idle[0].id;
|
||||||
|
await hub.close(retired);
|
||||||
|
}
|
||||||
const rt = await hub.open(req);
|
const rt = await hub.open(req);
|
||||||
// Another folder needs its own runtime, so the blank one is retired
|
// Another folder needs its own runtime, so the blank one is retired
|
||||||
// rather than left behind.
|
// rather than left behind.
|
||||||
if (blank && blank.id !== rt.id) await hub.close(blank.id);
|
if (blank && blank.id !== rt.id) await hub.close(blank.id);
|
||||||
await reloadPanes();
|
setRuntimes((prev) => [...prev.filter((pane) => pane.id !== rt.id && pane.id !== blank?.id && pane.id !== retired), rt]);
|
||||||
focusPane(rt.id);
|
focusPane(rt.id);
|
||||||
|
void reloadPanes();
|
||||||
},
|
},
|
||||||
[hub, reloadPanes, runtimes, panePorts, focusPane],
|
[hub, reloadPanes, runtimes, panePorts, focusPane, active],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Awaitable because deleting a conversation has to close its pane first and
|
// Awaitable because deleting a conversation has to close its pane first and
|
||||||
@@ -404,16 +460,11 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
const needsProject = !claimed && tree.every((ws) => !ws.remembered);
|
const needsProject = !claimed && tree.every((ws) => !ws.remembered);
|
||||||
|
|
||||||
useFoldAway("rail", setRail);
|
useFoldAway("rail", setRail);
|
||||||
useFoldAway("side", setSide, sideWanted());
|
|
||||||
|
|
||||||
const onRailW = useCallback((w: number) => {
|
const onRailW = useCallback((w: number) => {
|
||||||
setRailW(w);
|
setRailW(w);
|
||||||
localStorage.setItem(RAIL.key, String(Math.round(w)));
|
localStorage.setItem(RAIL.key, String(Math.round(w)));
|
||||||
}, []);
|
}, []);
|
||||||
const onSideW = useCallback((w: number) => {
|
|
||||||
setSideW(w);
|
|
||||||
localStorage.setItem(SIDE.key, String(Math.round(w)));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// A webview has nowhere to put a new tab, so target="_blank" opens nothing at
|
// A webview has nowhere to put a new tab, so target="_blank" opens nothing at
|
||||||
// all, and letting the link navigate in place would replace the session with
|
// all, and letting the link navigate in place would replace the session with
|
||||||
@@ -439,10 +490,9 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
const shortcuts: { chord: string; shift?: boolean; action: string; run: () => void }[] = useMemo(
|
const shortcuts: { chord: string; shift?: boolean; action: string; run: () => void }[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ chord: "\\", action: "rail.toggle", run: () => setRail((v) => !v) },
|
{ chord: "\\", action: "rail.toggle", run: () => setRail((v) => !v) },
|
||||||
{ chord: "\\", shift: true, action: "inspector.toggle", run: () => chooseSide((v) => !v) },
|
|
||||||
{ chord: ",", action: "chrome.settings", run: showPrefs },
|
{ chord: ",", action: "chrome.settings", run: showPrefs },
|
||||||
],
|
],
|
||||||
[showPrefs, chooseSide],
|
[showPrefs],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -464,14 +514,16 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
// the two, and doing both on one press would be neither.
|
// the two, and doing both on one press would be neither.
|
||||||
// Escape is two actions on one key, the way send and stop share one
|
// Escape is two actions on one key, the way send and stop share one
|
||||||
// button: session.stop while a turn is live, chrome.focus otherwise.
|
// button: session.stop while a turn is live, chrome.focus otherwise.
|
||||||
if (e.key === "Escape" && !settings) {
|
if (e.key === "Escape" && browser) {
|
||||||
|
setBrowser(false);
|
||||||
|
} else if (e.key === "Escape" && !settings) {
|
||||||
if (running) activePort?.cancel();
|
if (running) activePort?.cancel();
|
||||||
else setFocus(false);
|
else setFocus(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
addEventListener("keydown", onKey);
|
addEventListener("keydown", onKey);
|
||||||
return () => removeEventListener("keydown", onKey);
|
return () => removeEventListener("keydown", onKey);
|
||||||
}, [activePort, running, settings, shortcuts]);
|
}, [activePort, browser, running, settings, shortcuts]);
|
||||||
|
|
||||||
// A setting changed in the pane is a fact about the session behind it, and
|
// A setting changed in the pane is a fact about the session behind it, and
|
||||||
// the pane is what holds that fact. Without a nudge it keeps polling only
|
// the pane is what holds that fact. Without a nudge it keeps polling only
|
||||||
@@ -505,6 +557,11 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
);
|
);
|
||||||
// The folder only earns tab space when the panes actually span more than one.
|
// The folder only earns tab space when the panes actually span more than one.
|
||||||
const manyRoots = useMemo(() => new Set(runtimes.map((rt) => rt.root)).size > 1, [runtimes]);
|
const manyRoots = useMemo(() => new Set(runtimes.map((rt) => rt.root)).size > 1, [runtimes]);
|
||||||
|
const activeRuntime = runtimes.find((rt) => rt.id === active);
|
||||||
|
const activeWorkspace = tree.find((ws) => ws.root === activeRuntime?.root) ?? tree[0];
|
||||||
|
const sessionCount = tree.reduce((n, ws) => n + ws.sessions.filter((session) => !session.archived).length, 0);
|
||||||
|
const archivedCount = tree.reduce((n, ws) => n + ws.sessions.filter((session) => session.archived).length, 0);
|
||||||
|
const liveCount = liveIds(runtimes.map((rt) => rt.id)).length;
|
||||||
|
|
||||||
// The tab strip has nowhere to await: closing is the end of the gesture there,
|
// The tab strip has nowhere to await: closing is the end of the gesture there,
|
||||||
// so a refusal has to land in the error bar rather than in a caller.
|
// so a refusal has to land in the error bar rather than in a caller.
|
||||||
@@ -520,13 +577,37 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
[hub, reloadTree, fail],
|
[hub, reloadTree, fail],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const archiveSession = useCallback(
|
||||||
|
async (path: string, archived: boolean, runtimeId?: string) => {
|
||||||
|
if (runtimeId) await closePanes([runtimeId]);
|
||||||
|
await hub.archiveSession(path, archived);
|
||||||
|
if (archived) {
|
||||||
|
setPinnedSessions((current) => {
|
||||||
|
if (!current.has(path)) return current;
|
||||||
|
const next = new Set(current);
|
||||||
|
next.delete(path);
|
||||||
|
localStorage.setItem(PINNED_SESSIONS_KEY, JSON.stringify([...next]));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await reloadTree();
|
||||||
|
},
|
||||||
|
[closePanes, hub, reloadTree],
|
||||||
|
);
|
||||||
|
|
||||||
|
const togglePinnedSession = useCallback((path: string) => {
|
||||||
|
setPinnedSessions((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(path)) next.delete(path);
|
||||||
|
else next.add(path);
|
||||||
|
localStorage.setItem(PINNED_SESSIONS_KEY, JSON.stringify([...next]));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (setup === undefined || welcomed === undefined) return <div className="app" data-run="idle" />;
|
if (setup === undefined || welcomed === undefined) return <div className="app" data-run="idle" />;
|
||||||
// The sequence and the first connection are one scene, not two screens: the
|
if (setup?.required && activePort) {
|
||||||
// card rises inside it after the collapse, with the introduction still above.
|
return (
|
||||||
// A machine that has seen the sequence but still owes a key gets the card
|
|
||||||
// over a still scene rather than a replay.
|
|
||||||
if ((!welcomed || setup?.required) && activePort) {
|
|
||||||
const card = setup?.required ? (
|
|
||||||
<Onboarding
|
<Onboarding
|
||||||
port={activePort}
|
port={activePort}
|
||||||
setup={setup}
|
setup={setup}
|
||||||
@@ -539,18 +620,18 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
void reloadPanes();
|
void reloadPanes();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : undefined;
|
);
|
||||||
|
}
|
||||||
|
if (!welcomed && activePort) {
|
||||||
return (
|
return (
|
||||||
<Welcome
|
<Welcome
|
||||||
variant={setup?.required ? "full" : "short"}
|
variant="short"
|
||||||
replay={!welcomed}
|
replay
|
||||||
onDone={() => {
|
onDone={() => {
|
||||||
setWelcomed(true);
|
setWelcomed(true);
|
||||||
void activePort.markWelcomed().catch(() => {});
|
void activePort.markWelcomed().catch(() => {});
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{card}
|
|
||||||
</Welcome>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,13 +640,13 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
className="app"
|
className="app"
|
||||||
data-run={report.run}
|
data-run={report.run}
|
||||||
data-rail={rail ? "on" : "off"}
|
data-rail={rail ? "on" : "off"}
|
||||||
data-side={side ? "on" : "off"}
|
data-side={browser ? "on" : "off"}
|
||||||
data-focus={focus ? "true" : undefined}
|
data-focus={focus ? "true" : undefined}
|
||||||
data-plan={report.status?.plan ? "on" : "off"}
|
data-plan={report.status?.plan ? "on" : "off"}
|
||||||
data-apv={report.status?.toolApprovalMode ?? "ask"}
|
data-apv={report.status?.toolApprovalMode ?? "ask"}
|
||||||
data-prefs={settings ? "" : undefined}
|
data-prefs={settings ? "" : undefined}
|
||||||
data-tabs={runtimes.length > 1 ? "" : undefined}
|
data-tabs={runtimes.length > 1 ? "" : undefined}
|
||||||
style={{ "--rail-open": `${railW}px`, "--side-open": `${sideW}px` } as CSSProperties}
|
style={{ "--rail-open": `${railW}px`, "--side-open": "min(430px, 42vw)" } as CSSProperties}
|
||||||
>
|
>
|
||||||
{ask && <RemoteAsk ask={ask} onAnswer={answerRemote} />}
|
{ask && <RemoteAsk ask={ask} onAnswer={answerRemote} />}
|
||||||
|
|
||||||
@@ -573,13 +654,18 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
host={runtimes.find((rt) => rt.id === active)?.host}
|
host={runtimes.find((rt) => rt.id === active)?.host}
|
||||||
port={activePort}
|
port={activePort}
|
||||||
status={report.status}
|
status={report.status}
|
||||||
title={report.title}
|
title={tabs.find((tab) => tab.rt.id === active)?.title ?? report.title}
|
||||||
steer={report.steer}
|
steer={report.steer}
|
||||||
run={report.run}
|
|
||||||
onSettings={showPrefs}
|
onSettings={showPrefs}
|
||||||
|
onBrowser={() => setBrowser((value) => !value)}
|
||||||
|
browser={browser}
|
||||||
account={account}
|
account={account}
|
||||||
focus={focus}
|
focus={focus}
|
||||||
onFocus={() => setFocus((v) => !v)}
|
onFocus={() => setFocus((v) => !v)}
|
||||||
|
rail={rail}
|
||||||
|
theme={theme}
|
||||||
|
onRail={() => setRail((v) => !v)}
|
||||||
|
onTheme={() => setTheme((v) => (v === "dark" ? "light" : "dark"))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{pack?.sky && <Sky />}
|
{pack?.sky && <Sky />}
|
||||||
@@ -595,6 +681,90 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
屏幕上没有的栏还能被 Tab 走进去。 */}
|
屏幕上没有的栏还能被 Tab 走进去。 */}
|
||||||
<div className="rail" inert={focus}>
|
<div className="rail" inert={focus}>
|
||||||
<div className="railscroll">
|
<div className="railscroll">
|
||||||
|
<div className="studio-rail-head">
|
||||||
|
<div className="studio-brand" aria-label="Reasonix Studio">
|
||||||
|
<span className="studio-brand-mark" aria-hidden="true"><StudioIcon name="brand" /></span>
|
||||||
|
<span className="studio-brand-word" aria-hidden="true">
|
||||||
|
<b>reasoni<span className="studio-brand-accent">x</span></b>
|
||||||
|
<small>studio</small>
|
||||||
|
</span>
|
||||||
|
<button className="studio-collapse" data-action="chrome.rail" onClick={() => setRail(false)} aria-label={t("收起工作区栏")}><StudioIcon name="menu" /></button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="studio-new-task"
|
||||||
|
data-action="session.new"
|
||||||
|
onClick={() => void openPane({ root: tree[0]?.root }).catch(fail)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true"><StudioIcon name="plus" /></span>{t("新建会话")}<kbd>Alt N</kbd>
|
||||||
|
</button>
|
||||||
|
<button className="studio-search" data-action="workspace.search" onClick={() => document.querySelector<HTMLInputElement>(".wsfind input")?.focus()}>
|
||||||
|
<span aria-hidden="true"><StudioIcon name="search" /></span>{t("搜索与快捷操作")}<kbd>Ctrl K</kbd>
|
||||||
|
</button>
|
||||||
|
<div className="studio-section-label"><span>{t("工作空间")}</span><button data-action="workspace.add" onClick={() => adder.add("rail")} aria-label={t("添加工作区")}><StudioIcon name="plus" /></button></div>
|
||||||
|
{activeWorkspace && (
|
||||||
|
<div className="studio-workspace-switcher">
|
||||||
|
<button className="studio-current-workspace" data-action="workspace.switch" aria-haspopup="listbox" aria-expanded={showProjects} onClick={() => setShowProjects((v) => !v)} title={activeWorkspace.root}>
|
||||||
|
<span aria-hidden="true"><StudioIcon name="folder" /></span>
|
||||||
|
<span><b>{activeWorkspace.name}</b><small>{t("当前工作区 · 本地")}</small></span>
|
||||||
|
<i aria-hidden="true"><StudioIcon name="chevron" /></i>
|
||||||
|
</button>
|
||||||
|
{showProjects && (
|
||||||
|
<div className="studio-workspace-pop" role="listbox" aria-label={t("切换工作区")}>
|
||||||
|
<div className="studio-workspace-pop-head">
|
||||||
|
<span>{t("切换工作区")}</span><small>{tree.length}</small>
|
||||||
|
</div>
|
||||||
|
<div className="studio-workspace-pop-list">
|
||||||
|
{tree.map((workspace) => {
|
||||||
|
const current = workspace.root === activeWorkspace.root;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={workspace.root}
|
||||||
|
role="option"
|
||||||
|
data-action="workspace.switch"
|
||||||
|
data-value={workspace.root}
|
||||||
|
aria-selected={current}
|
||||||
|
title={workspace.root}
|
||||||
|
onClick={() => {
|
||||||
|
setShowProjects(false);
|
||||||
|
if (current) return;
|
||||||
|
const openRuntime = runtimes.find((runtime) => runtime.root === workspace.root);
|
||||||
|
if (openRuntime) {
|
||||||
|
focusPane(openRuntime.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void openPane({ root: workspace.root, sessionPath: workspace.sessions[0]?.path }).catch(fail);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true"><StudioIcon name="folder" /></span>
|
||||||
|
<span><b>{workspace.name}</b><small>{t("{n} 会话", { n: workspace.sessions.length })}</small></span>
|
||||||
|
{current && <i aria-hidden="true"><StudioIcon name="check" /></i>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button className="studio-workspace-pop-add" data-action="workspace.add" onClick={() => { setShowProjects(false); adder.add("rail"); }}>
|
||||||
|
<StudioIcon name="plus" /><span>{t("添加工作区")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="studio-quicknav">
|
||||||
|
<button data-action="settings.section" data-value="storage" onClick={() => showPrefs("storage")}><span aria-hidden="true"><StudioIcon name="file" /></span><b>{t("文件")}</b></button>
|
||||||
|
<button data-action="settings.section" data-value="ext" onClick={() => showPrefs("ext")}><span aria-hidden="true"><StudioIcon name="plug" /></span><b>{t("工具与集成")}</b><small>MCP · Skills</small></button>
|
||||||
|
<button data-action="settings.section" data-value="remote" onClick={() => showPrefs("remote")}><span aria-hidden="true"><StudioIcon name="server" /></span><b>{t("运行环境")}</b><small>Local / SSH</small></button>
|
||||||
|
</div>
|
||||||
|
<div className="studio-section-label studio-session-label"><span>{t("会话")}</span></div>
|
||||||
|
<div className="studio-session-filters" role="group" aria-label={t("会话范围")}>
|
||||||
|
<div className="studio-session-segments">
|
||||||
|
<button data-action="session.filter" data-value="all" aria-pressed={railScope === "all"} onClick={() => setRailScope("all")}><span>{t("全部")}</span><b>{sessionCount}</b></button>
|
||||||
|
<button data-action="session.filter" data-value="live" aria-pressed={railScope === "live"} onClick={() => setRailScope("live")}><span>{t("进行中")}</span><b>{liveCount}</b></button>
|
||||||
|
<button data-action="session.filter" data-value="pinned" aria-pressed={railScope === "pinned"} onClick={() => setRailScope("pinned")}><span>{t("置顶")}</span><b>{pinnedSessions.size}</b></button>
|
||||||
|
<button data-action="session.filter" data-value="archived" aria-pressed={railScope === "archived"} onClick={() => setRailScope("archived")}><span>{t("归档")}</span><b>{archivedCount}</b></button>
|
||||||
|
</div>
|
||||||
|
<button className="studio-filter-search" data-action="workspace.search" onClick={() => document.querySelector<HTMLInputElement>(".wsfind input")?.focus()} aria-label={t("按名称筛选会话")}><StudioIcon name="search" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<RailSearch>
|
<RailSearch>
|
||||||
<Workspaces
|
<Workspaces
|
||||||
hub={hub}
|
hub={hub}
|
||||||
@@ -608,6 +778,11 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
onFocus={focusPane}
|
onFocus={focusPane}
|
||||||
onClose={closePanes}
|
onClose={closePanes}
|
||||||
liveIds={liveIds}
|
liveIds={liveIds}
|
||||||
|
scope={railScope}
|
||||||
|
pinned={pinnedSessions}
|
||||||
|
onPin={togglePinnedSession}
|
||||||
|
onPause={(runtimeId) => panePorts.get(runtimeId)?.cancel()}
|
||||||
|
onArchive={archiveSession}
|
||||||
onRename={renameSession}
|
onRename={renameSession}
|
||||||
onError={fail}
|
onError={fail}
|
||||||
adder={adder}
|
adder={adder}
|
||||||
@@ -630,7 +805,12 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
</RailSearch>
|
</RailSearch>
|
||||||
</div>
|
</div>
|
||||||
<div className="railfoot">
|
<div className="railfoot">
|
||||||
<AccountRow account={account} unread={accountUnread} onOpen={() => showPrefs("account")} />
|
<button className="studio-wallet" data-action="settings.section" data-value="usage" onClick={() => showPrefs("usage")}><span aria-hidden="true"><StudioIcon name="wallet" /></span><b>{t("钱包与用量")}</b>{report.wallet && <small>{report.wallet}</small>}</button>
|
||||||
|
<div className="studio-user-foot">
|
||||||
|
<AccountRow account={account} unread={accountUnread} onOpen={() => showPrefs("account")} />
|
||||||
|
<span className="studio-workspace-kind">{t(account?.signedIn ? "个人工作空间" : "本地工作空间")}</span>
|
||||||
|
<button className="studio-settings" data-action="chrome.settings" onClick={() => showPrefs()} aria-label={t("设置")}><StudioIcon name="settings" /></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -644,16 +824,6 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
onWidth={onRailW}
|
onWidth={onRailW}
|
||||||
onOpen={setRail}
|
onOpen={setRail}
|
||||||
/>
|
/>
|
||||||
<Gutter
|
|
||||||
edge="r"
|
|
||||||
span={SIDE}
|
|
||||||
width={sideW}
|
|
||||||
label={t("调整度量栏宽度")}
|
|
||||||
open={side}
|
|
||||||
onWidth={onSideW}
|
|
||||||
onOpen={chooseSide}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* One conversation on screen at a time. Side by side, two panes
|
{/* One conversation on screen at a time. Side by side, two panes
|
||||||
squeezed each other and a glance could not tell which composer
|
squeezed each other and a glance could not tell which composer
|
||||||
belonged to which run; the ones behind keep streaming either way. */}
|
belonged to which run; the ones behind keep streaming either way. */}
|
||||||
@@ -669,7 +839,8 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="panes">
|
<div className="panes">
|
||||||
{runtimes.map((rt) => {
|
{activeRuntime && (() => {
|
||||||
|
const rt = activeRuntime;
|
||||||
const port = panePorts.get(rt.id);
|
const port = panePorts.get(rt.id);
|
||||||
return port ? (
|
return port ? (
|
||||||
<Pane
|
<Pane
|
||||||
@@ -678,8 +849,8 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
port={port}
|
port={port}
|
||||||
title={tabs.find((tab) => tab.rt.id === rt.id)?.title ?? t("新会话")}
|
title={tabs.find((tab) => tab.rt.id === rt.id)?.title ?? t("新会话")}
|
||||||
active={rt.id === active}
|
active={rt.id === active}
|
||||||
sideHost={sideHost}
|
sideHost={null}
|
||||||
side={side}
|
side={false}
|
||||||
onFocus={() => focusPane(rt.id)}
|
onFocus={() => focusPane(rt.id)}
|
||||||
visible={rt.id === active}
|
visible={rt.id === active}
|
||||||
onReport={onReport}
|
onReport={onReport}
|
||||||
@@ -698,10 +869,10 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
localStorage.setItem("rx-claim", "off");
|
localStorage.setItem("rx-claim", "off");
|
||||||
setClaimed(true);
|
setClaimed(true);
|
||||||
}}
|
}}
|
||||||
onSettings={() => showPrefs()}
|
onSettings={(section) => section ? showPrefs(section) : showPrefs()}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
})}
|
})()}
|
||||||
{runtimes.length === 0 && (
|
{runtimes.length === 0 && (
|
||||||
<div className="panes-empty">
|
<div className="panes-empty">
|
||||||
<span className="mk" aria-hidden="true">
|
<span className="mk" aria-hidden="true">
|
||||||
@@ -722,28 +893,17 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 栏横过来时缝也横过来,把手跟着走:位置仍是「主区和度量之间」,形状和
|
{browser && (
|
||||||
左边那枚一样,只是转了 90°。DOM 在栏之前,收起后它就落到窗口底边。 */}
|
<BrowserPanel
|
||||||
<button
|
onClose={() => setBrowser(false)}
|
||||||
className="sidepeek"
|
onExternal={(url) => void activePort?.openExternal(url).catch(fail)}
|
||||||
data-shut={side ? undefined : ""}
|
scheme={document.documentElement.dataset.theme === "light" ? "light" : "dark"}
|
||||||
title={side ? t("收起") : t("展开")}
|
/>
|
||||||
aria-label={side ? t("收起度量栏") : t("展开度量栏")}
|
)}
|
||||||
onClick={() => chooseSide(!side)}
|
|
||||||
>
|
|
||||||
<i className="knurl" aria-hidden="true" />
|
|
||||||
<span className="dir" aria-hidden="true">
|
|
||||||
{side ? "⌄" : "⌃"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="side" ref={setSideHost} inert={focus} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{settings && activePort && (
|
{settings && activePort && (
|
||||||
// The transcript's lazy markdown has had this since it was written; the
|
|
||||||
// settings chunk is the other deferred route and had a Suspense with
|
|
||||||
// nothing behind it, so a chunk that never arrives took the window.
|
|
||||||
<Boundary fallback={<SettingsUnavailable onClose={hidePrefs} />}>
|
<Boundary fallback={<SettingsUnavailable onClose={hidePrefs} />}>
|
||||||
<Suspense fallback={<div className="prefs" aria-busy="true" />}>
|
<Suspense fallback={<div className="prefs" aria-busy="true" />}>
|
||||||
<Settings
|
<Settings
|
||||||
@@ -761,11 +921,13 @@ export function App({ hub }: { hub: HubPort }) {
|
|||||||
onContrast={setContrast}
|
onContrast={setContrast}
|
||||||
onClose={hidePrefs}
|
onClose={hidePrefs}
|
||||||
onChanged={onSettingsChanged}
|
onChanged={onSettingsChanged}
|
||||||
|
onSessionsRecovered={() => void reloadTree()}
|
||||||
reloadThemes={reloadThemes}
|
reloadThemes={reloadThemes}
|
||||||
at={typeof settings === "string" ? settings : undefined}
|
at={typeof settings === "string" ? settings : undefined}
|
||||||
account={account}
|
account={account}
|
||||||
accountUnread={accountUnread}
|
accountUnread={accountUnread}
|
||||||
reloadAccount={reloadAccount}
|
reloadAccount={reloadAccount}
|
||||||
|
workspaceRoot={activeWorkspace?.root ?? ""}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Boundary>
|
</Boundary>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { BrowserPanel } from "./BrowserPanel";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("BrowserPanel", () => {
|
||||||
|
it("opens a typed address when Enter is pressed", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BrowserPanel scheme="light" onClose={vi.fn()} onExternal={vi.fn()} />);
|
||||||
|
|
||||||
|
const address = screen.getByRole("textbox", { name: "网页地址" });
|
||||||
|
await user.clear(address);
|
||||||
|
await user.type(address, "example.com/docs{Enter}");
|
||||||
|
|
||||||
|
expect((address as HTMLInputElement).value).toBe("https://example.com/docs");
|
||||||
|
expect(screen.getByTitle("Reasonix 内置 Browser").getAttribute("src")).toBe("https://example.com/docs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps invalid addresses in the field and explains the error", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BrowserPanel scheme="dark" onClose={vi.fn()} onExternal={vi.fn()} />);
|
||||||
|
|
||||||
|
const address = screen.getByRole("textbox", { name: "网页地址" });
|
||||||
|
await user.clear(address);
|
||||||
|
await user.type(address, "file:///private{Enter}");
|
||||||
|
|
||||||
|
expect(screen.getByRole("alert").textContent).toContain("请输入有效的 http 或 https 地址");
|
||||||
|
expect(screen.getByTitle("Reasonix 内置 Browser").hasAttribute("src")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useMemo, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
const START = "reasonix://start";
|
||||||
|
|
||||||
|
function normalise(value: string): string | null {
|
||||||
|
const raw = value.trim();
|
||||||
|
if (!raw) return START;
|
||||||
|
if (/^reasonix:\/\//i.test(raw)) return START;
|
||||||
|
const local = /^(?:localhost|127(?:\.\d+){3}|\[::1\])(?::\d+)?(?:\/|$)/i.test(raw);
|
||||||
|
const candidate = local ? `http://${raw}` : /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||||
|
try {
|
||||||
|
const url = new URL(candidate);
|
||||||
|
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPage(scheme: "light" | "dark"): string {
|
||||||
|
const light = scheme === "light";
|
||||||
|
const page = light ? "#f6f7f4" : "#171817";
|
||||||
|
const text = light ? "#242622" : "#e9eae5";
|
||||||
|
const muted = light ? "#747970" : "#90958d";
|
||||||
|
const surface = light ? "#ffffff" : "#1c1d1b";
|
||||||
|
const border = light ? "#dfe1db" : "#2c2f2b";
|
||||||
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>
|
||||||
|
:root{color-scheme:${scheme};font-family:Inter,"Noto Sans SC",system-ui,sans-serif;background:${page};color:${text}}
|
||||||
|
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:32px;background:${page}}
|
||||||
|
main{width:min(520px,100%);text-align:center}.mark{width:42px;height:42px;margin:0 auto 18px;display:grid;place-items:center;border:1px solid ${border};border-radius:12px;background:${surface};color:#7188c4;font-size:22px}
|
||||||
|
h1{margin:0 0 8px;font-size:18px;font-weight:620;letter-spacing:-.02em}p{margin:0;color:${muted};font-size:13px;line-height:1.7}
|
||||||
|
.hint{margin-top:22px;padding:11px 14px;border:1px solid ${border};border-radius:9px;background:${surface};color:${muted};font-size:12px;text-align:left}
|
||||||
|
</style></head><body><main><div class="mark">◎</div><h1>Reasonix Browser</h1><p>${t("在上方地址栏输入网址,即可在当前任务旁浏览资料。")}</p><div class="hint">${t("浏览内容不会替换或中断当前会话。部分网站禁止嵌入时,可使用右上角的外部打开。")}</div></main></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onExternal: (url: string) => void;
|
||||||
|
scheme: "light" | "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrowserPanel({ onClose, onExternal, scheme }: Props) {
|
||||||
|
const [history, setHistory] = useState([START]);
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [input, setInput] = useState(START);
|
||||||
|
const [reload, setReload] = useState(0);
|
||||||
|
const [invalid, setInvalid] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const current = history[index] ?? START;
|
||||||
|
const internal = current === START;
|
||||||
|
const srcDoc = useMemo(() => startPage(scheme), [scheme]);
|
||||||
|
|
||||||
|
const visit = (value: string) => {
|
||||||
|
const next = normalise(value);
|
||||||
|
if (!next) {
|
||||||
|
setInvalid(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInvalid(false);
|
||||||
|
setLoading(next !== START);
|
||||||
|
setInput(next);
|
||||||
|
if (next === current) {
|
||||||
|
setReload((value) => value + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextHistory = history.slice(0, index + 1).concat(next);
|
||||||
|
setHistory(nextHistory);
|
||||||
|
setIndex(nextHistory.length - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const move = (next: number) => {
|
||||||
|
const value = history[next];
|
||||||
|
if (!value) return;
|
||||||
|
setIndex(next);
|
||||||
|
setInput(value);
|
||||||
|
setInvalid(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
visit(input);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Some desktop webviews consume Enter before a form submit bubbles back to
|
||||||
|
// React. Commit the address at the field as well; IME confirmation must not
|
||||||
|
// navigate while the user is still composing Chinese text.
|
||||||
|
const commitAddress = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key !== "Enter" || event.nativeEvent.isComposing) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
visit(event.currentTarget.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="side studio-browser" aria-label={t("内置 Browser")}>
|
||||||
|
<header className="studio-browser-head">
|
||||||
|
<span><b>Browser</b><small>{t("内置浏览")}</small></span>
|
||||||
|
<button type="button" data-action="browser.close" onClick={onClose} aria-label={t("关闭 Browser")} title={t("关闭")}>
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 4 8 8M12 4l-8 8" /></svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<form className="studio-browser-toolbar" onSubmit={submit} data-action-submit="browser.navigate">
|
||||||
|
<button type="button" data-action="browser.back" onClick={() => move(index - 1)} disabled={index === 0} aria-label={t("后退")} title={t("后退")}>
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m9.5 3-5 5 5 5M5 8h7" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="browser.reload" onClick={() => setReload((value) => value + 1)} aria-label={t("刷新")} title={t("刷新")}>
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M12.7 6A5 5 0 1 0 13 9.5M12.7 6V2.8M12.7 6H9.5" /></svg>
|
||||||
|
</button>
|
||||||
|
<label className="studio-browser-address" data-invalid={invalid ? "" : undefined}>
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="5.3" /><path d="M2.8 8h10.4M8 2.7c1.5 1.5 2.2 3.2 2.2 5.3S9.5 11.8 8 13.3C6.5 11.8 5.8 10.1 5.8 8S6.5 4.2 8 2.7Z" /></svg>
|
||||||
|
<input data-action-change="browser.address" data-action-keydown="browser.navigate" value={input} onKeyDown={commitAddress} onChange={(event) => { setInput(event.target.value); setInvalid(false); }} aria-label={t("网页地址")} spellCheck={false} autoCapitalize="off" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="studio-browser-go" aria-label={t("打开网页")}>{t("打开")}</button>
|
||||||
|
<button type="button" data-action="browser.external" onClick={() => !internal && onExternal(current)} disabled={internal} aria-label={t("在外部浏览器打开")} title={t("在外部浏览器打开")}>
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M9 3h4v4M13 3 7.5 8.5" /><path d="M7 4H3v9h9V9" /></svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{invalid && <p className="studio-browser-error" role="alert">{t("请输入有效的 http 或 https 地址")}</p>}
|
||||||
|
<iframe
|
||||||
|
key={`${current}:${reload}`}
|
||||||
|
className="studio-browser-frame"
|
||||||
|
title={t("Reasonix 内置 Browser")}
|
||||||
|
src={internal ? undefined : current}
|
||||||
|
srcDoc={internal ? srcDoc : undefined}
|
||||||
|
sandbox="allow-scripts allow-forms allow-popups"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onLoad={() => setLoading(false)}
|
||||||
|
onError={() => setLoading(false)}
|
||||||
|
/>
|
||||||
|
<footer className="studio-browser-note" data-loading={loading ? "" : undefined}>
|
||||||
|
{loading ? t("正在打开网页…") : t("页面空白通常表示网站禁止嵌入,可使用右上角按钮在外部浏览器打开。")}
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,6 @@ import { useEffect, useState } from "react";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import type { AccountState, AgentPort, SessionStatus, WorkspaceInfo } from "../port/port";
|
import type { AccountState, AgentPort, SessionStatus, WorkspaceInfo } from "../port/port";
|
||||||
import { WindowControls, zoomOnTitleBar } from "./WindowControls";
|
import { WindowControls, zoomOnTitleBar } from "./WindowControls";
|
||||||
import { chord } from "./keys";
|
|
||||||
|
|
||||||
const RUN_LB: Record<string, string> = { running: "运行中", halt: "等待确认", done: "已完成", idle: "待命" };
|
|
||||||
|
|
||||||
const base = (p: string) => p.replace(/[/\\]+$/, "").split(/[/\\]/).pop() || p;
|
const base = (p: string) => p.replace(/[/\\]+$/, "").split(/[/\\]/).pop() || p;
|
||||||
// The filename is a timestamp and a model ref — true, and useless to read. The
|
// The filename is a timestamp and a model ref — true, and useless to read. The
|
||||||
@@ -22,17 +19,20 @@ interface Props {
|
|||||||
status: SessionStatus | null;
|
status: SessionStatus | null;
|
||||||
title?: string;
|
title?: string;
|
||||||
steer: number;
|
steer: number;
|
||||||
// What the focused pane is doing, as the pane already reports it upward. The
|
|
||||||
// chrome names it because that is the one line always on screen.
|
|
||||||
run: string;
|
|
||||||
// 临时观看状态,不是偏好:窗口决定它,这里只负责把开关画出来。
|
// 临时观看状态,不是偏好:窗口决定它,这里只负责把开关画出来。
|
||||||
focus: boolean;
|
focus: boolean;
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
onSettings: (section?: string) => void;
|
onSettings: (section?: string) => void;
|
||||||
|
onBrowser: () => void;
|
||||||
|
browser: boolean;
|
||||||
account: AccountState | null;
|
account: AccountState | null;
|
||||||
|
rail: boolean;
|
||||||
|
theme: string;
|
||||||
|
onRail: () => void;
|
||||||
|
onTheme: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Chrome({ port, status, title, steer, run, onSettings, account, host, focus, onFocus }: Props) {
|
export function Chrome({ port, status, title, steer, onSettings, onBrowser, browser, account, host, rail, theme, onRail, onTheme }: Props) {
|
||||||
const root = status?.workspaceRoot || status?.cwd || "";
|
const root = status?.workspaceRoot || status?.cwd || "";
|
||||||
const project = root ? base(root) : "—";
|
const project = root ? base(root) : "—";
|
||||||
// Only for the "隔离" tag: the folder list and the switch itself moved to the
|
// Only for the "隔离" tag: the folder list and the switch itself moved to the
|
||||||
@@ -48,9 +48,12 @@ export function Chrome({ port, status, title, steer, run, onSettings, account, h
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chrome" onDoubleClick={zoomOnTitleBar}>
|
<div className="chrome" onDoubleClick={zoomOnTitleBar}>
|
||||||
<span className="brand" role="img" aria-label="Reasonix" />
|
<button className="thbtn studio-menu" data-action="chrome.rail" onClick={onRail} aria-pressed={rail} aria-label={rail ? t("收起工作区栏") : t("展开工作区栏")}>
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 4h10M3 8h10M3 12h10" /></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="crumb">
|
<div className="crumb">
|
||||||
|
<svg className="crumb-folder" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 4.5h4l1.2 1.4h5.8v6.6h-11z" /></svg>
|
||||||
<span className="crumb-proj" title={root}>
|
<span className="crumb-proj" title={root}>
|
||||||
{project}
|
{project}
|
||||||
</span>
|
</span>
|
||||||
@@ -71,62 +74,22 @@ export function Chrome({ port, status, title, steer, run, onSettings, account, h
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="r">
|
<div className="r">
|
||||||
<span className="runpill" data-run={run}>
|
|
||||||
<i />
|
|
||||||
{t(RUN_LB[run] ?? "待命")}
|
|
||||||
</span>
|
|
||||||
{/* Identity sits where every app puts it, but signed out it stays an
|
|
||||||
outline in the icon cluster: an entry point, not a pitch. Reasonix
|
|
||||||
runs fine without an account and must not imply otherwise. */}
|
|
||||||
<button
|
<button
|
||||||
className="thbtn acct-btn"
|
className="thbtn theme-toggle"
|
||||||
data-action="chrome.account"
|
data-action="appearance.theme"
|
||||||
data-on={account?.signedIn ? "" : undefined}
|
onClick={onTheme}
|
||||||
onClick={() => onSettings("account")}
|
aria-label={theme === "dark" ? t("切换浅色主题") : t("切换深色主题")}
|
||||||
aria-label={account?.signedIn ? t("账号:{name}", { name: account.user?.label ?? "" }) : t("登录")}
|
title={theme === "dark" ? t("切换浅色主题") : t("切换深色主题")}
|
||||||
title={account?.signedIn ? `${account.user?.label ?? ""} <${account.user?.email ?? ""}>` : t("登录(社区与崩溃跟进,不影响使用)")}
|
|
||||||
>
|
>
|
||||||
{account?.signedIn && account.user?.label ? (
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2.5a5.5 5.5 0 1 0 0 11V2.5Z" /></svg>
|
||||||
<span className="ini" aria-hidden="true">
|
|
||||||
{[...account.user.label][0]?.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
||||||
<circle cx="8" cy="5.6" r="2.6" />
|
|
||||||
<path d="M3.2 13.4a4.8 4.8 0 0 1 9.6 0" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
{/* The icon cluster's weight class, not the preset control's: what is
|
<button className="top-action" data-action="settings.section" data-value="tools" onClick={() => onSettings("tools")} aria-label={t("沙盒")} title={t("沙盒与运行边界")}>
|
||||||
left up here is the window's, and the turn's policy is the shelf's. */}
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m8 2.5 5 2.7v5.6l-5 2.7-5-2.7V5.2Z" /><path d="m3 5.2 5 2.7 5-2.7M8 7.9v5.6" /></svg><span>{t("沙盒")}</span>
|
||||||
{/* 进出都是这一枚。专注不是模式切换到别处去了,是同一个会话把外围收起来
|
|
||||||
—— 所以按钮留在原地,只换它说的话。 */}
|
|
||||||
<button
|
|
||||||
className="thbtn"
|
|
||||||
data-action="chrome.focus"
|
|
||||||
onClick={onFocus}
|
|
||||||
aria-pressed={focus}
|
|
||||||
aria-label={focus ? t("退出专注") : t("专注")}
|
|
||||||
title={focus ? t("退出专注") : t("专注:收起两侧栏")}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.3">
|
|
||||||
{focus ? (
|
|
||||||
<>
|
|
||||||
<path d="M6.4 2.2v4.2H2.2M9.6 2.2v4.2h4.2M6.4 13.8V9.6H2.2M9.6 13.8V9.6h4.2" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<path d="M2.2 6.4V2.2h4.2M13.8 6.4V2.2H9.6M2.2 9.6v4.2h4.2M13.8 9.6v4.2H9.6" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button className="thbtn" data-action="chrome.settings" onClick={() => onSettings()} aria-label={t("设置")} title={`${t("设置")} ${chord(",")}`}>
|
<button className="thbtn browser-action" data-action="browser.open" onClick={onBrowser} aria-pressed={browser} aria-label="Browser" title={browser ? t("关闭内置 Browser") : t("打开内置 Browser")}>
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="5.4" /><path d="M2.8 8h10.4M8 2.6c1.5 1.5 2.3 3.3 2.3 5.4S9.5 11.9 8 13.4C6.5 11.9 5.7 10.1 5.7 8S6.5 4.1 8 2.6Z" /></svg>
|
||||||
<path d="M8 5.9a2.1 2.1 0 1 0 0 4.2 2.1 2.1 0 0 0 0-4.2" />
|
|
||||||
<path d="M12.7 9.8a1 1 0 0 0 .2 1.1l.04.04a1.2 1.2 0 1 1-1.7 1.7l-.04-.04a1 1 0 0 0-1.1-.2 1 1 0 0 0-.6.9v.11a1.2 1.2 0 1 1-2.4 0v-.06a1 1 0 0 0-.65-.9 1 1 0 0 0-1.1.2l-.04.04a1.2 1.2 0 1 1-1.7-1.7l.04-.04a1 1 0 0 0 .2-1.1 1 1 0 0 0-.9-.6h-.11a1.2 1.2 0 0 1 0-2.4h.06a1 1 0 0 0 .9-.65 1 1 0 0 0-.2-1.1l-.04-.04a1.2 1.2 0 1 1 1.7-1.7l.04.04a1 1 0 0 0 1.1.2h.05a1 1 0 0 0 .6-.9v-.11a1.2 1.2 0 1 1 2.4 0v.06a1 1 0 0 0 .6.9 1 1 0 0 0 1.1-.2l.04-.04a1.2 1.2 0 1 1 1.7 1.7l-.04.04a1 1 0 0 0-.2 1.1v.05a1 1 0 0 0 .9.6h.11a1.2 1.2 0 0 1 0 2.4h-.06a1 1 0 0 0-.9.6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
<span className="account-presence" title={account?.signedIn ? account.user?.label : t("未登录")} />
|
||||||
<WindowControls />
|
<WindowControls />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useIme } from "./ime";
|
|||||||
import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
|
import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
|
||||||
import { useIntake } from "./useIntake";
|
import { useIntake } from "./useIntake";
|
||||||
import type { Dropped } from "./filedrop";
|
import type { Dropped } from "./filedrop";
|
||||||
|
import { StudioIcon } from "./StudioIcon";
|
||||||
|
|
||||||
// Only the ladder the kernel would accept for the model in hand. A fixed list
|
// Only the ladder the kernel would accept for the model in hand. A fixed list
|
||||||
// here offered rungs a given model does not have, and picking one looked like
|
// here offered rungs a given model does not have, and picking one looked like
|
||||||
@@ -29,6 +30,36 @@ function effortsFor(models: ModelEntry[], ref?: string): string[] {
|
|||||||
return EFFORT_FALLBACK;
|
return EFFORT_FALLBACK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function effortReading(value?: string): string {
|
||||||
|
const id = (value || "auto").toLowerCase();
|
||||||
|
const label = ({ auto: "自动", low: "快速", medium: "平衡", high: "深入", xhigh: "极致", max: "极致" } as Record<string, string>)[id] ?? id;
|
||||||
|
const raw = id === "auto" ? "Auto" : id.charAt(0).toUpperCase() + id.slice(1);
|
||||||
|
return `${t(label)} · ${raw}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function effortLabel(value: string): string {
|
||||||
|
const id = value.toLowerCase();
|
||||||
|
return t(({ auto: "自动", disabled: "关闭", low: "快速", medium: "平衡", high: "深入", xhigh: "极致", max: "极致" } as Record<string, string>)[id] ?? id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function effortApi(value: string): string {
|
||||||
|
const id = value.toLowerCase();
|
||||||
|
return id === "xhigh" ? "XHigh" : id.charAt(0).toUpperCase() + id.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function effortDescription(value: string): string {
|
||||||
|
const id = value.toLowerCase();
|
||||||
|
return t(({
|
||||||
|
auto: "使用模型默认或自适应策略,按任务复杂度调整",
|
||||||
|
disabled: "不发送推理强度参数,使用服务端默认设置",
|
||||||
|
low: "轻量思考,适合改写、提取和明确的小任务",
|
||||||
|
medium: "兼顾响应速度与可靠性,适合大多数任务",
|
||||||
|
high: "投入更多时间分析复杂上下文与执行方案",
|
||||||
|
xhigh: "用于最复杂的问题,等待时间与消耗最高",
|
||||||
|
max: "用于最复杂的问题,等待时间与消耗最高",
|
||||||
|
} as Record<string, string>)[id] ?? value);
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
port: AgentPort;
|
port: AgentPort;
|
||||||
status: SessionStatus | null;
|
status: SessionStatus | null;
|
||||||
@@ -41,6 +72,7 @@ interface Props {
|
|||||||
onSubmit: (text: string) => Promise<boolean>;
|
onSubmit: (text: string) => Promise<boolean>;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
onError: (e: unknown) => void;
|
onError: (e: unknown) => void;
|
||||||
|
onSettings?: (section?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// What is riding along with this turn. An attachment travels as bytes or a path
|
// What is riding along with this turn. An attachment travels as bytes or a path
|
||||||
@@ -98,7 +130,15 @@ function releaseChip(c: Chip) {
|
|||||||
let chipSeq = 0;
|
let chipSeq = 0;
|
||||||
const chipId = () => `c${++chipSeq}`;
|
const chipId = () => `c${++chipSeq}`;
|
||||||
|
|
||||||
export function Composer({ port, status, running, focus, onSubmit, onChanged, onError }: Props) {
|
export function Composer({ port, status, running, focus, onSubmit, onChanged, onError, onSettings = () => {} }: Props) {
|
||||||
|
const [branch, setBranch] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
port.capabilityScope()
|
||||||
|
.then((scope) => alive && setBranch(scope.repo ? (scope.branch || t("分离状态")) : ""))
|
||||||
|
.catch(() => alive && setBranch(""));
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [port, status?.workspaceRoot]);
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
// The caret decides which token is being completed, so it is state here
|
// The caret decides which token is being completed, so it is state here
|
||||||
// rather than something read off the element when a menu happens to open.
|
// rather than something read off the element when a menu happens to open.
|
||||||
@@ -114,6 +154,8 @@ export function Composer({ port, status, running, focus, onSubmit, onChanged, on
|
|||||||
const box = useRef<HTMLTextAreaElement>(null);
|
const box = useRef<HTMLTextAreaElement>(null);
|
||||||
const guide = useId();
|
const guide = useId();
|
||||||
const completionId = useId();
|
const completionId = useId();
|
||||||
|
const attachTipId = useId();
|
||||||
|
const branchTipId = useId();
|
||||||
// Set only when a completion moved the caret: the browser puts it at the end
|
// Set only when a completion moved the caret: the browser puts it at the end
|
||||||
// of a programmatic value, which is wrong for anything accepted mid-line.
|
// of a programmatic value, which is wrong for anything accepted mid-line.
|
||||||
const pending = useRef<number | null>(null);
|
const pending = useRef<number | null>(null);
|
||||||
@@ -395,7 +437,7 @@ export function Composer({ port, status, running, focus, onSubmit, onChanged, on
|
|||||||
{c.state === "adding" && <span className="sz live">{t("正在添加…")}</span>}
|
{c.state === "adding" && <span className="sz live">{t("正在添加…")}</span>}
|
||||||
{c.state === "ready" && <span className="sz">{isPicture(c) ? t("图片") : t("文件")}</span>}
|
{c.state === "ready" && <span className="sz">{isPicture(c) ? t("图片") : t("文件")}</span>}
|
||||||
{c.state === "failed" && (
|
{c.state === "failed" && (
|
||||||
<button
|
<button
|
||||||
className="retry"
|
className="retry"
|
||||||
data-action="session.attach"
|
data-action="session.attach"
|
||||||
title={c.error}
|
title={c.error}
|
||||||
@@ -576,56 +618,67 @@ export function Composer({ port, status, running, focus, onSubmit, onChanged, on
|
|||||||
/>
|
/>
|
||||||
<div className="turntools">
|
<div className="turntools">
|
||||||
<button
|
<button
|
||||||
className="mode plain attach"
|
className="mode plain attach studio-attach"
|
||||||
title={t("添加附件 也可直接拖入或粘贴")}
|
|
||||||
aria-label={t("添加附件")}
|
aria-label={t("添加附件")}
|
||||||
|
aria-describedby={attachTipId}
|
||||||
onClick={() => picker.current?.click()}
|
onClick={() => picker.current?.click()}
|
||||||
>
|
>
|
||||||
<span className="ic" aria-hidden="true">
|
<StudioIcon name="plus" />
|
||||||
<svg viewBox="0 0 16 16">
|
<span className="studio-control-tip" id={attachTipId} role="tooltip">
|
||||||
<path d="M8 4.3v7.4M4.3 8h7.4" />
|
<b>{t("添加附件")}</b>
|
||||||
</svg>
|
<span>{t("也可直接拖入或粘贴")}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/* The one control on this shelf with no width of its own: a gateway
|
|
||||||
can publish an id longer than the shelf is wide. It gives up
|
|
||||||
characters before the shelf gives up a line, and the ref it was
|
|
||||||
shortened from stays readable on hover. */}
|
|
||||||
<Picker
|
<Picker
|
||||||
className="mode"
|
wrapClassName="studio-model-control"
|
||||||
|
className="mode model-picker"
|
||||||
data-action="model.select"
|
data-action="model.select"
|
||||||
place="bottom"
|
place="bottom"
|
||||||
title={status?.modelRef ?? modelLb}
|
title={status?.modelRef ?? modelLb}
|
||||||
current={status?.modelRef}
|
current={status?.modelRef}
|
||||||
items={modelMenu(models)}
|
items={modelMenu(models)}
|
||||||
pending={busy["model"]}
|
pending={busy["model"]}
|
||||||
onPick={(ref) => change("model", () => port.setModel(ref))}
|
onPick={(ref) => ref === "__manage-models" ? onSettings("model") : change("model", () => port.setModel(ref))}
|
||||||
label={
|
label={<><span className="modelmark" aria-hidden="true" /><span className="nm">{modelLb}</span><StudioIcon name="down" /></>}
|
||||||
<>
|
|
||||||
<span className="modelmark" aria-hidden="true" />
|
|
||||||
<span className="nm">{modelLb}</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{/* A status may recede to its baseline; the only way into a mode may
|
|
||||||
not. This is where plan mode is discovered, so it stays, drawn
|
|
||||||
quietly while it is off. */}
|
|
||||||
<span className="sep" aria-hidden="true" />
|
<span className="sep" aria-hidden="true" />
|
||||||
<button
|
<div className="studio-mode-control">
|
||||||
className="mode tog"
|
<Picker
|
||||||
data-action="plan.mode"
|
className="mode studio-mode-picker"
|
||||||
data-pending={busy["plan"] ? "" : undefined}
|
triggerAction="plan.mode"
|
||||||
disabled={busy["plan"]}
|
ariaPressed={status?.plan ?? false}
|
||||||
aria-pressed={status?.plan ?? false}
|
place="bottom"
|
||||||
onClick={() => change("plan", () => port.setPlanMode(!status?.plan))}
|
title={t("工作模式")}
|
||||||
>
|
current={status?.plan ? "plan" : "agent"}
|
||||||
<span className="ic" aria-hidden="true">
|
pending={busy["plan"]}
|
||||||
<svg viewBox="0 0 16 16">
|
items={[
|
||||||
<path pathLength={1} d="M2.9 5.1 4.2 6.4l2.2-2.4M8 5.1h5.2M2.9 10.6l1.3 1.3 2.2-2.4M8 10.6h5.2" />
|
{ value: "__mode", label: t("工作模式"), right: t("当前任务"), header: true },
|
||||||
</svg>
|
{ value: "agent", label: "Agent", desc: t("执行任务并使用已启用工具") },
|
||||||
</span>
|
{ value: "plan", label: "Plan", desc: t("先整理步骤,不写入文件") },
|
||||||
<span className="lb">{t("计划")}</span>
|
{ value: "ask", label: "Ask", desc: t("直接回答,不调用工具"), disabled: true },
|
||||||
</button>
|
]}
|
||||||
|
onPick={(value) => change("plan", () => port.setPlanMode(value === "plan"))}
|
||||||
|
label={<><StudioIcon name="spark" /><span className="studio-sr-label">{t("计划")}</span><span>{status?.plan ? "Plan" : "Agent"}</span><StudioIcon name="down" /></>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{branch && (
|
||||||
|
<div className="studio-branch-pop">
|
||||||
|
<div
|
||||||
|
className="mode plain studio-branch"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={t("当前 Git 分支:{branch}", { branch })}
|
||||||
|
aria-describedby={branchTipId}
|
||||||
|
>
|
||||||
|
<span className="ic" aria-hidden="true"><StudioIcon name="branch" /></span>
|
||||||
|
<span className="lb">{branch}</span>
|
||||||
|
</div>
|
||||||
|
<div className="studio-branch-card" id={branchTipId} role="tooltip">
|
||||||
|
<b>{t("当前分支 · {branch}", { branch })}</b>
|
||||||
|
<span>{t("当前工作区 · 后续任务继续使用此分支")}</span>
|
||||||
|
<small>{t("仅作状态提示,无需点击")}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* The toggle keeps its legacy meaning: it follows `plan`, which the
|
{/* The toggle keeps its legacy meaning: it follows `plan`, which the
|
||||||
kernel turns off the moment a plan is approved. The lifecycle is a
|
kernel turns off the moment a plan is approved. The lifecycle is a
|
||||||
separate reading — an approved plan is still running, and saying so
|
separate reading — an approved plan is still running, and saying so
|
||||||
@@ -635,11 +688,34 @@ export function Composer({ port, status, running, focus, onSubmit, onChanged, on
|
|||||||
<span className="lb">{t("执行计划中")}</span>
|
<span className="lb">{t("执行计划中")}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* 执行方式、思考强度、工具权限说的是同一件事:这一轮怎么跑。它们原来
|
<Policy port={port} status={status} onChanged={onChanged} onBoundary={() => onSettings("tools:sandbox")} />
|
||||||
一个在窗口顶栏、两个在这里,要拼出下一轮会怎么执行得看两个地方。
|
{/* 推理强度只在这里出现一次。它属于模型能力,不属于执行权限。 */}
|
||||||
模型留在外面:它是最常改也最认得出的选择,而且没有一个「默认」可以
|
{status && (
|
||||||
退回去 —— 每个值都是一次真实的选择。 */}
|
<div className="studio-effort-control">
|
||||||
<Policy port={port} status={status} efforts={efforts} onChanged={onChanged} />
|
<Picker
|
||||||
|
className="mode studio-effort-picker"
|
||||||
|
data-action="reasoning.effort"
|
||||||
|
place="bottom"
|
||||||
|
align="end"
|
||||||
|
title={t("推理强度")}
|
||||||
|
current={status.effort || "auto"}
|
||||||
|
pending={busy["effort"]}
|
||||||
|
items={[
|
||||||
|
{ value: "__effort-heading", label: t("推理强度"), right: modelLb, header: true },
|
||||||
|
...(efforts.length ? efforts : ["auto"]).map((value) => ({
|
||||||
|
value,
|
||||||
|
label: effortLabel(value),
|
||||||
|
right: effortApi(value),
|
||||||
|
desc: effortDescription(value),
|
||||||
|
})),
|
||||||
|
{ value: "__effort-note", label: t("仅显示当前模型实际支持的档位。"), right: t("按模型生效"), header: true },
|
||||||
|
]}
|
||||||
|
onPick={(value) => change("effort", () => port.setEffort(value))}
|
||||||
|
label={<><span>{effortReading(status.effort)}</span><StudioIcon name="down" /></>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="turntools-spacer" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<span className="go">
|
<span className="go">
|
||||||
{running && (
|
{running && (
|
||||||
@@ -684,7 +760,7 @@ export function Composer({ port, status, running, focus, onSubmit, onChanged, on
|
|||||||
>
|
>
|
||||||
<span className="ic" aria-hidden="true">
|
<span className="ic" aria-hidden="true">
|
||||||
<svg viewBox="0 0 16 16">
|
<svg viewBox="0 0 16 16">
|
||||||
<path d="M2.8 8h9.4M8.4 4.2 12.2 8l-3.8 3.8" />
|
<path d="M8 12.8V3.4M4.2 7.2 8 3.4l3.8 3.8" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span>{t(submitting ? "正在发送…" : running ? "插话" : "发送")}</span>
|
<span>{t(submitting ? "正在发送…" : running ? "插话" : "发送")}</span>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { t } from "../i18n";
|
||||||
|
import type { ContextBreakdown, McpEntry } from "../port/port";
|
||||||
|
|
||||||
|
const compact = (n: number): string => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${Math.round(n / 100) / 10}k` : String(n);
|
||||||
|
|
||||||
|
export function ContextSummaryCard({ context, mcp, percent, onManage, className = "" }: {
|
||||||
|
context: ContextBreakdown | null;
|
||||||
|
mcp: McpEntry[];
|
||||||
|
percent: number | null;
|
||||||
|
onManage: () => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const active = mcp.filter((entry) => entry.enabled);
|
||||||
|
const ready = active.filter((entry) => entry.state === "ready");
|
||||||
|
const tools = ready.reduce((sum, entry) => sum + (entry.tools || 0), 0);
|
||||||
|
const parts = context ? [
|
||||||
|
[t("系统提示"), context.system, "sys"],
|
||||||
|
[t("工具定义"), context.tools, "tool"],
|
||||||
|
[t("你的输入"), context.user, "user"],
|
||||||
|
[t("模型回复"), context.reply, "reply"],
|
||||||
|
[t("工具输出"), context.output, "output"],
|
||||||
|
] as const : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={["chrome-context-card", className].filter(Boolean).join(" ")} role="dialog" aria-label={t("上下文详情")}>
|
||||||
|
<header><span><b>{t("上下文详情")}</b><small>{t("当前会话发送给模型的内容")}</small></span><strong>{percent ?? 0}%</strong></header>
|
||||||
|
{context ? (
|
||||||
|
<>
|
||||||
|
<div className="chrome-context-total"><span>{t("已使用")} <b>{compact(context.used)}</b></span><span>{t("模型容量")} <b>{compact(context.window)}</b></span></div>
|
||||||
|
<div className="chrome-context-bar">{parts.map(([label, value, key]) => <i key={key} data-part={key} title={`${label} ${compact(value)}`} style={{ width: `${context.used ? Math.max(1, (value / context.used) * 100) : 0}%` }} />)}</div>
|
||||||
|
<div className="chrome-context-parts">{parts.map(([label, value, key]) => <div key={key}><i data-part={key} /><span>{label}</span><b>{compact(value)}</b></div>)}</div>
|
||||||
|
</>
|
||||||
|
) : <p>{t("暂时没有上下文数据")}</p>}
|
||||||
|
<section>
|
||||||
|
<div><b>MCP</b><small>{ready.length} / {active.length} {t("个已连接")} · {tools} {t("个工具")}</small></div>
|
||||||
|
{active.slice(0, 4).map((server) => <div className="chrome-mcp-row" key={server.name}><i data-ready={server.state === "ready" ? "" : undefined} /><span>{server.name}</span><small>{server.state === "ready" ? `${server.tools} ${t("个工具")}` : t("等待连接")}</small></div>)}
|
||||||
|
{!active.length && <p>{t("尚未启用 MCP 服务")}</p>}
|
||||||
|
<button data-action="settings.section" data-value="ext" onClick={onManage}>{t("管理 MCP 与工具")}</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { StudioIcon } from "./StudioIcon";
|
||||||
|
|
||||||
// Wails serves the window over a custom scheme on macOS and Linux, so those two
|
// Wails serves the window over a custom scheme on macOS and Linux, so those two
|
||||||
// hosts are not a secure context and navigator.clipboard is undefined there.
|
// hosts are not a secure context and navigator.clipboard is undefined there.
|
||||||
@@ -17,7 +18,7 @@ async function write(text: string) {
|
|||||||
if (!ok) throw new Error("copy rejected");
|
if (!ok) throw new Error("copy rejected");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CopyButton({ text }: { text: string }) {
|
export function CopyButton({ text, iconOnly = false }: { text: string; iconOnly?: boolean }) {
|
||||||
const [state, setState] = useState<"idle" | "done" | "failed">("idle");
|
const [state, setState] = useState<"idle" | "done" | "failed">("idle");
|
||||||
const timer = useRef<number | null>(null);
|
const timer = useRef<number | null>(null);
|
||||||
|
|
||||||
@@ -33,11 +34,20 @@ export function CopyButton({ text }: { text: string }) {
|
|||||||
|
|
||||||
// A clipboard the host denied is not the same as nothing happening, and the
|
// A clipboard the host denied is not the same as nothing happening, and the
|
||||||
// reader is about to try again — so the failure says so instead of staying idle.
|
// reader is about to try again — so the failure says so instead of staying idle.
|
||||||
const label = state === "done" ? t("已复制") : state === "failed" ? t("复制不了") : t("复制");
|
const label = state === "done" ? t("已复制") : state === "failed" ? t("复制不了") : t("复制回复");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className="copy" type="button" data-state={state} onClick={copy} aria-label={t("复制这段回答")}>
|
<button
|
||||||
<span aria-live="polite">{label}</span>
|
className="copy"
|
||||||
|
type="button"
|
||||||
|
data-icon={iconOnly ? "" : undefined}
|
||||||
|
data-state={state}
|
||||||
|
onClick={copy}
|
||||||
|
aria-label={t("复制这段回答")}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{iconOnly && <StudioIcon name={state === "done" ? "check" : "copy"} />}
|
||||||
|
<span className={iconOnly ? "sr-only" : undefined} aria-live="polite">{label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,7 @@ export interface MenuItem {
|
|||||||
// is not a button — a menu whose headings take focus is a menu you arrow
|
// is not a button — a menu whose headings take focus is a menu you arrow
|
||||||
// through twice.
|
// through twice.
|
||||||
header?: boolean;
|
header?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Where a list stops fitting the menu's own height cap. Past it a scrollbar
|
// Where a list stops fitting the menu's own height cap. Past it a scrollbar
|
||||||
@@ -36,13 +37,16 @@ interface Props {
|
|||||||
// trigger alone: whatever else is on that shelf is still the reader's to
|
// trigger alone: whatever else is on that shelf is still the reader's to
|
||||||
// change while this one waits.
|
// change while this one waits.
|
||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
|
triggerAction?: string;
|
||||||
|
ariaPressed?: boolean;
|
||||||
|
wrapClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// data-* rides through to the item that raises the pick, the way Switch and Seg
|
// data-* rides through to the item that raises the pick, the way Switch and Seg
|
||||||
// carry theirs: the action's identity is written at the call site, and the
|
// carry theirs: the action's identity is written at the call site, and the
|
||||||
// answer this menu gives is the item's own value.
|
// answer this menu gives is the item's own value.
|
||||||
export function Picker({
|
export function Picker({
|
||||||
label, items, current, onPick, place, align = "start", className, title, pending, ...id
|
label, items, current, onPick, place, align = "start", className, title, pending, triggerAction, ariaPressed, wrapClassName, ...id
|
||||||
}: Props & { [K in `data-${string}`]?: string }) {
|
}: Props & { [K in `data-${string}`]?: string }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -161,12 +165,14 @@ export function Picker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="picker" ref={wrap}>
|
<div className={["picker", wrapClassName].filter(Boolean).join(" ")} ref={wrap}>
|
||||||
<button
|
<button
|
||||||
ref={btn}
|
ref={btn}
|
||||||
className={className}
|
className={className}
|
||||||
|
data-action={triggerAction}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
aria-pressed={ariaPressed}
|
||||||
data-pending={pending ? "" : undefined}
|
data-pending={pending ? "" : undefined}
|
||||||
title={title}
|
title={title}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
@@ -211,6 +217,7 @@ export function Picker({
|
|||||||
data-value={it.value}
|
data-value={it.value}
|
||||||
className={it.plain ? "mi plain" : "mi"}
|
className={it.plain ? "mi plain" : "mi"}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
disabled={it.disabled}
|
||||||
data-on={it.value === current ? "" : undefined}
|
data-on={it.value === current ? "" : undefined}
|
||||||
data-lead={it.value === lead ? "" : undefined}
|
data-lead={it.value === lead ? "" : undefined}
|
||||||
onClick={() => take(it.value)}
|
onClick={() => take(it.value)}
|
||||||
|
|||||||
@@ -124,8 +124,24 @@ export function Onboarding({ port, setup, onDone }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="onb">
|
<div className="onb-stage">
|
||||||
<div className="onb-card">
|
<header className="onb-brandbar">
|
||||||
|
<span className="onb-brandmark" aria-hidden="true">R</span>
|
||||||
|
<span className="onb-brandname"><b>reasonix</b><small>studio</small></span>
|
||||||
|
</header>
|
||||||
|
<div className="onb-shell">
|
||||||
|
<aside className="onb-progress" aria-label={t("开始设置")}>
|
||||||
|
<span className="onb-progress-kicker">{t("开始设置")}</span>
|
||||||
|
<ol>
|
||||||
|
<li data-active=""><i>1</i><span><b>{t("连接模型服务")}</b><small>{t("地址、协议与凭据")}</small></span></li>
|
||||||
|
<li data-active={found ? "" : undefined}><i>2</i><span><b>{t("选择默认模型")}</b><small>{t("确认模型与能力")}</small></span></li>
|
||||||
|
<li><i>3</i><span><b>{t("打开工作区")}</b><small>{t("开始第一个任务")}</small></span></li>
|
||||||
|
</ol>
|
||||||
|
<p>{t("之后可以在设置中随时修改,不会锁定当前选择。")}</p>
|
||||||
|
</aside>
|
||||||
|
<main className="onb">
|
||||||
|
<div className="onb-card">
|
||||||
|
<span className="onb-eyebrow">MODEL CONNECTION</span>
|
||||||
<h1 className="onb-t">{t("首先连接模型服务")}</h1>
|
<h1 className="onb-t">{t("首先连接模型服务")}</h1>
|
||||||
<p className="onb-s">
|
<p className="onb-s">
|
||||||
{t("填写地址与 key,其余由系统探测 —— 协议、模型清单、是否支持读图均可自动获取。")}
|
{t("填写地址与 key,其余由系统探测 —— 协议、模型清单、是否支持读图均可自动获取。")}
|
||||||
@@ -242,7 +258,10 @@ export function Onboarding({ port, setup, onDone }: Props) {
|
|||||||
<div className="onb-note">
|
<div className="onb-note">
|
||||||
{t("key 保存在本机配置中,不会上传至任何第三方。模型、推理强度与执行设定均有默认值,可随时在输入框上方调整。")}
|
{t("key 保存在本机配置中,不会上传至任何第三方。模型、推理强度与执行设定均有默认值,可随时在输入框上方调整。")}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<footer className="onb-foot">{t("本地优先 · 凭据由系统安全存储 · 连接前不会发送请求")}</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react";
|
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
|
||||||
import { money } from "../i18n/format";
|
import { money } from "../i18n/format";
|
||||||
import { reason } from "../i18n/kernel";
|
import { reason } from "../i18n/kernel";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -28,6 +28,9 @@ import { ABSENT, accountOf, type Wallet } from "./wallet";
|
|||||||
import { swapping } from "./swap";
|
import { swapping } from "./swap";
|
||||||
import { PaneNav, type PaneView } from "./PaneNav";
|
import { PaneNav, type PaneView } from "./PaneNav";
|
||||||
import { useRate } from "./num";
|
import { useRate } from "./num";
|
||||||
|
import { StudioIcon } from "./StudioIcon";
|
||||||
|
import { useDismiss } from "./dismiss";
|
||||||
|
import { ContextSummaryCard } from "./ContextSummaryCard";
|
||||||
|
|
||||||
// PaneReport is what the window's own chrome needs from whichever pane has
|
// PaneReport is what the window's own chrome needs from whichever pane has
|
||||||
// focus: everything else about a session stays inside the pane that owns it.
|
// focus: everything else about a session stays inside the pane that owns it.
|
||||||
@@ -40,6 +43,10 @@ export interface PaneReport {
|
|||||||
// this: a reopened history sits at halt too, and closing that costs nothing.
|
// this: a reopened history sits at halt too, and closing that costs nothing.
|
||||||
live: boolean;
|
live: boolean;
|
||||||
cost: string;
|
cost: string;
|
||||||
|
contextPercent: number | null;
|
||||||
|
context: ContextBreakdown | null;
|
||||||
|
mcp: McpEntry[];
|
||||||
|
wallet: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A shared constant, not `?? []`: a fresh empty array every render reads as a
|
// A shared constant, not `?? []`: a fresh empty array every render reads as a
|
||||||
@@ -67,7 +74,7 @@ interface Props {
|
|||||||
// its session. /status is polled only while a turn runs, so without this the
|
// its session. /status is polled only while a turn runs, so without this the
|
||||||
// pane keeps reporting the posture it had when it opened.
|
// pane keeps reporting the posture it had when it opened.
|
||||||
pulse: number;
|
pulse: number;
|
||||||
onSettings: () => void;
|
onSettings: (section?: string) => void;
|
||||||
// 这个窗口还没有人选过的项目文件夹。空转录是唯一说得出这句话的地方 —— 那里
|
// 这个窗口还没有人选过的项目文件夹。空转录是唯一说得出这句话的地方 —— 那里
|
||||||
// 本来就在替一段还没开始的对话说明它该怎么开始。
|
// 本来就在替一段还没开始的对话说明它该怎么开始。
|
||||||
needsProject: boolean;
|
needsProject: boolean;
|
||||||
@@ -99,6 +106,10 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
|||||||
const [checkpoints, setCheckpoints] = useState<Checkpoint[]>([]);
|
const [checkpoints, setCheckpoints] = useState<Checkpoint[]>([]);
|
||||||
const [queue, setQueue] = useState<QueueSnapshot | null>(null);
|
const [queue, setQueue] = useState<QueueSnapshot | null>(null);
|
||||||
const [slots, setSlots] = useState<Record<string, string>>({});
|
const [slots, setSlots] = useState<Record<string, string>>({});
|
||||||
|
const [meterOpen, setMeterOpen] = useState(false);
|
||||||
|
const meterRef = useRef<HTMLDivElement>(null);
|
||||||
|
const closeMeter = useCallback(() => setMeterOpen(false), []);
|
||||||
|
useDismiss(meterOpen, meterRef, closeMeter);
|
||||||
const flow = useRef<HTMLDivElement>(null);
|
const flow = useRef<HTMLDivElement>(null);
|
||||||
const startedAt = useRef(0);
|
const startedAt = useRef(0);
|
||||||
// Elapsed is a clock reading and belongs on the tick. Throughput is not: it
|
// Elapsed is a clock reading and belongs on the tick. Throughput is not: it
|
||||||
@@ -594,12 +605,27 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
|||||||
const blocked = hasPendingDecision(status);
|
const blocked = hasPendingDecision(status);
|
||||||
const run = runState({ blocked, running: s.running, hasItems: s.items.length > 0, terminal: s.terminal });
|
const run = runState({ blocked, running: s.running, hasItems: s.items.length > 0, terminal: s.terminal });
|
||||||
const cost = money(s.metrics.cost, s.metrics.currency);
|
const cost = money(s.metrics.cost, s.metrics.currency);
|
||||||
|
const cacheTokens = s.metrics.hit + s.metrics.miss;
|
||||||
|
const cacheRate = cacheTokens > 0 ? Math.round((s.metrics.hit / cacheTokens) * 100) : null;
|
||||||
|
const contextPercent = ctx && ctx.window > 0 ? Math.min(100, Math.round((ctx.used / ctx.window) * 100)) : null;
|
||||||
|
const walletDisplay = wallet.kind === "read" ? wallet.reading.display : "";
|
||||||
|
|
||||||
// The chrome reads the focused pane. Reporting from an effect keeps it out of
|
// The chrome reads the focused pane. Reporting from an effect keeps it out of
|
||||||
// render, where it would set state on the parent mid-paint.
|
// render, where it would set state on the parent mid-paint.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onReport(rt.id, { status, title, steer: counts.steer, run, live: s.running || blocked, cost });
|
onReport(rt.id, {
|
||||||
}, [rt.id, onReport, status, title, counts.steer, run, s.running, blocked, cost]);
|
status,
|
||||||
|
title,
|
||||||
|
steer: counts.steer,
|
||||||
|
run,
|
||||||
|
live: s.running || blocked,
|
||||||
|
cost,
|
||||||
|
contextPercent,
|
||||||
|
context: ctx,
|
||||||
|
mcp,
|
||||||
|
wallet: walletDisplay,
|
||||||
|
});
|
||||||
|
}, [rt.id, onReport, status, title, counts.steer, run, s.running, blocked, cost, contextPercent, ctx, mcp, walletDisplay]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -733,7 +759,32 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Composer port={port} status={status} running={s.running} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={fail} />
|
<Composer port={port} status={status} running={s.running} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={fail} onSettings={onSettings} />
|
||||||
|
<div className="studio-meterrail" ref={meterRef} aria-label={t("运行统计")}>
|
||||||
|
<span className="studio-meter-static studio-meter-speed" title={t(s.running ? "生成中" : "本轮均速")}>
|
||||||
|
<StudioIcon name="gauge" /><b>{tps > 0 ? tps.toFixed(1) : "—"}</b><span>tok/s</span><i data-live={s.running ? "" : undefined} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="studio-meter-static studio-meter-cache" title={cacheRate === null ? t("尚无缓存数据") : t("命中 {hit} · 未命中 {miss}", { hit: s.metrics.hit.toLocaleString(), miss: s.metrics.miss.toLocaleString() })}>
|
||||||
|
<span>{t("缓存")}</span><b>{cacheRate === null ? "—" : `${cacheRate}%`}</b>
|
||||||
|
</span>
|
||||||
|
{ctx && ctx.window > 0 && (
|
||||||
|
<div className="studio-context-anchor" data-open={meterOpen ? "" : undefined}>
|
||||||
|
<button className="studio-meter-context" data-action="metrics.details" data-value="context" aria-expanded={meterOpen} aria-haspopup="dialog" aria-label={t("查看上下文与压缩")} onClick={() => setMeterOpen((open) => !open)}>
|
||||||
|
<span className="studio-context-ring" style={{ "--fill": `${Math.min(100, Math.round((ctx.used / ctx.window) * 100))}%` } as CSSProperties} aria-hidden="true" />
|
||||||
|
<span>{t("上下文")}</span><b>{Math.min(100, Math.round((ctx.used / ctx.window) * 100))}%</b>
|
||||||
|
</button>
|
||||||
|
<ContextSummaryCard
|
||||||
|
className="studio-context-summary"
|
||||||
|
context={ctx}
|
||||||
|
mcp={mcp}
|
||||||
|
percent={Math.min(100, Math.round((ctx.used / ctx.window) * 100))}
|
||||||
|
onManage={() => { closeMeter(); onSettings("ext"); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cost && <span className="studio-meter-cost"><span>{t("本轮")}</span><b>{cost}</b></span>}
|
||||||
|
{wallet.kind === "read" && <button className="studio-meter-wallet" data-action="settings.section" data-value="usage" aria-label={t("查看钱包余额")} onClick={() => onSettings("usage")}><StudioIcon name="wallet" /><b>{wallet.reading.display}</b></button>}
|
||||||
|
</div>
|
||||||
{/* Below the box, under a ceiling of their own. Both arrive unbidden and
|
{/* Below the box, under a ceiling of their own. Both arrive unbidden and
|
||||||
both are dismissed one at a time, so nothing else bounds how many can
|
both are dismissed one at a time, so nothing else bounds how many can
|
||||||
be on screen at once. */}
|
be on screen at once. */}
|
||||||
|
|||||||
@@ -1,116 +1,57 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import "./testkit";
|
import "./testkit";
|
||||||
import { Policy } from "./Policy";
|
import { Policy } from "./Policy";
|
||||||
import { MockPort } from "../port/mock";
|
import { MockPort } from "../port/mock";
|
||||||
import type { AgentPort, ApprovalMode, Preset, SessionStatus } from "../port/port";
|
import type { AgentPort, ApprovalMode, SessionStatus } from "../port/port";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const status = (preset: Preset, effort: string | undefined, mode: ApprovalMode) =>
|
const status = (mode: ApprovalMode = "ask") =>
|
||||||
({ preset, effort, toolApprovalMode: mode } as SessionStatus);
|
({ preset: "balanced", effort: "auto", toolApprovalMode: mode } as SessionStatus);
|
||||||
|
|
||||||
function draw(over: {
|
function draw(mode: ApprovalMode = "ask", onBoundary?: () => void) {
|
||||||
status?: SessionStatus | null;
|
|
||||||
efforts?: string[];
|
|
||||||
port?: Partial<AgentPort>;
|
|
||||||
} = {}) {
|
|
||||||
const port = { ...(new MockPort() as unknown as AgentPort), ...over.port };
|
|
||||||
const onChanged = vi.fn();
|
const onChanged = vi.fn();
|
||||||
const r = render(
|
const port = new MockPort() as unknown as AgentPort;
|
||||||
<Policy
|
const view = render(<Policy port={port} status={status(mode)} onChanged={onChanged} onBoundary={onBoundary} />);
|
||||||
port={port}
|
return { ...view, port, onChanged };
|
||||||
status={over.status === undefined ? status("balanced", "auto", "ask") : over.status}
|
|
||||||
efforts={over.efforts ?? ["auto", "low", "medium", "high"]}
|
|
||||||
onChanged={onChanged}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
// The trigger by its own element: getByRole would move to the menu's
|
|
||||||
// buttons the moment it is open, which is exactly when this is asked.
|
|
||||||
return { ...r, port, onChanged, shelf: () => r.container.querySelector(".policy .vl")?.textContent };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("what the closed control says", () => {
|
describe("execution permission control", () => {
|
||||||
it("keeps a quiet way into policy for a baseline session", () => {
|
it("names the committed permission directly", () => {
|
||||||
const { container } = draw();
|
const { container } = draw();
|
||||||
expect(container.querySelector(".policy[data-quiet]")).toBeTruthy();
|
expect(container.querySelector('[data-action="chrome.policy"]')?.getAttribute("aria-label")).toBe("执行权限:逐项确认");
|
||||||
expect(container.querySelector(".policy > button")?.textContent).toBe("执行");
|
expect(container.querySelector(".policy")?.hasAttribute("data-quiet")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("carries the whole reading on hover, so nothing is actually lost", () => {
|
it("keeps the dangerous state visible on the closed trigger", () => {
|
||||||
const { container } = draw({ status: status("delivery", "auto", "ask") });
|
const { container } = draw("yolo");
|
||||||
expect(container.querySelector("button")?.getAttribute("title")).toBe("交付 · auto · 询问");
|
expect(container.querySelector(".polrisk")?.textContent).toContain("全部放行");
|
||||||
|
expect(container.querySelector(".polwarn")?.textContent).toBe("⚠");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("draws nothing at all before the kernel has answered", () => {
|
it("contains permission choices only", async () => {
|
||||||
const { container } = draw({ status: null });
|
draw();
|
||||||
expect(container.querySelector(".policy")).toBeNull();
|
await userEvent.click(screen.getByRole("button", { name: "执行权限:逐项确认" }));
|
||||||
expect(container.textContent).toBe("");
|
const group = screen.getByRole("group", { name: "执行权限" });
|
||||||
|
expect(group.textContent).toMatch(/逐项确认.*不询问.*自动继续.*全部放行/s);
|
||||||
|
expect(group.textContent).not.toMatch(/高级执行设置|均衡|交付|思考强度/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tones a fully-permitted session and marks it, without repainting the control", () => {
|
it("opens the sandbox boundary from the same layer", async () => {
|
||||||
const { container } = draw({ status: status("balanced", "auto", "yolo") });
|
const onBoundary = vi.fn();
|
||||||
const risk = container.querySelector(".vl .polrisk");
|
draw("ask", onBoundary);
|
||||||
expect(risk?.textContent).toContain("全部放行");
|
await userEvent.click(screen.getByRole("button", { name: "执行权限:逐项确认" }));
|
||||||
expect(container.querySelector(".vl .polwarn")?.textContent).toBe("⚠");
|
await userEvent.click(screen.getByRole("button", { name: /沙盒与运行边界/ }));
|
||||||
// The button itself stays quiet; only the segment that must be remembered
|
expect(onBoundary).toHaveBeenCalledTimes(1);
|
||||||
// is toned, or every ordinary turn is watched too.
|
|
||||||
expect(container.querySelector("button")?.className).not.toMatch(/risk|danger/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves a stricter-than-default posture visible but untoned", () => {
|
it("draws nothing before the kernel has answered", () => {
|
||||||
const { container } = draw({ status: status("balanced", "auto", "dontAsk") });
|
const { container } = render(
|
||||||
expect(container.querySelector(".vl")?.textContent).toBe("不询问");
|
<Policy port={new MockPort() as unknown as AgentPort} status={null} onChanged={() => {}} />,
|
||||||
expect(container.querySelector(".vl .polrisk")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the shelf reads committed fact, never the answer being waited on", () => {
|
|
||||||
// Sabotage C. A summary that moves on click says the session is running under
|
|
||||||
// something the kernel has not accepted — and may refuse.
|
|
||||||
it("does not adopt a rung the kernel has not answered for", async () => {
|
|
||||||
let release!: () => void;
|
|
||||||
const setEffort = vi.fn(() => new Promise<void>((ok) => { release = ok; }));
|
|
||||||
const { shelf } = draw({ status: status("delivery", "auto", "ask"), port: { setEffort } });
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "交付" }));
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "high" }));
|
|
||||||
expect(setEffort).toHaveBeenCalledWith("high");
|
|
||||||
// Asked for, not yet true.
|
|
||||||
expect(shelf()).toBe("交付");
|
|
||||||
release();
|
|
||||||
await waitFor(() => expect(shelf()).toBe("交付"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moves only once the kernel's own reading says so", async () => {
|
|
||||||
const { rerender, shelf } = draw({ status: status("delivery", "auto", "ask") });
|
|
||||||
expect(shelf()).toBe("交付");
|
|
||||||
rerender(
|
|
||||||
<Policy port={new MockPort() as unknown as AgentPort} status={status("delivery", "high", "ask")}
|
|
||||||
efforts={["auto", "high"]} onChanged={() => {}} />,
|
|
||||||
);
|
);
|
||||||
expect(shelf()).toBe("交付 · High");
|
expect(container.querySelector(".policy")).toBeNull();
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the summary alone when the kernel refuses", async () => {
|
|
||||||
const setApprovalMode = vi.fn(() => Promise.reject(new Error("not while a turn is running")));
|
|
||||||
const { shelf, container } = draw({ status: status("delivery", "auto", "ask"), port: { setApprovalMode } });
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "交付" }));
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "全部放行" }));
|
|
||||||
await waitFor(() => expect(container.querySelector(".segbad")).toBeTruthy());
|
|
||||||
expect(shelf()).toBe("交付");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a model that publishes no rungs", () => {
|
|
||||||
// That the open menu draws no ladder is policy.interaction's; what is this
|
|
||||||
// file's is the other half — a stale rung may not leak onto the shelf, while
|
|
||||||
// preset and permissions remain reachable.
|
|
||||||
it("claims no stale rung but keeps the other policy fields reachable", () => {
|
|
||||||
const { container } = draw({ status: status("balanced", "high", "ask"), efforts: [] });
|
|
||||||
expect(container.querySelector(".policy[data-quiet]")).toBeTruthy();
|
|
||||||
expect(container.querySelector(".policy .vl")).toBeNull();
|
|
||||||
expect(container.querySelector(".policy > button")?.getAttribute("title")).toBe("均衡 · 询问");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,154 +1,96 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { reason } from "../i18n/kernel";
|
import { reason } from "../i18n/kernel";
|
||||||
import type { AgentPort, ApprovalMode, Preset, SessionStatus } from "../port/port";
|
import type { AgentPort, ApprovalMode, SessionStatus } from "../port/port";
|
||||||
import { useDismiss } from "./dismiss";
|
import { useDismiss } from "./dismiss";
|
||||||
import { Seg } from "./Seg";
|
|
||||||
import { deviates, policySummary } from "./policyview";
|
|
||||||
|
|
||||||
// 这一轮怎么跑,是三个变量共同回答的一个问题。它们原来分处窗口顶栏(执行方式)
|
// Permission is one boundary, not a catch-all for every way a turn can run.
|
||||||
// 和输入框底栏(强度、批准),要知道下一轮会怎么执行得看两个地方。
|
// Work strategy and model effort live in their own controls beside this one.
|
||||||
const PRESETS: [Preset, string, string][] = [
|
|
||||||
["balanced", "均衡", "以模型判定任务完成为准,适用于日常任务"],
|
|
||||||
["delivery", "交付", "任何修改均需经过验证、复核与签收,缺一不可视为完成"],
|
|
||||||
];
|
|
||||||
|
|
||||||
// 从紧到松排,和闸门环的缺口一个方向。「不打扰」在内核是 permission.Deny:
|
|
||||||
// 它比「询问」更严,不是更松 —— 排在询问前面才不会被读反。
|
|
||||||
const APPROVALS: [ApprovalMode, string, string][] = [
|
const APPROVALS: [ApprovalMode, string, string][] = [
|
||||||
|
["ask", "逐项确认", "每次执行操作前请求确认。"],
|
||||||
["dontAsk", "不询问", "不显示审批请求;需要批准的操作一律不执行。"],
|
["dontAsk", "不询问", "不显示审批请求;需要批准的操作一律不执行。"],
|
||||||
["ask", "询问", "每次执行操作前请求确认。"],
|
["auto", "自动继续", "低风险操作自动放行,写入操作仍需确认。"],
|
||||||
["auto", "自动", "低风险操作自动放行,写入操作仍需确认。"],
|
|
||||||
["yolo", "全部放行", "不再请求确认,仅在完全信任当前工作区时使用。"],
|
["yolo", "全部放行", "不再请求确认,仅在完全信任当前工作区时使用。"],
|
||||||
];
|
];
|
||||||
|
|
||||||
type Field = "preset" | "effort" | "approval";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
port: AgentPort;
|
port: AgentPort;
|
||||||
status: SessionStatus | null;
|
status: SessionStatus | null;
|
||||||
// The rungs this model actually has, decided upstream from the kernel's own
|
|
||||||
// list. An empty ladder means the endpoint names none, and the field is not
|
|
||||||
// drawn rather than drawn dead.
|
|
||||||
efforts: string[];
|
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
|
onBoundary?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 内核是三个独立的 effect,界面把它们画在一起不该把它们并成一个调用 —— 那会让
|
const approvalName = (mode: ApprovalMode) =>
|
||||||
// 「强度没换成」连坐掉已经换好的执行方式。
|
t(APPROVALS.find(([id]) => id === mode)?.[1] ?? "逐项确认");
|
||||||
export function Policy({ port, status, efforts, onChanged }: Props) {
|
|
||||||
|
export function Policy({ port, status, onChanged, onBoundary }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [asking, setAsking] = useState("");
|
||||||
|
const [refused, setRefused] = useState("");
|
||||||
const wrap = useRef<HTMLDivElement>(null);
|
const wrap = useRef<HTMLDivElement>(null);
|
||||||
const btn = useRef<HTMLButtonElement>(null);
|
|
||||||
useDismiss(open, wrap, () => setOpen(false));
|
useDismiss(open, wrap, () => setOpen(false));
|
||||||
|
|
||||||
// 每个字段各自记「问出去的是哪一档」和「上一次为什么被拒」。合成一个就回到了
|
|
||||||
// 整排一起变灰:一个字段在等,另外两个仍然能改。
|
|
||||||
const [asking, setAsking] = useState<Partial<Record<Field, string>>>({});
|
|
||||||
const [refused, setRefused] = useState<Partial<Record<Field, string>>>({});
|
|
||||||
|
|
||||||
const apply = (field: Field, value: string, call: () => Promise<void>) => {
|
|
||||||
if (asking[field]) return;
|
|
||||||
setAsking((a) => ({ ...a, [field]: value }));
|
|
||||||
setRefused((r) => ({ ...r, [field]: "" }));
|
|
||||||
call()
|
|
||||||
.then(onChanged)
|
|
||||||
.catch((e: unknown) => setRefused((r) => ({ ...r, [field]: reason(e) })))
|
|
||||||
.finally(() => setAsking((a) => ({ ...a, [field]: "" })));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!status) return null;
|
if (!status) return null;
|
||||||
|
|
||||||
const preset = status?.preset;
|
const apv = status.toolApprovalMode ?? "ask";
|
||||||
const eff = status?.effort || "auto";
|
const label = approvalName(apv);
|
||||||
const apv = status?.toolApprovalMode ?? "ask";
|
const apply = (value: ApprovalMode) => {
|
||||||
// The shelf reads committed fact, never the answer being waited on: a field
|
if (asking) return;
|
||||||
// still in flight shows its pending state inside the open menu, and a summary
|
setAsking(value);
|
||||||
// that moved first would say the session is running under something the
|
setRefused("");
|
||||||
// kernel has not accepted — and may refuse.
|
port.setApprovalMode(value)
|
||||||
const sum = policySummary(status, efforts.length > 0);
|
.then(onChanged)
|
||||||
const quiet = !deviates(sum);
|
.catch((e: unknown) => setRefused(reason(e)))
|
||||||
|
.finally(() => setAsking(""));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="picker policy" ref={wrap} data-quiet={quiet ? "" : undefined}>
|
<div className="picker policy" ref={wrap} data-quiet={apv === "ask" ? "" : undefined}>
|
||||||
<button
|
<button
|
||||||
ref={btn}
|
|
||||||
className="mode plain"
|
className="mode plain"
|
||||||
data-action="chrome.policy"
|
data-action="chrome.policy"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-label={quiet ? t("本轮执行策略") : undefined}
|
aria-label={`${t("执行权限")}:${label}`}
|
||||||
title={sum.ready ? sum.reading : ""}
|
title={`${t("执行权限")}:${label}`}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((value) => !value)}
|
||||||
>
|
>
|
||||||
{quiet ? (
|
<span className="ic" aria-hidden="true">
|
||||||
<>
|
<svg viewBox="0 0 16 16"><path d="M8 2.2 13 4v3.5c0 3-1.8 5.1-5 6.3-3.2-1.2-5-3.3-5-6.3V4l5-1.8Z" /></svg>
|
||||||
<span className="ic" aria-hidden="true">
|
</span>
|
||||||
<svg viewBox="0 0 16 16">
|
<span className={apv === "yolo" ? "lb polrisk" : "lb"}>
|
||||||
<path d="M2.5 4.2h4M9.5 4.2h4M6.5 2.7v3M2.5 11.8h7M12.5 11.8h1M10.5 10.3v3" />
|
{apv === "yolo" && <i className="polwarn" aria-hidden>⚠</i>}
|
||||||
</svg>
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span className="lb">{t("执行")}</span>
|
<span className="studio-control-chevron" aria-hidden="true">⌄</span>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="vl">
|
|
||||||
{sum.ready && (
|
|
||||||
<>
|
|
||||||
{[sum.preset, sum.effort].filter(Boolean).join(" · ")}
|
|
||||||
{sum.approval && (
|
|
||||||
<>
|
|
||||||
{/* Only between two things. With preset and effort both at
|
|
||||||
baseline the approval is the whole reading. */}
|
|
||||||
{(sum.preset || sum.effort) && " · "}
|
|
||||||
{/* The one state allowed to break the quiet. Nothing else on
|
|
||||||
this shelf can be forgotten and still cost a workspace. */}
|
|
||||||
{/* Scoped names: a bare .risk in this stylesheet is already an MCP
|
|
||||||
risk row, and inheriting its display:flex and margin doubled
|
|
||||||
this pill's height in exactly the dangerous state. */}
|
|
||||||
<span className={sum.danger ? "polrisk" : undefined}>
|
|
||||||
{sum.danger && <i className="polwarn" aria-hidden>⚠</i>}
|
|
||||||
{sum.approval}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<div className="menu modemenu polmenu" role="group" aria-label={t("本轮执行策略")} hidden={!open}>
|
<div className="menu modemenu polmenu" role="group" aria-label={t("执行权限")} hidden={!open}>
|
||||||
<Seg
|
<div className="studio-policy-head"><b>{t("执行权限")}</b><small>{t("当前任务")}</small></div>
|
||||||
label={t("执行方式")}
|
<div className="studio-policy-primary">
|
||||||
data-action="chrome.preset"
|
{APPROVALS.map(([value, name, note], index) => (
|
||||||
text
|
<div key={value}>
|
||||||
options={PRESETS.map(([value, lb, note]) => ({ value, label: t(lb), note: t(note) }))}
|
{index === 3 && <div className="studio-policy-risk">{t("高风险")}</div>}
|
||||||
current={preset}
|
<button
|
||||||
asking={asking.preset}
|
className={`studio-permission-option${value === "yolo" ? " studio-permission-yolo" : ""}`}
|
||||||
refused={refused.preset}
|
data-action="tool-approval.mode"
|
||||||
onClick={(v) => apply("preset", v, () => port.setPreset(v as Preset))}
|
data-value={value}
|
||||||
/>
|
data-on={apv === value ? "" : undefined}
|
||||||
{/* An endpoint that names no levels gets no ladder: a control whose
|
data-pending={asking === value ? "" : undefined}
|
||||||
every rung is refused downstream reads as a dead one. */}
|
aria-pressed={apv === value}
|
||||||
{efforts.length > 0 && (
|
disabled={!!asking}
|
||||||
<Seg
|
onClick={() => apply(value)}
|
||||||
label={t("思考强度")}
|
>
|
||||||
data-action="reasoning.effort"
|
<span><b>{t(name)}</b><small>{t(note)}</small></span>
|
||||||
options={efforts.map((value) => ({ value, label: value }))}
|
{apv === value && <span className="studio-permission-check" aria-hidden="true">✓</span>}
|
||||||
current={eff}
|
</button>
|
||||||
asking={asking.effort}
|
</div>
|
||||||
refused={refused.effort}
|
))}
|
||||||
onClick={(v) => apply("effort", v, () => port.setEffort(v))}
|
{refused && <div className="note bad studio-policy-error" role="alert">{refused}</div>}
|
||||||
/>
|
</div>
|
||||||
)}
|
<div className="studio-policy-note">{t("审批决定何时询问;沙盒限定实际访问范围。")}</div>
|
||||||
<Seg
|
<button className="studio-policy-boundary" data-action="settings.section" data-value="tools:sandbox" onClick={onBoundary} disabled={!onBoundary}>
|
||||||
label={t("工具权限")}
|
<span><b>{t("沙盒与运行边界")}</b><small>{t("在设置中查看当前隔离与网络权限")}</small></span>
|
||||||
data-action="tool-approval.mode"
|
<span aria-hidden="true">›</span>
|
||||||
text
|
</button>
|
||||||
options={APPROVALS.map(([value, lb, note]) => ({ value, label: t(lb), note: t(note) }))}
|
|
||||||
current={apv}
|
|
||||||
asking={asking.approval}
|
|
||||||
refused={refused.approval}
|
|
||||||
onClick={(v) => apply("approval", v, () => port.setApprovalMode(v as ApprovalMode))}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
// 一段带标签的互斥选择:树里已有的 .seg 槽,外面套上字段名和「选中这档意味着
|
|
||||||
// 什么」那一行,再加上它自己的等待与拒绝。
|
|
||||||
//
|
|
||||||
// 和 Switch 一样是个基元:它自己不是入口,动作的身份写在把 onClick 交给它的那
|
|
||||||
// 个调用点上,data-* 原样透传到每一颗按钮,答案落在 data-value 上。
|
|
||||||
export interface SegOption {
|
|
||||||
value: string;
|
|
||||||
// Already in the reader's language: the call site owns the wording, because
|
|
||||||
// that is the only place a catalogue check can see it.
|
|
||||||
label: string;
|
|
||||||
// What picking it means, where there is something to say. A rung on a ladder
|
|
||||||
// explains itself by its place on it.
|
|
||||||
note?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Seg({
|
|
||||||
label, options, current, asking, refused, text, onClick, ...id
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
options: SegOption[];
|
|
||||||
current?: string;
|
|
||||||
// The one value asked for and not yet answered. While it is out this group
|
|
||||||
// takes no second answer — a set of one-of-N buttons has no meaning for two
|
|
||||||
// in flight — and every other group beside it stays live.
|
|
||||||
asking?: string;
|
|
||||||
// Why the last answer did not land. It sits with the group it was about and
|
|
||||||
// does not lock it: picking again is the only thing to do here.
|
|
||||||
refused?: string;
|
|
||||||
// 标签是中文:mono 栈里汉字会掉到回退字体,跟旁边的界面字对不齐。
|
|
||||||
text?: boolean;
|
|
||||||
// Named for the DOM event it stands for, as Switch is. The action census
|
|
||||||
// places a primitive's call site by that name; a prop the DOM has no event
|
|
||||||
// for makes the call site invisible to it, which is why every Picker in the
|
|
||||||
// tree is uncounted today.
|
|
||||||
onClick: (value: string) => void;
|
|
||||||
} & { [K in `data-${string}`]?: string }) {
|
|
||||||
const note = options.find((o) => o.value === current)?.note;
|
|
||||||
return (
|
|
||||||
<div className="segf">
|
|
||||||
<div className="seglb">{label}</div>
|
|
||||||
<div className="seg" data-text={text ? "" : undefined} role="group" aria-label={label}>
|
|
||||||
{options.map((o) => (
|
|
||||||
<button
|
|
||||||
key={o.value}
|
|
||||||
{...id}
|
|
||||||
data-value={o.value}
|
|
||||||
aria-pressed={current === o.value}
|
|
||||||
data-asking={asking === o.value ? "" : undefined}
|
|
||||||
disabled={!!asking}
|
|
||||||
onClick={() => onClick(o.value)}
|
|
||||||
>
|
|
||||||
{o.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{refused ? <p className="note segbad" role="alert">{refused}</p> : note ? <p className="note">{note}</p> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "rea
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { listenAction } from "./listen";
|
import { listenAction } from "./listen";
|
||||||
import { useRuntimeReload } from "./RuntimeReload";
|
import { useRuntimeReload } from "./RuntimeReload";
|
||||||
import type { AccountState, AgentPort, Appearance as Look, ApprovalMode, CapabilityScope, McpEntry, ModelEntry, PluginPackage, Preset, RoleAssignments, SessionStatus, SkillEntry } from "../port/port";
|
import type { AccountState, AgentPort, Appearance as Look, ApprovalMode, CapabilityScope, McpEntry, ModelEntry, PluginPackage, RoleAssignments, SessionStatus, SkillEntry } from "../port/port";
|
||||||
import { arrowTabs } from "./tablist";
|
import { arrowTabs } from "./tablist";
|
||||||
import { bytes, tokens as fmtTokens } from "../i18n/format";
|
import { bytes, tokens as fmtTokens } from "../i18n/format";
|
||||||
import { ICON, NAV, SECTION_NAME, SETTINGS, settingMatches } from "./prefsnav";
|
import { ICON, NAV, SECTION_NAME, SETTINGS, settingMatches } from "./prefsnav";
|
||||||
@@ -41,11 +41,6 @@ import { ScopeBar } from "./CapabilityScope";
|
|||||||
import { reason } from "../i18n/kernel";
|
import { reason } from "../i18n/kernel";
|
||||||
import { SettingsHeading } from "./SettingsHeading";
|
import { SettingsHeading } from "./SettingsHeading";
|
||||||
|
|
||||||
const PRESETS: [Preset, string, string][] = [
|
|
||||||
["balanced", "均衡", "以模型判定任务完成为准,适用于日常任务"],
|
|
||||||
["delivery", "交付", "任何修改均需经过验证、复核与签收,缺一不可视为完成"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const APPROVALS: [ApprovalMode, string, string][] = [
|
const APPROVALS: [ApprovalMode, string, string][] = [
|
||||||
["dontAsk", "不询问", "不显示审批请求;需要批准的操作一律不执行"],
|
["dontAsk", "不询问", "不显示审批请求;需要批准的操作一律不执行"],
|
||||||
["ask", "询问", "每次执行操作前请求确认"],
|
["ask", "询问", "每次执行操作前请求确认"],
|
||||||
@@ -84,18 +79,21 @@ interface Props {
|
|||||||
onContrast: (c: string) => void;
|
onContrast: (c: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
|
onSessionsRecovered?: () => void;
|
||||||
at?: string;
|
at?: string;
|
||||||
account: AccountState | null;
|
account: AccountState | null;
|
||||||
accountUnread: string;
|
accountUnread: string;
|
||||||
reloadAccount: () => void;
|
reloadAccount: () => void;
|
||||||
|
workspaceRoot?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Settings({ hub, onError, port, status, theme, onTheme, contrast, onContrast, weight, onWeight, look, onLook, onClose, onChanged, reloadThemes, at: opened, account: acct, accountUnread, reloadAccount }: Props) {
|
export function Settings({ hub, onError, port, status, theme, onTheme, contrast, onContrast, weight, onWeight, look, onLook, onClose, onChanged, onSessionsRecovered, reloadThemes, at: opened, account: acct, accountUnread, reloadAccount, workspaceRoot }: Props) {
|
||||||
const [at, setAt] = useState<Section>((opened as Section) || "session");
|
const [openedSection, openedAnchor = ""] = (opened ?? "").split(":", 2);
|
||||||
|
const [at, setAt] = useState<Section>((openedSection as Section) || "session");
|
||||||
// 搜索的语料是这张表,不是屏幕上的 DOM:分区是按需挂载的,读 DOM 就只搜得到
|
// 搜索的语料是这张表,不是屏幕上的 DOM:分区是按需挂载的,读 DOM 就只搜得到
|
||||||
// 当前这一页,而为了搜索把每一页都挂起来会让隐藏页面开始发请求。
|
// 当前这一页,而为了搜索把每一页都挂起来会让隐藏页面开始发请求。
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [landed, setLanded] = useState("");
|
const [landed, setLanded] = useState(openedAnchor);
|
||||||
const [models, setModels] = useState<ModelEntry[]>([]);
|
const [models, setModels] = useState<ModelEntry[]>([]);
|
||||||
const [roles, setRoles] = useState<RoleAssignments | null>(null);
|
const [roles, setRoles] = useState<RoleAssignments | null>(null);
|
||||||
const [protocol, setProtocol] = useState<Record<string, string>>({});
|
const [protocol, setProtocol] = useState<Record<string, string>>({});
|
||||||
@@ -255,7 +253,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
|||||||
};
|
};
|
||||||
const efforts = models.find((m) => m.ref === status?.modelRef)?.efforts ?? [];
|
const efforts = models.find((m) => m.ref === status?.modelRef)?.efforts ?? [];
|
||||||
const assigned = roles ? Object.values(roles).filter(Boolean).length : 0;
|
const assigned = roles ? Object.values(roles).filter(Boolean).length : 0;
|
||||||
const preset = t(PRESETS.find(([id]) => id === status?.preset)?.[1] ?? "") || "—";
|
const preset = t("自动");
|
||||||
const approval = t(APPROVALS.find(([id]) => id === status?.toolApprovalMode)?.[1] ?? "—");
|
const approval = t(APPROVALS.find(([id]) => id === status?.toolApprovalMode)?.[1] ?? "—");
|
||||||
const broken = mcp.filter((m) => m.state === "failed").length;
|
const broken = mcp.filter((m) => m.state === "failed").length;
|
||||||
// A package owns what it brought, and its own row already lists it. What is
|
// A package owns what it brought, and its own row already lists it. What is
|
||||||
@@ -466,17 +464,8 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
|||||||
)}
|
)}
|
||||||
{at === "session" && (
|
{at === "session" && (
|
||||||
<>
|
<>
|
||||||
<Group id="preset" title={t("执行设定")} now={preset} hint={t("决定任务完成的判定标准。切换立即生效,不会重建运行时。")}>
|
<Group id="preset" title={t("完成标准")} now={preset} hint={t("系统根据任务风险自动决定需要的验证程度,不再要求手动选择均衡或交付。")}>
|
||||||
<div className="seg" data-text role="radiogroup" aria-label={t("执行设定")}>
|
<p className="note">{t("日常问答保持轻量;涉及文件修改、代码执行或明确交付物时自动增加验证与复核。")}</p>
|
||||||
{PRESETS.map(([id, name]) => (
|
|
||||||
<button key={id} role="radio" data-action="chrome.preset" data-value={id}
|
|
||||||
aria-checked={status?.preset === id} disabled={!!busy}
|
|
||||||
onClick={() => run(id, () => port.setPreset(id))}>
|
|
||||||
{t(name)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="note">{t(PRESETS.find(([id]) => id === status?.preset)?.[2] ?? "")}</p>
|
|
||||||
</Group>
|
</Group>
|
||||||
{/* An on/off that reverses by clicking again is a switch, not two
|
{/* An on/off that reverses by clicking again is a switch, not two
|
||||||
options — the same control the recipes and the sandbox's
|
options — the same control the recipes and the sandbox's
|
||||||
@@ -759,7 +748,7 @@ export function Settings({ hub, onError, port, status, theme, onTheme, contrast,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{at === "storage" && (
|
{at === "storage" && (
|
||||||
<Group id="storage" title={t("存储")} hint={t("数据的存储位置与占用空间。会话和索引会持续增长,配置和凭据不会,因此只有前者可以迁移。迁移在重启后生效。")}><Storage port={port} /></Group>
|
<Group id="storage" title={t("存储")} hint={t("数据的存储位置与占用空间。会话和索引会持续增长,配置和凭据不会,因此只有前者可以迁移。迁移在重启后生效。")}><Storage port={port} hub={hub} workspace={workspaceRoot ?? status?.workspaceRoot ?? ""} onRecovered={() => { onChanged(); onSessionsRecovered?.(); }} /></Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{at === "appearance" && (
|
{at === "appearance" && (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { bytes } from "../i18n/format";
|
import { bytes } from "../i18n/format";
|
||||||
import type { AgentPort } from "../port/port";
|
import type { AgentPort } from "../port/port";
|
||||||
|
import type { HubPort } from "../port/hub";
|
||||||
import type { StoragePlan, StorageRoot, StorageState } from "../port/storage";
|
import type { StoragePlan, StorageRoot, StorageState } from "../port/storage";
|
||||||
|
|
||||||
// Three questions in the order a person asks them: how much is there, where is
|
// Three questions in the order a person asks them: how much is there, where is
|
||||||
@@ -19,13 +20,16 @@ const NAMED: Record<string, [string, string]> = {
|
|||||||
locks: ["进程锁", "用于多实例互斥,必须保留在本机固定位置。每个均为空文件;删除会破坏互斥,因此只保留不清理"],
|
locks: ["进程锁", "用于多实例互斥,必须保留在本机固定位置。每个均为空文件;删除会破坏互斥,因此只保留不清理"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Storage({ port }: { port: AgentPort }) {
|
export function Storage({ port, hub, workspace, onRecovered }: { port: AgentPort; hub: HubPort; workspace: string; onRecovered: () => void }) {
|
||||||
const [state, setState] = useState<StorageState | null>(null);
|
const [state, setState] = useState<StorageState | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [picking, setPicking] = useState<string | null>(null);
|
const [picking, setPicking] = useState<string | null>(null);
|
||||||
const [target, setTarget] = useState("");
|
const [target, setTarget] = useState("");
|
||||||
const [plan, setPlan] = useState<StoragePlan | null>(null);
|
const [plan, setPlan] = useState<StoragePlan | null>(null);
|
||||||
const input = useRef<HTMLInputElement>(null);
|
const input = useRef<HTMLInputElement>(null);
|
||||||
|
const [recovering, setRecovering] = useState(false);
|
||||||
|
const [recovery, setRecovery] = useState<{ imported: number; warnings: number } | null>(null);
|
||||||
|
const [recoveryError, setRecoveryError] = useState("");
|
||||||
|
|
||||||
const read = useCallback(() => {
|
const read = useCallback(() => {
|
||||||
port
|
port
|
||||||
@@ -77,6 +81,22 @@ export function Storage({ port }: { port: AgentPort }) {
|
|||||||
[port, read],
|
[port, read],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const recoverLegacy = useCallback(async () => {
|
||||||
|
setRecoveryError("");
|
||||||
|
const source = await hub.pickFolder();
|
||||||
|
if (!source) return;
|
||||||
|
setRecovering(true);
|
||||||
|
try {
|
||||||
|
const result = await hub.importLegacySessions(source, workspace);
|
||||||
|
setRecovery({ imported: result.imported, warnings: result.warnings });
|
||||||
|
onRecovered();
|
||||||
|
} catch {
|
||||||
|
setRecoveryError(t("未能扫描这个文件夹。请确认它是旧版 Reasonix 的数据目录。"));
|
||||||
|
} finally {
|
||||||
|
setRecovering(false);
|
||||||
|
}
|
||||||
|
}, [hub, onRecovered, workspace]);
|
||||||
|
|
||||||
if (error) return <div className="empty">{error}</div>;
|
if (error) return <div className="empty">{error}</div>;
|
||||||
if (!state) return <div className="empty">{t("正在统计…")}</div>;
|
if (!state) return <div className="empty">{t("正在统计…")}</div>;
|
||||||
|
|
||||||
@@ -85,6 +105,28 @@ export function Storage({ port }: { port: AgentPort }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="storage">
|
<div className="storage">
|
||||||
|
<section className="grp">
|
||||||
|
<h3 className="lbl">{t("找回旧版会话")}</h3>
|
||||||
|
<div className="item">
|
||||||
|
<div className="l">
|
||||||
|
<b>{t("从旧版 Reasonix 导入")}</b>
|
||||||
|
<span className="hint">{t("选择旧版的数据文件夹。扫描只会复制会话,不会移动或删除原文件;找不到原工作区的会话会归入当前工作区。")}</span>
|
||||||
|
{recovery && (
|
||||||
|
<span className="ready">
|
||||||
|
{recovery.imported > 0
|
||||||
|
? t("已找回 {n} 个会话。", { n: recovery.imported })
|
||||||
|
: t("没有发现尚未导入的旧会话。")}
|
||||||
|
{recovery.warnings > 0 ? " " + t("有 {n} 项无法读取。", { n: recovery.warnings }) : null}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{recoveryError && <span className="held">{recoveryError}</span>}
|
||||||
|
</div>
|
||||||
|
<button className="btn" data-action="session.import-legacy" disabled={recovering || !workspace} onClick={() => void recoverLegacy()}>
|
||||||
|
{t(recovering ? "正在扫描…" : "选择旧版数据目录…")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="grp">
|
<section className="grp">
|
||||||
<h3 className="lbl">{t("占用")}</h3>
|
<h3 className="lbl">{t("占用")}</h3>
|
||||||
{measured.map((root) => (
|
{measured.map((root) => (
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
// Exact outline geometry used by the Reasonix Studio prototype.
|
||||||
|
const PATHS = {
|
||||||
|
brand: "M6 20V4h7a5 5 0 0 1 0 10H6m7 0 6 6",
|
||||||
|
folder: "M3 7V5h6l3 3h9v12H3V7z",
|
||||||
|
file: "M14 3H5v18h14V8l-5-5v5h5M8 12h8M8 16h5",
|
||||||
|
search: "M15 15l6 6M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0",
|
||||||
|
chevron: "m9 5 7 7-7 7",
|
||||||
|
down: "m6 9 6 6 6-6",
|
||||||
|
plus: "M12 5v14M5 12h14",
|
||||||
|
arrow: "M12 20V4m-6 6 6-6 6 6",
|
||||||
|
close: "m6 6 12 12M6 18 18 6",
|
||||||
|
check: "m5 12 4 4L19 6",
|
||||||
|
globe: "M3 12h18M12 3c-5 5-5 13 0 18 5-5 5-13 0-18M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0",
|
||||||
|
code: "m8 6-6 6 6 6m8-12 6 6-6 6M14 3l-4 18",
|
||||||
|
settings: "M4 7h16M4 17h16M9 4v6M16 14v6",
|
||||||
|
sliders: "M4 6h16M4 12h16M4 18h16M8 3v6M16 9v6M10 15v6",
|
||||||
|
more: "M5 12h.01M12 12h.01M19 12h.01",
|
||||||
|
menu: "M4 6h16M4 12h16M4 18h16",
|
||||||
|
shield: "m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6l8-3zM8 12l3 3 5-6",
|
||||||
|
branch: "M6 5a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm0 0v12m12-8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm0 0a6 6 0 0 1-6 6H6m0 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z",
|
||||||
|
layers: "m12 3 10 5-10 5L2 8l10-5zM2 12l10 5 10-5M2 16l10 5 10-5",
|
||||||
|
spark: "m12 3 2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3z",
|
||||||
|
refresh: "M20 7a9 9 0 1 0 1 8M20 3v5h-5",
|
||||||
|
clock: "M12 7v5l3 2M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0",
|
||||||
|
stop: "M6 6h12v12H6z",
|
||||||
|
play: "m8 4 12 8-12 8V4z",
|
||||||
|
pause: "M8 5v14M16 5v14",
|
||||||
|
warning: "M12 3 2 21h20L12 3zM12 9v5M12 17h.01",
|
||||||
|
copy: "M8 8h13v13H8V8zM16 8V3H3v13h5",
|
||||||
|
external: "M14 3h7v7M21 3 11 13M10 3H3v18h18v-7",
|
||||||
|
plug: "M8 3v5m8-5v5M5 8h14v4a7 7 0 0 1-14 0V8M12 19v3",
|
||||||
|
download: "M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4",
|
||||||
|
list: "M9 5h12M9 12h12M9 19h12M3 5h.01M3 12h.01M3 19h.01",
|
||||||
|
edit: "m4 16 12-12 4 4L8 20H4v-4zM13 7l4 4",
|
||||||
|
pin: "M9 3h6l-1 6 4 4H6l4-4-1-6zM12 13v8",
|
||||||
|
trash: "M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14M10 11v6M14 11v6",
|
||||||
|
archive: "M4 4h16v5H4V4zm2 5v11h12V9M9 13h6",
|
||||||
|
server: "M3 3h18v7H3V3zM3 14h18v7H3v-7zM7 6h.01M7 17h.01",
|
||||||
|
link: "m10 13 4-4M9 16l-2 2a4 4 0 0 1-6-5l4-4a4 4 0 0 1 6 0M14 8l2-2a4 4 0 0 1 6 5l-4 4a4 4 0 0 1-6 0",
|
||||||
|
wallet: "M3 6h17v15H3V6zM3 6l14-4v4M15 11h7v6h-7v-6zM18 14h.01",
|
||||||
|
gauge: "M4 18a8 8 0 1 1 16 0M12 18l4-6M7 17h.01M17 17h.01M12 9v.01",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type StudioIconName = keyof typeof PATHS;
|
||||||
|
|
||||||
|
export function StudioIcon({ name, className, ...props }: SVGProps<SVGSVGElement> & { name: StudioIconName }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={["studio-icon", className].filter(Boolean).join(" ")}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d={PATHS[name]} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
|
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||||
import { decimals } from "../i18n/format";
|
import { decimals } from "../i18n/format";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import type { Item, Waiting } from "../state/session";
|
import type { Item, Waiting } from "../state/session";
|
||||||
@@ -17,7 +17,9 @@ import { UserCard } from "./cards/UserCard";
|
|||||||
import { NoticeCard } from "./cards/NoticeCard";
|
import { NoticeCard } from "./cards/NoticeCard";
|
||||||
import { RememberCard } from "./cards/RememberCard";
|
import { RememberCard } from "./cards/RememberCard";
|
||||||
import { ExtensionCard } from "./cards/ExtensionCard";
|
import { ExtensionCard } from "./cards/ExtensionCard";
|
||||||
|
import { toolFailed } from "./cards/outcome";
|
||||||
import { Rail, type RailMark } from "./Rail";
|
import { Rail, type RailMark } from "./Rail";
|
||||||
|
import { StudioIcon } from "./StudioIcon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: Item[];
|
items: Item[];
|
||||||
@@ -500,13 +502,49 @@ const Block = memo(function Block({
|
|||||||
if (near && box.current) tall.current = box.current.offsetHeight;
|
if (near && box.current) tall.current = box.current.offsetHeight;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tool traffic is evidence for the answer, not a stack of equally important
|
||||||
|
// messages. Keep consecutive execution steps in one disclosure so a finished
|
||||||
|
// turn reads answer-first while every command and output remains inspectable.
|
||||||
|
const rows: Array<{ item: Item; activity?: Item[] } | { activity: Item[] }> = [];
|
||||||
|
for (let at = 0; at < items.length;) {
|
||||||
|
let end = at + 1;
|
||||||
|
while (end < items.length && items[end].t !== "user") end++;
|
||||||
|
const turn = items.slice(at, end);
|
||||||
|
const activity = turn.filter((item) => item.t === "tool" || item.t === "reads");
|
||||||
|
const answer = turn.find((item) => item.t === "say" && item.done);
|
||||||
|
if (answer && activity.length) {
|
||||||
|
for (const item of turn) {
|
||||||
|
if (item.t === "tool" || item.t === "reads") continue;
|
||||||
|
rows.push(item === answer ? { item, activity } : { item });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const item of turn) {
|
||||||
|
const isActivity = item.t === "tool" || item.t === "reads";
|
||||||
|
const last = rows[rows.length - 1];
|
||||||
|
if (isActivity && last && "activity" in last && !("item" in last)) last.activity.push(item);
|
||||||
|
else rows.push(isActivity ? { activity: [item] } : { item });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
at = end;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="chunk"
|
className="chunk"
|
||||||
ref={box}
|
ref={box}
|
||||||
style={near || keep ? undefined : { height: `${tall.current || items.length * 96}px` }}
|
style={near || keep ? undefined : { height: `${tall.current || items.length * 96}px` }}
|
||||||
>
|
>
|
||||||
{(near || keep) && items.map((it) => <Row key={it.id} it={it} {...rowProps} cp={checkpoints.get(it.id)} />)}
|
{(near || keep) && rows.map((row) => "item" in row ? (
|
||||||
|
<Row
|
||||||
|
key={row.item.id}
|
||||||
|
it={row.item}
|
||||||
|
{...rowProps}
|
||||||
|
cp={checkpoints.get(row.item.id)}
|
||||||
|
afterAnswer={row.activity ? <ActivityGroup items={row.activity} checkpoints={checkpoints} {...rowProps} /> : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ActivityGroup key={`activity:${row.activity[0].id}`} items={row.activity} checkpoints={checkpoints} {...rowProps} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -528,6 +566,39 @@ interface RowHandlers {
|
|||||||
onCommitFileRevert: Props["onCommitFileRevert"];
|
onCommitFileRevert: Props["onCommitFileRevert"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ActivityGroup = memo(function ActivityGroup({
|
||||||
|
items,
|
||||||
|
checkpoints,
|
||||||
|
...rowProps
|
||||||
|
}: { items: Item[]; checkpoints: Map<string, Checkpoint> } & RowHandlers) {
|
||||||
|
const running = items.some((item) => item.t === "tool" && item.running);
|
||||||
|
const [open, setOpen] = useState(running);
|
||||||
|
useEffect(() => {
|
||||||
|
if (running) setOpen(true);
|
||||||
|
}, [running]);
|
||||||
|
const calls = items.reduce((count, item) => count + (item.t === "reads" ? item.tools.length : 1), 0);
|
||||||
|
const failures = items.reduce((count, item) => {
|
||||||
|
if (item.t === "tool") return count + (toolFailed(item.tool) ? 1 : 0);
|
||||||
|
if (item.t === "reads") return count + item.tools.filter(toolFailed).length;
|
||||||
|
return count;
|
||||||
|
}, 0);
|
||||||
|
return (
|
||||||
|
<details className="activity-group" data-failed={failures ? "" : undefined} open={open} onToggle={(event) => setOpen(event.currentTarget.open)}>
|
||||||
|
<summary>
|
||||||
|
<StudioIcon name={running ? "clock" : failures ? "warning" : "check"} className="activity-status-icon" />
|
||||||
|
<span className="activity-title">{t("执行过程")}</span>
|
||||||
|
{failures > 0 && <span className="activity-errors">{t("{n} 项失败", { n: failures })}</span>}
|
||||||
|
<span className="activity-count">{t("{count} 项操作", { count: calls })}</span>
|
||||||
|
<StudioIcon name="down" className="activity-fold-icon" />
|
||||||
|
</summary>
|
||||||
|
<div className="activity-body">
|
||||||
|
{items.map((item) => <Row key={item.id} it={item} activity {...rowProps} cp={checkpoints.get(item.id)} />)}
|
||||||
|
</div>
|
||||||
|
<p className="activity-note">{t("点击步骤可原位查看详情、结果或文件差异")}</p>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// A streamed token replaces one item and leaves the rest identical, so the rest
|
// A streamed token replaces one item and leaves the rest identical, so the rest
|
||||||
// must not re-render: at a working session's length that walk cost more per
|
// must not re-render: at a working session's length that walk cost more per
|
||||||
// chunk than parsing the message did.
|
// chunk than parsing the message did.
|
||||||
@@ -548,7 +619,9 @@ const Row = memo(function Row({
|
|||||||
onUndoRewind,
|
onUndoRewind,
|
||||||
onPrepareFileRevert,
|
onPrepareFileRevert,
|
||||||
onCommitFileRevert,
|
onCommitFileRevert,
|
||||||
}: RowHandlers & { it: Item; cp?: Checkpoint }) {
|
afterAnswer,
|
||||||
|
activity = false,
|
||||||
|
}: RowHandlers & { it: Item; cp?: Checkpoint; afterAnswer?: ReactNode; activity?: boolean }) {
|
||||||
// Read once, for the life of this element. The projection spends the debt as
|
// Read once, for the life of this element. The projection spends the debt as
|
||||||
// soon as this is drawn, and a card whose answer is still streaming would
|
// soon as this is drawn, and a card whose answer is still streaming would
|
||||||
// otherwise lose the attribute mid-animation and have it cut short. A block
|
// otherwise lose the attribute mid-animation and have it cut short. A block
|
||||||
@@ -576,7 +649,7 @@ const Row = memo(function Row({
|
|||||||
// model. Gating the card on text meant all of it stayed invisible and
|
// model. Gating the card on text meant all of it stayed invisible and
|
||||||
// then landed at once. An empty card is still not a message, so a turn
|
// then landed at once. An empty card is still not a message, so a turn
|
||||||
// that produced neither draws nothing.
|
// that produced neither draws nothing.
|
||||||
return it.text.trim() || it.reasoning?.trim() ? <SayCard item={it} /> : null;
|
return it.text.trim() || it.reasoning?.trim() ? <SayCard item={it} afterAnswer={afterAnswer} /> : null;
|
||||||
case "tool":
|
case "tool":
|
||||||
// The ask tool also raises ask_request, which carries the id /answer
|
// The ask tool also raises ask_request, which carries the id /answer
|
||||||
// needs. Drawing the tool call too put two copies of the same question on
|
// needs. Drawing the tool call too put two copies of the same question on
|
||||||
@@ -585,6 +658,7 @@ const Row = memo(function Row({
|
|||||||
<ToolCard
|
<ToolCard
|
||||||
tool={it.tool}
|
tool={it.tool}
|
||||||
running={it.running}
|
running={it.running}
|
||||||
|
activity={activity}
|
||||||
children={it.children}
|
children={it.children}
|
||||||
takeover={it.tool.id ? takeovers[`tool:${it.tool.id}`] : undefined}
|
takeover={it.tool.id ? takeovers[`tool:${it.tool.id}`] : undefined}
|
||||||
onExtInvoke={onExtInvoke}
|
onExtInvoke={onExtInvoke}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { t } from "../i18n";
|
|||||||
import type { HubPort, RuntimeView, TreeSession, TreeWorkspace } from "../port/hub";
|
import type { HubPort, RuntimeView, TreeSession, TreeWorkspace } from "../port/hub";
|
||||||
import type { Adder } from "./addws";
|
import type { Adder } from "./addws";
|
||||||
import { useRailQuery } from "./railsearch";
|
import { useRailQuery } from "./railsearch";
|
||||||
|
import { StudioIcon } from "./StudioIcon";
|
||||||
|
import { download } from "../port/download";
|
||||||
|
import { host } from "../port/host";
|
||||||
|
|
||||||
const parentOf = (root: string) => root.replace(/[/\\]+$/, "").split(/[/\\]/).slice(-2, -1)[0] ?? "";
|
const parentOf = (root: string) => root.replace(/[/\\]+$/, "").split(/[/\\]/).slice(-2, -1)[0] ?? "";
|
||||||
|
|
||||||
@@ -22,6 +25,11 @@ interface Props {
|
|||||||
// Which of these panes are mid-turn. A callback rather than a prop: run state
|
// Which of these panes are mid-turn. A callback rather than a prop: run state
|
||||||
// changes constantly and this is only ever asked at confirmation time.
|
// changes constantly and this is only ever asked at confirmation time.
|
||||||
liveIds: (ids: string[]) => string[];
|
liveIds: (ids: string[]) => string[];
|
||||||
|
scope?: "all" | "live" | "pinned" | "archived";
|
||||||
|
pinned: Set<string>;
|
||||||
|
onPin: (path: string) => void;
|
||||||
|
onPause: (runtimeId: string) => void;
|
||||||
|
onArchive: (path: string, archived: boolean, runtimeId?: string) => Promise<void>;
|
||||||
onRename: (path: string, title: string) => void;
|
onRename: (path: string, title: string) => void;
|
||||||
onError: (e: unknown) => void;
|
onError: (e: unknown) => void;
|
||||||
// 打开项目这个动作归 App —— 首启那条横幅按的是同一个它。
|
// 打开项目这个动作归 App —— 首启那条横幅按的是同一个它。
|
||||||
@@ -38,7 +46,7 @@ interface Props {
|
|||||||
// nodes in the sidebar — more than the transcript at 20000 turns.
|
// nodes in the sidebar — more than the transcript at 20000 turns.
|
||||||
const SHOWN = 30;
|
const SHOWN = 30;
|
||||||
|
|
||||||
function WorkspacesView({ hub, tree, runtimes, active, folded, reload, onFold, onOpen, onFocus, onClose, liveIds, onRename, onError, adder, children }: Props) {
|
function WorkspacesView({ hub, tree, runtimes, active, folded, reload, onFold, onOpen, onFocus, onClose, liveIds, scope = "all", pinned, onPin, onPause, onArchive, onRename, onError, adder, children }: Props) {
|
||||||
const [busy, setBusy] = useState("");
|
const [busy, setBusy] = useState("");
|
||||||
// Folding a machine is the reader's own preference, held the way a host row
|
// Folding a machine is the reader's own preference, held the way a host row
|
||||||
// holds it.
|
// holds it.
|
||||||
@@ -48,6 +56,8 @@ function WorkspacesView({ hub, tree, runtimes, active, folded, reload, onFold, o
|
|||||||
// Renaming is a pencil, not a double-click: a single click already opens the
|
// Renaming is a pencil, not a double-click: a single click already opens the
|
||||||
// session, so a double one would open it twice on the way to the edit.
|
// session, so a double one would open it twice on the way to the edit.
|
||||||
const [editing, setEditing] = useState("");
|
const [editing, setEditing] = useState("");
|
||||||
|
const [sessionMenu, setSessionMenu] = useState("");
|
||||||
|
const [sessionMenuAt, setSessionMenuAt] = useState({ left: 0, top: 0 });
|
||||||
// What was already sent for this session, so Enter's commit and the blur it
|
// What was already sent for this session, so Enter's commit and the blur it
|
||||||
// causes do not both reach the host with the same name.
|
// causes do not both reach the host with the same name.
|
||||||
const renamed = useRef<Record<string, string>>({});
|
const renamed = useRef<Record<string, string>>({});
|
||||||
@@ -74,7 +84,10 @@ function WorkspacesView({ hub, tree, runtimes, active, folded, reload, onFold, o
|
|||||||
// one transcript — the kernel refuses that, and it is what forked a recovery
|
// one transcript — the kernel refuses that, and it is what forked a recovery
|
||||||
// branch on every save when two writers shared a file.
|
// branch on every save when two writers shared a file.
|
||||||
const pick = async (ws: TreeWorkspace, session: TreeSession) => {
|
const pick = async (ws: TreeWorkspace, session: TreeSession) => {
|
||||||
if (session.runtimeId) {
|
// The tree and runtime list arrive from separate reads. During a close or
|
||||||
|
// restore the tree can briefly retain an id that no longer exists; focusing
|
||||||
|
// that stale id changes no pane and makes the row look unclickable.
|
||||||
|
if (session.runtimeId && runtimes.some((rt) => rt.id === session.runtimeId)) {
|
||||||
onFocus(session.runtimeId);
|
onFocus(session.runtimeId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -137,6 +150,31 @@ function WorkspacesView({ hub, tree, runtimes, active, folded, reload, onFold, o
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveSession = async (session: TreeSession) => {
|
||||||
|
setBusy("export:" + session.path);
|
||||||
|
try {
|
||||||
|
const exported = await hub.exportSession(session.path);
|
||||||
|
const records = exported.content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.map((line) => JSON.parse(line) as unknown);
|
||||||
|
const content = JSON.stringify(
|
||||||
|
{ version: 1, title: session.title || session.name, exportedAt: new Date().toISOString(), records },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const clean = (session.title || exported.name || session.name).replace(/[<>:"/\\|?*\x00-\x1f]/g, "-").trim() || "reasonix-session";
|
||||||
|
const filename = `${clean}.json`;
|
||||||
|
const saved = await host().saveText(filename, content);
|
||||||
|
if (saved === null) download(filename, content);
|
||||||
|
} catch (e) {
|
||||||
|
onError(e);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
setSessionMenu("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Filtering is client-side because the tree is already here: the kernel has no
|
// Filtering is client-side because the tree is already here: the kernel has no
|
||||||
// search route, and a round trip to re-derive what the window is holding would
|
// search route, and a round trip to re-derive what the window is holding would
|
||||||
// be slower than the typing. A hit on the folder keeps all of its sessions.
|
// be slower than the typing. A hit on the folder keeps all of its sessions.
|
||||||
@@ -147,7 +185,19 @@ function WorkspacesView({ hub, tree, runtimes, active, folded, reload, onFold, o
|
|||||||
const sessions = ws.sessions.filter(named);
|
const sessions = ws.sessions.filter(named);
|
||||||
return sessions.length ? { ...ws, sessions } : null;
|
return sessions.length ? { ...ws, sessions } : null;
|
||||||
};
|
};
|
||||||
const shownTree = needle ? (tree.map(hit).filter(Boolean) as TreeWorkspace[]) : tree;
|
const searchedTree = needle ? (tree.map(hit).filter(Boolean) as TreeWorkspace[]) : tree;
|
||||||
|
const shownTree = searchedTree
|
||||||
|
.map((ws) => ({
|
||||||
|
...ws,
|
||||||
|
sessions: ws.sessions.filter((session) => {
|
||||||
|
if (scope === "archived") return !!session.archived;
|
||||||
|
if (session.archived) return false;
|
||||||
|
if (scope === "live") return !!session.runtimeId && liveIds([session.runtimeId]).length > 0;
|
||||||
|
if (scope === "pinned") return pinned.has(session.path);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
.filter((ws) => scope === "all" || ws.sessions.length > 0);
|
||||||
// A fold is a resting-state preference: while a word is being typed it would
|
// A fold is a resting-state preference: while a word is being typed it would
|
||||||
// hide the very rows that word just found.
|
// hide the very rows that word just found.
|
||||||
const shutHere = needle ? false : hereShut;
|
const shutHere = needle ? false : hereShut;
|
||||||
@@ -217,7 +267,7 @@ function WorkspacesView({ hub, tree, runtimes, active, folded, reload, onFold, o
|
|||||||
const doomed = confirm === ws.root ? panesOf(ws.root) : [];
|
const doomed = confirm === ws.root ? panesOf(ws.root) : [];
|
||||||
const busyPanes = liveIds(doomed).length;
|
const busyPanes = liveIds(doomed).length;
|
||||||
return (
|
return (
|
||||||
<div className="wsnode" key={ws.root} data-missing={ws.missing ? "" : undefined}>
|
<div className="wsnode" key={ws.root} data-current={panesOf(ws.root).includes(active) ? "" : undefined} data-missing={ws.missing ? "" : undefined}>
|
||||||
{confirm === ws.root ? (
|
{confirm === ws.root ? (
|
||||||
<Confirm
|
<Confirm
|
||||||
what={`从列表移除「${ws.name}」?`}
|
what={`从列表移除「${ws.name}」?`}
|
||||||
@@ -308,7 +358,9 @@ function WorkspacesView({ hub, tree, runtimes, active, folded, reload, onFold, o
|
|||||||
return (
|
return (
|
||||||
<Fragment key={session.path}>
|
<Fragment key={session.path}>
|
||||||
<div
|
<div
|
||||||
data-action="session.open"
|
data-action-click="session.open"
|
||||||
|
data-action-keydown="session.open"
|
||||||
|
data-target={session.path}
|
||||||
className="sessrow"
|
className="sessrow"
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
aria-selected={on}
|
aria-selected={on}
|
||||||
@@ -316,6 +368,15 @@ function WorkspacesView({ hub, tree, runtimes, active, folded, reload, onFold, o
|
|||||||
data-live={session.runtimeId ? "" : undefined}
|
data-live={session.runtimeId ? "" : undefined}
|
||||||
data-busy={busy === session.path ? "" : undefined}
|
data-busy={busy === session.path ? "" : undefined}
|
||||||
onClick={() => void pick(ws, session)}
|
onClick={() => void pick(ws, session)}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.target !== ev.currentTarget) return;
|
||||||
|
if (editing === session.path) return;
|
||||||
|
if (ev.key === "Enter" || ev.key === " ") {
|
||||||
|
ev.preventDefault();
|
||||||
|
void pick(ws, session);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<i className="pip" />
|
<i className="pip" />
|
||||||
{editing === session.path ? (
|
{editing === session.path ? (
|
||||||
@@ -347,13 +408,8 @@ function WorkspacesView({ hub, tree, runtimes, active, folded, reload, onFold, o
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="sesstitle" title={session.title || session.name}>{session.title || session.name}</span>
|
<span className="sesstitle" title={session.title || session.name}><span>{session.title || session.name}</span></span>
|
||||||
)}
|
)}
|
||||||
{/* The unit is the same on every row, so the row says
|
|
||||||
the number and the title says what it is about. */}
|
|
||||||
<span className="sessmeta" title={session.turns ? t("{n} 轮", { n: session.turns }) : t("空会话")}>
|
|
||||||
{session.turns || ""}
|
|
||||||
</span>
|
|
||||||
{copies.length > 0 && (
|
{copies.length > 0 && (
|
||||||
<button
|
<button
|
||||||
className="sesscopies"
|
className="sesscopies"
|
||||||
@@ -373,27 +429,73 @@ function WorkspacesView({ hub, tree, runtimes, active, folded, reload, onFold, o
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="sessedit-btn"
|
className="session-more"
|
||||||
title={t("重命名")}
|
data-action="session.menu"
|
||||||
aria-label={t("重命名该会话")}
|
data-target={session.path}
|
||||||
|
title={t("更多操作")}
|
||||||
|
aria-label={t("会话操作:{title}", { title: session.title || session.name })}
|
||||||
|
aria-expanded={sessionMenu === session.path}
|
||||||
onClick={(ev) => {
|
onClick={(ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
setEditing(session.path);
|
const opening = sessionMenu !== session.path;
|
||||||
|
if (opening) {
|
||||||
|
const anchor = ev.currentTarget.getBoundingClientRect();
|
||||||
|
setSessionMenuAt({
|
||||||
|
left: Math.min(window.innerWidth - 240, anchor.right + 8),
|
||||||
|
top: Math.max(12, Math.min(window.innerHeight - 270, anchor.top - 7)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSessionMenu(opening ? session.path : "");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
✎
|
<StudioIcon name="more" />
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="wsdel"
|
|
||||||
title={t("删除该会话")}
|
|
||||||
aria-label={t("删除该会话")}
|
|
||||||
onClick={(ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
setConfirm(session.path);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
</button>
|
||||||
|
{sessionMenu === session.path && (
|
||||||
|
<div className="session-pop" role="menu" aria-label={t("会话操作")} style={sessionMenuAt} onClick={(ev) => ev.stopPropagation()}>
|
||||||
|
<div className="session-pop-head">
|
||||||
|
<b>{session.title || session.name}</b>
|
||||||
|
<small>{session.runtimeId && liveIds([session.runtimeId]).length ? t("执行中") : t("已完成")} · {t("本地工作区")}</small>
|
||||||
|
</div>
|
||||||
|
<div className="session-pop-group">
|
||||||
|
<button role="menuitem" data-action="session.pin" data-target={session.path} onClick={() => { onPin(session.path); setSessionMenu(""); }}>
|
||||||
|
<StudioIcon name="pin" /><span>{pinned.has(session.path) ? t("取消置顶") : t("置顶会话")}</span>
|
||||||
|
</button>
|
||||||
|
<button role="menuitem" data-action="session.rename" data-target={session.path} onClick={() => { setEditing(session.path); setSessionMenu(""); }}>
|
||||||
|
<StudioIcon name="edit" /><span>{t("重命名")}</span>
|
||||||
|
</button>
|
||||||
|
{session.runtimeId && liveIds([session.runtimeId]).length > 0 && (
|
||||||
|
<button role="menuitem" data-action="session.pause" data-target={session.path} onClick={() => { onPause(session.runtimeId!); setSessionMenu(""); }}>
|
||||||
|
<StudioIcon name="pause" /><span>{t("暂停执行")}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
role="menuitem"
|
||||||
|
data-action="session.archive"
|
||||||
|
data-target={session.path}
|
||||||
|
data-value={session.archived ? "restore" : "archive"}
|
||||||
|
disabled={!!session.runtimeId && liveIds([session.runtimeId]).length > 0}
|
||||||
|
onClick={() => {
|
||||||
|
setSessionMenu("");
|
||||||
|
void onArchive(session.path, !session.archived, session.runtimeId).catch(onError);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StudioIcon name="archive" /><span>{t(session.archived ? "取消归档" : "归档会话")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="session-pop-divider" />
|
||||||
|
<div className="session-pop-group">
|
||||||
|
<button role="menuitem" data-action="session.export" data-target={session.path} disabled={busy === "export:" + session.path} onClick={() => void saveSession(session)}>
|
||||||
|
<StudioIcon name="download" /><span>{t("导出会话")}</span><small>JSON</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="session-pop-divider" />
|
||||||
|
<div className="session-pop-group">
|
||||||
|
<button className="danger" role="menuitem" data-action="session.delete" data-target={session.path} onClick={() => { setConfirm(session.path); setSessionMenu(""); }}>
|
||||||
|
<StudioIcon name="trash" /><span>{t("删除会话")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{open &&
|
{open &&
|
||||||
copies.map((copy) =>
|
copies.map((copy) =>
|
||||||
@@ -409,16 +511,25 @@ function WorkspacesView({ hub, tree, runtimes, active, folded, reload, onFold, o
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
data-action="session.open"
|
data-action-click="session.open"
|
||||||
|
data-action-keydown="session.open"
|
||||||
|
data-target={copy.path}
|
||||||
key={copy.path}
|
key={copy.path}
|
||||||
className="sessrow sesscopy"
|
className="sessrow sesscopy"
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
data-busy={busy === copy.path ? "" : undefined}
|
data-busy={busy === copy.path ? "" : undefined}
|
||||||
onClick={() => void pick(ws, copy)}
|
onClick={() => void pick(ws, copy)}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.target !== ev.currentTarget) return;
|
||||||
|
if (ev.key === "Enter" || ev.key === " ") {
|
||||||
|
ev.preventDefault();
|
||||||
|
void pick(ws, copy);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<i className="pip" />
|
<i className="pip" />
|
||||||
<span className="sesstitle">{t("恢复副本")}</span>
|
<span className="sesstitle"><span>{t("恢复副本")}</span></span>
|
||||||
<span className="sessmeta">{copy.turns ? t("{n} 轮", { n: copy.turns }) : t("空会话")}</span>
|
|
||||||
<button
|
<button
|
||||||
className="wsdel"
|
className="wsdel"
|
||||||
title={t("删除该会话")}
|
title={t("删除该会话")}
|
||||||
|
|||||||
@@ -31,16 +31,16 @@ function rowOf(t: Tool): Row {
|
|||||||
switch (t.name) {
|
switch (t.name) {
|
||||||
case "read_file": {
|
case "read_file": {
|
||||||
const [dir, name] = splitPath(arg);
|
const [dir, name] = splitPath(arg);
|
||||||
return { ...state, dir, name, n: state.failed ? tr("失败") : `Read · ${countLines(t.output)} 行` };
|
return { ...state, dir, name, n: state.failed ? tr("失败") : tr("读取 · {n} 行", { n: countLines(t.output) }) };
|
||||||
}
|
}
|
||||||
case "grep": {
|
case "grep": {
|
||||||
const where = argOf(t.args, "path", "file_path");
|
const where = argOf(t.args, "path", "file_path");
|
||||||
return { ...state, dir: "", name: `Search "${arg}"${where ? ` ${where}` : ""}`, n: state.failed ? tr("失败") : `${countHits(t.output)} 处` };
|
return { ...state, dir: "", name: `${tr("搜索")} “${arg}”${where ? ` ${where}` : ""}`, n: state.failed ? tr("失败") : tr("{n} 处", { n: countHits(t.output) }) };
|
||||||
}
|
}
|
||||||
case "glob":
|
case "glob":
|
||||||
return { ...state, dir: "", name: `Glob ${arg}`, n: state.failed ? tr("失败") : `${countRows(t.output)} 个` };
|
return { ...state, dir: "", name: `${tr("查找文件")} ${arg}`, n: state.failed ? tr("失败") : tr("{n} 个", { n: countRows(t.output) }) };
|
||||||
default:
|
default:
|
||||||
return { ...state, dir: "", name: `List ${arg}`, n: state.failed ? tr("失败") : `${countRows(t.output)} 项` };
|
return { ...state, dir: "", name: `${tr("列出目录")} ${arg}`, n: state.failed ? tr("失败") : tr("{n} 项", { n: countRows(t.output) }) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +53,10 @@ function summarise(tools: Tool[]): string {
|
|||||||
const lists = tools.filter((t) => t.name === "ls").length;
|
const lists = tools.filter((t) => t.name === "ls").length;
|
||||||
const hits = greps.reduce((n, t) => n + countHits(t.output), 0);
|
const hits = greps.reduce((n, t) => n + countHits(t.output), 0);
|
||||||
return [
|
return [
|
||||||
reads && `read_file ×${reads}`,
|
reads && tr("读取文件 ×{n}", { n: reads }),
|
||||||
greps.length && `grep 命中 ${hits} 处`,
|
greps.length && tr("搜索命中 {n} 处", { n: hits }),
|
||||||
globs && `glob ×${globs}`,
|
globs && tr("查找文件 ×{n}", { n: globs }),
|
||||||
lists && `ls ×${lists}`,
|
lists && tr("列出目录 ×{n}", { n: lists }),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
@@ -95,26 +95,38 @@ function Body({ tool }: { tool: Tool }) {
|
|||||||
// touched; the result itself is one click away rather than dumped into the flow.
|
// touched; the result itself is one click away rather than dumped into the flow.
|
||||||
export function ReadsCard({ tools }: { tools: Tool[] }) {
|
export function ReadsCard({ tools }: { tools: Tool[] }) {
|
||||||
const [open, setOpen] = useState(-1);
|
const [open, setOpen] = useState(-1);
|
||||||
|
const failed = tools.filter(toolFailed).length;
|
||||||
|
const [expanded, setExpanded] = useState(failed > 0);
|
||||||
const rows = tools.map(rowOf);
|
const rows = tools.map(rowOf);
|
||||||
const files = tools.filter((t) => t.name === "read_file").length;
|
const files = tools.filter((t) => t.name === "read_file").length;
|
||||||
const failed = tools.filter(toolFailed).length;
|
const searches = tools.filter((t) => t.name === "grep" || t.name === "glob").length;
|
||||||
|
const headline = files === tools.length
|
||||||
|
? tr("读取了 {n} 个文件", { n: files })
|
||||||
|
: searches === tools.length
|
||||||
|
? tr("完成了 {n} 次搜索", { n: searches })
|
||||||
|
: tr("查阅了 {n} 项内容", { n: tools.length });
|
||||||
return (
|
return (
|
||||||
<div className="call">
|
<div className="call" data-state={failed ? "failed" : "done"}>
|
||||||
<div className="g">
|
<div className="g">
|
||||||
<Sym glyph={glyphFor("read_file")} />
|
<Sym glyph={glyphFor("read_file")} />
|
||||||
<span className="line" />
|
<span className="line" />
|
||||||
</div>
|
</div>
|
||||||
<div className="c">
|
<div className="c">
|
||||||
<div className="hl">
|
<details className="tool-disclosure reads-disclosure" open={expanded} onToggle={(event) => setExpanded(event.currentTarget.open)}>
|
||||||
<span className="nm">{tr("读了 {n} 个文件", { n: files })}</span>
|
<summary className="hl">
|
||||||
{failed > 0 && <span className="fail">{tr("{n} 项失败", { n: failed })}</span>}
|
<span className="nm">{headline}</span>
|
||||||
<Cost tools={tools} />
|
<span className="arg">{summarise(tools)}</span>
|
||||||
</div>
|
{failed > 0 && <span className="fail">{tr("{n} 项失败", { n: failed })}</span>}
|
||||||
<div className="out">
|
<Cost tools={tools} />
|
||||||
<details>
|
<span
|
||||||
<summary>
|
className="tool-state"
|
||||||
<span className="fold">{summarise(tools)}</span>
|
data-state={failed ? "failed" : "done"}
|
||||||
</summary>
|
aria-label={tr(failed ? "失败" : "已完成")}
|
||||||
|
title={tr(failed ? "失败" : "已完成")}
|
||||||
|
/>
|
||||||
|
<span className="tool-fold" aria-hidden="true" />
|
||||||
|
</summary>
|
||||||
|
<div className="out">
|
||||||
<div className="peek">
|
<div className="peek">
|
||||||
{rows.map((r, i) => (
|
{rows.map((r, i) => (
|
||||||
<button
|
<button
|
||||||
@@ -134,8 +146,8 @@ export function ReadsCard({ tools }: { tools: Tool[] }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{open >= 0 && tools[open] && <Body tool={tools[open]} />}
|
{open >= 0 && tools[open] && <Body tool={tools[open]} />}
|
||||||
</details>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { count, decimals } from "../../i18n/format";
|
import { count, decimals } from "../../i18n/format";
|
||||||
import { Sym } from "../Sym";
|
import { Sym } from "../Sym";
|
||||||
@@ -20,7 +20,7 @@ function thoughtLabel(item: Extract<Item, { t: "say" }>) {
|
|||||||
: t("思考 {chars}", { chars });
|
: t("思考 {chars}", { chars });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SayCard({ item }: { item: Extract<Item, { t: "say" }> }) {
|
export function SayCard({ item, afterAnswer }: { item: Extract<Item, { t: "say" }>; afterAnswer?: ReactNode }) {
|
||||||
const [open, setOpen] = useState(!item.done);
|
const [open, setOpen] = useState(!item.done);
|
||||||
const wasDone = useRef(item.done);
|
const wasDone = useRef(item.done);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,7 +39,7 @@ export function SayCard({ item }: { item: Extract<Item, { t: "say" }> }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="c">
|
<div className="c">
|
||||||
<div className="hl">
|
<div className="hl">
|
||||||
<span className="nm">{t("助手")}</span>
|
<span className="nm">reasonix</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="out">
|
<div className="out">
|
||||||
{item.reasoning && (
|
{item.reasoning && (
|
||||||
@@ -60,11 +60,12 @@ export function SayCard({ item }: { item: Extract<Item, { t: "say" }> }) {
|
|||||||
</Boundary>
|
</Boundary>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{afterAnswer}
|
||||||
{/* Only once the answer is whole: copying half a stream hands over
|
{/* Only once the answer is whole: copying half a stream hands over
|
||||||
something that was never said. */}
|
something that was never said. */}
|
||||||
{item.done && item.text.trim() && (
|
{item.done && item.text.trim() && (
|
||||||
<div className="acts">
|
<div className="acts">
|
||||||
<CopyButton text={item.text} />
|
<CopyButton text={item.text} iconOnly />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { DiffView } from "./DiffView";
|
|||||||
import { Term, ToolOutput } from "./ToolOutput";
|
import { Term, ToolOutput } from "./ToolOutput";
|
||||||
import { ExtensionView } from "./ExtensionView";
|
import { ExtensionView } from "./ExtensionView";
|
||||||
import { toolFailed, toolFailureLabel } from "./outcome";
|
import { toolFailed, toolFailureLabel } from "./outcome";
|
||||||
|
import { StudioIcon } from "../StudioIcon";
|
||||||
|
|
||||||
// The spec pops a symbol as it settles — colour arriving is the finish signal.
|
// The spec pops a symbol as it settles — colour arriving is the finish signal.
|
||||||
// Only the transition may fire it: a restored transcript is all settled cards,
|
// Only the transition may fire it: a restored transcript is all settled cards,
|
||||||
@@ -21,6 +22,17 @@ import { toolFailed, toolFailureLabel } from "./outcome";
|
|||||||
// 唯一的状态跃迁,别用 0ms 换掉它". 90ms out, then the settled text.
|
// 唯一的状态跃迁,别用 0ms 换掉它". 90ms out, then the settled text.
|
||||||
const SWAP = 90;
|
const SWAP = 90;
|
||||||
|
|
||||||
|
function prettyArgs(args?: string): string {
|
||||||
|
if (!args) return "";
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(args), null, 2);
|
||||||
|
} catch {
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortCallId = (id?: string) => id ? id.replace(/^call_/, "").slice(-8) : "—";
|
||||||
|
|
||||||
function useSettling(running: boolean): { pop: boolean; swap: boolean } {
|
function useSettling(running: boolean): { pop: boolean; swap: boolean } {
|
||||||
const [pop, setPop] = useState(false);
|
const [pop, setPop] = useState(false);
|
||||||
const [swap, setSwap] = useState(false);
|
const [swap, setSwap] = useState(false);
|
||||||
@@ -42,10 +54,11 @@ function useSettling(running: boolean): { pop: boolean; swap: boolean } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ToolCard({
|
export function ToolCard({
|
||||||
tool, running, children = [], takeover, onExtInvoke, onPrepareFileRevert, onCommitFileRevert,
|
tool, running, children = [], takeover, onExtInvoke, onPrepareFileRevert, onCommitFileRevert, activity = false,
|
||||||
}: {
|
}: {
|
||||||
tool: Tool;
|
tool: Tool;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
|
activity?: boolean;
|
||||||
children?: Tool[];
|
children?: Tool[];
|
||||||
// Reverting the file this call wrote. Optional: a card drawn outside a live
|
// Reverting the file this call wrote. Optional: a card drawn outside a live
|
||||||
// pane (history, a fixture) has nothing to revert against.
|
// pane (history, a fixture) has nothing to revert against.
|
||||||
@@ -60,9 +73,10 @@ export function ToolCard({
|
|||||||
// about who answered. Name the resolved tool; only a target that really is an
|
// about who answered. Name the resolved tool; only a target that really is an
|
||||||
// MCP tool reads MCP, which labelFor decides from the mcp__ prefix.
|
// MCP tool reads MCP, which labelFor decides from the mcp__ prefix.
|
||||||
const shown = tool.resolvedName || tool.name;
|
const shown = tool.resolvedName || tool.name;
|
||||||
|
const from = mcpOrigin(shown);
|
||||||
// Running and settled are two different lines: the category says what it is
|
// Running and settled are two different lines: the category says what it is
|
||||||
// doing now, the label says what it was once it is done.
|
// doing now, the label says what it was once it is done.
|
||||||
const head = running ? runLabelFor(shown) : labelFor(shown);
|
const head = activity ? (from?.tool || shown) : running ? runLabelFor(shown) : labelFor(shown);
|
||||||
// A write's payload streams as arguments, and the provider only reports how
|
// A write's payload streams as arguments, and the provider only reports how
|
||||||
// many characters have landed — the JSON is unparseable until the last one.
|
// many characters have landed — the JSON is unparseable until the last one.
|
||||||
// Showing that count is the difference between a card that is visibly filling
|
// Showing that count is the difference between a card that is visibly filling
|
||||||
@@ -77,7 +91,6 @@ export function ToolCard({
|
|||||||
const badLabel = toolFailureLabel(tool);
|
const badLabel = toolFailureLabel(tool);
|
||||||
// Which server answered belongs on the card, not in a panel: this is the
|
// Which server answered belongs on the card, not in a panel: this is the
|
||||||
// moment the user can judge whether an external service should have run.
|
// moment the user can judge whether an external service should have run.
|
||||||
const from = mcpOrigin(shown);
|
|
||||||
// The interpreter that actually ran it, or the remote tool a capability call
|
// The interpreter that actually ran it, or the remote tool a capability call
|
||||||
// resolved to. Anything else the name already said.
|
// resolved to. Anything else the name already said.
|
||||||
const tag = tagFor(tool);
|
const tag = tagFor(tool);
|
||||||
@@ -93,44 +106,85 @@ export function ToolCard({
|
|||||||
// one claim — done, still going, or stuck — so the card says the claim rather
|
// one claim — done, still going, or stuck — so the card says the claim rather
|
||||||
// than the object carrying it.
|
// than the object carrying it.
|
||||||
const goal = tool.name === "update_goal" ? goalUpdate(tool.args) : null;
|
const goal = tool.name === "update_goal" ? goalUpdate(tool.args) : null;
|
||||||
return (
|
const [open, setOpen] = useState(running || bad);
|
||||||
// data-call is the anchor the run graph lands on. The rail could already
|
useEffect(() => {
|
||||||
// find a user message; a tool call had no id in the DOM at all, so nothing
|
if (running || bad) setOpen(true);
|
||||||
// outside the transcript could point at one.
|
}, [running, bad]);
|
||||||
<div
|
const changes = changeCounts(tool);
|
||||||
className="call"
|
const hasBody = Boolean(
|
||||||
data-call={tool.id || undefined}
|
takeover || tool.diff || goal || tool.name === "todo_write" ||
|
||||||
data-k={KINDED.has(categoryOf(shown)) ? categoryOf(shown) : undefined}
|
(tool.output && !echoed && children.length === 0) || tool.err || children.length,
|
||||||
data-running={running ? "" : undefined}
|
);
|
||||||
>
|
const activityArgs = activity ? prettyArgs(tool.args) : "";
|
||||||
<div className="g">
|
const activityResult = Boolean(
|
||||||
<Sym glyph={glyphFor(shown)} done={settling.pop && !bad} />
|
takeover || tool.diff || goal || tool.name === "todo_write" || tool.output || tool.err || children.length,
|
||||||
<span className="line" />
|
);
|
||||||
</div>
|
const heading = (
|
||||||
<div className="c">
|
<>
|
||||||
<div className="hl" data-swap={settling.swap ? "" : undefined}>
|
{/* 名字是给人读的,id 是给人查的。标签只在它说了名字没说的事时才占地方
|
||||||
{/* 名字是给人读的,id 是给人查的。标签只在它说了名字没说的事时才占地方
|
(见 tagFor),所以精确的那个字符串挂在这里,一直够得着。 */}
|
||||||
(见 tagFor),所以精确的那个字符串挂在这里,一直够得着。 */}
|
<span className={running ? "nm shim" : "nm"} title={shown}>{head}</span>
|
||||||
<span className={running ? "nm shim" : "nm"} title={shown}>{head}</span>
|
{who && <span className="who" title={t("按技能 {name} 的设定运行的子代理", { name: who })}>{who}</span>}
|
||||||
{who && <span className="who" title={t("按技能 {name} 的设定运行的子代理", { name: who })}>{who}</span>}
|
{from && <span className="src" title={t("外部服务 {name} 提供的工具", { name: from.server })}>{from.server}</span>}
|
||||||
{from && <span className="src" title={t("外部服务 {name} 提供的工具", { name: from.server })}>{from.server}</span>}
|
{tag && <span className="tag" title={tagHint(tool)}>{tag}</span>}
|
||||||
{tag && <span className="tag" title={tagHint(tool)}>{tag}</span>}
|
{arg && <span className={streaming ? "arg shim" : "arg"}>{arg}</span>}
|
||||||
{arg && <span className={streaming ? "arg shim" : "arg"}>{arg}</span>}
|
{changes && (
|
||||||
{bad && <span className="fail">{badLabel}</span>}
|
<span className="tool-delta" aria-label={t("新增 {added} 行,删除 {removed} 行", changes)}>
|
||||||
<Cost tools={[tool]} running={running} />
|
<b>+{changes.added}</b><i>−{changes.removed}</i>
|
||||||
</div>
|
</span>
|
||||||
<div className="out">
|
)}
|
||||||
{/* A takeover replaces what the body shows, never the frame around
|
{bad && <span className="fail">{badLabel}</span>}
|
||||||
it: the tool's name, its status and the attribution below stay
|
<Cost tools={[tool]} running={running} />
|
||||||
the host's, so a redrawn card is still recognisably this call and
|
<span
|
||||||
still visibly the extension's work. */}
|
className="tool-state"
|
||||||
{takeover ? (
|
data-state={running ? "running" : bad ? "failed" : "done"}
|
||||||
<>
|
aria-label={t(running ? "运行中" : bad ? "失败" : "已完成")}
|
||||||
<ExtensionView body={takeover.view?.body ?? []} onAction={(id) => onExtInvoke?.(id)} />
|
title={t(running ? "运行中" : bad ? "失败" : "已完成")}
|
||||||
<div className="drawnby">{t("由 {name} 渲染", { name: takeover.pluginId })}</div>
|
>
|
||||||
</>
|
<StudioIcon name={running ? "clock" : bad ? "warning" : "check"} />
|
||||||
) : (
|
</span>
|
||||||
<>
|
{hasBody && <span className="tool-fold" aria-hidden="true" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const body = (
|
||||||
|
<div className="out">
|
||||||
|
{activity && (
|
||||||
|
<>
|
||||||
|
<div className="activity-call-meta">
|
||||||
|
<span>{t("调用 {id}", { id: shortCallId(tool.id) })}</span>
|
||||||
|
<span>{t(running ? "运行中" : bad ? "失败" : "已完成")}</span>
|
||||||
|
</div>
|
||||||
|
{activityArgs && (
|
||||||
|
<section className="activity-call-input">
|
||||||
|
<header><b>{t("输入")}</b><span>JSON</span></header>
|
||||||
|
<pre>{activityArgs}</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{activityResult && (
|
||||||
|
<div className="activity-call-result">
|
||||||
|
<b>{t("结果")}</b>
|
||||||
|
{tool.output && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="tool.copy-output"
|
||||||
|
onClick={() => void navigator.clipboard.writeText(tool.output ?? "").catch(() => {})}
|
||||||
|
>{t("复制")}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* A takeover replaces what the body shows, never the frame around
|
||||||
|
it: the tool's name, its status and the attribution below stay
|
||||||
|
the host's, so a redrawn card is still recognisably this call and
|
||||||
|
still visibly the extension's work. */}
|
||||||
|
{takeover ? (
|
||||||
|
<>
|
||||||
|
<ExtensionView body={takeover.view?.body ?? []} onAction={(id) => onExtInvoke?.(id)} />
|
||||||
|
<div className="drawnby">{t("由 {name} 渲染", { name: takeover.pluginId })}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{tool.diff && (
|
{tool.diff && (
|
||||||
<DiffView
|
<DiffView
|
||||||
diff={tool.diff}
|
diff={tool.diff}
|
||||||
@@ -149,43 +203,75 @@ export function ToolCard({
|
|||||||
{!tool.diff && !goal && tool.name !== "todo_write" && tool.output && !echoed && children.length === 0 && (
|
{!tool.diff && !goal && tool.name !== "todo_write" && tool.output && !echoed && children.length === 0 && (
|
||||||
<ToolOutput name={shown} text={tool.output} bound={tool.bound} id={tool.id} />
|
<ToolOutput name={shown} text={tool.output} bound={tool.bound} id={tool.id} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* The error stays outside the takeover: an extension may redraw what
|
{/* The error stays outside the takeover: an extension may redraw what
|
||||||
a call produced, never whether it failed. */}
|
a call produced, never whether it failed. */}
|
||||||
{tool.err && <div className="txt bad">{tool.err}</div>}
|
{tool.err && <div className="txt bad">{tool.err}</div>}
|
||||||
{children.length > 0 && (
|
{children.length > 0 && (
|
||||||
<div className="nest">
|
<div className="nest">
|
||||||
<div className="nest-hd">
|
<div className="nest-hd">
|
||||||
<i className="pip" />
|
<i className="pip" />
|
||||||
<span className="who">{nestLabel(who, tool.profile?.count, children.length)}</span>
|
<span className="who">{nestLabel(who, tool.profile?.count, children.length)}</span>
|
||||||
<span className="prof">{t("独立上下文 · 不进主轨迹")}</span>
|
<span className="prof">{t("独立上下文 · 不进主轨迹")}</span>
|
||||||
{/* 委派出去那部分的账:耗时是父调用的,token 是子步骤各自留下的 */}
|
{/* 委派出去那部分的账:耗时是父调用的,token 是子步骤各自留下的 */}
|
||||||
<span className="rt">
|
<span className="rt">
|
||||||
{[
|
{[
|
||||||
tool.durationMs ? seconds(tool.durationMs) : "",
|
tool.durationMs ? seconds(tool.durationMs) : "",
|
||||||
childTokens(children) ? tokens(childTokens(children)) : "",
|
childTokens(children) ? tokens(childTokens(children)) : "",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ")}
|
.join(" · ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="nest-bd">
|
<div className="nest-bd">
|
||||||
{children.map((c) => (
|
{children.map((c) => (
|
||||||
<NestedCall key={c.id} tool={c} />
|
<NestedCall key={c.id} tool={c} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* What the delegate handed back closes its own panel: outside it,
|
{/* What the delegate handed back closes its own panel: outside it,
|
||||||
the sentence reads as the main run's conclusion. */}
|
the sentence reads as the main run's conclusion. */}
|
||||||
{tool.output && <div className="nest-ret">{tool.output}</div>}
|
{tool.output && <div className="nest-ret">{tool.output}</div>}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
// data-call is the anchor the run graph lands on. The rail could already
|
||||||
|
// find a user message; a tool call had no id in the DOM at all, so nothing
|
||||||
|
// outside the transcript could point at one.
|
||||||
|
<div
|
||||||
|
className="call"
|
||||||
|
data-call={tool.id || undefined}
|
||||||
|
data-k={KINDED.has(categoryOf(shown)) ? categoryOf(shown) : undefined}
|
||||||
|
data-running={running ? "" : undefined}
|
||||||
|
>
|
||||||
|
<div className="g">
|
||||||
|
<Sym glyph={glyphFor(shown)} done={settling.pop && !bad} />
|
||||||
|
<span className="line" />
|
||||||
|
</div>
|
||||||
|
<div className="c">
|
||||||
|
{hasBody ? (
|
||||||
|
<details className="tool-disclosure" open={open} onToggle={(event) => setOpen(event.currentTarget.open)}>
|
||||||
|
<summary className="hl" data-swap={settling.swap ? "" : undefined}>{heading}</summary>
|
||||||
|
{body}
|
||||||
|
</details>
|
||||||
|
) : (
|
||||||
|
<div className="hl" data-swap={settling.swap ? "" : undefined}>{heading}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeCounts(tool: Tool): { added: number; removed: number } | null {
|
||||||
|
if (!tool.diff && tool.added === undefined && tool.removed === undefined) return null;
|
||||||
|
const lines = tool.diff?.split("\n") ?? [];
|
||||||
|
const added = tool.added ?? lines.filter((line) => line.startsWith("+") && !line.startsWith("+++")).length;
|
||||||
|
const removed = tool.removed ?? lines.filter((line) => line.startsWith("-") && !line.startsWith("---")).length;
|
||||||
|
return { added, removed };
|
||||||
|
}
|
||||||
|
|
||||||
function NestedCall({ tool }: { tool: Tool }) {
|
function NestedCall({ tool }: { tool: Tool }) {
|
||||||
const shown = tool.resolvedName || tool.name;
|
const shown = tool.resolvedName || tool.name;
|
||||||
const tag = tagFor(tool);
|
const tag = tagFor(tool);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { ReadsCard } from "./ReadsCard";
|
import { ReadsCard } from "./ReadsCard";
|
||||||
import { ToolCard } from "./ToolCard";
|
import { ToolCard } from "./ToolCard";
|
||||||
|
|
||||||
@@ -22,6 +22,38 @@ describe("tool outcome cards", () => {
|
|||||||
expect(screen.getByText("失败")).toBeTruthy();
|
expect(screen.getByText("失败")).toBeTruthy();
|
||||||
expect(screen.getByText("boom")).toBeTruthy();
|
expect(screen.getByText("boom")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a completed result compact until its row is opened", () => {
|
||||||
|
const { container } = render(<ToolCard tool={{ id: "ok", name: "bash", args: '{"command":"npm test"}', output: "passed", readOnly: false }} running={false} />);
|
||||||
|
const disclosure = container.querySelector("details.tool-disclosure") as HTMLDetailsElement;
|
||||||
|
expect(disclosure.open).toBe(false);
|
||||||
|
expect(screen.getByText("运行命令")).toBeTruthy();
|
||||||
|
fireEvent.click(disclosure.querySelector("summary")!);
|
||||||
|
expect(disclosure.open).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarises edit size before the diff is opened", () => {
|
||||||
|
render(<ToolCard tool={{ id: "edit", name: "edit_file", args: '{"path":"src/App.tsx"}', diff: "+new\n-old", readOnly: false }} running={false} />);
|
||||||
|
expect(screen.getByLabelText("新增 1 行,删除 1 行").textContent).toBe("+1−1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the prototype's compact audit row and structured details inside activity", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ToolCard
|
||||||
|
activity
|
||||||
|
tool={{ id: "call_example123", name: "read_file", args: '{"path":"README.md"}', output: "hello", readOnly: true }}
|
||||||
|
running={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("read_file")).toBeTruthy();
|
||||||
|
const disclosure = container.querySelector("details.tool-disclosure") as HTMLDetailsElement;
|
||||||
|
fireEvent.click(disclosure.querySelector("summary")!);
|
||||||
|
expect(disclosure.open).toBe(true);
|
||||||
|
expect(screen.getByText("调用 ample123")).toBeTruthy();
|
||||||
|
expect(screen.getByText("输入")).toBeTruthy();
|
||||||
|
expect(screen.getByText("结果")).toBeTruthy();
|
||||||
|
expect(screen.getAllByText(/README\.md/)).toHaveLength(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// A command that runs for half a minute drew one line of text and a 1.9s pulse
|
// A command that runs for half a minute drew one line of text and a 1.9s pulse
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ describe("what the composer shows when nothing is unusual", () => {
|
|||||||
it("keeps policy discoverable without default-state noise", () => {
|
it("keeps policy discoverable without default-state noise", () => {
|
||||||
const { container } = draw();
|
const { container } = draw();
|
||||||
expect(container.querySelector(".policy[data-quiet]")).toBeTruthy();
|
expect(container.querySelector(".policy[data-quiet]")).toBeTruthy();
|
||||||
expect(container.querySelector('[data-action="chrome.policy"]')?.getAttribute("aria-label")).toBe("本轮执行策略");
|
expect(container.querySelector('[data-action="chrome.policy"]')?.getAttribute("aria-label")).toBe("执行权限:逐项确认");
|
||||||
expect(container.querySelector('[data-action="chrome.policy"]')?.textContent).not.toMatch(/均衡|询问/);
|
expect(container.querySelector('[data-action="chrome.policy"]')?.textContent).toMatch(/逐项确认/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A status may recede to its baseline. The only way into a mode may not: the
|
// A status may recede to its baseline. The only way into a mode may not: the
|
||||||
@@ -58,14 +58,19 @@ describe("what the composer shows when nothing is unusual", () => {
|
|||||||
expect(draw().seps()).toBe(1);
|
expect(draw().seps()).toBe(1);
|
||||||
expect(draw(status({ preset: "delivery" as Preset })).seps()).toBe(1);
|
expect(draw(status({ preset: "delivery" as Preset })).seps()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not ask people to choose an internal completion policy", () => {
|
||||||
|
const { container } = draw(status({ preset: "delivery" as Preset }));
|
||||||
|
expect(container.querySelector('[data-action="chrome.preset"]')).toBeNull();
|
||||||
|
expect(container.textContent).not.toMatch(/均衡|交付/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("every deviation is visible", () => {
|
describe("every deviation is visible", () => {
|
||||||
it.each([
|
it.each([
|
||||||
|
|
||||||
["preset", status({ preset: "delivery" as Preset }), /交付/],
|
|
||||||
["effort", status({ effort: "high" }), /High/],
|
["effort", status({ effort: "high" }), /High/],
|
||||||
["approval", status({ toolApprovalMode: "auto" as ApprovalMode }), /自动批准/],
|
["approval", status({ toolApprovalMode: "auto" as ApprovalMode }), /自动继续/],
|
||||||
["a stricter approval", status({ toolApprovalMode: "dontAsk" as ApprovalMode }), /不询问/],
|
["a stricter approval", status({ toolApprovalMode: "dontAsk" as ApprovalMode }), /不询问/],
|
||||||
])("surfaces %s", (_what, st, want) => {
|
])("surfaces %s", (_what, st, want) => {
|
||||||
const { container } = draw(st);
|
const { container } = draw(st);
|
||||||
@@ -80,9 +85,9 @@ describe("every deviation is visible", () => {
|
|||||||
|
|
||||||
it("surfaces several at once without losing any", () => {
|
it("surfaces several at once without losing any", () => {
|
||||||
const { container } = draw(
|
const { container } = draw(
|
||||||
status({ preset: "delivery" as Preset, effort: "high", toolApprovalMode: "yolo" as ApprovalMode, plan: true }),
|
status({ effort: "high", toolApprovalMode: "yolo" as ApprovalMode, plan: true }),
|
||||||
);
|
);
|
||||||
for (const want of [/交付/, /High/, /全部放行/, /计划/]) expect(container.textContent).toMatch(want);
|
for (const want of [/High/, /全部放行/, /计划/]) expect(container.textContent).toMatch(want);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
// The spec names a call by what it is — Search, Update, Read — and derives the
|
// The spec names a call by what it is — Search, Update, Read — and derives the
|
||||||
// running line from its category, so a new tool needs no new copy. The raw id
|
// running line from its category, so a new tool needs no new copy. The raw id
|
||||||
// is still on the row, in the tag beside the name.
|
// is still on the row, in the tag beside the name.
|
||||||
const LABEL: Record<string, string> = {
|
const LABEL: Record<string, string> = {
|
||||||
web_search: "Search", web_fetch: "Fetch", task: "Task", bash: "Bash",
|
web_search: "网页搜索", web_fetch: "获取网页", task: "委派任务", bash: "运行命令",
|
||||||
bash_output: "Bash 输出", kill_shell: "Kill", wait: "Wait",
|
bash_output: "命令输出", kill_shell: "停止命令", wait: "等待任务",
|
||||||
read_file: "Read", grep: "Search", glob: "Glob", ls: "List",
|
read_file: "读取文件", grep: "搜索代码", glob: "查找文件", ls: "列出目录",
|
||||||
edit_file: "Update", write_file: "Write", multi_edit: "MultiEdit",
|
edit_file: "编辑文件", write_file: "写入文件", multi_edit: "编辑多个文件",
|
||||||
todo_write: "Plan", remember: "Remember", use_capability: "MCP",
|
todo_write: "更新计划", remember: "保存记忆", use_capability: "调用 MCP",
|
||||||
code_index: "Index", complete_step: "Step", review_report: "Review",
|
code_index: "建立索引", complete_step: "完成步骤", review_report: "审阅结果",
|
||||||
update_goal: "Goal", guardian_assessment: "Guardian", compress: "Compress",
|
update_goal: "更新目标", guardian_assessment: "安全检查", compress: "压缩上下文",
|
||||||
// Checked against the kernel's registered names, not guessed: these all ship
|
// Checked against the kernel's registered names, not guessed: these all ship
|
||||||
// and were rendering as their raw id under a default icon.
|
// and were rendering as their raw id under a default icon.
|
||||||
delete_range: "Delete", delete_symbol: "Delete", move_file: "Migrate",
|
delete_range: "删除内容", delete_symbol: "删除符号", move_file: "移动文件",
|
||||||
notebook_edit: "Notebook", submit_plan: "Plan", ask: "Ask",
|
notebook_edit: "编辑笔记本", submit_plan: "提交计划", ask: "询问用户",
|
||||||
fleet: "Fleet", read_only_task: "Task", read_subagent_result: "Result",
|
fleet: "并行任务", read_only_task: "只读任务", read_subagent_result: "读取子任务结果",
|
||||||
complete_subtask: "Subtask", lsp_diagnostics: "Diagnostics",
|
complete_subtask: "完成子任务", lsp_diagnostics: "代码诊断",
|
||||||
};
|
};
|
||||||
|
|
||||||
const RUNNING: Record<string, string> = {
|
const RUNNING: Record<string, string> = {
|
||||||
@@ -28,7 +30,7 @@ const RUNNING: Record<string, string> = {
|
|||||||
// resolved to belongs in the mono tag beside it — and anything else with no
|
// resolved to belongs in the mono tag beside it — and anything else with no
|
||||||
// entry here is title-cased rather than passed through.
|
// entry here is title-cased rather than passed through.
|
||||||
export const labelFor = (tool: string) =>
|
export const labelFor = (tool: string) =>
|
||||||
LABEL[tool] ?? (isCapability(tool) ? "MCP" : titleCase(tool));
|
LABEL[tool] ? t(LABEL[tool]) : (isCapability(tool) ? t("调用 MCP") : titleCase(tool));
|
||||||
|
|
||||||
export const isCapability = (tool: string) => tool === "use_capability" || tool.startsWith("mcp__");
|
export const isCapability = (tool: string) => tool === "use_capability" || tool.startsWith("mcp__");
|
||||||
|
|
||||||
@@ -40,7 +42,7 @@ const titleCase = (id: string) =>
|
|||||||
.join(" ") || id;
|
.join(" ") || id;
|
||||||
|
|
||||||
export function runLabelFor(tool: string) {
|
export function runLabelFor(tool: string) {
|
||||||
return RUNNING[categoryOf(tool)] ?? "正在处理…";
|
return t(RUNNING[categoryOf(tool)] ?? "正在处理…");
|
||||||
}
|
}
|
||||||
|
|
||||||
// A tool that changes the tree has to read as one. delete_range, delete_symbol,
|
// A tool that changes the tree has to read as one. delete_range, delete_symbol,
|
||||||
|
|||||||
@@ -29,5 +29,12 @@ export function modelMenu(models: ModelEntry[]): MenuItem[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out.push({
|
||||||
|
value: "__manage-models",
|
||||||
|
label: t("管理模型与连接"),
|
||||||
|
desc: t("服务商、API 与可用模型"),
|
||||||
|
plain: true,
|
||||||
|
divide: out.length > 0,
|
||||||
|
});
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -9,162 +9,63 @@ import type { AgentPort, SessionStatus } from "../port/port";
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const STATUS = { preset: "balanced", effort: "medium", toolApprovalMode: "ask" } as SessionStatus;
|
||||||
|
|
||||||
function deferred<T>() {
|
function deferred<T>() {
|
||||||
let settle!: (v: T) => void;
|
let settle!: (value: T) => void;
|
||||||
let fail!: (e: unknown) => void;
|
let fail!: (error: unknown) => void;
|
||||||
const promise = new Promise<T>((res, rej) => {
|
const promise = new Promise<T>((resolve, reject) => { settle = resolve; fail = reject; });
|
||||||
settle = res;
|
|
||||||
fail = rej;
|
|
||||||
});
|
|
||||||
// A promise nobody rejects still counts as unhandled if the component drops
|
|
||||||
// it, so the test has to be the one holding it.
|
|
||||||
promise.catch(() => {});
|
promise.catch(() => {});
|
||||||
return { promise, settle, fail };
|
return { promise, settle, fail };
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS = { preset: "balanced", effort: "medium", toolApprovalMode: "ask" } as SessionStatus;
|
function draw(calls: Partial<AgentPort> = {}) {
|
||||||
|
|
||||||
function draw(calls: Partial<AgentPort> = {}, status: SessionStatus = STATUS) {
|
|
||||||
const onChanged = vi.fn();
|
const onChanged = vi.fn();
|
||||||
const port = {
|
const port = {
|
||||||
setPreset: vi.fn(async () => {}),
|
|
||||||
setEffort: vi.fn(async () => {}),
|
|
||||||
setApprovalMode: vi.fn(async () => {}),
|
setApprovalMode: vi.fn(async () => {}),
|
||||||
...calls,
|
...calls,
|
||||||
} as unknown as AgentPort;
|
} as unknown as AgentPort;
|
||||||
const view = render(
|
const view = render(<Policy port={port} status={STATUS} onChanged={onChanged} />);
|
||||||
<Policy port={port} status={status} efforts={["auto", "medium", "high"]} onChanged={onChanged} />,
|
const open = () => userEvent.click(screen.getByRole("button", { name: "执行权限:逐项确认" }));
|
||||||
);
|
const option = (name: string) => within(screen.getByRole("group", { name: "执行权限" })).getByRole("button", { name: new RegExp(`^${name}`) });
|
||||||
const open = () => userEvent.click(screen.getByRole("button", { expanded: false }));
|
return { ...view, port, onChanged, open, option };
|
||||||
const at = (field: string, name: string) =>
|
|
||||||
within(screen.getByRole("group", { name: field })).getByRole("button", { name });
|
|
||||||
const field = (name: string) => screen.getByRole("group", { name });
|
|
||||||
return { port, onChanged, view, open, at, field, status };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("the turn policy the composer owns", () => {
|
describe("execution permission changes", () => {
|
||||||
it("says what is unusual about this turn before it is opened", () => {
|
it("changes only the approval mode", async () => {
|
||||||
draw();
|
const { open, option, port } = draw();
|
||||||
// What deviates, and only that: the shelf answers "how will the next turn
|
|
||||||
// run" without being opened, and one that recites the defaults forever has
|
|
||||||
// no attention left for the turn where something is actually different.
|
|
||||||
// medium is a rung nobody asked for by default, so it shows; balanced and
|
|
||||||
// ask are what a session already runs as, so they do not.
|
|
||||||
expect(screen.getByRole("button", { expanded: false }).textContent).toBe("Medium");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("asks the kernel once, and does not move the selection on its own", async () => {
|
|
||||||
const call = deferred<void>();
|
|
||||||
const setPreset = vi.fn(() => call.promise);
|
|
||||||
const { at, open, port } = draw({ setPreset });
|
|
||||||
|
|
||||||
await open();
|
await open();
|
||||||
await userEvent.click(at("执行方式", "交付"));
|
await userEvent.click(option("自动继续"));
|
||||||
expect(setPreset).toHaveBeenCalledTimes(1);
|
|
||||||
expect(setPreset).toHaveBeenCalledWith("delivery");
|
|
||||||
// Still the kernel's answer, not the click's: nothing has come back yet.
|
|
||||||
expect(at("执行方式", "均衡").getAttribute("aria-pressed")).toBe("true");
|
|
||||||
expect(at("执行方式", "交付").getAttribute("aria-pressed")).toBe("false");
|
|
||||||
// One field, one effect. Drawing three fields in one panel must not turn
|
|
||||||
// three kernel operations into one call.
|
|
||||||
expect(port.setEffort).not.toHaveBeenCalled();
|
|
||||||
expect(port.setApprovalMode).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the call is out and refuses a second one on that field", async () => {
|
|
||||||
const call = deferred<void>();
|
|
||||||
const setPreset = vi.fn(() => call.promise);
|
|
||||||
const { at, open } = draw({ setPreset });
|
|
||||||
|
|
||||||
await open();
|
|
||||||
await userEvent.click(at("执行方式", "交付"));
|
|
||||||
expect(at("执行方式", "交付").hasAttribute("data-asking")).toBe(true);
|
|
||||||
expect((at("执行方式", "交付") as HTMLButtonElement).disabled).toBe(true);
|
|
||||||
expect((at("执行方式", "均衡") as HTMLButtonElement).disabled).toBe(true);
|
|
||||||
|
|
||||||
await userEvent.click(at("执行方式", "均衡"));
|
|
||||||
expect(setPreset).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The reason the three live in one panel and not one control: a panel that
|
|
||||||
// goes grey while one value is in flight says "nothing here can be changed",
|
|
||||||
// and that was never true.
|
|
||||||
it("leaves the other fields alone while one is waiting", async () => {
|
|
||||||
const call = deferred<void>();
|
|
||||||
const setPreset = vi.fn(() => call.promise);
|
|
||||||
const { at, open, port } = draw({ setPreset });
|
|
||||||
|
|
||||||
await open();
|
|
||||||
await userEvent.click(at("执行方式", "交付"));
|
|
||||||
expect((at("思考强度", "high") as HTMLButtonElement).disabled).toBe(false);
|
|
||||||
expect((at("工具权限", "自动") as HTMLButtonElement).disabled).toBe(false);
|
|
||||||
|
|
||||||
await userEvent.click(at("思考强度", "high"));
|
|
||||||
await userEvent.click(at("工具权限", "自动"));
|
|
||||||
expect(port.setEffort).toHaveBeenCalledWith("high");
|
|
||||||
expect(port.setApprovalMode).toHaveBeenCalledWith("auto");
|
expect(port.setApprovalMode).toHaveBeenCalledWith("auto");
|
||||||
// And the one still out has not been asked a second time by either.
|
|
||||||
expect(setPreset).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hands the selection back to the kernel's own answer when it lands", async () => {
|
it("does not submit a second permission while the first is pending", async () => {
|
||||||
const call = deferred<void>();
|
const call = deferred<void>();
|
||||||
const { at, open, onChanged, view, port } = draw({ setPreset: () => call.promise });
|
const setApprovalMode = vi.fn(() => call.promise);
|
||||||
|
const { open, option } = draw({ setApprovalMode });
|
||||||
await open();
|
await open();
|
||||||
await userEvent.click(at("执行方式", "交付"));
|
await userEvent.click(option("自动继续"));
|
||||||
|
expect((option("逐项确认") as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
await userEvent.click(option("逐项确认"));
|
||||||
|
expect(setApprovalMode).toHaveBeenCalledTimes(1);
|
||||||
call.settle();
|
call.settle();
|
||||||
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
|
|
||||||
// onChanged is what makes the pane re-read /status; the selection follows
|
|
||||||
// that read, which is why it is the prop and not local state.
|
|
||||||
view.rerender(
|
|
||||||
<Policy
|
|
||||||
port={port}
|
|
||||||
status={{ ...STATUS, preset: "delivery" } as SessionStatus}
|
|
||||||
efforts={["auto", "medium", "high"]}
|
|
||||||
onChanged={onChanged}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(at("执行方式", "交付").getAttribute("aria-pressed")).toBe("true");
|
|
||||||
expect((at("执行方式", "交付") as HTMLButtonElement).disabled).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the old value and says why, next to the field that was refused", async () => {
|
it("keeps the committed value and explains a refusal", async () => {
|
||||||
const call = deferred<void>();
|
const call = deferred<void>();
|
||||||
const { at, field, open, onChanged } = draw({ setEffort: () => call.promise });
|
const { open, option, container, onChanged } = draw({ setApprovalMode: () => call.promise });
|
||||||
|
|
||||||
await open();
|
await open();
|
||||||
await userEvent.click(at("思考强度", "high"));
|
await userEvent.click(option("全部放行"));
|
||||||
call.fail(new HttpError(409, "a turn is running", { code: "busy.switch_model" }));
|
call.fail(new HttpError(409, "a turn is running", { code: "busy.switch_model" }));
|
||||||
|
await waitFor(() => expect(container.querySelector('[role="alert"]')).toBeTruthy());
|
||||||
await waitFor(() => expect(field("思考强度").parentElement?.querySelector(".segbad")).toBeTruthy());
|
expect(option("逐项确认").getAttribute("aria-pressed")).toBe("true");
|
||||||
const said = field("思考强度").parentElement?.querySelector(".segbad");
|
|
||||||
expect(said?.textContent).toContain("任务正在运行");
|
|
||||||
// The refusal belongs to the field it was about, and to no other.
|
|
||||||
expect(field("执行方式").parentElement?.querySelector(".segbad")).toBeNull();
|
|
||||||
expect(at("思考强度", "medium").getAttribute("aria-pressed")).toBe("true");
|
|
||||||
// Usable again, and nothing told the pane to go and re-read a change that
|
|
||||||
// did not happen.
|
|
||||||
expect((at("思考强度", "high") as HTMLButtonElement).disabled).toBe(false);
|
|
||||||
expect(onChanged).not.toHaveBeenCalled();
|
expect(onChanged).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The kernel omits the field exactly when it would refuse every rung but one.
|
it("refreshes status after the kernel accepts the choice", async () => {
|
||||||
it("draws no ladder for an endpoint that names no levels", async () => {
|
const { open, option, onChanged } = draw();
|
||||||
const onChanged = vi.fn();
|
await open();
|
||||||
render(
|
await userEvent.click(option("不询问"));
|
||||||
<Policy
|
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
|
||||||
port={{ setPreset: async () => {} } as unknown as AgentPort}
|
|
||||||
// A posture that deviates, so there is a control to open at all: with
|
|
||||||
// no rungs published the effort is baseline, and so is the rest of
|
|
||||||
// STATUS.
|
|
||||||
status={{ ...STATUS, preset: "delivery" } as SessionStatus}
|
|
||||||
efforts={[]}
|
|
||||||
onChanged={onChanged}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await userEvent.click(screen.getByRole("button", { expanded: false }));
|
|
||||||
expect(screen.queryByRole("group", { name: "思考强度" })).toBeNull();
|
|
||||||
expect(screen.getByRole("group", { name: "工具权限" })).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -176,11 +176,11 @@ export interface SettingEntry {
|
|||||||
// what it really renders — a block with no row here fails, and a row nothing
|
// what it really renders — a block with no row here fails, and a row nothing
|
||||||
// renders fails too.
|
// renders fails too.
|
||||||
export const SETTINGS: SettingEntry[] = [
|
export const SETTINGS: SettingEntry[] = [
|
||||||
// Runtime only, both of them: POST /preset and POST /plan reach the
|
// The kernel still accepts legacy preset values for old clients and saved
|
||||||
// controller and write nothing, so a new session starts at the default. The
|
// sessions, but Studio presents one adaptive completion standard. Asking
|
||||||
// two rows below them on the model and tools pages look identical on screen
|
// people to predict the amount of verification before writing the task made
|
||||||
// and do persist — which is the whole reason this column exists.
|
// an internal policy look like a required work mode.
|
||||||
{ section: "session", anchor: "preset", title: "执行设定", scope: "session", apply: "immediate", keywords: ["均衡", "交付", "完成判定"] },
|
{ section: "session", anchor: "preset", title: "完成标准", scope: "session", apply: "immediate", keywords: ["自动", "验证", "复核", "完成判定"] },
|
||||||
{ section: "session", anchor: "plan-mode", title: "计划模式", scope: "session", apply: "immediate", keywords: ["只读", "先规划"] },
|
{ section: "session", anchor: "plan-mode", title: "计划模式", scope: "session", apply: "immediate", keywords: ["只读", "先规划"] },
|
||||||
{ section: "session", anchor: "session-dir", title: "会话写入位置", scope: "workspace", apply: "none", keywords: ["工作目录", "路径"] },
|
{ section: "session", anchor: "session-dir", title: "会话写入位置", scope: "workspace", apply: "none", keywords: ["工作目录", "路径"] },
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export interface Primitive {
|
|||||||
export const PRIMITIVES: Primitive[] = [
|
export const PRIMITIVES: Primitive[] = [
|
||||||
{ file: "ui/Switch.tsx", callbacks: { onClick: "click" } },
|
{ file: "ui/Switch.tsx", callbacks: { onClick: "click" } },
|
||||||
{ file: "ui/Menu.tsx", callbacks: { onPick: "click" } },
|
{ file: "ui/Menu.tsx", callbacks: { onPick: "click" } },
|
||||||
{ file: "ui/Seg.tsx", callbacks: { onClick: "click" } },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** A primitive is the file, wherever the tree being scanned is rooted. The
|
/** A primitive is the file, wherever the tree being scanned is rooted. The
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ type BranchMeta struct {
|
|||||||
TopicID string `json:"topic_id,omitempty"`
|
TopicID string `json:"topic_id,omitempty"`
|
||||||
TopicTitle string `json:"topic_title,omitempty"`
|
TopicTitle string `json:"topic_title,omitempty"`
|
||||||
CustomTitle string `json:"custom_title,omitempty"`
|
CustomTitle string `json:"custom_title,omitempty"`
|
||||||
Model string `json:"model,omitempty"`
|
// Archived removes a finished conversation from the everyday session list
|
||||||
|
// without moving or deleting its transcript. Keeping this as metadata makes
|
||||||
|
// archive/unarchive atomic and preserves every sidecar needed to resume it.
|
||||||
|
Archived bool `json:"archived,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
// TokenMode is the legacy dual-write value (economy|full|delivery). Prefer
|
// TokenMode is the legacy dual-write value (economy|full|delivery). Prefer
|
||||||
// AgentPreset (balanced|delivery) when both are present.
|
// AgentPreset (balanced|delivery) when both are present.
|
||||||
TokenMode string `json:"token_mode,omitempty"`
|
TokenMode string `json:"token_mode,omitempty"`
|
||||||
@@ -345,6 +349,15 @@ func TouchBranchMeta(sessionPath string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSessionArchived changes only the catalog state of a transcript. The
|
||||||
|
// conversation and all of its artifacts stay in place so unarchive is lossless.
|
||||||
|
func SetSessionArchived(sessionPath string, archived bool) error {
|
||||||
|
return UpdateBranchMeta(sessionPath, false, func(m *BranchMeta) error {
|
||||||
|
m.Archived = archived
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func MarkSessionInFlightTurn(sessionPath string, startMessageIndex int, preserveUser bool) error {
|
func MarkSessionInFlightTurn(sessionPath string, startMessageIndex int, preserveUser bool) error {
|
||||||
_, err := BeginSessionInFlightTurn(sessionPath, startMessageIndex, preserveUser)
|
_, err := BeginSessionInFlightTurn(sessionPath, startMessageIndex, preserveUser)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -169,6 +169,33 @@ func TestBranchMetaRoundTripAndList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetSessionArchivedRoundTripsAndAppearsInListing(t *testing.T) {
|
||||||
|
dir := testenv.TempDir(t)
|
||||||
|
path := filepath.Join(dir, "kept.jsonl")
|
||||||
|
s := NewSession("")
|
||||||
|
s.Add(provider.Message{Role: provider.RoleUser, Content: "keep this conversation"})
|
||||||
|
if err := s.Save(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := SetSessionArchived(path, true); err != nil {
|
||||||
|
t.Fatalf("SetSessionArchived(true): %v", err)
|
||||||
|
}
|
||||||
|
ordered, err := ListSessionOrder(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ordered) != 1 || !ordered[0].Archived {
|
||||||
|
t.Fatalf("archived listing = %+v, want one archived session", ordered)
|
||||||
|
}
|
||||||
|
if err := SetSessionArchived(path, false); err != nil {
|
||||||
|
t.Fatalf("SetSessionArchived(false): %v", err)
|
||||||
|
}
|
||||||
|
meta, ok, err := LoadBranchMeta(path)
|
||||||
|
if err != nil || !ok || meta.Archived {
|
||||||
|
t.Fatalf("unarchived meta = %+v ok=%v err=%v", meta, ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestListBranchesSkipsCleanupPending(t *testing.T) {
|
func TestListBranchesSkipsCleanupPending(t *testing.T) {
|
||||||
dir := testenv.TempDir(t)
|
dir := testenv.TempDir(t)
|
||||||
visiblePath := filepath.Join(dir, "visible.jsonl")
|
visiblePath := filepath.Join(dir, "visible.jsonl")
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ the lack of execution tools; it is by design. Just plan what the executor
|
|||||||
should do with its tools.
|
should do with its tools.
|
||||||
|
|
||||||
When you need external real data and the capability route does not name a
|
When you need external real data and the capability route does not name a
|
||||||
specific tool, call use_capability(action="list") first to see configured MCP
|
specific tool, call use_capability(action="search") with the goal before saying
|
||||||
servers, then inspect or call a non-destructive capability. If a capability is
|
the capability is unavailable, then inspect or call a non-destructive result. If a capability is
|
||||||
destructive, do not treat that as missing configuration or an unavailable MCP:
|
destructive, do not treat that as missing configuration or an unavailable MCP:
|
||||||
write the operation into the plan for the executor instead.
|
write the operation into the plan for the executor instead.
|
||||||
|
|
||||||
|
|||||||
@@ -678,7 +678,7 @@ func (a *Agent) handleToolRound(ctx context.Context, state *turnRuntime, step in
|
|||||||
}
|
}
|
||||||
state.contextToolRepairs++
|
state.contextToolRepairs++
|
||||||
nudge := fmt.Sprintf("The following tools are unavailable in the current workflow phase: %s. Do not call them again. Respond to the user's request with visible answer text now; call a different tool only if it is still needed to complete the request.", strings.Join(unavailableContextTools, ", "))
|
nudge := fmt.Sprintf("The following tools are unavailable in the current workflow phase: %s. Do not call them again. Respond to the user's request with visible answer text now; call a different tool only if it is still needed to complete the request.", strings.Join(unavailableContextTools, ", "))
|
||||||
a.sess.conversation.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(nudge)})
|
a.sess.conversation.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(nudge), HostAuthored: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// The prompt only grows from here; compact before the next turn so it
|
// The prompt only grows from here; compact before the next turn so it
|
||||||
|
|||||||
@@ -1954,6 +1954,7 @@ func ListSessionOrder(dir string) ([]SessionOrderInfo, error) {
|
|||||||
topicID := ""
|
topicID := ""
|
||||||
topicTitle := ""
|
topicTitle := ""
|
||||||
customTitle := ""
|
customTitle := ""
|
||||||
|
archived := false
|
||||||
recovered := false
|
recovered := false
|
||||||
recoveryReason := ""
|
recoveryReason := ""
|
||||||
recoveryDigest := ""
|
recoveryDigest := ""
|
||||||
@@ -1976,6 +1977,7 @@ func ListSessionOrder(dir string) ([]SessionOrderInfo, error) {
|
|||||||
topicID = meta.TopicID
|
topicID = meta.TopicID
|
||||||
topicTitle = meta.TopicTitle
|
topicTitle = meta.TopicTitle
|
||||||
customTitle = meta.CustomTitle
|
customTitle = meta.CustomTitle
|
||||||
|
archived = meta.Archived
|
||||||
recovered = meta.Recovered
|
recovered = meta.Recovered
|
||||||
recoveryReason = meta.RecoveryReason
|
recoveryReason = meta.RecoveryReason
|
||||||
recoveryDigest = meta.RecoveryDigest
|
recoveryDigest = meta.RecoveryDigest
|
||||||
@@ -1997,6 +1999,7 @@ func ListSessionOrder(dir string) ([]SessionOrderInfo, error) {
|
|||||||
TopicID: topicID,
|
TopicID: topicID,
|
||||||
TopicTitle: topicTitle,
|
TopicTitle: topicTitle,
|
||||||
CustomTitle: customTitle,
|
CustomTitle: customTitle,
|
||||||
|
Archived: archived,
|
||||||
Recovered: recovered,
|
Recovered: recovered,
|
||||||
RecoveryReason: recoveryReason,
|
RecoveryReason: recoveryReason,
|
||||||
RecoveryDigest: recoveryDigest,
|
RecoveryDigest: recoveryDigest,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type SessionInfo struct {
|
|||||||
TopicID string
|
TopicID string
|
||||||
TopicTitle string
|
TopicTitle string
|
||||||
CustomTitle string
|
CustomTitle string
|
||||||
|
Archived bool
|
||||||
Recovered bool
|
Recovered bool
|
||||||
RecoveryReason string
|
RecoveryReason string
|
||||||
RecoveryDigest string
|
RecoveryDigest string
|
||||||
@@ -41,6 +42,7 @@ type SessionOrderInfo struct {
|
|||||||
TopicID string
|
TopicID string
|
||||||
TopicTitle string
|
TopicTitle string
|
||||||
CustomTitle string
|
CustomTitle string
|
||||||
|
Archived bool
|
||||||
Recovered bool
|
Recovered bool
|
||||||
RecoveryReason string
|
RecoveryReason string
|
||||||
RecoveryDigest string
|
RecoveryDigest string
|
||||||
@@ -73,6 +75,7 @@ func sessionInfoFromOrder(session SessionOrderInfo, preview string, turns int, c
|
|||||||
TopicID: session.TopicID,
|
TopicID: session.TopicID,
|
||||||
TopicTitle: session.TopicTitle,
|
TopicTitle: session.TopicTitle,
|
||||||
CustomTitle: session.CustomTitle,
|
CustomTitle: session.CustomTitle,
|
||||||
|
Archived: session.Archived,
|
||||||
Recovered: session.Recovered,
|
Recovered: session.Recovered,
|
||||||
RecoveryReason: session.RecoveryReason,
|
RecoveryReason: session.RecoveryReason,
|
||||||
RecoveryDigest: session.RecoveryDigest,
|
RecoveryDigest: session.RecoveryDigest,
|
||||||
|
|||||||
+10
-3
@@ -1093,7 +1093,8 @@ func (t *restrictedCapabilityProxy) check(args json.RawMessage) error {
|
|||||||
if err := json.Unmarshal(args, &p); err != nil {
|
if err := json.Unmarshal(args, &p); err != nil {
|
||||||
return fmt.Errorf("invalid args: %w", err)
|
return fmt.Errorf("invalid args: %w", err)
|
||||||
}
|
}
|
||||||
if strings.EqualFold(strings.TrimSpace(p.Action), "list") {
|
action := strings.ToLower(strings.TrimSpace(p.Action))
|
||||||
|
if action == "list" || action == "search" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
id := strings.TrimSpace(p.CapabilityID)
|
id := strings.TrimSpace(p.CapabilityID)
|
||||||
@@ -1118,8 +1119,11 @@ func (t *restrictedCapabilityProxy) ResolveCall(ctx context.Context, args json.R
|
|||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(args, &p)
|
_ = json.Unmarshal(args, &p)
|
||||||
if strings.EqualFold(strings.TrimSpace(p.Action), "list") && rc.SkipExecute {
|
action := strings.ToLower(strings.TrimSpace(p.Action))
|
||||||
|
if action == "list" && rc.SkipExecute {
|
||||||
rc.Result = filterCapabilityListResult(rc.Result, t.servers)
|
rc.Result = filterCapabilityListResult(rc.Result, t.servers)
|
||||||
|
} else if action == "search" && rc.SkipExecute {
|
||||||
|
rc.Result = filterCapabilitySearchResult(rc.Result, t.allowed)
|
||||||
}
|
}
|
||||||
return rc, nil
|
return rc, nil
|
||||||
}
|
}
|
||||||
@@ -1136,8 +1140,11 @@ func (t *restrictedCapabilityProxy) Execute(ctx context.Context, args json.RawMe
|
|||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(args, &p)
|
_ = json.Unmarshal(args, &p)
|
||||||
if strings.EqualFold(strings.TrimSpace(p.Action), "list") {
|
action := strings.ToLower(strings.TrimSpace(p.Action))
|
||||||
|
if action == "list" {
|
||||||
return filterCapabilityListResult(out, t.servers), nil
|
return filterCapabilityListResult(out, t.servers), nil
|
||||||
|
} else if action == "search" {
|
||||||
|
return filterCapabilitySearchResult(out, t.allowed), nil
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-114
@@ -533,23 +533,25 @@ func (*UseCapabilityTool) Name() string { return "use_capability" }
|
|||||||
var CapabilityIDExamples = []string{"tool:grep", "skill:review", "task:subagent", "task:fleet"}
|
var CapabilityIDExamples = []string{"tool:grep", "skill:review", "task:subagent", "task:fleet"}
|
||||||
|
|
||||||
func (*UseCapabilityTool) Description() string {
|
func (*UseCapabilityTool) Description() string {
|
||||||
return "Unified capability proxy with a fixed schema: list catalog capabilities, inspect one (metadata plus its input schema), call a capability by stable id (tool:grep, skill:review, task:subagent, task:fleet, mcp-tool:server/tool), or decline a prefer capability with a non-empty reason. action=list names every callable id and its aliases; inspect one for its arguments before calling. task:subagent delegates one investigation; task:fleet dispatches 2-64 at once and orders them with depends_on where one needs another's answer. Calling does not change the provider-visible tool schema. Resolved writers still pass permission, plan mode, sandbox, write-path, and workspace-lease checks. The Planner leaves destructive MCP for the Executor."
|
return "Search and call deferred tools, skills, and MCP through stable IDs such as tool:grep, skill:review, task:subagent, task:fleet, and mcp-tool:server/tool. Before saying a tool, network, or integration is unavailable, search by natural-language goal; results include ranked IDs and available schemas. list enumerates the catalog, inspect loads one definition, call invokes it, and decline records why a preferred capability is skipped. task:subagent delegates one task; task:fleet delegates 2-64. Calls retain all permission, sandbox, and execution checks."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*UseCapabilityTool) ReadOnly() bool { return true }
|
func (*UseCapabilityTool) ReadOnly() bool { return true }
|
||||||
|
|
||||||
func (*UseCapabilityTool) Schema() json.RawMessage {
|
func (*UseCapabilityTool) Schema() json.RawMessage {
|
||||||
// Stable schema — must not change across turns or when MCP connects.
|
// Stable schema — must not change across turns or when MCP connects.
|
||||||
// capability_id is optional only for action=list; inspect/call/decline still
|
// capability_id is optional for list/search; inspect/call/decline still
|
||||||
// require it at resolve time. One intentional prefix upgrade; thereafter
|
// require it at resolve time. One intentional prefix upgrade; thereafter
|
||||||
// install/connect churn does not change this schema.
|
// install/connect churn does not change this schema.
|
||||||
return json.RawMessage(`{
|
return json.RawMessage(`{
|
||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"action":{"type":"string","description":"list | inspect | call | decline"},
|
"action":{"type":"string","description":"search | list | inspect | call | decline"},
|
||||||
"capability_id":{"type":"string","description":"Capability id such as skill:review, task:subagent, mcp-server:github, or mcp-tool:github/search_issues. Not required for action=list."},
|
"query":{"type":"string","description":"Natural-language search goal"},
|
||||||
"arguments":{"type":"object","description":"Raw MCP tool arguments for action=call"},
|
"limit":{"type":"integer","minimum":1,"maximum":20,"description":"Result limit; default 5"},
|
||||||
"reason":{"type":"string","description":"Required non-empty reason when action=decline"}
|
"capability_id":{"type":"string","description":"Stable ID required by inspect, call, and decline"},
|
||||||
|
"arguments":{"type":"object","description":"Target arguments for call"},
|
||||||
|
"reason":{"type":"string","description":"Non-empty decline reason"}
|
||||||
},
|
},
|
||||||
"required":["action"]
|
"required":["action"]
|
||||||
}`)
|
}`)
|
||||||
@@ -561,6 +563,8 @@ func (t *UseCapabilityTool) ResolveCall(ctx context.Context, args json.RawMessag
|
|||||||
var p struct {
|
var p struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
CapabilityID string `json:"capability_id"`
|
CapabilityID string `json:"capability_id"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
Arguments json.RawMessage `json:"arguments"`
|
Arguments json.RawMessage `json:"arguments"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
@@ -576,6 +580,21 @@ func (t *UseCapabilityTool) ResolveCall(ctx context.Context, args json.RawMessag
|
|||||||
Args: p.Arguments,
|
Args: p.Arguments,
|
||||||
}
|
}
|
||||||
switch action {
|
switch action {
|
||||||
|
case "search":
|
||||||
|
out, err := t.searchCapabilities(p.Query, p.Limit)
|
||||||
|
if err != nil {
|
||||||
|
if t.audit != nil {
|
||||||
|
t.audit.RecordMCPProxy(true, false, true)
|
||||||
|
}
|
||||||
|
return tool.ResolvedCall{}, err
|
||||||
|
}
|
||||||
|
if t.audit != nil {
|
||||||
|
t.audit.RecordMCPProxy(true, false, false)
|
||||||
|
}
|
||||||
|
base.SkipExecute = true
|
||||||
|
base.Result = out
|
||||||
|
base.ReadOnly = true
|
||||||
|
return base, nil
|
||||||
case "list":
|
case "list":
|
||||||
out, err := t.listCapabilities()
|
out, err := t.listCapabilities()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -645,7 +664,7 @@ func (t *UseCapabilityTool) ResolveCall(ctx context.Context, args json.RawMessag
|
|||||||
}
|
}
|
||||||
return t.resolveCall(ctx, id, p.Arguments, base)
|
return t.resolveCall(ctx, id, p.Arguments, base)
|
||||||
default:
|
default:
|
||||||
return tool.ResolvedCall{}, fmt.Errorf("unknown action %q; use list, inspect, call, or decline", p.Action)
|
return tool.ResolvedCall{}, fmt.Errorf("unknown action %q; use search, list, inspect, call, or decline", p.Action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,109 +724,6 @@ type listServerInfo struct {
|
|||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// listCapabilities returns the unified catalog summary: MCP servers plus
|
|
||||||
// non-provider-visible tools and skills available through this proxy. The
|
|
||||||
// top-level "servers" key stays compatible with restricted subagent list
|
|
||||||
// filtering.
|
|
||||||
func (t *UseCapabilityTool) listCapabilities() (string, error) {
|
|
||||||
type capInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Aliases []string `json:"aliases,omitempty"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Status string `json:"status,omitempty"`
|
|
||||||
ReadOnly bool `json:"read_only,omitempty"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
}
|
|
||||||
var caps []capInfo
|
|
||||||
if t.catalog != nil {
|
|
||||||
for _, e := range t.catalog().Entries {
|
|
||||||
// Skip provider-visible core tools — they are already top-level.
|
|
||||||
if e.Kind == capability.KindTool && t.registry != nil && t.registry.ProviderVisible(e.ToolName) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
caps = append(caps, capInfo{
|
|
||||||
ID: e.ID,
|
|
||||||
Aliases: e.Aliases,
|
|
||||||
Kind: string(e.Kind),
|
|
||||||
Name: e.Name,
|
|
||||||
Status: string(e.Status),
|
|
||||||
ReadOnly: e.ReadOnly,
|
|
||||||
Description: capabilityLead(e.Description),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
serversJSON, err := t.listServers()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
var serversPayload struct {
|
|
||||||
Servers []listServerInfo `json:"servers"`
|
|
||||||
Note string `json:"note"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal([]byte(serversJSON), &serversPayload)
|
|
||||||
payload := map[string]any{
|
|
||||||
"capabilities": caps,
|
|
||||||
"servers": serversPayload.Servers,
|
|
||||||
"note": "Call action=call with a capability_id to invoke a non-core tool, skill, MCP tool, or other catalog entry without changing the provider tool schema.",
|
|
||||||
}
|
|
||||||
if serversPayload.Note != "" {
|
|
||||||
payload["note"] = payload["note"].(string) + " " + serversPayload.Note
|
|
||||||
}
|
|
||||||
b, err := json.MarshalIndent(payload, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(b), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// listServers returns sorted configured MCP server names, status, and
|
|
||||||
// capability IDs without starting servers. Used by Planner discovery when no
|
|
||||||
// specific capability route was provided.
|
|
||||||
func (t *UseCapabilityTool) listServers() (string, error) {
|
|
||||||
configured := t.configuredServers()
|
|
||||||
list := make([]listServerInfo, 0, len(configured))
|
|
||||||
for _, server := range configured {
|
|
||||||
spec := server.spec
|
|
||||||
name := strings.TrimSpace(spec.Name)
|
|
||||||
if name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Apply stored project grants without process/network side effects so
|
|
||||||
// list status matches resolve/execute authorization.
|
|
||||||
resolved := plugin.ResolveStoredAuthorization(context.Background(), spec)
|
|
||||||
connected := server.enabled && resolved.ServerAuthorized() && t.host != nil && t.host.HasClientForSpec(resolved)
|
|
||||||
status := "configured"
|
|
||||||
if !server.enabled {
|
|
||||||
status = "disabled"
|
|
||||||
} else if connected {
|
|
||||||
status = "ready"
|
|
||||||
} else if t.host != nil {
|
|
||||||
for _, f := range t.host.Failures() {
|
|
||||||
if f.Name == name && strings.TrimSpace(f.Error) != "" {
|
|
||||||
status = "failed"
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
list = append(list, listServerInfo{
|
|
||||||
Name: name,
|
|
||||||
CapabilityID: "mcp-server:" + name,
|
|
||||||
Status: status,
|
|
||||||
Authorized: resolved.ServerAuthorized(),
|
|
||||||
Connected: connected,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
b, err := json.MarshalIndent(map[string]any{
|
|
||||||
"servers": list,
|
|
||||||
"note": "list does not start MCP servers. Call action=call on mcp-server:<name> to connect after authorization, or mcp-tool:<server>/<tool> for a concrete tool.",
|
|
||||||
}, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(b), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *UseCapabilityTool) inspect(ctx context.Context, id string) (string, error) {
|
func (t *UseCapabilityTool) inspect(ctx context.Context, id string) (string, error) {
|
||||||
cat := t.currentCatalog()
|
cat := t.currentCatalog()
|
||||||
if e, ok := cat.Lookup(id); ok {
|
if e, ok := cat.Lookup(id); ok {
|
||||||
@@ -1659,9 +1575,10 @@ func EmitProxyAudit(sink event.Sink, resolved tool.ResolvedCall) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
sink.Emit(event.Event{
|
sink.Emit(event.Event{
|
||||||
Kind: event.Notice,
|
Kind: event.Notice,
|
||||||
Level: event.LevelInfo,
|
Level: event.LevelInfo,
|
||||||
Text: fmt.Sprintf("capability proxy: %s → %s", resolved.DisplayName, resolved.TargetName),
|
Audience: event.NoticeAudienceOperator,
|
||||||
Detail: resolved.CapabilityID,
|
Text: fmt.Sprintf("capability proxy: %s → %s", resolved.DisplayName, resolved.TargetName),
|
||||||
|
Detail: resolved.CapabilityID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"reasonix/internal/capability"
|
||||||
|
"reasonix/internal/plugin"
|
||||||
|
"reasonix/internal/retrieval"
|
||||||
|
)
|
||||||
|
|
||||||
|
type capabilitySearchHit struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
|
Kind capability.Kind `json:"kind"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Status capability.Status `json:"status"`
|
||||||
|
ReadOnly bool `json:"read_only,omitempty"`
|
||||||
|
InputSchema json.RawMessage `json:"input_schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *UseCapabilityTool) listCapabilities() (string, error) {
|
||||||
|
type capInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
ReadOnly bool `json:"read_only,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
var caps []capInfo
|
||||||
|
if t.catalog != nil {
|
||||||
|
for _, e := range t.catalog().Entries {
|
||||||
|
if e.Kind == capability.KindTool && t.registry != nil && t.registry.ProviderVisible(e.ToolName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
caps = append(caps, capInfo{
|
||||||
|
ID: e.ID, Aliases: e.Aliases, Kind: string(e.Kind), Name: e.Name,
|
||||||
|
Status: string(e.Status), ReadOnly: e.ReadOnly, Description: capabilityLead(e.Description),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serversJSON, err := t.listServers()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var serversPayload struct {
|
||||||
|
Servers []listServerInfo `json:"servers"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(serversJSON), &serversPayload)
|
||||||
|
payload := struct {
|
||||||
|
Note string `json:"note"`
|
||||||
|
Capabilities []capInfo `json:"capabilities"`
|
||||||
|
Servers []listServerInfo `json:"servers"`
|
||||||
|
}{
|
||||||
|
Note: "This is the full catalog and may be truncated in conversation. Prefer action=search with capability terms, then action=call with a returned id.",
|
||||||
|
Capabilities: caps,
|
||||||
|
Servers: serversPayload.Servers,
|
||||||
|
}
|
||||||
|
if serversPayload.Note != "" {
|
||||||
|
payload.Note += " " + serversPayload.Note
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(payload, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *UseCapabilityTool) listServers() (string, error) {
|
||||||
|
configured := t.configuredServers()
|
||||||
|
list := make([]listServerInfo, 0, len(configured))
|
||||||
|
for _, server := range configured {
|
||||||
|
spec := server.spec
|
||||||
|
name := strings.TrimSpace(spec.Name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resolved := plugin.ResolveStoredAuthorization(context.Background(), spec)
|
||||||
|
connected := server.enabled && resolved.ServerAuthorized() && t.host != nil && t.host.HasClientForSpec(resolved)
|
||||||
|
status := "configured"
|
||||||
|
if !server.enabled {
|
||||||
|
status = "disabled"
|
||||||
|
} else if connected {
|
||||||
|
status = "ready"
|
||||||
|
} else if t.host != nil {
|
||||||
|
for _, f := range t.host.Failures() {
|
||||||
|
if f.Name == name && strings.TrimSpace(f.Error) != "" {
|
||||||
|
status = "failed"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list = append(list, listServerInfo{
|
||||||
|
Name: name, CapabilityID: "mcp-server:" + name, Status: status,
|
||||||
|
Authorized: resolved.ServerAuthorized(), Connected: connected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(map[string]any{
|
||||||
|
"servers": list,
|
||||||
|
"note": "list does not start MCP servers. Call action=call on mcp-server:<name> to connect after authorization, or mcp-tool:<server>/<tool> for a concrete tool.",
|
||||||
|
}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *UseCapabilityTool) searchCapabilities(query string, limit int) (string, error) {
|
||||||
|
query = strings.TrimSpace(query)
|
||||||
|
if query == "" {
|
||||||
|
return "", fmt.Errorf("query is required for action=search")
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 5
|
||||||
|
}
|
||||||
|
if limit > 20 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
catalog := t.currentCatalog()
|
||||||
|
docs := make([]retrieval.FieldedDoc, 0, len(catalog.Entries))
|
||||||
|
entries := make(map[string]capability.Entry, len(catalog.Entries))
|
||||||
|
for _, entry := range catalog.Entries {
|
||||||
|
if entry.Status == capability.StatusDisabled || entry.Status == capability.StatusFailed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := map[string]string{
|
||||||
|
"name": entry.ID + " " + entry.Name + " " + entry.ToolName,
|
||||||
|
"description": entry.Description,
|
||||||
|
"keywords": strings.Join(entry.Aliases, " "),
|
||||||
|
}
|
||||||
|
if entry.Kind == capability.KindTool && t.registry != nil {
|
||||||
|
if target, ok := t.registry.Get(entry.ToolName); ok {
|
||||||
|
fields["body"] = string(target.Schema())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
docs = append(docs, retrieval.FieldedDoc{ID: entry.ID, Fields: fields})
|
||||||
|
entries[entry.ID] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
ranked := retrieval.RankV2(query, docs)
|
||||||
|
if len(ranked) > limit {
|
||||||
|
ranked = ranked[:limit]
|
||||||
|
}
|
||||||
|
hits := make([]capabilitySearchHit, 0, len(ranked))
|
||||||
|
for _, rankedHit := range ranked {
|
||||||
|
entry, ok := entries[rankedHit.ID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hit := capabilitySearchHit{
|
||||||
|
ID: entry.ID,
|
||||||
|
Aliases: entry.Aliases,
|
||||||
|
Kind: entry.Kind,
|
||||||
|
Name: entry.Name,
|
||||||
|
Description: capabilityLead(entry.Description),
|
||||||
|
Status: entry.Status,
|
||||||
|
ReadOnly: entry.ReadOnly,
|
||||||
|
}
|
||||||
|
if entry.Kind == capability.KindTool && t.registry != nil {
|
||||||
|
if target, ok := t.registry.Get(entry.ToolName); ok {
|
||||||
|
hit.InputSchema = target.Schema()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hits = append(hits, hit)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"query": query,
|
||||||
|
"matches": hits,
|
||||||
|
"note": "Call action=call with a returned capability id. An empty matches array means this catalog search found no lexical match; it does not prove that every possible external capability is unavailable.",
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(payload, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterCapabilitySearchResult(raw string, allowed map[string]bool) string {
|
||||||
|
var payload struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Matches []capabilitySearchHit `json:"matches"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||||
|
return `{"query":"","matches":[],"note":"search is filtered to this subagent's allowed capabilities."}`
|
||||||
|
}
|
||||||
|
filtered := payload.Matches[:0]
|
||||||
|
for _, hit := range payload.Matches {
|
||||||
|
if allowed[hit.ID] || anyAllowedAlias(hit.Aliases, allowed) {
|
||||||
|
filtered = append(filtered, hit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload.Matches = filtered
|
||||||
|
payload.Note = "search is filtered to this subagent's allowed capabilities."
|
||||||
|
b, err := json.MarshalIndent(payload, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return `{"query":"","matches":[],"note":"search is filtered to this subagent's allowed capabilities."}`
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func anyAllowedAlias(aliases []string, allowed map[string]bool) bool {
|
||||||
|
for _, alias := range aliases {
|
||||||
|
if allowed[alias] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"reasonix/internal/capability"
|
||||||
|
"reasonix/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type searchableCapabilityTool struct{ name, description string }
|
||||||
|
|
||||||
|
func (t searchableCapabilityTool) Name() string { return t.name }
|
||||||
|
func (t searchableCapabilityTool) Description() string { return t.description }
|
||||||
|
func (searchableCapabilityTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||||
|
func (searchableCapabilityTool) Execute(context.Context, json.RawMessage) (string, error) {
|
||||||
|
return "ok", nil
|
||||||
|
}
|
||||||
|
func (searchableCapabilityTool) ReadOnly() bool { return true }
|
||||||
|
|
||||||
|
func TestUseCapabilitySearchFindsToolBeyondLargeCatalog(t *testing.T) {
|
||||||
|
registry := tool.NewRegistry()
|
||||||
|
target := searchableCapabilityTool{name: "web_fetch", description: "Fetch a URL over HTTPS or HTTP and return its text content."}
|
||||||
|
registry.Add(target)
|
||||||
|
entries := make([]capability.Entry, 0, 1001)
|
||||||
|
for i := range 1000 {
|
||||||
|
entries = append(entries, capability.Entry{
|
||||||
|
ID: fmt.Sprintf("mcp-tool:graphics/tool-%04d", i), Kind: capability.KindMCPTool,
|
||||||
|
Name: fmt.Sprintf("graphics/tool-%04d", i), Description: "Edit a scene asset", Status: capability.StatusReady,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
entries = append(entries, capability.Entry{
|
||||||
|
ID: "tool:web_fetch", Kind: capability.KindTool, Name: target.name, ToolName: target.name,
|
||||||
|
Description: target.description, Status: capability.StatusReady, ReadOnly: true,
|
||||||
|
})
|
||||||
|
proxy := NewUseCapabilityTool(context.Background(), nil, nil, registry, nil, nil, func() capability.Catalog {
|
||||||
|
return capability.Catalog{Entries: entries}
|
||||||
|
})
|
||||||
|
|
||||||
|
out, err := proxy.Execute(context.Background(), json.RawMessage(`{"action":"search","query":"fetch URL over HTTP","limit":5}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `"id": "tool:web_fetch"`) || !strings.Contains(out, `"input_schema"`) {
|
||||||
|
t.Fatalf("search did not load the matching callable definition:\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "graphics/tool-") {
|
||||||
|
t.Fatalf("search returned unrelated catalog entries:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUseCapabilitySearchRequiresQueryAndBoundsLimit(t *testing.T) {
|
||||||
|
proxy := NewUseCapabilityTool(context.Background(), nil, nil, tool.NewRegistry(), nil, nil, func() capability.Catalog {
|
||||||
|
return capability.Catalog{}
|
||||||
|
})
|
||||||
|
if _, err := proxy.Execute(context.Background(), json.RawMessage(`{"action":"search"}`)); err == nil || !strings.Contains(err.Error(), "query is required") {
|
||||||
|
t.Fatalf("missing query error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestrictedCapabilitySearchDoesNotLeakOutsideAllowlist(t *testing.T) {
|
||||||
|
raw := `{"query":"data","matches":[{"id":"mcp-tool:allowed/read","kind":"mcp-tool","name":"allowed/read","status":"ready"},{"id":"mcp-tool:secret/read","kind":"mcp-tool","name":"secret/read","status":"ready"}],"note":"full"}`
|
||||||
|
out := filterCapabilitySearchResult(raw, map[string]bool{"mcp-tool:allowed/read": true})
|
||||||
|
if !strings.Contains(out, "mcp-tool:allowed/read") || strings.Contains(out, "mcp-tool:secret/read") {
|
||||||
|
t.Fatalf("restricted search leaked or removed the wrong capability:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapabilityListLeadsWithSearchGuidanceBeforeLargeInventory(t *testing.T) {
|
||||||
|
proxy := NewUseCapabilityTool(context.Background(), nil, nil, tool.NewRegistry(), nil, nil, func() capability.Catalog {
|
||||||
|
return capability.Catalog{Entries: []capability.Entry{{
|
||||||
|
ID: "tool:late", Kind: capability.KindTool, Name: "late", ToolName: "late", Status: capability.StatusReady,
|
||||||
|
}}}
|
||||||
|
})
|
||||||
|
out, err := proxy.Execute(context.Background(), json.RawMessage(`{"action":"list"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
note, inventory := strings.Index(out, `"note"`), strings.Index(out, `"capabilities"`)
|
||||||
|
if note < 0 || inventory < 0 || note > inventory || !strings.Contains(out[:inventory], "action=search") {
|
||||||
|
t.Fatalf("list preview does not lead with bounded-search guidance:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,6 +124,28 @@ func TestEffectDescribedCapabilityIDsAreDiscoverable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEffectCapabilitySearchContractReachesFirstRequest(t *testing.T) {
|
||||||
|
probe := &capabilityProbeProvider{}
|
||||||
|
runProbeWith(t, "boot-capability-search-contract", probe, event.Discard)
|
||||||
|
probe.mu.Lock()
|
||||||
|
defer probe.mu.Unlock()
|
||||||
|
if len(probe.reqs) == 0 {
|
||||||
|
t.Fatal("provider received no request")
|
||||||
|
}
|
||||||
|
for _, schema := range probe.reqs[0].Tools {
|
||||||
|
if schema.Name != "use_capability" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(schema.Description, "Before saying a tool, network, or integration is unavailable") ||
|
||||||
|
!strings.Contains(string(schema.Parameters), `"query"`) ||
|
||||||
|
!strings.Contains(string(schema.Parameters), `"search | list | inspect | call | decline"`) {
|
||||||
|
t.Fatalf("first request carries an incomplete capability search contract:\n%s\n%s", schema.Description, schema.Parameters)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatal("first provider request is missing use_capability")
|
||||||
|
}
|
||||||
|
|
||||||
// TestEffectDelegationIDsCarryTheirSchema closes the second half: an id the
|
// TestEffectDelegationIDsCarryTheirSchema closes the second half: an id the
|
||||||
// provider schema hides is callable only if inspect hands back its arguments.
|
// provider schema hides is callable only if inspect hands back its arguments.
|
||||||
// Description text alone left the model guessing them.
|
// Description text alone left the model guessing them.
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"SystemHash": "ff6d21f7facf8350",
|
"SystemHash": "ff6d21f7facf8350",
|
||||||
"ToolsHash": "6302df80130a97e0",
|
"ToolsHash": "72cada1214281e15",
|
||||||
"PrefixHash": "68733035e3552dbc",
|
"PrefixHash": "c4564c7db7103e97",
|
||||||
"LogRewriteVersion": 0,
|
"LogRewriteVersion": 0,
|
||||||
"ToolSchemaTokens": 4584
|
"ToolSchemaTokens": 4552
|
||||||
}
|
}
|
||||||
+15
-5
@@ -482,23 +482,33 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "use_capability",
|
"name": "use_capability",
|
||||||
"description": "Unified capability proxy with a fixed schema: list catalog capabilities, inspect one (metadata plus its input schema), call a capability by stable id (tool:grep, skill:review, task:subagent, task:fleet, mcp-tool:server/tool), or decline a prefer capability with a non-empty reason. action=list names every callable id and its aliases; inspect one for its arguments before calling. task:subagent delegates one investigation; task:fleet dispatches 2-64 at once and orders them with depends_on where one needs another's answer. Calling does not change the provider-visible tool schema. Resolved writers still pass permission, plan mode, sandbox, write-path, and workspace-lease checks. The Planner leaves destructive MCP for the Executor.",
|
"description": "Search and call deferred tools, skills, and MCP through stable IDs such as tool:grep, skill:review, task:subagent, task:fleet, and mcp-tool:server/tool. Before saying a tool, network, or integration is unavailable, search by natural-language goal; results include ranked IDs and available schemas. list enumerates the catalog, inspect loads one definition, call invokes it, and decline records why a preferred capability is skipped. task:subagent delegates one task; task:fleet delegates 2-64. Calls retain all permission, sandbox, and execution checks.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"action": {
|
"action": {
|
||||||
"description": "list | inspect | call | decline",
|
"description": "search | list | inspect | call | decline",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"description": "Raw MCP tool arguments for action=call",
|
"description": "Target arguments for call",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"capability_id": {
|
"capability_id": {
|
||||||
"description": "Capability id such as skill:review, task:subagent, mcp-server:github, or mcp-tool:github/search_issues. Not required for action=list.",
|
"description": "Stable ID required by inspect, call, and decline",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"description": "Result limit; default 5",
|
||||||
|
"maximum": 20,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"query": {
|
||||||
|
"description": "Natural-language search goal",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"reason": {
|
"reason": {
|
||||||
"description": "Required non-empty reason when action=decline",
|
"description": "Non-empty decline reason",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+15
-5
@@ -464,24 +464,34 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "use_capability",
|
"Name": "use_capability",
|
||||||
"Description": "Unified capability proxy with a fixed schema: list catalog capabilities, inspect one (metadata plus its input schema), call a capability by stable id (tool:grep, skill:review, task:subagent, task:fleet, mcp-tool:server/tool), or decline a prefer capability with a non-empty reason. action=list names every callable id and its aliases; inspect one for its arguments before calling. task:subagent delegates one investigation; task:fleet dispatches 2-64 at once and orders them with depends_on where one needs another's answer. Calling does not change the provider-visible tool schema. Resolved writers still pass permission, plan mode, sandbox, write-path, and workspace-lease checks. The Planner leaves destructive MCP for the Executor.",
|
"Description": "Search and call deferred tools, skills, and MCP through stable IDs such as tool:grep, skill:review, task:subagent, task:fleet, and mcp-tool:server/tool. Before saying a tool, network, or integration is unavailable, search by natural-language goal; results include ranked IDs and available schemas. list enumerates the catalog, inspect loads one definition, call invokes it, and decline records why a preferred capability is skipped. task:subagent delegates one task; task:fleet delegates 2-64. Calls retain all permission, sandbox, and execution checks.",
|
||||||
"ReadOnly": true,
|
"ReadOnly": true,
|
||||||
"Schema": {
|
"Schema": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"action": {
|
"action": {
|
||||||
"description": "list | inspect | call | decline",
|
"description": "search | list | inspect | call | decline",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"description": "Raw MCP tool arguments for action=call",
|
"description": "Target arguments for call",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"capability_id": {
|
"capability_id": {
|
||||||
"description": "Capability id such as skill:review, task:subagent, mcp-server:github, or mcp-tool:github/search_issues. Not required for action=list.",
|
"description": "Stable ID required by inspect, call, and decline",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"description": "Result limit; default 5",
|
||||||
|
"maximum": 20,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"query": {
|
||||||
|
"description": "Natural-language search goal",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"reason": {
|
"reason": {
|
||||||
"description": "Required non-empty reason when action=decline",
|
"description": "Non-empty decline reason",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -130,6 +130,14 @@ func RunLegacyRescueCommand(args string, sink event.Sink) Result {
|
|||||||
// directory itself. Only sessions are imported; config and credentials stay on
|
// directory itself. Only sessions are imported; config and credentials stay on
|
||||||
// the default non-destructive migration path.
|
// the default non-destructive migration path.
|
||||||
func RunLegacySessionImportFrom(sourceRoot string, sink event.Sink) Result {
|
func RunLegacySessionImportFrom(sourceRoot string, sink event.Sink) Result {
|
||||||
|
return RunLegacySessionImportInto(sourceRoot, config.SessionDir(), sink)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunLegacySessionImportInto is the desktop recovery form. Sessions whose
|
||||||
|
// historical workspace still exists keep that workspace; orphaned or unscoped
|
||||||
|
// sessions land in fallbackDest so the user can see and resume them instead of
|
||||||
|
// being copied into the global store that the workspace sidebar cannot list.
|
||||||
|
func RunLegacySessionImportInto(sourceRoot, fallbackDest string, sink event.Sink) Result {
|
||||||
sink = event.Sync(sink)
|
sink = event.Sync(sink)
|
||||||
emit := func(level event.Level, text string) {
|
emit := func(level event.Level, text string) {
|
||||||
sink.Emit(event.Event{Kind: event.Notice, Level: level, Text: text})
|
sink.Emit(event.Event{Kind: event.Notice, Level: level, Text: text})
|
||||||
@@ -150,14 +158,14 @@ func RunLegacySessionImportFrom(sourceRoot string, sink event.Sink) Result {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
for _, src := range sources {
|
for _, src := range sources {
|
||||||
n, err := agent.MigrateLegacySessionsFromExplicitDir(src.dir, config.SessionDir(), config.ProjectSessionDir)
|
n, err := agent.MigrateLegacySessionsFromExplicitDir(src.dir, fallbackDest, config.ProjectSessionDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.SessionErrs = append(result.SessionErrs, fmt.Errorf("%s: %w", src.label, err))
|
result.SessionErrs = append(result.SessionErrs, fmt.Errorf("%s: %w", src.label, err))
|
||||||
emit(event.LevelWarn, "migration rescue: skipped "+src.label+": "+err.Error())
|
emit(event.LevelWarn, "migration rescue: skipped "+src.label+": "+err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
result.SessionImports = append(result.SessionImports, SessionImport{Source: src.label, Destination: config.SessionDir(), Count: n})
|
result.SessionImports = append(result.SessionImports, SessionImport{Source: src.label, Destination: fallbackDest, Count: n})
|
||||||
emit(event.LevelInfo, fmt.Sprintf("imported %d past session(s) from %s — resume them with --resume or the history panel", n, src.label))
|
emit(event.LevelInfo, fmt.Sprintf("imported %d past session(s) from %s — resume them with --resume or the history panel", n, src.label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-1
@@ -11,7 +11,9 @@ import (
|
|||||||
|
|
||||||
"reasonix/internal/agent"
|
"reasonix/internal/agent"
|
||||||
"reasonix/internal/config"
|
"reasonix/internal/config"
|
||||||
|
"reasonix/internal/event"
|
||||||
"reasonix/internal/fileutil"
|
"reasonix/internal/fileutil"
|
||||||
|
"reasonix/internal/migration"
|
||||||
"reasonix/internal/store"
|
"reasonix/internal/store"
|
||||||
"reasonix/internal/worktree"
|
"reasonix/internal/worktree"
|
||||||
)
|
)
|
||||||
@@ -52,6 +54,7 @@ type treeSession struct {
|
|||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Turns int `json:"turns,omitempty"`
|
Turns int `json:"turns,omitempty"`
|
||||||
RuntimeID string `json:"runtimeId,omitempty"`
|
RuntimeID string `json:"runtimeId,omitempty"`
|
||||||
|
Archived bool `json:"archived,omitempty"`
|
||||||
// Copies are this conversation's conflict-recovery copies. A save that
|
// Copies are this conversation's conflict-recovery copies. A save that
|
||||||
// keeps conflicting writes one file per turn, all under the one title, and
|
// keeps conflicting writes one file per turn, all under the one title, and
|
||||||
// unfolded that is a sidebar of rows the user never made.
|
// unfolded that is a sidebar of rows the user never made.
|
||||||
@@ -63,7 +66,10 @@ func (h *Hub) registerTreeRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("POST /tree/workspaces", h.addWorkspace)
|
mux.HandleFunc("POST /tree/workspaces", h.addWorkspace)
|
||||||
mux.HandleFunc("POST /tree/workspaces/remove", h.removeWorkspace)
|
mux.HandleFunc("POST /tree/workspaces/remove", h.removeWorkspace)
|
||||||
mux.HandleFunc("POST /tree/sessions/remove", h.removeSession)
|
mux.HandleFunc("POST /tree/sessions/remove", h.removeSession)
|
||||||
|
mux.HandleFunc("POST /tree/sessions/archive", h.archiveSession)
|
||||||
mux.HandleFunc("POST /tree/sessions/rename", h.renameSession)
|
mux.HandleFunc("POST /tree/sessions/rename", h.renameSession)
|
||||||
|
mux.HandleFunc("POST /tree/sessions/export", h.exportSession)
|
||||||
|
mux.HandleFunc("POST /tree/sessions/import-legacy", h.importLegacySessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// tree answers the whole sidebar in one request: every remembered workspace
|
// tree answers the whole sidebar in one request: every remembered workspace
|
||||||
@@ -144,12 +150,84 @@ func (h *Hub) workspaceSessions(root string, open map[string]string) []treeSessi
|
|||||||
lead[recoveryLineageRoot(si, byID)] = len(out)
|
lead[recoveryLineageRoot(si, byID)] = len(out)
|
||||||
}
|
}
|
||||||
out = append(out, treeSession{
|
out = append(out, treeSession{
|
||||||
Path: si.Path, Name: name, Title: title, Turns: si.Turns, RuntimeID: runtimeID,
|
Path: si.Path, Name: name, Title: title, Turns: si.Turns, RuntimeID: runtimeID, Archived: si.Archived,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// archiveSession changes catalog visibility without moving or deleting data.
|
||||||
|
// An open pane is closed by the client first; the server still refuses one to
|
||||||
|
// protect callers that bypass the UI from hiding a conversation being written.
|
||||||
|
func (h *Hub) archiveSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Archived bool `json:"archived"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
badBody(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path, err := filepath.Abs(strings.TrimSpace(body.Path))
|
||||||
|
if err != nil || !store.IsSessionTranscriptName(filepath.Base(path)) {
|
||||||
|
refuse(w, http.StatusBadRequest, codeSessionBadPath, "the session path could not be resolved", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.openSessions()[agent.CanonicalSessionPath(path)] != "" {
|
||||||
|
busy(w, "session.has_open_pane", "close this session's pane first", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.ownsSessionDir(filepath.Dir(path)) {
|
||||||
|
refuse(w, http.StatusForbidden, "session.outside_workspace", "path outside a known workspace", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := agent.SetSessionArchived(path, body.Archived); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// importLegacySessions is the user-facing recovery path for old installations.
|
||||||
|
// Unknown historical workspaces fall back to the workspace the user selected,
|
||||||
|
// so a successful import is never stranded outside the desktop tree.
|
||||||
|
func (h *Hub) importLegacySessions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Workspace string `json:"workspace"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
badBody(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
workspace, err := resolveWorkspaceDir(strings.TrimSpace(body.Workspace))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
known := false
|
||||||
|
for _, ref := range h.roots() {
|
||||||
|
if ref.dir == workspace {
|
||||||
|
known = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !known {
|
||||||
|
refuse(w, http.StatusForbidden, "workspace.unknown", "select a known workspace for recovered sessions", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := migration.RunLegacySessionImportInto(strings.TrimSpace(body.Path), SessionDirFor(workspace), event.Discard)
|
||||||
|
count := 0
|
||||||
|
for _, imported := range result.SessionImports {
|
||||||
|
count += imported.Count
|
||||||
|
}
|
||||||
|
writeJSON(w, struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Imported int `json:"imported"`
|
||||||
|
Warnings int `json:"warnings"`
|
||||||
|
}{Summary: result.Summary(), Imported: count, Warnings: len(result.SessionErrs)})
|
||||||
|
}
|
||||||
|
|
||||||
// recoveryLineageRoot names the conversation a copy belongs to. The stamped
|
// recoveryLineageRoot names the conversation a copy belongs to. The stamped
|
||||||
// root is authoritative: walking parents instead splits one chain into a row
|
// root is authoritative: walking parents instead splits one chain into a row
|
||||||
// per reclaimed middle link, which is precisely what GC leaves behind.
|
// per reclaimed middle link, which is precisely what GC leaves behind.
|
||||||
@@ -293,6 +371,37 @@ func (h *Hub) renameSession(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exportSession returns the transcript only after applying the same workspace
|
||||||
|
// boundary as rename and removal. The shell owns where the copy is saved; the
|
||||||
|
// kernel owns which path is safe to read.
|
||||||
|
func (h *Hub) exportSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
badBody(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path, err := filepath.Abs(strings.TrimSpace(body.Path))
|
||||||
|
if err != nil || !store.IsSessionTranscriptName(filepath.Base(path)) {
|
||||||
|
refuse(w, http.StatusBadRequest, codeSessionBadPath, "the session path could not be resolved", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.ownsSessionDir(filepath.Dir(path)) {
|
||||||
|
refuse(w, http.StatusForbidden, "session.outside_workspace", "path outside a known workspace", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}{Name: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), Content: string(content)})
|
||||||
|
}
|
||||||
|
|
||||||
// ownsSessionDir reports whether dir is the session directory of a workspace
|
// ownsSessionDir reports whether dir is the session directory of a workspace
|
||||||
// this hub lists, which is the boundary a delete may not reach past.
|
// this hub lists, which is the boundary a delete may not reach past.
|
||||||
func (h *Hub) ownsSessionDir(dir string) bool {
|
func (h *Hub) ownsSessionDir(dir string) bool {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package serve
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -27,6 +28,60 @@ func writeSessionAt(t *testing.T, path string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func postExportSession(t *testing.T, srv *httptest.Server, path string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
body, err := json.Marshal(map[string]string{"path": path})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp, err := http.Post(srv.URL+"/tree/sessions/export", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportSessionReadsOnlyAKnownWorkspaceTranscript(t *testing.T) {
|
||||||
|
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||||
|
root := testenv.TempDir(t)
|
||||||
|
h := NewHub(HubOptions{})
|
||||||
|
hubRuntime(t, h, root)
|
||||||
|
srv := httptest.NewServer(h.Handler())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
path := filepath.Join(SessionDirFor(root), "export-me.jsonl")
|
||||||
|
writeSessionAt(t, path)
|
||||||
|
want, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postExportSession(t, srv, path)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
got, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("POST /tree/sessions/export = %d, want 200: %s", resp.StatusCode, got)
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.Name != "export-me" || out.Content != string(want) {
|
||||||
|
t.Errorf("export = (%q, %d bytes), want (export-me, %d bytes)", out.Name, len(out.Content), len(want))
|
||||||
|
}
|
||||||
|
|
||||||
|
outside := filepath.Join(testenv.TempDir(t), "outside.jsonl")
|
||||||
|
writeSessionAt(t, outside)
|
||||||
|
refused := postExportSession(t, srv, outside)
|
||||||
|
defer refused.Body.Close()
|
||||||
|
if refused.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("outside export = %d, want 403", refused.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func postRemoveSession(t *testing.T, srv *httptest.Server, path string) *http.Response {
|
func postRemoveSession(t *testing.T, srv *httptest.Server, path string) *http.Response {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body, err := json.Marshal(map[string]string{"path": path})
|
body, err := json.Marshal(map[string]string{"path": path})
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light"><title>Reasonix — 工作,由此展开</title>
|
||||||
|
<link rel="stylesheet" href="workspace.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="studio" id="studio">
|
||||||
|
<aside class="sidebar" aria-label="工作空间导航">
|
||||||
|
<button class="wordmark" data-action="home"><span class="brand-symbol">r<span></span></span><span>Reasonix<small>PERSONAL WORKSPACE</small></span></button>
|
||||||
|
<button class="new-task" data-action="new"><i data-icon="plus"></i> 新任务 <kbd>Ctrl N</kbd></button>
|
||||||
|
<button class="nav-item" data-action="search"><i data-icon="search"></i> 搜索工作空间 <kbd>⌘ K</kbd></button>
|
||||||
|
<div class="nav-group">
|
||||||
|
<button class="nav-item" data-route="home"><i data-icon="grid"></i> 工作台</button>
|
||||||
|
<button class="nav-item" data-route="inbox"><i data-icon="inbox"></i> 待我处理 <span class="count" id="attentionCount">2</span></button>
|
||||||
|
<button class="nav-item" data-route="library"><i data-icon="files"></i> 我的成果</button>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-label">项目 <button aria-label="创建项目" data-action="project"><i data-icon="plus"></i></button></div>
|
||||||
|
<button class="project-nav" data-action="project-info"><span class="project-avatar">A</span><span>Atelier 品牌工作室<small>策略 · 产品 · 内容</small></span><i data-icon="chevron"></i></button>
|
||||||
|
<div class="sidebar-label">最近任务 <span id="taskCount"></span></div>
|
||||||
|
<div class="task-list" id="taskList"></div>
|
||||||
|
<div class="sidebar-bottom">
|
||||||
|
<button class="nav-item" data-route="automation"><i data-icon="clock"></i> 定时任务</button>
|
||||||
|
<button class="nav-item" data-route="capabilities"><i data-icon="puzzle"></i> 能力与连接</button>
|
||||||
|
<div class="profile"><span class="profile-avatar">L</span><span>我的工作空间<small><span class="dot green"></span> 本地交互原型</small></span><button class="icon-button" data-action="settings" aria-label="界面设置"><i data-icon="settings"></i></button></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="body-shell">
|
||||||
|
<header class="topbar"><div class="breadcrumb"><button class="icon-button" data-action="sidebar" aria-label="切换导航"><i data-icon="panel"></i></button><span>工作空间</span><span class="slash">/</span><strong id="pageTitle">秋季新品发布策略</strong></div><div class="top-actions"><span class="prototype-label">DESIGN PROTOTYPE</span><button class="icon-button" data-action="search" aria-label="搜索"><i data-icon="search"></i></button><button class="icon-button" data-action="notifications" aria-label="通知"><i data-icon="bell"></i><b id="notificationDot"></b></button><span class="top-avatar">L</span></div></header>
|
||||||
|
<section id="page" class="page"></section>
|
||||||
|
<footer class="statusbar"><span><i class="dot green"></i> Reasonix Studio <span class="statusbar-muted">/ 概念交互版</span></span><span id="bottomStatus">模型与工具调用均为模拟,不访问真实服务</span><span class="statusbar-muted">所有交互仅在本次页面中保留</span></footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dialog id="modal" class="modal"><header><div><span class="eyebrow" id="modalEyebrow">WORKSPACE</span><h2 id="modalTitle"></h2></div><button class="icon-button" data-action="close-modal" aria-label="关闭弹窗"><i data-icon="close"></i></button></header><div id="modalBody" class="modal-body"></div><footer id="modalFooter"></footer></dialog>
|
||||||
|
<input type="file" id="attachmentInput" hidden multiple>
|
||||||
|
<div class="toast" id="toast" role="status" hidden></div>
|
||||||
|
<div id="announcer" class="sr-only" aria-live="polite"></div>
|
||||||
|
<script src="workspace.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light"><title>Reasonix — 工作,由此展开</title>
|
||||||
|
<style>
|
||||||
|
@font-face{font-family:Studio;src:url('data:font/woff2;base64,d09GMgABAAAABV/wABMAAAANdbgABV91AAQAQgAAAAAAAAAAAAAAAAAAAAAAAAAAGsECG4mzChyBvlY/SFZBUtdnP01WQVKBKAZgP1NUQVSBXCc0AIHKLC+BNgqIy2iHsAQwm+8sATYCJAPbZAvbaAAEIAWaTAeCgAVbofjchl/SO317thTwxO+OOiix2RMp3YDazSKKy7TMhabXn38/nv9v/XdhgyrVmam6DL54ZLP3d4ILFSo1keBg13D8lIp3p2OMcrABqFZ1/2Y2lMrY/dgqwAAMrRZF7RreCqm8GhJI//////////////////////9/y8mPp9zqVXVS9X9fyd25DtKQhPMSCLegCKIojsCgMDrqjse4u0SoGmtdzw/CVDqTzeULRUReqWyptRUYaoyiSmslQ+qkYYghOWgWpaiApq1WvZpvW210GFV0x6gxrEjCejnE4zrRH+SKxeK4qXnDyeLU9DDyfU5ndJZ27Zy1VAxEySTMvDIiC+VFKQuKDWuYpYlWWyuN5RUP81DoqmBtPYShZsOENi3IIpbNJ2LLRbgab1X6QrfFI55gZ5TatZPNvX07l1b3AG7NI5L1rbFGVuvWPcz5vhSIl+lnhPSWQ+wL2T0KKuF+sTPwk+841Y72npITe1pC3sDyY5I0z5h1UkAQdLuH5HnSFZ5P0UZdj48sCTFrM/7Zi04c++cHFy+lm/UuF4pondQ8OkJuycRYRhCgh6K8ElxkLDYFPVkWGFEIWRISSD9WIguamJyg/TpzddQ2O6FnWuHIVt9U3yIcCJRKINA2K94G7KJYuFxJa6RT9yyso4PRNQlmFhhpKilIJBgsdnv2HTEelibcPxaVeAIfWcWmIAWj9r097W+RaeIPPWKMNe4wTVxXT3NF0iHm/SGzB62yhVLJFGBgJTtY1cgtZ8rM68JOEEJgYFQrQo2UxBMYH4HAwFRd1GFhiwit2jk/Rdptvbk9u6vSUwnWlC/jHVn40yKkunZ2xWyW9xPVvS98YD6e3OkD3RuhJdiAkI4wV0HAXD4aw4o4VnZbOI9bxF9qQmU08R8zbYxP42h9NnU5t6b9GcwyAp2qU+P8ufBnCWiwr0pItQPqOjgWZcb5bgIa7Kcec3BszAkuibXWokeNYUVYOXXYxvGaQG/XtefuPuvDQ9Co5wVFN+2SZSza2ZuA1Fw3IDlswjCfsf+Ws+ijHNGt1FzUIoGbfGOzm9jKzODN3iTXj5vK/Z3hBII2BCdGuYaqF6PcJRFdQr2MotDrkMnDVShzYeWc5hrWRkzVGBLhy6O1tMEUmJu8OwVRfD2X3Y3dYtuv+r4/apKZdVaEG5SU/UucDl6megtPiM1fn258IwN6LfMQhP+5R+ubizNkg33PRRu7/1hYBHjOKJ4xO9tDJpb9tG55WKsaA4Hs3WwRpTcBegIxZjogAnekUBPmSDbVd/V9i+k4bt9y+srZmYPQeSa7yFeE3EOgwt2TW7LasMzNjk/7InOoO8k9LeOa2WYuuWuIgycOZxNXdhYmf9qxJuEwI87WL5zvJc4/4IUJiTBCJEmezPXrqGIkf04pyTVwMT1uXIvc4LGzQDrfA/QFGaTZiYUiaUPIPRr0En+w8WodMndo4Sbhjwf+0OF3J0fJYNmfDleT6IrWyFj5tAKJIJXowOAACxk8wR02Yz+Q3mTk7a94zCaiZ6SspG/JVt5RD/FSgrKJ/9UVfozrsBviQBLdJoNDrERCYyFxk6iQUbCOB8GR76VFIBJnmlyWVLJKFmQls+rCfm/VVnFAvqVqo23821lpcr9C5eS1cgrhC8whHqfZHslgvdqIoTbyLOIYxgrOJrqkQMxin0lBJ7hWvoxWQWFIduSqwdCQr4Ivcj9Umrvza/WV6dD1pHZaUlddNwiqSNuvQauZrXuoeTPErZlcA+W8pjKXpSZSL09hzZzi6Q5840/onEHFkFeQfsp7mdlDOEfc0+v4A6l0ixgU6F6ZHq21u6VhpvrihefPHLjFva+BcpJlxXC/VZ1/drTHNh2eJZHBEIUTmGJcK04X4TFHVBIQsSKssN/T3N+L2qov5Qwugh/vyrPEddFmmlpgxflv9njgEVcgeI2Y/jTsfyVpXCpfyIfH1ZVZOoD7bp4x08rFN8nwiXuFc3o94/x/ESuJvOHnrcPPLmuTym8kxiY8Pp/ADxmgRVeWMthdkEJh2aNBwj6K0xb/bX1qd2Xt/GNqjhf+lp93PsGWN67Ss+dYLz7d8gKYtqZKerBrSm+9Gp56+uy1m4rSfv6l68lh5KEcl2KMlAxzF8Ph1O0VWsXCctWgU/fS6eXo4YAPhB0XRvFcHe/z/592MS0k3+hclBifn+Ul8Z/Y9O9KpdKrED/5RuzbPbwR/EqI4feT4SYacxYmTXZlfnbC6KI6UCKQlkIhZF7Yf8SJ9ZR+/hyG4XiEN+NsKGxakm6bPlBxEkmeCq+YEfNI8lQTY23GqU1kgUhiTpk2Gnc/63HwUESY+ElenA8Gi+QDuUvm9BeHyntJ9BUe9y+rMp0k8wf8x5B77d9VtdVmYQAEwnBEJE1lVCqVypwWRMOtI1a0OdFPNHZGeL5cMY64c+NuB2YFPZ5psG4pZ8QLdXuX+iHzZfAL+x9vhO+Jltqr+r97ZnZlluXESiKH4HwESOYAH6Kds0670/A8z1/r3D+IKyuRi2pVCwrz4P8/W1Xd983M3QMzK7Ogm2MIlkjqiFxmaY+OgmovzAeApQagGkRwSGuQWlrJvp8wlgkNKmNlBZh0eN5u7/7dxlnnjNmyUimyMuaxszJ2SJSVjLGLjHlWVkNCmihjZ5zZEkLWDuGGYG7dRvQaloxVsbFRI+NBFJGwCm3eajC+ysTmMQmJlB5RPWKj0pHCAM2tMxBFVESkS9gGG4sOVkGPbYwVA0a2EoIIBqhYifH6+qnv+360+u13D9Dcum1UCComPipWvPrGhw7B6H/1q32//bQ/FlWsihXbGBuwIiokSgQBURCpDU9LtX8rNsQyxXEIm6ZNmqYcaGUHWj7q3e/1gHo9wt8D0mqUpMBJ24Qdx3EcxyiDZOFqtYQzswP4hnhZuytpNTza5vOSXC6XW5MDjmNMWSKIC0RFbd21rVptdbd2a+uuo932r9W9rLX1t46FiIooW8a8MYE7bqwkl+T5/71Tu5ICm7HDEyClZRXIBXKXlWUs4ffSJzB4GbLRgk/qAqlAU3DrToDGTuNNXlji+Y/7dv95Kp90a7GydZnJysoC8cB/f9C3nvbNJne+MItySEVQBIHwGp7v/17ePff9e+b9DpZKsXgAMKIRCkGpmBWt1Obnac7+z713NKIEvFgF2lJdffv3dRJo93WffFObOxMIYhY0BEmQhIRg2jql1HS3u13xrg3QdHZ3Oc9FXBvzpi1VpZgOGDLE2wYbE5Of67PnmbE9c8aw8UOl1NI2N0/b4pu/UXWLPkX0dd99371jRgWkpUtKQBBadJzIjax33w4AKvh7Nre/4CEnmOaZpBBoSP2W67cnPHc90T3/f0IbrsV9EO0itr3+zAkttqdJKDpJwWeOZB6+/WIlmW+Dlf2zWKGf/UKL/kBT9d85xHYcM8kg2RbgsU50OgGiBWQ5irLuA0MHTB13HZd+gX5aXjQwz9En3NSGePSwHC491ZLqhs3ES7tdpQRi85iHAm9/YrKRRE0ondVfvQs8U/5NroGx3zm7qoMPNMfJA5SpCoXkgUUsugv24R+q879bne6O8Amu/oxX3lmpGS8lJw/KF12HaHL/23HgFlAAGKqLUCn+wY5HVvKw0pkX+9PD5SyCILqw0P+c6OZs3sHgIOTnt2Y1HZu3M2a/Nq2XQmm2gUl+P9xHlHrslyvyA4ZYY3C+MdnZQ6c2rV6/8XXKTy6Y4EazU/ohnSXZQkdUaNCCDTEcvK1jHa3Pf6LZ+j9XqqrTku4kk5mBEUWewFNhePJVlOrKALv71XSfChZWBRgkUuU/z1/6v84+0NVcjIKZidRUSVrnUV9SigdB/SreRxv4or3970z17enq3f05yUy8jxAtEo0K0SNRUSHRKI/QCIeMwk4XDAgk5J9/DvXnJPflREOjEyu0wEpToFSDGpdKBStYefL/fOux300yZ1e6xxn0U3z+TrKwAuERHqFQemp4Oq2fdvcI9z7FZntrlWkw0VBvhICTpGC6wp13PYPn/v+/9WRcYyTJGEmSjHa+PMd1PMf1PEmeJ/mSpJ2RJEmSZIx8yUiSkaSdJO0k7STJGEmSJEmS58s3kqSdJEmSJGnv8anNb3YYR5zlI0zH9wbE+UiRwjgepYawxlLXuFPjv1jTZQnbT8zUpaTfXn5ib39PP0sJn6V49LgNMfa8/Mbe9iIW6WX8l/Ffrh+PuIReMB0snRmKqGwWhtHjMg4gtHt6kCN/8fONv/h64/XGGx8vfZp4DY/gWzTW5vZuZ1/6x8dvEM/aLWRiIJulpJohUSo+37tlM1GkogJbV0eSpS1tJkH6oEbl70SMishqe3dMmCDrWckQbLMzY9qbM2bmnE6dCUYT2oggimKgICIqopQ2WIkF1pw9dTM2XZWrcpW//fr3v7dIl/9Vj1ZafyBO8SgCoJAwvstd05LXh5BdSulFgBkg+D9g7wpmuuD/4f/Kfu2qZJKRXd2ROMRN50Ff9Bc1JF+fBc/n3r68SdJ7nX/ITjEgM3MXT8jwqtU7ttmWOQoJGSDXshmd/gHIgQyVTTbZXD6fq6lEGhDopwiLcKTUTKCAefdrkyEk/ET73Penqvv6eGxCIJiuKvB895tr3x7grZnDc9RC4jN+RVvK1KSxwGaTgdwVmF0VsZAkLEtWrXFoZIUiV5YVivD/dnOajhRvxbFeiv949/ExeSYihcWwAPV4YQtw/X9t3/4mq5pVmb9qRbPp17Ggg94DFck5W4KnaJAsYlpSib/BrBxKG8R+npbq/7mXB3CBBCtZlLine truncated
|
||||||
|
:root{--bg:#f8f9f7;--paper:#fff;--rail:#f0f2ee;--line:#e4e8e1;--ink:#26352d;--muted:#778279;--faint:#98a197;--green:#37634e;--green-light:#edf4ed;--amber:#967234;--amber-light:#fbf6e9;--red:#a4544d;--blue:#526e91;--shadow:0 8px 32px #283c2c09;--ease:cubic-bezier(.2,.75,.2,1);font-family:Studio,Inter,"Segoe UI","Microsoft YaHei",sans-serif;color:var(--ink);font-size:13px;background:var(--bg)}
|
||||||
|
*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer;border:0;background:none;padding:8px 12px;border-radius:7px;transition:background .18s,color .18s,box-shadow .18s,transform .18s}button:hover{background:#e9eee7}button:active{transform:translateY(1px)}button:disabled{opacity:.45;cursor:default;transform:none}a{color:var(--green)}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid #70997c;outline-offset:3px}svg{width:18px;height:18px;stroke:currentColor;stroke-width:1.65;stroke-linecap:round;stroke-linejoin:round;fill:none;flex-shrink:0;vertical-align:middle}input,select,textarea{background:var(--paper);border:1px solid var(--line);border-radius:8px;padding:10px 12px;min-width:0}textarea{resize:vertical;line-height:1.75}input::placeholder,textarea::placeholder{color:#99a195}h1,h2,h3,p{margin:0}button.primary{background:var(--green);color:#fff;border:1px solid var(--green);box-shadow:0 2px 3px #294c3010}button.primary:hover{background:#284e3c;box-shadow:0 4px 9px #294c3020}button.outline{border:1px solid var(--line);background:var(--paper)}button.outline:hover{border-color:#bdcabc;background:#f7f9f4}button.text-button{color:var(--muted);font-size:11px;padding:4px 7px}button.text-button:hover{color:var(--green)}button.small{font-size:11px;padding:7px 10px}.icon-button{width:31px;height:31px;display:inline-grid;place-items:center;padding:6px;color:#778477}.icon-button svg{width:17px;height:17px}kbd{font-family:inherit;font-size:9px;font-weight:400;border:1px solid #dce2d8;border-radius:4px;padding:2px 4px;color:#8b9687;background:#fafbf8}.muted{color:var(--muted);font-size:12px;line-height:1.8}.eyebrow{font-size:9px;letter-spacing:1.7px;color:#8c998a;font-weight:600}.dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#9da797;flex-shrink:0}.dot.green{background:#6c9678}.dot.amber{background:#c39b51}.dot.red{background:#bd7970}.dot.running{background:#739383;box-shadow:0 0 0 3px #76988715}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}[hidden]{display:none!important}
|
||||||
|
.studio{height:100dvh;min-height:650px;display:grid;grid-template-columns:228px minmax(0,1fr);transition:grid-template-columns .28s var(--ease)}.studio.rail-closed{grid-template-columns:0 minmax(0,1fr)}.studio.rail-closed .sidebar{opacity:0;pointer-events:none}.sidebar{background:var(--rail);border-right:1px solid var(--line);padding:26px 17px 14px;display:flex;flex-direction:column;gap:5px;min-width:0;overflow:hidden;transition:opacity .18s}.wordmark{display:flex;align-items:center;gap:10px;text-align:left;padding:0 6px 24px;border-radius:0;flex-shrink:0}.wordmark:hover{background:transparent}.wordmark>span:last-child{font-size:23px;letter-spacing:-.8px;font-weight:550}.wordmark small{display:block;font-size:7px;letter-spacing:1.8px;color:#879583;margin:5px 0 0 2px;font-weight:500}.brand-symbol{position:relative;display:grid;place-items:center;width:32px;height:35px;font-size:35px;line-height:1;font-family:Georgia,serif;font-style:italic;font-weight:700;color:#3e5c42}.brand-symbol span{position:absolute;background:#98ad81;width:6px;height:6px;border-radius:50%;top:2px;right:0}.new-task{display:flex;align-items:center;gap:8px;font-size:12px;background:white;border:1px solid #dce3d7;box-shadow:0 2px 4px #27362804;text-align:left;padding:11px;margin:5px 0 9px}.new-task kbd{margin-left:auto;font-size:8px}.new-task svg{width:16px}.nav-item{display:flex;align-items:center;gap:10px;text-align:left;width:100%;font-size:11px;padding:10px;color:#687664;white-space:nowrap}.nav-item svg{width:16px;height:16px}.nav-item kbd{margin-left:auto;font-size:8px}.nav-item.active{background:#e0e8d9;color:#3c5c3b;font-weight:550}.nav-group{margin-top:13px;display:grid;gap:4px}.count{margin-left:auto;background:#dce3d3;border-radius:5px;padding:1px 6px;font-size:9px;color:#6e7d64}.sidebar-label{display:flex;align-items:center;justify-content:space-between;padding:0 9px;margin:23px 0 6px;font-size:9px;color:#929c89;letter-spacing:.4px}.sidebar-label button{padding:0;display:grid;place-items:center}.sidebar-label svg{width:13px}.project-nav{display:flex;gap:10px;text-align:left;padding:9px 8px;align-items:center;white-space:nowrap;font-size:10px}.project-avatar{width:29px;height:29px;border:1px solid #d5dfcd;background:#e8eede;display:grid;place-items:center;border-radius:8px;color:#637851;font-family:Georgia,serif;font-size:16px}.project-nav small{display:block;font-size:8px;color:#98a08e;margin-top:5px}.project-nav>svg{margin-left:auto;width:12px;color:#99a28f}.task-list{display:flex;flex-direction:column;gap:4px;overflow:auto;scrollbar-width:thin;flex:1;min-height:100px;margin-top:2px}.task-item{text-align:left;display:flex;gap:9px;align-items:center;font-size:10px;padding:11px 10px;flex-shrink:0;min-width:0;color:#78836e}.task-item .task-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.task-item.active{background:#fff;border:1px solid #e0e7d9;padding:10px 9px;box-shadow:0 2px 5px #43513803;color:#3c5134}.task-item .task-status-icon{margin-left:auto;color:#b28b44;display:flex}.task-item .task-status-icon svg{width:13px;height:13px}.sidebar-bottom{padding-top:18px;flex-shrink:0}.profile{border-top:1px solid #dde4d7;margin-top:18px;padding:19px 3px 1px;display:flex;gap:9px;align-items:center;font-size:10px;white-space:nowrap}.profile-avatar,.top-avatar{display:grid;place-items:center;background:#dfd5c4;border:2px solid #fff;border-radius:50%;height:29px;width:29px;color:#806e53;font-family:Georgia,serif;font-size:14px}.profile small{display:flex;align-items:center;gap:5px;color:#97a088;font-size:8px;margin-top:6px}.profile .icon-button{margin-left:auto}.body-shell{display:flex;flex-direction:column;min-width:0;overflow:hidden}.topbar{height:65px;flex-shrink:0;background:#fafbf9;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 24px;gap:15px}.breadcrumb{display:flex;align-items:center;gap:12px;font-size:10px;min-width:0;color:#8d9788}.breadcrumb strong{font-size:11px;color:#58664e;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.slash{color:#c1c8b9}.top-actions{display:flex;align-items:center;gap:12px}.prototype-label{font-size:8px;letter-spacing:1px;color:#a0aa96;padding:4px 7px;border:1px solid #e1e7d9;border-radius:4px}.top-avatar{width:27px;height:27px;font-size:12px}.top-actions .icon-button{position:relative}.top-actions b{position:absolute;top:5px;right:6px;width:4px;height:4px;border-radius:50%;background:#b29155}.statusbar{height:28px;flex-shrink:0;display:flex;justify-content:space-between;align-items:center;padding:0 20px;border-top:1px solid var(--line);font-size:8px;color:#91a082;gap:12px;background:#f7f9f4}.statusbar>span{display:flex;gap:7px;align-items:center}.statusbar-muted{color:#a6b199}.page{flex:1;min-height:0;overflow:auto}.task-workspace{height:100%;display:grid;grid-template-columns:minmax(390px,1fr) minmax(330px,40%);overflow:hidden}.task-workspace.canvas-closed{grid-template-columns:minmax(0,1fr) 0}.conversaLine truncated
|
||||||
|
.canvas{border-left:1px solid #e2e9d9;background:#f1f4ed;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden}.canvas-closed .canvas{visibility:hidden}.canvas-toolbar{height:54px;display:flex;align-items:center;justify-content:space-between;padding:0 19px;border-bottom:1px solid #e2e9d9;background:#f6f8f2;gap:8px;flex-shrink:0}.canvas-tabs{display:flex;gap:16px;height:100%;align-items:stretch}.canvas-tabs button{padding:0 1px;border-radius:0;position:relative;color:#9caa88;font-size:9px}.canvas-tabs button:hover{background:none;color:#6f8755}.canvas-tabs button[aria-selected="true"]{color:#6e8854}.canvas-tabs button[aria-selected="true"]:after{content:'';position:absolute;bottom:0;left:0;right:0;height:2px;background:#8ca86a}.canvas-toolbar .icon-button{width:23px;height:25px;padding:3px;color:#9aab83}.canvas-toolbar .icon-button svg{width:14px}.canvas-content{flex:1;overflow:auto;min-height:0;scrollbar-width:thin;scrollbar-color:#d2ddc4 transparent;padding:20px}.document-tools{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:16px;font-size:9px;color:#9aac83}.document-tools button{font-size:9px;padding:4px 7px;color:#8ba16d;border:1px solid #dfe7d4;background:#f9fbf6}.paper{background:#fff;border:1px solid #e0e8d7;border-radius:3px;box-shadow:0 10px 28px #536a3707;min-height:520px;overflow:hidden}.paper-cover{padding:31px 28px 27px;position:relative;overflow:hidden;border-bottom:1px solid #e7eddc;background:linear-gradient(125deg,#f6f8ef,#edf2e3)}.paper-cover .eyebrow{font-size:7px;letter-spacing:2px;color:#a2b581}.paper-cover h2{font-size:26px;line-height:1.4;font-weight:500;letter-spacing:-.8px;color:#788f51;position:relative;z-index:1;max-width:230px;margin:18px 0 13px}.paper-cover p{font-size:9px;color:#a1b280;line-height:1.8;max-width:220px;position:relative;z-index:1}.cover-art{position:absolute;right:-27px;bottom:-50px;width:175px;height:210px;transform:rotate(27deg)}.cover-art span{position:absolute;border-radius:90px 90px 10px 10px;border:1px solid #c9d6b0;width:90px;height:170px;background:linear-gradient(100deg,#d7e1c433,#bcca9d30);transform:rotate(-12deg);right:0;bottom:0}.cover-art span:nth-child(2){right:35px;bottom:8px;transform:rotate(-30deg);background:#c9d6b016}.cover-art span:nth-child(3){right:65px;bottom:17px;transform:rotate(-50deg);background:#e2e9d425}.paper-meta{display:flex;gap:16px;font-size:8px;color:#a3b58a;padding:14px 28px;border-bottom:1px solid #edf1e4}.paper-body{padding:22px 28px 27px;color:#81976a;font-size:10px;line-height:1.9}.paper-body h3{font-size:12px;font-weight:500;color:#79915d;margin:0 0 9px;display:flex;align-items:center;gap:8px}.paper-body h3 span{font-size:8px;color:#aabb93;font-family:Georgia,serif}.paper-body p{margin-bottom:19px}.document-cards{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin:14px 0 22px}.document-cards>div{padding:12px;background:#f6f9f0;border:1px solid #e9efdf;border-radius:5px}.document-cards b{font-size:9px;display:block;font-weight:500;color:#849e66}.document-cards small{font-size:8px;display:block;margin-top:5px;line-height:1.8;color:#a1b387}.doc-line{display:flex;gap:10px;padding:9px 0;border-bottom:1px solid #edf2e5;font-size:9px;color:#95a97c}.doc-line span:first-child{color:#b1bf9c;font-size:8px;width:33px;flex-shrink:0}.paper-footer{padding:13px 28px;border-top:1px solid #edf2e4;font-size:7px;color:#abbc95;display:flex;justify-content:space-between}.canvas-bottom{padding:12px 20px;border-top:1px solid #e1e9d5;display:flex;justify-content:space-between;align-items:center;background:#f7faf2;color:#9caf84;font-size:8px;flex-shrink:0}.canvas-bottom button{color:#90a673;font-size:9px;padding:6px 8px}.canvas-bottom svg{width:12px;height:12px}.activity-heading{font-size:10px;color:#94a97c;margin:3px 0 22px}.activity-entry{padding:0 0 23px 21px;border-left:1px solid #dbe6cc;position:relative;margin-left:6px;color:#8a9f71;font-size:10px}.activity-entry:before{content:'';position:absolute;left:-4px;top:3px;width:7px;height:7px;border-radius:50%;background:#b2c596;border:2px solid #f4f7ee}.activity-entry small{display:block;margin-top:6px;font-size:8px;color:#a6b992}.activity-entry:last-child{border-color:transparent}.file-row{display:flex;align-items:center;gap:12px;padding:14px;background:#fcfef8;border:1px solid #e0e9d2;border-radius:8px;margin-bottom:9px;text-align:left;width:100%;color:#8ca96b;font-size:10px}.file-row svg{width:19px}.file-row small{display:block;font-size:8px;color:#aabc93;margin-top:6px}.file-row .arrow{margin-left:auto;width:12px}.empty-panel{padding:45px 16px;text-align:center;color:#96ab81;font-size:11px;line-height:1.9}.empty-panel svg{width:30px;height:30px;margin-bottom:15px;color:#b3c69c;display:block;margin-left:auto;margin-right:auto}
|
||||||
|
.overview-page{padding:43px max(35px,calc((100% - 1060px)/2)) 35px;min-height:100%;background:#fafbf8}.overview-header{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:32px;gap:15px}.overview-header h1{font-size:30px;font-weight:500;letter-spacing:-.8px;color:#43593c;margin:9px 0 9px}.overview-header .muted{font-size:11px;color:#8e9d82}.overview-header button{font-size:11px}.home-composer{border:1px solid #dce6d2;background:#fff;border-radius:13px;padding:17px 20px;box-shadow:0 5px 25px #49653105;margin-bottom:31px}.home-composer textarea{width:100%;border:0;outline:none;resize:none;font-size:13px;min-height:64px}.home-composer>div{display:flex;justify-content:space-between;align-items:center;font-size:10px;color:#a1b28d}.home-composer button{font-size:10px}.section-heading{display:flex;justify-content:space-between;align-items:center;margin:26px 0 14px}.section-heading h2{font-size:13px;font-weight:500;color:#738e5c}.section-heading span{font-size:10px;color:#a6b894}.task-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px}.task-tile{border:1px solid #e0e9d5;background:#fff;border-radius:11px;padding:19px;text-align:left;min-height:162px;display:flex;flex-direction:column;align-items:flex-start;transition:transform .18s,box-shadow .18s,border-color .18s}.task-tile:hover{transform:translateY(-3px);box-shadow:0 10px 24px #5a793110;border-color:#bdcfa7;background:white}.task-tile h3{font-size:13px;font-weight:500;color:#6f8a53;margin:17px 0 9px}.task-tile p{font-size:10px;color:#a1b288;line-height:1.7}.task-tile>small{font-size:8px;color:#b2c09e;margin-top:auto;padding-top:18px}.task-tile .status-pill{font-size:8px}.project-banner{border:1px solid #dee8d1;background:linear-gradient(110deg,#f0f5e8,#f8faf5);border-radius:11px;padding:23px;display:flex;align-items:center;gap:18px;margin-top:25px}.project-banner .project-avatar{width:42px;height:42px;font-size:24px;background:#e3edda;color:#93ac75;border-color:#d5e2c6}.project-banner h3{font-size:13px;font-weight:500;color:#7b9860;margin-bottom:7px}.project-banner p{font-size:10px;color:#a2b68b}.project-banner button{margin-left:auto;font-size:10px}.connection-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.connection-card{border:1px solid #dfe9d2;border-radius:11px;padding:21px;background:#fff}.connection-card header{display:flex;align-items:center;gap:12px}.provider-symbol{width:34px;height:34px;border:1px solid #dae5ca;border-radius:9px;background:#f0f5e7;display:grid;place-items:center;font-family:Georgia,serif;font-size:17px;color:#8eab6b}.connection-card h3{font-size:12px;font-weight:500;color:#7c995b}.connection-card header small{font-size:9px;color:#a8bb90;display:block;margin-top:5px}.connection-card p{font-size:10px;color:#9db484;line-height:1.8;margin:17px 0}.connection-card footer{display:flex;justify-content:space-between;align-items:center;border-top:1px solid #edf2e5;padding-top:12px;font-size:9px;color:#aec29a}.connection-card footer button{font-size:9px;padding:4px 8px}.filter-tabs{display:flex;gap:7px;margin-bottom:25px}.filter-tabs button{font-size:11px;color:#8ea675;padding:8px 13px}.filter-tabs button.active{background:#e6eedb;color:#6b8c4e}.toggle{width:30px;height:18px;position:relative;border-radius:15px;background:#d4e0c6;padding:0}.toggle:after{content:'';width:12px;height:12px;background:#fff;border-radius:50%;position:absolute;left:3px;top:3px;box-shadow:0 1px 3px #47603215;transition:transform .2s}.toggle[aria-checked="true"]{background:#8aa967}.toggle[aria-checked="true"]:after{transform:translateX(12px)}.schedule-row{display:flex;align-items:center;gap:17px;padding:22px;border:1px solid #dfe9d3;background:#fff;border-radius:10px;margin-bottom:12px}.schedule-row h3{font-size:12px;font-weight:500;color:#799659;margin-bottom:8px}.schedule-row small{font-size:9px;color:#a4b88c}.schedule-row .toggle{margin-left:auto}.schedule-row>svg{color:#a7bd8d}.schedule-row .icon-button{color:#9fb587}.note-box{padding:15px 17px;border:1px solid #e5e9d8;border-radius:8px;background:#f8faef;font-size:10px;color:#9caa7e;line-height:1.9;margin-top:24px}
|
||||||
|
.modal{padding:0;border:1px solid #dfe7d6;border-radius:15px;width:590px;max-width:calc(100vw - 28px);color:#60764e;box-shadow:0 30px 90px #26371d30}.modal::backdrop{background:#24351e38;backdrop-filter:blur(5px)}.modal header{display:flex;justify-content:space-between;align-items:center;padding:23px 26px;border-bottom:1px solid #e7eddc;background:#fbfdf8}.modal h2{font-size:20px;font-weight:500;letter-spacing:-.4px;margin-top:8px;color:#698348}.modal-body{padding:23px 26px;max-height:66dvh;overflow:auto;background:#fff}.modal footer{padding:15px 26px;border-top:1px solid #e7eddd;background:#fcfdf9;display:flex;justify-content:flex-end;gap:8px}.modal footer:empty{display:none}.modal footer button{font-size:11px}.modal label{display:block;font-size:11px;color:#849d66;margin:16px 0 7px}.modal input,.modal select,.modal textarea{width:100%;font-size:11px;color:#779256;border-color:#dce7cf}.modal .muted{color:#91a778;font-size:11px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.form-error{font-size:11px;color:var(--red);margin-top:13px}.search-field{width:100%;margin-bottom:15px}.search-result{display:flex;gap:10px;width:100%;padding:12px;text-align:left;align-items:center;font-size:11px;color:#829f60}.search-result small{font-size:9px;color:#a1b989;margin-left:auto}.search-result svg{width:15px}.model-option{width:100%;padding:15px;display:flex;gap:11px;align-items:center;text-align:left;border:1px solid #e4ecdb;margin:9px 0}.model-option strong{font-size:12px;font-weight:500;color:#789651}.model-option small{font-size:9px;color:#a3ba8a;display:block;margin-top:6px}.model-option.selected{border-color:#afc992;background:#f4f9ed}.model-option>svg:last-child{margin-left:auto;color:#8caa68}.toast{position:fixed;bottom:43px;left:50%;transform:translateX(-50%);background:#456345;color:white;font-size:11px;border:1px solid #567a50;border-radius:9px;box-shadow:0 8px 30px #34502520;padding:12px 18px;z-index:10;max-width:90vw;line-height:1.7}
|
||||||
|
.event-arrival{animation:arrive .32s var(--ease) both}.panel-opening{animation:panel .3s var(--ease) both}.dialog-opening{animation:dialog .22s var(--ease) both}.picked{animation:pick .25s ease-out}.flash-update{animation:update .65s ease-out}.card-hover{transition:transform .18s,box-shadow .18s}@keyframes arrive{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes panel{from{opacity:.3;transform:translateX(18px)}to{opacity:1;transform:translateX(0)}}@keyframes dialog{from{opacity:.3;transform:translateY(8px) scale(.99)}to{opacity:1;transform:none}}@keyframes pick{50%{box-shadow:0 0 0 3px #b5c89225}}@keyframes update{0%,60%{background:#f1f6e8}100%{background:transparent}}@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
/* Keep supporting text readable on the softly tinted product surfaces. */
|
||||||
|
:root{--muted:#727e6a}.conversation-description,.composer-hint{color:#859277}.task-item{color:#68765b}.statusbar{color:#7e8e6d}.statusbar-muted{color:#859377}.sidebar-label{color:#849079}.project-nav small,.profile small{color:#849477}.message,.user-bubble{color:#55634a}.message p{color:#59694c}.ask-option small,.ask-body>.muted{color:#88956d}.ask-option strong{color:#657644}.card-footer>span{color:#938765}.tool-group summary,.tool-record{color:#7b8b64}.tool-group summary .tool-result,.tool-record small{color:#8d9e75}.paper-body{color:#71845c}.paper-cover h2{color:#657d42}.paper-cover p{color:#839969}.paper-meta,.paper-footer{color:#8d9f76}.paper-body h3{color:#647f47}.document-cards small,.doc-line{color:#839868}.canvas-tabs button{color:#859773}.canvas-bottom{color:#8a9f72}.composer textarea{color:#687a53}.composer textarea::placeholder{color:#8d9d7a}.overview-header .muted,.task-tile p{color:#879c72}.task-tile h3{color:#607d45}.connection-card p,.connection-card footer{color:#849f6b}.connection-card h3{color:#698a49}.connection-card header small,.schedule-row small{color:#8ca874}.note-box{color:#80965f}.empty-panel{color:#7e9864}.modal .muted{color:#809b64}.modal label{color:#6f8c51}
|
||||||
|
@media(min-width:1600px){.studio{grid-template-columns:245px minmax(0,1fr)}.task-workspace{grid-template-columns:minmax(500px,1fr) minmax(420px,41%)}.transcript{padding-top:17px}.conversation-title{font-size:28px}.message{font-size:13px}.user-bubble{font-size:12px}.ask-body h3{font-size:14px}.ask-option strong{font-size:12px}.ask-option small{font-size:10px}.paper-cover{padding:40px 35px}.paper-cover h2{font-size:32px}.paper-body{padding:28px 35px;font-size:12px}.paper-body h3{font-size:14px}.document-cards b{font-size:11px}.document-cards small{font-size:10px}.doc-line{font-size:11px}.paper-meta,.paper-footer{padding-left:35px}.task-item,.nav-item{font-size:12px}.project-nav{font-size:11px}.composer textarea{font-size:13px}.composer-bottom button{font-size:10px}.canvas-tabs button{font-size:11px}}
|
||||||
|
@media(max-width:1150px){.studio{grid-template-columns:198px minmax(0,1fr)}.sidebar{padding-left:12px;padding-right:12px}.task-workspace{grid-template-columns:minmax(340px,1fr) 330px}.transcript{padding-left:23px;padding-right:23px}.conversation-toolbar{padding-left:23px;padding-right:23px}.canvas-content{padding:15px}.paper-cover{padding:25px 22px}.paper-cover h2{font-size:24px}.paper-body{padding:21px}.task-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.prototype-label{display:none}.statusbar>span:last-child{display:none}}
|
||||||
|
@media(max-width:960px){.task-workspace{grid-template-columns:minmax(0,1fr)}.canvas{position:fixed;right:12px;top:77px;bottom:39px;width:min(440px,calc(100vw - 24px));z-index:4;border:1px solid #d9e5ca;border-radius:12px;box-shadow:0 12px 70px #3c522b2a}.canvas-closed .canvas{display:none}.conversation-title{font-size:27px}.paper-body{font-size:12px}.task-workspace .canvas-toggle{display:inline-flex}.topbar{padding:0 17px}.connection-grid{grid-template-columns:1fr}.overview-page{padding:32px 27px}.overview-header h1{font-size:26px}}
|
||||||
|
@media(max-width:650px){.studio{grid-template-columns:0 minmax(0,1fr)}.sidebar{visibility:hidden}.studio.mobile-rail .sidebar{visibility:visible;position:fixed;left:0;top:0;bottom:0;width:228px;z-index:6;box-shadow:10px 0 50px #2f442428;opacity:1;pointer-events:auto}.topbar{height:56px;padding:0 12px}.breadcrumb{gap:7px}.breadcrumb>span:not(.slash){display:none}.top-actions{gap:6px}.top-actions .top-avatar{display:none}.top-actions .icon-button:first-of-type{display:none}.statusbar{font-size:8px;padding:0 12px}.statusbar>span:nth-child(2){display:none}.conversation-toolbar{padding:14px 17px 8px}.transcript{padding:10px 19px 20px}.conversation-title{font-size:24px}.conversation-description{font-size:10px}.message{font-size:13px}.user-bubble{font-size:12px}.ask-option strong{font-size:12px}.ask-option small{font-size:10px}.ask-body h3{font-size:14px}.ask-body>.muted{font-size:11px}.ask-option{min-height:50px}.card-footer{flex-wrap:wrap}.card-footer button{min-height:38px;font-size:11px}.composer-zone{padding:7px 15px 13px}.composer textarea{font-size:16px}.composer-bottom button{font-size:10px;min-height:34px}.composer-bottom .send{width:34px;height:34px}.composer-bottom .composer-model{max-width:145px}.composer-hint{font-size:8px}.canvas{top:66px}.overview-page{padding:27px 19px}.overview-header h1{font-size:24px}.overview-header{align-items:flex-start}.overview-header .eyebrow{font-size:8px}.overview-header button{font-size:10px;white-space:nowrap}.task-grid{grid-template-columns:1fr}.task-tile{min-height:135px}.task-tile h3{font-size:14px}.task-tile p{font-size:12px}.task-tile>small{font-size:10px}.project-banner{padding:16px;gap:10px;flex-wrap:wrap}.project-banner h3{font-size:12px}.project-banner p{font-size:10px}.modal-body{padding:20px}.modal header{padding:20px}.form-grid{grid-template-columns:1fr}.modal input,.modal select,.modal textarea{font-size:16px}.modal{max-width:calc(100vw - 20px)}.schedule-row{padding:17px;gap:12px}.filter-tabs{gap:2px;flex-wrap:wrap}.filter-tabs button{font-size:11px;padding:8px}.studio{min-height:620px}}
|
||||||
|
.nav-item{font-size:12px}.task-item{font-size:11px}.sidebar-label{font-size:10px}.project-nav{font-size:11px}.profile{font-size:11px}.profile small,.project-nav small{font-size:10px}.breadcrumb{font-size:11px}.breadcrumb strong{font-size:12px}.conversation-description{font-size:11px}.message{font-size:14px}.user-bubble{font-size:13px;color:#4b5946}.agent-byline{font-size:12px}.agent-byline small{font-size:10px}.message p{color:#52614a}.compact-plan summary{font-size:12px}.plan-steps li{font-size:12px}.tool-group summary,.tool-record{font-size:11px}.tool-group summary .tool-result,.tool-record small{font-size:10px}.receipt{font-size:11px}.ask-body h3{font-size:15px;color:#4c5239}.ask-body>.muted{font-size:12px;color:#7b806a}.ask-option strong{font-size:12px;color:#546239}.ask-option small{font-size:11px;color:#7c8768}.card-heading{font-size:11px}.card-heading>span:last-child{font-size:10px}.card-footer>span{font-size:10px}.card-footer button{font-size:11px}.ask-free{font-size:12px}.ask-option .recommended{font-size:9px}.execution-top{font-size:12px}.execution-bottom{font-size:11px}.execution-top .small{font-size:10px}.subagent{font-size:10px}.result-card b{font-size:12px}.result-card small{font-size:11px}.composer textarea{font-size:13px}.composer-bottom button{font-size:11px}.composer-hint{font-size:9px}.status-pill{font-size:10px}.task-meta .project-tag{font-size:10px}.canvas-tabs button{font-size:11px}.document-tools{font-size:11px;color:#788969}.document-tools button{font-size:10px}.paper-meta{font-size:10px;color:#7b8c68}.paper-body{font-size:12px;color:#647650}.paper-body h3{font-size:14px;color:#566d3f}.paper-cover h2{color:#536b35}.paper-cover p{font-size:11px;color:#788d5d}.document-cards b{font-size:11px;color:#6a814f}.document-cards small{font-size:10px;color:#788a65}.doc-line{font-size:11px;color:#768960}.doc-line span:first-child{font-size:10px;color:#879873;width:40px}.paper-footer{font-size:9px;color:#8b9b78}.canvas-bottom{font-size:10px;color:#7f9366}.canvas-bottom button{font-size:10px}.activity-entry{font-size:12px;color:#758a5c}.activity-entry small{font-size:10px;color:#8d9f77}.file-row{font-size:12px;color:#728951}.file-row small{font-size:10px;color:#8b9c74}.task-tile h3{font-size:15px;color:#506c35}.task-tile p{font-size:12px;color:#7c9069}.task-tile>small{font-size:10px;color:#8fa17c}.task-tile .status-pill{font-size:10px}.section-heading h2{font-size:15px}.section-heading span{font-size:11px}.overview-header .muted{font-size:13px}.overview-header button{font-size:12px}.connection-card h3{font-size:14px}.connection-card p{font-size:12px;line-height:1.8}.connection-card header small,.connection-card footer{font-size:11px}.connection-card footer button{font-size:11px}.filter-tabs button{font-size:12px}.note-box{font-size:12px;color:#788a5e}.schedule-row h3{font-size:14px}.schedule-row small{font-size:11px}.modal label,.modal input,.modal select,.modal textarea{font-size:13px}.modal .muted{font-size:12px;color:#768a5f}.modal footer button{font-size:12px}.model-option strong{font-size:14px}.model-option small{font-size:11px}.search-result{font-size:13px}.search-result small{font-size:11px}.task-meta{min-width:0}.project-tag{max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.conversation-toolbar{flex-shrink:0}.task-workspace.canvas-closed .transcript{padding-left:max(28px,calc((100% - 770px)/2));padding-right:max(28px,calc((100% - 770px)/2))}.task-workspace.canvas-closed .composer-zone{padding-left:max(26px,calc((100% - 770px)/2));padding-right:max(26px,calc((100% - 770px)/2))}.task-workspace.canvas-closed .conversation-toolbar{padding-left:max(28px,calc((100% - 770px)/2));padding-right:max(28px,calc((100% - 770px)/2))}.large-type .message,.large-type .user-bubble{font-size:16px}.large-type .paper-body{font-size:14px}.large-type .composer textarea{font-size:16px}.large-type .ask-option strong{font-size:14px}.large-type .nav-item,.large-type .task-item{font-size:13px}.reduce-motion *,.reduce-motion *:before,.reduce-motion *:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}
|
||||||
|
@media(max-width:650px){.composer textarea,.modal input,.modal select,.modal textarea{font-size:16px}.composer-bottom button{font-size:10px}.composer-bottom .composer-model{max-width:125px}.task-meta .project-tag{max-width:135px;font-size:9px}.status-pill{font-size:9px}.task-workspace.canvas-closed .transcript{padding-left:19px;padding-right:19px}.task-workspace.canvas-closed .composer-zone{padding-left:15px;padding-right:15px}.task-workspace.canvas-closed .conversation-toolbar{padding-left:17px;padding-right:17px}.card-footer>span{font-size:10px}.task-tile p{font-size:12px}}
|
||||||
|
@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="studio" id="studio">
|
||||||
|
<aside class="sidebar" aria-label="工作空间导航">
|
||||||
|
<button class="wordmark" data-action="home"><span class="brand-symbol">r<span></span></span><span>Reasonix<small>PERSONAL WORKSPACE</small></span></button>
|
||||||
|
<button class="new-task" data-action="new"><i data-icon="plus"></i> 新任务 <kbd>Ctrl N</kbd></button>
|
||||||
|
<button class="nav-item" data-action="search"><i data-icon="search"></i> 搜索工作空间 <kbd>⌘ K</kbd></button>
|
||||||
|
<div class="nav-group">
|
||||||
|
<button class="nav-item" data-route="home"><i data-icon="grid"></i> 工作台</button>
|
||||||
|
<button class="nav-item" data-route="inbox"><i data-icon="inbox"></i> 待我处理 <span class="count" id="attentionCount">2</span></button>
|
||||||
|
<button class="nav-item" data-route="library"><i data-icon="files"></i> 我的成果</button>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-label">项目 <button aria-label="创建项目" data-action="project"><i data-icon="plus"></i></button></div>
|
||||||
|
<button class="project-nav" data-action="project-info"><span class="project-avatar">A</span><span>Atelier 品牌工作室<small>策略 · 产品 · 内容</small></span><i data-icon="chevron"></i></button>
|
||||||
|
<div class="sidebar-label">最近任务 <span id="taskCount"></span></div>
|
||||||
|
<div class="task-list" id="taskList"></div>
|
||||||
|
<div class="sidebar-bottom">
|
||||||
|
<button class="nav-item" data-route="automation"><i data-icon="clock"></i> 定时任务</button>
|
||||||
|
<button class="nav-item" data-route="capabilities"><i data-icon="puzzle"></i> 能力与连接</button>
|
||||||
|
<div class="profile"><span class="profile-avatar">L</span><span>我的工作空间<small><span class="dot green"></span> 本地交互原型</small></span><button class="icon-button" data-action="settings" aria-label="界面设置"><i data-icon="settings"></i></button></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="body-shell">
|
||||||
|
<header class="topbar"><div class="breadcrumb"><button class="icon-button" data-action="sidebar" aria-label="切换导航"><i data-icon="panel"></i></button><span>工作空间</span><span class="slash">/</span><strong id="pageTitle">秋季新品发布策略</strong></div><div class="top-actions"><span class="prototype-label">DESIGN PROTOTYPE</span><button class="icon-button" data-action="search" aria-label="搜索"><i data-icon="search"></i></button><button class="icon-button" data-action="notifications" aria-label="通知"><i data-icon="bell"></i><b id="notificationDot"></b></button><span class="top-avatar">L</span></div></header>
|
||||||
|
<section id="page" class="page"></section>
|
||||||
|
<footer class="statusbar"><span><i class="dot green"></i> Reasonix Studio <span class="statusbar-muted">/ 概念交互版</span></span><span id="bottomStatus">模型与工具调用均为模拟,不访问真实服务</span><span class="statusbar-muted">所有交互仅在本次页面中保留</span></footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dialog id="modal" class="modal"><header><div><span class="eyebrow" id="modalEyebrow">WORKSPACE</span><h2 id="modalTitle"></h2></div><button class="icon-button" data-action="close-modal" aria-label="关闭弹窗"><i data-icon="close"></i></button></header><div id="modalBody" class="modal-body"></div><footer id="modalFooter"></footer></dialog>
|
||||||
|
<input type="file" id="attachmentInput" hidden multiple>
|
||||||
|
<div class="toast" id="toast" role="status" hidden></div>
|
||||||
|
<div id="announcer" class="sr-only" aria-live="polite"></div>
|
||||||
|
<script>
|
||||||
|
(()=>{
|
||||||
|
'use strict';
|
||||||
|
const $=id=>document.getElementById(id);
|
||||||
|
const paths={plus:'M12 5v14M5 12h14',search:'M21 21l-5-5M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0',grid:'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z',inbox:'M4 4h16l2 12v4H2v-4L4 4zM2 15h6l2 3h4l2-3h6',files:'M6 3h9l4 4v14H6zM14 3v5h5M3 7v14M9 12h7M9 16h7',chevron:'M9 5l7 7-7 7',clock:'M12 8v5l3 2M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',puzzle:'M9 3H3v6c4-2 5 4 0 3v9h7c-2-5 5-5 3 0h8v-8c-5 2-5-5 0-3V3h-7c2 5-5 5-5 0',settings:'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8M9 2h6l1 4 4 1 2 5-3 3v4l-5 3-3-3H7l-4-4 1-4-2-3 3-4 4 1z',panel:'M3 3h18v18H3zM9 3v18',bell:'M5 17h14l-2-3V9a5 5 0 0 0-10 0v5l-2 3M10 21h4',close:'M6 6l12 12M6 18L18 6',arrow:'M5 12h14M13 6l6 6-6 6',up:'M12 19V5M6 11l6-6 6 6',check:'M5 12l4 4L19 6',checkcircle:'M8 12l3 3 5-6M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',question:'M9 8a3 3 0 1 1 4 3c-1 0-1 1-1 3M12 17h.01M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',shield:'M12 2l9 4v6c0 5-9 10-9 10S3 17 3 12V6l9-4M8 12l3 3 5-6',link:'M10 13a5 5 0 0 0 7 0l4-4a5 5 0 0 0-7-7l-2 2M14 11a5 5 0 0 0-7 0l-4 4a5 5 0 0 0 7 7l2-2',download:'M12 3v12M7 10l5 5 5-5M4 16v5h16v-5',edit:'M4 16L16 4l4 4L8 20H4v-4M14 6l4 4',more:'M5 12h.01M12 12h.01M19 12h.01',globe:'M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0M2 12h20M12 2c-5 5-5 15 0 20 5-5 5-15 0-20',code:'M8 6l-6 6 6 6M16 6l6 6-6 6M14 3l-4 18',folder:'M2 5h7l2 3h11v13H2V5z',paperclip:'M8 12l7-7a4 4 0 0 1 6 6l-9 9a6 6 0 0 1-9-9l9-9M6 14l9-9a2 2 0 0 1 3 3l-9 9',pause:'M8 5v14M16 5v14',play:'M7 4l14 8-14 8V4z',stop:'M6 6h12v12H6z',warning:'M12 3L1 21h22L12 3zM12 9v5M12 17h.01',list:'M9 6h12M9 12h12M9 18h12M3 6h.01M3 12h.01M3 18h.01',spark:'M12 2l3 7 7 3-7 3-3 7-3-7-7-3 7-3 3-7z',layers:'M12 2l10 6-10 6L2 8l10-6M2 12l10 6 10-6M2 16l10 6 10-6',expand:'M8 3H3v5M16 3h5v5M3 16v5h5M21 16v5h-5',mail:'M2 5h20v14H2zM2 5l10 8L22 5',refresh:'M20 7a9 9 0 1 0 1 8M20 2v6h-6',archive:'M3 3h18v5H3zM5 8v13h14V8M10 12h4'};
|
||||||
|
const icon=name=>`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${paths[name]||paths.spark}"/></svg>`;
|
||||||
|
function icons(root=document){root.querySelectorAll('[data-icon]').forEach(el=>{el.outerHTML=icon(el.dataset.icon)})}
|
||||||
|
const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const button=(action,label,cls='',data='')=>`<button class="${cls}" data-action="${action}" ${data}>${label}</button>`;
|
||||||
|
const statusLabels={waiting:'等你回答',approval:'需要授权',running:'执行中',paused:'已暂停',done:'已完成',blocked:'需要处理',idle:'就绪'};
|
||||||
|
const statusIcon=s=>s==='waiting'?'question':s==='approval'?'shield':s==='blocked'?'warning':s==='done'?'check':'clock';
|
||||||
|
const state={route:'task',active:'launch',canvas:true,canvasTab:'preview',capTab:'models',project:'Atelier 品牌工作室',models:[{id:'balanced',name:'通用模型',description:'标准对话 · 工具调用 · 流式输出',provider:'演示服务',protocol:'OpenAI 兼容',endpoint:'',effort:true},{id:'fast',name:'快速模型',description:'轻量问答 · 工具调用',provider:'演示服务',protocol:'OpenAI 兼容',endpoint:'',effort:false}],tools:[{id:'web',name:'网页检索',description:'搜索公开资料,读取页面并保留来源。',kind:'内置工具',icon:'globe',enabled:true},{id:'files',name:'工作区文件',description:'读取与编辑当前项目内的文件。',kind:'本地能力',icon:'folder',enabled:true},{id:'notion',name:'Notion',description:'搜索项目知识库,读取页面和数据库。',kind:'MCP 连接器',icon:'files',enabled:false},{id:'http',name:'自定义 HTTP API',description:'通过适配器连接业务服务。',kind:'HTTP API',icon:'link',enabled:false}],skills:[{id:'research',name:'研究与整理',description:'整理来源、比较信息并形成可核查的结论。',enabled:true},{id:'brand',name:'品牌写作',description:'在项目内保持一致的语气、术语和表达。',enabled:true},{id:'sheet',name:'表格分析',description:'分析数据、整理表格并检查计算。',enabled:false}],schedules:[{id:'weekly',name:'每周行业动态',prompt:'汇总本周与产品相关的行业动态,并保留来源。',cadence:'每周一 09:00',enabled:true}],drafts:{},attachments:{},askChoices:{},permission:'ask',model:'balanced',effort:'标准',notificationCount:0};
|
||||||
|
let sequence=20,toastTimer,clockTimer;
|
||||||
|
const tasks=[
|
||||||
|
{id:'launch',title:'秋季新品发布策略',summary:'定位、内容节奏与渠道建议',status:'waiting',kind:'strategy',model:'balanced',version:1,step:2,ticks:0,artifact:true,ask:true,choice:null,permission:null,goal:'结合项目资料,帮我整理一份秋季新品发布策略。先明确产品定位,再给出内容与渠道建议。',plan:['阅读项目资料与品牌约定','梳理产品定位与目标人群','制定发布节奏与渠道建议','整理成可讨论的策略文档'],messages:[{type:'user',text:'结合项目资料,帮我整理一份秋季新品发布策略。先明确产品定位,再给出内容与渠道建议。'},{type:'agent',text:'我已经整理了项目里的品牌方向和产品介绍,先搭好了策略文档的结构。\n发布节奏还需要明确一个重点:这次更希望让新用户认识品牌,还是推动已有用户尝试新品?',tools:['已读取 品牌手册.md','已读取 新品介绍.md','已整理 3 个发布方向']}],events:['开始整理发布策略','读取 2 份项目资料','完成定位初稿,等待确认目标'],revision:''},
|
||||||
|
{id:'research',title:'个人助手产品研究',summary:'比较交互方式与核心能力',status:'idle',kind:'research',model:'balanced',version:0,step:1,ticks:0,artifact:false,ask:false,goal:'比较主流个人助手的交互方式,整理一份产品研究简报。',plan:['明确比较维度','收集产品资料','整理功能与交互差异','形成研究简报'],messages:[{type:'user',text:'比较主流个人助手的交互方式,整理一份产品研究简报。'},{type:'agent',text:'我会从任务组织、工具接入和成果交付三个角度展开,保留来源。你可以在我工作时继续补充关注点。'}],events:['研究计划已准备好'],revision:''},
|
||||||
|
{id:'email',title:'合作邀约邮件',summary:'草稿已完成,发送前等待确认',status:'approval',kind:'email',model:'fast',version:1,step:2,ticks:0,artifact:true,ask:false,permission:'pending',goal:'根据项目内容起草一封合作邀约邮件。',plan:['梳理合作背景','起草邮件','确认收件人和内容','发送并记录结果'],messages:[{type:'user',text:'起草一封合作邀约邮件,发送前让我确认。'},{type:'agent',text:'邮件草稿已经准备好,你可以在右侧检查。下一步是发送邮件,这会联系外部收件人,需要你确认。'}],events:['已生成邮件草稿','等待发送授权'],revision:''},
|
||||||
|
{id:'analysis',title:'用户访谈主题整理',summary:'整理完成,含主题与后续问题',status:'done',kind:'research',model:'balanced',version:1,step:4,ticks:0,artifact:true,ask:false,goal:'把访谈笔记中的主要主题整理出来。',plan:['读取访谈笔记','整理主题','标记待验证问题','交付主题简报'],messages:[{type:'user',text:'把访谈笔记中的主要主题整理出来。'},{type:'agent',text:'主题简报已整理完成。示例文档列出了重点方向与后续验证问题,你可以直接打开,也可以继续提出修改。'}],events:['读取资料','整理主题','完成交付'],revision:''},
|
||||||
|
{id:'sync',title:'同步项目知识库',summary:'连接失效,现有进度已保存',status:'blocked',kind:'research',model:'balanced',version:0,step:1,ticks:0,artifact:false,ask:false,goal:'同步项目知识库里的最新资料。',plan:['检查已有资料','读取远程更新','合并新增内容','生成同步记录'],messages:[{type:'user',text:'同步项目知识库里的最新资料。'},{type:'agent',text:'本地资料已检查完成。知识库的访问凭证失效,无法继续读取远程更新;本地文件没有被修改。'}],events:['完成本地资料检查','连接返回身份验证错误'],revision:''}
|
||||||
|
];
|
||||||
|
function current(){return tasks.find(t=>t.id===state.active)||tasks[0]}
|
||||||
|
function pill(t){return `<span class="status-pill ${t.status}"><span class="dot ${t.status==='waiting'||t.status==='approval'?'amber':t.status==='blocked'?'red':t.status==='running'?'running':'green'}"></span>${statusLabels[t.status]}</span>`}
|
||||||
|
function announce(text){$('announcer').textContent=text}
|
||||||
|
function toast(text){clearTimeout(toastTimer);$('toast').textContent=text;$('toast').hidden=false;toastTimer=setTimeout(()=>$('toast').hidden=true,3500)}
|
||||||
|
function renderSidebar(){const visible=tasks.filter(t=>!t.archived);$('taskCount').textContent=visible.length;$('taskList').innerHTML=visible.map(t=>`<button class="task-item ${state.route==='task'&&state.active===t.id?'active':''}" data-task="${t.id}"><span class="dot ${t.status==='running'?'running':t.status==='done'?'green':t.status==='blocked'?'red':'amber'}"></span><span class="task-name">${esc(t.title)}</span>${['waiting','approval','blocked'].includes(t.status)?`<span class="task-status-icon">${icon(statusIcon(t.status))}</span>`:''}</button>`).join('');$('attentionCount').textContent=tasks.filter(t=>!t.archived&&['waiting','approval','blocked'].includes(t.status)).length;$('notificationDot').hidden=state.notificationCount===0;document.querySelectorAll('[data-route]').forEach(b=>b.classList.toggle('active',b.dataset.route===state.route));const label=document.querySelector('.project-nav>span:nth-child(2)');label.innerHTML=esc(state.project)+'<small>策略 · 产品 · 内容</small>'}
|
||||||
|
function render(){renderSidebar();if(state.route==='task')renderTask();else renderPage();icons();}
|
||||||
|
function navigate(route,id){state.route=route;if(id)state.active=id;$('studio').classList.remove('mobile-rail');render();}
|
||||||
|
function messageHTML(m,index){if(m.type==='user')return `<section class="message user" data-message="${index}"><div class="user-bubble">${esc(m.text)}</div></section>`;return `<section class="message" data-message="${index}"><div class="agent-byline"><span class="agent-avatar">r</span>Reasonix<small>${m.steering?'已接收补充':'通用助手'}</small></div><p>${esc(m.text)}</p>${m.tools?`<details class="tool-group"><summary>${icon('checkcircle')}<span>${m.tools.length} 项工作已完成</span><span class="tool-result">查看记录</span>${icon('chevron')}</summary>${m.tools.map(x=>`<div class="tool-record">${icon('check')}<span>${esc(x)}</span><small>示例</small></div>`).join('')}</details>`:''}</section>`}
|
||||||
|
function planHTML(t){if(!t.plan.length)return '';const n=Math.min(t.step,t.plan.length);return `<details class="compact-plan" id="taskPlan"><summary>${icon('list')}<span>执行计划</span><span class="plan-total">${n} / ${t.plan.length}</span><span class="chevron-small">${icon('chevron')}</span></summary><ol class="plan-steps">${t.plan.map((x,i)=>`<li class="${i<n?'complete':i===n?'current':''}"><span class="step-circle">${i<n?'✓':i+1}</span><span>${esc(x)}</span></li>`).join('')}</ol><div style="padding:0 8px 8px;text-align:right">${button('edit-plan','调整计划','text-button')}</div><div class="plan-progress"><i style="width:${100*n/t.plan.length}%"></i></div></details>`}
|
||||||
|
function askHTML(t){const options=[['品牌认知','让更多目标用户认识我们,建立清晰印象。'],['新品转化','面向已有用户,强调产品价值与尝试理由。'],['两者兼顾','分阶段推进:先建立认知,再承接转化。']];const selected=state.askChoices[t.id]??0;return `<section class="ask-card" id="activeDecision"><div class="card-heading">${icon('question')}<span>需要你确认一个方向</span><span>问题 1 / 1</span></div><div class="ask-body"><h3>这次发布,更希望优先达成什么?</h3><p class="muted">这会影响文档里的渠道权重和内容安排。</p><div class="ask-options">${options.map((x,i)=>`<button class="ask-option ${selected===i?'selected':''}" data-choice="${i}" aria-pressed="${selected===i}"><span class="radio"></span><span><strong>${x[0]}</strong><small>${x[1]}</small></span>${i===0?'<span class="recommended">建议</span>':''}</button>`).join('')}</div><input class="ask-free" id="askFree" aria-label="自定义回答" placeholder="也可以写下更具体的目标…" value="${esc(t.askDraft||'')}"></div><footer class="card-footer"><span>回答后继续,已有内容会保留。</span>${button('answer','确认方向 '+icon('arrow'),'primary')}</footer></section>`}
|
||||||
|
function approvalHTML(){return `<section class="ask-card approval-card" id="activeDecision"><div class="card-heading">${icon('shield')}<span>发送前需要你的授权</span><span>外部操作</span></div><div class="ask-body"><h3>发送这封合作邀约邮件?</h3><p class="muted">收件人:合作伙伴(示例)<br>主题:关于秋季新品的内容合作</p><div class="approval-scope"><span>仅这封邮件</span><span>不读取其他邮件</span><span>可先修改草稿</span></div><p class="muted">原型只演示确认过程,不会发送邮件。</p></div><footer class="card-footer">${button('deny','保留草稿','outline')}<div>${button('edit-artifact','修改内容','text-button')} ${button('approve','确认发送','primary')}</div></footer></section>`}
|
||||||
|
function blockedHTML(){return `<section class="ask-card"><div class="card-heading">${icon('warning')}<span>连接需要处理</span><span>AUTH_EXPIRED</span></div><div class="ask-body"><h3>知识库授权已过期</h3><p class="muted">可以重新连接,或仅使用本地资料继续。此前的工作不会丢失。</p></div><footer class="card-footer">${button('skip-connection','使用本地资料','outline')}${button('reconnect','重新连接','primary')}</footer></section>`}
|
||||||
|
function executionHTML(t){return `<div class="execution" id="execution"><div class="execution-top">${t.status==='running'?'<span class="spinner"></span>':icon('pause')}<span id="executionLabel">${t.status==='paused'?'执行已暂停':t.plan[Math.min(t.step,t.plan.length-1)]||'整理任务成果'}</span>${button(t.status==='paused'?'resume':'pause',t.status==='paused'?'继续':'暂停','small outline')}</div><div class="execution-bottom" id="executionDetail">${t.status==='paused'?'已有进度保留,可随时补充要求。':'正在推进 · 演示执行事件'}</div><div class="run-progress"><i id="executionProgress" style="width:${Math.min(90,20+t.ticks*14)}%"></i></div>${t.kind==='research'?'<div class="subagents"><span class="subagent">资料整理 · 工作中</span><span class="subagent">观点归纳 · 工作中</span></div>':''}</div>`}
|
||||||
|
function resultHTML(t){return `<button class="result-card" data-action="open-artifact"><span class="result-icon">${icon(t.kind==='email'?'mail':'files')}</span><span><b>${esc(t.title)}${t.kind==='email'?' · 草稿':'.md'}</b><small>版本 ${t.version} · ${t.status==='done'?'已完成':'可查看草稿'} · 点击打开</small></span>${icon('arrow')}</button>`}
|
||||||
|
function tailHTML(t){if(t.ask&&t.status==='waiting')return askHTML(t);if(t.status==='approval')return approvalHTML();if(t.status==='blocked')return blockedHTML();if(['running','paused'].includes(t.status))return executionHTML(t);if(t.status==='idle')return `<div class="receipt">${icon('checkcircle')}执行计划已就绪,随时可以补充要求。</div>${button('start','开始执行 '+icon('arrow'),'primary small')}`;if(t.artifact)return resultHTML(t);return ''}
|
||||||
|
function composerHTML(t){const model=state.models.find(m=>m.id===t.model)||state.models[0];return `<div class="composer-zone">${['running','paused'].includes(t.status)?`<div class="steering-banner"><span>${icon('spark')} ${t.status==='running'?'Agent 工作中,你仍然可以补充或调整方向':'任务已暂停,可以修改要求后继续'}</span>${button(t.status==='running'?'pause':'resume',t.status==='running'?'暂停':'继续','text-button')}</div>`:''}<form class="composer" id="composer"><div class="attached-files">${(state.attachments[t.id]||[]).map((f,i)=>`<span class="file-chip">${icon('files')}${esc(f)}<button type="button" data-remove-file="${i}" aria-label="移除附件 ${esc(f)}">${icon('close')}</button></span>`).join('')}</div><textarea id="messageInput" rows="2" aria-label="消息" placeholder="${t.status==='waiting'?'直接回复,或者补充你希望达成的目标…':t.status==='running'?'补充要求,Agent 会在执行中接收…':'继续聊聊,或告诉 Agent 下一步…'}">${esc(state.drafts[t.id]||'')}</textarea><div class="composer-bottom"><button type="button" data-action="attach" aria-label="添加附件">${icon('plus')}</button><button type="button" data-action="context">${icon('folder')} 项目上下文</button><button type="button" class="composer-model" data-action="model">${icon('spark')} ${esc(model.name)}</button><button type="button" data-action="permission" aria-label="权限设置">${icon('shield')}</button><button class="send" type="submit" aria-label="发送消息">${icon('up')}</button></div></form><div class="composer-hint"><span>Enter 发送 · Shift + Enter 换行</span><span>${(t.permissionMode||'ask')==='ask'?'必要时询问':'演示自动审批'} · ${model.effort?(t.effort||'标准'):'自动'}</span></div></div>`}
|
||||||
|
function renderTask(){const t=current();$('pageTitle').textContent=t.title;$('page').innerHTML=`<div class="task-workspace ${state.canvas?'':'canvas-closed'}"><main class="conversation"><div class="conversation-toolbar"><div class="task-meta"><span class="project-tag">${esc(state.project)}</span>${pill(t)}</div><div class="conversation-actions"><button class="icon-button" data-action="toggle-canvas" aria-label="${state.canvas?'收起':'打开'}工作区">${icon('panel')}</button><button class="icon-button" data-action="task-menu" aria-label="任务操作">${icon('more')}</button></div></div><div class="transcript" id="transcript"><h1 class="conversation-title">${esc(t.title)}</h1><p class="conversation-description">${esc(t.summary||'一个新的想法,逐步变成成果。')} <span style="margin:0 7px">·</span> 本次工作</p><div id="messages">${t.messages.map(messageHTML).join('')}</div>${planHTML(t)}<div id="taskTail">${tailHTML(t)}</div></div>${composerHTML(t)}</main><aside class="canvas" aria-label="任务工作区"><div class="canvas-toolbar"><div class="canvas-tabs" role="tablist" aria-label="工作区内容">${[['preview','成果预览'],['files','文件'],['activity','执行记录']].map(([id,label])=>`<button id="canvas-tab-${id}" role="tab" aria-controls="canvasContent" aria-selected="${state.canvasTab===id}" data-canvas-tab="${id}">${label}</button>`).join('')}</div><button class="icon-button" data-action="toggle-canvas" aria-label="收起工作区">${icon('close')}</button></div><div class="canvas-content" id="canvasContent" role="tabpanel" aria-labelledby="canvas-tab-${state.canvasTab}"></div><div class="canvas-bottom"><span>内容与当前任务保持关联</span>${button('edit-artifact',icon('edit')+' 提出修改')}</div></aside></div>`;renderCanvas();}
|
||||||
|
function documentHTML(t){const isEmail=t.kind==='email';const subtitle=isEmail?'一个真诚、简洁的合作邀请。':t.kind==='strategy'?'让新产品,以恰当的方式被看见。':'从分散的信息,到清晰的判断。';const body=isEmail?`<h3><span>01</span>邮件正文</h3><p>你好,\n\n我们正在准备秋季新品发布,希望与你讨论一次内容合作。我们欣赏你对日常体验的细腻表达,也期待一起呈现产品在真实生活中的价值。</p><p>如果你有兴趣,我们可以先交流产品方向、内容形式与时间安排,再共同确定具体方案。</p><p>期待你的回复。\nAtelier 团队</p>`:`<h3><span>01</span>${t.kind==='strategy'?'核心方向':'研究目标'}</h3><p>${t.choice?`以「${esc(t.choice)}」为主要方向,`:'以用户的实际需求为出发点,'}${t.kind==='strategy'?'将产品的功能价值转化成容易理解的生活场景,先解释为什么需要,再说明有什么不同。':'围绕实际使用过程,整理关键能力、交互方式与值得继续验证的问题。'}</p><div class="document-cards"><div><b>${t.kind==='strategy'?'表达重点':'任务连续性'}</b><small>${t.kind==='strategy'?'真实使用场景\n清楚、克制的产品表达':'上下文、计划与执行过程\n在同一任务中保持连贯'}</small></div><div><b>${t.kind==='strategy'?'内容原则':'用户可控性'}</b><small>${t.kind==='strategy'?'减少空泛的描述\n用具体体验解释价值':'可以补充要求、检查成果\n在重要操作前做出决定'}</small></div></div><h3><span>02</span>${t.kind==='strategy'?'推进节奏':'接下来需要验证'}</h3><div class="doc-line"><span>第一步</span><span>${t.kind==='strategy'?'预热:建立问题认知与期待':'明确比较对象与使用场景'}</span></div><div class="doc-line"><span>第二步</span><span>${t.kind==='strategy'?'发布:解释产品价值与使用方式':'通过实际任务观察执行过程'}</span></div><div class="doc-line"><span>第三步</span><span>${t.kind==='strategy'?'延续:收集反馈,补充真实案例':'检查结果质量与中途干预方式'}</span></div>`;return `<div class="document-tools"><span>${icon('files')} ${esc(t.title)}.md</span>${button('versions','v'+t.version+' ▾')}</div><article class="paper"><header class="paper-cover"><span class="eyebrow">ATELIER / ${isEmail?'CORRESPONDENCE':'WORKING DOCUMENT'}</span><h2>${esc(t.title)}</h2><p>${subtitle}</p><div class="cover-art" aria-hidden="true"><span></span><span></span><span></span></div></header><div class="paper-meta"><span>${t.status==='done'?'已交付':'工作草稿'}</span><span>版本 ${t.version}</span><span>中文</span></div><div class="paper-body">${body}${t.revision?`<h3><span>03</span>本轮修改要求</h3><p>${esc(t.revision)}</p>`:''}</div><footer class="paper-footer"><span>REASONIX STUDIO</span><span>原型示例内容 · 非真实执行结果</span></footer></article>`}
|
||||||
|
function renderCanvas(){if(state.route!=='task'||!$('canvasContent'))return;const t=current();document.querySelectorAll('[data-canvas-tab]').forEach(b=>b.setAttribute('aria-selected',String(state.canvasTab===b.dataset.canvasTab)));$('canvasContent').setAttribute('aria-labelledby','canvas-tab-'+state.canvasTab);if(state.canvasTab==='preview')$('canvasContent').innerHTML=t.artifact?documentHTML(t):`<div class="empty-panel">${icon('files')}成果会在这里逐步形成。<br><span class="muted">当前可以先查看文件与执行记录。</span></div>`;if(state.canvasTab==='files')$('canvasContent').innerHTML=`<h3 class="activity-heading">项目输入</h3>${['品牌手册.md','新品介绍.md'].map((x,i)=>`<button class="file-row" data-source="${i}">${icon('files')}<span>${x}<small>项目资料 · 示例</small></span><span class="arrow">${icon('chevron')}</span></button>`).join('')}<h3 class="activity-heading" style="margin-top:28px">本次产出</h3>${t.artifact?`<button class="file-row" data-action="open-artifact">${icon('files')}<span>${esc(t.title)}.md<small>版本 ${t.version} · 可预览和下载</small></span></button>`:'<p class="muted">暂无产出文件</p>'}`;if(state.canvasTab==='activity')$('canvasContent').innerHTML=`<h3 class="activity-heading">执行事件 · 模拟数据</h3>${t.events.map((event,i)=>`<div class="activity-entry">${esc(event)}<small>步骤 ${i+1} · ${i===t.events.length-1?'最新进展':'已记录'}</small></div>`).join('')}`;const bottom=document.querySelector('.canvas-bottom');bottom.innerHTML=t.artifact?`<span>${t.kind==='email'?'邮件草稿':'Markdown 文档'} · 可继续迭代</span><div>${button('download',icon('download')+' 下载')}${button('edit-artifact',icon('edit')+' 修改')}</div>`:'<span>工作区会随任务更新</span>';}
|
||||||
|
function taskTile(t){return `<button class="task-tile" data-task="${t.id}">${pill(t)}<h3>${esc(t.title)}</h3><p>${esc(t.summary||t.goal)}</p><small>${t.artifact?`已有成果 · v${t.version}`:'保留完整任务上下文'} ${t.archived?'· 已归档':''}</small></button>`}
|
||||||
|
function overviewHeader(eyebrow,title,desc,action=''){return `<div class="overview-header"><div><div class="eyebrow">${eyebrow}</div><h1>${title}</h1><p class="muted">${desc}</p></div>${action}</div>`}
|
||||||
|
function renderPage(){const labels={home:'工作台',inbox:'待我处理',library:'我的成果',capabilities:'能力与连接',automation:'定时任务'};$('pageTitle').textContent=labels[state.route];let html='';if(state.route==='home')html=overviewHeader('YOUR WORK, IN ONE PLACE','让想法,逐步成为成果。','开始一个新任务,或继续手头的工作。')+`<form class="home-composer" id="homeComposer"><textarea id="homePrompt" rows="2" aria-label="新任务目标" placeholder="今天,你想完成什么?"></textarea><div><span>${icon('folder')} ${esc(state.project)}</span><button class="primary" type="submit">开始工作 ${icon('arrow')}</button></div></form><div class="section-heading"><h2>继续你的工作</h2><span>${tasks.filter(t=>!t.archived).length} 个任务</span></div><div class="task-grid">${tasks.filter(t=>!t.archived).map(taskTile).join('')}</div><div class="project-banner"><span class="project-avatar">A</span><div><h3>${esc(state.project)}</h3><p>共享项目资料、工作约定与长期上下文。</p></div>${button('project-info','查看项目 '+icon('arrow'),'outline')}</div>`;
|
||||||
|
if(state.route==='inbox'){const waiting=tasks.filter(t=>!t.archived&&['waiting','approval','blocked'].includes(t.status));html=overviewHeader('YOUR ATTENTION','在这里,做关键决定。','需要回答、授权或处理的问题集中在这里。')+`<div class="task-grid">${waiting.length?waiting.map(taskTile).join(''):'<p class="muted">当前没有等待处理的任务。</p>'}</div>`}
|
||||||
|
if(state.route==='library')html=overviewHeader('MADE WITH REASONIX','值得留下来的成果。','打开、检查,再继续打磨。每份成果都能回到它所属的任务。')+`<div class="task-grid">${tasks.filter(t=>t.artifact).map(taskTile).join('')}</div>`;
|
||||||
|
if(state.route==='capabilities')html=capabilitiesHTML();if(state.route==='automation')html=overviewHeader('WORK THAT COMES BACK','把重复工作交给日程。','管理任务的目标、运行时间和启用状态。',button('add-schedule','新建定时任务 '+icon('plus'),'primary'))+state.schedules.map(s=>`<div class="schedule-row">${icon('clock')}<div><h3>${esc(s.name)}</h3><small>${esc(s.cadence)} · ${s.enabled?'已启用':'已暂停'} · 仅演示配置</small></div><button class="toggle" role="switch" aria-label="启用 ${esc(s.name)}" aria-checked="${s.enabled}" data-schedule-toggle="${s.id}"></button><button class="icon-button" data-schedule-edit="${s.id}" aria-label="编辑 ${esc(s.name)}">${icon('edit')}</button></div>`).join('')+'<div class="note-box">这是定时任务的界面原型,不会在后台真正调度。正式产品需要显示执行位置、运行历史与失败通知。</div>';$('page').innerHTML='<main class="overview-page">'+html+'</main>'}
|
||||||
|
function capabilitiesHTML(){const tabs=[['models','模型服务'],['tools','工具与连接器'],['skills','技能']];let cards='';if(state.capTab==='models')cards=state.models.map(m=>`<article class="connection-card"><header><span class="provider-symbol">${m.name.slice(0,1)}</span><div><h3>${esc(m.name)}</h3><small>${esc(m.provider)} · ${esc(m.protocol)}</small></div></header><p>${esc(m.description)}</p><footer><span>演示配置 · 未连接</span><button data-edit-model="${m.id}">配置 ${icon('chevron')}</button></footer></article>`).join('');if(state.capTab==='tools')cards=state.tools.map(t=>`<article class="connection-card"><header><span class="provider-symbol">${icon(t.icon)}</span><div><h3>${esc(t.name)}</h3><small>${esc(t.kind)}</small></div></header><p>${esc(t.description)}</p><footer><span>${t.enabled?'已在原型中启用':'尚未启用'} · 模拟</span><button class="toggle" role="switch" aria-label="启用 ${esc(t.name)}" aria-checked="${t.enabled}" data-tool-toggle="${t.id}"></button></footer></article>`).join('');if(state.capTab==='skills')cards=state.skills.map(t=>`<article class="connection-card"><header><span class="provider-symbol">${icon('spark')}</span><div><h3>${esc(t.name)}</h3><small>可复用工作方法</small></div></header><p>${esc(t.description)}</p><footer><span>${t.enabled?'已启用':'已停用'}</span><button class="toggle" role="switch" aria-label="启用 ${esc(t.name)}" aria-checked="${t.enabled}" data-skill-toggle="${t.id}"></button></footer></article>`).join('');return overviewHeader('A WORKSPACE THAT GROWS WITH YOU','连接能力,扩展可能。','模型负责理解,工具负责行动,技能决定工作方法。',button(state.capTab==='models'?'add-model':state.capTab==='tools'?'add-tool':'add-skill','添加'+(state.capTab==='models'?'模型':state.capTab==='tools'?'连接':'技能')+' '+icon('plus'),'primary'))+`<div class="filter-tabs">${tabs.map(([id,label])=>`<button class="${id===state.capTab?'active':''}" data-cap-tab="${id}" aria-pressed="${id===state.capTab}">${label}</button>`).join('')}</div><div class="connection-grid">${cards}</div><div class="note-box">可扩展接入不等于所有 API 都能直接互换。模型协议、工具调用、流式事件和结果格式需要适配;本原型展示配置方式,不发起真实请求。</div>`}
|
||||||
|
function openModal(title,body,footer='',eyebrow='WORKSPACE'){const d=$('modal');$('modalTitle').textContent=title;$('modalEyebrow').textContent=eyebrow;$('modalBody').innerHTML=body;$('modalFooter').innerHTML=footer||button('close-modal','完成','primary');if($('permissionMode'))$('permissionMode').value=current().permissionMode||'ask';if($('reasoningEffort'))$('reasoningEffort').value=current().effort||'标准';if($('motionMode'))$('motionMode').value=document.body.classList.contains('reduce-motion')?'reduce':'system';if($('fontScale'))$('fontScale').value=document.body.classList.contains('large-type')?'large':'normal';icons(d);if(!d.open)d.showModal();d.classList.remove('dialog-opening');void d.offsetWidth;d.classList.add('dialog-opening')}
|
||||||
|
function closeModal(){$('modal').close()}
|
||||||
|
function formError(text){let e=$('formError');if(!e){e=document.createElement('p');e.id='formError';e.className='form-error';e.setAttribute('role','alert');$('modalBody').appendChild(e)}e.textContent=text}
|
||||||
|
function openModelForm(id){const m=state.models.find(x=>x.id===id);openModal(m?'配置模型服务':'添加模型服务',`<p class="muted">配置模型的服务地址、协议和能力。密钥通过凭证引用管理,原型不接收真实密钥。</p><label for="providerName">服务名称</label><input id="providerName" value="${esc(m?.provider||'')}" placeholder="例如:团队模型服务"><div class="form-grid"><div><label for="modelName">模型标识</label><input id="modelName" value="${esc(m?.name||'')}" placeholder="provider/model-name"></div><div><label for="protocol">协议</label><select id="protocol"><option>OpenAI 兼容</option><option>Anthropic Messages</option><option>自定义适配器</option></select></div></div><label for="endpoint">API 地址</label><input id="endpoint" type="url" value="${esc(m?.endpoint||'')}" placeholder="https://api.example.com/v1"><label for="credential">凭证引用(仅名称)</label><input id="credential" placeholder="例如:team-model-key,不要填写真实密钥"><label for="effortSupport">推理强度</label><select id="effortSupport"><option value="yes">模型支持调整</option><option value="no">模型不支持调整</option></select><label for="adapter">自定义适配器标识(使用自定义协议时必填)</label><input id="adapter" placeholder="my-provider-adapter">`,button('close-modal','取消','outline')+button('save-model','保存演示配置','primary',`data-id="${esc(id||'')}"`),'MODEL PROVIDER');if(m){$('protocol').value=m.protocol;$('effortSupport').value=m.effort?'yes':'no'}}
|
||||||
|
function saveModel(id){const provider=$('providerName').value.trim(),name=$('modelName').value.trim(),endpoint=$('endpoint').value.trim(),protocol=$('protocol').value;let url;try{url=new URL(endpoint)}catch{}if(!provider||!name||!url||!['https:','http:'].includes(url.protocol)){formError('请填写名称、模型标识和有效的 HTTP / HTTPS 地址。');return}if(protocol==='自定义适配器'&&!$('adapter').value.trim()){formError('请填写适配器标识。');return}const model={id:id||'model-'+sequence++,name,provider,endpoint,protocol,effort:$('effortSupport').value==='yes',description:'自定义服务 · 能力以实际适配器为准'};const i=state.models.findIndex(m=>m.id===id);if(i>=0)state.models[i]=model;else state.models.push(model);closeModal();render();toast('演示配置已保存,未连接真实服务。')}
|
||||||
|
function showSearch(){openModal('搜索工作空间','<input id="searchInput" class="search-field" aria-label="搜索任务" placeholder="搜索任务标题或目标…"><div id="searchResults"></div>','','SEARCH');search('');$('searchInput').focus()}
|
||||||
|
function search(query){$('searchResults').innerHTML=tasks.filter(t=>(t.title+' '+t.goal).toLowerCase().includes(query.toLowerCase())).map(t=>`<button class="search-result" data-task="${t.id}" data-close="true">${icon('files')}<span>${esc(t.title)}</span><small>${statusLabels[t.status]}${t.archived?' · 已归档':''}</small></button>`).join('')||'<p class="muted">没有找到匹配的任务。</p>'}
|
||||||
|
function showNew(){openModal('你想完成什么?','<p class="muted">任务可以是一句简单的问题,也可以是一份需要持续完善的成果。</p><label for="newGoal">目标</label><textarea id="newGoal" rows="4" placeholder="描述你希望得到的结果…"></textarea><label for="newProject">所属项目</label><select id="newProject"><option>'+esc(state.project)+'</option></select>',button('close-modal','取消','outline')+button('create-task','开始工作','primary'),'NEW TASK');$('newGoal').focus()}
|
||||||
|
function createTask(goal){if(!goal.trim()){formError('请先描述你希望完成的工作。');return}const t={id:'task-'+sequence++,title:goal.trim().slice(0,24),summary:'根据你的目标持续推进',status:'idle',kind:'research',model:state.model,version:0,step:0,ticks:0,artifact:false,ask:false,goal:goal.trim(),plan:['梳理目标与可用资料','执行任务并整理结果','检查成果与未解决项'],messages:[{type:'user',text:goal.trim()},{type:'agent',text:'目标已记录。这是交互原型,我会用模拟执行展示任务推进、过程中补充要求,以及成果交付。'}],events:['创建新任务'],revision:''};tasks.unshift(t);closeModal();state.canvas=false;navigate('task',t.id)}
|
||||||
|
function refreshTask(t,{animate=false,scroll=false}={}){renderSidebar();if(state.route!=='task'||state.active!==t.id){if(state.route==='home'||state.route==='inbox')renderPage();return}const transcript=$('transcript'),position=transcript.scrollTop,open=$('taskPlan')?.open,draft=$('messageInput')?.value,focus=document.activeElement?.id,selection=$('messageInput')?.selectionStart;state.drafts[t.id]=draft||state.drafts[t.id]||'';renderTask();if($('taskPlan'))$('taskPlan').open=!!open;$('transcript').scrollTop=scroll?$('transcript').scrollHeight:position;if(focus==='messageInput'){$('messageInput').focus();$('messageInput').setSelectionRange(selection,selection)}if(animate){const el=$('taskTail').firstElementChild;if(el)el.classList.add('event-arrival')}}
|
||||||
|
function begin(t){t.status='running';t.ticks=0;t.ask=false;t.events.push('开始执行本轮工作');refreshTask(t,{animate:true,scroll:true});announce('任务已开始,可以继续补充要求。')}
|
||||||
|
function finish(t){t.status='done';t.step=t.plan.length;t.artifact=true;t.version+=1;t.events.push('本轮成果已整理完成');t.messages.push({type:'agent',text:t.revision?'已按本轮要求更新演示文档,并保留修改记录。你可以在工作区检查这个版本。':'本轮任务已完成,成果保留在工作区。这是示例内容,可继续修改或下载。'});state.notificationCount++;refreshTask(t,{animate:true,scroll:true});toast(t.title+' · 本轮成果已完成');announce('成果已完成')}
|
||||||
|
function runTick(){tasks.filter(t=>t.status==='running').forEach(t=>{t.ticks++;if(t.ticks===2){t.step=Math.min(t.plan.length-1,t.step+1);t.events.push('当前步骤已完成,继续下一项')}if(t.ticks===4){t.artifact=true;if(t.version===0)t.version=1;t.events.push('已生成可检查的工作草稿')}if(t.ticks>=6){finish(t);return}if(state.route==='task'&&state.active===t.id){const label=$('executionLabel'),bar=$('executionProgress'),detail=$('executionDetail');if(label)label.textContent=t.plan[Math.min(t.step,t.plan.length-1)]||'整理任务成果';if(bar)bar.style.width=Math.min(95,20+t.ticks*12)+'%';if(detail)detail.textContent=['','正在整理输入内容','当前步骤完成','正在形成下一部分','草稿已更新,可在右侧查看','正在检查交付内容'][t.ticks];const plan=$('taskPlan');if(plan){const open=plan.open;plan.outerHTML=planHTML(t);$('taskPlan').open=open}if(t.ticks===4||state.canvasTab==='activity')renderCanvas()}})}
|
||||||
|
function submitMessage(text){const t=current();if(!text.trim()&&!(state.attachments[t.id]||[]).length)return;const attach=state.attachments[t.id]||[],body=text.trim()+(attach.length?'\n附件(仅名称):'+attach.join('、'):'');t.messages.push({type:'user',text:body});state.drafts[t.id]='';state.attachments[t.id]=[];if($('messageInput'))$('messageInput').value='';if(t.ask&&t.status==='waiting'){t.choice=text.trim()||'结合附件进一步确认';t.ask=false;t.messages.push({type:'agent',text:'已记录你的方向,接下来会据此推进。'});begin(t)}else if(t.status==='done'){t.revision=text.trim();t.messages.push({type:'agent',text:'收到,我会开始下一轮修改。之前的成果仍然可以查看。',steering:true});begin(t)}else{t.revision=text.trim();t.messages.push({type:'agent',text:t.status==='approval'?'补充已记录。发送授权仍需通过确认按钮决定。':t.status==='blocked'?'补充已记录。连接问题仍需要重新连接或选择本地资料。':t.status==='running'?'补充已接收,已加入本轮任务上下文。':t.status==='paused'?'补充已记录,继续执行后会带上这些要求。':'补充已记录,可以开始执行。',steering:true});t.events.push('接收用户补充要求');refreshTask(t,{scroll:true});const last=$('messages')?.lastElementChild;if(last)last.classList.add('event-arrival')}announce('消息已接收')}
|
||||||
|
function download(t){const text='# '+t.title+'\n\n> Reasonix 界面原型示例,不是实际执行结果。\n\n## 原始目标\n'+t.goal+'\n\n## 本轮方向\n'+(t.choice||'以任务目标为准')+'\n\n## 工作步骤\n'+t.plan.map((p,i)=>(i+1)+'. '+p).join('\n')+'\n\n## 最新补充\n'+(t.revision||'暂无')+'\n\n版本:'+t.version;const url=URL.createObjectURL(new Blob([text],{type:'text/markdown;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download=t.title.replace(/[\\/:*?"<>|]/g,'-')+'.md';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast('示例文档已导出')}
|
||||||
|
function scheduleForm(id){const s=state.schedules.find(x=>x.id===id);openModal(s?'编辑定时任务':'新建定时任务',`<p class="muted">这里只保存本页内的演示配置,不会创建真实后台任务。</p><label for="scheduleName">任务名称</label><input id="scheduleName" value="${esc(s?.name||'')}"><label for="schedulePrompt">工作目标</label><textarea id="schedulePrompt" rows="3">${esc(s?.prompt||'')}</textarea><label for="scheduleCadence">运行时间</label><select id="scheduleCadence"><option>每周一 09:00</option><option>每个工作日 08:30</option><option>每天 18:00</option></select>`,button('close-modal','取消','outline')+button('save-schedule','保存','primary',`data-id="${id||''}"`),'SCHEDULE');if(s)$('scheduleCadence').value=s.cadence}
|
||||||
|
const actions={
|
||||||
|
'home':()=>navigate('home'),'new':showNew,'search':showSearch,'close-modal':closeModal,'create-task':()=>createTask($('newGoal').value),
|
||||||
|
'sidebar':()=>{if(innerWidth<=650)$('studio').classList.toggle('mobile-rail');else $('studio').classList.toggle('rail-closed')},
|
||||||
|
'toggle-canvas':()=>{state.canvas=!state.canvas;document.querySelector('.task-workspace').classList.toggle('canvas-closed',!state.canvas);if(state.canvas)document.querySelector('.canvas').classList.add('panel-opening')},
|
||||||
|
'open-artifact':()=>{state.canvas=true;state.canvasTab='preview';document.querySelector('.task-workspace').classList.remove('canvas-closed');renderCanvas();document.querySelector('.canvas').classList.add('panel-opening')},
|
||||||
|
'answer':()=>{const t=current();if(!t.ask)return;const free=$('askFree').value.trim();t.choice=free||['品牌认知','新品转化','两者兼顾'][state.askChoices[t.id]??0];t.messages.push({type:'user',text:t.choice},{type:'agent',text:'已确认:'+t.choice+'。我会据此补充发布节奏与渠道安排。'});t.events.push('用户确认目标:'+t.choice);begin(t)},
|
||||||
|
'start':()=>begin(current()),'pause':()=>{const t=current();t.status='paused';t.events.push('用户暂停执行');refreshTask(t,{animate:true});announce('任务已暂停')},'resume':()=>{const t=current();t.status='running';t.events.push('恢复执行');refreshTask(t,{animate:true})},
|
||||||
|
'approve':()=>{const t=current();t.permission='allowed';t.status='done';t.step=t.plan.length;t.messages.push({type:'agent',text:'已确认发送操作。本原型没有实际发送任何邮件,草稿仍保留在工作区。'});t.events.push('发送授权已确认(未实际发送)');refreshTask(t,{animate:true,scroll:true})},
|
||||||
|
'deny':()=>{const t=current();t.permission='denied';t.status='done';t.step=2;t.plan=['梳理合作背景','起草邮件'];t.messages.push({type:'agent',text:'已取消发送,保留邮件草稿。需要时可以继续修改。'});t.events.push('用户选择仅保留草稿');refreshTask(t,{animate:true,scroll:true})},
|
||||||
|
'skip-connection':()=>{const t=current();t.events.push('用户选择使用本地资料');t.messages.push({type:'agent',text:'接下来只使用已有的本地资料,并在结果中注明缺少远程更新。'});begin(t)},
|
||||||
|
'reconnect':()=>openModal('重新连接知识库','<p class="muted">正式产品将在服务商页面完成登录,并返回连接结果。此处只演示恢复成功后的任务继续。</p>',button('close-modal','稍后处理','outline')+button('simulate-reconnect','模拟重新连接成功','primary'),'RECONNECT'),
|
||||||
|
'simulate-reconnect':()=>{closeModal();current().events.push('知识库连接已恢复(模拟)');begin(current())},
|
||||||
|
'edit-plan':()=>openModal('调整执行计划','<p class="muted">每行一个步骤。保存后重置步骤完成标记,避免旧进度被错误套用。</p><label for="planEditor">任务步骤</label><textarea id="planEditor" rows="6">'+esc(current().plan.join('\n'))+'</textarea>',button('close-modal','取消','outline')+button('save-plan','保存计划','primary'),'TASK PLAN'),
|
||||||
|
'save-plan':()=>{const lines=$('planEditor').value.split('\n').map(x=>x.trim()).filter(Boolean);if(!lines.length){formError('至少保留一个步骤。');return}const t=current();t.plan=lines;t.step=0;t.events.push('用户调整了执行计划');closeModal();refreshTask(t);toast('计划已更新,完成标记已重置')},
|
||||||
|
'edit-artifact':()=>{if(!current().artifact){toast('成果尚未生成,可以先在对话中补充要求。');return}openModal('这份成果,你想怎样调整?','<label for="revisionInput">修改要求</label><textarea id="revisionInput" rows="4" placeholder="例如:把第二部分写得更具体,保留整体结构。"></textarea>',button('close-modal','取消','outline')+button('save-revision','开始修改','primary'),'REFINE OUTPUT')},
|
||||||
|
'save-revision':()=>{const value=$('revisionInput').value.trim();if(!value){formError('请写下需要修改的内容。');return}closeModal();const t=current();t.permission=t.permission==='pending'?'denied':t.permission;t.revision=value;t.messages.push({type:'user',text:value},{type:'agent',text:'修改要求已接收。将更新这份成果,修改要求会保留在成果中;不会执行任何外部发送操作。'});begin(t)},
|
||||||
|
'download':()=>download(current()),'versions':()=>openModal('成果版本','<p class="muted">当前版本 v'+current().version+'。此原型展示版本标签与迭代交互,尚未实现历史内容快照。</p>'),
|
||||||
|
'attach':()=>$('attachmentInput').click(),
|
||||||
|
'context':()=>openModal('这段任务使用的上下文','<p class="muted">项目资料在任务间共享,每段对话保留各自的目标与历史。</p><div class="note-box">项目:'+esc(state.project)+'<br>示例资料:品牌手册.md、新品介绍.md<br>工作约定:优先清晰、具体、可核查的表达。</div><p class="muted" style="margin-top:16px">附件在本原型中只显示文件名,不读取文件内容。</p>','','TASK CONTEXT'),
|
||||||
|
'model':()=>openModal('选择当前任务的模型',state.models.map(m=>`<button class="model-option ${m.id===current().model?'selected':''}" data-select-model="${m.id}"><span class="provider-symbol">${icon('spark')}</span><span><strong>${esc(m.name)}</strong><small>${esc(m.description)}</small></span>${m.id===current().model?icon('check'):''}</button>`).join('')+'<label for="reasoningEffort">推理强度</label><select id="reasoningEffort" '+(!state.models.find(m=>m.id===current().model)?.effort?'disabled':'')+'><option>标准</option><option>深入</option></select><p class="muted" style="margin-top:10px">仅在模型声明支持时显示可调整的能力。这里不会实际调用模型。</p>',button('close-modal','取消','outline')+button('apply-effort','完成','primary'),'MODEL'),
|
||||||
|
'apply-effort':()=>{current().effort=$('reasoningEffort').value;closeModal();refreshTask(current())},
|
||||||
|
'permission':()=>openModal('当前任务的权限','<p class="muted">普通问题与操作授权分别处理。权限决定执行边界,不改变模型。</p><label for="permissionMode">授权方式</label><select id="permissionMode"><option value="ask">必要时询问:重要操作由我确认</option><option value="auto">自动审批:演示配置,不实际执行</option></select><div class="note-box">当前示例范围:项目资料。任何真实的发送、发布或外部修改均未接入。</div>',button('close-modal','取消','outline')+button('save-permission','应用到当前会话','primary'),'PERMISSIONS'),
|
||||||
|
'save-permission':()=>{current().permissionMode=$('permissionMode').value;closeModal();refreshTask(current());toast('演示权限设置已更新')},
|
||||||
|
'task-menu':()=>openModal('任务操作','<label for="renameTask">任务名称</label><input id="renameTask" value="'+esc(current().title)+'"><div style="display:flex;gap:10px;margin-top:20px">'+button('archive-task',icon('archive')+' 归档任务','outline')+'</div>',button('close-modal','取消','outline')+button('rename-task','保存名称','primary'),'TASK'),
|
||||||
|
'rename-task':()=>{const value=$('renameTask').value.trim();if(!value){formError('任务名称不能为空。');return}current().title=value;closeModal();render()},
|
||||||
|
'archive-task':()=>{if(current().status==='running'){formError('请先暂停正在运行的任务,再归档。');return}current().archived=true;closeModal();navigate('home');toast('任务已归档,仍可通过搜索找到')},
|
||||||
|
'project-info':()=>openModal(state.project,'<p class="muted">项目连接相关任务与共享资料。每个任务有独立的对话、执行状态和成果。</p><div class="note-box">资料:品牌手册.md、新品介绍.md<br>项目约定:语气清晰克制,结论保留依据。<br>运行位置:本地交互原型。</div>','','PROJECT'),
|
||||||
|
'project':()=>openModal('设置项目名称','<label for="projectName">项目名称</label><input id="projectName" value="'+esc(state.project)+'"><p class="muted" style="margin-top:12px">此原型只有一个项目容器,可修改名称体验导航更新。</p>',button('close-modal','取消','outline')+button('save-project','保存','primary'),'PROJECT'),
|
||||||
|
'save-project':()=>{const value=$('projectName').value.trim();if(!value){formError('项目名称不能为空。');return}state.project=value;closeModal();render()},
|
||||||
|
'notifications':()=>{state.notificationCount=0;renderSidebar();navigate('inbox')},
|
||||||
|
'add-model':()=>openModelForm(),'save-model':el=>saveModel(el.dataset.id),
|
||||||
|
'add-tool':()=>openModal('添加外部能力','<p class="muted">用 MCP 或 HTTP 适配器连接外部服务。工具能力与模型服务分别配置。</p><label for="toolName">能力名称</label><input id="toolName" placeholder="例如:团队知识库"><label for="toolProtocol">接入方式</label><select id="toolProtocol"><option>MCP 连接器</option><option>HTTP API</option></select><label for="toolEndpoint">服务地址</label><input id="toolEndpoint" placeholder="https://example.com/mcp"><label for="toolScope">能力说明与访问范围</label><input id="toolScope" placeholder="例如:只读取产品知识库">',button('close-modal','取消','outline')+button('save-tool','添加演示连接','primary'),'TOOLS'),
|
||||||
|
'save-tool':()=>{const name=$('toolName').value.trim(),scope=$('toolScope').value.trim();let url;try{url=new URL($('toolEndpoint').value)}catch{}if(!name||!scope||!url||!['http:','https:'].includes(url.protocol)){formError('请填写名称、访问范围和有效服务地址。');return}state.tools.push({id:'tool-'+sequence++,name,description:scope,kind:$('toolProtocol').value,icon:'link',enabled:false});closeModal();render();toast('已添加配置,尚未连接真实服务')},
|
||||||
|
'add-skill':()=>openModal('创建工作技能','<label for="skillName">技能名称</label><input id="skillName"><label for="skillDescription">工作方法</label><textarea id="skillDescription" rows="4" placeholder="描述这类任务应该怎样完成…"></textarea>',button('close-modal','取消','outline')+button('save-skill','保存技能','primary'),'SKILL'),
|
||||||
|
'save-skill':()=>{const name=$('skillName').value.trim(),description=$('skillDescription').value.trim();if(!name||!description){formError('请填写技能名称和工作方法。');return}state.skills.push({id:'skill-'+sequence++,name,description,enabled:true});closeModal();render()},
|
||||||
|
'add-schedule':()=>scheduleForm(),'save-schedule':el=>{const name=$('scheduleName').value.trim(),prompt=$('schedulePrompt').value.trim();if(!name||!prompt){formError('请填写任务名称和目标。');return}const existing=state.schedules.find(x=>x.id===el.dataset.id),next={id:existing?.id||'schedule-'+sequence++,name,prompt,cadence:$('scheduleCadence').value,enabled:existing?.enabled??true};if(existing)Object.assign(existing,next);else state.schedules.push(next);closeModal();render();toast('演示日程已保存,不会在后台实际运行')},
|
||||||
|
'settings':()=>openModal('舒适地工作','<p class="muted">为长时间阅读设计的浅色工作区。动画只表达操作和状态变化。</p><label for="motionMode">动画效果</label><select id="motionMode"><option value="system">跟随系统偏好</option><option value="reduce">减少动态效果</option></select><label for="fontScale">文字大小</label><select id="fontScale"><option value="normal">标准</option><option value="large">舒适 · 较大文字</option></select>',button('close-modal','取消','outline')+button('save-settings','应用','primary'),'APPEARANCE'),
|
||||||
|
'save-settings':()=>{document.body.classList.toggle('reduce-motion',$('motionMode').value==='reduce');document.body.classList.toggle('large-type',$('fontScale').value==='large');closeModal();toast('界面偏好已应用')}
|
||||||
|
};
|
||||||
|
document.addEventListener('click',e=>{const action=e.target.closest('[data-action]');if(action){e.preventDefault();actions[action.dataset.action]?.(action);return}const task=e.target.closest('[data-task]');if(task){if(task.dataset.close)closeModal();state.canvas=innerWidth>960&&!!tasks.find(t=>t.id===task.dataset.task).artifact;state.canvasTab='preview';navigate('task',task.dataset.task);return}const route=e.target.closest('[data-route]');if(route){navigate(route.dataset.route);return}const choice=e.target.closest('[data-choice]');if(choice){state.askChoices[current().id]=Number(choice.dataset.choice);document.querySelectorAll('.ask-option').forEach(b=>{const selected=b===choice;b.classList.toggle('selected',selected);b.setAttribute('aria-pressed',String(selected));b.classList.remove('picked')});choice.classList.add('picked');return}const tab=e.target.closest('[data-canvas-tab]');if(tab){state.canvasTab=tab.dataset.canvasTab;renderCanvas();return}const cap=e.target.closest('[data-cap-tab]');if(cap){state.capTab=cap.dataset.capTab;renderPage();return}const model=e.target.closest('[data-select-model]');if(model){current().model=model.dataset.selectModel;state.model=model.dataset.selectModel;closeModal();refreshTask(current());toast('模型已切换(演示)');return}const editModel=e.target.closest('[data-edit-model]');if(editModel){openModelForm(editModel.dataset.editModel);return}for(const [key,list] of [['toolToggle',state.tools],['skillToggle',state.skills],['scheduleToggle',state.schedules]]){const attr=key.replace(/[A-Z]/g,c=>'-'+c.toLowerCase()),toggle=e.target.closest('[data-'+attr+']');if(toggle){const item=list.find(x=>x.id===toggle.dataset[key]);item.enabled=!item.enabled;renderPage();return}}const remove=e.target.closest('[data-remove-file]');if(remove){state.attachments[current().id].splice(Number(remove.dataset.removeFile),1);refreshTask(current());return}const schedule=e.target.closest('[data-schedule-edit]');if(schedule){scheduleForm(schedule.dataset.scheduleEdit);return}const source=e.target.closest('[data-source]');if(source)openModal(Number(source.dataset.source)===0?'品牌手册.md':'新品介绍.md','<p class="muted">项目输入文件示例。正式版本在此预览实际文件内容,本原型没有读取你的本地资料。</p>');});
|
||||||
|
document.addEventListener('submit',e=>{if(e.target.id==='composer'){e.preventDefault();submitMessage($('messageInput').value)}if(e.target.id==='homeComposer'){e.preventDefault();if(!$('homePrompt').value.trim()){toast('先写下你想完成的事。');return}createTask($('homePrompt').value)}});
|
||||||
|
document.addEventListener('input',e=>{if(e.target.id==='messageInput'){state.drafts[current().id]=e.target.value;e.target.style.height='auto';e.target.style.height=Math.min(140,e.target.scrollHeight)+'px'}if(e.target.id==='askFree')current().askDraft=e.target.value;if(e.target.id==='searchInput')search(e.target.value)});
|
||||||
|
document.addEventListener('keydown',e=>{if(e.isComposing)return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='k'){e.preventDefault();showSearch()}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='n'){e.preventDefault();showNew()}if(e.target.id==='messageInput'&&e.key==='Enter'&&!e.shiftKey){e.preventDefault();$('composer').requestSubmit()}if(e.key==='Escape')$('studio').classList.remove('mobile-rail');if(e.target.hasAttribute('data-canvas-tab')&&['ArrowLeft','ArrowRight'].includes(e.key)){e.preventDefault();const tabs=['preview','files','activity'],next=(tabs.indexOf(state.canvasTab)+(e.key==='ArrowRight'?1:2))%3;state.canvasTab=tabs[next];renderCanvas();$('canvas-tab-'+state.canvasTab).focus()}});
|
||||||
|
$('attachmentInput').addEventListener('change',e=>{const id=current().id;state.attachments[id]=[...(state.attachments[id]||[]),...Array.from(e.target.files).map(f=>f.name)];e.target.value='';refreshTask(current());toast('已添加附件名称;原型不读取文件内容。')});
|
||||||
|
state.canvas=innerWidth>960;
|
||||||
|
icons();render();clockTimer=setInterval(runTick,1700);
|
||||||
|
window.addEventListener('pagehide',()=>clearInterval(clockTimer));
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { JSDOM, VirtualConsole } = require('../../desktop/frontend-next/node_modules/jsdom');
|
||||||
|
const errors = [];
|
||||||
|
const consoleBridge = new VirtualConsole();
|
||||||
|
consoleBridge.on('jsdomError', error => errors.push(error.message));
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
|
||||||
|
const script = fs.readFileSync(path.join(__dirname, 'workspace.js'), 'utf8');
|
||||||
|
const dom = new JSDOM(html, { runScripts: 'outside-only', url: 'https://prototype.invalid', virtualConsole: consoleBridge });
|
||||||
|
const w = dom.window;
|
||||||
|
let tick;
|
||||||
|
w.setInterval = fn => { tick = fn; return 1; };
|
||||||
|
w.clearInterval = () => {};
|
||||||
|
w.HTMLDialogElement.prototype.showModal = function () { this.setAttribute('open', ''); };
|
||||||
|
w.HTMLDialogElement.prototype.close = function () { this.removeAttribute('open'); };
|
||||||
|
w.URL.createObjectURL = () => 'blob:mock';
|
||||||
|
w.URL.revokeObjectURL = () => {};
|
||||||
|
w.HTMLAnchorElement.prototype.click = () => {};
|
||||||
|
w.eval(script);
|
||||||
|
const q = s => w.document.querySelector(s);
|
||||||
|
function click(s) { const el = q(s); assert.ok(el, 'Missing control: ' + s); el.click(); }
|
||||||
|
function fill(s, value) { const el = q(s); assert.ok(el, 'Missing field: ' + s); el.value = value; el.dispatchEvent(new w.Event('input', { bubbles: true })); }
|
||||||
|
function submit(text) { fill('#messageInput', text); q('#composer').dispatchEvent(new w.Event('submit', { bubbles: true, cancelable: true })); }
|
||||||
|
function finish() { for (let i = 0; i < 6; i++) tick(); }
|
||||||
|
function textContains(selector, text) { assert.ok(q(selector).textContent.includes(text), `Expected ${selector} to contain ${text}`); }
|
||||||
|
|
||||||
|
textContains('#pageTitle', '秋季新品');
|
||||||
|
assert.ok(q('.paper'), 'Initial draft is available');
|
||||||
|
fill('#messageInput', '保留这个未发送的要求');
|
||||||
|
click('[data-task="research"]');
|
||||||
|
click('[data-task="launch"]');
|
||||||
|
assert.equal(q('#messageInput').value, '保留这个未发送的要求');
|
||||||
|
click('[data-choice="1"]');
|
||||||
|
click('[data-action="answer"]');
|
||||||
|
textContains('.status-pill', '执行中');
|
||||||
|
click('[data-action="pause"]');
|
||||||
|
textContains('.status-pill', '已暂停');
|
||||||
|
tick();
|
||||||
|
textContains('.status-pill', '已暂停');
|
||||||
|
submit('保持简洁,并保留渠道安排');
|
||||||
|
textContains('#messages', '补充已记录');
|
||||||
|
click('[data-action="resume"]');
|
||||||
|
fill('#messageInput', '执行中正在输入');
|
||||||
|
tick();
|
||||||
|
assert.equal(q('#messageInput').value, '执行中正在输入');
|
||||||
|
finish();
|
||||||
|
textContains('.status-pill', '已完成');
|
||||||
|
textContains('#canvasContent', '保持简洁');
|
||||||
|
click('[data-canvas-tab="activity"]');
|
||||||
|
textContains('#canvasContent', '本轮成果已整理完成');
|
||||||
|
click('[data-action="open-artifact"]');
|
||||||
|
click('[data-action="download"]');
|
||||||
|
click('[data-action="edit-artifact"]');
|
||||||
|
fill('#revisionInput', '补充面向老用户的内容');
|
||||||
|
click('[data-action="save-revision"]');
|
||||||
|
finish();
|
||||||
|
textContains('#canvasContent', '补充面向老用户');
|
||||||
|
|
||||||
|
click('[data-task="email"]');
|
||||||
|
click('[data-action="deny"]');
|
||||||
|
textContains('#messages', '取消发送');
|
||||||
|
assert.ok(q('.paper'), 'Deny keeps the draft');
|
||||||
|
assert.equal(q('#activeDecision'), null);
|
||||||
|
click('[data-task="sync"]');
|
||||||
|
click('[data-action="reconnect"]');
|
||||||
|
click('[data-action="simulate-reconnect"]');
|
||||||
|
finish();
|
||||||
|
textContains('.status-pill', '已完成');
|
||||||
|
|
||||||
|
click('[data-action="new"]');
|
||||||
|
fill('#newGoal', '测试自定义任务 <img src=x onerror=alert(1)>');
|
||||||
|
click('[data-action="create-task"]');
|
||||||
|
assert.equal(q('#transcript img'), null, 'User content is escaped');
|
||||||
|
click('[data-action="edit-plan"]');
|
||||||
|
fill('#planEditor', '步骤一\n步骤二');
|
||||||
|
click('[data-action="save-plan"]');
|
||||||
|
assert.equal(w.document.querySelectorAll('.plan-steps li').length, 2);
|
||||||
|
click('[data-action="model"]');
|
||||||
|
click('[data-select-model="fast"]');
|
||||||
|
click('[data-action="model"]');
|
||||||
|
assert.ok(q('#reasoningEffort').disabled, 'Unsupported model capability is disabled');
|
||||||
|
click('[data-action="close-modal"]');
|
||||||
|
click('[data-action="permission"]');
|
||||||
|
fill('#permissionMode', 'auto');
|
||||||
|
click('[data-action="save-permission"]');
|
||||||
|
click('[data-action="permission"]');
|
||||||
|
assert.equal(q('#permissionMode').value, 'auto', 'Permission selection persists in current task');
|
||||||
|
click('[data-action="close-modal"]');
|
||||||
|
click('[data-task="analysis"]');
|
||||||
|
click('[data-action="permission"]');
|
||||||
|
assert.equal(q('#permissionMode').value, 'ask', 'Permission is scoped to each task');
|
||||||
|
click('[data-action="close-modal"]');
|
||||||
|
|
||||||
|
click('[data-route="capabilities"]');
|
||||||
|
click('[data-action="add-model"]');
|
||||||
|
click('[data-action="save-model"]');
|
||||||
|
assert.ok(q('#formError'), 'Incomplete API config is rejected');
|
||||||
|
fill('#providerName', '团队服务');
|
||||||
|
fill('#modelName', 'custom-model');
|
||||||
|
fill('#endpoint', 'https://api.example.com/v1');
|
||||||
|
click('[data-action="save-model"]');
|
||||||
|
textContains('#page', 'custom-model');
|
||||||
|
click('[data-cap-tab="tools"]');
|
||||||
|
click('[data-tool-toggle="notion"]');
|
||||||
|
assert.equal(q('[data-tool-toggle="notion"]').getAttribute('aria-checked'), 'true');
|
||||||
|
click('[data-cap-tab="skills"]');
|
||||||
|
click('[data-action="add-skill"]');
|
||||||
|
fill('#skillName', '简洁写作');
|
||||||
|
fill('#skillDescription', '使用清晰的例子。');
|
||||||
|
click('[data-action="save-skill"]');
|
||||||
|
textContains('#page', '简洁写作');
|
||||||
|
|
||||||
|
click('[data-route="automation"]');
|
||||||
|
click('[data-action="add-schedule"]');
|
||||||
|
fill('#scheduleName', '每日摘要');
|
||||||
|
fill('#schedulePrompt', '汇总今天的信息。');
|
||||||
|
click('[data-action="save-schedule"]');
|
||||||
|
textContains('#page', '每日摘要');
|
||||||
|
click('[data-action="search"]');
|
||||||
|
fill('#searchInput', '合作');
|
||||||
|
assert.equal(w.document.querySelectorAll('.search-result').length, 1);
|
||||||
|
click('.search-result');
|
||||||
|
assert.ok(!q('#modal').open, 'Search closes after selection');
|
||||||
|
click('[data-action="settings"]');
|
||||||
|
fill('#motionMode', 'reduce');
|
||||||
|
fill('#fontScale', 'large');
|
||||||
|
click('[data-action="save-settings"]');
|
||||||
|
assert.ok(w.document.body.classList.contains('reduce-motion'));
|
||||||
|
assert.ok(w.document.body.classList.contains('large-type'));
|
||||||
|
assert.deepEqual(errors, [], 'No runtime exceptions');
|
||||||
|
console.log('PASS: task switching and drafts; ask; pause/resume; steering; delivery and revision; permission rejection; recovery; safe user text; editable plans; model capabilities; API validation; tools; skills; schedules; search; appearance.');
|
||||||
|
dom.window.close();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@font-face{font-family:Studio;src:url('../../desktop/frontend-next/public/fonts/InterVariable.woff2') format('woff2');font-display:swap}
|
||||||
|
:root{--bg:#f8f9f7;--paper:#fff;--rail:#f0f2ee;--line:#e4e8e1;--ink:#26352d;--muted:#778279;--faint:#98a197;--green:#37634e;--green-light:#edf4ed;--amber:#967234;--amber-light:#fbf6e9;--red:#a4544d;--blue:#526e91;--shadow:0 8px 32px #283c2c09;--ease:cubic-bezier(.2,.75,.2,1);font-family:Studio,Inter,"Segoe UI","Microsoft YaHei",sans-serif;color:var(--ink);font-size:13px;background:var(--bg)}
|
||||||
|
*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer;border:0;background:none;padding:8px 12px;border-radius:7px;transition:background .18s,color .18s,box-shadow .18s,transform .18s}button:hover{background:#e9eee7}button:active{transform:translateY(1px)}button:disabled{opacity:.45;cursor:default;transform:none}a{color:var(--green)}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid #70997c;outline-offset:3px}svg{width:18px;height:18px;stroke:currentColor;stroke-width:1.65;stroke-linecap:round;stroke-linejoin:round;fill:none;flex-shrink:0;vertical-align:middle}input,select,textarea{background:var(--paper);border:1px solid var(--line);border-radius:8px;padding:10px 12px;min-width:0}textarea{resize:vertical;line-height:1.75}input::placeholder,textarea::placeholder{color:#99a195}h1,h2,h3,p{margin:0}button.primary{background:var(--green);color:#fff;border:1px solid var(--green);box-shadow:0 2px 3px #294c3010}button.primary:hover{background:#284e3c;box-shadow:0 4px 9px #294c3020}button.outline{border:1px solid var(--line);background:var(--paper)}button.outline:hover{border-color:#bdcabc;background:#f7f9f4}button.text-button{color:var(--muted);font-size:11px;padding:4px 7px}button.text-button:hover{color:var(--green)}button.small{font-size:11px;padding:7px 10px}.icon-button{width:31px;height:31px;display:inline-grid;place-items:center;padding:6px;color:#778477}.icon-button svg{width:17px;height:17px}kbd{font-family:inherit;font-size:9px;font-weight:400;border:1px solid #dce2d8;border-radius:4px;padding:2px 4px;color:#8b9687;background:#fafbf8}.muted{color:var(--muted);font-size:12px;line-height:1.8}.eyebrow{font-size:9px;letter-spacing:1.7px;color:#8c998a;font-weight:600}.dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#9da797;flex-shrink:0}.dot.green{background:#6c9678}.dot.amber{background:#c39b51}.dot.red{background:#bd7970}.dot.running{background:#739383;box-shadow:0 0 0 3px #76988715}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}[hidden]{display:none!important}
|
||||||
|
.studio{height:100dvh;min-height:650px;display:grid;grid-template-columns:228px minmax(0,1fr);transition:grid-template-columns .28s var(--ease)}.studio.rail-closed{grid-template-columns:0 minmax(0,1fr)}.studio.rail-closed .sidebar{opacity:0;pointer-events:none}.sidebar{background:var(--rail);border-right:1px solid var(--line);padding:26px 17px 14px;display:flex;flex-direction:column;gap:5px;min-width:0;overflow:hidden;transition:opacity .18s}.wordmark{display:flex;align-items:center;gap:10px;text-align:left;padding:0 6px 24px;border-radius:0;flex-shrink:0}.wordmark:hover{background:transparent}.wordmark>span:last-child{font-size:23px;letter-spacing:-.8px;font-weight:550}.wordmark small{display:block;font-size:7px;letter-spacing:1.8px;color:#879583;margin:5px 0 0 2px;font-weight:500}.brand-symbol{position:relative;display:grid;place-items:center;width:32px;height:35px;font-size:35px;line-height:1;font-family:Georgia,serif;font-style:italic;font-weight:700;color:#3e5c42}.brand-symbol span{position:absolute;background:#98ad81;width:6px;height:6px;border-radius:50%;top:2px;right:0}.new-task{display:flex;align-items:center;gap:8px;font-size:12px;background:white;border:1px solid #dce3d7;box-shadow:0 2px 4px #27362804;text-align:left;padding:11px;margin:5px 0 9px}.new-task kbd{margin-left:auto;font-size:8px}.new-task svg{width:16px}.nav-item{display:flex;align-items:center;gap:10px;text-align:left;width:100%;font-size:11px;padding:10px;color:#687664;white-space:nowrap}.nav-item svg{width:16px;height:16px}.nav-item kbd{margin-left:auto;font-size:8px}.nav-item.active{background:#e0e8d9;color:#3c5c3b;font-weight:550}.nav-group{margin-top:13px;display:grid;gap:4px}.count{margin-left:auto;background:#dce3d3;border-radius:5px;padding:1px 6px;font-size:9px;color:#6e7d64}.sidebar-label{display:flex;align-items:center;justify-content:space-between;padding:0 9px;margin:23px 0 6px;font-size:9px;color:#929c89;letter-spacing:.4px}.sidebar-label button{padding:0;display:grid;place-items:center}.sidebar-label svg{width:13px}.project-nav{display:flex;gap:10px;text-align:left;padding:9px 8px;align-items:center;white-space:nowrap;font-size:10px}.project-avatar{width:29px;height:29px;border:1px solid #d5dfcd;background:#e8eede;display:grid;place-items:center;border-radius:8px;color:#637851;font-family:Georgia,serif;font-size:16px}.project-nav small{display:block;font-size:8px;color:#98a08e;margin-top:5px}.project-nav>svg{margin-left:auto;width:12px;color:#99a28f}.task-list{display:flex;flex-direction:column;gap:4px;overflow:auto;scrollbar-width:thin;flex:1;min-height:100px;margin-top:2px}.task-item{text-align:left;display:flex;gap:9px;align-items:center;font-size:10px;padding:11px 10px;flex-shrink:0;min-width:0;color:#78836e}.task-item .task-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.task-item.active{background:#fff;border:1px solid #e0e7d9;padding:10px 9px;box-shadow:0 2px 5px #43513803;color:#3c5134}.task-item .task-status-icon{margin-left:auto;color:#b28b44;display:flex}.task-item .task-status-icon svg{width:13px;height:13px}.sidebar-bottom{padding-top:18px;flex-shrink:0}.profile{border-top:1px solid #dde4d7;margin-top:18px;padding:19px 3px 1px;display:flex;gap:9px;align-items:center;font-size:10px;white-space:nowrap}.profile-avatar,.top-avatar{display:grid;place-items:center;background:#dfd5c4;border:2px solid #fff;border-radius:50%;height:29px;width:29px;color:#806e53;font-family:Georgia,serif;font-size:14px}.profile small{display:flex;align-items:center;gap:5px;color:#97a088;font-size:8px;margin-top:6px}.profile .icon-button{margin-left:auto}.body-shell{display:flex;flex-direction:column;min-width:0;overflow:hidden}.topbar{height:65px;flex-shrink:0;background:#fafbf9;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 24px;gap:15px}.breadcrumb{display:flex;align-items:center;gap:12px;font-size:10px;min-width:0;color:#8d9788}.breadcrumb strong{font-size:11px;color:#58664e;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.slash{color:#c1c8b9}.top-actions{display:flex;align-items:center;gap:12px}.prototype-label{font-size:8px;letter-spacing:1px;color:#a0aa96;padding:4px 7px;border:1px solid #e1e7d9;border-radius:4px}.top-avatar{width:27px;height:27px;font-size:12px}.top-actions .icon-button{position:relative}.top-actions b{position:absolute;top:5px;right:6px;width:4px;height:4px;border-radius:50%;background:#b29155}.statusbar{height:28px;flex-shrink:0;display:flex;justify-content:space-between;align-items:center;padding:0 20px;border-top:1px solid var(--line);font-size:8px;color:#91a082;gap:12px;background:#f7f9f4}.statusbar>span{display:flex;gap:7px;align-items:center}.statusbar-muted{color:#a6b199}.page{flex:1;min-height:0;overflow:auto}.task-workspace{height:100%;display:grid;grid-template-columns:minmax(390px,1fr) minmax(330px,40%);overflow:hidden}.task-workspace.canvas-closed{grid-template-columns:minmax(0,1fr) 0}.conversaLine truncated
|
||||||
|
.canvas{border-left:1px solid #e2e9d9;background:#f1f4ed;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden}.canvas-closed .canvas{visibility:hidden}.canvas-toolbar{height:54px;display:flex;align-items:center;justify-content:space-between;padding:0 19px;border-bottom:1px solid #e2e9d9;background:#f6f8f2;gap:8px;flex-shrink:0}.canvas-tabs{display:flex;gap:16px;height:100%;align-items:stretch}.canvas-tabs button{padding:0 1px;border-radius:0;position:relative;color:#9caa88;font-size:9px}.canvas-tabs button:hover{background:none;color:#6f8755}.canvas-tabs button[aria-selected="true"]{color:#6e8854}.canvas-tabs button[aria-selected="true"]:after{content:'';position:absolute;bottom:0;left:0;right:0;height:2px;background:#8ca86a}.canvas-toolbar .icon-button{width:23px;height:25px;padding:3px;color:#9aab83}.canvas-toolbar .icon-button svg{width:14px}.canvas-content{flex:1;overflow:auto;min-height:0;scrollbar-width:thin;scrollbar-color:#d2ddc4 transparent;padding:20px}.document-tools{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:16px;font-size:9px;color:#9aac83}.document-tools button{font-size:9px;padding:4px 7px;color:#8ba16d;border:1px solid #dfe7d4;background:#f9fbf6}.paper{background:#fff;border:1px solid #e0e8d7;border-radius:3px;box-shadow:0 10px 28px #536a3707;min-height:520px;overflow:hidden}.paper-cover{padding:31px 28px 27px;position:relative;overflow:hidden;border-bottom:1px solid #e7eddc;background:linear-gradient(125deg,#f6f8ef,#edf2e3)}.paper-cover .eyebrow{font-size:7px;letter-spacing:2px;color:#a2b581}.paper-cover h2{font-size:26px;line-height:1.4;font-weight:500;letter-spacing:-.8px;color:#788f51;position:relative;z-index:1;max-width:230px;margin:18px 0 13px}.paper-cover p{font-size:9px;color:#a1b280;line-height:1.8;max-width:220px;position:relative;z-index:1}.cover-art{position:absolute;right:-27px;bottom:-50px;width:175px;height:210px;transform:rotate(27deg)}.cover-art span{position:absolute;border-radius:90px 90px 10px 10px;border:1px solid #c9d6b0;width:90px;height:170px;background:linear-gradient(100deg,#d7e1c433,#bcca9d30);transform:rotate(-12deg);right:0;bottom:0}.cover-art span:nth-child(2){right:35px;bottom:8px;transform:rotate(-30deg);background:#c9d6b016}.cover-art span:nth-child(3){right:65px;bottom:17px;transform:rotate(-50deg);background:#e2e9d425}.paper-meta{display:flex;gap:16px;font-size:8px;color:#a3b58a;padding:14px 28px;border-bottom:1px solid #edf1e4}.paper-body{padding:22px 28px 27px;color:#81976a;font-size:10px;line-height:1.9}.paper-body h3{font-size:12px;font-weight:500;color:#79915d;margin:0 0 9px;display:flex;align-items:center;gap:8px}.paper-body h3 span{font-size:8px;color:#aabb93;font-family:Georgia,serif}.paper-body p{margin-bottom:19px}.document-cards{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin:14px 0 22px}.document-cards>div{padding:12px;background:#f6f9f0;border:1px solid #e9efdf;border-radius:5px}.document-cards b{font-size:9px;display:block;font-weight:500;color:#849e66}.document-cards small{font-size:8px;display:block;margin-top:5px;line-height:1.8;color:#a1b387}.doc-line{display:flex;gap:10px;padding:9px 0;border-bottom:1px solid #edf2e5;font-size:9px;color:#95a97c}.doc-line span:first-child{color:#b1bf9c;font-size:8px;width:33px;flex-shrink:0}.paper-footer{padding:13px 28px;border-top:1px solid #edf2e4;font-size:7px;color:#abbc95;display:flex;justify-content:space-between}.canvas-bottom{padding:12px 20px;border-top:1px solid #e1e9d5;display:flex;justify-content:space-between;align-items:center;background:#f7faf2;color:#9caf84;font-size:8px;flex-shrink:0}.canvas-bottom button{color:#90a673;font-size:9px;padding:6px 8px}.canvas-bottom svg{width:12px;height:12px}.activity-heading{font-size:10px;color:#94a97c;margin:3px 0 22px}.activity-entry{padding:0 0 23px 21px;border-left:1px solid #dbe6cc;position:relative;margin-left:6px;color:#8a9f71;font-size:10px}.activity-entry:before{content:'';position:absolute;left:-4px;top:3px;width:7px;height:7px;border-radius:50%;background:#b2c596;border:2px solid #f4f7ee}.activity-entry small{display:block;margin-top:6px;font-size:8px;color:#a6b992}.activity-entry:last-child{border-color:transparent}.file-row{display:flex;align-items:center;gap:12px;padding:14px;background:#fcfef8;border:1px solid #e0e9d2;border-radius:8px;margin-bottom:9px;text-align:left;width:100%;color:#8ca96b;font-size:10px}.file-row svg{width:19px}.file-row small{display:block;font-size:8px;color:#aabc93;margin-top:6px}.file-row .arrow{margin-left:auto;width:12px}.empty-panel{padding:45px 16px;text-align:center;color:#96ab81;font-size:11px;line-height:1.9}.empty-panel svg{width:30px;height:30px;margin-bottom:15px;color:#b3c69c;display:block;margin-left:auto;margin-right:auto}
|
||||||
|
.overview-page{padding:43px max(35px,calc((100% - 1060px)/2)) 35px;min-height:100%;background:#fafbf8}.overview-header{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:32px;gap:15px}.overview-header h1{font-size:30px;font-weight:500;letter-spacing:-.8px;color:#43593c;margin:9px 0 9px}.overview-header .muted{font-size:11px;color:#8e9d82}.overview-header button{font-size:11px}.home-composer{border:1px solid #dce6d2;background:#fff;border-radius:13px;padding:17px 20px;box-shadow:0 5px 25px #49653105;margin-bottom:31px}.home-composer textarea{width:100%;border:0;outline:none;resize:none;font-size:13px;min-height:64px}.home-composer>div{display:flex;justify-content:space-between;align-items:center;font-size:10px;color:#a1b28d}.home-composer button{font-size:10px}.section-heading{display:flex;justify-content:space-between;align-items:center;margin:26px 0 14px}.section-heading h2{font-size:13px;font-weight:500;color:#738e5c}.section-heading span{font-size:10px;color:#a6b894}.task-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px}.task-tile{border:1px solid #e0e9d5;background:#fff;border-radius:11px;padding:19px;text-align:left;min-height:162px;display:flex;flex-direction:column;align-items:flex-start;transition:transform .18s,box-shadow .18s,border-color .18s}.task-tile:hover{transform:translateY(-3px);box-shadow:0 10px 24px #5a793110;border-color:#bdcfa7;background:white}.task-tile h3{font-size:13px;font-weight:500;color:#6f8a53;margin:17px 0 9px}.task-tile p{font-size:10px;color:#a1b288;line-height:1.7}.task-tile>small{font-size:8px;color:#b2c09e;margin-top:auto;padding-top:18px}.task-tile .status-pill{font-size:8px}.project-banner{border:1px solid #dee8d1;background:linear-gradient(110deg,#f0f5e8,#f8faf5);border-radius:11px;padding:23px;display:flex;align-items:center;gap:18px;margin-top:25px}.project-banner .project-avatar{width:42px;height:42px;font-size:24px;background:#e3edda;color:#93ac75;border-color:#d5e2c6}.project-banner h3{font-size:13px;font-weight:500;color:#7b9860;margin-bottom:7px}.project-banner p{font-size:10px;color:#a2b68b}.project-banner button{margin-left:auto;font-size:10px}.connection-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.connection-card{border:1px solid #dfe9d2;border-radius:11px;padding:21px;background:#fff}.connection-card header{display:flex;align-items:center;gap:12px}.provider-symbol{width:34px;height:34px;border:1px solid #dae5ca;border-radius:9px;background:#f0f5e7;display:grid;place-items:center;font-family:Georgia,serif;font-size:17px;color:#8eab6b}.connection-card h3{font-size:12px;font-weight:500;color:#7c995b}.connection-card header small{font-size:9px;color:#a8bb90;display:block;margin-top:5px}.connection-card p{font-size:10px;color:#9db484;line-height:1.8;margin:17px 0}.connection-card footer{display:flex;justify-content:space-between;align-items:center;border-top:1px solid #edf2e5;padding-top:12px;font-size:9px;color:#aec29a}.connection-card footer button{font-size:9px;padding:4px 8px}.filter-tabs{display:flex;gap:7px;margin-bottom:25px}.filter-tabs button{font-size:11px;color:#8ea675;padding:8px 13px}.filter-tabs button.active{background:#e6eedb;color:#6b8c4e}.toggle{width:30px;height:18px;position:relative;border-radius:15px;background:#d4e0c6;padding:0}.toggle:after{content:'';width:12px;height:12px;background:#fff;border-radius:50%;position:absolute;left:3px;top:3px;box-shadow:0 1px 3px #47603215;transition:transform .2s}.toggle[aria-checked="true"]{background:#8aa967}.toggle[aria-checked="true"]:after{transform:translateX(12px)}.schedule-row{display:flex;align-items:center;gap:17px;padding:22px;border:1px solid #dfe9d3;background:#fff;border-radius:10px;margin-bottom:12px}.schedule-row h3{font-size:12px;font-weight:500;color:#799659;margin-bottom:8px}.schedule-row small{font-size:9px;color:#a4b88c}.schedule-row .toggle{margin-left:auto}.schedule-row>svg{color:#a7bd8d}.schedule-row .icon-button{color:#9fb587}.note-box{padding:15px 17px;border:1px solid #e5e9d8;border-radius:8px;background:#f8faef;font-size:10px;color:#9caa7e;line-height:1.9;margin-top:24px}
|
||||||
|
.modal{padding:0;border:1px solid #dfe7d6;border-radius:15px;width:590px;max-width:calc(100vw - 28px);color:#60764e;box-shadow:0 30px 90px #26371d30}.modal::backdrop{background:#24351e38;backdrop-filter:blur(5px)}.modal header{display:flex;justify-content:space-between;align-items:center;padding:23px 26px;border-bottom:1px solid #e7eddc;background:#fbfdf8}.modal h2{font-size:20px;font-weight:500;letter-spacing:-.4px;margin-top:8px;color:#698348}.modal-body{padding:23px 26px;max-height:66dvh;overflow:auto;background:#fff}.modal footer{padding:15px 26px;border-top:1px solid #e7eddd;background:#fcfdf9;display:flex;justify-content:flex-end;gap:8px}.modal footer:empty{display:none}.modal footer button{font-size:11px}.modal label{display:block;font-size:11px;color:#849d66;margin:16px 0 7px}.modal input,.modal select,.modal textarea{width:100%;font-size:11px;color:#779256;border-color:#dce7cf}.modal .muted{color:#91a778;font-size:11px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.form-error{font-size:11px;color:var(--red);margin-top:13px}.search-field{width:100%;margin-bottom:15px}.search-result{display:flex;gap:10px;width:100%;padding:12px;text-align:left;align-items:center;font-size:11px;color:#829f60}.search-result small{font-size:9px;color:#a1b989;margin-left:auto}.search-result svg{width:15px}.model-option{width:100%;padding:15px;display:flex;gap:11px;align-items:center;text-align:left;border:1px solid #e4ecdb;margin:9px 0}.model-option strong{font-size:12px;font-weight:500;color:#789651}.model-option small{font-size:9px;color:#a3ba8a;display:block;margin-top:6px}.model-option.selected{border-color:#afc992;background:#f4f9ed}.model-option>svg:last-child{margin-left:auto;color:#8caa68}.toast{position:fixed;bottom:43px;left:50%;transform:translateX(-50%);background:#456345;color:white;font-size:11px;border:1px solid #567a50;border-radius:9px;box-shadow:0 8px 30px #34502520;padding:12px 18px;z-index:10;max-width:90vw;line-height:1.7}
|
||||||
|
.event-arrival{animation:arrive .32s var(--ease) both}.panel-opening{animation:panel .3s var(--ease) both}.dialog-opening{animation:dialog .22s var(--ease) both}.picked{animation:pick .25s ease-out}.flash-update{animation:update .65s ease-out}.card-hover{transition:transform .18s,box-shadow .18s}@keyframes arrive{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes panel{from{opacity:.3;transform:translateX(18px)}to{opacity:1;transform:translateX(0)}}@keyframes dialog{from{opacity:.3;transform:translateY(8px) scale(.99)}to{opacity:1;transform:none}}@keyframes pick{50%{box-shadow:0 0 0 3px #b5c89225}}@keyframes update{0%,60%{background:#f1f6e8}100%{background:transparent}}@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
/* Keep supporting text readable on the softly tinted product surfaces. */
|
||||||
|
:root{--muted:#727e6a}.conversation-description,.composer-hint{color:#859277}.task-item{color:#68765b}.statusbar{color:#7e8e6d}.statusbar-muted{color:#859377}.sidebar-label{color:#849079}.project-nav small,.profile small{color:#849477}.message,.user-bubble{color:#55634a}.message p{color:#59694c}.ask-option small,.ask-body>.muted{color:#88956d}.ask-option strong{color:#657644}.card-footer>span{color:#938765}.tool-group summary,.tool-record{color:#7b8b64}.tool-group summary .tool-result,.tool-record small{color:#8d9e75}.paper-body{color:#71845c}.paper-cover h2{color:#657d42}.paper-cover p{color:#839969}.paper-meta,.paper-footer{color:#8d9f76}.paper-body h3{color:#647f47}.document-cards small,.doc-line{color:#839868}.canvas-tabs button{color:#859773}.canvas-bottom{color:#8a9f72}.composer textarea{color:#687a53}.composer textarea::placeholder{color:#8d9d7a}.overview-header .muted,.task-tile p{color:#879c72}.task-tile h3{color:#607d45}.connection-card p,.connection-card footer{color:#849f6b}.connection-card h3{color:#698a49}.connection-card header small,.schedule-row small{color:#8ca874}.note-box{color:#80965f}.empty-panel{color:#7e9864}.modal .muted{color:#809b64}.modal label{color:#6f8c51}
|
||||||
|
@media(min-width:1600px){.studio{grid-template-columns:245px minmax(0,1fr)}.task-workspace{grid-template-columns:minmax(500px,1fr) minmax(420px,41%)}.transcript{padding-top:17px}.conversation-title{font-size:28px}.message{font-size:13px}.user-bubble{font-size:12px}.ask-body h3{font-size:14px}.ask-option strong{font-size:12px}.ask-option small{font-size:10px}.paper-cover{padding:40px 35px}.paper-cover h2{font-size:32px}.paper-body{padding:28px 35px;font-size:12px}.paper-body h3{font-size:14px}.document-cards b{font-size:11px}.document-cards small{font-size:10px}.doc-line{font-size:11px}.paper-meta,.paper-footer{padding-left:35px}.task-item,.nav-item{font-size:12px}.project-nav{font-size:11px}.composer textarea{font-size:13px}.composer-bottom button{font-size:10px}.canvas-tabs button{font-size:11px}}
|
||||||
|
@media(max-width:1150px){.studio{grid-template-columns:198px minmax(0,1fr)}.sidebar{padding-left:12px;padding-right:12px}.task-workspace{grid-template-columns:minmax(340px,1fr) 330px}.transcript{padding-left:23px;padding-right:23px}.conversation-toolbar{padding-left:23px;padding-right:23px}.canvas-content{padding:15px}.paper-cover{padding:25px 22px}.paper-cover h2{font-size:24px}.paper-body{padding:21px}.task-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.prototype-label{display:none}.statusbar>span:last-child{display:none}}
|
||||||
|
@media(max-width:960px){.task-workspace{grid-template-columns:minmax(0,1fr)}.canvas{position:fixed;right:12px;top:77px;bottom:39px;width:min(440px,calc(100vw - 24px));z-index:4;border:1px solid #d9e5ca;border-radius:12px;box-shadow:0 12px 70px #3c522b2a}.canvas-closed .canvas{display:none}.conversation-title{font-size:27px}.paper-body{font-size:12px}.task-workspace .canvas-toggle{display:inline-flex}.topbar{padding:0 17px}.connection-grid{grid-template-columns:1fr}.overview-page{padding:32px 27px}.overview-header h1{font-size:26px}}
|
||||||
|
@media(max-width:650px){.studio{grid-template-columns:0 minmax(0,1fr)}.sidebar{visibility:hidden}.studio.mobile-rail .sidebar{visibility:visible;position:fixed;left:0;top:0;bottom:0;width:228px;z-index:6;box-shadow:10px 0 50px #2f442428;opacity:1;pointer-events:auto}.topbar{height:56px;padding:0 12px}.breadcrumb{gap:7px}.breadcrumb>span:not(.slash){display:none}.top-actions{gap:6px}.top-actions .top-avatar{display:none}.top-actions .icon-button:first-of-type{display:none}.statusbar{font-size:8px;padding:0 12px}.statusbar>span:nth-child(2){display:none}.conversation-toolbar{padding:14px 17px 8px}.transcript{padding:10px 19px 20px}.conversation-title{font-size:24px}.conversation-description{font-size:10px}.message{font-size:13px}.user-bubble{font-size:12px}.ask-option strong{font-size:12px}.ask-option small{font-size:10px}.ask-body h3{font-size:14px}.ask-body>.muted{font-size:11px}.ask-option{min-height:50px}.card-footer{flex-wrap:wrap}.card-footer button{min-height:38px;font-size:11px}.composer-zone{padding:7px 15px 13px}.composer textarea{font-size:16px}.composer-bottom button{font-size:10px;min-height:34px}.composer-bottom .send{width:34px;height:34px}.composer-bottom .composer-model{max-width:145px}.composer-hint{font-size:8px}.canvas{top:66px}.overview-page{padding:27px 19px}.overview-header h1{font-size:24px}.overview-header{align-items:flex-start}.overview-header .eyebrow{font-size:8px}.overview-header button{font-size:10px;white-space:nowrap}.task-grid{grid-template-columns:1fr}.task-tile{min-height:135px}.task-tile h3{font-size:14px}.task-tile p{font-size:12px}.task-tile>small{font-size:10px}.project-banner{padding:16px;gap:10px;flex-wrap:wrap}.project-banner h3{font-size:12px}.project-banner p{font-size:10px}.modal-body{padding:20px}.modal header{padding:20px}.form-grid{grid-template-columns:1fr}.modal input,.modal select,.modal textarea{font-size:16px}.modal{max-width:calc(100vw - 20px)}.schedule-row{padding:17px;gap:12px}.filter-tabs{gap:2px;flex-wrap:wrap}.filter-tabs button{font-size:11px;padding:8px}.studio{min-height:620px}}
|
||||||
|
.nav-item{font-size:12px}.task-item{font-size:11px}.sidebar-label{font-size:10px}.project-nav{font-size:11px}.profile{font-size:11px}.profile small,.project-nav small{font-size:10px}.breadcrumb{font-size:11px}.breadcrumb strong{font-size:12px}.conversation-description{font-size:11px}.message{font-size:14px}.user-bubble{font-size:13px;color:#4b5946}.agent-byline{font-size:12px}.agent-byline small{font-size:10px}.message p{color:#52614a}.compact-plan summary{font-size:12px}.plan-steps li{font-size:12px}.tool-group summary,.tool-record{font-size:11px}.tool-group summary .tool-result,.tool-record small{font-size:10px}.receipt{font-size:11px}.ask-body h3{font-size:15px;color:#4c5239}.ask-body>.muted{font-size:12px;color:#7b806a}.ask-option strong{font-size:12px;color:#546239}.ask-option small{font-size:11px;color:#7c8768}.card-heading{font-size:11px}.card-heading>span:last-child{font-size:10px}.card-footer>span{font-size:10px}.card-footer button{font-size:11px}.ask-free{font-size:12px}.ask-option .recommended{font-size:9px}.execution-top{font-size:12px}.execution-bottom{font-size:11px}.execution-top .small{font-size:10px}.subagent{font-size:10px}.result-card b{font-size:12px}.result-card small{font-size:11px}.composer textarea{font-size:13px}.composer-bottom button{font-size:11px}.composer-hint{font-size:9px}.status-pill{font-size:10px}.task-meta .project-tag{font-size:10px}.canvas-tabs button{font-size:11px}.document-tools{font-size:11px;color:#788969}.document-tools button{font-size:10px}.paper-meta{font-size:10px;color:#7b8c68}.paper-body{font-size:12px;color:#647650}.paper-body h3{font-size:14px;color:#566d3f}.paper-cover h2{color:#536b35}.paper-cover p{font-size:11px;color:#788d5d}.document-cards b{font-size:11px;color:#6a814f}.document-cards small{font-size:10px;color:#788a65}.doc-line{font-size:11px;color:#768960}.doc-line span:first-child{font-size:10px;color:#879873;width:40px}.paper-footer{font-size:9px;color:#8b9b78}.canvas-bottom{font-size:10px;color:#7f9366}.canvas-bottom button{font-size:10px}.activity-entry{font-size:12px;color:#758a5c}.activity-entry small{font-size:10px;color:#8d9f77}.file-row{font-size:12px;color:#728951}.file-row small{font-size:10px;color:#8b9c74}.task-tile h3{font-size:15px;color:#506c35}.task-tile p{font-size:12px;color:#7c9069}.task-tile>small{font-size:10px;color:#8fa17c}.task-tile .status-pill{font-size:10px}.section-heading h2{font-size:15px}.section-heading span{font-size:11px}.overview-header .muted{font-size:13px}.overview-header button{font-size:12px}.connection-card h3{font-size:14px}.connection-card p{font-size:12px;line-height:1.8}.connection-card header small,.connection-card footer{font-size:11px}.connection-card footer button{font-size:11px}.filter-tabs button{font-size:12px}.note-box{font-size:12px;color:#788a5e}.schedule-row h3{font-size:14px}.schedule-row small{font-size:11px}.modal label,.modal input,.modal select,.modal textarea{font-size:13px}.modal .muted{font-size:12px;color:#768a5f}.modal footer button{font-size:12px}.model-option strong{font-size:14px}.model-option small{font-size:11px}.search-result{font-size:13px}.search-result small{font-size:11px}.task-meta{min-width:0}.project-tag{max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.conversation-toolbar{flex-shrink:0}.task-workspace.canvas-closed .transcript{padding-left:max(28px,calc((100% - 770px)/2));padding-right:max(28px,calc((100% - 770px)/2))}.task-workspace.canvas-closed .composer-zone{padding-left:max(26px,calc((100% - 770px)/2));padding-right:max(26px,calc((100% - 770px)/2))}.task-workspace.canvas-closed .conversation-toolbar{padding-left:max(28px,calc((100% - 770px)/2));padding-right:max(28px,calc((100% - 770px)/2))}.large-type .message,.large-type .user-bubble{font-size:16px}.large-type .paper-body{font-size:14px}.large-type .composer textarea{font-size:16px}.large-type .ask-option strong{font-size:14px}.large-type .nav-item,.large-type .task-item{font-size:13px}.reduce-motion *,.reduce-motion *:before,.reduce-motion *:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}
|
||||||
|
@media(max-width:650px){.composer textarea,.modal input,.modal select,.modal textarea{font-size:16px}.composer-bottom button{font-size:10px}.composer-bottom .composer-model{max-width:125px}.task-meta .project-tag{max-width:135px;font-size:9px}.status-pill{font-size:9px}.task-workspace.canvas-closed .transcript{padding-left:19px;padding-right:19px}.task-workspace.canvas-closed .composer-zone{padding-left:15px;padding-right:15px}.task-workspace.canvas-closed .conversation-toolbar{padding-left:17px;padding-right:17px}.card-footer>span{font-size:10px}.task-tile p{font-size:12px}}
|
||||||
|
@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
(()=>{
|
||||||
|
'use strict';
|
||||||
|
const $=id=>document.getElementById(id);
|
||||||
|
const paths={plus:'M12 5v14M5 12h14',search:'M21 21l-5-5M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0',grid:'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z',inbox:'M4 4h16l2 12v4H2v-4L4 4zM2 15h6l2 3h4l2-3h6',files:'M6 3h9l4 4v14H6zM14 3v5h5M3 7v14M9 12h7M9 16h7',chevron:'M9 5l7 7-7 7',clock:'M12 8v5l3 2M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',puzzle:'M9 3H3v6c4-2 5 4 0 3v9h7c-2-5 5-5 3 0h8v-8c-5 2-5-5 0-3V3h-7c2 5-5 5-5 0',settings:'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8M9 2h6l1 4 4 1 2 5-3 3v4l-5 3-3-3H7l-4-4 1-4-2-3 3-4 4 1z',panel:'M3 3h18v18H3zM9 3v18',bell:'M5 17h14l-2-3V9a5 5 0 0 0-10 0v5l-2 3M10 21h4',close:'M6 6l12 12M6 18L18 6',arrow:'M5 12h14M13 6l6 6-6 6',up:'M12 19V5M6 11l6-6 6 6',check:'M5 12l4 4L19 6',checkcircle:'M8 12l3 3 5-6M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',question:'M9 8a3 3 0 1 1 4 3c-1 0-1 1-1 3M12 17h.01M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',shield:'M12 2l9 4v6c0 5-9 10-9 10S3 17 3 12V6l9-4M8 12l3 3 5-6',link:'M10 13a5 5 0 0 0 7 0l4-4a5 5 0 0 0-7-7l-2 2M14 11a5 5 0 0 0-7 0l-4 4a5 5 0 0 0 7 7l2-2',download:'M12 3v12M7 10l5 5 5-5M4 16v5h16v-5',edit:'M4 16L16 4l4 4L8 20H4v-4M14 6l4 4',more:'M5 12h.01M12 12h.01M19 12h.01',globe:'M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0M2 12h20M12 2c-5 5-5 15 0 20 5-5 5-15 0-20',code:'M8 6l-6 6 6 6M16 6l6 6-6 6M14 3l-4 18',folder:'M2 5h7l2 3h11v13H2V5z',paperclip:'M8 12l7-7a4 4 0 0 1 6 6l-9 9a6 6 0 0 1-9-9l9-9M6 14l9-9a2 2 0 0 1 3 3l-9 9',pause:'M8 5v14M16 5v14',play:'M7 4l14 8-14 8V4z',stop:'M6 6h12v12H6z',warning:'M12 3L1 21h22L12 3zM12 9v5M12 17h.01',list:'M9 6h12M9 12h12M9 18h12M3 6h.01M3 12h.01M3 18h.01',spark:'M12 2l3 7 7 3-7 3-3 7-3-7-7-3 7-3 3-7z',layers:'M12 2l10 6-10 6L2 8l10-6M2 12l10 6 10-6M2 16l10 6 10-6',expand:'M8 3H3v5M16 3h5v5M3 16v5h5M21 16v5h-5',mail:'M2 5h20v14H2zM2 5l10 8L22 5',refresh:'M20 7a9 9 0 1 0 1 8M20 2v6h-6',archive:'M3 3h18v5H3zM5 8v13h14V8M10 12h4'};
|
||||||
|
const icon=name=>`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${paths[name]||paths.spark}"/></svg>`;
|
||||||
|
function icons(root=document){root.querySelectorAll('[data-icon]').forEach(el=>{el.outerHTML=icon(el.dataset.icon)})}
|
||||||
|
const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const button=(action,label,cls='',data='')=>`<button class="${cls}" data-action="${action}" ${data}>${label}</button>`;
|
||||||
|
const statusLabels={waiting:'等你回答',approval:'需要授权',running:'执行中',paused:'已暂停',done:'已完成',blocked:'需要处理',idle:'就绪'};
|
||||||
|
const statusIcon=s=>s==='waiting'?'question':s==='approval'?'shield':s==='blocked'?'warning':s==='done'?'check':'clock';
|
||||||
|
const state={route:'task',active:'launch',canvas:true,canvasTab:'preview',capTab:'models',project:'Atelier 品牌工作室',models:[{id:'balanced',name:'通用模型',description:'标准对话 · 工具调用 · 流式输出',provider:'演示服务',protocol:'OpenAI 兼容',endpoint:'',effort:true},{id:'fast',name:'快速模型',description:'轻量问答 · 工具调用',provider:'演示服务',protocol:'OpenAI 兼容',endpoint:'',effort:false}],tools:[{id:'web',name:'网页检索',description:'搜索公开资料,读取页面并保留来源。',kind:'内置工具',icon:'globe',enabled:true},{id:'files',name:'工作区文件',description:'读取与编辑当前项目内的文件。',kind:'本地能力',icon:'folder',enabled:true},{id:'notion',name:'Notion',description:'搜索项目知识库,读取页面和数据库。',kind:'MCP 连接器',icon:'files',enabled:false},{id:'http',name:'自定义 HTTP API',description:'通过适配器连接业务服务。',kind:'HTTP API',icon:'link',enabled:false}],skills:[{id:'research',name:'研究与整理',description:'整理来源、比较信息并形成可核查的结论。',enabled:true},{id:'brand',name:'品牌写作',description:'在项目内保持一致的语气、术语和表达。',enabled:true},{id:'sheet',name:'表格分析',description:'分析数据、整理表格并检查计算。',enabled:false}],schedules:[{id:'weekly',name:'每周行业动态',prompt:'汇总本周与产品相关的行业动态,并保留来源。',cadence:'每周一 09:00',enabled:true}],drafts:{},attachments:{},askChoices:{},permission:'ask',model:'balanced',effort:'标准',notificationCount:0};
|
||||||
|
let sequence=20,toastTimer,clockTimer;
|
||||||
|
const tasks=[
|
||||||
|
{id:'launch',title:'秋季新品发布策略',summary:'定位、内容节奏与渠道建议',status:'waiting',kind:'strategy',model:'balanced',version:1,step:2,ticks:0,artifact:true,ask:true,choice:null,permission:null,goal:'结合项目资料,帮我整理一份秋季新品发布策略。先明确产品定位,再给出内容与渠道建议。',plan:['阅读项目资料与品牌约定','梳理产品定位与目标人群','制定发布节奏与渠道建议','整理成可讨论的策略文档'],messages:[{type:'user',text:'结合项目资料,帮我整理一份秋季新品发布策略。先明确产品定位,再给出内容与渠道建议。'},{type:'agent',text:'我已经整理了项目里的品牌方向和产品介绍,先搭好了策略文档的结构。\n发布节奏还需要明确一个重点:这次更希望让新用户认识品牌,还是推动已有用户尝试新品?',tools:['已读取 品牌手册.md','已读取 新品介绍.md','已整理 3 个发布方向']}],events:['开始整理发布策略','读取 2 份项目资料','完成定位初稿,等待确认目标'],revision:''},
|
||||||
|
{id:'research',title:'个人助手产品研究',summary:'比较交互方式与核心能力',status:'idle',kind:'research',model:'balanced',version:0,step:1,ticks:0,artifact:false,ask:false,goal:'比较主流个人助手的交互方式,整理一份产品研究简报。',plan:['明确比较维度','收集产品资料','整理功能与交互差异','形成研究简报'],messages:[{type:'user',text:'比较主流个人助手的交互方式,整理一份产品研究简报。'},{type:'agent',text:'我会从任务组织、工具接入和成果交付三个角度展开,保留来源。你可以在我工作时继续补充关注点。'}],events:['研究计划已准备好'],revision:''},
|
||||||
|
{id:'email',title:'合作邀约邮件',summary:'草稿已完成,发送前等待确认',status:'approval',kind:'email',model:'fast',version:1,step:2,ticks:0,artifact:true,ask:false,permission:'pending',goal:'根据项目内容起草一封合作邀约邮件。',plan:['梳理合作背景','起草邮件','确认收件人和内容','发送并记录结果'],messages:[{type:'user',text:'起草一封合作邀约邮件,发送前让我确认。'},{type:'agent',text:'邮件草稿已经准备好,你可以在右侧检查。下一步是发送邮件,这会联系外部收件人,需要你确认。'}],events:['已生成邮件草稿','等待发送授权'],revision:''},
|
||||||
|
{id:'analysis',title:'用户访谈主题整理',summary:'整理完成,含主题与后续问题',status:'done',kind:'research',model:'balanced',version:1,step:4,ticks:0,artifact:true,ask:false,goal:'把访谈笔记中的主要主题整理出来。',plan:['读取访谈笔记','整理主题','标记待验证问题','交付主题简报'],messages:[{type:'user',text:'把访谈笔记中的主要主题整理出来。'},{type:'agent',text:'主题简报已整理完成。示例文档列出了重点方向与后续验证问题,你可以直接打开,也可以继续提出修改。'}],events:['读取资料','整理主题','完成交付'],revision:''},
|
||||||
|
{id:'sync',title:'同步项目知识库',summary:'连接失效,现有进度已保存',status:'blocked',kind:'research',model:'balanced',version:0,step:1,ticks:0,artifact:false,ask:false,goal:'同步项目知识库里的最新资料。',plan:['检查已有资料','读取远程更新','合并新增内容','生成同步记录'],messages:[{type:'user',text:'同步项目知识库里的最新资料。'},{type:'agent',text:'本地资料已检查完成。知识库的访问凭证失效,无法继续读取远程更新;本地文件没有被修改。'}],events:['完成本地资料检查','连接返回身份验证错误'],revision:''}
|
||||||
|
];
|
||||||
|
function current(){return tasks.find(t=>t.id===state.active)||tasks[0]}
|
||||||
|
function pill(t){return `<span class="status-pill ${t.status}"><span class="dot ${t.status==='waiting'||t.status==='approval'?'amber':t.status==='blocked'?'red':t.status==='running'?'running':'green'}"></span>${statusLabels[t.status]}</span>`}
|
||||||
|
function announce(text){$('announcer').textContent=text}
|
||||||
|
function toast(text){clearTimeout(toastTimer);$('toast').textContent=text;$('toast').hidden=false;toastTimer=setTimeout(()=>$('toast').hidden=true,3500)}
|
||||||
|
function renderSidebar(){const visible=tasks.filter(t=>!t.archived);$('taskCount').textContent=visible.length;$('taskList').innerHTML=visible.map(t=>`<button class="task-item ${state.route==='task'&&state.active===t.id?'active':''}" data-task="${t.id}"><span class="dot ${t.status==='running'?'running':t.status==='done'?'green':t.status==='blocked'?'red':'amber'}"></span><span class="task-name">${esc(t.title)}</span>${['waiting','approval','blocked'].includes(t.status)?`<span class="task-status-icon">${icon(statusIcon(t.status))}</span>`:''}</button>`).join('');$('attentionCount').textContent=tasks.filter(t=>!t.archived&&['waiting','approval','blocked'].includes(t.status)).length;$('notificationDot').hidden=state.notificationCount===0;document.querySelectorAll('[data-route]').forEach(b=>b.classList.toggle('active',b.dataset.route===state.route));const label=document.querySelector('.project-nav>span:nth-child(2)');label.innerHTML=esc(state.project)+'<small>策略 · 产品 · 内容</small>'}
|
||||||
|
function render(){renderSidebar();if(state.route==='task')renderTask();else renderPage();icons();}
|
||||||
|
function navigate(route,id){state.route=route;if(id)state.active=id;$('studio').classList.remove('mobile-rail');render();}
|
||||||
|
function messageHTML(m,index){if(m.type==='user')return `<section class="message user" data-message="${index}"><div class="user-bubble">${esc(m.text)}</div></section>`;return `<section class="message" data-message="${index}"><div class="agent-byline"><span class="agent-avatar">r</span>Reasonix<small>${m.steering?'已接收补充':'通用助手'}</small></div><p>${esc(m.text)}</p>${m.tools?`<details class="tool-group"><summary>${icon('checkcircle')}<span>${m.tools.length} 项工作已完成</span><span class="tool-result">查看记录</span>${icon('chevron')}</summary>${m.tools.map(x=>`<div class="tool-record">${icon('check')}<span>${esc(x)}</span><small>示例</small></div>`).join('')}</details>`:''}</section>`}
|
||||||
|
function planHTML(t){if(!t.plan.length)return '';const n=Math.min(t.step,t.plan.length);return `<details class="compact-plan" id="taskPlan"><summary>${icon('list')}<span>执行计划</span><span class="plan-total">${n} / ${t.plan.length}</span><span class="chevron-small">${icon('chevron')}</span></summary><ol class="plan-steps">${t.plan.map((x,i)=>`<li class="${i<n?'complete':i===n?'current':''}"><span class="step-circle">${i<n?'✓':i+1}</span><span>${esc(x)}</span></li>`).join('')}</ol><div style="padding:0 8px 8px;text-align:right">${button('edit-plan','调整计划','text-button')}</div><div class="plan-progress"><i style="width:${100*n/t.plan.length}%"></i></div></details>`}
|
||||||
|
function askHTML(t){const options=[['品牌认知','让更多目标用户认识我们,建立清晰印象。'],['新品转化','面向已有用户,强调产品价值与尝试理由。'],['两者兼顾','分阶段推进:先建立认知,再承接转化。']];const selected=state.askChoices[t.id]??0;return `<section class="ask-card" id="activeDecision"><div class="card-heading">${icon('question')}<span>需要你确认一个方向</span><span>问题 1 / 1</span></div><div class="ask-body"><h3>这次发布,更希望优先达成什么?</h3><p class="muted">这会影响文档里的渠道权重和内容安排。</p><div class="ask-options">${options.map((x,i)=>`<button class="ask-option ${selected===i?'selected':''}" data-choice="${i}" aria-pressed="${selected===i}"><span class="radio"></span><span><strong>${x[0]}</strong><small>${x[1]}</small></span>${i===0?'<span class="recommended">建议</span>':''}</button>`).join('')}</div><input class="ask-free" id="askFree" aria-label="自定义回答" placeholder="也可以写下更具体的目标…" value="${esc(t.askDraft||'')}"></div><footer class="card-footer"><span>回答后继续,已有内容会保留。</span>${button('answer','确认方向 '+icon('arrow'),'primary')}</footer></section>`}
|
||||||
|
function approvalHTML(){return `<section class="ask-card approval-card" id="activeDecision"><div class="card-heading">${icon('shield')}<span>发送前需要你的授权</span><span>外部操作</span></div><div class="ask-body"><h3>发送这封合作邀约邮件?</h3><p class="muted">收件人:合作伙伴(示例)<br>主题:关于秋季新品的内容合作</p><div class="approval-scope"><span>仅这封邮件</span><span>不读取其他邮件</span><span>可先修改草稿</span></div><p class="muted">原型只演示确认过程,不会发送邮件。</p></div><footer class="card-footer">${button('deny','保留草稿','outline')}<div>${button('edit-artifact','修改内容','text-button')} ${button('approve','确认发送','primary')}</div></footer></section>`}
|
||||||
|
function blockedHTML(){return `<section class="ask-card"><div class="card-heading">${icon('warning')}<span>连接需要处理</span><span>AUTH_EXPIRED</span></div><div class="ask-body"><h3>知识库授权已过期</h3><p class="muted">可以重新连接,或仅使用本地资料继续。此前的工作不会丢失。</p></div><footer class="card-footer">${button('skip-connection','使用本地资料','outline')}${button('reconnect','重新连接','primary')}</footer></section>`}
|
||||||
|
function executionHTML(t){return `<div class="execution" id="execution"><div class="execution-top">${t.status==='running'?'<span class="spinner"></span>':icon('pause')}<span id="executionLabel">${t.status==='paused'?'执行已暂停':t.plan[Math.min(t.step,t.plan.length-1)]||'整理任务成果'}</span>${button(t.status==='paused'?'resume':'pause',t.status==='paused'?'继续':'暂停','small outline')}</div><div class="execution-bottom" id="executionDetail">${t.status==='paused'?'已有进度保留,可随时补充要求。':'正在推进 · 演示执行事件'}</div><div class="run-progress"><i id="executionProgress" style="width:${Math.min(90,20+t.ticks*14)}%"></i></div>${t.kind==='research'?'<div class="subagents"><span class="subagent">资料整理 · 工作中</span><span class="subagent">观点归纳 · 工作中</span></div>':''}</div>`}
|
||||||
|
function resultHTML(t){return `<button class="result-card" data-action="open-artifact"><span class="result-icon">${icon(t.kind==='email'?'mail':'files')}</span><span><b>${esc(t.title)}${t.kind==='email'?' · 草稿':'.md'}</b><small>版本 ${t.version} · ${t.status==='done'?'已完成':'可查看草稿'} · 点击打开</small></span>${icon('arrow')}</button>`}
|
||||||
|
function tailHTML(t){if(t.ask&&t.status==='waiting')return askHTML(t);if(t.status==='approval')return approvalHTML();if(t.status==='blocked')return blockedHTML();if(['running','paused'].includes(t.status))return executionHTML(t);if(t.status==='idle')return `<div class="receipt">${icon('checkcircle')}执行计划已就绪,随时可以补充要求。</div>${button('start','开始执行 '+icon('arrow'),'primary small')}`;if(t.artifact)return resultHTML(t);return ''}
|
||||||
|
function composerHTML(t){const model=state.models.find(m=>m.id===t.model)||state.models[0];return `<div class="composer-zone">${['running','paused'].includes(t.status)?`<div class="steering-banner"><span>${icon('spark')} ${t.status==='running'?'Agent 工作中,你仍然可以补充或调整方向':'任务已暂停,可以修改要求后继续'}</span>${button(t.status==='running'?'pause':'resume',t.status==='running'?'暂停':'继续','text-button')}</div>`:''}<form class="composer" id="composer"><div class="attached-files">${(state.attachments[t.id]||[]).map((f,i)=>`<span class="file-chip">${icon('files')}${esc(f)}<button type="button" data-remove-file="${i}" aria-label="移除附件 ${esc(f)}">${icon('close')}</button></span>`).join('')}</div><textarea id="messageInput" rows="2" aria-label="消息" placeholder="${t.status==='waiting'?'直接回复,或者补充你希望达成的目标…':t.status==='running'?'补充要求,Agent 会在执行中接收…':'继续聊聊,或告诉 Agent 下一步…'}">${esc(state.drafts[t.id]||'')}</textarea><div class="composer-bottom"><button type="button" data-action="attach" aria-label="添加附件">${icon('plus')}</button><button type="button" data-action="context">${icon('folder')} 项目上下文</button><button type="button" class="composer-model" data-action="model">${icon('spark')} ${esc(model.name)}</button><button type="button" data-action="permission" aria-label="权限设置">${icon('shield')}</button><button class="send" type="submit" aria-label="发送消息">${icon('up')}</button></div></form><div class="composer-hint"><span>Enter 发送 · Shift + Enter 换行</span><span>${(t.permissionMode||'ask')==='ask'?'必要时询问':'演示自动审批'} · ${model.effort?(t.effort||'标准'):'自动'}</span></div></div>`}
|
||||||
|
function renderTask(){const t=current();$('pageTitle').textContent=t.title;$('page').innerHTML=`<div class="task-workspace ${state.canvas?'':'canvas-closed'}"><main class="conversation"><div class="conversation-toolbar"><div class="task-meta"><span class="project-tag">${esc(state.project)}</span>${pill(t)}</div><div class="conversation-actions"><button class="icon-button" data-action="toggle-canvas" aria-label="${state.canvas?'收起':'打开'}工作区">${icon('panel')}</button><button class="icon-button" data-action="task-menu" aria-label="任务操作">${icon('more')}</button></div></div><div class="transcript" id="transcript"><h1 class="conversation-title">${esc(t.title)}</h1><p class="conversation-description">${esc(t.summary||'一个新的想法,逐步变成成果。')} <span style="margin:0 7px">·</span> 本次工作</p><div id="messages">${t.messages.map(messageHTML).join('')}</div>${planHTML(t)}<div id="taskTail">${tailHTML(t)}</div></div>${composerHTML(t)}</main><aside class="canvas" aria-label="任务工作区"><div class="canvas-toolbar"><div class="canvas-tabs" role="tablist" aria-label="工作区内容">${[['preview','成果预览'],['files','文件'],['activity','执行记录']].map(([id,label])=>`<button id="canvas-tab-${id}" role="tab" aria-controls="canvasContent" aria-selected="${state.canvasTab===id}" data-canvas-tab="${id}">${label}</button>`).join('')}</div><button class="icon-button" data-action="toggle-canvas" aria-label="收起工作区">${icon('close')}</button></div><div class="canvas-content" id="canvasContent" role="tabpanel" aria-labelledby="canvas-tab-${state.canvasTab}"></div><div class="canvas-bottom"><span>内容与当前任务保持关联</span>${button('edit-artifact',icon('edit')+' 提出修改')}</div></aside></div>`;renderCanvas();}
|
||||||
|
function documentHTML(t){const isEmail=t.kind==='email';const subtitle=isEmail?'一个真诚、简洁的合作邀请。':t.kind==='strategy'?'让新产品,以恰当的方式被看见。':'从分散的信息,到清晰的判断。';const body=isEmail?`<h3><span>01</span>邮件正文</h3><p>你好,\n\n我们正在准备秋季新品发布,希望与你讨论一次内容合作。我们欣赏你对日常体验的细腻表达,也期待一起呈现产品在真实生活中的价值。</p><p>如果你有兴趣,我们可以先交流产品方向、内容形式与时间安排,再共同确定具体方案。</p><p>期待你的回复。\nAtelier 团队</p>`:`<h3><span>01</span>${t.kind==='strategy'?'核心方向':'研究目标'}</h3><p>${t.choice?`以「${esc(t.choice)}」为主要方向,`:'以用户的实际需求为出发点,'}${t.kind==='strategy'?'将产品的功能价值转化成容易理解的生活场景,先解释为什么需要,再说明有什么不同。':'围绕实际使用过程,整理关键能力、交互方式与值得继续验证的问题。'}</p><div class="document-cards"><div><b>${t.kind==='strategy'?'表达重点':'任务连续性'}</b><small>${t.kind==='strategy'?'真实使用场景\n清楚、克制的产品表达':'上下文、计划与执行过程\n在同一任务中保持连贯'}</small></div><div><b>${t.kind==='strategy'?'内容原则':'用户可控性'}</b><small>${t.kind==='strategy'?'减少空泛的描述\n用具体体验解释价值':'可以补充要求、检查成果\n在重要操作前做出决定'}</small></div></div><h3><span>02</span>${t.kind==='strategy'?'推进节奏':'接下来需要验证'}</h3><div class="doc-line"><span>第一步</span><span>${t.kind==='strategy'?'预热:建立问题认知与期待':'明确比较对象与使用场景'}</span></div><div class="doc-line"><span>第二步</span><span>${t.kind==='strategy'?'发布:解释产品价值与使用方式':'通过实际任务观察执行过程'}</span></div><div class="doc-line"><span>第三步</span><span>${t.kind==='strategy'?'延续:收集反馈,补充真实案例':'检查结果质量与中途干预方式'}</span></div>`;return `<div class="document-tools"><span>${icon('files')} ${esc(t.title)}.md</span>${button('versions','v'+t.version+' ▾')}</div><article class="paper"><header class="paper-cover"><span class="eyebrow">ATELIER / ${isEmail?'CORRESPONDENCE':'WORKING DOCUMENT'}</span><h2>${esc(t.title)}</h2><p>${subtitle}</p><div class="cover-art" aria-hidden="true"><span></span><span></span><span></span></div></header><div class="paper-meta"><span>${t.status==='done'?'已交付':'工作草稿'}</span><span>版本 ${t.version}</span><span>中文</span></div><div class="paper-body">${body}${t.revision?`<h3><span>03</span>本轮修改要求</h3><p>${esc(t.revision)}</p>`:''}</div><footer class="paper-footer"><span>REASONIX STUDIO</span><span>原型示例内容 · 非真实执行结果</span></footer></article>`}
|
||||||
|
function renderCanvas(){if(state.route!=='task'||!$('canvasContent'))return;const t=current();document.querySelectorAll('[data-canvas-tab]').forEach(b=>b.setAttribute('aria-selected',String(state.canvasTab===b.dataset.canvasTab)));$('canvasContent').setAttribute('aria-labelledby','canvas-tab-'+state.canvasTab);if(state.canvasTab==='preview')$('canvasContent').innerHTML=t.artifact?documentHTML(t):`<div class="empty-panel">${icon('files')}成果会在这里逐步形成。<br><span class="muted">当前可以先查看文件与执行记录。</span></div>`;if(state.canvasTab==='files')$('canvasContent').innerHTML=`<h3 class="activity-heading">项目输入</h3>${['品牌手册.md','新品介绍.md'].map((x,i)=>`<button class="file-row" data-source="${i}">${icon('files')}<span>${x}<small>项目资料 · 示例</small></span><span class="arrow">${icon('chevron')}</span></button>`).join('')}<h3 class="activity-heading" style="margin-top:28px">本次产出</h3>${t.artifact?`<button class="file-row" data-action="open-artifact">${icon('files')}<span>${esc(t.title)}.md<small>版本 ${t.version} · 可预览和下载</small></span></button>`:'<p class="muted">暂无产出文件</p>'}`;if(state.canvasTab==='activity')$('canvasContent').innerHTML=`<h3 class="activity-heading">执行事件 · 模拟数据</h3>${t.events.map((event,i)=>`<div class="activity-entry">${esc(event)}<small>步骤 ${i+1} · ${i===t.events.length-1?'最新进展':'已记录'}</small></div>`).join('')}`;const bottom=document.querySelector('.canvas-bottom');bottom.innerHTML=t.artifact?`<span>${t.kind==='email'?'邮件草稿':'Markdown 文档'} · 可继续迭代</span><div>${button('download',icon('download')+' 下载')}${button('edit-artifact',icon('edit')+' 修改')}</div>`:'<span>工作区会随任务更新</span>';}
|
||||||
|
function taskTile(t){return `<button class="task-tile" data-task="${t.id}">${pill(t)}<h3>${esc(t.title)}</h3><p>${esc(t.summary||t.goal)}</p><small>${t.artifact?`已有成果 · v${t.version}`:'保留完整任务上下文'} ${t.archived?'· 已归档':''}</small></button>`}
|
||||||
|
function overviewHeader(eyebrow,title,desc,action=''){return `<div class="overview-header"><div><div class="eyebrow">${eyebrow}</div><h1>${title}</h1><p class="muted">${desc}</p></div>${action}</div>`}
|
||||||
|
function renderPage(){const labels={home:'工作台',inbox:'待我处理',library:'我的成果',capabilities:'能力与连接',automation:'定时任务'};$('pageTitle').textContent=labels[state.route];let html='';if(state.route==='home')html=overviewHeader('YOUR WORK, IN ONE PLACE','让想法,逐步成为成果。','开始一个新任务,或继续手头的工作。')+`<form class="home-composer" id="homeComposer"><textarea id="homePrompt" rows="2" aria-label="新任务目标" placeholder="今天,你想完成什么?"></textarea><div><span>${icon('folder')} ${esc(state.project)}</span><button class="primary" type="submit">开始工作 ${icon('arrow')}</button></div></form><div class="section-heading"><h2>继续你的工作</h2><span>${tasks.filter(t=>!t.archived).length} 个任务</span></div><div class="task-grid">${tasks.filter(t=>!t.archived).map(taskTile).join('')}</div><div class="project-banner"><span class="project-avatar">A</span><div><h3>${esc(state.project)}</h3><p>共享项目资料、工作约定与长期上下文。</p></div>${button('project-info','查看项目 '+icon('arrow'),'outline')}</div>`;
|
||||||
|
if(state.route==='inbox'){const waiting=tasks.filter(t=>!t.archived&&['waiting','approval','blocked'].includes(t.status));html=overviewHeader('YOUR ATTENTION','在这里,做关键决定。','需要回答、授权或处理的问题集中在这里。')+`<div class="task-grid">${waiting.length?waiting.map(taskTile).join(''):'<p class="muted">当前没有等待处理的任务。</p>'}</div>`}
|
||||||
|
if(state.route==='library')html=overviewHeader('MADE WITH REASONIX','值得留下来的成果。','打开、检查,再继续打磨。每份成果都能回到它所属的任务。')+`<div class="task-grid">${tasks.filter(t=>t.artifact).map(taskTile).join('')}</div>`;
|
||||||
|
if(state.route==='capabilities')html=capabilitiesHTML();if(state.route==='automation')html=overviewHeader('WORK THAT COMES BACK','把重复工作交给日程。','管理任务的目标、运行时间和启用状态。',button('add-schedule','新建定时任务 '+icon('plus'),'primary'))+state.schedules.map(s=>`<div class="schedule-row">${icon('clock')}<div><h3>${esc(s.name)}</h3><small>${esc(s.cadence)} · ${s.enabled?'已启用':'已暂停'} · 仅演示配置</small></div><button class="toggle" role="switch" aria-label="启用 ${esc(s.name)}" aria-checked="${s.enabled}" data-schedule-toggle="${s.id}"></button><button class="icon-button" data-schedule-edit="${s.id}" aria-label="编辑 ${esc(s.name)}">${icon('edit')}</button></div>`).join('')+'<div class="note-box">这是定时任务的界面原型,不会在后台真正调度。正式产品需要显示执行位置、运行历史与失败通知。</div>';$('page').innerHTML='<main class="overview-page">'+html+'</main>'}
|
||||||
|
function capabilitiesHTML(){const tabs=[['models','模型服务'],['tools','工具与连接器'],['skills','技能']];let cards='';if(state.capTab==='models')cards=state.models.map(m=>`<article class="connection-card"><header><span class="provider-symbol">${m.name.slice(0,1)}</span><div><h3>${esc(m.name)}</h3><small>${esc(m.provider)} · ${esc(m.protocol)}</small></div></header><p>${esc(m.description)}</p><footer><span>演示配置 · 未连接</span><button data-edit-model="${m.id}">配置 ${icon('chevron')}</button></footer></article>`).join('');if(state.capTab==='tools')cards=state.tools.map(t=>`<article class="connection-card"><header><span class="provider-symbol">${icon(t.icon)}</span><div><h3>${esc(t.name)}</h3><small>${esc(t.kind)}</small></div></header><p>${esc(t.description)}</p><footer><span>${t.enabled?'已在原型中启用':'尚未启用'} · 模拟</span><button class="toggle" role="switch" aria-label="启用 ${esc(t.name)}" aria-checked="${t.enabled}" data-tool-toggle="${t.id}"></button></footer></article>`).join('');if(state.capTab==='skills')cards=state.skills.map(t=>`<article class="connection-card"><header><span class="provider-symbol">${icon('spark')}</span><div><h3>${esc(t.name)}</h3><small>可复用工作方法</small></div></header><p>${esc(t.description)}</p><footer><span>${t.enabled?'已启用':'已停用'}</span><button class="toggle" role="switch" aria-label="启用 ${esc(t.name)}" aria-checked="${t.enabled}" data-skill-toggle="${t.id}"></button></footer></article>`).join('');return overviewHeader('A WORKSPACE THAT GROWS WITH YOU','连接能力,扩展可能。','模型负责理解,工具负责行动,技能决定工作方法。',button(state.capTab==='models'?'add-model':state.capTab==='tools'?'add-tool':'add-skill','添加'+(state.capTab==='models'?'模型':state.capTab==='tools'?'连接':'技能')+' '+icon('plus'),'primary'))+`<div class="filter-tabs">${tabs.map(([id,label])=>`<button class="${id===state.capTab?'active':''}" data-cap-tab="${id}" aria-pressed="${id===state.capTab}">${label}</button>`).join('')}</div><div class="connection-grid">${cards}</div><div class="note-box">可扩展接入不等于所有 API 都能直接互换。模型协议、工具调用、流式事件和结果格式需要适配;本原型展示配置方式,不发起真实请求。</div>`}
|
||||||
|
function openModal(title,body,footer='',eyebrow='WORKSPACE'){const d=$('modal');$('modalTitle').textContent=title;$('modalEyebrow').textContent=eyebrow;$('modalBody').innerHTML=body;$('modalFooter').innerHTML=footer||button('close-modal','完成','primary');if($('permissionMode'))$('permissionMode').value=current().permissionMode||'ask';if($('reasoningEffort'))$('reasoningEffort').value=current().effort||'标准';if($('motionMode'))$('motionMode').value=document.body.classList.contains('reduce-motion')?'reduce':'system';if($('fontScale'))$('fontScale').value=document.body.classList.contains('large-type')?'large':'normal';icons(d);if(!d.open)d.showModal();d.classList.remove('dialog-opening');void d.offsetWidth;d.classList.add('dialog-opening')}
|
||||||
|
function closeModal(){$('modal').close()}
|
||||||
|
function formError(text){let e=$('formError');if(!e){e=document.createElement('p');e.id='formError';e.className='form-error';e.setAttribute('role','alert');$('modalBody').appendChild(e)}e.textContent=text}
|
||||||
|
function openModelForm(id){const m=state.models.find(x=>x.id===id);openModal(m?'配置模型服务':'添加模型服务',`<p class="muted">配置模型的服务地址、协议和能力。密钥通过凭证引用管理,原型不接收真实密钥。</p><label for="providerName">服务名称</label><input id="providerName" value="${esc(m?.provider||'')}" placeholder="例如:团队模型服务"><div class="form-grid"><div><label for="modelName">模型标识</label><input id="modelName" value="${esc(m?.name||'')}" placeholder="provider/model-name"></div><div><label for="protocol">协议</label><select id="protocol"><option>OpenAI 兼容</option><option>Anthropic Messages</option><option>自定义适配器</option></select></div></div><label for="endpoint">API 地址</label><input id="endpoint" type="url" value="${esc(m?.endpoint||'')}" placeholder="https://api.example.com/v1"><label for="credential">凭证引用(仅名称)</label><input id="credential" placeholder="例如:team-model-key,不要填写真实密钥"><label for="effortSupport">推理强度</label><select id="effortSupport"><option value="yes">模型支持调整</option><option value="no">模型不支持调整</option></select><label for="adapter">自定义适配器标识(使用自定义协议时必填)</label><input id="adapter" placeholder="my-provider-adapter">`,button('close-modal','取消','outline')+button('save-model','保存演示配置','primary',`data-id="${esc(id||'')}"`),'MODEL PROVIDER');if(m){$('protocol').value=m.protocol;$('effortSupport').value=m.effort?'yes':'no'}}
|
||||||
|
function saveModel(id){const provider=$('providerName').value.trim(),name=$('modelName').value.trim(),endpoint=$('endpoint').value.trim(),protocol=$('protocol').value;let url;try{url=new URL(endpoint)}catch{}if(!provider||!name||!url||!['https:','http:'].includes(url.protocol)){formError('请填写名称、模型标识和有效的 HTTP / HTTPS 地址。');return}if(protocol==='自定义适配器'&&!$('adapter').value.trim()){formError('请填写适配器标识。');return}const model={id:id||'model-'+sequence++,name,provider,endpoint,protocol,effort:$('effortSupport').value==='yes',description:'自定义服务 · 能力以实际适配器为准'};const i=state.models.findIndex(m=>m.id===id);if(i>=0)state.models[i]=model;else state.models.push(model);closeModal();render();toast('演示配置已保存,未连接真实服务。')}
|
||||||
|
function showSearch(){openModal('搜索工作空间','<input id="searchInput" class="search-field" aria-label="搜索任务" placeholder="搜索任务标题或目标…"><div id="searchResults"></div>','','SEARCH');search('');$('searchInput').focus()}
|
||||||
|
function search(query){$('searchResults').innerHTML=tasks.filter(t=>(t.title+' '+t.goal).toLowerCase().includes(query.toLowerCase())).map(t=>`<button class="search-result" data-task="${t.id}" data-close="true">${icon('files')}<span>${esc(t.title)}</span><small>${statusLabels[t.status]}${t.archived?' · 已归档':''}</small></button>`).join('')||'<p class="muted">没有找到匹配的任务。</p>'}
|
||||||
|
function showNew(){openModal('你想完成什么?','<p class="muted">任务可以是一句简单的问题,也可以是一份需要持续完善的成果。</p><label for="newGoal">目标</label><textarea id="newGoal" rows="4" placeholder="描述你希望得到的结果…"></textarea><label for="newProject">所属项目</label><select id="newProject"><option>'+esc(state.project)+'</option></select>',button('close-modal','取消','outline')+button('create-task','开始工作','primary'),'NEW TASK');$('newGoal').focus()}
|
||||||
|
function createTask(goal){if(!goal.trim()){formError('请先描述你希望完成的工作。');return}const t={id:'task-'+sequence++,title:goal.trim().slice(0,24),summary:'根据你的目标持续推进',status:'idle',kind:'research',model:state.model,version:0,step:0,ticks:0,artifact:false,ask:false,goal:goal.trim(),plan:['梳理目标与可用资料','执行任务并整理结果','检查成果与未解决项'],messages:[{type:'user',text:goal.trim()},{type:'agent',text:'目标已记录。这是交互原型,我会用模拟执行展示任务推进、过程中补充要求,以及成果交付。'}],events:['创建新任务'],revision:''};tasks.unshift(t);closeModal();state.canvas=false;navigate('task',t.id)}
|
||||||
|
function refreshTask(t,{animate=false,scroll=false}={}){renderSidebar();if(state.route!=='task'||state.active!==t.id){if(state.route==='home'||state.route==='inbox')renderPage();return}const transcript=$('transcript'),position=transcript.scrollTop,open=$('taskPlan')?.open,draft=$('messageInput')?.value,focus=document.activeElement?.id,selection=$('messageInput')?.selectionStart;state.drafts[t.id]=draft||state.drafts[t.id]||'';renderTask();if($('taskPlan'))$('taskPlan').open=!!open;$('transcript').scrollTop=scroll?$('transcript').scrollHeight:position;if(focus==='messageInput'){$('messageInput').focus();$('messageInput').setSelectionRange(selection,selection)}if(animate){const el=$('taskTail').firstElementChild;if(el)el.classList.add('event-arrival')}}
|
||||||
|
function begin(t){t.status='running';t.ticks=0;t.ask=false;t.events.push('开始执行本轮工作');refreshTask(t,{animate:true,scroll:true});announce('任务已开始,可以继续补充要求。')}
|
||||||
|
function finish(t){t.status='done';t.step=t.plan.length;t.artifact=true;t.version+=1;t.events.push('本轮成果已整理完成');t.messages.push({type:'agent',text:t.revision?'已按本轮要求更新演示文档,并保留修改记录。你可以在工作区检查这个版本。':'本轮任务已完成,成果保留在工作区。这是示例内容,可继续修改或下载。'});state.notificationCount++;refreshTask(t,{animate:true,scroll:true});toast(t.title+' · 本轮成果已完成');announce('成果已完成')}
|
||||||
|
function runTick(){tasks.filter(t=>t.status==='running').forEach(t=>{t.ticks++;if(t.ticks===2){t.step=Math.min(t.plan.length-1,t.step+1);t.events.push('当前步骤已完成,继续下一项')}if(t.ticks===4){t.artifact=true;if(t.version===0)t.version=1;t.events.push('已生成可检查的工作草稿')}if(t.ticks>=6){finish(t);return}if(state.route==='task'&&state.active===t.id){const label=$('executionLabel'),bar=$('executionProgress'),detail=$('executionDetail');if(label)label.textContent=t.plan[Math.min(t.step,t.plan.length-1)]||'整理任务成果';if(bar)bar.style.width=Math.min(95,20+t.ticks*12)+'%';if(detail)detail.textContent=['','正在整理输入内容','当前步骤完成','正在形成下一部分','草稿已更新,可在右侧查看','正在检查交付内容'][t.ticks];const plan=$('taskPlan');if(plan){const open=plan.open;plan.outerHTML=planHTML(t);$('taskPlan').open=open}if(t.ticks===4||state.canvasTab==='activity')renderCanvas()}})}
|
||||||
|
function submitMessage(text){const t=current();if(!text.trim()&&!(state.attachments[t.id]||[]).length)return;const attach=state.attachments[t.id]||[],body=text.trim()+(attach.length?'\n附件(仅名称):'+attach.join('、'):'');t.messages.push({type:'user',text:body});state.drafts[t.id]='';state.attachments[t.id]=[];if($('messageInput'))$('messageInput').value='';if(t.ask&&t.status==='waiting'){t.choice=text.trim()||'结合附件进一步确认';t.ask=false;t.messages.push({type:'agent',text:'已记录你的方向,接下来会据此推进。'});begin(t)}else if(t.status==='done'){t.revision=text.trim();t.messages.push({type:'agent',text:'收到,我会开始下一轮修改。之前的成果仍然可以查看。',steering:true});begin(t)}else{t.revision=text.trim();t.messages.push({type:'agent',text:t.status==='approval'?'补充已记录。发送授权仍需通过确认按钮决定。':t.status==='blocked'?'补充已记录。连接问题仍需要重新连接或选择本地资料。':t.status==='running'?'补充已接收,已加入本轮任务上下文。':t.status==='paused'?'补充已记录,继续执行后会带上这些要求。':'补充已记录,可以开始执行。',steering:true});t.events.push('接收用户补充要求');refreshTask(t,{scroll:true});const last=$('messages')?.lastElementChild;if(last)last.classList.add('event-arrival')}announce('消息已接收')}
|
||||||
|
function download(t){const text='# '+t.title+'\n\n> Reasonix 界面原型示例,不是实际执行结果。\n\n## 原始目标\n'+t.goal+'\n\n## 本轮方向\n'+(t.choice||'以任务目标为准')+'\n\n## 工作步骤\n'+t.plan.map((p,i)=>(i+1)+'. '+p).join('\n')+'\n\n## 最新补充\n'+(t.revision||'暂无')+'\n\n版本:'+t.version;const url=URL.createObjectURL(new Blob([text],{type:'text/markdown;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download=t.title.replace(/[\\/:*?"<>|]/g,'-')+'.md';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast('示例文档已导出')}
|
||||||
|
function scheduleForm(id){const s=state.schedules.find(x=>x.id===id);openModal(s?'编辑定时任务':'新建定时任务',`<p class="muted">这里只保存本页内的演示配置,不会创建真实后台任务。</p><label for="scheduleName">任务名称</label><input id="scheduleName" value="${esc(s?.name||'')}"><label for="schedulePrompt">工作目标</label><textarea id="schedulePrompt" rows="3">${esc(s?.prompt||'')}</textarea><label for="scheduleCadence">运行时间</label><select id="scheduleCadence"><option>每周一 09:00</option><option>每个工作日 08:30</option><option>每天 18:00</option></select>`,button('close-modal','取消','outline')+button('save-schedule','保存','primary',`data-id="${id||''}"`),'SCHEDULE');if(s)$('scheduleCadence').value=s.cadence}
|
||||||
|
const actions={
|
||||||
|
'home':()=>navigate('home'),'new':showNew,'search':showSearch,'close-modal':closeModal,'create-task':()=>createTask($('newGoal').value),
|
||||||
|
'sidebar':()=>{if(innerWidth<=650)$('studio').classList.toggle('mobile-rail');else $('studio').classList.toggle('rail-closed')},
|
||||||
|
'toggle-canvas':()=>{state.canvas=!state.canvas;document.querySelector('.task-workspace').classList.toggle('canvas-closed',!state.canvas);if(state.canvas)document.querySelector('.canvas').classList.add('panel-opening')},
|
||||||
|
'open-artifact':()=>{state.canvas=true;state.canvasTab='preview';document.querySelector('.task-workspace').classList.remove('canvas-closed');renderCanvas();document.querySelector('.canvas').classList.add('panel-opening')},
|
||||||
|
'answer':()=>{const t=current();if(!t.ask)return;const free=$('askFree').value.trim();t.choice=free||['品牌认知','新品转化','两者兼顾'][state.askChoices[t.id]??0];t.messages.push({type:'user',text:t.choice},{type:'agent',text:'已确认:'+t.choice+'。我会据此补充发布节奏与渠道安排。'});t.events.push('用户确认目标:'+t.choice);begin(t)},
|
||||||
|
'start':()=>begin(current()),'pause':()=>{const t=current();t.status='paused';t.events.push('用户暂停执行');refreshTask(t,{animate:true});announce('任务已暂停')},'resume':()=>{const t=current();t.status='running';t.events.push('恢复执行');refreshTask(t,{animate:true})},
|
||||||
|
'approve':()=>{const t=current();t.permission='allowed';t.status='done';t.step=t.plan.length;t.messages.push({type:'agent',text:'已确认发送操作。本原型没有实际发送任何邮件,草稿仍保留在工作区。'});t.events.push('发送授权已确认(未实际发送)');refreshTask(t,{animate:true,scroll:true})},
|
||||||
|
'deny':()=>{const t=current();t.permission='denied';t.status='done';t.step=2;t.plan=['梳理合作背景','起草邮件'];t.messages.push({type:'agent',text:'已取消发送,保留邮件草稿。需要时可以继续修改。'});t.events.push('用户选择仅保留草稿');refreshTask(t,{animate:true,scroll:true})},
|
||||||
|
'skip-connection':()=>{const t=current();t.events.push('用户选择使用本地资料');t.messages.push({type:'agent',text:'接下来只使用已有的本地资料,并在结果中注明缺少远程更新。'});begin(t)},
|
||||||
|
'reconnect':()=>openModal('重新连接知识库','<p class="muted">正式产品将在服务商页面完成登录,并返回连接结果。此处只演示恢复成功后的任务继续。</p>',button('close-modal','稍后处理','outline')+button('simulate-reconnect','模拟重新连接成功','primary'),'RECONNECT'),
|
||||||
|
'simulate-reconnect':()=>{closeModal();current().events.push('知识库连接已恢复(模拟)');begin(current())},
|
||||||
|
'edit-plan':()=>openModal('调整执行计划','<p class="muted">每行一个步骤。保存后重置步骤完成标记,避免旧进度被错误套用。</p><label for="planEditor">任务步骤</label><textarea id="planEditor" rows="6">'+esc(current().plan.join('\n'))+'</textarea>',button('close-modal','取消','outline')+button('save-plan','保存计划','primary'),'TASK PLAN'),
|
||||||
|
'save-plan':()=>{const lines=$('planEditor').value.split('\n').map(x=>x.trim()).filter(Boolean);if(!lines.length){formError('至少保留一个步骤。');return}const t=current();t.plan=lines;t.step=0;t.events.push('用户调整了执行计划');closeModal();refreshTask(t);toast('计划已更新,完成标记已重置')},
|
||||||
|
'edit-artifact':()=>{if(!current().artifact){toast('成果尚未生成,可以先在对话中补充要求。');return}openModal('这份成果,你想怎样调整?','<label for="revisionInput">修改要求</label><textarea id="revisionInput" rows="4" placeholder="例如:把第二部分写得更具体,保留整体结构。"></textarea>',button('close-modal','取消','outline')+button('save-revision','开始修改','primary'),'REFINE OUTPUT')},
|
||||||
|
'save-revision':()=>{const value=$('revisionInput').value.trim();if(!value){formError('请写下需要修改的内容。');return}closeModal();const t=current();t.permission=t.permission==='pending'?'denied':t.permission;t.revision=value;t.messages.push({type:'user',text:value},{type:'agent',text:'修改要求已接收。将更新这份成果,修改要求会保留在成果中;不会执行任何外部发送操作。'});begin(t)},
|
||||||
|
'download':()=>download(current()),'versions':()=>openModal('成果版本','<p class="muted">当前版本 v'+current().version+'。此原型展示版本标签与迭代交互,尚未实现历史内容快照。</p>'),
|
||||||
|
'attach':()=>$('attachmentInput').click(),
|
||||||
|
'context':()=>openModal('这段任务使用的上下文','<p class="muted">项目资料在任务间共享,每段对话保留各自的目标与历史。</p><div class="note-box">项目:'+esc(state.project)+'<br>示例资料:品牌手册.md、新品介绍.md<br>工作约定:优先清晰、具体、可核查的表达。</div><p class="muted" style="margin-top:16px">附件在本原型中只显示文件名,不读取文件内容。</p>','','TASK CONTEXT'),
|
||||||
|
'model':()=>openModal('选择当前任务的模型',state.models.map(m=>`<button class="model-option ${m.id===current().model?'selected':''}" data-select-model="${m.id}"><span class="provider-symbol">${icon('spark')}</span><span><strong>${esc(m.name)}</strong><small>${esc(m.description)}</small></span>${m.id===current().model?icon('check'):''}</button>`).join('')+'<label for="reasoningEffort">推理强度</label><select id="reasoningEffort" '+(!state.models.find(m=>m.id===current().model)?.effort?'disabled':'')+'><option>标准</option><option>深入</option></select><p class="muted" style="margin-top:10px">仅在模型声明支持时显示可调整的能力。这里不会实际调用模型。</p>',button('close-modal','取消','outline')+button('apply-effort','完成','primary'),'MODEL'),
|
||||||
|
'apply-effort':()=>{current().effort=$('reasoningEffort').value;closeModal();refreshTask(current())},
|
||||||
|
'permission':()=>openModal('当前任务的权限','<p class="muted">普通问题与操作授权分别处理。权限决定执行边界,不改变模型。</p><label for="permissionMode">授权方式</label><select id="permissionMode"><option value="ask">必要时询问:重要操作由我确认</option><option value="auto">自动审批:演示配置,不实际执行</option></select><div class="note-box">当前示例范围:项目资料。任何真实的发送、发布或外部修改均未接入。</div>',button('close-modal','取消','outline')+button('save-permission','应用到当前会话','primary'),'PERMISSIONS'),
|
||||||
|
'save-permission':()=>{current().permissionMode=$('permissionMode').value;closeModal();refreshTask(current());toast('演示权限设置已更新')},
|
||||||
|
'task-menu':()=>openModal('任务操作','<label for="renameTask">任务名称</label><input id="renameTask" value="'+esc(current().title)+'"><div style="display:flex;gap:10px;margin-top:20px">'+button('archive-task',icon('archive')+' 归档任务','outline')+'</div>',button('close-modal','取消','outline')+button('rename-task','保存名称','primary'),'TASK'),
|
||||||
|
'rename-task':()=>{const value=$('renameTask').value.trim();if(!value){formError('任务名称不能为空。');return}current().title=value;closeModal();render()},
|
||||||
|
'archive-task':()=>{if(current().status==='running'){formError('请先暂停正在运行的任务,再归档。');return}current().archived=true;closeModal();navigate('home');toast('任务已归档,仍可通过搜索找到')},
|
||||||
|
'project-info':()=>openModal(state.project,'<p class="muted">项目连接相关任务与共享资料。每个任务有独立的对话、执行状态和成果。</p><div class="note-box">资料:品牌手册.md、新品介绍.md<br>项目约定:语气清晰克制,结论保留依据。<br>运行位置:本地交互原型。</div>','','PROJECT'),
|
||||||
|
'project':()=>openModal('设置项目名称','<label for="projectName">项目名称</label><input id="projectName" value="'+esc(state.project)+'"><p class="muted" style="margin-top:12px">此原型只有一个项目容器,可修改名称体验导航更新。</p>',button('close-modal','取消','outline')+button('save-project','保存','primary'),'PROJECT'),
|
||||||
|
'save-project':()=>{const value=$('projectName').value.trim();if(!value){formError('项目名称不能为空。');return}state.project=value;closeModal();render()},
|
||||||
|
'notifications':()=>{state.notificationCount=0;renderSidebar();navigate('inbox')},
|
||||||
|
'add-model':()=>openModelForm(),'save-model':el=>saveModel(el.dataset.id),
|
||||||
|
'add-tool':()=>openModal('添加外部能力','<p class="muted">用 MCP 或 HTTP 适配器连接外部服务。工具能力与模型服务分别配置。</p><label for="toolName">能力名称</label><input id="toolName" placeholder="例如:团队知识库"><label for="toolProtocol">接入方式</label><select id="toolProtocol"><option>MCP 连接器</option><option>HTTP API</option></select><label for="toolEndpoint">服务地址</label><input id="toolEndpoint" placeholder="https://example.com/mcp"><label for="toolScope">能力说明与访问范围</label><input id="toolScope" placeholder="例如:只读取产品知识库">',button('close-modal','取消','outline')+button('save-tool','添加演示连接','primary'),'TOOLS'),
|
||||||
|
'save-tool':()=>{const name=$('toolName').value.trim(),scope=$('toolScope').value.trim();let url;try{url=new URL($('toolEndpoint').value)}catch{}if(!name||!scope||!url||!['http:','https:'].includes(url.protocol)){formError('请填写名称、访问范围和有效服务地址。');return}state.tools.push({id:'tool-'+sequence++,name,description:scope,kind:$('toolProtocol').value,icon:'link',enabled:false});closeModal();render();toast('已添加配置,尚未连接真实服务')},
|
||||||
|
'add-skill':()=>openModal('创建工作技能','<label for="skillName">技能名称</label><input id="skillName"><label for="skillDescription">工作方法</label><textarea id="skillDescription" rows="4" placeholder="描述这类任务应该怎样完成…"></textarea>',button('close-modal','取消','outline')+button('save-skill','保存技能','primary'),'SKILL'),
|
||||||
|
'save-skill':()=>{const name=$('skillName').value.trim(),description=$('skillDescription').value.trim();if(!name||!description){formError('请填写技能名称和工作方法。');return}state.skills.push({id:'skill-'+sequence++,name,description,enabled:true});closeModal();render()},
|
||||||
|
'add-schedule':()=>scheduleForm(),'save-schedule':el=>{const name=$('scheduleName').value.trim(),prompt=$('schedulePrompt').value.trim();if(!name||!prompt){formError('请填写任务名称和目标。');return}const existing=state.schedules.find(x=>x.id===el.dataset.id),next={id:existing?.id||'schedule-'+sequence++,name,prompt,cadence:$('scheduleCadence').value,enabled:existing?.enabled??true};if(existing)Object.assign(existing,next);else state.schedules.push(next);closeModal();render();toast('演示日程已保存,不会在后台实际运行')},
|
||||||
|
'settings':()=>openModal('舒适地工作','<p class="muted">为长时间阅读设计的浅色工作区。动画只表达操作和状态变化。</p><label for="motionMode">动画效果</label><select id="motionMode"><option value="system">跟随系统偏好</option><option value="reduce">减少动态效果</option></select><label for="fontScale">文字大小</label><select id="fontScale"><option value="normal">标准</option><option value="large">舒适 · 较大文字</option></select>',button('close-modal','取消','outline')+button('save-settings','应用','primary'),'APPEARANCE'),
|
||||||
|
'save-settings':()=>{document.body.classList.toggle('reduce-motion',$('motionMode').value==='reduce');document.body.classList.toggle('large-type',$('fontScale').value==='large');closeModal();toast('界面偏好已应用')}
|
||||||
|
};
|
||||||
|
document.addEventListener('click',e=>{const action=e.target.closest('[data-action]');if(action){e.preventDefault();actions[action.dataset.action]?.(action);return}const task=e.target.closest('[data-task]');if(task){if(task.dataset.close)closeModal();state.canvas=innerWidth>960&&!!tasks.find(t=>t.id===task.dataset.task).artifact;state.canvasTab='preview';navigate('task',task.dataset.task);return}const route=e.target.closest('[data-route]');if(route){navigate(route.dataset.route);return}const choice=e.target.closest('[data-choice]');if(choice){state.askChoices[current().id]=Number(choice.dataset.choice);document.querySelectorAll('.ask-option').forEach(b=>{const selected=b===choice;b.classList.toggle('selected',selected);b.setAttribute('aria-pressed',String(selected));b.classList.remove('picked')});choice.classList.add('picked');return}const tab=e.target.closest('[data-canvas-tab]');if(tab){state.canvasTab=tab.dataset.canvasTab;renderCanvas();return}const cap=e.target.closest('[data-cap-tab]');if(cap){state.capTab=cap.dataset.capTab;renderPage();return}const model=e.target.closest('[data-select-model]');if(model){current().model=model.dataset.selectModel;state.model=model.dataset.selectModel;closeModal();refreshTask(current());toast('模型已切换(演示)');return}const editModel=e.target.closest('[data-edit-model]');if(editModel){openModelForm(editModel.dataset.editModel);return}for(const [key,list] of [['toolToggle',state.tools],['skillToggle',state.skills],['scheduleToggle',state.schedules]]){const attr=key.replace(/[A-Z]/g,c=>'-'+c.toLowerCase()),toggle=e.target.closest('[data-'+attr+']');if(toggle){const item=list.find(x=>x.id===toggle.dataset[key]);item.enabled=!item.enabled;renderPage();return}}const remove=e.target.closest('[data-remove-file]');if(remove){state.attachments[current().id].splice(Number(remove.dataset.removeFile),1);refreshTask(current());return}const schedule=e.target.closest('[data-schedule-edit]');if(schedule){scheduleForm(schedule.dataset.scheduleEdit);return}const source=e.target.closest('[data-source]');if(source)openModal(Number(source.dataset.source)===0?'品牌手册.md':'新品介绍.md','<p class="muted">项目输入文件示例。正式版本在此预览实际文件内容,本原型没有读取你的本地资料。</p>');});
|
||||||
|
document.addEventListener('submit',e=>{if(e.target.id==='composer'){e.preventDefault();submitMessage($('messageInput').value)}if(e.target.id==='homeComposer'){e.preventDefault();if(!$('homePrompt').value.trim()){toast('先写下你想完成的事。');return}createTask($('homePrompt').value)}});
|
||||||
|
document.addEventListener('input',e=>{if(e.target.id==='messageInput'){state.drafts[current().id]=e.target.value;e.target.style.height='auto';e.target.style.height=Math.min(140,e.target.scrollHeight)+'px'}if(e.target.id==='askFree')current().askDraft=e.target.value;if(e.target.id==='searchInput')search(e.target.value)});
|
||||||
|
document.addEventListener('keydown',e=>{if(e.isComposing)return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='k'){e.preventDefault();showSearch()}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='n'){e.preventDefault();showNew()}if(e.target.id==='messageInput'&&e.key==='Enter'&&!e.shiftKey){e.preventDefault();$('composer').requestSubmit()}if(e.key==='Escape')$('studio').classList.remove('mobile-rail');if(e.target.hasAttribute('data-canvas-tab')&&['ArrowLeft','ArrowRight'].includes(e.key)){e.preventDefault();const tabs=['preview','files','activity'],next=(tabs.indexOf(state.canvasTab)+(e.key==='ArrowRight'?1:2))%3;state.canvasTab=tabs[next];renderCanvas();$('canvas-tab-'+state.canvasTab).focus()}});
|
||||||
|
$('attachmentInput').addEventListener('change',e=>{const id=current().id;state.attachments[id]=[...(state.attachments[id]||[]),...Array.from(e.target.files).map(f=>f.name)];e.target.value='';refreshTask(current());toast('已添加附件名称;原型不读取文件内容。')});
|
||||||
|
state.canvas=innerWidth>960;
|
||||||
|
icons();render();clockTimer=setInterval(runTick,1700);
|
||||||
|
window.addEventListener('pagehide',()=>clearInterval(clockTimer));
|
||||||
|
})();
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light"><title>Reasonix — 工作,由此展开</title>
|
||||||
|
<link rel="stylesheet" href="workspace.css">
|
||||||
|
<link rel="stylesheet" href="refinement.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="studio" id="studio">
|
||||||
|
<aside class="sidebar" aria-label="工作空间导航">
|
||||||
|
<button class="wordmark" data-action="home"><span class="brand-symbol">r<span></span></span><span>Reasonix<small>PERSONAL WORKSPACE</small></span></button>
|
||||||
|
<button class="new-task" data-action="new"><i data-icon="plus"></i> 新任务 <kbd>Ctrl N</kbd></button>
|
||||||
|
<button class="nav-item" data-action="search"><i data-icon="search"></i> 搜索工作空间 <kbd>⌘ K</kbd></button>
|
||||||
|
<div class="nav-group">
|
||||||
|
<button class="nav-item" data-route="home"><i data-icon="grid"></i> 工作台</button>
|
||||||
|
<button class="nav-item" data-route="inbox"><i data-icon="inbox"></i> 待我处理 <span class="count" id="attentionCount">2</span></button>
|
||||||
|
<button class="nav-item" data-route="library"><i data-icon="files"></i> 我的成果</button>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-label">项目 <button aria-label="创建项目" data-action="project"><i data-icon="plus"></i></button></div>
|
||||||
|
<button class="project-nav" data-action="project-info"><span class="project-avatar">A</span><span>Atelier 品牌工作室<small>策略 · 产品 · 内容</small></span><i data-icon="chevron"></i></button>
|
||||||
|
<div class="sidebar-label">最近任务 <span id="taskCount"></span></div>
|
||||||
|
<div class="task-list" id="taskList"></div>
|
||||||
|
<div class="sidebar-bottom">
|
||||||
|
<button class="nav-item" data-route="automation"><i data-icon="clock"></i> 定时任务</button>
|
||||||
|
<button class="nav-item" data-route="capabilities"><i data-icon="puzzle"></i> 能力与连接</button>
|
||||||
|
<div class="profile"><span class="profile-avatar">L</span><span>我的工作空间<small><span class="dot green"></span> 本地交互原型</small></span><button class="icon-button" data-action="settings" aria-label="界面设置"><i data-icon="settings"></i></button></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="body-shell">
|
||||||
|
<header class="topbar"><div class="breadcrumb"><button class="icon-button" data-action="sidebar" aria-label="切换导航"><i data-icon="panel"></i></button><span>工作空间</span><span class="slash">/</span><strong id="pageTitle">秋季新品发布策略</strong></div><div class="top-actions"><span class="prototype-label">DESIGN PROTOTYPE</span><button class="icon-button" data-action="search" aria-label="搜索"><i data-icon="search"></i></button><button class="icon-button" data-action="notifications" aria-label="通知"><i data-icon="bell"></i><b id="notificationDot"></b></button><span class="top-avatar">L</span></div></header>
|
||||||
|
<section id="page" class="page"></section>
|
||||||
|
<footer class="statusbar"><span><i class="dot green"></i> Reasonix Studio <span class="statusbar-muted">/ 概念交互版</span></span><span id="bottomStatus">模型与工具调用均为模拟,不访问真实服务</span><span class="statusbar-muted">所有交互仅在本次页面中保留</span></footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dialog id="modal" class="modal"><header><div><span class="eyebrow" id="modalEyebrow">WORKSPACE</span><h2 id="modalTitle"></h2></div><button class="icon-button" data-action="close-modal" aria-label="关闭弹窗"><i data-icon="close"></i></button></header><div id="modalBody" class="modal-body"></div><footer id="modalFooter"></footer></dialog>
|
||||||
|
<input type="file" id="attachmentInput" hidden multiple>
|
||||||
|
<div class="toast" id="toast" role="status" hidden></div>
|
||||||
|
<div id="announcer" class="sr-only" aria-live="polite"></div>
|
||||||
|
<script src="workspace.js"></script>
|
||||||
|
<script src="motion.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
(()=>{
|
||||||
|
'use strict';
|
||||||
|
const reduced=()=>document.body.classList.contains('reduce-motion')||window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const frames=new Set();
|
||||||
|
function animate(el,keyframes,options={}){
|
||||||
|
if(!el||reduced()||typeof el.animate!=='function')return;
|
||||||
|
const animation=el.animate(keyframes,{duration:360,easing:'cubic-bezier(.2,.8,.2,1)',...options});
|
||||||
|
frames.add(animation);animation.finished.then(()=>frames.delete(animation)).catch(()=>frames.delete(animation));
|
||||||
|
return animation;
|
||||||
|
}
|
||||||
|
function revealPage(){
|
||||||
|
const elements=document.querySelectorAll('.conversation-title,.conversation-description,.overview-header,.home-composer,.task-tile,.connection-card,.schedule-row');
|
||||||
|
elements.forEach((el,i)=>animate(el,[{opacity:.15,transform:'translateY(9px)'},{opacity:1,transform:'translateY(0)'}],{duration:420,delay:Math.min(i*32,150)}));
|
||||||
|
}
|
||||||
|
function marker(){
|
||||||
|
const tabs=document.querySelector('.canvas-tabs');if(!tabs)return;
|
||||||
|
const active=tabs.querySelector('[aria-selected="true"]');if(!active)return;
|
||||||
|
let line=tabs.querySelector('.tab-marker');if(!line){line=document.createElement('span');line.className='tab-marker';line.setAttribute('aria-hidden','true');tabs.appendChild(line);tabs.classList.add('has-marker')}
|
||||||
|
line.style.left=active.offsetLeft+'px';line.style.width=active.offsetWidth+'px';
|
||||||
|
}
|
||||||
|
function ripple(button,e){
|
||||||
|
if(reduced()||!button.isConnected||!button.animate||button.closest('.canvas-tabs')||button.classList.contains('wordmark'))return;
|
||||||
|
const rect=button.getBoundingClientRect(),r=document.createElement('span');
|
||||||
|
r.className='motion-ripple';r.style.left=(e.clientX?e.clientX-rect.left:rect.width/2)+'px';r.style.top=(e.clientY?e.clientY-rect.top:rect.height/2)+'px';
|
||||||
|
button.classList.add('motion-pressed');button.appendChild(r);
|
||||||
|
const a=animate(r,[{opacity:.8,transform:'translate(-50%,-50%) scale(0)'},{opacity:0,transform:'translate(-50%,-50%) scale('+Math.max(rect.width,rect.height)/5+')'}],{duration:450});
|
||||||
|
const cleanup=()=>{r.remove();button.classList.remove('motion-pressed')};
|
||||||
|
if(a)a.finished.then(cleanup,cleanup);else cleanup();
|
||||||
|
}
|
||||||
|
document.addEventListener('click',e=>{
|
||||||
|
const target=e.target.closest('button');if(!target)return;
|
||||||
|
const route=target.hasAttribute('data-route')||target.hasAttribute('data-task')||['home','create-task'].includes(target.dataset.action);
|
||||||
|
const tab=target.hasAttribute('data-canvas-tab')||target.hasAttribute('data-cap-tab');
|
||||||
|
const canvasOpen=target.dataset.action==='open-artifact';
|
||||||
|
const update=['save-revision','answer','save-plan','approve','deny','pause','resume','simulate-reconnect'].includes(target.dataset.action);
|
||||||
|
requestAnimationFrame(()=>{
|
||||||
|
ripple(target,e);marker();
|
||||||
|
if(route)revealPage();
|
||||||
|
if(tab)animate(document.querySelector(target.hasAttribute('data-canvas-tab')?'#canvasContent':'.connection-grid'),[{opacity:.25,transform:'translateY(6px)'},{opacity:1,transform:'translateY(0)'}],{duration:300});
|
||||||
|
if(canvasOpen)animate(document.querySelector('.paper'),[{opacity:.6,transform:'translateY(10px) scale(.985)'},{opacity:1,transform:'translateY(0) scale(1)'}],{duration:430});
|
||||||
|
if(update)animate(document.querySelector('.status-pill'),[{transform:'scale(.95)',opacity:.5},{transform:'scale(1)',opacity:1}],{duration:270});
|
||||||
|
});
|
||||||
|
},true);
|
||||||
|
document.addEventListener('submit',e=>{
|
||||||
|
if(e.target.id!=='composer')return;
|
||||||
|
requestAnimationFrame(()=>{
|
||||||
|
const last=document.querySelector('#messages .message:last-child');
|
||||||
|
animate(last,[{opacity:0,transform:'translateY(12px)'},{opacity:1,transform:'translateY(0)'}],{duration:400});
|
||||||
|
animate(document.querySelector('.composer'),[{boxShadow:'0 0 0 3px #b9cbe630'},{boxShadow:'0 0 0 0 #b9cbe600'}],{duration:500});
|
||||||
|
marker();
|
||||||
|
});
|
||||||
|
},true);
|
||||||
|
const modal=document.getElementById('modal');
|
||||||
|
if(modal)new MutationObserver(()=>{
|
||||||
|
if(modal.open)animate(modal.querySelector('.modal-body'),[{opacity:0,transform:'translateY(5px)'},{opacity:1,transform:'translateY(0)'}],{duration:280,delay:50});
|
||||||
|
}).observe(modal,{attributes:true,attributeFilter:['open']});
|
||||||
|
let version='';
|
||||||
|
const page=document.getElementById('page');
|
||||||
|
if(page)new MutationObserver(()=>{
|
||||||
|
const paper=page.querySelector('.paper');if(!paper)return;
|
||||||
|
const next=paper.querySelector('.paper-meta')?.textContent||'';
|
||||||
|
const identity=page.querySelector('.conversation-title')?.textContent||'';
|
||||||
|
const key=identity+'|'+next;
|
||||||
|
if(version&&version.split('|')[0]===identity&&version!==key){
|
||||||
|
animate(paper,[{boxShadow:'0 0 0 2px #b1c9e550,0 8px 25px #31547c0a'},{boxShadow:'0 0 0 0 #b1c9e500,0 8px 25px #31547c0a'}],{duration:850});
|
||||||
|
}
|
||||||
|
version=key;
|
||||||
|
}).observe(page,{childList:true,subtree:true});
|
||||||
|
new MutationObserver(()=>{if(reduced()){frames.forEach(a=>a.cancel());frames.clear()}}).observe(document.body,{attributes:true,attributeFilter:['class']});
|
||||||
|
window.addEventListener('resize',marker);
|
||||||
|
window.addEventListener('pagehide',()=>frames.forEach(a=>a.cancel()));
|
||||||
|
marker();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light"><title>Reasonix — 工作,由此展开</title>
|
||||||
|
<style>
|
||||||
|
@font-face{font-family:Studio;src:url('data:font/woff2;base64,d09GMgABAAAABV/wABMAAAANdbgABV91AAQAQgAAAAAAAAAAAAAAAAAAAAAAAAAAGsECG4mzChyBvlY/SFZBUtdnP01WQVKBKAZgP1NUQVSBXCc0AIHKLC+BNgqIy2iHsAQwm+8sATYCJAPbZAvbaAAEIAWaTAeCgAVbofjchl/SO317thTwxO+OOiix2RMp3YDazSKKy7TMhabXn38/nv9v/XdhgyrVmam6DL54ZLP3d4ILFSo1keBg13D8lIp3p2OMcrABqFZ1/2Y2lMrY/dgqwAAMrRZF7RreCqm8GhJI//////////////////////9/y8mPp9zqVXVS9X9fyd25DtKQhPMSCLegCKIojsCgMDrqjse4u0SoGmtdzw/CVDqTzeULRUReqWyptRUYaoyiSmslQ+qkYYghOWgWpaiApq1WvZpvW210GFV0x6gxrEjCejnE4zrRH+SKxeK4qXnDyeLU9DDyfU5ndJZ27Zy1VAxEySTMvDIiC+VFKQuKDWuYpYlWWyuN5RUP81DoqmBtPYShZsOENi3IIpbNJ2LLRbgab1X6QrfFI55gZ5TatZPNvX07l1b3AG7NI5L1rbFGVuvWPcz5vhSIl+lnhPSWQ+wL2T0KKuF+sTPwk+841Y72npITe1pC3sDyY5I0z5h1UkAQdLuH5HnSFZ5P0UZdj48sCTFrM/7Zi04c++cHFy+lm/UuF4pondQ8OkJuycRYRhCgh6K8ElxkLDYFPVkWGFEIWRISSD9WIguamJyg/TpzddQ2O6FnWuHIVt9U3yIcCJRKINA2K94G7KJYuFxJa6RT9yyso4PRNQlmFhhpKilIJBgsdnv2HTEelibcPxaVeAIfWcWmIAWj9r097W+RaeIPPWKMNe4wTVxXT3NF0iHm/SGzB62yhVLJFGBgJTtY1cgtZ8rM68JOEEJgYFQrQo2UxBMYH4HAwFRd1GFhiwit2jk/Rdptvbk9u6vSUwnWlC/jHVn40yKkunZ2xWyW9xPVvS98YD6e3OkD3RuhJdiAkI4wV0HAXD4aw4o4VnZbOI9bxF9qQmU08R8zbYxP42h9NnU5t6b9GcwyAp2qU+P8ufBnCWiwr0pItQPqOjgWZcb5bgIa7Kcec3BszAkuibXWokeNYUVYOXXYxvGaQG/XtefuPuvDQ9Co5wVFN+2SZSza2ZuA1Fw3IDlswjCfsf+Ws+ijHNGt1FzUIoGbfGOzm9jKzODN3iTXj5vK/Z3hBII2BCdGuYaqF6PcJRFdQr2MotDrkMnDVShzYeWc5hrWRkzVGBLhy6O1tMEUmJu8OwVRfD2X3Y3dYtuv+r4/apKZdVaEG5SU/UucDl6megtPiM1fn258IwN6LfMQhP+5R+ubizNkg33PRRu7/1hYBHjOKJ4xO9tDJpb9tG55WKsaA4Hs3WwRpTcBegIxZjogAnekUBPmSDbVd/V9i+k4bt9y+srZmYPQeSa7yFeE3EOgwt2TW7LasMzNjk/7InOoO8k9LeOa2WYuuWuIgycOZxNXdhYmf9qxJuEwI87WL5zvJc4/4IUJiTBCJEmezPXrqGIkf04pyTVwMT1uXIvc4LGzQDrfA/QFGaTZiYUiaUPIPRr0En+w8WodMndo4Sbhjwf+0OF3J0fJYNmfDleT6IrWyFj5tAKJIJXowOAACxk8wR02Yz+Q3mTk7a94zCaiZ6SspG/JVt5RD/FSgrKJ/9UVfozrsBviQBLdJoNDrERCYyFxk6iQUbCOB8GR76VFIBJnmlyWVLJKFmQls+rCfm/VVnFAvqVqo23821lpcr9C5eS1cgrhC8whHqfZHslgvdqIoTbyLOIYxgrOJrqkQMxin0lBJ7hWvoxWQWFIduSqwdCQr4Ivcj9Umrvza/WV6dD1pHZaUlddNwiqSNuvQauZrXuoeTPErZlcA+W8pjKXpSZSL09hzZzi6Q5840/onEHFkFeQfsp7mdlDOEfc0+v4A6l0ixgU6F6ZHq21u6VhpvrihefPHLjFva+BcpJlxXC/VZ1/drTHNh2eJZHBEIUTmGJcK04X4TFHVBIQsSKssN/T3N+L2qov5Qwugh/vyrPEddFmmlpgxflv9njgEVcgeI2Y/jTsfyVpXCpfyIfH1ZVZOoD7bp4x08rFN8nwiXuFc3o94/x/ESuJvOHnrcPPLmuTym8kxiY8Pp/ADxmgRVeWMthdkEJh2aNBwj6K0xb/bX1qd2Xt/GNqjhf+lp93PsGWN67Ss+dYLz7d8gKYtqZKerBrSm+9Gp56+uy1m4rSfv6l68lh5KEcl2KMlAxzF8Ph1O0VWsXCctWgU/fS6eXo4YAPhB0XRvFcHe/z/592MS0k3+hclBifn+Ul8Z/Y9O9KpdKrED/5RuzbPbwR/EqI4feT4SYacxYmTXZlfnbC6KI6UCKQlkIhZF7Yf8SJ9ZR+/hyG4XiEN+NsKGxakm6bPlBxEkmeCq+YEfNI8lQTY23GqU1kgUhiTpk2Gnc/63HwUESY+ElenA8Gi+QDuUvm9BeHyntJ9BUe9y+rMp0k8wf8x5B77d9VtdVmYQAEwnBEJE1lVCqVypwWRMOtI1a0OdFPNHZGeL5cMY64c+NuB2YFPZ5psG4pZ8QLdXuX+iHzZfAL+x9vhO+Jltqr+r97ZnZlluXESiKH4HwESOYAH6Kds0670/A8z1/r3D+IKyuRi2pVCwrz4P8/W1Xd983M3QMzK7Ogm2MIlkjqiFxmaY+OgmovzAeApQagGkRwSGuQWlrJvp8wlgkNKmNlBZh0eN5u7/7dxlnnjNmyUimyMuaxszJ2SJSVjLGLjHlWVkNCmihjZ5zZEkLWDuGGYG7dRvQaloxVsbFRI+NBFJGwCm3eajC+ysTmMQmJlB5RPWKj0pHCAM2tMxBFVESkS9gGG4sOVkGPbYwVA0a2EoIIBqhYifH6+qnv+360+u13D9Dcum1UCComPipWvPrGhw7B6H/1q32//bQ/FlWsihXbGBuwIiokSgQBURCpDU9LtX8rNsQyxXEIm6ZNmqYcaGUHWj7q3e/1gHo9wt8D0mqUpMBJ24Qdx3EcxyiDZOFqtYQzswP4hnhZuytpNTza5vOSXC6XW5MDjmNMWSKIC0RFbd21rVptdbd2a+uuo932r9W9rLX1t46FiIooW8a8MYE7bqwkl+T5/71Tu5ICm7HDEyClZRXIBXKXlWUs4ffSJzB4GbLRgk/qAqlAU3DrToDGTuNNXlji+Y/7dv95Kp90a7GydZnJysoC8cB/f9C3nvbNJne+MItySEVQBIHwGp7v/17ePff9e+b9DpZKsXgAMKIRCkGpmBWt1Obnac7+z713NKIEvFgF2lJdffv3dRJo93WffFObOxMIYhY0BEmQhIRg2jql1HS3u13xrg3QdHZ3Oc9FXBvzpi1VpZgOGDLE2wYbE5Of67PnmbE9c8aw8UOl1NI2N0/b4pu/UXWLPkX0dd99371jRgWkpUtKQBBadJzIjax33w4AKvh7Nre/4CEnmOaZpBBoSP2W67cnPHc90T3/f0IbrsV9EO0itr3+zAkttqdJKDpJwWeOZB6+/WIlmW+Dlf2zWKGf/UKL/kBT9d85xHYcM8kg2RbgsU50OgGiBWQ5irLuA0MHTB13HZd+gX5aXjQwz9En3NSGePSwHC491ZLqhs3ES7tdpQRi85iHAm9/YrKRRE0ondVfvQs8U/5NroGx3zm7qoMPNMfJA5SpCoXkgUUsugv24R+q879bne6O8Amu/oxX3lmpGS8lJw/KF12HaHL/23HgFlAAGKqLUCn+wY5HVvKw0pkX+9PD5SyCILqw0P+c6OZs3sHgIOTnt2Y1HZu3M2a/Nq2XQmm2gUl+P9xHlHrslyvyA4ZYY3C+MdnZQ6c2rV6/8XXKTy6Y4EazU/ohnSXZQkdUaNCCDTEcvK1jHa3Pf6LZ+j9XqqrTku4kk5mBEUWewFNhePJVlOrKALv71XSfChZWBRgkUuU/z1/6v84+0NVcjIKZidRUSVrnUV9SigdB/SreRxv4or3970z17enq3f05yUy8jxAtEo0K0SNRUSHRKI/QCIeMwk4XDAgk5J9/DvXnJPflREOjEyu0wEpToFSDGpdKBStYefL/fOux300yZ1e6xxn0U3z+TrKwAuERHqFQemp4Oq2fdvcI9z7FZntrlWkw0VBvhICTpGC6wp13PYPn/v+/9WRcYyTJGEmSjHa+PMd1PMf1PEmeJ/mSpJ2RJEmSZIx8yUiSkaSdJO0k7STJGEmSJEmS58s3kqSdJEmSJGnv8anNb3YYR5zlI0zH9wbE+UiRwjgepYawxlLXuFPjv1jTZQnbT8zUpaTfXn5ib39PP0sJn6V49LgNMfa8/Mbe9iIW6WX8l/Ffrh+PuIReMB0snRmKqGwWhtHjMg4gtHt6kCN/8fONv/h64/XGGx8vfZp4DY/gWzTW5vZuZ1/6x8dvEM/aLWRiIJulpJohUSo+37tlM1GkogJbV0eSpS1tJkH6oEbl70SMishqe3dMmCDrWckQbLMzY9qbM2bmnE6dCUYT2oggimKgICIqopQ2WIkF1pw9dTM2XZWrcpW//fr3v7dIl/9Vj1ZafyBO8SgCoJAwvstd05LXh5BdSulFgBkg+D9g7wpmuuD/4f/Kfu2qZJKRXd2ROMRN50Ff9Bc1JF+fBc/n3r68SdJ7nX/ITjEgM3MXT8jwqtU7ttmWOQoJGSDXshmd/gHIgQyVTTbZXD6fq6lEGhDopwiLcKTUTKCAefdrkyEk/ET73Penqvv6eGxCIJiuKvB895tr3x7grZnDc9RC4jN+RVvK1KSxwGaTgdwVmF0VsZAkLEtWrXFoZIUiV5YVivD/dnOajhRvxbFeiv949/ExeSYihcWwAPV4YQtw/X9t3/4mq5pVmb9qRbPp17Ggg94DFck5W4KnaJAsYlpSib/BrBxKG8R+npbq/7mXB3CBBCtZlLine truncated
|
||||||
|
:root{--bg:#f8f9f7;--paper:#fff;--rail:#f0f2ee;--line:#e4e8e1;--ink:#26352d;--muted:#778279;--faint:#98a197;--green:#37634e;--green-light:#edf4ed;--amber:#967234;--amber-light:#fbf6e9;--red:#a4544d;--blue:#526e91;--shadow:0 8px 32px #283c2c09;--ease:cubic-bezier(.2,.75,.2,1);font-family:Studio,Inter,"Segoe UI","Microsoft YaHei",sans-serif;color:var(--ink);font-size:13px;background:var(--bg)}
|
||||||
|
*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer;border:0;background:none;padding:8px 12px;border-radius:7px;transition:background .18s,color .18s,box-shadow .18s,transform .18s}button:hover{background:#e9eee7}button:active{transform:translateY(1px)}button:disabled{opacity:.45;cursor:default;transform:none}a{color:var(--green)}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid #70997c;outline-offset:3px}svg{width:18px;height:18px;stroke:currentColor;stroke-width:1.65;stroke-linecap:round;stroke-linejoin:round;fill:none;flex-shrink:0;vertical-align:middle}input,select,textarea{background:var(--paper);border:1px solid var(--line);border-radius:8px;padding:10px 12px;min-width:0}textarea{resize:vertical;line-height:1.75}input::placeholder,textarea::placeholder{color:#99a195}h1,h2,h3,p{margin:0}button.primary{background:var(--green);color:#fff;border:1px solid var(--green);box-shadow:0 2px 3px #294c3010}button.primary:hover{background:#284e3c;box-shadow:0 4px 9px #294c3020}button.outline{border:1px solid var(--line);background:var(--paper)}button.outline:hover{border-color:#bdcabc;background:#f7f9f4}button.text-button{color:var(--muted);font-size:11px;padding:4px 7px}button.text-button:hover{color:var(--green)}button.small{font-size:11px;padding:7px 10px}.icon-button{width:31px;height:31px;display:inline-grid;place-items:center;padding:6px;color:#778477}.icon-button svg{width:17px;height:17px}kbd{font-family:inherit;font-size:9px;font-weight:400;border:1px solid #dce2d8;border-radius:4px;padding:2px 4px;color:#8b9687;background:#fafbf8}.muted{color:var(--muted);font-size:12px;line-height:1.8}.eyebrow{font-size:9px;letter-spacing:1.7px;color:#8c998a;font-weight:600}.dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#9da797;flex-shrink:0}.dot.green{background:#6c9678}.dot.amber{background:#c39b51}.dot.red{background:#bd7970}.dot.running{background:#739383;box-shadow:0 0 0 3px #76988715}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}[hidden]{display:none!important}
|
||||||
|
.studio{height:100dvh;min-height:650px;display:grid;grid-template-columns:228px minmax(0,1fr);transition:grid-template-columns .28s var(--ease)}.studio.rail-closed{grid-template-columns:0 minmax(0,1fr)}.studio.rail-closed .sidebar{opacity:0;pointer-events:none}.sidebar{background:var(--rail);border-right:1px solid var(--line);padding:26px 17px 14px;display:flex;flex-direction:column;gap:5px;min-width:0;overflow:hidden;transition:opacity .18s}.wordmark{display:flex;align-items:center;gap:10px;text-align:left;padding:0 6px 24px;border-radius:0;flex-shrink:0}.wordmark:hover{background:transparent}.wordmark>span:last-child{font-size:23px;letter-spacing:-.8px;font-weight:550}.wordmark small{display:block;font-size:7px;letter-spacing:1.8px;color:#879583;margin:5px 0 0 2px;font-weight:500}.brand-symbol{position:relative;display:grid;place-items:center;width:32px;height:35px;font-size:35px;line-height:1;font-family:Georgia,serif;font-style:italic;font-weight:700;color:#3e5c42}.brand-symbol span{position:absolute;background:#98ad81;width:6px;height:6px;border-radius:50%;top:2px;right:0}.new-task{display:flex;align-items:center;gap:8px;font-size:12px;background:white;border:1px solid #dce3d7;box-shadow:0 2px 4px #27362804;text-align:left;padding:11px;margin:5px 0 9px}.new-task kbd{margin-left:auto;font-size:8px}.new-task svg{width:16px}.nav-item{display:flex;align-items:center;gap:10px;text-align:left;width:100%;font-size:11px;padding:10px;color:#687664;white-space:nowrap}.nav-item svg{width:16px;height:16px}.nav-item kbd{margin-left:auto;font-size:8px}.nav-item.active{background:#e0e8d9;color:#3c5c3b;font-weight:550}.nav-group{margin-top:13px;display:grid;gap:4px}.count{margin-left:auto;background:#dce3d3;border-radius:5px;padding:1px 6px;font-size:9px;color:#6e7d64}.sidebar-label{display:flex;align-items:center;justify-content:space-between;padding:0 9px;margin:23px 0 6px;font-size:9px;color:#929c89;letter-spacing:.4px}.sidebar-label button{padding:0;display:grid;place-items:center}.sidebar-label svg{width:13px}.project-nav{display:flex;gap:10px;text-align:left;padding:9px 8px;align-items:center;white-space:nowrap;font-size:10px}.project-avatar{width:29px;height:29px;border:1px solid #d5dfcd;background:#e8eede;display:grid;place-items:center;border-radius:8px;color:#637851;font-family:Georgia,serif;font-size:16px}.project-nav small{display:block;font-size:8px;color:#98a08e;margin-top:5px}.project-nav>svg{margin-left:auto;width:12px;color:#99a28f}.task-list{display:flex;flex-direction:column;gap:4px;overflow:auto;scrollbar-width:thin;flex:1;min-height:100px;margin-top:2px}.task-item{text-align:left;display:flex;gap:9px;align-items:center;font-size:10px;padding:11px 10px;flex-shrink:0;min-width:0;color:#78836e}.task-item .task-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.task-item.active{background:#fff;border:1px solid #e0e7d9;padding:10px 9px;box-shadow:0 2px 5px #43513803;color:#3c5134}.task-item .task-status-icon{margin-left:auto;color:#b28b44;display:flex}.task-item .task-status-icon svg{width:13px;height:13px}.sidebar-bottom{padding-top:18px;flex-shrink:0}.profile{border-top:1px solid #dde4d7;margin-top:18px;padding:19px 3px 1px;display:flex;gap:9px;align-items:center;font-size:10px;white-space:nowrap}.profile-avatar,.top-avatar{display:grid;place-items:center;background:#dfd5c4;border:2px solid #fff;border-radius:50%;height:29px;width:29px;color:#806e53;font-family:Georgia,serif;font-size:14px}.profile small{display:flex;align-items:center;gap:5px;color:#97a088;font-size:8px;margin-top:6px}.profile .icon-button{margin-left:auto}.body-shell{display:flex;flex-direction:column;min-width:0;overflow:hidden}.topbar{height:65px;flex-shrink:0;background:#fafbf9;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 24px;gap:15px}.breadcrumb{display:flex;align-items:center;gap:12px;font-size:10px;min-width:0;color:#8d9788}.breadcrumb strong{font-size:11px;color:#58664e;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.slash{color:#c1c8b9}.top-actions{display:flex;align-items:center;gap:12px}.prototype-label{font-size:8px;letter-spacing:1px;color:#a0aa96;padding:4px 7px;border:1px solid #e1e7d9;border-radius:4px}.top-avatar{width:27px;height:27px;font-size:12px}.top-actions .icon-button{position:relative}.top-actions b{position:absolute;top:5px;right:6px;width:4px;height:4px;border-radius:50%;background:#b29155}.statusbar{height:28px;flex-shrink:0;display:flex;justify-content:space-between;align-items:center;padding:0 20px;border-top:1px solid var(--line);font-size:8px;color:#91a082;gap:12px;background:#f7f9f4}.statusbar>span{display:flex;gap:7px;align-items:center}.statusbar-muted{color:#a6b199}.page{flex:1;min-height:0;overflow:auto}.task-workspace{height:100%;display:grid;grid-template-columns:minmax(390px,1fr) minmax(330px,40%);overflow:hidden}.task-workspace.canvas-closed{grid-template-columns:minmax(0,1fr) 0}.conversaLine truncated
|
||||||
|
.canvas{border-left:1px solid #e2e9d9;background:#f1f4ed;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden}.canvas-closed .canvas{visibility:hidden}.canvas-toolbar{height:54px;display:flex;align-items:center;justify-content:space-between;padding:0 19px;border-bottom:1px solid #e2e9d9;background:#f6f8f2;gap:8px;flex-shrink:0}.canvas-tabs{display:flex;gap:16px;height:100%;align-items:stretch}.canvas-tabs button{padding:0 1px;border-radius:0;position:relative;color:#9caa88;font-size:9px}.canvas-tabs button:hover{background:none;color:#6f8755}.canvas-tabs button[aria-selected="true"]{color:#6e8854}.canvas-tabs button[aria-selected="true"]:after{content:'';position:absolute;bottom:0;left:0;right:0;height:2px;background:#8ca86a}.canvas-toolbar .icon-button{width:23px;height:25px;padding:3px;color:#9aab83}.canvas-toolbar .icon-button svg{width:14px}.canvas-content{flex:1;overflow:auto;min-height:0;scrollbar-width:thin;scrollbar-color:#d2ddc4 transparent;padding:20px}.document-tools{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:16px;font-size:9px;color:#9aac83}.document-tools button{font-size:9px;padding:4px 7px;color:#8ba16d;border:1px solid #dfe7d4;background:#f9fbf6}.paper{background:#fff;border:1px solid #e0e8d7;border-radius:3px;box-shadow:0 10px 28px #536a3707;min-height:520px;overflow:hidden}.paper-cover{padding:31px 28px 27px;position:relative;overflow:hidden;border-bottom:1px solid #e7eddc;background:linear-gradient(125deg,#f6f8ef,#edf2e3)}.paper-cover .eyebrow{font-size:7px;letter-spacing:2px;color:#a2b581}.paper-cover h2{font-size:26px;line-height:1.4;font-weight:500;letter-spacing:-.8px;color:#788f51;position:relative;z-index:1;max-width:230px;margin:18px 0 13px}.paper-cover p{font-size:9px;color:#a1b280;line-height:1.8;max-width:220px;position:relative;z-index:1}.cover-art{position:absolute;right:-27px;bottom:-50px;width:175px;height:210px;transform:rotate(27deg)}.cover-art span{position:absolute;border-radius:90px 90px 10px 10px;border:1px solid #c9d6b0;width:90px;height:170px;background:linear-gradient(100deg,#d7e1c433,#bcca9d30);transform:rotate(-12deg);right:0;bottom:0}.cover-art span:nth-child(2){right:35px;bottom:8px;transform:rotate(-30deg);background:#c9d6b016}.cover-art span:nth-child(3){right:65px;bottom:17px;transform:rotate(-50deg);background:#e2e9d425}.paper-meta{display:flex;gap:16px;font-size:8px;color:#a3b58a;padding:14px 28px;border-bottom:1px solid #edf1e4}.paper-body{padding:22px 28px 27px;color:#81976a;font-size:10px;line-height:1.9}.paper-body h3{font-size:12px;font-weight:500;color:#79915d;margin:0 0 9px;display:flex;align-items:center;gap:8px}.paper-body h3 span{font-size:8px;color:#aabb93;font-family:Georgia,serif}.paper-body p{margin-bottom:19px}.document-cards{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin:14px 0 22px}.document-cards>div{padding:12px;background:#f6f9f0;border:1px solid #e9efdf;border-radius:5px}.document-cards b{font-size:9px;display:block;font-weight:500;color:#849e66}.document-cards small{font-size:8px;display:block;margin-top:5px;line-height:1.8;color:#a1b387}.doc-line{display:flex;gap:10px;padding:9px 0;border-bottom:1px solid #edf2e5;font-size:9px;color:#95a97c}.doc-line span:first-child{color:#b1bf9c;font-size:8px;width:33px;flex-shrink:0}.paper-footer{padding:13px 28px;border-top:1px solid #edf2e4;font-size:7px;color:#abbc95;display:flex;justify-content:space-between}.canvas-bottom{padding:12px 20px;border-top:1px solid #e1e9d5;display:flex;justify-content:space-between;align-items:center;background:#f7faf2;color:#9caf84;font-size:8px;flex-shrink:0}.canvas-bottom button{color:#90a673;font-size:9px;padding:6px 8px}.canvas-bottom svg{width:12px;height:12px}.activity-heading{font-size:10px;color:#94a97c;margin:3px 0 22px}.activity-entry{padding:0 0 23px 21px;border-left:1px solid #dbe6cc;position:relative;margin-left:6px;color:#8a9f71;font-size:10px}.activity-entry:before{content:'';position:absolute;left:-4px;top:3px;width:7px;height:7px;border-radius:50%;background:#b2c596;border:2px solid #f4f7ee}.activity-entry small{display:block;margin-top:6px;font-size:8px;color:#a6b992}.activity-entry:last-child{border-color:transparent}.file-row{display:flex;align-items:center;gap:12px;padding:14px;background:#fcfef8;border:1px solid #e0e9d2;border-radius:8px;margin-bottom:9px;text-align:left;width:100%;color:#8ca96b;font-size:10px}.file-row svg{width:19px}.file-row small{display:block;font-size:8px;color:#aabc93;margin-top:6px}.file-row .arrow{margin-left:auto;width:12px}.empty-panel{padding:45px 16px;text-align:center;color:#96ab81;font-size:11px;line-height:1.9}.empty-panel svg{width:30px;height:30px;margin-bottom:15px;color:#b3c69c;display:block;margin-left:auto;margin-right:auto}
|
||||||
|
.overview-page{padding:43px max(35px,calc((100% - 1060px)/2)) 35px;min-height:100%;background:#fafbf8}.overview-header{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:32px;gap:15px}.overview-header h1{font-size:30px;font-weight:500;letter-spacing:-.8px;color:#43593c;margin:9px 0 9px}.overview-header .muted{font-size:11px;color:#8e9d82}.overview-header button{font-size:11px}.home-composer{border:1px solid #dce6d2;background:#fff;border-radius:13px;padding:17px 20px;box-shadow:0 5px 25px #49653105;margin-bottom:31px}.home-composer textarea{width:100%;border:0;outline:none;resize:none;font-size:13px;min-height:64px}.home-composer>div{display:flex;justify-content:space-between;align-items:center;font-size:10px;color:#a1b28d}.home-composer button{font-size:10px}.section-heading{display:flex;justify-content:space-between;align-items:center;margin:26px 0 14px}.section-heading h2{font-size:13px;font-weight:500;color:#738e5c}.section-heading span{font-size:10px;color:#a6b894}.task-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px}.task-tile{border:1px solid #e0e9d5;background:#fff;border-radius:11px;padding:19px;text-align:left;min-height:162px;display:flex;flex-direction:column;align-items:flex-start;transition:transform .18s,box-shadow .18s,border-color .18s}.task-tile:hover{transform:translateY(-3px);box-shadow:0 10px 24px #5a793110;border-color:#bdcfa7;background:white}.task-tile h3{font-size:13px;font-weight:500;color:#6f8a53;margin:17px 0 9px}.task-tile p{font-size:10px;color:#a1b288;line-height:1.7}.task-tile>small{font-size:8px;color:#b2c09e;margin-top:auto;padding-top:18px}.task-tile .status-pill{font-size:8px}.project-banner{border:1px solid #dee8d1;background:linear-gradient(110deg,#f0f5e8,#f8faf5);border-radius:11px;padding:23px;display:flex;align-items:center;gap:18px;margin-top:25px}.project-banner .project-avatar{width:42px;height:42px;font-size:24px;background:#e3edda;color:#93ac75;border-color:#d5e2c6}.project-banner h3{font-size:13px;font-weight:500;color:#7b9860;margin-bottom:7px}.project-banner p{font-size:10px;color:#a2b68b}.project-banner button{margin-left:auto;font-size:10px}.connection-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.connection-card{border:1px solid #dfe9d2;border-radius:11px;padding:21px;background:#fff}.connection-card header{display:flex;align-items:center;gap:12px}.provider-symbol{width:34px;height:34px;border:1px solid #dae5ca;border-radius:9px;background:#f0f5e7;display:grid;place-items:center;font-family:Georgia,serif;font-size:17px;color:#8eab6b}.connection-card h3{font-size:12px;font-weight:500;color:#7c995b}.connection-card header small{font-size:9px;color:#a8bb90;display:block;margin-top:5px}.connection-card p{font-size:10px;color:#9db484;line-height:1.8;margin:17px 0}.connection-card footer{display:flex;justify-content:space-between;align-items:center;border-top:1px solid #edf2e5;padding-top:12px;font-size:9px;color:#aec29a}.connection-card footer button{font-size:9px;padding:4px 8px}.filter-tabs{display:flex;gap:7px;margin-bottom:25px}.filter-tabs button{font-size:11px;color:#8ea675;padding:8px 13px}.filter-tabs button.active{background:#e6eedb;color:#6b8c4e}.toggle{width:30px;height:18px;position:relative;border-radius:15px;background:#d4e0c6;padding:0}.toggle:after{content:'';width:12px;height:12px;background:#fff;border-radius:50%;position:absolute;left:3px;top:3px;box-shadow:0 1px 3px #47603215;transition:transform .2s}.toggle[aria-checked="true"]{background:#8aa967}.toggle[aria-checked="true"]:after{transform:translateX(12px)}.schedule-row{display:flex;align-items:center;gap:17px;padding:22px;border:1px solid #dfe9d3;background:#fff;border-radius:10px;margin-bottom:12px}.schedule-row h3{font-size:12px;font-weight:500;color:#799659;margin-bottom:8px}.schedule-row small{font-size:9px;color:#a4b88c}.schedule-row .toggle{margin-left:auto}.schedule-row>svg{color:#a7bd8d}.schedule-row .icon-button{color:#9fb587}.note-box{padding:15px 17px;border:1px solid #e5e9d8;border-radius:8px;background:#f8faef;font-size:10px;color:#9caa7e;line-height:1.9;margin-top:24px}
|
||||||
|
.modal{padding:0;border:1px solid #dfe7d6;border-radius:15px;width:590px;max-width:calc(100vw - 28px);color:#60764e;box-shadow:0 30px 90px #26371d30}.modal::backdrop{background:#24351e38;backdrop-filter:blur(5px)}.modal header{display:flex;justify-content:space-between;align-items:center;padding:23px 26px;border-bottom:1px solid #e7eddc;background:#fbfdf8}.modal h2{font-size:20px;font-weight:500;letter-spacing:-.4px;margin-top:8px;color:#698348}.modal-body{padding:23px 26px;max-height:66dvh;overflow:auto;background:#fff}.modal footer{padding:15px 26px;border-top:1px solid #e7eddd;background:#fcfdf9;display:flex;justify-content:flex-end;gap:8px}.modal footer:empty{display:none}.modal footer button{font-size:11px}.modal label{display:block;font-size:11px;color:#849d66;margin:16px 0 7px}.modal input,.modal select,.modal textarea{width:100%;font-size:11px;color:#779256;border-color:#dce7cf}.modal .muted{color:#91a778;font-size:11px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.form-error{font-size:11px;color:var(--red);margin-top:13px}.search-field{width:100%;margin-bottom:15px}.search-result{display:flex;gap:10px;width:100%;padding:12px;text-align:left;align-items:center;font-size:11px;color:#829f60}.search-result small{font-size:9px;color:#a1b989;margin-left:auto}.search-result svg{width:15px}.model-option{width:100%;padding:15px;display:flex;gap:11px;align-items:center;text-align:left;border:1px solid #e4ecdb;margin:9px 0}.model-option strong{font-size:12px;font-weight:500;color:#789651}.model-option small{font-size:9px;color:#a3ba8a;display:block;margin-top:6px}.model-option.selected{border-color:#afc992;background:#f4f9ed}.model-option>svg:last-child{margin-left:auto;color:#8caa68}.toast{position:fixed;bottom:43px;left:50%;transform:translateX(-50%);background:#456345;color:white;font-size:11px;border:1px solid #567a50;border-radius:9px;box-shadow:0 8px 30px #34502520;padding:12px 18px;z-index:10;max-width:90vw;line-height:1.7}
|
||||||
|
.event-arrival{animation:arrive .32s var(--ease) both}.panel-opening{animation:panel .3s var(--ease) both}.dialog-opening{animation:dialog .22s var(--ease) both}.picked{animation:pick .25s ease-out}.flash-update{animation:update .65s ease-out}.card-hover{transition:transform .18s,box-shadow .18s}@keyframes arrive{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes panel{from{opacity:.3;transform:translateX(18px)}to{opacity:1;transform:translateX(0)}}@keyframes dialog{from{opacity:.3;transform:translateY(8px) scale(.99)}to{opacity:1;transform:none}}@keyframes pick{50%{box-shadow:0 0 0 3px #b5c89225}}@keyframes update{0%,60%{background:#f1f6e8}100%{background:transparent}}@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
/* Keep supporting text readable on the softly tinted product surfaces. */
|
||||||
|
:root{--muted:#727e6a}.conversation-description,.composer-hint{color:#859277}.task-item{color:#68765b}.statusbar{color:#7e8e6d}.statusbar-muted{color:#859377}.sidebar-label{color:#849079}.project-nav small,.profile small{color:#849477}.message,.user-bubble{color:#55634a}.message p{color:#59694c}.ask-option small,.ask-body>.muted{color:#88956d}.ask-option strong{color:#657644}.card-footer>span{color:#938765}.tool-group summary,.tool-record{color:#7b8b64}.tool-group summary .tool-result,.tool-record small{color:#8d9e75}.paper-body{color:#71845c}.paper-cover h2{color:#657d42}.paper-cover p{color:#839969}.paper-meta,.paper-footer{color:#8d9f76}.paper-body h3{color:#647f47}.document-cards small,.doc-line{color:#839868}.canvas-tabs button{color:#859773}.canvas-bottom{color:#8a9f72}.composer textarea{color:#687a53}.composer textarea::placeholder{color:#8d9d7a}.overview-header .muted,.task-tile p{color:#879c72}.task-tile h3{color:#607d45}.connection-card p,.connection-card footer{color:#849f6b}.connection-card h3{color:#698a49}.connection-card header small,.schedule-row small{color:#8ca874}.note-box{color:#80965f}.empty-panel{color:#7e9864}.modal .muted{color:#809b64}.modal label{color:#6f8c51}
|
||||||
|
@media(min-width:1600px){.studio{grid-template-columns:245px minmax(0,1fr)}.task-workspace{grid-template-columns:minmax(500px,1fr) minmax(420px,41%)}.transcript{padding-top:17px}.conversation-title{font-size:28px}.message{font-size:13px}.user-bubble{font-size:12px}.ask-body h3{font-size:14px}.ask-option strong{font-size:12px}.ask-option small{font-size:10px}.paper-cover{padding:40px 35px}.paper-cover h2{font-size:32px}.paper-body{padding:28px 35px;font-size:12px}.paper-body h3{font-size:14px}.document-cards b{font-size:11px}.document-cards small{font-size:10px}.doc-line{font-size:11px}.paper-meta,.paper-footer{padding-left:35px}.task-item,.nav-item{font-size:12px}.project-nav{font-size:11px}.composer textarea{font-size:13px}.composer-bottom button{font-size:10px}.canvas-tabs button{font-size:11px}}
|
||||||
|
@media(max-width:1150px){.studio{grid-template-columns:198px minmax(0,1fr)}.sidebar{padding-left:12px;padding-right:12px}.task-workspace{grid-template-columns:minmax(340px,1fr) 330px}.transcript{padding-left:23px;padding-right:23px}.conversation-toolbar{padding-left:23px;padding-right:23px}.canvas-content{padding:15px}.paper-cover{padding:25px 22px}.paper-cover h2{font-size:24px}.paper-body{padding:21px}.task-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.prototype-label{display:none}.statusbar>span:last-child{display:none}}
|
||||||
|
@media(max-width:960px){.task-workspace{grid-template-columns:minmax(0,1fr)}.canvas{position:fixed;right:12px;top:77px;bottom:39px;width:min(440px,calc(100vw - 24px));z-index:4;border:1px solid #d9e5ca;border-radius:12px;box-shadow:0 12px 70px #3c522b2a}.canvas-closed .canvas{display:none}.conversation-title{font-size:27px}.paper-body{font-size:12px}.task-workspace .canvas-toggle{display:inline-flex}.topbar{padding:0 17px}.connection-grid{grid-template-columns:1fr}.overview-page{padding:32px 27px}.overview-header h1{font-size:26px}}
|
||||||
|
@media(max-width:650px){.studio{grid-template-columns:0 minmax(0,1fr)}.sidebar{visibility:hidden}.studio.mobile-rail .sidebar{visibility:visible;position:fixed;left:0;top:0;bottom:0;width:228px;z-index:6;box-shadow:10px 0 50px #2f442428;opacity:1;pointer-events:auto}.topbar{height:56px;padding:0 12px}.breadcrumb{gap:7px}.breadcrumb>span:not(.slash){display:none}.top-actions{gap:6px}.top-actions .top-avatar{display:none}.top-actions .icon-button:first-of-type{display:none}.statusbar{font-size:8px;padding:0 12px}.statusbar>span:nth-child(2){display:none}.conversation-toolbar{padding:14px 17px 8px}.transcript{padding:10px 19px 20px}.conversation-title{font-size:24px}.conversation-description{font-size:10px}.message{font-size:13px}.user-bubble{font-size:12px}.ask-option strong{font-size:12px}.ask-option small{font-size:10px}.ask-body h3{font-size:14px}.ask-body>.muted{font-size:11px}.ask-option{min-height:50px}.card-footer{flex-wrap:wrap}.card-footer button{min-height:38px;font-size:11px}.composer-zone{padding:7px 15px 13px}.composer textarea{font-size:16px}.composer-bottom button{font-size:10px;min-height:34px}.composer-bottom .send{width:34px;height:34px}.composer-bottom .composer-model{max-width:145px}.composer-hint{font-size:8px}.canvas{top:66px}.overview-page{padding:27px 19px}.overview-header h1{font-size:24px}.overview-header{align-items:flex-start}.overview-header .eyebrow{font-size:8px}.overview-header button{font-size:10px;white-space:nowrap}.task-grid{grid-template-columns:1fr}.task-tile{min-height:135px}.task-tile h3{font-size:14px}.task-tile p{font-size:12px}.task-tile>small{font-size:10px}.project-banner{padding:16px;gap:10px;flex-wrap:wrap}.project-banner h3{font-size:12px}.project-banner p{font-size:10px}.modal-body{padding:20px}.modal header{padding:20px}.form-grid{grid-template-columns:1fr}.modal input,.modal select,.modal textarea{font-size:16px}.modal{max-width:calc(100vw - 20px)}.schedule-row{padding:17px;gap:12px}.filter-tabs{gap:2px;flex-wrap:wrap}.filter-tabs button{font-size:11px;padding:8px}.studio{min-height:620px}}
|
||||||
|
.nav-item{font-size:12px}.task-item{font-size:11px}.sidebar-label{font-size:10px}.project-nav{font-size:11px}.profile{font-size:11px}.profile small,.project-nav small{font-size:10px}.breadcrumb{font-size:11px}.breadcrumb strong{font-size:12px}.conversation-description{font-size:11px}.message{font-size:14px}.user-bubble{font-size:13px;color:#4b5946}.agent-byline{font-size:12px}.agent-byline small{font-size:10px}.message p{color:#52614a}.compact-plan summary{font-size:12px}.plan-steps li{font-size:12px}.tool-group summary,.tool-record{font-size:11px}.tool-group summary .tool-result,.tool-record small{font-size:10px}.receipt{font-size:11px}.ask-body h3{font-size:15px;color:#4c5239}.ask-body>.muted{font-size:12px;color:#7b806a}.ask-option strong{font-size:12px;color:#546239}.ask-option small{font-size:11px;color:#7c8768}.card-heading{font-size:11px}.card-heading>span:last-child{font-size:10px}.card-footer>span{font-size:10px}.card-footer button{font-size:11px}.ask-free{font-size:12px}.ask-option .recommended{font-size:9px}.execution-top{font-size:12px}.execution-bottom{font-size:11px}.execution-top .small{font-size:10px}.subagent{font-size:10px}.result-card b{font-size:12px}.result-card small{font-size:11px}.composer textarea{font-size:13px}.composer-bottom button{font-size:11px}.composer-hint{font-size:9px}.status-pill{font-size:10px}.task-meta .project-tag{font-size:10px}.canvas-tabs button{font-size:11px}.document-tools{font-size:11px;color:#788969}.document-tools button{font-size:10px}.paper-meta{font-size:10px;color:#7b8c68}.paper-body{font-size:12px;color:#647650}.paper-body h3{font-size:14px;color:#566d3f}.paper-cover h2{color:#536b35}.paper-cover p{font-size:11px;color:#788d5d}.document-cards b{font-size:11px;color:#6a814f}.document-cards small{font-size:10px;color:#788a65}.doc-line{font-size:11px;color:#768960}.doc-line span:first-child{font-size:10px;color:#879873;width:40px}.paper-footer{font-size:9px;color:#8b9b78}.canvas-bottom{font-size:10px;color:#7f9366}.canvas-bottom button{font-size:10px}.activity-entry{font-size:12px;color:#758a5c}.activity-entry small{font-size:10px;color:#8d9f77}.file-row{font-size:12px;color:#728951}.file-row small{font-size:10px;color:#8b9c74}.task-tile h3{font-size:15px;color:#506c35}.task-tile p{font-size:12px;color:#7c9069}.task-tile>small{font-size:10px;color:#8fa17c}.task-tile .status-pill{font-size:10px}.section-heading h2{font-size:15px}.section-heading span{font-size:11px}.overview-header .muted{font-size:13px}.overview-header button{font-size:12px}.connection-card h3{font-size:14px}.connection-card p{font-size:12px;line-height:1.8}.connection-card header small,.connection-card footer{font-size:11px}.connection-card footer button{font-size:11px}.filter-tabs button{font-size:12px}.note-box{font-size:12px;color:#788a5e}.schedule-row h3{font-size:14px}.schedule-row small{font-size:11px}.modal label,.modal input,.modal select,.modal textarea{font-size:13px}.modal .muted{font-size:12px;color:#768a5f}.modal footer button{font-size:12px}.model-option strong{font-size:14px}.model-option small{font-size:11px}.search-result{font-size:13px}.search-result small{font-size:11px}.task-meta{min-width:0}.project-tag{max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.conversation-toolbar{flex-shrink:0}.task-workspace.canvas-closed .transcript{padding-left:max(28px,calc((100% - 770px)/2));padding-right:max(28px,calc((100% - 770px)/2))}.task-workspace.canvas-closed .composer-zone{padding-left:max(26px,calc((100% - 770px)/2));padding-right:max(26px,calc((100% - 770px)/2))}.task-workspace.canvas-closed .conversation-toolbar{padding-left:max(28px,calc((100% - 770px)/2));padding-right:max(28px,calc((100% - 770px)/2))}.large-type .message,.large-type .user-bubble{font-size:16px}.large-type .paper-body{font-size:14px}.large-type .composer textarea{font-size:16px}.large-type .ask-option strong{font-size:14px}.large-type .nav-item,.large-type .task-item{font-size:13px}.reduce-motion *,.reduce-motion *:before,.reduce-motion *:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}
|
||||||
|
@media(max-width:650px){.composer textarea,.modal input,.modal select,.modal textarea{font-size:16px}.composer-bottom button{font-size:10px}.composer-bottom .composer-model{max-width:125px}.task-meta .project-tag{max-width:135px;font-size:9px}.status-pill{font-size:9px}.task-workspace.canvas-closed .transcript{padding-left:19px;padding-right:19px}.task-workspace.canvas-closed .composer-zone{padding-left:15px;padding-right:15px}.task-workspace.canvas-closed .conversation-toolbar{padding-left:17px;padding-right:17px}.card-footer>span{font-size:10px}.task-tile p{font-size:12px}}
|
||||||
|
@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#f6f7f9;--paper:#fff;--rail:#eceef1;--line:#e0e3e9;--ink:#242730;--muted:#727782;--faint:#9297a2;--green:#353942;--green-light:#f1f3f6;--amber:#966b21;--amber-light:#fff7e6;--red:#ac4b45;--blue:#5877ad;--shadow:0 2px 5px #10182804,0 12px 32px #10182808;--ease:cubic-bezier(.2,.8,.2,1)}
|
||||||
|
body{background:#eef0f3;color:#242730;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
|
||||||
|
button{transition:background .18s,border-color .18s,color .18s,box-shadow .2s,transform .18s var(--ease)}
|
||||||
|
button:hover{background:#e9ecf1}button:active{transform:translateY(1px) scale(.98)}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{outline:2px solid #92a8ce;outline-offset:3px}
|
||||||
|
button.primary{background:linear-gradient(180deg,#424751,#2e333d);border-color:#2e343f;color:#fff;box-shadow:inset 0 1px #ffffff1a,0 1px 2px #1018281a,0 3px 5px #1018280a}
|
||||||
|
button.primary:hover{background:linear-gradient(180deg,#505662,#383e49);box-shadow:inset 0 1px #ffffff25,0 4px 10px #10182820;transform:translateY(-1px)}button.primary:active{transform:translateY(1px);box-shadow:inset 0 1px 3px #10182824}
|
||||||
|
button.outline{background:linear-gradient(#fff,#fcfcfd);border-color:#dce0e7;color:#555b68;box-shadow:0 1px 2px #10182804}button.outline:hover{background:#f5f6f9;border-color:#bbc3d1}
|
||||||
|
.sidebar{background:linear-gradient(110deg,#f0f1f4,#e9ecf0);border-color:#dce0e6;box-shadow:inset -1px 0 #ffffff70}.wordmark>span:last-child{color:#2f343e;letter-spacing:-.85px}.wordmark small{color:#9298a4;letter-spacing:2px}.brand-symbol{color:#343a46}.brand-symbol span{background:#8193b1}
|
||||||
|
.new-task{background:linear-gradient(180deg,#fff,#f9fafc);border:1px solid #d3d9e2;box-shadow:inset 0 1px #fff,0 2px 4px #1b25300a;color:#535a67;border-radius:9px}.new-task:hover{border-color:#b9c2d0;box-shadow:0 4px 10px #1b253011;transform:translateY(-1px)}
|
||||||
|
kbd{background:#f6f7fa;border-color:#dce0e8;color:#8b93a2}.nav-item{color:#6c7380;border:1px solid transparent}.nav-item:hover{background:#e2e6ec;color:#3c4554}.nav-item.active{background:linear-gradient(#fff,#fafbfe);border-color:#d7dce5;color:#414b5e;box-shadow:0 1px 3px #23324a08;font-weight:550}
|
||||||
|
.sidebar-label{color:#8d95a2;letter-spacing:.6px}.project-avatar{background:linear-gradient(135deg,#f9fafc,#e2e7ef);color:#6a7891;border-color:#d2dae5;box-shadow:inset 0 1px #fff}.project-nav{color:#626c7b}.project-nav small,.profile small{color:#9199a6}.project-nav>svg{color:#949dad}.count{background:#e5e9f0;color:#7a8496}.task-item{color:#76808e}.task-item.active{background:#fff;border-color:#d8dee8;color:#46536a;box-shadow:0 1px 2px #25324a07,0 4px 10px #25324a03}.task-item:hover{background:#f8f9fc}.task-item .task-status-icon{color:#a18755}.profile{border-color:#d8dee7;color:#697588}.profile-avatar,.top-avatar{background:linear-gradient(135deg,#c8d3e1,#a3b3c8);color:#455973;border-color:#fff;box-shadow:0 0 0 1px #d7dfe9}.sidebar .icon-button{color:#7f8999}
|
||||||
|
.topbar{background:#fafbfc;border-bottom-color:#e3e7ee;box-shadow:0 1px 0 #fff;padding-top:1px}.breadcrumb{color:#929aa7}.breadcrumb strong{color:#555f70;font-weight:550}.slash{color:#c0c8d4}.top-actions .icon-button{color:#8b96a7}.prototype-label{border-color:#e0e5ee;color:#8995a7;letter-spacing:1.1px;background:#f5f7fa}.statusbar{background:#eef1f5;border-color:#dce2eb;color:#7e8ca2;letter-spacing:.1px}.statusbar-muted{color:#8b98ab}.top-actions b{background:#a18659}
|
||||||
|
.conversation{background:#fff}.conversation-toolbar{padding-top:22px;padding-bottom:16px}.conversation-title{color:#2b3341;font-weight:550;letter-spacing:-.7px}.conversation-description{color:#8794a7}.conversation-actions .icon-button{color:#8998ac}.task-meta .project-tag{border-color:#e3e9f0;background:#fbfcfe;color:#7f8fa5}.status-pill{background:#f2f5f9;color:#7a8ba3}.status-pill.waiting{background:#fbf5e9;color:#997d48}.status-pill.blocked{background:#fcf0ed;color:#a76c61}.status-pill.done{background:#edf4f0;color:#658776}.status-pill.running{background:#edf3fb;color:#6987b2}.dot.green{background:#7f9b91}.dot.amber{background:#c4a367}.dot.red{background:#c18b80}.dot.running{background:#7c9cbe;box-shadow:0 0 0 3px #6f8daf13}
|
||||||
|
.message{color:#596778;line-height:2}.message p{color:#596779;line-height:1.95}.user-bubble{background:linear-gradient(120deg,#f1f4f8,#edf1f6);border-color:#e4e9f0;color:#59677a;border-radius:14px 14px 4px 14px;box-shadow:inset 0 1px #ffffffa0}.agent-byline{color:#566781}.agent-byline small{color:#8f9eb2}.agent-avatar{background:linear-gradient(145deg,#f6f8fc,#e2e9f2);border-color:#d7e1ee;color:#6f86a6;box-shadow:inset 0 1px #fff,0 2px 4px #233b5906}.receipt{color:#7d95a0}
|
||||||
|
.compact-plan{background:linear-gradient(#fbfcfe,#f8fafc);border-color:#e2e8f0;box-shadow:inset 0 1px #fff}.compact-plan summary{color:#70849e;padding:12px 14px}.compact-plan summary .plan-total{color:#95a4b9}.plan-steps li{color:#8a9ab0}.plan-steps .current{color:#637d9e}.step-circle{border-color:#dbe4ef;color:#9aaac0}.plan-steps .complete .step-circle{background:#eaf0f5;color:#7b98a6}.plan-steps .current .step-circle{border-color:#96aac5}.plan-progress{background:#edf2f8}.plan-progress i{background:#99b1d0}.compact-plan .text-button{color:#8a9db6}
|
||||||
|
.tool-group{border-color:#e5ebf3;background:#fff}.tool-group summary{color:#8296af}.tool-record{border-color:#edf2f8;color:#8a9eb7}.tool-record svg{color:#9eafc6}.tool-group summary .tool-result,.tool-record small{color:#9aadc6}
|
||||||
|
.ask-card{border:1px solid #dbe3ee;border-radius:13px;box-shadow:0 1px 3px #28415b04,0 7px 22px #28415b06;background:linear-gradient(125deg,#fff,#fefeff);position:relative}.ask-card:before{content:'';position:absolute;top:0;left:15px;right:15px;height:1px;background:linear-gradient(90deg,transparent,#aec0d8,transparent)}.card-heading{color:#7a91ad;padding-top:16px}.card-heading>span:last-child{color:#a3b2c6}.ask-body h3{color:#4f657f;font-weight:550}.ask-body>.muted{color:#869ab4}.ask-options{gap:8px}.ask-option{border-color:#e6ecf4;background:#fff;padding:12px;border-radius:8px}.ask-option:hover{background:#f8fafd;border-color:#cedbeb;box-shadow:0 2px 7px #4b6f9906}.ask-option.selected{background:linear-gradient(115deg,#f3f7fc,#f8fafd);border-color:#b5c9e2;box-shadow:0 0 0 1px #c5d5e821,inset 0 1px #fff}.ask-option .radio{border-color:#cedae9;background:#fff}.ask-option.selected .radio{border-color:#91acce}.ask-option strong{color:#607b9b;font-weight:500}.ask-option small{color:#8b9fba}.ask-option .recommended{background:#eaf1fa;color:#8da8ca;border:1px solid #dfe9f6;padding:2px 6px}.ask-free{background:#fbfcfe;border-color:#e2eaf5;color:#7994b6}.ask-free::placeholder{color:#a1b2c9}.card-footer{background:linear-gradient(#fbfcfe,#f7f9fc);border-color:#e6edf6}.card-footer>span{color:#93a6bf}.card-footer .primary{color:#fff}.approval-card .card-heading{color:#a88f5f}.approval-scope span{border-color:#ebe4d7;background:#fcfaf5;color:#a59779}
|
||||||
|
.execution{background:linear-gradient(110deg,#f5f8fc,#fafcfe);border-color:#dfe9f4;box-shadow:0 3px 12px #4f719305}.execution-top{color:#7897b9}.execution-bottom{color:#97adc7}.spinner{border-color:#dce7f4;border-top-color:#8eadcf}.run-progress{background:#e8eff7;overflow:hidden}.run-progress i{background:linear-gradient(90deg,#b7cbe3,#88a8cf);position:relative}.run-progress i:after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,transparent,#ffffffa0,transparent);transform:translateX(-100%);animation:progress-sweep 2.4s ease-in-out infinite}.subagent{border-color:#e1eaf6;color:#97aec9;background:#fafcff}.result-card{background:linear-gradient(120deg,#fcfdff,#f6f9fc);border-color:#dce7f2;box-shadow:0 2px 6px #3d689508;transition:transform .2s,box-shadow .2s,border-color .2s}.result-card:hover{transform:translateY(-2px);box-shadow:0 8px 22px #3d689513;background:#fff;border-color:#b4cce5}.result-icon{background:linear-gradient(140deg,#fff,#e8f0f8);border-color:#d6e4f2;color:#8cadd0;box-shadow:inset 0 1px #fff}.result-card b{color:#7393b6}.result-card small{color:#9eb4cf}.result-card>svg{color:#9db7d6}
|
||||||
|
.composer-zone{background:linear-gradient(0deg,#fff 87%,#ffffff00)}.composer{border-color:#d5e0ed;border-radius:14px;box-shadow:0 1px 3px #29466705,0 7px 26px #29466709,inset 0 1px #fff;background:linear-gradient(160deg,#fff,#fefeff);transition:border-color .25s,box-shadow .25s,transform .25s}.composer:focus-within{border-color:#a8bfdc;box-shadow:0 0 0 3px #c9d9ec25,0 8px 30px #2946670b}.composer textarea{color:#667f9d}.composer textarea::placeholder{color:#9badc6}.composer-bottom button{color:#8aa2c0}.composer-bottom .composer-model{background:#f3f7fb;color:#849cba}.composer-bottom .send{background:linear-gradient(180deg,#4d6481,#3e526c);color:white;box-shadow:inset 0 1px #ffffff25,0 2px 4px #29456820}.composer-bottom .send:hover{background:linear-gradient(180deg,#597696,#466180);transform:translateY(-1px);box-shadow:0 3px 8px #29456828}.composer-hint{color:#99abc3}.steering-banner{background:#f1f6fc;color:#8fa8c7;border:1px solid #e3edf8}.file-chip{background:#f6f9fe;border-color:#dfe9f6;color:#88a4c6}
|
||||||
|
.canvas{background:linear-gradient(125deg,#e8edf3,#eff3f7);border-left-color:#d9e3ef;box-shadow:inset 1px 0 #fff;transition:opacity .2s,transform .32s var(--ease)}.canvas-toolbar{background:linear-gradient(#f6f9fc,#f0f4f8);border-bottom-color:#dbe5f0;box-shadow:inset 0 1px #fff}.canvas-tabs{position:relative;gap:17px}.canvas-tabs button{color:#94a9c2;transition:color .2s}.canvas-tabs button:hover,.canvas-tabs button[aria-selected="true"]{color:#708dab}.canvas-tabs button[aria-selected="true"]:after{background:#94b0d0;height:2px}.canvas-toolbar .icon-button{color:#9bb0c8}.canvas-content{padding:23px;scrollbar-color:#cbd9e9 transparent}.document-tools{color:#90a7c2}.document-tools button{border-color:#d9e5f2;background:#f8fbff;color:#98b0cc}.paper{border:1px solid #d9e4f1;border-radius:4px;box-shadow:0 1px 2px #31547c07,0 7px 17px #31547c0a,0 25px 50px -20px #31547c1a;position:relative}.paper-cover{background:linear-gradient(125deg,#f2f6fb,#e4edf7);border-color:#dce7f4;padding-top:35px;padding-bottom:31px}.paper-cover .eyebrow{color:#91abc9;letter-spacing:2.4px}.paper-cover h2{color:#6b88aa;font-weight:550;letter-spacing:-.9px}.paper-cover p{color:#8ea9c8}.cover-art span{border-color:#b6cce4;background:linear-gradient(100deg,#dae7f733,#a1bbdc30);box-shadow:inset 0 0 20px #ffffff50}.cover-art span:nth-child(2){background:#b6cce416}.cover-art span:nth-child(3){background:#d7e4f225}.paper-meta{border-color:#e7eff8;color:#9ab0ca}.paper-body{color:#829bb9}.paper-body h3{color:#7694b6}.paper-body h3 span{color:#a4b9d4}.document-cards>div{background:linear-gradient(#f6f9fe,#f0f5fc);border-color:#e3edf9;border-radius:6px}.document-cards b{color:#87a6c9}.document-cards small{color:#9eb6d2}.doc-line{border-color:#e9f1fa;color:#96b0ce}.doc-line span:first-child{color:#a9bfd9}.paper-footer{color:#a9bfd8;border-color:#e8f1fb}.canvas-bottom{background:#f4f8fc;border-color:#dce7f4;color:#a0b7d1}.canvas-bottom button{color:#98b2d0}.activity-entry{border-color:#d6e4f4;color:#8ba9ca}.activity-entry:before{background:#a8c1df;border-color:#f5f9fe}.activity-entry small{color:#acc1db}.activity-heading{color:#97b0ce}.file-row{background:#fafdff;border-color:#dce8f6;color:#8bacca;box-shadow:0 1px 3px #386d9b04}.file-row small{color:#abc1db}.empty-panel{color:#95b0ce}.empty-panel svg{color:#b3cbe4}
|
||||||
|
.overview-page{background:radial-gradient(ellipse at 80% 0,#eaf0f855,transparent 55%),#f8fafc}.overview-header h1{color:#3d5067;letter-spacing:-1px;font-weight:550}.overview-header .muted{color:#8ca1ba}.overview-header .eyebrow{color:#99aec7}.home-composer{background:linear-gradient(120deg,#fff,#fcfdff);border-color:#d8e3ef;box-shadow:0 1px 3px #25466906,0 9px 28px #25466908}.home-composer textarea{color:#7691b0}.home-composer>div{color:#a1b7d0}.section-heading h2{color:#7894b5}.section-heading span{color:#a4b9d2}.task-tile{border-color:#dce7f4;box-shadow:0 1px 3px #28538104,0 3px 8px #28538103;background:linear-gradient(145deg,#fff,#fcfdff);border-radius:12px;padding:22px;min-height:178px}.task-tile:hover{transform:translateY(-4px);border-color:#b6cce6;box-shadow:0 3px 7px #28538105,0 12px 30px #28538110}.task-tile h3{color:#708dac;font-weight:550}.task-tile p{color:#97aec9}.task-tile>small{color:#a9bed7}.project-banner{background:linear-gradient(110deg,#edf3fa,#f8fbff);border-color:#dce7f5;box-shadow:inset 0 1px #fff}.project-banner .project-avatar{background:linear-gradient(135deg,#f4f8fe,#dce8f6);border-color:#d0e0f3;color:#96b3d4}.project-banner h3{color:#87a4c6}.project-banner p{color:#a7bfd9}.filter-tabs{position:relative}.filter-tabs button{color:#92acc9}.filter-tabs button.active{background:#e6eef9;color:#7f9cbd}.connection-card{border-color:#dce8f6;box-shadow:0 2px 7px #386e9f05;background:linear-gradient(140deg,#fff,#fcfdff)}.connection-card h3{color:#829fbf}.connection-card header small,.connection-card p{color:#a0b8d3}.connection-card footer{border-color:#e8f1fb;color:#aec5df}.provider-symbol{background:linear-gradient(145deg,#fbfdff,#e3eefb);border-color:#d5e5f7;color:#93b3d7;box-shadow:inset 0 1px #fff}.toggle{background:#d4e2f3}.toggle[aria-checked="true"]{background:#8dadd2}.schedule-row{border-color:#dce9f8;box-shadow:0 2px 7px #386e9f05}.schedule-row h3{color:#7b9cc0}.schedule-row small,.schedule-row>svg{color:#a2bbd8}.note-box{background:#f5f8fc;border-color:#dfe9f5;color:#9ab3cf}
|
||||||
|
.modal{border-color:#dbe6f4;color:#7897bc;box-shadow:0 5px 20px #182f4c0a,0 32px 100px #182f4c28;border-radius:16px}.modal::backdrop{background:#24354c45;backdrop-filter:blur(7px)}.modal header{background:linear-gradient(#fcfdff,#f4f8fd);border-color:#e1eaf6}.modal h2{color:#637fa2;font-weight:550}.modal .eyebrow{color:#97b0ce}.modal label{color:#869fc0}.modal input,.modal select,.modal textarea{border-color:#dce7f5;background:#fcfdff;color:#829fbe}.modal input::placeholder,.modal textarea::placeholder{color:#a7bed8}.modal .muted{color:#9cb4d0}.modal footer{background:#f6f9fd;border-color:#e0eaf7}.model-option{border-color:#e2ecf8}.model-option.selected{border-color:#adc7e6;background:#f1f7fe}.model-option strong{color:#83a2c5}.model-option small{color:#a5bdd9}.search-result{color:#8faecd}.search-result small{color:#a7bfdc}.toast{background:linear-gradient(#526a88,#425a78);color:#fff;border-color:#60799b;box-shadow:inset 0 1px #ffffff1a,0 7px 30px #28436325}
|
||||||
|
/* Text stays neutral; accent is reserved for selection and status. */
|
||||||
|
.conversation-title,.overview-header h1,.modal h2{color:#303640}.wordmark>span:last-child{color:#30353e}.message,.message p,.user-bubble,.composer textarea,.home-composer textarea{color:#555f6e}.agent-byline,.ask-body h3,.section-heading h2,.task-tile h3,.connection-card h3,.schedule-row h3,.paper-body h3,.paper-cover h2{color:#485463}.ask-option strong,.plan-steps .current,.project-banner h3,.model-option strong,.search-result{color:#56677d}.conversation-description,.agent-byline small,.ask-body>.muted,.ask-option small,.task-tile p,.connection-card p,.overview-header .muted,.modal .muted,.paper-body,.paper-cover p{color:#7b8798}.paper-meta,.paper-footer,.paper-body h3 span,.document-cards small,.doc-line,.doc-line span:first-child,.composer-hint,.card-footer>span,.card-heading>span:last-child,.tool-record small,.tool-group summary .tool-result,.connection-card header small,.connection-card footer,.schedule-row small,.activity-entry small,.canvas-bottom,.statusbar,.statusbar-muted{color:#8494aa}.project-banner p,.task-tile>small,.document-tools,.activity-heading,.canvas-tabs button,.file-row small,.project-nav small,.profile small{color:#8492a5}.tool-group summary,.tool-record,.plan-steps li,.compact-plan summary,.receipt,.card-heading,.execution-top,.execution-bottom,.result-card b,.result-card small,.canvas-bottom button,.activity-entry,.file-row,.empty-panel,.note-box,.modal label{color:#7488a3}.paper-cover{background:linear-gradient(125deg,#f4f6f9,#e9eef5)}.paper-body{line-height:2}.paper-cover h2{letter-spacing:-1px}.document-cards b{color:#73869f}.canvas{background:linear-gradient(130deg,#e9edf2,#f1f4f7)}
|
||||||
|
@property --canvas-width{syntax:'<percentage>';inherits:false;initial-value:40%}
|
||||||
|
.task-workspace{--canvas-width:40%;grid-template-columns:minmax(0,1fr) var(--canvas-width);transition:--canvas-width .38s var(--ease)}.task-workspace.canvas-closed{--canvas-width:0%;grid-template-columns:minmax(0,1fr) var(--canvas-width)}.canvas{min-width:320px}.canvas-closed .canvas{visibility:visible;opacity:0;transform:translateX(18px);pointer-events:none}.canvas-content{min-width:0}
|
||||||
|
.compact-plan,.tool-group{interpolate-size:allow-keywords}.compact-plan::details-content,.tool-group::details-content{block-size:0;opacity:0;overflow:clip;transition:block-size .32s var(--ease),opacity .2s,content-visibility .32s;transition-behavior:allow-discrete}.compact-plan[open]::details-content,.tool-group[open]::details-content{block-size:auto;opacity:1}
|
||||||
|
.motion-ripple{position:absolute;pointer-events:none;background:#879dbc20;border:1px solid #879dbc18;border-radius:50%;width:12px;height:12px;transform:translate(-50%,-50%);z-index:2}.motion-pressed{position:relative;overflow:hidden}.tab-marker{position:absolute;height:2px;bottom:0;background:#819dbf;border-radius:3px;pointer-events:none;transition:left .32s var(--ease),width .32s var(--ease)}.canvas-tabs.has-marker button[aria-selected="true"]:after{display:none}.motion-update-tag{position:absolute;top:12px;right:12px;border:1px solid #cddbec;border-radius:5px;background:#f7faff;color:#8198b9;padding:4px 7px;font-size:9px;pointer-events:none}.toast:not([hidden]){animation:toast-in .3s var(--ease)}
|
||||||
|
@keyframes progress-sweep{70%,100%{transform:translateX(100%)}}@keyframes toast-in{from{opacity:0;translate:0 8px}to{opacity:1;translate:0 0}}
|
||||||
|
.picked{animation:selection-ring .35s ease-out}.event-arrival{animation:arrive .42s var(--ease) both}@keyframes selection-ring{0%{box-shadow:0 0 0 0 #9fb9db40}65%{box-shadow:0 0 0 4px #9fb9db15}100%{box-shadow:0 0 0 0 #9fb9db00}}
|
||||||
|
@media(min-width:1600px){.task-workspace{--canvas-width:41%;grid-template-columns:minmax(0,1fr) var(--canvas-width)}}
|
||||||
|
@media(max-width:1150px){.task-workspace{--canvas-width:44%}.canvas{min-width:300px}.canvas-content{padding:17px}}
|
||||||
|
@media(max-width:960px){.task-workspace,.task-workspace.canvas-closed{grid-template-columns:minmax(0,1fr);--canvas-width:0%}.canvas{min-width:0;display:flex;transform:translateX(0);opacity:1;box-shadow:0 3px 12px #20334f0d,0 25px 75px #20334f28}.canvas-closed .canvas{display:flex;visibility:hidden;opacity:0;transform:translateX(calc(100% + 25px));transition:transform .32s var(--ease),opacity .2s,visibility .32s}.canvas-content{padding:19px}}
|
||||||
|
@media(max-width:650px){.studio.mobile-rail .sidebar{box-shadow:10px 0 60px #1d2f4b30}.canvas{top:67px}.task-tile{min-height:154px}.conversation-toolbar{padding-top:15px}.ask-option{padding:12px 10px}}
|
||||||
|
@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
||||||
|
.reduce-motion *,.reduce-motion *:before,.reduce-motion *:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}
|
||||||
|
/* Keep secondary text readable across the document and configuration surfaces. */
|
||||||
|
.conversation-description,.agent-byline small,.ask-body>.muted,.ask-option small,.task-tile p,.connection-card p,.overview-header .muted,.modal .muted,.paper-cover p{color:#66707e}
|
||||||
|
.paper-body,.modal input,.modal select,.modal textarea,.model-option strong,.search-result,.document-cards b,.doc-line{color:#4f5967}
|
||||||
|
.paper-meta,.paper-footer,.document-cards small,.doc-line span:first-child,.composer-hint,.card-footer>span,.card-heading>span:last-child,.tool-record small,.connection-card header small,.connection-card footer,.schedule-row small,.activity-entry small,.canvas-bottom,.statusbar,.project-banner p,.task-tile>small,.document-tools,.activity-heading,.file-row small,.model-option small,.search-result small,.section-heading span,.home-composer>div{color:#727b89}
|
||||||
|
.tool-group summary,.tool-record,.plan-steps li,.compact-plan summary,.receipt,.card-heading,.execution-top,.execution-bottom,.result-card b,.result-card small,.activity-entry,.file-row,.empty-panel,.note-box,.modal label,.composer-bottom button,.canvas-tabs button,.canvas-bottom button{color:#616c7b}
|
||||||
|
.canvas-tabs button[aria-selected="true"],.filter-tabs button.active{color:#354b69}.composer-bottom .composer-model{color:#556171;background:#f1f3f6}.composer-bottom .send{color:white;background:linear-gradient(#424751,#2e333d)}.composer-bottom .send:hover{background:linear-gradient(#535b68,#3c4451)}
|
||||||
|
.composer textarea::placeholder,.home-composer textarea::placeholder,.modal input::placeholder,.modal textarea::placeholder{color:#858d99}.model-option>svg:last-child{color:#617da5}.provider-symbol{color:#627186;background:linear-gradient(#fafbfc,#e8edf3)}.toggle[aria-checked="true"]{background:#526b8d}.toast{background:linear-gradient(#424954,#303640);border-color:#535b68}.document-tools button{color:#647184}.canvas-toolbar .icon-button{color:#737e8e}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="studio" id="studio">
|
||||||
|
<aside class="sidebar" aria-label="工作空间导航">
|
||||||
|
<button class="wordmark" data-action="home"><span class="brand-symbol">r<span></span></span><span>Reasonix<small>PERSONAL WORKSPACE</small></span></button>
|
||||||
|
<button class="new-task" data-action="new"><i data-icon="plus"></i> 新任务 <kbd>Ctrl N</kbd></button>
|
||||||
|
<button class="nav-item" data-action="search"><i data-icon="search"></i> 搜索工作空间 <kbd>⌘ K</kbd></button>
|
||||||
|
<div class="nav-group">
|
||||||
|
<button class="nav-item" data-route="home"><i data-icon="grid"></i> 工作台</button>
|
||||||
|
<button class="nav-item" data-route="inbox"><i data-icon="inbox"></i> 待我处理 <span class="count" id="attentionCount">2</span></button>
|
||||||
|
<button class="nav-item" data-route="library"><i data-icon="files"></i> 我的成果</button>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-label">项目 <button aria-label="创建项目" data-action="project"><i data-icon="plus"></i></button></div>
|
||||||
|
<button class="project-nav" data-action="project-info"><span class="project-avatar">A</span><span>Atelier 品牌工作室<small>策略 · 产品 · 内容</small></span><i data-icon="chevron"></i></button>
|
||||||
|
<div class="sidebar-label">最近任务 <span id="taskCount"></span></div>
|
||||||
|
<div class="task-list" id="taskList"></div>
|
||||||
|
<div class="sidebar-bottom">
|
||||||
|
<button class="nav-item" data-route="automation"><i data-icon="clock"></i> 定时任务</button>
|
||||||
|
<button class="nav-item" data-route="capabilities"><i data-icon="puzzle"></i> 能力与连接</button>
|
||||||
|
<div class="profile"><span class="profile-avatar">L</span><span>我的工作空间<small><span class="dot green"></span> 本地交互原型</small></span><button class="icon-button" data-action="settings" aria-label="界面设置"><i data-icon="settings"></i></button></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="body-shell">
|
||||||
|
<header class="topbar"><div class="breadcrumb"><button class="icon-button" data-action="sidebar" aria-label="切换导航"><i data-icon="panel"></i></button><span>工作空间</span><span class="slash">/</span><strong id="pageTitle">秋季新品发布策略</strong></div><div class="top-actions"><span class="prototype-label">DESIGN PROTOTYPE</span><button class="icon-button" data-action="search" aria-label="搜索"><i data-icon="search"></i></button><button class="icon-button" data-action="notifications" aria-label="通知"><i data-icon="bell"></i><b id="notificationDot"></b></button><span class="top-avatar">L</span></div></header>
|
||||||
|
<section id="page" class="page"></section>
|
||||||
|
<footer class="statusbar"><span><i class="dot green"></i> Reasonix Studio <span class="statusbar-muted">/ 概念交互版</span></span><span id="bottomStatus">模型与工具调用均为模拟,不访问真实服务</span><span class="statusbar-muted">所有交互仅在本次页面中保留</span></footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dialog id="modal" class="modal"><header><div><span class="eyebrow" id="modalEyebrow">WORKSPACE</span><h2 id="modalTitle"></h2></div><button class="icon-button" data-action="close-modal" aria-label="关闭弹窗"><i data-icon="close"></i></button></header><div id="modalBody" class="modal-body"></div><footer id="modalFooter"></footer></dialog>
|
||||||
|
<input type="file" id="attachmentInput" hidden multiple>
|
||||||
|
<div class="toast" id="toast" role="status" hidden></div>
|
||||||
|
<div id="announcer" class="sr-only" aria-live="polite"></div>
|
||||||
|
<script>
|
||||||
|
(()=>{
|
||||||
|
'use strict';
|
||||||
|
const $=id=>document.getElementById(id);
|
||||||
|
const paths={plus:'M12 5v14M5 12h14',search:'M21 21l-5-5M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0',grid:'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z',inbox:'M4 4h16l2 12v4H2v-4L4 4zM2 15h6l2 3h4l2-3h6',files:'M6 3h9l4 4v14H6zM14 3v5h5M3 7v14M9 12h7M9 16h7',chevron:'M9 5l7 7-7 7',clock:'M12 8v5l3 2M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',puzzle:'M9 3H3v6c4-2 5 4 0 3v9h7c-2-5 5-5 3 0h8v-8c-5 2-5-5 0-3V3h-7c2 5-5 5-5 0',settings:'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8M9 2h6l1 4 4 1 2 5-3 3v4l-5 3-3-3H7l-4-4 1-4-2-3 3-4 4 1z',panel:'M3 3h18v18H3zM9 3v18',bell:'M5 17h14l-2-3V9a5 5 0 0 0-10 0v5l-2 3M10 21h4',close:'M6 6l12 12M6 18L18 6',arrow:'M5 12h14M13 6l6 6-6 6',up:'M12 19V5M6 11l6-6 6 6',check:'M5 12l4 4L19 6',checkcircle:'M8 12l3 3 5-6M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',question:'M9 8a3 3 0 1 1 4 3c-1 0-1 1-1 3M12 17h.01M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',shield:'M12 2l9 4v6c0 5-9 10-9 10S3 17 3 12V6l9-4M8 12l3 3 5-6',link:'M10 13a5 5 0 0 0 7 0l4-4a5 5 0 0 0-7-7l-2 2M14 11a5 5 0 0 0-7 0l-4 4a5 5 0 0 0 7 7l2-2',download:'M12 3v12M7 10l5 5 5-5M4 16v5h16v-5',edit:'M4 16L16 4l4 4L8 20H4v-4M14 6l4 4',more:'M5 12h.01M12 12h.01M19 12h.01',globe:'M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0M2 12h20M12 2c-5 5-5 15 0 20 5-5 5-15 0-20',code:'M8 6l-6 6 6 6M16 6l6 6-6 6M14 3l-4 18',folder:'M2 5h7l2 3h11v13H2V5z',paperclip:'M8 12l7-7a4 4 0 0 1 6 6l-9 9a6 6 0 0 1-9-9l9-9M6 14l9-9a2 2 0 0 1 3 3l-9 9',pause:'M8 5v14M16 5v14',play:'M7 4l14 8-14 8V4z',stop:'M6 6h12v12H6z',warning:'M12 3L1 21h22L12 3zM12 9v5M12 17h.01',list:'M9 6h12M9 12h12M9 18h12M3 6h.01M3 12h.01M3 18h.01',spark:'M12 2l3 7 7 3-7 3-3 7-3-7-7-3 7-3 3-7z',layers:'M12 2l10 6-10 6L2 8l10-6M2 12l10 6 10-6M2 16l10 6 10-6',expand:'M8 3H3v5M16 3h5v5M3 16v5h5M21 16v5h-5',mail:'M2 5h20v14H2zM2 5l10 8L22 5',refresh:'M20 7a9 9 0 1 0 1 8M20 2v6h-6',archive:'M3 3h18v5H3zM5 8v13h14V8M10 12h4'};
|
||||||
|
const icon=name=>`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${paths[name]||paths.spark}"/></svg>`;
|
||||||
|
function icons(root=document){root.querySelectorAll('[data-icon]').forEach(el=>{el.outerHTML=icon(el.dataset.icon)})}
|
||||||
|
const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const button=(action,label,cls='',data='')=>`<button class="${cls}" data-action="${action}" ${data}>${label}</button>`;
|
||||||
|
const statusLabels={waiting:'等你回答',approval:'需要授权',running:'执行中',paused:'已暂停',done:'已完成',blocked:'需要处理',idle:'就绪'};
|
||||||
|
const statusIcon=s=>s==='waiting'?'question':s==='approval'?'shield':s==='blocked'?'warning':s==='done'?'check':'clock';
|
||||||
|
const state={route:'task',active:'launch',canvas:true,canvasTab:'preview',capTab:'models',project:'Atelier 品牌工作室',models:[{id:'balanced',name:'通用模型',description:'标准对话 · 工具调用 · 流式输出',provider:'演示服务',protocol:'OpenAI 兼容',endpoint:'',effort:true},{id:'fast',name:'快速模型',description:'轻量问答 · 工具调用',provider:'演示服务',protocol:'OpenAI 兼容',endpoint:'',effort:false}],tools:[{id:'web',name:'网页检索',description:'搜索公开资料,读取页面并保留来源。',kind:'内置工具',icon:'globe',enabled:true},{id:'files',name:'工作区文件',description:'读取与编辑当前项目内的文件。',kind:'本地能力',icon:'folder',enabled:true},{id:'notion',name:'Notion',description:'搜索项目知识库,读取页面和数据库。',kind:'MCP 连接器',icon:'files',enabled:false},{id:'http',name:'自定义 HTTP API',description:'通过适配器连接业务服务。',kind:'HTTP API',icon:'link',enabled:false}],skills:[{id:'research',name:'研究与整理',description:'整理来源、比较信息并形成可核查的结论。',enabled:true},{id:'brand',name:'品牌写作',description:'在项目内保持一致的语气、术语和表达。',enabled:true},{id:'sheet',name:'表格分析',description:'分析数据、整理表格并检查计算。',enabled:false}],schedules:[{id:'weekly',name:'每周行业动态',prompt:'汇总本周与产品相关的行业动态,并保留来源。',cadence:'每周一 09:00',enabled:true}],drafts:{},attachments:{},askChoices:{},permission:'ask',model:'balanced',effort:'标准',notificationCount:0};
|
||||||
|
let sequence=20,toastTimer,clockTimer;
|
||||||
|
const tasks=[
|
||||||
|
{id:'launch',title:'秋季新品发布策略',summary:'定位、内容节奏与渠道建议',status:'waiting',kind:'strategy',model:'balanced',version:1,step:2,ticks:0,artifact:true,ask:true,choice:null,permission:null,goal:'结合项目资料,帮我整理一份秋季新品发布策略。先明确产品定位,再给出内容与渠道建议。',plan:['阅读项目资料与品牌约定','梳理产品定位与目标人群','制定发布节奏与渠道建议','整理成可讨论的策略文档'],messages:[{type:'user',text:'结合项目资料,帮我整理一份秋季新品发布策略。先明确产品定位,再给出内容与渠道建议。'},{type:'agent',text:'我已经整理了项目里的品牌方向和产品介绍,先搭好了策略文档的结构。\n发布节奏还需要明确一个重点:这次更希望让新用户认识品牌,还是推动已有用户尝试新品?',tools:['已读取 品牌手册.md','已读取 新品介绍.md','已整理 3 个发布方向']}],events:['开始整理发布策略','读取 2 份项目资料','完成定位初稿,等待确认目标'],revision:''},
|
||||||
|
{id:'research',title:'个人助手产品研究',summary:'比较交互方式与核心能力',status:'idle',kind:'research',model:'balanced',version:0,step:1,ticks:0,artifact:false,ask:false,goal:'比较主流个人助手的交互方式,整理一份产品研究简报。',plan:['明确比较维度','收集产品资料','整理功能与交互差异','形成研究简报'],messages:[{type:'user',text:'比较主流个人助手的交互方式,整理一份产品研究简报。'},{type:'agent',text:'我会从任务组织、工具接入和成果交付三个角度展开,保留来源。你可以在我工作时继续补充关注点。'}],events:['研究计划已准备好'],revision:''},
|
||||||
|
{id:'email',title:'合作邀约邮件',summary:'草稿已完成,发送前等待确认',status:'approval',kind:'email',model:'fast',version:1,step:2,ticks:0,artifact:true,ask:false,permission:'pending',goal:'根据项目内容起草一封合作邀约邮件。',plan:['梳理合作背景','起草邮件','确认收件人和内容','发送并记录结果'],messages:[{type:'user',text:'起草一封合作邀约邮件,发送前让我确认。'},{type:'agent',text:'邮件草稿已经准备好,你可以在右侧检查。下一步是发送邮件,这会联系外部收件人,需要你确认。'}],events:['已生成邮件草稿','等待发送授权'],revision:''},
|
||||||
|
{id:'analysis',title:'用户访谈主题整理',summary:'整理完成,含主题与后续问题',status:'done',kind:'research',model:'balanced',version:1,step:4,ticks:0,artifact:true,ask:false,goal:'把访谈笔记中的主要主题整理出来。',plan:['读取访谈笔记','整理主题','标记待验证问题','交付主题简报'],messages:[{type:'user',text:'把访谈笔记中的主要主题整理出来。'},{type:'agent',text:'主题简报已整理完成。示例文档列出了重点方向与后续验证问题,你可以直接打开,也可以继续提出修改。'}],events:['读取资料','整理主题','完成交付'],revision:''},
|
||||||
|
{id:'sync',title:'同步项目知识库',summary:'连接失效,现有进度已保存',status:'blocked',kind:'research',model:'balanced',version:0,step:1,ticks:0,artifact:false,ask:false,goal:'同步项目知识库里的最新资料。',plan:['检查已有资料','读取远程更新','合并新增内容','生成同步记录'],messages:[{type:'user',text:'同步项目知识库里的最新资料。'},{type:'agent',text:'本地资料已检查完成。知识库的访问凭证失效,无法继续读取远程更新;本地文件没有被修改。'}],events:['完成本地资料检查','连接返回身份验证错误'],revision:''}
|
||||||
|
];
|
||||||
|
function current(){return tasks.find(t=>t.id===state.active)||tasks[0]}
|
||||||
|
function pill(t){return `<span class="status-pill ${t.status}"><span class="dot ${t.status==='waiting'||t.status==='approval'?'amber':t.status==='blocked'?'red':t.status==='running'?'running':'green'}"></span>${statusLabels[t.status]}</span>`}
|
||||||
|
function announce(text){$('announcer').textContent=text}
|
||||||
|
function toast(text){clearTimeout(toastTimer);$('toast').textContent=text;$('toast').hidden=false;toastTimer=setTimeout(()=>$('toast').hidden=true,3500)}
|
||||||
|
function renderSidebar(){const visible=tasks.filter(t=>!t.archived);$('taskCount').textContent=visible.length;$('taskList').innerHTML=visible.map(t=>`<button class="task-item ${state.route==='task'&&state.active===t.id?'active':''}" data-task="${t.id}"><span class="dot ${t.status==='running'?'running':t.status==='done'?'green':t.status==='blocked'?'red':'amber'}"></span><span class="task-name">${esc(t.title)}</span>${['waiting','approval','blocked'].includes(t.status)?`<span class="task-status-icon">${icon(statusIcon(t.status))}</span>`:''}</button>`).join('');$('attentionCount').textContent=tasks.filter(t=>!t.archived&&['waiting','approval','blocked'].includes(t.status)).length;$('notificationDot').hidden=state.notificationCount===0;document.querySelectorAll('[data-route]').forEach(b=>b.classList.toggle('active',b.dataset.route===state.route));const label=document.querySelector('.project-nav>span:nth-child(2)');label.innerHTML=esc(state.project)+'<small>策略 · 产品 · 内容</small>'}
|
||||||
|
function render(){renderSidebar();if(state.route==='task')renderTask();else renderPage();icons();}
|
||||||
|
function navigate(route,id){state.route=route;if(id)state.active=id;$('studio').classList.remove('mobile-rail');render();}
|
||||||
|
function messageHTML(m,index){if(m.type==='user')return `<section class="message user" data-message="${index}"><div class="user-bubble">${esc(m.text)}</div></section>`;return `<section class="message" data-message="${index}"><div class="agent-byline"><span class="agent-avatar">r</span>Reasonix<small>${m.steering?'已接收补充':'通用助手'}</small></div><p>${esc(m.text)}</p>${m.tools?`<details class="tool-group"><summary>${icon('checkcircle')}<span>${m.tools.length} 项工作已完成</span><span class="tool-result">查看记录</span>${icon('chevron')}</summary>${m.tools.map(x=>`<div class="tool-record">${icon('check')}<span>${esc(x)}</span><small>示例</small></div>`).join('')}</details>`:''}</section>`}
|
||||||
|
function planHTML(t){if(!t.plan.length)return '';const n=Math.min(t.step,t.plan.length);return `<details class="compact-plan" id="taskPlan"><summary>${icon('list')}<span>执行计划</span><span class="plan-total">${n} / ${t.plan.length}</span><span class="chevron-small">${icon('chevron')}</span></summary><ol class="plan-steps">${t.plan.map((x,i)=>`<li class="${i<n?'complete':i===n?'current':''}"><span class="step-circle">${i<n?'✓':i+1}</span><span>${esc(x)}</span></li>`).join('')}</ol><div style="padding:0 8px 8px;text-align:right">${button('edit-plan','调整计划','text-button')}</div><div class="plan-progress"><i style="width:${100*n/t.plan.length}%"></i></div></details>`}
|
||||||
|
function askHTML(t){const options=[['品牌认知','让更多目标用户认识我们,建立清晰印象。'],['新品转化','面向已有用户,强调产品价值与尝试理由。'],['两者兼顾','分阶段推进:先建立认知,再承接转化。']];const selected=state.askChoices[t.id]??0;return `<section class="ask-card" id="activeDecision"><div class="card-heading">${icon('question')}<span>需要你确认一个方向</span><span>问题 1 / 1</span></div><div class="ask-body"><h3>这次发布,更希望优先达成什么?</h3><p class="muted">这会影响文档里的渠道权重和内容安排。</p><div class="ask-options">${options.map((x,i)=>`<button class="ask-option ${selected===i?'selected':''}" data-choice="${i}" aria-pressed="${selected===i}"><span class="radio"></span><span><strong>${x[0]}</strong><small>${x[1]}</small></span>${i===0?'<span class="recommended">建议</span>':''}</button>`).join('')}</div><input class="ask-free" id="askFree" aria-label="自定义回答" placeholder="也可以写下更具体的目标…" value="${esc(t.askDraft||'')}"></div><footer class="card-footer"><span>回答后继续,已有内容会保留。</span>${button('answer','确认方向 '+icon('arrow'),'primary')}</footer></section>`}
|
||||||
|
function approvalHTML(){return `<section class="ask-card approval-card" id="activeDecision"><div class="card-heading">${icon('shield')}<span>发送前需要你的授权</span><span>外部操作</span></div><div class="ask-body"><h3>发送这封合作邀约邮件?</h3><p class="muted">收件人:合作伙伴(示例)<br>主题:关于秋季新品的内容合作</p><div class="approval-scope"><span>仅这封邮件</span><span>不读取其他邮件</span><span>可先修改草稿</span></div><p class="muted">原型只演示确认过程,不会发送邮件。</p></div><footer class="card-footer">${button('deny','保留草稿','outline')}<div>${button('edit-artifact','修改内容','text-button')} ${button('approve','确认发送','primary')}</div></footer></section>`}
|
||||||
|
function blockedHTML(){return `<section class="ask-card"><div class="card-heading">${icon('warning')}<span>连接需要处理</span><span>AUTH_EXPIRED</span></div><div class="ask-body"><h3>知识库授权已过期</h3><p class="muted">可以重新连接,或仅使用本地资料继续。此前的工作不会丢失。</p></div><footer class="card-footer">${button('skip-connection','使用本地资料','outline')}${button('reconnect','重新连接','primary')}</footer></section>`}
|
||||||
|
function executionHTML(t){return `<div class="execution" id="execution"><div class="execution-top">${t.status==='running'?'<span class="spinner"></span>':icon('pause')}<span id="executionLabel">${t.status==='paused'?'执行已暂停':t.plan[Math.min(t.step,t.plan.length-1)]||'整理任务成果'}</span>${button(t.status==='paused'?'resume':'pause',t.status==='paused'?'继续':'暂停','small outline')}</div><div class="execution-bottom" id="executionDetail">${t.status==='paused'?'已有进度保留,可随时补充要求。':'正在推进 · 演示执行事件'}</div><div class="run-progress"><i id="executionProgress" style="width:${Math.min(90,20+t.ticks*14)}%"></i></div>${t.kind==='research'?'<div class="subagents"><span class="subagent">资料整理 · 工作中</span><span class="subagent">观点归纳 · 工作中</span></div>':''}</div>`}
|
||||||
|
function resultHTML(t){return `<button class="result-card" data-action="open-artifact"><span class="result-icon">${icon(t.kind==='email'?'mail':'files')}</span><span><b>${esc(t.title)}${t.kind==='email'?' · 草稿':'.md'}</b><small>版本 ${t.version} · ${t.status==='done'?'已完成':'可查看草稿'} · 点击打开</small></span>${icon('arrow')}</button>`}
|
||||||
|
function tailHTML(t){if(t.ask&&t.status==='waiting')return askHTML(t);if(t.status==='approval')return approvalHTML();if(t.status==='blocked')return blockedHTML();if(['running','paused'].includes(t.status))return executionHTML(t);if(t.status==='idle')return `<div class="receipt">${icon('checkcircle')}执行计划已就绪,随时可以补充要求。</div>${button('start','开始执行 '+icon('arrow'),'primary small')}`;if(t.artifact)return resultHTML(t);return ''}
|
||||||
|
function composerHTML(t){const model=state.models.find(m=>m.id===t.model)||state.models[0];return `<div class="composer-zone">${['running','paused'].includes(t.status)?`<div class="steering-banner"><span>${icon('spark')} ${t.status==='running'?'Agent 工作中,你仍然可以补充或调整方向':'任务已暂停,可以修改要求后继续'}</span>${button(t.status==='running'?'pause':'resume',t.status==='running'?'暂停':'继续','text-button')}</div>`:''}<form class="composer" id="composer"><div class="attached-files">${(state.attachments[t.id]||[]).map((f,i)=>`<span class="file-chip">${icon('files')}${esc(f)}<button type="button" data-remove-file="${i}" aria-label="移除附件 ${esc(f)}">${icon('close')}</button></span>`).join('')}</div><textarea id="messageInput" rows="2" aria-label="消息" placeholder="${t.status==='waiting'?'直接回复,或者补充你希望达成的目标…':t.status==='running'?'补充要求,Agent 会在执行中接收…':'继续聊聊,或告诉 Agent 下一步…'}">${esc(state.drafts[t.id]||'')}</textarea><div class="composer-bottom"><button type="button" data-action="attach" aria-label="添加附件">${icon('plus')}</button><button type="button" data-action="context">${icon('folder')} 项目上下文</button><button type="button" class="composer-model" data-action="model">${icon('spark')} ${esc(model.name)}</button><button type="button" data-action="permission" aria-label="权限设置">${icon('shield')}</button><button class="send" type="submit" aria-label="发送消息">${icon('up')}</button></div></form><div class="composer-hint"><span>Enter 发送 · Shift + Enter 换行</span><span>${(t.permissionMode||'ask')==='ask'?'必要时询问':'演示自动审批'} · ${model.effort?(t.effort||'标准'):'自动'}</span></div></div>`}
|
||||||
|
function renderTask(){const t=current();$('pageTitle').textContent=t.title;$('page').innerHTML=`<div class="task-workspace ${state.canvas?'':'canvas-closed'}"><main class="conversation"><div class="conversation-toolbar"><div class="task-meta"><span class="project-tag">${esc(state.project)}</span>${pill(t)}</div><div class="conversation-actions"><button class="icon-button" data-action="toggle-canvas" aria-label="${state.canvas?'收起':'打开'}工作区">${icon('panel')}</button><button class="icon-button" data-action="task-menu" aria-label="任务操作">${icon('more')}</button></div></div><div class="transcript" id="transcript"><h1 class="conversation-title">${esc(t.title)}</h1><p class="conversation-description">${esc(t.summary||'一个新的想法,逐步变成成果。')} <span style="margin:0 7px">·</span> 本次工作</p><div id="messages">${t.messages.map(messageHTML).join('')}</div>${planHTML(t)}<div id="taskTail">${tailHTML(t)}</div></div>${composerHTML(t)}</main><aside class="canvas" aria-label="任务工作区"><div class="canvas-toolbar"><div class="canvas-tabs" role="tablist" aria-label="工作区内容">${[['preview','成果预览'],['files','文件'],['activity','执行记录']].map(([id,label])=>`<button id="canvas-tab-${id}" role="tab" aria-controls="canvasContent" aria-selected="${state.canvasTab===id}" data-canvas-tab="${id}">${label}</button>`).join('')}</div><button class="icon-button" data-action="toggle-canvas" aria-label="收起工作区">${icon('close')}</button></div><div class="canvas-content" id="canvasContent" role="tabpanel" aria-labelledby="canvas-tab-${state.canvasTab}"></div><div class="canvas-bottom"><span>内容与当前任务保持关联</span>${button('edit-artifact',icon('edit')+' 提出修改')}</div></aside></div>`;renderCanvas();}
|
||||||
|
function documentHTML(t){const isEmail=t.kind==='email';const subtitle=isEmail?'一个真诚、简洁的合作邀请。':t.kind==='strategy'?'让新产品,以恰当的方式被看见。':'从分散的信息,到清晰的判断。';const body=isEmail?`<h3><span>01</span>邮件正文</h3><p>你好,\n\n我们正在准备秋季新品发布,希望与你讨论一次内容合作。我们欣赏你对日常体验的细腻表达,也期待一起呈现产品在真实生活中的价值。</p><p>如果你有兴趣,我们可以先交流产品方向、内容形式与时间安排,再共同确定具体方案。</p><p>期待你的回复。\nAtelier 团队</p>`:`<h3><span>01</span>${t.kind==='strategy'?'核心方向':'研究目标'}</h3><p>${t.choice?`以「${esc(t.choice)}」为主要方向,`:'以用户的实际需求为出发点,'}${t.kind==='strategy'?'将产品的功能价值转化成容易理解的生活场景,先解释为什么需要,再说明有什么不同。':'围绕实际使用过程,整理关键能力、交互方式与值得继续验证的问题。'}</p><div class="document-cards"><div><b>${t.kind==='strategy'?'表达重点':'任务连续性'}</b><small>${t.kind==='strategy'?'真实使用场景\n清楚、克制的产品表达':'上下文、计划与执行过程\n在同一任务中保持连贯'}</small></div><div><b>${t.kind==='strategy'?'内容原则':'用户可控性'}</b><small>${t.kind==='strategy'?'减少空泛的描述\n用具体体验解释价值':'可以补充要求、检查成果\n在重要操作前做出决定'}</small></div></div><h3><span>02</span>${t.kind==='strategy'?'推进节奏':'接下来需要验证'}</h3><div class="doc-line"><span>第一步</span><span>${t.kind==='strategy'?'预热:建立问题认知与期待':'明确比较对象与使用场景'}</span></div><div class="doc-line"><span>第二步</span><span>${t.kind==='strategy'?'发布:解释产品价值与使用方式':'通过实际任务观察执行过程'}</span></div><div class="doc-line"><span>第三步</span><span>${t.kind==='strategy'?'延续:收集反馈,补充真实案例':'检查结果质量与中途干预方式'}</span></div>`;return `<div class="document-tools"><span>${icon('files')} ${esc(t.title)}.md</span>${button('versions','v'+t.version+' ▾')}</div><article class="paper"><header class="paper-cover"><span class="eyebrow">ATELIER / ${isEmail?'CORRESPONDENCE':'WORKING DOCUMENT'}</span><h2>${esc(t.title)}</h2><p>${subtitle}</p><div class="cover-art" aria-hidden="true"><span></span><span></span><span></span></div></header><div class="paper-meta"><span>${t.status==='done'?'已交付':'工作草稿'}</span><span>版本 ${t.version}</span><span>中文</span></div><div class="paper-body">${body}${t.revision?`<h3><span>03</span>本轮修改要求</h3><p>${esc(t.revision)}</p>`:''}</div><footer class="paper-footer"><span>REASONIX STUDIO</span><span>原型示例内容 · 非真实执行结果</span></footer></article>`}
|
||||||
|
function renderCanvas(){if(state.route!=='task'||!$('canvasContent'))return;const t=current();document.querySelectorAll('[data-canvas-tab]').forEach(b=>b.setAttribute('aria-selected',String(state.canvasTab===b.dataset.canvasTab)));$('canvasContent').setAttribute('aria-labelledby','canvas-tab-'+state.canvasTab);if(state.canvasTab==='preview')$('canvasContent').innerHTML=t.artifact?documentHTML(t):`<div class="empty-panel">${icon('files')}成果会在这里逐步形成。<br><span class="muted">当前可以先查看文件与执行记录。</span></div>`;if(state.canvasTab==='files')$('canvasContent').innerHTML=`<h3 class="activity-heading">项目输入</h3>${['品牌手册.md','新品介绍.md'].map((x,i)=>`<button class="file-row" data-source="${i}">${icon('files')}<span>${x}<small>项目资料 · 示例</small></span><span class="arrow">${icon('chevron')}</span></button>`).join('')}<h3 class="activity-heading" style="margin-top:28px">本次产出</h3>${t.artifact?`<button class="file-row" data-action="open-artifact">${icon('files')}<span>${esc(t.title)}.md<small>版本 ${t.version} · 可预览和下载</small></span></button>`:'<p class="muted">暂无产出文件</p>'}`;if(state.canvasTab==='activity')$('canvasContent').innerHTML=`<h3 class="activity-heading">执行事件 · 模拟数据</h3>${t.events.map((event,i)=>`<div class="activity-entry">${esc(event)}<small>步骤 ${i+1} · ${i===t.events.length-1?'最新进展':'已记录'}</small></div>`).join('')}`;const bottom=document.querySelector('.canvas-bottom');bottom.innerHTML=t.artifact?`<span>${t.kind==='email'?'邮件草稿':'Markdown 文档'} · 可继续迭代</span><div>${button('download',icon('download')+' 下载')}${button('edit-artifact',icon('edit')+' 修改')}</div>`:'<span>工作区会随任务更新</span>';}
|
||||||
|
function taskTile(t){return `<button class="task-tile" data-task="${t.id}">${pill(t)}<h3>${esc(t.title)}</h3><p>${esc(t.summary||t.goal)}</p><small>${t.artifact?`已有成果 · v${t.version}`:'保留完整任务上下文'} ${t.archived?'· 已归档':''}</small></button>`}
|
||||||
|
function overviewHeader(eyebrow,title,desc,action=''){return `<div class="overview-header"><div><div class="eyebrow">${eyebrow}</div><h1>${title}</h1><p class="muted">${desc}</p></div>${action}</div>`}
|
||||||
|
function renderPage(){const labels={home:'工作台',inbox:'待我处理',library:'我的成果',capabilities:'能力与连接',automation:'定时任务'};$('pageTitle').textContent=labels[state.route];let html='';if(state.route==='home')html=overviewHeader('YOUR WORK, IN ONE PLACE','让想法,逐步成为成果。','开始一个新任务,或继续手头的工作。')+`<form class="home-composer" id="homeComposer"><textarea id="homePrompt" rows="2" aria-label="新任务目标" placeholder="今天,你想完成什么?"></textarea><div><span>${icon('folder')} ${esc(state.project)}</span><button class="primary" type="submit">开始工作 ${icon('arrow')}</button></div></form><div class="section-heading"><h2>继续你的工作</h2><span>${tasks.filter(t=>!t.archived).length} 个任务</span></div><div class="task-grid">${tasks.filter(t=>!t.archived).map(taskTile).join('')}</div><div class="project-banner"><span class="project-avatar">A</span><div><h3>${esc(state.project)}</h3><p>共享项目资料、工作约定与长期上下文。</p></div>${button('project-info','查看项目 '+icon('arrow'),'outline')}</div>`;
|
||||||
|
if(state.route==='inbox'){const waiting=tasks.filter(t=>!t.archived&&['waiting','approval','blocked'].includes(t.status));html=overviewHeader('YOUR ATTENTION','在这里,做关键决定。','需要回答、授权或处理的问题集中在这里。')+`<div class="task-grid">${waiting.length?waiting.map(taskTile).join(''):'<p class="muted">当前没有等待处理的任务。</p>'}</div>`}
|
||||||
|
if(state.route==='library')html=overviewHeader('MADE WITH REASONIX','值得留下来的成果。','打开、检查,再继续打磨。每份成果都能回到它所属的任务。')+`<div class="task-grid">${tasks.filter(t=>t.artifact).map(taskTile).join('')}</div>`;
|
||||||
|
if(state.route==='capabilities')html=capabilitiesHTML();if(state.route==='automation')html=overviewHeader('WORK THAT COMES BACK','把重复工作交给日程。','管理任务的目标、运行时间和启用状态。',button('add-schedule','新建定时任务 '+icon('plus'),'primary'))+state.schedules.map(s=>`<div class="schedule-row">${icon('clock')}<div><h3>${esc(s.name)}</h3><small>${esc(s.cadence)} · ${s.enabled?'已启用':'已暂停'} · 仅演示配置</small></div><button class="toggle" role="switch" aria-label="启用 ${esc(s.name)}" aria-checked="${s.enabled}" data-schedule-toggle="${s.id}"></button><button class="icon-button" data-schedule-edit="${s.id}" aria-label="编辑 ${esc(s.name)}">${icon('edit')}</button></div>`).join('')+'<div class="note-box">这是定时任务的界面原型,不会在后台真正调度。正式产品需要显示执行位置、运行历史与失败通知。</div>';$('page').innerHTML='<main class="overview-page">'+html+'</main>'}
|
||||||
|
function capabilitiesHTML(){const tabs=[['models','模型服务'],['tools','工具与连接器'],['skills','技能']];let cards='';if(state.capTab==='models')cards=state.models.map(m=>`<article class="connection-card"><header><span class="provider-symbol">${m.name.slice(0,1)}</span><div><h3>${esc(m.name)}</h3><small>${esc(m.provider)} · ${esc(m.protocol)}</small></div></header><p>${esc(m.description)}</p><footer><span>演示配置 · 未连接</span><button data-edit-model="${m.id}">配置 ${icon('chevron')}</button></footer></article>`).join('');if(state.capTab==='tools')cards=state.tools.map(t=>`<article class="connection-card"><header><span class="provider-symbol">${icon(t.icon)}</span><div><h3>${esc(t.name)}</h3><small>${esc(t.kind)}</small></div></header><p>${esc(t.description)}</p><footer><span>${t.enabled?'已在原型中启用':'尚未启用'} · 模拟</span><button class="toggle" role="switch" aria-label="启用 ${esc(t.name)}" aria-checked="${t.enabled}" data-tool-toggle="${t.id}"></button></footer></article>`).join('');if(state.capTab==='skills')cards=state.skills.map(t=>`<article class="connection-card"><header><span class="provider-symbol">${icon('spark')}</span><div><h3>${esc(t.name)}</h3><small>可复用工作方法</small></div></header><p>${esc(t.description)}</p><footer><span>${t.enabled?'已启用':'已停用'}</span><button class="toggle" role="switch" aria-label="启用 ${esc(t.name)}" aria-checked="${t.enabled}" data-skill-toggle="${t.id}"></button></footer></article>`).join('');return overviewHeader('A WORKSPACE THAT GROWS WITH YOU','连接能力,扩展可能。','模型负责理解,工具负责行动,技能决定工作方法。',button(state.capTab==='models'?'add-model':state.capTab==='tools'?'add-tool':'add-skill','添加'+(state.capTab==='models'?'模型':state.capTab==='tools'?'连接':'技能')+' '+icon('plus'),'primary'))+`<div class="filter-tabs">${tabs.map(([id,label])=>`<button class="${id===state.capTab?'active':''}" data-cap-tab="${id}" aria-pressed="${id===state.capTab}">${label}</button>`).join('')}</div><div class="connection-grid">${cards}</div><div class="note-box">可扩展接入不等于所有 API 都能直接互换。模型协议、工具调用、流式事件和结果格式需要适配;本原型展示配置方式,不发起真实请求。</div>`}
|
||||||
|
function openModal(title,body,footer='',eyebrow='WORKSPACE'){const d=$('modal');$('modalTitle').textContent=title;$('modalEyebrow').textContent=eyebrow;$('modalBody').innerHTML=body;$('modalFooter').innerHTML=footer||button('close-modal','完成','primary');if($('permissionMode'))$('permissionMode').value=current().permissionMode||'ask';if($('reasoningEffort'))$('reasoningEffort').value=current().effort||'标准';if($('motionMode'))$('motionMode').value=document.body.classList.contains('reduce-motion')?'reduce':'system';if($('fontScale'))$('fontScale').value=document.body.classList.contains('large-type')?'large':'normal';icons(d);if(!d.open)d.showModal();d.classList.remove('dialog-opening');void d.offsetWidth;d.classList.add('dialog-opening')}
|
||||||
|
function closeModal(){$('modal').close()}
|
||||||
|
function formError(text){let e=$('formError');if(!e){e=document.createElement('p');e.id='formError';e.className='form-error';e.setAttribute('role','alert');$('modalBody').appendChild(e)}e.textContent=text}
|
||||||
|
function openModelForm(id){const m=state.models.find(x=>x.id===id);openModal(m?'配置模型服务':'添加模型服务',`<p class="muted">配置模型的服务地址、协议和能力。密钥通过凭证引用管理,原型不接收真实密钥。</p><label for="providerName">服务名称</label><input id="providerName" value="${esc(m?.provider||'')}" placeholder="例如:团队模型服务"><div class="form-grid"><div><label for="modelName">模型标识</label><input id="modelName" value="${esc(m?.name||'')}" placeholder="provider/model-name"></div><div><label for="protocol">协议</label><select id="protocol"><option>OpenAI 兼容</option><option>Anthropic Messages</option><option>自定义适配器</option></select></div></div><label for="endpoint">API 地址</label><input id="endpoint" type="url" value="${esc(m?.endpoint||'')}" placeholder="https://api.example.com/v1"><label for="credential">凭证引用(仅名称)</label><input id="credential" placeholder="例如:team-model-key,不要填写真实密钥"><label for="effortSupport">推理强度</label><select id="effortSupport"><option value="yes">模型支持调整</option><option value="no">模型不支持调整</option></select><label for="adapter">自定义适配器标识(使用自定义协议时必填)</label><input id="adapter" placeholder="my-provider-adapter">`,button('close-modal','取消','outline')+button('save-model','保存演示配置','primary',`data-id="${esc(id||'')}"`),'MODEL PROVIDER');if(m){$('protocol').value=m.protocol;$('effortSupport').value=m.effort?'yes':'no'}}
|
||||||
|
function saveModel(id){const provider=$('providerName').value.trim(),name=$('modelName').value.trim(),endpoint=$('endpoint').value.trim(),protocol=$('protocol').value;let url;try{url=new URL(endpoint)}catch{}if(!provider||!name||!url||!['https:','http:'].includes(url.protocol)){formError('请填写名称、模型标识和有效的 HTTP / HTTPS 地址。');return}if(protocol==='自定义适配器'&&!$('adapter').value.trim()){formError('请填写适配器标识。');return}const model={id:id||'model-'+sequence++,name,provider,endpoint,protocol,effort:$('effortSupport').value==='yes',description:'自定义服务 · 能力以实际适配器为准'};const i=state.models.findIndex(m=>m.id===id);if(i>=0)state.models[i]=model;else state.models.push(model);closeModal();render();toast('演示配置已保存,未连接真实服务。')}
|
||||||
|
function showSearch(){openModal('搜索工作空间','<input id="searchInput" class="search-field" aria-label="搜索任务" placeholder="搜索任务标题或目标…"><div id="searchResults"></div>','','SEARCH');search('');$('searchInput').focus()}
|
||||||
|
function search(query){$('searchResults').innerHTML=tasks.filter(t=>(t.title+' '+t.goal).toLowerCase().includes(query.toLowerCase())).map(t=>`<button class="search-result" data-task="${t.id}" data-close="true">${icon('files')}<span>${esc(t.title)}</span><small>${statusLabels[t.status]}${t.archived?' · 已归档':''}</small></button>`).join('')||'<p class="muted">没有找到匹配的任务。</p>'}
|
||||||
|
function showNew(){openModal('你想完成什么?','<p class="muted">任务可以是一句简单的问题,也可以是一份需要持续完善的成果。</p><label for="newGoal">目标</label><textarea id="newGoal" rows="4" placeholder="描述你希望得到的结果…"></textarea><label for="newProject">所属项目</label><select id="newProject"><option>'+esc(state.project)+'</option></select>',button('close-modal','取消','outline')+button('create-task','开始工作','primary'),'NEW TASK');$('newGoal').focus()}
|
||||||
|
function createTask(goal){if(!goal.trim()){formError('请先描述你希望完成的工作。');return}const t={id:'task-'+sequence++,title:goal.trim().slice(0,24),summary:'根据你的目标持续推进',status:'idle',kind:'research',model:state.model,version:0,step:0,ticks:0,artifact:false,ask:false,goal:goal.trim(),plan:['梳理目标与可用资料','执行任务并整理结果','检查成果与未解决项'],messages:[{type:'user',text:goal.trim()},{type:'agent',text:'目标已记录。这是交互原型,我会用模拟执行展示任务推进、过程中补充要求,以及成果交付。'}],events:['创建新任务'],revision:''};tasks.unshift(t);closeModal();state.canvas=false;navigate('task',t.id)}
|
||||||
|
function refreshTask(t,{animate=false,scroll=false}={}){renderSidebar();if(state.route!=='task'||state.active!==t.id){if(state.route==='home'||state.route==='inbox')renderPage();return}const transcript=$('transcript'),position=transcript.scrollTop,open=$('taskPlan')?.open,draft=$('messageInput')?.value,focus=document.activeElement?.id,selection=$('messageInput')?.selectionStart;state.drafts[t.id]=draft||state.drafts[t.id]||'';renderTask();if($('taskPlan'))$('taskPlan').open=!!open;$('transcript').scrollTop=scroll?$('transcript').scrollHeight:position;if(focus==='messageInput'){$('messageInput').focus();$('messageInput').setSelectionRange(selection,selection)}if(animate){const el=$('taskTail').firstElementChild;if(el)el.classList.add('event-arrival')}}
|
||||||
|
function begin(t){t.status='running';t.ticks=0;t.ask=false;t.events.push('开始执行本轮工作');refreshTask(t,{animate:true,scroll:true});announce('任务已开始,可以继续补充要求。')}
|
||||||
|
function finish(t){t.status='done';t.step=t.plan.length;t.artifact=true;t.version+=1;t.events.push('本轮成果已整理完成');t.messages.push({type:'agent',text:t.revision?'已按本轮要求更新演示文档,并保留修改记录。你可以在工作区检查这个版本。':'本轮任务已完成,成果保留在工作区。这是示例内容,可继续修改或下载。'});state.notificationCount++;refreshTask(t,{animate:true,scroll:true});toast(t.title+' · 本轮成果已完成');announce('成果已完成')}
|
||||||
|
function runTick(){tasks.filter(t=>t.status==='running').forEach(t=>{t.ticks++;if(t.ticks===2){t.step=Math.min(t.plan.length-1,t.step+1);t.events.push('当前步骤已完成,继续下一项')}if(t.ticks===4){t.artifact=true;if(t.version===0)t.version=1;t.events.push('已生成可检查的工作草稿')}if(t.ticks>=6){finish(t);return}if(state.route==='task'&&state.active===t.id){const label=$('executionLabel'),bar=$('executionProgress'),detail=$('executionDetail');if(label)label.textContent=t.plan[Math.min(t.step,t.plan.length-1)]||'整理任务成果';if(bar)bar.style.width=Math.min(95,20+t.ticks*12)+'%';if(detail)detail.textContent=['','正在整理输入内容','当前步骤完成','正在形成下一部分','草稿已更新,可在右侧查看','正在检查交付内容'][t.ticks];const plan=$('taskPlan');if(plan){const open=plan.open;plan.outerHTML=planHTML(t);$('taskPlan').open=open}if(t.ticks===4||state.canvasTab==='activity')renderCanvas()}})}
|
||||||
|
function submitMessage(text){const t=current();if(!text.trim()&&!(state.attachments[t.id]||[]).length)return;const attach=state.attachments[t.id]||[],body=text.trim()+(attach.length?'\n附件(仅名称):'+attach.join('、'):'');t.messages.push({type:'user',text:body});state.drafts[t.id]='';state.attachments[t.id]=[];if($('messageInput'))$('messageInput').value='';if(t.ask&&t.status==='waiting'){t.choice=text.trim()||'结合附件进一步确认';t.ask=false;t.messages.push({type:'agent',text:'已记录你的方向,接下来会据此推进。'});begin(t)}else if(t.status==='done'){t.revision=text.trim();t.messages.push({type:'agent',text:'收到,我会开始下一轮修改。之前的成果仍然可以查看。',steering:true});begin(t)}else{t.revision=text.trim();t.messages.push({type:'agent',text:t.status==='approval'?'补充已记录。发送授权仍需通过确认按钮决定。':t.status==='blocked'?'补充已记录。连接问题仍需要重新连接或选择本地资料。':t.status==='running'?'补充已接收,已加入本轮任务上下文。':t.status==='paused'?'补充已记录,继续执行后会带上这些要求。':'补充已记录,可以开始执行。',steering:true});t.events.push('接收用户补充要求');refreshTask(t,{scroll:true});const last=$('messages')?.lastElementChild;if(last)last.classList.add('event-arrival')}announce('消息已接收')}
|
||||||
|
function download(t){const text='# '+t.title+'\n\n> Reasonix 界面原型示例,不是实际执行结果。\n\n## 原始目标\n'+t.goal+'\n\n## 本轮方向\n'+(t.choice||'以任务目标为准')+'\n\n## 工作步骤\n'+t.plan.map((p,i)=>(i+1)+'. '+p).join('\n')+'\n\n## 最新补充\n'+(t.revision||'暂无')+'\n\n版本:'+t.version;const url=URL.createObjectURL(new Blob([text],{type:'text/markdown;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download=t.title.replace(/[\\/:*?"<>|]/g,'-')+'.md';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast('示例文档已导出')}
|
||||||
|
function scheduleForm(id){const s=state.schedules.find(x=>x.id===id);openModal(s?'编辑定时任务':'新建定时任务',`<p class="muted">这里只保存本页内的演示配置,不会创建真实后台任务。</p><label for="scheduleName">任务名称</label><input id="scheduleName" value="${esc(s?.name||'')}"><label for="schedulePrompt">工作目标</label><textarea id="schedulePrompt" rows="3">${esc(s?.prompt||'')}</textarea><label for="scheduleCadence">运行时间</label><select id="scheduleCadence"><option>每周一 09:00</option><option>每个工作日 08:30</option><option>每天 18:00</option></select>`,button('close-modal','取消','outline')+button('save-schedule','保存','primary',`data-id="${id||''}"`),'SCHEDULE');if(s)$('scheduleCadence').value=s.cadence}
|
||||||
|
const actions={
|
||||||
|
'home':()=>navigate('home'),'new':showNew,'search':showSearch,'close-modal':closeModal,'create-task':()=>createTask($('newGoal').value),
|
||||||
|
'sidebar':()=>{if(innerWidth<=650)$('studio').classList.toggle('mobile-rail');else $('studio').classList.toggle('rail-closed')},
|
||||||
|
'toggle-canvas':()=>{state.canvas=!state.canvas;document.querySelector('.task-workspace').classList.toggle('canvas-closed',!state.canvas);if(state.canvas)document.querySelector('.canvas').classList.add('panel-opening')},
|
||||||
|
'open-artifact':()=>{state.canvas=true;state.canvasTab='preview';document.querySelector('.task-workspace').classList.remove('canvas-closed');renderCanvas();document.querySelector('.canvas').classList.add('panel-opening')},
|
||||||
|
'answer':()=>{const t=current();if(!t.ask)return;const free=$('askFree').value.trim();t.choice=free||['品牌认知','新品转化','两者兼顾'][state.askChoices[t.id]??0];t.messages.push({type:'user',text:t.choice},{type:'agent',text:'已确认:'+t.choice+'。我会据此补充发布节奏与渠道安排。'});t.events.push('用户确认目标:'+t.choice);begin(t)},
|
||||||
|
'start':()=>begin(current()),'pause':()=>{const t=current();t.status='paused';t.events.push('用户暂停执行');refreshTask(t,{animate:true});announce('任务已暂停')},'resume':()=>{const t=current();t.status='running';t.events.push('恢复执行');refreshTask(t,{animate:true})},
|
||||||
|
'approve':()=>{const t=current();t.permission='allowed';t.status='done';t.step=t.plan.length;t.messages.push({type:'agent',text:'已确认发送操作。本原型没有实际发送任何邮件,草稿仍保留在工作区。'});t.events.push('发送授权已确认(未实际发送)');refreshTask(t,{animate:true,scroll:true})},
|
||||||
|
'deny':()=>{const t=current();t.permission='denied';t.status='done';t.step=2;t.plan=['梳理合作背景','起草邮件'];t.messages.push({type:'agent',text:'已取消发送,保留邮件草稿。需要时可以继续修改。'});t.events.push('用户选择仅保留草稿');refreshTask(t,{animate:true,scroll:true})},
|
||||||
|
'skip-connection':()=>{const t=current();t.events.push('用户选择使用本地资料');t.messages.push({type:'agent',text:'接下来只使用已有的本地资料,并在结果中注明缺少远程更新。'});begin(t)},
|
||||||
|
'reconnect':()=>openModal('重新连接知识库','<p class="muted">正式产品将在服务商页面完成登录,并返回连接结果。此处只演示恢复成功后的任务继续。</p>',button('close-modal','稍后处理','outline')+button('simulate-reconnect','模拟重新连接成功','primary'),'RECONNECT'),
|
||||||
|
'simulate-reconnect':()=>{closeModal();current().events.push('知识库连接已恢复(模拟)');begin(current())},
|
||||||
|
'edit-plan':()=>openModal('调整执行计划','<p class="muted">每行一个步骤。保存后重置步骤完成标记,避免旧进度被错误套用。</p><label for="planEditor">任务步骤</label><textarea id="planEditor" rows="6">'+esc(current().plan.join('\n'))+'</textarea>',button('close-modal','取消','outline')+button('save-plan','保存计划','primary'),'TASK PLAN'),
|
||||||
|
'save-plan':()=>{const lines=$('planEditor').value.split('\n').map(x=>x.trim()).filter(Boolean);if(!lines.length){formError('至少保留一个步骤。');return}const t=current();t.plan=lines;t.step=0;t.events.push('用户调整了执行计划');closeModal();refreshTask(t);toast('计划已更新,完成标记已重置')},
|
||||||
|
'edit-artifact':()=>{if(!current().artifact){toast('成果尚未生成,可以先在对话中补充要求。');return}openModal('这份成果,你想怎样调整?','<label for="revisionInput">修改要求</label><textarea id="revisionInput" rows="4" placeholder="例如:把第二部分写得更具体,保留整体结构。"></textarea>',button('close-modal','取消','outline')+button('save-revision','开始修改','primary'),'REFINE OUTPUT')},
|
||||||
|
'save-revision':()=>{const value=$('revisionInput').value.trim();if(!value){formError('请写下需要修改的内容。');return}closeModal();const t=current();t.permission=t.permission==='pending'?'denied':t.permission;t.revision=value;t.messages.push({type:'user',text:value},{type:'agent',text:'修改要求已接收。将更新这份成果,修改要求会保留在成果中;不会执行任何外部发送操作。'});begin(t)},
|
||||||
|
'download':()=>download(current()),'versions':()=>openModal('成果版本','<p class="muted">当前版本 v'+current().version+'。此原型展示版本标签与迭代交互,尚未实现历史内容快照。</p>'),
|
||||||
|
'attach':()=>$('attachmentInput').click(),
|
||||||
|
'context':()=>openModal('这段任务使用的上下文','<p class="muted">项目资料在任务间共享,每段对话保留各自的目标与历史。</p><div class="note-box">项目:'+esc(state.project)+'<br>示例资料:品牌手册.md、新品介绍.md<br>工作约定:优先清晰、具体、可核查的表达。</div><p class="muted" style="margin-top:16px">附件在本原型中只显示文件名,不读取文件内容。</p>','','TASK CONTEXT'),
|
||||||
|
'model':()=>openModal('选择当前任务的模型',state.models.map(m=>`<button class="model-option ${m.id===current().model?'selected':''}" data-select-model="${m.id}"><span class="provider-symbol">${icon('spark')}</span><span><strong>${esc(m.name)}</strong><small>${esc(m.description)}</small></span>${m.id===current().model?icon('check'):''}</button>`).join('')+'<label for="reasoningEffort">推理强度</label><select id="reasoningEffort" '+(!state.models.find(m=>m.id===current().model)?.effort?'disabled':'')+'><option>标准</option><option>深入</option></select><p class="muted" style="margin-top:10px">仅在模型声明支持时显示可调整的能力。这里不会实际调用模型。</p>',button('close-modal','取消','outline')+button('apply-effort','完成','primary'),'MODEL'),
|
||||||
|
'apply-effort':()=>{current().effort=$('reasoningEffort').value;closeModal();refreshTask(current())},
|
||||||
|
'permission':()=>openModal('当前任务的权限','<p class="muted">普通问题与操作授权分别处理。权限决定执行边界,不改变模型。</p><label for="permissionMode">授权方式</label><select id="permissionMode"><option value="ask">必要时询问:重要操作由我确认</option><option value="auto">自动审批:演示配置,不实际执行</option></select><div class="note-box">当前示例范围:项目资料。任何真实的发送、发布或外部修改均未接入。</div>',button('close-modal','取消','outline')+button('save-permission','应用到当前会话','primary'),'PERMISSIONS'),
|
||||||
|
'save-permission':()=>{current().permissionMode=$('permissionMode').value;closeModal();refreshTask(current());toast('演示权限设置已更新')},
|
||||||
|
'task-menu':()=>openModal('任务操作','<label for="renameTask">任务名称</label><input id="renameTask" value="'+esc(current().title)+'"><div style="display:flex;gap:10px;margin-top:20px">'+button('archive-task',icon('archive')+' 归档任务','outline')+'</div>',button('close-modal','取消','outline')+button('rename-task','保存名称','primary'),'TASK'),
|
||||||
|
'rename-task':()=>{const value=$('renameTask').value.trim();if(!value){formError('任务名称不能为空。');return}current().title=value;closeModal();render()},
|
||||||
|
'archive-task':()=>{if(current().status==='running'){formError('请先暂停正在运行的任务,再归档。');return}current().archived=true;closeModal();navigate('home');toast('任务已归档,仍可通过搜索找到')},
|
||||||
|
'project-info':()=>openModal(state.project,'<p class="muted">项目连接相关任务与共享资料。每个任务有独立的对话、执行状态和成果。</p><div class="note-box">资料:品牌手册.md、新品介绍.md<br>项目约定:语气清晰克制,结论保留依据。<br>运行位置:本地交互原型。</div>','','PROJECT'),
|
||||||
|
'project':()=>openModal('设置项目名称','<label for="projectName">项目名称</label><input id="projectName" value="'+esc(state.project)+'"><p class="muted" style="margin-top:12px">此原型只有一个项目容器,可修改名称体验导航更新。</p>',button('close-modal','取消','outline')+button('save-project','保存','primary'),'PROJECT'),
|
||||||
|
'save-project':()=>{const value=$('projectName').value.trim();if(!value){formError('项目名称不能为空。');return}state.project=value;closeModal();render()},
|
||||||
|
'notifications':()=>{state.notificationCount=0;renderSidebar();navigate('inbox')},
|
||||||
|
'add-model':()=>openModelForm(),'save-model':el=>saveModel(el.dataset.id),
|
||||||
|
'add-tool':()=>openModal('添加外部能力','<p class="muted">用 MCP 或 HTTP 适配器连接外部服务。工具能力与模型服务分别配置。</p><label for="toolName">能力名称</label><input id="toolName" placeholder="例如:团队知识库"><label for="toolProtocol">接入方式</label><select id="toolProtocol"><option>MCP 连接器</option><option>HTTP API</option></select><label for="toolEndpoint">服务地址</label><input id="toolEndpoint" placeholder="https://example.com/mcp"><label for="toolScope">能力说明与访问范围</label><input id="toolScope" placeholder="例如:只读取产品知识库">',button('close-modal','取消','outline')+button('save-tool','添加演示连接','primary'),'TOOLS'),
|
||||||
|
'save-tool':()=>{const name=$('toolName').value.trim(),scope=$('toolScope').value.trim();let url;try{url=new URL($('toolEndpoint').value)}catch{}if(!name||!scope||!url||!['http:','https:'].includes(url.protocol)){formError('请填写名称、访问范围和有效服务地址。');return}state.tools.push({id:'tool-'+sequence++,name,description:scope,kind:$('toolProtocol').value,icon:'link',enabled:false});closeModal();render();toast('已添加配置,尚未连接真实服务')},
|
||||||
|
'add-skill':()=>openModal('创建工作技能','<label for="skillName">技能名称</label><input id="skillName"><label for="skillDescription">工作方法</label><textarea id="skillDescription" rows="4" placeholder="描述这类任务应该怎样完成…"></textarea>',button('close-modal','取消','outline')+button('save-skill','保存技能','primary'),'SKILL'),
|
||||||
|
'save-skill':()=>{const name=$('skillName').value.trim(),description=$('skillDescription').value.trim();if(!name||!description){formError('请填写技能名称和工作方法。');return}state.skills.push({id:'skill-'+sequence++,name,description,enabled:true});closeModal();render()},
|
||||||
|
'add-schedule':()=>scheduleForm(),'save-schedule':el=>{const name=$('scheduleName').value.trim(),prompt=$('schedulePrompt').value.trim();if(!name||!prompt){formError('请填写任务名称和目标。');return}const existing=state.schedules.find(x=>x.id===el.dataset.id),next={id:existing?.id||'schedule-'+sequence++,name,prompt,cadence:$('scheduleCadence').value,enabled:existing?.enabled??true};if(existing)Object.assign(existing,next);else state.schedules.push(next);closeModal();render();toast('演示日程已保存,不会在后台实际运行')},
|
||||||
|
'settings':()=>openModal('舒适地工作','<p class="muted">为长时间阅读设计的浅色工作区。动画只表达操作和状态变化。</p><label for="motionMode">动画效果</label><select id="motionMode"><option value="system">跟随系统偏好</option><option value="reduce">减少动态效果</option></select><label for="fontScale">文字大小</label><select id="fontScale"><option value="normal">标准</option><option value="large">舒适 · 较大文字</option></select>',button('close-modal','取消','outline')+button('save-settings','应用','primary'),'APPEARANCE'),
|
||||||
|
'save-settings':()=>{document.body.classList.toggle('reduce-motion',$('motionMode').value==='reduce');document.body.classList.toggle('large-type',$('fontScale').value==='large');closeModal();toast('界面偏好已应用')}
|
||||||
|
};
|
||||||
|
document.addEventListener('click',e=>{const action=e.target.closest('[data-action]');if(action){e.preventDefault();actions[action.dataset.action]?.(action);return}const task=e.target.closest('[data-task]');if(task){if(task.dataset.close)closeModal();state.canvas=innerWidth>960&&!!tasks.find(t=>t.id===task.dataset.task).artifact;state.canvasTab='preview';navigate('task',task.dataset.task);return}const route=e.target.closest('[data-route]');if(route){navigate(route.dataset.route);return}const choice=e.target.closest('[data-choice]');if(choice){state.askChoices[current().id]=Number(choice.dataset.choice);document.querySelectorAll('.ask-option').forEach(b=>{const selected=b===choice;b.classList.toggle('selected',selected);b.setAttribute('aria-pressed',String(selected));b.classList.remove('picked')});choice.classList.add('picked');return}const tab=e.target.closest('[data-canvas-tab]');if(tab){state.canvasTab=tab.dataset.canvasTab;renderCanvas();return}const cap=e.target.closest('[data-cap-tab]');if(cap){state.capTab=cap.dataset.capTab;renderPage();return}const model=e.target.closest('[data-select-model]');if(model){current().model=model.dataset.selectModel;state.model=model.dataset.selectModel;closeModal();refreshTask(current());toast('模型已切换(演示)');return}const editModel=e.target.closest('[data-edit-model]');if(editModel){openModelForm(editModel.dataset.editModel);return}for(const [key,list] of [['toolToggle',state.tools],['skillToggle',state.skills],['scheduleToggle',state.schedules]]){const attr=key.replace(/[A-Z]/g,c=>'-'+c.toLowerCase()),toggle=e.target.closest('[data-'+attr+']');if(toggle){const item=list.find(x=>x.id===toggle.dataset[key]);item.enabled=!item.enabled;renderPage();return}}const remove=e.target.closest('[data-remove-file]');if(remove){state.attachments[current().id].splice(Number(remove.dataset.removeFile),1);refreshTask(current());return}const schedule=e.target.closest('[data-schedule-edit]');if(schedule){scheduleForm(schedule.dataset.scheduleEdit);return}const source=e.target.closest('[data-source]');if(source)openModal(Number(source.dataset.source)===0?'品牌手册.md':'新品介绍.md','<p class="muted">项目输入文件示例。正式版本在此预览实际文件内容,本原型没有读取你的本地资料。</p>');});
|
||||||
|
document.addEventListener('submit',e=>{if(e.target.id==='composer'){e.preventDefault();submitMessage($('messageInput').value)}if(e.target.id==='homeComposer'){e.preventDefault();if(!$('homePrompt').value.trim()){toast('先写下你想完成的事。');return}createTask($('homePrompt').value)}});
|
||||||
|
document.addEventListener('input',e=>{if(e.target.id==='messageInput'){state.drafts[current().id]=e.target.value;e.target.style.height='auto';e.target.style.height=Math.min(140,e.target.scrollHeight)+'px'}if(e.target.id==='askFree')current().askDraft=e.target.value;if(e.target.id==='searchInput')search(e.target.value)});
|
||||||
|
document.addEventListener('keydown',e=>{if(e.isComposing)return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='k'){e.preventDefault();showSearch()}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='n'){e.preventDefault();showNew()}if(e.target.id==='messageInput'&&e.key==='Enter'&&!e.shiftKey){e.preventDefault();$('composer').requestSubmit()}if(e.key==='Escape')$('studio').classList.remove('mobile-rail');if(e.target.hasAttribute('data-canvas-tab')&&['ArrowLeft','ArrowRight'].includes(e.key)){e.preventDefault();const tabs=['preview','files','activity'],next=(tabs.indexOf(state.canvasTab)+(e.key==='ArrowRight'?1:2))%3;state.canvasTab=tabs[next];renderCanvas();$('canvas-tab-'+state.canvasTab).focus()}});
|
||||||
|
$('attachmentInput').addEventListener('change',e=>{const id=current().id;state.attachments[id]=[...(state.attachments[id]||[]),...Array.from(e.target.files).map(f=>f.name)];e.target.value='';refreshTask(current());toast('已添加附件名称;原型不读取文件内容。')});
|
||||||
|
state.canvas=innerWidth>960;
|
||||||
|
icons();render();clockTimer=setInterval(runTick,1700);
|
||||||
|
window.addEventListener('pagehide',()=>clearInterval(clockTimer));
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(()=>{
|
||||||
|
'use strict';
|
||||||
|
const reduced=()=>document.body.classList.contains('reduce-motion')||window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const frames=new Set();
|
||||||
|
function animate(el,keyframes,options={}){
|
||||||
|
if(!el||reduced()||typeof el.animate!=='function')return;
|
||||||
|
const animation=el.animate(keyframes,{duration:360,easing:'cubic-bezier(.2,.8,.2,1)',...options});
|
||||||
|
frames.add(animation);animation.finished.then(()=>frames.delete(animation)).catch(()=>frames.delete(animation));
|
||||||
|
return animation;
|
||||||
|
}
|
||||||
|
function revealPage(){
|
||||||
|
const elements=document.querySelectorAll('.conversation-title,.conversation-description,.overview-header,.home-composer,.task-tile,.connection-card,.schedule-row');
|
||||||
|
elements.forEach((el,i)=>animate(el,[{opacity:.15,transform:'translateY(9px)'},{opacity:1,transform:'translateY(0)'}],{duration:420,delay:Math.min(i*32,150)}));
|
||||||
|
}
|
||||||
|
function marker(){
|
||||||
|
const tabs=document.querySelector('.canvas-tabs');if(!tabs)return;
|
||||||
|
const active=tabs.querySelector('[aria-selected="true"]');if(!active)return;
|
||||||
|
let line=tabs.querySelector('.tab-marker');if(!line){line=document.createElement('span');line.className='tab-marker';line.setAttribute('aria-hidden','true');tabs.appendChild(line);tabs.classList.add('has-marker')}
|
||||||
|
line.style.left=active.offsetLeft+'px';line.style.width=active.offsetWidth+'px';
|
||||||
|
}
|
||||||
|
function ripple(button,e){
|
||||||
|
if(reduced()||!button.isConnected||!button.animate||button.closest('.canvas-tabs')||button.classList.contains('wordmark'))return;
|
||||||
|
const rect=button.getBoundingClientRect(),r=document.createElement('span');
|
||||||
|
r.className='motion-ripple';r.style.left=(e.clientX?e.clientX-rect.left:rect.width/2)+'px';r.style.top=(e.clientY?e.clientY-rect.top:rect.height/2)+'px';
|
||||||
|
button.classList.add('motion-pressed');button.appendChild(r);
|
||||||
|
const a=animate(r,[{opacity:.8,transform:'translate(-50%,-50%) scale(0)'},{opacity:0,transform:'translate(-50%,-50%) scale('+Math.max(rect.width,rect.height)/5+')'}],{duration:450});
|
||||||
|
const cleanup=()=>{r.remove();button.classList.remove('motion-pressed')};
|
||||||
|
if(a)a.finished.then(cleanup,cleanup);else cleanup();
|
||||||
|
}
|
||||||
|
document.addEventListener('click',e=>{
|
||||||
|
const target=e.target.closest('button');if(!target)return;
|
||||||
|
const route=target.hasAttribute('data-route')||target.hasAttribute('data-task')||['home','create-task'].includes(target.dataset.action);
|
||||||
|
const tab=target.hasAttribute('data-canvas-tab')||target.hasAttribute('data-cap-tab');
|
||||||
|
const canvasOpen=target.dataset.action==='open-artifact';
|
||||||
|
const update=['save-revision','answer','save-plan','approve','deny','pause','resume','simulate-reconnect'].includes(target.dataset.action);
|
||||||
|
requestAnimationFrame(()=>{
|
||||||
|
ripple(target,e);marker();
|
||||||
|
if(route)revealPage();
|
||||||
|
if(tab)animate(document.querySelector(target.hasAttribute('data-canvas-tab')?'#canvasContent':'.connection-grid'),[{opacity:.25,transform:'translateY(6px)'},{opacity:1,transform:'translateY(0)'}],{duration:300});
|
||||||
|
if(canvasOpen)animate(document.querySelector('.paper'),[{opacity:.6,transform:'translateY(10px) scale(.985)'},{opacity:1,transform:'translateY(0) scale(1)'}],{duration:430});
|
||||||
|
if(update)animate(document.querySelector('.status-pill'),[{transform:'scale(.95)',opacity:.5},{transform:'scale(1)',opacity:1}],{duration:270});
|
||||||
|
});
|
||||||
|
},true);
|
||||||
|
document.addEventListener('submit',e=>{
|
||||||
|
if(e.target.id!=='composer')return;
|
||||||
|
requestAnimationFrame(()=>{
|
||||||
|
const last=document.querySelector('#messages .message:last-child');
|
||||||
|
animate(last,[{opacity:0,transform:'translateY(12px)'},{opacity:1,transform:'translateY(0)'}],{duration:400});
|
||||||
|
animate(document.querySelector('.composer'),[{boxShadow:'0 0 0 3px #b9cbe630'},{boxShadow:'0 0 0 0 #b9cbe600'}],{duration:500});
|
||||||
|
marker();
|
||||||
|
});
|
||||||
|
},true);
|
||||||
|
const modal=document.getElementById('modal');
|
||||||
|
if(modal)new MutationObserver(()=>{
|
||||||
|
if(modal.open)animate(modal.querySelector('.modal-body'),[{opacity:0,transform:'translateY(5px)'},{opacity:1,transform:'translateY(0)'}],{duration:280,delay:50});
|
||||||
|
}).observe(modal,{attributes:true,attributeFilter:['open']});
|
||||||
|
let version='';
|
||||||
|
const page=document.getElementById('page');
|
||||||
|
if(page)new MutationObserver(()=>{
|
||||||
|
const paper=page.querySelector('.paper');if(!paper)return;
|
||||||
|
const next=paper.querySelector('.paper-meta')?.textContent||'';
|
||||||
|
const identity=page.querySelector('.conversation-title')?.textContent||'';
|
||||||
|
const key=identity+'|'+next;
|
||||||
|
if(version&&version.split('|')[0]===identity&&version!==key){
|
||||||
|
animate(paper,[{boxShadow:'0 0 0 2px #b1c9e550,0 8px 25px #31547c0a'},{boxShadow:'0 0 0 0 #b1c9e500,0 8px 25px #31547c0a'}],{duration:850});
|
||||||
|
}
|
||||||
|
version=key;
|
||||||
|
}).observe(page,{childList:true,subtree:true});
|
||||||
|
new MutationObserver(()=>{if(reduced()){frames.forEach(a=>a.cancel());frames.clear()}}).observe(document.body,{attributes:true,attributeFilter:['class']});
|
||||||
|
window.addEventListener('resize',marker);
|
||||||
|
window.addEventListener('pagehide',()=>frames.forEach(a=>a.cancel()));
|
||||||
|
marker();
|
||||||
|
})();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
:root{--bg:#f6f7f9;--paper:#fff;--rail:#eceef1;--line:#e0e3e9;--ink:#242730;--muted:#727782;--faint:#9297a2;--green:#353942;--green-light:#f1f3f6;--amber:#966b21;--amber-light:#fff7e6;--red:#ac4b45;--blue:#5877ad;--shadow:0 2px 5px #10182804,0 12px 32px #10182808;--ease:cubic-bezier(.2,.8,.2,1)}
|
||||||
|
body{background:#eef0f3;color:#242730;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
|
||||||
|
button{transition:background .18s,border-color .18s,color .18s,box-shadow .2s,transform .18s var(--ease)}
|
||||||
|
button:hover{background:#e9ecf1}button:active{transform:translateY(1px) scale(.98)}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{outline:2px solid #92a8ce;outline-offset:3px}
|
||||||
|
button.primary{background:linear-gradient(180deg,#424751,#2e333d);border-color:#2e343f;color:#fff;box-shadow:inset 0 1px #ffffff1a,0 1px 2px #1018281a,0 3px 5px #1018280a}
|
||||||
|
button.primary:hover{background:linear-gradient(180deg,#505662,#383e49);box-shadow:inset 0 1px #ffffff25,0 4px 10px #10182820;transform:translateY(-1px)}button.primary:active{transform:translateY(1px);box-shadow:inset 0 1px 3px #10182824}
|
||||||
|
button.outline{background:linear-gradient(#fff,#fcfcfd);border-color:#dce0e7;color:#555b68;box-shadow:0 1px 2px #10182804}button.outline:hover{background:#f5f6f9;border-color:#bbc3d1}
|
||||||
|
.sidebar{background:linear-gradient(110deg,#f0f1f4,#e9ecf0);border-color:#dce0e6;box-shadow:inset -1px 0 #ffffff70}.wordmark>span:last-child{color:#2f343e;letter-spacing:-.85px}.wordmark small{color:#9298a4;letter-spacing:2px}.brand-symbol{color:#343a46}.brand-symbol span{background:#8193b1}
|
||||||
|
.new-task{background:linear-gradient(180deg,#fff,#f9fafc);border:1px solid #d3d9e2;box-shadow:inset 0 1px #fff,0 2px 4px #1b25300a;color:#535a67;border-radius:9px}.new-task:hover{border-color:#b9c2d0;box-shadow:0 4px 10px #1b253011;transform:translateY(-1px)}
|
||||||
|
kbd{background:#f6f7fa;border-color:#dce0e8;color:#8b93a2}.nav-item{color:#6c7380;border:1px solid transparent}.nav-item:hover{background:#e2e6ec;color:#3c4554}.nav-item.active{background:linear-gradient(#fff,#fafbfe);border-color:#d7dce5;color:#414b5e;box-shadow:0 1px 3px #23324a08;font-weight:550}
|
||||||
|
.sidebar-label{color:#8d95a2;letter-spacing:.6px}.project-avatar{background:linear-gradient(135deg,#f9fafc,#e2e7ef);color:#6a7891;border-color:#d2dae5;box-shadow:inset 0 1px #fff}.project-nav{color:#626c7b}.project-nav small,.profile small{color:#9199a6}.project-nav>svg{color:#949dad}.count{background:#e5e9f0;color:#7a8496}.task-item{color:#76808e}.task-item.active{background:#fff;border-color:#d8dee8;color:#46536a;box-shadow:0 1px 2px #25324a07,0 4px 10px #25324a03}.task-item:hover{background:#f8f9fc}.task-item .task-status-icon{color:#a18755}.profile{border-color:#d8dee7;color:#697588}.profile-avatar,.top-avatar{background:linear-gradient(135deg,#c8d3e1,#a3b3c8);color:#455973;border-color:#fff;box-shadow:0 0 0 1px #d7dfe9}.sidebar .icon-button{color:#7f8999}
|
||||||
|
.topbar{background:#fafbfc;border-bottom-color:#e3e7ee;box-shadow:0 1px 0 #fff;padding-top:1px}.breadcrumb{color:#929aa7}.breadcrumb strong{color:#555f70;font-weight:550}.slash{color:#c0c8d4}.top-actions .icon-button{color:#8b96a7}.prototype-label{border-color:#e0e5ee;color:#8995a7;letter-spacing:1.1px;background:#f5f7fa}.statusbar{background:#eef1f5;border-color:#dce2eb;color:#7e8ca2;letter-spacing:.1px}.statusbar-muted{color:#8b98ab}.top-actions b{background:#a18659}
|
||||||
|
.conversation{background:#fff}.conversation-toolbar{padding-top:22px;padding-bottom:16px}.conversation-title{color:#2b3341;font-weight:550;letter-spacing:-.7px}.conversation-description{color:#8794a7}.conversation-actions .icon-button{color:#8998ac}.task-meta .project-tag{border-color:#e3e9f0;background:#fbfcfe;color:#7f8fa5}.status-pill{background:#f2f5f9;color:#7a8ba3}.status-pill.waiting{background:#fbf5e9;color:#997d48}.status-pill.blocked{background:#fcf0ed;color:#a76c61}.status-pill.done{background:#edf4f0;color:#658776}.status-pill.running{background:#edf3fb;color:#6987b2}.dot.green{background:#7f9b91}.dot.amber{background:#c4a367}.dot.red{background:#c18b80}.dot.running{background:#7c9cbe;box-shadow:0 0 0 3px #6f8daf13}
|
||||||
|
.message{color:#596778;line-height:2}.message p{color:#596779;line-height:1.95}.user-bubble{background:linear-gradient(120deg,#f1f4f8,#edf1f6);border-color:#e4e9f0;color:#59677a;border-radius:14px 14px 4px 14px;box-shadow:inset 0 1px #ffffffa0}.agent-byline{color:#566781}.agent-byline small{color:#8f9eb2}.agent-avatar{background:linear-gradient(145deg,#f6f8fc,#e2e9f2);border-color:#d7e1ee;color:#6f86a6;box-shadow:inset 0 1px #fff,0 2px 4px #233b5906}.receipt{color:#7d95a0}
|
||||||
|
.compact-plan{background:linear-gradient(#fbfcfe,#f8fafc);border-color:#e2e8f0;box-shadow:inset 0 1px #fff}.compact-plan summary{color:#70849e;padding:12px 14px}.compact-plan summary .plan-total{color:#95a4b9}.plan-steps li{color:#8a9ab0}.plan-steps .current{color:#637d9e}.step-circle{border-color:#dbe4ef;color:#9aaac0}.plan-steps .complete .step-circle{background:#eaf0f5;color:#7b98a6}.plan-steps .current .step-circle{border-color:#96aac5}.plan-progress{background:#edf2f8}.plan-progress i{background:#99b1d0}.compact-plan .text-button{color:#8a9db6}
|
||||||
|
.tool-group{border-color:#e5ebf3;background:#fff}.tool-group summary{color:#8296af}.tool-record{border-color:#edf2f8;color:#8a9eb7}.tool-record svg{color:#9eafc6}.tool-group summary .tool-result,.tool-record small{color:#9aadc6}
|
||||||
|
.ask-card{border:1px solid #dbe3ee;border-radius:13px;box-shadow:0 1px 3px #28415b04,0 7px 22px #28415b06;background:linear-gradient(125deg,#fff,#fefeff);position:relative}.ask-card:before{content:'';position:absolute;top:0;left:15px;right:15px;height:1px;background:linear-gradient(90deg,transparent,#aec0d8,transparent)}.card-heading{color:#7a91ad;padding-top:16px}.card-heading>span:last-child{color:#a3b2c6}.ask-body h3{color:#4f657f;font-weight:550}.ask-body>.muted{color:#869ab4}.ask-options{gap:8px}.ask-option{border-color:#e6ecf4;background:#fff;padding:12px;border-radius:8px}.ask-option:hover{background:#f8fafd;border-color:#cedbeb;box-shadow:0 2px 7px #4b6f9906}.ask-option.selected{background:linear-gradient(115deg,#f3f7fc,#f8fafd);border-color:#b5c9e2;box-shadow:0 0 0 1px #c5d5e821,inset 0 1px #fff}.ask-option .radio{border-color:#cedae9;background:#fff}.ask-option.selected .radio{border-color:#91acce}.ask-option strong{color:#607b9b;font-weight:500}.ask-option small{color:#8b9fba}.ask-option .recommended{background:#eaf1fa;color:#8da8ca;border:1px solid #dfe9f6;padding:2px 6px}.ask-free{background:#fbfcfe;border-color:#e2eaf5;color:#7994b6}.ask-free::placeholder{color:#a1b2c9}.card-footer{background:linear-gradient(#fbfcfe,#f7f9fc);border-color:#e6edf6}.card-footer>span{color:#93a6bf}.card-footer .primary{color:#fff}.approval-card .card-heading{color:#a88f5f}.approval-scope span{border-color:#ebe4d7;background:#fcfaf5;color:#a59779}
|
||||||
|
.execution{background:linear-gradient(110deg,#f5f8fc,#fafcfe);border-color:#dfe9f4;box-shadow:0 3px 12px #4f719305}.execution-top{color:#7897b9}.execution-bottom{color:#97adc7}.spinner{border-color:#dce7f4;border-top-color:#8eadcf}.run-progress{background:#e8eff7;overflow:hidden}.run-progress i{background:linear-gradient(90deg,#b7cbe3,#88a8cf);position:relative}.run-progress i:after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,transparent,#ffffffa0,transparent);transform:translateX(-100%);animation:progress-sweep 2.4s ease-in-out infinite}.subagent{border-color:#e1eaf6;color:#97aec9;background:#fafcff}.result-card{background:linear-gradient(120deg,#fcfdff,#f6f9fc);border-color:#dce7f2;box-shadow:0 2px 6px #3d689508;transition:transform .2s,box-shadow .2s,border-color .2s}.result-card:hover{transform:translateY(-2px);box-shadow:0 8px 22px #3d689513;background:#fff;border-color:#b4cce5}.result-icon{background:linear-gradient(140deg,#fff,#e8f0f8);border-color:#d6e4f2;color:#8cadd0;box-shadow:inset 0 1px #fff}.result-card b{color:#7393b6}.result-card small{color:#9eb4cf}.result-card>svg{color:#9db7d6}
|
||||||
|
.composer-zone{background:linear-gradient(0deg,#fff 87%,#ffffff00)}.composer{border-color:#d5e0ed;border-radius:14px;box-shadow:0 1px 3px #29466705,0 7px 26px #29466709,inset 0 1px #fff;background:linear-gradient(160deg,#fff,#fefeff);transition:border-color .25s,box-shadow .25s,transform .25s}.composer:focus-within{border-color:#a8bfdc;box-shadow:0 0 0 3px #c9d9ec25,0 8px 30px #2946670b}.composer textarea{color:#667f9d}.composer textarea::placeholder{color:#9badc6}.composer-bottom button{color:#8aa2c0}.composer-bottom .composer-model{background:#f3f7fb;color:#849cba}.composer-bottom .send{background:linear-gradient(180deg,#4d6481,#3e526c);color:white;box-shadow:inset 0 1px #ffffff25,0 2px 4px #29456820}.composer-bottom .send:hover{background:linear-gradient(180deg,#597696,#466180);transform:translateY(-1px);box-shadow:0 3px 8px #29456828}.composer-hint{color:#99abc3}.steering-banner{background:#f1f6fc;color:#8fa8c7;border:1px solid #e3edf8}.file-chip{background:#f6f9fe;border-color:#dfe9f6;color:#88a4c6}
|
||||||
|
.canvas{background:linear-gradient(125deg,#e8edf3,#eff3f7);border-left-color:#d9e3ef;box-shadow:inset 1px 0 #fff;transition:opacity .2s,transform .32s var(--ease)}.canvas-toolbar{background:linear-gradient(#f6f9fc,#f0f4f8);border-bottom-color:#dbe5f0;box-shadow:inset 0 1px #fff}.canvas-tabs{position:relative;gap:17px}.canvas-tabs button{color:#94a9c2;transition:color .2s}.canvas-tabs button:hover,.canvas-tabs button[aria-selected="true"]{color:#708dab}.canvas-tabs button[aria-selected="true"]:after{background:#94b0d0;height:2px}.canvas-toolbar .icon-button{color:#9bb0c8}.canvas-content{padding:23px;scrollbar-color:#cbd9e9 transparent}.document-tools{color:#90a7c2}.document-tools button{border-color:#d9e5f2;background:#f8fbff;color:#98b0cc}.paper{border:1px solid #d9e4f1;border-radius:4px;box-shadow:0 1px 2px #31547c07,0 7px 17px #31547c0a,0 25px 50px -20px #31547c1a;position:relative}.paper-cover{background:linear-gradient(125deg,#f2f6fb,#e4edf7);border-color:#dce7f4;padding-top:35px;padding-bottom:31px}.paper-cover .eyebrow{color:#91abc9;letter-spacing:2.4px}.paper-cover h2{color:#6b88aa;font-weight:550;letter-spacing:-.9px}.paper-cover p{color:#8ea9c8}.cover-art span{border-color:#b6cce4;background:linear-gradient(100deg,#dae7f733,#a1bbdc30);box-shadow:inset 0 0 20px #ffffff50}.cover-art span:nth-child(2){background:#b6cce416}.cover-art span:nth-child(3){background:#d7e4f225}.paper-meta{border-color:#e7eff8;color:#9ab0ca}.paper-body{color:#829bb9}.paper-body h3{color:#7694b6}.paper-body h3 span{color:#a4b9d4}.document-cards>div{background:linear-gradient(#f6f9fe,#f0f5fc);border-color:#e3edf9;border-radius:6px}.document-cards b{color:#87a6c9}.document-cards small{color:#9eb6d2}.doc-line{border-color:#e9f1fa;color:#96b0ce}.doc-line span:first-child{color:#a9bfd9}.paper-footer{color:#a9bfd8;border-color:#e8f1fb}.canvas-bottom{background:#f4f8fc;border-color:#dce7f4;color:#a0b7d1}.canvas-bottom button{color:#98b2d0}.activity-entry{border-color:#d6e4f4;color:#8ba9ca}.activity-entry:before{background:#a8c1df;border-color:#f5f9fe}.activity-entry small{color:#acc1db}.activity-heading{color:#97b0ce}.file-row{background:#fafdff;border-color:#dce8f6;color:#8bacca;box-shadow:0 1px 3px #386d9b04}.file-row small{color:#abc1db}.empty-panel{color:#95b0ce}.empty-panel svg{color:#b3cbe4}
|
||||||
|
.overview-page{background:radial-gradient(ellipse at 80% 0,#eaf0f855,transparent 55%),#f8fafc}.overview-header h1{color:#3d5067;letter-spacing:-1px;font-weight:550}.overview-header .muted{color:#8ca1ba}.overview-header .eyebrow{color:#99aec7}.home-composer{background:linear-gradient(120deg,#fff,#fcfdff);border-color:#d8e3ef;box-shadow:0 1px 3px #25466906,0 9px 28px #25466908}.home-composer textarea{color:#7691b0}.home-composer>div{color:#a1b7d0}.section-heading h2{color:#7894b5}.section-heading span{color:#a4b9d2}.task-tile{border-color:#dce7f4;box-shadow:0 1px 3px #28538104,0 3px 8px #28538103;background:linear-gradient(145deg,#fff,#fcfdff);border-radius:12px;padding:22px;min-height:178px}.task-tile:hover{transform:translateY(-4px);border-color:#b6cce6;box-shadow:0 3px 7px #28538105,0 12px 30px #28538110}.task-tile h3{color:#708dac;font-weight:550}.task-tile p{color:#97aec9}.task-tile>small{color:#a9bed7}.project-banner{background:linear-gradient(110deg,#edf3fa,#f8fbff);border-color:#dce7f5;box-shadow:inset 0 1px #fff}.project-banner .project-avatar{background:linear-gradient(135deg,#f4f8fe,#dce8f6);border-color:#d0e0f3;color:#96b3d4}.project-banner h3{color:#87a4c6}.project-banner p{color:#a7bfd9}.filter-tabs{position:relative}.filter-tabs button{color:#92acc9}.filter-tabs button.active{background:#e6eef9;color:#7f9cbd}.connection-card{border-color:#dce8f6;box-shadow:0 2px 7px #386e9f05;background:linear-gradient(140deg,#fff,#fcfdff)}.connection-card h3{color:#829fbf}.connection-card header small,.connection-card p{color:#a0b8d3}.connection-card footer{border-color:#e8f1fb;color:#aec5df}.provider-symbol{background:linear-gradient(145deg,#fbfdff,#e3eefb);border-color:#d5e5f7;color:#93b3d7;box-shadow:inset 0 1px #fff}.toggle{background:#d4e2f3}.toggle[aria-checked="true"]{background:#8dadd2}.schedule-row{border-color:#dce9f8;box-shadow:0 2px 7px #386e9f05}.schedule-row h3{color:#7b9cc0}.schedule-row small,.schedule-row>svg{color:#a2bbd8}.note-box{background:#f5f8fc;border-color:#dfe9f5;color:#9ab3cf}
|
||||||
|
.modal{border-color:#dbe6f4;color:#7897bc;box-shadow:0 5px 20px #182f4c0a,0 32px 100px #182f4c28;border-radius:16px}.modal::backdrop{background:#24354c45;backdrop-filter:blur(7px)}.modal header{background:linear-gradient(#fcfdff,#f4f8fd);border-color:#e1eaf6}.modal h2{color:#637fa2;font-weight:550}.modal .eyebrow{color:#97b0ce}.modal label{color:#869fc0}.modal input,.modal select,.modal textarea{border-color:#dce7f5;background:#fcfdff;color:#829fbe}.modal input::placeholder,.modal textarea::placeholder{color:#a7bed8}.modal .muted{color:#9cb4d0}.modal footer{background:#f6f9fd;border-color:#e0eaf7}.model-option{border-color:#e2ecf8}.model-option.selected{border-color:#adc7e6;background:#f1f7fe}.model-option strong{color:#83a2c5}.model-option small{color:#a5bdd9}.search-result{color:#8faecd}.search-result small{color:#a7bfdc}.toast{background:linear-gradient(#526a88,#425a78);color:#fff;border-color:#60799b;box-shadow:inset 0 1px #ffffff1a,0 7px 30px #28436325}
|
||||||
|
/* Text stays neutral; accent is reserved for selection and status. */
|
||||||
|
.conversation-title,.overview-header h1,.modal h2{color:#303640}.wordmark>span:last-child{color:#30353e}.message,.message p,.user-bubble,.composer textarea,.home-composer textarea{color:#555f6e}.agent-byline,.ask-body h3,.section-heading h2,.task-tile h3,.connection-card h3,.schedule-row h3,.paper-body h3,.paper-cover h2{color:#485463}.ask-option strong,.plan-steps .current,.project-banner h3,.model-option strong,.search-result{color:#56677d}.conversation-description,.agent-byline small,.ask-body>.muted,.ask-option small,.task-tile p,.connection-card p,.overview-header .muted,.modal .muted,.paper-body,.paper-cover p{color:#7b8798}.paper-meta,.paper-footer,.paper-body h3 span,.document-cards small,.doc-line,.doc-line span:first-child,.composer-hint,.card-footer>span,.card-heading>span:last-child,.tool-record small,.tool-group summary .tool-result,.connection-card header small,.connection-card footer,.schedule-row small,.activity-entry small,.canvas-bottom,.statusbar,.statusbar-muted{color:#8494aa}.project-banner p,.task-tile>small,.document-tools,.activity-heading,.canvas-tabs button,.file-row small,.project-nav small,.profile small{color:#8492a5}.tool-group summary,.tool-record,.plan-steps li,.compact-plan summary,.receipt,.card-heading,.execution-top,.execution-bottom,.result-card b,.result-card small,.canvas-bottom button,.activity-entry,.file-row,.empty-panel,.note-box,.modal label{color:#7488a3}.paper-cover{background:linear-gradient(125deg,#f4f6f9,#e9eef5)}.paper-body{line-height:2}.paper-cover h2{letter-spacing:-1px}.document-cards b{color:#73869f}.canvas{background:linear-gradient(130deg,#e9edf2,#f1f4f7)}
|
||||||
|
@property --canvas-width{syntax:'<percentage>';inherits:false;initial-value:40%}
|
||||||
|
.task-workspace{--canvas-width:40%;grid-template-columns:minmax(0,1fr) var(--canvas-width);transition:--canvas-width .38s var(--ease)}.task-workspace.canvas-closed{--canvas-width:0%;grid-template-columns:minmax(0,1fr) var(--canvas-width)}.canvas{min-width:320px}.canvas-closed .canvas{visibility:visible;opacity:0;transform:translateX(18px);pointer-events:none}.canvas-content{min-width:0}
|
||||||
|
.compact-plan,.tool-group{interpolate-size:allow-keywords}.compact-plan::details-content,.tool-group::details-content{block-size:0;opacity:0;overflow:clip;transition:block-size .32s var(--ease),opacity .2s,content-visibility .32s;transition-behavior:allow-discrete}.compact-plan[open]::details-content,.tool-group[open]::details-content{block-size:auto;opacity:1}
|
||||||
|
.motion-ripple{position:absolute;pointer-events:none;background:#879dbc20;border:1px solid #879dbc18;border-radius:50%;width:12px;height:12px;transform:translate(-50%,-50%);z-index:2}.motion-pressed{position:relative;overflow:hidden}.tab-marker{position:absolute;height:2px;bottom:0;background:#819dbf;border-radius:3px;pointer-events:none;transition:left .32s var(--ease),width .32s var(--ease)}.canvas-tabs.has-marker button[aria-selected="true"]:after{display:none}.motion-update-tag{position:absolute;top:12px;right:12px;border:1px solid #cddbec;border-radius:5px;background:#f7faff;color:#8198b9;padding:4px 7px;font-size:9px;pointer-events:none}.toast:not([hidden]){animation:toast-in .3s var(--ease)}
|
||||||
|
@keyframes progress-sweep{70%,100%{transform:translateX(100%)}}@keyframes toast-in{from{opacity:0;translate:0 8px}to{opacity:1;translate:0 0}}
|
||||||
|
.picked{animation:selection-ring .35s ease-out}.event-arrival{animation:arrive .42s var(--ease) both}@keyframes selection-ring{0%{box-shadow:0 0 0 0 #9fb9db40}65%{box-shadow:0 0 0 4px #9fb9db15}100%{box-shadow:0 0 0 0 #9fb9db00}}
|
||||||
|
@media(min-width:1600px){.task-workspace{--canvas-width:41%;grid-template-columns:minmax(0,1fr) var(--canvas-width)}}
|
||||||
|
@media(max-width:1150px){.task-workspace{--canvas-width:44%}.canvas{min-width:300px}.canvas-content{padding:17px}}
|
||||||
|
@media(max-width:960px){.task-workspace,.task-workspace.canvas-closed{grid-template-columns:minmax(0,1fr);--canvas-width:0%}.canvas{min-width:0;display:flex;transform:translateX(0);opacity:1;box-shadow:0 3px 12px #20334f0d,0 25px 75px #20334f28}.canvas-closed .canvas{display:flex;visibility:hidden;opacity:0;transform:translateX(calc(100% + 25px));transition:transform .32s var(--ease),opacity .2s,visibility .32s}.canvas-content{padding:19px}}
|
||||||
|
@media(max-width:650px){.studio.mobile-rail .sidebar{box-shadow:10px 0 60px #1d2f4b30}.canvas{top:67px}.task-tile{min-height:154px}.conversation-toolbar{padding-top:15px}.ask-option{padding:12px 10px}}
|
||||||
|
@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
||||||
|
.reduce-motion *,.reduce-motion *:before,.reduce-motion *:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}
|
||||||
|
/* Keep secondary text readable across the document and configuration surfaces. */
|
||||||
|
.conversation-description,.agent-byline small,.ask-body>.muted,.ask-option small,.task-tile p,.connection-card p,.overview-header .muted,.modal .muted,.paper-cover p{color:#66707e}
|
||||||
|
.paper-body,.modal input,.modal select,.modal textarea,.model-option strong,.search-result,.document-cards b,.doc-line{color:#4f5967}
|
||||||
|
.paper-meta,.paper-footer,.document-cards small,.doc-line span:first-child,.composer-hint,.card-footer>span,.card-heading>span:last-child,.tool-record small,.connection-card header small,.connection-card footer,.schedule-row small,.activity-entry small,.canvas-bottom,.statusbar,.project-banner p,.task-tile>small,.document-tools,.activity-heading,.file-row small,.model-option small,.search-result small,.section-heading span,.home-composer>div{color:#727b89}
|
||||||
|
.tool-group summary,.tool-record,.plan-steps li,.compact-plan summary,.receipt,.card-heading,.execution-top,.execution-bottom,.result-card b,.result-card small,.activity-entry,.file-row,.empty-panel,.note-box,.modal label,.composer-bottom button,.canvas-tabs button,.canvas-bottom button{color:#616c7b}
|
||||||
|
.canvas-tabs button[aria-selected="true"],.filter-tabs button.active{color:#354b69}.composer-bottom .composer-model{color:#556171;background:#f1f3f6}.composer-bottom .send{color:white;background:linear-gradient(#424751,#2e333d)}.composer-bottom .send:hover{background:linear-gradient(#535b68,#3c4451)}
|
||||||
|
.composer textarea::placeholder,.home-composer textarea::placeholder,.modal input::placeholder,.modal textarea::placeholder{color:#858d99}.model-option>svg:last-child{color:#617da5}.provider-symbol{color:#627186;background:linear-gradient(#fafbfc,#e8edf3)}.toggle[aria-checked="true"]{background:#526b8d}.toast{background:linear-gradient(#424954,#303640);border-color:#535b68}.document-tools button{color:#647184}.canvas-toolbar .icon-button{color:#737e8e}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { JSDOM, VirtualConsole } = require('../../desktop/frontend-next/node_modules/jsdom');
|
||||||
|
const errors = [];
|
||||||
|
const consoleBridge = new VirtualConsole();
|
||||||
|
consoleBridge.on('jsdomError', error => errors.push(error.message));
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
|
||||||
|
const script = fs.readFileSync(path.join(__dirname, 'workspace.js'), 'utf8');
|
||||||
|
const dom = new JSDOM(html, { runScripts: 'outside-only', url: 'https://prototype.invalid', virtualConsole: consoleBridge });
|
||||||
|
const w = dom.window;
|
||||||
|
let tick;
|
||||||
|
w.setInterval = fn => { tick = fn; return 1; };
|
||||||
|
w.clearInterval = () => {};
|
||||||
|
w.HTMLDialogElement.prototype.showModal = function () { this.setAttribute('open', ''); };
|
||||||
|
w.HTMLDialogElement.prototype.close = function () { this.removeAttribute('open'); };
|
||||||
|
w.URL.createObjectURL = () => 'blob:mock';
|
||||||
|
w.URL.revokeObjectURL = () => {};
|
||||||
|
w.HTMLAnchorElement.prototype.click = () => {};
|
||||||
|
let animationCount = 0;
|
||||||
|
const pendingFrames = [];
|
||||||
|
w.requestAnimationFrame = callback => { pendingFrames.push(callback); return pendingFrames.length; };
|
||||||
|
w.Element.prototype.animate = function () { animationCount++; return { finished: Promise.resolve(), cancel() {} }; };
|
||||||
|
function flushFrames() { while (pendingFrames.length) pendingFrames.shift()(0); }
|
||||||
|
w.eval(script);
|
||||||
|
w.eval(fs.readFileSync(path.join(__dirname, 'motion.js'), 'utf8'));
|
||||||
|
const q = s => w.document.querySelector(s);
|
||||||
|
function click(s) { const el = q(s); assert.ok(el, 'Missing control: ' + s); el.click(); flushFrames(); }
|
||||||
|
function fill(s, value) { const el = q(s); assert.ok(el, 'Missing field: ' + s); el.value = value; el.dispatchEvent(new w.Event('input', { bubbles: true })); }
|
||||||
|
function submit(text) { fill('#messageInput', text); q('#composer').dispatchEvent(new w.Event('submit', { bubbles: true, cancelable: true })); }
|
||||||
|
function finish() { for (let i = 0; i < 6; i++) tick(); }
|
||||||
|
function textContains(selector, text) { assert.ok(q(selector).textContent.includes(text), `Expected ${selector} to contain ${text}`); }
|
||||||
|
|
||||||
|
textContains('#pageTitle', '秋季新品');
|
||||||
|
assert.ok(q('.paper'), 'Initial draft is available');
|
||||||
|
fill('#messageInput', '保留这个未发送的要求');
|
||||||
|
click('[data-task="research"]');
|
||||||
|
click('[data-task="launch"]');
|
||||||
|
assert.equal(q('#messageInput').value, '保留这个未发送的要求');
|
||||||
|
click('[data-choice="1"]');
|
||||||
|
click('[data-action="answer"]');
|
||||||
|
textContains('.status-pill', '执行中');
|
||||||
|
click('[data-action="pause"]');
|
||||||
|
textContains('.status-pill', '已暂停');
|
||||||
|
tick();
|
||||||
|
textContains('.status-pill', '已暂停');
|
||||||
|
submit('保持简洁,并保留渠道安排');
|
||||||
|
textContains('#messages', '补充已记录');
|
||||||
|
click('[data-action="resume"]');
|
||||||
|
fill('#messageInput', '执行中正在输入');
|
||||||
|
tick();
|
||||||
|
assert.equal(q('#messageInput').value, '执行中正在输入');
|
||||||
|
finish();
|
||||||
|
textContains('.status-pill', '已完成');
|
||||||
|
textContains('#canvasContent', '保持简洁');
|
||||||
|
click('[data-canvas-tab="activity"]');
|
||||||
|
textContains('#canvasContent', '本轮成果已整理完成');
|
||||||
|
click('[data-action="open-artifact"]');
|
||||||
|
click('[data-action="download"]');
|
||||||
|
click('[data-action="edit-artifact"]');
|
||||||
|
fill('#revisionInput', '补充面向老用户的内容');
|
||||||
|
click('[data-action="save-revision"]');
|
||||||
|
finish();
|
||||||
|
textContains('#canvasContent', '补充面向老用户');
|
||||||
|
|
||||||
|
click('[data-task="email"]');
|
||||||
|
click('[data-action="deny"]');
|
||||||
|
textContains('#messages', '取消发送');
|
||||||
|
assert.ok(q('.paper'), 'Deny keeps the draft');
|
||||||
|
assert.equal(q('#activeDecision'), null);
|
||||||
|
click('[data-task="sync"]');
|
||||||
|
click('[data-action="reconnect"]');
|
||||||
|
click('[data-action="simulate-reconnect"]');
|
||||||
|
finish();
|
||||||
|
textContains('.status-pill', '已完成');
|
||||||
|
|
||||||
|
click('[data-action="new"]');
|
||||||
|
fill('#newGoal', '测试自定义任务 <img src=x onerror=alert(1)>');
|
||||||
|
click('[data-action="create-task"]');
|
||||||
|
assert.equal(q('#transcript img'), null, 'User content is escaped');
|
||||||
|
click('[data-action="edit-plan"]');
|
||||||
|
fill('#planEditor', '步骤一\n步骤二');
|
||||||
|
click('[data-action="save-plan"]');
|
||||||
|
assert.equal(w.document.querySelectorAll('.plan-steps li').length, 2);
|
||||||
|
click('[data-action="model"]');
|
||||||
|
click('[data-select-model="fast"]');
|
||||||
|
click('[data-action="model"]');
|
||||||
|
assert.ok(q('#reasoningEffort').disabled, 'Unsupported model capability is disabled');
|
||||||
|
click('[data-action="close-modal"]');
|
||||||
|
click('[data-action="permission"]');
|
||||||
|
fill('#permissionMode', 'auto');
|
||||||
|
click('[data-action="save-permission"]');
|
||||||
|
click('[data-action="permission"]');
|
||||||
|
assert.equal(q('#permissionMode').value, 'auto', 'Permission selection persists in current task');
|
||||||
|
click('[data-action="close-modal"]');
|
||||||
|
click('[data-task="analysis"]');
|
||||||
|
click('[data-action="permission"]');
|
||||||
|
assert.equal(q('#permissionMode').value, 'ask', 'Permission is scoped to each task');
|
||||||
|
click('[data-action="close-modal"]');
|
||||||
|
|
||||||
|
click('[data-route="capabilities"]');
|
||||||
|
click('[data-action="add-model"]');
|
||||||
|
click('[data-action="save-model"]');
|
||||||
|
assert.ok(q('#formError'), 'Incomplete API config is rejected');
|
||||||
|
fill('#providerName', '团队服务');
|
||||||
|
fill('#modelName', 'custom-model');
|
||||||
|
fill('#endpoint', 'https://api.example.com/v1');
|
||||||
|
click('[data-action="save-model"]');
|
||||||
|
textContains('#page', 'custom-model');
|
||||||
|
click('[data-cap-tab="tools"]');
|
||||||
|
click('[data-tool-toggle="notion"]');
|
||||||
|
assert.equal(q('[data-tool-toggle="notion"]').getAttribute('aria-checked'), 'true');
|
||||||
|
click('[data-cap-tab="skills"]');
|
||||||
|
click('[data-action="add-skill"]');
|
||||||
|
fill('#skillName', '简洁写作');
|
||||||
|
fill('#skillDescription', '使用清晰的例子。');
|
||||||
|
click('[data-action="save-skill"]');
|
||||||
|
textContains('#page', '简洁写作');
|
||||||
|
|
||||||
|
click('[data-route="automation"]');
|
||||||
|
click('[data-action="add-schedule"]');
|
||||||
|
fill('#scheduleName', '每日摘要');
|
||||||
|
fill('#schedulePrompt', '汇总今天的信息。');
|
||||||
|
click('[data-action="save-schedule"]');
|
||||||
|
textContains('#page', '每日摘要');
|
||||||
|
click('[data-action="search"]');
|
||||||
|
fill('#searchInput', '合作');
|
||||||
|
assert.equal(w.document.querySelectorAll('.search-result').length, 1);
|
||||||
|
click('.search-result');
|
||||||
|
assert.ok(!q('#modal').open, 'Search closes after selection');
|
||||||
|
click('[data-action="settings"]');
|
||||||
|
fill('#motionMode', 'reduce');
|
||||||
|
fill('#fontScale', 'large');
|
||||||
|
click('[data-action="save-settings"]');
|
||||||
|
assert.ok(w.document.body.classList.contains('reduce-motion'));
|
||||||
|
assert.ok(w.document.body.classList.contains('large-type'));
|
||||||
|
assert.ok(animationCount > 0, 'Explicit interactions trigger motion');
|
||||||
|
const countBeforeReducedClick = animationCount;
|
||||||
|
click('[data-action="settings"]');
|
||||||
|
assert.equal(animationCount, countBeforeReducedClick, 'Reduced motion skips interaction animations');
|
||||||
|
assert.ok(q('.tab-marker'), 'Canvas tabs have a moving selection indicator');
|
||||||
|
assert.deepEqual(errors, [], 'No runtime exceptions');
|
||||||
|
console.log('PASS: task switching and drafts; ask; pause/resume; steering; delivery and revision; permission rejection; recovery; safe user text; editable plans; model capabilities; API validation; tools; skills; schedules; search; appearance.');
|
||||||
|
dom.window.close();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@font-face{font-family:Studio;src:url('../../desktop/frontend-next/public/fonts/InterVariable.woff2') format('woff2');font-display:swap}
|
||||||
|
:root{--bg:#f8f9f7;--paper:#fff;--rail:#f0f2ee;--line:#e4e8e1;--ink:#26352d;--muted:#778279;--faint:#98a197;--green:#37634e;--green-light:#edf4ed;--amber:#967234;--amber-light:#fbf6e9;--red:#a4544d;--blue:#526e91;--shadow:0 8px 32px #283c2c09;--ease:cubic-bezier(.2,.75,.2,1);font-family:Studio,Inter,"Segoe UI","Microsoft YaHei",sans-serif;color:var(--ink);font-size:13px;background:var(--bg)}
|
||||||
|
*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer;border:0;background:none;padding:8px 12px;border-radius:7px;transition:background .18s,color .18s,box-shadow .18s,transform .18s}button:hover{background:#e9eee7}button:active{transform:translateY(1px)}button:disabled{opacity:.45;cursor:default;transform:none}a{color:var(--green)}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid #70997c;outline-offset:3px}svg{width:18px;height:18px;stroke:currentColor;stroke-width:1.65;stroke-linecap:round;stroke-linejoin:round;fill:none;flex-shrink:0;vertical-align:middle}input,select,textarea{background:var(--paper);border:1px solid var(--line);border-radius:8px;padding:10px 12px;min-width:0}textarea{resize:vertical;line-height:1.75}input::placeholder,textarea::placeholder{color:#99a195}h1,h2,h3,p{margin:0}button.primary{background:var(--green);color:#fff;border:1px solid var(--green);box-shadow:0 2px 3px #294c3010}button.primary:hover{background:#284e3c;box-shadow:0 4px 9px #294c3020}button.outline{border:1px solid var(--line);background:var(--paper)}button.outline:hover{border-color:#bdcabc;background:#f7f9f4}button.text-button{color:var(--muted);font-size:11px;padding:4px 7px}button.text-button:hover{color:var(--green)}button.small{font-size:11px;padding:7px 10px}.icon-button{width:31px;height:31px;display:inline-grid;place-items:center;padding:6px;color:#778477}.icon-button svg{width:17px;height:17px}kbd{font-family:inherit;font-size:9px;font-weight:400;border:1px solid #dce2d8;border-radius:4px;padding:2px 4px;color:#8b9687;background:#fafbf8}.muted{color:var(--muted);font-size:12px;line-height:1.8}.eyebrow{font-size:9px;letter-spacing:1.7px;color:#8c998a;font-weight:600}.dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#9da797;flex-shrink:0}.dot.green{background:#6c9678}.dot.amber{background:#c39b51}.dot.red{background:#bd7970}.dot.running{background:#739383;box-shadow:0 0 0 3px #76988715}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}[hidden]{display:none!important}
|
||||||
|
.studio{height:100dvh;min-height:650px;display:grid;grid-template-columns:228px minmax(0,1fr);transition:grid-template-columns .28s var(--ease)}.studio.rail-closed{grid-template-columns:0 minmax(0,1fr)}.studio.rail-closed .sidebar{opacity:0;pointer-events:none}.sidebar{background:var(--rail);border-right:1px solid var(--line);padding:26px 17px 14px;display:flex;flex-direction:column;gap:5px;min-width:0;overflow:hidden;transition:opacity .18s}.wordmark{display:flex;align-items:center;gap:10px;text-align:left;padding:0 6px 24px;border-radius:0;flex-shrink:0}.wordmark:hover{background:transparent}.wordmark>span:last-child{font-size:23px;letter-spacing:-.8px;font-weight:550}.wordmark small{display:block;font-size:7px;letter-spacing:1.8px;color:#879583;margin:5px 0 0 2px;font-weight:500}.brand-symbol{position:relative;display:grid;place-items:center;width:32px;height:35px;font-size:35px;line-height:1;font-family:Georgia,serif;font-style:italic;font-weight:700;color:#3e5c42}.brand-symbol span{position:absolute;background:#98ad81;width:6px;height:6px;border-radius:50%;top:2px;right:0}.new-task{display:flex;align-items:center;gap:8px;font-size:12px;background:white;border:1px solid #dce3d7;box-shadow:0 2px 4px #27362804;text-align:left;padding:11px;margin:5px 0 9px}.new-task kbd{margin-left:auto;font-size:8px}.new-task svg{width:16px}.nav-item{display:flex;align-items:center;gap:10px;text-align:left;width:100%;font-size:11px;padding:10px;color:#687664;white-space:nowrap}.nav-item svg{width:16px;height:16px}.nav-item kbd{margin-left:auto;font-size:8px}.nav-item.active{background:#e0e8d9;color:#3c5c3b;font-weight:550}.nav-group{margin-top:13px;display:grid;gap:4px}.count{margin-left:auto;background:#dce3d3;border-radius:5px;padding:1px 6px;font-size:9px;color:#6e7d64}.sidebar-label{display:flex;align-items:center;justify-content:space-between;padding:0 9px;margin:23px 0 6px;font-size:9px;color:#929c89;letter-spacing:.4px}.sidebar-label button{padding:0;display:grid;place-items:center}.sidebar-label svg{width:13px}.project-nav{display:flex;gap:10px;text-align:left;padding:9px 8px;align-items:center;white-space:nowrap;font-size:10px}.project-avatar{width:29px;height:29px;border:1px solid #d5dfcd;background:#e8eede;display:grid;place-items:center;border-radius:8px;color:#637851;font-family:Georgia,serif;font-size:16px}.project-nav small{display:block;font-size:8px;color:#98a08e;margin-top:5px}.project-nav>svg{margin-left:auto;width:12px;color:#99a28f}.task-list{display:flex;flex-direction:column;gap:4px;overflow:auto;scrollbar-width:thin;flex:1;min-height:100px;margin-top:2px}.task-item{text-align:left;display:flex;gap:9px;align-items:center;font-size:10px;padding:11px 10px;flex-shrink:0;min-width:0;color:#78836e}.task-item .task-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.task-item.active{background:#fff;border:1px solid #e0e7d9;padding:10px 9px;box-shadow:0 2px 5px #43513803;color:#3c5134}.task-item .task-status-icon{margin-left:auto;color:#b28b44;display:flex}.task-item .task-status-icon svg{width:13px;height:13px}.sidebar-bottom{padding-top:18px;flex-shrink:0}.profile{border-top:1px solid #dde4d7;margin-top:18px;padding:19px 3px 1px;display:flex;gap:9px;align-items:center;font-size:10px;white-space:nowrap}.profile-avatar,.top-avatar{display:grid;place-items:center;background:#dfd5c4;border:2px solid #fff;border-radius:50%;height:29px;width:29px;color:#806e53;font-family:Georgia,serif;font-size:14px}.profile small{display:flex;align-items:center;gap:5px;color:#97a088;font-size:8px;margin-top:6px}.profile .icon-button{margin-left:auto}.body-shell{display:flex;flex-direction:column;min-width:0;overflow:hidden}.topbar{height:65px;flex-shrink:0;background:#fafbf9;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 24px;gap:15px}.breadcrumb{display:flex;align-items:center;gap:12px;font-size:10px;min-width:0;color:#8d9788}.breadcrumb strong{font-size:11px;color:#58664e;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.slash{color:#c1c8b9}.top-actions{display:flex;align-items:center;gap:12px}.prototype-label{font-size:8px;letter-spacing:1px;color:#a0aa96;padding:4px 7px;border:1px solid #e1e7d9;border-radius:4px}.top-avatar{width:27px;height:27px;font-size:12px}.top-actions .icon-button{position:relative}.top-actions b{position:absolute;top:5px;right:6px;width:4px;height:4px;border-radius:50%;background:#b29155}.statusbar{height:28px;flex-shrink:0;display:flex;justify-content:space-between;align-items:center;padding:0 20px;border-top:1px solid var(--line);font-size:8px;color:#91a082;gap:12px;background:#f7f9f4}.statusbar>span{display:flex;gap:7px;align-items:center}.statusbar-muted{color:#a6b199}.page{flex:1;min-height:0;overflow:auto}.task-workspace{height:100%;display:grid;grid-template-columns:minmax(390px,1fr) minmax(330px,40%);overflow:hidden}.task-workspace.canvas-closed{grid-template-columns:minmax(0,1fr) 0}.conversaLine truncated
|
||||||
|
.canvas{border-left:1px solid #e2e9d9;background:#f1f4ed;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden}.canvas-closed .canvas{visibility:hidden}.canvas-toolbar{height:54px;display:flex;align-items:center;justify-content:space-between;padding:0 19px;border-bottom:1px solid #e2e9d9;background:#f6f8f2;gap:8px;flex-shrink:0}.canvas-tabs{display:flex;gap:16px;height:100%;align-items:stretch}.canvas-tabs button{padding:0 1px;border-radius:0;position:relative;color:#9caa88;font-size:9px}.canvas-tabs button:hover{background:none;color:#6f8755}.canvas-tabs button[aria-selected="true"]{color:#6e8854}.canvas-tabs button[aria-selected="true"]:after{content:'';position:absolute;bottom:0;left:0;right:0;height:2px;background:#8ca86a}.canvas-toolbar .icon-button{width:23px;height:25px;padding:3px;color:#9aab83}.canvas-toolbar .icon-button svg{width:14px}.canvas-content{flex:1;overflow:auto;min-height:0;scrollbar-width:thin;scrollbar-color:#d2ddc4 transparent;padding:20px}.document-tools{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:16px;font-size:9px;color:#9aac83}.document-tools button{font-size:9px;padding:4px 7px;color:#8ba16d;border:1px solid #dfe7d4;background:#f9fbf6}.paper{background:#fff;border:1px solid #e0e8d7;border-radius:3px;box-shadow:0 10px 28px #536a3707;min-height:520px;overflow:hidden}.paper-cover{padding:31px 28px 27px;position:relative;overflow:hidden;border-bottom:1px solid #e7eddc;background:linear-gradient(125deg,#f6f8ef,#edf2e3)}.paper-cover .eyebrow{font-size:7px;letter-spacing:2px;color:#a2b581}.paper-cover h2{font-size:26px;line-height:1.4;font-weight:500;letter-spacing:-.8px;color:#788f51;position:relative;z-index:1;max-width:230px;margin:18px 0 13px}.paper-cover p{font-size:9px;color:#a1b280;line-height:1.8;max-width:220px;position:relative;z-index:1}.cover-art{position:absolute;right:-27px;bottom:-50px;width:175px;height:210px;transform:rotate(27deg)}.cover-art span{position:absolute;border-radius:90px 90px 10px 10px;border:1px solid #c9d6b0;width:90px;height:170px;background:linear-gradient(100deg,#d7e1c433,#bcca9d30);transform:rotate(-12deg);right:0;bottom:0}.cover-art span:nth-child(2){right:35px;bottom:8px;transform:rotate(-30deg);background:#c9d6b016}.cover-art span:nth-child(3){right:65px;bottom:17px;transform:rotate(-50deg);background:#e2e9d425}.paper-meta{display:flex;gap:16px;font-size:8px;color:#a3b58a;padding:14px 28px;border-bottom:1px solid #edf1e4}.paper-body{padding:22px 28px 27px;color:#81976a;font-size:10px;line-height:1.9}.paper-body h3{font-size:12px;font-weight:500;color:#79915d;margin:0 0 9px;display:flex;align-items:center;gap:8px}.paper-body h3 span{font-size:8px;color:#aabb93;font-family:Georgia,serif}.paper-body p{margin-bottom:19px}.document-cards{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin:14px 0 22px}.document-cards>div{padding:12px;background:#f6f9f0;border:1px solid #e9efdf;border-radius:5px}.document-cards b{font-size:9px;display:block;font-weight:500;color:#849e66}.document-cards small{font-size:8px;display:block;margin-top:5px;line-height:1.8;color:#a1b387}.doc-line{display:flex;gap:10px;padding:9px 0;border-bottom:1px solid #edf2e5;font-size:9px;color:#95a97c}.doc-line span:first-child{color:#b1bf9c;font-size:8px;width:33px;flex-shrink:0}.paper-footer{padding:13px 28px;border-top:1px solid #edf2e4;font-size:7px;color:#abbc95;display:flex;justify-content:space-between}.canvas-bottom{padding:12px 20px;border-top:1px solid #e1e9d5;display:flex;justify-content:space-between;align-items:center;background:#f7faf2;color:#9caf84;font-size:8px;flex-shrink:0}.canvas-bottom button{color:#90a673;font-size:9px;padding:6px 8px}.canvas-bottom svg{width:12px;height:12px}.activity-heading{font-size:10px;color:#94a97c;margin:3px 0 22px}.activity-entry{padding:0 0 23px 21px;border-left:1px solid #dbe6cc;position:relative;margin-left:6px;color:#8a9f71;font-size:10px}.activity-entry:before{content:'';position:absolute;left:-4px;top:3px;width:7px;height:7px;border-radius:50%;background:#b2c596;border:2px solid #f4f7ee}.activity-entry small{display:block;margin-top:6px;font-size:8px;color:#a6b992}.activity-entry:last-child{border-color:transparent}.file-row{display:flex;align-items:center;gap:12px;padding:14px;background:#fcfef8;border:1px solid #e0e9d2;border-radius:8px;margin-bottom:9px;text-align:left;width:100%;color:#8ca96b;font-size:10px}.file-row svg{width:19px}.file-row small{display:block;font-size:8px;color:#aabc93;margin-top:6px}.file-row .arrow{margin-left:auto;width:12px}.empty-panel{padding:45px 16px;text-align:center;color:#96ab81;font-size:11px;line-height:1.9}.empty-panel svg{width:30px;height:30px;margin-bottom:15px;color:#b3c69c;display:block;margin-left:auto;margin-right:auto}
|
||||||
|
.overview-page{padding:43px max(35px,calc((100% - 1060px)/2)) 35px;min-height:100%;background:#fafbf8}.overview-header{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:32px;gap:15px}.overview-header h1{font-size:30px;font-weight:500;letter-spacing:-.8px;color:#43593c;margin:9px 0 9px}.overview-header .muted{font-size:11px;color:#8e9d82}.overview-header button{font-size:11px}.home-composer{border:1px solid #dce6d2;background:#fff;border-radius:13px;padding:17px 20px;box-shadow:0 5px 25px #49653105;margin-bottom:31px}.home-composer textarea{width:100%;border:0;outline:none;resize:none;font-size:13px;min-height:64px}.home-composer>div{display:flex;justify-content:space-between;align-items:center;font-size:10px;color:#a1b28d}.home-composer button{font-size:10px}.section-heading{display:flex;justify-content:space-between;align-items:center;margin:26px 0 14px}.section-heading h2{font-size:13px;font-weight:500;color:#738e5c}.section-heading span{font-size:10px;color:#a6b894}.task-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px}.task-tile{border:1px solid #e0e9d5;background:#fff;border-radius:11px;padding:19px;text-align:left;min-height:162px;display:flex;flex-direction:column;align-items:flex-start;transition:transform .18s,box-shadow .18s,border-color .18s}.task-tile:hover{transform:translateY(-3px);box-shadow:0 10px 24px #5a793110;border-color:#bdcfa7;background:white}.task-tile h3{font-size:13px;font-weight:500;color:#6f8a53;margin:17px 0 9px}.task-tile p{font-size:10px;color:#a1b288;line-height:1.7}.task-tile>small{font-size:8px;color:#b2c09e;margin-top:auto;padding-top:18px}.task-tile .status-pill{font-size:8px}.project-banner{border:1px solid #dee8d1;background:linear-gradient(110deg,#f0f5e8,#f8faf5);border-radius:11px;padding:23px;display:flex;align-items:center;gap:18px;margin-top:25px}.project-banner .project-avatar{width:42px;height:42px;font-size:24px;background:#e3edda;color:#93ac75;border-color:#d5e2c6}.project-banner h3{font-size:13px;font-weight:500;color:#7b9860;margin-bottom:7px}.project-banner p{font-size:10px;color:#a2b68b}.project-banner button{margin-left:auto;font-size:10px}.connection-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.connection-card{border:1px solid #dfe9d2;border-radius:11px;padding:21px;background:#fff}.connection-card header{display:flex;align-items:center;gap:12px}.provider-symbol{width:34px;height:34px;border:1px solid #dae5ca;border-radius:9px;background:#f0f5e7;display:grid;place-items:center;font-family:Georgia,serif;font-size:17px;color:#8eab6b}.connection-card h3{font-size:12px;font-weight:500;color:#7c995b}.connection-card header small{font-size:9px;color:#a8bb90;display:block;margin-top:5px}.connection-card p{font-size:10px;color:#9db484;line-height:1.8;margin:17px 0}.connection-card footer{display:flex;justify-content:space-between;align-items:center;border-top:1px solid #edf2e5;padding-top:12px;font-size:9px;color:#aec29a}.connection-card footer button{font-size:9px;padding:4px 8px}.filter-tabs{display:flex;gap:7px;margin-bottom:25px}.filter-tabs button{font-size:11px;color:#8ea675;padding:8px 13px}.filter-tabs button.active{background:#e6eedb;color:#6b8c4e}.toggle{width:30px;height:18px;position:relative;border-radius:15px;background:#d4e0c6;padding:0}.toggle:after{content:'';width:12px;height:12px;background:#fff;border-radius:50%;position:absolute;left:3px;top:3px;box-shadow:0 1px 3px #47603215;transition:transform .2s}.toggle[aria-checked="true"]{background:#8aa967}.toggle[aria-checked="true"]:after{transform:translateX(12px)}.schedule-row{display:flex;align-items:center;gap:17px;padding:22px;border:1px solid #dfe9d3;background:#fff;border-radius:10px;margin-bottom:12px}.schedule-row h3{font-size:12px;font-weight:500;color:#799659;margin-bottom:8px}.schedule-row small{font-size:9px;color:#a4b88c}.schedule-row .toggle{margin-left:auto}.schedule-row>svg{color:#a7bd8d}.schedule-row .icon-button{color:#9fb587}.note-box{padding:15px 17px;border:1px solid #e5e9d8;border-radius:8px;background:#f8faef;font-size:10px;color:#9caa7e;line-height:1.9;margin-top:24px}
|
||||||
|
.modal{padding:0;border:1px solid #dfe7d6;border-radius:15px;width:590px;max-width:calc(100vw - 28px);color:#60764e;box-shadow:0 30px 90px #26371d30}.modal::backdrop{background:#24351e38;backdrop-filter:blur(5px)}.modal header{display:flex;justify-content:space-between;align-items:center;padding:23px 26px;border-bottom:1px solid #e7eddc;background:#fbfdf8}.modal h2{font-size:20px;font-weight:500;letter-spacing:-.4px;margin-top:8px;color:#698348}.modal-body{padding:23px 26px;max-height:66dvh;overflow:auto;background:#fff}.modal footer{padding:15px 26px;border-top:1px solid #e7eddd;background:#fcfdf9;display:flex;justify-content:flex-end;gap:8px}.modal footer:empty{display:none}.modal footer button{font-size:11px}.modal label{display:block;font-size:11px;color:#849d66;margin:16px 0 7px}.modal input,.modal select,.modal textarea{width:100%;font-size:11px;color:#779256;border-color:#dce7cf}.modal .muted{color:#91a778;font-size:11px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.form-error{font-size:11px;color:var(--red);margin-top:13px}.search-field{width:100%;margin-bottom:15px}.search-result{display:flex;gap:10px;width:100%;padding:12px;text-align:left;align-items:center;font-size:11px;color:#829f60}.search-result small{font-size:9px;color:#a1b989;margin-left:auto}.search-result svg{width:15px}.model-option{width:100%;padding:15px;display:flex;gap:11px;align-items:center;text-align:left;border:1px solid #e4ecdb;margin:9px 0}.model-option strong{font-size:12px;font-weight:500;color:#789651}.model-option small{font-size:9px;color:#a3ba8a;display:block;margin-top:6px}.model-option.selected{border-color:#afc992;background:#f4f9ed}.model-option>svg:last-child{margin-left:auto;color:#8caa68}.toast{position:fixed;bottom:43px;left:50%;transform:translateX(-50%);background:#456345;color:white;font-size:11px;border:1px solid #567a50;border-radius:9px;box-shadow:0 8px 30px #34502520;padding:12px 18px;z-index:10;max-width:90vw;line-height:1.7}
|
||||||
|
.event-arrival{animation:arrive .32s var(--ease) both}.panel-opening{animation:panel .3s var(--ease) both}.dialog-opening{animation:dialog .22s var(--ease) both}.picked{animation:pick .25s ease-out}.flash-update{animation:update .65s ease-out}.card-hover{transition:transform .18s,box-shadow .18s}@keyframes arrive{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes panel{from{opacity:.3;transform:translateX(18px)}to{opacity:1;transform:translateX(0)}}@keyframes dialog{from{opacity:.3;transform:translateY(8px) scale(.99)}to{opacity:1;transform:none}}@keyframes pick{50%{box-shadow:0 0 0 3px #b5c89225}}@keyframes update{0%,60%{background:#f1f6e8}100%{background:transparent}}@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
/* Keep supporting text readable on the softly tinted product surfaces. */
|
||||||
|
:root{--muted:#727e6a}.conversation-description,.composer-hint{color:#859277}.task-item{color:#68765b}.statusbar{color:#7e8e6d}.statusbar-muted{color:#859377}.sidebar-label{color:#849079}.project-nav small,.profile small{color:#849477}.message,.user-bubble{color:#55634a}.message p{color:#59694c}.ask-option small,.ask-body>.muted{color:#88956d}.ask-option strong{color:#657644}.card-footer>span{color:#938765}.tool-group summary,.tool-record{color:#7b8b64}.tool-group summary .tool-result,.tool-record small{color:#8d9e75}.paper-body{color:#71845c}.paper-cover h2{color:#657d42}.paper-cover p{color:#839969}.paper-meta,.paper-footer{color:#8d9f76}.paper-body h3{color:#647f47}.document-cards small,.doc-line{color:#839868}.canvas-tabs button{color:#859773}.canvas-bottom{color:#8a9f72}.composer textarea{color:#687a53}.composer textarea::placeholder{color:#8d9d7a}.overview-header .muted,.task-tile p{color:#879c72}.task-tile h3{color:#607d45}.connection-card p,.connection-card footer{color:#849f6b}.connection-card h3{color:#698a49}.connection-card header small,.schedule-row small{color:#8ca874}.note-box{color:#80965f}.empty-panel{color:#7e9864}.modal .muted{color:#809b64}.modal label{color:#6f8c51}
|
||||||
|
@media(min-width:1600px){.studio{grid-template-columns:245px minmax(0,1fr)}.task-workspace{grid-template-columns:minmax(500px,1fr) minmax(420px,41%)}.transcript{padding-top:17px}.conversation-title{font-size:28px}.message{font-size:13px}.user-bubble{font-size:12px}.ask-body h3{font-size:14px}.ask-option strong{font-size:12px}.ask-option small{font-size:10px}.paper-cover{padding:40px 35px}.paper-cover h2{font-size:32px}.paper-body{padding:28px 35px;font-size:12px}.paper-body h3{font-size:14px}.document-cards b{font-size:11px}.document-cards small{font-size:10px}.doc-line{font-size:11px}.paper-meta,.paper-footer{padding-left:35px}.task-item,.nav-item{font-size:12px}.project-nav{font-size:11px}.composer textarea{font-size:13px}.composer-bottom button{font-size:10px}.canvas-tabs button{font-size:11px}}
|
||||||
|
@media(max-width:1150px){.studio{grid-template-columns:198px minmax(0,1fr)}.sidebar{padding-left:12px;padding-right:12px}.task-workspace{grid-template-columns:minmax(340px,1fr) 330px}.transcript{padding-left:23px;padding-right:23px}.conversation-toolbar{padding-left:23px;padding-right:23px}.canvas-content{padding:15px}.paper-cover{padding:25px 22px}.paper-cover h2{font-size:24px}.paper-body{padding:21px}.task-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.prototype-label{display:none}.statusbar>span:last-child{display:none}}
|
||||||
|
@media(max-width:960px){.task-workspace{grid-template-columns:minmax(0,1fr)}.canvas{position:fixed;right:12px;top:77px;bottom:39px;width:min(440px,calc(100vw - 24px));z-index:4;border:1px solid #d9e5ca;border-radius:12px;box-shadow:0 12px 70px #3c522b2a}.canvas-closed .canvas{display:none}.conversation-title{font-size:27px}.paper-body{font-size:12px}.task-workspace .canvas-toggle{display:inline-flex}.topbar{padding:0 17px}.connection-grid{grid-template-columns:1fr}.overview-page{padding:32px 27px}.overview-header h1{font-size:26px}}
|
||||||
|
@media(max-width:650px){.studio{grid-template-columns:0 minmax(0,1fr)}.sidebar{visibility:hidden}.studio.mobile-rail .sidebar{visibility:visible;position:fixed;left:0;top:0;bottom:0;width:228px;z-index:6;box-shadow:10px 0 50px #2f442428;opacity:1;pointer-events:auto}.topbar{height:56px;padding:0 12px}.breadcrumb{gap:7px}.breadcrumb>span:not(.slash){display:none}.top-actions{gap:6px}.top-actions .top-avatar{display:none}.top-actions .icon-button:first-of-type{display:none}.statusbar{font-size:8px;padding:0 12px}.statusbar>span:nth-child(2){display:none}.conversation-toolbar{padding:14px 17px 8px}.transcript{padding:10px 19px 20px}.conversation-title{font-size:24px}.conversation-description{font-size:10px}.message{font-size:13px}.user-bubble{font-size:12px}.ask-option strong{font-size:12px}.ask-option small{font-size:10px}.ask-body h3{font-size:14px}.ask-body>.muted{font-size:11px}.ask-option{min-height:50px}.card-footer{flex-wrap:wrap}.card-footer button{min-height:38px;font-size:11px}.composer-zone{padding:7px 15px 13px}.composer textarea{font-size:16px}.composer-bottom button{font-size:10px;min-height:34px}.composer-bottom .send{width:34px;height:34px}.composer-bottom .composer-model{max-width:145px}.composer-hint{font-size:8px}.canvas{top:66px}.overview-page{padding:27px 19px}.overview-header h1{font-size:24px}.overview-header{align-items:flex-start}.overview-header .eyebrow{font-size:8px}.overview-header button{font-size:10px;white-space:nowrap}.task-grid{grid-template-columns:1fr}.task-tile{min-height:135px}.task-tile h3{font-size:14px}.task-tile p{font-size:12px}.task-tile>small{font-size:10px}.project-banner{padding:16px;gap:10px;flex-wrap:wrap}.project-banner h3{font-size:12px}.project-banner p{font-size:10px}.modal-body{padding:20px}.modal header{padding:20px}.form-grid{grid-template-columns:1fr}.modal input,.modal select,.modal textarea{font-size:16px}.modal{max-width:calc(100vw - 20px)}.schedule-row{padding:17px;gap:12px}.filter-tabs{gap:2px;flex-wrap:wrap}.filter-tabs button{font-size:11px;padding:8px}.studio{min-height:620px}}
|
||||||
|
.nav-item{font-size:12px}.task-item{font-size:11px}.sidebar-label{font-size:10px}.project-nav{font-size:11px}.profile{font-size:11px}.profile small,.project-nav small{font-size:10px}.breadcrumb{font-size:11px}.breadcrumb strong{font-size:12px}.conversation-description{font-size:11px}.message{font-size:14px}.user-bubble{font-size:13px;color:#4b5946}.agent-byline{font-size:12px}.agent-byline small{font-size:10px}.message p{color:#52614a}.compact-plan summary{font-size:12px}.plan-steps li{font-size:12px}.tool-group summary,.tool-record{font-size:11px}.tool-group summary .tool-result,.tool-record small{font-size:10px}.receipt{font-size:11px}.ask-body h3{font-size:15px;color:#4c5239}.ask-body>.muted{font-size:12px;color:#7b806a}.ask-option strong{font-size:12px;color:#546239}.ask-option small{font-size:11px;color:#7c8768}.card-heading{font-size:11px}.card-heading>span:last-child{font-size:10px}.card-footer>span{font-size:10px}.card-footer button{font-size:11px}.ask-free{font-size:12px}.ask-option .recommended{font-size:9px}.execution-top{font-size:12px}.execution-bottom{font-size:11px}.execution-top .small{font-size:10px}.subagent{font-size:10px}.result-card b{font-size:12px}.result-card small{font-size:11px}.composer textarea{font-size:13px}.composer-bottom button{font-size:11px}.composer-hint{font-size:9px}.status-pill{font-size:10px}.task-meta .project-tag{font-size:10px}.canvas-tabs button{font-size:11px}.document-tools{font-size:11px;color:#788969}.document-tools button{font-size:10px}.paper-meta{font-size:10px;color:#7b8c68}.paper-body{font-size:12px;color:#647650}.paper-body h3{font-size:14px;color:#566d3f}.paper-cover h2{color:#536b35}.paper-cover p{font-size:11px;color:#788d5d}.document-cards b{font-size:11px;color:#6a814f}.document-cards small{font-size:10px;color:#788a65}.doc-line{font-size:11px;color:#768960}.doc-line span:first-child{font-size:10px;color:#879873;width:40px}.paper-footer{font-size:9px;color:#8b9b78}.canvas-bottom{font-size:10px;color:#7f9366}.canvas-bottom button{font-size:10px}.activity-entry{font-size:12px;color:#758a5c}.activity-entry small{font-size:10px;color:#8d9f77}.file-row{font-size:12px;color:#728951}.file-row small{font-size:10px;color:#8b9c74}.task-tile h3{font-size:15px;color:#506c35}.task-tile p{font-size:12px;color:#7c9069}.task-tile>small{font-size:10px;color:#8fa17c}.task-tile .status-pill{font-size:10px}.section-heading h2{font-size:15px}.section-heading span{font-size:11px}.overview-header .muted{font-size:13px}.overview-header button{font-size:12px}.connection-card h3{font-size:14px}.connection-card p{font-size:12px;line-height:1.8}.connection-card header small,.connection-card footer{font-size:11px}.connection-card footer button{font-size:11px}.filter-tabs button{font-size:12px}.note-box{font-size:12px;color:#788a5e}.schedule-row h3{font-size:14px}.schedule-row small{font-size:11px}.modal label,.modal input,.modal select,.modal textarea{font-size:13px}.modal .muted{font-size:12px;color:#768a5f}.modal footer button{font-size:12px}.model-option strong{font-size:14px}.model-option small{font-size:11px}.search-result{font-size:13px}.search-result small{font-size:11px}.task-meta{min-width:0}.project-tag{max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.conversation-toolbar{flex-shrink:0}.task-workspace.canvas-closed .transcript{padding-left:max(28px,calc((100% - 770px)/2));padding-right:max(28px,calc((100% - 770px)/2))}.task-workspace.canvas-closed .composer-zone{padding-left:max(26px,calc((100% - 770px)/2));padding-right:max(26px,calc((100% - 770px)/2))}.task-workspace.canvas-closed .conversation-toolbar{padding-left:max(28px,calc((100% - 770px)/2));padding-right:max(28px,calc((100% - 770px)/2))}.large-type .message,.large-type .user-bubble{font-size:16px}.large-type .paper-body{font-size:14px}.large-type .composer textarea{font-size:16px}.large-type .ask-option strong{font-size:14px}.large-type .nav-item,.large-type .task-item{font-size:13px}.reduce-motion *,.reduce-motion *:before,.reduce-motion *:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}
|
||||||
|
@media(max-width:650px){.composer textarea,.modal input,.modal select,.modal textarea{font-size:16px}.composer-bottom button{font-size:10px}.composer-bottom .composer-model{max-width:125px}.task-meta .project-tag{max-width:135px;font-size:9px}.status-pill{font-size:9px}.task-workspace.canvas-closed .transcript{padding-left:19px;padding-right:19px}.task-workspace.canvas-closed .composer-zone{padding-left:15px;padding-right:15px}.task-workspace.canvas-closed .conversation-toolbar{padding-left:17px;padding-right:17px}.card-footer>span{font-size:10px}.task-tile p{font-size:12px}}
|
||||||
|
@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
(()=>{
|
||||||
|
'use strict';
|
||||||
|
const $=id=>document.getElementById(id);
|
||||||
|
const paths={plus:'M12 5v14M5 12h14',search:'M21 21l-5-5M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0',grid:'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z',inbox:'M4 4h16l2 12v4H2v-4L4 4zM2 15h6l2 3h4l2-3h6',files:'M6 3h9l4 4v14H6zM14 3v5h5M3 7v14M9 12h7M9 16h7',chevron:'M9 5l7 7-7 7',clock:'M12 8v5l3 2M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',puzzle:'M9 3H3v6c4-2 5 4 0 3v9h7c-2-5 5-5 3 0h8v-8c-5 2-5-5 0-3V3h-7c2 5-5 5-5 0',settings:'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8M9 2h6l1 4 4 1 2 5-3 3v4l-5 3-3-3H7l-4-4 1-4-2-3 3-4 4 1z',panel:'M3 3h18v18H3zM9 3v18',bell:'M5 17h14l-2-3V9a5 5 0 0 0-10 0v5l-2 3M10 21h4',close:'M6 6l12 12M6 18L18 6',arrow:'M5 12h14M13 6l6 6-6 6',up:'M12 19V5M6 11l6-6 6 6',check:'M5 12l4 4L19 6',checkcircle:'M8 12l3 3 5-6M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',question:'M9 8a3 3 0 1 1 4 3c-1 0-1 1-1 3M12 17h.01M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',shield:'M12 2l9 4v6c0 5-9 10-9 10S3 17 3 12V6l9-4M8 12l3 3 5-6',link:'M10 13a5 5 0 0 0 7 0l4-4a5 5 0 0 0-7-7l-2 2M14 11a5 5 0 0 0-7 0l-4 4a5 5 0 0 0 7 7l2-2',download:'M12 3v12M7 10l5 5 5-5M4 16v5h16v-5',edit:'M4 16L16 4l4 4L8 20H4v-4M14 6l4 4',more:'M5 12h.01M12 12h.01M19 12h.01',globe:'M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0M2 12h20M12 2c-5 5-5 15 0 20 5-5 5-15 0-20',code:'M8 6l-6 6 6 6M16 6l6 6-6 6M14 3l-4 18',folder:'M2 5h7l2 3h11v13H2V5z',paperclip:'M8 12l7-7a4 4 0 0 1 6 6l-9 9a6 6 0 0 1-9-9l9-9M6 14l9-9a2 2 0 0 1 3 3l-9 9',pause:'M8 5v14M16 5v14',play:'M7 4l14 8-14 8V4z',stop:'M6 6h12v12H6z',warning:'M12 3L1 21h22L12 3zM12 9v5M12 17h.01',list:'M9 6h12M9 12h12M9 18h12M3 6h.01M3 12h.01M3 18h.01',spark:'M12 2l3 7 7 3-7 3-3 7-3-7-7-3 7-3 3-7z',layers:'M12 2l10 6-10 6L2 8l10-6M2 12l10 6 10-6M2 16l10 6 10-6',expand:'M8 3H3v5M16 3h5v5M3 16v5h5M21 16v5h-5',mail:'M2 5h20v14H2zM2 5l10 8L22 5',refresh:'M20 7a9 9 0 1 0 1 8M20 2v6h-6',archive:'M3 3h18v5H3zM5 8v13h14V8M10 12h4'};
|
||||||
|
const icon=name=>`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${paths[name]||paths.spark}"/></svg>`;
|
||||||
|
function icons(root=document){root.querySelectorAll('[data-icon]').forEach(el=>{el.outerHTML=icon(el.dataset.icon)})}
|
||||||
|
const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const button=(action,label,cls='',data='')=>`<button class="${cls}" data-action="${action}" ${data}>${label}</button>`;
|
||||||
|
const statusLabels={waiting:'等你回答',approval:'需要授权',running:'执行中',paused:'已暂停',done:'已完成',blocked:'需要处理',idle:'就绪'};
|
||||||
|
const statusIcon=s=>s==='waiting'?'question':s==='approval'?'shield':s==='blocked'?'warning':s==='done'?'check':'clock';
|
||||||
|
const state={route:'task',active:'launch',canvas:true,canvasTab:'preview',capTab:'models',project:'Atelier 品牌工作室',models:[{id:'balanced',name:'通用模型',description:'标准对话 · 工具调用 · 流式输出',provider:'演示服务',protocol:'OpenAI 兼容',endpoint:'',effort:true},{id:'fast',name:'快速模型',description:'轻量问答 · 工具调用',provider:'演示服务',protocol:'OpenAI 兼容',endpoint:'',effort:false}],tools:[{id:'web',name:'网页检索',description:'搜索公开资料,读取页面并保留来源。',kind:'内置工具',icon:'globe',enabled:true},{id:'files',name:'工作区文件',description:'读取与编辑当前项目内的文件。',kind:'本地能力',icon:'folder',enabled:true},{id:'notion',name:'Notion',description:'搜索项目知识库,读取页面和数据库。',kind:'MCP 连接器',icon:'files',enabled:false},{id:'http',name:'自定义 HTTP API',description:'通过适配器连接业务服务。',kind:'HTTP API',icon:'link',enabled:false}],skills:[{id:'research',name:'研究与整理',description:'整理来源、比较信息并形成可核查的结论。',enabled:true},{id:'brand',name:'品牌写作',description:'在项目内保持一致的语气、术语和表达。',enabled:true},{id:'sheet',name:'表格分析',description:'分析数据、整理表格并检查计算。',enabled:false}],schedules:[{id:'weekly',name:'每周行业动态',prompt:'汇总本周与产品相关的行业动态,并保留来源。',cadence:'每周一 09:00',enabled:true}],drafts:{},attachments:{},askChoices:{},permission:'ask',model:'balanced',effort:'标准',notificationCount:0};
|
||||||
|
let sequence=20,toastTimer,clockTimer;
|
||||||
|
const tasks=[
|
||||||
|
{id:'launch',title:'秋季新品发布策略',summary:'定位、内容节奏与渠道建议',status:'waiting',kind:'strategy',model:'balanced',version:1,step:2,ticks:0,artifact:true,ask:true,choice:null,permission:null,goal:'结合项目资料,帮我整理一份秋季新品发布策略。先明确产品定位,再给出内容与渠道建议。',plan:['阅读项目资料与品牌约定','梳理产品定位与目标人群','制定发布节奏与渠道建议','整理成可讨论的策略文档'],messages:[{type:'user',text:'结合项目资料,帮我整理一份秋季新品发布策略。先明确产品定位,再给出内容与渠道建议。'},{type:'agent',text:'我已经整理了项目里的品牌方向和产品介绍,先搭好了策略文档的结构。\n发布节奏还需要明确一个重点:这次更希望让新用户认识品牌,还是推动已有用户尝试新品?',tools:['已读取 品牌手册.md','已读取 新品介绍.md','已整理 3 个发布方向']}],events:['开始整理发布策略','读取 2 份项目资料','完成定位初稿,等待确认目标'],revision:''},
|
||||||
|
{id:'research',title:'个人助手产品研究',summary:'比较交互方式与核心能力',status:'idle',kind:'research',model:'balanced',version:0,step:1,ticks:0,artifact:false,ask:false,goal:'比较主流个人助手的交互方式,整理一份产品研究简报。',plan:['明确比较维度','收集产品资料','整理功能与交互差异','形成研究简报'],messages:[{type:'user',text:'比较主流个人助手的交互方式,整理一份产品研究简报。'},{type:'agent',text:'我会从任务组织、工具接入和成果交付三个角度展开,保留来源。你可以在我工作时继续补充关注点。'}],events:['研究计划已准备好'],revision:''},
|
||||||
|
{id:'email',title:'合作邀约邮件',summary:'草稿已完成,发送前等待确认',status:'approval',kind:'email',model:'fast',version:1,step:2,ticks:0,artifact:true,ask:false,permission:'pending',goal:'根据项目内容起草一封合作邀约邮件。',plan:['梳理合作背景','起草邮件','确认收件人和内容','发送并记录结果'],messages:[{type:'user',text:'起草一封合作邀约邮件,发送前让我确认。'},{type:'agent',text:'邮件草稿已经准备好,你可以在右侧检查。下一步是发送邮件,这会联系外部收件人,需要你确认。'}],events:['已生成邮件草稿','等待发送授权'],revision:''},
|
||||||
|
{id:'analysis',title:'用户访谈主题整理',summary:'整理完成,含主题与后续问题',status:'done',kind:'research',model:'balanced',version:1,step:4,ticks:0,artifact:true,ask:false,goal:'把访谈笔记中的主要主题整理出来。',plan:['读取访谈笔记','整理主题','标记待验证问题','交付主题简报'],messages:[{type:'user',text:'把访谈笔记中的主要主题整理出来。'},{type:'agent',text:'主题简报已整理完成。示例文档列出了重点方向与后续验证问题,你可以直接打开,也可以继续提出修改。'}],events:['读取资料','整理主题','完成交付'],revision:''},
|
||||||
|
{id:'sync',title:'同步项目知识库',summary:'连接失效,现有进度已保存',status:'blocked',kind:'research',model:'balanced',version:0,step:1,ticks:0,artifact:false,ask:false,goal:'同步项目知识库里的最新资料。',plan:['检查已有资料','读取远程更新','合并新增内容','生成同步记录'],messages:[{type:'user',text:'同步项目知识库里的最新资料。'},{type:'agent',text:'本地资料已检查完成。知识库的访问凭证失效,无法继续读取远程更新;本地文件没有被修改。'}],events:['完成本地资料检查','连接返回身份验证错误'],revision:''}
|
||||||
|
];
|
||||||
|
function current(){return tasks.find(t=>t.id===state.active)||tasks[0]}
|
||||||
|
function pill(t){return `<span class="status-pill ${t.status}"><span class="dot ${t.status==='waiting'||t.status==='approval'?'amber':t.status==='blocked'?'red':t.status==='running'?'running':'green'}"></span>${statusLabels[t.status]}</span>`}
|
||||||
|
function announce(text){$('announcer').textContent=text}
|
||||||
|
function toast(text){clearTimeout(toastTimer);$('toast').textContent=text;$('toast').hidden=false;toastTimer=setTimeout(()=>$('toast').hidden=true,3500)}
|
||||||
|
function renderSidebar(){const visible=tasks.filter(t=>!t.archived);$('taskCount').textContent=visible.length;$('taskList').innerHTML=visible.map(t=>`<button class="task-item ${state.route==='task'&&state.active===t.id?'active':''}" data-task="${t.id}"><span class="dot ${t.status==='running'?'running':t.status==='done'?'green':t.status==='blocked'?'red':'amber'}"></span><span class="task-name">${esc(t.title)}</span>${['waiting','approval','blocked'].includes(t.status)?`<span class="task-status-icon">${icon(statusIcon(t.status))}</span>`:''}</button>`).join('');$('attentionCount').textContent=tasks.filter(t=>!t.archived&&['waiting','approval','blocked'].includes(t.status)).length;$('notificationDot').hidden=state.notificationCount===0;document.querySelectorAll('[data-route]').forEach(b=>b.classList.toggle('active',b.dataset.route===state.route));const label=document.querySelector('.project-nav>span:nth-child(2)');label.innerHTML=esc(state.project)+'<small>策略 · 产品 · 内容</small>'}
|
||||||
|
function render(){renderSidebar();if(state.route==='task')renderTask();else renderPage();icons();}
|
||||||
|
function navigate(route,id){state.route=route;if(id)state.active=id;$('studio').classList.remove('mobile-rail');render();}
|
||||||
|
function messageHTML(m,index){if(m.type==='user')return `<section class="message user" data-message="${index}"><div class="user-bubble">${esc(m.text)}</div></section>`;return `<section class="message" data-message="${index}"><div class="agent-byline"><span class="agent-avatar">r</span>Reasonix<small>${m.steering?'已接收补充':'通用助手'}</small></div><p>${esc(m.text)}</p>${m.tools?`<details class="tool-group"><summary>${icon('checkcircle')}<span>${m.tools.length} 项工作已完成</span><span class="tool-result">查看记录</span>${icon('chevron')}</summary>${m.tools.map(x=>`<div class="tool-record">${icon('check')}<span>${esc(x)}</span><small>示例</small></div>`).join('')}</details>`:''}</section>`}
|
||||||
|
function planHTML(t){if(!t.plan.length)return '';const n=Math.min(t.step,t.plan.length);return `<details class="compact-plan" id="taskPlan"><summary>${icon('list')}<span>执行计划</span><span class="plan-total">${n} / ${t.plan.length}</span><span class="chevron-small">${icon('chevron')}</span></summary><ol class="plan-steps">${t.plan.map((x,i)=>`<li class="${i<n?'complete':i===n?'current':''}"><span class="step-circle">${i<n?'✓':i+1}</span><span>${esc(x)}</span></li>`).join('')}</ol><div style="padding:0 8px 8px;text-align:right">${button('edit-plan','调整计划','text-button')}</div><div class="plan-progress"><i style="width:${100*n/t.plan.length}%"></i></div></details>`}
|
||||||
|
function askHTML(t){const options=[['品牌认知','让更多目标用户认识我们,建立清晰印象。'],['新品转化','面向已有用户,强调产品价值与尝试理由。'],['两者兼顾','分阶段推进:先建立认知,再承接转化。']];const selected=state.askChoices[t.id]??0;return `<section class="ask-card" id="activeDecision"><div class="card-heading">${icon('question')}<span>需要你确认一个方向</span><span>问题 1 / 1</span></div><div class="ask-body"><h3>这次发布,更希望优先达成什么?</h3><p class="muted">这会影响文档里的渠道权重和内容安排。</p><div class="ask-options">${options.map((x,i)=>`<button class="ask-option ${selected===i?'selected':''}" data-choice="${i}" aria-pressed="${selected===i}"><span class="radio"></span><span><strong>${x[0]}</strong><small>${x[1]}</small></span>${i===0?'<span class="recommended">建议</span>':''}</button>`).join('')}</div><input class="ask-free" id="askFree" aria-label="自定义回答" placeholder="也可以写下更具体的目标…" value="${esc(t.askDraft||'')}"></div><footer class="card-footer"><span>回答后继续,已有内容会保留。</span>${button('answer','确认方向 '+icon('arrow'),'primary')}</footer></section>`}
|
||||||
|
function approvalHTML(){return `<section class="ask-card approval-card" id="activeDecision"><div class="card-heading">${icon('shield')}<span>发送前需要你的授权</span><span>外部操作</span></div><div class="ask-body"><h3>发送这封合作邀约邮件?</h3><p class="muted">收件人:合作伙伴(示例)<br>主题:关于秋季新品的内容合作</p><div class="approval-scope"><span>仅这封邮件</span><span>不读取其他邮件</span><span>可先修改草稿</span></div><p class="muted">原型只演示确认过程,不会发送邮件。</p></div><footer class="card-footer">${button('deny','保留草稿','outline')}<div>${button('edit-artifact','修改内容','text-button')} ${button('approve','确认发送','primary')}</div></footer></section>`}
|
||||||
|
function blockedHTML(){return `<section class="ask-card"><div class="card-heading">${icon('warning')}<span>连接需要处理</span><span>AUTH_EXPIRED</span></div><div class="ask-body"><h3>知识库授权已过期</h3><p class="muted">可以重新连接,或仅使用本地资料继续。此前的工作不会丢失。</p></div><footer class="card-footer">${button('skip-connection','使用本地资料','outline')}${button('reconnect','重新连接','primary')}</footer></section>`}
|
||||||
|
function executionHTML(t){return `<div class="execution" id="execution"><div class="execution-top">${t.status==='running'?'<span class="spinner"></span>':icon('pause')}<span id="executionLabel">${t.status==='paused'?'执行已暂停':t.plan[Math.min(t.step,t.plan.length-1)]||'整理任务成果'}</span>${button(t.status==='paused'?'resume':'pause',t.status==='paused'?'继续':'暂停','small outline')}</div><div class="execution-bottom" id="executionDetail">${t.status==='paused'?'已有进度保留,可随时补充要求。':'正在推进 · 演示执行事件'}</div><div class="run-progress"><i id="executionProgress" style="width:${Math.min(90,20+t.ticks*14)}%"></i></div>${t.kind==='research'?'<div class="subagents"><span class="subagent">资料整理 · 工作中</span><span class="subagent">观点归纳 · 工作中</span></div>':''}</div>`}
|
||||||
|
function resultHTML(t){return `<button class="result-card" data-action="open-artifact"><span class="result-icon">${icon(t.kind==='email'?'mail':'files')}</span><span><b>${esc(t.title)}${t.kind==='email'?' · 草稿':'.md'}</b><small>版本 ${t.version} · ${t.status==='done'?'已完成':'可查看草稿'} · 点击打开</small></span>${icon('arrow')}</button>`}
|
||||||
|
function tailHTML(t){if(t.ask&&t.status==='waiting')return askHTML(t);if(t.status==='approval')return approvalHTML();if(t.status==='blocked')return blockedHTML();if(['running','paused'].includes(t.status))return executionHTML(t);if(t.status==='idle')return `<div class="receipt">${icon('checkcircle')}执行计划已就绪,随时可以补充要求。</div>${button('start','开始执行 '+icon('arrow'),'primary small')}`;if(t.artifact)return resultHTML(t);return ''}
|
||||||
|
function composerHTML(t){const model=state.models.find(m=>m.id===t.model)||state.models[0];return `<div class="composer-zone">${['running','paused'].includes(t.status)?`<div class="steering-banner"><span>${icon('spark')} ${t.status==='running'?'Agent 工作中,你仍然可以补充或调整方向':'任务已暂停,可以修改要求后继续'}</span>${button(t.status==='running'?'pause':'resume',t.status==='running'?'暂停':'继续','text-button')}</div>`:''}<form class="composer" id="composer"><div class="attached-files">${(state.attachments[t.id]||[]).map((f,i)=>`<span class="file-chip">${icon('files')}${esc(f)}<button type="button" data-remove-file="${i}" aria-label="移除附件 ${esc(f)}">${icon('close')}</button></span>`).join('')}</div><textarea id="messageInput" rows="2" aria-label="消息" placeholder="${t.status==='waiting'?'直接回复,或者补充你希望达成的目标…':t.status==='running'?'补充要求,Agent 会在执行中接收…':'继续聊聊,或告诉 Agent 下一步…'}">${esc(state.drafts[t.id]||'')}</textarea><div class="composer-bottom"><button type="button" data-action="attach" aria-label="添加附件">${icon('plus')}</button><button type="button" data-action="context">${icon('folder')} 项目上下文</button><button type="button" class="composer-model" data-action="model">${icon('spark')} ${esc(model.name)}</button><button type="button" data-action="permission" aria-label="权限设置">${icon('shield')}</button><button class="send" type="submit" aria-label="发送消息">${icon('up')}</button></div></form><div class="composer-hint"><span>Enter 发送 · Shift + Enter 换行</span><span>${(t.permissionMode||'ask')==='ask'?'必要时询问':'演示自动审批'} · ${model.effort?(t.effort||'标准'):'自动'}</span></div></div>`}
|
||||||
|
function renderTask(){const t=current();$('pageTitle').textContent=t.title;$('page').innerHTML=`<div class="task-workspace ${state.canvas?'':'canvas-closed'}"><main class="conversation"><div class="conversation-toolbar"><div class="task-meta"><span class="project-tag">${esc(state.project)}</span>${pill(t)}</div><div class="conversation-actions"><button class="icon-button" data-action="toggle-canvas" aria-label="${state.canvas?'收起':'打开'}工作区">${icon('panel')}</button><button class="icon-button" data-action="task-menu" aria-label="任务操作">${icon('more')}</button></div></div><div class="transcript" id="transcript"><h1 class="conversation-title">${esc(t.title)}</h1><p class="conversation-description">${esc(t.summary||'一个新的想法,逐步变成成果。')} <span style="margin:0 7px">·</span> 本次工作</p><div id="messages">${t.messages.map(messageHTML).join('')}</div>${planHTML(t)}<div id="taskTail">${tailHTML(t)}</div></div>${composerHTML(t)}</main><aside class="canvas" aria-label="任务工作区"><div class="canvas-toolbar"><div class="canvas-tabs" role="tablist" aria-label="工作区内容">${[['preview','成果预览'],['files','文件'],['activity','执行记录']].map(([id,label])=>`<button id="canvas-tab-${id}" role="tab" aria-controls="canvasContent" aria-selected="${state.canvasTab===id}" data-canvas-tab="${id}">${label}</button>`).join('')}</div><button class="icon-button" data-action="toggle-canvas" aria-label="收起工作区">${icon('close')}</button></div><div class="canvas-content" id="canvasContent" role="tabpanel" aria-labelledby="canvas-tab-${state.canvasTab}"></div><div class="canvas-bottom"><span>内容与当前任务保持关联</span>${button('edit-artifact',icon('edit')+' 提出修改')}</div></aside></div>`;renderCanvas();}
|
||||||
|
function documentHTML(t){const isEmail=t.kind==='email';const subtitle=isEmail?'一个真诚、简洁的合作邀请。':t.kind==='strategy'?'让新产品,以恰当的方式被看见。':'从分散的信息,到清晰的判断。';const body=isEmail?`<h3><span>01</span>邮件正文</h3><p>你好,\n\n我们正在准备秋季新品发布,希望与你讨论一次内容合作。我们欣赏你对日常体验的细腻表达,也期待一起呈现产品在真实生活中的价值。</p><p>如果你有兴趣,我们可以先交流产品方向、内容形式与时间安排,再共同确定具体方案。</p><p>期待你的回复。\nAtelier 团队</p>`:`<h3><span>01</span>${t.kind==='strategy'?'核心方向':'研究目标'}</h3><p>${t.choice?`以「${esc(t.choice)}」为主要方向,`:'以用户的实际需求为出发点,'}${t.kind==='strategy'?'将产品的功能价值转化成容易理解的生活场景,先解释为什么需要,再说明有什么不同。':'围绕实际使用过程,整理关键能力、交互方式与值得继续验证的问题。'}</p><div class="document-cards"><div><b>${t.kind==='strategy'?'表达重点':'任务连续性'}</b><small>${t.kind==='strategy'?'真实使用场景\n清楚、克制的产品表达':'上下文、计划与执行过程\n在同一任务中保持连贯'}</small></div><div><b>${t.kind==='strategy'?'内容原则':'用户可控性'}</b><small>${t.kind==='strategy'?'减少空泛的描述\n用具体体验解释价值':'可以补充要求、检查成果\n在重要操作前做出决定'}</small></div></div><h3><span>02</span>${t.kind==='strategy'?'推进节奏':'接下来需要验证'}</h3><div class="doc-line"><span>第一步</span><span>${t.kind==='strategy'?'预热:建立问题认知与期待':'明确比较对象与使用场景'}</span></div><div class="doc-line"><span>第二步</span><span>${t.kind==='strategy'?'发布:解释产品价值与使用方式':'通过实际任务观察执行过程'}</span></div><div class="doc-line"><span>第三步</span><span>${t.kind==='strategy'?'延续:收集反馈,补充真实案例':'检查结果质量与中途干预方式'}</span></div>`;return `<div class="document-tools"><span>${icon('files')} ${esc(t.title)}.md</span>${button('versions','v'+t.version+' ▾')}</div><article class="paper"><header class="paper-cover"><span class="eyebrow">ATELIER / ${isEmail?'CORRESPONDENCE':'WORKING DOCUMENT'}</span><h2>${esc(t.title)}</h2><p>${subtitle}</p><div class="cover-art" aria-hidden="true"><span></span><span></span><span></span></div></header><div class="paper-meta"><span>${t.status==='done'?'已交付':'工作草稿'}</span><span>版本 ${t.version}</span><span>中文</span></div><div class="paper-body">${body}${t.revision?`<h3><span>03</span>本轮修改要求</h3><p>${esc(t.revision)}</p>`:''}</div><footer class="paper-footer"><span>REASONIX STUDIO</span><span>原型示例内容 · 非真实执行结果</span></footer></article>`}
|
||||||
|
function renderCanvas(){if(state.route!=='task'||!$('canvasContent'))return;const t=current();document.querySelectorAll('[data-canvas-tab]').forEach(b=>b.setAttribute('aria-selected',String(state.canvasTab===b.dataset.canvasTab)));$('canvasContent').setAttribute('aria-labelledby','canvas-tab-'+state.canvasTab);if(state.canvasTab==='preview')$('canvasContent').innerHTML=t.artifact?documentHTML(t):`<div class="empty-panel">${icon('files')}成果会在这里逐步形成。<br><span class="muted">当前可以先查看文件与执行记录。</span></div>`;if(state.canvasTab==='files')$('canvasContent').innerHTML=`<h3 class="activity-heading">项目输入</h3>${['品牌手册.md','新品介绍.md'].map((x,i)=>`<button class="file-row" data-source="${i}">${icon('files')}<span>${x}<small>项目资料 · 示例</small></span><span class="arrow">${icon('chevron')}</span></button>`).join('')}<h3 class="activity-heading" style="margin-top:28px">本次产出</h3>${t.artifact?`<button class="file-row" data-action="open-artifact">${icon('files')}<span>${esc(t.title)}.md<small>版本 ${t.version} · 可预览和下载</small></span></button>`:'<p class="muted">暂无产出文件</p>'}`;if(state.canvasTab==='activity')$('canvasContent').innerHTML=`<h3 class="activity-heading">执行事件 · 模拟数据</h3>${t.events.map((event,i)=>`<div class="activity-entry">${esc(event)}<small>步骤 ${i+1} · ${i===t.events.length-1?'最新进展':'已记录'}</small></div>`).join('')}`;const bottom=document.querySelector('.canvas-bottom');bottom.innerHTML=t.artifact?`<span>${t.kind==='email'?'邮件草稿':'Markdown 文档'} · 可继续迭代</span><div>${button('download',icon('download')+' 下载')}${button('edit-artifact',icon('edit')+' 修改')}</div>`:'<span>工作区会随任务更新</span>';}
|
||||||
|
function taskTile(t){return `<button class="task-tile" data-task="${t.id}">${pill(t)}<h3>${esc(t.title)}</h3><p>${esc(t.summary||t.goal)}</p><small>${t.artifact?`已有成果 · v${t.version}`:'保留完整任务上下文'} ${t.archived?'· 已归档':''}</small></button>`}
|
||||||
|
function overviewHeader(eyebrow,title,desc,action=''){return `<div class="overview-header"><div><div class="eyebrow">${eyebrow}</div><h1>${title}</h1><p class="muted">${desc}</p></div>${action}</div>`}
|
||||||
|
function renderPage(){const labels={home:'工作台',inbox:'待我处理',library:'我的成果',capabilities:'能力与连接',automation:'定时任务'};$('pageTitle').textContent=labels[state.route];let html='';if(state.route==='home')html=overviewHeader('YOUR WORK, IN ONE PLACE','让想法,逐步成为成果。','开始一个新任务,或继续手头的工作。')+`<form class="home-composer" id="homeComposer"><textarea id="homePrompt" rows="2" aria-label="新任务目标" placeholder="今天,你想完成什么?"></textarea><div><span>${icon('folder')} ${esc(state.project)}</span><button class="primary" type="submit">开始工作 ${icon('arrow')}</button></div></form><div class="section-heading"><h2>继续你的工作</h2><span>${tasks.filter(t=>!t.archived).length} 个任务</span></div><div class="task-grid">${tasks.filter(t=>!t.archived).map(taskTile).join('')}</div><div class="project-banner"><span class="project-avatar">A</span><div><h3>${esc(state.project)}</h3><p>共享项目资料、工作约定与长期上下文。</p></div>${button('project-info','查看项目 '+icon('arrow'),'outline')}</div>`;
|
||||||
|
if(state.route==='inbox'){const waiting=tasks.filter(t=>!t.archived&&['waiting','approval','blocked'].includes(t.status));html=overviewHeader('YOUR ATTENTION','在这里,做关键决定。','需要回答、授权或处理的问题集中在这里。')+`<div class="task-grid">${waiting.length?waiting.map(taskTile).join(''):'<p class="muted">当前没有等待处理的任务。</p>'}</div>`}
|
||||||
|
if(state.route==='library')html=overviewHeader('MADE WITH REASONIX','值得留下来的成果。','打开、检查,再继续打磨。每份成果都能回到它所属的任务。')+`<div class="task-grid">${tasks.filter(t=>t.artifact).map(taskTile).join('')}</div>`;
|
||||||
|
if(state.route==='capabilities')html=capabilitiesHTML();if(state.route==='automation')html=overviewHeader('WORK THAT COMES BACK','把重复工作交给日程。','管理任务的目标、运行时间和启用状态。',button('add-schedule','新建定时任务 '+icon('plus'),'primary'))+state.schedules.map(s=>`<div class="schedule-row">${icon('clock')}<div><h3>${esc(s.name)}</h3><small>${esc(s.cadence)} · ${s.enabled?'已启用':'已暂停'} · 仅演示配置</small></div><button class="toggle" role="switch" aria-label="启用 ${esc(s.name)}" aria-checked="${s.enabled}" data-schedule-toggle="${s.id}"></button><button class="icon-button" data-schedule-edit="${s.id}" aria-label="编辑 ${esc(s.name)}">${icon('edit')}</button></div>`).join('')+'<div class="note-box">这是定时任务的界面原型,不会在后台真正调度。正式产品需要显示执行位置、运行历史与失败通知。</div>';$('page').innerHTML='<main class="overview-page">'+html+'</main>'}
|
||||||
|
function capabilitiesHTML(){const tabs=[['models','模型服务'],['tools','工具与连接器'],['skills','技能']];let cards='';if(state.capTab==='models')cards=state.models.map(m=>`<article class="connection-card"><header><span class="provider-symbol">${m.name.slice(0,1)}</span><div><h3>${esc(m.name)}</h3><small>${esc(m.provider)} · ${esc(m.protocol)}</small></div></header><p>${esc(m.description)}</p><footer><span>演示配置 · 未连接</span><button data-edit-model="${m.id}">配置 ${icon('chevron')}</button></footer></article>`).join('');if(state.capTab==='tools')cards=state.tools.map(t=>`<article class="connection-card"><header><span class="provider-symbol">${icon(t.icon)}</span><div><h3>${esc(t.name)}</h3><small>${esc(t.kind)}</small></div></header><p>${esc(t.description)}</p><footer><span>${t.enabled?'已在原型中启用':'尚未启用'} · 模拟</span><button class="toggle" role="switch" aria-label="启用 ${esc(t.name)}" aria-checked="${t.enabled}" data-tool-toggle="${t.id}"></button></footer></article>`).join('');if(state.capTab==='skills')cards=state.skills.map(t=>`<article class="connection-card"><header><span class="provider-symbol">${icon('spark')}</span><div><h3>${esc(t.name)}</h3><small>可复用工作方法</small></div></header><p>${esc(t.description)}</p><footer><span>${t.enabled?'已启用':'已停用'}</span><button class="toggle" role="switch" aria-label="启用 ${esc(t.name)}" aria-checked="${t.enabled}" data-skill-toggle="${t.id}"></button></footer></article>`).join('');return overviewHeader('A WORKSPACE THAT GROWS WITH YOU','连接能力,扩展可能。','模型负责理解,工具负责行动,技能决定工作方法。',button(state.capTab==='models'?'add-model':state.capTab==='tools'?'add-tool':'add-skill','添加'+(state.capTab==='models'?'模型':state.capTab==='tools'?'连接':'技能')+' '+icon('plus'),'primary'))+`<div class="filter-tabs">${tabs.map(([id,label])=>`<button class="${id===state.capTab?'active':''}" data-cap-tab="${id}" aria-pressed="${id===state.capTab}">${label}</button>`).join('')}</div><div class="connection-grid">${cards}</div><div class="note-box">可扩展接入不等于所有 API 都能直接互换。模型协议、工具调用、流式事件和结果格式需要适配;本原型展示配置方式,不发起真实请求。</div>`}
|
||||||
|
function openModal(title,body,footer='',eyebrow='WORKSPACE'){const d=$('modal');$('modalTitle').textContent=title;$('modalEyebrow').textContent=eyebrow;$('modalBody').innerHTML=body;$('modalFooter').innerHTML=footer||button('close-modal','完成','primary');if($('permissionMode'))$('permissionMode').value=current().permissionMode||'ask';if($('reasoningEffort'))$('reasoningEffort').value=current().effort||'标准';if($('motionMode'))$('motionMode').value=document.body.classList.contains('reduce-motion')?'reduce':'system';if($('fontScale'))$('fontScale').value=document.body.classList.contains('large-type')?'large':'normal';icons(d);if(!d.open)d.showModal();d.classList.remove('dialog-opening');void d.offsetWidth;d.classList.add('dialog-opening')}
|
||||||
|
function closeModal(){$('modal').close()}
|
||||||
|
function formError(text){let e=$('formError');if(!e){e=document.createElement('p');e.id='formError';e.className='form-error';e.setAttribute('role','alert');$('modalBody').appendChild(e)}e.textContent=text}
|
||||||
|
function openModelForm(id){const m=state.models.find(x=>x.id===id);openModal(m?'配置模型服务':'添加模型服务',`<p class="muted">配置模型的服务地址、协议和能力。密钥通过凭证引用管理,原型不接收真实密钥。</p><label for="providerName">服务名称</label><input id="providerName" value="${esc(m?.provider||'')}" placeholder="例如:团队模型服务"><div class="form-grid"><div><label for="modelName">模型标识</label><input id="modelName" value="${esc(m?.name||'')}" placeholder="provider/model-name"></div><div><label for="protocol">协议</label><select id="protocol"><option>OpenAI 兼容</option><option>Anthropic Messages</option><option>自定义适配器</option></select></div></div><label for="endpoint">API 地址</label><input id="endpoint" type="url" value="${esc(m?.endpoint||'')}" placeholder="https://api.example.com/v1"><label for="credential">凭证引用(仅名称)</label><input id="credential" placeholder="例如:team-model-key,不要填写真实密钥"><label for="effortSupport">推理强度</label><select id="effortSupport"><option value="yes">模型支持调整</option><option value="no">模型不支持调整</option></select><label for="adapter">自定义适配器标识(使用自定义协议时必填)</label><input id="adapter" placeholder="my-provider-adapter">`,button('close-modal','取消','outline')+button('save-model','保存演示配置','primary',`data-id="${esc(id||'')}"`),'MODEL PROVIDER');if(m){$('protocol').value=m.protocol;$('effortSupport').value=m.effort?'yes':'no'}}
|
||||||
|
function saveModel(id){const provider=$('providerName').value.trim(),name=$('modelName').value.trim(),endpoint=$('endpoint').value.trim(),protocol=$('protocol').value;let url;try{url=new URL(endpoint)}catch{}if(!provider||!name||!url||!['https:','http:'].includes(url.protocol)){formError('请填写名称、模型标识和有效的 HTTP / HTTPS 地址。');return}if(protocol==='自定义适配器'&&!$('adapter').value.trim()){formError('请填写适配器标识。');return}const model={id:id||'model-'+sequence++,name,provider,endpoint,protocol,effort:$('effortSupport').value==='yes',description:'自定义服务 · 能力以实际适配器为准'};const i=state.models.findIndex(m=>m.id===id);if(i>=0)state.models[i]=model;else state.models.push(model);closeModal();render();toast('演示配置已保存,未连接真实服务。')}
|
||||||
|
function showSearch(){openModal('搜索工作空间','<input id="searchInput" class="search-field" aria-label="搜索任务" placeholder="搜索任务标题或目标…"><div id="searchResults"></div>','','SEARCH');search('');$('searchInput').focus()}
|
||||||
|
function search(query){$('searchResults').innerHTML=tasks.filter(t=>(t.title+' '+t.goal).toLowerCase().includes(query.toLowerCase())).map(t=>`<button class="search-result" data-task="${t.id}" data-close="true">${icon('files')}<span>${esc(t.title)}</span><small>${statusLabels[t.status]}${t.archived?' · 已归档':''}</small></button>`).join('')||'<p class="muted">没有找到匹配的任务。</p>'}
|
||||||
|
function showNew(){openModal('你想完成什么?','<p class="muted">任务可以是一句简单的问题,也可以是一份需要持续完善的成果。</p><label for="newGoal">目标</label><textarea id="newGoal" rows="4" placeholder="描述你希望得到的结果…"></textarea><label for="newProject">所属项目</label><select id="newProject"><option>'+esc(state.project)+'</option></select>',button('close-modal','取消','outline')+button('create-task','开始工作','primary'),'NEW TASK');$('newGoal').focus()}
|
||||||
|
function createTask(goal){if(!goal.trim()){formError('请先描述你希望完成的工作。');return}const t={id:'task-'+sequence++,title:goal.trim().slice(0,24),summary:'根据你的目标持续推进',status:'idle',kind:'research',model:state.model,version:0,step:0,ticks:0,artifact:false,ask:false,goal:goal.trim(),plan:['梳理目标与可用资料','执行任务并整理结果','检查成果与未解决项'],messages:[{type:'user',text:goal.trim()},{type:'agent',text:'目标已记录。这是交互原型,我会用模拟执行展示任务推进、过程中补充要求,以及成果交付。'}],events:['创建新任务'],revision:''};tasks.unshift(t);closeModal();state.canvas=false;navigate('task',t.id)}
|
||||||
|
function refreshTask(t,{animate=false,scroll=false}={}){renderSidebar();if(state.route!=='task'||state.active!==t.id){if(state.route==='home'||state.route==='inbox')renderPage();return}const transcript=$('transcript'),position=transcript.scrollTop,open=$('taskPlan')?.open,draft=$('messageInput')?.value,focus=document.activeElement?.id,selection=$('messageInput')?.selectionStart;state.drafts[t.id]=draft||state.drafts[t.id]||'';renderTask();if($('taskPlan'))$('taskPlan').open=!!open;$('transcript').scrollTop=scroll?$('transcript').scrollHeight:position;if(focus==='messageInput'){$('messageInput').focus();$('messageInput').setSelectionRange(selection,selection)}if(animate){const el=$('taskTail').firstElementChild;if(el)el.classList.add('event-arrival')}}
|
||||||
|
function begin(t){t.status='running';t.ticks=0;t.ask=false;t.events.push('开始执行本轮工作');refreshTask(t,{animate:true,scroll:true});announce('任务已开始,可以继续补充要求。')}
|
||||||
|
function finish(t){t.status='done';t.step=t.plan.length;t.artifact=true;t.version+=1;t.events.push('本轮成果已整理完成');t.messages.push({type:'agent',text:t.revision?'已按本轮要求更新演示文档,并保留修改记录。你可以在工作区检查这个版本。':'本轮任务已完成,成果保留在工作区。这是示例内容,可继续修改或下载。'});state.notificationCount++;refreshTask(t,{animate:true,scroll:true});toast(t.title+' · 本轮成果已完成');announce('成果已完成')}
|
||||||
|
function runTick(){tasks.filter(t=>t.status==='running').forEach(t=>{t.ticks++;if(t.ticks===2){t.step=Math.min(t.plan.length-1,t.step+1);t.events.push('当前步骤已完成,继续下一项')}if(t.ticks===4){t.artifact=true;if(t.version===0)t.version=1;t.events.push('已生成可检查的工作草稿')}if(t.ticks>=6){finish(t);return}if(state.route==='task'&&state.active===t.id){const label=$('executionLabel'),bar=$('executionProgress'),detail=$('executionDetail');if(label)label.textContent=t.plan[Math.min(t.step,t.plan.length-1)]||'整理任务成果';if(bar)bar.style.width=Math.min(95,20+t.ticks*12)+'%';if(detail)detail.textContent=['','正在整理输入内容','当前步骤完成','正在形成下一部分','草稿已更新,可在右侧查看','正在检查交付内容'][t.ticks];const plan=$('taskPlan');if(plan){const open=plan.open;plan.outerHTML=planHTML(t);$('taskPlan').open=open}if(t.ticks===4||state.canvasTab==='activity')renderCanvas()}})}
|
||||||
|
function submitMessage(text){const t=current();if(!text.trim()&&!(state.attachments[t.id]||[]).length)return;const attach=state.attachments[t.id]||[],body=text.trim()+(attach.length?'\n附件(仅名称):'+attach.join('、'):'');t.messages.push({type:'user',text:body});state.drafts[t.id]='';state.attachments[t.id]=[];if($('messageInput'))$('messageInput').value='';if(t.ask&&t.status==='waiting'){t.choice=text.trim()||'结合附件进一步确认';t.ask=false;t.messages.push({type:'agent',text:'已记录你的方向,接下来会据此推进。'});begin(t)}else if(t.status==='done'){t.revision=text.trim();t.messages.push({type:'agent',text:'收到,我会开始下一轮修改。之前的成果仍然可以查看。',steering:true});begin(t)}else{t.revision=text.trim();t.messages.push({type:'agent',text:t.status==='approval'?'补充已记录。发送授权仍需通过确认按钮决定。':t.status==='blocked'?'补充已记录。连接问题仍需要重新连接或选择本地资料。':t.status==='running'?'补充已接收,已加入本轮任务上下文。':t.status==='paused'?'补充已记录,继续执行后会带上这些要求。':'补充已记录,可以开始执行。',steering:true});t.events.push('接收用户补充要求');refreshTask(t,{scroll:true});const last=$('messages')?.lastElementChild;if(last)last.classList.add('event-arrival')}announce('消息已接收')}
|
||||||
|
function download(t){const text='# '+t.title+'\n\n> Reasonix 界面原型示例,不是实际执行结果。\n\n## 原始目标\n'+t.goal+'\n\n## 本轮方向\n'+(t.choice||'以任务目标为准')+'\n\n## 工作步骤\n'+t.plan.map((p,i)=>(i+1)+'. '+p).join('\n')+'\n\n## 最新补充\n'+(t.revision||'暂无')+'\n\n版本:'+t.version;const url=URL.createObjectURL(new Blob([text],{type:'text/markdown;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download=t.title.replace(/[\\/:*?"<>|]/g,'-')+'.md';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast('示例文档已导出')}
|
||||||
|
function scheduleForm(id){const s=state.schedules.find(x=>x.id===id);openModal(s?'编辑定时任务':'新建定时任务',`<p class="muted">这里只保存本页内的演示配置,不会创建真实后台任务。</p><label for="scheduleName">任务名称</label><input id="scheduleName" value="${esc(s?.name||'')}"><label for="schedulePrompt">工作目标</label><textarea id="schedulePrompt" rows="3">${esc(s?.prompt||'')}</textarea><label for="scheduleCadence">运行时间</label><select id="scheduleCadence"><option>每周一 09:00</option><option>每个工作日 08:30</option><option>每天 18:00</option></select>`,button('close-modal','取消','outline')+button('save-schedule','保存','primary',`data-id="${id||''}"`),'SCHEDULE');if(s)$('scheduleCadence').value=s.cadence}
|
||||||
|
const actions={
|
||||||
|
'home':()=>navigate('home'),'new':showNew,'search':showSearch,'close-modal':closeModal,'create-task':()=>createTask($('newGoal').value),
|
||||||
|
'sidebar':()=>{if(innerWidth<=650)$('studio').classList.toggle('mobile-rail');else $('studio').classList.toggle('rail-closed')},
|
||||||
|
'toggle-canvas':()=>{state.canvas=!state.canvas;document.querySelector('.task-workspace').classList.toggle('canvas-closed',!state.canvas);if(state.canvas)document.querySelector('.canvas').classList.add('panel-opening')},
|
||||||
|
'open-artifact':()=>{state.canvas=true;state.canvasTab='preview';document.querySelector('.task-workspace').classList.remove('canvas-closed');renderCanvas();document.querySelector('.canvas').classList.add('panel-opening')},
|
||||||
|
'answer':()=>{const t=current();if(!t.ask)return;const free=$('askFree').value.trim();t.choice=free||['品牌认知','新品转化','两者兼顾'][state.askChoices[t.id]??0];t.messages.push({type:'user',text:t.choice},{type:'agent',text:'已确认:'+t.choice+'。我会据此补充发布节奏与渠道安排。'});t.events.push('用户确认目标:'+t.choice);begin(t)},
|
||||||
|
'start':()=>begin(current()),'pause':()=>{const t=current();t.status='paused';t.events.push('用户暂停执行');refreshTask(t,{animate:true});announce('任务已暂停')},'resume':()=>{const t=current();t.status='running';t.events.push('恢复执行');refreshTask(t,{animate:true})},
|
||||||
|
'approve':()=>{const t=current();t.permission='allowed';t.status='done';t.step=t.plan.length;t.messages.push({type:'agent',text:'已确认发送操作。本原型没有实际发送任何邮件,草稿仍保留在工作区。'});t.events.push('发送授权已确认(未实际发送)');refreshTask(t,{animate:true,scroll:true})},
|
||||||
|
'deny':()=>{const t=current();t.permission='denied';t.status='done';t.step=2;t.plan=['梳理合作背景','起草邮件'];t.messages.push({type:'agent',text:'已取消发送,保留邮件草稿。需要时可以继续修改。'});t.events.push('用户选择仅保留草稿');refreshTask(t,{animate:true,scroll:true})},
|
||||||
|
'skip-connection':()=>{const t=current();t.events.push('用户选择使用本地资料');t.messages.push({type:'agent',text:'接下来只使用已有的本地资料,并在结果中注明缺少远程更新。'});begin(t)},
|
||||||
|
'reconnect':()=>openModal('重新连接知识库','<p class="muted">正式产品将在服务商页面完成登录,并返回连接结果。此处只演示恢复成功后的任务继续。</p>',button('close-modal','稍后处理','outline')+button('simulate-reconnect','模拟重新连接成功','primary'),'RECONNECT'),
|
||||||
|
'simulate-reconnect':()=>{closeModal();current().events.push('知识库连接已恢复(模拟)');begin(current())},
|
||||||
|
'edit-plan':()=>openModal('调整执行计划','<p class="muted">每行一个步骤。保存后重置步骤完成标记,避免旧进度被错误套用。</p><label for="planEditor">任务步骤</label><textarea id="planEditor" rows="6">'+esc(current().plan.join('\n'))+'</textarea>',button('close-modal','取消','outline')+button('save-plan','保存计划','primary'),'TASK PLAN'),
|
||||||
|
'save-plan':()=>{const lines=$('planEditor').value.split('\n').map(x=>x.trim()).filter(Boolean);if(!lines.length){formError('至少保留一个步骤。');return}const t=current();t.plan=lines;t.step=0;t.events.push('用户调整了执行计划');closeModal();refreshTask(t);toast('计划已更新,完成标记已重置')},
|
||||||
|
'edit-artifact':()=>{if(!current().artifact){toast('成果尚未生成,可以先在对话中补充要求。');return}openModal('这份成果,你想怎样调整?','<label for="revisionInput">修改要求</label><textarea id="revisionInput" rows="4" placeholder="例如:把第二部分写得更具体,保留整体结构。"></textarea>',button('close-modal','取消','outline')+button('save-revision','开始修改','primary'),'REFINE OUTPUT')},
|
||||||
|
'save-revision':()=>{const value=$('revisionInput').value.trim();if(!value){formError('请写下需要修改的内容。');return}closeModal();const t=current();t.permission=t.permission==='pending'?'denied':t.permission;t.revision=value;t.messages.push({type:'user',text:value},{type:'agent',text:'修改要求已接收。将更新这份成果,修改要求会保留在成果中;不会执行任何外部发送操作。'});begin(t)},
|
||||||
|
'download':()=>download(current()),'versions':()=>openModal('成果版本','<p class="muted">当前版本 v'+current().version+'。此原型展示版本标签与迭代交互,尚未实现历史内容快照。</p>'),
|
||||||
|
'attach':()=>$('attachmentInput').click(),
|
||||||
|
'context':()=>openModal('这段任务使用的上下文','<p class="muted">项目资料在任务间共享,每段对话保留各自的目标与历史。</p><div class="note-box">项目:'+esc(state.project)+'<br>示例资料:品牌手册.md、新品介绍.md<br>工作约定:优先清晰、具体、可核查的表达。</div><p class="muted" style="margin-top:16px">附件在本原型中只显示文件名,不读取文件内容。</p>','','TASK CONTEXT'),
|
||||||
|
'model':()=>openModal('选择当前任务的模型',state.models.map(m=>`<button class="model-option ${m.id===current().model?'selected':''}" data-select-model="${m.id}"><span class="provider-symbol">${icon('spark')}</span><span><strong>${esc(m.name)}</strong><small>${esc(m.description)}</small></span>${m.id===current().model?icon('check'):''}</button>`).join('')+'<label for="reasoningEffort">推理强度</label><select id="reasoningEffort" '+(!state.models.find(m=>m.id===current().model)?.effort?'disabled':'')+'><option>标准</option><option>深入</option></select><p class="muted" style="margin-top:10px">仅在模型声明支持时显示可调整的能力。这里不会实际调用模型。</p>',button('close-modal','取消','outline')+button('apply-effort','完成','primary'),'MODEL'),
|
||||||
|
'apply-effort':()=>{current().effort=$('reasoningEffort').value;closeModal();refreshTask(current())},
|
||||||
|
'permission':()=>openModal('当前任务的权限','<p class="muted">普通问题与操作授权分别处理。权限决定执行边界,不改变模型。</p><label for="permissionMode">授权方式</label><select id="permissionMode"><option value="ask">必要时询问:重要操作由我确认</option><option value="auto">自动审批:演示配置,不实际执行</option></select><div class="note-box">当前示例范围:项目资料。任何真实的发送、发布或外部修改均未接入。</div>',button('close-modal','取消','outline')+button('save-permission','应用到当前会话','primary'),'PERMISSIONS'),
|
||||||
|
'save-permission':()=>{current().permissionMode=$('permissionMode').value;closeModal();refreshTask(current());toast('演示权限设置已更新')},
|
||||||
|
'task-menu':()=>openModal('任务操作','<label for="renameTask">任务名称</label><input id="renameTask" value="'+esc(current().title)+'"><div style="display:flex;gap:10px;margin-top:20px">'+button('archive-task',icon('archive')+' 归档任务','outline')+'</div>',button('close-modal','取消','outline')+button('rename-task','保存名称','primary'),'TASK'),
|
||||||
|
'rename-task':()=>{const value=$('renameTask').value.trim();if(!value){formError('任务名称不能为空。');return}current().title=value;closeModal();render()},
|
||||||
|
'archive-task':()=>{if(current().status==='running'){formError('请先暂停正在运行的任务,再归档。');return}current().archived=true;closeModal();navigate('home');toast('任务已归档,仍可通过搜索找到')},
|
||||||
|
'project-info':()=>openModal(state.project,'<p class="muted">项目连接相关任务与共享资料。每个任务有独立的对话、执行状态和成果。</p><div class="note-box">资料:品牌手册.md、新品介绍.md<br>项目约定:语气清晰克制,结论保留依据。<br>运行位置:本地交互原型。</div>','','PROJECT'),
|
||||||
|
'project':()=>openModal('设置项目名称','<label for="projectName">项目名称</label><input id="projectName" value="'+esc(state.project)+'"><p class="muted" style="margin-top:12px">此原型只有一个项目容器,可修改名称体验导航更新。</p>',button('close-modal','取消','outline')+button('save-project','保存','primary'),'PROJECT'),
|
||||||
|
'save-project':()=>{const value=$('projectName').value.trim();if(!value){formError('项目名称不能为空。');return}state.project=value;closeModal();render()},
|
||||||
|
'notifications':()=>{state.notificationCount=0;renderSidebar();navigate('inbox')},
|
||||||
|
'add-model':()=>openModelForm(),'save-model':el=>saveModel(el.dataset.id),
|
||||||
|
'add-tool':()=>openModal('添加外部能力','<p class="muted">用 MCP 或 HTTP 适配器连接外部服务。工具能力与模型服务分别配置。</p><label for="toolName">能力名称</label><input id="toolName" placeholder="例如:团队知识库"><label for="toolProtocol">接入方式</label><select id="toolProtocol"><option>MCP 连接器</option><option>HTTP API</option></select><label for="toolEndpoint">服务地址</label><input id="toolEndpoint" placeholder="https://example.com/mcp"><label for="toolScope">能力说明与访问范围</label><input id="toolScope" placeholder="例如:只读取产品知识库">',button('close-modal','取消','outline')+button('save-tool','添加演示连接','primary'),'TOOLS'),
|
||||||
|
'save-tool':()=>{const name=$('toolName').value.trim(),scope=$('toolScope').value.trim();let url;try{url=new URL($('toolEndpoint').value)}catch{}if(!name||!scope||!url||!['http:','https:'].includes(url.protocol)){formError('请填写名称、访问范围和有效服务地址。');return}state.tools.push({id:'tool-'+sequence++,name,description:scope,kind:$('toolProtocol').value,icon:'link',enabled:false});closeModal();render();toast('已添加配置,尚未连接真实服务')},
|
||||||
|
'add-skill':()=>openModal('创建工作技能','<label for="skillName">技能名称</label><input id="skillName"><label for="skillDescription">工作方法</label><textarea id="skillDescription" rows="4" placeholder="描述这类任务应该怎样完成…"></textarea>',button('close-modal','取消','outline')+button('save-skill','保存技能','primary'),'SKILL'),
|
||||||
|
'save-skill':()=>{const name=$('skillName').value.trim(),description=$('skillDescription').value.trim();if(!name||!description){formError('请填写技能名称和工作方法。');return}state.skills.push({id:'skill-'+sequence++,name,description,enabled:true});closeModal();render()},
|
||||||
|
'add-schedule':()=>scheduleForm(),'save-schedule':el=>{const name=$('scheduleName').value.trim(),prompt=$('schedulePrompt').value.trim();if(!name||!prompt){formError('请填写任务名称和目标。');return}const existing=state.schedules.find(x=>x.id===el.dataset.id),next={id:existing?.id||'schedule-'+sequence++,name,prompt,cadence:$('scheduleCadence').value,enabled:existing?.enabled??true};if(existing)Object.assign(existing,next);else state.schedules.push(next);closeModal();render();toast('演示日程已保存,不会在后台实际运行')},
|
||||||
|
'settings':()=>openModal('舒适地工作','<p class="muted">为长时间阅读设计的浅色工作区。动画只表达操作和状态变化。</p><label for="motionMode">动画效果</label><select id="motionMode"><option value="system">跟随系统偏好</option><option value="reduce">减少动态效果</option></select><label for="fontScale">文字大小</label><select id="fontScale"><option value="normal">标准</option><option value="large">舒适 · 较大文字</option></select>',button('close-modal','取消','outline')+button('save-settings','应用','primary'),'APPEARANCE'),
|
||||||
|
'save-settings':()=>{document.body.classList.toggle('reduce-motion',$('motionMode').value==='reduce');document.body.classList.toggle('large-type',$('fontScale').value==='large');closeModal();toast('界面偏好已应用')}
|
||||||
|
};
|
||||||
|
document.addEventListener('click',e=>{const action=e.target.closest('[data-action]');if(action){e.preventDefault();actions[action.dataset.action]?.(action);return}const task=e.target.closest('[data-task]');if(task){if(task.dataset.close)closeModal();state.canvas=innerWidth>960&&!!tasks.find(t=>t.id===task.dataset.task).artifact;state.canvasTab='preview';navigate('task',task.dataset.task);return}const route=e.target.closest('[data-route]');if(route){navigate(route.dataset.route);return}const choice=e.target.closest('[data-choice]');if(choice){state.askChoices[current().id]=Number(choice.dataset.choice);document.querySelectorAll('.ask-option').forEach(b=>{const selected=b===choice;b.classList.toggle('selected',selected);b.setAttribute('aria-pressed',String(selected));b.classList.remove('picked')});choice.classList.add('picked');return}const tab=e.target.closest('[data-canvas-tab]');if(tab){state.canvasTab=tab.dataset.canvasTab;renderCanvas();return}const cap=e.target.closest('[data-cap-tab]');if(cap){state.capTab=cap.dataset.capTab;renderPage();return}const model=e.target.closest('[data-select-model]');if(model){current().model=model.dataset.selectModel;state.model=model.dataset.selectModel;closeModal();refreshTask(current());toast('模型已切换(演示)');return}const editModel=e.target.closest('[data-edit-model]');if(editModel){openModelForm(editModel.dataset.editModel);return}for(const [key,list] of [['toolToggle',state.tools],['skillToggle',state.skills],['scheduleToggle',state.schedules]]){const attr=key.replace(/[A-Z]/g,c=>'-'+c.toLowerCase()),toggle=e.target.closest('[data-'+attr+']');if(toggle){const item=list.find(x=>x.id===toggle.dataset[key]);item.enabled=!item.enabled;renderPage();return}}const remove=e.target.closest('[data-remove-file]');if(remove){state.attachments[current().id].splice(Number(remove.dataset.removeFile),1);refreshTask(current());return}const schedule=e.target.closest('[data-schedule-edit]');if(schedule){scheduleForm(schedule.dataset.scheduleEdit);return}const source=e.target.closest('[data-source]');if(source)openModal(Number(source.dataset.source)===0?'品牌手册.md':'新品介绍.md','<p class="muted">项目输入文件示例。正式版本在此预览实际文件内容,本原型没有读取你的本地资料。</p>');});
|
||||||
|
document.addEventListener('submit',e=>{if(e.target.id==='composer'){e.preventDefault();submitMessage($('messageInput').value)}if(e.target.id==='homeComposer'){e.preventDefault();if(!$('homePrompt').value.trim()){toast('先写下你想完成的事。');return}createTask($('homePrompt').value)}});
|
||||||
|
document.addEventListener('input',e=>{if(e.target.id==='messageInput'){state.drafts[current().id]=e.target.value;e.target.style.height='auto';e.target.style.height=Math.min(140,e.target.scrollHeight)+'px'}if(e.target.id==='askFree')current().askDraft=e.target.value;if(e.target.id==='searchInput')search(e.target.value)});
|
||||||
|
document.addEventListener('keydown',e=>{if(e.isComposing)return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='k'){e.preventDefault();showSearch()}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='n'){e.preventDefault();showNew()}if(e.target.id==='messageInput'&&e.key==='Enter'&&!e.shiftKey){e.preventDefault();$('composer').requestSubmit()}if(e.key==='Escape')$('studio').classList.remove('mobile-rail');if(e.target.hasAttribute('data-canvas-tab')&&['ArrowLeft','ArrowRight'].includes(e.key)){e.preventDefault();const tabs=['preview','files','activity'],next=(tabs.indexOf(state.canvasTab)+(e.key==='ArrowRight'?1:2))%3;state.canvasTab=tabs[next];renderCanvas();$('canvas-tab-'+state.canvasTab).focus()}});
|
||||||
|
$('attachmentInput').addEventListener('change',e=>{const id=current().id;state.attachments[id]=[...(state.attachments[id]||[]),...Array.from(e.target.files).map(f=>f.name)];e.target.value='';refreshTask(current());toast('已添加附件名称;原型不读取文件内容。')});
|
||||||
|
state.canvas=innerWidth>960;
|
||||||
|
icons();render();clockTimer=setInterval(runTick,1700);
|
||||||
|
window.addEventListener('pagehide',()=>clearInterval(clockTimer));
|
||||||
|
})();
|
||||||
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Reasonix Studio · Agent Workspace</title>
|
||||||
|
<link rel="stylesheet" href="studio.css">
|
||||||
|
<style>
|
||||||
|
:root{font-family:Inter,"Segoe UI","Microsoft YaHei",sans-serif;color:#252722;background:#eeefeb;font-synthesis:none;--muted:#74796f;--line:#e4e7de;--green:#365d44;--soft:#eff4eb}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button{cursor:pointer}button:disabled{cursor:default;opacity:.5}button,a,input,textarea,select{outline-offset:4px}button{color:inherit;border:0;background:none}button:hover{background:#edf0e9}button.primary{background:var(--green);color:white;border:1px solid var(--green)}button.primary:hover{background:#284c35}button.secondary{border:1px solid var(--line);background:white}button.small{padding:7px 10px;font-size:12px}button{border-radius:8px;padding:10px 14px}input,select,textarea{width:100%;border:1px solid var(--line);border-radius:8px;padding:11px;background:#fff;color:#252722}textarea{resize:vertical}.lab{min-height:43px;background:#252d26;color:#e5ecdf;display:flex;align-items:center;gap:16px;padding:8px 23px;font-size:12px;flex-wrap:wrap}.lab span{opacity:.7}.lab select{width:auto;padding:4px 25px 4px 8px;background:#394339;color:#fff;border:0;font-size:12px}.lab button{color:#fff;padding:4px 8px;font-size:12px}.lab button:hover{background:#465345}.lab .spacer{flex:1}.app{display:grid;grid-template-columns:190px minmax(360px,1fr) 290px;height:calc(100dvh - 47px);min-height:670px;background:#fff}.sidebar{background:#f4f5f0;border-right:1px solid var(--line);padding:26px 15px;display:flex;flex-direction:column;gap:8px}.brand{font-size:19px;font-weight:650;letter-spacing:-.7px;padding:0 9px 30px}.brand b{display:inline-grid;place-items:center;width:27px;height:27px;border-radius:8px;background:var(--green);color:#fff;margin-right:7px;font-size:17px}.brand small{display:block;padding-left:38px;font-size:10px;font-weight:500;color:var(--muted);letter-spacing:2px;margin-top:3px}.nav{text-align:left;color:#575f51;font-size:13px}.nav.active{background:#e7ebdf;color:#2d4a34}.section-label{font-size:11px;color:var(--muted);margin:24px 10px 9px;letter-spacing:1px}.tasknav{text-align:left;font-size:12px;line-height:1.8;padding:11px}.tasknav.selected{background:#fff;box-shadow:0 1px 4px #23301409}.tasknav small{display:block;color:var(--muted);font-size:10px}.sidebar-footer{margin-top:auto;padding-top:25px}.connection{display:flex;align-items:center;gap:8px;font-size:12px}.dot{width:6px;height:6px;display:inline-block;border-radius:50%;background:#789c6a}.userprofile{border-top:1px solid var(--line);margin-top:18px;padding:20px 9px 0;display:flex;gap:10px;font-size:12px;align-items:center}.avatar{display:grid;place-items:center;background:#e7eadf;border-radius:9px;width:30px;height:30px;font-size:12px}.main{display:flex;min-width:0;flex-direction:column;background:#fff}.top{height:76px;flex-shrink:0;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;padding:0 30px;gap:10px}.top h1{font-size:15px;margin:0 0 6px;font-weight:600}.subtitle{font-size:11px;color:var(--muted)}.badge{font-size:11px;background:#f4f1e5;color:#886819;padding:5px 9px;border-radius:20px;white-space:nowrap}.chat{overflow-y:auto;flex:1;scroll-behavior:smooth;padding:28px max(26px,calc((100% - 740px)/2)) 20px}.date{text-align:center;font-size:10px;color:#94988f;margin-bottom:26px}.message{margin-bottom:24px;font-size:14px;line-height:1.85}.message.user{display:flex;justify-content:flex-end}.bubble{padding:12px 18px;background:#f1f3ed;border-radius:14px 14px 3px 14px;max-width:88%;white-space:pre-wrap;overflow-wrap:anywhere}.byline{font-size:11px;display:flex;align-items:center;gap:9px;margin-bottom:10px;font-weight:600}.agentmark{display:inline-grid;place-items:center;width:24px;height:24px;border:1px solid #d5ddcb;border-radius:7px;color:var(--green);background:#f7f9f3}.copy{margin:0 0 15px}.interaction{border:1px solid #dce3d2;border-radius:12px;background:#fff;overflow:hidden;margin-top:14px;box-shadow:0 4px 16px #273c2805}.interaction-body{padding:20px}.eyebrow{font-size:10px;color:#73816b;letter-spacing:1px;margin-bottom:9px}.interaction h2{font-size:16px;margin:0 0 8px;font-weight:600}.muted{font-size:12px;color:var(--muted);line-height:1.7}.choices{display:grid;gap:9px;margin-top:17px}.choice{padding:12px 14px;border:1px solid var(--line);text-align:left;display:flex;gap:13px;border-radius:9px;align-items:center;font-size:13px}.choice .radio{width:16px;height:16px;border:1px solid #c8cebf;border-radius:50%;flex-shrink:0}.choice.selected{border-color:#6e8b59;background:#f6f8f0}.choice.selected .radio{border:5px solid #54713e}.choice small{display:block;font-size:11px;color:var(--muted);margin-top:2px}.choice b{font-weight:500}.interaction-footer{padding:12px 20px;background:#fafbf7;border-top:1px solid #edf0e7;display:flex;justify-content:space-between;align-items:center;gap:9px;font-size:11px;color:var(--muted);flex-wrap:wrap}.interaction-footer button{font-size:12px}.receipt{font-size:12px;color:#67745f;border-left:2px solid #c7d3bc;paddLine truncated
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="lab"><strong>STUDIO <span>/</span> DESIGN PREVIEW 04</strong><span>交互演示 · 无真实 API 请求</span><div class="spacer"></div><label for="scene">场景</label><select id="scene"><option value="research">研究与整理</option><option value="travel">旅行规划</option><option value="code">软件开发</option></select><label for="jump">状态</label><select id="jump"><option value="ask">Ask · 澄清</option><option value="plan">Todo · 计划</option><option value="running">执行中</option><option value="approval">请求授权</option><option value="blocked">遇到阻碍</option><option value="done">成果交付</option></select><button id="restart">↺ 重新体验</button></div>
|
||||||
|
<div class="app" id="app">
|
||||||
|
<nav class="sidebar" aria-label="主导航"><div class="brand"><b>r</b>Reasonix<small>STUDIO</small></div><button class="secondary nav" id="newTask">+ 新任务</button><div class="section-label">工作空间</div><button class="nav active" id="conversationNav">◌ 对话</button><button class="nav" id="artifactsNav">▤ 成果</button><button class="nav" id="connectionsNav">⌘ 连接与能力</button><div class="section-label">当前任务</div><button class="tasknav selected" id="currentTask">准备一份行业研究<small>需要你回答</small></button><div class="sidebar-footer"><button class="connection" id="connectFooter"><i class="dot"></i> 演示连接 <span style="color:#929987">↗</span></button><div class="userprofile"><span class="avatar">我</span><div>个人工作空间<div class="subtitle" style="margin-top:4px">Local workspace</div></div></div></nav>
|
||||||
|
<main class="main"><header class="top"><div><h1 id="taskTitle">准备一份行业研究</h1><div class="subtitle">通用助手 <span style="margin:0 7px">/</span> <span id="modelTitle">演示模型</span></div></div><div style="display:flex;align-items:center;gap:8px"><span class="badge" id="status">需要你回答</span><button class="small plan-toggle hidden" id="togglePlan">计划</button></div></header><div class="chat" id="chat" aria-live="polite"></div><div class="composer-wrap"><form class="composer" id="composer"><div class="attachments" id="attachments"></div><textarea id="input" rows="2" aria-label="发送消息" placeholder="直接回答,或者补充你的想法…"></textarea><div class="composer-bottom"><div style="display:flex;gap:4px;align-items:center"><button type="button" id="attach" aria-label="添加附件">+ 附件</button><select id="model" aria-label="选择模型"><option>演示模型</option></select><button type="button" id="modelSettings" aria-label="配置模型">⚙</button></div><button class="primary" type="submit" id="send">发送 ↑</button></div></form><div class="composer-hint">Enter 发送 · Shift + Enter 换行 · 随时补充要求或调整方向</div></div></main>
|
||||||
|
<aside class="right"><div class="right-title">任务空间 <span class="tag" id="planCount">0 / 4</span></div><h3>执行计划 <button class="small" style="float:right;padding:0" id="editPlan">编辑</button></h3><ol class="plan" id="plan"></ol><div class="aside-section"><h3>已确认的上下文</h3><div id="context"></div></div><div class="aside-section"><h3>成果 <span id="artifactCount" class="subtitle">0</span></h3><div id="artifacts" class="empty">成果会留在这里。<br>无需回翻聊天记录。</div></div><div class="right-note">对话承接你的意图。<br>计划、上下文与成果随任务更新。</div></aside>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="file" class="hidden" multiple>
|
||||||
|
<dialog class="modal" id="dialog"><div class="modal-header"><h2 id="dialogTitle"></h2><button id="closeDialog" aria-label="关闭">✕</button></div><div class="modal-content" id="dialogBody"></div><div class="modal-footer" id="dialogFooter"></div></dialog><div class="toast hidden" id="toast" role="status"></div>
|
||||||
|
<script>
|
||||||
|
(()=>{
|
||||||
|
const $=id=>document.getElementById(id), esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const scenes={research:{title:'准备一份行业研究',request:'帮我研究一下 AI 个人助手的发展方向,整理成一份适合团队讨论的简报。',intro:'可以。我会把产品方向、典型场景和机会点整理在一起。先确认一个会影响研究重点的问题。',question:'这份简报主要用来做什么?',options:[['探索产品机会','重点看用户需求、使用场景和差异化方向'],['了解行业现状','重点看代表产品、能力边界和发展趋势'],['支持技术选型','重点看模型、工具接入和实现方式']],steps:['确认研究重点','收集并整理资料','形成观点与对比','交付研究简报'],tool:'读取公开资料',permission:'连接你的团队知识库?',permissionDesc:'读取你选择的「产品研究」目录,补充已有讨论。不会修改或分享文件。',blocked:'有一份参考资料无法访问',result:'AI 个人助手 · 方向研究',extension:'研究简报'},travel:{title:'安排一次京都旅行',request:'帮我安排一个轻松的京都三日行程,想吃好吃的,也想逛一些安静的地方。',intro:'我会把路线、餐饮和休息时间一起考虑。先了解你喜欢的节奏。',question:'你更喜欢哪一种旅行节奏?',options:[['慢慢逛','每天 2–3 个地点,留出自由时间'],['经典路线','覆盖主要景点,兼顾特色街区'],['当地生活','小店、咖啡馆和人少的地方']],steps:['确认旅行偏好','整理地点与路线','补充时间安排','交付三日行程'],tool:'整理目的地信息',permission:'读取日历中的出行日期?',permissionDesc:'仅查看这次旅行对应的日期,避开已有安排。不会创建或修改日程。',blocked:'一个地点的信息暂时不可用',result:'京都三日 · 慢旅行计划',extension:'旅行计划'},code:{title:'制作个人作品集',request:'帮我做一个个人作品集网站,简洁一点,方便展示我的项目。',intro:'我会先整理结构,再制作页面并检查效果。先确定网站最重要的内容。',question:'你希望访客最先看到什么?',options:[['项目作品','以作品封面与项目介绍为核心'],['个人介绍','强调你的经历、能力和表达'],['联系与合作','突出服务内容和合作入口']],steps:['确认网站重点','规划页面与内容','实现并检查页面','交付预览与文件'],tool:'整理页面结构',permission:'允许在项目目录中生成文件?',permissionDesc:'只在 portfolio/ 目录新增页面和样式文件,不覆盖已有文件。',blocked:'一张作品封面图片无法读取',result:'个人作品集 · 页面方案',extension:'设计方案'}};
|
||||||
|
let scene='research', stage='ask', choice=0, answer='', history='', timer=null, tick=0, paused=false, runTarget='approval', files=[], extraContext='', profile=null, toolProfiles=[], planCustom=null;
|
||||||
|
let stateNames={ask:'需要你回答',plan:'计划已就绪',running:'正在推进',approval:'需要你的授权',blocked:'需要你处理',done:'已完成'};
|
||||||
|
function agent(html){return '<section class="message"><div class="byline"><span class="agentmark">r</span>Reasonix <span class="subtitle">· 通用助手</span></div>'+html+'</section>'}
|
||||||
|
function user(text){return '<div class="message user"><div class="bubble">'+esc(text)+'</div></div>'}
|
||||||
|
function button(action,label,primary=false){return '<button data-action="'+action+'" class="'+(primary?'primary':'secondary')+'">'+label+'</button>'}
|
||||||
|
function receipt(text){return '<details class="receipt"><summary>'+esc(text)+'</summary><p>这条记录已保留。你可以在下方对话中随时补充或修改要求。</p></details>'}
|
||||||
|
function initial(){const s=scenes[scene];return '<div class="date">今天 · 一段持续推进的协作</div>'+user(s.request)}
|
||||||
|
function card(eyebrow,title,body,footer=''){return '<div class="interaction"><div class="interaction-body"><div class="eyebrow">'+eyebrow+'</div><h2>'+title+'</h2>'+body+'</div>'+(footer?'<div class="interaction-footer">'+footer+'</div>':'')+'</div>'}
|
||||||
|
function render(){const s=scenes[scene];$('taskTitle').textContent=s.title;$('status').textContent=paused?'已暂停':stateNames[stage];$('jump').value=stage;$('currentTask').innerHTML=esc(s.title)+'<small>'+esc(paused?'已暂停':stateNames[stage])+'</small>';let content='';
|
||||||
|
if(stage==='ask')content=agent('<p class="copy">'+s.intro+'</p>'+card('ASK / 需要你回答',s.question,'<div class="choices">'+s.options.map((o,i)=>'<button class="choice '+(choice===i?'selected':'')+'" data-choice="'+i+'" aria-pressed="'+(choice===i)+'"><span class="radio"></span><span><b>'+o[0]+'</b><small>'+o[1]+'</small></span></button>').join('')+'</div>','<span>不必拘泥于选项,也可以直接回复</span>'+button('answer','就按这个方向 →',true)));
|
||||||
|
if(stage==='plan')content=agent('<p class="copy">明白,重点是「'+esc(answer||s.options[0][0])+'」。我已经更新右侧计划,接下来会按这个方向推进。</p>'+card('TODO / 计划就绪','先看清路径,再开始','<div class="muted">'+(planCustom||s.steps).slice(1).map((x,i)=>'<div style="padding:5px 0">'+(i+1)+'. '+esc(x)+'</div>').join('')+'</div>','<span>执行过程中仍然可以调整</span><div>'+button('edit','调整计划')+' '+button('start','开始执行 →',true)+'</div>'));
|
||||||
|
if(stage==='running')content=agent('<p class="copy">'+(paused?'进度已保存。准备好后可以继续,也可以告诉我需要调整什么。':'我正在推进任务。你可以继续补充要求,不需要等待我结束。')+'</p>'+card('RUNNING / '+(paused?'已暂停':'执行中'),paused?'任务已暂停':s.tool,'<div class="runline"><span class="'+(paused?'dot':'pulse')+'"></span><span id="runText">'+(paused?'等待继续':'正在整理第 '+(tick+1)+' 组信息')+'</span></div><div class="progress"><i style="width:'+Math.min(90,20+tick*20)+'%"></i></div><details class="trace"><summary>查看执行记录</summary><div>✓ 已载入任务上下文</div><div>✓ 已准备所需能力</div><div>◌ '+s.tool+'(模拟)</div></details>','<span>进展在这里更新,不刷满对话</span>'+button(paused?'resume':'pause',paused?'继续执行':'暂停')));
|
||||||
|
if(stage==='approval')content=agent('<p class="copy">下一步可以结合你的资料,让结果更贴近实际。这需要你授权,也可以跳过。</p>'+card('APPROVAL / 由你决定',s.permission,'<p class="muted">'+s.permissionDesc+'</p><div class="notice">仅本次任务有效 · 本原型不会调用真实服务</div>','<span>拒绝后仍可使用公开资料继续</span><div>'+button('deny','跳过')+' '+button('allow','允许这一次',true)+'</div>'));
|
||||||
|
if(stage==='blocked')content=agent('<p class="copy">大部分内容已经整理好。有一项信息需要换一种方式获取,现有进度已保留。</p>'+card('BLOCKED / 可以恢复',s.blocked,'<p class="muted">演示原因:外部服务暂时没有响应。你可以重试,或跳过这一项继续。</p>','<span>不会重新开始整个任务</span><div>'+button('skip','跳过这项')+' '+button('retry','重试连接',true)+'</div>'));
|
||||||
|
if(stage==='done')content=agent('<p class="copy">已经整理好了。成果留在右侧,你可以直接打开,也可以继续告诉我哪里需要调整。</p>'+card('DONE / 已交付',s.result,'<p class="muted">包含目标、核心内容与下一步建议。<br>这是用于体验交付流程的示例内容。</p>','<span>第 1 版 · 可继续迭代</span><div>'+button('download','下载')+' '+button('preview','打开成果 ↗',true)+'</div>'));
|
||||||
|
$('chat').innerHTML=history+content;renderAside();$('input').placeholder=stage==='ask'?'直接回答,或者补充你的想法…':stage==='done'?'继续修改:比如「更简洁一些」…':'补充要求、改变方向,或者问问当前进展…';}
|
||||||
|
function renderAside(){const s=scenes[scene],n=stage==='ask'?0:stage==='done'?4:stage==='blocked'?2:1;$('planCount').textContent=n+' / 4';$('plan').innerHTML=(planCustom||s.steps).map((x,i)=>'<li class="'+(i<n?'done':i===n?'current':'pending')+'"><span class="stepnum">'+(i<n?'✓':i+1)+'</span><div>'+esc(x)+(i===n?'<small>'+(paused?'已暂停':stage==='ask'?'等你确认方向':stateNames[stage])+'</small>':'')+'</div></li>').join('');$('context').innerHTML='<div class="context-item"><span>工作重点</span><b>'+esc(answer||'待确认')+'</b></div><div class="context-item"><span>输出语言</span><b>中文</b></div>'+(extraContext?'<div class="context-item"><span>补充要求</span><b>'+esc(extraContext)+'</b></div>':'');$('artifactCount').textContent=stage==='done'?'1':'0';$('artifacts').innerHTML=stage==='done'?'<button class="artifact" data-action="preview">▤ '+s.result+'<small>可预览 · 可下载 · 可继续修改 ↗</small></button>':'成果会留在这里。<br>无需回翻聊天记录。';}
|
||||||
|
function transition(next){clearInterval(timer);timer=null;paused=false;stage=next;render();$('chat').scrollTop=$('chat').scrollHeight;}
|
||||||
|
function reset(){clearInterval(timer);answer='';extraContext='';choice=0;files=[];planCustom=null;tick=0;paused=false;$('attachments').textContent='';history=initial();transition('ask');}
|
||||||
|
function start(target='approval'){tick=0;runTarget=target;transition('running');schedule();}
|
||||||
|
function schedule(){clearInterval(timer);timer=setInterval(()=>{tick++;if(tick>=4){history+=agent(receipt('✓ 已完成当前阶段 · 执行记录已收起'));transition(runTarget)}else{const label=$('runText');if(label)label.textContent='正在整理第 '+(tick+1)+' 组信息';const bar=$('chat').querySelector('.progress i');if(bar)bar.style.width=Math.min(90,20+tick*20)+'%';document.dispatchEvent(new CustomEvent('studio-progress',{detail:{tick}}));}},2200)}
|
||||||
|
function toast(text){$('toast').textContent=text;$('toast').classList.remove('hidden');setTimeout(()=>$('toast').classList.add('hidden'),3000)}
|
||||||
|
function modal(title,body,footer){$('dialogTitle').textContent=title;$('dialogBody').innerHTML=body;$('dialogFooter').innerHTML=footer||'<button data-close class="secondary">关闭</button>';if(!$('dialog').open)$('dialog').showModal();}
|
||||||
|
function edit(){modal('调整执行计划','<p class="muted">每行一个步骤。此原型保留四个阶段,修改后会同步到任务空间。</p><textarea id="planEditor" rows="6">'+esc((planCustom||scenes[scene].steps).join('\n'))+'</textarea>','<button data-close>取消</button>'+button('savePlan','保存计划',true));}
|
||||||
|
function documentText(){const s=scenes[scene];return '# '+s.result+'\n\n> 交互原型示例,不是实际研究或执行结果。\n\n## 目标\n'+s.request+'\n\n## 已确认方向\n'+(answer||s.options[0][0])+'\n\n## 核心内容\n'+(scene==='research'?'1. 从单次回答转向持续完成任务。\n2. 把澄清、授权和交付变成清晰的交互节点。\n3. 模型与工具独立接入,任务上下文保持连续。':scene==='travel'?'第一天:抵达与附近街区漫步。\n第二天:安排一条步行路线,中午留出休息时间。\n第三天:自由探索,提前预留返程时间。':'首页:个人介绍与精选作品。\n项目:作品封面、过程与结果。\n联系:合作方式与联系方式。')+'\n\n## 补充要求\n'+(extraContext||'暂无')+'\n\n## 下一步\n确认内容后继续细化。';}
|
||||||
|
function preview(){modal(scenes[scene].result,'<article class="document">'+documentText().split('\n').map(l=>l.startsWith('# ')?'<h2>'+esc(l.slice(2))+'</h2>':l.startsWith('## ')?'<h3>'+esc(l.slice(3))+'</h3>':'<p>'+esc(l)+'</p>').join('')+'</article>','<button data-close>返回对话</button>'+button('download','下载 Markdown',true));}
|
||||||
|
function download(){const url=URL.createObjectURL(new Blob([documentText()],{type:'text/markdown;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download=scenes[scene].result+'.md';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
|
||||||
|
function connections(tab='model'){modal('连接与能力','<div class="tabs"><button data-tab="model" class="'+(tab==='model'?'selected':'')+'">模型 API</button><button data-tab="tool" class="'+(tab==='tool'?'selected':'')+'">工具与外部服务</button></div><p class="muted">'+(tab==='model'?'模型负责理解与规划。不同协议通过适配器接入,工作台的对话和状态保持一致。':'工具负责执行操作。HTTP API、MCP 和自定义适配器独立于模型配置。')+'</p><div class="notice">配置交互演示:不会连接服务,也不保存密钥。请勿填写真实凭证。</div><label for="providerName">连接名称</label><input id="providerName" placeholder="'+(tab==='model'?'例如:我的模型服务':'例如:团队知识库')+'" value="'+esc(tab==='model'&&profile?profile.name:'')+'"><div class="form-grid"><div><label for="protocol">'+(tab==='model'?'API 协议':'接入方式')+'</label><select id="protocol">'+(tab==='model'?'<option>OpenAI 兼容</option><option>Anthropic Messages</option><option>自定义适配器</option>':'<option>HTTP API</option><option>MCP</option><option>自定义适配器</option>')+'</select></div><div><label for="modelId">'+(tab==='model'?'模型标识':'能力名称')+'</label><input id="modelId" placeholder="'+(tab==='model'?'provider/model-name':'search_documents')+'" value="'+esc(tab==='model'&&profile?profile.id:'')+'"></div></div><label for="endpoint">服务地址</label><input id="endpoint" type="url" placeholder="https://api.example.com/v1" value="'+esc(tab==='model'&&profile?profile.endpoint:'')+'"><label for="auth">认证方式</label><select id="auth"><option>Bearer Token(运行时从凭证库读取)</option><option>API Key 请求头</option><option>OAuth</option><option>无需认证</option></select><details class="trace"><summary>适配与能力声明</summary><p class="muted">不同 API 需要适配请求、响应和事件结构,不能只替换地址。以下为配置界面示例。</p><label for="mapping">适配器标识</label><input id="mapping" placeholder="例如:my-provider-adapter"><label><input style="width:auto" type="checkbox" checked> 支持流式输出 / 进度事件</label><label><input style="width:auto" type="checkbox" checked> 支持工具调用 / 结构化结果</label></details>'+(toolProfiles.length?'<p class="muted">已添加工具:'+toolProfiles.map(esc).join('、')+'</p>':''),'<button data-close>取消</button><button class="primary" data-save-connection="'+tab+'">添加演示连接</button>');}
|
||||||
|
function action(a){switch(a){case 'answer':answer=scenes[scene].options[choice][0];history+=agent(receipt('已确认 · '+answer));transition('plan');break;case 'edit':edit();break;case 'savePlan':{const lines=$('planEditor').value.split('\n').map(s=>s.trim()).filter(Boolean);if(lines.length!==4){toast('请保留四个步骤,以便体验阶段更新');return}planCustom=lines;$('dialog').close();render();break}case 'start':history+=agent(receipt('执行计划已确认 · 4 个步骤'));start();break;case 'pause':clearInterval(timer);paused=true;render();break;case 'resume':paused=false;render();schedule();break;case 'allow':case 'deny':history+=agent(receipt(a==='allow'?'已允许 · 仅本次任务访问':'已跳过连接 · 使用已有资料继续'));start('blocked');break;case 'retry':history+=agent(receipt('你选择了重试 · 正在恢复当前步骤'));start('done');break;case 'skip':history+=agent(receipt('已跳过不可用资料 · 其余内容已保留'));transition('done');break;case 'preview':preview();break;case 'download':download();break;}}
|
||||||
|
document.addEventListener('click',e=>{const c=e.target.closest('[data-choice]');if(c){choice=Number(c.dataset.choice);render();return}const a=e.target.closest('[data-action]');if(a)action(a.dataset.action);if(e.target.closest('[data-close]'))$('dialog').close();const t=e.target.closest('[data-tab]');if(t)connections(t.dataset.tab);const save=e.target.closest('[data-save-connection]');if(save){const name=$('providerName').value.trim(),endpoint=$('endpoint').value.trim(),id=$('modelId').value.trim();let valid=false;try{valid=['https:','http:'].includes(new URL(endpoint).protocol)}catch{}if(!name||!id||!valid){toast('请填写连接名称、标识和有效的 HTTP 服务地址');return}if(save.dataset.saveConnection==='model'){profile={name,id,endpoint};const opt=document.createElement('option');opt.textContent=name+' / '+id;opt.selected=true;$('model').add(opt);$('modelTitle').textContent=name+' · 演示'}else toolProfiles.push(name);$('dialog').close();toast('演示配置已添加,未连接真实服务')}});
|
||||||
|
$('composer').addEventListener('submit',e=>{e.preventDefault();const text=$('input').value.trim();if(!text&&!files.length)return;$('input').value='';const display=text+(files.length?'\n附件:'+files.join('、'):'');history+=user(display);files=[];$('attachments').textContent='';if(stage==='ask'){answer=text||'结合附件确定方向';history+=agent(receipt('已记录你的回答'));transition('plan')}else if(stage==='done'){extraContext=text;history+=agent('<p>收到。这一轮会结合你的补充更新成果。原型将演示修改和再次交付的过程。</p>');start('done')}else{extraContext=text;history+=agent('<p>已把这条补充放入任务上下文。'+(stage==='approval'?'当前授权仍由你通过上方操作决定。':stage==='blocked'?'当前问题仍可选择重试或跳过。':'你可以继续使用当前步骤的操作推进任务。')+'</p>');render();$('chat').scrollTop=$('chat').scrollHeight}});
|
||||||
|
$('input').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey&&!e.isComposing){e.preventDefault();$('composer').requestSubmit()}});
|
||||||
|
$('scene').onchange=e=>{scene=e.target.value;reset()};$('restart').onclick=reset;$('newTask').onclick=()=>{reset();$('input').focus()};$('jump').onchange=e=>{const next=e.target.value;clearInterval(timer);history=initial();answer=next==='ask'?'':scenes[scene].options[0][0];if(next!=='ask')history+=agent(receipt('已确认方向 · '+answer));if(next==='running')start();else transition(next)};
|
||||||
|
$('closeDialog').onclick=()=>$('dialog').close();$('connectionsNav').onclick=()=>connections();$('connectFooter').onclick=()=>connections();$('modelSettings').onclick=()=>connections();$('model').onchange=e=>$('modelTitle').textContent=e.target.value+' · 演示';$('editPlan').onclick=edit;$('togglePlan').onclick=()=>$('app').classList.toggle('show-plan');$('conversationNav').onclick=()=>{$('app').classList.remove('show-plan');$('input').focus()};$('currentTask').onclick=()=>$('input').focus();$('artifactsNav').onclick=()=>stage==='done'?preview():modal('任务成果','<p class="muted">任务完成后,成果会出现在这里。你可以从顶部「查看状态」选择「成果交付」体验。</p>');$('attach').onclick=()=>$('file').click();$('file').onchange=e=>{files=Array.from(e.target.files).map(f=>f.name);$('attachments').textContent='附件(仅展示文件名):'+files.join(' · ');e.target.value=''};
|
||||||
|
reset();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script src="studio.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Reasonix Studio · Agent Workspace</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root{font-family:Inter,"Segoe UI","Microsoft YaHei",sans-serif;color:#252722;background:#eeefeb;font-synthesis:none;--muted:#74796f;--line:#e4e7de;--green:#365d44;--soft:#eff4eb}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button{cursor:pointer}button:disabled{cursor:default;opacity:.5}button,a,input,textarea,select{outline-offset:4px}button{color:inherit;border:0;background:none}button:hover{background:#edf0e9}button.primary{background:var(--green);color:white;border:1px solid var(--green)}button.primary:hover{background:#284c35}button.secondary{border:1px solid var(--line);background:white}button.small{padding:7px 10px;font-size:12px}button{border-radius:8px;padding:10px 14px}input,select,textarea{width:100%;border:1px solid var(--line);border-radius:8px;padding:11px;background:#fff;color:#252722}textarea{resize:vertical}.lab{min-height:43px;background:#252d26;color:#e5ecdf;display:flex;align-items:center;gap:16px;padding:8px 23px;font-size:12px;flex-wrap:wrap}.lab span{opacity:.7}.lab select{width:auto;padding:4px 25px 4px 8px;background:#394339;color:#fff;border:0;font-size:12px}.lab button{color:#fff;padding:4px 8px;font-size:12px}.lab button:hover{background:#465345}.lab .spacer{flex:1}.app{display:grid;grid-template-columns:190px minmax(360px,1fr) 290px;height:calc(100dvh - 47px);min-height:670px;background:#fff}.sidebar{background:#f4f5f0;border-right:1px solid var(--line);padding:26px 15px;display:flex;flex-direction:column;gap:8px}.brand{font-size:19px;font-weight:650;letter-spacing:-.7px;padding:0 9px 30px}.brand b{display:inline-grid;place-items:center;width:27px;height:27px;border-radius:8px;background:var(--green);color:#fff;margin-right:7px;font-size:17px}.brand small{display:block;padding-left:38px;font-size:10px;font-weight:500;color:var(--muted);letter-spacing:2px;margin-top:3px}.nav{text-align:left;color:#575f51;font-size:13px}.nav.active{background:#e7ebdf;color:#2d4a34}.section-label{font-size:11px;color:var(--muted);margin:24px 10px 9px;letter-spacing:1px}.tasknav{text-align:left;font-size:12px;line-height:1.8;padding:11px}.tasknav.selected{background:#fff;box-shadow:0 1px 4px #23301409}.tasknav small{display:block;color:var(--muted);font-size:10px}.sidebar-footer{margin-top:auto;padding-top:25px}.connection{display:flex;align-items:center;gap:8px;font-size:12px}.dot{width:6px;height:6px;display:inline-block;border-radius:50%;background:#789c6a}.userprofile{border-top:1px solid var(--line);margin-top:18px;padding:20px 9px 0;display:flex;gap:10px;font-size:12px;align-items:center}.avatar{display:grid;place-items:center;background:#e7eadf;border-radius:9px;width:30px;height:30px;font-size:12px}.main{display:flex;min-width:0;flex-direction:column;background:#fff}.top{height:76px;flex-shrink:0;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;padding:0 30px;gap:10px}.top h1{font-size:15px;margin:0 0 6px;font-weight:600}.subtitle{font-size:11px;color:var(--muted)}.badge{font-size:11px;background:#f4f1e5;color:#886819;padding:5px 9px;border-radius:20px;white-space:nowrap}.chat{overflow-y:auto;flex:1;scroll-behavior:smooth;padding:28px max(26px,calc((100% - 740px)/2)) 20px}.date{text-align:center;font-size:10px;color:#94988f;margin-bottom:26px}.message{margin-bottom:24px;font-size:14px;line-height:1.85}.message.user{display:flex;justify-content:flex-end}.bubble{padding:12px 18px;background:#f1f3ed;border-radius:14px 14px 3px 14px;max-width:88%;white-space:pre-wrap;overflow-wrap:anywhere}.byline{font-size:11px;display:flex;align-items:center;gap:9px;margin-bottom:10px;font-weight:600}.agentmark{display:inline-grid;place-items:center;width:24px;height:24px;border:1px solid #d5ddcb;border-radius:7px;color:var(--green);background:#f7f9f3}.copy{margin:0 0 15px}.interaction{border:1px solid #dce3d2;border-radius:12px;background:#fff;overflow:hidden;margin-top:14px;box-shadow:0 4px 16px #273c2805}.interaction-body{padding:20px}.eyebrow{font-size:10px;color:#73816b;letter-spacing:1px;margin-bottom:9px}.interaction h2{font-size:16px;margin:0 0 8px;font-weight:600}.muted{font-size:12px;color:var(--muted);line-height:1.7}.choices{display:grid;gap:9px;margin-top:17px}.choice{padding:12px 14px;border:1px solid var(--line);text-align:left;display:flex;gap:13px;border-radius:9px;align-items:center;font-size:13px}.choice .radio{width:16px;height:16px;border:1px solid #c8cebf;border-radius:50%;flex-shrink:0}.choice.selected{border-color:#6e8b59;background:#f6f8f0}.choice.selected .radio{border:5px solid #54713e}.choice small{display:block;font-size:11px;color:var(--muted);margin-top:2px}.choice b{font-weight:500}.interaction-footer{padding:12px 20px;background:#fafbf7;border-top:1px solid #edf0e7;display:flex;justify-content:space-between;align-items:center;gap:9px;font-size:11px;color:var(--muted);flex-wrap:wrap}.interaction-footer button{font-size:12px}.receipt{font-size:12px;color:#67745f;border-left:2px solid #c7d3bc;paddLine truncated
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
/* Specificity keeps the visual system separate from the original prototype. */
|
||||||
|
html body{background:#e9eaef;color:#292a35;font-family:Inter,"Segoe UI","Microsoft YaHei",sans-serif;--muted:#818391;--line:#e8e8ef;--green:#6658ce;--soft:#f3f1fe}
|
||||||
|
html .lab{background:#ececf1;color:#6d6d7a;min-height:42px;padding:8px 22px;font-size:10px;letter-spacing:.2px;border-bottom:1px solid #dddde6}
|
||||||
|
html .lab strong{color:#383744;font-size:10px;letter-spacing:1px}html .lab select{color:#545364;background:#f8f8fb;border:1px solid #dbdbe5;font-size:10px;border-radius:5px}html .lab button{color:#686577;font-size:10px}html .lab button:hover{background:#e0dfeb}
|
||||||
|
html .app{grid-template-columns:216px minmax(380px,1fr) 328px;height:calc(100dvh - 43px);min-height:710px;background:#fafafd}
|
||||||
|
html .sidebar{background:linear-gradient(165deg,#232430,#1d1e29 70%);padding:26px 17px 20px;border:0;color:#e9e8ef;gap:7px}
|
||||||
|
html .brand{font-size:21px;font-weight:620;letter-spacing:-.7px;padding-bottom:25px}html .brand b{background:linear-gradient(135deg,#ad9bfa,#7266d0);box-shadow:0 0 25px #a596e523;color:#fff;border-radius:9px;transform:rotate(-7deg)}html .brand small{color:#9a97af;font-size:9px;letter-spacing:3px}
|
||||||
|
html .sidebar .nav{color:#aaaaba;padding:11px 13px;font-size:12px;border-radius:9px;transition:background .2s,color .2s,transform .2s}html .sidebar .nav:hover{background:#ffffff0a;color:#fff}html .sidebar .nav.active{background:#3b364f;color:#e1d8ff;box-shadow:inset 2px 0 #ad96f2}
|
||||||
|
html .sidebar #newTask{background:#343440;color:#f1eff8;border:1px solid #494752;margin-bottom:17px;box-shadow:0 3px 10px #00000010}html .sidebar #newTask:hover{background:#454052;transform:translateY(-1px)}html .section-label{font-size:9px;color:#7c7d90;margin:20px 12px 9px;letter-spacing:1.2px}html .tasknav.selected{background:#2d2e3b;box-shadow:none;border:1px solid #3d3c4a;color:#efedf5;padding:13px}html .tasknav small{color:#a4a0b5;margin-top:7px;font-size:10px}html .sidebar .connection{color:#aeacbd;font-size:11px}html .sidebar .connection:hover{background:#ffffff08}html .sidebar .dot{background:#b9a2f5}html .userprofile{border-color:#373642;color:#d5d2df}html .avatar{background:#424052;color:#d9cef4}html .sidebar .subtitle{color:#8b879d;font-size:10px}
|
||||||
|
html .main{background:radial-gradient(ellipse at 70% 0,#f2effb66,transparent 48%),#fdfdff}html .top{height:83px;border-bottom:1px solid #eaeaf1;background:#ffffffb8;padding:0 34px}html .top h1{font-size:15px;letter-spacing:.2px}html .top .subtitle{font-size:10px;color:#8a8998}html .badge{color:#806027;background:#fcf4e5;border:1px solid #f0e2c6;padding:6px 11px;font-size:10px}.badge:before{content:'●';font-size:7px;margin-right:6px}
|
||||||
|
html .chat{padding:29px max(32px,calc((100% - 710px)/2)) 20px;scrollbar-width:thin;scrollbar-color:#dbd9e5 transparent}html .date{font-size:9px;color:#aaa6b7;letter-spacing:.7px;margin-bottom:28px}html .message{font-size:13px;line-height:1.95;margin-bottom:29px}html .bubble{background:#edeaf5;color:#4a445b;border:1px solid #e4dfef;max-width:86%;padding:14px 18px;border-radius:16px 16px 4px 16px}html .byline{font-size:11px;gap:9px;color:#4d465e;margin-bottom:13px}html .agentmark{background:linear-gradient(150deg,#f1eafa,#e3def2);border:1px solid #dfd6ed;width:29px;height:29px;border-radius:9px;font-size:17px;color:#8471b2;box-shadow:0 2px 5px #765ba90a}html .copy{color:#55505f;font-size:13px;line-height:1.95;margin-bottom:16px}
|
||||||
|
html .interaction{border:1px solid #ded8ec;border-radius:15px;box-shadow:0 10px 35px #43306406,0 2px 5px #35273e03;position:relative;background:#fff}html .interaction:before{content:'';display:block;height:3px;background:linear-gradient(90deg,#a593d8,#d9c6e9,#f3e8dd)}html .interaction-body{padding:23px}html .eyebrow{font-size:9px;font-weight:600;letter-spacing:1.35px;color:#9482b0;margin-bottom:12px}html .interaction h2{font-size:17px;font-weight:600;letter-spacing:-.25px;color:#34303e;margin-bottom:12px}html .choices{gap:9px;margin-top:20px}html .choice{border-color:#ece9f1;padding:13px 15px;border-radius:10px;transition:background .18s,border-color .18s,box-shadow .18s,transform .18s;position:relative}html .choice:hover{transform:translateX(3px);background:#faf8fd;border-color:#cec4e2}html .choice.selected{background:#f7f3fd;border-color:#b5a4d1;box-shadow:0 0 0 2px #bba0e30b}html .choice.selected .radio{border-color:#8a73b4}html .choice small{font-size:10px;color:#938b9f;margin-top:3px}html .choice b{font-size:12px;color:#55505d;font-weight:500}.choice .option-key{margin-left:auto;font-size:10px;border:1px solid #ded7e9;padding:0 6px;border-radius:4px;color:#9b8dad;background:#fff;line-height:20px}html .interaction-footer{background:#fbfafd;border-color:#efebf4;padding:14px 23px;font-size:10px}html .interaction-footer button{font-size:11px;border-radius:8px;padding:10px 14px}html button.primary{background:#756291;border-color:#756291;box-shadow:0 3px 7px #79659220;color:#fff;transition:background .2s,box-shadow .2s,transform .2s}html button.primary:hover{background:#635079;box-shadow:0 5px 12px #79659230;transform:translateY(-1px)}html button.primary:active{transform:translateY(1px);box-shadow:none}html button.secondary{border-color:#e5dfed;background:#fff;color:#71627e}html button.secondary:hover{background:#f5f0fa}html button{transition:background .16s,transform .16s,color .16s}html button:focus-visible,html select:focus-visible,html textarea:focus-visible,html input:focus-visible{outline:2px solid #a88aca;outline-offset:3px}
|
||||||
|
html .receipt{border-left-color:#c9bcdb;background:#f8f6fb;padding:10px 13px;border-radius:0 8px 8px 0;color:#8a7d99;font-size:11px}html .progress{background:#eeebf3;height:5px}html .progress i{background:linear-gradient(90deg,#b9a6d5,#8261ab);transition:width 1s cubic-bezier(.2,.7,.2,1)}html .pulse{background:#957aaf}html .runline{color:#796189}html .notice{background:#fcf7ee;color:#947649;border:1px solid #f1e4cd;font-size:11px}
|
||||||
|
html .composer-wrap{padding:8px 28px 18px;background:linear-gradient(0deg,#fdfdff 80%,#fdfdff00)}html .composer{border:1px solid #dcd6e8;border-radius:15px;padding:13px 14px;background:#fff;box-shadow:0 5px 24px #4d30600a,0 1px 4px #3a2a4204;transition:border-color .2s,box-shadow .2s}html .composer:focus-within{border-color:#b8a2ce;box-shadow:0 0 0 3px #c3addb13,0 8px 30px #4d30600b}html .composer textarea{font-size:12px;min-height:53px;max-height:140px;line-height:1.8;color:#5f536c}html .composer textarea::placeholder{color:#aaa1b5}html .composer-bottom select{font-size:10px;color:#8d7a9e;background:#f8f5fa;border-radius:6px}html .composer-bottom button{color:#91809e;font-size:10px}html .composer-bottom .primary{color:#fff;padding:8px 14px}html .composer-hint{font-size:9px;color:#afa7b7}.quick-replies{display:flex;gap:7px;margin-bottom:10px;flex-wrap:wrap}.quick-replies button{font-size:10px;padding:6px 10px;color:#9686a3;background:#f6f3f9;border:1px solid #e9e2ef;border-radius:20px}.quick-replies button:hover{color:#766184;background:#eee6f5}
|
||||||
|
html .right{background:#f8f7fb;border-left:1px solid #e9e5ef;padding:24px 22px;gap:0}html .right-title{font-size:12px;color:#5c506a;margin-bottom:20px}html .tag{background:#ebe5f2;color:#9479ac;font-size:9px;padding:5px 9px}html .right h3{font-size:11px;color:#71607e;margin-bottom:17px}html .right #editPlan{font-size:10px;color:#a58bb6}html .plan li{font-size:11px;padding-bottom:24px;color:#83708f}html .plan li.pending{color:#ada3b7}html .plan li:before{background:#e3dbe9}html .plan small{color:#a899b3;font-size:9px}html .stepnum{border-color:#e2d8eb;color:#ac99bc;width:21px;height:21px}html .plan li.current .stepnum{background:#9b7eb0;box-shadow:0 0 0 4px #a88cb813}html .plan li.done .stepnum{background:#e9dfef;color:#9875af}html .aside-section{border-color:#e7dfed;padding-top:20px;margin-top:8px}html .context-item{font-size:10px;color:#b09abe}html .context-item b{font-size:10px;color:#9682a3;max-width:155px;overflow-wrap:anywhere}html .empty{font-size:11px;color:#b2a4bd}html .right-note{font-size:9px;color:#b5a5bf}.workspace-tabs{display:flex;gap:2px;background:#eeebf3;border-radius:8px;padding:3px;margin-bottom:25px}.workspace-tabs button{flex:1;font-size:10px;padding:7px 4px;color:#9b89a8;border-radius:6px}.workspace-tabs button.active{background:#fff;color:#836591;box-shadow:0 1px 4px #7253840c}
|
||||||
|
.ambient-preview{border:1px solid #e5dced;border-radius:12px;overflow:hidden;background:#fff;margin-bottom:22px}.preview-top{padding:10px 13px;font-size:9px;color:#ac96b9;border-bottom:1px solid #f0eaf4;display:flex;justify-content:space-between}.preview-art{height:125px;background:radial-gradient(ellipse at 15% 85%,#d5c3e8,transparent 55%),radial-gradient(ellipse at 95% 0%,#e4caca,transparent 70%),#f3ebf8;position:relative;overflow:hidden;padding:23px}.preview-art:before,.preview-art:after{content:'';position:absolute;width:100px;height:100px;border:1px solid #ffffffaa;border-radius:50%;right:20px;top:30px;transform:rotate(-25deg) scaleX(1.5)}.preview-art:after{right:55px;top:60px}.preview-art strong{display:block;position:relative;z-index:1;font-size:19px;font-weight:500;line-height:1.4;color:#81678e;letter-spacing:-.6px;max-width:150px}.preview-art small{display:block;margin-top:9px;font-size:8px;letter-spacing:2px;color:#a085ac}.preview-bottom{padding:10px 13px;font-size:9px;color:#a58db1;display:flex;justify-content:space-between}.activity-row{border-left:1px solid #e1d5e9;padding:0 0 23px 17px;font-size:11px;color:#8d749c;position:relative}.activity-row:before{content:'';width:6px;height:6px;border-radius:50%;background:#bea2ce;position:absolute;left:-4px;top:5px}.activity-row small{display:block;color:#b7a3c3;font-size:9px;margin-top:5px}.activity-row:last-child{border-color:transparent}.capabilities{display:flex;gap:6px;flex-wrap:wrap;margin-top:15px}.capabilities span{font-size:9px;border:1px solid #e6ddee;background:#fcfaff;padding:4px 7px;border-radius:5px;color:#a98bb8}.workspace-banner{font-size:9px;color:#b09bbe;letter-spacing:1px;margin-bottom:14px}.workspace-pane[hidden]{display:none}.workspace-pane{min-width:0}.empty-art{border:1px dashed #dacbe5;border-radius:10px;text-align:center;padding:26px 12px;color:#a68db5;font-size:11px}.empty-art b{display:block;font-size:24px;font-weight:400;margin-bottom:12px;color:#c0a6cf}html .artifact{border-color:#e5d9ef;color:#a181b4;box-shadow:0 3px 12px #9871ad08;padding:16px;font-size:11px}html .artifact:hover{transform:translateY(-2px);box-shadow:0 5px 18px #9871ad15}html .artifact small{color:#b69cc6;font-size:9px}
|
||||||
|
html .modal{border:1px solid #e5d6ed;border-radius:18px;color:#6d527c;box-shadow:0 25px 100px #29173830}html .modal::backdrop{background:#241b304a;backdrop-filter:blur(6px)}html .modal-header{border-color:#eee4f4;background:#fcfaff}html .modal-header h2{font-size:17px}html .modal-content{background:#fff}html .modal label{color:#9c7eac;font-size:11px}html .modal input,html .modal select,html .modal textarea{border-color:#e7ddee;color:#876394;font-size:12px;border-radius:9px}html .modal-footer{background:#fcfaff;border-color:#eee4f4}html .modal .tabs .selected{background:#f0e5f7;color:#9a73ad}html .modal .muted{font-size:11px;color:#a18aaa}html .modal .trace{color:#a488b3}html .modal.document-drawer{height:calc(100dvh - 32px);max-height:none;width:min(660px,95vw);max-width:95vw;margin:16px 16px 16px auto;border-radius:16px;display:flex;flex-direction:column}html .modal.document-drawer:not([open]){display:none}html .modal.document-drawer .modal-content{flex:1;max-height:none;overflow:auto;background:#f8f5fb;padding:28px}html .document{background:#fff;border:1px solid #eee5f4;box-shadow:0 5px 24px #6e438c08;padding:32px;font-size:12px;color:#90789c;min-height:100%}html .document h2{font-size:26px;color:#765484;line-height:1.4}html .document h3{font-size:13px;color:#89649c;margin-top:27px}html .document p{line-height:1.95}html .toast{background:#826193;box-shadow:0 6px 25px #65437030;font-size:12px}
|
||||||
|
.state-enter{animation:state-arrival .36s cubic-bezier(.2,.7,.2,1) both}.drawer-enter{animation:drawer-arrival .32s cubic-bezier(.2,.7,.2,1) both}.modal-enter{animation:modal-arrival .22s ease-out both}.choice-pick{animation:choice-pick .22s ease-out}.sent-feedback{animation:send-confirm .5s ease-out}.workspace-change{animation:workspace-reveal .25s ease-out both}@keyframes state-arrival{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@keyframes drawer-arrival{from{opacity:0;transform:translateX(35px)}to{opacity:1;transform:translateX(0)}}@keyframes modal-arrival{from{opacity:.4;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}@keyframes choice-pick{50%{box-shadow:0 0 0 4px #baa0d128}}@keyframes send-confirm{50%{box-shadow:0 0 0 4px #bda1d624}}@keyframes workspace-reveal{from{opacity:.3;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||||||
|
@media(min-width:1600px){html .app{grid-template-columns:235px minmax(400px,1fr) 365px}html .right{padding:26px 28px}html .chat{padding-top:40px}html .interaction-body{padding:27px}html .message{font-size:14px}html .copy{font-size:14px}html .choice b{font-size:13px}}
|
||||||
|
@media(max-width:1200px){html .app{grid-template-columns:180px minmax(350px,1fr) 280px}html .right{padding:24px 18px}html .chat{padding-left:25px;padding-right:25px}html .brand{font-size:18px;padding-left:2px}html .sidebar{padding-left:12px;padding-right:12px}html .top{padding:0 25px}}
|
||||||
|
@media(max-width:1000px){html .app{grid-template-columns:174px minmax(0,1fr)}html .right{display:none}html .plan-toggle{display:block!important}html .app.show-plan .right{display:flex;position:fixed;right:12px;top:110px;bottom:12px;width:310px;border:1px solid #e0d2e9;border-radius:14px;box-shadow:0 15px 70px #47325225;z-index:3}.ambient-preview{flex-shrink:0}}
|
||||||
|
@media(max-width:600px){html .app{grid-template-columns:minmax(0,1fr);height:calc(100dvh - 77px);min-height:650px}html .sidebar{display:none}html .lab{padding:8px 12px;gap:7px}html .lab strong{width:100%;font-size:9px}html .lab>span{display:none}html .chat{padding:22px 18px}html .top{padding:0 19px;height:75px}html .top h1{font-size:13px}html .interaction-body{padding:19px}html .interaction h2{font-size:16px}html .interaction-footer{padding:13px 18px}html .composer-wrap{padding:8px 16px 15px}html .choice b{font-size:12px}html .choice small{font-size:10px}html .quick-replies button{padding:6px 8px;font-size:9px}html .modal.document-drawer{margin:8px;height:calc(100dvh - 16px);max-width:calc(100vw - 16px);width:calc(100vw - 16px)}html .document{padding:23px}html .modal.document-drawer .modal-content{padding:14px}}
|
||||||
|
html .right h3,html .right-title{color:#675672}html .context-item{color:#8b7899}html .context-item b{color:#756080}html .empty,html .empty-art{color:#8e759e}html .plan li.pending{color:#94839f}html .plan small{color:#93809f}html .activity-row{color:#776084}html .activity-row small{color:#94809f}html .workspace-tabs button{color:#897394}html .workspace-banner{color:#92779f}html .preview-top,html .preview-bottom{color:#8b7398}html .quick-replies button{color:#806c8d}html .artifact{color:#846092}html .artifact small{color:#967ba5}html .right-note{color:#93819e}html .composer-hint{color:#928598}html .choice small{color:#82748d}html .modal .muted{color:#887293}html .modal label{color:#856b92}html .document{color:#7b6585}
|
||||||
|
@media(prefers-reduced-motion:reduce){html *,html *:before,html *:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="lab"><strong>STUDIO <span>/</span> DESIGN PREVIEW 04</strong><span>交互演示 · 无真实 API 请求</span><div class="spacer"></div><label for="scene">场景</label><select id="scene"><option value="research">研究与整理</option><option value="travel">旅行规划</option><option value="code">软件开发</option></select><label for="jump">状态</label><select id="jump"><option value="ask">Ask · 澄清</option><option value="plan">Todo · 计划</option><option value="running">执行中</option><option value="approval">请求授权</option><option value="blocked">遇到阻碍</option><option value="done">成果交付</option></select><button id="restart">↺ 重新体验</button></div>
|
||||||
|
<div class="app" id="app">
|
||||||
|
<nav class="sidebar" aria-label="主导航"><div class="brand"><b>r</b>Reasonix<small>STUDIO</small></div><button class="secondary nav" id="newTask">+ 新任务</button><div class="section-label">工作空间</div><button class="nav active" id="conversationNav">◌ 对话</button><button class="nav" id="artifactsNav">▤ 成果</button><button class="nav" id="connectionsNav">⌘ 连接与能力</button><div class="section-label">当前任务</div><button class="tasknav selected" id="currentTask">准备一份行业研究<small>需要你回答</small></button><div class="sidebar-footer"><button class="connection" id="connectFooter"><i class="dot"></i> 演示连接 <span style="color:#929987">↗</span></button><div class="userprofile"><span class="avatar">我</span><div>个人工作空间<div class="subtitle" style="margin-top:4px">Local workspace</div></div></div></nav>
|
||||||
|
<main class="main"><header class="top"><div><h1 id="taskTitle">准备一份行业研究</h1><div class="subtitle">通用助手 <span style="margin:0 7px">/</span> <span id="modelTitle">演示模型</span></div></div><div style="display:flex;align-items:center;gap:8px"><span class="badge" id="status">需要你回答</span><button class="small plan-toggle hidden" id="togglePlan">计划</button></div></header><div class="chat" id="chat" aria-live="polite"></div><div class="composer-wrap"><form class="composer" id="composer"><div class="attachments" id="attachments"></div><textarea id="input" rows="2" aria-label="发送消息" placeholder="直接回答,或者补充你的想法…"></textarea><div class="composer-bottom"><div style="display:flex;gap:4px;align-items:center"><button type="button" id="attach" aria-label="添加附件">+ 附件</button><select id="model" aria-label="选择模型"><option>演示模型</option></select><button type="button" id="modelSettings" aria-label="配置模型">⚙</button></div><button class="primary" type="submit" id="send">发送 ↑</button></div></form><div class="composer-hint">Enter 发送 · Shift + Enter 换行 · 随时补充要求或调整方向</div></div></main>
|
||||||
|
<aside class="right"><div class="right-title">任务空间 <span class="tag" id="planCount">0 / 4</span></div><h3>执行计划 <button class="small" style="float:right;padding:0" id="editPlan">编辑</button></h3><ol class="plan" id="plan"></ol><div class="aside-section"><h3>已确认的上下文</h3><div id="context"></div></div><div class="aside-section"><h3>成果 <span id="artifactCount" class="subtitle">0</span></h3><div id="artifacts" class="empty">成果会留在这里。<br>无需回翻聊天记录。</div></div><div class="right-note">对话承接你的意图。<br>计划、上下文与成果随任务更新。</div></aside>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="file" class="hidden" multiple>
|
||||||
|
<dialog class="modal" id="dialog"><div class="modal-header"><h2 id="dialogTitle"></h2><button id="closeDialog" aria-label="关闭">✕</button></div><div class="modal-content" id="dialogBody"></div><div class="modal-footer" id="dialogFooter"></div></dialog><div class="toast hidden" id="toast" role="status"></div>
|
||||||
|
<script>
|
||||||
|
(()=>{
|
||||||
|
const $=id=>document.getElementById(id), esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const scenes={research:{title:'准备一份行业研究',request:'帮我研究一下 AI 个人助手的发展方向,整理成一份适合团队讨论的简报。',intro:'可以。我会把产品方向、典型场景和机会点整理在一起。先确认一个会影响研究重点的问题。',question:'这份简报主要用来做什么?',options:[['探索产品机会','重点看用户需求、使用场景和差异化方向'],['了解行业现状','重点看代表产品、能力边界和发展趋势'],['支持技术选型','重点看模型、工具接入和实现方式']],steps:['确认研究重点','收集并整理资料','形成观点与对比','交付研究简报'],tool:'读取公开资料',permission:'连接你的团队知识库?',permissionDesc:'读取你选择的「产品研究」目录,补充已有讨论。不会修改或分享文件。',blocked:'有一份参考资料无法访问',result:'AI 个人助手 · 方向研究',extension:'研究简报'},travel:{title:'安排一次京都旅行',request:'帮我安排一个轻松的京都三日行程,想吃好吃的,也想逛一些安静的地方。',intro:'我会把路线、餐饮和休息时间一起考虑。先了解你喜欢的节奏。',question:'你更喜欢哪一种旅行节奏?',options:[['慢慢逛','每天 2–3 个地点,留出自由时间'],['经典路线','覆盖主要景点,兼顾特色街区'],['当地生活','小店、咖啡馆和人少的地方']],steps:['确认旅行偏好','整理地点与路线','补充时间安排','交付三日行程'],tool:'整理目的地信息',permission:'读取日历中的出行日期?',permissionDesc:'仅查看这次旅行对应的日期,避开已有安排。不会创建或修改日程。',blocked:'一个地点的信息暂时不可用',result:'京都三日 · 慢旅行计划',extension:'旅行计划'},code:{title:'制作个人作品集',request:'帮我做一个个人作品集网站,简洁一点,方便展示我的项目。',intro:'我会先整理结构,再制作页面并检查效果。先确定网站最重要的内容。',question:'你希望访客最先看到什么?',options:[['项目作品','以作品封面与项目介绍为核心'],['个人介绍','强调你的经历、能力和表达'],['联系与合作','突出服务内容和合作入口']],steps:['确认网站重点','规划页面与内容','实现并检查页面','交付预览与文件'],tool:'整理页面结构',permission:'允许在项目目录中生成文件?',permissionDesc:'只在 portfolio/ 目录新增页面和样式文件,不覆盖已有文件。',blocked:'一张作品封面图片无法读取',result:'个人作品集 · 页面方案',extension:'设计方案'}};
|
||||||
|
let scene='research', stage='ask', choice=0, answer='', history='', timer=null, tick=0, paused=false, runTarget='approval', files=[], extraContext='', profile=null, toolProfiles=[], planCustom=null;
|
||||||
|
let stateNames={ask:'需要你回答',plan:'计划已就绪',running:'正在推进',approval:'需要你的授权',blocked:'需要你处理',done:'已完成'};
|
||||||
|
function agent(html){return '<section class="message"><div class="byline"><span class="agentmark">r</span>Reasonix <span class="subtitle">· 通用助手</span></div>'+html+'</section>'}
|
||||||
|
function user(text){return '<div class="message user"><div class="bubble">'+esc(text)+'</div></div>'}
|
||||||
|
function button(action,label,primary=false){return '<button data-action="'+action+'" class="'+(primary?'primary':'secondary')+'">'+label+'</button>'}
|
||||||
|
function receipt(text){return '<details class="receipt"><summary>'+esc(text)+'</summary><p>这条记录已保留。你可以在下方对话中随时补充或修改要求。</p></details>'}
|
||||||
|
function initial(){const s=scenes[scene];return '<div class="date">今天 · 一段持续推进的协作</div>'+user(s.request)}
|
||||||
|
function card(eyebrow,title,body,footer=''){return '<div class="interaction"><div class="interaction-body"><div class="eyebrow">'+eyebrow+'</div><h2>'+title+'</h2>'+body+'</div>'+(footer?'<div class="interaction-footer">'+footer+'</div>':'')+'</div>'}
|
||||||
|
function render(){const s=scenes[scene];$('taskTitle').textContent=s.title;$('status').textContent=paused?'已暂停':stateNames[stage];$('jump').value=stage;$('currentTask').innerHTML=esc(s.title)+'<small>'+esc(paused?'已暂停':stateNames[stage])+'</small>';let content='';
|
||||||
|
if(stage==='ask')content=agent('<p class="copy">'+s.intro+'</p>'+card('ASK / 需要你回答',s.question,'<div class="choices">'+s.options.map((o,i)=>'<button class="choice '+(choice===i?'selected':'')+'" data-choice="'+i+'" aria-pressed="'+(choice===i)+'"><span class="radio"></span><span><b>'+o[0]+'</b><small>'+o[1]+'</small></span></button>').join('')+'</div>','<span>不必拘泥于选项,也可以直接回复</span>'+button('answer','就按这个方向 →',true)));
|
||||||
|
if(stage==='plan')content=agent('<p class="copy">明白,重点是「'+esc(answer||s.options[0][0])+'」。我已经更新右侧计划,接下来会按这个方向推进。</p>'+card('TODO / 计划就绪','先看清路径,再开始','<div class="muted">'+(planCustom||s.steps).slice(1).map((x,i)=>'<div style="padding:5px 0">'+(i+1)+'. '+esc(x)+'</div>').join('')+'</div>','<span>执行过程中仍然可以调整</span><div>'+button('edit','调整计划')+' '+button('start','开始执行 →',true)+'</div>'));
|
||||||
|
if(stage==='running')content=agent('<p class="copy">'+(paused?'进度已保存。准备好后可以继续,也可以告诉我需要调整什么。':'我正在推进任务。你可以继续补充要求,不需要等待我结束。')+'</p>'+card('RUNNING / '+(paused?'已暂停':'执行中'),paused?'任务已暂停':s.tool,'<div class="runline"><span class="'+(paused?'dot':'pulse')+'"></span><span id="runText">'+(paused?'等待继续':'正在整理第 '+(tick+1)+' 组信息')+'</span></div><div class="progress"><i style="width:'+Math.min(90,20+tick*20)+'%"></i></div><details class="trace"><summary>查看执行记录</summary><div>✓ 已载入任务上下文</div><div>✓ 已准备所需能力</div><div>◌ '+s.tool+'(模拟)</div></details>','<span>进展在这里更新,不刷满对话</span>'+button(paused?'resume':'pause',paused?'继续执行':'暂停')));
|
||||||
|
if(stage==='approval')content=agent('<p class="copy">下一步可以结合你的资料,让结果更贴近实际。这需要你授权,也可以跳过。</p>'+card('APPROVAL / 由你决定',s.permission,'<p class="muted">'+s.permissionDesc+'</p><div class="notice">仅本次任务有效 · 本原型不会调用真实服务</div>','<span>拒绝后仍可使用公开资料继续</span><div>'+button('deny','跳过')+' '+button('allow','允许这一次',true)+'</div>'));
|
||||||
|
if(stage==='blocked')content=agent('<p class="copy">大部分内容已经整理好。有一项信息需要换一种方式获取,现有进度已保留。</p>'+card('BLOCKED / 可以恢复',s.blocked,'<p class="muted">演示原因:外部服务暂时没有响应。你可以重试,或跳过这一项继续。</p>','<span>不会重新开始整个任务</span><div>'+button('skip','跳过这项')+' '+button('retry','重试连接',true)+'</div>'));
|
||||||
|
if(stage==='done')content=agent('<p class="copy">已经整理好了。成果留在右侧,你可以直接打开,也可以继续告诉我哪里需要调整。</p>'+card('DONE / 已交付',s.result,'<p class="muted">包含目标、核心内容与下一步建议。<br>这是用于体验交付流程的示例内容。</p>','<span>第 1 版 · 可继续迭代</span><div>'+button('download','下载')+' '+button('preview','打开成果 ↗',true)+'</div>'));
|
||||||
|
$('chat').innerHTML=history+content;renderAside();$('input').placeholder=stage==='ask'?'直接回答,或者补充你的想法…':stage==='done'?'继续修改:比如「更简洁一些」…':'补充要求、改变方向,或者问问当前进展…';}
|
||||||
|
function renderAside(){const s=scenes[scene],n=stage==='ask'?0:stage==='done'?4:stage==='blocked'?2:1;$('planCount').textContent=n+' / 4';$('plan').innerHTML=(planCustom||s.steps).map((x,i)=>'<li class="'+(i<n?'done':i===n?'current':'pending')+'"><span class="stepnum">'+(i<n?'✓':i+1)+'</span><div>'+esc(x)+(i===n?'<small>'+(paused?'已暂停':stage==='ask'?'等你确认方向':stateNames[stage])+'</small>':'')+'</div></li>').join('');$('context').innerHTML='<div class="context-item"><span>工作重点</span><b>'+esc(answer||'待确认')+'</b></div><div class="context-item"><span>输出语言</span><b>中文</b></div>'+(extraContext?'<div class="context-item"><span>补充要求</span><b>'+esc(extraContext)+'</b></div>':'');$('artifactCount').textContent=stage==='done'?'1':'0';$('artifacts').innerHTML=stage==='done'?'<button class="artifact" data-action="preview">▤ '+s.result+'<small>可预览 · 可下载 · 可继续修改 ↗</small></button>':'成果会留在这里。<br>无需回翻聊天记录。';}
|
||||||
|
function transition(next){clearInterval(timer);timer=null;paused=false;stage=next;render();$('chat').scrollTop=$('chat').scrollHeight;}
|
||||||
|
function reset(){clearInterval(timer);answer='';extraContext='';choice=0;files=[];planCustom=null;tick=0;paused=false;$('attachments').textContent='';history=initial();transition('ask');}
|
||||||
|
function start(target='approval'){tick=0;runTarget=target;transition('running');schedule();}
|
||||||
|
function schedule(){clearInterval(timer);timer=setInterval(()=>{tick++;if(tick>=4){history+=agent(receipt('✓ 已完成当前阶段 · 执行记录已收起'));transition(runTarget)}else{const label=$('runText');if(label)label.textContent='正在整理第 '+(tick+1)+' 组信息';const bar=$('chat').querySelector('.progress i');if(bar)bar.style.width=Math.min(90,20+tick*20)+'%';document.dispatchEvent(new CustomEvent('studio-progress',{detail:{tick}}));}},2200)}
|
||||||
|
function toast(text){$('toast').textContent=text;$('toast').classList.remove('hidden');setTimeout(()=>$('toast').classList.add('hidden'),3000)}
|
||||||
|
function modal(title,body,footer){$('dialogTitle').textContent=title;$('dialogBody').innerHTML=body;$('dialogFooter').innerHTML=footer||'<button data-close class="secondary">关闭</button>';if(!$('dialog').open)$('dialog').showModal();}
|
||||||
|
function edit(){modal('调整执行计划','<p class="muted">每行一个步骤。此原型保留四个阶段,修改后会同步到任务空间。</p><textarea id="planEditor" rows="6">'+esc((planCustom||scenes[scene].steps).join('\n'))+'</textarea>','<button data-close>取消</button>'+button('savePlan','保存计划',true));}
|
||||||
|
function documentText(){const s=scenes[scene];return '# '+s.result+'\n\n> 交互原型示例,不是实际研究或执行结果。\n\n## 目标\n'+s.request+'\n\n## 已确认方向\n'+(answer||s.options[0][0])+'\n\n## 核心内容\n'+(scene==='research'?'1. 从单次回答转向持续完成任务。\n2. 把澄清、授权和交付变成清晰的交互节点。\n3. 模型与工具独立接入,任务上下文保持连续。':scene==='travel'?'第一天:抵达与附近街区漫步。\n第二天:安排一条步行路线,中午留出休息时间。\n第三天:自由探索,提前预留返程时间。':'首页:个人介绍与精选作品。\n项目:作品封面、过程与结果。\n联系:合作方式与联系方式。')+'\n\n## 补充要求\n'+(extraContext||'暂无')+'\n\n## 下一步\n确认内容后继续细化。';}
|
||||||
|
function preview(){modal(scenes[scene].result,'<article class="document">'+documentText().split('\n').map(l=>l.startsWith('# ')?'<h2>'+esc(l.slice(2))+'</h2>':l.startsWith('## ')?'<h3>'+esc(l.slice(3))+'</h3>':'<p>'+esc(l)+'</p>').join('')+'</article>','<button data-close>返回对话</button>'+button('download','下载 Markdown',true));}
|
||||||
|
function download(){const url=URL.createObjectURL(new Blob([documentText()],{type:'text/markdown;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download=scenes[scene].result+'.md';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
|
||||||
|
function connections(tab='model'){modal('连接与能力','<div class="tabs"><button data-tab="model" class="'+(tab==='model'?'selected':'')+'">模型 API</button><button data-tab="tool" class="'+(tab==='tool'?'selected':'')+'">工具与外部服务</button></div><p class="muted">'+(tab==='model'?'模型负责理解与规划。不同协议通过适配器接入,工作台的对话和状态保持一致。':'工具负责执行操作。HTTP API、MCP 和自定义适配器独立于模型配置。')+'</p><div class="notice">配置交互演示:不会连接服务,也不保存密钥。请勿填写真实凭证。</div><label for="providerName">连接名称</label><input id="providerName" placeholder="'+(tab==='model'?'例如:我的模型服务':'例如:团队知识库')+'" value="'+esc(tab==='model'&&profile?profile.name:'')+'"><div class="form-grid"><div><label for="protocol">'+(tab==='model'?'API 协议':'接入方式')+'</label><select id="protocol">'+(tab==='model'?'<option>OpenAI 兼容</option><option>Anthropic Messages</option><option>自定义适配器</option>':'<option>HTTP API</option><option>MCP</option><option>自定义适配器</option>')+'</select></div><div><label for="modelId">'+(tab==='model'?'模型标识':'能力名称')+'</label><input id="modelId" placeholder="'+(tab==='model'?'provider/model-name':'search_documents')+'" value="'+esc(tab==='model'&&profile?profile.id:'')+'"></div></div><label for="endpoint">服务地址</label><input id="endpoint" type="url" placeholder="https://api.example.com/v1" value="'+esc(tab==='model'&&profile?profile.endpoint:'')+'"><label for="auth">认证方式</label><select id="auth"><option>Bearer Token(运行时从凭证库读取)</option><option>API Key 请求头</option><option>OAuth</option><option>无需认证</option></select><details class="trace"><summary>适配与能力声明</summary><p class="muted">不同 API 需要适配请求、响应和事件结构,不能只替换地址。以下为配置界面示例。</p><label for="mapping">适配器标识</label><input id="mapping" placeholder="例如:my-provider-adapter"><label><input style="width:auto" type="checkbox" checked> 支持流式输出 / 进度事件</label><label><input style="width:auto" type="checkbox" checked> 支持工具调用 / 结构化结果</label></details>'+(toolProfiles.length?'<p class="muted">已添加工具:'+toolProfiles.map(esc).join('、')+'</p>':''),'<button data-close>取消</button><button class="primary" data-save-connection="'+tab+'">添加演示连接</button>');}
|
||||||
|
function action(a){switch(a){case 'answer':answer=scenes[scene].options[choice][0];history+=agent(receipt('已确认 · '+answer));transition('plan');break;case 'edit':edit();break;case 'savePlan':{const lines=$('planEditor').value.split('\n').map(s=>s.trim()).filter(Boolean);if(lines.length!==4){toast('请保留四个步骤,以便体验阶段更新');return}planCustom=lines;$('dialog').close();render();break}case 'start':history+=agent(receipt('执行计划已确认 · 4 个步骤'));start();break;case 'pause':clearInterval(timer);paused=true;render();break;case 'resume':paused=false;render();schedule();break;case 'allow':case 'deny':history+=agent(receipt(a==='allow'?'已允许 · 仅本次任务访问':'已跳过连接 · 使用已有资料继续'));start('blocked');break;case 'retry':history+=agent(receipt('你选择了重试 · 正在恢复当前步骤'));start('done');break;case 'skip':history+=agent(receipt('已跳过不可用资料 · 其余内容已保留'));transition('done');break;case 'preview':preview();break;case 'download':download();break;}}
|
||||||
|
document.addEventListener('click',e=>{const c=e.target.closest('[data-choice]');if(c){choice=Number(c.dataset.choice);render();return}const a=e.target.closest('[data-action]');if(a)action(a.dataset.action);if(e.target.closest('[data-close]'))$('dialog').close();const t=e.target.closest('[data-tab]');if(t)connections(t.dataset.tab);const save=e.target.closest('[data-save-connection]');if(save){const name=$('providerName').value.trim(),endpoint=$('endpoint').value.trim(),id=$('modelId').value.trim();let valid=false;try{valid=['https:','http:'].includes(new URL(endpoint).protocol)}catch{}if(!name||!id||!valid){toast('请填写连接名称、标识和有效的 HTTP 服务地址');return}if(save.dataset.saveConnection==='model'){profile={name,id,endpoint};const opt=document.createElement('option');opt.textContent=name+' / '+id;opt.selected=true;$('model').add(opt);$('modelTitle').textContent=name+' · 演示'}else toolProfiles.push(name);$('dialog').close();toast('演示配置已添加,未连接真实服务')}});
|
||||||
|
$('composer').addEventListener('submit',e=>{e.preventDefault();const text=$('input').value.trim();if(!text&&!files.length)return;$('input').value='';const display=text+(files.length?'\n附件:'+files.join('、'):'');history+=user(display);files=[];$('attachments').textContent='';if(stage==='ask'){answer=text||'结合附件确定方向';history+=agent(receipt('已记录你的回答'));transition('plan')}else if(stage==='done'){extraContext=text;history+=agent('<p>收到。这一轮会结合你的补充更新成果。原型将演示修改和再次交付的过程。</p>');start('done')}else{extraContext=text;history+=agent('<p>已把这条补充放入任务上下文。'+(stage==='approval'?'当前授权仍由你通过上方操作决定。':stage==='blocked'?'当前问题仍可选择重试或跳过。':'你可以继续使用当前步骤的操作推进任务。')+'</p>');render();$('chat').scrollTop=$('chat').scrollHeight}});
|
||||||
|
$('input').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey&&!e.isComposing){e.preventDefault();$('composer').requestSubmit()}});
|
||||||
|
$('scene').onchange=e=>{scene=e.target.value;reset()};$('restart').onclick=reset;$('newTask').onclick=()=>{reset();$('input').focus()};$('jump').onchange=e=>{const next=e.target.value;clearInterval(timer);history=initial();answer=next==='ask'?'':scenes[scene].options[0][0];if(next!=='ask')history+=agent(receipt('已确认方向 · '+answer));if(next==='running')start();else transition(next)};
|
||||||
|
$('closeDialog').onclick=()=>$('dialog').close();$('connectionsNav').onclick=()=>connections();$('connectFooter').onclick=()=>connections();$('modelSettings').onclick=()=>connections();$('model').onchange=e=>$('modelTitle').textContent=e.target.value+' · 演示';$('editPlan').onclick=edit;$('togglePlan').onclick=()=>$('app').classList.toggle('show-plan');$('conversationNav').onclick=()=>{$('app').classList.remove('show-plan');$('input').focus()};$('currentTask').onclick=()=>$('input').focus();$('artifactsNav').onclick=()=>stage==='done'?preview():modal('任务成果','<p class="muted">任务完成后,成果会出现在这里。你可以从顶部「查看状态」选择「成果交付」体验。</p>');$('attach').onclick=()=>$('file').click();$('file').onchange=e=>{files=Array.from(e.target.files).map(f=>f.name);$('attachments').textContent='附件(仅展示文件名):'+files.join(' · ');e.target.value=''};
|
||||||
|
reset();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(()=>{
|
||||||
|
const $=id=>document.getElementById(id);
|
||||||
|
// Load after the base styles so the prototype remains a self-contained enhancement.
|
||||||
|
const stylesheet=document.querySelector('link[href="studio.css"]');if(stylesheet)document.head.appendChild(stylesheet);
|
||||||
|
const right=document.querySelector('.right'), title=right.querySelector('.right-title');
|
||||||
|
const tabs=document.createElement('div');tabs.className='workspace-tabs';tabs.setAttribute('role','tablist');tabs.setAttribute('aria-label','任务空间');tabs.innerHTML=['计划','活动','成果'].map((x,i)=>`<button role="tab" id="workspace-tab-${i}" aria-controls="workspace-pane-${i}" aria-selected="${i===0}" class="${i===0?'active':''}" data-workspace="${i}">${x}</button>`).join('');title.after(tabs);
|
||||||
|
const overview=document.createElement('div');overview.className='workspace-pane';overview.id='workspace-pane-0';overview.setAttribute('role','tabpanel');overview.setAttribute('aria-labelledby','workspace-tab-0');while(tabs.nextSibling)overview.appendChild(tabs.nextSibling);right.appendChild(overview);
|
||||||
|
const activity=document.createElement('div');activity.className='workspace-pane';activity.id='workspace-pane-1';activity.hidden=true;activity.setAttribute('role','tabpanel');activity.setAttribute('aria-labelledby','workspace-tab-1');right.appendChild(activity);
|
||||||
|
const output=document.createElement('div');output.className='workspace-pane';output.id='workspace-pane-2';output.hidden=true;output.setAttribute('role','tabpanel');output.setAttribute('aria-labelledby','workspace-tab-2');right.appendChild(output);
|
||||||
|
const preview=document.createElement('div');preview.className='ambient-preview';preview.innerHTML='<div class="preview-top"><span>工作画布</span><span id="canvasState">等待确认</span></div><div class="preview-art"><strong id="canvasTitle">从想法<br>到有用的成果。</strong><small>REASONIX / WORK IN PROGRESS</small></div><div class="preview-bottom"><span id="canvasKind">研究简报</span><span>持续更新 ↗</span></div>';overview.prepend(preview);
|
||||||
|
const quick=document.createElement('div');quick.className='quick-replies';quick.setAttribute('aria-label','快捷补充');quick.innerHTML=['先给我一个简洁版本','说明你的判断依据','我想调整一下方向'].map(x=>`<button type="button">${x}</button>`).join('');document.querySelector('.composer').before(quick);quick.addEventListener('click',e=>{if(e.target.tagName!=='BUTTON')return;$('input').value=e.target.textContent;$('input').focus()});
|
||||||
|
let lastState=$('jump').value,lastStatus=$('status').textContent,lastScene=$('scene').value,eventLog=[],selectedTab=0,progressTick=0;
|
||||||
|
const labels={ask:'等待你确认任务方向',plan:'执行计划已准备就绪',running:'正在执行当前步骤',approval:'等待本次访问授权',blocked:'发现问题,保留进度',done:'成果已生成,可继续修改'};
|
||||||
|
function selectTab(index){selectedTab=index;[overview,activity,output].forEach((p,i)=>{p.hidden=i!==index;p.classList.remove('workspace-change');if(i===index){void p.offsetWidth;p.classList.add('workspace-change')}});tabs.querySelectorAll('button').forEach((b,i)=>{b.classList.toggle('active',i===index);b.setAttribute('aria-selected',String(i===index))})}
|
||||||
|
tabs.addEventListener('click',e=>{const b=e.target.closest('[data-workspace]');if(b)selectTab(Number(b.dataset.workspace))});tabs.addEventListener('keydown',e=>{if(!['ArrowLeft','ArrowRight','Home','End'].includes(e.key))return;e.preventDefault();const next=e.key==='Home'?0:e.key==='End'?2:(selectedTab+(e.key==='ArrowRight'?1:2))%3;selectTab(next);tabs.children[next].focus()});
|
||||||
|
function safe(text){const el=document.createElement('span');el.textContent=text;return el.innerHTML}
|
||||||
|
function renderActivity(){activity.innerHTML='<div class="workspace-banner">ACTIVITY / 本次任务</div>'+eventLog.slice(-8).map((x,i)=>`<div class="activity-row">${safe(x)}<small>${i===eventLog.length-1?'刚刚更新':'已记录'} · 本地模拟事件</small></div>`).join('')+'<div class="capabilities"><span>模型推理</span><span>工具调用</span><span>文件产出</span></div>'}
|
||||||
|
function sync(animate=false){const state=$('jump').value,scene=$('scene').value,changed=state!==lastState||scene!==lastScene,status=$('status').textContent;if(scene!==lastScene){eventLog=[];progressTick=0}if(changed){eventLog.push(labels[state]);if(animate){const current=$('chat').lastElementChild;if(current)current.classList.add('state-enter')}lastState=state;lastScene=scene}if(status!==lastStatus){if(status==='已暂停')eventLog.push('任务已暂停,进度已保留');lastStatus=status}if(!eventLog.length)eventLog.push(labels[state]);$('canvasState').textContent=status;$('canvasTitle').innerHTML=state==='done'?'想法,<br>已经成为成果。':state==='running'?'正在把线索<br>整理成答案。':'从想法<br>到有用的成果。';$('canvasKind').textContent={research:'研究简报',travel:'旅行行程',code:'页面方案'}[scene];
|
||||||
|
const n={ask:0,plan:1,running:1,approval:2,blocked:2,done:4}[state];$('planCount').textContent=n+' / 4';$('plan').querySelectorAll('li').forEach((li,i)=>{li.className=i<n?'done':i===n?'current':'pending';li.querySelector('.stepnum').textContent=i<n?'✓':i+1;const old=li.querySelector('small');if(old)old.remove();if(i===n){const sm=document.createElement('small');sm.textContent=status;li.lastElementChild.appendChild(sm)}});
|
||||||
|
$('chat').querySelectorAll('.choice').forEach((el,i)=>{if(!el.querySelector('.option-key')){const key=document.createElement('span');key.className='option-key';key.textContent=String.fromCharCode(65+i);el.appendChild(key)}});
|
||||||
|
output.innerHTML=state==='done'?'<div class="workspace-banner">DELIVERABLE / 可继续迭代</div>'+ $('artifacts').innerHTML:'<div class="empty-art"><b>▤</b>下一份成果,从这段对话开始。<div style="font-size:10px;margin-top:10px;line-height:1.8">文件和预览会出现在这里,<br>随时打开,继续完善。</div></div>';renderActivity();}
|
||||||
|
const observer=new MutationObserver(()=>sync(true));observer.observe($('chat'),{childList:true});observer.observe($('status'),{childList:true});
|
||||||
|
document.addEventListener('studio-progress',e=>{progressTick=e.detail.tick;eventLog.push(['','已整理任务相关信息','正在交叉检查内容','正在汇总当前阶段结果'][progressTick]);renderActivity()});
|
||||||
|
document.addEventListener('click',e=>{const picked=e.target.closest('[data-choice]');if(picked){requestAnimationFrame(()=>{const active=$('chat').querySelector('.choice.selected');if(active)active.classList.add('choice-pick')})}});
|
||||||
|
$('composer').addEventListener('submit',()=>{const composer=$('composer');composer.classList.remove('sent-feedback');requestAnimationFrame(()=>composer.classList.add('sent-feedback'));$('input').style.height='auto'});$('input').addEventListener('input',()=>{$('input').style.height='auto';$('input').style.height=Math.min(140,$('input').scrollHeight)+'px'});
|
||||||
|
const modal=$('dialog');new MutationObserver(records=>{if(!records.some(x=>x.attributeName==='open')||!modal.open)return;const isDocument=!!modal.querySelector('.document');modal.classList.toggle('document-drawer',isDocument);modal.classList.remove('drawer-enter','modal-enter');void modal.offsetWidth;modal.classList.add(isDocument?'drawer-enter':'modal-enter')}).observe(modal,{attributes:true,attributeFilter:['open']});
|
||||||
|
const artifactNav=$('artifactsNav');artifactNav.onclick=()=>{if($('jump').value==='done'){$('artifacts').querySelector('button').click()}else{selectTab(2);$('app').classList.add('show-plan')}};
|
||||||
|
document.addEventListener('keydown',e=>{if(e.key==='Escape')$('app').classList.remove('show-plan')});
|
||||||
|
for(const id of ['restart','newTask'])$(id).addEventListener('click',()=>{eventLog=[];progressTick=0;selectTab(0);sync()});
|
||||||
|
sync();
|
||||||
|
})();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/* Specificity keeps the visual system separate from the original prototype. */
|
||||||
|
html body{background:#e9eaef;color:#292a35;font-family:Inter,"Segoe UI","Microsoft YaHei",sans-serif;--muted:#818391;--line:#e8e8ef;--green:#6658ce;--soft:#f3f1fe}
|
||||||
|
html .lab{background:#ececf1;color:#6d6d7a;min-height:42px;padding:8px 22px;font-size:10px;letter-spacing:.2px;border-bottom:1px solid #dddde6}
|
||||||
|
html .lab strong{color:#383744;font-size:10px;letter-spacing:1px}html .lab select{color:#545364;background:#f8f8fb;border:1px solid #dbdbe5;font-size:10px;border-radius:5px}html .lab button{color:#686577;font-size:10px}html .lab button:hover{background:#e0dfeb}
|
||||||
|
html .app{grid-template-columns:216px minmax(380px,1fr) 328px;height:calc(100dvh - 43px);min-height:710px;background:#fafafd}
|
||||||
|
html .sidebar{background:linear-gradient(165deg,#232430,#1d1e29 70%);padding:26px 17px 20px;border:0;color:#e9e8ef;gap:7px}
|
||||||
|
html .brand{font-size:21px;font-weight:620;letter-spacing:-.7px;padding-bottom:25px}html .brand b{background:linear-gradient(135deg,#ad9bfa,#7266d0);box-shadow:0 0 25px #a596e523;color:#fff;border-radius:9px;transform:rotate(-7deg)}html .brand small{color:#9a97af;font-size:9px;letter-spacing:3px}
|
||||||
|
html .sidebar .nav{color:#aaaaba;padding:11px 13px;font-size:12px;border-radius:9px;transition:background .2s,color .2s,transform .2s}html .sidebar .nav:hover{background:#ffffff0a;color:#fff}html .sidebar .nav.active{background:#3b364f;color:#e1d8ff;box-shadow:inset 2px 0 #ad96f2}
|
||||||
|
html .sidebar #newTask{background:#343440;color:#f1eff8;border:1px solid #494752;margin-bottom:17px;box-shadow:0 3px 10px #00000010}html .sidebar #newTask:hover{background:#454052;transform:translateY(-1px)}html .section-label{font-size:9px;color:#7c7d90;margin:20px 12px 9px;letter-spacing:1.2px}html .tasknav.selected{background:#2d2e3b;box-shadow:none;border:1px solid #3d3c4a;color:#efedf5;padding:13px}html .tasknav small{color:#a4a0b5;margin-top:7px;font-size:10px}html .sidebar .connection{color:#aeacbd;font-size:11px}html .sidebar .connection:hover{background:#ffffff08}html .sidebar .dot{background:#b9a2f5}html .userprofile{border-color:#373642;color:#d5d2df}html .avatar{background:#424052;color:#d9cef4}html .sidebar .subtitle{color:#8b879d;font-size:10px}
|
||||||
|
html .main{background:radial-gradient(ellipse at 70% 0,#f2effb66,transparent 48%),#fdfdff}html .top{height:83px;border-bottom:1px solid #eaeaf1;background:#ffffffb8;padding:0 34px}html .top h1{font-size:15px;letter-spacing:.2px}html .top .subtitle{font-size:10px;color:#8a8998}html .badge{color:#806027;background:#fcf4e5;border:1px solid #f0e2c6;padding:6px 11px;font-size:10px}.badge:before{content:'●';font-size:7px;margin-right:6px}
|
||||||
|
html .chat{padding:29px max(32px,calc((100% - 710px)/2)) 20px;scrollbar-width:thin;scrollbar-color:#dbd9e5 transparent}html .date{font-size:9px;color:#aaa6b7;letter-spacing:.7px;margin-bottom:28px}html .message{font-size:13px;line-height:1.95;margin-bottom:29px}html .bubble{background:#edeaf5;color:#4a445b;border:1px solid #e4dfef;max-width:86%;padding:14px 18px;border-radius:16px 16px 4px 16px}html .byline{font-size:11px;gap:9px;color:#4d465e;margin-bottom:13px}html .agentmark{background:linear-gradient(150deg,#f1eafa,#e3def2);border:1px solid #dfd6ed;width:29px;height:29px;border-radius:9px;font-size:17px;color:#8471b2;box-shadow:0 2px 5px #765ba90a}html .copy{color:#55505f;font-size:13px;line-height:1.95;margin-bottom:16px}
|
||||||
|
html .interaction{border:1px solid #ded8ec;border-radius:15px;box-shadow:0 10px 35px #43306406,0 2px 5px #35273e03;position:relative;background:#fff}html .interaction:before{content:'';display:block;height:3px;background:linear-gradient(90deg,#a593d8,#d9c6e9,#f3e8dd)}html .interaction-body{padding:23px}html .eyebrow{font-size:9px;font-weight:600;letter-spacing:1.35px;color:#9482b0;margin-bottom:12px}html .interaction h2{font-size:17px;font-weight:600;letter-spacing:-.25px;color:#34303e;margin-bottom:12px}html .choices{gap:9px;margin-top:20px}html .choice{border-color:#ece9f1;padding:13px 15px;border-radius:10px;transition:background .18s,border-color .18s,box-shadow .18s,transform .18s;position:relative}html .choice:hover{transform:translateX(3px);background:#faf8fd;border-color:#cec4e2}html .choice.selected{background:#f7f3fd;border-color:#b5a4d1;box-shadow:0 0 0 2px #bba0e30b}html .choice.selected .radio{border-color:#8a73b4}html .choice small{font-size:10px;color:#938b9f;margin-top:3px}html .choice b{font-size:12px;color:#55505d;font-weight:500}.choice .option-key{margin-left:auto;font-size:10px;border:1px solid #ded7e9;padding:0 6px;border-radius:4px;color:#9b8dad;background:#fff;line-height:20px}html .interaction-footer{background:#fbfafd;border-color:#efebf4;padding:14px 23px;font-size:10px}html .interaction-footer button{font-size:11px;border-radius:8px;padding:10px 14px}html button.primary{background:#756291;border-color:#756291;box-shadow:0 3px 7px #79659220;color:#fff;transition:background .2s,box-shadow .2s,transform .2s}html button.primary:hover{background:#635079;box-shadow:0 5px 12px #79659230;transform:translateY(-1px)}html button.primary:active{transform:translateY(1px);box-shadow:none}html button.secondary{border-color:#e5dfed;background:#fff;color:#71627e}html button.secondary:hover{background:#f5f0fa}html button{transition:background .16s,transform .16s,color .16s}html button:focus-visible,html select:focus-visible,html textarea:focus-visible,html input:focus-visible{outline:2px solid #a88aca;outline-offset:3px}
|
||||||
|
html .receipt{border-left-color:#c9bcdb;background:#f8f6fb;padding:10px 13px;border-radius:0 8px 8px 0;color:#8a7d99;font-size:11px}html .progress{background:#eeebf3;height:5px}html .progress i{background:linear-gradient(90deg,#b9a6d5,#8261ab);transition:width 1s cubic-bezier(.2,.7,.2,1)}html .pulse{background:#957aaf}html .runline{color:#796189}html .notice{background:#fcf7ee;color:#947649;border:1px solid #f1e4cd;font-size:11px}
|
||||||
|
html .composer-wrap{padding:8px 28px 18px;background:linear-gradient(0deg,#fdfdff 80%,#fdfdff00)}html .composer{border:1px solid #dcd6e8;border-radius:15px;padding:13px 14px;background:#fff;box-shadow:0 5px 24px #4d30600a,0 1px 4px #3a2a4204;transition:border-color .2s,box-shadow .2s}html .composer:focus-within{border-color:#b8a2ce;box-shadow:0 0 0 3px #c3addb13,0 8px 30px #4d30600b}html .composer textarea{font-size:12px;min-height:53px;max-height:140px;line-height:1.8;color:#5f536c}html .composer textarea::placeholder{color:#aaa1b5}html .composer-bottom select{font-size:10px;color:#8d7a9e;background:#f8f5fa;border-radius:6px}html .composer-bottom button{color:#91809e;font-size:10px}html .composer-bottom .primary{color:#fff;padding:8px 14px}html .composer-hint{font-size:9px;color:#afa7b7}.quick-replies{display:flex;gap:7px;margin-bottom:10px;flex-wrap:wrap}.quick-replies button{font-size:10px;padding:6px 10px;color:#9686a3;background:#f6f3f9;border:1px solid #e9e2ef;border-radius:20px}.quick-replies button:hover{color:#766184;background:#eee6f5}
|
||||||
|
html .right{background:#f8f7fb;border-left:1px solid #e9e5ef;padding:24px 22px;gap:0}html .right-title{font-size:12px;color:#5c506a;margin-bottom:20px}html .tag{background:#ebe5f2;color:#9479ac;font-size:9px;padding:5px 9px}html .right h3{font-size:11px;color:#71607e;margin-bottom:17px}html .right #editPlan{font-size:10px;color:#a58bb6}html .plan li{font-size:11px;padding-bottom:24px;color:#83708f}html .plan li.pending{color:#ada3b7}html .plan li:before{background:#e3dbe9}html .plan small{color:#a899b3;font-size:9px}html .stepnum{border-color:#e2d8eb;color:#ac99bc;width:21px;height:21px}html .plan li.current .stepnum{background:#9b7eb0;box-shadow:0 0 0 4px #a88cb813}html .plan li.done .stepnum{background:#e9dfef;color:#9875af}html .aside-section{border-color:#e7dfed;padding-top:20px;margin-top:8px}html .context-item{font-size:10px;color:#b09abe}html .context-item b{font-size:10px;color:#9682a3;max-width:155px;overflow-wrap:anywhere}html .empty{font-size:11px;color:#b2a4bd}html .right-note{font-size:9px;color:#b5a5bf}.workspace-tabs{display:flex;gap:2px;background:#eeebf3;border-radius:8px;padding:3px;margin-bottom:25px}.workspace-tabs button{flex:1;font-size:10px;padding:7px 4px;color:#9b89a8;border-radius:6px}.workspace-tabs button.active{background:#fff;color:#836591;box-shadow:0 1px 4px #7253840c}
|
||||||
|
.ambient-preview{border:1px solid #e5dced;border-radius:12px;overflow:hidden;background:#fff;margin-bottom:22px}.preview-top{padding:10px 13px;font-size:9px;color:#ac96b9;border-bottom:1px solid #f0eaf4;display:flex;justify-content:space-between}.preview-art{height:125px;background:radial-gradient(ellipse at 15% 85%,#d5c3e8,transparent 55%),radial-gradient(ellipse at 95% 0%,#e4caca,transparent 70%),#f3ebf8;position:relative;overflow:hidden;padding:23px}.preview-art:before,.preview-art:after{content:'';position:absolute;width:100px;height:100px;border:1px solid #ffffffaa;border-radius:50%;right:20px;top:30px;transform:rotate(-25deg) scaleX(1.5)}.preview-art:after{right:55px;top:60px}.preview-art strong{display:block;position:relative;z-index:1;font-size:19px;font-weight:500;line-height:1.4;color:#81678e;letter-spacing:-.6px;max-width:150px}.preview-art small{display:block;margin-top:9px;font-size:8px;letter-spacing:2px;color:#a085ac}.preview-bottom{padding:10px 13px;font-size:9px;color:#a58db1;display:flex;justify-content:space-between}.activity-row{border-left:1px solid #e1d5e9;padding:0 0 23px 17px;font-size:11px;color:#8d749c;position:relative}.activity-row:before{content:'';width:6px;height:6px;border-radius:50%;background:#bea2ce;position:absolute;left:-4px;top:5px}.activity-row small{display:block;color:#b7a3c3;font-size:9px;margin-top:5px}.activity-row:last-child{border-color:transparent}.capabilities{display:flex;gap:6px;flex-wrap:wrap;margin-top:15px}.capabilities span{font-size:9px;border:1px solid #e6ddee;background:#fcfaff;padding:4px 7px;border-radius:5px;color:#a98bb8}.workspace-banner{font-size:9px;color:#b09bbe;letter-spacing:1px;margin-bottom:14px}.workspace-pane[hidden]{display:none}.workspace-pane{min-width:0}.empty-art{border:1px dashed #dacbe5;border-radius:10px;text-align:center;padding:26px 12px;color:#a68db5;font-size:11px}.empty-art b{display:block;font-size:24px;font-weight:400;margin-bottom:12px;color:#c0a6cf}html .artifact{border-color:#e5d9ef;color:#a181b4;box-shadow:0 3px 12px #9871ad08;padding:16px;font-size:11px}html .artifact:hover{transform:translateY(-2px);box-shadow:0 5px 18px #9871ad15}html .artifact small{color:#b69cc6;font-size:9px}
|
||||||
|
html .modal{border:1px solid #e5d6ed;border-radius:18px;color:#6d527c;box-shadow:0 25px 100px #29173830}html .modal::backdrop{background:#241b304a;backdrop-filter:blur(6px)}html .modal-header{border-color:#eee4f4;background:#fcfaff}html .modal-header h2{font-size:17px}html .modal-content{background:#fff}html .modal label{color:#9c7eac;font-size:11px}html .modal input,html .modal select,html .modal textarea{border-color:#e7ddee;color:#876394;font-size:12px;border-radius:9px}html .modal-footer{background:#fcfaff;border-color:#eee4f4}html .modal .tabs .selected{background:#f0e5f7;color:#9a73ad}html .modal .muted{font-size:11px;color:#a18aaa}html .modal .trace{color:#a488b3}html .modal.document-drawer{height:calc(100dvh - 32px);max-height:none;width:min(660px,95vw);max-width:95vw;margin:16px 16px 16px auto;border-radius:16px;display:flex;flex-direction:column}html .modal.document-drawer:not([open]){display:none}html .modal.document-drawer .modal-content{flex:1;max-height:none;overflow:auto;background:#f8f5fb;padding:28px}html .document{background:#fff;border:1px solid #eee5f4;box-shadow:0 5px 24px #6e438c08;padding:32px;font-size:12px;color:#90789c;min-height:100%}html .document h2{font-size:26px;color:#765484;line-height:1.4}html .document h3{font-size:13px;color:#89649c;margin-top:27px}html .document p{line-height:1.95}html .toast{background:#826193;box-shadow:0 6px 25px #65437030;font-size:12px}
|
||||||
|
.state-enter{animation:state-arrival .36s cubic-bezier(.2,.7,.2,1) both}.drawer-enter{animation:drawer-arrival .32s cubic-bezier(.2,.7,.2,1) both}.modal-enter{animation:modal-arrival .22s ease-out both}.choice-pick{animation:choice-pick .22s ease-out}.sent-feedback{animation:send-confirm .5s ease-out}.workspace-change{animation:workspace-reveal .25s ease-out both}@keyframes state-arrival{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@keyframes drawer-arrival{from{opacity:0;transform:translateX(35px)}to{opacity:1;transform:translateX(0)}}@keyframes modal-arrival{from{opacity:.4;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}@keyframes choice-pick{50%{box-shadow:0 0 0 4px #baa0d128}}@keyframes send-confirm{50%{box-shadow:0 0 0 4px #bda1d624}}@keyframes workspace-reveal{from{opacity:.3;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||||||
|
@media(min-width:1600px){html .app{grid-template-columns:235px minmax(400px,1fr) 365px}html .right{padding:26px 28px}html .chat{padding-top:40px}html .interaction-body{padding:27px}html .message{font-size:14px}html .copy{font-size:14px}html .choice b{font-size:13px}}
|
||||||
|
@media(max-width:1200px){html .app{grid-template-columns:180px minmax(350px,1fr) 280px}html .right{padding:24px 18px}html .chat{padding-left:25px;padding-right:25px}html .brand{font-size:18px;padding-left:2px}html .sidebar{padding-left:12px;padding-right:12px}html .top{padding:0 25px}}
|
||||||
|
@media(max-width:1000px){html .app{grid-template-columns:174px minmax(0,1fr)}html .right{display:none}html .plan-toggle{display:block!important}html .app.show-plan .right{display:flex;position:fixed;right:12px;top:110px;bottom:12px;width:310px;border:1px solid #e0d2e9;border-radius:14px;box-shadow:0 15px 70px #47325225;z-index:3}.ambient-preview{flex-shrink:0}}
|
||||||
|
@media(max-width:600px){html .app{grid-template-columns:minmax(0,1fr);height:calc(100dvh - 77px);min-height:650px}html .sidebar{display:none}html .lab{padding:8px 12px;gap:7px}html .lab strong{width:100%;font-size:9px}html .lab>span{display:none}html .chat{padding:22px 18px}html .top{padding:0 19px;height:75px}html .top h1{font-size:13px}html .interaction-body{padding:19px}html .interaction h2{font-size:16px}html .interaction-footer{padding:13px 18px}html .composer-wrap{padding:8px 16px 15px}html .choice b{font-size:12px}html .choice small{font-size:10px}html .quick-replies button{padding:6px 8px;font-size:9px}html .modal.document-drawer{margin:8px;height:calc(100dvh - 16px);max-width:calc(100vw - 16px);width:calc(100vw - 16px)}html .document{padding:23px}html .modal.document-drawer .modal-content{padding:14px}}
|
||||||
|
html .right h3,html .right-title{color:#675672}html .context-item{color:#8b7899}html .context-item b{color:#756080}html .empty,html .empty-art{color:#8e759e}html .plan li.pending{color:#94839f}html .plan small{color:#93809f}html .activity-row{color:#776084}html .activity-row small{color:#94809f}html .workspace-tabs button{color:#897394}html .workspace-banner{color:#92779f}html .preview-top,html .preview-bottom{color:#8b7398}html .quick-replies button{color:#806c8d}html .artifact{color:#846092}html .artifact small{color:#967ba5}html .right-note{color:#93819e}html .composer-hint{color:#928598}html .choice small{color:#82748d}html .modal .muted{color:#887293}html .modal label{color:#856b92}html .document{color:#7b6585}
|
||||||
|
@media(prefers-reduced-motion:reduce){html *,html *:before,html *:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
(()=>{
|
||||||
|
const $=id=>document.getElementById(id);
|
||||||
|
// Load after the base styles so the prototype remains a self-contained enhancement.
|
||||||
|
const stylesheet=document.querySelector('link[href="studio.css"]');if(stylesheet)document.head.appendChild(stylesheet);
|
||||||
|
const right=document.querySelector('.right'), title=right.querySelector('.right-title');
|
||||||
|
const tabs=document.createElement('div');tabs.className='workspace-tabs';tabs.setAttribute('role','tablist');tabs.setAttribute('aria-label','任务空间');tabs.innerHTML=['计划','活动','成果'].map((x,i)=>`<button role="tab" id="workspace-tab-${i}" aria-controls="workspace-pane-${i}" aria-selected="${i===0}" class="${i===0?'active':''}" data-workspace="${i}">${x}</button>`).join('');title.after(tabs);
|
||||||
|
const overview=document.createElement('div');overview.className='workspace-pane';overview.id='workspace-pane-0';overview.setAttribute('role','tabpanel');overview.setAttribute('aria-labelledby','workspace-tab-0');while(tabs.nextSibling)overview.appendChild(tabs.nextSibling);right.appendChild(overview);
|
||||||
|
const activity=document.createElement('div');activity.className='workspace-pane';activity.id='workspace-pane-1';activity.hidden=true;activity.setAttribute('role','tabpanel');activity.setAttribute('aria-labelledby','workspace-tab-1');right.appendChild(activity);
|
||||||
|
const output=document.createElement('div');output.className='workspace-pane';output.id='workspace-pane-2';output.hidden=true;output.setAttribute('role','tabpanel');output.setAttribute('aria-labelledby','workspace-tab-2');right.appendChild(output);
|
||||||
|
const preview=document.createElement('div');preview.className='ambient-preview';preview.innerHTML='<div class="preview-top"><span>工作画布</span><span id="canvasState">等待确认</span></div><div class="preview-art"><strong id="canvasTitle">从想法<br>到有用的成果。</strong><small>REASONIX / WORK IN PROGRESS</small></div><div class="preview-bottom"><span id="canvasKind">研究简报</span><span>持续更新 ↗</span></div>';overview.prepend(preview);
|
||||||
|
const quick=document.createElement('div');quick.className='quick-replies';quick.setAttribute('aria-label','快捷补充');quick.innerHTML=['先给我一个简洁版本','说明你的判断依据','我想调整一下方向'].map(x=>`<button type="button">${x}</button>`).join('');document.querySelector('.composer').before(quick);quick.addEventListener('click',e=>{if(e.target.tagName!=='BUTTON')return;$('input').value=e.target.textContent;$('input').focus()});
|
||||||
|
let lastState=$('jump').value,lastStatus=$('status').textContent,lastScene=$('scene').value,eventLog=[],selectedTab=0,progressTick=0;
|
||||||
|
const labels={ask:'等待你确认任务方向',plan:'执行计划已准备就绪',running:'正在执行当前步骤',approval:'等待本次访问授权',blocked:'发现问题,保留进度',done:'成果已生成,可继续修改'};
|
||||||
|
function selectTab(index){selectedTab=index;[overview,activity,output].forEach((p,i)=>{p.hidden=i!==index;p.classList.remove('workspace-change');if(i===index){void p.offsetWidth;p.classList.add('workspace-change')}});tabs.querySelectorAll('button').forEach((b,i)=>{b.classList.toggle('active',i===index);b.setAttribute('aria-selected',String(i===index))})}
|
||||||
|
tabs.addEventListener('click',e=>{const b=e.target.closest('[data-workspace]');if(b)selectTab(Number(b.dataset.workspace))});tabs.addEventListener('keydown',e=>{if(!['ArrowLeft','ArrowRight','Home','End'].includes(e.key))return;e.preventDefault();const next=e.key==='Home'?0:e.key==='End'?2:(selectedTab+(e.key==='ArrowRight'?1:2))%3;selectTab(next);tabs.children[next].focus()});
|
||||||
|
function safe(text){const el=document.createElement('span');el.textContent=text;return el.innerHTML}
|
||||||
|
function renderActivity(){activity.innerHTML='<div class="workspace-banner">ACTIVITY / 本次任务</div>'+eventLog.slice(-8).map((x,i)=>`<div class="activity-row">${safe(x)}<small>${i===eventLog.length-1?'刚刚更新':'已记录'} · 本地模拟事件</small></div>`).join('')+'<div class="capabilities"><span>模型推理</span><span>工具调用</span><span>文件产出</span></div>'}
|
||||||
|
function sync(animate=false){const state=$('jump').value,scene=$('scene').value,changed=state!==lastState||scene!==lastScene,status=$('status').textContent;if(scene!==lastScene){eventLog=[];progressTick=0}if(changed){eventLog.push(labels[state]);if(animate){const current=$('chat').lastElementChild;if(current)current.classList.add('state-enter')}lastState=state;lastScene=scene}if(status!==lastStatus){if(status==='已暂停')eventLog.push('任务已暂停,进度已保留');lastStatus=status}if(!eventLog.length)eventLog.push(labels[state]);$('canvasState').textContent=status;$('canvasTitle').innerHTML=state==='done'?'想法,<br>已经成为成果。':state==='running'?'正在把线索<br>整理成答案。':'从想法<br>到有用的成果。';$('canvasKind').textContent={research:'研究简报',travel:'旅行行程',code:'页面方案'}[scene];
|
||||||
|
const n={ask:0,plan:1,running:1,approval:2,blocked:2,done:4}[state];$('planCount').textContent=n+' / 4';$('plan').querySelectorAll('li').forEach((li,i)=>{li.className=i<n?'done':i===n?'current':'pending';li.querySelector('.stepnum').textContent=i<n?'✓':i+1;const old=li.querySelector('small');if(old)old.remove();if(i===n){const sm=document.createElement('small');sm.textContent=status;li.lastElementChild.appendChild(sm)}});
|
||||||
|
$('chat').querySelectorAll('.choice').forEach((el,i)=>{if(!el.querySelector('.option-key')){const key=document.createElement('span');key.className='option-key';key.textContent=String.fromCharCode(65+i);el.appendChild(key)}});
|
||||||
|
output.innerHTML=state==='done'?'<div class="workspace-banner">DELIVERABLE / 可继续迭代</div>'+ $('artifacts').innerHTML:'<div class="empty-art"><b>▤</b>下一份成果,从这段对话开始。<div style="font-size:10px;margin-top:10px;line-height:1.8">文件和预览会出现在这里,<br>随时打开,继续完善。</div></div>';renderActivity();}
|
||||||
|
const observer=new MutationObserver(()=>sync(true));observer.observe($('chat'),{childList:true});observer.observe($('status'),{childList:true});
|
||||||
|
document.addEventListener('studio-progress',e=>{progressTick=e.detail.tick;eventLog.push(['','已整理任务相关信息','正在交叉检查内容','正在汇总当前阶段结果'][progressTick]);renderActivity()});
|
||||||
|
document.addEventListener('click',e=>{const picked=e.target.closest('[data-choice]');if(picked){requestAnimationFrame(()=>{const active=$('chat').querySelector('.choice.selected');if(active)active.classList.add('choice-pick')})}});
|
||||||
|
$('composer').addEventListener('submit',()=>{const composer=$('composer');composer.classList.remove('sent-feedback');requestAnimationFrame(()=>composer.classList.add('sent-feedback'));$('input').style.height='auto'});$('input').addEventListener('input',()=>{$('input').style.height='auto';$('input').style.height=Math.min(140,$('input').scrollHeight)+'px'});
|
||||||
|
const modal=$('dialog');new MutationObserver(records=>{if(!records.some(x=>x.attributeName==='open')||!modal.open)return;const isDocument=!!modal.querySelector('.document');modal.classList.toggle('document-drawer',isDocument);modal.classList.remove('drawer-enter','modal-enter');void modal.offsetWidth;modal.classList.add(isDocument?'drawer-enter':'modal-enter')}).observe(modal,{attributes:true,attributeFilter:['open']});
|
||||||
|
const artifactNav=$('artifactsNav');artifactNav.onclick=()=>{if($('jump').value==='done'){$('artifacts').querySelector('button').click()}else{selectTab(2);$('app').classList.add('show-plan')}};
|
||||||
|
document.addEventListener('keydown',e=>{if(e.key==='Escape')$('app').classList.remove('show-plan')});
|
||||||
|
for(const id of ['restart','newTask'])$(id).addEventListener('click',()=>{eventLog=[];progressTick=0;selectTab(0);sync()});
|
||||||
|
sync();
|
||||||
|
})();
|
||||||
Reference in new issue
Block a user