@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,469 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { motion } from 'framer-motion';
|
|
3
|
+
import { LuCheck, LuCopy, LuListChecks, LuLoaderCircle, LuRefreshCw, LuShare2 } from 'react-icons/lu';
|
|
4
|
+
import type { ChatMessage } from '@steerable/agent-protocol';
|
|
5
|
+
import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
|
|
6
|
+
import { Markdown } from './Markdown';
|
|
7
|
+
import { ExecutedActionsCard, type ExecutedAction } from './ExecutedActionsCard';
|
|
8
|
+
import { OrchestrationChildrenCard, type ChildInfo } from './OrchestrationChildrenCard';
|
|
9
|
+
import { StreamingStatus } from './StreamingStatus';
|
|
10
|
+
import { getFriendlyDate } from './timestamp';
|
|
11
|
+
import { useCopy } from './useCopy';
|
|
12
|
+
import type { TurnBlock } from './turn-timeline';
|
|
13
|
+
import { TurnProcessGroup } from './TurnProcessGroup';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* AssistantMessage — Tier-1 port of `deeppath`'s assistant-bubble.
|
|
17
|
+
*
|
|
18
|
+
* **Covered**:
|
|
19
|
+
* - Left-aligned bubble flush with user bubbles (no right-align).
|
|
20
|
+
* - Agent badge at top showing the agent that authored this message
|
|
21
|
+
* (color dot + name). Historical messages trust `message.agentId`; the
|
|
22
|
+
* in-flight tail message falls back to `currentAgent` so the badge shows
|
|
23
|
+
* up the moment streaming starts (before the DB row gets flushed).
|
|
24
|
+
* - Turn timeline (reasoning / tool cards / reply text in call order)
|
|
25
|
+
* reconstructed from the SSE stream. After the trailing summary lands,
|
|
26
|
+
* think+tool steps collapse into one disclosure (Codex / DeepSeek
|
|
27
|
+
* turn-process). Legacy messages without a timeline still render the
|
|
28
|
+
* grouped `ExecutedActionsCard` above the body.
|
|
29
|
+
* - Markdown body (gfm tables, raw html via the shared `Markdown` helper).
|
|
30
|
+
* - `StreamingStatus` while in-flight + no content yet — escalates the
|
|
31
|
+
* label based on round / action count so multi-round runs don't feel
|
|
32
|
+
* stuck.
|
|
33
|
+
* - Streaming cursor blink at the tail of the rendered content.
|
|
34
|
+
* - Hover-revealed copy button + friendly timestamp at bottom-left.
|
|
35
|
+
*
|
|
36
|
+
* **Not here yet** (depends on local-backend emitting structured segments):
|
|
37
|
+
* - `PlanSteps` / `PlanSelector` / `ResearchPlan` /
|
|
38
|
+
* `AnalysisDocument` / `QuizCard` / `CoverageReport`. These come from
|
|
39
|
+
* the cloud product's content-segment pipeline.
|
|
40
|
+
* - `ActionRenderer` (Goal/Task/Event/Resource/Automation update cards).
|
|
41
|
+
* Skip per the project's "agent-only" charter (no business entities).
|
|
42
|
+
* - `SearchSources` (web-search source list under the bubble). Local
|
|
43
|
+
* agent doesn't run web search in the cloud sense.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
interface AssistantMessageProps {
|
|
47
|
+
message: ChatMessage;
|
|
48
|
+
/** True iff this is the most recent message AND the stream is still open. */
|
|
49
|
+
isStreaming: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Lookup table for agent metadata. The bubble shows the color dot + name
|
|
52
|
+
* for the agent that authored this turn. When the message itself doesn't
|
|
53
|
+
* carry `agentId` (historical / builtin / not-yet-flushed during streaming)
|
|
54
|
+
* we fall back to `currentAgent`.
|
|
55
|
+
*/
|
|
56
|
+
agents: LocalChatAgent[];
|
|
57
|
+
chats?: LocalChat[];
|
|
58
|
+
/** Active chat-level agent — used as a fallback during streaming. */
|
|
59
|
+
currentAgent: LocalChatAgent | null;
|
|
60
|
+
/**
|
|
61
|
+
* Tool calls that ran during this assistant turn (emitted by local-backend
|
|
62
|
+
* via the `executed_actions` SSE event). On the legacy path they render
|
|
63
|
+
* as a grouped card above the body. With a `timeline` they sit inside the
|
|
64
|
+
* foldable think→act process. Empty / undefined => hidden.
|
|
65
|
+
*/
|
|
66
|
+
executedActions?: ExecutedAction[];
|
|
67
|
+
/**
|
|
68
|
+
* Call-order blocks for this turn (reasoning → tools → text). When present
|
|
69
|
+
* the bubble renders them interleaved; otherwise `executedActions` +
|
|
70
|
+
* `content` keep the legacy stacked layout.
|
|
71
|
+
*/
|
|
72
|
+
timeline?: TurnBlock[];
|
|
73
|
+
/** P3.1: live child-agent list for this turn (orchestration card). */
|
|
74
|
+
orchestrationChildren?: ChildInfo[];
|
|
75
|
+
/**
|
|
76
|
+
* Current round number for the in-flight turn (1-based). Only meaningful
|
|
77
|
+
* when `isStreaming === true` and this is the tail message — used by
|
|
78
|
+
* `StreamingStatus` to render "Round 2 · 继续推理..." between LLM bursts.
|
|
79
|
+
*/
|
|
80
|
+
currentRound?: number;
|
|
81
|
+
/** True iff the message was generated in Plan mode. */
|
|
82
|
+
isPlanMode?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* W1.2.1: regenerate this assistant turn (non-destructive — the old tail
|
|
85
|
+
* is forked into a branch by the backend). When provided, a hover action
|
|
86
|
+
* appears next to copy. The promise resolves when the regenerated turn
|
|
87
|
+
* has fully streamed; the caller then re-hydrates the message list.
|
|
88
|
+
*/
|
|
89
|
+
onRegenerate?: (messageId: string) => Promise<void>;
|
|
90
|
+
/** Epoch ms when this turn started — live ticker while streaming. */
|
|
91
|
+
startedAtMs?: number;
|
|
92
|
+
/** Frozen wall-clock of a finished turn. */
|
|
93
|
+
durationMs?: number;
|
|
94
|
+
/**
|
|
95
|
+
* 分享当前对话(截图复制到剪贴板)。只传给最近一条助手消息,画在时间戳
|
|
96
|
+
* 行上,跟复制 / 重新生成同一排。
|
|
97
|
+
*/
|
|
98
|
+
onShare?: () => Promise<boolean>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function agentInitial(agent: LocalChatAgent | null): string {
|
|
102
|
+
if (!agent?.name) return 'A';
|
|
103
|
+
return agent.name.trim()[0]?.toUpperCase() ?? 'A';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function AgentBadge({
|
|
107
|
+
agent,
|
|
108
|
+
isStreaming,
|
|
109
|
+
}: {
|
|
110
|
+
agent: LocalChatAgent;
|
|
111
|
+
isStreaming: boolean;
|
|
112
|
+
}) {
|
|
113
|
+
return (
|
|
114
|
+
<div
|
|
115
|
+
className="inline-flex items-center gap-1.5 text-[11px] leading-none text-agent-muted-foreground"
|
|
116
|
+
title={agent.description || agent.name}
|
|
117
|
+
>
|
|
118
|
+
<span
|
|
119
|
+
className="inline-flex items-center justify-center rounded-full text-[9px] font-semibold text-white"
|
|
120
|
+
style={{
|
|
121
|
+
width: 14,
|
|
122
|
+
height: 14,
|
|
123
|
+
backgroundColor: agent.color || '#7c3aed',
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
126
|
+
{agentInitial(agent)}
|
|
127
|
+
</span>
|
|
128
|
+
<span className="max-w-[160px] truncate rounded bg-agent-canvas/90 px-1">
|
|
129
|
+
{agent.name}
|
|
130
|
+
</span>
|
|
131
|
+
{isStreaming && (
|
|
132
|
+
<span className="ml-1 inline-flex h-1.5 w-1.5 animate-pulse rounded-full bg-agent-foreground/30" />
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const META_ACTION =
|
|
139
|
+
'inline-flex items-center gap-0.5 rounded transition-all duration-200 hover:text-agent-foreground opacity-0 focus:opacity-100 group-hover/message:opacity-100';
|
|
140
|
+
|
|
141
|
+
function bubbleClass(isPlanMode: boolean): string {
|
|
142
|
+
return `rounded-agent-lg border p-3 shadow-sm transition-all duration-200 ${
|
|
143
|
+
isPlanMode
|
|
144
|
+
? 'border-amber-400/50 dark:border-amber-500/30 bg-amber-50/10 dark:bg-amber-950/5 shadow-amber-500/5'
|
|
145
|
+
: 'border-agent-border bg-agent-canvas'
|
|
146
|
+
}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 框架 useChatStream 收到 SSE error 事件时把最后一条助手消息的内容打成
|
|
150
|
+
// "[stream error] ..."(live 路径);落库后失败原因在
|
|
151
|
+
// messageMetadata.completionReason(刷新/重载路径,可能还留着部分正文)。
|
|
152
|
+
// 两处汇合到同一个错误气泡,失败回合不再只显示"(空消息)"。
|
|
153
|
+
const STREAM_ERROR_PREFIX = '[stream error] ';
|
|
154
|
+
|
|
155
|
+
function readTurnFailure(
|
|
156
|
+
message: ChatMessage,
|
|
157
|
+
content: string,
|
|
158
|
+
): { reason: string; contentIsError: boolean } | null {
|
|
159
|
+
if (content.startsWith(STREAM_ERROR_PREFIX)) {
|
|
160
|
+
return { reason: content.slice(STREAM_ERROR_PREFIX.length), contentIsError: true };
|
|
161
|
+
}
|
|
162
|
+
const raw = (message as { messageMetadata?: unknown }).messageMetadata;
|
|
163
|
+
if (typeof raw !== 'string' || !raw) return null;
|
|
164
|
+
try {
|
|
165
|
+
const meta = JSON.parse(raw) as {
|
|
166
|
+
completionStatus?: unknown;
|
|
167
|
+
completionReason?: unknown;
|
|
168
|
+
};
|
|
169
|
+
if (
|
|
170
|
+
meta?.completionStatus === 'failed' &&
|
|
171
|
+
typeof meta.completionReason === 'string' &&
|
|
172
|
+
meta.completionReason
|
|
173
|
+
) {
|
|
174
|
+
return { reason: meta.completionReason, contentIsError: false };
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// 元数据损坏按无失败处理——落回原有的空消息展示。
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function TurnErrorBubble({ reason }: { reason: string }) {
|
|
183
|
+
return (
|
|
184
|
+
<div className="rounded-agent-lg border border-agent-destructive/40 bg-agent-destructive/5 p-3 text-sm leading-relaxed text-agent-destructive shadow-sm">
|
|
185
|
+
请求失败:{reason}
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function StreamingCursor() {
|
|
191
|
+
return (
|
|
192
|
+
<span className="ml-0.5 inline-block h-3.5 w-[3px] animate-agent-cursor-blink bg-agent-foreground/60 align-text-bottom" />
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function AssistantMessage({
|
|
197
|
+
message,
|
|
198
|
+
isStreaming,
|
|
199
|
+
agents,
|
|
200
|
+
chats = [],
|
|
201
|
+
currentAgent,
|
|
202
|
+
executedActions,
|
|
203
|
+
timeline,
|
|
204
|
+
orchestrationChildren,
|
|
205
|
+
currentRound = 1,
|
|
206
|
+
isPlanMode = false,
|
|
207
|
+
onRegenerate,
|
|
208
|
+
startedAtMs,
|
|
209
|
+
durationMs,
|
|
210
|
+
onShare,
|
|
211
|
+
}: AssistantMessageProps) {
|
|
212
|
+
const content = message.content || '';
|
|
213
|
+
const failure = readTurnFailure(message, content);
|
|
214
|
+
// live 错误文本("[stream error] ...")不当正文渲染;落库的失败回合可能
|
|
215
|
+
// 留有部分正文,此时正文与错误气泡都展示。
|
|
216
|
+
const displayContent = failure?.contentIsError ? '' : content;
|
|
217
|
+
const { copied, copy } = useCopy(content);
|
|
218
|
+
const [regenerating, setRegenerating] = useState(false);
|
|
219
|
+
const [regenerateError, setRegenerateError] = useState<string | null>(null);
|
|
220
|
+
const persistedAgent = message.agentId
|
|
221
|
+
? agents.find((a) => a.id === message.agentId) ?? null
|
|
222
|
+
: null;
|
|
223
|
+
// Stream-time fallback: only allow the chat-level agent to "claim" a turn
|
|
224
|
+
// while it's actively streaming and hasn't flushed agentId to the DB yet.
|
|
225
|
+
const displayAgent = persistedAgent ?? (isStreaming ? currentAgent : null);
|
|
226
|
+
const useTimeline = timeline !== undefined;
|
|
227
|
+
const blocks = timeline ?? [];
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<motion.div
|
|
231
|
+
className="group/message mb-2"
|
|
232
|
+
data-message-role="assistant"
|
|
233
|
+
data-message-id={message.id}
|
|
234
|
+
initial={{ opacity: 0, y: 8 }}
|
|
235
|
+
animate={{ opacity: 1, y: 0 }}
|
|
236
|
+
transition={{ duration: 0.25 }}
|
|
237
|
+
>
|
|
238
|
+
<div className="mx-auto w-full max-w-[var(--chat-input-box-width)] px-1">
|
|
239
|
+
{(displayAgent || isPlanMode) && (
|
|
240
|
+
<div className="flex items-center justify-between mb-1.5 min-h-[18px]">
|
|
241
|
+
{displayAgent ? (
|
|
242
|
+
<AgentBadge agent={displayAgent} isStreaming={isStreaming} />
|
|
243
|
+
) : (
|
|
244
|
+
<div />
|
|
245
|
+
)}
|
|
246
|
+
{isPlanMode && (
|
|
247
|
+
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 dark:border-amber-800/50 bg-amber-100/70 dark:bg-amber-900/30 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300 select-none">
|
|
248
|
+
<LuListChecks className="h-3.5 w-3.5" />
|
|
249
|
+
<span>计划模式 (Plan)</span>
|
|
250
|
+
</span>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
{orchestrationChildren && orchestrationChildren.length > 0 && (
|
|
255
|
+
<OrchestrationChildrenCard children={orchestrationChildren} />
|
|
256
|
+
)}
|
|
257
|
+
{useTimeline ? (
|
|
258
|
+
<>
|
|
259
|
+
<TurnProcessGroup
|
|
260
|
+
blocks={blocks}
|
|
261
|
+
isStreaming={isStreaming}
|
|
262
|
+
startedAtMs={startedAtMs}
|
|
263
|
+
durationMs={durationMs}
|
|
264
|
+
agents={agents}
|
|
265
|
+
chats={chats}
|
|
266
|
+
emptyFallback={
|
|
267
|
+
failure ? (
|
|
268
|
+
<TurnErrorBubble reason={failure.reason} />
|
|
269
|
+
) : isStreaming ? (
|
|
270
|
+
<div className={bubbleClass(isPlanMode)}>
|
|
271
|
+
<StreamingStatus
|
|
272
|
+
round={currentRound}
|
|
273
|
+
actionCount={executedActions?.length ?? 0}
|
|
274
|
+
hasContent={false}
|
|
275
|
+
/>
|
|
276
|
+
</div>
|
|
277
|
+
) : (
|
|
278
|
+
<div className={`${bubbleClass(isPlanMode)} text-sm italic text-agent-muted-foreground`}>
|
|
279
|
+
(空消息)
|
|
280
|
+
</div>
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
streamingHint={
|
|
284
|
+
<StreamingStatus
|
|
285
|
+
round={currentRound}
|
|
286
|
+
actionCount={executedActions?.length ?? 0}
|
|
287
|
+
hasContent={false}
|
|
288
|
+
/>
|
|
289
|
+
}
|
|
290
|
+
renderAnswer={(block, isLast) => (
|
|
291
|
+
<div className={bubbleClass(isPlanMode)}>
|
|
292
|
+
<div className="markdown-content text-sm leading-relaxed text-agent-foreground">
|
|
293
|
+
<Markdown agents={agents} chats={chats}>{block.content}</Markdown>
|
|
294
|
+
</div>
|
|
295
|
+
{isStreaming && isLast && <StreamingCursor />}
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
/>
|
|
299
|
+
{/* 有部分正文/工具块但回合失败:正文照常渲染,错误气泡补在下方
|
|
300
|
+
(无块时走上面的 emptyFallback,不会重复)。 */}
|
|
301
|
+
{failure && blocks.length > 0 ? (
|
|
302
|
+
<div className="mt-1.5">
|
|
303
|
+
<TurnErrorBubble reason={failure.reason} />
|
|
304
|
+
</div>
|
|
305
|
+
) : null}
|
|
306
|
+
</>
|
|
307
|
+
) : (
|
|
308
|
+
<>
|
|
309
|
+
{executedActions && executedActions.length > 0 && (
|
|
310
|
+
<ExecutedActionsCard actions={executedActions} />
|
|
311
|
+
)}
|
|
312
|
+
{displayContent ? (
|
|
313
|
+
<div className={bubbleClass(isPlanMode)}>
|
|
314
|
+
<div className="markdown-content text-sm leading-relaxed text-agent-foreground">
|
|
315
|
+
<Markdown agents={agents} chats={chats}>{displayContent}</Markdown>
|
|
316
|
+
</div>
|
|
317
|
+
{isStreaming && <StreamingCursor />}
|
|
318
|
+
</div>
|
|
319
|
+
) : failure ? (
|
|
320
|
+
<TurnErrorBubble reason={failure.reason} />
|
|
321
|
+
) : isStreaming ? (
|
|
322
|
+
<div className={bubbleClass(isPlanMode)}>
|
|
323
|
+
<StreamingStatus
|
|
324
|
+
round={currentRound}
|
|
325
|
+
actionCount={executedActions?.length ?? 0}
|
|
326
|
+
hasContent={false}
|
|
327
|
+
/>
|
|
328
|
+
</div>
|
|
329
|
+
) : (
|
|
330
|
+
<div className={`${bubbleClass(isPlanMode)} text-sm italic text-agent-muted-foreground`}>
|
|
331
|
+
(空消息)
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
334
|
+
{displayContent && failure ? (
|
|
335
|
+
<div className="mt-1.5">
|
|
336
|
+
<TurnErrorBubble reason={failure.reason} />
|
|
337
|
+
</div>
|
|
338
|
+
) : null}
|
|
339
|
+
</>
|
|
340
|
+
)}
|
|
341
|
+
<div className="mt-1 flex items-center gap-2 text-[11px] text-agent-muted-foreground">
|
|
342
|
+
<span>
|
|
343
|
+
{message.createdAt
|
|
344
|
+
? getFriendlyDate(new Date(message.createdAt))
|
|
345
|
+
: ''}
|
|
346
|
+
</span>
|
|
347
|
+
{content && !isStreaming && (
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
onClick={() => void copy()}
|
|
351
|
+
className={META_ACTION}
|
|
352
|
+
title={copied ? '已复制' : '复制消息'}
|
|
353
|
+
aria-label={copied ? '已复制' : '复制消息'}
|
|
354
|
+
>
|
|
355
|
+
{copied ? (
|
|
356
|
+
<>
|
|
357
|
+
<LuCheck className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
|
|
358
|
+
<span className="text-emerald-600 dark:text-emerald-400">
|
|
359
|
+
已复制
|
|
360
|
+
</span>
|
|
361
|
+
</>
|
|
362
|
+
) : (
|
|
363
|
+
<LuCopy className="h-3 w-3" />
|
|
364
|
+
)}
|
|
365
|
+
</button>
|
|
366
|
+
)}
|
|
367
|
+
{content && !isStreaming && onRegenerate && (
|
|
368
|
+
<button
|
|
369
|
+
type="button"
|
|
370
|
+
disabled={regenerating}
|
|
371
|
+
onClick={() => {
|
|
372
|
+
if (regenerating) return;
|
|
373
|
+
setRegenerating(true);
|
|
374
|
+
setRegenerateError(null);
|
|
375
|
+
onRegenerate(message.id)
|
|
376
|
+
// The backend refuses when it could not preserve this reply
|
|
377
|
+
// as a branch. Dropping that leaves the spinner stopping for
|
|
378
|
+
// no stated reason while the reply sits unchanged.
|
|
379
|
+
.catch((err: unknown) =>
|
|
380
|
+
setRegenerateError(
|
|
381
|
+
err instanceof Error ? err.message : String(err),
|
|
382
|
+
),
|
|
383
|
+
)
|
|
384
|
+
.finally(() => setRegenerating(false));
|
|
385
|
+
}}
|
|
386
|
+
className={`${META_ACTION} disabled:cursor-not-allowed`}
|
|
387
|
+
title={regenerating ? '正在重新生成…' : '重新生成(旧回复保留为分支)'}
|
|
388
|
+
aria-label={regenerating ? '正在重新生成' : '重新生成'}
|
|
389
|
+
data-action="regenerate"
|
|
390
|
+
>
|
|
391
|
+
<LuRefreshCw
|
|
392
|
+
className={`h-3 w-3 ${regenerating ? 'animate-spin' : ''}`}
|
|
393
|
+
/>
|
|
394
|
+
</button>
|
|
395
|
+
)}
|
|
396
|
+
{onShare && !isStreaming && <ShareChatButton onShare={onShare} />}
|
|
397
|
+
</div>
|
|
398
|
+
{regenerateError && (
|
|
399
|
+
<div
|
|
400
|
+
className="mt-1 text-[11px] text-amber-700 dark:text-amber-400"
|
|
401
|
+
role="status"
|
|
402
|
+
data-regenerate-error
|
|
403
|
+
>
|
|
404
|
+
{regenerateError}
|
|
405
|
+
</div>
|
|
406
|
+
)}
|
|
407
|
+
</div>
|
|
408
|
+
</motion.div>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export default AssistantMessage;
|
|
413
|
+
|
|
414
|
+
function ShareChatButton({ onShare }: { onShare: () => Promise<boolean> }) {
|
|
415
|
+
const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error'>('idle');
|
|
416
|
+
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
417
|
+
|
|
418
|
+
useEffect(
|
|
419
|
+
() => () => {
|
|
420
|
+
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
|
|
421
|
+
},
|
|
422
|
+
[],
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
return (
|
|
426
|
+
<button
|
|
427
|
+
type="button"
|
|
428
|
+
disabled={state === 'busy'}
|
|
429
|
+
onClick={() => {
|
|
430
|
+
if (state === 'busy') return;
|
|
431
|
+
setState('busy');
|
|
432
|
+
void (async () => {
|
|
433
|
+
let ok = false;
|
|
434
|
+
try {
|
|
435
|
+
ok = await onShare();
|
|
436
|
+
} catch {
|
|
437
|
+
ok = false;
|
|
438
|
+
}
|
|
439
|
+
setState(ok ? 'done' : 'error');
|
|
440
|
+
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
|
|
441
|
+
resetTimerRef.current = setTimeout(() => setState('idle'), 2000);
|
|
442
|
+
})();
|
|
443
|
+
}}
|
|
444
|
+
className={`${META_ACTION} disabled:cursor-not-allowed ${
|
|
445
|
+
state === 'done'
|
|
446
|
+
? 'text-emerald-600 dark:text-emerald-400'
|
|
447
|
+
: state === 'error'
|
|
448
|
+
? 'text-agent-destructive'
|
|
449
|
+
: ''
|
|
450
|
+
}`}
|
|
451
|
+
title={
|
|
452
|
+
state === 'done'
|
|
453
|
+
? '截图已复制到剪贴板'
|
|
454
|
+
: state === 'error'
|
|
455
|
+
? '截图失败,请重试'
|
|
456
|
+
: '分享对话(截图复制到剪贴板)'
|
|
457
|
+
}
|
|
458
|
+
aria-label="分享对话截图"
|
|
459
|
+
>
|
|
460
|
+
{state === 'busy' ? (
|
|
461
|
+
<LuLoaderCircle className="h-3 w-3 animate-spin" />
|
|
462
|
+
) : state === 'done' ? (
|
|
463
|
+
<LuCheck className="h-3 w-3" />
|
|
464
|
+
) : (
|
|
465
|
+
<LuShare2 className="h-3 w-3" />
|
|
466
|
+
)}
|
|
467
|
+
</button>
|
|
468
|
+
);
|
|
469
|
+
}
|