@steerable/agent-shell-web 0.6.36 → 0.6.38
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/package.json +4 -4
- package/src/components/AgentSidebar.tsx +20 -68
- package/src/components/ChatHeader.tsx +34 -0
- package/src/components/chat/LocalChatPanel.tsx +84 -43
- package/src/layouts/AgentLayout.tsx +114 -36
- package/src/lib/attachments.ts +81 -31
- package/src/pages/AgentPage.tsx +54 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.38",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Steerable framework — product-neutral renderer SPA source for @steerable/agent-shell (React + Vite). Consumed by product web entry points via the `@/` alias (anchored at this package's src/) and the createProductViteConfig factory. Published as a source package (src/), plus the compiled vite.base config factory (vite.base.js + .d.ts) for Node-side config loading.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"react-router-dom": "^6.30.0",
|
|
44
44
|
"rehype-raw": "^7.0.0",
|
|
45
45
|
"remark-gfm": "^4.0.1",
|
|
46
|
-
"@steerable/agent-protocol": "0.6.
|
|
47
|
-
"@steerable/agent-ui": "0.6.
|
|
46
|
+
"@steerable/agent-protocol": "0.6.38",
|
|
47
|
+
"@steerable/agent-ui": "0.6.38"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@tailwindcss/vite": "^4.3.0",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"typescript": "^5.8.3",
|
|
58
58
|
"vite": "^5.4.21",
|
|
59
59
|
"vitest": "^3.2.4",
|
|
60
|
-
"@steerable/pack-sdk": "0.6.
|
|
60
|
+
"@steerable/pack-sdk": "0.6.38"
|
|
61
61
|
},
|
|
62
62
|
"scripts": {
|
|
63
63
|
"gen": "echo agent-shell-web",
|
|
@@ -115,7 +115,6 @@ import {
|
|
|
115
115
|
type LocalProject,
|
|
116
116
|
} from "@/lib/local-api";
|
|
117
117
|
import type { UseChatsAndAgentsResult } from "@/hooks/useChatsAndAgents";
|
|
118
|
-
import type { PackChatSlotContribution } from "@/packs/registry";
|
|
119
118
|
import type { RightPanelState } from "@/layouts/AgentLayout";
|
|
120
119
|
import { BrandLockup } from "@/components/BrandLockup";
|
|
121
120
|
import { CreateProjectModal } from "@/components/CreateProjectModal";
|
|
@@ -272,8 +271,6 @@ interface AgentSidebarProps {
|
|
|
272
271
|
rightPanel: RightPanelState;
|
|
273
272
|
/** 切换右侧栏面板显隐——面板不是路由也不是独立窗口,只是布局里的一栏。 */
|
|
274
273
|
onToggleRightPanel: (kind: string) => void;
|
|
275
|
-
/** 包注册的聊天页槽位(分段控件里每个槽位一个按钮);空 = 只有终端。 */
|
|
276
|
-
chatSlots: readonly PackChatSlotContribution[];
|
|
277
274
|
/** 收起侧边栏(AgentLayout 换成窄 rail,展开按钮在 rail 上)。 */
|
|
278
275
|
onCollapse: () => void;
|
|
279
276
|
}
|
|
@@ -282,7 +279,6 @@ export function AgentSidebar({
|
|
|
282
279
|
data,
|
|
283
280
|
rightPanel,
|
|
284
281
|
onToggleRightPanel,
|
|
285
|
-
chatSlots,
|
|
286
282
|
onCollapse,
|
|
287
283
|
}: AgentSidebarProps) {
|
|
288
284
|
const navigate = useNavigate();
|
|
@@ -1054,70 +1050,26 @@ export function AgentSidebar({
|
|
|
1054
1050
|
|
|
1055
1051
|
{/* ───── Footer: 右侧面板切换(终端 | 包槽位)+ 设置 ───── */}
|
|
1056
1052
|
<div className="flex-shrink-0 border-t border-agent-border/40 px-2.5 py-1.5">
|
|
1057
|
-
{
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
) : null
|
|
1078
|
-
) : (
|
|
1079
|
-
<div
|
|
1080
|
-
role="group"
|
|
1081
|
-
aria-label="右侧面板切换"
|
|
1082
|
-
className="flex h-7 w-full items-center gap-0.5 rounded-full bg-agent-foreground/5 p-0.5"
|
|
1083
|
-
>
|
|
1084
|
-
{showTerminalChrome ? (
|
|
1085
|
-
<button
|
|
1086
|
-
type="button"
|
|
1087
|
-
onClick={() => onToggleRightPanel("terminal")}
|
|
1088
|
-
className={[
|
|
1089
|
-
"flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors",
|
|
1090
|
-
rightPanel === "terminal"
|
|
1091
|
-
? "bg-agent-foreground/10 text-agent-foreground"
|
|
1092
|
-
: "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
|
|
1093
|
-
].join(" ")}
|
|
1094
|
-
title={`${rightPanel === "terminal" ? "关闭" : "打开"}终端面板`}
|
|
1095
|
-
data-testid="sidebar-terminal"
|
|
1096
|
-
>
|
|
1097
|
-
<LuTerminal className="h-3.5 w-3.5" />
|
|
1098
|
-
<span>终端</span>
|
|
1099
|
-
</button>
|
|
1100
|
-
) : null}
|
|
1101
|
-
{chatSlots.map((slot) => (
|
|
1102
|
-
<button
|
|
1103
|
-
key={slot.slotId}
|
|
1104
|
-
type="button"
|
|
1105
|
-
onClick={() => onToggleRightPanel(slot.slotId)}
|
|
1106
|
-
className={[
|
|
1107
|
-
"flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors",
|
|
1108
|
-
rightPanel === slot.slotId
|
|
1109
|
-
? "bg-agent-foreground/10 text-agent-foreground"
|
|
1110
|
-
: "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
|
|
1111
|
-
].join(" ")}
|
|
1112
|
-
title={`${rightPanel === slot.slotId ? "关闭" : "打开"}${slot.title}`}
|
|
1113
|
-
data-testid={`sidebar-slot-${slot.slotId}`}
|
|
1114
|
-
>
|
|
1115
|
-
<slot.Icon className="h-3.5 w-3.5" />
|
|
1116
|
-
<span>{slot.title}</span>
|
|
1117
|
-
</button>
|
|
1118
|
-
))}
|
|
1119
|
-
</div>
|
|
1120
|
-
)}
|
|
1053
|
+
{showTerminalChrome ? (
|
|
1054
|
+
<button
|
|
1055
|
+
type="button"
|
|
1056
|
+
onClick={() => onToggleRightPanel("terminal")}
|
|
1057
|
+
className={[
|
|
1058
|
+
"flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
|
|
1059
|
+
rightPanel === "terminal"
|
|
1060
|
+
? "bg-agent-foreground/10 font-medium text-agent-foreground"
|
|
1061
|
+
: "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-foreground",
|
|
1062
|
+
].join(" ")}
|
|
1063
|
+
title={`${rightPanel === "terminal" ? "关闭" : "打开"}终端面板 (${isMac ? "⌘T" : "Ctrl+T"})`}
|
|
1064
|
+
data-testid="sidebar-terminal"
|
|
1065
|
+
>
|
|
1066
|
+
<LuTerminal className="h-3.5 w-3.5" />
|
|
1067
|
+
<span>终端</span>
|
|
1068
|
+
<span className="ml-auto text-[10px] text-agent-muted-foreground/70">
|
|
1069
|
+
{isMac ? "⌘T" : "Ctrl+T"}
|
|
1070
|
+
</span>
|
|
1071
|
+
</button>
|
|
1072
|
+
) : null}
|
|
1121
1073
|
{hasGeneralSettingsChrome() && (
|
|
1122
1074
|
<button
|
|
1123
1075
|
type="button"
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { SessionTreeModal } from './chat/SessionTreeModal';
|
|
11
11
|
import { TaskPanelModal } from './chat/TaskPanelModal';
|
|
12
12
|
import { actionableTasks, summarizeTasks, type ChatTaskSummary } from './chat/useChatTasks';
|
|
13
|
+
import type { PackChatSlotContribution } from '@/packs/registry';
|
|
13
14
|
|
|
14
15
|
interface ChatHeaderProps {
|
|
15
16
|
chat: LocalChat | null;
|
|
@@ -23,6 +24,12 @@ interface ChatHeaderProps {
|
|
|
23
24
|
onInspectTask?: (task: { id: string; chatId: string; title: string }) => void;
|
|
24
25
|
/** 本对话的后台任务(AgentPage 的 `useChatTasks` 单一订阅源)。 */
|
|
25
26
|
tasks?: LocalTask[];
|
|
27
|
+
/** 包注册的聊天页槽位(如「文档预览」):入口放在本标题栏,按会话独立开关。 */
|
|
28
|
+
chatSlots?: readonly PackChatSlotContribution[];
|
|
29
|
+
/** 当前会话打开的右侧栏位(null = 都关着)。 */
|
|
30
|
+
rightPanel?: string | null;
|
|
31
|
+
/** 点击槽位入口:点已打开的关闭,点另一个直接切换。 */
|
|
32
|
+
onToggleChatSlot?: (slotId: string) => void;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
/**
|
|
@@ -40,6 +47,9 @@ export function ChatHeader({
|
|
|
40
47
|
onBranchSwitched,
|
|
41
48
|
onInspectTask,
|
|
42
49
|
tasks = [],
|
|
50
|
+
chatSlots = [],
|
|
51
|
+
rightPanel = null,
|
|
52
|
+
onToggleChatSlot,
|
|
43
53
|
}: ChatHeaderProps) {
|
|
44
54
|
const [branchMenuOpen, setBranchMenuOpen] = useState(false);
|
|
45
55
|
const [treeModalOpen, setTreeModalOpen] = useState(false);
|
|
@@ -233,6 +243,30 @@ export function ChatHeader({
|
|
|
233
243
|
{taskBadge && <span className="text-xs">{taskBadge.count}</span>}
|
|
234
244
|
</button>
|
|
235
245
|
)}
|
|
246
|
+
{chat &&
|
|
247
|
+
chatSlots.map((slot) => {
|
|
248
|
+
const open = rightPanel === slot.slotId;
|
|
249
|
+
return (
|
|
250
|
+
<button
|
|
251
|
+
key={slot.slotId}
|
|
252
|
+
type="button"
|
|
253
|
+
onClick={() => onToggleChatSlot?.(slot.slotId)}
|
|
254
|
+
className={`flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs font-medium transition-colors ${
|
|
255
|
+
open
|
|
256
|
+
? 'bg-agent-foreground/10 text-agent-foreground'
|
|
257
|
+
: 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground'
|
|
258
|
+
}`}
|
|
259
|
+
title={`${open ? '关闭' : '打开'}${slot.title}`}
|
|
260
|
+
aria-label={slot.title}
|
|
261
|
+
aria-pressed={open}
|
|
262
|
+
data-action={`chat-slot-${slot.slotId}`}
|
|
263
|
+
data-testid={`header-slot-${slot.slotId}`}
|
|
264
|
+
>
|
|
265
|
+
<slot.Icon className="h-3.5 w-3.5" />
|
|
266
|
+
{slot.title}
|
|
267
|
+
</button>
|
|
268
|
+
);
|
|
269
|
+
})}
|
|
236
270
|
{chat && taskPanelOpen && (
|
|
237
271
|
<TaskPanelModal
|
|
238
272
|
chatId={chat.id}
|
|
@@ -21,7 +21,9 @@ import type { LlmSpeedSnapshot } from './process-status';
|
|
|
21
21
|
import { SessionTodoList } from './SessionTodoList';
|
|
22
22
|
import { resolveLatestSessionTodos } from './todo-list-model';
|
|
23
23
|
import {
|
|
24
|
-
|
|
24
|
+
appendAttachmentRefs,
|
|
25
|
+
collectImageAttachments,
|
|
26
|
+
formatAttachmentFailures,
|
|
25
27
|
saveChatAttachments,
|
|
26
28
|
type AttachmentFile,
|
|
27
29
|
} from '@/lib/attachments';
|
|
@@ -214,6 +216,7 @@ export function LocalChatPanel({
|
|
|
214
216
|
}: LocalChatPanelProps) {
|
|
215
217
|
const [inputValue, setInputValue] = useState('');
|
|
216
218
|
const [files, setFiles] = useState<AttachmentFile[]>([]);
|
|
219
|
+
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
|
217
220
|
const [mentionReferences, setMentionReferences] = useState<MentionReference[]>([]);
|
|
218
221
|
const inputRef = useRef<ChatInputHandle>(null);
|
|
219
222
|
|
|
@@ -231,12 +234,32 @@ export function LocalChatPanel({
|
|
|
231
234
|
if (!trimmed && files.length === 0) return;
|
|
232
235
|
|
|
233
236
|
// 把上传的文件持久化到会话空间:成功项用落盘路径(稳定、可被 agent
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
237
|
+
// 读回),失败项若在 Electron 下有源路径则退回源路径;浏览器模式下没有
|
|
238
|
+
// 任何可用路径的失败项会被剔除并上报,绝不把空路径写进正文。
|
|
239
|
+
// 没有文件就没有可持久化的东西,直接跳过 saveChatAttachments:它的 await
|
|
240
|
+
// 会让「无附件提交」多出一个微任务,打乱调用方基于 streaming 上升/下降沿的
|
|
241
|
+
// 判定(plan 模式操作条依赖该沿)。语义与 saveChatAttachments(chatId, [])
|
|
242
|
+
// 完全一致(后者原样返回空列表、无失败)。
|
|
243
|
+
const { files: resolvedFiles, failures } =
|
|
244
|
+
chatId && files.length > 0
|
|
245
|
+
? await saveChatAttachments(chatId, files)
|
|
246
|
+
: { files, failures: [] };
|
|
247
|
+
|
|
248
|
+
if (failures.length > 0) {
|
|
249
|
+
// 本次消息至少保留可用的文件继续发送,但失败项必须让用户看到。
|
|
250
|
+
setAttachmentError(formatAttachmentFailures(failures));
|
|
251
|
+
} else {
|
|
252
|
+
setAttachmentError(null);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Append file references to the user message content
|
|
256
|
+
const content = appendAttachmentRefs(trimmed, resolvedFiles);
|
|
257
|
+
if (resolvedFiles.length === 0 && !trimmed) {
|
|
258
|
+
// 唯一的文件全是失败项、又没有正文 —— 没有可发送的内容,保留输入框
|
|
259
|
+
// (含失败文件)让用户重试或移除。
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
trimmed = content;
|
|
240
263
|
|
|
241
264
|
const mentionedAgentIds = mentionReferences
|
|
242
265
|
.filter((ref) => ref.type === 'agent')
|
|
@@ -247,13 +270,14 @@ export function LocalChatPanel({
|
|
|
247
270
|
// W6-3: image attachments ride as metadata so the main process reads the
|
|
248
271
|
// bytes into base64 ImageParts; the text path refs above stay so the
|
|
249
272
|
// persisted record reflects that an image was attached.
|
|
273
|
+
const imageAttachments = collectImageAttachments(resolvedFiles);
|
|
250
274
|
const metadata = {
|
|
251
275
|
...(mode === 'plan' ? { mode: 'plan' as const } : {}),
|
|
252
276
|
...(execPolicy === 'full' ? { execPolicy: 'full' as const } : {}),
|
|
253
277
|
...(selectedAgentId ? { agentId: selectedAgentId } : {}),
|
|
254
278
|
...(mentionedAgentIds.length > 0 ? { mentionedAgentIds } : {}),
|
|
255
279
|
...(referencedChatIds.length > 0 ? { referencedChatIds } : {}),
|
|
256
|
-
...(
|
|
280
|
+
...(imageAttachments.length > 0 ? { images: imageAttachments } : {}),
|
|
257
281
|
};
|
|
258
282
|
|
|
259
283
|
// Snapshot what we're about to clear so a failed submit can restore it —
|
|
@@ -261,8 +285,10 @@ export function LocalChatPanel({
|
|
|
261
285
|
// (and any attachments/mentions) whenever the submit rejected.
|
|
262
286
|
const submittedFiles = files;
|
|
263
287
|
const submittedMentions = mentionReferences;
|
|
288
|
+
// 失败项留在输入框里,用户可以直接重试 / 移除;成功项随消息发出后清掉。
|
|
289
|
+
const failedFiles = failures.map((f) => f.file);
|
|
264
290
|
setInputValue('');
|
|
265
|
-
setFiles(
|
|
291
|
+
setFiles(failedFiles);
|
|
266
292
|
setMentionReferences([]);
|
|
267
293
|
try {
|
|
268
294
|
await onSubmit({
|
|
@@ -329,40 +355,55 @@ export function LocalChatPanel({
|
|
|
329
355
|
}, [showEmptyHero]);
|
|
330
356
|
|
|
331
357
|
const chatInputNode = (
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
358
|
+
<>
|
|
359
|
+
{attachmentError && (
|
|
360
|
+
<p
|
|
361
|
+
className="mx-3 mb-1 text-xs text-agent-destructive"
|
|
362
|
+
role="alert"
|
|
363
|
+
data-testid="attachment-error"
|
|
364
|
+
>
|
|
365
|
+
{attachmentError}
|
|
366
|
+
</p>
|
|
367
|
+
)}
|
|
368
|
+
<ChatInput
|
|
369
|
+
ref={inputRef}
|
|
370
|
+
value={inputValue}
|
|
371
|
+
onChange={setInputValue}
|
|
372
|
+
onSubmit={handleSubmit}
|
|
373
|
+
onCancel={onCancel}
|
|
374
|
+
onSteer={onSteer}
|
|
375
|
+
onFollowUp={onFollowUp}
|
|
376
|
+
pendingFollowUps={pendingFollowUps}
|
|
377
|
+
onRemoveFollowUp={onRemoveFollowUp}
|
|
378
|
+
isStreaming={isStreaming}
|
|
379
|
+
disabled={disabled}
|
|
380
|
+
placeholder={
|
|
381
|
+
mode === 'plan'
|
|
382
|
+
? '描述你的目标,Agent 将先制定计划…'
|
|
383
|
+
: inputPlaceholder
|
|
384
|
+
}
|
|
385
|
+
currentAgent={currentAgent}
|
|
386
|
+
agents={agents}
|
|
387
|
+
chats={chats}
|
|
388
|
+
selectedAgentId={selectedAgentId}
|
|
389
|
+
onSelectAgent={onSelectAgent}
|
|
390
|
+
onOpenSettings={onOpenSettings}
|
|
391
|
+
toolbarExtras={inputToolbarExtras}
|
|
392
|
+
leadingChrome={inputLeadingChrome}
|
|
393
|
+
trailingChrome={sessionTodos ? <SessionTodoList todos={sessionTodos} /> : undefined}
|
|
394
|
+
mode={mode}
|
|
395
|
+
onModeChange={onModeChange}
|
|
396
|
+
execPolicy={execPolicy}
|
|
397
|
+
onExecPolicyChange={onExecPolicyChange}
|
|
398
|
+
files={files}
|
|
399
|
+
onFilesChange={(next) => {
|
|
400
|
+
setFiles(next);
|
|
401
|
+
// 用户手动增删文件后,上一次提交的失败提示已过期。
|
|
402
|
+
if (attachmentError) setAttachmentError(null);
|
|
403
|
+
}}
|
|
404
|
+
onMentionReferencesChange={setMentionReferences}
|
|
405
|
+
/>
|
|
406
|
+
</>
|
|
366
407
|
);
|
|
367
408
|
|
|
368
409
|
return (
|
|
@@ -10,7 +10,7 @@ import { InsightsConsentBanner } from '@/components/settings/InsightsSettingsPan
|
|
|
10
10
|
import { trackBehavior } from '@/lib/insights';
|
|
11
11
|
import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
|
|
12
12
|
import { deleteChatIfEmpty, pruneEmptyChats } from '@/lib/local-api';
|
|
13
|
-
import { getPackChatSlots } from '@/packs/registry';
|
|
13
|
+
import { getPackChatSlots, type PackChatSlotContribution } from '@/packs/registry';
|
|
14
14
|
import {
|
|
15
15
|
useChatsAndAgents,
|
|
16
16
|
type UseChatsAndAgentsResult,
|
|
@@ -75,6 +75,46 @@ const MAX_TERMINAL_WIDTH = 960;
|
|
|
75
75
|
*/
|
|
76
76
|
export type RightPanelState = string | null;
|
|
77
77
|
|
|
78
|
+
/** 每个会话各自的右侧栏位状态:chatId → RightPanelState(null 不落盘)。 */
|
|
79
|
+
export type RightPanelMap = Record<string, string>;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 解析持久化的「会话 → 右侧栏位」映射。
|
|
83
|
+
*
|
|
84
|
+
* 兼容三种历史格式:
|
|
85
|
+
* - 新格式:{"<chatId>": "terminal" | "<slotId>"};
|
|
86
|
+
* - 旧格式:整个值就是单个字符串('' / 'terminal' / slotId)→ 迁到当前会话;
|
|
87
|
+
* - 更老:只记录终端是否打开过的布尔 key。
|
|
88
|
+
* 无效的会话 / 栏位值会被丢弃,避免脏数据把某个会话卡在打不开的面板上。
|
|
89
|
+
*/
|
|
90
|
+
export function parseRightPanelMap(options: {
|
|
91
|
+
raw: string | null;
|
|
92
|
+
legacyTerminalOpen: string | null;
|
|
93
|
+
chatId: string | null;
|
|
94
|
+
isValidValue: (value: string) => boolean;
|
|
95
|
+
}): RightPanelMap {
|
|
96
|
+
const { raw, legacyTerminalOpen, chatId, isValidValue } = options;
|
|
97
|
+
if (raw !== null) {
|
|
98
|
+
if (raw === '') return {};
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
101
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
102
|
+
const map: RightPanelMap = {};
|
|
103
|
+
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
104
|
+
if (typeof value === 'string' && isValidValue(value)) map[key] = value;
|
|
105
|
+
}
|
|
106
|
+
return map;
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// 旧格式:整个 key 就是单个值。
|
|
110
|
+
if (isValidValue(raw) && chatId) return { [chatId]: raw };
|
|
111
|
+
}
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
if (legacyTerminalOpen === '1' && chatId) return { [chatId]: 'terminal' };
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
|
|
78
118
|
/** 从聊天页外把一段内容作为普通用户消息发进当前会话(包槽位的 fallback 通道)。 */
|
|
79
119
|
export type ChatMessageSender = (input: {
|
|
80
120
|
content: string;
|
|
@@ -87,6 +127,12 @@ export type AgentOutletContext = UseChatsAndAgentsResult & {
|
|
|
87
127
|
/** 发送普通用户消息到当前聊天;未注册时返回 false。 */
|
|
88
128
|
sendChatMessage: ChatMessageSender;
|
|
89
129
|
inspectTask: (task: InspectedTask) => void;
|
|
130
|
+
/** 包槽位(如文档预览):入口渲染在 chat 标题栏,状态按会话隔离。 */
|
|
131
|
+
chatSlots: readonly PackChatSlotContribution[];
|
|
132
|
+
/** 当前会话打开的右侧栏位(null = 都关着)。 */
|
|
133
|
+
rightPanel: RightPanelState;
|
|
134
|
+
/** 顶栏入口:点已打开的关闭,点另一个直接切换。 */
|
|
135
|
+
onToggleChatSlot: (slotId: string) => void;
|
|
90
136
|
};
|
|
91
137
|
|
|
92
138
|
function AgentLayoutContent() {
|
|
@@ -269,48 +315,81 @@ function AgentLayoutContent() {
|
|
|
269
315
|
// null | 'terminal' | <slotId>。持久化到新 key;老版本只持久化终端打开
|
|
270
316
|
// 状态,首次读取时迁移。
|
|
271
317
|
const packChatSlots = getPackChatSlots();
|
|
272
|
-
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 右侧栏位状态按会话隔离:chatId → null | 'terminal' | <slotId>。
|
|
321
|
+
*
|
|
322
|
+
* 以前是单个全局值(localStorage 里存一个字符串),会话 1 打开预览会连带
|
|
323
|
+
* 影响会话 2。现在存成映射,每个会话独立;老的单值 key 读取时自动迁移到
|
|
324
|
+
* 当前会话。
|
|
325
|
+
*/
|
|
326
|
+
const readRightPanelMap = useCallback((): RightPanelMap => {
|
|
327
|
+
const isValidValue = (value: string): boolean =>
|
|
328
|
+
value === 'terminal' || packChatSlots.some((s) => s.slotId === value);
|
|
273
329
|
try {
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
330
|
+
const map = parseRightPanelMap({
|
|
331
|
+
raw: window.localStorage.getItem(RIGHT_PANEL_KEY),
|
|
332
|
+
legacyTerminalOpen: window.localStorage.getItem(TERMINAL_OPEN_KEY),
|
|
333
|
+
chatId: chatId ?? null,
|
|
334
|
+
isValidValue,
|
|
335
|
+
});
|
|
336
|
+
const sanitized: RightPanelMap = {};
|
|
337
|
+
for (const [id, value] of Object.entries(map)) {
|
|
338
|
+
const next = sanitizeRightPanelKind(value);
|
|
339
|
+
if (next) sanitized[id] = next;
|
|
281
340
|
}
|
|
282
|
-
|
|
283
|
-
return sanitizeRightPanelKind(
|
|
284
|
-
window.localStorage.getItem(TERMINAL_OPEN_KEY) === '1' ? 'terminal' : null,
|
|
285
|
-
);
|
|
341
|
+
return sanitized;
|
|
286
342
|
} catch {
|
|
287
|
-
return
|
|
343
|
+
return {};
|
|
288
344
|
}
|
|
289
|
-
});
|
|
345
|
+
}, [chatId, packChatSlots]);
|
|
290
346
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
347
|
+
const [rightPanelMap, setRightPanelMap] = useState<RightPanelMap>(() =>
|
|
348
|
+
readRightPanelMap(),
|
|
349
|
+
);
|
|
294
350
|
|
|
295
|
-
|
|
351
|
+
// 切换会话时重新读取映射(迁移老 key / 多窗口写入)。
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
setRightPanelMap(readRightPanelMap());
|
|
354
|
+
}, [readRightPanelMap]);
|
|
355
|
+
|
|
356
|
+
const rightPanel: RightPanelState = chatId ? rightPanelMap[chatId] ?? null : null;
|
|
357
|
+
|
|
358
|
+
const persistRightPanelMap = useCallback((map: RightPanelMap) => {
|
|
296
359
|
try {
|
|
297
|
-
window.localStorage.setItem(RIGHT_PANEL_KEY,
|
|
360
|
+
window.localStorage.setItem(RIGHT_PANEL_KEY, JSON.stringify(map));
|
|
298
361
|
} catch {
|
|
299
362
|
/* ignore */
|
|
300
363
|
}
|
|
301
364
|
}, []);
|
|
302
365
|
|
|
366
|
+
const setRightPanel = useCallback(
|
|
367
|
+
(next: RightPanelState) => {
|
|
368
|
+
if (!chatId) return;
|
|
369
|
+
setRightPanelMap((prev) => {
|
|
370
|
+
const map = { ...prev };
|
|
371
|
+
if (next === null) delete map[chatId];
|
|
372
|
+
else map[chatId] = next;
|
|
373
|
+
persistRightPanelMap(map);
|
|
374
|
+
return map;
|
|
375
|
+
});
|
|
376
|
+
},
|
|
377
|
+
[chatId, persistRightPanelMap],
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// 自动展开 / 手动切换回调是挂载时注册的,直接读 state 会拿到过期闭包,
|
|
381
|
+
// 所以用 ref 跟踪当前栏位状态(见下方同步 effect)。
|
|
382
|
+
const rightPanelRef = useRef<RightPanelState>(rightPanel);
|
|
383
|
+
|
|
303
384
|
useEffect(() => {
|
|
304
385
|
rightPanelRef.current = rightPanel;
|
|
305
386
|
}, [rightPanel]);
|
|
306
387
|
|
|
307
388
|
const closeRightPanel = useCallback(() => {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
persistRightPanel(null);
|
|
311
|
-
}, [persistRightPanel]);
|
|
389
|
+
setRightPanel(null);
|
|
390
|
+
}, [setRightPanel]);
|
|
312
391
|
|
|
313
|
-
/**
|
|
392
|
+
/** 顶栏 / 快捷键入口:点已打开的按钮关闭;点另一个按钮直接切换。
|
|
314
393
|
打开任一面板即离开任务 dock(栏位同一时刻只呈现一个内容)。 */
|
|
315
394
|
const toggleRightPanel = useCallback(
|
|
316
395
|
(kind: string) => {
|
|
@@ -318,11 +397,11 @@ function AgentLayoutContent() {
|
|
|
318
397
|
const next = sanitizeRightPanelKind(raw);
|
|
319
398
|
rightPanelRef.current = next;
|
|
320
399
|
if (next !== null) setInspectedTask(null);
|
|
321
|
-
|
|
322
|
-
persistRightPanel(next);
|
|
400
|
+
setRightPanel(next);
|
|
323
401
|
},
|
|
324
|
-
[
|
|
402
|
+
[setRightPanel],
|
|
325
403
|
);
|
|
404
|
+
|
|
326
405
|
// dock 里当前显示的任务(null = 显示终端)。recentTask 在切回终端后仍
|
|
327
406
|
// 保留,终端头部的「后台任务」按钮据此切回来——任务的挑选入口只有一个,
|
|
328
407
|
// 就是 chat 头部的后台任务弹层。
|
|
@@ -369,10 +448,8 @@ function AgentLayoutContent() {
|
|
|
369
448
|
const showTerminalFromTask = useCallback(() => {
|
|
370
449
|
if (!hostToolChrome('terminal')) return;
|
|
371
450
|
setInspectedTask(null);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
persistRightPanel('terminal');
|
|
375
|
-
}, [persistRightPanel]);
|
|
451
|
+
setRightPanel('terminal');
|
|
452
|
+
}, [setRightPanel]);
|
|
376
453
|
|
|
377
454
|
// 包槽位的自动展开(如文档包:后端在本轮产出新设计稿时广播包事件)。
|
|
378
455
|
// 互斥规则:仅当栏位空闲(null)时 reveal 才生效;
|
|
@@ -384,8 +461,7 @@ function AgentLayoutContent() {
|
|
|
384
461
|
reveal: () => {
|
|
385
462
|
if (rightPanelRef.current !== null) return;
|
|
386
463
|
rightPanelRef.current = slot.slotId;
|
|
387
|
-
|
|
388
|
-
persistRightPanel(slot.slotId);
|
|
464
|
+
setRightPanel(slot.slotId);
|
|
389
465
|
},
|
|
390
466
|
getCurrentChatId: () => chatId ?? null,
|
|
391
467
|
});
|
|
@@ -396,7 +472,7 @@ function AgentLayoutContent() {
|
|
|
396
472
|
};
|
|
397
473
|
// packChatSlots 在 bootstrap 后稳定;chatId 经 getCurrentChatId 闭包读取。
|
|
398
474
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
399
|
-
}, [chatId,
|
|
475
|
+
}, [chatId, setRightPanel]);
|
|
400
476
|
|
|
401
477
|
// Same drag ergonomics as the sidebar handle, mirrored: the terminal's
|
|
402
478
|
// right edge is pinned to the window's right padding (p-1.5 = 6px), so the
|
|
@@ -475,7 +551,6 @@ function AgentLayoutContent() {
|
|
|
475
551
|
data={data}
|
|
476
552
|
rightPanel={rightPanel}
|
|
477
553
|
onToggleRightPanel={toggleRightPanel}
|
|
478
|
-
chatSlots={packChatSlots}
|
|
479
554
|
onCollapse={toggleSidebarCollapsed}
|
|
480
555
|
/>
|
|
481
556
|
</div>
|
|
@@ -501,6 +576,9 @@ function AgentLayoutContent() {
|
|
|
501
576
|
registerChatMessageSender,
|
|
502
577
|
sendChatMessage,
|
|
503
578
|
inspectTask,
|
|
579
|
+
chatSlots: packChatSlots,
|
|
580
|
+
rightPanel,
|
|
581
|
+
onToggleChatSlot: toggleRightPanel,
|
|
504
582
|
} satisfies AgentOutletContext}
|
|
505
583
|
/>
|
|
506
584
|
</div>
|
package/src/lib/attachments.ts
CHANGED
|
@@ -39,43 +39,43 @@ export async function fileToBase64(file: File): Promise<string> {
|
|
|
39
39
|
return btoa(binary);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
export
|
|
44
|
-
|
|
42
|
+
/** 单个附件落盘失败的原因(附带原文件,调用方可保留在选择器里让用户重试)。 */
|
|
43
|
+
export interface AttachmentSaveFailure {
|
|
44
|
+
name: string;
|
|
45
|
+
error: string;
|
|
46
|
+
/** 失败前的原始选择项;浏览器模式下 `file` 仍在,可重试。 */
|
|
47
|
+
file: AttachmentFile;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
files: AttachmentFile[]
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
let content = text.trim();
|
|
57
|
-
if (usable.length > 0) {
|
|
58
|
-
const fileRefs = usable.map((file) => `- \`${attachmentRefLabel(file)}\``).join('\n');
|
|
59
|
-
content = content ? `${content}\n\n---\n关联文件:\n${fileRefs}` : `关联文件:\n${fileRefs}`;
|
|
60
|
-
}
|
|
61
|
-
const images = usable
|
|
62
|
-
.filter((file) => isImageFile(file.path || file.name))
|
|
63
|
-
.map((file) => ({ path: attachmentRefLabel(file), name: file.name }));
|
|
64
|
-
return { content, images };
|
|
50
|
+
export interface SaveChatAttachmentsResult {
|
|
51
|
+
/**
|
|
52
|
+
* 本条消息可用的附件:已落盘(返回落盘绝对路径)的项;Electron 下若落盘
|
|
53
|
+
* 失败但源路径仍在,也保留源路径。**不会**再出现浏览器模式那种
|
|
54
|
+
* `path: ''` 的空引用。
|
|
55
|
+
*/
|
|
56
|
+
files: AttachmentFile[];
|
|
57
|
+
/** 无法持久化、也没有任何可用路径的文件——调用方必须让用户看到。 */
|
|
58
|
+
failures: AttachmentSaveFailure[];
|
|
65
59
|
}
|
|
66
60
|
|
|
67
61
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
62
|
+
* 把一批附件持久化到会话附件目录,返回「提交时应使用」的文件列表 + 失败项:
|
|
63
|
+
* - 成功项用落盘路径(稳定、可被 agent 的 local_read_file 读回);
|
|
64
|
+
* - 失败项:Electron 下源路径还可用就退回源路径;浏览器模式下文件没有
|
|
65
|
+
* 路径,落盘失败即不可用,从 `files` 剔除并进 `failures`(避免把
|
|
66
|
+
* 空路径写进消息、让模型以为「收到了文件」却什么都读不到)。
|
|
67
|
+
*
|
|
68
|
+
* 无 attachments 桥 / 空 chatId 时不做持久化,原样返回
|
|
69
|
+
* (`failures` 为空——此时是调用方自己决定不落盘,不算失败)。
|
|
70
|
+
* 浏览器模式只要桥在,仍把字节读成 base64 走 HTTP 落盘。
|
|
71
71
|
*/
|
|
72
72
|
export async function saveChatAttachments(
|
|
73
73
|
chatId: string | null | undefined,
|
|
74
74
|
files: AttachmentFile[],
|
|
75
|
-
): Promise<
|
|
76
|
-
if (!chatId || files.length === 0) return files;
|
|
75
|
+
): Promise<SaveChatAttachmentsResult> {
|
|
76
|
+
if (!chatId || files.length === 0) return { files, failures: [] };
|
|
77
77
|
const bridge = getElectronBridge();
|
|
78
|
-
if (!bridge?.attachments?.save) return files;
|
|
78
|
+
if (!bridge?.attachments?.save) return { files, failures: [] };
|
|
79
79
|
try {
|
|
80
80
|
// 有真实路径走 path 拷贝(Electron);没有则读字节走 data 上传(BS/browser)。
|
|
81
81
|
const payloadFiles = await Promise.all(
|
|
@@ -88,15 +88,65 @@ export async function saveChatAttachments(
|
|
|
88
88
|
const result = await bridge.attachments.save({ chatId, files: payloadFiles });
|
|
89
89
|
const stored = Array.isArray(result?.files) ? result.files : [];
|
|
90
90
|
// 服务端按输入顺序返回逐文件结果,这里按下标对齐回填。
|
|
91
|
-
|
|
91
|
+
const kept: AttachmentFile[] = [];
|
|
92
|
+
const failures: AttachmentSaveFailure[] = [];
|
|
93
|
+
files.forEach((file, index) => {
|
|
92
94
|
const entry = stored[index];
|
|
93
95
|
if (entry && entry.path && !entry.error) {
|
|
94
|
-
|
|
96
|
+
kept.push({ name: entry.name || file.name, path: entry.path });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (file.path) {
|
|
100
|
+
// Electron 源路径仍可用,退回源路径(与旧行为一致)。
|
|
101
|
+
kept.push(file);
|
|
102
|
+
return;
|
|
95
103
|
}
|
|
96
|
-
|
|
104
|
+
failures.push({ name: file.name, error: entry?.error || '上传失败', file });
|
|
97
105
|
});
|
|
106
|
+
return { files: kept, failures };
|
|
98
107
|
} catch (err) {
|
|
108
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
99
109
|
console.warn('[attachments] save failed, falling back to source paths', err);
|
|
100
|
-
|
|
110
|
+
const kept = files.filter((f) => Boolean(f.path));
|
|
111
|
+
const failures = files
|
|
112
|
+
.filter((f) => !f.path)
|
|
113
|
+
.map((f) => ({ name: f.name, error: message, file: f }));
|
|
114
|
+
return { files: kept, failures };
|
|
101
115
|
}
|
|
102
116
|
}
|
|
117
|
+
|
|
118
|
+
/** 把失败项拼成一条给用户看的中文提示(供各提交入口复用)。 */
|
|
119
|
+
export function formatAttachmentFailures(failures: AttachmentSaveFailure[]): string {
|
|
120
|
+
if (failures.length === 0) return '';
|
|
121
|
+
const detail = failures.map((f) => `${f.name}(${f.error})`).join(';');
|
|
122
|
+
return failures.length === 1
|
|
123
|
+
? `文件「${failures[0].name}」未能上传,本条消息未包含它:${failures[0].error}`
|
|
124
|
+
: `${failures.length} 个文件未能上传,本条消息未包含它们:${detail}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 把附件引用段落拼进用户消息正文。**所有文件**(docx / pdf / xlsx / 任意
|
|
129
|
+
* 二进制,不只是图片)都写落盘绝对路径,agent 用 `local_read_file` 读回;
|
|
130
|
+
* 图片的多模态通道是**额外**的,见 {@link collectImageAttachments}。
|
|
131
|
+
* 两个提交入口(会话内 / 落地页)共用本函数,避免装配规则再次分叉。
|
|
132
|
+
*/
|
|
133
|
+
export function appendAttachmentRefs(rawText: string, files: AttachmentFile[]): string {
|
|
134
|
+
if (files.length === 0) return rawText;
|
|
135
|
+
const refs = files.map((f) => `- \`${f.path}\``).join('\n');
|
|
136
|
+
return rawText
|
|
137
|
+
? `${rawText}\n\n---\n关联文件:\n${refs}`
|
|
138
|
+
: `关联文件:\n${refs}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 图片附件的多模态元数据(`metadata.images`)。非图片文件不进这里——它们
|
|
143
|
+
* 已经通过 {@link appendAttachmentRefs} 的路径引用交给 agent 读取,不会被
|
|
144
|
+
* 图片逻辑过滤掉。
|
|
145
|
+
*/
|
|
146
|
+
export function collectImageAttachments(
|
|
147
|
+
files: AttachmentFile[],
|
|
148
|
+
): Array<{ path: string; name: string }> {
|
|
149
|
+
return files
|
|
150
|
+
.filter((f) => isImageFile(f.path))
|
|
151
|
+
.map((f) => ({ path: f.path, name: f.name }));
|
|
152
|
+
}
|
package/src/pages/AgentPage.tsx
CHANGED
|
@@ -61,11 +61,14 @@ import {
|
|
|
61
61
|
setPendingFirstMessage,
|
|
62
62
|
takePendingFirstMessage,
|
|
63
63
|
} from "@/lib/pending-first-message";
|
|
64
|
+
import { BRAND_NAME, pickDefaultAgentId } from "@/brand";
|
|
64
65
|
import {
|
|
65
|
-
|
|
66
|
+
appendAttachmentRefs,
|
|
67
|
+
collectImageAttachments,
|
|
68
|
+
formatAttachmentFailures,
|
|
66
69
|
saveChatAttachments,
|
|
70
|
+
type AttachmentFile,
|
|
67
71
|
} from "@/lib/attachments";
|
|
68
|
-
import { BRAND_NAME, pickDefaultAgentId } from "@/brand";
|
|
69
72
|
|
|
70
73
|
/**
|
|
71
74
|
* `GET /api/v2/chats/:id/messages` returns rows in `createdAt DESC` (latest
|
|
@@ -672,6 +675,15 @@ function AgentChatView({
|
|
|
672
675
|
// 是 fire-and-forget 的,所以这边不需要也不应该处理 chat_title_updated。
|
|
673
676
|
}, []);
|
|
674
677
|
|
|
678
|
+
// 前端流式连接断开(浏览器/网络层)时,后端回合可能仍在运行。不要停在
|
|
679
|
+
// 「请求失败:network error」;立即 remount 做一次对账:
|
|
680
|
+
// - 后端仍 active → 新 mount 从 /live-stream 快照继续展示;
|
|
681
|
+
// - 后端已结束 → 新 mount 从 /messages 拉最终落库消息。
|
|
682
|
+
// 这样就不需要用户手动刷新页面。
|
|
683
|
+
const handleStreamError = useCallback(() => {
|
|
684
|
+
onBranchTick();
|
|
685
|
+
}, [onBranchTick]);
|
|
686
|
+
|
|
675
687
|
const {
|
|
676
688
|
messages,
|
|
677
689
|
isStreaming,
|
|
@@ -687,6 +699,7 @@ function AgentChatView({
|
|
|
687
699
|
transport,
|
|
688
700
|
initialMessages,
|
|
689
701
|
onUnknownEvent: handleUnknownEvent,
|
|
702
|
+
onStreamError: handleStreamError,
|
|
690
703
|
});
|
|
691
704
|
|
|
692
705
|
// ── 切回恢复:远端回合仍在跑,但本 mount 不是发起者 ──────────────────
|
|
@@ -1065,6 +1078,7 @@ function AgentChatView({
|
|
|
1065
1078
|
</div>
|
|
1066
1079
|
)}
|
|
1067
1080
|
<LocalChatPanel
|
|
1081
|
+
chatId={chatId}
|
|
1068
1082
|
messages={effectiveMessages}
|
|
1069
1083
|
isStreaming={effectiveIsStreaming}
|
|
1070
1084
|
onSubmit={handleSubmit}
|
|
@@ -1074,7 +1088,6 @@ function AgentChatView({
|
|
|
1074
1088
|
pendingFollowUps={pendingFollowUps.map((m) => m.content)}
|
|
1075
1089
|
onRemoveFollowUp={removeFollowUp}
|
|
1076
1090
|
className="flex-1"
|
|
1077
|
-
chatId={chatId}
|
|
1078
1091
|
emptyHero={{
|
|
1079
1092
|
title: BRAND_NAME,
|
|
1080
1093
|
subtitle: '输入消息,直接开始一段新对话。',
|
|
@@ -1085,6 +1098,9 @@ function AgentChatView({
|
|
|
1085
1098
|
onBranchSwitched={isElectron() ? onBranchTick : undefined}
|
|
1086
1099
|
onInspectTask={ctx.inspectTask}
|
|
1087
1100
|
tasks={tasks}
|
|
1101
|
+
chatSlots={ctx.chatSlots}
|
|
1102
|
+
rightPanel={ctx.rightPanel}
|
|
1103
|
+
onToggleChatSlot={ctx.onToggleChatSlot}
|
|
1088
1104
|
/>
|
|
1089
1105
|
}
|
|
1090
1106
|
onRegenerate={
|
|
@@ -1225,7 +1241,7 @@ function EmptyChatGate() {
|
|
|
1225
1241
|
const [searchParams] = useSearchParams();
|
|
1226
1242
|
const projectIdFromUrl = searchParams.get("projectId");
|
|
1227
1243
|
const [inputValue, setInputValue] = useState("");
|
|
1228
|
-
const [files, setFiles] = useState<
|
|
1244
|
+
const [files, setFiles] = useState<AttachmentFile[]>([]);
|
|
1229
1245
|
const [mentionReferences, setMentionReferences] = useState<
|
|
1230
1246
|
MentionReference[]
|
|
1231
1247
|
>([]);
|
|
@@ -1293,14 +1309,14 @@ function EmptyChatGate() {
|
|
|
1293
1309
|
|
|
1294
1310
|
const handleSubmit = useCallback(async () => {
|
|
1295
1311
|
if (isCreating) return;
|
|
1296
|
-
|
|
1297
|
-
if (!
|
|
1312
|
+
const rawText = inputValue.trim();
|
|
1313
|
+
if (!rawText && files.length === 0) return;
|
|
1298
1314
|
trackBehavior('composer_send', {
|
|
1299
1315
|
empty: false,
|
|
1300
1316
|
home: true,
|
|
1301
1317
|
mode: mode === 'plan' ? 'plan' : 'agent',
|
|
1302
1318
|
execPolicy,
|
|
1303
|
-
length:
|
|
1319
|
+
length: rawText.length,
|
|
1304
1320
|
});
|
|
1305
1321
|
|
|
1306
1322
|
const mentionedAgentIds = mentionReferences
|
|
@@ -1309,6 +1325,18 @@ function EmptyChatGate() {
|
|
|
1309
1325
|
const referencedChatIds = mentionReferences
|
|
1310
1326
|
.filter((ref) => ref.type === "chat")
|
|
1311
1327
|
.map((ref) => ref.id);
|
|
1328
|
+
// 文件路径引用与图像元数据在会话建好、附件落盘之后再装配(见下)——
|
|
1329
|
+
// 落地页提交时还没有 chatId,不能像旧代码那样直接用 `f.path`(浏览器
|
|
1330
|
+
// 模式下它是空串,会把一条空引用写进正文,模型以为收到了文件却读不到)。
|
|
1331
|
+
const baseMetadata = {
|
|
1332
|
+
...(mode === "plan" ? { mode: "plan" as const } : {}),
|
|
1333
|
+
...(execPolicy === "full" ? { execPolicy: "full" as const } : {}),
|
|
1334
|
+
...(ctx.selectedAgentId ? { agentId: ctx.selectedAgentId } : {}),
|
|
1335
|
+
...(mentionedAgentIds.length > 0 ? { mentionedAgentIds } : {}),
|
|
1336
|
+
...(referencedChatIds.length > 0 ? { referencedChatIds } : {}),
|
|
1337
|
+
...(modelOverride ? { model: modelOverride } : {}),
|
|
1338
|
+
...(effortOverride ? { reasoningEffort: effortOverride } : {}),
|
|
1339
|
+
};
|
|
1312
1340
|
|
|
1313
1341
|
setIsCreating(true);
|
|
1314
1342
|
setCreateError(null);
|
|
@@ -1318,23 +1346,29 @@ function EmptyChatGate() {
|
|
|
1318
1346
|
...(ctx.selectedAgentId ? { agentId: ctx.selectedAgentId } : {}),
|
|
1319
1347
|
});
|
|
1320
1348
|
if (!id) throw new Error("创建对话失败,请重试");
|
|
1321
|
-
//
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
const
|
|
1349
|
+
// 会话已建,把附件落进会话空间(与 LocalChatPanel 同一套语义):
|
|
1350
|
+
// 所有文件都写落盘路径引用(agent 用 local_read_file 读回);仅图片
|
|
1351
|
+
// 额外进 metadata.images 走多模态,非图片文件不会被图片逻辑吞掉。
|
|
1352
|
+
const { files: resolvedFiles, failures } = await saveChatAttachments(id, files);
|
|
1353
|
+
if (failures.length > 0) {
|
|
1354
|
+
// 落地页一旦跳转就没法让用户重试了,所以这里宁可中止本次发送:
|
|
1355
|
+
// 删掉刚建的空会话,保留输入框(含失败文件),把原因显示出来。
|
|
1356
|
+
await ctx.deleteChat(id);
|
|
1357
|
+
setCreateError(formatAttachmentFailures(failures));
|
|
1358
|
+
setIsCreating(false);
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
const content = appendAttachmentRefs(rawText, resolvedFiles);
|
|
1363
|
+
const imageAttachments = collectImageAttachments(resolvedFiles);
|
|
1325
1364
|
const metadata = {
|
|
1326
|
-
...
|
|
1327
|
-
...(
|
|
1328
|
-
...(ctx.selectedAgentId ? { agentId: ctx.selectedAgentId } : {}),
|
|
1329
|
-
...(mentionedAgentIds.length > 0 ? { mentionedAgentIds } : {}),
|
|
1330
|
-
...(referencedChatIds.length > 0 ? { referencedChatIds } : {}),
|
|
1331
|
-
...(modelOverride ? { model: modelOverride } : {}),
|
|
1332
|
-
...(effortOverride ? { reasoningEffort: effortOverride } : {}),
|
|
1333
|
-
...(assembled.images.length > 0 ? { images: assembled.images } : {}),
|
|
1365
|
+
...baseMetadata,
|
|
1366
|
+
...(imageAttachments.length > 0 ? { images: imageAttachments } : {}),
|
|
1334
1367
|
};
|
|
1368
|
+
|
|
1335
1369
|
setPendingFirstMessage({
|
|
1336
1370
|
chatId: id,
|
|
1337
|
-
content
|
|
1371
|
+
content,
|
|
1338
1372
|
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
|
1339
1373
|
});
|
|
1340
1374
|
navigate(`/agent/${id}`);
|