@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 +3 -3
- package/src/components/ai/ai-chat-prompt.tsx +1 -1
- package/src/components/ai/global-harness-sidebar/index.tsx +1 -1
- package/src/components/command/command-palette.tsx +6 -3
- package/src/components/harness/__tests__/composer-draft.test.ts +18 -0
- package/src/components/harness/__tests__/harness-workbench-first-send.test.tsx +260 -0
- package/src/components/harness/composer/composer-editor.tsx +5 -1
- package/src/components/harness/harness-workbench-page.tsx +52 -20
- package/src/components/tiptap/ui/ai-menu/ai-menu.tsx +1 -1
- package/src/lib/changelog/sdk-versions.ts +2 -2
- package/src/lib/commands/types.ts +4 -2
- package/src/server/ai-gateway/sandbox/__tests__/harness-sandbox.test.ts +71 -1
- package/src/server/ai-gateway/sandbox/harness-sandbox.ts +9 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allbluecn/web-app",
|
|
3
|
-
"version": "0.19.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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.
|
|
99
|
-
"lastUpdate": "Oct
|
|
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?:
|
|
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?: {
|
|
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:
|
|
257
|
-
userId:
|
|
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 }
|