@allbluecn/web-app 0.23.3 → 0.23.5

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 (134) hide show
  1. package/package.json +3 -3
  2. package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +2 -0
  3. package/src/components/desktop/__tests__/cloud-login-rollback.test.tsx +161 -0
  4. package/src/components/desktop/cloud-login.tsx +29 -26
  5. package/src/components/desktop-agent/__tests__/agent-chat.test.tsx +194 -0
  6. package/src/components/desktop-agent/__tests__/agent-tool-card.test.tsx +138 -0
  7. package/src/components/desktop-agent/__tests__/agent-workspace-card.test.tsx +189 -0
  8. package/src/components/desktop-agent/agent-chat.tsx +221 -0
  9. package/src/components/desktop-agent/agent-tool-card.tsx +185 -0
  10. package/src/components/desktop-agent/agent-workspace-card.tsx +176 -0
  11. package/src/components/layout/sidebar-03/app-sidebar.tsx +9 -2
  12. package/src/config/sidebar-routes.tsx +14 -1
  13. package/src/lib/changelog/sdk-versions.ts +1 -1
  14. package/src/lib/desktop-agent/__tests__/connection.test.ts +223 -0
  15. package/src/lib/desktop-agent/__tests__/errors.test.ts +43 -0
  16. package/src/lib/desktop-agent/__tests__/tool-result.test.ts +163 -0
  17. package/src/lib/desktop-agent/__tests__/use-desktop-agent-presence.test.tsx +181 -0
  18. package/src/lib/desktop-agent/availability.ts +39 -0
  19. package/src/lib/desktop-agent/connection.ts +72 -0
  20. package/src/lib/desktop-agent/errors.ts +21 -0
  21. package/src/lib/desktop-agent/tool-result.ts +170 -0
  22. package/src/lib/desktop-agent/use-desktop-agent-presence.ts +64 -0
  23. package/src/lib/desktop-bridge-types.ts +22 -1
  24. package/src/lib/hotkeys/__tests__/registry.test.ts +11 -4
  25. package/src/lib/hotkeys/registry.ts +9 -1
  26. package/src/lib/queries/README.md +1 -0
  27. package/src/lib/queries/__tests__/desktop-agent-queries.test.ts +80 -0
  28. package/src/lib/queries/desktop-agent.ts +25 -0
  29. package/src/paraglide/messages/_index.d.ts +42 -0
  30. package/src/paraglide/messages/_index.js +42 -0
  31. package/src/paraglide/messages/agent_desktop_chatcwd1.d.ts +19 -0
  32. package/src/paraglide/messages/agent_desktop_chatcwd1.js +30 -0
  33. package/src/paraglide/messages/agent_desktop_chatemptyhint2.d.ts +17 -0
  34. package/src/paraglide/messages/agent_desktop_chatemptyhint2.js +30 -0
  35. package/src/paraglide/messages/agent_desktop_chatemptytitle2.d.ts +17 -0
  36. package/src/paraglide/messages/agent_desktop_chatemptytitle2.js +30 -0
  37. package/src/paraglide/messages/agent_desktop_chatindependenthint2.d.ts +17 -0
  38. package/src/paraglide/messages/agent_desktop_chatindependenthint2.js +30 -0
  39. package/src/paraglide/messages/agent_desktop_chatnoworkspace2.d.ts +17 -0
  40. package/src/paraglide/messages/agent_desktop_chatnoworkspace2.js +30 -0
  41. package/src/paraglide/messages/agent_desktop_chatplaceholder1.d.ts +17 -0
  42. package/src/paraglide/messages/agent_desktop_chatplaceholder1.js +30 -0
  43. package/src/paraglide/messages/agent_desktop_chatsend1.d.ts +17 -0
  44. package/src/paraglide/messages/agent_desktop_chatsend1.js +30 -0
  45. package/src/paraglide/messages/agent_desktop_chatstop1.d.ts +17 -0
  46. package/src/paraglide/messages/agent_desktop_chatstop1.js +30 -0
  47. package/src/paraglide/messages/agent_desktop_chatthinking1.d.ts +17 -0
  48. package/src/paraglide/messages/agent_desktop_chatthinking1.js +30 -0
  49. package/src/paraglide/messages/agent_desktop_errorconnection1.d.ts +17 -0
  50. package/src/paraglide/messages/agent_desktop_errorconnection1.js +30 -0
  51. package/src/paraglide/messages/agent_desktop_errorforbidden1.d.ts +17 -0
  52. package/src/paraglide/messages/agent_desktop_errorforbidden1.js +30 -0
  53. package/src/paraglide/messages/agent_desktop_errorgeneric1.d.ts +19 -0
  54. package/src/paraglide/messages/agent_desktop_errorgeneric1.js +30 -0
  55. package/src/paraglide/messages/agent_desktop_errorunauthorized1.d.ts +17 -0
  56. package/src/paraglide/messages/agent_desktop_errorunauthorized1.js +30 -0
  57. package/src/paraglide/messages/agent_desktop_errorunavailable1.d.ts +17 -0
  58. package/src/paraglide/messages/agent_desktop_errorunavailable1.js +30 -0
  59. package/src/paraglide/messages/agent_desktop_pagedescription1.d.ts +17 -0
  60. package/src/paraglide/messages/agent_desktop_pagedescription1.js +30 -0
  61. package/src/paraglide/messages/agent_desktop_title.d.ts +16 -0
  62. package/src/paraglide/messages/agent_desktop_title.js +29 -0
  63. package/src/paraglide/messages/agent_desktop_toolcountfiles2.d.ts +19 -0
  64. package/src/paraglide/messages/agent_desktop_toolcountfiles2.js +30 -0
  65. package/src/paraglide/messages/agent_desktop_toolcounthits2.d.ts +19 -0
  66. package/src/paraglide/messages/agent_desktop_toolcounthits2.js +30 -0
  67. package/src/paraglide/messages/agent_desktop_toolcountlines2.d.ts +19 -0
  68. package/src/paraglide/messages/agent_desktop_toolcountlines2.js +30 -0
  69. package/src/paraglide/messages/agent_desktop_toolemptydir2.d.ts +17 -0
  70. package/src/paraglide/messages/agent_desktop_toolemptydir2.js +30 -0
  71. package/src/paraglide/messages/agent_desktop_toolerror1.d.ts +17 -0
  72. package/src/paraglide/messages/agent_desktop_toolerror1.js +30 -0
  73. package/src/paraglide/messages/agent_desktop_toolfind1.d.ts +19 -0
  74. package/src/paraglide/messages/agent_desktop_toolfind1.js +30 -0
  75. package/src/paraglide/messages/agent_desktop_toolgrep1.d.ts +19 -0
  76. package/src/paraglide/messages/agent_desktop_toolgrep1.js +30 -0
  77. package/src/paraglide/messages/agent_desktop_toolls1.d.ts +19 -0
  78. package/src/paraglide/messages/agent_desktop_toolls1.js +30 -0
  79. package/src/paraglide/messages/agent_desktop_toolmore1.d.ts +19 -0
  80. package/src/paraglide/messages/agent_desktop_toolmore1.js +30 -0
  81. package/src/paraglide/messages/agent_desktop_toolnomatches2.d.ts +17 -0
  82. package/src/paraglide/messages/agent_desktop_toolnomatches2.js +30 -0
  83. package/src/paraglide/messages/agent_desktop_toolother1.d.ts +19 -0
  84. package/src/paraglide/messages/agent_desktop_toolother1.js +30 -0
  85. package/src/paraglide/messages/agent_desktop_toolread1.d.ts +19 -0
  86. package/src/paraglide/messages/agent_desktop_toolread1.js +30 -0
  87. package/src/paraglide/messages/agent_desktop_toolrunning1.d.ts +17 -0
  88. package/src/paraglide/messages/agent_desktop_toolrunning1.js +30 -0
  89. package/src/paraglide/messages/agent_desktop_tooltruncated1.d.ts +17 -0
  90. package/src/paraglide/messages/agent_desktop_tooltruncated1.js +30 -0
  91. package/src/paraglide/messages/agent_desktop_workspaceadd1.d.ts +17 -0
  92. package/src/paraglide/messages/agent_desktop_workspaceadd1.js +30 -0
  93. package/src/paraglide/messages/agent_desktop_workspaceaddfailed2.d.ts +17 -0
  94. package/src/paraglide/messages/agent_desktop_workspaceaddfailed2.js +30 -0
  95. package/src/paraglide/messages/agent_desktop_workspacedescription1.d.ts +17 -0
  96. package/src/paraglide/messages/agent_desktop_workspacedescription1.js +30 -0
  97. package/src/paraglide/messages/agent_desktop_workspaceempty1.d.ts +17 -0
  98. package/src/paraglide/messages/agent_desktop_workspaceempty1.js +30 -0
  99. package/src/paraglide/messages/agent_desktop_workspaceemptyhint2.d.ts +17 -0
  100. package/src/paraglide/messages/agent_desktop_workspaceemptyhint2.js +30 -0
  101. package/src/paraglide/messages/agent_desktop_workspaceloadfailed2.d.ts +17 -0
  102. package/src/paraglide/messages/agent_desktop_workspaceloadfailed2.js +30 -0
  103. package/src/paraglide/messages/agent_desktop_workspaceremove1.d.ts +17 -0
  104. package/src/paraglide/messages/agent_desktop_workspaceremove1.js +30 -0
  105. package/src/paraglide/messages/agent_desktop_workspaceremovefailed2.d.ts +17 -0
  106. package/src/paraglide/messages/agent_desktop_workspaceremovefailed2.js +30 -0
  107. package/src/paraglide/messages/agent_desktop_workspacetitle1.d.ts +17 -0
  108. package/src/paraglide/messages/agent_desktop_workspacetitle1.js +30 -0
  109. package/src/paraglide/messages/agent_desktop_workspaceunsupported1.d.ts +17 -0
  110. package/src/paraglide/messages/agent_desktop_workspaceunsupported1.js +30 -0
  111. package/src/paraglide/messages/common_hotkey_desc_agentabort1.d.ts +17 -0
  112. package/src/paraglide/messages/common_hotkey_desc_agentabort1.js +30 -0
  113. package/src/paraglide/messages/sync_bind_rolled_back.d.ts +16 -0
  114. package/src/paraglide/messages/sync_bind_rolled_back.js +29 -0
  115. package/src/routeTree.gen.ts +23 -0
  116. package/src/routes/-tests/agent-gate.test.ts +45 -0
  117. package/src/routes/_nav/agent/route.lazy.tsx +63 -0
  118. package/src/routes/_nav/agent/route.tsx +21 -0
  119. package/src/routes/api/sync/account-status.ts +4 -2
  120. package/src/routes/api/sync/push.ts +11 -3
  121. package/src/server/__tests__/desktop-cloud-identity.test.ts +331 -0
  122. package/src/server/__tests__/desktop-session-merge.test.ts +42 -0
  123. package/src/server/__tests__/exchange-remote-token.test.ts +92 -10
  124. package/src/server/__tests__/remote-auth.test.ts +39 -0
  125. package/src/server/desktop-bootstrap.ts +16 -5
  126. package/src/server/desktop-cloud-identity.ts +247 -0
  127. package/src/server/remote-auth.ts +33 -1
  128. package/src/server/serverFns/auth/session.ts +27 -2
  129. package/src/server/serverFns/sync/transfer.ts +28 -27
  130. package/src/server/serverFns/workspace-kb.ts +10 -0
  131. package/src/server/sync/__tests__/ownership-transfer.test.ts +261 -48
  132. package/src/server/sync/__tests__/sync-first-push.e2e.test.ts +349 -0
  133. package/src/server/sync/account-status-cloud.ts +41 -0
  134. package/src/server/sync/ownership-transfer.ts +88 -49
