@allbluecn/web-app 0.23.7 → 0.24.1

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 (68) 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 +57 -20
  7. package/src/components/harness/composer/harness-engine-control.tsx +129 -0
  8. package/src/components/harness/harness-workbench-page.tsx +140 -18
  9. package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -9
  10. package/src/config/sidebar-routes.tsx +0 -13
  11. package/src/lib/changelog/sdk-versions.ts +1 -1
  12. package/src/lib/hotkeys/__tests__/registry.test.ts +4 -11
  13. package/src/lib/hotkeys/registry.ts +0 -8
  14. package/src/paraglide/README.md +1 -1
  15. package/src/paraglide/messages/_index.d.ts +12 -2
  16. package/src/paraglide/messages/_index.js +12 -2
  17. package/src/paraglide/messages/{agent_desktop_title.d.ts → harness_engine_cloud.d.ts} +5 -5
  18. package/src/paraglide/messages/harness_engine_cloud.js +29 -0
  19. package/src/paraglide/messages/harness_engine_label.d.ts +16 -0
  20. package/src/paraglide/messages/harness_engine_label.js +29 -0
  21. package/src/paraglide/messages/harness_engine_local.d.ts +16 -0
  22. package/src/paraglide/messages/harness_engine_local.js +29 -0
  23. package/src/paraglide/messages/harness_engine_localbadge1.d.ts +17 -0
  24. package/src/paraglide/messages/harness_engine_localbadge1.js +30 -0
  25. package/src/paraglide/messages/harness_engine_localhint1.d.ts +17 -0
  26. package/src/paraglide/messages/harness_engine_localhint1.js +30 -0
  27. package/src/paraglide/messages/harness_engine_localnoattachments2.d.ts +17 -0
  28. package/src/paraglide/messages/harness_engine_localnoattachments2.js +30 -0
  29. package/src/paraglide/messages/harness_engine_switchlocked1.d.ts +17 -0
  30. package/src/paraglide/messages/harness_engine_switchlocked1.js +30 -0
  31. package/src/paraglide/messages/harness_localworkspace_choose1.d.ts +17 -0
  32. package/src/paraglide/messages/harness_localworkspace_choose1.js +30 -0
  33. package/src/paraglide/messages/harness_localworkspace_dialogdescription2.d.ts +17 -0
  34. package/src/paraglide/messages/harness_localworkspace_dialogdescription2.js +30 -0
  35. package/src/paraglide/messages/harness_localworkspace_dialogtitle2.d.ts +17 -0
  36. package/src/paraglide/messages/harness_localworkspace_dialogtitle2.js +30 -0
  37. package/src/paraglide/messages/harness_localworkspace_manage1.d.ts +17 -0
  38. package/src/paraglide/messages/harness_localworkspace_manage1.js +30 -0
  39. package/src/paraglide/messages/harness_localworkspace_required1.d.ts +17 -0
  40. package/src/paraglide/messages/harness_localworkspace_required1.js +30 -0
  41. package/src/routeTree.gen.ts +0 -23
  42. package/src/routes/_nav/harness/$threadId.lazy.tsx +19 -2
  43. package/src/routes/_nav/harness/$threadId.tsx +8 -2
  44. package/src/server/__tests__/chat-conversations.test.ts +13 -3
  45. package/src/server/__tests__/desktop-bootstrap.test.ts +0 -1
  46. package/src/server/ai-gateway/__tests__/local-agent-adapter.test.ts +110 -0
  47. package/src/server/ai-gateway/__tests__/unified-router-local-agent.test.ts +63 -0
  48. package/src/server/ai-gateway/chat-handler.ts +68 -2
  49. package/src/server/ai-gateway/chat-stream-builder.ts +52 -0
  50. package/src/server/ai-gateway/local-agent-adapter.ts +232 -0
  51. package/src/server/ai-gateway/unified-router.ts +49 -6
  52. package/src/server/serverFns/chat/conversations.ts +122 -1
  53. package/src/components/desktop-agent/__tests__/agent-chat.test.tsx +0 -194
  54. package/src/components/desktop-agent/agent-chat.tsx +0 -222
  55. package/src/lib/desktop-agent/__tests__/connection.test.ts +0 -310
  56. package/src/lib/desktop-agent/__tests__/errors.test.ts +0 -43
  57. package/src/lib/desktop-agent/connection.ts +0 -93
  58. package/src/lib/desktop-agent/errors.ts +0 -21
  59. package/src/paraglide/messages/agent_desktop_title.js +0 -29
  60. package/src/paraglide/messages/common_hotkey_desc_agentabort1.d.ts +0 -17
  61. package/src/paraglide/messages/common_hotkey_desc_agentabort1.js +0 -30
  62. package/src/routes/-tests/agent-gate.test.ts +0 -45
  63. package/src/routes/_nav/agent/route.lazy.tsx +0 -63
  64. package/src/routes/_nav/agent/route.tsx +0 -21
  65. /package/src/components/{desktop-agent → harness/local-agent}/__tests__/agent-tool-card.test.tsx +0 -0
  66. /package/src/components/{desktop-agent → harness/local-agent}/__tests__/agent-workspace-card.test.tsx +0 -0
  67. /package/src/components/{desktop-agent → harness/local-agent}/agent-tool-card.tsx +0 -0
  68. /package/src/components/{desktop-agent → harness/local-agent}/agent-workspace-card.tsx +0 -0
