@allbluecn/web-app 0.23.6 → 0.24.0

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.
Files changed (91) hide show
  1. package/package.json +2 -2
  2. package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +0 -2
  3. package/src/components/harness/ai-chat-history/components/conversation-item.tsx +6 -0
  4. package/src/components/harness/ai-chat-history/types.ts +4 -0
  5. package/src/components/harness/chat-ui.tsx +20 -0
  6. package/src/components/harness/composer/harness-composer.tsx +43 -10
  7. package/src/components/harness/composer/harness-engine-control.tsx +129 -0
  8. package/src/components/harness/harness-workbench-page.tsx +129 -17
  9. package/src/components/layout/sidebar-03/__tests__/user-menu-cloud-logout.test.tsx +185 -0
  10. package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -9
  11. package/src/components/layout/sidebar-03/user-menu.tsx +27 -50
  12. package/src/components/shared-kit/auth/auth-client.ts +4 -0
  13. package/src/config/sidebar-routes.tsx +0 -13
  14. package/src/lib/__tests__/desktop-logout.test.ts +239 -0
  15. package/src/lib/__tests__/session.test.ts +85 -2
  16. package/src/lib/auth/__tests__/page-gate.test.ts +37 -1
  17. package/src/lib/auth/anonymous-safe.ts +5 -0
  18. package/src/lib/auth/page-gate.ts +11 -1
  19. package/src/lib/changelog/sdk-versions.ts +1 -1
  20. package/src/lib/desktop-env.ts +25 -0
  21. package/src/lib/desktop-logout.ts +117 -0
  22. package/src/lib/hotkeys/__tests__/registry.test.ts +4 -11
  23. package/src/lib/hotkeys/registry.ts +0 -8
  24. package/src/lib/session.ts +70 -0
  25. package/src/paraglide/README.md +1 -1
  26. package/src/paraglide/messages/_index.d.ts +12 -2
  27. package/src/paraglide/messages/_index.js +12 -2
  28. package/src/paraglide/messages/{agent_desktop_title.d.ts → harness_engine_cloud.d.ts} +5 -5
  29. package/src/paraglide/messages/harness_engine_cloud.js +29 -0
  30. package/src/paraglide/messages/harness_engine_label.d.ts +16 -0
  31. package/src/paraglide/messages/harness_engine_label.js +29 -0
  32. package/src/paraglide/messages/harness_engine_local.d.ts +16 -0
  33. package/src/paraglide/messages/harness_engine_local.js +29 -0
  34. package/src/paraglide/messages/harness_engine_localbadge1.d.ts +17 -0
  35. package/src/paraglide/messages/harness_engine_localbadge1.js +30 -0
  36. package/src/paraglide/messages/harness_engine_localhint1.d.ts +17 -0
  37. package/src/paraglide/messages/harness_engine_localhint1.js +30 -0
  38. package/src/paraglide/messages/harness_engine_localnoattachments2.d.ts +17 -0
  39. package/src/paraglide/messages/harness_engine_localnoattachments2.js +30 -0
  40. package/src/paraglide/messages/harness_engine_switchlocked1.d.ts +17 -0
  41. package/src/paraglide/messages/harness_engine_switchlocked1.js +30 -0
  42. package/src/paraglide/messages/harness_localworkspace_choose1.d.ts +17 -0
  43. package/src/paraglide/messages/harness_localworkspace_choose1.js +30 -0
  44. package/src/paraglide/messages/harness_localworkspace_dialogdescription2.d.ts +17 -0
  45. package/src/paraglide/messages/harness_localworkspace_dialogdescription2.js +30 -0
  46. package/src/paraglide/messages/harness_localworkspace_dialogtitle2.d.ts +17 -0
  47. package/src/paraglide/messages/harness_localworkspace_dialogtitle2.js +30 -0
  48. package/src/paraglide/messages/harness_localworkspace_manage1.d.ts +17 -0
  49. package/src/paraglide/messages/harness_localworkspace_manage1.js +30 -0
  50. package/src/paraglide/messages/harness_localworkspace_required1.d.ts +17 -0
  51. package/src/paraglide/messages/harness_localworkspace_required1.js +30 -0
  52. package/src/routeTree.gen.ts +0 -23
  53. package/src/routes/__tests__/-edition-routing.test.tsx +48 -7
  54. package/src/routes/__tests__/-login-admin-redirect.test.ts +99 -0
  55. package/src/routes/_login/login/route.tsx +18 -3
  56. package/src/routes/_nav/design/canvas/$id/route.tsx +5 -0
  57. package/src/routes/_nav/harness/$threadId.lazy.tsx +19 -2
  58. package/src/routes/_nav/harness/$threadId.tsx +8 -2
  59. package/src/routes/_nav/knowledge/$kbId/route.tsx +6 -2
  60. package/src/routes/_nav/route.tsx +5 -1
  61. package/src/routes/_nav/settings/account/route.tsx +5 -0
  62. package/src/routes/_nav/settings/team/route.tsx +5 -0
  63. package/src/routes/api/auth/$.ts +87 -2
  64. package/src/routes/api/auth/__tests__/auth-session-desktop.test.ts +194 -0
  65. package/src/server/__tests__/chat-conversations.test.ts +13 -3
  66. package/src/server/__tests__/desktop-bootstrap.test.ts +0 -1
  67. package/src/server/ai-gateway/__tests__/local-agent-adapter.test.ts +110 -0
  68. package/src/server/ai-gateway/__tests__/unified-router-local-agent.test.ts +63 -0
  69. package/src/server/ai-gateway/chat-handler.ts +68 -2
  70. package/src/server/ai-gateway/chat-stream-builder.ts +52 -0
  71. package/src/server/ai-gateway/local-agent-adapter.ts +232 -0
  72. package/src/server/ai-gateway/unified-router.ts +49 -6
  73. package/src/server/desktop-guest.ts +5 -1
  74. package/src/server/serverFns/auth/session.ts +19 -10
  75. package/src/server/serverFns/chat/conversations.ts +122 -1
  76. package/src/components/desktop-agent/__tests__/agent-chat.test.tsx +0 -194
  77. package/src/components/desktop-agent/agent-chat.tsx +0 -222
  78. package/src/lib/desktop-agent/__tests__/connection.test.ts +0 -310
  79. package/src/lib/desktop-agent/__tests__/errors.test.ts +0 -43
  80. package/src/lib/desktop-agent/connection.ts +0 -93
  81. package/src/lib/desktop-agent/errors.ts +0 -21
  82. package/src/paraglide/messages/agent_desktop_title.js +0 -29
  83. package/src/paraglide/messages/common_hotkey_desc_agentabort1.d.ts +0 -17
  84. package/src/paraglide/messages/common_hotkey_desc_agentabort1.js +0 -30
  85. package/src/routes/-tests/agent-gate.test.ts +0 -45
  86. package/src/routes/_nav/agent/route.lazy.tsx +0 -63
  87. package/src/routes/_nav/agent/route.tsx +0 -21
  88. /package/src/components/{desktop-agent → harness/local-agent}/__tests__/agent-tool-card.test.tsx +0 -0
  89. /package/src/components/{desktop-agent → harness/local-agent}/__tests__/agent-workspace-card.test.tsx +0 -0
  90. /package/src/components/{desktop-agent → harness/local-agent}/agent-tool-card.tsx +0 -0
  91. /package/src/components/{desktop-agent → harness/local-agent}/agent-workspace-card.tsx +0 -0
