@allbluecn/web-app 0.23.6 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/package.json +2 -2
  2. package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +0 -2
  3. package/src/components/harness/ai-chat-history/components/conversation-item.tsx +6 -0
  4. package/src/components/harness/ai-chat-history/types.ts +4 -0
  5. package/src/components/harness/chat-ui.tsx +20 -0
  6. package/src/components/harness/composer/harness-composer.tsx +43 -10
  7. package/src/components/harness/composer/harness-engine-control.tsx +129 -0
  8. package/src/components/harness/harness-workbench-page.tsx +129 -17
  9. package/src/components/layout/sidebar-03/__tests__/user-menu-cloud-logout.test.tsx +185 -0
  10. package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -9
  11. package/src/components/layout/sidebar-03/user-menu.tsx +27 -50
  12. package/src/components/shared-kit/auth/auth-client.ts +4 -0
  13. package/src/config/sidebar-routes.tsx +0 -13
  14. package/src/lib/__tests__/desktop-logout.test.ts +239 -0
  15. package/src/lib/__tests__/session.test.ts +85 -2
  16. package/src/lib/auth/__tests__/page-gate.test.ts +37 -1
  17. package/src/lib/auth/anonymous-safe.ts +5 -0
  18. package/src/lib/auth/page-gate.ts +11 -1
  19. package/src/lib/changelog/sdk-versions.ts +1 -1
  20. package/src/lib/desktop-env.ts +25 -0
  21. package/src/lib/desktop-logout.ts +117 -0
  22. package/src/lib/hotkeys/__tests__/registry.test.ts +4 -11
  23. package/src/lib/hotkeys/registry.ts +0 -8
  24. package/src/lib/session.ts +70 -0
  25. package/src/paraglide/README.md +1 -1
  26. package/src/paraglide/messages/_index.d.ts +12 -2
  27. package/src/paraglide/messages/_index.js +12 -2
  28. package/src/paraglide/messages/{agent_desktop_title.d.ts → harness_engine_cloud.d.ts} +5 -5
  29. package/src/paraglide/messages/harness_engine_cloud.js +29 -0
  30. package/src/paraglide/messages/harness_engine_label.d.ts +16 -0
  31. package/src/paraglide/messages/harness_engine_label.js +29 -0
  32. package/src/paraglide/messages/harness_engine_local.d.ts +16 -0
  33. package/src/paraglide/messages/harness_engine_local.js +29 -0
  34. package/src/paraglide/messages/harness_engine_localbadge1.d.ts +17 -0
  35. package/src/paraglide/messages/harness_engine_localbadge1.js +30 -0
  36. package/src/paraglide/messages/harness_engine_localhint1.d.ts +17 -0
  37. package/src/paraglide/messages/harness_engine_localhint1.js +30 -0
  38. package/src/paraglide/messages/harness_engine_localnoattachments2.d.ts +17 -0
  39. package/src/paraglide/messages/harness_engine_localnoattachments2.js +30 -0
  40. package/src/paraglide/messages/harness_engine_switchlocked1.d.ts +17 -0
  41. package/src/paraglide/messages/harness_engine_switchlocked1.js +30 -0
  42. package/src/paraglide/messages/harness_localworkspace_choose1.d.ts +17 -0
  43. package/src/paraglide/messages/harness_localworkspace_choose1.js +30 -0
  44. package/src/paraglide/messages/harness_localworkspace_dialogdescription2.d.ts +17 -0
  45. package/src/paraglide/messages/harness_localworkspace_dialogdescription2.js +30 -0
  46. package/src/paraglide/messages/harness_localworkspace_dialogtitle2.d.ts +17 -0
  47. package/src/paraglide/messages/harness_localworkspace_dialogtitle2.js +30 -0
  48. package/src/paraglide/messages/harness_localworkspace_manage1.d.ts +17 -0
  49. package/src/paraglide/messages/harness_localworkspace_manage1.js +30 -0
  50. package/src/paraglide/messages/harness_localworkspace_required1.d.ts +17 -0
  51. package/src/paraglide/messages/harness_localworkspace_required1.js +30 -0
  52. package/src/routeTree.gen.ts +0 -23
  53. package/src/routes/__tests__/-edition-routing.test.tsx +48 -7
  54. package/src/routes/__tests__/-login-admin-redirect.test.ts +99 -0
  55. package/src/routes/_login/login/route.tsx +18 -3
  56. package/src/routes/_nav/design/canvas/$id/route.tsx +5 -0
  57. package/src/routes/_nav/harness/$threadId.lazy.tsx +19 -2
  58. package/src/routes/_nav/harness/$threadId.tsx +8 -2
  59. package/src/routes/_nav/knowledge/$kbId/route.tsx +6 -2
  60. package/src/routes/_nav/route.tsx +5 -1
  61. package/src/routes/_nav/settings/account/route.tsx +5 -0
  62. package/src/routes/_nav/settings/team/route.tsx +5 -0
  63. package/src/routes/api/auth/$.ts +87 -2
  64. package/src/routes/api/auth/__tests__/auth-session-desktop.test.ts +194 -0
  65. package/src/server/__tests__/chat-conversations.test.ts +13 -3
  66. package/src/server/__tests__/desktop-bootstrap.test.ts +0 -1
  67. package/src/server/ai-gateway/__tests__/local-agent-adapter.test.ts +110 -0
  68. package/src/server/ai-gateway/__tests__/unified-router-local-agent.test.ts +63 -0
  69. package/src/server/ai-gateway/chat-handler.ts +68 -2
  70. package/src/server/ai-gateway/chat-stream-builder.ts +52 -0
  71. package/src/server/ai-gateway/local-agent-adapter.ts +232 -0
  72. package/src/server/ai-gateway/unified-router.ts +49 -6
  73. package/src/server/desktop-guest.ts +5 -1
  74. package/src/server/serverFns/auth/session.ts +19 -10
  75. package/src/server/serverFns/chat/conversations.ts +122 -1
  76. package/src/components/desktop-agent/__tests__/agent-chat.test.tsx +0 -194
  77. package/src/components/desktop-agent/agent-chat.tsx +0 -222
  78. package/src/lib/desktop-agent/__tests__/connection.test.ts +0 -310
  79. package/src/lib/desktop-agent/__tests__/errors.test.ts +0 -43
  80. package/src/lib/desktop-agent/connection.ts +0 -93
  81. package/src/lib/desktop-agent/errors.ts +0 -21
  82. package/src/paraglide/messages/agent_desktop_title.js +0 -29
  83. package/src/paraglide/messages/common_hotkey_desc_agentabort1.d.ts +0 -17
  84. package/src/paraglide/messages/common_hotkey_desc_agentabort1.js +0 -30
  85. package/src/routes/-tests/agent-gate.test.ts +0 -45
  86. package/src/routes/_nav/agent/route.lazy.tsx +0 -63
  87. package/src/routes/_nav/agent/route.tsx +0 -21
  88. /package/src/components/{desktop-agent → harness/local-agent}/__tests__/agent-tool-card.test.tsx +0 -0
  89. /package/src/components/{desktop-agent → harness/local-agent}/__tests__/agent-workspace-card.test.tsx +0 -0
  90. /package/src/components/{desktop-agent → harness/local-agent}/agent-tool-card.tsx +0 -0
  91. /package/src/components/{desktop-agent → harness/local-agent}/agent-workspace-card.tsx +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.23.6",
