@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
@@ -23,6 +23,7 @@ import { Route as LoginLoginRouteRouteImport } from './routes/_login/login/route
23
23
  import { Route as LoginRegisterRouteRouteImport } from './routes/_login/register/route'
24
24
  import { Route as LoginResetPasswordRouteRouteImport } from './routes/_login/reset-password/route'
25
25
  import { Route as LoginVerifyEmailRouteRouteImport } from './routes/_login/verify-email/route'
26
+ import { Route as NavAgentRouteRouteImport } from './routes/_nav/agent/route'
26
27
  import { Route as NavAppsRouteRouteImport } from './routes/_nav/apps/route'
27
28
  import { Route as NavBugsRouteRouteImport } from './routes/_nav/bugs/route'
28
29
  import { Route as NavChangelogRouteRouteImport } from './routes/_nav/changelog/route'
@@ -251,6 +252,13 @@ const LoginVerifyEmailRouteRoute = LoginVerifyEmailRouteRouteImport.update({
251
252
  } as any).lazy(() =>
252
253
  import('./routes/_login/verify-email/route.lazy').then((d) => d.Route),
253
254
  )
255
+ const NavAgentRouteRoute = NavAgentRouteRouteImport.update({
256
+ id: '/agent',
257
+ path: '/agent',
258
+ getParentRoute: () => NavRouteRoute,
259
+ } as any).lazy(() =>
260
+ import('./routes/_nav/agent/route.lazy').then((d) => d.Route),
261
+ )
254
262
  const NavAppsRouteRoute = NavAppsRouteRouteImport.update({
255
263
  id: '/apps',
256
264
  path: '/apps',
@@ -1123,6 +1131,7 @@ export interface FileRoutesByFullPath {
1123
1131
  '/register': typeof LoginRegisterRouteRoute
1124
1132
  '/reset-password': typeof LoginResetPasswordRouteRoute
1125
1133
  '/verify-email': typeof LoginVerifyEmailRouteRoute
1134
+ '/agent': typeof NavAgentRouteRoute
1126
1135
  '/apps': typeof NavAppsRouteRouteWithChildren
1127
1136
  '/bugs': typeof NavBugsRouteRouteWithChildren
1128
1137
  '/changelog': typeof NavChangelogRouteRoute
@@ -1262,6 +1271,7 @@ export interface FileRoutesByTo {
1262
1271
  '/register': typeof LoginRegisterRouteRoute
1263
1272
  '/reset-password': typeof LoginResetPasswordRouteRoute
1264
1273
  '/verify-email': typeof LoginVerifyEmailRouteRoute
1274
+ '/agent': typeof NavAgentRouteRoute
1265
1275
  '/apps': typeof NavAppsRouteRouteWithChildren
1266
1276
  '/changelog': typeof NavChangelogRouteRoute
1267
1277
  '/dashboard': typeof NavDashboardRouteRoute
@@ -1390,6 +1400,7 @@ export interface FileRoutesById {
1390
1400
  '/_login/register': typeof LoginRegisterRouteRoute
1391
1401
  '/_login/reset-password': typeof LoginResetPasswordRouteRoute
1392
1402
  '/_login/verify-email': typeof LoginVerifyEmailRouteRoute
1403
+ '/_nav/agent': typeof NavAgentRouteRoute
1393
1404
  '/_nav/apps': typeof NavAppsRouteRouteWithChildren
1394
1405
  '/_nav/bugs': typeof NavBugsRouteRouteWithChildren
1395
1406
  '/_nav/changelog': typeof NavChangelogRouteRoute
@@ -1531,6 +1542,7 @@ export interface FileRouteTypes {
1531
1542
  | '/register'
1532
1543
  | '/reset-password'
1533
1544
  | '/verify-email'
1545
+ | '/agent'
1534
1546
  | '/apps'
1535
1547
  | '/bugs'
1536
1548
  | '/changelog'
@@ -1670,6 +1682,7 @@ export interface FileRouteTypes {
1670
1682
  | '/register'
1671
1683
  | '/reset-password'
1672
1684
  | '/verify-email'
1685
+ | '/agent'
1673
1686
  | '/apps'
1674
1687
  | '/changelog'
1675
1688
  | '/dashboard'
@@ -1797,6 +1810,7 @@ export interface FileRouteTypes {
1797
1810
  | '/_login/register'
1798
1811
  | '/_login/reset-password'
1799
1812
  | '/_login/verify-email'
1813
+ | '/_nav/agent'
1800
1814
  | '/_nav/apps'
1801
1815
  | '/_nav/bugs'
1802
1816
  | '/_nav/changelog'
@@ -2059,6 +2073,13 @@ declare module '@tanstack/react-router' {
2059
2073
  preLoaderRoute: typeof LoginVerifyEmailRouteRouteImport
2060
2074
  parentRoute: typeof LoginRouteRoute
2061
2075
  }
2076
+ '/_nav/agent': {
2077
+ id: '/_nav/agent'
2078
+ path: '/agent'
2079
+ fullPath: '/agent'
2080
+ preLoaderRoute: typeof NavAgentRouteRouteImport
2081
+ parentRoute: typeof NavRouteRoute
2082
+ }
2062
2083
  '/_nav/apps': {
2063
2084
  id: '/_nav/apps'
2064
2085
  path: '/apps'
@@ -3351,6 +3372,7 @@ const NavChatRouteWithChildren =
3351
3372
  NavChatRoute._addFileChildren(NavChatRouteChildren)
3352
3373
 
3353
3374
  interface NavRouteRouteChildren {
3375
+ NavAgentRouteRoute: typeof NavAgentRouteRoute
3354
3376
  NavAppsRouteRoute: typeof NavAppsRouteRouteWithChildren
3355
3377
  NavBugsRouteRoute: typeof NavBugsRouteRouteWithChildren
3356
3378
  NavChangelogRouteRoute: typeof NavChangelogRouteRoute
@@ -3372,6 +3394,7 @@ interface NavRouteRouteChildren {
3372
3394
  }
3373
3395
 
3374
3396
  const NavRouteRouteChildren: NavRouteRouteChildren = {
3397
+ NavAgentRouteRoute: NavAgentRouteRoute,
3375
3398
  NavAppsRouteRoute: NavAppsRouteRouteWithChildren,
3376
3399
  NavBugsRouteRoute: NavBugsRouteRouteWithChildren,
3377
3400
  NavChangelogRouteRoute: NavChangelogRouteRoute,
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 本地文件助手页(/_nav/agent)路由门控(桌面本地优先 P3-B5):依赖桌面壳
6
+ // AgentService,云 Web / OSS 构建无此页——beforeLoad 以构建期常量
7
+ // isDesktopEdition(VITE_DEPLOY_TARGET)判定,非桌面构建直接 throw notFound
8
+ // (SSR 即 404,无占位闪烁)。运行时的桥探测(useDesktopAgentPresence)另由
9
+ // lib/desktop-agent/__tests__/use-desktop-agent-presence.test.tsx 覆盖。
10
+ // 断言方式同 -edition-routing.test.tsx:直接调 Route.options.beforeLoad。
11
+ import { afterEach, describe, expect, it, vi } from 'vitest'
12
+
13
+ async function importAgentRoute(desktop: boolean) {
14
+ vi.resetModules()
15
+ vi.doMock('@/lib/edition-desktop', () => ({ isDesktopEdition: () => desktop }))
16
+ return import('../_nav/agent/route')
17
+ }
18
+
19
+ /** beforeLoad 抛出的 notFound 对象(@tanstack/router-core:{ isNotFound: true }) */
20
+ function catchNotFound(fn: () => unknown): { isNotFound?: boolean } | null {
21
+ try {
22
+ fn()
23
+ return null
24
+ } catch (error) {
25
+ return error as { isNotFound?: boolean }
26
+ }
27
+ }
28
+
29
+ afterEach(() => {
30
+ vi.doUnmock('@/lib/edition-desktop')
31
+ vi.resetModules()
32
+ })
33
+
34
+ describe('/_nav/agent beforeLoad 门控', () => {
35
+ it('非桌面构建(云 Web / OSS):throw notFound(页面不存在,SSR 即 404)', async () => {
36
+ const { Route } = await importAgentRoute(false)
37
+ const result = catchNotFound(() => Route.options.beforeLoad!({} as never))
38
+ expect(result?.isNotFound).toBe(true)
39
+ })
40
+
41
+ it('桌面构建:放行(运行时无桥由 lazy 组件的 useDesktopAgentPresence 落到 NotFound)', async () => {
42
+ const { Route } = await importAgentRoute(true)
43
+ expect(Route.options.beforeLoad!({} as never)).toBeUndefined()
44
+ })
45
+ })
@@ -0,0 +1,63 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 本地文件助手页(桌面本地优先 P3-B5):授权目录卡 + agent 对话区。门控说明见
6
+ // 同目录 route.tsx;本文件承担运行时桥探测(mounted 模式:SSR / hydration 首帧
7
+ // 渲染空白、不读 window,挂载后 Electron 恢复页面,无桥即 NotFound)。
8
+ //
9
+ // 双层判定:presence(含 agentPort 装配,见 lib/desktop-agent/availability)决定
10
+ // 页面是否可达;isAgentWorkspaceSupported(B1 三方法)决定目录授权是否可用——
11
+ // 旧壳缺三方法时整页只留升级提示,不渲染对话区(否则对话区「请先添加授权目录」
12
+ // 与卡片「请升级客户端」互相打脸)。
13
+ import { useState } from 'react'
14
+ import { createLazyFileRoute } from '@tanstack/react-router'
15
+ import { useQuery } from '@tanstack/react-query'
16
+ import { AgentChat } from '@/components/desktop-agent/agent-chat'
17
+ import { AgentWorkspaceCard } from '@/components/desktop-agent/agent-workspace-card'
18
+ import { isAgentWorkspaceSupported } from '@/lib/desktop-agent/availability'
19
+ import { useDesktopAgentPresence } from '@/lib/desktop-agent/use-desktop-agent-presence'
20
+ import { desktopAgentWorkspacesQueryOptions } from '@/lib/queries/desktop-agent'
21
+ import { m } from '@/paraglide/messages.js'
22
+ import { NavNotFound } from '../-components/nav-not-found'
23
+
24
+ export const Route = createLazyFileRoute('/_nav/agent')({
25
+ component: DesktopAgentRoute,
26
+ })
27
+
28
+ function DesktopAgentRoute() {
29
+ const presence = useDesktopAgentPresence()
30
+ if (presence === 'pending') return null
31
+ if (presence === 'absent') return <NavNotFound />
32
+ return <DesktopAgentPage />
33
+ }
34
+
35
+ function DesktopAgentPage() {
36
+ const supportsWorkspaces = isAgentWorkspaceSupported()
37
+ // enabled 与卡片同口径:不支持目录授权时不查清单,避免无效 IPC
38
+ const { data: workspaces = [] } = useQuery({
39
+ ...desktopAgentWorkspacesQueryOptions(),
40
+ enabled: supportsWorkspaces,
41
+ })
42
+ const [picked, setPicked] = useState<string | null>(null)
43
+ const [running, setRunning] = useState(false)
44
+ // 未选择 / 选中项已被撤销 → 回落清单首项(清单为空则 null,对话区禁用输入)
45
+ const cwd = picked !== null && workspaces.includes(picked) ? picked : (workspaces[0] ?? null)
46
+
47
+ return (
48
+ <div className="flex h-full min-h-0 flex-col gap-4 overflow-hidden p-6">
49
+ <header className="space-y-1">
50
+ <h1 className="text-lg font-semibold">{m.agent_desktop_title()}</h1>
51
+ <p className="text-sm text-muted-foreground">{m.agent_desktop_pageDescription()}</p>
52
+ </header>
53
+ <div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row">
54
+ <div className="shrink-0 lg:w-80 lg:overflow-y-auto">
55
+ <AgentWorkspaceCard selected={cwd} onSelect={setPicked} disabled={running} />
56
+ </div>
57
+ {/* 旧壳(无目录授权 IPC)只留卡片里的升级提示;否则每个目录一份会话
58
+ (换目录即新会话,运行中由 disabled 锁定切换) */}
59
+ {supportsWorkspaces && <AgentChat key={cwd ?? 'none'} cwd={cwd} onRunningChange={setRunning} />}
60
+ </div>
61
+ </div>
62
+ )
63
+ }
@@ -0,0 +1,21 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 本地文件助手(桌面本地优先 P3-B5):依赖桌面壳的 AgentService,云 Web / OSS
6
+ // 部署无此页。门控三层,保证「入口可见 ⟹ 页面可达」:
7
+ // ① beforeLoad:构建期常量 isDesktopEdition()(VITE_DEPLOY_TARGET,同构执行、SSR
8
+ // 安全——与 settings/sync、_public/index 的 beforeLoad 同口径),非桌面构建直接
9
+ // notFound(服务端即 404,无占位闪烁);
10
+ // ② lazy 组件:挂载后 getDesktopBridge() 运行时探测(useDesktopAgentPresence)——
11
+ // 桌面构建被浏览器直开 backend 端口(无桥)→ NotFound;
12
+ // ③ 侧栏入口:同一 presence 判定条件渲染(app-sidebar)。
13
+ // 无 loader:授权清单走 react-query 工厂(lib/queries/desktop-agent,IPC 数据源)。
14
+ import { createFileRoute, notFound } from '@tanstack/react-router'
15
+ import { isDesktopEdition } from '@/lib/edition-desktop'
16
+
17
+ export const Route = createFileRoute('/_nav/agent')({
18
+ beforeLoad: () => {
19
+ if (!isDesktopEdition()) throw notFound()
20
+ },
21
+ })
@@ -5,8 +5,10 @@
5
5
  // account-status 端点(桌面本地优先 P2-A5,迁移设计归属转移前置核对):桌面端绑定
6
6
  // 云账号前探测目标账号是否「空账号」——仅空账号才走归属转移(一期唯一路径)。
7
7
  // 六表 count 口径与属主字段实查收敛在 server/sync/account-status.ts 的
8
- // isAccountEmpty(A8a 抽取,transferOwnershipFn 复核复用同源),此处只做
9
- // requireAuth + 响应映射。
8
+ // isAccountEmpty,此处只做 requireAuth + 响应映射。
9
+ // 注意(I2 裁定):转移侧的复核**不再**调本地 isAccountEmpty(本地库看不到云端
10
+ // 数据、且转移后本地计数恒 >0 会破坏幂等重试),改由
11
+ // server/sync/account-status-cloud.ts 请求**本端点**取云端权威结果。
10
12
  import { createFileRoute } from "@tanstack/react-router"
11
13
  import { getPrisma } from "@allbluecn/database/get-prisma"
12
14
  import { requireAuth, UnauthorizedError } from "@/server/auth-helpers"
@@ -87,9 +87,15 @@ export const Route = createFileRoute("/api/sync/push")({
87
87
  const body = pushSchema.parse(await request.json())
88
88
 
89
89
  const prisma = await getPrisma()
90
- const { applyOp } = await import("@allbluecn/sync-core")
90
+ const { applyOp, admitScopeForOp } = await import("@allbluecn/sync-core")
91
91
  const { SYNC_TABLE_MAP } = await import("@allbluecn/sync-core/registrations")
92
92
  const scopes = await resolveSyncScopes(prisma, userId)
93
+ // 首次上云的 scope 动态准入(C1 / 裁定 R5):空云账号的 scopes 只有
94
+ // user:<id>,而 outbox 的容器行 scope = 自身 id、子行 scope = 父容器 id。
95
+ // 逐 op 按「属主自证」累积准入集合(同一 Set 穿过整批 op,依赖 outbox
96
+ // 的父先子后拓扑序),再交给 applyOp 做行级校验——准入只覆盖自证行,
97
+ // 不放宽到整表(边界见 @allbluecn/sync-core 的 scope-admission 头注释)。
98
+ const admittedScopes = new Set(scopes)
93
99
 
94
100
  // 整批单事务:逐 op 应用(依赖拓扑由客户端 outbox 顺序保证)
95
101
  const results = await prisma.$transaction(async (tx) => {
@@ -107,9 +113,11 @@ export const Route = createFileRoute("/api/sync/push")({
107
113
  continue
108
114
  }
109
115
  const payload = op.payload as SyncPayload | undefined
116
+ await admitScopeForOp(executor, reg, { pk: op.pk, op: op.op, payload }, userId, admittedScopes)
117
+ const ctxScopes = [...admittedScopes]
110
118
  let outcome = await applyOp(executor, reg, { ...op, payload }, {
111
119
  userId,
112
- scopes,
120
+ scopes: ctxScopes,
113
121
  now: new Date(),
114
122
  })
115
123
  // 编号重发号特例:stories/bugs 撞 identifier 唯一键 → 服务端重发号重试
@@ -119,7 +127,7 @@ export const Route = createFileRoute("/api/sync/push")({
119
127
  if (newIdentifier && newIdentifier !== oldIdentifier) {
120
128
  outcome = await applyOp(executor, reg, { ...op, payload }, {
121
129
  userId,
122
- scopes,
130
+ scopes: ctxScopes,
123
131
  now: new Date(),
124
132
  })
125
133
  if (outcome.kind === "applied") {
@@ -0,0 +1,331 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 云身份幂等落库(C2 / 裁定 R3)回归哨兵:
6
+ // ① 新建:upsert create 载荷含云端资料(name→username、email、头像三件)+ 随机
7
+ // password 占位 + ACTIVE;update 留空;
8
+ // ② 幂等:同 id 已存在 → created:false 且零写入(不覆盖用户后续改名/改头像);
9
+ // ③ email 冲突(本地凭据用户已占用云邮箱)→ 不崩,降级 `<cloudUserId>@cloud.local`;
10
+ // ④ username 冲突逐级降级 name → id → `<id>@cloud`;
11
+ // ⑤ copyPreferencesFrom:新建行复制用户级配置列(C3 裁定;null 不复制);
12
+ // ⑥ R1:existing 行 + copyPreferencesFrom → 仅填充空/默认值列(真实绑定链:
13
+ // exchange 先幂等建行 → transfer 命中 existing,不填充则配置随 guest 行丢失);
14
+ // ⑦ R1:existing 行已有非空配置 → 不被覆盖(云态用户自己的值优先)。
15
+ // @vitest-environment node
16
+ import { describe, expect, it, vi } from 'vitest'
17
+ import type { Prisma } from '@allbluecn/database'
18
+
19
+ import {
20
+ USER_PREFERENCE_COLUMNS,
21
+ ensureCloudIdentity,
22
+ readUserPreferences,
23
+ } from '../desktop-cloud-identity'
24
+
25
+ type UserRow = Record<string, unknown> & { id: string }
26
+
27
+ /** 最小 users 表 fake:findUnique 按 where 的单键查找,upsert 落行,update 合并 patch */
28
+ function makeTx(seed: UserRow[] = []) {
29
+ const rows = new Map<string, UserRow>(seed.map((r) => [r.id, r]))
30
+ const upsert = vi.fn(async ({ create }: { create: UserRow; update: Record<string, never> }) => {
31
+ rows.set(create.id, create)
32
+ return create
33
+ })
34
+ const update = vi.fn(async ({ where, data }: { where: { id: string }; data: UserRow }) => {
35
+ const row = rows.get(where.id)
36
+ if (row) rows.set(where.id, { ...row, ...data })
37
+ return row
38
+ })
39
+ const findUnique = vi.fn(async ({ where }: { where: Record<string, string> }) => {
40
+ const [field, value] = Object.entries(where)[0] ?? []
41
+ for (const row of rows.values()) {
42
+ if (row[field] === value) return row
43
+ }
44
+ return null
45
+ })
46
+ const tx = { user: { findUnique, upsert, update } } as unknown as Prisma.TransactionClient
47
+ return { tx, rows, findUnique, upsert, update }
48
+ }
49
+
50
+ const CLOUD = 'cloud-user-1'
51
+
52
+ describe('ensureCloudIdentity(C2 云身份自洽)', () => {
53
+ it('①新建:create 载荷含云端资料 + 随机 password 占位 + ACTIVE,update 留空', async () => {
54
+ const { tx, upsert } = makeTx()
55
+
56
+ const result = await ensureCloudIdentity(tx, {
57
+ id: CLOUD,
58
+ name: '云上名字',
59
+ email: 'cloud@example.com',
60
+ avatarConfig: { hat: 2 },
61
+ bgShape: 'circle',
62
+ bgColor: '#3b82f6',
63
+ })
64
+
65
+ expect(result).toEqual({
66
+ created: true,
67
+ email: 'cloud@example.com',
68
+ username: '云上名字',
69
+ emailFallback: false,
70
+ })
71
+ expect(upsert).toHaveBeenCalledTimes(1)
72
+ const arg = upsert.mock.calls[0][0] as { where: { id: string }; create: UserRow; update: object }
73
+ expect(arg.where).toEqual({ id: CLOUD })
74
+ expect(arg.create).toMatchObject({
75
+ id: CLOUD,
76
+ email: 'cloud@example.com',
77
+ username: '云上名字',
78
+ avatarConfig: { hat: 2 },
79
+ bgShape: 'circle',
80
+ bgColor: '#3b82f6',
81
+ status: 'ACTIVE',
82
+ })
83
+ // password 随机占位(非 bcrypt):凭据登录必失败
84
+ expect(typeof arg.create.password).toBe('string')
85
+ expect((arg.create.password as string).length).toBeGreaterThan(0)
86
+ expect(arg.update).toEqual({})
87
+ })
88
+
89
+ it('②幂等:同 id 行已存在 → created:false 且零写入(不覆盖改后资料)', async () => {
90
+ const { tx, upsert, findUnique } = makeTx([{ id: CLOUD, email: 'x@y.z', username: '我改过的名字' }])
91
+
92
+ const result = await ensureCloudIdentity(tx, { id: CLOUD, name: '云上名字' })
93
+
94
+ expect(result.created).toBe(false)
95
+ expect(upsert).not.toHaveBeenCalled()
96
+ // 只做了存在性探测(单次 findUnique by id),未触发冲突探测链
97
+ expect(findUnique).toHaveBeenCalledTimes(1)
98
+ })
99
+
100
+ it('③email 冲突(本地凭据用户已占用云邮箱)→ 降级占位地址,不抛 P2002', async () => {
101
+ const { tx, upsert } = makeTx([
102
+ { id: 'local-user-9', email: 'cloud@example.com', username: '本地用户九' },
103
+ ])
104
+
105
+ const result = await ensureCloudIdentity(tx, {
106
+ id: CLOUD,
107
+ name: '云上名字',
108
+ email: 'cloud@example.com',
109
+ })
110
+
111
+ expect(result).toMatchObject({ created: true, emailFallback: true })
112
+ expect(result.email).toBe(`${CLOUD}@cloud.local`)
113
+ const create = (upsert.mock.calls[0][0] as { create: UserRow }).create
114
+ expect(create.email).toBe(`${CLOUD}@cloud.local`)
115
+ })
116
+
117
+ it('④username 冲突逐级降级:name 被占 → id → (id 也被占)<id>@cloud', async () => {
118
+ const taken = makeTx([{ id: 'local-2', email: 'a@b.c', username: '云上名字' }])
119
+ const first = await ensureCloudIdentity(taken.tx, { id: CLOUD, name: '云上名字' })
120
+ expect(first.username).toBe(CLOUD)
121
+
122
+ const bothTaken = makeTx([
123
+ { id: 'local-2', email: 'a@b.c', username: '云上名字' },
124
+ { id: 'local-3', email: 'd@e.f', username: CLOUD },
125
+ ])
126
+ const second = await ensureCloudIdentity(bothTaken.tx, { id: CLOUD, name: '云上名字' })
127
+ expect(second.username).toBe(`${CLOUD}@cloud`)
128
+ })
129
+
130
+ it('⑤copyPreferencesFrom:复制用户级配置列(null 不复制,未列列不出现)', async () => {
131
+ const { tx, upsert } = makeTx([
132
+ {
133
+ id: 'local-guest',
134
+ email: 'guest@localhost',
135
+ username: '本地用户',
136
+ aiApiKey: 'sk-local',
137
+ aiProvider: 'openrouter',
138
+ aiModel: 'openai/gpt-4o-mini',
139
+ aiSourceMode: null,
140
+ locale: 'zh',
141
+ timezone: 'Asia/Shanghai',
142
+ storyDisplayOptions: { showEmptyGroups: true },
143
+ preferredCurrency: 'cny',
144
+ defaultWorkspaceId: 'ws-1',
145
+ role: 'ADMIN', // 非配置列,不得复制
146
+ },
147
+ ])
148
+
149
+ await ensureCloudIdentity(
150
+ tx,
151
+ { id: CLOUD, name: '云上名字' },
152
+ { copyPreferencesFrom: 'local-guest' },
153
+ )
154
+
155
+ const create = (upsert.mock.calls[0][0] as { create: UserRow }).create
156
+ expect(create).toMatchObject({
157
+ aiApiKey: 'sk-local',
158
+ aiProvider: 'openrouter',
159
+ aiModel: 'openai/gpt-4o-mini',
160
+ locale: 'zh',
161
+ timezone: 'Asia/Shanghai',
162
+ storyDisplayOptions: { showEmptyGroups: true },
163
+ preferredCurrency: 'cny',
164
+ defaultWorkspaceId: 'ws-1',
165
+ })
166
+ // null 不复制(保持目标行 DB 默认/空值)
167
+ expect('aiSourceMode' in create).toBe(false)
168
+ // 账号安全态不在复制清单内
169
+ expect('role' in create).toBe(false)
170
+ expect(USER_PREFERENCE_COLUMNS).not.toContain('role' as never)
171
+ })
172
+
173
+ it('readUserPreferences:源行不存在 → 空对象(不抛)', async () => {
174
+ const { tx } = makeTx()
175
+ await expect(readUserPreferences(tx, 'missing')).resolves.toEqual({})
176
+ })
177
+
178
+ it('⑥R1 existing 行 + copyPreferencesFrom → 仅填充空/默认值列(不整行覆盖)', async () => {
179
+ // 真实绑定链:exchange 先幂等建行(无源)→ 云行只有资料、配置列全空
180
+ const { tx, rows, upsert, update } = makeTx([
181
+ {
182
+ id: 'local-guest',
183
+ username: '本地用户',
184
+ aiApiKey: 'sk-local',
185
+ aiProvider: 'openrouter',
186
+ aiModel: 'openai/gpt-4o-mini',
187
+ aiSourceMode: 'byok',
188
+ locale: 'zh',
189
+ timezone: 'Asia/Shanghai',
190
+ storyDisplayOptions: { showEmptyGroups: true },
191
+ preferredCurrency: 'cny',
192
+ defaultWorkspaceId: 'ws-1',
193
+ },
194
+ {
195
+ // exchange 建出来的云行:资料有值,配置列全 null(preferredCurrency 走 DB 默认 usd)
196
+ id: CLOUD,
197
+ username: '云上名字',
198
+ email: 'cloud@example.com',
199
+ aiApiKey: null,
200
+ aiProvider: null,
201
+ aiModel: null,
202
+ aiSourceMode: null,
203
+ locale: null,
204
+ timezone: null,
205
+ storyDisplayOptions: null,
206
+ preferredCurrency: 'usd',
207
+ defaultWorkspaceId: null,
208
+ },
209
+ ])
210
+
211
+ const result = await ensureCloudIdentity(
212
+ tx,
213
+ { id: CLOUD, name: '云上名字' },
214
+ { copyPreferencesFrom: 'local-guest' },
215
+ )
216
+
217
+ expect(result.created).toBe(false)
218
+ // 已存在不建行
219
+ expect(upsert).not.toHaveBeenCalled()
220
+ // 只发一次 patch 更新,且只含空列(preferredCurrency 默认 usd 也算空)
221
+ expect(update).toHaveBeenCalledTimes(1)
222
+ const arg = update.mock.calls[0][0] as { where: { id: string }; data: Record<string, unknown> }
223
+ expect(arg.where).toEqual({ id: CLOUD })
224
+ expect(arg.data).toEqual({
225
+ aiApiKey: 'sk-local',
226
+ aiProvider: 'openrouter',
227
+ aiModel: 'openai/gpt-4o-mini',
228
+ aiSourceMode: 'byok',
229
+ locale: 'zh',
230
+ timezone: 'Asia/Shanghai',
231
+ storyDisplayOptions: { showEmptyGroups: true },
232
+ preferredCurrency: 'cny',
233
+ defaultWorkspaceId: 'ws-1',
234
+ })
235
+ // 落地后配置到手(guest 行随后被删也不丢)
236
+ expect(rows.get(CLOUD)).toMatchObject({ aiApiKey: 'sk-local', locale: 'zh', preferredCurrency: 'cny' })
237
+ })
238
+
239
+ it('⑦R1 existing 行已有非空配置 → 不被覆盖(云端值优先)', async () => {
240
+ const { tx, rows, update } = makeTx([
241
+ {
242
+ id: 'local-guest',
243
+ username: '本地用户',
244
+ aiApiKey: 'sk-local',
245
+ aiProvider: 'openrouter',
246
+ locale: 'zh',
247
+ timezone: 'Asia/Shanghai',
248
+ preferredCurrency: 'cny',
249
+ },
250
+ {
251
+ // 用户在云态已自行填过 AI key / 语言(云行有值)
252
+ id: CLOUD,
253
+ username: '云上名字',
254
+ aiApiKey: 'sk-cloud',
255
+ aiProvider: 'openai',
256
+ locale: 'en',
257
+ preferredCurrency: 'usd',
258
+ },
259
+ ])
260
+
261
+ const result = await ensureCloudIdentity(
262
+ tx,
263
+ { id: CLOUD, name: '云上名字' },
264
+ { copyPreferencesFrom: 'local-guest' },
265
+ )
266
+
267
+ expect(result.created).toBe(false)
268
+ // 只补云行仍为空的列:timezone(aiApiKey/aiProvider/locale 有值不覆盖;
269
+ // preferredCurrency 为默认 usd → 视为未设置,可填)
270
+ expect(update).toHaveBeenCalledTimes(1)
271
+ const patch = (update.mock.calls[0][0] as { data: Record<string, unknown> }).data
272
+ expect(patch).toEqual({ timezone: 'Asia/Shanghai', preferredCurrency: 'cny' })
273
+ expect(patch).not.toHaveProperty('aiApiKey')
274
+ expect(patch).not.toHaveProperty('aiProvider')
275
+ expect(patch).not.toHaveProperty('locale')
276
+ expect(rows.get(CLOUD)).toMatchObject({ aiApiKey: 'sk-cloud', aiProvider: 'openai', locale: 'en' })
277
+ })
278
+
279
+ it('⑦b R1 existing 行目标列全部已有值 → patch 为空,零写入', async () => {
280
+ const { tx, update } = makeTx([
281
+ { id: 'local-guest', username: '本地用户', locale: 'zh' },
282
+ { id: CLOUD, username: '云上名字', locale: 'en' },
283
+ ])
284
+
285
+ await ensureCloudIdentity(tx, { id: CLOUD, name: '云上名字' }, { copyPreferencesFrom: 'local-guest' })
286
+
287
+ expect(update).not.toHaveBeenCalled()
288
+ })
289
+
290
+ it('⑧R1 真实顺序模拟:exchange 建行(无源)→ transfer 复制 → 配置到手', async () => {
291
+ const { tx, rows } = makeTx([
292
+ {
293
+ id: 'local-guest',
294
+ username: '本地用户',
295
+ aiApiKey: 'sk-local',
296
+ aiModel: 'openai/gpt-4o-mini',
297
+ locale: 'zh',
298
+ timezone: 'Asia/Shanghai',
299
+ preferredCurrency: 'cny',
300
+ },
301
+ ])
302
+
303
+ // ① exchange:建云行(无 copyPreferencesFrom —— 交换阶段没有源)
304
+ await ensureCloudIdentity(tx, { id: CLOUD, name: '云上名字', email: 'cloud@example.com' })
305
+ expect(rows.get(CLOUD)).toMatchObject({ id: CLOUD, username: '云上名字' })
306
+ expect(rows.get(CLOUD)?.aiApiKey).toBeUndefined()
307
+
308
+ // ② transfer:命中 existing + 传源 → 仅填空复制(此前实现直接 return,配置全丢)
309
+ const result = await ensureCloudIdentity(
310
+ tx,
311
+ { id: CLOUD, name: '云上名字', email: 'cloud@example.com' },
312
+ { copyPreferencesFrom: 'local-guest' },
313
+ )
314
+ expect(result.created).toBe(false)
315
+ expect(rows.get(CLOUD)).toMatchObject({
316
+ aiApiKey: 'sk-local',
317
+ aiModel: 'openai/gpt-4o-mini',
318
+ locale: 'zh',
319
+ timezone: 'Asia/Shanghai',
320
+ preferredCurrency: 'cny',
321
+ // 资料不被覆盖
322
+ username: '云上名字',
323
+ email: 'cloud@example.com',
324
+ })
325
+ })
326
+
327
+ it('id 为空 → 拒绝(防御非法调用)', async () => {
328
+ const { tx } = makeTx()
329
+ await expect(ensureCloudIdentity(tx, { id: ' ' })).rejects.toThrow(/id/)
330
+ })
331
+ })
@@ -0,0 +1,42 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 桌面回落会话组装(C2):remote-auth 落盘的云资料(name/email/avatar)必须
6
+ // 组装成真实身份——此前 remote-auth 不存 profile,name 恒回落 UUID。
7
+ // @vitest-environment node
8
+ import { describe, expect, it } from 'vitest'
9
+
10
+ import { buildRemoteSession } from '../desktop-session-merge'
11
+
12
+ describe('buildRemoteSession(C2 云资料组装)', () => {
13
+ it('有资料:name/email/avatar 取自云资料,不再是 UUID', () => {
14
+ const session = buildRemoteSession({
15
+ userId: 'cloud-uuid-1',
16
+ planName: 'pro',
17
+ features: {},
18
+ username: '云上名字',
19
+ email: 'cloud@example.com',
20
+ avatarConfig: { hat: 1, face: 2 },
21
+ bgShape: 'circle',
22
+ bgColor: '#3b82f6',
23
+ })
24
+
25
+ expect(session.user).toMatchObject({
26
+ id: 'cloud-uuid-1',
27
+ name: '云上名字',
28
+ email: 'cloud@example.com',
29
+ avatarConfig: { hat: 1, face: 2 },
30
+ bgShape: 'circle',
31
+ bgColor: '#3b82f6',
32
+ isGuest: false,
33
+ })
34
+ })
35
+
36
+ it('旧文件无资料:name 回落 userId(行为与旧版逐字一致)', () => {
37
+ const session = buildRemoteSession({ userId: 'cloud-uuid-2', planName: 'free', features: {} })
38
+ expect(session.user).toMatchObject({ id: 'cloud-uuid-2', name: 'cloud-uuid-2' })
39
+ expect(session.user?.email).toBeUndefined()
40
+ expect(session.user?.avatarConfig).toBeUndefined()
41
+ })
42
+ })