@@ -0,0 +1,176 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 本地文件助手 · 授权目录卡(桌面本地优先 P3-B5):agent 只能读取这里列出的目录。
6
+ // - 添加:desktopBridge.pickDirectory(main 侧原生目录对话框,选中即写入
7
+ // userData/agent/workspaces.json 并返回规范化绝对路径;取消返回 null)——
8
+ // renderer 无法注入任意路径,授权入口只有这一个(B1 授权模型)。
9
+ // - 清单:react-query 工厂 desktopAgentWorkspacesQueryOptions(listWorkspaces)。
10
+ // - 撤销:removeWorkspace;增删成功后按 DESKTOP_AGENT_QUERY_KEY 前缀 invalidate。
11
+ // - 单选当前工作目录(对话 cwd);运行中由父级锁定(disabled),避免中途换目录。
12
+ // - 门控:useDesktopAgentPresence(含 agentPort 装配判定 + 冷启动有界重试)+
13
+ // isAgentWorkspaceSupported(B1 三方法)双条件,与侧栏入口/页面同源;presence
14
+ // 为 pending(探测/重试中)时按加载态渲染,absent(终态)才提示升级。
15
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
16
+ import { FolderOpen, FolderPlus, Loader2, X } from 'lucide-react'
17
+ import { Button } from '@/components/ui/button'
18
+ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
19
+ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
20
+ import { Label } from '@/components/ui/label'
21
+ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
22
+ import { Skeleton } from '@/components/ui/skeleton'
23
+ import { toast } from '@/components/ui/toast'
24
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
25
+ import { getDesktopBridge } from '@/lib/desktop-bridge-types'
26
+ import { isAgentWorkspaceSupported } from '@/lib/desktop-agent/availability'
27
+ import { useDesktopAgentPresence } from '@/lib/desktop-agent/use-desktop-agent-presence'
28
+ import { DESKTOP_AGENT_QUERY_KEY, desktopAgentWorkspacesQueryOptions } from '@/lib/queries/desktop-agent'
29
+ import { cn } from '@/lib/utils'
30
+ import { m } from '@/paraglide/messages.js'
31
+
32
+ /** 路径末段作为目录显示名(POSIX / Windows 分隔符均可) */
33
+ function folderNameOf(path: string): string {
34
+ return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path
35
+ }
36
+
37
+ export interface AgentWorkspaceCardProps {
38
+ /** 当前选中的工作目录(对话 cwd) */
39
+ selected: string | null
40
+ onSelect: (path: string) => void
41
+ /** 运行中锁定切换与撤销 */
42
+ disabled?: boolean
43
+ }
44
+
45
+ export function AgentWorkspaceCard({ selected, onSelect, disabled = false }: AgentWorkspaceCardProps) {
46
+ const queryClient = useQueryClient()
47
+ // 门控口径与侧栏入口/页面同源(presence 已含 agentPort:agent 未装配即不可用),
48
+ // 再叠加 B1 三方法检查——旧壳(无目录授权 IPC)同样整卡降级为升级提示。
49
+ const presence = useDesktopAgentPresence()
50
+ const supported = presence === 'present' && isAgentWorkspaceSupported()
51
+ const workspacesQuery = useQuery({ ...desktopAgentWorkspacesQueryOptions(), enabled: supported })
52
+
53
+ const addMutation = useMutation({
54
+ mutationFn: async () => (await getDesktopBridge()?.pickDirectory?.()) ?? null,
55
+ onSuccess: async (picked) => {
56
+ if (!picked) return // 用户取消对话框
57
+ await queryClient.invalidateQueries({ queryKey: DESKTOP_AGENT_QUERY_KEY })
58
+ onSelect(picked)
59
+ },
60
+ onError: () => {
61
+ toast.error(m.agent_desktop_workspaceAddFailed())
62
+ },
63
+ })
64
+
65
+ const removeMutation = useMutation({
66
+ mutationFn: async (path: string) => {
67
+ await getDesktopBridge()?.removeWorkspace?.(path)
68
+ },
69
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: DESKTOP_AGENT_QUERY_KEY }),
70
+ onError: () => {
71
+ toast.error(m.agent_desktop_workspaceRemoveFailed())
72
+ },
73
+ })
74
+
75
+ const workspaces = workspacesQuery.data ?? []
76
+ const loadingBody = (
77
+ <div className="flex flex-col gap-2">
78
+ <Skeleton className="h-12 w-full" />
79
+ <Skeleton className="h-12 w-full" />
80
+ </div>
81
+ )
82
+
83
+ let body: React.ReactNode
84
+ if (presence === 'pending') {
85
+ // 门控探测 / 冷启动有界重试中:按加载态渲染,不提前报「请升级客户端」
86
+ body = loadingBody
87
+ } else if (!supported) {
88
+ body = <p className="text-sm text-muted-foreground">{m.agent_desktop_workspaceUnsupported()}</p>
89
+ } else if (workspacesQuery.isPending) {
90
+ body = loadingBody
91
+ } else if (workspacesQuery.isError) {
92
+ body = <p className="text-sm text-destructive">{m.agent_desktop_workspaceLoadFailed()}</p>
93
+ } else if (workspaces.length === 0) {
94
+ body = (
95
+ <Empty className="border p-6">
96
+ <EmptyHeader>
97
+ <EmptyMedia variant="icon">
98
+ <FolderOpen />
99
+ </EmptyMedia>
100
+ <EmptyTitle>{m.agent_desktop_workspaceEmpty()}</EmptyTitle>
101
+ <EmptyDescription>{m.agent_desktop_workspaceEmptyHint()}</EmptyDescription>
102
+ </EmptyHeader>
103
+ </Empty>
104
+ )
105
+ } else {
106
+ body = (
107
+ <RadioGroup
108
+ value={selected ?? ''}
109
+ onValueChange={(value) => {
110
+ if (typeof value === 'string' && value !== '') onSelect(value)
111
+ }}
112
+ disabled={disabled}
113
+ className="gap-2"
114
+ >
115
+ {workspaces.map((path, index) => {
116
+ const id = `desktop-agent-workspace-${index}`
117
+ const checked = path === selected
118
+ return (
119
+ <div
120
+ key={path}
121
+ className={cn(
122
+ 'flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors',
123
+ checked ? 'border-primary/60 bg-primary/5' : 'hover:border-primary/30',
124
+ )}
125
+ >
126
+ <RadioGroupItem value={path} id={id} />
127
+ <Label htmlFor={id} className="min-w-0 flex-1 cursor-pointer flex-col items-start gap-1">
128
+ <span className="w-full truncate text-sm font-medium">{folderNameOf(path)}</span>
129
+ <span className="w-full truncate text-xs font-normal text-muted-foreground">{path}</span>
130
+ </Label>
131
+ <Tooltip>
132
+ <TooltipTrigger
133
+ render={
134
+ <Button
135
+ variant="ghost"
136
+ size="icon-sm"
137
+ aria-label={m.agent_desktop_workspaceRemove()}
138
+ disabled={disabled || removeMutation.isPending}
139
+ onClick={() => removeMutation.mutate(path)}
140
+ />
141
+ }
142
+ >
143
+ <X />
144
+ </TooltipTrigger>
145
+ <TooltipContent>{m.agent_desktop_workspaceRemove()}</TooltipContent>
146
+ </Tooltip>
147
+ </div>
148
+ )
149
+ })}
150
+ </RadioGroup>
151
+ )
152
+ }
153
+
154
+ return (
155
+ <Card className="gap-4">
156
+ <CardHeader>
157
+ <CardTitle>{m.agent_desktop_workspaceTitle()}</CardTitle>
158
+ <CardDescription>{m.agent_desktop_workspaceDescription()}</CardDescription>
159
+ {supported && (
160
+ <CardAction>
161
+ <Button
162
+ variant="outline"
163
+ size="sm"
164
+ disabled={addMutation.isPending}
165
+ onClick={() => addMutation.mutate()}
166
+ >
167
+ {addMutation.isPending ? <Loader2 className="animate-spin" /> : <FolderPlus />}
168
+ {m.agent_desktop_workspaceAdd()}
169
+ </Button>
170
+ </CardAction>
171
+ )}
172
+ </CardHeader>
173
+ <CardContent>{body}</CardContent>
174
+ </Card>
175
+ )
176
+ }
@@ -32,7 +32,7 @@ import { NavKnowledge } from "@/components/layout/sidebar-03/nav-knowledge";
32
32
  import { NavWorkspace } from "@/components/layout/sidebar-03/nav-workspace";
