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
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:
+804
-17
@@ -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
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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
'此知识库使用托管存储。删除后,应用保存的托管副本、索引和图谱都会被永久删除。'
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
+937
-366
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')
|
||||
})
|
||||
})
|
||||
@@ -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-256。GoodBuddy
|
||||
不会自动下载或执行安装包。
|
||||
</p>
|
||||
</article>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user