@steerable/agent-shell-web 0.6.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +91 -0
- package/README.md +20 -0
- package/package.json +69 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/src/AppShell.tsx +24 -0
- package/src/assets/logo-generic.png +0 -0
- package/src/brand.test.ts +22 -0
- package/src/brand.ts +48 -0
- package/src/components/AgentSidebar.test.tsx +127 -0
- package/src/components/AgentSidebar.tsx +1010 -0
- package/src/components/ChatHeader.test.tsx +230 -0
- package/src/components/ChatHeader.tsx +436 -0
- package/src/components/DockPanelHeader.tsx +69 -0
- package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
- package/src/components/LocalLlmSettingsModal.tsx +72 -0
- package/src/components/TerminalPanel.tsx +68 -0
- package/src/components/TerminalView.tsx +228 -0
- package/src/components/chat/ApprovalModal.test.tsx +83 -0
- package/src/components/chat/ApprovalModal.tsx +229 -0
- package/src/components/chat/AskUserModalHost.test.tsx +176 -0
- package/src/components/chat/AskUserModalHost.tsx +61 -0
- package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
- package/src/components/chat/AssistantMessage.test.tsx +68 -0
- package/src/components/chat/AssistantMessage.tsx +469 -0
- package/src/components/chat/ChatInput.test.tsx +609 -0
- package/src/components/chat/ChatInput.tsx +2068 -0
- package/src/components/chat/ChatProjectBadge.tsx +362 -0
- package/src/components/chat/EmptyChat.tsx +18 -0
- package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
- package/src/components/chat/ExecPolicyPicker.tsx +120 -0
- package/src/components/chat/ExecutedActionsCard.tsx +216 -0
- package/src/components/chat/InterruptedTurnCard.tsx +53 -0
- package/src/components/chat/LocalChatPanel.tsx +408 -0
- package/src/components/chat/Markdown.test.tsx +170 -0
- package/src/components/chat/Markdown.tsx +432 -0
- package/src/components/chat/MessageList.tsx +332 -0
- package/src/components/chat/ModelPicker.test.tsx +149 -0
- package/src/components/chat/ModelPicker.tsx +289 -0
- package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
- package/src/components/chat/PlanTodoList.tsx +121 -0
- package/src/components/chat/ProjectTrustBanner.tsx +91 -0
- package/src/components/chat/SessionTreeModal.test.tsx +161 -0
- package/src/components/chat/SessionTreeModal.tsx +221 -0
- package/src/components/chat/StreamingStatus.tsx +95 -0
- package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
- package/src/components/chat/TaskOutcomeCards.tsx +128 -0
- package/src/components/chat/TaskPanelModal.test.tsx +198 -0
- package/src/components/chat/TaskPanelModal.tsx +310 -0
- package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
- package/src/components/chat/TaskProcessPanel.tsx +208 -0
- package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
- package/src/components/chat/TurnProcessGroup.tsx +193 -0
- package/src/components/chat/UnifiedDiff.tsx +31 -0
- package/src/components/chat/UserMessage.tsx +155 -0
- package/src/components/chat/elapsed.test.ts +42 -0
- package/src/components/chat/elapsed.ts +45 -0
- package/src/components/chat/executed-actions-model.test.ts +134 -0
- package/src/components/chat/executed-actions-model.ts +143 -0
- package/src/components/chat/orchestration-children-model.test.ts +115 -0
- package/src/components/chat/orchestration-children-model.ts +104 -0
- package/src/components/chat/timestamp.ts +24 -0
- package/src/components/chat/turn-timeline.test.ts +97 -0
- package/src/components/chat/turn-timeline.ts +132 -0
- package/src/components/chat/useChatTasks.test.ts +136 -0
- package/src/components/chat/useChatTasks.ts +134 -0
- package/src/components/chat/useCopy.ts +62 -0
- package/src/components/settings/AgentCapabilityFields.tsx +343 -0
- package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
- package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
- package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
- package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
- package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
- package/src/components/settings/LlmSettingsPanel.tsx +875 -0
- package/src/components/settings/McpSettingsPanel.tsx +466 -0
- package/src/components/settings/ModelCapabilityChips.tsx +46 -0
- package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
- package/src/components/settings/ModelIdCombobox.tsx +185 -0
- package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
- package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
- package/src/components/settings/SettingsSaveButton.tsx +35 -0
- package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
- package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
- package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
- package/src/components/settings/UsagePanel.tsx +196 -0
- package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
- package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
- package/src/components/settings/compat-flags-model.test.ts +71 -0
- package/src/components/settings/compat-flags-model.ts +57 -0
- package/src/components/settings/llm-vendors.test.ts +171 -0
- package/src/components/settings/llm-vendors.ts +275 -0
- package/src/components/settings/model-capabilities.test.ts +88 -0
- package/src/components/settings/model-capabilities.ts +123 -0
- package/src/components/settings/preset-choice-model.test.ts +96 -0
- package/src/components/settings/preset-choice-model.ts +75 -0
- package/src/fixtures/browser-dev-data.json +315 -0
- package/src/hooks/useChatsAndAgents.ts +182 -0
- package/src/layouts/AgentLayout.tsx +574 -0
- package/src/lib/attachments.ts +78 -0
- package/src/lib/browser-dev-electron-mock.ts +640 -0
- package/src/lib/chat-title.ts +33 -0
- package/src/lib/chat-transport.ts +346 -0
- package/src/lib/date-groups.ts +34 -0
- package/src/lib/demo-flag.ts +15 -0
- package/src/lib/electron-bridge.ts +270 -0
- package/src/lib/exec-policy.ts +25 -0
- package/src/lib/http-bridge.ts +293 -0
- package/src/lib/insights.ts +17 -0
- package/src/lib/local-api.ts +714 -0
- package/src/lib/pending-first-message.test.ts +17 -0
- package/src/lib/pending-first-message.ts +36 -0
- package/src/lib/slash-sources.ts +191 -0
- package/src/main.tsx +103 -0
- package/src/packs/registry.ts +64 -0
- package/src/pages/AgentPage.tsx +1226 -0
- package/src/pages/ChatPanelPreviewPage.tsx +264 -0
- package/src/pages/SettingsPage.test.tsx +102 -0
- package/src/pages/SettingsPage.tsx +197 -0
- package/src/styles/index.css +156 -0
- package/src/vite-env.d.ts +1 -0
- package/vite.base.ts +94 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { LuCircleCheck, LuCircleX } from 'react-icons/lu';
|
|
3
|
+
import { ToolExecutionCard } from '@steerable/agent-ui/cards';
|
|
4
|
+
import type { ToolExecutionPayload } from '@steerable/agent-protocol';
|
|
5
|
+
import { UnifiedDiff } from './UnifiedDiff';
|
|
6
|
+
import { summarizeRunCodeAction, summarizeWebAction, expandRunCodeActions } from './executed-actions-model';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ExecutedActionsCard — visual surface for the `executed_actions` SSE event
|
|
10
|
+
* emitted by local-backend after each tool-call round.
|
|
11
|
+
*
|
|
12
|
+
* Per `src/local-backend/router.ts` each entry is:
|
|
13
|
+
* { tool, mode, policy, shellClassification, arguments, result }
|
|
14
|
+
*
|
|
15
|
+
* The list rendering is delegated to `@steerable/agent-ui/cards`'
|
|
16
|
+
* `ToolExecutionCard`; this file is the thin local adapter that:
|
|
17
|
+
* 1. Maps the local-backend `ExecutedAction` shape to the framework's
|
|
18
|
+
* canonical `ToolExecutionPayload` (id / name / status / args / output).
|
|
19
|
+
* 2. Adds the "已自动执行 N 个操作" summary banner above the list — the
|
|
20
|
+
* framework intentionally leaves grouping policy to the host app since
|
|
21
|
+
* different products count successes / failures differently.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface ExecutedAction {
|
|
25
|
+
/** Sidecar tool-call id; used to update a running row when the result lands. */
|
|
26
|
+
id?: string;
|
|
27
|
+
tool: string;
|
|
28
|
+
mode?: string;
|
|
29
|
+
policy?: unknown;
|
|
30
|
+
shellClassification?: string;
|
|
31
|
+
arguments?: unknown;
|
|
32
|
+
result?: unknown;
|
|
33
|
+
/**
|
|
34
|
+
* W4-2: per-exec sandbox marker (`data._sandbox` lifted by the CoreLoop).
|
|
35
|
+
* enforcement: full = OS deny-by-default; partial = documented gap (e.g.
|
|
36
|
+
* port-only egress); none = no backend on this platform.
|
|
37
|
+
*/
|
|
38
|
+
sandbox?: { backend?: string; enforcement: string };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ExecutedActionsCardProps {
|
|
42
|
+
actions: ExecutedAction[];
|
|
43
|
+
/** Drop the outer card chrome when nested in a turn-process group. */
|
|
44
|
+
compact?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function deriveStatus(action: ExecutedAction): ToolExecutionPayload['status'] {
|
|
48
|
+
const result = action.result;
|
|
49
|
+
if (result === undefined || result === null) return 'running';
|
|
50
|
+
if (typeof result !== 'object') return 'succeeded';
|
|
51
|
+
if ('success' in result) {
|
|
52
|
+
return (result as { success?: unknown }).success ? 'succeeded' : 'failed';
|
|
53
|
+
}
|
|
54
|
+
if ('error' in result) return 'failed';
|
|
55
|
+
return 'succeeded';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function summarizeArguments(args: unknown): string | null {
|
|
59
|
+
if (args === null || args === undefined) return null;
|
|
60
|
+
if (typeof args === 'string') return args.length > 60 ? args.slice(0, 57) + '…' : args;
|
|
61
|
+
if (typeof args !== 'object') return String(args);
|
|
62
|
+
const obj = args as Record<string, unknown>;
|
|
63
|
+
for (const key of ['command', 'cmd', 'query', 'path', 'file', 'target', 'message']) {
|
|
64
|
+
const v = obj[key];
|
|
65
|
+
if (typeof v === 'string' && v.length > 0) {
|
|
66
|
+
return v.length > 60 ? v.slice(0, 57) + '…' : v;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sandboxBadge(sandbox: ExecutedAction['sandbox']): string | null {
|
|
73
|
+
if (!sandbox) return null;
|
|
74
|
+
switch (sandbox.enforcement) {
|
|
75
|
+
case 'full':
|
|
76
|
+
return '[沙箱]';
|
|
77
|
+
case 'partial':
|
|
78
|
+
return '[沙箱·部分]';
|
|
79
|
+
default:
|
|
80
|
+
return '[未沙箱]';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function actionToTool(action: ExecutedAction, idx: number): ToolExecutionPayload {
|
|
85
|
+
const status = deriveStatus(action);
|
|
86
|
+
const errorText =
|
|
87
|
+
status === 'failed' && action.result && typeof action.result === 'object'
|
|
88
|
+
? typeof (action.result as { error?: unknown }).error === 'string'
|
|
89
|
+
? ((action.result as { error: string }).error)
|
|
90
|
+
: null
|
|
91
|
+
: null;
|
|
92
|
+
// W5-2: web_search/web_fetch 用结构化中文摘要(查询词/URL + 结果计数/
|
|
93
|
+
// 状态码),其余工具走通用参数摘要。
|
|
94
|
+
const summary =
|
|
95
|
+
summarizeRunCodeAction(action.tool, action.arguments, action.result) ??
|
|
96
|
+
summarizeWebAction(action.tool, action.arguments, action.result) ??
|
|
97
|
+
summarizeArguments(action.arguments);
|
|
98
|
+
const badge = sandboxBadge(action.sandbox);
|
|
99
|
+
return {
|
|
100
|
+
id: action.id ?? `${action.tool}-${idx}`,
|
|
101
|
+
name: action.tool,
|
|
102
|
+
status,
|
|
103
|
+
summary: badge ? `${badge} ${summary ?? ''}`.trim() : summary,
|
|
104
|
+
args: action.arguments,
|
|
105
|
+
output: action.result,
|
|
106
|
+
error: errorText,
|
|
107
|
+
durationMs: null,
|
|
108
|
+
icon: null,
|
|
109
|
+
expandable: true,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* renderOutput slot for ToolExecutionCard: when a tool result carries a
|
|
115
|
+
* unified `diff` (local_edit_file), render it as a coloured diff; otherwise
|
|
116
|
+
* fall back to the default JSON/text view.
|
|
117
|
+
*/
|
|
118
|
+
function renderActionOutput(output: unknown): ReactNode {
|
|
119
|
+
if (output && typeof output === 'object') {
|
|
120
|
+
const obj = output as Record<string, unknown>;
|
|
121
|
+
if (typeof obj.diff === 'string' && obj.diff.length > 0) {
|
|
122
|
+
const { diff, ...rest } = obj;
|
|
123
|
+
const hasRest = Object.keys(rest).some(
|
|
124
|
+
(k) => k !== 'success' && rest[k] !== undefined && rest[k] !== null,
|
|
125
|
+
);
|
|
126
|
+
return (
|
|
127
|
+
<div className="space-y-2">
|
|
128
|
+
<UnifiedDiff diff={diff} />
|
|
129
|
+
{hasRest && (
|
|
130
|
+
<pre className="whitespace-pre-wrap break-words rounded bg-agent-muted/40 px-2 py-1.5 text-[11px] text-agent-foreground">
|
|
131
|
+
{JSON.stringify(rest, null, 2)}
|
|
132
|
+
</pre>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (output === undefined || output === null) return null;
|
|
139
|
+
const text = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
|
|
140
|
+
return (
|
|
141
|
+
<pre className="whitespace-pre-wrap break-words rounded bg-agent-muted/40 px-2 py-1.5 text-[11px] text-agent-foreground">
|
|
142
|
+
{text}
|
|
143
|
+
</pre>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Inline tool rows for a mixed think→act timeline (no summary banner). */
|
|
148
|
+
export function ToolsFlow({ actions, compact = false }: ExecutedActionsCardProps) {
|
|
149
|
+
if (!actions || actions.length === 0) return null;
|
|
150
|
+
const expanded = expandRunCodeActions(actions);
|
|
151
|
+
return (
|
|
152
|
+
<div
|
|
153
|
+
className={
|
|
154
|
+
compact
|
|
155
|
+
? 'overflow-hidden rounded-agent-md border border-agent-border/80 bg-agent-canvas'
|
|
156
|
+
: 'my-2 overflow-hidden rounded-agent-md border border-agent-border bg-agent-canvas'
|
|
157
|
+
}
|
|
158
|
+
>
|
|
159
|
+
<div className="space-y-px">
|
|
160
|
+
{expanded.map((action, i) => (
|
|
161
|
+
<ToolExecutionCard
|
|
162
|
+
key={action.id ?? `${action.tool}-${i}`}
|
|
163
|
+
payload={actionToTool(action, i)}
|
|
164
|
+
renderOutput={renderActionOutput}
|
|
165
|
+
className="rounded-none border-0 border-t border-agent-border first:border-t-0"
|
|
166
|
+
/>
|
|
167
|
+
))}
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function ExecutedActionsCard({ actions }: ExecutedActionsCardProps) {
|
|
174
|
+
if (!actions || actions.length === 0) return null;
|
|
175
|
+
|
|
176
|
+
const expanded = expandRunCodeActions(actions);
|
|
177
|
+
const successCount = expanded.filter((a) => deriveStatus(a) === 'succeeded').length;
|
|
178
|
+
const failureCount = expanded.filter((a) => deriveStatus(a) === 'failed').length;
|
|
179
|
+
const runningCount = expanded.filter((a) => deriveStatus(a) === 'running').length;
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className="my-2 overflow-hidden rounded-agent-md border border-agent-border bg-agent-canvas">
|
|
183
|
+
<div className="flex items-center justify-between border-b border-agent-border bg-agent-muted/30 px-3 py-1.5">
|
|
184
|
+
<div className="flex items-center gap-1.5 text-xs">
|
|
185
|
+
{failureCount === 0 ? (
|
|
186
|
+
<LuCircleCheck className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
|
|
187
|
+
) : (
|
|
188
|
+
<LuCircleX className="h-3.5 w-3.5 text-agent-destructive" />
|
|
189
|
+
)}
|
|
190
|
+
<span className="font-medium text-agent-foreground">
|
|
191
|
+
已自动执行 {expanded.length} 个操作
|
|
192
|
+
</span>
|
|
193
|
+
</div>
|
|
194
|
+
<span className="text-[11px] text-agent-muted-foreground">
|
|
195
|
+
{runningCount > 0
|
|
196
|
+
? `${runningCount} 执行中`
|
|
197
|
+
: failureCount > 0
|
|
198
|
+
? `${successCount} 成功 · ${failureCount} 失败`
|
|
199
|
+
: `全部成功`}
|
|
200
|
+
</span>
|
|
201
|
+
</div>
|
|
202
|
+
<div className="space-y-px">
|
|
203
|
+
{expanded.map((action, i) => (
|
|
204
|
+
<ToolExecutionCard
|
|
205
|
+
key={action.id ?? `${action.tool}-${i}`}
|
|
206
|
+
payload={actionToTool(action, i)}
|
|
207
|
+
renderOutput={renderActionOutput}
|
|
208
|
+
className="rounded-none border-0 border-t border-agent-border first:border-t-0"
|
|
209
|
+
/>
|
|
210
|
+
))}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export default ExecutedActionsCard;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W7-1 崩溃恢复入口卡片。
|
|
3
|
+
*
|
|
4
|
+
* 上一轮回复因应用退出/异常中断而没有完成时(local-backend 在 messages
|
|
5
|
+
* 响应里下发 `interrupted: true`——签名是 settings_kv 里残留的
|
|
6
|
+
* turn_active 标记,而非文案截断的启发式),在消息列表尾部展示本卡片:
|
|
7
|
+
* 「继续上次回复」走后端的 resume 通道(sidecar 回放 durable record 的
|
|
8
|
+
* 投影作为循环种子,用户无需重发请求);「忽略」仅在本次挂载内隐藏卡片,
|
|
9
|
+
* 不写库——下次打开会话若标记仍在会再次提示。
|
|
10
|
+
*
|
|
11
|
+
* 用户主动停止(completionStatus: cancelled)与失败(failed)都是活进程
|
|
12
|
+
* 写下的终态,不会进入本卡片:前者是用户的明确意图,后者已有错误展示。
|
|
13
|
+
*/
|
|
14
|
+
import { LuTriangleAlert } from 'react-icons/lu';
|
|
15
|
+
|
|
16
|
+
export interface InterruptedTurnCardProps {
|
|
17
|
+
/** 点击「继续上次回复」——触发 resume 流(不再追加用户消息)。 */
|
|
18
|
+
onContinue: () => void;
|
|
19
|
+
/** 点击「忽略」——本次挂载内隐藏卡片(不落库)。 */
|
|
20
|
+
onDismiss: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function InterruptedTurnCard({ onContinue, onDismiss }: InterruptedTurnCardProps) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
role="status"
|
|
27
|
+
className="mt-1.5 flex items-center justify-between gap-3 rounded-agent-md border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs"
|
|
28
|
+
>
|
|
29
|
+
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-300">
|
|
30
|
+
<LuTriangleAlert className="h-4 w-4 shrink-0" />
|
|
31
|
+
<span>上次回复因应用退出或异常中断,未能完成。</span>
|
|
32
|
+
</div>
|
|
33
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
onClick={onDismiss}
|
|
37
|
+
className="rounded px-1.5 py-0.5 text-amber-700/80 transition-colors hover:text-amber-900 dark:text-amber-300/80 dark:hover:text-amber-100"
|
|
38
|
+
>
|
|
39
|
+
忽略
|
|
40
|
+
</button>
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
onClick={onContinue}
|
|
44
|
+
className="inline-flex items-center gap-1 rounded-full bg-agent-foreground px-3 py-1 font-medium text-agent-canvas transition hover:opacity-90"
|
|
45
|
+
>
|
|
46
|
+
继续上次回复
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default InterruptedTurnCard;
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import type { ChatMessage } from '@steerable/agent-protocol';
|
|
3
|
+
import { ChatPanel } from '@steerable/agent-ui';
|
|
4
|
+
import type { SteerOutcome } from '@steerable/agent-ui';
|
|
5
|
+
import type { LocalChat, LocalChatAgent, LocalTask } from '@/lib/local-api';
|
|
6
|
+
import {
|
|
7
|
+
ChatInput,
|
|
8
|
+
type ChatInputHandle,
|
|
9
|
+
type ChatMode,
|
|
10
|
+
type ExecPolicy,
|
|
11
|
+
type MentionReference,
|
|
12
|
+
} from './ChatInput';
|
|
13
|
+
import { EmptyChat } from './EmptyChat';
|
|
14
|
+
import { MessageList } from './MessageList';
|
|
15
|
+
import type { ExecutedAction } from './ExecutedActionsCard';
|
|
16
|
+
import type { ChildInfo } from './OrchestrationChildrenCard';
|
|
17
|
+
import type { TurnBlock } from './turn-timeline';
|
|
18
|
+
import {
|
|
19
|
+
isImageFile,
|
|
20
|
+
saveChatAttachments,
|
|
21
|
+
type AttachmentFile,
|
|
22
|
+
} from '@/lib/attachments';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* LocalChatPanel — the product chat shell.
|
|
26
|
+
*
|
|
27
|
+
* History:
|
|
28
|
+
* - Pre-0.3 of `@steerable/agent-ui`, the framework's `ChatPanel` was a
|
|
29
|
+
* barebones monolith with no slots — so this file shipped a complete
|
|
30
|
+
* parallel implementation.
|
|
31
|
+
* - 0.3 introduced the compound API (`ChatPanel.Root/.Header/.Messages/.
|
|
32
|
+
* Input/.Empty`). This file is now a thin facade that plugs in the local
|
|
33
|
+
* rich renderers (`MessageList` with agent badges + executed-actions
|
|
34
|
+
* cards, `ChatInput` with the meta-row agent picker + settings gear)
|
|
35
|
+
* into the framework's structural shell.
|
|
36
|
+
*
|
|
37
|
+
* Props mirror the framework's `ChatPanelProps` so the swap in `AgentPage`
|
|
38
|
+
* remained a single import change. Two extras are accepted:
|
|
39
|
+
* - `agents` — agent catalog for the assistant bubble badge.
|
|
40
|
+
* - `currentAgent` — chat-level agent, used as the badge fallback while
|
|
41
|
+
* the in-flight assistant turn hasn't flushed its `agentId` to the DB.
|
|
42
|
+
*
|
|
43
|
+
* Input value is lifted here (not inside `ChatInput`) so the empty-state
|
|
44
|
+
* `EmptyChat` can call back into us to set the textarea contents — same UX
|
|
45
|
+
* as the cloud product's `setAndFocusInputMessage`.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export interface LocalChatPanelProps {
|
|
49
|
+
messages: ChatMessage[];
|
|
50
|
+
isStreaming?: boolean;
|
|
51
|
+
onSubmit: (input: { content: string; metadata?: Record<string, unknown> }) => void | Promise<void>;
|
|
52
|
+
onCancel?: () => void;
|
|
53
|
+
/** 轮中转向(streaming 期间 Enter):注入失败由 hook 兜底为排队/直发,见 ChatInput。 */
|
|
54
|
+
onSteer?: (text: string) => Promise<SteerOutcome>;
|
|
55
|
+
/** W6-2 follow-up 排队:streaming 期间 ⌘/Ctrl+Enter 把文本排入待发队列。 */
|
|
56
|
+
onFollowUp?: (text: string) => void;
|
|
57
|
+
/** 排队待发的 follow-up 文本列表。 */
|
|
58
|
+
pendingFollowUps?: string[];
|
|
59
|
+
/** 撤回第 N 条排队中的 follow-up。 */
|
|
60
|
+
onRemoveFollowUp?: (index: number) => void;
|
|
61
|
+
className?: string;
|
|
62
|
+
inputPlaceholder?: string;
|
|
63
|
+
/**
|
|
64
|
+
* 当前会话 id。提供后,提交时上传的附件会被持久化到会话空间
|
|
65
|
+
* (`saveChatAttachments`),消息正文与图像元数据用落盘路径。落地页
|
|
66
|
+
* (尚无 chatId)可省略,由调用方在创建会话后自行持久化。
|
|
67
|
+
*/
|
|
68
|
+
chatId?: string | null;
|
|
69
|
+
/** Slot rendered above the message list (header / agent picker / …). */
|
|
70
|
+
header?: ReactNode;
|
|
71
|
+
/**
|
|
72
|
+
* 空会话 hero 布局:提供后,当 messages 为空且未在流式时,整个面板渲染为
|
|
73
|
+
* 居中首屏(品牌标题 + 居中输入框,无 header)——与 /agent 落地页
|
|
74
|
+
* (EmptyChatGate)同款视觉。首条消息落地后自动切回常规布局。
|
|
75
|
+
*/
|
|
76
|
+
emptyHero?: { title: string; subtitle?: string };
|
|
77
|
+
/** Override the default `EmptyChat` empty-state. */
|
|
78
|
+
emptyState?: ReactNode;
|
|
79
|
+
/** Disable the input (e.g. during initial chat-history load). */
|
|
80
|
+
disabled?: boolean;
|
|
81
|
+
|
|
82
|
+
agents: LocalChatAgent[];
|
|
83
|
+
chats?: LocalChat[];
|
|
84
|
+
currentAgent: LocalChatAgent | null;
|
|
85
|
+
selectedAgentId?: string | null;
|
|
86
|
+
onSelectAgent?: (agentId: string) => void | Promise<void>;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Tool calls keyed by persisted assistant message id. Forwarded straight
|
|
90
|
+
* to `MessageList`. See its prop docs for the in-flight vs historical
|
|
91
|
+
* split.
|
|
92
|
+
*/
|
|
93
|
+
executedActionsByMessageId?: Record<string, ExecutedAction[]>;
|
|
94
|
+
/** In-flight tool calls — rendered under the latest assistant bubble. */
|
|
95
|
+
currentTurnActions?: ExecutedAction[];
|
|
96
|
+
timelineByMessageId?: Record<string, TurnBlock[]>;
|
|
97
|
+
currentTurnTimeline?: TurnBlock[];
|
|
98
|
+
currentTurnStartedAtMs?: number;
|
|
99
|
+
durationByMessageId?: Record<string, number>;
|
|
100
|
+
/** P3.1: in-flight child agents + per-message reconciliation map. */
|
|
101
|
+
currentTurnChildren?: ChildInfo[];
|
|
102
|
+
orchestrationChildrenByMessageId?: Record<string, ChildInfo[]>;
|
|
103
|
+
/** Current round number for the in-flight turn (1-based). */
|
|
104
|
+
currentRound?: number;
|
|
105
|
+
/**
|
|
106
|
+
* Settings gear callback for the input toolbar. Receiving this enables
|
|
107
|
+
* the gear button on `ChatInput`; AgentPage wires it to opening
|
|
108
|
+
* `LocalLlmSettingsModal`. Optional so the panel still works in places
|
|
109
|
+
* (preview harness, unit tests) without modal infra.
|
|
110
|
+
*/
|
|
111
|
+
onOpenSettings?: () => void;
|
|
112
|
+
/** Extra controls on the input's left toolbar (AgentPage mounts the
|
|
113
|
+
* model/effort picker here). Forwarded to ChatInput's `toolbarExtras`. */
|
|
114
|
+
inputToolbarExtras?: ReactNode;
|
|
115
|
+
/** Slot in the composer meta row above the input box, before the agent
|
|
116
|
+
* picker (AgentPage mounts the project badge here). */
|
|
117
|
+
inputLeadingChrome?: ReactNode;
|
|
118
|
+
|
|
119
|
+
/** Current chat mode (Agent / Plan). When unset, the toggle is hidden. */
|
|
120
|
+
mode?: ChatMode;
|
|
121
|
+
/** Called when the user switches modes. */
|
|
122
|
+
onModeChange?: (mode: ChatMode) => void;
|
|
123
|
+
/** 命令沙箱档。未传 onExecPolicyChange 时不显示选择器。 */
|
|
124
|
+
execPolicy?: ExecPolicy;
|
|
125
|
+
onExecPolicyChange?: (policy: ExecPolicy) => void;
|
|
126
|
+
/** Slot rendered just above the input box (e.g. "执行此计划" 操作条). */
|
|
127
|
+
inputBanner?: ReactNode;
|
|
128
|
+
/** W1.2.1: regenerate an assistant turn (fork-preserving). */
|
|
129
|
+
onRegenerate?: (messageId: string) => Promise<void>;
|
|
130
|
+
/** W7-1: 上一轮被中断(崩溃/强杀)——消息列表尾部展示「继续上次回复」卡片。 */
|
|
131
|
+
interrupted?: boolean;
|
|
132
|
+
/** W7-1: 点击「继续上次回复」。 */
|
|
133
|
+
onContinueInterrupted?: () => void;
|
|
134
|
+
/** W7-1: 点击「忽略」(本次挂载内隐藏,不落库)。 */
|
|
135
|
+
onDismissInterrupted?: () => void;
|
|
136
|
+
/** 本次挂载期间跑完的后台任务——消息列尾部的终态通知卡。 */
|
|
137
|
+
finishedTasks?: LocalTask[];
|
|
138
|
+
/** 终态卡「查看过程」:在右侧栏打开该任务的推理过程。 */
|
|
139
|
+
onInspectTask?: (task: LocalTask) => void;
|
|
140
|
+
/** 终态卡「忽略」(本次挂载内隐藏,不落库)。 */
|
|
141
|
+
onDismissFinishedTask?: (taskId: string) => void;
|
|
142
|
+
/** 分享当前对话(截图)。落到最近一条助手消息的时间戳行。 */
|
|
143
|
+
onShare?: () => Promise<boolean>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function LocalChatPanel({
|
|
147
|
+
messages,
|
|
148
|
+
isStreaming = false,
|
|
149
|
+
onSubmit,
|
|
150
|
+
onCancel,
|
|
151
|
+
className,
|
|
152
|
+
inputPlaceholder,
|
|
153
|
+
chatId,
|
|
154
|
+
header,
|
|
155
|
+
emptyHero,
|
|
156
|
+
emptyState,
|
|
157
|
+
disabled = false,
|
|
158
|
+
agents,
|
|
159
|
+
chats = [],
|
|
160
|
+
currentAgent,
|
|
161
|
+
selectedAgentId,
|
|
162
|
+
onSelectAgent,
|
|
163
|
+
executedActionsByMessageId,
|
|
164
|
+
currentTurnActions,
|
|
165
|
+
timelineByMessageId,
|
|
166
|
+
currentTurnTimeline,
|
|
167
|
+
currentTurnStartedAtMs,
|
|
168
|
+
durationByMessageId,
|
|
169
|
+
currentTurnChildren,
|
|
170
|
+
orchestrationChildrenByMessageId,
|
|
171
|
+
currentRound,
|
|
172
|
+
onOpenSettings,
|
|
173
|
+
inputToolbarExtras,
|
|
174
|
+
inputLeadingChrome,
|
|
175
|
+
mode,
|
|
176
|
+
onModeChange,
|
|
177
|
+
execPolicy,
|
|
178
|
+
onExecPolicyChange,
|
|
179
|
+
inputBanner,
|
|
180
|
+
onSteer,
|
|
181
|
+
onFollowUp,
|
|
182
|
+
pendingFollowUps,
|
|
183
|
+
onRemoveFollowUp,
|
|
184
|
+
onRegenerate,
|
|
185
|
+
interrupted,
|
|
186
|
+
onContinueInterrupted,
|
|
187
|
+
onDismissInterrupted,
|
|
188
|
+
finishedTasks,
|
|
189
|
+
onInspectTask,
|
|
190
|
+
onDismissFinishedTask,
|
|
191
|
+
onShare,
|
|
192
|
+
}: LocalChatPanelProps) {
|
|
193
|
+
const [inputValue, setInputValue] = useState('');
|
|
194
|
+
const [files, setFiles] = useState<AttachmentFile[]>([]);
|
|
195
|
+
const [mentionReferences, setMentionReferences] = useState<MentionReference[]>([]);
|
|
196
|
+
const inputRef = useRef<ChatInputHandle>(null);
|
|
197
|
+
|
|
198
|
+
const handleSelectPrompt = useCallback((prompt: string) => {
|
|
199
|
+
setInputValue(prompt);
|
|
200
|
+
// Wait for the textarea to receive the new value before pulling focus,
|
|
201
|
+
// otherwise `focusAtEnd` would land on an empty buffer.
|
|
202
|
+
requestAnimationFrame(() => {
|
|
203
|
+
inputRef.current?.focusAtEnd();
|
|
204
|
+
});
|
|
205
|
+
}, []);
|
|
206
|
+
|
|
207
|
+
const handleSubmit = useCallback(async () => {
|
|
208
|
+
let trimmed = inputValue.trim();
|
|
209
|
+
if (!trimmed && files.length === 0) return;
|
|
210
|
+
|
|
211
|
+
// 把上传的文件持久化到会话空间:成功项用落盘路径(稳定、可被 agent
|
|
212
|
+
// 读回),失败项退回原源路径。落地页(无 chatId)由调用方创建会话后
|
|
213
|
+
// 自行持久化,这里 chatId 为空时原样用源路径。
|
|
214
|
+
const resolvedFiles = chatId ? await saveChatAttachments(chatId, files) : files;
|
|
215
|
+
|
|
216
|
+
// Append file references to the user message content
|
|
217
|
+
if (resolvedFiles.length > 0) {
|
|
218
|
+
const fileRefs = resolvedFiles.map(f => `- \`${f.path}\``).join('\n');
|
|
219
|
+
if (trimmed) {
|
|
220
|
+
trimmed = `${trimmed}\n\n---\n关联文件:\n${fileRefs}`;
|
|
221
|
+
} else {
|
|
222
|
+
trimmed = `关联文件:\n${fileRefs}`;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const mentionedAgentIds = mentionReferences
|
|
227
|
+
.filter((ref) => ref.type === 'agent')
|
|
228
|
+
.map((ref) => ref.id);
|
|
229
|
+
const referencedChatIds = mentionReferences
|
|
230
|
+
.filter((ref) => ref.type === 'chat')
|
|
231
|
+
.map((ref) => ref.id);
|
|
232
|
+
// W6-3: image attachments ride as metadata so the main process reads the
|
|
233
|
+
// bytes into base64 ImageParts; the text path refs above stay so the
|
|
234
|
+
// persisted record reflects that an image was attached.
|
|
235
|
+
const imageAttachments = resolvedFiles
|
|
236
|
+
.filter((f) => isImageFile(f.path))
|
|
237
|
+
.map((f) => ({ path: f.path, name: f.name }));
|
|
238
|
+
const metadata = {
|
|
239
|
+
...(mode === 'plan' ? { mode: 'plan' as const } : {}),
|
|
240
|
+
...(execPolicy === 'full' ? { execPolicy: 'full' as const } : {}),
|
|
241
|
+
...(selectedAgentId ? { agentId: selectedAgentId } : {}),
|
|
242
|
+
...(mentionedAgentIds.length > 0 ? { mentionedAgentIds } : {}),
|
|
243
|
+
...(referencedChatIds.length > 0 ? { referencedChatIds } : {}),
|
|
244
|
+
...(imageAttachments.length > 0 ? { images: imageAttachments } : {}),
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// Snapshot what we're about to clear so a failed submit can restore it —
|
|
248
|
+
// clearing eagerly before `onSubmit` settles used to lose the user's text
|
|
249
|
+
// (and any attachments/mentions) whenever the submit rejected.
|
|
250
|
+
const submittedFiles = files;
|
|
251
|
+
const submittedMentions = mentionReferences;
|
|
252
|
+
setInputValue('');
|
|
253
|
+
setFiles([]);
|
|
254
|
+
setMentionReferences([]);
|
|
255
|
+
try {
|
|
256
|
+
await onSubmit({
|
|
257
|
+
content: trimmed,
|
|
258
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
|
259
|
+
});
|
|
260
|
+
} catch (err) {
|
|
261
|
+
// Restore so the user doesn't lose what they typed; they can retry.
|
|
262
|
+
// Callers here (ChatInput) invoke this fire-and-forget (`void onSubmit()`),
|
|
263
|
+
// so we swallow rather than rethrow into an unhandled rejection.
|
|
264
|
+
console.warn('[LocalChatPanel] submit failed, restoring input', err);
|
|
265
|
+
setInputValue(inputValue);
|
|
266
|
+
setFiles(submittedFiles);
|
|
267
|
+
setMentionReferences(submittedMentions);
|
|
268
|
+
}
|
|
269
|
+
}, [inputValue, files, mentionReferences, onSubmit, mode, execPolicy, chatId, selectedAgentId]);
|
|
270
|
+
|
|
271
|
+
// 空会话 hero:messages 为空且未在流式时整面板切到居中首屏布局。
|
|
272
|
+
const showEmptyHero = Boolean(emptyHero) && messages.length === 0 && !isStreaming;
|
|
273
|
+
|
|
274
|
+
// Sync `chat-input-box-width` CSS var so bubbles match the input width
|
|
275
|
+
// (cloud product uses 95% of the input box; we mirror that here so message
|
|
276
|
+
// bubbles + input visually align in a column).
|
|
277
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
const containerEl = containerRef.current;
|
|
280
|
+
if (!containerEl) return;
|
|
281
|
+
const inputBox = containerEl.querySelector<HTMLElement>('.chat-input-box');
|
|
282
|
+
if (!inputBox) return;
|
|
283
|
+
|
|
284
|
+
const sync = () => {
|
|
285
|
+
const width = inputBox.getBoundingClientRect().width;
|
|
286
|
+
if (width > 0) {
|
|
287
|
+
containerEl.style.setProperty(
|
|
288
|
+
'--chat-input-box-width',
|
|
289
|
+
`${Math.round(width * 0.95)}px`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
sync();
|
|
295
|
+
const obs = new ResizeObserver(sync);
|
|
296
|
+
obs.observe(inputBox);
|
|
297
|
+
return () => obs.disconnect();
|
|
298
|
+
// hero ↔ 常规布局切换会重挂 ChatInput(DOM 位置变了),需要重新找
|
|
299
|
+
// inputBox 并重新 observe,否则气泡宽度变量停在旧节点上。
|
|
300
|
+
}, [showEmptyHero]);
|
|
301
|
+
|
|
302
|
+
const chatInputNode = (
|
|
303
|
+
<ChatInput
|
|
304
|
+
ref={inputRef}
|
|
305
|
+
value={inputValue}
|
|
306
|
+
onChange={setInputValue}
|
|
307
|
+
onSubmit={handleSubmit}
|
|
308
|
+
onCancel={onCancel}
|
|
309
|
+
onSteer={onSteer}
|
|
310
|
+
onFollowUp={onFollowUp}
|
|
311
|
+
pendingFollowUps={pendingFollowUps}
|
|
312
|
+
onRemoveFollowUp={onRemoveFollowUp}
|
|
313
|
+
isStreaming={isStreaming}
|
|
314
|
+
disabled={disabled}
|
|
315
|
+
placeholder={
|
|
316
|
+
mode === 'plan'
|
|
317
|
+
? '描述你的目标,Agent 将先制定计划…'
|
|
318
|
+
: inputPlaceholder
|
|
319
|
+
}
|
|
320
|
+
currentAgent={currentAgent}
|
|
321
|
+
agents={agents}
|
|
322
|
+
chats={chats}
|
|
323
|
+
selectedAgentId={selectedAgentId}
|
|
324
|
+
onSelectAgent={onSelectAgent}
|
|
325
|
+
onOpenSettings={onOpenSettings}
|
|
326
|
+
toolbarExtras={inputToolbarExtras}
|
|
327
|
+
leadingChrome={inputLeadingChrome}
|
|
328
|
+
mode={mode}
|
|
329
|
+
onModeChange={onModeChange}
|
|
330
|
+
execPolicy={execPolicy}
|
|
331
|
+
onExecPolicyChange={onExecPolicyChange}
|
|
332
|
+
files={files}
|
|
333
|
+
onFilesChange={setFiles}
|
|
334
|
+
onMentionReferencesChange={setMentionReferences}
|
|
335
|
+
/>
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
return (
|
|
339
|
+
<div
|
|
340
|
+
ref={containerRef}
|
|
341
|
+
className={`chat-panel-container flex h-full flex-col overflow-hidden ${className ?? ''}`.trim()}
|
|
342
|
+
>
|
|
343
|
+
<ChatPanel.Root className="flex h-full flex-col overflow-hidden" unstyled>
|
|
344
|
+
{showEmptyHero ? (
|
|
345
|
+
<div className="flex min-h-0 flex-1 items-center justify-center p-4 sm:p-8">
|
|
346
|
+
<div className="flex w-full max-w-2xl flex-col items-center gap-6">
|
|
347
|
+
<div className="text-center">
|
|
348
|
+
<h1 className="text-xl font-semibold tracking-tight text-agent-foreground">
|
|
349
|
+
{emptyHero?.title}
|
|
350
|
+
</h1>
|
|
351
|
+
{emptyHero?.subtitle && (
|
|
352
|
+
<p className="mt-1.5 text-sm text-agent-muted-foreground">
|
|
353
|
+
{emptyHero.subtitle}
|
|
354
|
+
</p>
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
<div className="w-full">
|
|
358
|
+
{inputBanner}
|
|
359
|
+
{chatInputNode}
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
) : (
|
|
364
|
+
<>
|
|
365
|
+
{/* 不用框架的 ChatPanel.Header:它默认带 border-b 下划线且 cn 是
|
|
366
|
+
纯拼接(无 tailwind-merge),类覆盖不可靠。这里用无边框的
|
|
367
|
+
普通容器,padding 由内部 ChatHeader 自带。 */}
|
|
368
|
+
{header ? (
|
|
369
|
+
<div className="flex items-center gap-2 bg-agent-canvas">{header}</div>
|
|
370
|
+
) : null}
|
|
371
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
372
|
+
<MessageList
|
|
373
|
+
messages={messages}
|
|
374
|
+
isStreaming={isStreaming}
|
|
375
|
+
agents={agents}
|
|
376
|
+
chats={chats}
|
|
377
|
+
currentAgent={currentAgent}
|
|
378
|
+
executedActionsByMessageId={executedActionsByMessageId}
|
|
379
|
+
currentTurnActions={currentTurnActions}
|
|
380
|
+
timelineByMessageId={timelineByMessageId}
|
|
381
|
+
currentTurnTimeline={currentTurnTimeline}
|
|
382
|
+
currentTurnStartedAtMs={currentTurnStartedAtMs}
|
|
383
|
+
durationByMessageId={durationByMessageId}
|
|
384
|
+
currentTurnChildren={currentTurnChildren}
|
|
385
|
+
orchestrationChildrenByMessageId={orchestrationChildrenByMessageId}
|
|
386
|
+
currentRound={currentRound}
|
|
387
|
+
emptyState={emptyState ?? <EmptyChat onSelectPrompt={handleSelectPrompt} />}
|
|
388
|
+
mode={mode}
|
|
389
|
+
onRegenerate={onRegenerate}
|
|
390
|
+
interrupted={interrupted}
|
|
391
|
+
onContinueInterrupted={onContinueInterrupted}
|
|
392
|
+
onDismissInterrupted={onDismissInterrupted}
|
|
393
|
+
finishedTasks={finishedTasks}
|
|
394
|
+
onInspectTask={onInspectTask}
|
|
395
|
+
onDismissFinishedTask={onDismissFinishedTask}
|
|
396
|
+
onShare={onShare}
|
|
397
|
+
/>
|
|
398
|
+
{inputBanner}
|
|
399
|
+
{chatInputNode}
|
|
400
|
+
</div>
|
|
401
|
+
</>
|
|
402
|
+
)}
|
|
403
|
+
</ChatPanel.Root>
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export default LocalChatPanel;
|