33
33
  import { UserMenu } from "@/components/layout/sidebar-03/user-menu";
34
34
  import { AnimatedThemeToggler } from '@/components/magicui/animated-theme-toggler'
35
- import { workspaceRoutes } from "@/config/sidebar-routes";
35
+ import { desktopAgentRoute, workspaceRoutes } from "@/config/sidebar-routes";
36
36
  import type { Route } from "@/config/sidebar-routes";
37
37
  import { PresenceAvatarStack } from "@/components/layout/sidebar-03/presence-avatar-stack";
38
38
  import { SidebarSearchTrigger } from "@/components/layout/sidebar-03/nav-search-trigger"
@@ -40,6 +40,7 @@ import { NavSecondary } from "@/components/layout/sidebar-03/nav-secondary"
40
40
  import { PluginNavItems } from '@/components/plugin-slots/plugin-nav-items'
41
41
  import { isDesktopEdition } from "@/lib/edition-desktop";
42
42
  import { getDesktopBridge } from "@/lib/desktop-bridge-types";
43
+ import { useDesktopAgentPresence } from "@/lib/desktop-agent/use-desktop-agent-presence";
43
44
 
44
45
  // presence 渲染工厂(§8.2 消费者两文件之一):nav-main 按 route.id 查
45
46
  // MODULE_ROUTES 得 module key 后回调;module 级单例 map,identity 稳定。
