chore: prepare GoodBuddy 0.8.2
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions

This commit is contained in:
lofyer
2026-08-06 22:47:13 +08:00
parent 8d00e6371d
commit b8fc7bc86e
114 changed files with 22916 additions and 1560 deletions
+804 -17
View File
@@ -13,6 +13,18 @@ import type {
BrowserLiveState,
DesktopApi
} from '../../shared/contracts'
const speechRecognitionMocks = vi.hoisted(() => ({
startPcmRecording: vi.fn()
}))
vi.mock('./speech-recognition', async (importOriginal) => ({
...(await importOriginal<
typeof import('./speech-recognition')
>()),
startPcmRecording: speechRecognitionMocks.startPcmRecording
}))
import App from './App'
let agentListener: ((event: AgentEvent) => void) | undefined
@@ -41,7 +53,7 @@ const api: DesktopApi = {
version: '0.1.0',
platform: 'win32',
arch: 'x64',
shortcut: 'CommandOrControl+Shift+Space'
shortcut: 'Ctrl+Shift+Space'
})),
show: vi.fn(async () => {}),
hide: vi.fn(async () => {}),
@@ -89,6 +101,10 @@ const api: DesktopApi = {
}
})
},
speech: {
transcribe: vi.fn(async () => ({ text: '本地语音结果' })),
cancel: vi.fn(async () => true)
},
settings: {
getRuntime: vi.fn<DesktopApi['settings']['getRuntime']>(async () => ({
provider: 'auto',
@@ -106,6 +122,7 @@ const api: DesktopApi = {
continueMode: 'chat',
runtimeSandboxMode: 'auto',
subagentSmartRoutingEnabled: false,
intranetCompatibilityEnabled: true,
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl:
'http://127.0.0.1:11434/v1/embeddings',
@@ -129,8 +146,14 @@ const api: DesktopApi = {
}
],
defaultModelProfileId: modelProfileId,
opencodeModelSource: { kind: 'platform' },
continueModelSource: { kind: 'platform' },
opencodeModelSource: {
kind: 'profile',
profileId: modelProfileId
},
continueModelSource: {
kind: 'profile',
profileId: modelProfileId
},
secureStorageAvailable: true,
toolApproval: 'always'
})),
@@ -152,6 +175,8 @@ const api: DesktopApi = {
runtimeSandboxMode: input.runtimeSandboxMode,
subagentSmartRoutingEnabled:
input.subagentSmartRoutingEnabled ?? false,
intranetCompatibilityEnabled:
input.intranetCompatibilityEnabled ?? true,
knowledgeEmbeddingEnabled: input.knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl: input.knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel: input.knowledgeEmbeddingModel,
@@ -211,6 +236,16 @@ const api: DesktopApi = {
}
})),
selectRuntimeFile: vi.fn(async () => undefined),
openRuntimeConfig: vi.fn(async () => {}),
testModelConnection: vi.fn<
DesktopApi['settings']['testModelConnection']
>(async () => ({
id: 'model',
label: 'sonnet-5',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})),
testRuntime: vi.fn<DesktopApi['settings']['testRuntime']>(
async () => ({
id: 'model',
@@ -455,6 +490,26 @@ describe('App', () => {
newConversationListener = undefined
browserListener = undefined
maximizedChangedListener = undefined
speechRecognitionMocks.startPcmRecording.mockResolvedValue({
result: Promise.resolve({
audio: new Float32Array([0, 0.25, -0.25]).buffer,
sampleRate: 16_000
}),
stop: vi.fn(),
cancel: vi.fn()
})
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia: vi.fn() }
})
Object.defineProperty(window, 'AudioContext', {
configurable: true,
value: class AudioContextMock {}
})
vi.mocked(api.speech!.transcribe).mockResolvedValue({
text: '本地语音结果'
})
vi.mocked(api.speech!.cancel).mockResolvedValue(true)
vi.mocked(api.agent.getStatus).mockResolvedValue({
id: 'model',
label: 'sonnet-5',
@@ -510,7 +565,42 @@ describe('App', () => {
).toBeInTheDocument()
})
it('uses local transcription when Electron has no Web Speech API', async () => {
Object.defineProperty(window, 'SpeechRecognition', {
configurable: true,
value: undefined
})
Object.defineProperty(window, 'webkitSpeechRecognition', {
configurable: true,
value: undefined
})
render(<App />)
fireEvent.click(await screen.findByLabelText('语音输入'))
await waitFor(() =>
expect(api.speech?.transcribe).toHaveBeenCalledWith(
expect.objectContaining({
sampleRate: 16_000,
audio: expect.any(ArrayBuffer)
})
)
)
expect(await screen.findByDisplayValue('本地语音结果')).toBeInTheDocument()
expect(
screen.getByText(/快捷唤起:Ctrl\+Shift\+Space/)
).toBeInTheDocument()
expect(
screen.queryByText(/CommandOrControl/)
).not.toBeInTheDocument()
})
it('keeps conversation actions in the conversation list', async () => {
const writeText = vi.fn(async () => {})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText }
})
const { container } = render(<App />)
const topbar = container.querySelector<HTMLElement>('.topbar')
const conversationList =
@@ -586,6 +676,7 @@ describe('App', () => {
name: '复制完整会话'
})
)
await waitFor(() => expect(writeText).toHaveBeenCalledOnce())
expect(await screen.findByRole('status')).toBeVisible()
})
@@ -601,6 +692,10 @@ describe('App', () => {
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
expect(request?.prompt).toBe('帮我分析项目')
expect(request?.runtimeSelection).toEqual({
provider: 'model',
profileId: modelProfileId
})
const userMessage = screen
.getAllByText('帮我分析项目')
.map((element) => element.closest('article'))
@@ -631,6 +726,95 @@ describe('App', () => {
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
})
it('submits knowledge scope without eager search or prompt injection and merges runtime references', async () => {
const libraryId = '11111111-1111-4111-8111-111111111111'
vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({
libraries: [
{
id: libraryId,
name: '产品知识',
description: '',
storageMode: 'managed',
graphEnabled: false,
graphStrategy: 'rules',
sourceCount: 1,
documentCount: 1,
indexedDocumentCount: 1
}
],
sources: [],
documents: [],
graphNodes: [],
graphRelations: [],
evidence: []
})
render(<App />)
await screen.findByText('知识库 1')
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '发布流程是什么?' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
expect(request).toMatchObject({
prompt: '发布流程是什么?',
knowledgeLibraryIds: [libraryId]
})
expect(api.knowledge.search).not.toHaveBeenCalled()
expect(
screen.queryByText(/ \d+ /u)
).not.toBeInTheDocument()
act(() => {
if (!request) {
throw new Error('Missing request')
}
for (let batch = 0; batch < 5; batch += 1) {
agentListener?.({
requestId: request.requestId,
type: 'source-references',
references: Array.from({ length: 25 }, (_, index) => ({
libraryId,
libraryName: '产品知识',
documentId: crypto.randomUUID(),
documentName: `发布手册 ${batch}-${index}`,
sourceName: `release-${batch}-${index}.md`,
locator: `${batch}-${index}`,
snippet: `证据 ${batch}-${index}`,
rank: index + 1
}))
})
}
agentListener?.({
requestId: request.requestId,
type: 'done'
})
})
expect(
await screen.findByText('查看 20 条证据引用')
).toBeInTheDocument()
await waitFor(
() => {
const persistedMessages = vi
.mocked(api.conversations.replace)
.mock.calls.flatMap(([conversations]) =>
conversations.flatMap((conversation) => conversation.messages)
)
const persisted = persistedMessages
.filter((message) => message.role === 'assistant')
.slice()
.reverse()
.find(
(message) => message.sourceReferences?.length === 20
)
expect(persisted?.sourceReferences).toHaveLength(20)
expect(persisted?.sources).toHaveLength(100)
},
{ timeout: 2_000 }
)
})
it('keeps a running response visible when cancellation fails', async () => {
vi.mocked(api.agent.cancel).mockRejectedValueOnce(
new Error('cancel failed')
@@ -652,6 +836,23 @@ describe('App', () => {
await screen.findByText(//u)
).toBeInTheDocument()
expect(screen.getByLabelText('停止生成')).toBeInTheDocument()
vi.useFakeTimers()
try {
act(() => vi.advanceTimersByTime(10_000))
expect(
screen.getByText(//u)
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: '关闭通知'
})
)
expect(
screen.queryByText(//u)
).not.toBeInTheDocument()
} finally {
vi.useRealTimers()
}
})
it('keeps sent documents and images in conversation history', async () => {
@@ -1256,7 +1457,7 @@ describe('App', () => {
['opencode', 'OpenCode'],
['continue', 'Continue CLI']
] as const)(
'locks %s to Execute and submits without a mode choice',
'lets %s select Ask or Execute',
async (runtimeId, label) => {
vi.mocked(api.agent.getStatus).mockResolvedValue({
id: runtimeId,
@@ -1268,14 +1469,15 @@ describe('App', () => {
render(<App />)
const mode = await screen.findByLabelText('工作模式')
expect(mode).toHaveValue('execute')
expect(mode).toBeDisabled()
expect(mode).toHaveValue('ask')
expect(mode).toBeEnabled()
expect(mode.closest('.composer')).not.toBeNull()
expect(
await screen.findByText(
new RegExp(`${label} 固定为 Execute.*不会弹出 GoodBuddy 审批`)
new RegExp(`${label} Ask 模式.*只允许搜索当前启用的知识库`)
)
).toBeInTheDocument()
fireEvent.change(mode, { target: { value: 'execute' } })
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '执行任务' }
@@ -1294,6 +1496,13 @@ describe('App', () => {
)
it('restores the direct-model mode after leaving an Agent Runtime', async () => {
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
provider: 'opencode',
opencodeEmbedded: true,
opencodeModelSource: { kind: 'platform' }
})
vi.mocked(api.agent.getStatus)
.mockResolvedValueOnce({
id: 'opencode',
@@ -1312,12 +1521,16 @@ describe('App', () => {
render(<App />)
const mode = await screen.findByLabelText('工作模式')
expect(mode).toHaveValue('ask')
expect(mode).toBeEnabled()
fireEvent.change(mode, { target: { value: 'execute' } })
expect(mode).toHaveValue('execute')
expect(mode).toBeDisabled()
fireEvent.click(await screen.findByRole('button', { name: /OpenCode/u }))
fireEvent.click(
screen.getByRole('menuitemradio', { name: //u })
screen.getByRole('menuitemradio', {
name: /^.*sonnet-5$/u
})
)
await waitFor(() => {
@@ -1428,26 +1641,600 @@ describe('App', () => {
expect(
await screen.findByRole('menu', { name: 'Runtime 和模型' })
).toBeInTheDocument()
expect(
screen.queryByText('自动选择')
).not.toBeInTheDocument()
expect(
screen.getByRole('menuitemradio', {
name: /^.*sonnet-5$/u
})
).toBeInTheDocument()
expect(
screen.getByRole('menuitemradio', {
name: /^OpenCode · .*sonnet-5$/u
})
).toBeInTheDocument()
expect(
screen.getByRole('menuitemradio', {
name: /^Continue · .*sonnet-5$/u
})
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /.*sonnet-5/u
name: /^.*sonnet-5$/u
})
)
await waitFor(() =>
expect(api.settings.updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'model',
defaultModelProfileId: modelProfileId
})
)
expect(api.agent.getStatus).toHaveBeenLastCalledWith({
provider: 'model',
profileId: modelProfileId
})
)
expect(api.settings.updateRuntime).not.toHaveBeenCalled()
expect(
screen.queryByRole('heading', { name: '设置中心' })
).not.toBeInTheDocument()
})
it('shows Runtime switches globally without replacing composer guidance', async () => {
render(<App />)
const runtimeButton = await screen.findByRole('button', {
name: /sonnet-5/u
})
vi.useFakeTimers()
try {
fireEvent.click(runtimeButton)
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^OpenCode · .*sonnet-5$/u
})
)
await act(async () => {
await Promise.resolve()
})
const notification = screen.getByRole('status')
expect(notification).toHaveTextContent(
'当前对话已切换到 OpenCode · 默认模型'
)
expect(screen.getByText(/Ask 模式:只读问答/)).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: /OpenCode · /u
})
)
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^Continue · .*sonnet-5$/u
})
)
await act(async () => {
await Promise.resolve()
})
expect(screen.getAllByRole('status')).toHaveLength(1)
expect(screen.getByRole('status')).toHaveTextContent(
'当前对话已切换到 Continue · 默认模型'
)
act(() => vi.advanceTimersByTime(4_500))
expect(
screen.queryByText(
'当前对话已切换到 Continue · 默认模型'
)
).not.toBeInTheDocument()
expect(screen.getByText(/Ask 模式:只读问答/)).toBeInTheDocument()
} finally {
vi.useRealTimers()
}
})
it('shows one configured choice per Agent Runtime in a flat keyboard menu', async () => {
const settings = await api.settings.getRuntime()
const secondProfileId =
'00000000-0000-4000-8000-000000000002'
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
provider: 'model',
modelProfiles: [
...settings.modelProfiles,
{
id: secondProfileId,
name: '第二模型',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
imageGenerationQuality: 'auto',
apiKeyConfigured: false,
credentialSource: 'none'
}
]
})
render(<App />)
const runtimeButton = await screen.findByRole('button', {
name: /sonnet-5/u
})
fireEvent.click(runtimeButton)
const runtimeMenu = screen.getByRole('menu', {
name: 'Runtime 和模型'
})
const directModel = screen.getByRole('menuitemradio', {
name: /^.*sonnet-5$/u
})
const secondDirectModel = screen.getByRole('menuitemradio', {
name: /^.*qwen3$/u
})
const openCodeModel = screen.getByRole('menuitemradio', {
name: /^OpenCode · .*sonnet-5$/u
})
const continueModel = screen.getByRole('menuitemradio', {
name: /^Continue · .*sonnet-5$/u
})
expect(directModel).toBeEnabled()
expect(secondDirectModel).toBeEnabled()
expect(openCodeModel).toBeEnabled()
expect(continueModel).toBeEnabled()
expect(screen.getAllByRole('menuitemradio')).toHaveLength(4)
expect(within(runtimeMenu).getAllByRole('separator')).toHaveLength(3)
expect(within(runtimeMenu).queryByRole('menu')).not.toBeInTheDocument()
expect(
screen.queryByRole('menuitemradio', {
name: /^OpenCode · /u
})
).not.toBeInTheDocument()
expect(
screen.queryByRole('menuitemradio', {
name: /^Continue · /u
})
).not.toBeInTheDocument()
expect(
screen.queryByRole('menuitem', { name: /Agent Runtime/u })
).not.toBeInTheDocument()
await waitFor(() => expect(directModel).toHaveFocus())
expect(directModel).toHaveAttribute('tabindex', '0')
expect(secondDirectModel).toHaveAttribute('tabindex', '-1')
fireEvent.keyDown(directModel, { key: 'ArrowDown' })
expect(secondDirectModel).toHaveFocus()
expect(directModel).toHaveAttribute('tabindex', '-1')
expect(secondDirectModel).toHaveAttribute('tabindex', '0')
fireEvent.keyDown(secondDirectModel, { key: 'ArrowDown' })
expect(openCodeModel).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(runtimeButton).toHaveFocus()
expect(
screen.queryByRole('menu', { name: 'Runtime 和模型' })
).not.toBeInTheDocument()
fireEvent.click(runtimeButton)
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^OpenCode · .*sonnet-5$/u
})
)
expect(runtimeButton).toHaveFocus()
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenLastCalledWith({
provider: 'opencode',
profileId: modelProfileId
})
)
const selectedRuntimeButton = await screen.findByRole('button', {
name: /OpenCode · /u
})
fireEvent.click(selectedRuntimeButton)
const selectedOpenCodeModel = screen.getByRole('menuitemradio', {
name: /^OpenCode · .*sonnet-5$/u
})
await waitFor(() => expect(selectedOpenCodeModel).toHaveFocus())
expect(selectedOpenCodeModel).toHaveAttribute('aria-checked', 'true')
expect(selectedOpenCodeModel).toHaveAttribute('tabindex', '0')
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^Continue · .*sonnet-5$/u
})
)
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenLastCalledWith({
provider: 'continue',
profileId: modelProfileId
})
)
})
it('dismisses the Runtime menu on outside pointer and focus changes', async () => {
render(<App />)
const runtimeButton = await screen.findByRole('button', {
name: /sonnet-5/u
})
const composer = screen.getByLabelText('向 GoodBuddy 提问')
fireEvent.click(runtimeButton)
expect(
screen.getByRole('menu', { name: 'Runtime 和模型' })
).toBeInTheDocument()
fireEvent.pointerDown(composer)
expect(
screen.queryByRole('menu', { name: 'Runtime 和模型' })
).not.toBeInTheDocument()
fireEvent.click(runtimeButton)
const selectedModel = screen.getByRole('menuitemradio', {
name: /^.*sonnet-5$/u
})
await waitFor(() => expect(selectedModel).toHaveFocus())
fireEvent.keyDown(selectedModel, { key: 'Tab' })
composer.focus()
expect(composer).toHaveFocus()
await waitFor(() =>
expect(
screen.queryByRole('menu', { name: 'Runtime 和模型' })
).not.toBeInTheDocument()
)
})
it('labels explicitly configured Runtime-owned model sources', async () => {
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
provider: 'model',
opencodeModelSource: { kind: 'platform' },
continueModelSource: { kind: 'platform' }
})
render(<App />)
fireEvent.click(
await screen.findByRole('button', { name: /sonnet-5/u })
)
expect(
screen.getByRole('menuitemradio', {
name: /^OpenCode · .*使 OpenCode $/u
})
).toBeInTheDocument()
const continueChoice = screen.getByRole('menuitemradio', {
name: /^Continue · .*使 Continue $/u
})
fireEvent.click(continueChoice)
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenLastCalledWith({
provider: 'continue'
})
)
})
it('normalizes a legacy Auto conversation to the explicit default Runtime', async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000020',
runtimeSelection: { provider: 'auto' },
title: '旧自动对话',
updatedAt: 1,
messages: [
{
id: '00000000-0000-4000-8000-000000000021',
role: 'assistant',
content: '旧消息',
createdAt: 1,
state: 'complete'
}
]
}
])
render(<App />)
expect(
await screen.findByRole('button', { name: /.*sonnet-5/u })
).toBeInTheDocument()
await waitFor(() =>
expect(api.conversations.replace).toHaveBeenLastCalledWith(
expect.arrayContaining([
expect.objectContaining({
id: '00000000-0000-4000-8000-000000000020',
runtimeSelection: {
provider: 'model',
profileId: modelProfileId
}
})
])
)
)
})
it('rebinds a loaded conversation when its model profile was removed', async () => {
const removedProfileId =
'00000000-0000-4000-8000-000000000099'
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000022',
runtimeSelection: {
provider: 'model',
profileId: removedProfileId
},
title: '旧模型对话',
updatedAt: 1,
messages: [
{
id: '00000000-0000-4000-8000-000000000023',
role: 'assistant',
content: '旧消息',
createdAt: 1,
state: 'complete'
}
]
}
])
render(<App />)
expect(
await screen.findByRole('button', { name: /.*sonnet-5/u })
).toBeInTheDocument()
await waitFor(() =>
expect(api.conversations.replace).toHaveBeenLastCalledWith(
expect.arrayContaining([
expect.objectContaining({
id: '00000000-0000-4000-8000-000000000022',
runtimeSelection: {
provider: 'model',
profileId: modelProfileId
}
})
])
)
)
})
it('keeps model Runtime selection scoped to its conversation', async () => {
const secondProfileId =
'00000000-0000-4000-8000-000000000002'
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
modelProfiles: [
...settings.modelProfiles,
{
id: secondProfileId,
name: '第二模型',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
imageGenerationQuality: 'auto',
apiKeyConfigured: false,
credentialSource: 'none'
}
]
})
vi.mocked(api.agent.getStatus).mockImplementation(
async (selection) => ({
id: 'model',
label:
selection?.provider === 'model' &&
selection.profileId === secondProfileId
? 'qwen3'
: 'sonnet-5',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
)
render(<App />)
fireEvent.click(
await screen.findByRole('button', { name: /sonnet-5/u })
)
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^.*qwen3$/u
})
)
expect(
await screen.findByRole('button', { name: //u })
).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '第二模型对话' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
expect(request?.runtimeSelection).toEqual({
provider: 'model',
profileId: secondProfileId
})
if (!request) {
throw new Error('Missing request')
}
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'done'
})
})
fireEvent.click(
screen.getByRole('button', { name: //u })
)
expect(
await screen.findByRole('button', { name: //u })
).toBeInTheDocument()
const previousConversation = screen
.getAllByText('第二模型对话')
.map((element) => element.closest('button'))
.find((button) => button?.classList.contains('conversation-item'))
if (!previousConversation) {
throw new Error('Missing previous conversation')
}
fireEvent.click(previousConversation)
await waitFor(() =>
expect(
screen
.getAllByRole('button', { name: //u })
.find((button) => button.classList.contains('model-button'))
).toBeInTheDocument()
)
await waitFor(
() =>
expect(api.conversations.replace).toHaveBeenLastCalledWith(
expect.arrayContaining([
expect.objectContaining({
title: '第二模型对话',
runtimeSelection: {
provider: 'model',
profileId: secondProfileId
}
})
])
),
{ timeout: 2_000 }
)
})
it('ignores a stale picker status after rapidly changing conversations', async () => {
const secondProfileId =
'00000000-0000-4000-8000-000000000002'
const thirdProfileId =
'00000000-0000-4000-8000-000000000003'
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
modelProfiles: [
...settings.modelProfiles,
{
id: secondProfileId,
name: '第二模型',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
imageGenerationQuality: 'auto',
apiKeyConfigured: false,
credentialSource: 'none'
},
{
id: thirdProfileId,
name: '第三模型',
baseUrl: 'http://127.0.0.1:11435/v1',
modelName: 'llama3',
protocol: 'openai-chat-completions',
authentication: 'none',
imageGenerationQuality: 'auto',
apiKeyConfigured: false,
credentialSource: 'none'
}
]
})
let resolveThird!: (status: {
id: 'model'
label: string
available: boolean
supportsToolExecution: boolean
detail: string
}) => void
const thirdStatus = new Promise<{
id: 'model'
label: string
available: boolean
supportsToolExecution: boolean
detail: string
}>((resolve) => {
resolveThird = resolve
})
vi.mocked(api.agent.getStatus).mockImplementation(async (selection) => {
if (
selection?.provider === 'model' &&
selection.profileId === thirdProfileId
) {
return thirdStatus
}
return {
id: 'model',
label:
selection?.provider === 'model' &&
selection.profileId === secondProfileId
? 'qwen3'
: 'sonnet-5',
available: true,
supportsToolExecution: true,
detail: 'Ready'
}
})
render(<App />)
fireEvent.click(
await screen.findByRole('button', { name: /sonnet-5/u })
)
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^.*qwen3$/u
})
)
await screen.findByRole('button', { name: //u })
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '保留第二模型会话' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
act(() => {
agentListener?.({ requestId: request.requestId, type: 'done' })
})
const secondModelButton = screen
.getAllByRole('button', { name: //u })
.find((button) => button.classList.contains('model-button'))
if (!secondModelButton) {
throw new Error('Missing second model picker')
}
fireEvent.click(secondModelButton)
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^.*llama3$/u
})
)
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenCalledWith({
provider: 'model',
profileId: thirdProfileId
})
)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '状态未完成时不能发送' }
})
expect(screen.getByLabelText('发送')).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: //u }))
await screen.findByRole('button', { name: //u })
await act(async () => {
resolveThird({
id: 'model',
label: 'llama3',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
await thirdStatus
})
expect(
screen.getByRole('button', { name: //u })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: //u })
).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '新对话仍可发送' }
})
await waitFor(() => expect(screen.getByLabelText('发送')).toBeEnabled())
})
it('opens project creation as an unobscured dialog', async () => {
render(<App />)
+1097 -290
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor
} 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 { ChannelSettingsSection } from './ChannelSettingsSection'
const snapshot: ChannelSettingsSnapshot = {
wecom: {
enabled: false,
botId: '',
secretConfigured: false,
source: 'none',
readOnly: false,
allowedSenderIds: [],
allowGroupMessages: false,
status: { state: 'disabled' }
},
dingtalk: {
enabled: false,
clientId: 'environment-client',
secretConfigured: true,
source: 'environment',
readOnly: true,
allowedSenderIds: ['staff-1'],
allowGroupMessages: false,
status: { state: 'running' }
}
}
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('ChannelSettingsSection', () => {
it('saves editable channel settings without returning stored secrets', async () => {
const apply = vi.fn(async () => ({
...snapshot,
wecom: {
...snapshot.wecom,
enabled: true,
botId: 'bot-1',
secretConfigured: true,
source: 'encrypted' as const,
allowedSenderIds: ['user-1', 'user-2'],
status: { state: 'running' as const }
}
}))
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
channels: {
getSnapshot: vi.fn(async () => snapshot),
apply,
testConnection: vi.fn(async () => ({
channel: 'wecom',
ok: true
}))
}
} as unknown as DesktopApi
})
render(<ChannelSettingsSection />)
fireEvent.click(
await screen.findByRole('checkbox', {
name: '启用企业微信通道'
})
)
fireEvent.change(screen.getByLabelText('企业微信机器人 ID'), {
target: { value: 'bot-1' }
})
fireEvent.change(screen.getByLabelText('企业微信Secret'), {
target: { value: 'channel-secret' }
})
fireEvent.change(screen.getByLabelText('企业微信允许的发送者 ID'), {
target: { value: 'user-1\nuser-2\nuser-1' }
})
fireEvent.click(
screen.getByRole('button', { name: '保存通道设置' })
)
await waitFor(() =>
expect(apply).toHaveBeenCalledWith({
wecom: {
enabled: true,
botId: 'bot-1',
secret: {
action: 'replace',
value: 'channel-secret'
},
allowedSenderIds: ['user-1', 'user-2'],
allowGroupMessages: false
}
})
)
expect(screen.queryByDisplayValue('channel-secret')).toBeNull()
expect(await screen.findByText('企业通信设置已保存并应用'))
.toBeInTheDocument()
})
it('tests environment-owned channels without exposing draft credentials', async () => {
const testConnection = vi.fn(async () => ({
channel: 'dingtalk' as const,
ok: true as const
}))
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
channels: {
getSnapshot: vi.fn(async () => snapshot),
apply: vi.fn(),
testConnection
}
} as unknown as DesktopApi
})
render(<ChannelSettingsSection />)
fireEvent.click(
await screen.findByRole('button', { name: '测试钉钉连接' })
)
await waitFor(() =>
expect(testConnection).toHaveBeenCalledWith(
'dingtalk',
undefined
)
)
expect(screen.getByText('钉钉连接成功')).toBeInTheDocument()
})
})
+419
View File
@@ -0,0 +1,419 @@
import { FlaskConical, MessageSquare, Save } from 'lucide-react'
import { useEffect, useState } from 'react'
import type {
ChannelConnectionTestResult,
ChannelSettingsApply,
ChannelSettingsSnapshot,
DingTalkChannelSettingsInput,
ManagedChannel,
WeComChannelSettingsInput
} from '../../shared/channel-settings-contracts'
type ChannelDraft = {
enabled: boolean
identifier: string
secret: string
clearSecret: boolean
allowedSenderIdsText: string
allowGroupMessages: boolean
}
const emptyDraft: ChannelDraft = {
enabled: false,
identifier: '',
secret: '',
clearSecret: false,
allowedSenderIdsText: '',
allowGroupMessages: false
}
const statusLabels: Record<
ChannelSettingsSnapshot['wecom']['status']['state'],
string
> = {
disabled: '未启用',
stopped: '已停止',
starting: '正在连接',
running: '已连接',
error: '连接失败'
}
function allowedSenderIds(value: string): string[] {
return [
...new Set(
value
.split(/[,\r\n]+/u)
.map((item) => item.trim())
.filter(Boolean)
)
]
}
function secretUpdate(draft: ChannelDraft) {
return draft.clearSecret
? ({ action: 'clear' } as const)
: draft.secret.trim()
? ({ action: 'replace', value: draft.secret.trim() } as const)
: ({ action: 'keep' } as const)
}
function draftFromSnapshot(
channel: ManagedChannel,
snapshot: ChannelSettingsSnapshot
): ChannelDraft {
const settings = snapshot[channel]
return {
enabled: settings.enabled,
identifier:
channel === 'wecom'
? snapshot.wecom.botId
: snapshot.dingtalk.clientId,
secret: '',
clearSecret: false,
allowedSenderIdsText: settings.allowedSenderIds.join('\n'),
allowGroupMessages: settings.allowGroupMessages
}
}
function inputFor(
channel: 'wecom',
draft: ChannelDraft
): WeComChannelSettingsInput
function inputFor(
channel: 'dingtalk',
draft: ChannelDraft
): DingTalkChannelSettingsInput
function inputFor(
channel: ManagedChannel,
draft: ChannelDraft
): WeComChannelSettingsInput | DingTalkChannelSettingsInput {
const common = {
enabled: draft.enabled,
secret: secretUpdate(draft),
allowedSenderIds: allowedSenderIds(draft.allowedSenderIdsText),
allowGroupMessages: draft.allowGroupMessages
}
return channel === 'wecom'
? { ...common, botId: draft.identifier.trim() }
: { ...common, clientId: draft.identifier.trim() }
}
function ChannelEditor({
channel,
draft,
onChange,
onTest,
settings,
testing
}: {
channel: ManagedChannel
draft: ChannelDraft
onChange: (next: ChannelDraft) => void
onTest: () => void
settings: ChannelSettingsSnapshot[ManagedChannel]
testing: boolean
}): React.JSX.Element {
const title = channel === 'wecom' ? '企业微信' : '钉钉'
const identifierLabel = channel === 'wecom' ? '机器人 ID' : 'Client ID'
const secretLabel = channel === 'wecom' ? 'Secret' : 'Client Secret'
const prefix = `channel-${channel}`
return (
<article className="capability-card channel-settings-card">
<div className="capability-card__header">
<div>
<strong>{title}</strong>
<small>
{settings.source === 'environment'
? '由环境变量提供'
: settings.secretConfigured
? 'Secret 已加密保存'
: 'Secret 尚未配置'}
</small>
</div>
<span>{statusLabels[settings.status.state]}</span>
</div>
{settings.readOnly && (
<p className="settings-notice">
</p>
)}
{settings.status.lastError && (
<p className="settings-warning" role="alert">
{settings.status.lastError}
</p>
)}
<label className="toggle-row" htmlFor={`${prefix}-enabled`}>
<input
checked={draft.enabled}
disabled={settings.readOnly}
id={`${prefix}-enabled`}
onChange={(event) =>
onChange({ ...draft, enabled: event.target.checked })
}
type="checkbox"
/>
<span>{title}</span>
</label>
<label className="field">
<span>{identifierLabel}</span>
<input
aria-label={`${title}${identifierLabel}`}
disabled={settings.readOnly}
maxLength={256}
onChange={(event) =>
onChange({ ...draft, identifier: event.target.value })
}
value={draft.identifier}
/>
</label>
<label className="field">
<span>{secretLabel}</span>
<input
aria-label={`${title}${secretLabel}`}
autoComplete="off"
disabled={settings.readOnly || draft.clearSecret}
maxLength={4_096}
onChange={(event) =>
onChange({ ...draft, secret: event.target.value })
}
placeholder={
settings.secretConfigured ? '留空以保留现有 Secret' : '请输入 Secret'
}
type="password"
value={draft.secret}
/>
</label>
{settings.secretConfigured && !settings.readOnly && (
<label className="toggle-row">
<input
checked={draft.clearSecret}
onChange={(event) =>
onChange({
...draft,
clearSecret: event.target.checked,
secret: event.target.checked ? '' : draft.secret
})
}
type="checkbox"
/>
<span> Secret</span>
</label>
)}
<label className="field">
<span> ID</span>
<textarea
aria-label={`${title}允许的发送者 ID`}
disabled={settings.readOnly}
onChange={(event) =>
onChange({
...draft,
allowedSenderIdsText: event.target.value
})
}
placeholder="每行一个 ID,最多 100 个"
rows={4}
value={draft.allowedSenderIdsText}
/>
<small>
GoodBuddy
</small>
</label>
<label className="toggle-row">
<input
checked={draft.allowGroupMessages}
disabled={settings.readOnly}
onChange={(event) =>
onChange({
...draft,
allowGroupMessages: event.target.checked
})
}
type="checkbox"
/>
<span></span>
</label>
<button
className="secondary-button"
disabled={testing}
onClick={onTest}
type="button"
>
<FlaskConical aria-hidden="true" size={13} />
{testing ? '正在测试…' : `测试${title}连接`}
</button>
</article>
)
}
export function ChannelSettingsSection(): React.JSX.Element {
const [snapshot, setSnapshot] = useState<ChannelSettingsSnapshot>()
const [drafts, setDrafts] = useState<Record<ManagedChannel, ChannelDraft>>({
wecom: { ...emptyDraft },
dingtalk: { ...emptyDraft }
})
const [busy, setBusy] = useState(false)
const [testing, setTesting] = useState<ManagedChannel>()
const [error, setError] = useState<string>()
const [notice, setNotice] = useState<string>()
const applySnapshot = (next: ChannelSettingsSnapshot): void => {
setSnapshot(next)
setDrafts({
wecom: draftFromSnapshot('wecom', next),
dingtalk: draftFromSnapshot('dingtalk', next)
})
}
useEffect(() => {
const api = window.goodbuddy.channels
let active = true
void (async () => {
if (!api) {
throw new Error('当前版本未提供企业通信设置服务')
}
return api.getSnapshot()
})()
.then((next) => {
if (active) {
applySnapshot(next)
}
})
.catch((reason: unknown) => {
if (active) {
setError(
reason instanceof Error ? reason.message : '读取企业通信设置失败'
)
}
})
return () => {
active = false
}
}, [])
const save = async (): Promise<void> => {
const api = window.goodbuddy.channels
if (!api || !snapshot) {
return
}
const input: ChannelSettingsApply = {
...(snapshot.wecom.readOnly
? {}
: { wecom: inputFor('wecom', drafts.wecom) }),
...(snapshot.dingtalk.readOnly
? {}
: { dingtalk: inputFor('dingtalk', drafts.dingtalk) })
}
if (!input.wecom && !input.dingtalk) {
setError('所有通道均由环境变量管理,不能在设置中修改')
return
}
setBusy(true)
setError(undefined)
setNotice(undefined)
try {
applySnapshot(await api.apply(input))
setNotice('企业通信设置已保存并应用')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '保存企业通信设置失败')
} finally {
setBusy(false)
}
}
const test = async (channel: ManagedChannel): Promise<void> => {
const api = window.goodbuddy.channels
if (!api || !snapshot) {
return
}
setTesting(channel)
setError(undefined)
setNotice(undefined)
try {
const settings = snapshot[channel].readOnly
? undefined
: channel === 'wecom'
? inputFor('wecom', drafts.wecom)
: inputFor('dingtalk', drafts.dingtalk)
const result: ChannelConnectionTestResult =
await api.testConnection(channel, settings)
if (!result.ok) {
throw new Error(result.error)
}
setNotice(channel === 'wecom' ? '企业微信连接成功' : '钉钉连接成功')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '通道连接测试失败')
} finally {
setTesting(undefined)
}
}
if (!snapshot) {
return (
<div className="settings-section">
<p className={error ? 'settings-warning' : 'settings-empty'}>
{error ?? '正在读取企业通信设置…'}
</p>
</div>
)
}
return (
<section
aria-labelledby="channel-settings-heading"
className="settings-section channel-settings"
>
<div className="settings-section__title settings-section__title--actions">
<MessageSquare aria-hidden="true" size={17} />
<div>
<strong id="channel-settings-heading"></strong>
<small></small>
</div>
<button
className="primary-button"
disabled={busy}
onClick={() => void save()}
type="button"
>
<Save aria-hidden="true" size={13} />
{busy ? '保存中…' : '保存通道设置'}
</button>
</div>
{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__grid">
<ChannelEditor
channel="wecom"
draft={drafts.wecom}
onChange={(next) =>
setDrafts((current) => ({ ...current, wecom: next }))
}
onTest={() => void test('wecom')}
settings={snapshot.wecom}
testing={testing === 'wecom'}
/>
<ChannelEditor
channel="dingtalk"
draft={drafts.dingtalk}
onChange={(next) =>
setDrafts((current) => ({ ...current, dingtalk: next }))
}
onTest={() => void test('dingtalk')}
settings={snapshot.dingtalk}
testing={testing === 'dingtalk'}
/>
</div>
</section>
)
}
@@ -0,0 +1,257 @@
import {
cleanup,
fireEvent,
render,
screen
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
EmbeddingConfigurationSummary,
EmbeddingIndexStatus
} from '../../shared/embedding-contracts'
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
const configuration: EmbeddingConfigurationSummary = {
provider: 'openai-compatible',
model: 'text-embedding-3-small',
endpoint: 'https://vectors.example/v1/embeddings',
credentialConfigured: true
}
const idleIndex: EmbeddingIndexStatus = {
job: null
}
afterEach(() => {
cleanup()
})
describe('EmbeddingSettingsSection', () => {
it('uses supplied callbacks without depending on a preload API', () => {
const onTest = vi.fn()
const onRebuild = vi.fn()
render(
<EmbeddingSettingsSection
configuration={configuration}
indexStatus={idleIndex}
onRebuild={onRebuild}
onTest={onTest}
/>
)
expect(
screen.getByRole('heading', { name: '向量与知识检索' })
).toBeInTheDocument()
expect(
screen.getByRole('heading', { name: '当前向量模型' })
).toBeInTheDocument()
expect(screen.getByText('text-embedding-3-small')).toBeInTheDocument()
expect(screen.getByText('已配置凭据')).toBeInTheDocument()
expect(screen.getByText('还没有重建记录')).toBeInTheDocument()
expect(
screen.getByText(
'点击“重建向量索引”,为知识文档生成可用于检索的向量。'
)
).toBeInTheDocument()
expect(screen.queryByText(/快照/)).not.toBeInTheDocument()
expect(screen.queryByText(/当前检索索引/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '测试向量模型' }))
fireEvent.click(screen.getByRole('button', { name: '重建向量索引' }))
expect(onTest).toHaveBeenCalledOnce()
expect(onRebuild).toHaveBeenCalledOnce()
})
it('shows dimensions and latency from a real diagnostic result', () => {
render(
<EmbeddingSettingsSection
configuration={configuration}
diagnostic={{
status: 'available',
provider: 'openai-compatible',
model: 'text-embedding-3-small',
checkedAt: 1_700_000_000_000,
latencyMs: 126,
dimensions: 1_536
}}
indexStatus={idleIndex}
onRebuild={vi.fn()}
onTest={vi.fn()}
/>
)
expect(screen.getByText('测试成功')).toBeInTheDocument()
expect(
screen.getByText('服务返回 1536 维向量,耗时 126 毫秒。')
).toBeInTheDocument()
})
it('renders a safe actionable diagnostic error', () => {
render(
<EmbeddingSettingsSection
configuration={configuration}
diagnostic={{
status: 'unavailable',
provider: 'openai-compatible',
model: 'missing-model',
checkedAt: 1,
latencyMs: 25,
error: {
code: 'model_not_found',
message: '未找到指定的向量模型。',
retryable: false,
remedy: '请确认模型名称正确。'
}
}}
indexStatus={idleIndex}
onRebuild={vi.fn()}
onTest={vi.fn()}
/>
)
expect(screen.getByRole('alert')).toHaveTextContent(
'未找到指定的向量模型。'
)
expect(screen.getByRole('alert')).toHaveTextContent(
'处理建议:请确认模型名称正确。'
)
})
it('shows document progress and atomic availability while rebuilding', () => {
const onCancel = vi.fn()
render(
<EmbeddingSettingsSection
configuration={configuration}
indexStatus={{
job: {
id: 'job-new',
status: 'running',
provider: 'openai-compatible',
model: 'embed-v2',
progress: {
completed: 10,
total: 40,
percent: 25
},
createdAt: 1_700_000_000_100,
startedAt: 1_700_000_000_200
}
}}
onCancel={onCancel}
onRebuild={vi.fn()}
onTest={vi.fn()}
/>
)
expect(screen.getByRole('progressbar')).toHaveAttribute('value', '25')
expect(screen.getByText('已完成 10 / 40 篇文档')).toBeInTheDocument()
expect(
screen.getByText(/每篇文档会一次性更新,处理完成后立即可用于检索。/)
).toBeInTheDocument()
expect(
screen.getByText(/其余文档的原有或缺失状态不变。/)
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '重建进行中…' })
).toBeDisabled()
fireEvent.click(
screen.getByRole('button', { name: '取消向量索引重建' })
)
expect(onCancel).toHaveBeenCalledWith('job-new')
})
it('shows a failed rebuild remedy and retries from the rebuild button', () => {
const onRebuild = vi.fn()
render(
<EmbeddingSettingsSection
configuration={configuration}
indexStatus={{
job: {
id: 'job-failed',
status: 'failed',
provider: 'provider',
model: 'model',
progress: { completed: 2, total: 4, percent: 50 },
createdAt: 1,
completedAt: 2,
error: {
code: 'rate_limited',
message: '向量服务当前请求过多。',
retryable: true
}
}
}}
onRebuild={onRebuild}
onTest={vi.fn()}
/>
)
expect(screen.getByText('最近一次重建失败')).toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent(
'向量服务当前请求过多。'
)
expect(screen.getByRole('alert')).toHaveTextContent(
'已完成 2 / 4 篇文档。发生错误的文档已标记为错误,已完成文档仍可用于检索。'
)
expect(screen.getByRole('alert')).toHaveTextContent(
'请检查向量模型配置和网络连接。修复后点击“重建向量索引”重试。'
)
fireEvent.click(screen.getByRole('button', { name: '重建向量索引' }))
expect(onRebuild).toHaveBeenCalledOnce()
})
it('reports successful and cancelled rebuilds distinctly', () => {
const { rerender } = render(
<EmbeddingSettingsSection
configuration={configuration}
indexStatus={{
job: {
id: 'job-completed',
status: 'completed',
provider: 'provider',
model: 'model',
progress: { completed: 4, total: 4, percent: 100 },
createdAt: 1,
completedAt: 2
}
}}
onRebuild={vi.fn()}
onTest={vi.fn()}
/>
)
expect(screen.getByText('最近一次重建成功')).toBeInTheDocument()
expect(screen.getByText('已完成 4 / 4 篇文档', { exact: false }))
.toBeInTheDocument()
rerender(
<EmbeddingSettingsSection
configuration={configuration}
indexStatus={{
job: {
id: 'job-cancelled',
status: 'cancelled',
provider: 'provider',
model: 'model',
progress: { completed: 2, total: 4, percent: 50 },
createdAt: 1,
completedAt: 2
}
}}
onRebuild={vi.fn()}
onTest={vi.fn()}
/>
)
expect(screen.getByText('最近一次重建已取消')).toBeInTheDocument()
expect(
screen.getByText('已完成 2 / 4 篇文档。')
).toBeInTheDocument()
expect(
screen.getByText(/已完成文档保留新向量;其余文档保留原有向量/)
).toBeInTheDocument()
expect(screen.getByText(/原本没有向量的仍保持缺失。/))
.toBeInTheDocument()
expect(screen.queryByText(/索引未更改/)).not.toBeInTheDocument()
})
})
@@ -0,0 +1,263 @@
import {
Activity,
Database,
FlaskConical,
RefreshCw,
XCircle
} from 'lucide-react'
import type {
EmbeddingConfigurationSummary,
EmbeddingDiagnosticResult,
EmbeddingIndexJob,
EmbeddingIndexStatus
} from '../../shared/embedding-contracts'
import { isEmbeddingIndexJobActive } from '../../shared/embedding-contracts'
const jobStatusLabels: Record<EmbeddingIndexJob['status'], string> = {
queued: '重建等待开始',
running: '正在重建',
completed: '最近一次重建成功',
failed: '最近一次重建失败',
cancelled: '最近一次重建已取消'
}
export interface EmbeddingSettingsSectionProps {
configuration: EmbeddingConfigurationSummary
diagnostic?: EmbeddingDiagnosticResult | null
diagnosticRunning?: boolean
indexStatus: EmbeddingIndexStatus
disabled?: boolean
onTest: () => void
onRebuild: () => void
onCancel?: (jobId: string) => void
}
function formatCheckedAt(timestamp: number): string {
return new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(timestamp)
}
function DiagnosticResult({
result
}: {
result: EmbeddingDiagnosticResult
}): React.JSX.Element {
if (result.status === 'available') {
return (
<div aria-live="polite" className="capability-diagnostic__result">
<strong></strong>
<p>
{result.dimensions} {result.latencyMs}
</p>
<small>{formatCheckedAt(result.checkedAt)}</small>
</div>
)
}
return (
<div
aria-live="assertive"
className="capability-diagnostic__result"
role="alert"
>
<strong></strong>
<p>{result.error.message}</p>
{result.error.remedy && <p>{result.error.remedy}</p>}
</div>
)
}
function IndexJobStatus({
job,
disabled,
onCancel
}: {
job: EmbeddingIndexJob
disabled: boolean
onCancel?: (jobId: string) => void
}): React.JSX.Element {
const active = isEmbeddingIndexJobActive(job)
return (
<div
aria-live="polite"
className="embedding-settings__job"
data-status={job.status}
>
<div className="embedding-settings__job-header">
<div>
<strong>{jobStatusLabels[job.status]}</strong>
<small>
{job.provider} · {job.model}
</small>
</div>
{active && onCancel && (
<button
aria-label="取消向量索引重建"
className="secondary-button"
disabled={disabled}
onClick={() => onCancel(job.id)}
type="button"
>
<XCircle aria-hidden="true" size={13} />
</button>
)}
</div>
{active && (
<>
<progress
aria-label="向量索引重建进度"
max={100}
{...(job.progress.total > 0
? { value: job.progress.percent }
: {})}
/>
<p>
{job.progress.total > 0
? `已完成 ${job.progress.completed} / ${job.progress.total} 篇文档`
: '正在准备待处理文档…'}
</p>
<p className="settings-notice">
</p>
</>
)}
{job.status === 'completed' && (
<p>
{job.progress.completed} / {job.progress.total}
{job.completedAt
? `,完成于 ${formatCheckedAt(job.completedAt)}`
: '。'}
</p>
)}
{job.status === 'cancelled' && (
<>
<p>
{job.progress.completed} / {job.progress.total}
</p>
<p>
</p>
</>
)}
{job.status === 'failed' && job.error && (
<div role="alert">
<p>{job.error.message}</p>
<p>{`已完成 ${job.progress.completed} / ${job.progress.total} 篇文档。发生错误的文档已标记为错误,已完成文档仍可用于检索。`}</p>
<p>
{job.error.remedy ?? '请检查向量模型配置和网络连接。'}
</p>
</div>
)}
</div>
)
}
export function EmbeddingSettingsSection({
configuration,
diagnostic,
diagnosticRunning = false,
indexStatus,
disabled = false,
onTest,
onRebuild,
onCancel
}: EmbeddingSettingsSectionProps): React.JSX.Element {
const active = isEmbeddingIndexJobActive(indexStatus.job)
return (
<section
aria-label="向量模型"
className="embedding-settings settings-section"
>
<div className="settings-section__title">
<Activity aria-hidden="true" size={17} />
<div>
<h2 id="embedding-settings-heading"></h2>
<small>使</small>
</div>
</div>
<div
aria-labelledby="embedding-model-heading"
className="embedding-settings__group"
>
<div className="embedding-settings__subheading">
<div>
<FlaskConical aria-hidden="true" size={15} />
<h3 id="embedding-model-heading"></h3>
</div>
</div>
<div className="embedding-settings__model">
<div className="embedding-settings__model-name">
<span></span>
<strong>{configuration.model}</strong>
<small>{configuration.provider}</small>
</div>
<span className="embedding-settings__credential">
{configuration.credentialConfigured ? '已配置凭据' : '未配置凭据'}
</span>
</div>
{configuration.endpoint && (
<p className="embedding-settings__endpoint">
<code>{configuration.endpoint}</code>
</p>
)}
<div className="capability-diagnostic">
<button
className="secondary-button"
disabled={disabled || diagnosticRunning}
onClick={onTest}
type="button"
>
<FlaskConical aria-hidden="true" size={13} />
{diagnosticRunning ? '正在测试…' : '测试向量模型'}
</button>
{diagnostic && <DiagnosticResult result={diagnostic} />}
{!diagnostic && !diagnosticRunning && (
<p className="settings-notice">
</p>
)}
</div>
</div>
<div
aria-labelledby="embedding-index-heading"
className="embedding-settings__group"
>
<div className="embedding-settings__subheading">
<div>
<Database aria-hidden="true" size={15} />
<h3 id="embedding-index-heading"></h3>
</div>
<button
className="secondary-button"
disabled={disabled || active}
onClick={onRebuild}
type="button"
>
<RefreshCw aria-hidden="true" size={13} />
{active ? '重建进行中…' : '重建向量索引'}
</button>
</div>
{indexStatus.job ? (
<IndexJobStatus
disabled={disabled}
job={indexStatus.job}
onCancel={onCancel}
/>
) : (
<div className="embedding-settings__empty">
<strong></strong>
<p></p>
</div>
)}
</div>
</section>
)
}
+24 -3
View File
@@ -229,6 +229,7 @@ describe('KnowledgeWorkspace', () => {
expect(workspace.querySelector('aside')).toHaveClass(
'knowledge-workspace__sidebar'
)
expect(workspace.querySelector('aside')).not.toHaveAttribute('style')
expect(workspace.querySelector('main')).toHaveClass(
'knowledge-workspace__main'
)
@@ -236,6 +237,18 @@ describe('KnowledgeWorkspace', () => {
background: 'var(--surface-raised)'
})
expect(screen.getByText('全局')).toHaveClass('scope-badge')
const mobileBack = screen.getByRole('button', {
name: '返回知识库列表'
})
expect(mobileBack).toHaveClass('knowledge-workspace__mobile-back')
fireEvent.click(mobileBack)
expect(workspace).toHaveClass('knowledge-workspace--mobile-list')
fireEvent.click(
screen.getByRole('button', {
name: /^ 1 /u
})
)
expect(workspace).not.toHaveClass('knowledge-workspace--mobile-list')
expect(screen.getByRole('tablist', { name: '知识库视图' })).toHaveClass(
'page-tabs'
)
@@ -369,9 +382,17 @@ describe('KnowledgeWorkspace', () => {
/>
)
fireEvent.click(
screen.getByRole('button', { name: '删除知识库 产品知识' })
)
const trigger = screen.getByRole('button', {
name: '删除知识库 产品知识'
})
fireEvent.click(trigger)
const dialog = screen.getByRole('dialog', {
name: '删除知识库确认'
})
expect(screen.getByRole('button', { name: '取消' })).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() => expect(trigger).toHaveFocus())
fireEvent.click(trigger)
expect(
screen.getByText(
'此知识库使用托管存储。删除后,应用保存的托管副本、索引和图谱都会被永久删除。'
+52 -13
View File
@@ -1,5 +1,6 @@
import {
AlertCircle,
ArrowLeft,
ArrowRight,
BookOpen,
Check,
@@ -36,6 +37,7 @@ import {
PageTabs,
type PageTab
} from './WorkspacePrimitives'
import { trapTabFocus } from './dialog-focus'
export type KnowledgeStorageMode = 'reference' | 'managed'
export type KnowledgeGraphStrategy =
@@ -254,12 +256,6 @@ const styles = {
color: 'var(--text-primary)',
boxShadow: 'var(--shadow-card)'
},
sidebar: {
display: 'flex',
flexDirection: 'column' as const,
gap: 16,
background: 'var(--surface-subtle)'
},
surface: {
border: '1px solid var(--border-default)',
borderRadius: 'var(--radius-control)',
@@ -447,7 +443,7 @@ function CreateLibraryWizard({
>
<div>
<span style={{ color: 'var(--accent)', fontSize: 12, fontWeight: 800 }}>
NEW KNOWLEDGE BASE
</span>
<h2 style={{ margin: '5px 0 0', fontSize: 22 }}></h2>
</div>
@@ -605,6 +601,12 @@ function DeleteLibraryDialog({
}): React.JSX.Element {
const [deleting, setDeleting] = useState(false)
const [error, setError] = useState<string>()
const dialogRef = useRef<HTMLDivElement>(null)
const cancelRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
cancelRef.current?.focus()
}, [])
const confirm = async (): Promise<void> => {
setDeleting(true)
@@ -623,6 +625,15 @@ function DeleteLibraryDialog({
<div
aria-label="删除知识库确认"
aria-modal="true"
onKeyDown={(event) => {
if (event.key === 'Escape' && !deleting) {
event.preventDefault()
onCancel()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
role="dialog"
style={{
position: 'fixed',
@@ -670,7 +681,9 @@ function DeleteLibraryDialog({
className="secondary-button"
disabled={deleting}
onClick={onCancel}
ref={cancelRef}
style={styles.button}
type="button"
>
</button>
@@ -679,6 +692,7 @@ function DeleteLibraryDialog({
disabled={deleting}
onClick={() => void confirm()}
style={styles.button}
type="button"
>
<Trash2 aria-hidden="true" size={15} />
{deleting ? '删除中…' : '确认删除'}
@@ -2217,9 +2231,11 @@ export function KnowledgeWorkspace({
onOpenEvidence
}: KnowledgeWorkspaceProps): React.JSX.Element {
const [creating, setCreating] = useState(false)
const [mobileListOpen, setMobileListOpen] = useState(false)
const [tab, setTab] = useState<WorkspaceTab>('documents')
const [deletingLibrary, setDeletingLibrary] =
useState<KnowledgeLibrary>()
const deleteLibraryTriggerRef = useRef<HTMLButtonElement>(null)
const selectedLibrary =
libraries.find((library) => library.id === selectedLibraryId) ??
libraries[0]
@@ -2258,19 +2274,27 @@ export function KnowledgeWorkspace({
]
: [])
]
const closeDeleteDialog = (): void => {
setDeletingLibrary(undefined)
requestAnimationFrame(() =>
deleteLibraryTriggerRef.current?.focus()
)
}
return (
<section
aria-busy={loading}
aria-label="知识工作区"
className="knowledge-workspace"
className={`knowledge-workspace${
mobileListOpen ? ' knowledge-workspace--mobile-list' : ''
}`}
style={styles.workspace}
>
<aside className="knowledge-workspace__sidebar" style={styles.sidebar}>
<aside className="knowledge-workspace__sidebar">
<PageHeader
compact
description={`${libraries.length} 个知识库 · 跨项目共享`}
eyebrow="KNOWLEDGE"
eyebrow="知识库"
headingId="knowledge-workspace-title"
icon={<Database size={18} />}
scope={{ kind: 'global' }}
@@ -2279,7 +2303,10 @@ export function KnowledgeWorkspace({
<button
className="primary-button"
disabled={loading}
onClick={() => setCreating(true)}
onClick={() => {
setCreating(true)
setMobileListOpen(false)
}}
style={{ ...styles.button, width: '100%' }}
type="button"
>
@@ -2322,6 +2349,7 @@ export function KnowledgeWorkspace({
onClick={() => {
onSelectLibrary(library.id)
setTab('documents')
setMobileListOpen(false)
}}
style={{
width: '100%',
@@ -2384,6 +2412,16 @@ export function KnowledgeWorkspace({
className="knowledge-workspace__main"
style={{ minWidth: 0, background: 'var(--surface-raised)' }}
>
{selectedLibrary && !creating && !loading && (
<button
className="knowledge-workspace__mobile-back secondary-button"
onClick={() => setMobileListOpen(true)}
type="button"
>
<ArrowLeft aria-hidden="true" size={15} />
</button>
)}
{loading ? (
<EmptyState
description="正在读取知识库、来源和索引状态。"
@@ -2431,7 +2469,7 @@ export function KnowledgeWorkspace({
}}
>
<Database aria-hidden="true" size={13} />
{storageModeLabels[selectedLibrary.storageMode]}
· {storageModeLabels[selectedLibrary.storageMode]}
{selectedLibrary.graphEnabled &&
` · ${strategyLabels[selectedLibrary.graphStrategy]}`}
</span>
@@ -2493,6 +2531,7 @@ export function KnowledgeWorkspace({
aria-label={`删除知识库 ${selectedLibrary.name}`}
className="danger-button danger-button--quiet"
onClick={() => setDeletingLibrary(selectedLibrary)}
ref={deleteLibraryTriggerRef}
style={styles.button}
type="button"
>
@@ -2552,7 +2591,7 @@ export function KnowledgeWorkspace({
{deletingLibrary && (
<DeleteLibraryDialog
library={deletingLibrary}
onCancel={() => setDeletingLibrary(undefined)}
onCancel={closeDeleteDialog}
onConfirm={() => onDeleteLibrary(deletingLibrary.id)}
/>
)}
+140 -15
View File
@@ -1,5 +1,6 @@
import {
CircleAlert,
Database,
FlaskConical,
Globe2,
MonitorCog,
@@ -11,7 +12,9 @@ import {
Wrench,
X
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
import { builtinModelTools } from '../../shared/builtin-model-tools'
import type {
CapabilityDiagnosticReport,
@@ -24,6 +27,7 @@ import type {
McpTransport,
RuntimeTarget
} from '../../shared/capability-contracts'
import { trapTabFocus } from './dialog-focus'
const runtimeLabels: Record<RuntimeTarget, string> = {
model: '模型',
@@ -101,6 +105,12 @@ export function McpSettingsSection(): React.JSX.Element {
const [profileNames, setProfileNames] = useState<Record<string, string>>(
{}
)
const editorDialogRef = useRef<HTMLDivElement>(null)
const editorNameRef = useRef<HTMLInputElement>(null)
const editorTriggerRef = useRef<HTMLButtonElement | undefined>(
undefined
)
const editorOpen = Boolean(editor)
useEffect(() => {
void window.goodbuddy.capabilities
@@ -111,6 +121,16 @@ export function McpSettingsSection(): React.JSX.Element {
})
}, [])
useEffect(() => {
if (!editorOpen) {
return
}
const frame = requestAnimationFrame(() =>
editorNameRef.current?.focus()
)
return () => cancelAnimationFrame(frame)
}, [editorOpen])
const run = async (
key: string,
operation: () => Promise<CapabilitySnapshot>
@@ -173,7 +193,7 @@ export function McpSettingsSection(): React.JSX.Element {
const secret: McpServerInput['secret'] = editor.clearToken
? { action: 'clear' }
: editor.token.trim()
? { action: 'replace', value: editor.token.trim() }
? { action: 'replace', value: editor.token }
: { action: 'keep' }
const common = {
name: editor.name,
@@ -202,7 +222,7 @@ export function McpSettingsSection(): React.JSX.Element {
window.goodbuddy.capabilities.saveMcpServer(editor.id, input)
)
if (saved) {
setEditor(undefined)
closeEditor()
}
}
@@ -240,6 +260,37 @@ export function McpSettingsSection(): React.JSX.Element {
})
}
const openEditor = (
nextEditor: McpEditor,
trigger: HTMLButtonElement
): void => {
editorTriggerRef.current = trigger
setError(undefined)
setEditor(nextEditor)
}
const closeEditor = (): void => {
if (busy === 'save') {
return
}
const trigger = editorTriggerRef.current
editorTriggerRef.current = undefined
setError(undefined)
setEditor(undefined)
requestAnimationFrame(() => trigger?.focus())
}
const handleEditorKeyDown = (
event: React.KeyboardEvent<HTMLDivElement>
): void => {
if (event.key === 'Escape') {
event.preventDefault()
closeEditor()
return
}
trapTabFocus(event, editorDialogRef.current)
}
const computerCapabilities = snapshot?.computerCapabilities ?? []
const browserProfiles = snapshot?.browserProfiles ?? {
profiles: [],
@@ -252,12 +303,14 @@ export function McpSettingsSection(): React.JSX.Element {
<Network size={17} />
<div>
<strong> MCP</strong>
<small> MCP Server</small>
<small> MCP MCP Server</small>
</div>
<button
className="secondary-button"
disabled={Boolean(busy) || Boolean(editor)}
onClick={() => setEditor({ ...emptyEditor })}
onClick={(event) =>
openEditor({ ...emptyEditor }, event.currentTarget)
}
type="button"
>
<Plus size={14} />
@@ -271,7 +324,7 @@ export function McpSettingsSection(): React.JSX.Element {
Execute
GoodBuddy
</p>
{error && <p className="settings-warning">{error}</p>}
{error && !editor && <p className="settings-warning">{error}</p>}
<section
aria-labelledby="computer-capabilities-heading"
@@ -513,6 +566,45 @@ export function McpSettingsSection(): React.JSX.Element {
</div>
</section>
<section
aria-labelledby="builtin-mcp-heading"
className="mcp-tool-section"
>
<div className="mcp-subsection-heading">
<div>
<Database size={15} />
<strong id="builtin-mcp-heading">GoodBuddy MCP</strong>
</div>
<small>{builtinMcpServers.length} </small>
</div>
<p className="settings-notice">
MCP GoodBuddy
</p>
<div className="capability-list capability-list--tools">
{builtinMcpServers.map((server) => (
<article className="capability-card" key={server.id}>
<div className="capability-card__header">
<div>
<strong>{server.name}</strong>
<small> · </small>
</div>
<span className="builtin-tool-badge"> MCP</span>
</div>
<p>{server.description}</p>
<code>{server.tools.join('、')}</code>
<div className="runtime-assignments">
<small></small>
<span>
{server.assignments
.map((target) => runtimeLabels[target])
.join('、')}
</span>
</div>
</article>
))}
</div>
</section>
<div className="mcp-tool-section">
<div className="mcp-subsection-heading">
<div>
@@ -541,25 +633,50 @@ export function McpSettingsSection(): React.JSX.Element {
</div>
</div>
{editor && (
<div className="mcp-editor">
<div className="mcp-editor__header">
<strong>{editor.id ? '编辑 MCP Server' : '添加 MCP Server'}</strong>
{editor &&
createPortal(
<div
className="mcp-editor-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
closeEditor()
}
}}
>
<div
aria-labelledby="mcp-editor-title"
aria-modal="true"
className="mcp-editor"
onKeyDown={handleEditorKeyDown}
ref={editorDialogRef}
role="dialog"
>
<div className="mcp-editor__header">
<strong id="mcp-editor-title">
{editor.id ? '编辑 MCP Server' : '添加 MCP Server'}
</strong>
<button
aria-label="关闭 MCP 编辑器"
className="icon-button"
onClick={() => setEditor(undefined)}
disabled={busy === 'save'}
onClick={closeEditor}
type="button"
>
<X size={16} />
</button>
</div>
{error && (
<p className="settings-warning" role="alert">
{error}
</p>
)}
<label className="field">
<span></span>
<input
onChange={(event) =>
setEditor({ ...editor, name: event.target.value })
}
ref={editorNameRef}
value={editor.name}
/>
</label>
@@ -702,7 +819,8 @@ export function McpSettingsSection(): React.JSX.Element {
<div className="mcp-editor__actions">
<button
className="secondary-button"
onClick={() => setEditor(undefined)}
disabled={busy === 'save'}
onClick={closeEditor}
type="button"
>
@@ -716,8 +834,10 @@ export function McpSettingsSection(): React.JSX.Element {
{busy === 'save' ? '保存中…' : '保存 MCP Server'}
</button>
</div>
</div>
)}
</div>
</div>,
document.body
)}
<div className="mcp-subsection-heading">
<div>
@@ -759,7 +879,12 @@ export function McpSettingsSection(): React.JSX.Element {
<button
aria-label={`编辑 ${server.name}`}
disabled={Boolean(busy) || Boolean(editor)}
onClick={() => setEditor(editorFromServer(server))}
onClick={(event) =>
openEditor(
editorFromServer(server),
event.currentTarget
)
}
type="button"
>
<Pencil size={13} />
+2 -21
View File
@@ -7,6 +7,7 @@ import type {
WorkMode
} from '../../shared/assistant-contracts'
import { interactiveWorkModes } from '../../shared/assistant-contracts'
import { trapTabFocus } from './dialog-focus'
type ProjectSwitcherProps = {
projects: AssistantProject[]
@@ -59,27 +60,7 @@ export function ProjectSwitcher({
setCreating(false)
return
}
if (event.key !== 'Tab') {
return
}
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled])'
)
if (!focusable?.length) {
return
}
const first = focusable[0]!
const last = focusable[focusable.length - 1]!
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (
!event.shiftKey &&
document.activeElement === last
) {
event.preventDefault()
first.focus()
}
trapTabFocus(event, dialogRef.current)
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
@@ -0,0 +1,153 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AssistantExpert } from '../../shared/assistant-contracts'
import type { DesktopApi } from '../../shared/contracts'
import { RolePromptSettingsSection } from './RolePromptSettingsSection'
const defaultModelProfileId =
'00000000-0000-4000-8000-000000000501'
const alternateModelProfileId =
'00000000-0000-4000-8000-000000000502'
const removedModelProfileId =
'00000000-0000-4000-8000-000000000503'
const baseExpert: AssistantExpert = {
id: '00000000-0000-4000-8000-000000000511',
name: '研究专家',
description: '分析资料',
systemInstructions: 'Separate evidence from assumptions.',
routingKeywords: ['研究'],
enabled: true,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
function installExpertsApi(expert: AssistantExpert) {
const update = vi.fn<DesktopApi['experts']['update']>(
async (expertId, input) => ({
...expert,
...input,
id: expertId,
modelProfileId: input.modelProfileId,
routingKeywords: input.routingKeywords ?? [],
updatedAt: '2026-08-02T00:00:00.000Z'
})
)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
experts: {
list: vi.fn(async () => [expert]),
create: vi.fn(),
update,
remove: vi.fn()
}
} as unknown as DesktopApi
})
return { update }
}
describe('RolePromptSettingsSection model connections', () => {
it('selects an expert connection without exposing connection secrets', async () => {
const expert = {
...baseExpert,
modelProfileId: alternateModelProfileId
}
const { update } = installExpertsApi(expert)
const profiles = [
{
id: defaultModelProfileId,
name: '默认模型',
apiKey: 'must-not-appear'
},
{
id: alternateModelProfileId,
name: '研究模型',
apiKey: 'another-secret'
}
]
render(
<RolePromptSettingsSection
defaultModelProfileId={defaultModelProfileId}
modelProfiles={profiles}
onChanged={vi.fn()}
/>
)
const selector = await screen.findByLabelText('角色模型连接')
expect(selector).toHaveValue(alternateModelProfileId)
expect(
screen.getByRole('option', {
name: '继承默认模型(默认模型)'
})
).toBeInTheDocument()
expect(
screen.getByText(/综合模式和专家团队始终继承默认模型/)
).toBeInTheDocument()
expect(
screen.queryByText(/must-not-appear|another-secret/)
).not.toBeInTheDocument()
fireEvent.change(selector, { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: '保存角色' }))
await waitFor(() =>
expect(update).toHaveBeenCalledWith(expert.id, {
name: expert.name,
description: expert.description,
systemInstructions: expert.systemInstructions,
routingKeywords: expert.routingKeywords
})
)
fireEvent.change(selector, {
target: { value: alternateModelProfileId }
})
fireEvent.click(screen.getByRole('button', { name: '保存角色' }))
await waitFor(() =>
expect(update).toHaveBeenLastCalledWith(
expert.id,
expect.objectContaining({
modelProfileId: alternateModelProfileId
})
)
)
})
it('shows the default fallback when a saved connection was removed', async () => {
installExpertsApi({
...baseExpert,
modelProfileId: removedModelProfileId
})
render(
<RolePromptSettingsSection
defaultModelProfileId={defaultModelProfileId}
modelProfiles={[
{ id: defaultModelProfileId, name: '默认模型' }
]}
onChanged={vi.fn()}
/>
)
expect(
await screen.findByText(
/指定的模型连接已失效,运行时将回退到默认模型“默认模型”/
)
).toBeInTheDocument()
expect(screen.getByLabelText('角色模型连接')).toHaveValue(
removedModelProfileId
)
})
})
+74 -3
View File
@@ -4,6 +4,7 @@ import type {
AssistantExpert,
ExpertCreateInput
} from '../../shared/assistant-contracts'
import type { ModelConnectionSettings } from '../../shared/contracts'
import { DestructiveConfirmActions } from './WorkspacePrimitives'
type ExpertDraft = Omit<ExpertCreateInput, 'routingKeywords'> & {
@@ -13,6 +14,10 @@ type ExpertDraft = Omit<ExpertCreateInput, 'routingKeywords'> & {
type RolePromptSettingsSectionProps = {
onChanged: (experts: AssistantExpert[]) => void
modelProfiles?: ReadonlyArray<
Pick<ModelConnectionSettings, 'id' | 'name'>
>
defaultModelProfileId?: string
}
const emptyDraft: ExpertDraft = {
@@ -28,6 +33,7 @@ function draftFromExpert(expert: AssistantExpert): ExpertDraft {
name: expert.name,
description: expert.description,
systemInstructions: expert.systemInstructions,
modelProfileId: expert.modelProfileId,
routingKeywordsText: (expert.routingKeywords ?? []).join('、')
}
}
@@ -68,7 +74,9 @@ function sortExperts(experts: AssistantExpert[]): AssistantExpert[] {
}
export function RolePromptSettingsSection({
onChanged
onChanged,
modelProfiles = [],
defaultModelProfileId
}: RolePromptSettingsSectionProps): React.JSX.Element {
const [experts, setExperts] = useState<AssistantExpert[]>([])
const [selectedId, setSelectedId] = useState<string>()
@@ -134,7 +142,10 @@ export function RolePromptSettingsSection({
name: draft.name,
description: draft.description,
systemInstructions: draft.systemInstructions,
routingKeywords
routingKeywords,
...(draft.modelProfileId
? { modelProfileId: draft.modelProfileId }
: {})
}
const saved = draft.id
? await window.goodbuddy.experts.update(draft.id, input)
@@ -187,6 +198,18 @@ export function RolePromptSettingsSection({
}
}
const defaultModelProfile = modelProfiles.find(
(profile) => profile.id === defaultModelProfileId
)
const selectedModelProfileAvailable =
!draft?.modelProfileId ||
modelProfiles.some(
(profile) => profile.id === draft.modelProfileId
)
const inheritedModelLabel = defaultModelProfile
? `继承默认模型(${defaultModelProfile.name}`
: '继承默认模型'
return (
<div className="settings-section">
<div className="settings-section__title settings-section__title--actions">
@@ -208,7 +231,8 @@ export function RolePromptSettingsSection({
<p className="settings-notice">
使
3 使
3
使使
</p>
{error && <p className="settings-warning" role="alert">{error}</p>}
@@ -296,6 +320,53 @@ export function RolePromptSettingsSection({
20,000
</small>
</label>
<label className="field">
<span></span>
<select
aria-describedby={
selectedModelProfileAvailable
? 'role-model-profile-help'
: 'role-model-profile-fallback role-model-profile-help'
}
aria-label="角色模型连接"
onChange={(event) =>
setDraft({
...draft,
modelProfileId: event.target.value || undefined
})
}
value={draft.modelProfileId ?? ''}
>
<option value="">{inheritedModelLabel}</option>
{!selectedModelProfileAvailable &&
draft.modelProfileId && (
<option disabled value={draft.modelProfileId}>
</option>
)}
{modelProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</select>
<small id="role-model-profile-help">
</small>
{!selectedModelProfileAvailable && (
<small
className="field-error"
id="role-model-profile-fallback"
role="status"
>
退
{defaultModelProfile
? `默认模型“${defaultModelProfile.name}`
: '当前默认模型'}
</small>
)}
</label>
<label className="field">
<span></span>
<textarea
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,267 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SpeechModelSnapshot } from '../../shared/speech-model-contracts'
import type { DesktopApi } from '../../shared/contracts'
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
const entry = {
id: 'sensevoice-small-int8',
displayName: 'SenseVoiceSmall INT8',
description: '快速中文语音识别。',
languages: ['中文', '粤语'],
family: 'sensevoice' as const,
quantization: 'int8' as const,
repositoryUrl: 'https://huggingface.co/example/model',
license: {
name: '模型仓库自定义许可',
notice: '使用前请阅读许可。',
url: 'https://example.com/license'
},
manualOnly: false,
files: [
{
name: 'model.int8.onnx',
role: 'model' as const,
download: {
url: 'https://huggingface.co/example/model/resolve/revision/model.int8.onnx',
size: 1_000,
sha256: 'a'.repeat(64)
}
},
{
name: 'tokens.txt',
role: 'tokens' as const,
download: {
url: 'https://huggingface.co/example/model/resolve/revision/tokens.txt',
size: 100,
sha256: 'b'.repeat(64)
}
}
]
}
const snapshot: SpeechModelSnapshot = {
rootDirectory: 'C:\\Users\\test\\models\\speech',
catalog: [entry],
installed: [],
operations: [],
selectedModelId: null
}
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('SpeechModelSettingsSection', () => {
it('lists downloadable models and starts a verified download', async () => {
const installedSnapshot: SpeechModelSnapshot = {
...snapshot,
installed: [
{
id: entry.id,
displayName: entry.displayName,
source: 'download',
installedAt: '2026-08-06T00:00:00.000Z',
files: [
{
name: 'model.int8.onnx',
role: 'model',
size: 1_000,
sha256: 'a'.repeat(64)
}
]
}
]
}
const install = vi.fn(async () => installedSnapshot)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
speechModels: {
getSnapshot: vi.fn(async () => snapshot),
install,
cancel: vi.fn(async () => true),
remove: vi.fn(),
select: vi.fn(),
importLocalDirectory: vi.fn(),
openRepository: vi.fn(),
openModelsDirectory: vi.fn()
}
} as unknown as DesktopApi
})
render(<SpeechModelSettingsSection />)
expect(await screen.findByText('SenseVoiceSmall INT8'))
.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '下载模型' }))
await waitFor(() =>
expect(install).toHaveBeenCalledWith('sensevoice-small-int8')
)
expect(await screen.findByText('SenseVoiceSmall INT8 已安装'))
.toBeInTheDocument()
})
it('offers a download button for a verified Whisper model', async () => {
const whisperEntry = {
...entry,
id: 'whisper-tiny-multilingual',
displayName: 'Whisper Tiny(多语言)',
family: 'whisper' as const,
files: [
{
...entry.files[0],
name: 'tiny-encoder.int8.onnx',
role: 'encoder' as const
}
]
}
const whisperSnapshot: SpeechModelSnapshot = {
...snapshot,
catalog: [whisperEntry]
}
const install = vi.fn(async () => whisperSnapshot)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
speechModels: {
getSnapshot: vi.fn(async () => whisperSnapshot),
install,
cancel: vi.fn(async () => true),
remove: vi.fn(),
select: vi.fn(),
importLocalDirectory: vi.fn(),
openRepository: vi.fn(),
openModelsDirectory: vi.fn()
}
} as unknown as DesktopApi
})
render(<SpeechModelSettingsSection />)
expect(await screen.findByText('Whisper Tiny(多语言)'))
.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '下载模型' }))
await waitFor(() =>
expect(install).toHaveBeenCalledWith('whisper-tiny-multilingual')
)
})
it('shows live progress and cancellation for an active download', async () => {
const active: SpeechModelSnapshot = {
...snapshot,
operations: [
{
modelId: entry.id,
kind: 'download',
phase: 'transferring',
currentFile: 'model.int8.onnx',
completedBytes: 550,
totalBytes: 1_100
}
]
}
const cancel = vi.fn(async () => true)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
speechModels: {
getSnapshot: vi.fn(async () => active),
install: vi.fn(),
cancel,
remove: vi.fn(),
select: vi.fn(),
importLocalDirectory: vi.fn(),
openRepository: vi.fn(),
openModelsDirectory: vi.fn()
}
} as unknown as DesktopApi
})
render(<SpeechModelSettingsSection />)
expect(await screen.findByRole('progressbar', {
name: 'SenseVoiceSmall INT8下载进度'
})).toHaveValue(50)
fireEvent.click(screen.getByRole('button', { name: '取消' }))
await waitFor(() =>
expect(cancel).toHaveBeenCalledWith('sensevoice-small-int8')
)
})
it('resumes polling an active download after remounting', async () => {
const active: SpeechModelSnapshot = {
...snapshot,
operations: [
{
modelId: entry.id,
kind: 'download',
phase: 'transferring',
currentFile: 'model.int8.onnx',
completedBytes: 550,
totalBytes: 1_100
}
]
}
const completed: SpeechModelSnapshot = {
...snapshot,
installed: [
{
id: entry.id,
displayName: entry.displayName,
source: 'download',
installedAt: '2026-08-06T00:00:00.000Z',
files: [
{
name: 'model.int8.onnx',
role: 'model',
size: 1_000,
sha256: 'a'.repeat(64)
}
]
}
]
}
const getSnapshot = vi
.fn<() => Promise<SpeechModelSnapshot>>()
.mockResolvedValueOnce(active)
.mockResolvedValueOnce(active)
.mockResolvedValue(completed)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
speechModels: {
getSnapshot,
install: vi.fn(),
cancel: vi.fn(async () => true),
remove: vi.fn(),
select: vi.fn(),
importLocalDirectory: vi.fn(),
openRepository: vi.fn(),
openModelsDirectory: vi.fn()
}
} as unknown as DesktopApi
})
const first = render(<SpeechModelSettingsSection />)
expect(await screen.findByRole('progressbar')).toBeInTheDocument()
first.unmount()
render(<SpeechModelSettingsSection />)
expect(await screen.findByRole('progressbar')).toBeInTheDocument()
await waitFor(
() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.getByText('已安装')).toBeInTheDocument()
},
{ timeout: 1_000 }
)
expect(getSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3)
})
})
@@ -0,0 +1,367 @@
import {
Download,
ExternalLink,
FolderOpen,
Mic,
Square,
Trash2
} from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type {
SpeechModelCatalogEntry,
SpeechModelOperation,
SpeechModelSnapshot
} from '../../shared/speech-model-contracts'
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function catalogSize(entry: SpeechModelCatalogEntry): number | undefined {
const downloads = entry.files.map((file) => file.download)
return downloads.every(Boolean)
? downloads.reduce(
(total, download) => total + (download?.size ?? 0),
0
)
: undefined
}
function progressPercent(operation: SpeechModelOperation): number | undefined {
return operation.totalBytes && operation.totalBytes > 0
? Math.min(
100,
(operation.completedBytes / operation.totalBytes) * 100
)
: undefined
}
export function SpeechModelSettingsSection(): React.JSX.Element {
const [snapshot, setSnapshot] = useState<SpeechModelSnapshot>()
const [busyModelId, setBusyModelId] = useState<string>()
const [confirmingRemove, setConfirmingRemove] = useState<string>()
const [error, setError] = useState<string>()
const [notice, setNotice] = useState<string>()
const mountedRef = useRef(false)
const refresh = useCallback(async (): Promise<void> => {
const api = window.goodbuddy.speechModels
if (!api) {
throw new Error('当前版本未提供语音模型服务')
}
const next = await api.getSnapshot()
if (mountedRef.current) {
setSnapshot(next)
}
}, [])
useEffect(() => {
const api = window.goodbuddy.speechModels
let active = true
mountedRef.current = true
void (async () => {
if (!api) {
throw new Error('当前版本未提供语音模型服务')
}
return api.getSnapshot()
})()
.then((next) => {
if (active) {
setSnapshot(next)
}
})
.catch((reason: unknown) => {
if (active) {
setError(
reason instanceof Error ? reason.message : '读取语音模型失败'
)
}
})
return () => {
active = false
mountedRef.current = false
}
}, [])
const shouldPoll =
busyModelId !== undefined || Boolean(snapshot?.operations.length)
useEffect(() => {
if (!shouldPoll) {
return
}
const timer = window.setInterval(() => {
void refresh().catch(() => undefined)
}, 300)
return () => window.clearInterval(timer)
}, [refresh, shouldPoll])
const run = async (
modelId: string,
operation: () => Promise<SpeechModelSnapshot | undefined>,
successMessage: string
): Promise<void> => {
setBusyModelId(modelId)
setError(undefined)
setNotice(undefined)
try {
const next = await operation()
if (next && mountedRef.current) {
setSnapshot(next)
setNotice(successMessage)
}
} catch (reason) {
if (mountedRef.current) {
setError(
reason instanceof Error ? reason.message : '语音模型操作失败'
)
}
} finally {
if (mountedRef.current) {
setBusyModelId(undefined)
void refresh().catch(() => undefined)
}
}
}
const remove = async (modelId: string): Promise<void> => {
const api = window.goodbuddy.speechModels
if (!api) {
return
}
if (confirmingRemove !== modelId) {
setConfirmingRemove(modelId)
return
}
setConfirmingRemove(undefined)
await run(
modelId,
() => api.remove(modelId),
'语音模型已删除'
)
}
if (!snapshot) {
return (
<div className="settings-section">
<p className={error ? 'settings-warning' : 'settings-empty'}>
{error ?? '正在读取语音模型…'}
</p>
</div>
)
}
const installedById = new Map(
snapshot.installed.map((model) => [model.id, model])
)
const operationsById = new Map(
snapshot.operations.map((operation) => [
operation.modelId,
operation
])
)
return (
<section
aria-labelledby="speech-model-settings-heading"
className="settings-section speech-model-settings"
>
<div className="settings-section__title settings-section__title--actions">
<Mic aria-hidden="true" size={17} />
<div>
<strong id="speech-model-settings-heading"></strong>
<small></small>
</div>
<button
className="secondary-button"
onClick={() =>
void window.goodbuddy.speechModels?.openModelsDirectory()
}
type="button"
>
<FolderOpen aria-hidden="true" size={13} />
</button>
</div>
<p className="settings-notice">
<code>{snapshot.rootDirectory}</code>
SHA-256
</p>
{error && <p className="settings-warning" role="alert">{error}</p>}
{notice && <p className="settings-success" role="status">{notice}</p>}
<div className="speech-model-settings__list">
{snapshot.catalog.map((entry) => {
const installed = installedById.get(entry.id)
const operation = operationsById.get(entry.id)
const percent = operation
? progressPercent(operation)
: undefined
const size = catalogSize(entry)
const selected = snapshot.selectedModelId === entry.id
return (
<article className="capability-card" key={entry.id}>
<div className="capability-card__header">
<div>
<strong>{entry.displayName}</strong>
<small>
{entry.languages.join('、')} · {entry.quantization.toUpperCase()}
{size ? ` · ${formatBytes(size)}` : ''}
</small>
</div>
<span>
{selected
? '正在使用'
: installed
? '已安装'
: entry.manualOnly
? '手动导入'
: '可下载'}
</span>
</div>
<p>{entry.description}</p>
<p>
<strong>{entry.license.name}</strong>
{entry.license.notice}
</p>
{operation && (
<div aria-live="polite" className="speech-model-operation">
<progress
aria-label={`${entry.displayName}下载进度`}
max={100}
{...(percent === undefined ? {} : { value: percent })}
/>
<small>
{operation.currentFile
? `正在处理 ${operation.currentFile}`
: operation.phase === 'installing'
? '正在校验并安装…'
: '正在准备…'}
{percent === undefined
? ''
: ` · ${percent.toFixed(0)}%`}
</small>
</div>
)}
{entry.manualOnly && entry.manualReason && !installed && (
<p className="settings-notice">{entry.manualReason}</p>
)}
<div className="speech-model-card__actions">
{operation ? (
<button
className="secondary-button"
onClick={() =>
void window.goodbuddy.speechModels
?.cancel(entry.id)
.then(() => refresh())
}
type="button"
>
<Square aria-hidden="true" size={12} />
</button>
) : installed ? (
<>
{!selected && (
<button
className="primary-button"
disabled={busyModelId === entry.id}
onClick={() =>
void run(
entry.id,
() =>
window.goodbuddy.speechModels!.select(
entry.id
),
`已切换到 ${entry.displayName}`
)
}
type="button"
>
使
</button>
)}
<button
className={
confirmingRemove === entry.id
? 'danger-button'
: 'secondary-button'
}
disabled={busyModelId === entry.id}
onClick={() => void remove(entry.id)}
type="button"
>
<Trash2 aria-hidden="true" size={12} />
{confirmingRemove === entry.id
? '确认删除模型'
: '删除模型'}
</button>
</>
) : (
<>
{!entry.manualOnly && (
<button
className="primary-button"
disabled={busyModelId === entry.id}
onClick={() =>
void run(
entry.id,
() =>
window.goodbuddy.speechModels!.install(
entry.id
),
`${entry.displayName} 已安装`
)
}
type="button"
>
<Download aria-hidden="true" size={13} />
</button>
)}
<button
className="secondary-button"
disabled={busyModelId === entry.id}
onClick={() =>
void run(
entry.id,
() =>
window.goodbuddy.speechModels!
.importLocalDirectory(entry.id),
`${entry.displayName} 已从本地目录导入`
)
}
type="button"
>
<FolderOpen aria-hidden="true" size={13} />
</button>
</>
)}
<button
className="secondary-button"
onClick={() =>
void window.goodbuddy.speechModels?.openRepository(
entry.id
)
}
type="button"
>
<ExternalLink aria-hidden="true" size={13} />
</button>
</div>
</article>
)
})}
</div>
</section>
)
}
@@ -0,0 +1,129 @@
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 { UpdateSettingsSection } from './UpdateSettingsSection'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('UpdateSettingsSection', () => {
it('checks the official release manifest and updates the startup preference', async () => {
const updateSettings = vi.fn<
NonNullable<DesktopApi['updates']>['updateSettings']
>(async (input) => input)
const check = vi.fn<
NonNullable<DesktopApi['updates']>['check']
>(async () => ({
updateAvailable: true,
currentVersion: '0.8.1',
latestVersion: '0.9.0',
releaseUrl:
'https://github.com/mesalogo/goodbuddy/releases/tag/v0.9.0',
target: {
platform: 'windows' as const,
arch: 'x64' as const,
formats: ['nsis', 'portable'],
files: [
{
name: 'GoodBuddy-0.9.0-windows-x64-setup.exe',
size: 1024 * 1024,
sha256: 'a'.repeat(64)
}
]
}
}))
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
app: {
getInfo: vi.fn(async () => ({
name: 'GoodBuddy',
version: '0.8.1',
platform: 'win32',
arch: 'x64',
shortcut: 'Ctrl+Shift+Space'
}))
},
updates: {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true
})),
updateSettings,
check,
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {})
}
} as unknown as DesktopApi
})
render(<UpdateSettingsSection />)
const startup = await screen.findByRole('checkbox', {
name: '启动时检查新版本'
})
expect(startup).toBeChecked()
fireEvent.click(startup)
await waitFor(() =>
expect(updateSettings).toHaveBeenCalledWith({
checkUpdatesOnStartup: false
})
)
fireEvent.click(
screen.getByRole('button', { name: '立即检查更新' })
)
expect(await screen.findByText('发现新版本 0.9.0'))
.toBeInTheDocument()
expect(
screen.getByText('GoodBuddy-0.9.0-windows-x64-setup.exe')
).toBeInTheDocument()
})
it('replaces Electron fetch wrappers with an actionable network error', async () => {
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
app: {
getInfo: vi.fn(async () => ({
name: 'GoodBuddy',
version: '0.8.1',
platform: 'win32',
arch: 'x64',
shortcut: 'Ctrl+Shift+Space'
}))
},
updates: {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true
})),
updateSettings: vi.fn(async (input) => input),
check: vi.fn(async () => {
throw new Error(
"Error invoking remote method 'application:update:check': TypeError: fetch failed"
)
}),
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {})
}
} as unknown as DesktopApi
})
render(<UpdateSettingsSection />)
fireEvent.click(
await screen.findByRole('button', { name: '立即检查更新' })
)
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent(
'版本检查失败:无法连接 GoodBuddy 官方 GitHub Release,请检查网络或代理后重试'
)
expect(alert).not.toHaveTextContent('Error invoking remote method')
})
})
+207
View File
@@ -0,0 +1,207 @@
import { ExternalLink, Info, RefreshCw } from 'lucide-react'
import { useEffect, useState } from 'react'
import type {
ApplicationSettings,
VersionCheckResult
} from '../../shared/application-settings-contracts'
import type { AppInfo } from '../../shared/contracts'
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function updateErrorMessage(
reason: unknown,
fallback: string
): string {
if (!(reason instanceof Error)) {
return fallback
}
const message = reason.message
.replace(
/^Error invoking remote method '[^']+':\s*/,
''
)
.replace(/^(?:TypeError|Error):\s*/, '')
.trim()
if (/fetch failed/i.test(message)) {
return `${fallback}:无法连接 GoodBuddy 官方 GitHub Release,请检查网络或代理后重试`
}
return message || fallback
}
export function UpdateSettingsSection(): React.JSX.Element {
const [settings, setSettings] = useState<ApplicationSettings>()
const [appInfo, setAppInfo] = useState<AppInfo>()
const [result, setResult] = useState<VersionCheckResult>()
const [checking, setChecking] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string>()
useEffect(() => {
const updates = window.goodbuddy.updates
let active = true
void (async () => {
if (!updates) {
throw new Error('当前版本未提供版本检查服务')
}
return Promise.all([
updates.getSettings(),
window.goodbuddy.app.getInfo()
])
})()
.then(([nextSettings, info]) => {
if (active) {
setSettings(nextSettings)
setAppInfo(info)
}
})
.catch((reason: unknown) => {
if (active) {
setError(updateErrorMessage(reason, '读取应用设置失败'))
}
})
return () => {
active = false
}
}, [])
const changeStartupCheck = async (enabled: boolean): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
setSettings(
await updates.updateSettings({
checkUpdatesOnStartup: enabled
})
)
} catch (reason) {
setError(updateErrorMessage(reason, '保存更新设置失败'))
} finally {
setSaving(false)
}
}
const check = async (): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates) {
return
}
setChecking(true)
setError(undefined)
try {
setResult(await updates.check())
} catch (reason) {
setError(updateErrorMessage(reason, '版本检查失败'))
} finally {
setChecking(false)
}
}
return (
<section
aria-labelledby="update-settings-heading"
className="settings-section update-settings"
>
<div className="settings-section__title">
<Info aria-hidden="true" size={17} />
<div>
<strong id="update-settings-heading"></strong>
<small> GoodBuddy GitHub Release</small>
</div>
</div>
<article className="capability-card">
<div className="capability-card__header">
<div>
<strong>GoodBuddy {appInfo?.version ?? '—'}</strong>
<small>
{appInfo
? `${appInfo.platform} · ${appInfo.arch}`
: '正在读取应用信息…'}
</small>
</div>
</div>
<label className="toggle-row">
<input
checked={settings?.checkUpdatesOnStartup ?? false}
disabled={!settings || saving}
onChange={(event) =>
void changeStartupCheck(event.target.checked)
}
type="checkbox"
/>
<span></span>
</label>
<div className="update-settings__actions">
<button
className="secondary-button"
disabled={checking}
onClick={() => void check()}
type="button"
>
<RefreshCw aria-hidden="true" size={13} />
{checking ? '正在检查…' : '立即检查更新'}
</button>
<button
className="secondary-button"
onClick={() =>
void window.goodbuddy.updates?.openReleasePage()
}
type="button"
>
<ExternalLink aria-hidden="true" size={13} />
</button>
</div>
</article>
{error && (
<p className="settings-warning" role="alert">
{error}
</p>
)}
{result && (
<article
aria-live="polite"
className="capability-card update-settings__result"
>
<div className="capability-card__header">
<div>
<strong>
{result.updateAvailable
? `发现新版本 ${result.latestVersion}`
: '当前已是最新版本'}
</strong>
<small>
{result.currentVersion} · {result.target.platform}/
{result.target.arch}
</small>
</div>
</div>
<ul>
{result.target.files.map((file) => (
<li key={file.name}>
<code>{file.name}</code>
<span>{formatBytes(file.size)}</span>
</li>
))}
</ul>
<p>
SHA-256GoodBuddy
</p>
</article>
)}
</section>
)
}
+34
View File
@@ -0,0 +1,34 @@
type TabKeyEvent = {
key: string
shiftKey: boolean
preventDefault: () => void
}
const focusableSelector =
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled])'
export function trapTabFocus(
event: TabKeyEvent,
container: HTMLElement | null
): void {
if (event.key !== 'Tab' || !container) {
return
}
const focusable =
container.querySelectorAll<HTMLElement>(focusableSelector)
if (focusable.length === 0) {
return
}
const first = focusable[0]!
const last = focusable[focusable.length - 1]!
if (!container.contains(document.activeElement)) {
event.preventDefault()
first.focus()
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
+13 -2
View File
@@ -4,6 +4,7 @@ import {
getSpeechRecognitionConstructor,
isElectronUserAgent,
prepareSpeechRecognition,
resamplePcm,
type SpeechRecognitionConstructor,
type SpeechRecognitionInstance
} from './speech-recognition'
@@ -98,7 +99,7 @@ describe('speech recognition', () => {
expect(instance.processLocally).toBeUndefined()
})
it('avoids Electron speech APIs that can freeze the renderer', async () => {
it('keeps the unsafe Web Speech fallback disabled in Electron', async () => {
const { Recognition } = createRecognitionConstructor()
Recognition.available = vi.fn(
async () => 'available' as const
@@ -112,11 +113,21 @@ describe('speech recognition', () => {
{},
'Mozilla/5.0 Electron/43.2.0'
)
).rejects.toThrow('不支持可靠的语音识别')
).rejects.toThrow('本地语音识别服务未加载')
expect(Recognition).not.toHaveBeenCalled()
expect(Recognition.available).not.toHaveBeenCalled()
})
it('resamples bounded microphone PCM to the local runtime rate', () => {
const result = resamplePcm(
new Float32Array([0, 0.5, 1, 0.5]),
32_000,
16_000
)
expect([...result]).toEqual([0, 1])
})
it('reports a language pack that is still downloading', async () => {
const { Recognition } = createRecognitionConstructor()
Recognition.available = vi.fn(
+161 -3
View File
@@ -105,9 +105,7 @@ export async function prepareSpeechRecognition(
userAgent = navigator.userAgent
): Promise<PreparedSpeechRecognition> {
if (isElectronUserAgent(userAgent)) {
throw new Error(
'当前 Electron 版本不支持可靠的语音识别,请改用系统听写功能输入文字'
)
throw new Error('本地语音识别服务未加载,请重启 GoodBuddy 后重试')
}
const recognition = new Recognition()
const options: LocalSpeechOptions = {
@@ -148,6 +146,166 @@ export async function prepareSpeechRecognition(
return { recognition, local }
}
export type PcmRecordingResult = {
audio: ArrayBuffer
sampleRate: 16_000
}
export type PcmRecording = {
result: Promise<PcmRecordingResult>
stop: () => void
cancel: () => void
}
type AudioContextConstructor = new () => AudioContext
function recordingAbortError(): Error {
const error = new Error('语音录音已取消')
error.name = 'AbortError'
return error
}
export function resamplePcm(
samples: Float32Array,
sourceRate: number,
targetRate = 16_000
): Float32Array {
if (
samples.length === 0 ||
!Number.isFinite(sourceRate) ||
sourceRate <= 0 ||
!Number.isFinite(targetRate) ||
targetRate <= 0
) {
return new Float32Array()
}
if (sourceRate === targetRate) {
return samples.slice()
}
const outputLength = Math.max(
1,
Math.floor((samples.length * targetRate) / sourceRate)
)
const output = new Float32Array(outputLength)
const ratio = sourceRate / targetRate
for (let index = 0; index < outputLength; index += 1) {
const position = index * ratio
const leftIndex = Math.min(Math.floor(position), samples.length - 1)
const rightIndex = Math.min(leftIndex + 1, samples.length - 1)
const fraction = position - leftIndex
output[index] =
(samples[leftIndex] ?? 0) * (1 - fraction) +
(samples[rightIndex] ?? 0) * fraction
}
return output
}
export async function startPcmRecording(
mediaDevices: Pick<MediaDevices, 'getUserMedia'>,
AudioContextType: AudioContextConstructor,
maxSeconds = 20
): Promise<PcmRecording> {
const stream = await mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true
},
video: false
})
let context: AudioContext | undefined
let source: MediaStreamAudioSourceNode | undefined
let processor: ScriptProcessorNode | undefined
let timer: ReturnType<typeof setTimeout> | undefined
let settled = false
const chunks: Float32Array[] = []
let sampleCount = 0
let resolveResult!: (result: PcmRecordingResult) => void
let rejectResult!: (reason: Error) => void
const result = new Promise<PcmRecordingResult>((resolve, reject) => {
resolveResult = resolve
rejectResult = reject
})
const cleanup = (): void => {
if (timer) {
clearTimeout(timer)
timer = undefined
}
processor?.disconnect()
source?.disconnect()
for (const track of stream.getTracks()) {
track.stop()
}
if (context) {
void context.close().catch(() => undefined)
}
}
const stop = (): void => {
if (settled) {
return
}
settled = true
cleanup()
if (!context || sampleCount === 0) {
rejectResult(new Error('没有录到声音,请检查麦克风后重试'))
return
}
const combined = new Float32Array(sampleCount)
let offset = 0
for (const chunk of chunks) {
combined.set(chunk, offset)
offset += chunk.length
}
const resampled = resamplePcm(combined, context.sampleRate)
resolveResult({
audio: resampled.buffer as ArrayBuffer,
sampleRate: 16_000
})
}
const cancel = (): void => {
if (settled) {
return
}
settled = true
cleanup()
rejectResult(recordingAbortError())
}
try {
context = new AudioContextType()
source = context.createMediaStreamSource(stream)
processor = context.createScriptProcessor(4_096, 1, 1)
const maximumSamples = Math.ceil(
Math.min(context.sampleRate, 192_000) * maxSeconds
)
processor.onaudioprocess = (event) => {
if (settled) {
return
}
const channel = event.inputBuffer.getChannelData(0)
const remaining = maximumSamples - sampleCount
if (remaining <= 0) {
stop()
return
}
const chunk = channel.slice(0, remaining)
chunks.push(chunk)
sampleCount += chunk.length
if (sampleCount >= maximumSamples) {
stop()
}
}
source.connect(processor)
processor.connect(context.destination)
timer = setTimeout(stop, maxSeconds * 1_000)
return { result, stop, cancel }
} catch (error) {
cleanup()
throw error
}
}
export function isElectronUserAgent(userAgent: string): boolean {
return /\bElectron\/[\d.]+\b/u.test(userAgent)
}
+564 -39
View File
@@ -1426,32 +1426,41 @@ textarea:focus-visible {
.topbar-menu__popover {
position: absolute;
z-index: 40;
top: calc(100% + 7px);
top: calc(100% + 6px);
right: 0;
display: grid;
width: 210px;
padding: var(--space-2);
width: 188px;
padding: var(--space-1);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
gap: 2px;
gap: var(--space-1);
}
.topbar-menu__popover button {
display: flex;
width: 100%;
min-height: 34px;
min-height: 32px;
align-items: center;
padding: 0 var(--space-3);
padding: 0 var(--space-2);
border-radius: var(--radius-control);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: var(--font-section-title);
font-weight: 500;
gap: var(--space-2);
line-height: 1.4;
text-align: left;
}
.topbar-menu__popover button svg {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.topbar-menu__popover button:hover {
background: var(--accent-subtle);
color: var(--accent);
@@ -2517,6 +2526,8 @@ textarea:focus-visible {
}
.runtime-picker__menu > button > small {
grid-column: 1;
grid-row: 2;
overflow: hidden;
color: #8c8c8c;
font-size: 8px;
@@ -2524,6 +2535,18 @@ textarea:focus-visible {
white-space: nowrap;
}
.runtime-picker__menu > button.runtime-picker__back {
display: flex;
min-height: 34px;
align-items: center;
justify-content: flex-start;
}
.runtime-picker__chevron {
grid-column: 2;
grid-row: 1 / span 2;
}
.runtime-picker__divider {
height: 1px;
margin: 4px;
@@ -2649,7 +2672,7 @@ textarea:focus-visible {
padding: 3px;
border-radius: 9px;
background: #f5f5f5;
grid-template-columns: repeat(8, 1fr);
grid-template-columns: repeat(10, 1fr);
}
.settings-tabs button {
@@ -2788,6 +2811,145 @@ textarea:focus-visible {
line-height: 1.5;
}
.agent-runtime-navigation {
display: grid;
align-items: center;
grid-template-columns: max-content minmax(0, 1fr);
}
.agent-runtime-navigation > small {
min-width: 0;
overflow-wrap: anywhere;
}
.runtime-note {
margin: 0;
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--text-secondary);
font-size: var(--font-body);
line-height: 1.6;
overflow-wrap: anywhere;
}
.runtime-note strong {
color: var(--text-primary);
font-size: inherit;
font-weight: 650;
}
.runtime-source-options {
display: grid;
min-width: 0;
margin: 0 var(--space-4);
padding: 0;
border: 0;
gap: var(--space-2);
}
.runtime-source-options legend {
margin-bottom: var(--space-2);
color: var(--text-primary);
font-size: var(--font-body);
font-weight: 650;
}
.runtime-source-options label {
display: grid;
min-width: 0;
padding: var(--space-3);
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
background: var(--surface-raised);
cursor: pointer;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-3);
}
.runtime-source-options label:has(input:checked) {
border-color: var(--accent);
background: var(--accent-selected);
}
.runtime-source-options label:has(input:disabled) {
cursor: not-allowed;
opacity: 0.65;
}
.runtime-source-options input {
margin-top: 2px;
}
.runtime-source-options label > span,
.runtime-config-card > div:first-child {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.runtime-source-options strong,
.runtime-config-card strong {
color: var(--text-primary);
font-size: var(--font-body);
}
.runtime-source-options small,
.runtime-config-card small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
overflow-wrap: anywhere;
}
.runtime-config-card {
display: grid;
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-subtle);
gap: var(--space-3);
}
.runtime-config-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.runtime-config-card__hint {
color: var(--text-secondary) !important;
}
details.settings-section {
padding: 0;
gap: 0;
}
details.settings-section > summary {
padding: var(--space-4);
color: var(--text-primary);
cursor: pointer;
font-size: var(--font-body);
font-weight: 650;
line-height: 1.4;
}
details.settings-section[open] {
padding-bottom: var(--space-4);
}
details.settings-section[open] > summary {
margin-bottom: var(--space-4);
border-bottom: 1px solid var(--border-subtle);
}
details.settings-section > :not(summary) {
margin-right: var(--space-4);
margin-left: var(--space-4);
}
.model-connection-manager {
display: grid;
min-width: 0;
@@ -2965,6 +3127,89 @@ textarea:focus-visible {
gap: var(--space-2);
}
.channel-settings__grid {
display: grid;
align-items: start;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.channel-settings-card > .secondary-button,
.channel-settings .settings-section__title--actions > .primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.update-settings__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.update-settings__actions button {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.update-settings__result ul {
display: grid;
padding: 0;
margin: 0;
gap: var(--space-2);
list-style: none;
}
.update-settings__result li {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.update-settings__result li code {
min-width: 0;
padding: 0;
overflow: hidden;
background: transparent;
text-overflow: ellipsis;
white-space: nowrap;
}
.speech-model-settings__list {
display: grid;
gap: var(--space-3);
}
.speech-model-settings .settings-section__title--actions > button,
.speech-model-card__actions,
.speech-model-card__actions button {
display: flex;
align-items: center;
}
.speech-model-settings .settings-section__title--actions > button,
.speech-model-card__actions button {
gap: var(--space-2);
}
.speech-model-card__actions {
flex-wrap: wrap;
gap: var(--space-2);
}
.speech-model-operation {
display: grid;
gap: var(--space-1);
}
.speech-model-operation progress {
width: 100%;
}
.role-prompt-empty {
min-height: 180px;
padding: var(--space-6);
@@ -3035,6 +3280,9 @@ textarea:focus-visible {
.credential-state span {
flex: 1;
min-width: 0;
line-height: 1.5;
overflow-wrap: anywhere;
}
.credential-state button {
@@ -3128,6 +3376,157 @@ textarea:focus-visible {
font-size: var(--font-caption);
}
.embedding-settings .settings-section__title h2 {
margin: 0;
color: var(--text-primary);
font-size: var(--font-body);
line-height: 1.4;
}
.embedding-settings__group {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-3);
}
.embedding-settings__group + .embedding-settings__group {
padding-top: var(--space-4);
border-top: 1px solid var(--border-subtle);
}
.embedding-settings__subheading,
.embedding-settings__subheading > div,
.embedding-settings__job-header {
display: flex;
align-items: center;
}
.embedding-settings__subheading {
justify-content: space-between;
gap: var(--space-3);
}
.embedding-settings__subheading > div {
min-width: 0;
color: var(--text-secondary);
gap: var(--space-2);
}
.embedding-settings__subheading h3 {
margin: 0;
color: var(--text-primary);
font-size: var(--font-body);
}
.embedding-settings__model,
.embedding-settings__job {
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
}
.embedding-settings__model {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
.embedding-settings__model-name,
.embedding-settings__job-header > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-1);
}
.embedding-settings__model-name > span,
.embedding-settings__model-name small,
.embedding-settings__job small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.embedding-settings__model-name strong,
.embedding-settings__job strong {
color: var(--text-primary);
font-size: var(--font-body);
}
.embedding-settings__credential {
color: var(--text-secondary);
font-size: var(--font-caption);
white-space: nowrap;
}
.embedding-settings__endpoint,
.embedding-settings__empty p,
.embedding-settings__job p {
margin: 0;
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.55;
}
.embedding-settings__endpoint code {
overflow-wrap: anywhere;
}
.embedding-settings__job {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.embedding-settings__empty {
display: grid;
padding: var(--space-4);
border: 1px dashed var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-subtle);
gap: var(--space-1);
}
.embedding-settings__empty strong {
color: var(--text-primary);
font-size: var(--font-body);
}
.embedding-settings__job-header {
justify-content: space-between;
gap: var(--space-3);
}
.embedding-settings__job-header > div {
flex: 1;
}
.embedding-settings__job progress {
width: 100%;
accent-color: var(--accent-solid);
}
.embedding-settings__job[data-status='failed'] {
border-color: var(--danger-border);
background: var(--danger-subtle);
}
@media (max-width: 720px) {
.embedding-settings__subheading,
.embedding-settings__model,
.embedding-settings__job-header {
align-items: stretch;
flex-direction: column;
}
.embedding-settings__subheading > button,
.embedding-settings__job-header > button {
justify-content: center;
}
}
.browser-profile-create,
.browser-profile-row {
display: flex;
@@ -3338,14 +3737,28 @@ textarea:focus-visible {
gap: 3px;
}
.mcp-editor-backdrop {
position: fixed;
z-index: 70;
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
inset: 38px 0 0;
place-items: center;
}
.mcp-editor {
display: flex;
width: min(560px, 100%);
max-height: calc(100vh - 70px);
flex-direction: column;
padding: 13px;
border: 1px solid #91caff;
border-radius: 8px;
background: #f0f8ff;
gap: 12px;
padding: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
overflow-y: auto;
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
gap: var(--space-3);
}
.mcp-editor__header,
@@ -3356,12 +3769,15 @@ textarea:focus-visible {
.mcp-editor__header strong {
flex: 1;
font-size: 11px;
color: var(--text-primary);
font-size: var(--font-section-title);
}
.mcp-editor__actions {
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
justify-content: flex-end;
gap: 8px;
gap: var(--space-2);
}
.mcp-test-result {
@@ -3537,13 +3953,21 @@ textarea:focus-visible {
cursor: pointer;
}
.app-notice {
.app-notification-viewport {
position: fixed;
z-index: 60;
bottom: var(--space-6);
left: 50%;
display: flex;
max-width: min(520px, calc(100vw - 32px));
z-index: 75;
top: 54px;
right: var(--space-4);
display: grid;
width: min(420px, calc(100vw - 32px));
max-height: calc(100vh - 70px);
overflow-y: auto;
gap: var(--space-2);
}
.app-notification {
display: grid;
min-width: 0;
align-items: center;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--border-default);
@@ -3552,16 +3976,53 @@ textarea:focus-visible {
box-shadow: var(--shadow-dialog);
color: var(--text-secondary);
font-size: var(--font-body);
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--space-3);
transform: translateX(-50%);
}
.app-notice span {
.app-notification--success {
border-color: var(--success);
background: var(--success-subtle);
}
.app-notification--info {
border-color: var(--accent);
background: var(--accent-subtle);
}
.app-notification--error {
border-color: var(--danger-border);
background: var(--danger-subtle);
}
.app-notification--success > svg {
color: var(--success);
}
.app-notification--info > svg {
color: var(--accent);
}
.app-notification--error > svg {
color: var(--danger);
}
.app-notification > div {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.app-notification strong {
color: var(--text-primary);
font-size: var(--font-body);
}
.app-notification span {
overflow-wrap: anywhere;
}
.app-notice button {
.app-notification button {
display: grid;
width: 28px;
height: 28px;
@@ -3573,7 +4034,7 @@ textarea:focus-visible {
cursor: pointer;
}
.app-notice button:hover {
.app-notification button:hover {
background: var(--surface-muted);
color: var(--text-primary);
}
@@ -3724,6 +4185,7 @@ textarea:focus-visible {
.workspace-picker input {
flex: 1;
min-width: 0;
}
.check-field {
@@ -4108,15 +4570,19 @@ textarea:focus-visible {
.knowledge-workspace {
width: 100%;
min-height: max(520px, calc(100dvh - 114px));
grid-template-columns: clamp(230px, 20vw, 280px) minmax(0, 1fr);
grid-template-columns: clamp(280px, 24vw, 340px) minmax(0, 1fr);
container-type: inline-size;
}
.knowledge-workspace__sidebar {
display: flex;
min-width: 0;
padding: 18px;
overflow: hidden auto;
border-right: 1px solid #f0f0f0;
border-right: 1px solid var(--border-subtle);
background: var(--surface-subtle);
flex-direction: column;
gap: var(--space-4);
}
.knowledge-workspace__library-nav {
@@ -4129,12 +4595,16 @@ textarea:focus-visible {
overflow: hidden;
}
.knowledge-workspace__mobile-back {
display: none;
}
.knowledge-workspace__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 22px 15px;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid var(--border-subtle);
gap: 16px;
}
@@ -4231,8 +4701,8 @@ textarea:focus-visible {
align-items: center;
flex-wrap: wrap;
padding: 10px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
border-bottom: 1px solid var(--border-subtle);
background: var(--surface-subtle);
gap: 8px;
}
@@ -4264,27 +4734,58 @@ textarea:focus-visible {
}
}
@container (max-width: 860px) {
@container (max-width: 1000px) {
.knowledge-workspace__header {
flex-direction: column;
}
.knowledge-workspace__header-actions {
width: 100%;
justify-content: flex-start;
}
.knowledge-documents__section-heading {
align-items: stretch;
flex-direction: column;
}
.knowledge-documents__import-actions {
width: 100%;
}
.knowledge-documents__search {
width: min(360px, 100%);
}
}
@container (max-width: 780px) {
.knowledge-workspace {
grid-template-columns: minmax(0, 1fr);
}
.knowledge-workspace__sidebar {
display: none;
padding: 14px;
overflow: visible;
border-right: 0;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid var(--border-subtle);
}
.knowledge-workspace--mobile-list .knowledge-workspace__sidebar {
display: flex;
}
.knowledge-workspace--mobile-list .knowledge-workspace__main {
display: none;
}
.knowledge-workspace__mobile-back {
display: inline-flex;
margin: 14px 18px 0;
}
.knowledge-workspace__library-nav {
overflow-x: auto;
overflow-y: hidden;
}
.knowledge-workspace__library-nav > ul {
grid-auto-columns: minmax(190px, 240px);
grid-auto-flow: column;
padding-bottom: 2px !important;
overflow: visible;
}
.knowledge-workspace__header {
@@ -5869,6 +6370,10 @@ textarea:focus-visible {
padding: 20px;
}
.agent-runtime-navigation {
grid-template-columns: 1fr;
}
.model-connection-manager {
grid-template-columns: 1fr;
}
@@ -5903,6 +6408,26 @@ textarea:focus-visible {
.appearance-options {
grid-template-columns: 1fr;
}
.workspace-picker {
flex-wrap: wrap;
}
.workspace-picker input {
flex-basis: 100%;
}
.settings-panel__footer {
flex-wrap: wrap;
}
.settings-feedback {
flex-basis: 100%;
}
.mcp-editor {
width: 100%;
}
}
@media (max-width: 520px) {