3
+ "version": "0.24.0",
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.12.3",
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",
@@ -92,8 +92,6 @@ function coveredByBoardRule(rel: string): boolean {
92
92
  * 新增业务页面时二者必居其一:在此登记,或在 BOARD_RULES 增加板块规则。
93
93
  */
94
94
  const PAGES_WITHOUT_BOARDS = [
95
- // 本地文件助手(桌面本地优先 P3-B5):页面自带对话区,非业务板块,无全局 AI 板块上下文
96
- "agent",
97
95
  "bugs",
98
96
  "chat",
99
97
  "changelog",
@@ -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,6 +191,11 @@ 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) {
186
200
  const [input, setInput] = useState("")
187
201
  const [pendingPrefillEntity, setPendingPrefillEntity] = useState<PrefillMentionEntity | null>(null)
@@ -384,20 +398,39 @@ export function HarnessComposer({
384
398
  )}
385
399
  <div className="flex items-center justify-between gap-2 p-2">
386
400
  <div className="flex min-w-0 flex-wrap items-center gap-1.5">
387
- <ModelSelect models={models} onModelChange={onModelChange} />
401
+ {engine && onEngineChange && (
402
+ <HarnessEngineControl
403
+ engine={engine}
404
+ onEngineChange={onEngineChange}
405
+ localCwd={localCwd ?? null}
406
+ onLocalCwdChange={(path) => onLocalCwdChange?.(path)}
407
+ disabled={engineLocked || isRunning}
408
+ />
409
+ )}
410
+ {engine !== "local" && (
411
+ <ModelSelect models={models} onModelChange={onModelChange} />
412
+ )}
388
413
  <ContextMeter messages={messages} />
389
- <SandboxPolicyControl
390
- sandboxPolicy={sandboxPolicy}
391
- onSandboxPolicyChange={onSandboxPolicyChange}
392
- />
393
- <MediaRouteControl
394
- mediaRoute={mediaRoute}
395
- onMediaRouteChange={onMediaRouteChange}
396
- />
414
+ {engine !== "local" ? (
415
+ <>
416
+ <SandboxPolicyControl
417
+ sandboxPolicy={sandboxPolicy}
418
+ onSandboxPolicyChange={onSandboxPolicyChange}
419
+ />
420
+ <MediaRouteControl
421
+ mediaRoute={mediaRoute}
422
+ onMediaRouteChange={onMediaRouteChange}
423
+ />
424
+ </>
425
+ ) : (
426
+ <span className="truncate text-xs text-muted-foreground">
427
+ {m.harness_engine_localHint()}
428
+ </span>
429
+ )}
397
430
  </div>
398
431
  <Button
399
432
  size="sm"
400
- disabled={!hasAiKey || sendBlocked}
433
+ disabled={!hasAiKey || sendBlocked || (engine === "local" && !localCwd)}
401
434
  onClick={() => handleSend(editorRef.current?.getText() ?? "")}
402
435
  >
403
436
  {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({ data: { agentId: selectedAgentId } }).then((conv) => {
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: opts.knowledgeBaseIds ? { knowledgeBaseIds: opts.knowledgeBaseIds } : undefined,
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({ data: { agentId: selectedAgentId } })
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
- <AgentPresetPicker
465
- value={selectedAgentId}
466
- onValueChange={handleAgentChange}
467
- agents={agents ?? []}
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
@@ -549,6 +656,11 @@ export default function HarnessWorkbenchPage({ initialThreadId, initialMessages
549
656
  sandboxPolicy={sandboxPolicy}
550
657
  onSandboxPolicyChange={setSandboxPolicy}
551
658
  handoffContext={handoffContext}
659
+ engine={engine}
660
+ onEngineChange={handleEngineChange}
661
+ localCwd={localCwd}
662
+ onLocalCwdChange={handleLocalCwdChange}
663
+ engineLocked={chat.messages.length > 0}
552
664
  />
553
665
  <ConnectionIndicator
554
666
  status={chat.connectionStatus === "error" || chat.error ? "disconnected" : chat.connectionStatus === "connecting" ? "reconnecting" : "hidden"}