@allbluecn/web-app 0.23.6 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +0 -2
- package/src/components/harness/ai-chat-history/components/conversation-item.tsx +6 -0
- package/src/components/harness/ai-chat-history/types.ts +4 -0
- package/src/components/harness/chat-ui.tsx +20 -0
- package/src/components/harness/composer/harness-composer.tsx +43 -10
- package/src/components/harness/composer/harness-engine-control.tsx +129 -0
- package/src/components/harness/harness-workbench-page.tsx +129 -17
- package/src/components/layout/sidebar-03/__tests__/user-menu-cloud-logout.test.tsx +185 -0
- package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -9
- package/src/components/layout/sidebar-03/user-menu.tsx +27 -50
- package/src/components/shared-kit/auth/auth-client.ts +4 -0
- package/src/config/sidebar-routes.tsx +0 -13
- package/src/lib/__tests__/desktop-logout.test.ts +239 -0
- package/src/lib/__tests__/session.test.ts +85 -2
- package/src/lib/auth/__tests__/page-gate.test.ts +37 -1
- package/src/lib/auth/anonymous-safe.ts +5 -0
- package/src/lib/auth/page-gate.ts +11 -1
- package/src/lib/changelog/sdk-versions.ts +1 -1
- package/src/lib/desktop-env.ts +25 -0
- package/src/lib/desktop-logout.ts +117 -0
- package/src/lib/hotkeys/__tests__/registry.test.ts +4 -11
- package/src/lib/hotkeys/registry.ts +0 -8
- package/src/lib/session.ts +70 -0
- package/src/paraglide/README.md +1 -1
- package/src/paraglide/messages/_index.d.ts +12 -2
- package/src/paraglide/messages/_index.js +12 -2
- package/src/paraglide/messages/{agent_desktop_title.d.ts → harness_engine_cloud.d.ts} +5 -5
- package/src/paraglide/messages/harness_engine_cloud.js +29 -0
- package/src/paraglide/messages/harness_engine_label.d.ts +16 -0
- package/src/paraglide/messages/harness_engine_label.js +29 -0
- package/src/paraglide/messages/harness_engine_local.d.ts +16 -0
- package/src/paraglide/messages/harness_engine_local.js +29 -0
- package/src/paraglide/messages/harness_engine_localbadge1.d.ts +17 -0
- package/src/paraglide/messages/harness_engine_localbadge1.js +30 -0
- package/src/paraglide/messages/harness_engine_localhint1.d.ts +17 -0
- package/src/paraglide/messages/harness_engine_localhint1.js +30 -0
- package/src/paraglide/messages/harness_engine_localnoattachments2.d.ts +17 -0
- package/src/paraglide/messages/harness_engine_localnoattachments2.js +30 -0
- package/src/paraglide/messages/harness_engine_switchlocked1.d.ts +17 -0
- package/src/paraglide/messages/harness_engine_switchlocked1.js +30 -0
- package/src/paraglide/messages/harness_localworkspace_choose1.d.ts +17 -0
- package/src/paraglide/messages/harness_localworkspace_choose1.js +30 -0
- package/src/paraglide/messages/harness_localworkspace_dialogdescription2.d.ts +17 -0
- package/src/paraglide/messages/harness_localworkspace_dialogdescription2.js +30 -0
- package/src/paraglide/messages/harness_localworkspace_dialogtitle2.d.ts +17 -0
- package/src/paraglide/messages/harness_localworkspace_dialogtitle2.js +30 -0
- package/src/paraglide/messages/harness_localworkspace_manage1.d.ts +17 -0
- package/src/paraglide/messages/harness_localworkspace_manage1.js +30 -0
- package/src/paraglide/messages/harness_localworkspace_required1.d.ts +17 -0
- package/src/paraglide/messages/harness_localworkspace_required1.js +30 -0
- package/src/routeTree.gen.ts +0 -23
- package/src/routes/__tests__/-edition-routing.test.tsx +48 -7
- package/src/routes/__tests__/-login-admin-redirect.test.ts +99 -0
- package/src/routes/_login/login/route.tsx +18 -3
- package/src/routes/_nav/design/canvas/$id/route.tsx +5 -0
- package/src/routes/_nav/harness/$threadId.lazy.tsx +19 -2
- package/src/routes/_nav/harness/$threadId.tsx +8 -2
- package/src/routes/_nav/knowledge/$kbId/route.tsx +6 -2
- package/src/routes/_nav/route.tsx +5 -1
- package/src/routes/_nav/settings/account/route.tsx +5 -0
- package/src/routes/_nav/settings/team/route.tsx +5 -0
- package/src/routes/api/auth/$.ts +87 -2
- package/src/routes/api/auth/__tests__/auth-session-desktop.test.ts +194 -0
- package/src/server/__tests__/chat-conversations.test.ts +13 -3
- package/src/server/__tests__/desktop-bootstrap.test.ts +0 -1
- package/src/server/ai-gateway/__tests__/local-agent-adapter.test.ts +110 -0
- package/src/server/ai-gateway/__tests__/unified-router-local-agent.test.ts +63 -0
- package/src/server/ai-gateway/chat-handler.ts +68 -2
- package/src/server/ai-gateway/chat-stream-builder.ts +52 -0
- package/src/server/ai-gateway/local-agent-adapter.ts +232 -0
- package/src/server/ai-gateway/unified-router.ts +49 -6
- package/src/server/desktop-guest.ts +5 -1
- package/src/server/serverFns/auth/session.ts +19 -10
- package/src/server/serverFns/chat/conversations.ts +122 -1
- package/src/components/desktop-agent/__tests__/agent-chat.test.tsx +0 -194
- package/src/components/desktop-agent/agent-chat.tsx +0 -222
- package/src/lib/desktop-agent/__tests__/connection.test.ts +0 -310
- package/src/lib/desktop-agent/__tests__/errors.test.ts +0 -43
- package/src/lib/desktop-agent/connection.ts +0 -93
- package/src/lib/desktop-agent/errors.ts +0 -21
- package/src/paraglide/messages/agent_desktop_title.js +0 -29
- package/src/paraglide/messages/common_hotkey_desc_agentabort1.d.ts +0 -17
- package/src/paraglide/messages/common_hotkey_desc_agentabort1.js +0 -30
- package/src/routes/-tests/agent-gate.test.ts +0 -45
- package/src/routes/_nav/agent/route.lazy.tsx +0 -63
- package/src/routes/_nav/agent/route.tsx +0 -21
- /package/src/components/{desktop-agent → harness/local-agent}/__tests__/agent-tool-card.test.tsx +0 -0
- /package/src/components/{desktop-agent → harness/local-agent}/__tests__/agent-workspace-card.test.tsx +0 -0
- /package/src/components/{desktop-agent → harness/local-agent}/agent-tool-card.tsx +0 -0
- /package/src/components/{desktop-agent → harness/local-agent}/agent-workspace-card.tsx +0 -0
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
-
// AllBlue - 理想之海
|
|
3
|
-
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
-
|
|
5
|
-
// 本地文件助手 · 对话区(桌面本地优先 P3-B5):useChat 接线(懒取端点的 connection
|
|
6
|
-
// 包装 + 稳定 threadId + forwardedProps.cwd)、消息渲染(空 assistant 消息跳过、
|
|
7
|
-
// 工具调用折叠卡按 toolCallId 关联结果)、输入(Enter 发送 / Shift+Enter /
|
|
8
|
-
// 输入法组合)、中止(按钮 + agent-abort 热键仅运行中生效)、错误文案映射。
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
-
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
11
|
-
import { HotkeysProvider } from '@tanstack/react-hotkeys'
|
|
12
|
-
import type { UIMessage } from '@tanstack/ai-client'
|
|
13
|
-
import { TooltipProvider } from '@/components/ui/tooltip'
|
|
14
|
-
import { appHotkeys } from '@/lib/hotkeys/registry'
|
|
15
|
-
import { m } from '@/paraglide/messages.js'
|
|
16
|
-
|
|
17
|
-
const chat = vi.hoisted(() => ({
|
|
18
|
-
messages: [] as unknown[],
|
|
19
|
-
isLoading: false,
|
|
20
|
-
error: undefined as Error | undefined,
|
|
21
|
-
sendMessage: vi.fn(async (_text: string) => {}),
|
|
22
|
-
stop: vi.fn(),
|
|
23
|
-
options: [] as Array<Record<string, unknown>>,
|
|
24
|
-
}))
|
|
25
|
-
|
|
26
|
-
vi.mock('@tanstack/ai-react', () => ({
|
|
27
|
-
useChat: (options: Record<string, unknown>) => {
|
|
28
|
-
chat.options.push(options)
|
|
29
|
-
return {
|
|
30
|
-
messages: chat.messages,
|
|
31
|
-
isLoading: chat.isLoading,
|
|
32
|
-
error: chat.error,
|
|
33
|
-
sendMessage: chat.sendMessage,
|
|
34
|
-
stop: chat.stop,
|
|
35
|
-
}
|
|
36
|
-
},
|
|
37
|
-
}))
|
|
38
|
-
// markdown 渲染器替身(react-markdown/shiki 与本组件行为无关,保测试轻量)
|
|
39
|
-
vi.mock('@/components/harness/ai-message/components/markdown-content', () => ({
|
|
40
|
-
MarkdownContent: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>,
|
|
41
|
-
}))
|
|
42
|
-
|
|
43
|
-
import { AgentChat } from '../agent-chat'
|
|
44
|
-
|
|
45
|
-
const piResult = (text: string) => ({ content: [{ type: 'text', text }], details: undefined })
|
|
46
|
-
|
|
47
|
-
function renderChat(props: { cwd?: string | null; onRunningChange?: (running: boolean) => void } = {}) {
|
|
48
|
-
const ui = (cwd: string | null) => (
|
|
49
|
-
<HotkeysProvider appHotkeys={appHotkeys}>
|
|
50
|
-
<TooltipProvider>
|
|
51
|
-
<AgentChat cwd={cwd} onRunningChange={props.onRunningChange} />
|
|
52
|
-
</TooltipProvider>
|
|
53
|
-
</HotkeysProvider>
|
|
54
|
-
)
|
|
55
|
-
const cwd = props.cwd === undefined ? '/Users/x/proj' : props.cwd
|
|
56
|
-
const utils = render(ui(cwd))
|
|
57
|
-
return { ...utils, rerenderWith: (next: string | null) => utils.rerender(ui(next)) }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
beforeEach(() => {
|
|
61
|
-
chat.messages = []
|
|
62
|
-
chat.isLoading = false
|
|
63
|
-
chat.error = undefined
|
|
64
|
-
chat.options = []
|
|
65
|
-
chat.sendMessage.mockClear()
|
|
66
|
-
chat.stop.mockClear()
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
afterEach(() => cleanup())
|
|
70
|
-
|
|
71
|
-
describe('AgentChat · useChat 接线', () => {
|
|
72
|
-
it('connection 为懒取端点的包装(含 connect)、threadId 跨渲染稳定、forwardedProps 携带 cwd', () => {
|
|
73
|
-
const { rerenderWith } = renderChat({ cwd: '/Users/x/proj' })
|
|
74
|
-
const first = chat.options[0]!
|
|
75
|
-
expect(typeof (first.connection as { connect?: unknown }).connect).toBe('function')
|
|
76
|
-
expect(typeof first.threadId).toBe('string')
|
|
77
|
-
expect(first.forwardedProps).toEqual({ cwd: '/Users/x/proj' })
|
|
78
|
-
rerenderWith('/Users/x/proj')
|
|
79
|
-
const last = chat.options.at(-1)!
|
|
80
|
-
expect(last.threadId).toBe(first.threadId)
|
|
81
|
-
expect(last.connection).toBe(first.connection)
|
|
82
|
-
})
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
describe('AgentChat · 消息渲染', () => {
|
|
86
|
-
it('user 气泡 + assistant 正文;pi 回显产生的空 assistant 消息被跳过', () => {
|
|
87
|
-
chat.messages = [
|
|
88
|
-
{ id: 'u1', role: 'user', parts: [{ type: 'text', content: '列出目录' }] },
|
|
89
|
-
{ id: 'msg-1', role: 'assistant', parts: [] },
|
|
90
|
-
{ id: 'msg-2', role: 'assistant', parts: [{ type: 'text', content: ' ' }] },
|
|
91
|
-
{ id: 'msg-3', role: 'assistant', parts: [{ type: 'text', content: '目录里有 a.txt' }] },
|
|
92
|
-
] satisfies UIMessage[]
|
|
93
|
-
renderChat()
|
|
94
|
-
expect(screen.getByText('列出目录')).toBeInTheDocument()
|
|
95
|
-
expect(screen.getByText('目录里有 a.txt')).toBeInTheDocument()
|
|
96
|
-
const rendered = screen.getAllByTestId('agent-message').map((el) => el.getAttribute('data-role'))
|
|
97
|
-
expect(rendered).toEqual(['user', 'assistant'])
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('工具调用:tool-call 渲染折叠卡,同 toolCallId 的 tool-result 不单独渲染', () => {
|
|
101
|
-
chat.messages = [
|
|
102
|
-
{ id: 'u1', role: 'user', parts: [{ type: 'text', content: '总结项目' }] },
|
|
103
|
-
{
|
|
104
|
-
id: 'msg-2',
|
|
105
|
-
role: 'assistant',
|
|
106
|
-
parts: [
|
|
107
|
-
{ type: 'tool-call', id: 'call_ls_1', name: 'ls', arguments: '{"path":"."}', state: 'complete', output: piResult('README.md\nsrc/') },
|
|
108
|
-
{ type: 'tool-result', toolCallId: 'call_ls_1', content: JSON.stringify(piResult('README.md\nsrc/')), state: 'complete' },
|
|
109
|
-
],
|
|
110
|
-
},
|
|
111
|
-
] satisfies UIMessage[]
|
|
112
|
-
renderChat()
|
|
113
|
-
expect(screen.getAllByTestId('agent-tool-card-trigger')).toHaveLength(1)
|
|
114
|
-
expect(screen.getByText(m.agent_desktop_toolLs({ target: '.' }))).toBeInTheDocument()
|
|
115
|
-
expect(screen.getByText(m.agent_desktop_toolCountFiles({ count: 2 }))).toBeInTheDocument()
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
it('空对话:引导空态', () => {
|
|
119
|
-
renderChat()
|
|
120
|
-
expect(screen.getByText(m.agent_desktop_chatEmptyTitle())).toBeInTheDocument()
|
|
121
|
-
})
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
describe('AgentChat · 输入与发送', () => {
|
|
125
|
-
it('Enter 发送(trim)并清空输入;Shift+Enter 与输入法组合中的 Enter 不发送', () => {
|
|
126
|
-
renderChat()
|
|
127
|
-
const textarea = screen.getByRole('textbox')
|
|
128
|
-
fireEvent.change(textarea, { target: { value: ' 总结这个项目 ' } })
|
|
129
|
-
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
|
|
130
|
-
fireEvent.keyDown(textarea, { key: 'Enter', isComposing: true })
|
|
131
|
-
expect(chat.sendMessage).not.toHaveBeenCalled()
|
|
132
|
-
fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
133
|
-
expect(chat.sendMessage).toHaveBeenCalledWith('总结这个项目')
|
|
134
|
-
expect(textarea).toHaveValue('')
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
it('发送按钮:空输入禁用,有内容可点', () => {
|
|
138
|
-
renderChat()
|
|
139
|
-
const send = screen.getByRole('button', { name: m.agent_desktop_chatSend() })
|
|
140
|
-
expect(send).toBeDisabled()
|
|
141
|
-
fireEvent.change(screen.getByRole('textbox'), { target: { value: '找出所有 TODO' } })
|
|
142
|
-
fireEvent.click(screen.getByRole('button', { name: m.agent_desktop_chatSend() }))
|
|
143
|
-
expect(chat.sendMessage).toHaveBeenCalledWith('找出所有 TODO')
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
it('无授权目录(cwd=null):输入禁用 + 引导先选目录', () => {
|
|
147
|
-
renderChat({ cwd: null })
|
|
148
|
-
expect(screen.getByRole('textbox')).toBeDisabled()
|
|
149
|
-
expect(screen.getByTestId('agent-chat-cwd')).toHaveTextContent(m.agent_desktop_chatNoWorkspace())
|
|
150
|
-
})
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
describe('AgentChat · 中止(按钮 + agent-abort 热键)', () => {
|
|
154
|
-
it('运行中:显示中止按钮(替代发送)并调用 stop;思考指示可见', () => {
|
|
155
|
-
chat.isLoading = true
|
|
156
|
-
renderChat()
|
|
157
|
-
expect(screen.queryByRole('button', { name: m.agent_desktop_chatSend() })).toBeNull()
|
|
158
|
-
fireEvent.click(screen.getByRole('button', { name: m.agent_desktop_chatStop() }))
|
|
159
|
-
expect(chat.stop).toHaveBeenCalledTimes(1)
|
|
160
|
-
expect(screen.getByTestId('agent-chat-thinking')).toBeInTheDocument()
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
it('运行中按 Mod+. → stop(声明即绑定:appHotkeys["agent-abort"])', () => {
|
|
164
|
-
chat.isLoading = true
|
|
165
|
-
renderChat()
|
|
166
|
-
act(() => {
|
|
167
|
-
fireEvent.keyDown(document.body, { key: '.', code: 'Period', ctrlKey: true })
|
|
168
|
-
})
|
|
169
|
-
expect(chat.stop).toHaveBeenCalledTimes(1)
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
it('未运行时 Mod+. 不触发 stop(enabled 门控)', () => {
|
|
173
|
-
renderChat()
|
|
174
|
-
act(() => {
|
|
175
|
-
fireEvent.keyDown(document.body, { key: '.', code: 'Period', ctrlKey: true })
|
|
176
|
-
})
|
|
177
|
-
expect(chat.stop).not.toHaveBeenCalled()
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
it('运行态变化通知父级(锁定目录切换用)', () => {
|
|
181
|
-
const onRunningChange = vi.fn()
|
|
182
|
-
chat.isLoading = true
|
|
183
|
-
renderChat({ onRunningChange })
|
|
184
|
-
expect(onRunningChange).toHaveBeenLastCalledWith(true)
|
|
185
|
-
})
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
describe('AgentChat · 错误文案', () => {
|
|
189
|
-
it('401 令牌失效 → 可读提示(role=alert)', () => {
|
|
190
|
-
chat.error = Object.assign(new Error('HTTP error! status: 401'), { name: 'StreamHttpError', status: 401 })
|
|
191
|
-
renderChat()
|
|
192
|
-
expect(screen.getByRole('alert')).toHaveTextContent(m.agent_desktop_errorUnauthorized())
|
|
193
|
-
})
|
|
194
|
-
})
|
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
-
// AllBlue - 理想之海
|
|
3
|
-
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
-
|
|
5
|
-
// 本地文件助手 · 对话区(桌面本地优先 P3-B5):useChat 直连 Pro desktop 域
|
|
6
|
-
// AgentService 的 `POST /prompt`(AG-UI SSE)。
|
|
7
|
-
// - connection:lib/desktop-agent/connection 包装——每次 connect 懒取桌面桥的 agent
|
|
8
|
-
// 端点(新壳 agentCredentials 按需取 {port, token},旧壳回落 appInfo 的
|
|
9
|
-
// agentPort/agentToken;backend 重启换端口/令牌后下一问即用新值),引用稳定。
|
|
10
|
-
// - threadId:mount 级稳定值(useChat 1.0 起必填,变化即重建 ChatClient)。B4 端点
|
|
11
|
-
// 每次 POST 新建 pi 会话,threadId 仅作 run 归属回显——一期不保留多轮上下文,
|
|
12
|
-
// 输入区常驻提示告知。
|
|
13
|
-
// - cwd:经 forwardedProps 进 RunAgentInput.forwardedProps(端点 extractCwd 取值位,
|
|
14
|
-
// 服务端再按 workspaces.json 复核授权);父级以 key={cwd} 让每个目录一份会话。
|
|
15
|
-
// - 消息:user 气泡 / assistant Markdown + 工具调用折叠卡(同 toolCallId 的
|
|
16
|
-
// tool-result 已由 processor 挂到 tool-call part.output,不重复渲染);pi 对
|
|
17
|
-
// user / toolResult 消息也发 message_start/end,processor 会建空 assistant 消息,
|
|
18
|
-
// 渲染层跳过(B3B4 报告疑虑 3)。
|
|
19
|
-
// - 输入:Enter 发送 / Shift+Enter 换行 / 输入法组合中的 Enter 不发送。
|
|
20
|
-
// - 中止:运行中发送按钮换成「中止」+ 应用热键 agent-abort(Mod+.,T23 声明即绑定,
|
|
21
|
-
// 仅运行中 enabled)。
|
|
22
|
-
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type UIEvent } from 'react'
|
|
23
|
-
import { useChat } from '@tanstack/ai-react'
|
|
24
|
-
import type { UIMessage } from '@tanstack/ai-client'
|
|
25
|
-
import { useAppHotkeys, useHotkey } from '@tanstack/react-hotkeys'
|
|
26
|
-
import { ArrowUp, CircleAlert, FolderSearch, Loader2, MessageSquareText, Square } from 'lucide-react'
|
|
27
|
-
import { AgentToolCard, type AgentToolResultPart } from '@/components/desktop-agent/agent-tool-card'
|
|
28
|
-
import { MarkdownContent } from '@/components/harness/ai-message/components/markdown-content'
|
|
29
|
-
import { ShortcutBadge } from '@/components/hotkeys/shortcut-badge'
|
|
30
|
-
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
|
|
31
|
-
import {
|
|
32
|
-
InputGroup,
|
|
33
|
-
InputGroupAddon,
|
|
34
|
-
InputGroupButton,
|
|
35
|
-
InputGroupTextarea,
|
|
36
|
-
} from '@/components/ui/input-group'
|
|
37
|
-
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
|
38
|
-
import { createDesktopAgentConnection } from '@/lib/desktop-agent/connection'
|
|
39
|
-
import { describeDesktopAgentError } from '@/lib/desktop-agent/errors'
|
|
40
|
-
import { formatAppHotkeyForBadge } from '@/lib/hotkeys/registry'
|
|
41
|
-
import { m } from '@/paraglide/messages.js'
|
|
42
|
-
|
|
43
|
-
/** 距底部小于该值视为「贴底」:新内容到达时自动滚到底;用户上翻阅读时不打断 */
|
|
44
|
-
const STICK_TO_BOTTOM_PX = 80
|
|
45
|
-
|
|
46
|
-
/** 有可见内容:user 有正文;assistant 有非空正文或工具调用(空消息对跳过) */
|
|
47
|
-
function hasVisibleContent(message: UIMessage): boolean {
|
|
48
|
-
return message.parts.some((part) =>
|
|
49
|
-
part.type === 'text' ? part.content.trim() !== '' : part.type === 'tool-call',
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function AgentMessage({ message }: { message: UIMessage }) {
|
|
54
|
-
if (message.role === 'user') {
|
|
55
|
-
const text = message.parts.map((part) => (part.type === 'text' ? part.content : '')).join('')
|
|
56
|
-
return (
|
|
57
|
-
<div className="flex justify-end" data-testid="agent-message" data-role="user">
|
|
58
|
-
<div className="max-w-xl rounded-2xl rounded-br-sm bg-primary px-3.5 py-2 text-sm whitespace-pre-wrap text-primary-foreground">
|
|
59
|
-
{text}
|
|
60
|
-
</div>
|
|
61
|
-
</div>
|
|
62
|
-
)
|
|
63
|
-
}
|
|
64
|
-
const results = new Map<string, AgentToolResultPart>()
|
|
65
|
-
for (const part of message.parts) {
|
|
66
|
-
if (part.type === 'tool-result') results.set(part.toolCallId, part)
|
|
67
|
-
}
|
|
68
|
-
return (
|
|
69
|
-
<div className="flex flex-col gap-2 text-sm" data-testid="agent-message" data-role="assistant">
|
|
70
|
-
{message.parts.map((part, index) => {
|
|
71
|
-
if (part.type === 'text') {
|
|
72
|
-
// parts 流式只追加不重排,index key 稳定
|
|
73
|
-
return part.content.trim() === '' ? null : (
|
|
74
|
-
<MarkdownContent key={index} content={part.content} skipCodeHighlighting />
|
|
75
|
-
)
|
|
76
|
-
}
|
|
77
|
-
if (part.type === 'tool-call') {
|
|
78
|
-
return <AgentToolCard key={part.id || index} part={part} result={results.get(part.id)} />
|
|
79
|
-
}
|
|
80
|
-
return null
|
|
81
|
-
})}
|
|
82
|
-
</div>
|
|
83
|
-
)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export interface AgentChatProps {
|
|
87
|
-
/** 当前工作目录(授权清单选中项);null = 尚未授权/选择,输入禁用 */
|
|
88
|
-
cwd: string | null
|
|
89
|
-
/** 运行态变化通知父级(运行中锁定目录切换);须传稳定引用(如 setState) */
|
|
90
|
-
onRunningChange?: (running: boolean) => void
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function AgentChat({ cwd, onRunningChange }: AgentChatProps) {
|
|
94
|
-
const [connection] = useState(() => createDesktopAgentConnection())
|
|
95
|
-
const [threadId] = useState(() => crypto.randomUUID())
|
|
96
|
-
const forwardedProps = useMemo(() => ({ cwd: cwd ?? '' }), [cwd])
|
|
97
|
-
const { messages, sendMessage, stop, isLoading, error } = useChat({ connection, threadId, forwardedProps })
|
|
98
|
-
|
|
99
|
-
const [input, setInput] = useState('')
|
|
100
|
-
const scrollRef = useRef<HTMLDivElement>(null)
|
|
101
|
-
const stickToBottomRef = useRef(true)
|
|
102
|
-
|
|
103
|
-
// 声明即绑定:agent-abort 单一来源 lib/hotkeys/registry.ts
|
|
104
|
-
const hk = useAppHotkeys()
|
|
105
|
-
useHotkey(hk['agent-abort'], () => stop(), { enabled: isLoading })
|
|
106
|
-
|
|
107
|
-
useEffect(() => {
|
|
108
|
-
onRunningChange?.(isLoading)
|
|
109
|
-
}, [isLoading, onRunningChange])
|
|
110
|
-
// 运行中被卸载(离开页面):解除父级锁定
|
|
111
|
-
useEffect(() => () => onRunningChange?.(false), [onRunningChange])
|
|
112
|
-
|
|
113
|
-
const visible = useMemo(() => messages.filter(hasVisibleContent), [messages])
|
|
114
|
-
|
|
115
|
-
useEffect(() => {
|
|
116
|
-
const el = scrollRef.current
|
|
117
|
-
if (el && stickToBottomRef.current) el.scrollTop = el.scrollHeight
|
|
118
|
-
}, [visible, isLoading])
|
|
119
|
-
|
|
120
|
-
const onScroll = (event: UIEvent<HTMLDivElement>) => {
|
|
121
|
-
const el = event.currentTarget
|
|
122
|
-
stickToBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_TO_BOTTOM_PX
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const canSend = cwd !== null && input.trim() !== '' && !isLoading
|
|
126
|
-
|
|
127
|
-
const submit = () => {
|
|
128
|
-
if (!canSend) return
|
|
129
|
-
const text = input.trim()
|
|
130
|
-
setInput('')
|
|
131
|
-
stickToBottomRef.current = true
|
|
132
|
-
// 失败由 useChat error 态渲染(describeDesktopAgentError),这里只吞 unhandledrejection
|
|
133
|
-
Promise.resolve(sendMessage(text)).catch(() => {})
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
137
|
-
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
|
|
138
|
-
event.preventDefault()
|
|
139
|
-
submit()
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
return (
|
|
143
|
-
<section className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border bg-card">
|
|
144
|
-
<div className="flex items-center gap-2 border-b px-4 py-2.5 text-xs text-muted-foreground">
|
|
145
|
-
<FolderSearch className="size-3.5 shrink-0" />
|
|
146
|
-
<span className="min-w-0 truncate" data-testid="agent-chat-cwd">
|
|
147
|
-
{cwd ? m.agent_desktop_chatCwd({ path: cwd }) : m.agent_desktop_chatNoWorkspace()}
|
|
148
|
-
</span>
|
|
149
|
-
</div>
|
|
150
|
-
|
|
151
|
-
<div ref={scrollRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4">
|
|
152
|
-
{visible.length === 0 && !isLoading ? (
|
|
153
|
-
<Empty className="h-full">
|
|
154
|
-
<EmptyHeader>
|
|
155
|
-
<EmptyMedia variant="icon">
|
|
156
|
-
<MessageSquareText />
|
|
157
|
-
</EmptyMedia>
|
|
158
|
-
<EmptyTitle>{m.agent_desktop_chatEmptyTitle()}</EmptyTitle>
|
|
159
|
-
<EmptyDescription>{m.agent_desktop_chatEmptyHint()}</EmptyDescription>
|
|
160
|
-
</EmptyHeader>
|
|
161
|
-
</Empty>
|
|
162
|
-
) : (
|
|
163
|
-
<div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
|
|
164
|
-
{visible.map((message) => (
|
|
165
|
-
<AgentMessage key={message.id} message={message} />
|
|
166
|
-
))}
|
|
167
|
-
{isLoading && (
|
|
168
|
-
<div className="flex items-center gap-2 text-sm text-muted-foreground" data-testid="agent-chat-thinking">
|
|
169
|
-
<Loader2 className="size-4 animate-spin" />
|
|
170
|
-
{m.agent_desktop_chatThinking()}
|
|
171
|
-
</div>
|
|
172
|
-
)}
|
|
173
|
-
</div>
|
|
174
|
-
)}
|
|
175
|
-
</div>
|
|
176
|
-
|
|
177
|
-
{error && (
|
|
178
|
-
<div
|
|
179
|
-
role="alert"
|
|
180
|
-
className="mx-4 mb-2 flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive"
|
|
181
|
-
>
|
|
182
|
-
<CircleAlert className="mt-0.5 size-3.5 shrink-0" />
|
|
183
|
-
<span className="min-w-0 wrap-break-word">{describeDesktopAgentError(error)}</span>
|
|
184
|
-
</div>
|
|
185
|
-
)}
|
|
186
|
-
|
|
187
|
-
<div className="border-t p-3">
|
|
188
|
-
<InputGroup>
|
|
189
|
-
<InputGroupTextarea
|
|
190
|
-
value={input}
|
|
191
|
-
onChange={(event) => setInput(event.target.value)}
|
|
192
|
-
onKeyDown={onKeyDown}
|
|
193
|
-
disabled={cwd === null}
|
|
194
|
-
placeholder={cwd ? m.agent_desktop_chatPlaceholder() : m.agent_desktop_chatNoWorkspace()}
|
|
195
|
-
className="max-h-40"
|
|
196
|
-
/>
|
|
197
|
-
<InputGroupAddon align="block-end">
|
|
198
|
-
<span className="min-w-0 truncate text-xs font-normal">{m.agent_desktop_chatIndependentHint()}</span>
|
|
199
|
-
{isLoading ? (
|
|
200
|
-
<Tooltip>
|
|
201
|
-
<TooltipTrigger
|
|
202
|
-
render={<InputGroupButton variant="outline" size="sm" className="ml-auto" onClick={() => stop()} />}
|
|
203
|
-
>
|
|
204
|
-
<Square className="fill-current" />
|
|
205
|
-
{m.agent_desktop_chatStop()}
|
|
206
|
-
</TooltipTrigger>
|
|
207
|
-
<TooltipContent>
|
|
208
|
-
<ShortcutBadge keys={formatAppHotkeyForBadge(hk['agent-abort'].hotkey)} />
|
|
209
|
-
</TooltipContent>
|
|
210
|
-
</Tooltip>
|
|
211
|
-
) : (
|
|
212
|
-
<InputGroupButton variant="default" size="sm" className="ml-auto" disabled={!canSend} onClick={submit}>
|
|
213
|
-
<ArrowUp />
|
|
214
|
-
{m.agent_desktop_chatSend()}
|
|
215
|
-
</InputGroupButton>
|
|
216
|
-
)}
|
|
217
|
-
</InputGroupAddon>
|
|
218
|
-
</InputGroup>
|
|
219
|
-
</div>
|
|
220
|
-
</section>
|
|
221
|
-
)
|
|
222
|
-
}
|