@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.
- package/package.json +2 -2
- package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +0 -2
- package/src/components/harness/ai-chat-history/components/conversation-item.tsx +6 -0
- package/src/components/harness/ai-chat-history/types.ts +4 -0
- package/src/components/harness/chat-ui.tsx +20 -0
- package/src/components/harness/composer/harness-composer.tsx +43 -10
- package/src/components/harness/composer/harness-engine-control.tsx +129 -0
- package/src/components/harness/harness-workbench-page.tsx +129 -17
- package/src/components/layout/sidebar-03/__tests__/user-menu-cloud-logout.test.tsx +185 -0
- package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -9
- package/src/components/layout/sidebar-03/user-menu.tsx +27 -50
- package/src/components/shared-kit/auth/auth-client.ts +4 -0
- package/src/config/sidebar-routes.tsx +0 -13
- package/src/lib/__tests__/desktop-logout.test.ts +239 -0
- package/src/lib/__tests__/session.test.ts +85 -2
- package/src/lib/auth/__tests__/page-gate.test.ts +37 -1
- package/src/lib/auth/anonymous-safe.ts +5 -0
- package/src/lib/auth/page-gate.ts +11 -1
- package/src/lib/changelog/sdk-versions.ts +1 -1
- package/src/lib/desktop-env.ts +25 -0
- package/src/lib/desktop-logout.ts +117 -0
- package/src/lib/hotkeys/__tests__/registry.test.ts +4 -11
- package/src/lib/hotkeys/registry.ts +0 -8
- package/src/lib/session.ts +70 -0
- package/src/paraglide/README.md +1 -1
- package/src/paraglide/messages/_index.d.ts +12 -2
- package/src/paraglide/messages/_index.js +12 -2
- package/src/paraglide/messages/{agent_desktop_title.d.ts → harness_engine_cloud.d.ts} +5 -5
- package/src/paraglide/messages/harness_engine_cloud.js +29 -0
- package/src/paraglide/messages/harness_engine_label.d.ts +16 -0
- package/src/paraglide/messages/harness_engine_label.js +29 -0
- package/src/paraglide/messages/harness_engine_local.d.ts +16 -0
- package/src/paraglide/messages/harness_engine_local.js +29 -0
- package/src/paraglide/messages/harness_engine_localbadge1.d.ts +17 -0
- package/src/paraglide/messages/harness_engine_localbadge1.js +30 -0
- package/src/paraglide/messages/harness_engine_localhint1.d.ts +17 -0
- package/src/paraglide/messages/harness_engine_localhint1.js +30 -0
- package/src/paraglide/messages/harness_engine_localnoattachments2.d.ts +17 -0
- package/src/paraglide/messages/harness_engine_localnoattachments2.js +30 -0
- package/src/paraglide/messages/harness_engine_switchlocked1.d.ts +17 -0
- package/src/paraglide/messages/harness_engine_switchlocked1.js +30 -0
- package/src/paraglide/messages/harness_localworkspace_choose1.d.ts +17 -0
- package/src/paraglide/messages/harness_localworkspace_choose1.js +30 -0
- package/src/paraglide/messages/harness_localworkspace_dialogdescription2.d.ts +17 -0
- package/src/paraglide/messages/harness_localworkspace_dialogdescription2.js +30 -0
- package/src/paraglide/messages/harness_localworkspace_dialogtitle2.d.ts +17 -0
- package/src/paraglide/messages/harness_localworkspace_dialogtitle2.js +30 -0
- package/src/paraglide/messages/harness_localworkspace_manage1.d.ts +17 -0
- package/src/paraglide/messages/harness_localworkspace_manage1.js +30 -0
- package/src/paraglide/messages/harness_localworkspace_required1.d.ts +17 -0
- package/src/paraglide/messages/harness_localworkspace_required1.js +30 -0
- package/src/routeTree.gen.ts +0 -23
- package/src/routes/__tests__/-edition-routing.test.tsx +48 -7
- package/src/routes/__tests__/-login-admin-redirect.test.ts +99 -0
- package/src/routes/_login/login/route.tsx +18 -3
- package/src/routes/_nav/design/canvas/$id/route.tsx +5 -0
- package/src/routes/_nav/harness/$threadId.lazy.tsx +19 -2
- package/src/routes/_nav/harness/$threadId.tsx +8 -2
- package/src/routes/_nav/knowledge/$kbId/route.tsx +6 -2
- package/src/routes/_nav/route.tsx +5 -1
- package/src/routes/_nav/settings/account/route.tsx +5 -0
- package/src/routes/_nav/settings/team/route.tsx +5 -0
- package/src/routes/api/auth/$.ts +87 -2
- package/src/routes/api/auth/__tests__/auth-session-desktop.test.ts +194 -0
- package/src/server/__tests__/chat-conversations.test.ts +13 -3
- package/src/server/__tests__/desktop-bootstrap.test.ts +0 -1
- package/src/server/ai-gateway/__tests__/local-agent-adapter.test.ts +110 -0
- package/src/server/ai-gateway/__tests__/unified-router-local-agent.test.ts +63 -0
- package/src/server/ai-gateway/chat-handler.ts +68 -2
- package/src/server/ai-gateway/chat-stream-builder.ts +52 -0
- package/src/server/ai-gateway/local-agent-adapter.ts +232 -0
- package/src/server/ai-gateway/unified-router.ts +49 -6
- package/src/server/desktop-guest.ts +5 -1
- package/src/server/serverFns/auth/session.ts +19 -10
- package/src/server/serverFns/chat/conversations.ts +122 -1
- package/src/components/desktop-agent/__tests__/agent-chat.test.tsx +0 -194
- package/src/components/desktop-agent/agent-chat.tsx +0 -222
- package/src/lib/desktop-agent/__tests__/connection.test.ts +0 -310
- package/src/lib/desktop-agent/__tests__/errors.test.ts +0 -43
- package/src/lib/desktop-agent/connection.ts +0 -93
- package/src/lib/desktop-agent/errors.ts +0 -21
- package/src/paraglide/messages/agent_desktop_title.js +0 -29
- package/src/paraglide/messages/common_hotkey_desc_agentabort1.d.ts +0 -17
- package/src/paraglide/messages/common_hotkey_desc_agentabort1.js +0 -30
- package/src/routes/-tests/agent-gate.test.ts +0 -45
- package/src/routes/_nav/agent/route.lazy.tsx +0 -63
- package/src/routes/_nav/agent/route.tsx +0 -21
- /package/src/components/{desktop-agent → harness/local-agent}/__tests__/agent-tool-card.test.tsx +0 -0
- /package/src/components/{desktop-agent → harness/local-agent}/__tests__/agent-workspace-card.test.tsx +0 -0
- /package/src/components/{desktop-agent → harness/local-agent}/agent-tool-card.tsx +0 -0
- /package/src/components/{desktop-agent → harness/local-agent}/agent-workspace-card.tsx +0 -0
|
@@ -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
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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)
|
|
@@ -38,5 +38,9 @@ export function buildGuestSession(): Session {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export function isDesktopServer(): boolean {
|
|
41
|
-
|
|
41
|
+
// 本模块被路由模块(如 _nav/route.tsx 的 beforeLoad)在客户端一并加载:
|
|
42
|
+
// 浏览器 bundle 无 Node process(或 env 被静态替换为空对象)时必须安全
|
|
43
|
+
// 返回 false——客户端侧的桌面判定走 lib/desktop-env.ts 的 isDesktopClient()
|
|
44
|
+
// (desktopBridge 运行时事实),两者在 beforeLoad 里合并使用。
|
|
45
|
+
return typeof process !== 'undefined' && process.env?.DEPLOY_TARGET === 'desktop'
|
|
42
46
|
}
|
|
@@ -22,7 +22,7 @@ import type { FeatureKey } from '@allbluecn/kernel'
|
|
|
22
22
|
import { getPrisma } from '@allbluecn/database/get-prisma'
|
|
23
23
|
import { getCurrentSession } from '@/lib/session'
|
|
24
24
|
import { resetProfileCache } from '@/lib/auth/auth.config'
|
|
25
|
-
import { buildGuestSession, isDesktopServer } from '@/server/desktop-guest'
|
|
25
|
+
import { buildGuestSession, isDesktopServer, isGuestSession } from '@/server/desktop-guest'
|
|
26
26
|
import { ensureLocalShadowUser } from '@/server/desktop-bootstrap'
|
|
27
27
|
import { ensureCloudIdentity } from '@/server/desktop-cloud-identity'
|
|
28
28
|
import { readRemoteAuth, writeRemoteAuth, writeRemoteSession, clearRemoteAuth } from '@/server/remote-auth'
|
|
@@ -53,22 +53,31 @@ const saasProfileSchema = z.object({
|
|
|
53
53
|
// getCurrentSession 桌面回落需复用同一组装,且 lib/session 不得 import
|
|
54
54
|
// serverFns/**(循环依赖),组装点移至 server 域纯模块。
|
|
55
55
|
|
|
56
|
+
/** 桌面 guest 懒 seed 影子用户:失败仅 warn 不阻断会话发放(首启体验优先,规格 §3.1) */
|
|
57
|
+
async function ensureGuestSeeded(): Promise<void> {
|
|
58
|
+
try {
|
|
59
|
+
await ensureLocalShadowUser()
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.warn('[getSessionFn] 本地影子用户落库失败(不阻断 guest 会话):', err)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
export const getSessionFn = createServerFn({ method: 'GET' })
|
|
57
66
|
.handler(async () => {
|
|
58
67
|
const request = getRequest()
|
|
59
68
|
const session = await getCurrentSession(request)
|
|
60
|
-
if (session?.user?.id)
|
|
69
|
+
if (session?.user?.id) {
|
|
70
|
+
// getCurrentSession 已含桌面 guest 回落(鉴权单点):guest 首启仍需懒 seed
|
|
71
|
+
// 本地影子用户(本地优先 P1),使 assertOwned/requireUserInDb/FK 全链可写。
|
|
72
|
+
// 失败仅 warn 不阻断会话发放——首启体验优先(规格 §3.1),下一请求再重试。
|
|
73
|
+
if (isGuestSession(session)) await ensureGuestSeeded()
|
|
74
|
+
return session
|
|
75
|
+
}
|
|
61
76
|
if (!isDesktopServer()) return null
|
|
62
77
|
const remoteAuth = await readRemoteAuth()
|
|
63
78
|
if (remoteAuth) return buildRemoteSession(remoteAuth)
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
// ——首启体验优先(规格 §3.1),下一请求再重试。
|
|
67
|
-
try {
|
|
68
|
-
await ensureLocalShadowUser()
|
|
69
|
-
} catch (err) {
|
|
70
|
-
console.warn('[getSessionFn] 本地影子用户落库失败(不阻断 guest 会话):', err)
|
|
71
|
-
}
|
|
79
|
+
// 兜底分支(getCurrentSession 已覆盖 guest,此处防御未来改动):保持旧行为
|
|
80
|
+
await ensureGuestSeeded()
|
|
72
81
|
return buildGuestSession()
|
|
73
82
|
})
|
|
74
83
|
|
|
@@ -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: {
|
|
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
|
}
|