@@ -0,0 +1,194 @@
1
+ // /api/auth/$ 的 GET 桌面回落(useSession 依赖的「我是谁」端点)。
2
+ // 根因:authjs 只认自己的 JWT cookie,桌面壳内(本地免登录 / 云端身份在
3
+ // remote-auth.json)恒返回 null → useSession() 恒 null → 各组件把本地用户当匿名。
4
+ // 修法:仅 GET /api/auth/session 在 desktop 且 authjs 无会话时回落
5
+ // getCurrentSession(authjs → remote-auth 云身份 → guest 影子身份)。
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ const H = vi.hoisted(() => ({
9
+ getSession: vi.fn(),
10
+ readRemoteAuth: vi.fn(),
11
+ authJsGet: vi.fn(),
12
+ authJsPost: vi.fn(),
13
+ ensureLocalShadowUser: vi.fn(async () => 'local-guest'),
14
+ desktopServer: true,
15
+ }))
16
+
17
+ vi.mock('start-authjs', () => ({
18
+ StartAuthJS: async () => ({ GET: H.authJsGet, POST: H.authJsPost }),
19
+ getSession: H.getSession,
20
+ }))
21
+ vi.mock('@/lib/auth/auth.config', () => ({
22
+ getAuthConfig: async () => ({}),
23
+ resetProfileCache: vi.fn(),
24
+ }))
25
+ vi.mock('@/server/remote-auth', () => ({
26
+ readRemoteAuth: H.readRemoteAuth,
27
+ }))
28
+ // seed 走真模块会拉起 @allbluecn/database 的模块级 prisma 单例(测试环境无 DB)
29
+ vi.mock('@/server/desktop-bootstrap', () => ({
30
+ ensureLocalShadowUser: H.ensureLocalShadowUser,
31
+ }))
32
+
33
+ type AuthRoute = typeof import('../$')['Route']
34
+
35
+ async function importAuthRoute(desktop: boolean): Promise<AuthRoute> {
36
+ vi.resetModules()
37
+ H.desktopServer = desktop
38
+ vi.doMock('@/server/desktop-guest', async (importOriginal) => {
39
+ const actual = await importOriginal<typeof import('@/server/desktop-guest')>()
40
+ return { ...actual, isDesktopServer: () => H.desktopServer }
41
+ })
42
+ return (await import('../$')).Route
43
+ }
44
+
45
+ /** 模拟 authjs 的 session 端点响应(无会话 → JSON null) */
46
+ function authJsResponse(body: unknown, init?: ResponseInit): Response {
47
+ return new Response(JSON.stringify(body), {
48
+ status: 200,
49
+ headers: { 'content-type': 'application/json' },
50
+ ...init,
51
+ })
52
+ }
53
+
54
+ function callGet(Route: AuthRoute, path = '/api/auth/session'): Promise<Response> {
55
+ const handlers = (Route.options as unknown as {
56
+ server: { handlers: Record<string, (ctx: { request: Request }) => Promise<Response>> }
57
+ }).server.handlers
58
+ return handlers.GET!({ request: new Request(`http://localhost${path}`, { method: 'GET' }) })
59
+ }
60
+
61
+ describe('/api/auth/session 桌面回落', () => {
62
+ beforeEach(() => {
63
+ // 真实桌面后端由 Electron 主进程注入该 env(fail-open 自检的正常路径):
64
+ // 测试里显式给出,避免每条用例都打安全告警
65
+ vi.stubEnv('DESKTOP_USER_DATA_DIR', '/tmp/allblue-desktop-test')
66
+ })
67
+
68
+ afterEach(() => {
69
+ vi.unstubAllEnvs()
70
+ vi.clearAllMocks()
71
+ })
72
+
73
+ it('desktop + 无 authjs cookie → 返回 guest 影子身份(user.id=local-guest)且懒 seed 影子用户', async () => {
74
+ H.getSession.mockResolvedValue(null)
75
+ H.readRemoteAuth.mockResolvedValue(null)
76
+ H.authJsGet.mockResolvedValue(authJsResponse(null))
77
+
78
+ const Route = await importAuthRoute(true)
79
+ const res = await callGet(Route)
80
+ const data = (await res.json()) as { user?: { id?: string; isGuest?: boolean }; desktopSession?: string }
81
+
82
+ expect(res.status).toBe(200)
83
+ expect(data.user?.id).toBe('local-guest')
84
+ expect(data.user?.isGuest).toBe(true)
85
+ expect(data.desktopSession).toBe('guest')
86
+ // I3:端点命中 guest 时补 seed(不能只靠 root beforeLoad 的 getSessionFn——
87
+ // 那一步 RPC 失败时这里会下发未落库身份,后续写入撞 FK P2003)
88
+ expect(H.ensureLocalShadowUser).toHaveBeenCalledTimes(1)
89
+ })
90
+
91
+ it('desktop guest seed 抛错:仅 warn,不阻断「我是谁」响应', async () => {
92
+ H.getSession.mockResolvedValue(null)
93
+ H.readRemoteAuth.mockResolvedValue(null)
94
+ H.authJsGet.mockResolvedValue(authJsResponse(null))
95
+ H.ensureLocalShadowUser.mockRejectedValueOnce(new Error('db gone'))
96
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
97
+
98
+ const Route = await importAuthRoute(true)
99
+ const res = await callGet(Route)
100
+ const data = (await res.json()) as { user?: { id?: string } }
101
+
102
+ expect(res.status).toBe(200)
103
+ expect(data.user?.id).toBe('local-guest')
104
+ expect(warnSpy).toHaveBeenCalled()
105
+ })
106
+
107
+ it('desktop + remoteAuth 云身份 → 返回云 userId(不是 guest),且不 seed guest 影子行', async () => {
108
+ H.getSession.mockResolvedValue(null)
109
+ H.readRemoteAuth.mockResolvedValue({
110
+ userId: 'cloud-user-abc',
111
+ planName: 'pro',
112
+ features: {},
113
+ sessionToken: 'cloud-session-token',
114
+ username: '云端用户',
115
+ })
116
+ H.authJsGet.mockResolvedValue(authJsResponse(null))
117
+
118
+ const Route = await importAuthRoute(true)
119
+ const data = (await (await callGet(Route)).json()) as {
120
+ user?: { id?: string; name?: string; isGuest?: boolean }
121
+ desktopSession?: string
122
+ }
123
+
124
+ expect(data.user?.id).toBe('cloud-user-abc')
125
+ expect(data.user?.name).toBe('云端用户')
126
+ expect(data.user?.isGuest).toBe(false)
127
+ expect(data.desktopSession).toBe('cloud')
128
+ expect(H.ensureLocalShadowUser).not.toHaveBeenCalled()
129
+ })
130
+
131
+ it('非 desktop:authjs 的 null 原样透传(不扩大暴露面)', async () => {
132
+ H.getSession.mockResolvedValue(null)
133
+ H.readRemoteAuth.mockResolvedValue(null)
134
+ H.authJsGet.mockResolvedValue(authJsResponse(null))
135
+
136
+ const Route = await importAuthRoute(false)
137
+ const data = await (await callGet(Route)).json()
138
+
139
+ expect(data).toBeNull()
140
+ })
141
+
142
+ it('desktop + 有效 authjs 会话:authjs 结果优先(不追加 desktopSession 标记)', async () => {
143
+ const authJsSession = { user: { id: 'authjs-user-1', name: '本地账号' }, expires: '2026-12-31' }
144
+ H.getSession.mockResolvedValue(authJsSession)
145
+ H.authJsGet.mockResolvedValue(authJsResponse(authJsSession))
146
+
147
+ const Route = await importAuthRoute(true)
148
+ const data = (await (await callGet(Route)).json()) as Record<string, unknown>
149
+
150
+ expect((data.user as { id?: string }).id).toBe('authjs-user-1')
151
+ expect(data.desktopSession).toBeUndefined()
152
+ })
153
+
154
+ it('desktop + 非 session 路径(csrf):原样透传,不做身份回落', async () => {
155
+ H.authJsGet.mockResolvedValue(
156
+ new Response(JSON.stringify({ csrfToken: 't' }), { status: 200, headers: { 'content-type': 'application/json' } }),
157
+ )
158
+
159
+ const Route = await importAuthRoute(true)
160
+ const data = (await (await callGet(Route, '/api/auth/csrf')).json()) as Record<string, unknown>
161
+
162
+ expect(data.csrfToken).toBe('t')
163
+ expect(H.getSession).not.toHaveBeenCalled()
164
+ })
165
+
166
+ it('POST 原样交给 authjs(登录/登出流程零改动)', async () => {
167
+ H.authJsPost.mockResolvedValue(new Response('{}', { status: 200 }))
168
+
169
+ const Route = await importAuthRoute(true)
170
+ const handlers = (Route.options as unknown as {
171
+ server: { handlers: Record<string, (ctx: { request: Request }) => Promise<Response>> }
172
+ }).server.handlers
173
+ const res = await handlers.POST!({
174
+ request: new Request('http://localhost/api/auth/callback/credentials', { method: 'POST' }),
175
+ })
176
+
177
+ expect(H.authJsPost).toHaveBeenCalledTimes(1)
178
+ expect(res.status).toBe(200)
179
+ })
180
+
181
+ it('透传 authjs 的 Set-Cookie(坏 cookie 清理头不丢)', async () => {
182
+ H.getSession.mockResolvedValue(null)
183
+ H.readRemoteAuth.mockResolvedValue(null)
184
+ H.authJsGet.mockResolvedValue(
185
+ authJsResponse(null, { headers: { 'content-type': 'application/json', 'set-cookie': 'auth.session-token=; Max-Age=0' } }),
186
+ )
187
+
188
+ const Route = await importAuthRoute(true)
189
+ const res = await callGet(Route)
190
+
191
+ expect(res.headers.getSetCookie().some((c) => c.startsWith('auth.session-token='))).toBe(true)
192
+ expect(res.headers.get('cache-control')).toBe('no-store')
193
+ })
194
+ })
@@ -142,7 +142,7 @@ describe("createConversationFn", () => {
142
142
 
143
143
  expect(mocks.findFirst).not.toHaveBeenCalled()
144
144
  expect(mocks.create).toHaveBeenCalledWith({
145
- data: { userId, agentId: null },
145
+ data: { userId, agentId: null, engine: "cloud", localCwd: null },
146
146
  include: { agent: { select: { name: true, avatarEmoji: true } } },
147
147
  })
148
148
  expect(result).toMatchObject({
@@ -173,7 +173,7 @@ describe("createConversationFn", () => {
173
173
  },
174
174
  })
175
175
  expect(mocks.create).toHaveBeenCalledWith({
176
- data: { userId, agentId },
176
+ data: { userId, agentId, engine: "cloud", localCwd: null },
177
177
  include: { agent: { select: { name: true, avatarEmoji: true } } },
178
178
  })
179
179
  expect(result).toMatchObject({
@@ -198,7 +198,7 @@ describe("createConversationFn", () => {
198
198
 
199
199
  expect(mocks.findFirst).not.toHaveBeenCalled()
200
200
  expect(mocks.create).toHaveBeenCalledWith({
201
- data: { userId, agentId: null },
201
+ data: { userId, agentId: null, engine: "cloud", localCwd: null },
202
202
  include: { agent: { select: { name: true, avatarEmoji: true } } },
203
203
  })
204
204
  })
@@ -213,6 +213,8 @@ describe("listConversationsFn", () => {
213
213
  updatedAt: now,
214
214
  isArchived: false,
215
215
  agentId: "agent-001",
216
+ engine: "cloud",
217
+ localCwd: null,
216
218
  _count: { messages: 5 },
217
219
  agent: { name: "Researcher", avatarEmoji: "🔬" },
218
220
  interrupts: [{ id: "int-001" }],
@@ -223,6 +225,8 @@ describe("listConversationsFn", () => {
223
225
  updatedAt: new Date("2026-08-16T08:00:00.000Z"),
224
226
  isArchived: false,
225
227
  agentId: null,
228
+ engine: "cloud",
229
+ localCwd: null,
226
230
  _count: { messages: 0 },
227
231
  agent: null,
228
232
  interrupts: [],
@@ -240,6 +244,8 @@ describe("listConversationsFn", () => {
240
244
  title: true,
241
245
  isArchived: true,
242
246
  agentId: true,
247
+ engine: true,
248
+ localCwd: true,
243
249
  updatedAt: true,
244
250
  _count: { select: { messages: true } },
245
251
  agent: { select: { name: true, avatarEmoji: true } },
@@ -254,6 +260,8 @@ describe("listConversationsFn", () => {
254
260
  messageCount: 5,
255
261
  isArchived: false,
256
262
  agentId: "agent-001",
263
+ engine: "cloud",
264
+ localCwd: null,
257
265
  agentName: "Researcher",
258
266
  agentEmoji: "🔬",
259
267
  pendingInteraction: true,
@@ -265,6 +273,8 @@ describe("listConversationsFn", () => {
265
273
  messageCount: 0,
266
274
  isArchived: false,
267
275
  agentId: null,
276
+ engine: "cloud",
277
+ localCwd: null,
268
278
  agentName: null,
269
279
  agentEmoji: null,
270
280
  pendingInteraction: false,
@@ -83,7 +83,6 @@ vi.mock('@/server/entitlements-token', () => ({ generateEntitlementsToken: vi.fn
83
83
  vi.mock('@/server/middlewares/rate-limit', () => ({ checkAuthRateLimit: vi.fn() }))
84
84
 
85
85
  import { prisma } from '@allbluecn/database'
86
- import { ensureBuiltinIndependentProject } from '@/server/builtin-project'
87
86
  import { ensureLocalShadowUser } from '../desktop-bootstrap'
88
87
  import { getSessionFn } from '../serverFns/auth/session'
89
88
 
@@ -0,0 +1,110 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ import { afterEach, describe, expect, it } from 'vitest'
6
+ import {
7
+ LOCAL_AGENT_GLOBAL_KEY,
8
+ LocalAgentAdapter,
9
+ extractLastUserText,
10
+ } from '../local-agent-adapter'
11
+
12
+ const original = (globalThis as Record<string, unknown>)[LOCAL_AGENT_GLOBAL_KEY]
13
+
14
+ afterEach(() => {
15
+ if (original === undefined) delete (globalThis as Record<string, unknown>)[LOCAL_AGENT_GLOBAL_KEY]
16
+ else (globalThis as Record<string, unknown>)[LOCAL_AGENT_GLOBAL_KEY] = original
17
+ })
18
+
19
+ function installService(run: (input: unknown) => AsyncIterable<unknown>) {
20
+ ;(globalThis as Record<string, unknown>)[LOCAL_AGENT_GLOBAL_KEY] = {
21
+ runLocalAgentPrompt: run,
22
+ }
23
+ }
24
+
25
+ function options(overrides: Record<string, unknown> = {}) {
26
+ return {
27
+ messages: [{ role: 'user', content: '总结这个目录' }],
28
+ threadId: 'thread-1',
29
+ runId: 'run-1',
30
+ modelOptions: { cwd: '/tmp/workspace' },
31
+ ...overrides,
32
+ } as never
33
+ }
34
+
35
+ describe('extractLastUserText', () => {
36
+ it('取最后一条非空 user 文本(字符串 / text parts 两种形态)', () => {
37
+ expect(
38
+ extractLastUserText([
39
+ { role: 'user', content: 'old' },
40
+ { role: 'assistant', content: 'answer' },
41
+ { role: 'user', content: [{ type: 'text', content: 'new' }] },
42
+ ] as never),
43
+ ).toBe('new')
44
+ })
45
+
46
+ it('忽略空 user 文本,回退更早的一条', () => {
47
+ expect(
48
+ extractLastUserText([
49
+ { role: 'user', content: 'first' },
50
+ { role: 'user', content: ' ' },
51
+ ] as never),
52
+ ).toBe('first')
53
+ })
54
+ })
55
+
56
+ describe('LocalAgentAdapter', () => {
57
+ it('未装配桌面运行时 → RUN_ERROR,不抛错', async () => {
58
+ delete (globalThis as Record<string, unknown>)[LOCAL_AGENT_GLOBAL_KEY]
59
+ const events: Array<{ type: string; message?: string }> = []
60
+ for await (const event of new LocalAgentAdapter().chatStream(options())) {
61
+ events.push(event as never)
62
+ }
63
+ expect(events).toHaveLength(1)
64
+ expect(events[0]?.type).toBe('RUN_ERROR')
65
+ expect(events[0]?.message).toContain('本地 Agent 不可用')
66
+ })
67
+
68
+ it('转发 pi 的 AG-UI 事件,并为 RUN_FINISHED 补 finishReason=stop', async () => {
69
+ const received: unknown[] = []
70
+ installService(async function* (input) {
71
+ received.push(input)
72
+ yield { type: 'RUN_STARTED', threadId: 'thread-1', runId: 'run-1' }
73
+ yield { type: 'TEXT_MESSAGE_START', messageId: 'm1', role: 'assistant' }
74
+ yield { type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: '你好' }
75
+ yield { type: 'TEXT_MESSAGE_END', messageId: 'm1' }
76
+ yield { type: 'RUN_FINISHED', threadId: 'thread-1', runId: 'run-1' }
77
+ })
78
+
79
+ const events: Array<Record<string, unknown>> = []
80
+ for await (const event of new LocalAgentAdapter().chatStream(options())) {
81
+ events.push(event as never)
82
+ }
83
+
84
+ expect(received[0]).toMatchObject({
85
+ cwd: '/tmp/workspace',
86
+ sessionId: 'thread-1',
87
+ threadId: 'thread-1',
88
+ runId: 'run-1',
89
+ })
90
+ expect(events.map((event) => event.type)).toEqual([
91
+ 'RUN_STARTED',
92
+ 'TEXT_MESSAGE_START',
93
+ 'TEXT_MESSAGE_CONTENT',
94
+ 'TEXT_MESSAGE_END',
95
+ 'RUN_FINISHED',
96
+ ])
97
+ expect(events[4]).toMatchObject({ finishReason: 'stop', model: 'local-pi' })
98
+ })
99
+
100
+ it('缺少 cwd → RUN_ERROR', async () => {
101
+ installService(async function* () {})
102
+ const events: Array<{ type: string }> = []
103
+ for await (const event of new LocalAgentAdapter().chatStream(
104
+ options({ modelOptions: {} }),
105
+ )) {
106
+ events.push(event as never)
107
+ }
108
+ expect(events[0]?.type).toBe('RUN_ERROR')
109
+ })
110
+ })
@@ -0,0 +1,63 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ import { afterEach, describe, expect, it } from 'vitest'
6
+ import { resolveUnifiedAiRoute } from '../unified-router'
7
+ import { LOCAL_AGENT_GLOBAL_KEY } from '../local-agent-adapter'
8
+
9
+ const originalTarget = process.env.DEPLOY_TARGET
10
+ const originalService = (globalThis as Record<string, unknown>)[LOCAL_AGENT_GLOBAL_KEY]
11
+
12
+ afterEach(() => {
13
+ if (originalTarget === undefined) delete process.env.DEPLOY_TARGET
14
+ else process.env.DEPLOY_TARGET = originalTarget
15
+ if (originalService === undefined) delete (globalThis as Record<string, unknown>)[LOCAL_AGENT_GLOBAL_KEY]
16
+ else (globalThis as Record<string, unknown>)[LOCAL_AGENT_GLOBAL_KEY] = originalService
17
+ })
18
+
19
+ const baseRequest = {
20
+ userId: 'local-guest',
21
+ messages: [{ role: 'user' as const, content: '看看这个目录' }],
22
+ threadId: 'thread-local-1',
23
+ engine: 'local' as const,
24
+ localCwd: '/tmp/work',
25
+ }
26
+
27
+ describe('resolveUnifiedAiRoute — 本地 Agent 引擎', () => {
28
+ it('非桌面部署 → 拒绝(云端不能走本地文件)', async () => {
29
+ process.env.DEPLOY_TARGET = 'web'
30
+ await expect(resolveUnifiedAiRoute(baseRequest)).rejects.toThrow(/仅在桌面客户端可用/)
31
+ })
32
+
33
+ it('桌面但未装配运行时 → 明确报错而不是回落 BYOK', async () => {
34
+ process.env.DEPLOY_TARGET = 'desktop'
35
+ delete (globalThis as Record<string, unknown>)[LOCAL_AGENT_GLOBAL_KEY]
36
+ await expect(resolveUnifiedAiRoute(baseRequest)).rejects.toThrow(/运行时未装配/)
37
+ })
38
+
39
+ it('桌面 + 运行时 + cwd → 装配 local 路由(adapter 由声明式 harness 包装)', async () => {
40
+ process.env.DEPLOY_TARGET = 'desktop'
41
+ ;(globalThis as Record<string, unknown>)[LOCAL_AGENT_GLOBAL_KEY] = {
42
+ runLocalAgentPrompt: async function* () {},
43
+ }
44
+ const route = await resolveUnifiedAiRoute(baseRequest)
45
+ expect(route.kind).toBe('local')
46
+ if (route.kind === 'local') {
47
+ // 只需确认可迭代对象已装配;真正的事件映射在 local-agent-adapter 单测覆盖
48
+ expect(typeof route.stream[Symbol.asyncIterator]).toBe('function')
49
+ }
50
+ })
51
+
52
+ it('engine 缺省时不走本地链路(保持云端行为)', async () => {
53
+ process.env.DEPLOY_TARGET = 'desktop'
54
+ ;(globalThis as Record<string, unknown>)[LOCAL_AGENT_GLOBAL_KEY] = {
55
+ runLocalAgentPrompt: async function* () {},
56
+ }
57
+ // 没有 engine=local:会进入 BYOK/hosted 解析(测试环境无 provider → NoAiAvailableError),
58
+ // 关键断言是错误不是「本地 Agent」相关,证明没有误走本地分支
59
+ await expect(
60
+ resolveUnifiedAiRoute({ ...baseRequest, engine: undefined }),
61
+ ).rejects.not.toThrow(/本地 Agent/)
62
+ })
63
+ })
@@ -303,6 +303,11 @@ export const chatBodySchema = z.object({
303
303
  parentRunId: z.string().max(200).optional(),
304
304
  // resume 为 durable run 重放载荷(chunk 数组):限条数,单条体积随 chunk 契约
305
305
  resume: z.array(z.unknown()).max(100).optional(),
306
+ // 执行引擎(线程级):cloud=服务端模型;local=桌面 pi 本地 Agent。
307
+ // 只有 desktop 部署且后端装配 __allblueAgentService 时才允许 local。
308
+ engine: z.enum(["cloud", "local"]).default("cloud"),
309
+ /** local 引擎的授权工作目录(桌面壳 workspaces.json 精确匹配复核) */
310
+ cwd: z.string().min(1).max(1000).optional(),
306
311
  promptKey: z.string().max(200).optional(),
307
312
  /**
308
313
  * 附件列表:图片走 dataURL parts,非图片走服务端 BlobStore ref。
@@ -411,7 +416,12 @@ export async function* handleChatRequest(
411
416
  scheduleLazyReap()
412
417
 
413
418
  const raw = await (options?.body ?? request.json())
414
- const body = { ...raw, ...(raw.forwardedProps ?? {}) }
419
+ // Pro 兼容窗口:旧 OSS 客户端(0.x 信封)把业务字段放在 data 里,经
420
+ // forwardToSaas 转发进来;forwardedProps 缺省时回落 data。窗口结束后可删。
421
+ const body = {
422
+ ...raw,
423
+ ...(raw.forwardedProps ?? (raw as { data?: Record<string, unknown> }).data ?? {}),
424
+ }
415
425
  // 服务端 overrides 最后落笔(审计 2026-10-25 🟠,原顺序 body 在后展开会把服务端
416
426
  // 值覆盖掉):allowedTools / knowledgeBaseIds / temperature 是 serverFn/agent 侧
417
427
  // 下发的服务端约束,客户端 body 不得放宽——否则 agent 收紧的工具白名单被击穿。
@@ -426,11 +436,67 @@ export async function* handleChatRequest(
426
436
  `Invalid chat request body [${path}]: ${issue?.message ?? "validation failed"}`,
427
437
  )
428
438
  }
429
- const { messages, allowedTools, knowledgeBaseIds, temperature, webSearchEnabled, codeModeEnabled, sandbox, model, provider, workspaceId, workspaceContext, threadId, runId, parentRunId, resume, attachments } = parsed.data
439
+ const { messages, allowedTools, knowledgeBaseIds, temperature, webSearchEnabled, codeModeEnabled, sandbox, model, provider, workspaceId, workspaceContext, threadId, runId, parentRunId, resume, attachments, engine, cwd } = parsed.data
430
440
  // 1.0 起 threadId 为必填:非 1.0 客户端缺省时由服务端兜底生成(每次请求即新会话),
431
441
  // 防 resolveUnifiedAiRoute 的 durability/memory 装配与 SaaS 转发在缺参时静默降级。
432
442
  // 空字符串已在 schema 被 min(1) 拒绝,这里只需处理 undefined。
433
443
  const effectiveThreadId = threadId ?? crypto.randomUUID()
444
+
445
+ // 桌面本地 Agent(pi):线程级引擎与 cwd 是持久化语义的一部分——
446
+ // - 既有的 cloud 线程不允许中途改成本地(会让历史消息与 pi 会话错位);
447
+ // - 线程必须先落 engine=local 再跑,pi 侧以 threadId 作为 session id 续接。
448
+ // 鉴权边界仍在桌面壳:cwd 必须精确命中 workspaces.json 授权清单。
449
+ if (engine === "local") {
450
+ if (attachments.length > 0) {
451
+ throw new Error("本地 Agent 不支持附件:请移除附件或切换到云端引擎")
452
+ }
453
+ const existing = await prisma.chatThread.findUnique({
454
+ where: { id: effectiveThreadId },
455
+ select: { userId: true, engine: true, localCwd: true },
456
+ })
457
+ if (existing && existing.userId !== session.user.id) {
458
+ throw new Error("thread not found")
459
+ }
460
+ if (existing && existing.engine !== "local") {
461
+ throw new Error("该会话不是本地 Agent 会话:请新建会话后选择本地工作区")
462
+ }
463
+ if (!existing) {
464
+ await prisma.chatThread.create({
465
+ data: {
466
+ id: effectiveThreadId,
467
+ userId: session.user.id,
468
+ engine: "local",
469
+ localCwd: cwd ?? null,
470
+ },
471
+ })
472
+ } else if (cwd && existing.localCwd !== cwd) {
473
+ // 同一线程固定工作目录(UI 在已有消息时禁用切换);服务端兜底更新
474
+ // 只影响 pi 的 sessionId 派生,不改变历史消息归属。
475
+ await prisma.chatThread.update({
476
+ where: { id: effectiveThreadId },
477
+ data: { localCwd: cwd },
478
+ })
479
+ }
480
+ const localRoute = await resolveUnifiedAiRoute(
481
+ {
482
+ userId: session.user.id,
483
+ messages: messages as any[],
484
+ threadId: effectiveThreadId,
485
+ runId,
486
+ parentRunId,
487
+ engine: "local",
488
+ localCwd: cwd,
489
+ aiSourceMode: session.user.aiSourceMode ?? null,
490
+ },
491
+ request,
492
+ )
493
+ if (localRoute.kind !== "local") {
494
+ throw new Error("本地 Agent 路由装配失败")
495
+ }
496
+ yield* localRoute.stream
497
+ return
498
+ }
499
+
434
500
  // 计费闸只在请求真的启用沙箱时才走:feature 未开通或分钟配额耗尽都在这一步
435
501
  // fail-closed,不会白付一次 provider 装配与沙箱创建的成本。
436
502
  // 知识库所有权校验:过滤客户端传入的非法 KB ID(非自有/非所属工作空间共享)
@@ -95,6 +95,13 @@ interface BuildChatStreamParams {
95
95
  * 允许 adapter 传 null(后台无 adapter 可用);非 attach 模式 adapter 必填。
96
96
  */
97
97
  attach?: boolean
98
+ /**
99
+ * 桌面本地 Agent(pi):true 时跳过服务端工具/技能/沙箱/系统提示装配,
100
+ * adapter 自己跑 agent loop(只读四件套由桌面壳强制)。
101
+ */
102
+ localAgent?: boolean
103
+ /** localAgent 的授权工作目录(桌面壳会再次复核 workspaces.json) */
104
+ localCwd?: string
98
105
  }
99
106
 
100
107
  /**
@@ -126,6 +133,51 @@ export async function buildChatStreamWithTools(
126
133
  throw new Error("buildChatStreamWithTools: 非 attach 模式必须提供 adapter")
127
134
  }
128
135
 
136
+ // 本地 Agent 是 harness adapter:pi 自己跑工具循环。服务端工具池、技能、
137
+ // Code Mode、沙箱与系统提示全部不参与(pi 的封闭 ResourceLoader 自带提示词,
138
+ // 且只读四件套由桌面壳的 path-guard 强制)。持久化/取消/工具卡仍走同一份
139
+ // chat() + withPersistence,保证 Harness 的 thread/run 语义不变。
140
+ if (params.localAgent) {
141
+ const localThreadId = params.threadId ?? randomUUID()
142
+ const localPersistence = createPrismaPersistence(params.userId)
143
+ const localContext: AppContext = {
144
+ userId: params.userId,
145
+ db: prisma,
146
+ threadId: localThreadId,
147
+ provider: 'local-pi',
148
+ model: 'local-pi',
149
+ runId: params.runId,
150
+ workspaceContext: params.localCwd
151
+ ? { type: 'local', path: params.localCwd }
152
+ : undefined,
153
+ }
154
+ return chat({
155
+ adapter: params.adapter,
156
+ messages: params.messages,
157
+ tools: [],
158
+ threadId: localThreadId,
159
+ runId: params.runId,
160
+ context: localContext,
161
+ modelOptions: {
162
+ cwd: params.localCwd,
163
+ sessionId: localThreadId,
164
+ },
165
+ // pi 自带 agent loop:单次 adapter 调用即完整运行,禁止引擎再发起模型轮次
166
+ agentLoopStrategy: maxIterations(1),
167
+ middleware: [
168
+ loggerMiddleware,
169
+ rateLimitMiddleware,
170
+ otelMiddleware({ tracer: otelTracer, captureContent: false }),
171
+ createAiOutputGuardMiddleware(),
172
+ withPersistence(localPersistence, {
173
+ snapshotStreaming: true,
174
+ snapshotIntervalMs: 1500,
175
+ }),
176
+ createUsageLogMiddleware({ activity: 'chat' }),
177
+ ] as any,
178
+ }) as AsyncIterable<StreamChunk>
179
+ }
180
+
129
181
  const nativeSearchTools = buildProviderWebSearchTools(
130
182
  params.providerConfigKind,
131
183
  ) as any[]