@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.
- 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 +57 -20
- package/src/components/harness/composer/harness-engine-control.tsx +129 -0
- package/src/components/harness/harness-workbench-page.tsx +140 -18
- package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -9
- package/src/config/sidebar-routes.tsx +0 -13
- package/src/lib/changelog/sdk-versions.ts +1 -1
- package/src/lib/hotkeys/__tests__/registry.test.ts +4 -11
- package/src/lib/hotkeys/registry.ts +0 -8
- 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/_nav/harness/$threadId.lazy.tsx +19 -2
- package/src/routes/_nav/harness/$threadId.tsx +8 -2
- 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/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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allbluecn/web-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"license": "AGPL-3.0-or-later",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@ag-ui/core": "1.0.0",
|
|
62
62
|
"@allbluecn/collab-core": "0.2.0",
|
|
63
|
-
"@allbluecn/database": "0.
|
|
63
|
+
"@allbluecn/database": "0.13.0",
|
|
64
64
|
"@allbluecn/kernel": "0.12.0",
|
|
65
65
|
"@allbluecn/sync-core": "0.7.0",
|
|
66
66
|
"@ariakit/react": "^0.4.28",
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
28
28
|
import { cn } from "@/lib/utils";
|
|
29
29
|
import { formatChatDate } from "@/lib/datetime";
|
|
30
|
+
import { Badge } from "@/components/ui/badge";
|
|
30
31
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
|
31
32
|
import { Button } from "@/components/ui/button";
|
|
32
33
|
import { toast } from "@/components/ui/toast";
|
|
@@ -161,6 +162,11 @@ export function ConversationItem({
|
|
|
161
162
|
<h4 className="wrap-break-word min-w-0 font-medium text-sm">
|
|
162
163
|
{conversation.title ?? m.chat_newConversation()}
|
|
163
164
|
</h4>
|
|
165
|
+
{conversation.engine === "local" && (
|
|
166
|
+
<Badge variant="outline" className="shrink-0 px-1.5 py-0 text-[10px] font-normal">
|
|
167
|
+
{m.harness_engine_localBadge()}
|
|
168
|
+
</Badge>
|
|
169
|
+
)}
|
|
164
170
|
{conversation.pendingInteraction && (
|
|
165
171
|
<Tooltip>
|
|
166
172
|
<TooltipTrigger
|
|
@@ -24,6 +24,10 @@ export interface Conversation {
|
|
|
24
24
|
isArchived?: boolean;
|
|
25
25
|
isActive?: boolean;
|
|
26
26
|
pendingInteraction?: boolean;
|
|
27
|
+
/** 执行引擎:cloud(服务端模型)/ local(桌面 pi 本地 Agent) */
|
|
28
|
+
engine?: "cloud" | "local";
|
|
29
|
+
/** 本地 Agent 的授权工作目录(engine=local 时有值) */
|
|
30
|
+
localCwd?: string | null;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export interface AIChatHistoryProps {
|
|
@@ -20,6 +20,7 @@ import { fetchServerSentEvents } from "@tanstack/ai-react";
|
|
|
20
20
|
import {
|
|
21
21
|
createChatHook,
|
|
22
22
|
type PartProps,
|
|
23
|
+
type ToolProps,
|
|
23
24
|
} from "@tanstack/ai-react/ui";
|
|
24
25
|
import {
|
|
25
26
|
automaticPartsForMessage,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
} from "@/components/ai/tool-cards";
|
|
46
47
|
import { PlanApprovalCard } from "./plan-approval-card";
|
|
47
48
|
import { PluginDefineApproval } from "./plugin-define-approval";
|
|
49
|
+
import { AgentToolCard } from "./local-agent/agent-tool-card";
|
|
48
50
|
import { buildPluginClientTools } from "@/lib/plugin-runtime/plugin-client-tools";
|
|
49
51
|
|
|
50
52
|
/**
|
|
@@ -159,6 +161,11 @@ function HarnessSelectedPart({ part }: { part: ChatUISelectedPart }) {
|
|
|
159
161
|
return (
|
|
160
162
|
<KnowledgeSearchCard part={part.part as never} result={part.result as never} />
|
|
161
163
|
);
|
|
164
|
+
case "read":
|
|
165
|
+
case "grep":
|
|
166
|
+
case "find":
|
|
167
|
+
case "ls":
|
|
168
|
+
return <AgentToolCard part={part.part as never} result={part.result as never} />;
|
|
162
169
|
default:
|
|
163
170
|
return <FallbackToolCard part={part.part as never} result={part.result as never} />;
|
|
164
171
|
}
|
|
@@ -171,6 +178,15 @@ function HarnessSelectedPart({ part }: { part: ChatUISelectedPart }) {
|
|
|
171
178
|
return null;
|
|
172
179
|
}
|
|
173
180
|
|
|
181
|
+
/**
|
|
182
|
+
* 桌面本地 Agent(pi)的只读四件套工具卡:createChatHook 的 toolsComponents
|
|
183
|
+
* 以 ToolProps 形状调用,而 AgentToolCard 用的是 UIMessage 的 tool-call/
|
|
184
|
+
* tool-result part 类型;两者运行时同形,这里做一次承载性 as 收窄。
|
|
185
|
+
*/
|
|
186
|
+
function LocalAgentToolCard(props: ToolProps<unknown, string>) {
|
|
187
|
+
return <AgentToolCard part={props.part as never} result={props.result as never} />;
|
|
188
|
+
}
|
|
189
|
+
|
|
174
190
|
export const {
|
|
175
191
|
useAppChat: useHarnessChat,
|
|
176
192
|
useChatContext: useHarnessChatContext,
|
|
@@ -201,6 +217,10 @@ export const {
|
|
|
201
217
|
plugin_stop: FallbackToolCard,
|
|
202
218
|
plugin_undefine: FallbackToolCard,
|
|
203
219
|
plugin_inspect_list: FallbackToolCard,
|
|
220
|
+
read: LocalAgentToolCard,
|
|
221
|
+
grep: LocalAgentToolCard,
|
|
222
|
+
find: LocalAgentToolCard,
|
|
223
|
+
ls: LocalAgentToolCard,
|
|
204
224
|
},
|
|
205
225
|
interruptsComponents: {
|
|
206
226
|
generic: {
|
|
@@ -23,6 +23,7 @@ import { decideSubmit, type BusyAction } from "./submit-machine"
|
|
|
23
23
|
import { useAttachments } from "./attachments/use-attachments"
|
|
24
24
|
import { AttachmentTray } from "./attachments/attachment-tray"
|
|
25
25
|
import { ModelSelect } from "./model-select"
|
|
26
|
+
import { HarnessEngineControl, type HarnessEngine } from "./harness-engine-control"
|
|
26
27
|
import { ContextMeter } from "./context-meter"
|
|
27
28
|
import { InterruptApproval } from "@/components/harness/interrupt-approval"
|
|
28
29
|
import { ComposerEditor } from "./composer-editor"
|
|
@@ -157,6 +158,14 @@ export interface HarnessComposerProps {
|
|
|
157
158
|
handoffContext?: { board: string; entityId: string } | null
|
|
158
159
|
/** 纯文本 handoff 预填值(dashboard 等 board:跳过 chip 插入,直接写入编辑器文本) */
|
|
159
160
|
handoffPrefillText?: string | null
|
|
161
|
+
/** 执行引擎(桌面端):cloud=服务端模型;local=pi 本地工作区 */
|
|
162
|
+
engine?: HarnessEngine
|
|
163
|
+
onEngineChange?: (engine: HarnessEngine) => void
|
|
164
|
+
/** 本地 Agent 的授权工作目录 */
|
|
165
|
+
localCwd?: string | null
|
|
166
|
+
onLocalCwdChange?: (path: string) => void
|
|
167
|
+
/** 运行中 / 已有消息:锁定引擎与目录 */
|
|
168
|
+
engineLocked?: boolean
|
|
160
169
|
}
|
|
161
170
|
|
|
162
171
|
export function HarnessComposer({
|
|
@@ -182,7 +191,16 @@ export function HarnessComposer({
|
|
|
182
191
|
scrollContainerRef,
|
|
183
192
|
handoffContext,
|
|
184
193
|
handoffPrefillText,
|
|
194
|
+
engine,
|
|
195
|
+
onEngineChange,
|
|
196
|
+
localCwd,
|
|
197
|
+
onLocalCwdChange,
|
|
198
|
+
engineLocked,
|
|
185
199
|
}: HarnessComposerProps) {
|
|
200
|
+
// 本地工作区引擎由桌面 pi 按「设置 → AI 服务商」里已启用的服务商自行解析,
|
|
201
|
+
// 不依赖 session.hasAiKey(guest 会话该字段恒 false);云端仍要求 hasAiKey。
|
|
202
|
+
const localEngine = engine === "local"
|
|
203
|
+
const aiReady = hasAiKey || localEngine
|
|
186
204
|
const [input, setInput] = useState("")
|
|
187
205
|
const [pendingPrefillEntity, setPendingPrefillEntity] = useState<PrefillMentionEntity | null>(null)
|
|
188
206
|
const [pendingPrefillText, setPendingPrefillText] = useState<string | null>(null)
|
|
@@ -190,7 +208,7 @@ export function HarnessComposer({
|
|
|
190
208
|
const receiveEditor = useCallback((editor: Editor | null) => {
|
|
191
209
|
editorRef.current = editor ?? null
|
|
192
210
|
setInput(editor?.getText() ?? "")
|
|
193
|
-
if (editor &&
|
|
211
|
+
if (editor && aiReady) {
|
|
194
212
|
if (pendingPrefillEntity) {
|
|
195
213
|
insertMentionChip(editor, pendingPrefillEntity)
|
|
196
214
|
setPendingPrefillEntity(null)
|
|
@@ -199,7 +217,7 @@ export function HarnessComposer({
|
|
|
199
217
|
setPendingPrefillText(null)
|
|
200
218
|
}
|
|
201
219
|
}
|
|
202
|
-
}, [pendingPrefillEntity, pendingPrefillText,
|
|
220
|
+
}, [pendingPrefillEntity, pendingPrefillText, aiReady])
|
|
203
221
|
const { attachments, addFiles, removeAttachment, clearAttachments, hasUploading } = useAttachments()
|
|
204
222
|
// @mention chip 中携带的 kbId(由 ComposerEditor 通过 onMentionKbIds 回调上报)
|
|
205
223
|
const [editorMentionKbIds, setEditorMentionKbIds] = useState<string[]>([])
|
|
@@ -228,24 +246,24 @@ export function HarnessComposer({
|
|
|
228
246
|
}
|
|
229
247
|
const entity: PrefillMentionEntity = prefill.entity
|
|
230
248
|
setPendingPrefillEntity(entity)
|
|
231
|
-
if (editorRef.current &&
|
|
249
|
+
if (editorRef.current && aiReady) {
|
|
232
250
|
insertMentionChip(editorRef.current, entity)
|
|
233
251
|
setPendingPrefillEntity(null)
|
|
234
252
|
}
|
|
235
|
-
}, [handoffContext, handoffPrefillText,
|
|
253
|
+
}, [handoffContext, handoffPrefillText, aiReady])
|
|
236
254
|
|
|
237
255
|
// placeholder 五态
|
|
238
256
|
const placeholder = computePlaceholder({
|
|
239
|
-
hasAiKey,
|
|
257
|
+
hasAiKey: aiReady,
|
|
240
258
|
isRunning,
|
|
241
259
|
busyPreference,
|
|
242
260
|
})
|
|
243
261
|
|
|
244
262
|
const handleSend = useCallback(
|
|
245
263
|
(text: string, whenBusy: "queue" | "interrupt" = "queue") => {
|
|
246
|
-
if (!text.trim() || !
|
|
264
|
+
if (!text.trim() || !aiReady) return
|
|
247
265
|
|
|
248
|
-
if (mediaRoute === "spec") {
|
|
266
|
+
if (!localEngine && mediaRoute === "spec") {
|
|
249
267
|
if (!threadId) {
|
|
250
268
|
toast.info(m.harness_spec_needThread())
|
|
251
269
|
editorRef.current?.commands.clearContent()
|
|
@@ -295,7 +313,7 @@ export function HarnessComposer({
|
|
|
295
313
|
clearAttachments()
|
|
296
314
|
}
|
|
297
315
|
},
|
|
298
|
-
[
|
|
316
|
+
[aiReady, localEngine, onSend, knowledgeBaseIds, editorMentionKbIds, handoffContext, clearAttachments, removeAttachment, attachments, mediaRoute, threadId]
|
|
299
317
|
)
|
|
300
318
|
|
|
301
319
|
const handleStop = useCallback(() => {
|
|
@@ -364,9 +382,9 @@ export function HarnessComposer({
|
|
|
364
382
|
}}
|
|
365
383
|
>
|
|
366
384
|
<ComposerEditor
|
|
367
|
-
key={String(
|
|
385
|
+
key={String(aiReady)}
|
|
368
386
|
threadId={threadId ?? "default"}
|
|
369
|
-
editable={
|
|
387
|
+
editable={aiReady}
|
|
370
388
|
editorRef={receiveEditor}
|
|
371
389
|
onTextChange={() => setInput(editorRef.current?.getText() ?? "")}
|
|
372
390
|
onMentionKbIds={setEditorMentionKbIds}
|
|
@@ -384,20 +402,39 @@ export function HarnessComposer({
|
|
|
384
402
|
)}
|
|
385
403
|
<div className="flex items-center justify-between gap-2 p-2">
|
|
386
404
|
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
|
387
|
-
|
|
405
|
+
{engine && onEngineChange && (
|
|
406
|
+
<HarnessEngineControl
|
|
407
|
+
engine={engine}
|
|
408
|
+
onEngineChange={onEngineChange}
|
|
409
|
+
localCwd={localCwd ?? null}
|
|
410
|
+
onLocalCwdChange={(path) => onLocalCwdChange?.(path)}
|
|
411
|
+
disabled={engineLocked || isRunning}
|
|
412
|
+
/>
|
|
413
|
+
)}
|
|
414
|
+
{engine !== "local" && (
|
|
415
|
+
<ModelSelect models={models} onModelChange={onModelChange} />
|
|
416
|
+
)}
|
|
388
417
|
<ContextMeter messages={messages} />
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
418
|
+
{engine !== "local" ? (
|
|
419
|
+
<>
|
|
420
|
+
<SandboxPolicyControl
|
|
421
|
+
sandboxPolicy={sandboxPolicy}
|
|
422
|
+
onSandboxPolicyChange={onSandboxPolicyChange}
|
|
423
|
+
/>
|
|
424
|
+
<MediaRouteControl
|
|
425
|
+
mediaRoute={mediaRoute}
|
|
426
|
+
onMediaRouteChange={onMediaRouteChange}
|
|
427
|
+
/>
|
|
428
|
+
</>
|
|
429
|
+
) : (
|
|
430
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
431
|
+
{m.harness_engine_localHint()}
|
|
432
|
+
</span>
|
|
433
|
+
)}
|
|
397
434
|
</div>
|
|
398
435
|
<Button
|
|
399
436
|
size="sm"
|
|
400
|
-
disabled={!
|
|
437
|
+
disabled={!aiReady || sendBlocked || (localEngine && !localCwd)}
|
|
401
438
|
onClick={() => handleSend(editorRef.current?.getText() ?? "")}
|
|
402
439
|
>
|
|
403
440
|
{m.harness_composer_send()}
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
* Harness 引擎切换(桌面端):云端模型 ↔ 本地工作区(pi)。
|
|
20
|
+
*
|
|
21
|
+
* 本地文件助手已收编进 Harness:这里不再有独立模块入口,用户在同一会话工作台
|
|
22
|
+
* 里选择执行引擎。本地引擎的授权目录仍走桌面壳的 workspaces.json(唯一添加入口
|
|
23
|
+
* 是原生目录对话框),复用 AgentWorkspaceCard 的增删/选择交互。
|
|
24
|
+
*
|
|
25
|
+
* 门控与旧入口同源:useDesktopAgentPresence 在挂载后探测 appInfo.agentPort,
|
|
26
|
+
* SSR/hydration 首帧不渲染(云 Web / OSS 恒不可见)。线程已有消息时锁定切换,
|
|
27
|
+
* 避免把云会话历史与 pi 会话错位;首次消息前可自由切换。
|
|
28
|
+
*/
|
|
29
|
+
import { useState } from 'react'
|
|
30
|
+
import { FolderOpen } from 'lucide-react'
|
|
31
|
+
import { Button } from '@/components/ui/button'
|
|
32
|
+
import {
|
|
33
|
+
Dialog,
|
|
34
|
+
DialogContent,
|
|
35
|
+
DialogDescription,
|
|
36
|
+
DialogHeader,
|
|
37
|
+
DialogTitle,
|
|
38
|
+
} from '@/components/ui/dialog'
|
|
39
|
+
import {
|
|
40
|
+
Select,
|
|
41
|
+
SelectContent,
|
|
42
|
+
SelectItem,
|
|
43
|
+
SelectTrigger,
|
|
44
|
+
SelectValue,
|
|
45
|
+
} from '@/components/ui/select'
|
|
46
|
+
import { AgentWorkspaceCard } from '@/components/harness/local-agent/agent-workspace-card'
|
|
47
|
+
import { useDesktopAgentPresence } from '@/lib/desktop-agent/use-desktop-agent-presence'
|
|
48
|
+
import { m } from '@/paraglide/messages.js'
|
|
49
|
+
|
|
50
|
+
export type HarnessEngine = 'cloud' | 'local'
|
|
51
|
+
|
|
52
|
+
function folderNameOf(path: string): string {
|
|
53
|
+
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface HarnessEngineControlProps {
|
|
57
|
+
engine: HarnessEngine
|
|
58
|
+
onEngineChange: (engine: HarnessEngine) => void
|
|
59
|
+
localCwd: string | null
|
|
60
|
+
onLocalCwdChange: (path: string) => void
|
|
61
|
+
/** 运行中 / 已有消息:锁定引擎与目录,避免线程语义漂移 */
|
|
62
|
+
disabled?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function HarnessEngineControl({
|
|
66
|
+
engine,
|
|
67
|
+
onEngineChange,
|
|
68
|
+
localCwd,
|
|
69
|
+
onLocalCwdChange,
|
|
70
|
+
disabled = false,
|
|
71
|
+
}: HarnessEngineControlProps) {
|
|
72
|
+
const presence = useDesktopAgentPresence()
|
|
73
|
+
const [dialogOpen, setDialogOpen] = useState(false)
|
|
74
|
+
|
|
75
|
+
// pending(探测中)与 absent(云 Web / OSS / 旧壳)都不渲染:与服务端
|
|
76
|
+
// engine=local 的门控一致,避免用户选中一个永远不可用的引擎。
|
|
77
|
+
if (presence !== 'present') return null
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
<Select
|
|
82
|
+
value={engine}
|
|
83
|
+
onValueChange={(value) => {
|
|
84
|
+
if (value === 'cloud' || value === 'local') onEngineChange(value)
|
|
85
|
+
}}
|
|
86
|
+
disabled={disabled}
|
|
87
|
+
>
|
|
88
|
+
<SelectTrigger className="h-7 w-auto text-xs" aria-label={m.harness_engine_label()}>
|
|
89
|
+
<SelectValue />
|
|
90
|
+
</SelectTrigger>
|
|
91
|
+
<SelectContent>
|
|
92
|
+
<SelectItem value="cloud">{m.harness_engine_cloud()}</SelectItem>
|
|
93
|
+
<SelectItem value="local">{m.harness_engine_local()}</SelectItem>
|
|
94
|
+
</SelectContent>
|
|
95
|
+
</Select>
|
|
96
|
+
{engine === 'local' && (
|
|
97
|
+
<Button
|
|
98
|
+
variant="outline"
|
|
99
|
+
size="sm"
|
|
100
|
+
className="h-7 max-w-44 text-xs"
|
|
101
|
+
disabled={disabled}
|
|
102
|
+
onClick={() => setDialogOpen(true)}
|
|
103
|
+
aria-label={m.harness_localWorkspace_manage()}
|
|
104
|
+
>
|
|
105
|
+
<FolderOpen className="size-3 shrink-0" />
|
|
106
|
+
<span className="truncate">
|
|
107
|
+
{localCwd ? folderNameOf(localCwd) : m.harness_localWorkspace_choose()}
|
|
108
|
+
</span>
|
|
109
|
+
</Button>
|
|
110
|
+
)}
|
|
111
|
+
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
112
|
+
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-lg">
|
|
113
|
+
<DialogHeader>
|
|
114
|
+
<DialogTitle>{m.harness_localWorkspace_dialogTitle()}</DialogTitle>
|
|
115
|
+
<DialogDescription>{m.harness_localWorkspace_dialogDescription()}</DialogDescription>
|
|
116
|
+
</DialogHeader>
|
|
117
|
+
<AgentWorkspaceCard
|
|
118
|
+
selected={localCwd}
|
|
119
|
+
onSelect={(path) => {
|
|
120
|
+
onLocalCwdChange(path)
|
|
121
|
+
setDialogOpen(false)
|
|
122
|
+
}}
|
|
123
|
+
disabled={disabled}
|
|
124
|
+
/>
|
|
125
|
+
</DialogContent>
|
|
126
|
+
</Dialog>
|
|
127
|
+
</>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { Link as RouterLink, useNavigate } from '@tanstack/react-router'
|
|
19
19
|
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
|
20
20
|
import { useQuery } from '@tanstack/react-query'
|
|
21
|
-
import { AlertCircle, ListTodo } from 'lucide-react'
|
|
21
|
+
import { AlertCircle, ListTodo, FolderOpen } from 'lucide-react'
|
|
22
22
|
import AIChatHistory from '@/components/harness/ai-chat-history'
|
|
23
23
|
import type { Conversation } from '@/components/harness/ai-chat-history'
|
|
24
24
|
import { useHarnessChat } from '@/components/harness/chat-ui'
|
|
@@ -61,9 +61,19 @@ import { ConnectionIndicator } from '@/components/harness/connection-indicator'
|
|
|
61
61
|
import { RetryCountdownBar } from '@/components/harness/retry-countdown-bar'
|
|
62
62
|
import { GhostMessageRow } from '@/components/harness/ghost-message-row'
|
|
63
63
|
|
|
64
|
+
export interface HarnessInitialConversation {
|
|
65
|
+
id: string
|
|
66
|
+
title: string | null
|
|
67
|
+
agentId: string | null
|
|
68
|
+
engine: "cloud" | "local"
|
|
69
|
+
localCwd: string | null
|
|
70
|
+
isArchived: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
64
73
|
export interface HarnessWorkbenchPageProps {
|
|
65
74
|
initialThreadId?: string
|
|
66
75
|
initialMessages?: UIMessage[]
|
|
76
|
+
initialConversation?: HarnessInitialConversation | null
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
type HarnessChatInstance = ReturnType<typeof useHarnessChat>
|
|
@@ -78,12 +88,16 @@ interface PendingThreadSend {
|
|
|
78
88
|
sendOptions?: Parameters<HarnessChatInstance["sendMessage"]>[1]
|
|
79
89
|
}
|
|
80
90
|
|
|
81
|
-
export default function HarnessWorkbenchPage({ initialThreadId, initialMessages }: HarnessWorkbenchPageProps) {
|
|
91
|
+
export default function HarnessWorkbenchPage({ initialThreadId, initialMessages, initialConversation }: HarnessWorkbenchPageProps) {
|
|
82
92
|
const { data: session } = useSession()
|
|
83
93
|
const hasAiKey = session?.user?.hasAiKey
|
|
84
94
|
const navigate = useNavigate()
|
|
85
95
|
const [conversations, setConversations] = useState<Conversation[]>([])
|
|
86
96
|
const [activeConversationId, setActiveConversationId] = useState<string | null>(initialThreadId ?? null)
|
|
97
|
+
// 线程级执行引擎(桌面端才有本地工作区选项):从 loader 元数据还原;
|
|
98
|
+
// 新建会话沿用当前选择。已有消息的线程锁定,避免云历史与 pi 会话错位。
|
|
99
|
+
const [engine, setEngine] = useState<"cloud" | "local">(initialConversation?.engine ?? "cloud")
|
|
100
|
+
const [localCwd, setLocalCwd] = useState<string | null>(initialConversation?.localCwd ?? null)
|
|
87
101
|
const [selectedModel, setSelectedModel] = useSelectedModel()
|
|
88
102
|
const [webSearchEnabled, setWebSearchEnabled] = useState(false)
|
|
89
103
|
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
|
|
@@ -151,13 +165,16 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
|
|
|
151
165
|
threadId: activeConversationId ?? initialThreadId ?? fallbackThreadIdRef.current,
|
|
152
166
|
initialMessages,
|
|
153
167
|
forwardedProps: {
|
|
154
|
-
webSearchEnabled,
|
|
155
|
-
codeModeEnabled,
|
|
168
|
+
webSearchEnabled: engine === "local" ? false : webSearchEnabled,
|
|
169
|
+
codeModeEnabled: engine === "local" ? false : codeModeEnabled,
|
|
156
170
|
model: effectiveModel,
|
|
157
171
|
provider: adapterKind,
|
|
158
|
-
agentId: selectedAgentId,
|
|
172
|
+
agentId: engine === "local" ? undefined : selectedAgentId,
|
|
159
173
|
promptKey: 'chat.default',
|
|
160
|
-
sandbox: sandboxPolicy !== "readonly",
|
|
174
|
+
sandbox: engine === "local" ? false : sandboxPolicy !== "readonly",
|
|
175
|
+
engine,
|
|
176
|
+
// 只读 pi 只接受目录;云端线程不发送 cwd(schema 会忽略)
|
|
177
|
+
cwd: engine === "local" ? (localCwd ?? undefined) : undefined,
|
|
161
178
|
workspaceId: workspace?.type === "cloud" ? workspace.id : undefined,
|
|
162
179
|
workspaceContext: workspace?.type === "github"
|
|
163
180
|
? { type: "github", owner: workspace.owner, repo: workspace.repo, defaultBranch: workspace.branch }
|
|
@@ -302,18 +319,30 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
|
|
|
302
319
|
}, [])
|
|
303
320
|
|
|
304
321
|
const handleNewConversation = useCallback(() => {
|
|
322
|
+
if (engine === "local" && !localCwd) {
|
|
323
|
+
toastError(new Error(m.harness_localWorkspace_required()), m.common_operationFailed())
|
|
324
|
+
return
|
|
325
|
+
}
|
|
305
326
|
clearHarnessCitations()
|
|
306
327
|
setLatestCitations(undefined)
|
|
307
328
|
setActiveConversationId(null)
|
|
308
329
|
activeIdRef.current = null
|
|
309
330
|
chat.clear()
|
|
310
|
-
createConversationFn({
|
|
331
|
+
createConversationFn({
|
|
332
|
+
data: {
|
|
333
|
+
agentId: engine === "local" ? undefined : selectedAgentId,
|
|
334
|
+
engine,
|
|
335
|
+
localCwd: engine === "local" ? (localCwd ?? undefined) : undefined,
|
|
336
|
+
},
|
|
337
|
+
}).then((conv) => {
|
|
311
338
|
setConversations((prev) => [conv, ...prev])
|
|
339
|
+
// 新线程 id 变化会重建 hook,但本页 activeId 先置空再导航;loader 会把
|
|
340
|
+
// engine/localCwd 从 DB 读回,保持引擎选择不丢
|
|
312
341
|
navigate({ to: '/harness/$threadId', params: { threadId: conv.id } })
|
|
313
342
|
}).catch((e) => {
|
|
314
343
|
toastError(e, m.common_operationFailed())
|
|
315
344
|
})
|
|
316
|
-
}, [chat, navigate, selectedAgentId])
|
|
345
|
+
}, [chat, navigate, selectedAgentId, engine, localCwd])
|
|
317
346
|
|
|
318
347
|
const handleAgentChange = useCallback((newAgentId: string | undefined) => {
|
|
319
348
|
if (newAgentId === selectedAgentId) return
|
|
@@ -333,6 +362,58 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
|
|
|
333
362
|
setConfirmSwitchOpen(false)
|
|
334
363
|
}, [chat])
|
|
335
364
|
|
|
365
|
+
/** 空线程切换引擎/目录时同步持久化;有消息后由 UI 锁定(服务端亦拒绝) */
|
|
366
|
+
const persistThreadEngine = useCallback(
|
|
367
|
+
async (nextEngine: "cloud" | "local", nextCwd: string | null) => {
|
|
368
|
+
if (!activeIdRef.current || chat.messages.length > 0) return
|
|
369
|
+
try {
|
|
370
|
+
await updateConversationFn({
|
|
371
|
+
data: {
|
|
372
|
+
id: activeIdRef.current,
|
|
373
|
+
engine: nextEngine,
|
|
374
|
+
localCwd: nextEngine === "local" ? nextCwd : null,
|
|
375
|
+
},
|
|
376
|
+
})
|
|
377
|
+
setConversations((prev) =>
|
|
378
|
+
prev.map((conversation) =>
|
|
379
|
+
conversation.id === activeIdRef.current
|
|
380
|
+
? { ...conversation, engine: nextEngine, localCwd: nextEngine === "local" ? nextCwd : null }
|
|
381
|
+
: conversation,
|
|
382
|
+
),
|
|
383
|
+
)
|
|
384
|
+
} catch (error) {
|
|
385
|
+
toastError(error, m.common_operationFailed())
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
[chat.messages.length],
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
const handleEngineChange = useCallback(
|
|
392
|
+
(nextEngine: "cloud" | "local") => {
|
|
393
|
+
if (nextEngine === engine) return
|
|
394
|
+
if (chat.messages.length > 0) {
|
|
395
|
+
toastError(new Error(m.harness_engine_switchLocked()), m.common_operationFailed())
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
setEngine(nextEngine)
|
|
399
|
+
if (nextEngine === "cloud") {
|
|
400
|
+
setLocalCwd(null)
|
|
401
|
+
setSelectedAgentId(undefined)
|
|
402
|
+
}
|
|
403
|
+
void persistThreadEngine(nextEngine, nextEngine === "local" ? localCwd : null)
|
|
404
|
+
},
|
|
405
|
+
[engine, chat.messages.length, localCwd, persistThreadEngine],
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
const handleLocalCwdChange = useCallback(
|
|
409
|
+
(path: string) => {
|
|
410
|
+
if (chat.messages.length > 0) return
|
|
411
|
+
setLocalCwd(path)
|
|
412
|
+
void persistThreadEngine("local", path)
|
|
413
|
+
},
|
|
414
|
+
[chat.messages.length, persistThreadEngine],
|
|
415
|
+
)
|
|
416
|
+
|
|
336
417
|
const handleSelect = useCallback((id: string) => {
|
|
337
418
|
clearHarnessCitations()
|
|
338
419
|
setLatestCitations(undefined)
|
|
@@ -403,6 +484,14 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
|
|
|
403
484
|
}, [navigate])
|
|
404
485
|
|
|
405
486
|
const handleSend = useCallback(async (val: string, opts?: { whenBusy?: "queue" | "interrupt"; knowledgeBaseIds?: string[]; attachments?: Array<{ type: "image"; source: { type: "data"; value: string; mimeType: string } }> }, flags?: { isRetry?: boolean }) => {
|
|
487
|
+
if (engine === "local" && !localCwd) {
|
|
488
|
+
toastError(new Error(m.harness_localWorkspace_required()), m.common_operationFailed())
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
if (engine === "local" && opts?.attachments && opts.attachments.length > 0) {
|
|
492
|
+
toastError(new Error(m.harness_engine_localNoAttachments()), m.common_operationFailed())
|
|
493
|
+
return
|
|
494
|
+
}
|
|
406
495
|
setLatestCitations(undefined)
|
|
407
496
|
setRetryCount(0)
|
|
408
497
|
if (val.trim() && !flags?.isRetry) {
|
|
@@ -415,10 +504,15 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
|
|
|
415
504
|
},
|
|
416
505
|
})
|
|
417
506
|
}
|
|
418
|
-
// knowledgeBaseIds 通过 body 传递到 forwardedProps
|
|
507
|
+
// knowledgeBaseIds 通过 body 传递到 forwardedProps;本地 Agent 不消费知识库
|
|
419
508
|
const sendOptions = opts ? {
|
|
420
509
|
whenBusy: opts.whenBusy,
|
|
421
|
-
body:
|
|
510
|
+
body:
|
|
511
|
+
engine === "local"
|
|
512
|
+
? undefined
|
|
513
|
+
: opts.knowledgeBaseIds
|
|
514
|
+
? { knowledgeBaseIds: opts.knowledgeBaseIds }
|
|
515
|
+
: undefined,
|
|
422
516
|
} : undefined
|
|
423
517
|
const content: Parameters<HarnessChatInstance["sendMessage"]>[0]
|
|
424
518
|
= opts?.attachments && opts.attachments.length > 0
|
|
@@ -432,7 +526,13 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
|
|
|
432
526
|
if (creatingConversationRef.current) return
|
|
433
527
|
creatingConversationRef.current = true
|
|
434
528
|
try {
|
|
435
|
-
const conv = await createConversationFn({
|
|
529
|
+
const conv = await createConversationFn({
|
|
530
|
+
data: {
|
|
531
|
+
agentId: engine === "local" ? undefined : selectedAgentId,
|
|
532
|
+
engine,
|
|
533
|
+
localCwd: engine === "local" ? (localCwd ?? undefined) : undefined,
|
|
534
|
+
},
|
|
535
|
+
})
|
|
436
536
|
setConversations(prev => [conv, ...prev])
|
|
437
537
|
setActiveConversationId(conv.id)
|
|
438
538
|
activeIdRef.current = conv.id
|
|
@@ -447,7 +547,7 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
|
|
|
447
547
|
return
|
|
448
548
|
}
|
|
449
549
|
void chat.sendMessage(content, sendOptions)
|
|
450
|
-
}, [chat, selectedAgentId])
|
|
550
|
+
}, [chat, selectedAgentId, engine, localCwd])
|
|
451
551
|
|
|
452
552
|
const handleStop = useCallback(() => {
|
|
453
553
|
chat.stop()
|
|
@@ -461,11 +561,18 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
|
|
|
461
561
|
const centerContent = (
|
|
462
562
|
<div className="flex h-full flex-col">
|
|
463
563
|
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
564
|
+
{engine === "local" ? (
|
|
565
|
+
<span className="inline-flex items-center gap-1.5 rounded-md border border-border bg-muted/40 px-2 py-1 text-xs text-muted-foreground">
|
|
566
|
+
<FolderOpen className="size-3.5" />
|
|
567
|
+
{m.harness_engine_localBadge()}
|
|
568
|
+
</span>
|
|
569
|
+
) : (
|
|
570
|
+
<AgentPresetPicker
|
|
571
|
+
value={selectedAgentId}
|
|
572
|
+
onValueChange={handleAgentChange}
|
|
573
|
+
agents={agents ?? []}
|
|
574
|
+
/>
|
|
575
|
+
)}
|
|
469
576
|
<h2 className="truncate font-semibold">{activeConversation?.title ?? m.chat_newConversation()}</h2>
|
|
470
577
|
<div className="ml-auto flex items-center gap-1">
|
|
471
578
|
<Button
|
|
@@ -483,7 +590,7 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
|
|
|
483
590
|
</div>
|
|
484
591
|
</div>
|
|
485
592
|
|
|
486
|
-
{!hasAiKey && (
|
|
593
|
+
{!hasAiKey && engine !== "local" && (
|
|
487
594
|
<div className="mx-6 mt-3 flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
|
|
488
595
|
<AlertCircle className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
|
489
596
|
<p className="flex-1 text-xs text-amber-700 dark:text-amber-300">
|
|
@@ -493,6 +600,16 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
|
|
|
493
600
|
</div>
|
|
494
601
|
)}
|
|
495
602
|
|
|
603
|
+
{!hasAiKey && engine === "local" && (
|
|
604
|
+
<div className="mx-6 mt-3 flex items-center gap-2 rounded-lg border border-border bg-muted/40 p-3">
|
|
605
|
+
<AlertCircle className="size-4 shrink-0 text-muted-foreground" />
|
|
606
|
+
<p className="flex-1 text-xs text-muted-foreground">
|
|
607
|
+
{m.harness_engine_localNeedsProvider()}
|
|
608
|
+
</p>
|
|
609
|
+
<Button render={<RouterLink to="/settings/ai" />} size="sm" variant="outline" className="shrink-0 h-7 text-xs" nativeButton={false}>{m.chat_goToSettings()}</Button>
|
|
610
|
+
</div>
|
|
611
|
+
)}
|
|
612
|
+
|
|
496
613
|
{chat.error && !retryDismissed && (
|
|
497
614
|
<RetryCountdownBar
|
|
498
615
|
error={{ message: chat.error.message, retryable: (chat.error as unknown as { retryable?: boolean }).retryable ?? true }}
|
|
@@ -549,6 +666,11 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
|
|
|
549
666
|
sandboxPolicy={sandboxPolicy}
|
|
550
667
|
onSandboxPolicyChange={setSandboxPolicy}
|
|
551
668
|
handoffContext={handoffContext}
|
|
669
|
+
engine={engine}
|
|
670
|
+
onEngineChange={handleEngineChange}
|
|
671
|
+
localCwd={localCwd}
|
|
672
|
+
onLocalCwdChange={handleLocalCwdChange}
|
|
673
|
+
engineLocked={chat.messages.length > 0}
|
|
552
674
|
/>
|
|
553
675
|
<ConnectionIndicator
|
|
554
676
|
status={chat.connectionStatus === "error" || chat.error ? "disconnected" : chat.connectionStatus === "connecting" ? "reconnecting" : "hidden"}
|