@@ -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[]
@@ -0,0 +1,232 @@
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
+ * 桌面本地 Agent(pi)的 Harness 适配器。
20
+ *
21
+ * 为什么是 adapter 而不是另起一条 SSE:
22
+ * - Harness 的 thread / run / 持久化 / 取消 / 工具卡全部建立在 `chat()` 之上;
23
+ * pi 作为「自带工具循环的 harness adapter」接入后,这些能力原样复用
24
+ * (与 claude-code / codex adapter 同一范式)。
25
+ * - pi 运行时(@earendil-works/pi-coding-agent)只存在于 Pro 的桌面壳 bundle,
26
+ * 上游不能 import。后端 utilityProcess 在启动时把能力挂到
27
+ * `globalThis.__allblueAgentService`(与 PGlite seam 同进程同模式),本适配器
28
+ * 只消费一个最小接口:给 cwd + 历史消息,产出 AG-UI 事件流。
29
+ *
30
+ * 安全边界不在本文件:cwd 授权(workspaces.json 精确匹配)、只读四件套、
31
+ * path-guard、工具结果截断全部由 Pro 的 AgentService 在每次运行前复核。
32
+ * 本适配器在非桌面环境直接拒绝构造,避免云端误用。
33
+ */
34
+
35
+ import {
36
+ EventType,
37
+ type AdapterYieldChunk,
38
+ type AnyTextAdapter,
39
+ type ModelMessage,
40
+ type TextOptions,
41
+ } from '@tanstack/ai'
42
+
43
+ /** 桌面壳注入的本地 Agent 服务最小契约(与 Pro agent-service 的导出同形) */
44
+ export interface LocalAgentRunInput {
45
+ /** 已由桌面壳授权的工作目录(服务端会再次复核) */
46
+ cwd: string
47
+ /** 会话标识:Harness threadId(Pro 侧映射为 pi session id,支持续接) */
48
+ sessionId: string
49
+ /** 全量消息(Pro 侧决定「续接会话」还是「首次运行展平历史」) */
50
+ messages: LocalAgentMessage[]
51
+ threadId: string
52
+ runId: string
53
+ signal?: AbortSignal
54
+ }
55
+
56
+ export interface LocalAgentMessage {
57
+ role: string
58
+ content: unknown
59
+ toolCalls?: unknown
60
+ toolCallId?: string
61
+ }
62
+
63
+ export interface LocalAgentService {
64
+ /**
65
+ * 运行一次本地 Agent prompt,返回 AG-UI 事件流(结构见 @ag-ui/core)。
66
+ * 缺失即表示当前部署没有桌面 Agent 能力。
67
+ */
68
+ runLocalAgentPrompt?: (input: LocalAgentRunInput) => AsyncIterable<unknown>
69
+ /** 删除线程对应的 pi 会话文件(Harness 删除会话时同步清理,防本地残留) */
70
+ deleteLocalAgentSession?: (input: {
71
+ cwd: string
72
+ sessionId: string
73
+ }) => void | Promise<void>
74
+ }
75
+
76
+ /** 后端装配时挂在 globalThis 的 seam 名(Pro backend/entry.cjs 同值) */
77
+ export const LOCAL_AGENT_GLOBAL_KEY = '__allblueAgentService' as const
78
+
79
+ export function getLocalAgentService(): LocalAgentService | null {
80
+ const service = (globalThis as Record<string, unknown>)[LOCAL_AGENT_GLOBAL_KEY]
81
+ if (service === null || typeof service !== 'object') return null
82
+ return service as LocalAgentService
83
+ }
84
+
85
+ export function hasLocalAgentService(): boolean {
86
+ return typeof getLocalAgentService()?.runLocalAgentPrompt === 'function'
87
+ }
88
+
89
+ /** 从 ModelMessage 中提取最后一条 user 文本(pi 的 prompt 入参) */
90
+ export function extractLastUserText(messages: readonly ModelMessage[]): string {
91
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
92
+ const message = messages[i]
93
+ if (message?.role !== 'user') continue
94
+ const text = extractText(message.content)
95
+ if (text.trim() !== '') return text
96
+ }
97
+ return ''
98
+ }
99
+
100
+ function extractText(content: ModelMessage['content']): string {
101
+ if (typeof content === 'string') return content
102
+ if (!Array.isArray(content)) return ''
103
+ return content
104
+ .filter(
105
+ (part): part is { type: 'text'; content: string } =>
106
+ part !== null &&
107
+ typeof part === 'object' &&
108
+ (part as { type?: unknown }).type === 'text' &&
109
+ typeof (part as { content?: unknown }).content === 'string',
110
+ )
111
+ .map((part) => part.content)
112
+ .join('')
113
+ }
114
+
115
+ /**
116
+ * 本地 Agent 适配器(harness adapter):pi 自己跑 agent loop 与只读四件套,
117
+ * 这里只做「Harness 输入 → 本地运行时 → AG-UI 流」的搬运。
118
+ */
119
+ export class LocalAgentAdapter {
120
+ readonly kind = 'text' as const
121
+ readonly name = 'allblue-local-agent'
122
+ readonly model = 'local-pi'
123
+
124
+ async *chatStream(options: TextOptions): AsyncIterable<AdapterYieldChunk> {
125
+ const service = getLocalAgentService()
126
+ if (typeof service?.runLocalAgentPrompt !== 'function') {
127
+ yield this.errorEvent('本地 Agent 不可用:当前部署未装配桌面 pi 运行时')
128
+ return
129
+ }
130
+
131
+ const modelOptions = options.modelOptions as { cwd?: unknown } | undefined
132
+ const cwd = typeof modelOptions?.cwd === 'string' ? modelOptions.cwd : ''
133
+ if (cwd === '') {
134
+ yield this.errorEvent('本地 Agent 缺少工作目录(cwd)')
135
+ return
136
+ }
137
+
138
+ const prompt = extractLastUserText(options.messages)
139
+ if (prompt === '') {
140
+ yield this.errorEvent('本地 Agent 需要一条非空用户消息')
141
+ return
142
+ }
143
+
144
+ const runId = options.runId ?? `local-${Date.now().toString(36)}`
145
+ const abort = new AbortController()
146
+ const upstreamSignal = options.abortController?.signal
147
+ const forwardAbort = () => abort.abort()
148
+ if (upstreamSignal?.aborted) abort.abort()
149
+ else upstreamSignal?.addEventListener('abort', forwardAbort, { once: true })
150
+
151
+ try {
152
+ for await (const raw of service.runLocalAgentPrompt({
153
+ cwd,
154
+ sessionId: options.threadId,
155
+ messages: toLocalMessages(options.messages),
156
+ threadId: options.threadId,
157
+ runId,
158
+ signal: abort.signal,
159
+ })) {
160
+ const event = normalizeEvent(raw, this.model, options.threadId, runId)
161
+ if (event === null) continue
162
+ yield event
163
+ }
164
+ } catch (error) {
165
+ // 客户端主动中止:引擎自己收尾,不把取消当故障
166
+ if (abort.signal.aborted) return
167
+ const message = error instanceof Error ? error.message : String(error)
168
+ yield this.errorEvent(message)
169
+ } finally {
170
+ upstreamSignal?.removeEventListener('abort', forwardAbort)
171
+ }
172
+ }
173
+
174
+ structuredOutput(): Promise<never> {
175
+ return Promise.reject(
176
+ new Error(
177
+ '本地 Agent 是 harness adapter(自带 agent loop),不支持一次性结构化抽取;请使用云端模型 adapter',
178
+ ),
179
+ )
180
+ }
181
+
182
+ private errorEvent(message: string): AdapterYieldChunk {
183
+ return {
184
+ type: EventType.RUN_ERROR,
185
+ message,
186
+ code: 'local_agent_failed',
187
+ timestamp: Date.now(),
188
+ } as unknown as AdapterYieldChunk
189
+ }
190
+ }
191
+
192
+ /** 工厂:Harness 路由按需构造(非桌面环境由 router 先拒绝,不走到这里) */
193
+ export function createLocalAgentAdapter(): AnyTextAdapter {
194
+ return new LocalAgentAdapter() as unknown as AnyTextAdapter
195
+ }
196
+
197
+ function toLocalMessages(messages: readonly ModelMessage[]): LocalAgentMessage[] {
198
+ return messages.map((message) => ({
199
+ role: message.role,
200
+ content: message.content,
201
+ ...(message.toolCalls !== undefined ? { toolCalls: message.toolCalls } : {}),
202
+ ...(message.toolCallId !== undefined ? { toolCallId: message.toolCallId } : {}),
203
+ }))
204
+ }
205
+
206
+ /**
207
+ * AG-UI 事件归一化:桌面壳的事件已由 pi→AG-UI mapper 产出,这里只补协议必需
208
+ * 字段(RUN_FINISHED.finishReason 缺省会让引擎等待工具循环;harness 恒为 stop)。
209
+ */
210
+ function normalizeEvent(
211
+ raw: unknown,
212
+ model: string,
213
+ threadId: string,
214
+ runId: string,
215
+ ): AdapterYieldChunk | null {
216
+ if (raw === null || typeof raw !== 'object') return null
217
+ const source = raw as Record<string, unknown>
218
+ if (typeof source.type !== 'string') return null
219
+ const event: Record<string, unknown> = { ...source }
220
+ if (event.model === undefined) event.model = model
221
+ if (event.threadId === undefined && (source.type === 'RUN_STARTED' || source.type === 'RUN_FINISHED')) {
222
+ event.threadId = threadId
223
+ }
224
+ if (event.runId === undefined && (source.type === 'RUN_STARTED' || source.type === 'RUN_FINISHED')) {
225
+ event.runId = runId
226
+ }
227
+ if (source.type === 'RUN_FINISHED' && event.finishReason === undefined) {
228
+ // harness adapter 自己跑完工具循环;引擎不应再发起下一轮模型调用
229
+ event.finishReason = 'stop'
230
+ }
231
+ return event as unknown as AdapterYieldChunk
232
+ }
@@ -28,6 +28,11 @@ import {
28
28
  resolveDefaultProviderConfig,
29
29
  } from './provider-router'
