feat: add secure remote channel media

This commit is contained in:
lofyer
2026-08-09 20:33:35 +08:00
parent 6c891f3522
commit 66b098ae36
46 changed files with 4403 additions and 1594 deletions
+4 -2
View File
@@ -95,10 +95,12 @@ describe('ActivityPanel', () => {
)
fireEvent.click(screen.getByRole('button', { name: '进行中' }))
fireEvent.click(screen.getByText('对话:活动 1'))
expect(screen.getByText('活动 1')).toBeInTheDocument()
expect(screen.queryByText('活动 2')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '失败' }))
fireEvent.click(screen.getByText('对话:活动 2'))
expect(screen.getByText('活动 2')).toBeInTheDocument()
expect(screen.getByText('活动 3')).toBeInTheDocument()
expect(screen.queryByText('活动 1')).not.toBeInTheDocument()
@@ -179,9 +181,9 @@ describe('ActivityPanel', () => {
})
it('groups activity by conversation in collapsible sections', () => {
const first = makeRecord(1)
const first = makeRecord(1, 'running')
const second = {
...makeRecord(2),
...makeRecord(2, 'failed'),
conversationId: first.conversationId
}
const { container } = render(
-7
View File
@@ -374,13 +374,6 @@ export function ActivityPanel({
<details
className="activity-group"
key={group.conversationId}
open={
group.records.some(
(record) => isActive(record) || isFailed(record)
)
? true
: undefined
}
>
<summary>
<span>
+199 -3
View File
@@ -715,13 +715,53 @@ describe('App', () => {
)
expect(await screen.findByDisplayValue('本地语音结果')).toBeInTheDocument()
expect(
screen.getByText(/快捷唤起:Ctrl\+Shift\+Space/)
).toBeInTheDocument()
screen.getByText('快捷唤起:', { exact: false })
).toHaveTextContent('快捷唤起:Ctrl+Shift+Space')
expect(
screen.queryByText(/CommandOrControl/)
).not.toBeInTheDocument()
})
it('shows a red recording state until microphone capture stops', async () => {
let resolveRecording!: (value: {
audio: ArrayBuffer
sampleRate: 16_000
}) => void
const stop = vi.fn()
speechRecognitionMocks.startPcmRecording.mockResolvedValueOnce({
result: new Promise((resolve) => {
resolveRecording = resolve
}),
stop,
cancel: vi.fn()
})
render(<App />)
const input = await screen.findByLabelText('向 GoodBuddy 提问')
expect(input).toHaveAttribute('rows', '3')
expect(input).toHaveStyle({ height: '72px' })
fireEvent.click(screen.getByLabelText('语音输入'))
const recordingButton = await screen.findByRole('button', {
name: '停止录音'
})
expect(recordingButton).toHaveAttribute('data-state', 'recording')
expect(recordingButton).toHaveAttribute('aria-pressed', 'true')
expect(recordingButton).toHaveClass(
'composer__voice-button--recording'
)
fireEvent.click(recordingButton)
expect(stop).toHaveBeenCalledOnce()
resolveRecording({
audio: new Float32Array([0, 0.25, -0.25]).buffer,
sampleRate: 16_000
})
expect(
await screen.findByDisplayValue('本地语音结果')
).toBeInTheDocument()
})
it('keeps conversation actions in the conversation list', async () => {
const writeText = vi.fn(async () => {})
Object.defineProperty(navigator, 'clipboard', {
@@ -981,7 +1021,9 @@ describe('App', () => {
evidence: []
})
render(<App />)
await screen.findByText('知识库 1')
await screen.findByRole('button', {
name: '选择知识库,本次已启用 1 个'
})
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '发布流程是什么?' }
@@ -1735,6 +1777,47 @@ describe('App', () => {
expect(screen.queryByRole('option', { name: /Plan/u })).toBeNull()
})
it('groups composer tools and exposes clear control descriptions', async () => {
render(<App />)
const composer = (await screen.findByLabelText(
'向 GoodBuddy 提问'
)).closest<HTMLElement>('.composer')
expect(composer).not.toBeNull()
const contentTools = within(composer!).getByRole('group', {
name: '添加内容'
})
expect(
within(contentTools).getByRole('button', { name: '添加附件' })
).toHaveAttribute('title', '添加附件')
expect(
within(contentTools).getByRole('button', { name: '语音输入' })
).toHaveAttribute(
'title',
'语音转文字,转写后可编辑再发送'
)
const conversationSettings = within(composer!).getByRole(
'group',
{ name: '对话设置' }
)
expect(
within(conversationSettings).getByLabelText('专家角色')
).toBeInTheDocument()
expect(
within(conversationSettings).getByLabelText('工作模式')
).toBeInTheDocument()
expect(
within(conversationSettings).getByRole('button', {
name: //u
})
).toHaveAttribute(
'title',
expect.stringContaining('Runtime 和模型')
)
})
it('normalizes a legacy Plan project default to Ask', async () => {
vi.mocked(api.projects.list).mockResolvedValueOnce([
{
@@ -1795,6 +1878,119 @@ describe('App', () => {
)
})
it('keeps channel projects empty until a client message creates a remote conversation', async () => {
const channelProject = {
...project,
id: '00000000-0000-4000-8000-000000000201',
name: '微信 ClawBot',
kind: 'channel' as const,
channel: 'weixin' as const,
runtimeSelection: {
provider: 'model' as const,
profileId: modelProfileId
}
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
channelProject
])
render(<App />)
await screen.findByRole('option', { name: '微信 ClawBot' })
fireEvent.change(await screen.findByLabelText('当前项目'), {
target: { value: channelProject.id }
})
expect(
screen.queryByRole('button', { name: //u })
).not.toBeInTheDocument()
expect(screen.queryByText('Ctrl N')).not.toBeInTheDocument()
expect(
screen.getAllByText('尚无远程会话').length
).toBeGreaterThan(0)
expect(
screen.getByText(
'请先连接微信 ClawBot,远程用户发送第一条消息后,会话会自动出现在这里。'
)
).toBeInTheDocument()
fireEvent.keyDown(document, {
key: 'n',
ctrlKey: true
})
expect(
await screen.findByText(
'通道项目的会话由客户端收到新消息后自动创建'
)
).toBeInTheDocument()
await act(
() =>
new Promise((resolve) => {
setTimeout(resolve, 550)
})
)
expect(api.conversations.replace).not.toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
projectId: channelProject.id,
remote: undefined
})
])
)
expect(screen.getAllByText('尚无远程会话').length).toBeGreaterThan(0)
})
it('shows client-created remote conversations without obsolete approval copy', async () => {
const channelProject = {
...project,
id: '00000000-0000-4000-8000-000000000201',
name: '微信 ClawBot',
kind: 'channel' as const,
channel: 'weixin' as const,
runtimeSelection: {
provider: 'model' as const,
profileId: modelProfileId
}
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
channelProject
])
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000301',
projectId: channelProject.id,
runtimeSelection: channelProject.runtimeSelection,
remote: {
channel: 'weixin',
accountDisplay: '发送者 ****0001',
conversationType: 'direct'
},
title: '微信 ClawBot · ****0001',
updatedAt: 1_775_000_000_000,
messages: []
}
])
render(<App />)
await screen.findByRole('option', { name: '微信 ClawBot' })
fireEvent.change(await screen.findByLabelText('当前项目'), {
target: { value: channelProject.id }
})
expect(
screen.getAllByRole('button', {
name: / ClawBot · \*{4}0001/u
}).length
).toBeGreaterThan(0)
expect(
screen.getByText(
'请在 微信 ClawBot 客户端继续发送消息。本窗口用于查看历史、任务与执行结果。'
)
).toBeInTheDocument()
expect(screen.queryByText(//u)).not.toBeInTheDocument()
})
it('falls back when the last active project is no longer available', async () => {
localStorage.setItem(
'goodbuddy.active-project.v1',
+483 -282
View File
File diff suppressed because it is too large Load Diff
+261 -28
View File
@@ -8,13 +8,67 @@ import {
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ChannelSettingsSnapshot } from '../../shared/channel-settings-contracts'
import type { DesktopApi } from '../../shared/contracts'
import {
defaultRuntimeSettings,
type DesktopApi,
type RuntimeSettings
} from '../../shared/contracts'
import type {
AssistantProject,
ProjectCreateInput
} from '../../shared/assistant-contracts'
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
import { ChannelSettingsSection } from './ChannelSettingsSection'
const directProfileId = '00000000-0000-4000-8000-000000000011'
const runtimeSettings: RuntimeSettings = {
...defaultRuntimeSettings,
workspacePath: 'C:\\Users\\tester',
apiKeyConfigured: true,
credentialSource: 'encrypted',
modelProfiles: [
{
id: directProfileId,
name: '默认模型',
baseUrl: 'https://example.com',
modelName: 'text-model',
protocol: 'openai-responses',
authentication: 'api-key',
imageGenerationQuality: 'auto',
apiKeyConfigured: true,
credentialSource: 'encrypted'
},
{
id: '00000000-0000-4000-8000-000000000012',
name: '图片模型',
baseUrl: 'https://example.com',
modelName: 'image-model',
protocol: 'openai-images-generations',
authentication: 'api-key',
imageGenerationQuality: 'auto',
apiKeyConfigured: true,
credentialSource: 'encrypted'
},
{
id: '00000000-0000-4000-8000-000000000013',
name: '未配置模型',
baseUrl: 'https://example.com',
modelName: 'missing-key-model',
protocol: 'openai-responses',
authentication: 'api-key',
imageGenerationQuality: 'auto',
apiKeyConfigured: false,
credentialSource: 'none'
}
],
defaultModelProfileId: directProfileId,
opencodeModelSource: { kind: 'platform' },
continueModelSource: { kind: 'platform' },
knowledgeEmbeddingApiKeyConfigured: false,
knowledgeEmbeddingCredentialSource: 'none',
secureStorageAvailable: true
}
const snapshot: ChannelSettingsSnapshot = {
weixin: {
enabled: false,
@@ -54,6 +108,7 @@ const projects: AssistantProject[] = [
description: `${name}通道项目`,
rootPath: 'C:\\Users\\tester',
defaultWorkMode: 'ask',
runtimeSelection: { provider: 'auto' },
kind: 'channel',
channel: channel as 'weixin' | 'wecom' | 'dingtalk',
status: 'active',
@@ -73,10 +128,14 @@ function bindingApi() {
disconnectWeixin: vi.fn(async () => ({
status: 'stopped' as const
})),
onWeixinBindingChanged: vi.fn(() => () => undefined),
respondRemoteApproval: vi.fn(async () => true),
getPendingRemoteApprovals: vi.fn(async () => []),
onRemoteApproval: vi.fn(() => () => undefined)
onWeixinBindingChanged: vi.fn(() => () => undefined)
}
}
function settingsApi() {
return {
getRuntime: vi.fn(async () => runtimeSettings),
selectWorkspace: vi.fn(async () => undefined)
}
}
@@ -122,13 +181,12 @@ describe('ChannelSettingsSection', () => {
list: vi.fn(async () => projects),
update: updateProject
},
settings: {
selectWorkspace: vi.fn(async () => undefined)
}
settings: settingsApi()
} as unknown as DesktopApi
})
render(<ChannelSettingsSection />)
const onNotify = vi.fn()
render(<ChannelSettingsSection onNotify={onNotify} />)
fireEvent.click(
await screen.findByRole('tab', { name: '企业微信' })
)
@@ -146,13 +204,21 @@ describe('ChannelSettingsSection', () => {
fireEvent.change(screen.getByLabelText('企业微信允许的发送者 ID'), {
target: { value: 'user-1\nuser-2\nuser-1' }
})
fireEvent.change(screen.getByLabelText('企业微信默认工作目录'), {
fireEvent.change(screen.getByLabelText('企业微信 默认工作目录'), {
target: { value: 'C:\\RemoteWorkspace' }
})
fireEvent.change(screen.getByLabelText('企业微信 消息处理后端'), {
target: {
value: agentRuntimeSelectionKey({
provider: 'model',
profileId: directProfileId
})
}
})
fireEvent.click(
within(
screen.getByRole('group', {
name: '企业微信默认模式'
name: '企业微信 默认模式'
})
).getByRole('button', { name: '执行' })
)
@@ -163,15 +229,16 @@ describe('ChannelSettingsSection', () => {
projects[1]!.id,
expect.objectContaining({
rootPath: 'C:\\RemoteWorkspace',
defaultWorkMode: 'execute'
defaultWorkMode: 'execute',
runtimeSelection: {
provider: 'model',
profileId: directProfileId
}
})
)
await waitFor(() =>
expect(apply).toHaveBeenCalledWith({
weixin: {
enabled: false
},
wecom: {
enabled: true,
botId: 'bot-1',
@@ -185,8 +252,11 @@ describe('ChannelSettingsSection', () => {
})
)
expect(screen.queryByDisplayValue('channel-secret')).toBeNull()
expect(await screen.findByText('消息通道设置已保存并应用'))
.toBeInTheDocument()
expect(onNotify).toHaveBeenCalledWith({
tone: 'success',
message: '消息通道设置已保存并应用',
dedupeKey: 'channel-settings-saved'
})
})
it('tests environment-owned channels without exposing draft credentials', async () => {
@@ -207,13 +277,12 @@ describe('ChannelSettingsSection', () => {
list: vi.fn(async () => projects),
update: vi.fn()
},
settings: {
selectWorkspace: vi.fn(async () => undefined)
}
settings: settingsApi()
} as unknown as DesktopApi
})
render(<ChannelSettingsSection />)
const onNotify = vi.fn()
render(<ChannelSettingsSection onNotify={onNotify} />)
fireEvent.click(
await screen.findByRole('tab', { name: '钉钉' })
)
@@ -227,7 +296,11 @@ describe('ChannelSettingsSection', () => {
undefined
)
)
expect(screen.getByText('钉钉连接成功')).toBeInTheDocument()
expect(onNotify).toHaveBeenCalledWith({
tone: 'success',
message: '钉钉连接成功',
dedupeKey: 'channel-test-dingtalk'
})
})
it('focuses and restores the Weixin binding trigger', async () => {
@@ -245,9 +318,7 @@ describe('ChannelSettingsSection', () => {
list: vi.fn(async () => projects),
update: vi.fn()
},
settings: {
selectWorkspace: vi.fn(async () => undefined)
}
settings: settingsApi()
} as unknown as DesktopApi
})
@@ -268,6 +339,169 @@ describe('ChannelSettingsSection', () => {
expect(trigger).toHaveFocus()
})
it('shows Weixin verification failures inside the QR dialog', async () => {
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
channels: {
...bindingApi(),
getSnapshot: vi.fn(async () => snapshot),
startWeixinBinding: vi.fn(async () => ({
status: 'verification_required' as const,
qrPayload: 'verification-qr'
})),
submitWeixinVerification: vi.fn(async () => {
throw new Error('验证码不正确,请重新输入')
}),
apply: vi.fn(),
testConnection: vi.fn()
},
projects: {
list: vi.fn(async () => projects),
update: vi.fn()
},
settings: settingsApi()
} as unknown as DesktopApi
})
render(<ChannelSettingsSection />)
fireEvent.click(
await screen.findByRole('button', { name: '扫码绑定' })
)
const verificationInput = await screen.findByLabelText('验证码')
fireEvent.change(verificationInput, { target: { value: '123456' } })
fireEvent.click(
screen.getByRole('button', { name: '提交验证码' })
)
const error = await screen.findByRole('alert')
expect(error).toHaveTextContent('验证码不正确,请重新输入')
expect(verificationInput).toHaveAttribute(
'aria-describedby',
error.id
)
await waitFor(() => expect(verificationInput).toHaveFocus())
})
it('defaults Weixin to a direct text model and also offers Agent Runtimes', async () => {
const updateProject = vi.fn(async (
projectId: string,
input: ProjectCreateInput
) => ({
...projects.find((project) => project.id === projectId)!,
...input
}))
const apply = vi.fn(async () => snapshot)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
channels: {
...bindingApi(),
getSnapshot: vi.fn(async () => snapshot),
apply,
testConnection: vi.fn()
},
projects: {
list: vi.fn(async () => projects),
update: updateProject
},
settings: settingsApi()
} as unknown as DesktopApi
})
render(<ChannelSettingsSection />)
const backend = await screen.findByLabelText(
'微信 ClawBot 消息处理后端'
)
expect(backend).toHaveValue(
agentRuntimeSelectionKey({
provider: 'model',
profileId: directProfileId
})
)
expect(
within(backend).queryByRole('option', {
name: //u
})
).not.toBeInTheDocument()
expect(
within(backend).getByRole('option', {
name: '默认模型 · text-model'
})
).toBeInTheDocument()
expect(
within(backend).queryByRole('option', {
name: '图片模型 · image-model'
})
).not.toBeInTheDocument()
expect(
within(backend).queryByRole('option', {
name: '未配置模型 · missing-key-model'
})
).not.toBeInTheDocument()
expect(
within(backend).getByRole('option', { name: 'OpenCode' })
).toBeInTheDocument()
expect(
within(backend).getByRole('option', { name: 'Continue' })
).toBeInTheDocument()
fireEvent.change(backend, {
target: {
value: agentRuntimeSelectionKey({ provider: 'opencode' })
}
})
fireEvent.click(
screen.getByRole('button', { name: '保存通道设置' })
)
await waitFor(() =>
expect(updateProject).toHaveBeenCalledWith(
projects[0]!.id,
expect.objectContaining({
runtimeSelection: { provider: 'opencode' }
})
)
)
expect(apply).not.toHaveBeenCalled()
})
it('validates every project root before saving any channel', async () => {
const updateProject = vi.fn()
const apply = vi.fn()
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
channels: {
...bindingApi(),
getSnapshot: vi.fn(async () => snapshot),
apply,
testConnection: vi.fn()
},
projects: {
list: vi.fn(async () => projects),
update: updateProject
},
settings: settingsApi()
} as unknown as DesktopApi
})
render(<ChannelSettingsSection />)
fireEvent.change(
await screen.findByLabelText('微信 ClawBot 默认工作目录'),
{ target: { value: '' } }
)
fireEvent.click(
screen.getByRole('button', { name: '保存通道设置' })
)
expect(
await screen.findByText('微信 ClawBot 必须设置默认工作目录')
).toBeInTheDocument()
expect(updateProject).not.toHaveBeenCalled()
expect(apply).not.toHaveBeenCalled()
})
it('presents the three channel configurations as keyboard tabs', async () => {
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
@@ -282,9 +516,7 @@ describe('ChannelSettingsSection', () => {
list: vi.fn(async () => projects),
update: vi.fn()
},
settings: {
selectWorkspace: vi.fn(async () => undefined)
}
settings: settingsApi()
} as unknown as DesktopApi
})
@@ -292,6 +524,7 @@ describe('ChannelSettingsSection', () => {
const tablist = await screen.findByRole('tablist', {
name: '消息通道配置'
})
expect(tablist).toHaveClass('page-tabs--segmented')
const weixinTab = within(tablist).getByRole('tab', {
name: '微信 ClawBot'
})
+304 -32
View File
@@ -16,6 +16,7 @@ import type {
DingTalkChannelSettingsInput,
WeComChannelSettingsInput
} from '../../shared/channel-settings-contracts'
import type { RuntimeSettings } from '../../shared/contracts'
import {
normalizeInteractiveWorkMode,
projectChannels,
@@ -23,7 +24,14 @@ import {
type InteractiveWorkMode,
type ProjectChannel
} from '../../shared/assistant-contracts'
import {
agentRuntimeSelectionKey,
isChannelModelProfileUsable,
repairChannelRuntimeSelection,
type AgentRuntimeSelection
} from '../../shared/runtime-selection-contracts'
import type { WeixinBindingSnapshot } from '../../shared/weixin-channel-contracts'
import type { AppNotificationInput } from './notifications'
import { trapTabFocus } from './dialog-focus'
import { PageTabs, SegmentedControl } from './WorkspacePrimitives'
@@ -42,6 +50,7 @@ type ChannelProjectDraft = {
description: string
rootPath: string
defaultWorkMode: InteractiveWorkMode
runtimeSelection: AgentRuntimeSelection
}
const channelOrder: readonly ProjectChannel[] = projectChannels
@@ -131,8 +140,35 @@ function inputFor(
: { ...common, clientId: draft.identifier.trim() }
}
function channelDraftChanged(
channel: CredentialChannel,
draft: ChannelDraft,
snapshot: ChannelSettingsSnapshot
): boolean {
const current = snapshot[channel]
const nextAllowedSenders = allowedSenderIds(
draft.allowedSenderIdsText
)
const currentIdentifier =
channel === 'wecom'
? snapshot.wecom.botId
: snapshot.dingtalk.clientId
return (
draft.enabled !== current.enabled ||
draft.identifier.trim() !== currentIdentifier ||
draft.secret.trim().length > 0 ||
draft.clearSecret ||
draft.allowGroupMessages !== current.allowGroupMessages ||
nextAllowedSenders.length !== current.allowedSenderIds.length ||
nextAllowedSenders.some(
(senderId) => !current.allowedSenderIds.includes(senderId)
)
)
}
function projectDraftsFrom(
projects: AssistantProject[]
projects: AssistantProject[],
runtimeSettings: RuntimeSettings
): Partial<Record<ProjectChannel, ChannelProjectDraft>> {
return Object.fromEntries(
projects
@@ -152,24 +188,115 @@ function projectDraftsFrom(
rootPath: project.rootPath,
defaultWorkMode: normalizeInteractiveWorkMode(
project.defaultWorkMode
),
runtimeSelection: repairChannelRuntimeSelection(
project.runtimeSelection ?? { provider: 'auto' },
runtimeSettings
)
}
])
)
}
function usableChannelModelProfiles(
settings: RuntimeSettings
): RuntimeSettings['modelProfiles'] {
return settings.modelProfiles.filter(
isChannelModelProfileUsable
)
}
function configuredRuntimeSelection(
provider: 'opencode' | 'continue'
): AgentRuntimeSelection {
return { provider }
}
function runtimeSelectionDescription(
selection: AgentRuntimeSelection,
settings: RuntimeSettings
): string {
if (selection.provider === 'model') {
const profile = settings.modelProfiles.find(
(candidate) => candidate.id === selection.profileId
)
if (!profile) {
return '所选直连模型已不存在,请重新选择。'
}
if (profile.protocol === 'openai-images-generations') {
return '所选连接仅支持图片生成,请选择文本模型或 Agent Runtime。'
}
if (
profile.authentication === 'api-key' &&
!profile.apiKeyConfigured
) {
return '所选直连模型尚未配置密钥,请先到模型连接中完成配置。'
}
return `直接使用 ${profile.name}${profile.modelName})处理消息。`
}
if (selection.provider === 'auto') {
return '使用模型设置中的默认直连模型处理消息。'
}
const runtimeLabel =
selection.provider === 'opencode' ? 'OpenCode' : 'Continue'
const profile =
'profileId' in selection
? settings.modelProfiles.find(
(candidate) => candidate.id === selection.profileId
)
: undefined
return profile
? `通过 ${runtimeLabel} Agent Runtime 运行,并使用 ${profile.name}`
: `通过 ${runtimeLabel} Agent Runtime 及其当前模型配置运行。`
}
function ChannelProjectControls({
draft,
onChange,
onSelectRoot
onSelectRoot,
runtimeSettings
}: {
draft: ChannelProjectDraft
onChange: (draft: ChannelProjectDraft) => void
onSelectRoot: () => void
runtimeSettings: RuntimeSettings
}): React.JSX.Element {
const openCodeSelection = configuredRuntimeSelection(
'opencode'
)
const continueSelection = configuredRuntimeSelection(
'continue'
)
const directProfiles = usableChannelModelProfiles(runtimeSettings)
const selectedDirectProfileId =
draft.runtimeSelection.provider === 'model'
? draft.runtimeSelection.profileId
: undefined
const selectedDirectProfile = runtimeSettings.modelProfiles.find(
(profile) => profile.id === selectedDirectProfileId
)
const selectedDirectUnavailable =
selectedDirectProfileId !== undefined &&
!directProfiles.some(
(profile) => profile.id === selectedDirectProfileId
)
const selections = [
...directProfiles.map((profile) => ({
provider: 'model' as const,
profileId: profile.id
})),
openCodeSelection,
continueSelection
]
const selectionByKey = new Map(
selections.map((selection) => [
agentRuntimeSelectionKey(selection),
selection
])
)
return (
<section
aria-label={`${draft.name}通道项目设置`}
aria-label={`${draft.name} 通道项目设置`}
className="channel-project-settings"
>
<div className="channel-project-settings__identity">
@@ -180,7 +307,7 @@ function ChannelProjectControls({
<span></span>
<div className="channel-project-settings__root">
<input
aria-label={`${draft.name}默认工作目录`}
aria-label={`${draft.name} 默认工作目录`}
maxLength={4_096}
onChange={(event) =>
onChange({ ...draft, rootPath: event.target.value })
@@ -188,7 +315,7 @@ function ChannelProjectControls({
value={draft.rootPath}
/>
<button
aria-label={`选择${draft.name}默认工作目录`}
aria-label={`选择 ${draft.name} 默认工作目录`}
className="secondary-button"
onClick={onSelectRoot}
type="button"
@@ -199,10 +326,71 @@ function ChannelProjectControls({
</div>
<small> Execute </small>
</label>
<label className="field">
<span></span>
<select
aria-label={`${draft.name} 消息处理后端`}
onChange={(event) => {
const runtimeSelection = selectionByKey.get(
event.target.value
)
if (runtimeSelection) {
onChange({ ...draft, runtimeSelection })
}
}}
value={agentRuntimeSelectionKey(draft.runtimeSelection)}
>
<optgroup label="直连模型">
{selectedDirectUnavailable && (
<option
disabled
value={agentRuntimeSelectionKey(draft.runtimeSelection)}
>
{selectedDirectProfile
? `${selectedDirectProfile.name} · ${selectedDirectProfile.modelName}(不可用)`
: '原直连模型已不存在'}
</option>
)}
{directProfiles.length === 0 && (
<option disabled value="model:unavailable">
</option>
)}
{directProfiles.map((profile) => {
const selection = {
provider: 'model' as const,
profileId: profile.id
}
return (
<option
key={profile.id}
value={agentRuntimeSelectionKey(selection)}
>
{profile.name} · {profile.modelName}
</option>
)
})}
</optgroup>
<optgroup label="Agent Runtime">
<option value={agentRuntimeSelectionKey(openCodeSelection)}>
OpenCode
</option>
<option value={agentRuntimeSelectionKey(continueSelection)}>
Continue
</option>
</optgroup>
</select>
<small>
{runtimeSelectionDescription(
draft.runtimeSelection,
runtimeSettings
)}
</small>
</label>
<fieldset className="channel-work-mode">
<legend></legend>
<SegmentedControl
ariaLabel={`${draft.name}默认模式`}
ariaLabel={`${draft.name} 默认模式`}
onChange={(defaultWorkMode) =>
onChange({ ...draft, defaultWorkMode })
}
@@ -216,6 +404,12 @@ function ChannelProjectControls({
/ask/execute
</small>
</fieldset>
<p className="channel-project-settings__risk">
{draft.defaultWorkMode === 'execute'
? '执行消息会立即交给所选后端,不再逐次弹窗确认。'
: '默认对话时,白名单发送者仍可用 /execute 临时发起执行,且不会弹窗确认。'}
</p>
</section>
)
}
@@ -228,6 +422,7 @@ function ChannelEditor({
onSelectRoot,
onTest,
project,
runtimeSettings,
settings,
testing
}: {
@@ -238,6 +433,7 @@ function ChannelEditor({
onSelectRoot: () => void
onTest: () => void
project: ChannelProjectDraft
runtimeSettings: RuntimeSettings
settings: ChannelSettingsSnapshot[CredentialChannel]
testing: boolean
}): React.JSX.Element {
@@ -350,7 +546,7 @@ function ChannelEditor({
value={draft.allowedSenderIdsText}
/>
<small>
GoodBuddy
GoodBuddy
</small>
</label>
@@ -373,6 +569,7 @@ function ChannelEditor({
draft={project}
onChange={onProjectChange}
onSelectRoot={onSelectRoot}
runtimeSettings={runtimeSettings}
/>
<button
@@ -391,12 +588,14 @@ function ChannelEditor({
function WeixinQrDialog({
binding,
busy,
error,
onClose,
onRestart,
onVerify
}: {
binding: WeixinBindingSnapshot
busy: boolean
error?: string
onClose: () => void
onRestart: () => void
onVerify: (code: string) => void
@@ -409,17 +608,20 @@ function WeixinQrDialog({
const [now, setNow] = useState(0)
const dialogRef = useRef<HTMLElement>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
const verificationInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const frame = window.requestAnimationFrame(() => {
if (busy) {
dialogRef.current?.focus()
} else if (binding.status === 'verification_required') {
verificationInputRef.current?.focus()
} else {
closeButtonRef.current?.focus()
}
})
return () => window.cancelAnimationFrame(frame)
}, [busy])
}, [binding.status, busy, error])
useEffect(() => {
if (!binding.qrPayload) {
@@ -540,6 +742,9 @@ function WeixinQrDialog({
<label className="field">
<span></span>
<input
aria-describedby={
error ? 'channel-verification-error' : undefined
}
autoComplete="one-time-code"
inputMode="numeric"
maxLength={32}
@@ -548,9 +753,19 @@ function WeixinQrDialog({
event.target.value.replace(/\D/gu, '')
)
}
ref={verificationInputRef}
required
value={verificationCode}
/>
{error && (
<small
className="field-error"
id="channel-verification-error"
role="alert"
>
{error}
</small>
)}
</label>
<button
className="primary-button"
@@ -590,6 +805,7 @@ function WeixinChannelEditor({
binding,
bindingButtonRef,
bindingOpen,
bindingError,
busy,
enabled,
onBindingClose,
@@ -600,11 +816,13 @@ function WeixinChannelEditor({
onStartBinding,
onVerify,
project,
runtimeSettings,
settings
}: {
binding: WeixinBindingSnapshot
bindingButtonRef: React.RefObject<HTMLButtonElement | null>
bindingOpen: boolean
bindingError?: string
busy: boolean
enabled: boolean
onBindingClose: () => void
@@ -615,6 +833,7 @@ function WeixinChannelEditor({
onStartBinding: () => void
onVerify: (code: string) => void
project: ChannelProjectDraft
runtimeSettings: RuntimeSettings
settings: ChannelSettingsSnapshot['weixin']
}): React.JSX.Element {
return (
@@ -683,17 +902,22 @@ function WeixinChannelEditor({
</small>
)}
<small>
ClawBot 4 12MB
</small>
<ChannelProjectControls
draft={project}
onChange={onProjectChange}
onSelectRoot={onSelectRoot}
runtimeSettings={runtimeSettings}
/>
</article>
{bindingOpen && (
<WeixinQrDialog
binding={binding}
busy={busy}
error={bindingError}
onClose={onBindingClose}
onRestart={onStartBinding}
onVerify={onVerify}
@@ -703,8 +927,14 @@ function WeixinChannelEditor({
)
}
export function ChannelSettingsSection(): React.JSX.Element {
export function ChannelSettingsSection({
onNotify = () => undefined
}: {
onNotify?: (notification: AppNotificationInput) => void
}): React.JSX.Element {
const [snapshot, setSnapshot] = useState<ChannelSettingsSnapshot>()
const [runtimeSettings, setRuntimeSettings] =
useState<RuntimeSettings>()
const [projects, setProjects] = useState<
Partial<Record<ProjectChannel, ChannelProjectDraft>>
>({})
@@ -713,6 +943,7 @@ export function ChannelSettingsSection(): React.JSX.Element {
status: 'stopped'
})
const [bindingOpen, setBindingOpen] = useState(false)
const [bindingError, setBindingError] = useState<string>()
const [activeChannel, setActiveChannel] =
useState<ProjectChannel>('weixin')
const [drafts, setDrafts] = useState<
@@ -724,7 +955,6 @@ export function ChannelSettingsSection(): React.JSX.Element {
const [busy, setBusy] = useState(false)
const [testing, setTesting] = useState<CredentialChannel>()
const [error, setError] = useState<string>()
const [notice, setNotice] = useState<string>()
const bindingButtonRef = useRef<HTMLButtonElement>(null)
const closeBinding = useCallback((): void => {
@@ -751,13 +981,17 @@ export function ChannelSettingsSection(): React.JSX.Element {
return Promise.all([
api.getSnapshot(),
window.goodbuddy.projects.list(false),
api.getWeixinBinding()
api.getWeixinBinding(),
window.goodbuddy.settings.getRuntime()
])
})()
.then(([next, projectList, bindingSnapshot]) => {
.then(([next, projectList, bindingSnapshot, nextRuntimeSettings]) => {
if (active) {
applySnapshot(next)
setProjects(projectDraftsFrom(projectList))
setRuntimeSettings(nextRuntimeSettings)
setProjects(
projectDraftsFrom(projectList, nextRuntimeSettings)
)
setBinding(bindingSnapshot)
}
})
@@ -787,7 +1021,7 @@ export function ChannelSettingsSection(): React.JSX.Element {
const save = async (): Promise<void> => {
const api = window.goodbuddy.channels
if (!api || !snapshot) {
if (!api || !snapshot || !runtimeSettings) {
return
}
const channelProjects = channelOrder.map(
@@ -797,18 +1031,33 @@ export function ChannelSettingsSection(): React.JSX.Element {
setError('通道项目尚未加载')
return
}
const invalidRootIndex = channelProjects.findIndex(
(project) => project!.rootPath.trim().length === 0
)
if (invalidRootIndex >= 0) {
const invalidChannel = channelOrder[invalidRootIndex]!
setActiveChannel(invalidChannel)
setError(
`${channelTabs[invalidRootIndex]!.label} 必须设置默认工作目录`
)
return
}
const input: ChannelSettingsApply = {
weixin: { enabled: weixinEnabled },
...(snapshot.wecom.readOnly
...(weixinEnabled === snapshot.weixin.enabled
? {}
: { wecom: inputFor('wecom', drafts.wecom) }),
...(snapshot.dingtalk.readOnly
? {}
: { dingtalk: inputFor('dingtalk', drafts.dingtalk) })
: { weixin: { enabled: weixinEnabled } }),
...(!snapshot.wecom.readOnly &&
channelDraftChanged('wecom', drafts.wecom, snapshot)
? { wecom: inputFor('wecom', drafts.wecom) }
: {}),
...(!snapshot.dingtalk.readOnly &&
channelDraftChanged('dingtalk', drafts.dingtalk, snapshot)
? { dingtalk: inputFor('dingtalk', drafts.dingtalk) }
: {})
}
setBusy(true)
setError(undefined)
setNotice(undefined)
setBindingError(undefined)
try {
const updatedProjects = await Promise.all(
channelProjects.map((project) =>
@@ -816,13 +1065,20 @@ export function ChannelSettingsSection(): React.JSX.Element {
name: project!.name,
description: project!.description,
rootPath: project!.rootPath,
defaultWorkMode: project!.defaultWorkMode
defaultWorkMode: project!.defaultWorkMode,
runtimeSelection: project!.runtimeSelection
})
)
)
setProjects(projectDraftsFrom(updatedProjects))
applySnapshot(await api.apply(input))
setNotice('消息通道设置已保存并应用')
setProjects(projectDraftsFrom(updatedProjects, runtimeSettings))
if (Object.keys(input).length > 0) {
applySnapshot(await api.apply(input))
}
onNotify({
tone: 'success',
message: '消息通道设置已保存并应用',
dedupeKey: 'channel-settings-saved'
})
} catch (reason) {
setError(reason instanceof Error ? reason.message : '保存消息通道设置失败')
} finally {
@@ -863,6 +1119,7 @@ export function ChannelSettingsSection(): React.JSX.Element {
}
setBusy(true)
setError(undefined)
setBindingError(undefined)
setBindingOpen(true)
try {
setBinding(await api.startWeixinBinding())
@@ -887,10 +1144,11 @@ export function ChannelSettingsSection(): React.JSX.Element {
}
setBusy(true)
setError(undefined)
setBindingError(undefined)
try {
setBinding(await api.submitWeixinVerification(code))
} catch (reason) {
setError(
setBindingError(
reason instanceof Error ? reason.message : '提交微信验证码失败'
)
} finally {
@@ -905,11 +1163,14 @@ export function ChannelSettingsSection(): React.JSX.Element {
}
setBusy(true)
setError(undefined)
setNotice(undefined)
try {
setBinding(await api.disconnectWeixin())
applySnapshot(await api.getSnapshot())
setNotice('已删除本机保存的微信绑定')
onNotify({
tone: 'success',
message: '已删除本机保存的微信绑定',
dedupeKey: 'weixin-binding-disconnected'
})
} catch (reason) {
setError(
reason instanceof Error ? reason.message : '断开微信绑定失败'
@@ -926,7 +1187,6 @@ export function ChannelSettingsSection(): React.JSX.Element {
}
setTesting(channel)
setError(undefined)
setNotice(undefined)
try {
const settings = snapshot[channel].readOnly
? undefined
@@ -938,7 +1198,14 @@ export function ChannelSettingsSection(): React.JSX.Element {
if (!result.ok) {
throw new Error(result.error)
}
setNotice(channel === 'wecom' ? '企业微信连接成功' : '钉钉连接成功')
onNotify({
tone: 'success',
message:
channel === 'wecom'
? '企业微信连接成功'
: '钉钉连接成功',
dedupeKey: `channel-test-${channel}`
})
} catch (reason) {
setError(reason instanceof Error ? reason.message : '通道连接测试失败')
} finally {
@@ -951,6 +1218,7 @@ export function ChannelSettingsSection(): React.JSX.Element {
const dingtalkProject = projects.dingtalk
if (
!snapshot ||
!runtimeSettings ||
!weixinProject ||
!wecomProject ||
!dingtalkProject
@@ -974,7 +1242,7 @@ export function ChannelSettingsSection(): React.JSX.Element {
<div>
<strong id="channel-settings-heading"></strong>
<small>
</small>
</div>
<button
@@ -990,7 +1258,6 @@ export function ChannelSettingsSection(): React.JSX.Element {
{snapshot.warning && <p className="settings-warning">{snapshot.warning}</p>}
{error && <p className="settings-warning" role="alert">{error}</p>}
{notice && <p className="settings-success" role="status">{notice}</p>}
<div className="channel-settings__tabs">
<PageTabs
@@ -999,6 +1266,7 @@ export function ChannelSettingsSection(): React.JSX.Element {
onChange={setActiveChannel}
tabs={channelTabs}
value={activeChannel}
variant="segmented"
/>
</div>
@@ -1012,6 +1280,7 @@ export function ChannelSettingsSection(): React.JSX.Element {
<WeixinChannelEditor
binding={binding}
bindingButtonRef={bindingButtonRef}
bindingError={bindingError}
bindingOpen={bindingOpen}
busy={busy}
enabled={weixinEnabled}
@@ -1025,6 +1294,7 @@ export function ChannelSettingsSection(): React.JSX.Element {
onStartBinding={() => void startBinding()}
onVerify={(code) => void verifyBinding(code)}
project={weixinProject}
runtimeSettings={runtimeSettings}
settings={snapshot.weixin}
/>
) : activeChannel === 'wecom' ? (
@@ -1038,6 +1308,7 @@ export function ChannelSettingsSection(): React.JSX.Element {
onSelectRoot={() => void selectRoot('wecom')}
onTest={() => void test('wecom')}
project={wecomProject}
runtimeSettings={runtimeSettings}
settings={snapshot.wecom}
testing={testing === 'wecom'}
/>
@@ -1054,6 +1325,7 @@ export function ChannelSettingsSection(): React.JSX.Element {
onSelectRoot={() => void selectRoot('dingtalk')}
onTest={() => void test('dingtalk')}
project={dingtalkProject}
runtimeSettings={runtimeSettings}
settings={snapshot.dingtalk}
testing={testing === 'dingtalk'}
/>
@@ -1,72 +0,0 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { DesktopApi } from '../../shared/contracts'
import type { RemoteChannelApproval } from '../../shared/remote-channel-contracts'
import { RemoteChannelApprovalDialog } from './RemoteChannelApprovalDialog'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('RemoteChannelApprovalDialog', () => {
it('requires an explicit local one-time decision', async () => {
let publish: ((approval: RemoteChannelApproval) => void) | undefined
const respondRemoteApproval = vi.fn(async () => true)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
channels: {
getPendingRemoteApprovals: vi.fn(async () => []),
onRemoteApproval: vi.fn((listener) => {
publish = listener
return () => undefined
}),
respondRemoteApproval
}
} as unknown as DesktopApi
})
render(<RemoteChannelApprovalDialog />)
publish?.({
approvalId: '00000000-0000-4000-8000-000000000001',
requestId: '00000000-0000-4000-8000-000000000002',
kind: 'request',
channel: 'weixin',
channelLabel: '微信 ClawBot',
senderDisplay: '发送者 ****1234',
projectName: '微信 ClawBot',
rootPath: 'C:\\Users\\tester',
title: '请求执行任务',
description: '创建一份本地报告',
expiresAt: new Date(Date.now() + 60_000).toISOString()
})
expect(
await screen.findByRole('alertdialog', {
name: '确认远程执行请求'
})
).toBeInTheDocument()
expect(screen.getByText('创建一份本地报告')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /|/u })
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '仅批准本次执行' })
)
await waitFor(() =>
expect(respondRemoteApproval).toHaveBeenCalledWith(
'00000000-0000-4000-8000-000000000001',
'once'
)
)
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
})
@@ -1,222 +0,0 @@
import { ShieldCheck } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type {
RemoteChannelApproval,
RemoteChannelApprovalDecision
} from '../../shared/remote-channel-contracts'
import { trapTabFocus } from './dialog-focus'
export function RemoteChannelApprovalDialog(): React.JSX.Element | null {
const [requests, setRequests] = useState<RemoteChannelApproval[]>([])
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string>()
const dialogRef = useRef<HTMLDivElement>(null)
const current = requests[0]
useEffect(() => {
const api = window.goodbuddy.channels
if (!api) {
return
}
let active = true
void api
.getPendingRemoteApprovals()
.then((pending) => {
if (active) {
setRequests((existing) => {
const merged = new Map(
[...pending, ...existing].map((request) => [
request.approvalId,
request
])
)
return [...merged.values()]
})
}
})
.catch(() => undefined)
const remove = api.onRemoteApproval((approval) => {
setRequests((existing) =>
existing.some(
(candidate) => candidate.approvalId === approval.approvalId
)
? existing
: [...existing, approval]
)
})
return () => {
active = false
remove()
}
}, [])
useEffect(() => {
if (!current) {
return
}
const remaining = Math.max(
0,
new Date(current.expiresAt).getTime() - Date.now()
)
const timeout = window.setTimeout(() => {
setRequests((existing) =>
existing.filter(
(request) => request.approvalId !== current.approvalId
)
)
setError(undefined)
}, remaining)
return () => window.clearTimeout(timeout)
}, [current])
const respond = useCallback(
async (
decision: RemoteChannelApprovalDecision
): Promise<void> => {
if (!current || busy) {
return
}
const api = window.goodbuddy.channels
if (!api) {
setError('本机审批服务不可用')
return
}
setBusy(true)
setError(undefined)
try {
const accepted = await api.respondRemoteApproval(
current.approvalId,
decision
)
if (!accepted) {
throw new Error('审批请求已超时或不再有效')
}
setRequests((existing) => existing.slice(1))
} catch (reason) {
setError(
reason instanceof Error ? reason.message : '提交审批结果失败'
)
} finally {
setBusy(false)
}
},
[busy, current]
)
useEffect(() => {
if (!current) {
return
}
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape' && !busy) {
event.preventDefault()
void respond('deny')
return
}
trapTabFocus(event, dialogRef.current)
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [busy, current, respond])
if (!current) {
return null
}
return (
<div className="remote-approval-backdrop">
<section
aria-describedby="remote-approval-description"
aria-labelledby="remote-approval-title"
aria-modal="true"
className="remote-approval-dialog"
ref={dialogRef}
role="alertdialog"
>
<header>
<span className="remote-approval-dialog__icon">
<ShieldCheck aria-hidden="true" size={20} />
</span>
<div>
<strong id="remote-approval-title">
{current.kind === 'request'
? '确认远程执行请求'
: '确认远程工具调用'}
</strong>
<small>
{current.channelLabel} · {current.senderDisplay}
</small>
</div>
</header>
<div className="remote-approval-dialog__scope">
<span>{current.projectName}</span>
<span title={current.rootPath}>
{current.rootPath || '未设置'}
</span>
</div>
<div
className="remote-approval-dialog__request"
id="remote-approval-description"
>
<strong>{current.title}</strong>
<p>{current.description}</p>
{current.toolName && (
<dl>
<div>
<dt></dt>
<dd>{current.toolName}</dd>
</div>
{current.argumentSummary && (
<div>
<dt></dt>
<dd>{current.argumentSummary}</dd>
</div>
)}
</dl>
)}
</div>
<p className="remote-approval-dialog__warning">
</p>
{error && (
<p className="settings-warning" role="alert">
{error}
</p>
)}
<footer>
<button
autoFocus
className="secondary-button"
disabled={busy}
onClick={() => void respond('deny')}
type="button"
>
</button>
<button
className="primary-button"
disabled={busy}
onClick={() => void respond('once')}
type="button"
>
{busy
? '提交中…'
: current.kind === 'request'
? '仅批准本次执行'
: '仅允许本次调用'}
</button>
</footer>
{requests.length > 1 && (
<small className="remote-approval-dialog__queue">
{requests.length - 1}
</small>
)}
</section>
</div>
)
}
+21
View File
@@ -408,6 +408,27 @@ describe('SettingsPanel runtime files', () => {
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
})
it('keeps page navigation beside an independently scrollable panel', () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
presentation="page"
/>
)
const navigation = screen.getByRole('tablist', {
name: '设置分类'
})
const content = screen.getByRole('tabpanel')
expect(navigation.parentElement).toHaveClass('settings-panel__body')
expect(content.parentElement).toBe(navigation.parentElement)
expect(content).toHaveClass('settings-panel__content')
})
it('supports keyboard navigation between settings tabs', () => {
render(
<SettingsPanel
+8 -2
View File
@@ -38,6 +38,7 @@ import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { SegmentedControl } from './WorkspacePrimitives'
import type { AppearanceTheme } from './theme'
import type { AppNotificationInput } from './notifications'
import type {
EmbeddingDiagnosticResult,
EmbeddingSettingsSnapshot
@@ -79,6 +80,7 @@ type SettingsPanelProps = {
presentation?: 'modal' | 'page'
onClose: () => void
onSaved: (settings: RuntimeSettings) => void
onNotify?: (notification: AppNotificationInput) => void
onExpertsChanged?: (experts: AssistantExpert[]) => void
onClearLocalData: () => Promise<void>
heartbeats: AssistantHeartbeatConfig[]
@@ -244,6 +246,7 @@ export function SettingsPanel({
presentation = 'modal',
onClose,
onSaved,
onNotify,
onClearLocalData,
heartbeats,
onCreateHeartbeat,
@@ -966,7 +969,7 @@ export function SettingsPanel({
</button>
</header>
<div className="settings-panel__body" ref={settingsBodyRef}>
<div className="settings-panel__body">
<nav
aria-label="设置分类"
aria-orientation="vertical"
@@ -1139,6 +1142,7 @@ export function SettingsPanel({
aria-labelledby={`settings-tab-${activeTab}`}
className="settings-panel__content"
id={`settings-panel-${activeTab}`}
ref={settingsBodyRef}
role="tabpanel"
>
{activeTab === 'appearance' && (
@@ -2212,7 +2216,9 @@ export function SettingsPanel({
/>
</div>
)}
{activeTab === 'channels' && <ChannelSettingsSection />}
{activeTab === 'channels' && (
<ChannelSettingsSection onNotify={onNotify} />
)}
{activeTab === 'roles' && (
<>
<div className="settings-section subagent-routing-settings">
+401 -259
View File
@@ -41,6 +41,9 @@
--radius-control: 8px;
--radius-card: 12px;
--control-height: 36px;
--motion-fast: 120ms;
--motion-normal: 180ms;
--motion-slow: 240ms;
--font-page-title: 24px;
--font-section-title: 14px;
--font-body: 13px;
@@ -1143,6 +1146,10 @@ textarea:focus-visible {
overflow: auto;
}
.conversation-entry + .conversation-entry {
margin-top: var(--space-1);
}
.section-label {
padding: 0 9px;
margin: 0 0 7px;
@@ -3164,25 +3171,27 @@ textarea:focus-visible {
.composer-wrap {
padding:
8px
var(--space-3)
max(var(--page-gutter), calc((100% - var(--content-reading)) / 2))
15px;
background: #f5f5f5;
var(--space-4);
background: var(--surface-canvas);
}
.composer {
padding: 12px 13px 10px;
border: 1px solid #d9d9d9;
border-radius: 10px;
background: #fff;
box-shadow: 0 2px 8px rgb(0 0 0 / 6%);
padding: 0;
border: 1px solid var(--border-control);
border-radius: var(--radius-card);
background: var(--surface-raised);
transition:
border-color var(--motion-fast, 120ms) ease-out,
box-shadow var(--motion-fast, 120ms) ease-out;
}
.context-list {
display: flex;
padding: 0 1px 9px;
padding: var(--space-3) var(--space-4) 0;
flex-wrap: wrap;
gap: 7px;
gap: var(--space-2);
}
.context-chip {
@@ -3190,12 +3199,12 @@ textarea:focus-visible {
min-width: 150px;
max-width: 220px;
align-items: center;
padding: 7px 8px;
border: 1px solid #d9d9d9;
border-radius: 9px;
background: #fafafa;
color: #595959;
gap: 7px;
padding: var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-subtle);
color: var(--text-secondary);
gap: var(--space-2);
}
.context-chip > span {
@@ -3207,28 +3216,29 @@ textarea:focus-visible {
.context-chip strong {
overflow: hidden;
font-size: 9px;
color: var(--text-primary);
font-size: var(--font-body);
text-overflow: ellipsis;
white-space: nowrap;
}
.context-chip small {
color: #8c8c8c;
font-size: 8px;
color: var(--text-muted);
font-size: var(--font-caption);
}
.context-chip button {
width: 21px;
height: 21px;
border-radius: 6px;
width: 28px;
height: 28px;
border-radius: var(--radius-control);
background: transparent;
color: #8c8c8c;
color: var(--text-muted);
cursor: pointer;
}
.context-chip button:hover {
background: #fafafa;
color: #ff4d4f;
background: var(--danger-subtle);
color: var(--danger);
}
.window-capture-backdrop {
@@ -3251,107 +3261,6 @@ textarea:focus-visible {
place-items: center;
}
.remote-approval-backdrop {
position: fixed;
z-index: var(--z-dialog);
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
inset: 38px 0 0;
place-items: center;
}
.remote-approval-dialog {
display: grid;
width: min(580px, 100%);
max-height: min(720px, calc(100vh - 70px));
padding: var(--space-6);
overflow: auto;
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
gap: var(--space-4);
}
.remote-approval-dialog > header,
.remote-approval-dialog > footer {
display: flex;
align-items: center;
gap: var(--space-3);
}
.remote-approval-dialog > header > div {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.remote-approval-dialog > header small,
.remote-approval-dialog__queue {
color: var(--text-muted);
}
.remote-approval-dialog__icon {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 auto;
border-radius: var(--radius-control);
background: var(--surface-subtle);
color: var(--warning);
place-items: center;
}
.remote-approval-dialog__scope,
.remote-approval-dialog__request {
display: grid;
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-subtle);
gap: var(--space-2);
}
.remote-approval-dialog__scope span {
overflow: hidden;
color: var(--text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-approval-dialog__request p,
.remote-approval-dialog__request dd {
margin: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.remote-approval-dialog__request dl,
.remote-approval-dialog__request dl div {
display: grid;
margin: 0;
gap: var(--space-1);
}
.remote-approval-dialog__request dl {
gap: var(--space-2);
}
.remote-approval-dialog__request dt {
color: var(--text-muted);
font-size: var(--font-caption);
}
.remote-approval-dialog__warning {
margin: 0;
color: var(--warning);
}
.remote-approval-dialog > footer {
justify-content: flex-end;
}
.image-viewer-dialog {
display: grid;
width: min(1120px, 100%);
@@ -3479,104 +3388,182 @@ textarea:focus-visible {
}
.composer:focus-within {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgb(22 119 255 / 12%);
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent);
}
.composer textarea {
.composer__input {
padding: var(--space-4) var(--space-4) var(--space-3);
border-radius: calc(var(--radius-card) - 1px)
calc(var(--radius-card) - 1px) 0 0;
background: var(--surface-raised);
}
.context-list + .composer__input {
padding-top: var(--space-3);
}
.composer__input textarea {
display: block;
width: 100%;
min-height: 38px;
max-height: 160px;
padding: 2px 3px;
min-height: 72px;
max-height: 220px;
padding: 0;
border: 0;
overflow-y: auto;
outline: 0;
resize: none;
background: transparent;
color: #1f1f1f;
font-size: 13px;
line-height: 1.55;
background: transparent !important;
color: var(--text-primary);
font-size: var(--font-body);
line-height: 1.6;
}
.composer textarea::placeholder {
color: #8c8c8c;
.composer__input textarea::placeholder {
color: var(--text-muted);
opacity: 1;
}
.composer__toolbar {
display: flex;
min-width: 0;
min-height: 56px;
padding: 10px var(--space-3);
align-items: center;
gap: 6px;
min-height: 31px;
border-top: 1px solid var(--border-default);
border-radius: 0 0 calc(var(--radius-card) - 1px)
calc(var(--radius-card) - 1px);
background: var(--surface-subtle);
gap: var(--space-3);
}
.composer__attachments {
.composer__controls {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
gap: var(--space-3);
}
.composer__attachments button {
.composer__tool-group,
.composer__configuration {
display: flex;
height: 29px;
align-items: center;
padding: 0 7px;
border-radius: 7px;
gap: var(--space-2);
}
.composer__tool-group {
gap: 2px;
}
.composer__tool-group button,
.knowledge-scope > button {
display: grid;
min-width: 32px;
height: 32px;
align-items: center;
padding: 0 6px;
border: 1px solid transparent;
border-radius: var(--radius-control);
background: transparent;
color: #595959;
color: var(--text-secondary);
cursor: pointer;
gap: 5px;
place-items: center;
transition:
background var(--motion-fast, 120ms) ease-out,
border-color var(--motion-fast, 120ms) ease-out,
color var(--motion-fast, 120ms) ease-out;
}
.composer__attachments button:hover {
background: #e6f4ff;
color: #1677ff;
.composer__tool-group button:hover,
.knowledge-scope > button:hover {
border-color: var(--accent-selected);
background: var(--accent-subtle);
color: var(--accent);
}
.divider {
width: 1px;
height: 17px;
margin: 0 4px;
background: #f0f0f0;
.composer__tool-group button:disabled,
.knowledge-scope > button:disabled {
border-color: transparent;
background: transparent;
color: var(--text-muted);
cursor: default;
opacity: 0.55;
}
.composer__voice-button--recording {
position: relative;
border-color: var(--danger-border) !important;
background: var(--danger-subtle) !important;
color: var(--danger) !important;
}
.composer__voice-button--recording::after {
position: absolute;
top: 5px;
right: 5px;
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--danger);
content: "";
animation: composer-recording-pulse 1.4s ease-in-out infinite;
}
.composer__voice-button--processing {
border-color: var(--accent-selected) !important;
background: var(--accent-subtle) !important;
color: var(--accent) !important;
}
.composer__configuration {
min-width: 0;
padding-left: var(--space-3);
border-left: 1px solid var(--border-default);
}
.composer__expert,
.composer__mode {
display: flex;
height: 29px;
height: 34px;
min-width: 0;
align-items: center;
border: 1px solid;
border-radius: 7px;
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--text-secondary);
gap: var(--space-1);
user-select: none;
}
.composer__expert {
max-width: 150px;
padding: 0 5px 0 8px;
border-color: var(--border-default);
background: var(--surface-subtle);
color: var(--text-secondary);
gap: 3px;
width: 124px;
padding-left: var(--space-2);
}
.composer__expert svg {
.composer__expert svg,
.composer__mode svg {
flex: 0 0 auto;
}
.composer__expert select,
.composer__mode select {
min-width: 0;
height: 100%;
padding: 0 var(--space-2) 0 0;
border: 0;
outline: 0;
background: transparent;
color: inherit;
cursor: pointer;
font-size: var(--font-caption);
user-select: none;
}
.composer__expert select {
min-width: 0;
max-width: 120px;
width: 96px;
overflow: hidden;
font-size: 10px;
text-overflow: ellipsis;
}
@@ -3587,77 +3574,115 @@ textarea:focus-visible {
}
.composer__mode {
padding: 0 4px 0 8px;
border-color: #91caff;
background: #e6f4ff;
color: #0958d9;
font-size: 10px;
font-weight: 700;
gap: 2px;
width: 144px;
padding-left: var(--space-2);
color: var(--text-primary);
}
.composer__mode select {
max-width: 138px;
font: inherit;
width: 116px;
font-weight: 600;
}
.composer__mode--ask svg {
color: var(--accent);
}
.composer__mode--execute {
border-color: #ffd591;
background: #fff7e6;
color: #ad4e00;
border-color: var(--border-default);
}
.composer__mode--execute svg {
color: var(--warning);
}
.model-button {
font-size: 10px;
display: flex;
width: 160px;
height: 34px;
min-width: 0;
align-items: center;
padding: 0 var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--text-primary);
cursor: pointer;
font-size: var(--font-caption);
font-weight: 600;
gap: var(--space-1);
user-select: none;
}
.model-button:hover:not(:disabled) {
border-color: var(--accent);
background: var(--accent-subtle);
color: var(--accent);
}
.model-button:disabled {
border-color: var(--border-default);
background: var(--surface-muted);
color: var(--text-muted);
cursor: default;
}
.model-button__label {
min-width: 0;
overflow: hidden;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.runtime-picker {
position: relative;
min-width: 0;
}
.runtime-picker__menu {
position: absolute;
z-index: 25;
right: 0;
bottom: 36px;
bottom: calc(100% + var(--space-2));
display: flex;
width: 286px;
width: 300px;
max-height: min(420px, 60vh);
flex-direction: column;
padding: 8px;
border: 1px solid #d9d9d9;
border-radius: 10px;
padding: var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
overflow-y: auto;
background: #fff;
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
gap: 3px;
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
gap: var(--space-1);
}
.runtime-picker__menu > strong {
padding: 5px 7px 3px;
color: #8c8c8c;
font-size: 9px;
padding: var(--space-2) var(--space-2) var(--space-1);
color: var(--text-muted);
font-size: var(--font-caption);
}
.runtime-picker__menu > button {
display: grid;
width: 100%;
height: auto;
min-height: 42px;
min-height: 44px;
align-items: center;
padding: 7px 8px;
border-radius: 7px;
color: #1f1f1f;
gap: 2px 7px;
padding: var(--space-2);
border-radius: var(--radius-control);
color: var(--text-primary);
cursor: pointer;
gap: var(--space-1) var(--space-2);
grid-template-columns: minmax(0, 1fr) auto;
text-align: left;
}
.runtime-picker__menu > button:hover,
.runtime-picker__menu > button[aria-checked="true"] {
background: #e6f4ff;
color: #0958d9;
background: var(--accent-subtle);
color: var(--accent);
}
.runtime-picker__menu > button[aria-checked="true"]::after {
@@ -3671,17 +3696,17 @@ textarea:focus-visible {
display: flex;
min-width: 0;
align-items: center;
font-size: 10px;
font-size: var(--font-body);
font-weight: 650;
gap: 5px;
gap: var(--space-1);
}
.runtime-picker__menu > button > small {
grid-column: 1;
grid-row: 2;
overflow: hidden;
color: #8c8c8c;
font-size: 8px;
color: var(--text-muted);
font-size: var(--font-caption);
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -3700,44 +3725,97 @@ textarea:focus-visible {
.runtime-picker__divider {
height: 1px;
margin: 4px;
background: #f0f0f0;
margin: var(--space-1);
background: var(--border-subtle);
}
.send-button {
display: grid;
width: 32px;
height: 32px;
width: 36px;
height: 36px;
flex: 0 0 36px;
place-items: center;
border-radius: 9px;
margin-left: auto;
background: #1677ff;
color: #fff;
border-radius: var(--radius-control);
background: var(--accent-solid);
color: var(--text-on-accent);
cursor: pointer;
}
.send-button:hover {
background: #4096ff;
background: var(--accent-solid-hover);
}
.send-button:disabled {
background: #f5f5f5;
color: #8c8c8c;
border: 1px solid var(--border-default);
background: var(--surface-muted);
color: var(--text-muted);
cursor: default;
box-shadow: none;
}
.send-button--stop {
background: #ff4d4f;
background: var(--danger-solid);
}
.composer-hint {
margin: 7px 0 0;
color: #8c8c8c;
font-size: 9px;
display: flex;
margin: var(--space-2) 0 0;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: var(--font-caption);
flex-wrap: wrap;
gap: var(--space-1) var(--space-3);
line-height: 1.45;
text-align: center;
}
.composer-hint--error {
color: var(--danger);
}
.composer-hint__shortcut {
color: var(--text-muted);
white-space: nowrap;
}
.composer-hint kbd {
padding: 1px var(--space-1);
border: 1px solid var(--border-default);
border-radius: 4px;
background: var(--surface-subtle);
color: var(--text-secondary);
font-family: var(--font-family-mono);
font-size: 10px;
}
@container (max-width: 700px) {
.composer__configuration {
width: 100%;
padding-left: 0;
padding-top: 10px;
border-top: 1px solid var(--border-default);
border-left: 0;
}
.composer__expert,
.composer__mode,
.runtime-picker {
flex: 1 1 150px;
}
.composer__expert,
.composer__mode,
.model-button {
width: 100%;
}
.composer__expert select,
.composer__mode select {
width: 100%;
}
}
.settings-backdrop {
position: fixed;
z-index: 50;
@@ -3853,20 +3931,32 @@ textarea:focus-visible {
.settings-page .settings-panel__body {
display: grid;
min-height: 0;
padding: 24px 32px 32px;
align-items: start;
overflow: hidden;
align-items: stretch;
grid-template-columns: 190px minmax(0, 760px);
justify-content: center;
gap: 28px;
}
.settings-page .settings-tabs {
position: sticky;
top: 0;
display: flex;
min-height: 0;
max-height: 100%;
padding: 6px;
overflow-y: auto;
align-self: stretch;
flex-direction: column;
grid-template-columns: none;
scrollbar-gutter: stable;
}
.settings-page .settings-panel__content {
min-height: 0;
padding-right: var(--space-1);
overflow-y: auto;
scrollbar-gutter: stable;
}
.settings-page .settings-tabs button {
@@ -4352,6 +4442,17 @@ details.settings-section > :not(summary) {
color: var(--text-muted);
}
.channel-project-settings__risk {
padding: var(--space-3);
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
margin: 0;
background: var(--surface-subtle);
color: var(--warning);
font-size: var(--font-caption);
line-height: 1.5;
}
.channel-qr-backdrop {
position: fixed;
z-index: var(--z-dialog);
@@ -5435,60 +5536,87 @@ details.settings-section > :not(summary) {
position: relative;
}
.knowledge-scope > button {
display: flex;
gap: var(--space-1);
}
.knowledge-scope > button > span {
display: flex;
align-items: center;
font-size: var(--font-caption);
font-weight: 600;
gap: var(--space-1);
white-space: nowrap;
}
.knowledge-scope > button strong {
display: grid;
min-width: 18px;
height: 18px;
padding: 0 var(--space-1);
border-radius: 999px;
background: var(--surface-muted);
color: var(--text-primary);
font-size: 10px;
place-items: center;
}
.knowledge-scope__popover {
position: absolute;
z-index: 20;
bottom: 36px;
bottom: calc(100% + var(--space-2));
left: 0;
display: flex;
width: 270px;
max-height: 260px;
flex-direction: column;
padding: 10px;
border: 1px solid #d9d9d9;
border-radius: 8px;
padding: var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
overflow-y: auto;
background: #fff;
box-shadow: 0 6px 16px rgb(0 0 0 / 8%);
gap: 4px;
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
gap: var(--space-1);
}
.knowledge-scope__popover > strong {
padding: 3px 5px 7px;
color: #1f1f1f;
font-size: 10px;
padding: var(--space-1) var(--space-2) var(--space-2);
color: var(--text-primary);
font-size: var(--font-body);
}
.knowledge-scope__popover label {
display: grid;
align-items: center;
padding: 7px 6px;
border-radius: 7px;
color: #595959;
min-height: 36px;
padding: var(--space-2);
border-radius: var(--radius-control);
color: var(--text-secondary);
cursor: pointer;
gap: 3px 7px;
gap: var(--space-1) var(--space-2);
grid-template-columns: auto 1fr auto;
}
.knowledge-scope__popover label:hover {
background: #e6f4ff;
color: #1677ff;
background: var(--accent-subtle);
color: var(--accent);
}
.knowledge-scope__popover label input {
accent-color: #1677ff;
accent-color: var(--accent-solid);
}
.knowledge-scope__popover label span {
overflow: hidden;
font-size: 10px;
font-size: var(--font-body);
text-overflow: ellipsis;
white-space: nowrap;
}
.knowledge-scope__popover label small {
color: #8c8c8c;
font-size: 8px;
color: var(--text-muted);
font-size: var(--font-caption);
}
.workspace-picker {
@@ -5629,6 +5757,7 @@ details.settings-section > :not(summary) {
min-height: 0;
padding: 0;
overflow: hidden;
container-type: inline-size;
grid-template-rows: minmax(0, 1fr) auto;
}
@@ -7407,7 +7536,6 @@ details.settings-section > :not(summary) {
.assistant-sidebar,
.project-create-card,
.settings-panel,
.composer,
.runtime-picker__menu,
.knowledge-scope__popover,
.knowledge-panel__document,
@@ -7432,7 +7560,6 @@ details.settings-section > :not(summary) {
.assistant-sidebar,
.settings-panel,
.project-create-card,
.composer,
.workspace-panel-scroll
) :where(input, textarea, select) {
border-color: #344258;
@@ -7446,8 +7573,7 @@ details.settings-section > :not(summary) {
.topbar,
.assistant-sidebar,
.settings-panel,
.project-create-card,
.composer
.project-create-card
) :where(input, textarea)::placeholder {
color: #718096;
}
@@ -7566,7 +7692,6 @@ details.settings-section > :not(summary) {
.runtime-picker__menu > button > small,
.message__meta span,
.message__status,
.composer-hint,
.settings-panel__description,
.settings-tabs button small,
.settings-section__title small,
@@ -7604,7 +7729,6 @@ details.settings-section > :not(summary) {
:root[data-theme='dark'] :where(
.nav-item--active,
.brand__mark,
.composer__mode--ask,
.runtime-capability-badge,
.model-capability-badge
) {
@@ -7771,6 +7895,19 @@ details.settings-section > :not(summary) {
}
}
@keyframes composer-recording-pulse {
0%,
100% {
opacity: 0.45;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 1279px) {
.assistant-sidebar {
position: absolute;
@@ -7820,28 +7957,33 @@ details.settings-section > :not(summary) {
}
.settings-page .settings-panel__body {
display: flex;
display: grid;
padding: 20px;
overflow: hidden;
align-items: stretch;
justify-content: flex-start;
grid-template-columns: 176px minmax(0, 1fr);
justify-content: stretch;
gap: var(--space-4);
}
.settings-page .settings-tabs {
position: static;
display: grid;
width: 100%;
flex: 0 0 auto;
grid-template-columns: repeat(3, 1fr);
display: flex;
width: auto;
min-height: 0;
overflow-y: auto;
flex-direction: column;
grid-template-columns: none;
}
.settings-page .settings-tabs button {
min-height: 42px;
align-items: center;
text-align: center;
min-height: 52px;
align-items: flex-start;
text-align: left;
}
.settings-page .settings-tabs button small {
display: none;
display: block;
}
.heartbeat-center__metrics {