@allbluecn/web-app 0.23.2 → 0.23.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +2 -0
- package/src/components/desktop/__tests__/desktop-state-listener.test.tsx +183 -0
- package/src/components/desktop/__tests__/sync-indicator.test.tsx +130 -0
- package/src/components/desktop/cloud-login.tsx +108 -6
- package/src/components/desktop/desktop-state-listener.tsx +76 -0
- package/src/components/desktop/shell-widgets.tsx +12 -5
- package/src/components/desktop/sync-indicator.tsx +62 -13
- package/src/components/desktop-agent/__tests__/agent-chat.test.tsx +194 -0
- package/src/components/desktop-agent/__tests__/agent-tool-card.test.tsx +138 -0
- package/src/components/desktop-agent/__tests__/agent-workspace-card.test.tsx +189 -0
- package/src/components/desktop-agent/agent-chat.tsx +221 -0
- package/src/components/desktop-agent/agent-tool-card.tsx +185 -0
- package/src/components/desktop-agent/agent-workspace-card.tsx +176 -0
- package/src/components/layout/sidebar-03/__tests__/user-menu-anonymous.test.tsx +4 -0
- package/src/components/layout/sidebar-03/app-sidebar.tsx +9 -2
- package/src/components/layout/sidebar-03/user-menu.tsx +63 -0
- package/src/config/sidebar-routes.tsx +14 -1
- package/src/lib/__tests__/session.test.ts +95 -0
- package/src/lib/auth/auth.config.ts +13 -0
- package/src/lib/changelog/sdk-versions.ts +1 -1
- package/src/lib/desktop-agent/__tests__/connection.test.ts +223 -0
- package/src/lib/desktop-agent/__tests__/errors.test.ts +43 -0
- package/src/lib/desktop-agent/__tests__/tool-result.test.ts +163 -0
- package/src/lib/desktop-agent/__tests__/use-desktop-agent-presence.test.tsx +181 -0
- package/src/lib/desktop-agent/availability.ts +39 -0
- package/src/lib/desktop-agent/connection.ts +72 -0
- package/src/lib/desktop-agent/errors.ts +21 -0
- package/src/lib/desktop-agent/tool-result.ts +170 -0
- package/src/lib/desktop-agent/use-desktop-agent-presence.ts +64 -0
- package/src/lib/desktop-bridge-types.ts +58 -1
- package/src/lib/hotkeys/__tests__/registry.test.ts +11 -4
- package/src/lib/hotkeys/registry.ts +9 -1
- package/src/lib/queries/README.md +1 -0
- package/src/lib/queries/__tests__/desktop-agent-queries.test.ts +80 -0
- package/src/lib/queries/desktop-agent.ts +25 -0
- package/src/lib/session.ts +15 -1
- package/src/paraglide/messages/_index.js +820 -0
- package/src/paraglide/messages/en.js +260 -0
- package/src/paraglide/messages/zh.js +260 -0
- package/src/routeTree.gen.ts +23 -0
- package/src/routes/-tests/agent-gate.test.ts +45 -0
- package/src/routes/_nav/agent/route.lazy.tsx +63 -0
- package/src/routes/_nav/agent/route.tsx +21 -0
- package/src/routes/api/sync/account-status.ts +5 -21
- package/src/server/__tests__/exchange-remote-token.test.ts +113 -0
- package/src/server/__tests__/remote-auth.test.ts +131 -1
- package/src/server/desktop-bootstrap.ts +19 -3
- package/src/server/desktop-session-merge.ts +51 -0
- package/src/server/remote-auth.ts +61 -3
- package/src/server/serverFns/auth/session.ts +28 -28
- package/src/server/serverFns/sync/transfer.ts +168 -0
- package/src/server/serverfn-public-allowlist.ts +4 -0
- package/src/server/sync/__tests__/ownership-transfer.test.ts +896 -0
- package/src/server/sync/account-status.ts +36 -0
- package/src/server/sync/ownership-transfer.ts +291 -0
- package/src/server/sync/saas-profile.ts +70 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// 本地文件助手入口门控(桌面本地优先 P3-B5):路由页与侧栏入口同源判定——
|
|
6
|
+
// 桌面构建(VITE_DEPLOY_TARGET=desktop,与路由 beforeLoad 同口径)× 存在
|
|
7
|
+
// desktopBridge × agent 已装配(appInfo.agentPort 为数字,与 Pro main 侧 B4 广播
|
|
8
|
+
// 语义一致:null = 未就绪 → 隐藏入口)。首帧恒 pending(SSR 无 window,hydration
|
|
9
|
+
// 首帧须与 SSR 一致);「桌面 + 有桥 + 端口未广播」走 1s 间隔、累计约 20s 的
|
|
10
|
+
// 有界重试(冷启动 backend 约 3s 才广播,单次探测会让入口整会话消失)。
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
+
import { act, renderHook } from '@testing-library/react'
|
|
13
|
+
import { renderToString } from 'react-dom/server'
|
|
14
|
+
import {
|
|
15
|
+
DESKTOP_AGENT_RETRY_BUDGET_MS,
|
|
16
|
+
DESKTOP_AGENT_RETRY_INTERVAL_MS,
|
|
17
|
+
useDesktopAgentPresence,
|
|
18
|
+
} from '../use-desktop-agent-presence'
|
|
19
|
+
|
|
20
|
+
const state = vi.hoisted(() => ({
|
|
21
|
+
desktopEdition: true as boolean,
|
|
22
|
+
bridge: null as unknown,
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
vi.mock('@/lib/edition-desktop', () => ({
|
|
26
|
+
isDesktopEdition: () => state.desktopEdition,
|
|
27
|
+
}))
|
|
28
|
+
vi.mock('@/lib/desktop-bridge-types', () => ({
|
|
29
|
+
getDesktopBridge: () => state.bridge,
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
const BASE_INFO = { version: '1.0.0', clientId: 'c1', platform: 'darwin', backendPort: 51000 }
|
|
33
|
+
|
|
34
|
+
/** 桥 + 可定制 appInfo(agent 装配状态由 appInfo 返回值决定) */
|
|
35
|
+
function bridgeWith(appInfoImpl: () => Promise<unknown>) {
|
|
36
|
+
const appInfo = vi.fn(appInfoImpl)
|
|
37
|
+
state.bridge = { appInfo }
|
|
38
|
+
return appInfo
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 推进假时钟(含微任务),用于驱动重试链 */
|
|
42
|
+
async function advance(ms: number) {
|
|
43
|
+
await act(async () => {
|
|
44
|
+
await vi.advanceTimersByTimeAsync(ms)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.useFakeTimers()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.useRealTimers()
|
|
54
|
+
state.desktopEdition = true
|
|
55
|
+
state.bridge = null
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('useDesktopAgentPresence', () => {
|
|
59
|
+
it('桌面构建 + 有桥 + agent 已装配(agentPort 为数字)→ 首次探测即 present,不再重试', async () => {
|
|
60
|
+
const appInfo = bridgeWith(async () => ({ ...BASE_INFO, agentPort: 4567, agentToken: 'tok' }))
|
|
61
|
+
const { result } = renderHook(() => useDesktopAgentPresence())
|
|
62
|
+
expect(result.current).toBe('pending')
|
|
63
|
+
await advance(0)
|
|
64
|
+
expect(result.current).toBe('present')
|
|
65
|
+
await advance(DESKTOP_AGENT_RETRY_INTERVAL_MS * 3)
|
|
66
|
+
expect(appInfo).toHaveBeenCalledTimes(1)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('冷启动:首次 agentPort=null → 下一次探测拿到数字 → present 并停止重试', async () => {
|
|
70
|
+
const appInfo = vi
|
|
71
|
+
.fn<() => Promise<unknown>>()
|
|
72
|
+
.mockResolvedValueOnce({ ...BASE_INFO, agentPort: null, agentToken: null })
|
|
73
|
+
.mockResolvedValue({ ...BASE_INFO, agentPort: 4567, agentToken: 'tok' })
|
|
74
|
+
state.bridge = { appInfo }
|
|
75
|
+
const { result } = renderHook(() => useDesktopAgentPresence())
|
|
76
|
+
expect(result.current).toBe('pending')
|
|
77
|
+
|
|
78
|
+
await advance(0)
|
|
79
|
+
expect(appInfo).toHaveBeenCalledTimes(1)
|
|
80
|
+
expect(result.current).toBe('pending') // 仍在窗口内:保持 pending,不报不可用
|
|
81
|
+
|
|
82
|
+
await advance(DESKTOP_AGENT_RETRY_INTERVAL_MS)
|
|
83
|
+
expect(appInfo).toHaveBeenCalledTimes(2)
|
|
84
|
+
expect(result.current).toBe('present')
|
|
85
|
+
|
|
86
|
+
await advance(DESKTOP_AGENT_RETRY_INTERVAL_MS * 5)
|
|
87
|
+
expect(appInfo).toHaveBeenCalledTimes(2) // 就绪即停,无残余 timer
|
|
88
|
+
expect(result.current).toBe('present')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('agentPort 持续 null(未装配 / 旧壳无该字段)→ 重试到预算上限后 absent 并停止', async () => {
|
|
92
|
+
const appInfo = bridgeWith(async () => ({ ...BASE_INFO, agentPort: null, agentToken: null }))
|
|
93
|
+
const { result } = renderHook(() => useDesktopAgentPresence())
|
|
94
|
+
await advance(0)
|
|
95
|
+
expect(result.current).toBe('pending')
|
|
96
|
+
|
|
97
|
+
// 预算内一直 pending(入口尚未出现,但也没有被判死)
|
|
98
|
+
await advance(DESKTOP_AGENT_RETRY_BUDGET_MS - DESKTOP_AGENT_RETRY_INTERVAL_MS)
|
|
99
|
+
expect(result.current).toBe('pending')
|
|
100
|
+
|
|
101
|
+
await advance(DESKTOP_AGENT_RETRY_INTERVAL_MS)
|
|
102
|
+
expect(result.current).toBe('absent')
|
|
103
|
+
|
|
104
|
+
const calls = appInfo.mock.calls.length
|
|
105
|
+
expect(calls).toBe(DESKTOP_AGENT_RETRY_BUDGET_MS / DESKTOP_AGENT_RETRY_INTERVAL_MS + 1)
|
|
106
|
+
await advance(DESKTOP_AGENT_RETRY_INTERVAL_MS * 5)
|
|
107
|
+
expect(appInfo).toHaveBeenCalledTimes(calls) // 达上限即停
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('旧壳(appInfo 无 agent 字段)同样走重试 → 上限后 absent', async () => {
|
|
111
|
+
const appInfo = bridgeWith(async () => BASE_INFO)
|
|
112
|
+
const { result } = renderHook(() => useDesktopAgentPresence())
|
|
113
|
+
await advance(DESKTOP_AGENT_RETRY_BUDGET_MS)
|
|
114
|
+
expect(result.current).toBe('absent')
|
|
115
|
+
expect(appInfo).toHaveBeenCalledTimes(DESKTOP_AGENT_RETRY_BUDGET_MS / DESKTOP_AGENT_RETRY_INTERVAL_MS + 1)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('appInfo 持续抛错(IPC 断开 / main 未就绪)→ 重试到上限后 absent,不炸整页', async () => {
|
|
119
|
+
bridgeWith(async () => {
|
|
120
|
+
throw new Error('ipc closed')
|
|
121
|
+
})
|
|
122
|
+
const { result } = renderHook(() => useDesktopAgentPresence())
|
|
123
|
+
await advance(DESKTOP_AGENT_RETRY_BUDGET_MS)
|
|
124
|
+
expect(result.current).toBe('absent')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('重试窗口内 appInfo 抛错后恢复 → 下一次探测成功即 present', async () => {
|
|
128
|
+
const appInfo = vi
|
|
129
|
+
.fn<() => Promise<unknown>>()
|
|
130
|
+
.mockRejectedValueOnce(new Error('ipc closed'))
|
|
131
|
+
.mockResolvedValue({ ...BASE_INFO, agentPort: 4567, agentToken: 'tok' })
|
|
132
|
+
state.bridge = { appInfo }
|
|
133
|
+
const { result } = renderHook(() => useDesktopAgentPresence())
|
|
134
|
+
await advance(0)
|
|
135
|
+
expect(result.current).toBe('pending')
|
|
136
|
+
await advance(DESKTOP_AGENT_RETRY_INTERVAL_MS)
|
|
137
|
+
expect(result.current).toBe('present')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('重试中卸载 → 清理 timer,不再发起探测', async () => {
|
|
141
|
+
const appInfo = bridgeWith(async () => ({ ...BASE_INFO, agentPort: null }))
|
|
142
|
+
const { result, unmount } = renderHook(() => useDesktopAgentPresence())
|
|
143
|
+
await advance(0)
|
|
144
|
+
expect(appInfo).toHaveBeenCalledTimes(1)
|
|
145
|
+
unmount()
|
|
146
|
+
await advance(DESKTOP_AGENT_RETRY_INTERVAL_MS * 5)
|
|
147
|
+
expect(appInfo).toHaveBeenCalledTimes(1)
|
|
148
|
+
expect(result.current).toBe('pending')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('桌面构建但无桥(浏览器直开桌面 backend 端口)→ 终态 absent,不调 appInfo、不重试', async () => {
|
|
152
|
+
const appInfo = vi.fn(async () => ({ ...BASE_INFO, agentPort: 4567 }))
|
|
153
|
+
state.bridge = null
|
|
154
|
+
const { result } = renderHook(() => useDesktopAgentPresence())
|
|
155
|
+
await advance(0)
|
|
156
|
+
expect(result.current).toBe('absent')
|
|
157
|
+
await advance(DESKTOP_AGENT_RETRY_INTERVAL_MS * 3)
|
|
158
|
+
expect(appInfo).not.toHaveBeenCalled()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('非桌面构建(云 Web / OSS)即使注入了桥对象也 → 终态 absent(与路由 beforeLoad 404 一致,入口不成死链)', async () => {
|
|
162
|
+
state.desktopEdition = false
|
|
163
|
+
const appInfo = bridgeWith(async () => ({ ...BASE_INFO, agentPort: 4567 }))
|
|
164
|
+
const { result } = renderHook(() => useDesktopAgentPresence())
|
|
165
|
+
await advance(0)
|
|
166
|
+
expect(result.current).toBe('absent')
|
|
167
|
+
await advance(DESKTOP_AGENT_RETRY_INTERVAL_MS * 3)
|
|
168
|
+
expect(appInfo).not.toHaveBeenCalled()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('SSR / hydration 首帧恒 pending(不读 window,服务端与客户端首帧一致)', () => {
|
|
172
|
+
bridgeWith(async () => ({ ...BASE_INFO, agentPort: 4567 }))
|
|
173
|
+
let firstRender: string | null = null
|
|
174
|
+
function Probe() {
|
|
175
|
+
firstRender = useDesktopAgentPresence()
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
renderToString(<Probe />)
|
|
179
|
+
expect(firstRender).toBe('pending')
|
|
180
|
+
})
|
|
181
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// 桌面本地助手的运行时可用性判定(单一来源):入口门控、页面与卡片降级共用,
|
|
6
|
+
// 避免「侧栏入口 / 页面 / 卡片」三处各自判一套口径。
|
|
7
|
+
//
|
|
8
|
+
// 两层判定:
|
|
9
|
+
// - agent 可用(resolveDesktopAgentAvailability):桌面构建 × 存在 desktopBridge ×
|
|
10
|
+
// agent 已装配。第三项与 Pro main 侧 B4 广播契约同口径——`appInfo.agentPort`
|
|
11
|
+
// 为 null/undefined 表示 AgentService 尚未就绪(DESKTOP_AGENT 门控未开 / bundle
|
|
12
|
+
// 装配失败 / backend 启动窗口期),renderer 据此隐藏 agent 入口;否则入口可见
|
|
13
|
+
// 但页面打开后用户输入才报「服务未就绪」。
|
|
14
|
+
// 单次探测分三态:present 已就绪 / retry 桌面+有桥但端口未广播(**可重试**,
|
|
15
|
+
// 冷启动时 renderer 挂载早于 backend 广播约 3s,单次判定会把入口误判为不可用)/
|
|
16
|
+
// absent 终态不可用(非桌面构建 / 无桥,重试无意义)。
|
|
17
|
+
// - 目录授权可用(isAgentWorkspaceSupported):B1 起的三方法(pickDirectory /
|
|
18
|
+
// listWorkspaces / removeWorkspace)齐备,旧壳缺失 → 整页降级升级提示。
|
|
19
|
+
import { getDesktopBridge } from '@/lib/desktop-bridge-types'
|
|
20
|
+
import { isDesktopEdition } from '@/lib/edition-desktop'
|
|
21
|
+
|
|
22
|
+
export type DesktopAgentAvailability = 'present' | 'retry' | 'absent'
|
|
23
|
+
|
|
24
|
+
/** 单次探测(appInfo 经 IPC 取 main 侧端口广播)。appInfo 抛错(IPC 断开 / main
|
|
25
|
+
* 未就绪)与「端口未广播」同归 retry——重试由调用方(presence hook)做有界收敛,
|
|
26
|
+
* 到上限仍不就绪才落 absent。 */
|
|
27
|
+
export async function resolveDesktopAgentAvailability(): Promise<DesktopAgentAvailability> {
|
|
28
|
+
if (!isDesktopEdition()) return 'absent'
|
|
29
|
+
const bridge = getDesktopBridge()
|
|
30
|
+
if (!bridge) return 'absent'
|
|
31
|
+
const info = await bridge.appInfo().catch(() => null)
|
|
32
|
+
return typeof info?.agentPort === 'number' ? 'present' : 'retry'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 目录授权三方法齐备(B1 旧壳无此三方法);非桌面 / 无桥同样 false */
|
|
36
|
+
export function isAgentWorkspaceSupported(): boolean {
|
|
37
|
+
const bridge = getDesktopBridge()
|
|
38
|
+
return Boolean(bridge?.pickDirectory && bridge.listWorkspaces && bridge.removeWorkspace)
|
|
39
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// 本地文件助手的 agent 连接(桌面本地优先 P3-B5):useChat 的 connection 适配。
|
|
6
|
+
//
|
|
7
|
+
// 端点 = Pro desktop 域 AgentService(B4):backend utilityProcess 内 127.0.0.1
|
|
8
|
+
// 随机端口 `POST /prompt`(AG-UI SSE),须带 `X-Desktop-Token`。端口与令牌经
|
|
9
|
+
// desktopBridge.appInfo() 的 agentPort/agentToken 广播——backend 重启即换新值
|
|
10
|
+
// (main 侧 backend exit 清空),所以**每次请求时懒取**,不在组件顶层静态拼 URL。
|
|
11
|
+
//
|
|
12
|
+
// 为什么包一层而不是直接 fetchServerSentEvents(url, options):其 url 参数只接受
|
|
13
|
+
// 同步 getter,且在 connect() 内先于(可异步的)options 求值——URL 无法来自同一次
|
|
14
|
+
// 调用里 await 的 appInfo。包装只做「await appInfo → 构造 fetchServerSentEvents →
|
|
15
|
+
// 原样委托 connect」,SSE 解析/AG-UI 协议/abort 全部是官方实现,无自定义协议面。
|
|
16
|
+
//
|
|
17
|
+
// 请求线格式(与 Pro 端点的跨仓契约,见 agent-service.ts extractCwd/extractPrompt):
|
|
18
|
+
// cwd 由 useChat `forwardedProps: { cwd }` 进 RunAgentInput.forwardedProps;端点
|
|
19
|
+
// 取 messages 末条 user 消息为 prompt;X-Run-Id 头由官方客户端自动附带(端点预检已放行)。
|
|
20
|
+
import { fetchServerSentEvents } from '@tanstack/ai-client'
|
|
21
|
+
import type { ConnectConnectionAdapter } from '@tanstack/ai-client'
|
|
22
|
+
import { getDesktopBridge } from '@/lib/desktop-bridge-types'
|
|
23
|
+
|
|
24
|
+
export interface DesktopAgentEndpoint {
|
|
25
|
+
/** `http://127.0.0.1:{agentPort}/prompt` */
|
|
26
|
+
url: string
|
|
27
|
+
/** X-Desktop-Token 访问令牌(AgentService 每次启动随机生成) */
|
|
28
|
+
token: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** agent 端点不可用:无桥(云 Web / OSS)、旧壳(appInfo 无 agent 字段)、或
|
|
32
|
+
* agent 未装配(DESKTOP_AGENT 门控未开 / bundle 装配失败 → agentPort null) */
|
|
33
|
+
export class DesktopAgentUnavailableError extends Error {
|
|
34
|
+
constructor() {
|
|
35
|
+
super('desktop agent endpoint unavailable')
|
|
36
|
+
this.name = 'DesktopAgentUnavailableError'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 调用时读取当前 agent 端点;不可用返回 null(端口/令牌非法同样视为不可用,不拼坏 URL) */
|
|
41
|
+
export async function resolveDesktopAgentEndpoint(): Promise<DesktopAgentEndpoint | null> {
|
|
42
|
+
const bridge = getDesktopBridge()
|
|
43
|
+
if (!bridge) return null
|
|
44
|
+
// main 未就绪 / IPC 断开时 appInfo 会抛错:归「服务未就绪」(不可用 → 上层抛
|
|
45
|
+
// DesktopAgentUnavailableError),不把原生错误原文当「运行失败:<原文>」透出
|
|
46
|
+
const info = await bridge.appInfo().catch(() => null)
|
|
47
|
+
if (!info) return null
|
|
48
|
+
const { agentPort, agentToken } = info
|
|
49
|
+
if (typeof agentPort !== 'number' || !Number.isInteger(agentPort) || agentPort < 1 || agentPort > 65535) {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
if (typeof agentToken !== 'string' || agentToken === '') return null
|
|
53
|
+
return { url: `http://127.0.0.1:${agentPort}/prompt`, token: agentToken }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* useChat 的 connection:每次 connect 懒取端点后委托官方 fetchServerSentEvents。
|
|
58
|
+
* `resolveEndpoint` 可注入(测试 / 未来多端点),缺省读 desktopBridge.appInfo。
|
|
59
|
+
*/
|
|
60
|
+
export function createDesktopAgentConnection(
|
|
61
|
+
resolveEndpoint: () => Promise<DesktopAgentEndpoint | null> = resolveDesktopAgentEndpoint,
|
|
62
|
+
): ConnectConnectionAdapter {
|
|
63
|
+
return {
|
|
64
|
+
async *connect(messages, data, abortSignal, runContext) {
|
|
65
|
+
const endpoint = await resolveEndpoint()
|
|
66
|
+
if (!endpoint) throw new DesktopAgentUnavailableError()
|
|
67
|
+
yield* fetchServerSentEvents(endpoint.url, {
|
|
68
|
+
headers: { 'X-Desktop-Token': endpoint.token },
|
|
69
|
+
}).connect(messages, data, abortSignal, runContext)
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -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):把 useChat 的 error 态翻译成
|
|
6
|
+
// 用户可读的 i18n 文案。三类可归因错误:
|
|
7
|
+
// - 端点不可用(DesktopAgentUnavailableError):无桥 / 旧壳 / agent 未装配
|
|
8
|
+
// - 端点非 2xx(@tanstack/ai-client StreamHttpError,带 status):401 令牌失效
|
|
9
|
+
// (backend 重启换了令牌,下一问懒取即恢复)、403 目录未授权或 origin 被拒
|
|
10
|
+
// - 网络层(StreamReadError):端口不通、CORS 预检被拒(浏览器只给 TypeError)
|
|
11
|
+
// 其余(RUN_ERROR 的 agent 失败原因、400/5xx 等)原文透出,便于排障。
|
|
12
|
+
import { m } from '@/paraglide/messages.js'
|
|
13
|
+
|
|
14
|
+
export function describeDesktopAgentError(error: Error): string {
|
|
15
|
+
if (error.name === 'DesktopAgentUnavailableError') return m.agent_desktop_errorUnavailable()
|
|
16
|
+
const status = (error as { status?: unknown }).status
|
|
17
|
+
if (status === 401) return m.agent_desktop_errorUnauthorized()
|
|
18
|
+
if (status === 403) return m.agent_desktop_errorForbidden()
|
|
19
|
+
if (error.name === 'StreamReadError') return m.agent_desktop_errorConnection()
|
|
20
|
+
return m.agent_desktop_errorGeneric({ message: error.message || String(error) })
|
|
21
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// 本地文件助手工具结果解析(桌面本地优先 P3-B5):把 pi 只读四件套
|
|
6
|
+
// (read / grep / find / ls)的结果整理成「文件名 / 命中行」条目,供折叠卡片渲染。
|
|
7
|
+
//
|
|
8
|
+
// 数据来路:B3 mapper 产出 TOOL_CALL_RESULT.content = JSON.stringify(pi
|
|
9
|
+
// AgentToolResult = { content: [{ type: 'text', text }], details });TanStack
|
|
10
|
+
// processor 收到后 JSON.parse 挂到 tool-call part.output(tool-result part 另存
|
|
11
|
+
// 原串,作回落)。文本格式按 pi-coding-agent 1.4 源码(dist/core/tools/*.js):
|
|
12
|
+
// - grep:命中行 `path:N: text`,上下文行 `path-N- text`(不计命中)
|
|
13
|
+
// - ls:一行一个条目,目录带 `/` 后缀;find:一行一个相对路径
|
|
14
|
+
// - read:文件原文
|
|
15
|
+
// - 尾段 `\n\n[...]` 是 pi 的截断/续读提示(limit reached / Use offset= …)
|
|
16
|
+
// - 空结果哨兵:`No matches found` / `(empty directory)` / `No files found matching pattern`
|
|
17
|
+
|
|
18
|
+
export type AgentToolKind = 'read' | 'grep' | 'find' | 'ls' | 'other'
|
|
19
|
+
|
|
20
|
+
export type AgentToolEntry =
|
|
21
|
+
/** ls / find:文件或目录(目录保留 `/` 后缀) */
|
|
22
|
+
| { kind: 'file'; path: string }
|
|
23
|
+
/** grep:命中行 */
|
|
24
|
+
| { kind: 'hit'; path: string; line: number; text: string }
|
|
25
|
+
/** read 内容行 / 未知工具文本行 */
|
|
26
|
+
| { kind: 'text'; text: string }
|
|
27
|
+
|
|
28
|
+
export interface AgentToolSummary {
|
|
29
|
+
kind: AgentToolKind
|
|
30
|
+
/** 工具目标:read/ls 的路径(ls 缺省 `.`)、grep/find 的模式;未知工具 null */
|
|
31
|
+
target: string | null
|
|
32
|
+
/** 展示条目(截至 AGENT_TOOL_MAX_ENTRIES) */
|
|
33
|
+
entries: AgentToolEntry[]
|
|
34
|
+
/** 条目总数(截断前) */
|
|
35
|
+
total: number
|
|
36
|
+
/** pi 侧结果已截断(尾段提示存在) */
|
|
37
|
+
truncated: boolean
|
|
38
|
+
/** pi 空结果哨兵(无匹配 / 空目录 / 无文件) */
|
|
39
|
+
empty: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 折叠卡片单个工具最多展示的条目数(其余以「还有 N 项」提示) */
|
|
43
|
+
export const AGENT_TOOL_MAX_ENTRIES = 50
|
|
44
|
+
|
|
45
|
+
const EMPTY_SENTINELS = new Set(['No matches found', '(empty directory)', 'No files found matching pattern'])
|
|
46
|
+
|
|
47
|
+
/** pi 尾段提示的措辞特征——只认这些,正文恰以方括号结尾时不误剥 */
|
|
48
|
+
const PI_NOTICE_RE = /^\[[^\n]*(?:limit reached|Use offset=|more lines in file|Showing lines|exceeds)[^\n]*\]$/
|
|
49
|
+
|
|
50
|
+
/** grep 命中行:取首个 `:行号: ` 边界(惰性),内容里再出现 `:3: ` 也不误切 */
|
|
51
|
+
const GREP_HIT_RE = /^(.+?):(\d+): (.*)$/
|
|
52
|
+
|
|
53
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
54
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function tryParseJson(raw: string): unknown {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(raw)
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function textFromParts(parts: readonly unknown[]): string | null {
|
|
66
|
+
const texts: string[] = []
|
|
67
|
+
for (const part of parts) {
|
|
68
|
+
if (!isPlainObject(part) || part.type !== 'text') continue
|
|
69
|
+
if (typeof part.text === 'string') texts.push(part.text)
|
|
70
|
+
else if (typeof part.content === 'string') texts.push(part.content)
|
|
71
|
+
}
|
|
72
|
+
return texts.length > 0 ? texts.join('\n') : null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function textFromValue(value: unknown): string | null {
|
|
76
|
+
if (typeof value === 'string') {
|
|
77
|
+
// processor 对非 JSON content 保留原串;JSON 串(pi AgentToolResult)再解一层
|
|
78
|
+
const parsed = tryParseJson(value)
|
|
79
|
+
// 解出的对象/数组里没有 text 块(如 read 图片 → 只有 base64 image 块):判定为
|
|
80
|
+
// 无文本,**不**回落原始 JSON 串(否则巨型 base64 会整段铺进折叠卡)
|
|
81
|
+
if (parsed !== null && typeof parsed === 'object') return textFromValue(parsed)
|
|
82
|
+
return value
|
|
83
|
+
}
|
|
84
|
+
if (Array.isArray(value)) {
|
|
85
|
+
// tool-result part 回落形态:ContentPart[] 内嵌 JSON 串
|
|
86
|
+
const joined = textFromParts(value)
|
|
87
|
+
return joined === null ? null : textFromValue(joined)
|
|
88
|
+
}
|
|
89
|
+
if (isPlainObject(value)) {
|
|
90
|
+
if (Array.isArray(value.content)) return textFromParts(value.content)
|
|
91
|
+
if (typeof value.error === 'string') return value.error
|
|
92
|
+
}
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 工具结果文本:优先 tool-call part.output(已解析对象),回落 tool-result part 内容 */
|
|
97
|
+
export function extractAgentToolText(output: unknown, resultContent?: unknown): string | null {
|
|
98
|
+
return textFromValue(output) ?? textFromValue(resultContent)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 工具参数:优先已解析 input;否则解析 arguments 串(流式未完成的 JSON → 空对象) */
|
|
102
|
+
export function parseAgentToolArgs(part: { input?: unknown; arguments?: string }): Record<string, unknown> {
|
|
103
|
+
if (isPlainObject(part.input)) return part.input
|
|
104
|
+
const parsed = tryParseJson(part.arguments ?? '')
|
|
105
|
+
return isPlainObject(parsed) ? parsed : {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function toolKindOf(name: string): AgentToolKind {
|
|
109
|
+
return name === 'read' || name === 'grep' || name === 'find' || name === 'ls' ? name : 'other'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function stringArg(value: unknown): string | null {
|
|
113
|
+
return typeof value === 'string' && value !== '' ? value : null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function targetOf(kind: AgentToolKind, args: Record<string, unknown>): string | null {
|
|
117
|
+
switch (kind) {
|
|
118
|
+
case 'grep':
|
|
119
|
+
case 'find':
|
|
120
|
+
return stringArg(args.pattern)
|
|
121
|
+
case 'read':
|
|
122
|
+
return stringArg(args.path)
|
|
123
|
+
case 'ls':
|
|
124
|
+
return stringArg(args.path) ?? '.'
|
|
125
|
+
default:
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 剥离 pi 尾段提示:返回正文与是否截断 */
|
|
131
|
+
function splitNotice(text: string): { body: string; truncated: boolean } {
|
|
132
|
+
const trimmed = text.replace(/\s+$/, '')
|
|
133
|
+
if (PI_NOTICE_RE.test(trimmed)) return { body: '', truncated: true }
|
|
134
|
+
const cut = trimmed.lastIndexOf('\n\n')
|
|
135
|
+
if (cut !== -1 && PI_NOTICE_RE.test(trimmed.slice(cut + 2))) {
|
|
136
|
+
return { body: trimmed.slice(0, cut), truncated: true }
|
|
137
|
+
}
|
|
138
|
+
return { body: trimmed, truncated: false }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function entriesOf(kind: AgentToolKind, lines: string[]): AgentToolEntry[] {
|
|
142
|
+
switch (kind) {
|
|
143
|
+
case 'grep':
|
|
144
|
+
return lines.flatMap((line): AgentToolEntry[] => {
|
|
145
|
+
const hit = GREP_HIT_RE.exec(line)
|
|
146
|
+
return hit ? [{ kind: 'hit', path: hit[1]!, line: Number(hit[2]), text: hit[3]! }] : []
|
|
147
|
+
})
|
|
148
|
+
case 'ls':
|
|
149
|
+
case 'find':
|
|
150
|
+
return lines.filter((line) => line.trim() !== '').map((path) => ({ kind: 'file', path }))
|
|
151
|
+
default:
|
|
152
|
+
return lines.map((text) => ({ kind: 'text', text }))
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 汇总一次工具调用:目标 + 截断后的展示条目 + 计数/截断/空结果标记 */
|
|
157
|
+
export function summarizeAgentTool(
|
|
158
|
+
name: string,
|
|
159
|
+
args: Record<string, unknown>,
|
|
160
|
+
text: string | null,
|
|
161
|
+
): AgentToolSummary {
|
|
162
|
+
const kind = toolKindOf(name)
|
|
163
|
+
const target = targetOf(kind, args)
|
|
164
|
+
const base = { kind, target, entries: [], total: 0, truncated: false, empty: false }
|
|
165
|
+
if (text === null) return base
|
|
166
|
+
if (EMPTY_SENTINELS.has(text.trim())) return { ...base, empty: true }
|
|
167
|
+
const { body, truncated } = splitNotice(text)
|
|
168
|
+
const all = body === '' ? [] : entriesOf(kind, body.split('\n'))
|
|
169
|
+
return { ...base, entries: all.slice(0, AGENT_TOOL_MAX_ENTRIES), total: all.length, truncated }
|
|
170
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// 本地文件助手入口门控(桌面本地优先 P3-B5):路由页(_nav/agent lazy 组件)与
|
|
6
|
+
// 侧栏入口共用同一判定,保证「入口可见 ⟹ 页面可达」。
|
|
7
|
+
//
|
|
8
|
+
// 判定 = 桌面构建(isDesktopEdition:VITE_DEPLOY_TARGET 构建期常量,与路由
|
|
9
|
+
// beforeLoad 同口径)× 存在 desktopBridge(Electron preload 注入)× agent 已装配
|
|
10
|
+
// (appInfo.agentPort 为数字,见 ./availability)。云 Web / OSS 构建恒 absent
|
|
11
|
+
// (effect 体被常量折叠);桌面构建被浏览器直开(无桥)也 absent。
|
|
12
|
+
//
|
|
13
|
+
// 有界重试(冷启动窗口):Electron 冷启动时 renderer 先挂载、backend 约 3s 后才
|
|
14
|
+
// 广播 agentPort——单次探测会把窗口期误判为 absent,使入口在整次会话内消失(要等
|
|
15
|
+
// 导航重挂载/重启)。故「桌面构建 + 有桥 + agentPort 未广播(或 appInfo 抛错)」
|
|
16
|
+
// 按 1s 间隔重试,累计约 20s;一旦拿到端口立即 present 并停止;到上限仍不就绪
|
|
17
|
+
// 才落 absent 并停止。非桌面构建 / 无桥是终态,不重试。卸载清 timer,无泄漏。
|
|
18
|
+
//
|
|
19
|
+
// mounted 模式(与 sync-indicator / cloud-login 同构):首帧恒 'pending'——SSR
|
|
20
|
+
// 无 window,hydration 首帧必须与服务端输出一致,挂载后再经 IPC 读桥;重试期间
|
|
21
|
+
// 同样保持 'pending'(消费者按加载态渲染,不提前报「不可用」)。
|
|
22
|
+
import { useEffect, useState } from 'react'
|
|
23
|
+
import { resolveDesktopAgentAvailability } from '@/lib/desktop-agent/availability'
|
|
24
|
+
|
|
25
|
+
export type DesktopAgentPresence = 'pending' | 'present' | 'absent'
|
|
26
|
+
|
|
27
|
+
/** 重试间隔与总预算(固定间隔,累计约 20s 覆盖 backend 冷启动窗口) */
|
|
28
|
+
export const DESKTOP_AGENT_RETRY_INTERVAL_MS = 1000
|
|
29
|
+
export const DESKTOP_AGENT_RETRY_BUDGET_MS = 20_000
|
|
30
|
+
|
|
31
|
+
export function useDesktopAgentPresence(): DesktopAgentPresence {
|
|
32
|
+
const [presence, setPresence] = useState<DesktopAgentPresence>('pending')
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
let cancelled = false
|
|
35
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
36
|
+
let spent = 0
|
|
37
|
+
|
|
38
|
+
const probe = async () => {
|
|
39
|
+
const availability = await resolveDesktopAgentAvailability()
|
|
40
|
+
if (cancelled) return
|
|
41
|
+
// present / absent 都是终态(absent = 非桌面构建或无桥),停止探测
|
|
42
|
+
if (availability !== 'retry') {
|
|
43
|
+
setPresence(availability)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
if (spent >= DESKTOP_AGENT_RETRY_BUDGET_MS) {
|
|
47
|
+
setPresence('absent')
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
spent += DESKTOP_AGENT_RETRY_INTERVAL_MS
|
|
51
|
+
timer = setTimeout(() => {
|
|
52
|
+
timer = null
|
|
53
|
+
void probe()
|
|
54
|
+
}, DESKTOP_AGENT_RETRY_INTERVAL_MS)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
void probe()
|
|
58
|
+
return () => {
|
|
59
|
+
cancelled = true
|
|
60
|
+
if (timer !== null) clearTimeout(timer)
|
|
61
|
+
}
|
|
62
|
+
}, [])
|
|
63
|
+
return presence
|
|
64
|
+
}
|
|
@@ -5,8 +5,23 @@
|
|
|
5
5
|
// 桌面端类型声明(迁移 P5.3/P6):desktopBridge 桥(Electron preload 暴露)。
|
|
6
6
|
// 仅类型——运行时对象由 Electron preload 注入,浏览器环境无此对象。
|
|
7
7
|
export interface DesktopBridgeApi {
|
|
8
|
-
|
|
8
|
+
/** B4 起追加 agentPort/agentToken(本地文件助手 SSE 端点;add-only,不改既有
|
|
9
|
+
* 字段):backend utilityProcess 装配 AgentService 后随 appInfo 广播——
|
|
10
|
+
* agentPort = 127.0.0.1 随机端口,agentToken = 启动时生成的访问令牌
|
|
11
|
+
* (请求须带 X-Desktop-Token 头)。null = agent 未装配(旧壳,或 DESKTOP_AGENT
|
|
12
|
+
* 门控未开/bundle 装配失败降级),renderer 据此隐藏 agent 入口。 */
|
|
13
|
+
appInfo: () => Promise<{
|
|
14
|
+
version: string
|
|
15
|
+
clientId: string
|
|
16
|
+
platform: string
|
|
17
|
+
backendPort: number | null
|
|
18
|
+
agentPort?: number | null
|
|
19
|
+
agentToken?: string | null
|
|
20
|
+
}>
|
|
9
21
|
cloudLogin: (email: string, password: string) => Promise<{ ok: boolean; userId?: string; error?: string }>
|
|
22
|
+
/** A10 轻量云身份激活:登录成功后取 cloud-session.json 解出的纯 session
|
|
23
|
+
* token(renderer 再走 exchangeRemoteTokenFn 换权益并写 remote-auth)。 */
|
|
24
|
+
exchangeCloudSession: () => Promise<{ ok: boolean; token?: string; error?: string }>
|
|
10
25
|
conflictRpc: (
|
|
11
26
|
method: "list" | "applyLocal" | "keepRemote" | "keepBoth",
|
|
12
27
|
...args: unknown[]
|
|
@@ -18,6 +33,48 @@ export interface DesktopBridgeApi {
|
|
|
18
33
|
onRenamed: (cb: (info: { table: string; from: string; to: string }) => void) => () => void
|
|
19
34
|
/** macOS 全屏态切换(enter/leave-full-screen);旧壳无此 API 时 undefined */
|
|
20
35
|
onFullscreenChange?: (cb: (fullscreen: boolean) => void) => () => void
|
|
36
|
+
/** A8b 归属转移 bind-flow(main 侧阶段编排;本段为 add-only 追加,不改既有段落):
|
|
37
|
+
* - bindBegin:停引擎自动 tick(bind 锁文件)+ PGlite 数据目录文件级快照;
|
|
38
|
+
* - bindDone:清快照 + 解锁 + 请求 backend 触发一次 push;
|
|
39
|
+
* - bindFail:停 backend → 还原快照 → 重启 backend(恢复 guest 态)+ 解锁。
|
|
40
|
+
* 转移 serverFn(transferOwnershipFn)由 renderer 自行调用(localhost cookie
|
|
41
|
+
* 过鉴权),不经本桥。旧壳(无此三方法)undefined。 */
|
|
42
|
+
bindBegin?: () => Promise<{ ok: boolean; error?: string }>
|
|
43
|
+
bindDone?: () => Promise<{ ok: boolean; error?: string }>
|
|
44
|
+
bindFail?: () => Promise<{ ok: boolean; error?: string }>
|
|
45
|
+
/** A9 登出反向转移(本段为 add-only 追加,不改既有段落):transferLock 置/清
|
|
46
|
+
* 转移锁——复用 bind 锁文件(引擎每 tick 判定同源,语义「转移进行中,引擎勿
|
|
47
|
+
* tick」);clearCloudSession 清 cloud-session.json(登出后引擎 guest 守卫回
|
|
48
|
+
* idle)。旧壳无此二方法 undefined——renderer 优雅降级(反向转移单事务自身
|
|
49
|
+
* 原子,锁为防 tick 并发的双保险;旧壳无云会话文件可清,调用方 optional-
|
|
50
|
+
* chaining)。 */
|
|
51
|
+
transferLock?: (action: "begin" | "end") => Promise<{ ok: boolean; error?: string }>
|
|
52
|
+
clearCloudSession?: () => Promise<{ ok: boolean; error?: string }>
|
|
53
|
+
/** A11 desktop-state 三态(本段为 add-only 追加,不改既有段落):main 侧状态机
|
|
54
|
+
* (guest | transferring | cloud,数据属主视角)的查询 + 推送双面。查询用于
|
|
55
|
+
* 页面 reload 后补齐错过的推送事件;推送由 main 侧 bind/登出流 dispatch 时
|
|
56
|
+
* 发送。旧壳(无此二方法)undefined——徽标与缓存失效监听 optional-chaining
|
|
57
|
+
* 优雅降级。 */
|
|
58
|
+
desktopState?: () => Promise<DesktopStatePayload>
|
|
59
|
+
onDesktopState?: (cb: (payload: DesktopStatePayload) => void) => () => void
|
|
60
|
+
/** B1 目录授权(本地优先 P3 agent,本段为 add-only 追加,不改既有段落):
|
|
61
|
+
* agent 访问本地文件的授权清单(main 侧 userData/agent/workspaces.json 增删
|
|
62
|
+
* 查)。授权模型:renderer 不能注入任意路径——唯一添加入口是用户经原生目录
|
|
63
|
+
* 选择对话框拣选(pickDirectory 成功即写入清单,返回规范化绝对路径;取消返
|
|
64
|
+
* 回 null),removeWorkspace 撤销授权、listWorkspaces 查询。旧壳(无此三方
|
|
65
|
+
* 法)undefined——agent 文件面调用方 optional-chaining 优雅降级。 */
|
|
66
|
+
pickDirectory?: () => Promise<string | null>
|
|
67
|
+
listWorkspaces?: () => Promise<string[]>
|
|
68
|
+
removeWorkspace?: (path: string) => Promise<void>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A11 desktop-state 三态载荷(main 侧 desktop-state.json 持久化态的广播形状):
|
|
72
|
+
* guest 本地访客属主 / transferring 归属转移进行中 / cloud 云账号属主。 */
|
|
73
|
+
export type DesktopStateKind = "guest" | "transferring" | "cloud"
|
|
74
|
+
|
|
75
|
+
export interface DesktopStatePayload {
|
|
76
|
+
kind: DesktopStateKind
|
|
77
|
+
updatedAt: string
|
|
21
78
|
}
|
|
22
79
|
|
|
23
80
|
declare global {
|
|
@@ -23,8 +23,8 @@ import { appHotkeys, findAppHotkeyConflicts, formatAppHotkeyForBadge, CATEGORY_O
|
|
|
23
23
|
const DECLARED_IDS = Object.keys(appHotkeys) as AppHotkeyId[]
|
|
24
24
|
|
|
25
25
|
describe("appHotkeys(defineAppHotkeys 官方声明)", () => {
|
|
26
|
-
it("恰好
|
|
27
|
-
expect(DECLARED_IDS).toHaveLength(
|
|
26
|
+
it("恰好 26 条声明,且 id 与迁移前注册表对齐(+ B5 agent-abort)", () => {
|
|
27
|
+
expect(DECLARED_IDS).toHaveLength(26)
|
|
28
28
|
expect(DECLARED_IDS).toEqual(
|
|
29
29
|
expect.arrayContaining([
|
|
30
30
|
"shortcuts-panel",
|
|
@@ -34,6 +34,7 @@ describe("appHotkeys(defineAppHotkeys 官方声明)", () => {
|
|
|
34
34
|
"copy-message",
|
|
35
35
|
"chat-prev",
|
|
36
36
|
"chat-next",
|
|
37
|
+
"agent-abort",
|
|
37
38
|
"command-palette",
|
|
38
39
|
"toggle-theme",
|
|
39
40
|
"goto-dashboard",
|
|
@@ -82,9 +83,15 @@ describe("appHotkeys(defineAppHotkeys 官方声明)", () => {
|
|
|
82
83
|
}
|
|
83
84
|
})
|
|
84
85
|
|
|
85
|
-
it("非序列条目为
|
|
86
|
+
it("非序列条目为 10 条 chord(Mod+/ 等)", () => {
|
|
86
87
|
const chords = DECLARED_IDS.map((id) => appHotkeys[id]!).filter((e) => typeof e.hotkey === "string")
|
|
87
|
-
expect(chords).toHaveLength(
|
|
88
|
+
expect(chords).toHaveLength(10)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("agent-abort(本地文件助手中止运行):Mod+. 聊天分类——输入框内同样生效(ignoreInputs auto 对 Mod 组合放行)", () => {
|
|
92
|
+
const entry = appHotkeys["agent-abort"]
|
|
93
|
+
expect(entry.hotkey).toBe("Mod+.")
|
|
94
|
+
expect(entry.meta).toEqual({ descriptionKey: "common_hotkey_desc_agentAbort", category: "聊天" })
|
|
88
95
|
})
|
|
89
96
|
|
|
90
97
|
it("现网声明零冲突(findAppHotkeyConflicts)", () => {
|