30
30
  import { buildChatStreamWithTools } from './chat-stream-builder'
31
+ import {
32
+ createLocalAgentAdapter,
33
+ hasLocalAgentService,
34
+ } from './local-agent-adapter'
35
+ import { isDesktopServer } from '@/server/desktop-guest'
31
36
  import type { PgStreamDurabilityFactory } from './durability/pg-stream'
32
37
  import { resolveByokApiKey, resolveByokProviderMeta } from './byok'
33
38
  import { resolveSourceMode } from './source-mode'
@@ -66,6 +71,13 @@ export interface UnifiedAiRequest {
66
71
  | { type: "cloud"; workspaceId: string }
67
72
  /** 已解析的模型来源模式(由 chat-handler 从 session 注入,跳过 per-request DB findUnique) */
68
73
  aiSourceMode?: string | null
74
+ /**
75
+ * 执行引擎(线程级):cloud=服务端模型;local=桌面 pi 本地 Agent。
76
+ * 只有 desktop 部署且后端装配了 __allblueAgentService 时才允许 local。
77
+ */
78
+ engine?: 'cloud' | 'local'
79
+ /** local 引擎的授权工作目录(桌面壳 workspaces.json 复核) */
80
+ localCwd?: string
69
81
  }
70
82
 
71
83
  export type UnifiedAiStream = AsyncIterable<StreamChunk>
