@allbluecn/web-app 0.19.0 → 0.19.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "files": [
@@ -58,10 +58,10 @@
58
58
  "access": "public"
59
59
  },
60
60
  "dependencies": {
61
+ "@ag-ui/core": "1.0.0",
61
62
  "@allbluecn/collab-core": "0.2.0",
62
- "@allbluecn/database": "0.9.3",
63
+ "@allbluecn/database": "0.9.4",
63
64
  "@allbluecn/kernel": "0.10.0",
64
- "@ag-ui/core": "1.0.0",
65
65
  "@ariakit/react": "^0.4.28",
66
66
  "@auth/core": "^0.41.2",
67
67
  "@auth/prisma-adapter": "^2.11.2",
@@ -56,7 +56,7 @@ export function AiChatPrompt({ hasAiKey }: { hasAiKey?: boolean | null }) {
56
56
  // threadId mount 级稳定值(useRef 初始值,绝无 render 路径重新生成):
57
57
  // 1.0 起 threadId 必填,且 useChat 在 connection/选项变化时重建 ChatClient——
58
58
  // 每次 render 新值 = 每次发消息开新会话。
59
- const threadIdRef = useRef(crypto.randomUUID())
59
+ const threadIdRef = useRef(crypto.randomUUID());
60
60
 
61
61
  // reconnect 约束(P1-5):同 harness/chat-ui.tsx,dev server 故障期减少自动
62
62
  // 重放 POST(默认 5 次 → 2 次、退避 250ms → 1s),避免 400 风暴。
@@ -286,7 +286,7 @@ export function GlobalHarnessSidebar({ hasAiKey }: { hasAiKey?: boolean | null }
286
286
  // 空会话态(尚无 activeThreadId)的 threadId 兜底:mount 级稳定 UUID。
287
287
  // 缺省时客户端每次发消息 mint 新 thread_*(= 每次开新会话);真实会话 id
288
288
  // 在新建/切换会话后经 activeThreadId 接管。
289
- const fallbackThreadIdRef = useRef(crypto.randomUUID())
289
+ const fallbackThreadIdRef = useRef(crypto.randomUUID());
290
290
 
291
291
  const chat = useHarnessChat({
292
292
  threadId: activeThreadId ?? fallbackThreadIdRef.current,
@@ -118,9 +118,12 @@ export function CommandPalette() {
118
118
 
119
119
  useHotkeys(
120
120
  commands
121
- .filter((c) => c.shortcut && c.shortcut.length >= 2)
121
+ // 类型谓词收窄:shortcut 已前移为 HotkeySequence(registry 录入非法
122
+ // 键名在定义处即 tsc 报错),此处无需再 as 断言
123
+ .filter((c): c is ResolvedCommand & { shortcut: HotkeySequence } =>
124
+ !!c.shortcut && c.shortcut.length >= 2)
122
125
  .map((c) => ({
123
- hotkey: c.shortcut as HotkeySequence,
126
+ hotkey: c.shortcut,
124
127
  callback: () => run(c),
125
128
  options: { enabled: !open },
126
129
  })),
@@ -149,7 +152,7 @@ export function CommandPalette() {
149
152
  )
150
153
  }
151
154
 
152
- function resolveShortcut(shortcut: string[]): string {
155
+ function resolveShortcut(shortcut: readonly string[]): string {
153
156
  const isMac = typeof navigator !== "undefined"
154
157
  && (navigator.platform.toUpperCase().includes("MAC")
155
158
  || navigator.userAgent.toUpperCase().includes("MAC"))
@@ -62,6 +62,24 @@ describe("parseStoredDraft(草稿串 parse 回 doc,修复双重序列化)"
62
62
  expect(parseStoredDraft("hello draft")).toBe("hello draft")
63
63
  })
64
64
 
65
+ it("doc 缺 content 或 content 非数组(损坏值)拒绝为空 doc;content 空数组合法", () => {
66
+ // sessionStorage 手改/损坏:content 缺失
67
+ expect(parseStoredDraft(JSON.stringify({ type: "doc" }))).toEqual({
68
+ type: "doc",
69
+ content: [],
70
+ })
71
+ // content 为字符串而非数组
72
+ expect(parseStoredDraft(JSON.stringify({ type: "doc", content: "oops" }))).toEqual({
73
+ type: "doc",
74
+ content: [],
75
+ })
76
+ // content 空数组仍是合法 doc(原样 parse 回)
77
+ expect(parseStoredDraft(JSON.stringify({ type: "doc", content: [] }))).toEqual({
78
+ type: "doc",
79
+ content: [],
80
+ })
81
+ })
82
+
65
83
  it("空串返回空串", () => {
66
84
  expect(parseStoredDraft("")).toBe("")
67
85
  })
@@ -0,0 +1,260 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ /**
19
+ * 空态首条消息孤儿线程修复的回归测试(T5/T8 发现)。
20
+ *
21
+ * useHarnessChat 工厂按 threadId 重建 ChatClient(useMemo 依赖 threadId)。
22
+ * 修复前:空态发送时 handleSend 建会话后直接调用旧渲染闭包里的
23
+ * chat.sendMessage——旧 client 仍绑定兜底 UUID,首条交换持久化到孤儿线程。
24
+ * 修复后:先建会话 → setActiveConversationId + pendingSend 槽 → effect 在
25
+ * hook 重绑定真实会话 id 的提交里消费发送。
26
+ *
27
+ * 测试口径:mock useHarnessChat 记录每次渲染绑定的 threadId;sendMessage
28
+ * 按其所属绑定的 threadId 写入事件日志,createConversationFn 同样写日志。
29
+ * 断言「先 create 后 send」且 send 的 threadId == 创建的会话 id。
30
+ */
31
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
32
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
33
+ import type { ReactNode } from "react"
34
+ import HarnessWorkbenchPage from "../harness-workbench-page"
35
+ import { createConversationFn } from "@/server/serverFns/chat/conversations"
36
+ import { stashPendingWorkspaceMessage, PENDING_WORKSPACE_MESSAGE_KEY } from "@/lib/ai/chat-handoff"
37
+
38
+ const CONV_ID = "conv-real-1"
39
+
40
+ const h = vi.hoisted(() => {
41
+ return {
42
+ // 事件日志:'create:<id>' / 'send:<绑定线程id>',按发生顺序
43
+ log: [] as string[],
44
+ // 每次 useHarnessChat 渲染绑定的 threadId
45
+ bindings: [] as string[],
46
+ // 每次 sendMessage 的实参(wire 内容与透传选项)
47
+ sends: [] as Array<{ threadId: string; content: unknown; sendOptions: unknown }>,
48
+ }
49
+ })
50
+
51
+ vi.mock("@/components/harness/chat-ui", () => ({
52
+ useHarnessChat: (options: { threadId: string }) => {
53
+ h.bindings.push(options.threadId)
54
+ return {
55
+ messages: [],
56
+ isLoading: false,
57
+ status: "idle",
58
+ connectionStatus: "connected",
59
+ error: undefined,
60
+ runId: undefined,
61
+ interrupts: [],
62
+ sendMessage: (content: unknown, sendOptions?: unknown) => {
63
+ h.log.push(`send:${options.threadId}`)
64
+ h.sends.push({ threadId: options.threadId, content, sendOptions })
65
+ return Promise.resolve()
66
+ },
67
+ stop: () => {},
68
+ clear: () => {},
69
+ AppChat: () => null,
70
+ }
71
+ },
72
+ useHarnessChatContext: () => ({}),
73
+ }))
74
+
75
+ vi.mock("@tanstack/react-router", () => ({
76
+ useNavigate: () => vi.fn(),
77
+ Link: (props: { to: string; children?: ReactNode }) => (
78
+ <a href={props.to}>{props.children}</a>
79
+ ),
80
+ }))
81
+
82
+ vi.mock("@tanstack/react-query", () => ({
83
+ useQuery: () => ({ data: undefined }),
84
+ }))
85
+
86
+ vi.mock("@/lib/auth/auth-client", () => ({
87
+ useSession: () => ({ data: { user: { hasAiKey: true } } }),
88
+ }))
89
+
90
+ vi.mock("@/server/serverFns/chat/conversations", () => ({
91
+ listConversationsFn: vi.fn(() => Promise.resolve([])),
92
+ createConversationFn: vi.fn(async () => {
93
+ h.log.push(`create:${CONV_ID}`)
94
+ return { id: CONV_ID, title: "" }
95
+ }),
96
+ updateConversationFn: vi.fn(() => Promise.resolve({})),
97
+ deleteConversationFn: vi.fn(() => Promise.resolve({})),
98
+ forkConversationFn: vi.fn(() => Promise.resolve({})),
99
+ }))
100
+
101
+ vi.mock("@/server/serverFns/agents/agent-crud", () => ({
102
+ listAgentsFn: vi.fn(() => Promise.resolve([])),
103
+ }))
104
+
105
+ vi.mock("@/server/serverFns/design/ai/providers", () => ({
106
+ listAiProvidersFn: vi.fn(() => Promise.resolve([])),
107
+ }))
108
+
109
+ vi.mock("@/server/serverFns/harness/cancel-run", () => ({
110
+ cancelRunFn: vi.fn(() => Promise.resolve({})),
111
+ }))
112
+
113
+ vi.mock("@/lib/ai/use-selected-model", () => ({
114
+ useSelectedModel: () => ["", vi.fn()] as const,
115
+ }))
116
+
117
+ vi.mock("@/lib/ai/use-search-capability", () => ({
118
+ useSearchCapability: () => ({
119
+ enabled: false,
120
+ adapterKind: "openrouter",
121
+ hasNative: true,
122
+ searxngEnabled: false,
123
+ }),
124
+ }))
125
+
126
+ vi.mock("@/lib/ai/use-code-mode-readiness", () => ({
127
+ useCodeModeReadiness: () => ({ enabled: false }),
128
+ }))
129
+
130
+ vi.mock("@/components/harness/composer/harness-composer", () => ({
131
+ HarnessComposer: ({ onSend }: { onSend: (val: string, opts?: unknown) => void }) => (
132
+ <>
133
+ <button type="button" onClick={() => onSend("hello there")}>test-send</button>
134
+ <button
135
+ type="button"
136
+ onClick={() => onSend("rich payload", {
137
+ whenBusy: "interrupt",
138
+ knowledgeBaseIds: ["kb-1"],
139
+ attachments: [{
140
+ type: "image",
141
+ source: { type: "data", value: "data:image/png;base64,xyz", mimeType: "image/png" },
142
+ }],
143
+ })}
144
+ >
145
+ test-send-rich
146
+ </button>
147
+ </>
148
+ ),
149
+ }))
150
+
151
+ vi.mock("@/components/harness/ai-chat-history", () => ({ default: () => null }))
152
+ vi.mock("@/components/harness/agent-preset-picker", () => ({ AgentPresetPicker: () => null }))
153
+ vi.mock("@/components/harness/dock/dock-panel-container", () => ({ DockPanelContainer: () => null }))
154
+ vi.mock("@/components/harness/workbench/workbench-shell", () => ({
155
+ WorkbenchShell: ({ center }: { center: ReactNode }) => <div>{center}</div>,
156
+ }))
157
+ vi.mock("@/components/harness/code/code-workspace-sidebar", () => ({
158
+ CodeWorkspaceSidebar: () => null,
159
+ }))
160
+ vi.mock("@/components/confirm-dialog", () => ({ ConfirmDialog: () => null }))
161
+
162
+ beforeEach(() => {
163
+ h.log.length = 0
164
+ h.bindings.length = 0
165
+ h.sends.length = 0
166
+ sessionStorage.clear()
167
+ })
168
+
169
+ afterEach(cleanup)
170
+
171
+ describe("HarnessWorkbenchPage 空态首条消息", () => {
172
+ it("先创建会话,且 wire threadId == 创建的会话 id(非兜底 UUID)", async () => {
173
+ render(<HarnessWorkbenchPage />)
174
+ fireEvent.click(screen.getByRole("button", { name: "test-send" }))
175
+ await waitFor(() => {
176
+ expect(h.log).toEqual([`create:${CONV_ID}`, `send:${CONV_ID}`])
177
+ })
178
+ // 无任何发送落在兜底 UUID 线程
179
+ expect(h.log.filter((l) => l.startsWith("send:"))).toEqual([`send:${CONV_ID}`])
180
+ // 发送内容为用户输入原文,无 sendOptions 时透传侧不被伪造
181
+ expect(h.sends).toEqual([
182
+ { threadId: CONV_ID, content: "hello there", sendOptions: undefined },
183
+ ])
184
+ })
185
+
186
+ it("会话创建后的再次发送不新建会话,仍绑定同一会话 id", async () => {
187
+ render(<HarnessWorkbenchPage />)
188
+ const send = screen.getByRole("button", { name: "test-send" })
189
+ fireEvent.click(send)
190
+ await waitFor(() => {
191
+ expect(h.log).toEqual([`create:${CONV_ID}`, `send:${CONV_ID}`])
192
+ })
193
+ fireEvent.click(send)
194
+ await waitFor(() => {
195
+ expect(h.log).toEqual([`create:${CONV_ID}`, `send:${CONV_ID}`, `send:${CONV_ID}`])
196
+ })
197
+ expect(h.bindings.filter((id) => id === CONV_ID).length).toBeGreaterThan(0)
198
+ // 两次发送 content 均为原文且都绑定真实会话
199
+ expect(h.sends).toEqual([
200
+ { threadId: CONV_ID, content: "hello there", sendOptions: undefined },
201
+ { threadId: CONV_ID, content: "hello there", sendOptions: undefined },
202
+ ])
203
+ })
204
+
205
+ it("会话创建失败时放弃发送(不产生孤儿线程)", async () => {
206
+ vi.mocked(createConversationFn).mockRejectedValueOnce(new Error("boom"))
207
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
208
+ try {
209
+ render(<HarnessWorkbenchPage />)
210
+ fireEvent.click(screen.getByRole("button", { name: "test-send" }))
211
+ await waitFor(() => {
212
+ expect(vi.mocked(createConversationFn)).toHaveBeenCalled()
213
+ })
214
+ expect(h.log.filter((l) => l.startsWith("send:"))).toEqual([])
215
+ expect(h.sends).toEqual([])
216
+ } finally {
217
+ errorSpy.mockRestore()
218
+ }
219
+ })
220
+
221
+ it("工作区交接 pending 消息在 mount 时发送到新建会话 id", async () => {
222
+ stashPendingWorkspaceMessage("handoff hello")
223
+ render(<HarnessWorkbenchPage />)
224
+ await waitFor(() => {
225
+ expect(h.log).toEqual([`create:${CONV_ID}`, `send:${CONV_ID}`])
226
+ })
227
+ // 交接内容原文到达发送侧(防槽位内容丢失发空串)
228
+ expect(h.sends).toEqual([
229
+ { threadId: CONV_ID, content: "handoff hello", sendOptions: undefined },
230
+ ])
231
+ expect(sessionStorage.getItem(PENDING_WORKSPACE_MESSAGE_KEY)).toBeNull()
232
+ })
233
+
234
+ it("空态首条 rich 发送透传 sendOptions 与多模态 content 到真实会话", async () => {
235
+ render(<HarnessWorkbenchPage />)
236
+ fireEvent.click(screen.getByRole("button", { name: "test-send-rich" }))
237
+ await waitFor(() => {
238
+ expect(h.log).toEqual([`create:${CONV_ID}`, `send:${CONV_ID}`])
239
+ })
240
+ // whenBusy / knowledgeBaseIds(body) / 图片 attachments 经 pendingSend 槽零丢失
241
+ expect(h.sends).toEqual([
242
+ {
243
+ threadId: CONV_ID,
244
+ content: {
245
+ content: [
246
+ { type: "text", content: "rich payload" },
247
+ {
248
+ type: "image",
249
+ source: { type: "data", value: "data:image/png;base64,xyz", mimeType: "image/png" },
250
+ },
251
+ ],
252
+ },
253
+ sendOptions: {
254
+ whenBusy: "interrupt",
255
+ body: { knowledgeBaseIds: ["kb-1"] },
256
+ },
257
+ },
258
+ ])
259
+ })
260
+ })
@@ -33,7 +33,11 @@ function isDocNode(value: unknown): value is JSONContent {
33
33
  typeof value === "object" &&
34
34
  value !== null &&
35
35
  !Array.isArray(value) &&
36
- (value as Record<string, unknown>).type === "doc"
36
+ (value as Record<string, unknown>).type === "doc" &&
37
+ // content 形状校验:doc 节点的 content 必须是数组(可为空)。
38
+ // 防 sessionStorage 手改/损坏值(content 缺失或非数组)直进 TipTap
39
+ // 触发 schema 断言崩溃。
40
+ Array.isArray((value as Record<string, unknown>).content)
37
41
  )
38
42
  }
39
43
 
@@ -64,6 +64,18 @@ export interface HarnessWorkbenchPageProps {
64
64
  initialMessages?: UIMessage[]
65
65
  }
66
66
 
67
+ type HarnessChatInstance = ReturnType<typeof useHarnessChat>
68
+
69
+ /**
70
+ * 空态首条消息的延迟发送槽(孤儿线程修复,见 pendingSend effect)。
71
+ * content/sendOptions 形状跟随 useHarnessChat.sendMessage 的真实签名。
72
+ */
73
+ interface PendingThreadSend {
74
+ threadId: string
75
+ content: Parameters<HarnessChatInstance["sendMessage"]>[0]
76
+ sendOptions?: Parameters<HarnessChatInstance["sendMessage"]>[1]
77
+ }
78
+
67
79
  export default function HarnessWorkbenchPage({ initialThreadId, initialMessages }: HarnessWorkbenchPageProps) {
68
80
  const { data: session } = useSession()
69
81
  const hasAiKey = session?.user?.hasAiKey
@@ -132,7 +144,7 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
132
144
  // 空会话态(尚无 activeConversationId)的 threadId 兜底:mount 级稳定 UUID。
133
145
  // 缺省时客户端每次发消息 mint 新 thread_*(= 每次开新会话);真实会话 id
134
146
  // 在 handleSend 创建后经 activeConversationId 接管。
135
- const fallbackThreadIdRef = useRef(crypto.randomUUID())
147
+ const fallbackThreadIdRef = useRef(crypto.randomUUID());
136
148
 
137
149
  const chat = useHarnessChat({
138
150
  threadId: activeConversationId ?? initialThreadId ?? fallbackThreadIdRef.current,
@@ -159,6 +171,21 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
159
171
  const activeIdRef = useRef(activeConversationId)
160
172
  activeIdRef.current = activeConversationId
161
173
  const loadingRef = useRef(false)
174
+ const [pendingSend, setPendingSend] = useState<PendingThreadSend | null>(null)
175
+ const creatingConversationRef = useRef(false)
176
+
177
+ // 孤儿线程修复(T5/T8):useHarnessChat 工厂按 threadId 重建 ChatClient
178
+ // (useMemo 依赖 options.threadId),空态首条发送时 activeConversationId
179
+ // 尚未经历渲染——直接 chat.sendMessage 会走旧 client 兜底 UUID 线程。
180
+ // 因此先建会话 → setActiveConversationId + 落 pendingSend 槽 → 本 effect
181
+ // 在 hook 已重绑定真实会话 id 的那次提交里消费并发送。
182
+ useEffect(() => {
183
+ if (!pendingSend) return
184
+ // 守卫:仅在 hook 已绑定目标会话时才消费(防陈旧槽位误发)
185
+ if ((activeConversationId ?? initialThreadId) !== pendingSend.threadId) return
186
+ setPendingSend(null)
187
+ void chat.sendMessage(pendingSend.content, pendingSend.sendOptions)
188
+ }, [pendingSend, chat, activeConversationId, initialThreadId])
162
189
 
163
190
  const loadConversations = useCallback(() => {
164
191
  if (initialThreadId != null) return
@@ -237,8 +264,10 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
237
264
  activeIdRef.current = conv.id
238
265
  clearHarnessCitations()
239
266
  setLatestCitations(undefined)
240
- chat.clear()
241
- void chat.sendMessage(pending)
267
+ // 与 handleSend 空态同理:经 pendingSend 槽等 hook 重绑定后再发送,
268
+ // 避免首条交接消息落进兜底 UUID 孤儿线程(此路径 hook 为全新实例,
269
+ // 新 client 以空消息起播,无需 clear)
270
+ setPendingSend({ threadId: conv.id, content: pending })
242
271
  }
243
272
 
244
273
  void (async () => {
@@ -350,35 +379,38 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
350
379
  },
351
380
  })
352
381
  }
382
+ // knowledgeBaseIds 通过 body 传递到 forwardedProps
383
+ const sendOptions = opts ? {
384
+ whenBusy: opts.whenBusy,
385
+ body: opts.knowledgeBaseIds ? { knowledgeBaseIds: opts.knowledgeBaseIds } : undefined,
386
+ } : undefined
387
+ const content: Parameters<HarnessChatInstance["sendMessage"]>[0]
388
+ = opts?.attachments && opts.attachments.length > 0
389
+ // 多模态消息:文本 + 图片 parts
390
+ ? { content: [{ type: 'text' as const, content: val }, ...opts.attachments] }
391
+ : val
353
392
  if (!activeIdRef.current) {
393
+ // 空态首条消息:先建会话拿真实 id(失败早退),再经 pendingSend 槽
394
+ // 等 hook 以新 threadId 重建 client 后发送——直接 sendMessage 会把
395
+ // 首条交换持久化到兜底 UUID 的孤儿线程(刷新后首条不回显)。
396
+ if (creatingConversationRef.current) return
397
+ creatingConversationRef.current = true
354
398
  try {
355
399
  const conv = await createConversationFn({ data: { agentId: selectedAgentId } })
356
400
  setConversations(prev => [conv, ...prev])
357
401
  setActiveConversationId(conv.id)
358
402
  activeIdRef.current = conv.id
359
403
  clearHarnessCitations()
404
+ setPendingSend({ threadId: conv.id, content, sendOptions })
360
405
  } catch (e) {
361
406
  console.error('Failed to create conversation:', e)
362
407
  return
408
+ } finally {
409
+ creatingConversationRef.current = false
363
410
  }
411
+ return
364
412
  }
365
- // knowledgeBaseIds 通过 body 传递到 forwardedProps
366
- const sendOptions = opts ? {
367
- whenBusy: opts.whenBusy,
368
- body: opts.knowledgeBaseIds ? { knowledgeBaseIds: opts.knowledgeBaseIds } : undefined,
369
- } : undefined
370
- if (opts?.attachments && opts.attachments.length > 0) {
371
- // 多模态消息:文本 + 图片 parts
372
- const content = {
373
- content: [
374
- { type: 'text' as const, content: val },
375
- ...opts.attachments
376
- ]
377
- }
378
- void chat.sendMessage(content, sendOptions)
379
- } else {
380
- void chat.sendMessage(val, sendOptions)
381
- }
413
+ void chat.sendMessage(content, sendOptions)
382
414
  }, [chat, selectedAgentId])
383
415
 
384
416
  const handleStop = useCallback(() => {
@@ -101,7 +101,7 @@ export function AiMenuContent({
101
101
 
102
102
  // threadId mount 级稳定值(useRef 初始值,绝无 render 路径重新生成):
103
103
  // 1.0 起 threadId 必填,每次 render 新值 = 每次发消息开新会话。
104
- const threadIdRef = useRef(crypto.randomUUID())
104
+ const threadIdRef = useRef(crypto.randomUUID());
105
105
 
106
106
  const { sendMessage, isLoading, stop, messages } = useChat({
107
107
  // reconnect 约束(P1-5):同 harness/chat-ui.tsx,dev server 故障期减少自动重放 POST
@@ -95,8 +95,8 @@ export const SDK_GROUPS: SdkGroup[] = [
95
95
  "items": [
96
96
  {
97
97
  "name": "@allbluecn/web-app",
98
- "version": "v0.19.0",
99
- "lastUpdate": "Oct 25, 2026",
98
+ "version": "v0.19.2",
99
+ "lastUpdate": "Oct 26, 2026",
100
100
  "source": "apps/web/package.json",
101
101
  "docsUrl": "https://github.com/allbluecn/allblue"
102
102
  },
@@ -15,6 +15,8 @@
15
15
  // You should have received a copy of the GNU Affero General Public License
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
+ import type { HotkeySequence } from "@tanstack/hotkeys"
19
+
18
20
  export type CommandAction =
19
21
  | { kind: "navigate"; to: string }
20
22
  | { kind: "callback"; id: string }
@@ -28,8 +30,8 @@ export interface CommandDefinition {
28
30
  group: CommandGroup
29
31
  action: CommandAction
30
32
  keywords?: string[]
31
- /** 序列键(如 ["G","D"]),注册为 useHotkeys */
32
- shortcut?: string[]
33
+ /** 序列键(如 ["G","D"]),注册为 useHotkeys;非法键名在定义处即报 tsc 错误 */
34
+ shortcut?: HotkeySequence
33
35
  /** 组合键(如 ["Mod","Shift","L"]),仅用于面板展示 */
34
36
  chord?: string[]
35
37
  }
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => {
11
11
  const pgLockStore = vi.fn()
12
12
  const generationRunFindMany = vi.fn()
13
13
  const generationRunFindUnique = vi.fn()
14
+ const generationRunUpsert = vi.fn()
14
15
  return {
15
16
  withSandbox,
16
17
  defineSandbox,
@@ -20,6 +21,7 @@ const mocks = vi.hoisted(() => {
20
21
  pgLockStore,
21
22
  generationRunFindMany,
22
23
  generationRunFindUnique,
24
+ generationRunUpsert,
23
25
  }
24
26
  })
25
27
 
@@ -50,6 +52,7 @@ vi.mock("@allbluecn/database", () => ({
50
52
  generationRun: {
51
53
  findMany: mocks.generationRunFindMany,
52
54
  findUnique: mocks.generationRunFindUnique,
55
+ upsert: mocks.generationRunUpsert,
53
56
  },
54
57
  },
55
58
  }))
@@ -186,6 +189,11 @@ describe("buildSandboxMiddleware 装配", () => {
186
189
  describe("snapshots artifacts/blobs store 装配", () => {
187
190
  beforeEach(() => {
188
191
  vi.clearAllMocks()
192
+ // 显式 mockReset 对齐第一个 describe 的卫生标准(clearAllMocks 只清
193
+ // 调用记录,不清 mockResolvedValue 等实现,跨用例会泄漏)
194
+ mocks.generationRunFindMany.mockReset()
195
+ mocks.generationRunFindUnique.mockReset()
196
+ mocks.generationRunUpsert.mockReset()
189
197
  vi.unstubAllEnvs()
190
198
  delete process.env.DEPLOY_TARGET
191
199
  mocks.resolveSandboxProvider.mockResolvedValue({ provider: { name: "mock" } })
@@ -202,7 +210,11 @@ describe("snapshots artifacts/blobs store 装配", () => {
202
210
  persistence?: {
203
211
  stores?: {
204
212
  artifacts?: { listForThread: (threadId: string) => Promise<Array<Record<string, unknown>>> }
205
- blobs?: { get: (key: string) => Promise<{ arrayBuffer: () => Promise<ArrayBuffer> } | null> }
213
+ blobs?: {
214
+ get: (key: string) => Promise<{ arrayBuffer: () => Promise<ArrayBuffer> } | null>
215
+ head: (key: string) => Promise<{ size: number } | null>
216
+ put: (key: string, body: Uint8Array) => Promise<{ size: number }>
217
+ }
206
218
  }
207
219
  }
208
220
  }
@@ -255,4 +267,62 @@ describe("snapshots artifacts/blobs store 装配", () => {
255
267
  expect.objectContaining({ where: { id: "run-no-artifact" } }),
256
268
  )
257
269
  })
270
+
271
+ it("blobs.put 全新 key 的 create 分支携带真实 threadId/userId(而非 key 充当主外键)", async () => {
272
+ mocks.generationRunUpsert.mockResolvedValue({})
273
+ await buildSandboxMiddleware(baseParams({ userId: "user-1" }))
274
+ const stores = snapshotStores()
275
+
276
+ const bytes = new TextEncoder().encode("hello")
277
+ const result = await stores.blobs!.put("sandbox-files/sha256/abc", bytes)
278
+
279
+ expect(result).toEqual({ size: bytes.byteLength })
280
+ expect(mocks.generationRunUpsert).toHaveBeenCalledTimes(1)
281
+ const args = mocks.generationRunUpsert.mock.calls[0][0] as {
282
+ where: { id: string }
283
+ create: Record<string, unknown>
284
+ }
285
+ expect(args.where).toEqual({ id: "sandbox-files/sha256/abc" })
286
+ // 回归断言:threadId/userId 是真实归属(FK 指向 ChatThread),绝不能是 blob key
287
+ expect(args.create.threadId).toBe("thread-1")
288
+ expect(args.create.userId).toBe("user-1")
289
+ expect(args.create).toMatchObject({
290
+ id: "sandbox-files/sha256/abc",
291
+ kind: "sandbox-blob",
292
+ status: "completed",
293
+ activity: "sandbox",
294
+ result: Buffer.from(bytes).toString("base64"),
295
+ })
296
+ })
297
+
298
+ it("blobs.put 未传 userId 时 create 落 sandbox 标记(desktop 后台路径无 FK 风险)", async () => {
299
+ mocks.generationRunUpsert.mockResolvedValue({})
300
+ await buildSandboxMiddleware(baseParams())
301
+ const stores = snapshotStores()
302
+
303
+ await stores.blobs!.put("sandbox-files/sha256/xyz", new Uint8Array([1]))
304
+
305
+ const args = mocks.generationRunUpsert.mock.calls[0][0] as {
306
+ create: Record<string, unknown>
307
+ }
308
+ expect(args.create.threadId).toBe("thread-1")
309
+ expect(args.create.userId).toBe("sandbox")
310
+ })
311
+
312
+ it("blobs.put 已存在 key 走 update 分支仅写 result(既有行为不变)", async () => {
313
+ mocks.generationRunUpsert.mockResolvedValue({})
314
+ await buildSandboxMiddleware(baseParams({ userId: "user-1" }))
315
+ const stores = snapshotStores()
316
+
317
+ await stores.blobs!.put("run-existing", new TextEncoder().encode("updated"))
318
+
319
+ const args = mocks.generationRunUpsert.mock.calls[0][0] as {
320
+ where: { id: string }
321
+ update: Record<string, unknown>
322
+ }
323
+ expect(args.where).toEqual({ id: "run-existing" })
324
+ expect(args.update).toEqual({
325
+ result: Buffer.from("updated").toString("base64"),
326
+ })
327
+ })
258
328
  })
@@ -251,13 +251,20 @@ export async function buildSandboxMiddleware(
251
251
  await prisma.generationRun.upsert({
252
252
  where: { id: key },
253
253
  update: { result: base64 as any },
254
+ // create 分支必须携带真实 threadId/userId:GenerationRun.threadId
255
+ // 有 FK 指向 ChatThread(schema.prisma),旧实现误用 blob key 充当
256
+ // 二者——全新 key(快照捕获经 ai-sandbox putIfAbsent 到达此处时的
257
+ // 内容寻址 sandbox-files/sha256/<hash>)必然 FK 违反,create 路径
258
+ // 实际不可用。快照 blob 归属当前会话沙箱,threadId 取 params;
259
+ // userId 无 FK 仅作归属索引,desktop 后台等无 userId 路径落标记值。
254
260
  create: {
255
261
  id: key,
256
- threadId: key,
257
- userId: key,
262
+ threadId: params.threadId,
263
+ userId: params.userId ?? "sandbox",
258
264
  kind: "sandbox-blob",
259
265
  status: "completed",
260
266
  result: base64 as any,
267
+ activity: "sandbox",
261
268
  },
262
269
  })
263
270
  return { size: body.length }