feat: add secure remote channel media
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user