@@ -107,10 +119,41 @@ async function tryRemoteRoute(req: UnifiedAiRequest): Promise<UnifiedAiRoute | n
107
119
  }
108
120
  }
109
121
 
122
+ async function resolveLocalAgentRoute(req: UnifiedAiRequest): Promise<UnifiedAiRoute> {
123
+ if (!isDesktopServer()) {
124
+ throw new NoAiAvailableError('本地 Agent 仅在桌面客户端可用')
125
+ }
126
+ if (!req.localCwd) {
127
+ throw new NoAiAvailableError('本地 Agent 缺少授权工作目录')
128
+ }
129
+ if (!hasLocalAgentService()) {
130
+ throw new NoAiAvailableError(
131
+ '本地 Agent 运行时未装配:桌面后端未加载 pi(检查 DESKTOP_AGENT 与 agent bundle)',
132
+ )
133
+ }
134
+ return {
135
+ kind: 'local',
136
+ stream: await buildChatStreamWithTools({
137
+ ...req,
138
+ adapter: createLocalAgentAdapter(),
139
+ localAgent: true,
140
+ localCwd: req.localCwd,
141
+ usageProvider: 'local-pi',
142
+ usageModel: 'local-pi',
143
+ providerConfigKind: 'local-pi',
144
+ }),
145
+ }
146
+ }
147
+
110
148
  export async function resolveUnifiedAiRoute(
111
149
  req: UnifiedAiRequest,
112
150
  request?: Request,
113
151
  ): Promise<UnifiedAiRoute> {
152
+ // 线程级引擎选择优先于用户级 aiSourceMode:Harness 的「本地工作区」线程
153
+ // 必须稳定走 pi,不能被设置里的 hosted/byok 模式劫持。
154
+ if (req.engine === 'local') {
155
+ return resolveLocalAgentRoute(req)
156
+ }
114
157
  // aiSourceMode 是第一优先级互斥开关:选中模式后其余路由层全部跳过
115
158
  // (P0-4 修复:客户端 byokProvider 回调使 x-byok header 常态携带,固定顺序下 BYOK 会劫持模式 2/1)
116
159
  // 优先使用 chat-handler 从 session 注入的已解析值,避免 per-request DB findUnique
@@ -125,12 +168,12 @@ export async function resolveUnifiedAiRoute(
125
168
  }
126
169
 
127
170
  if (mode === 'local-agent') {
128
- // M1 阶段占位:本地 Agent 会话在 M5(AcpSession)落地前,配置了该模式的用户得到明确错误
129
- // 而非静默回落 BYOK(静默回落会掩盖用户的模式选择)
130
- const configured = false // TODO(M5): resolveLocalAgentConfig(req.userId, req.model)
131
- if (!configured) {
132
- throw new NoAiAvailableError('local-agent-unconfigured:本地 Agent 模式尚未配置可用 CLI')
133
- }
171
+ // 用户级「本地 Agent」模式:有线程级 cwd 时走同一适配器;没有 cwd 时给出
172
+ // 可执行的指引,而不是静默回落 BYOK(回落会掩盖用户的模式选择)。
173
+ if (req.localCwd) return resolveLocalAgentRoute(req)
174
+ throw new NoAiAvailableError(
175
+ 'local-agent-unconfigured:本地 Agent 需要授权工作目录,请在 Harness 选择「本地工作区」后重试',
176
+ )
134
177
  }
135
178
  if (mode === 'hosted') {
136
179
  const hosted = await tryHosted(req, request)
@@ -19,6 +19,7 @@ import { getPrisma } from "@allbluecn/database/get-prisma"
19
19
  import { Prisma } from "@allbluecn/database"
20
20
  import { createServerFn } from "@tanstack/react-start"
21
21
  import { authContextMiddleware } from "@/server/middlewares/auth-context"
22
+ import { isDesktopServer } from "@/server/desktop-guest"
22
23
  import { z } from "zod"
23
24
 
24
25
  /** 可空 Json 列的 createMany 入参收窄:null → Prisma.JsonNull(Prisma 拒绝裸 null) */
@@ -43,6 +44,8 @@ export const listConversationsFn = createServerFn({ method: "GET" })
43
44
  title: true,
44
45
  isArchived: true,
45
46
  agentId: true,
47
+ engine: true,
48
+ localCwd: true,
46
49
  updatedAt: true,
47
50
  _count: { select: { messages: true } },
48
51
  agent: { select: { name: true, avatarEmoji: true } },
@@ -61,6 +64,8 @@ export const listConversationsFn = createServerFn({ method: "GET" })
61
64
  messageCount: c._count.messages,
62
65
  isArchived: c.isArchived,
63
66
  agentId: c.agentId,
67
+ engine: c.engine === "local" ? ("local" as const) : ("cloud" as const),
68
+ localCwd: c.localCwd,
64
69
  agentName: c.agent?.name ?? null,
65
70
  agentEmoji: c.agent?.avatarEmoji ?? null,
66
71
  pendingInteraction: (c.interrupts?.length ?? 0) > 0,
@@ -69,6 +74,10 @@ export const listConversationsFn = createServerFn({ method: "GET" })
69
74
 
70
75
  const createConversationSchema = z.object({
71
76
  agentId: z.string().optional().default(""),
77
+ /** 执行引擎:cloud(默认)/ local(桌面 pi 本地 Agent) */
78
+ engine: z.enum(["cloud", "local"]).default("cloud"),
79
+ /** local 引擎的授权工作目录(桌面壳复核) */
80
+ localCwd: z.string().min(1).max(1000).optional(),
72
81
  })
73
82
 
74
83
  export const createConversationFn = createServerFn({ method: "POST" })
@@ -79,7 +88,19 @@ export const createConversationFn = createServerFn({ method: "POST" })
79
88
 
80
89
  const agentId = data.agentId || null
81
90
 
91
+ if (data.engine === "local") {
92
+ if (!isDesktopServer()) {
93
+ throw new Error("本地 Agent 仅在桌面客户端可用")
94
+ }
95
+ if (!data.localCwd) {
96
+ throw new Error("本地 Agent 需要先选择授权工作目录")
97
+ }
98
+ }
99
+
82
100
  if (agentId) {
101
+ if (data.engine === "local") {
102
+ throw new Error("本地 Agent 会话不能绑定云端 Agent 预设")
103
+ }
83
104
  const agent = await prisma.userAgent.findFirst({
84
105
  where: {
85
106
  id: agentId,
@@ -92,7 +113,12 @@ export const createConversationFn = createServerFn({ method: "POST" })
92
113
  }
93
114
 
94
115
  const conversation = await prisma.chatThread.create({
95
- data: { userId: context.user.id, agentId },
116
+ data: {
117
+ userId: context.user.id,
118
+ agentId,
119
+ engine: data.engine,
120
+ localCwd: data.engine === "local" ? (data.localCwd ?? null) : null,
121
+ },
96
122
  include: { agent: { select: { name: true, avatarEmoji: true } } },
97
123
  })
98
124
 
@@ -103,15 +129,55 @@ export const createConversationFn = createServerFn({ method: "POST" })
103
129
  messageCount: 0,
104
130
  isArchived: conversation.isArchived,
105
131
  agentId: conversation.agentId,
132
+ engine: data.engine,
133
+ localCwd: conversation.localCwd,
106
134
  agentName: conversation.agent?.name ?? null,
107
135
  agentEmoji: conversation.agent?.avatarEmoji ?? null,
108
136
  }
109
137
  })
110
138
 
139
+ const getConversationSchema = z.object({
140
+ id: z.string().min(1),
141
+ })
142
+
143
+ /**
144
+ * 单会话元数据(线程级引擎 + 本地工作目录):$threadId 路由打开既有会话时
145
+ * 需要它初始化 Harness 的引擎/目录选择(消息列表本身不含这些字段)。
146
+ */
147
+ export const getConversationFn = createServerFn({ method: "GET" })
148
+ .middleware([authContextMiddleware])
149
+ .validator(getConversationSchema)
150
+ .handler(async ({ data, context }) => {
151
+ const prisma = await getPrisma()
152
+ const thread = await prisma.chatThread.findFirst({
153
+ where: { id: data.id, userId: context.user.id },
154
+ select: {
155
+ id: true,
156
+ title: true,
157
+ agentId: true,
158
+ engine: true,
159
+ localCwd: true,
160
+ isArchived: true,
161
+ },
162
+ })
163
+ if (!thread) return null
164
+ return {
165
+ id: thread.id,
166
+ title: thread.title,
167
+ agentId: thread.agentId,
168
+ engine: thread.engine === "local" ? ("local" as const) : ("cloud" as const),
169
+ localCwd: thread.localCwd,
170
+ isArchived: thread.isArchived,
171
+ }
172
+ })
173
+
111
174
  const updateConversationSchema = z.object({
112
175
  id: z.string(),
113
176
  title: z.string().optional(),
114
177
  isArchived: z.boolean().optional(),
178
+ /** 空线程可切换引擎(有消息后锁定,客户端亦禁用) */
179
+ engine: z.enum(["cloud", "local"]).optional(),
180
+ localCwd: z.string().min(1).max(1000).nullable().optional(),
115
181
  })
116
182
 
117
183
  export const updateConversationFn = createServerFn({ method: "POST" })
@@ -124,6 +190,30 @@ export const updateConversationFn = createServerFn({ method: "POST" })
124
190
  if (data.title !== undefined) update.title = data.title
125
191
  if (data.isArchived !== undefined) update.isArchived = data.isArchived
126
192
 
193
+ if (data.engine !== undefined || data.localCwd !== undefined) {
194
+ const thread = await prisma.chatThread.findFirst({
195
+ where: { id: data.id, userId: context.user.id },
196
+ select: { engine: true, localCwd: true, _count: { select: { messages: true } } },
197
+ })
198
+ if (!thread) throw new Error("thread not found")
199
+ if (thread._count.messages > 0) {
200
+ throw new Error("该会话已有消息,不能切换执行引擎:请新建会话")
201
+ }
202
+ const nextEngine = data.engine ?? (thread.engine === "local" ? "local" : "cloud")
203
+ if (nextEngine === "local" && !isDesktopServer()) {
204
+ throw new Error("本地 Agent 仅在桌面客户端可用")
205
+ }
206
+ update.engine = nextEngine
207
+ update.localCwd =
208
+ nextEngine === "local"
209
+ ? (data.localCwd !== undefined ? data.localCwd : thread.localCwd)
210
+ : null
211
+ if (nextEngine === "local") {
212
+ // 本地 Agent 不绑定云端 Agent 预设
213
+ update.agentId = null
214
+ }
215
+ }
216
+
127
217
  await prisma.chatThread.update({
128
218
  where: { id: data.id, userId: context.user.id },
129
219
  data: update,
@@ -142,10 +232,37 @@ export const deleteConversationFn = createServerFn({ method: "POST" })
142
232
  .handler(async ({ data, context }) => {
143
233
  const prisma = await getPrisma()
144
234
 
235
+ const thread = await prisma.chatThread.findFirst({
236
+ where: { id: data.id, userId: context.user.id },
237
+ select: { engine: true, localCwd: true },
238
+ })
239
+ if (!thread) return { success: true }
240
+
145
241
  await prisma.chatThread.delete({
146
242
  where: { id: data.id, userId: context.user.id },
147
243
  })
148
244
 
245
+ // 本地 Agent 的 pi 会话文件与 Harness 消息是双份存储:删线程时同步清理,
246
+ // 否则已删除会话的本地对话仍留在 userData/agent/sessions。失败不阻塞删除。
247
+ if (thread.engine === "local" && thread.localCwd) {
248
+ try {
249
+ // 动态 import:serverFn 客户端 split 只重写 createServerFn 调用,顶层
250
+ // import 的服务端模块会被保留进客户端图;adapter 只在服务端执行。
251
+ const { getLocalAgentService } = await import(
252
+ "@/server/ai-gateway/local-agent-adapter"
253
+ )
254
+ await getLocalAgentService()?.deleteLocalAgentSession?.({
255
+ cwd: thread.localCwd,
256
+ sessionId: data.id,
257
+ })
258
+ } catch (error) {
259
+ console.warn(
260
+ "[chat] 本地 Agent 会话文件清理失败:",
261
+ error instanceof Error ? error.message : String(error),
262
+ )
263
+ }
264
+ }
265
+
149
266
  return { success: true }
150
267
  })
151
268
 
@@ -196,6 +313,8 @@ export const forkConversationFn = createServerFn({ method: "POST" })
196
313
  userId,
197
314
  title: source.title ? `${source.title} (fork)` : "(fork)",
198
315
  agentId: source.agentId,
316
+ engine: source.engine,
317
+ localCwd: source.localCwd,
199
318
  },
200
319
  })
201
320
 
@@ -233,6 +352,8 @@ export const forkConversationFn = createServerFn({ method: "POST" })
233
352
  messageCount: source._count.messages,
234
353
  isArchived: forked.isArchived,
235
354
  agentId: forked.agentId,
355
+ engine: forked.engine === "local" ? ("local" as const) : ("cloud" as const),
356
+ localCwd: forked.localCwd,
236
357
  agentName: source.agent?.name ?? null,
237
358
  agentEmoji: source.agent?.avatarEmoji ?? null,
238
359
  }