@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.
- package/package.json +3 -3
- package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +2 -0
- package/src/components/desktop/__tests__/cloud-login-rollback.test.tsx +161 -0
- package/src/components/desktop/cloud-login.tsx +29 -26
- 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/app-sidebar.tsx +9 -2
- package/src/config/sidebar-routes.tsx +14 -1
- 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 +22 -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/paraglide/messages/_index.d.ts +42 -0
- package/src/paraglide/messages/_index.js +42 -0
- package/src/paraglide/messages/agent_desktop_chatcwd1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_chatcwd1.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatemptyhint2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatemptyhint2.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatemptytitle2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatemptytitle2.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatindependenthint2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatindependenthint2.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatnoworkspace2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatnoworkspace2.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatplaceholder1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatplaceholder1.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatsend1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatsend1.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatstop1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatstop1.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatthinking1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatthinking1.js +30 -0
- package/src/paraglide/messages/agent_desktop_errorconnection1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_errorconnection1.js +30 -0
- package/src/paraglide/messages/agent_desktop_errorforbidden1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_errorforbidden1.js +30 -0
- package/src/paraglide/messages/agent_desktop_errorgeneric1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_errorgeneric1.js +30 -0
- package/src/paraglide/messages/agent_desktop_errorunauthorized1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_errorunauthorized1.js +30 -0
- package/src/paraglide/messages/agent_desktop_errorunavailable1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_errorunavailable1.js +30 -0
- package/src/paraglide/messages/agent_desktop_pagedescription1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_pagedescription1.js +30 -0
- package/src/paraglide/messages/agent_desktop_title.d.ts +16 -0
- package/src/paraglide/messages/agent_desktop_title.js +29 -0
- package/src/paraglide/messages/agent_desktop_toolcountfiles2.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolcountfiles2.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolcounthits2.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolcounthits2.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolcountlines2.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolcountlines2.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolemptydir2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_toolemptydir2.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolerror1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_toolerror1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolfind1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolfind1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolgrep1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolgrep1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolls1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolls1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolmore1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolmore1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolnomatches2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_toolnomatches2.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolother1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolother1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolread1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolread1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolrunning1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_toolrunning1.js +30 -0
- package/src/paraglide/messages/agent_desktop_tooltruncated1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_tooltruncated1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceadd1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceadd1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceaddfailed2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceaddfailed2.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspacedescription1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspacedescription1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceempty1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceempty1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceemptyhint2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceemptyhint2.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceloadfailed2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceloadfailed2.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceremove1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceremove1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceremovefailed2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceremovefailed2.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspacetitle1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspacetitle1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceunsupported1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceunsupported1.js +30 -0
- package/src/paraglide/messages/common_hotkey_desc_agentabort1.d.ts +17 -0
- package/src/paraglide/messages/common_hotkey_desc_agentabort1.js +30 -0
- package/src/paraglide/messages/sync_bind_rolled_back.d.ts +16 -0
- package/src/paraglide/messages/sync_bind_rolled_back.js +29 -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 +4 -2
- package/src/routes/api/sync/push.ts +11 -3
- package/src/server/__tests__/desktop-cloud-identity.test.ts +331 -0
- package/src/server/__tests__/desktop-session-merge.test.ts +42 -0
- package/src/server/__tests__/exchange-remote-token.test.ts +92 -10
- package/src/server/__tests__/remote-auth.test.ts +39 -0
- package/src/server/desktop-bootstrap.ts +16 -5
- package/src/server/desktop-cloud-identity.ts +247 -0
- package/src/server/remote-auth.ts +33 -1
- package/src/server/serverFns/auth/session.ts +27 -2
- package/src/server/serverFns/sync/transfer.ts +28 -27
- package/src/server/serverFns/workspace-kb.ts +10 -0
- package/src/server/sync/__tests__/ownership-transfer.test.ts +261 -48
- package/src/server/sync/__tests__/sync-first-push.e2e.test.ts +349 -0
- package/src/server/sync/account-status-cloud.ts +41 -0
- package/src/server/sync/ownership-transfer.ts +88 -49
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// desktop-agent 工具结果解析(桌面本地优先 P3-B5):TOOL_CALL_RESULT.content =
|
|
6
|
+
// JSON.stringify(pi AgentToolResult)(B3 mapper),TanStack processor JSON.parse 后
|
|
7
|
+
// 挂到 tool-call part.output。本测试按 pi 1.4 只读四件套的真实文本格式取证——
|
|
8
|
+
// grep `path:N: text`(上下文行 `path-N- text`)、ls 目录带 `/`、find 相对路径、
|
|
9
|
+
// read 原文;尾部 `\n\n[...limit reached / Use offset=...]` 截断提示;空结果哨兵。
|
|
10
|
+
import { describe, expect, it } from 'vitest'
|
|
11
|
+
import {
|
|
12
|
+
AGENT_TOOL_MAX_ENTRIES,
|
|
13
|
+
extractAgentToolText,
|
|
14
|
+
parseAgentToolArgs,
|
|
15
|
+
summarizeAgentTool,
|
|
16
|
+
} from '../tool-result'
|
|
17
|
+
|
|
18
|
+
const piResult = (text: string) => ({ content: [{ type: 'text', text }], details: undefined })
|
|
19
|
+
|
|
20
|
+
describe('extractAgentToolText', () => {
|
|
21
|
+
it('pi AgentToolResult 对象(processor 已 JSON.parse)→ 文本块拼接', () => {
|
|
22
|
+
expect(extractAgentToolText(piResult('a.txt\nsrc/'))).toBe('a.txt\nsrc/')
|
|
23
|
+
expect(
|
|
24
|
+
extractAgentToolText({ content: [{ type: 'text', text: 'L1' }, { type: 'image', data: 'x' }, { type: 'text', text: 'L2' }] }),
|
|
25
|
+
).toBe('L1\nL2')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('output 缺席时回落 tool-result part 内容(字符串或 ContentPart[] 内嵌 JSON)', () => {
|
|
29
|
+
const json = JSON.stringify(piResult('hello'))
|
|
30
|
+
expect(extractAgentToolText(undefined, json)).toBe('hello')
|
|
31
|
+
expect(extractAgentToolText(undefined, [{ type: 'text', content: json }])).toBe('hello')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('非 JSON 文本 / processor 错误对象 / 空值', () => {
|
|
35
|
+
expect(extractAgentToolText('plain text')).toBe('plain text')
|
|
36
|
+
expect(extractAgentToolText({ error: '读取失败' })).toBe('读取失败')
|
|
37
|
+
expect(extractAgentToolText(undefined, undefined)).toBeNull()
|
|
38
|
+
expect(extractAgentToolText({ unrelated: true })).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('无 text 块的对象(read 图片:仅 base64 image 块)→ null,不回落原始 JSON 串', () => {
|
|
42
|
+
const base64 = 'iVBORw0KGgoAAAANSUhEUg=='
|
|
43
|
+
const imageOnly = { content: [{ type: 'image', mimeType: 'image/png', data: base64 }], details: undefined }
|
|
44
|
+
// processor 已解析的对象形态
|
|
45
|
+
expect(extractAgentToolText(imageOnly)).toBeNull()
|
|
46
|
+
// tool-result part 回落形态:JSON 串 / ContentPart[] 内嵌 JSON 串
|
|
47
|
+
expect(extractAgentToolText(undefined, JSON.stringify(imageOnly))).toBeNull()
|
|
48
|
+
expect(extractAgentToolText(undefined, [{ type: 'text', content: JSON.stringify(imageOnly) }])).toBeNull()
|
|
49
|
+
// 摘要侧同样无条目(不会把 base64 铺进折叠卡)
|
|
50
|
+
expect(summarizeAgentTool('read', { path: 'logo.png' }, extractAgentToolText(imageOnly))).toMatchObject({
|
|
51
|
+
entries: [],
|
|
52
|
+
total: 0,
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('parseAgentToolArgs', () => {
|
|
58
|
+
it('优先已解析 input;否则解析 arguments 字符串;流式不完整 JSON → 空对象', () => {
|
|
59
|
+
expect(parseAgentToolArgs({ input: { path: 'src' }, arguments: '{"path":"x"}' })).toEqual({ path: 'src' })
|
|
60
|
+
expect(parseAgentToolArgs({ arguments: '{"pattern":"TODO"}' })).toEqual({ pattern: 'TODO' })
|
|
61
|
+
expect(parseAgentToolArgs({ arguments: '{"pattern":"TO' })).toEqual({})
|
|
62
|
+
expect(parseAgentToolArgs({ arguments: '' })).toEqual({})
|
|
63
|
+
expect(parseAgentToolArgs({ arguments: '[1,2]' })).toEqual({})
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('summarizeAgentTool', () => {
|
|
68
|
+
it('grep:命中行解析为 文件/行号/内容,上下文行与截断提示不计入,提示置 truncated', () => {
|
|
69
|
+
const text = [
|
|
70
|
+
'src/a.ts:12: const TODO = 1',
|
|
71
|
+
'src/a.ts-13- context line',
|
|
72
|
+
'docs/b.md:3: TODO: write docs',
|
|
73
|
+
'',
|
|
74
|
+
'[100 matches limit reached. Use limit=200 for more, or refine pattern]',
|
|
75
|
+
].join('\n')
|
|
76
|
+
const s = summarizeAgentTool('grep', { pattern: 'TODO' }, text)
|
|
77
|
+
expect(s.kind).toBe('grep')
|
|
78
|
+
expect(s.target).toBe('TODO')
|
|
79
|
+
expect(s.entries).toEqual([
|
|
80
|
+
{ kind: 'hit', path: 'src/a.ts', line: 12, text: 'const TODO = 1' },
|
|
81
|
+
{ kind: 'hit', path: 'docs/b.md', line: 3, text: 'TODO: write docs' },
|
|
82
|
+
])
|
|
83
|
+
expect(s.total).toBe(2)
|
|
84
|
+
expect(s.truncated).toBe(true)
|
|
85
|
+
expect(s.empty).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('grep:路径含冒号 / 内容含 `:N: ` 均不误切(取首个 `:行号: ` 边界)', () => {
|
|
89
|
+
const s = summarizeAgentTool(
|
|
90
|
+
'grep',
|
|
91
|
+
{ pattern: 'x' },
|
|
92
|
+
'dir:with:colon/a.ts:7: x = 1\nsrc/log.ts:9: msg("at a.ts:3: boom")',
|
|
93
|
+
)
|
|
94
|
+
expect(s.entries).toEqual([
|
|
95
|
+
{ kind: 'hit', path: 'dir:with:colon/a.ts', line: 7, text: 'x = 1' },
|
|
96
|
+
{ kind: 'hit', path: 'src/log.ts', line: 9, text: 'msg("at a.ts:3: boom")' },
|
|
97
|
+
])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('ls:条目为文件名(目录保留 / 后缀),target 缺省为 .', () => {
|
|
101
|
+
const s = summarizeAgentTool('ls', {}, 'README.md\nsrc/\npackage.json')
|
|
102
|
+
expect(s.target).toBe('.')
|
|
103
|
+
expect(s.entries).toEqual([
|
|
104
|
+
{ kind: 'file', path: 'README.md' },
|
|
105
|
+
{ kind: 'file', path: 'src/' },
|
|
106
|
+
{ kind: 'file', path: 'package.json' },
|
|
107
|
+
])
|
|
108
|
+
expect(s.total).toBe(3)
|
|
109
|
+
expect(s.truncated).toBe(false)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('find:target 为 glob,条目为相对路径', () => {
|
|
113
|
+
const s = summarizeAgentTool('find', { pattern: '**/*.ts', path: 'src' }, 'src/a.ts\nsrc/b/c.ts')
|
|
114
|
+
expect(s.target).toBe('**/*.ts')
|
|
115
|
+
expect(s.entries.map((e) => (e.kind === 'file' ? e.path : null))).toEqual(['src/a.ts', 'src/b/c.ts'])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('read:target 为文件路径,条目为内容行,尾部 Showing lines 提示剥离并置 truncated', () => {
|
|
119
|
+
const s = summarizeAgentTool(
|
|
120
|
+
'read',
|
|
121
|
+
{ path: 'README.md' },
|
|
122
|
+
'# Title\n\nbody line\n\n[Showing lines 1-3 of 900. Use offset=4 to continue.]',
|
|
123
|
+
)
|
|
124
|
+
expect(s.target).toBe('README.md')
|
|
125
|
+
expect(s.entries).toEqual([
|
|
126
|
+
{ kind: 'text', text: '# Title' },
|
|
127
|
+
{ kind: 'text', text: '' },
|
|
128
|
+
{ kind: 'text', text: 'body line' },
|
|
129
|
+
])
|
|
130
|
+
expect(s.truncated).toBe(true)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('read:正文本身以方括号结尾(非 pi 提示措辞)不被误剥离', () => {
|
|
134
|
+
const s = summarizeAgentTool('read', { path: 'notes.md' }, 'intro\n\n[TODO]')
|
|
135
|
+
expect(s.entries.map((e) => (e.kind === 'text' ? e.text : null))).toEqual(['intro', '', '[TODO]'])
|
|
136
|
+
expect(s.truncated).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('空结果哨兵:grep 无匹配 / ls 空目录 / find 无文件 → empty 且无条目', () => {
|
|
140
|
+
expect(summarizeAgentTool('grep', { pattern: 'zzz' }, 'No matches found')).toMatchObject({ empty: true, entries: [], total: 0 })
|
|
141
|
+
expect(summarizeAgentTool('ls', { path: 'empty' }, '(empty directory)')).toMatchObject({ empty: true, entries: [] })
|
|
142
|
+
expect(summarizeAgentTool('find', { pattern: '*.x' }, 'No files found matching pattern')).toMatchObject({ empty: true })
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('条目超过展示上限:entries 截至上限、total 保留真实数量', () => {
|
|
146
|
+
const lines = Array.from({ length: AGENT_TOOL_MAX_ENTRIES + 7 }, (_, i) => `f${i}.txt`).join('\n')
|
|
147
|
+
const s = summarizeAgentTool('ls', {}, lines)
|
|
148
|
+
expect(s.entries).toHaveLength(AGENT_TOOL_MAX_ENTRIES)
|
|
149
|
+
expect(s.total).toBe(AGENT_TOOL_MAX_ENTRIES + 7)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('未知工具:kind=other、target=null、条目为文本行;无结果文本 → 无条目', () => {
|
|
153
|
+
expect(summarizeAgentTool('custom', {}, 'line1\nline2')).toMatchObject({
|
|
154
|
+
kind: 'other',
|
|
155
|
+
target: null,
|
|
156
|
+
entries: [
|
|
157
|
+
{ kind: 'text', text: 'line1' },
|
|
158
|
+
{ kind: 'text', text: 'line2' },
|
|
159
|
+
],
|
|
160
|
+
})
|
|
161
|
+
expect(summarizeAgentTool('read', { path: 'a' }, null)).toMatchObject({ entries: [], total: 0, empty: false })
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -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
|
+
}
|