@@ -47,10 +48,16 @@ const routes: Route[] = workspaceRoutes.map((route) => ({
47
48
  ...route,
48
49
  presence: (module: string) => <PresenceAvatarStack module={module} />,
49
50
  }));
51
+ // 桌面壳追加本地文件助手入口(P3-B5):module 级常量保 identity 稳定
52
+ const routesWithDesktopAgent: Route[] = [...routes, desktopAgentRoute];
50
53
 
51
54
  export function DashboardSidebar() {
52
55
  const { state } = useSidebar();
53
56
  const isCollapsed = state === "collapsed";
57
+ // 本地文件助手入口门控(与 /_nav/agent 路由同源判定):SSR / hydration 首帧恒
58
+ // pending(不渲染该项,首帧一致),挂载后桌面壳恢复;云 Web / OSS 恒不可见
59
+ const desktopAgentPresence = useDesktopAgentPresence();
60
+ const navRoutes = desktopAgentPresence === "present" ? routesWithDesktopAgent : routes;
54
61
  // 桌面壳(Electron hiddenInset):红绿灯悬浮在窗口左上角,顶栏重排为
55
62
  // 「第一行红绿灯让位 + 右侧按钮组(整行可拖动窗口)、第二行 logo」。
56
63
  // web/云端 SaaS 布局不变(isDesktopEdition 为构建期常量,死分支消除)。
@@ -149,7 +156,7 @@ export function DashboardSidebar() {
149
156
  )}
150
157
  </SidebarHeader>
151
158
  <SidebarContent className="gap-4 px-2 py-4">
152
- <DashboardNavigation routes={routes} />
159
+ <DashboardNavigation routes={navRoutes} />
153
160
  <NavWorkspace />
154
161
  <NavKnowledge />
155
162
  <PluginNavItems />
@@ -26,6 +26,7 @@ import {
26
26
  Bug,
27
27
  PenTool,
28
28
  ListTodo,
29
+ FolderSearch,
29
30
  } from "lucide-react";
30
31
 
31
32
  export type RouteSubItem = {
@@ -100,4 +101,16 @@ export const workspaceRoutes: Route[] = [
100
101
  icon: <ListTodo className="size-4" />,
101
102
  link: "/tasks",
102
103
  },
103
- ];
104
+ ];
105
+
106
+ /**
107
+ * 本地文件助手(桌面本地优先 P3-B5):依赖桌面壳 AgentService,不进上方常驻列表——
108
+ * app-sidebar 经 useDesktopAgentPresence(桌面构建 + desktopBridge,mounted 模式)
109
+ * 判定后条件追加,云 Web / OSS 恒不可见(与 /_nav/agent 路由门控同源)。
110
+ */
111
+ export const desktopAgentRoute: Route = {
112
+ id: "desktop-agent",
113
+ title: m.agent_desktop_title,
114
+ icon: <FolderSearch className="size-4" />,
115
+ link: "/agent",
116
+ };
@@ -112,7 +112,7 @@ export const SDK_GROUPS: SdkGroup[] = [
112
112
  "items": [
113
113
  {
114
114
  "name": "@allbluecn/web-app",
115
- "version": "v0.23.3",
115
+ "version": "v0.23.5",
116
116
  "lastUpdate": "Nov 1, 2026",
117
117
  "source": "apps/web/package.json",
118
118
  "docsUrl": "https://github.com/allbluecn/allblue"
@@ -0,0 +1,223 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // desktop-agent 连接(桌面本地优先 P3-B5):appInfo 调用时懒取 agentPort/agentToken
6
+ // → 真实 fetchServerSentEvents 直连 http://127.0.0.1:{port}/prompt。fetch 打桩、
7
+ // SSE 解析走真实 @tanstack/ai-client 实现——请求线格式即与 Pro 端点的跨仓契约:
8
+ // URL / X-Desktop-Token / X-Run-Id 头 / forwardedProps.cwd(端点 extractCwd 取值位)。
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
10
+ import type { UIMessage } from '@tanstack/ai-client'
11
+
12
+ type AppInfo = {
13
+ version: string
14
+ clientId: string
15
+ platform: string
16
+ backendPort: number | null
17
+ agentPort?: number | null
18
+ agentToken?: string | null
19
+ }
20
+
21
+ const bridgeRef: { current: { appInfo: () => Promise<AppInfo> } | null } = { current: null }
22
+
23
+ vi.mock('@/lib/desktop-bridge-types', () => ({
24
+ getDesktopBridge: () => bridgeRef.current,
25
+ }))
26
+
27
+ import {
28
+ DesktopAgentUnavailableError,
29
+ createDesktopAgentConnection,
30
+ resolveDesktopAgentEndpoint,
31
+ } from '../connection'
32
+
33
+ const BASE_INFO: AppInfo = { version: '1.0.0', clientId: 'c1', platform: 'darwin', backendPort: 51000 }
34
+
35
+ function bridgeWith(info: Partial<AppInfo>) {
36
+ return { appInfo: vi.fn(async () => ({ ...BASE_INFO, ...info })) }
37
+ }
38
+
39
+ const encoder = new TextEncoder()
40
+ function sse(event: Record<string, unknown>): Uint8Array {
41
+ return encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
42
+ }
43
+
44
+ /** 打桩 fetch:返回给定 AG-UI 事件序列的 SSE 响应 */
45
+ function stubSseFetch(events: Array<Record<string, unknown>>) {
46
+ const fetchMock = vi.fn(async (_url: string | URL | RequestInfo, _init?: RequestInit) => {
47
+ const stream = new ReadableStream<Uint8Array>({
48
+ start(c) {
49
+ for (const e of events) c.enqueue(sse(e))
50
+ c.close()
51
+ },
52
+ })
53
+ return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } })
54
+ })
55
+ vi.stubGlobal('fetch', fetchMock)
56
+ return fetchMock
57
+ }
58
+
59
+ const RUN_EVENTS = [
60
+ { type: 'RUN_STARTED', threadId: 't-1', runId: 'r-1' },
61
+ { type: 'TEXT_MESSAGE_START', messageId: 'msg-1', role: 'assistant' },
62
+ { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', delta: '目录里有 a.txt' },
63
+ { type: 'TEXT_MESSAGE_END', messageId: 'msg-1' },
64
+ { type: 'RUN_FINISHED', threadId: 't-1', runId: 'r-1' },
65
+ ]
66
+
67
+ const USER_MESSAGE: UIMessage = { id: 'u1', role: 'user', parts: [{ type: 'text', content: '列出目录' }] }
68
+
69
+ async function drain(iterable: AsyncIterable<unknown>): Promise<unknown[]> {
70
+ const out: unknown[] = []
71
+ for await (const chunk of iterable) out.push(chunk)
72
+ return out
73
+ }
74
+
75
+ beforeEach(() => {
76
+ bridgeRef.current = null
77
+ })
78
+
79
+ afterEach(() => {
80
+ vi.unstubAllGlobals()
81
+ })
82
+
83
+ describe('resolveDesktopAgentEndpoint(appInfo 懒取)', () => {
84
+ it('无桥(云 Web / OSS)→ null', async () => {
85
+ await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
86
+ })
87
+
88
+ it('agent 未装配(agentPort/agentToken 为 null 或旧壳 undefined)→ null', async () => {
89
+ bridgeRef.current = bridgeWith({ agentPort: null, agentToken: null })
90
+ await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
91
+ bridgeRef.current = bridgeWith({})
92
+ await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
93
+ })
94
+
95
+ it('端口或令牌非法(越界 / 非整数 / 空令牌)→ null,不拼出坏 URL', async () => {
96
+ for (const agentPort of [0, 65536, 1.5, -1]) {
97
+ bridgeRef.current = bridgeWith({ agentPort, agentToken: 'tok' })
98
+ await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
99
+ }
100
+ bridgeRef.current = bridgeWith({ agentPort: 4567, agentToken: '' })
101
+ await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
102
+ })
103
+
104
+ it('就绪 → 127.0.0.1 端点 URL + 令牌', async () => {
105
+ bridgeRef.current = bridgeWith({ agentPort: 4567, agentToken: 'tok-abc' })
106
+ await expect(resolveDesktopAgentEndpoint()).resolves.toEqual({
107
+ url: 'http://127.0.0.1:4567/prompt',
108
+ token: 'tok-abc',
109
+ })
110
+ })
111
+
112
+ it('appInfo 抛错(IPC 断开 / main 未就绪)→ null,不流出原生错误原文', async () => {
113
+ bridgeRef.current = {
114
+ appInfo: vi.fn(async () => {
115
+ throw new Error('ipc closed')
116
+ }),
117
+ }
118
+ await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
119
+ })
120
+ })
121
+
122
+ describe('createDesktopAgentConnection(fetchServerSentEvents 包装)', () => {
123
+ it('端点不可用 → 抛 DesktopAgentUnavailableError,且不发起 fetch', async () => {
124
+ const fetchMock = stubSseFetch(RUN_EVENTS)
125
+ bridgeRef.current = bridgeWith({ agentPort: null, agentToken: null })
126
+ const connection = createDesktopAgentConnection()
127
+ await expect(
128
+ drain(connection.connect([USER_MESSAGE], undefined, undefined, { threadId: 't-1', runId: 'r-1' })),
129
+ ).rejects.toBeInstanceOf(DesktopAgentUnavailableError)
130
+ expect(fetchMock).not.toHaveBeenCalled()
131
+ })
132
+
133
+ it('appInfo 抛错 → 同样归「服务未就绪」(DesktopAgentUnavailableError),不发 fetch、不透出 IPC 原文', async () => {
134
+ const fetchMock = stubSseFetch(RUN_EVENTS)
135
+ bridgeRef.current = {
136
+ appInfo: vi.fn(async () => {
137
+ throw new Error('ipc closed')
138
+ }),
139
+ }
140
+ const error = await drain(
141
+ createDesktopAgentConnection().connect([USER_MESSAGE], undefined, undefined, { threadId: 't', runId: 'r' }),
142
+ ).catch((cause: unknown) => cause)
143
+ expect(error).toBeInstanceOf(DesktopAgentUnavailableError)
144
+ expect(fetchMock).not.toHaveBeenCalled()
145
+ })
146
+
147
+ it('请求线格式:URL / X-Desktop-Token / X-Run-Id / forwardedProps.cwd 与 Pro 端点契约一致', async () => {
148
+ const fetchMock = stubSseFetch(RUN_EVENTS)
149
+ bridgeRef.current = bridgeWith({ agentPort: 4567, agentToken: 'tok-abc' })
150
+ const connection = createDesktopAgentConnection()
151
+ await drain(
152
+ connection.connect([USER_MESSAGE], undefined, undefined, {
153
+ threadId: 't-1',
154
+ runId: 'r-1',
155
+ forwardedProps: { cwd: '/Users/x/proj' },
156
+ }),
157
+ )
158
+ expect(fetchMock).toHaveBeenCalledTimes(1)
159
+ const [url, init] = fetchMock.mock.calls[0]!
160
+ expect(url).toBe('http://127.0.0.1:4567/prompt')
161
+ expect(init?.method).toBe('POST')
162
+ const headers = init?.headers as Record<string, string>
163
+ expect(headers['X-Desktop-Token']).toBe('tok-abc')
164
+ expect(headers['X-Run-Id']).toBe('r-1')
165
+ expect(headers['Content-Type']).toBe('application/json')
166
+ const body = JSON.parse(String(init?.body)) as Record<string, unknown>
167
+ // 端点 extractCwd:顶层无 cwd,取 forwardedProps.cwd(fetchServerSentEvents 真实形态)
168
+ expect(body.cwd).toBeUndefined()
169
+ expect(body.forwardedProps).toEqual({ cwd: '/Users/x/proj' })
170
+ expect(body.threadId).toBe('t-1')
171
+ expect(body.runId).toBe('r-1')
172
+ // 端点 extractPrompt:messages 末条 user 消息文本(纯文本 content 为 string)
173
+ expect(body.messages).toEqual([expect.objectContaining({ role: 'user', content: '列出目录' })])
174
+ })
175
+
176
+ it('SSE 帧原样产出为 AG-UI chunk(RUN_STARTED 起、RUN_FINISHED 止)', async () => {
177
+ stubSseFetch(RUN_EVENTS)
178
+ bridgeRef.current = bridgeWith({ agentPort: 4567, agentToken: 'tok-abc' })
179
+ const chunks = (await drain(
180
+ createDesktopAgentConnection().connect([USER_MESSAGE], undefined, undefined, {
181
+ threadId: 't-1',
182
+ runId: 'r-1',
183
+ }),
184
+ )) as Array<{ type: string }>
185
+ expect(chunks.map((c) => c.type)).toEqual([
186
+ 'RUN_STARTED',
187
+ 'TEXT_MESSAGE_START',
188
+ 'TEXT_MESSAGE_CONTENT',
189
+ 'TEXT_MESSAGE_END',
190
+ 'RUN_FINISHED',
191
+ ])
192
+ })
193
+
194
+ it('每次 connect 都重取 appInfo(backend 重启换端口/令牌后下一问即用新值)', async () => {
195
+ const fetchMock = stubSseFetch(RUN_EVENTS)
196
+ const appInfo = vi
197
+ .fn<() => Promise<AppInfo>>()
198
+ .mockResolvedValueOnce({ ...BASE_INFO, agentPort: 4567, agentToken: 'tok-old' })
199
+ .mockResolvedValueOnce({ ...BASE_INFO, agentPort: 5678, agentToken: 'tok-new' })
200
+ bridgeRef.current = { appInfo }
201
+ const connection = createDesktopAgentConnection()
202
+ const ctx = { threadId: 't-1', runId: 'r-1' }
203
+ await drain(connection.connect([USER_MESSAGE], undefined, undefined, ctx))
204
+ await drain(connection.connect([USER_MESSAGE], undefined, undefined, ctx))
205
+ expect(appInfo).toHaveBeenCalledTimes(2)
206
+ expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([
207
+ 'http://127.0.0.1:4567/prompt',
208
+ 'http://127.0.0.1:5678/prompt',
209
+ ])
210
+ expect((fetchMock.mock.calls[1]![1]?.headers as Record<string, string>)['X-Desktop-Token']).toBe('tok-new')
211
+ })
212
+
213
+ it('端点非 2xx(如 401 令牌失效)→ 错误携带 status 透传给 useChat error 态', async () => {
214
+ vi.stubGlobal(
215
+ 'fetch',
216
+ vi.fn(async () => new Response('{"error":"bad token"}', { status: 401, statusText: 'Unauthorized' })),
217
+ )
218
+ bridgeRef.current = bridgeWith({ agentPort: 4567, agentToken: 'tok-abc' })
219
+ await expect(
220
+ drain(createDesktopAgentConnection().connect([USER_MESSAGE], undefined, undefined, { threadId: 't', runId: 'r' })),
221
+ ).rejects.toMatchObject({ status: 401 })
222
+ })
223
+ })
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // desktop-agent 错误文案映射(桌面本地优先 P3-B5):useChat error 态 → 可读 i18n 文案。
6
+ // 错误来源三类:连接包装(DesktopAgentUnavailableError)、端点非 2xx(官方
7
+ // StreamHttpError 携带 status:401 令牌失效 / 403 目录未授权或 origin 被拒)、
8
+ // 网络层(StreamReadError:端口不通 / CORS 预检被拒),其余(RUN_ERROR 等)透出原文。
9
+ import { describe, expect, it } from 'vitest'
10
+ import { m } from '@/paraglide/messages.js'
11
+ import { DesktopAgentUnavailableError } from '../connection'
12
+ import { describeDesktopAgentError } from '../errors'
13
+
14
+ function withStatus(status: number): Error {
15
+ return Object.assign(new Error(`HTTP error! status: ${status}`), { name: 'StreamHttpError', status })
16
+ }
17
+
18
+ describe('describeDesktopAgentError', () => {
19
+ it('端点不可用 → 未就绪提示(实例与同名错误两种形态)', () => {
20
+ expect(describeDesktopAgentError(new DesktopAgentUnavailableError())).toBe(m.agent_desktop_errorUnavailable())
21
+ const renamed = Object.assign(new Error('x'), { name: 'DesktopAgentUnavailableError' })
22
+ expect(describeDesktopAgentError(renamed)).toBe(m.agent_desktop_errorUnavailable())
23
+ })
24
+
25
+ it('401 → 令牌失效提示;403 → 未授权/来源被拒提示', () => {
26
+ expect(describeDesktopAgentError(withStatus(401))).toBe(m.agent_desktop_errorUnauthorized())
27
+ expect(describeDesktopAgentError(withStatus(403))).toBe(m.agent_desktop_errorForbidden())
28
+ })
29
+
30
+ it('网络层失败(StreamReadError:端口不通 / CORS)→ 无法连接提示', () => {
31
+ const err = Object.assign(new Error('Failed to fetch'), { name: 'StreamReadError' })
32
+ expect(describeDesktopAgentError(err)).toBe(m.agent_desktop_errorConnection())
33
+ })
34
+
35
+ it('其余(RUN_ERROR、400、500 等)→ 运行失败 + 原文', () => {
36
+ expect(describeDesktopAgentError(new Error('模型配置缺失'))).toBe(
37
+ m.agent_desktop_errorGeneric({ message: '模型配置缺失' }),
38
+ )
39
+ expect(describeDesktopAgentError(withStatus(500))).toBe(
40
+ m.agent_desktop_errorGeneric({ message: 'HTTP error! status: 500' }),
41
+ )
42
+ })
43
+ })