feat: add computer control and managed browser
This commit is contained in:
@@ -8,10 +8,15 @@ import {
|
||||
waitFor
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentEvent, DesktopApi } from '../../shared/contracts'
|
||||
import type {
|
||||
AgentEvent,
|
||||
BrowserLiveState,
|
||||
DesktopApi
|
||||
} from '../../shared/contracts'
|
||||
import App from './App'
|
||||
|
||||
let agentListener: ((event: AgentEvent) => void) | undefined
|
||||
let browserListener: ((state: BrowserLiveState) => void) | undefined
|
||||
let newConversationListener: (() => void) | undefined
|
||||
let maximizedChangedListener: ((maximized: boolean) => void) | undefined
|
||||
const removeMaximizedChangedListener = vi.fn()
|
||||
@@ -75,6 +80,15 @@ const api: DesktopApi = {
|
||||
}
|
||||
})
|
||||
},
|
||||
browser: {
|
||||
stop: vi.fn(async () => {}),
|
||||
onState: vi.fn((listener) => {
|
||||
browserListener = listener
|
||||
return () => {
|
||||
browserListener = undefined
|
||||
}
|
||||
})
|
||||
},
|
||||
settings: {
|
||||
getRuntime: vi.fn<DesktopApi['settings']['getRuntime']>(async () => ({
|
||||
provider: 'auto',
|
||||
@@ -319,7 +333,15 @@ const api: DesktopApi = {
|
||||
enabled: true,
|
||||
createdAt: '2026-07-31T00:00:00.000Z',
|
||||
updatedAt: '2026-07-31T00:00:00.000Z'
|
||||
}))
|
||||
})),
|
||||
update: vi.fn(async (expertId, input) => ({
|
||||
...input,
|
||||
id: expertId,
|
||||
enabled: true,
|
||||
createdAt: '2026-07-31T00:00:00.000Z',
|
||||
updatedAt: '2026-08-04T00:00:00.000Z'
|
||||
})),
|
||||
remove: vi.fn(async () => {})
|
||||
},
|
||||
capabilities: {
|
||||
getSnapshot: vi.fn(async () => ({
|
||||
@@ -413,6 +435,7 @@ describe('App', () => {
|
||||
document.documentElement.style.colorScheme = ''
|
||||
vi.clearAllMocks()
|
||||
newConversationListener = undefined
|
||||
browserListener = undefined
|
||||
maximizedChangedListener = undefined
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValue({
|
||||
id: 'model',
|
||||
@@ -452,6 +475,22 @@ describe('App', () => {
|
||||
expect(removeMaximizedChangedListener).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps rendering when an older preload has no browser bridge', async () => {
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
...api,
|
||||
browser: undefined
|
||||
}
|
||||
})
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
await screen.findByLabelText('向 GoodBuddy 提问')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps conversation actions in the conversation list', async () => {
|
||||
const { container } = render(<App />)
|
||||
const topbar = container.querySelector<HTMLElement>('.topbar')
|
||||
@@ -938,6 +977,55 @@ describe('App', () => {
|
||||
expect(within(stats).getByText('345')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers only Ask and Execute in visible work mode controls', async () => {
|
||||
render(<App />)
|
||||
|
||||
const mode = await screen.findByLabelText('工作模式')
|
||||
expect(
|
||||
within(mode)
|
||||
.getAllByRole('option')
|
||||
.map((option) => option.textContent)
|
||||
).toEqual(['Ask · 只读问答', 'Execute · 受控执行'])
|
||||
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
const defaultMode = within(dialog).getByRole('combobox', {
|
||||
name: '默认模式'
|
||||
})
|
||||
expect(
|
||||
within(defaultMode)
|
||||
.getAllByRole('option')
|
||||
.map((option) => option.textContent)
|
||||
).toEqual(['Ask · 只读问答', 'Execute · 受控执行'])
|
||||
expect(screen.queryByRole('option', { name: /Plan/u })).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes a legacy Plan project default to Ask', async () => {
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([
|
||||
{
|
||||
...project,
|
||||
defaultWorkMode: 'plan'
|
||||
}
|
||||
])
|
||||
render(<App />)
|
||||
|
||||
const mode = await screen.findByLabelText('工作模式')
|
||||
expect(mode).toHaveValue('ask')
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '制定发布方案' }
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('发送'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '制定发布方案',
|
||||
workMode: 'ask'
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['opencode', 'OpenCode'],
|
||||
['continue', 'Continue CLI']
|
||||
@@ -1349,6 +1437,47 @@ describe('App', () => {
|
||||
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
|
||||
})
|
||||
|
||||
it('opens the live browser tab for the active conversation and can stop it', async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '打开示例网页' }
|
||||
})
|
||||
fireEvent.click(await screen.findByLabelText('发送'))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
const conversationId = run.mock.calls[0]?.[0].conversationId
|
||||
expect(conversationId).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
browserListener?.({
|
||||
conversationId: conversationId ?? '',
|
||||
status: 'ready',
|
||||
url: 'https://example.com/',
|
||||
frameDataUrl: 'data:image/png;base64,iVBORw0KGgo=',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('助手工作栏')).toHaveClass(
|
||||
'assistant-sidebar--open'
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '浏览器' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
expect(
|
||||
screen.getByAltText('Agent 实时浏览器画面')
|
||||
).toHaveAttribute(
|
||||
'src',
|
||||
'data:image/png;base64,iVBORw0KGgo='
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '停止浏览器' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(api.browser.stop).toHaveBeenCalledWith(conversationId)
|
||||
)
|
||||
})
|
||||
|
||||
it('opens Smart Heartbeat as a first-class workspace', async () => {
|
||||
render(<App />)
|
||||
|
||||
|
||||
+114
-26
@@ -40,6 +40,7 @@ import type {
|
||||
AgentEvent,
|
||||
AgentRuntimeStatus,
|
||||
AppInfo,
|
||||
BrowserLiveState,
|
||||
ContextAttachment,
|
||||
KnowledgeSearchReference,
|
||||
KnowledgeSnapshot,
|
||||
@@ -60,9 +61,13 @@ import type {
|
||||
TokenUsageSummary,
|
||||
ConversationSnapshot,
|
||||
ProjectCreateInput,
|
||||
WorkMode,
|
||||
InteractiveWorkMode,
|
||||
WorkspaceChanges
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
interactiveWorkModes,
|
||||
normalizeInteractiveWorkMode
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { ActivityPanel } from './ActivityPanel'
|
||||
import {
|
||||
loadActivityRecords,
|
||||
@@ -110,6 +115,7 @@ type ToolActivity = {
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'recoverable'
|
||||
| 'cancelled'
|
||||
| 'interrupted'
|
||||
summary: string
|
||||
@@ -194,6 +200,7 @@ const toolStateLabels: Record<ToolActivity['state'], string> = {
|
||||
running: '进行中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
recoverable: '可重试',
|
||||
cancelled: '已取消',
|
||||
interrupted: '已中断'
|
||||
}
|
||||
@@ -552,7 +559,8 @@ function App(): React.JSX.Element {
|
||||
const workspaceChangesRequestRef = useRef(0)
|
||||
const viewRef = useRef<WorkspaceView>('chat')
|
||||
const heartbeatLoadRequestRef = useRef(0)
|
||||
const [workMode, setWorkMode] = useState<WorkMode>('ask')
|
||||
const [workMode, setWorkMode] =
|
||||
useState<InteractiveWorkMode>('ask')
|
||||
const [input, setInput] = useState('')
|
||||
const [voiceListening, setVoiceListening] = useState(false)
|
||||
const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
|
||||
@@ -585,6 +593,9 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
const [assistantSidebarTab, setAssistantSidebarTab] =
|
||||
useState<AssistantSidebarTab>('tasks')
|
||||
const [browserStates, setBrowserStates] = useState<
|
||||
Record<string, BrowserLiveState>
|
||||
>({})
|
||||
const [view, setView] = useState<WorkspaceView>('chat')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [conversationActionsId, setConversationActionsId] = useState('')
|
||||
@@ -1286,7 +1297,9 @@ function App(): React.JSX.Element {
|
||||
const project = value[0]!
|
||||
setProjects(value)
|
||||
setActiveProjectId(project.id)
|
||||
setWorkMode(project.defaultWorkMode)
|
||||
setWorkMode(
|
||||
normalizeInteractiveWorkMode(project.defaultWorkMode)
|
||||
)
|
||||
let nextConversations: Conversation[] =
|
||||
persistedConversations.length > 0
|
||||
? persistedConversations
|
||||
@@ -1665,6 +1678,41 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
}, [handleAgentEvent])
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
const browserApi = window.goodbuddy.browser
|
||||
if (!browserApi) {
|
||||
return
|
||||
}
|
||||
return browserApi.onState((state) => {
|
||||
setBrowserStates((current) => {
|
||||
const previous = current[state.conversationId]
|
||||
return {
|
||||
...current,
|
||||
[state.conversationId]:
|
||||
state.status === 'stopped' ||
|
||||
state.frameDataUrl ||
|
||||
!previous?.frameDataUrl
|
||||
? state
|
||||
: {
|
||||
...state,
|
||||
frameDataUrl: previous.frameDataUrl
|
||||
}
|
||||
}
|
||||
})
|
||||
if (
|
||||
state.status !== 'stopped' &&
|
||||
state.conversationId ===
|
||||
conversationNavigationRef.current.activeId
|
||||
) {
|
||||
setAssistantSidebarOpen(true)
|
||||
setAssistantSidebarTab('browser')
|
||||
}
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
window.goodbuddy.app.onNewConversation(() => {
|
||||
@@ -1689,7 +1737,7 @@ function App(): React.JSX.Element {
|
||||
return
|
||||
}
|
||||
setActiveProjectId(projectId)
|
||||
setWorkMode(project.defaultWorkMode)
|
||||
setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode))
|
||||
const conversation = conversations.find(
|
||||
(candidate) => candidate.projectId === projectId
|
||||
)
|
||||
@@ -1709,7 +1757,7 @@ function App(): React.JSX.Element {
|
||||
const project = await window.goodbuddy.projects.create(input)
|
||||
setProjects((current) => [project, ...current])
|
||||
setActiveProjectId(project.id)
|
||||
setWorkMode(project.defaultWorkMode)
|
||||
setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode))
|
||||
const conversation = createConversation(project.id)
|
||||
setConversations((current) => [conversation, ...current])
|
||||
setActiveId(conversation.id)
|
||||
@@ -1747,7 +1795,7 @@ function App(): React.JSX.Element {
|
||||
|
||||
const useHeartbeatTask = (task: AssistantTask): void => {
|
||||
newConversation()
|
||||
setWorkMode('plan')
|
||||
setWorkMode('ask')
|
||||
setInput(
|
||||
[
|
||||
'请根据以下智能心跳建议制定可执行方案:',
|
||||
@@ -1790,6 +1838,17 @@ function App(): React.JSX.Element {
|
||||
if (activeRequest) {
|
||||
void window.goodbuddy.agent.cancel(activeRequest)
|
||||
}
|
||||
const browserStop = window.goodbuddy.browser?.stop(conversationId)
|
||||
if (browserStop) {
|
||||
void browserStop.catch(() => {
|
||||
setNotice('关闭已删除对话的浏览器失败')
|
||||
})
|
||||
}
|
||||
setBrowserStates((current) => {
|
||||
const next = { ...current }
|
||||
delete next[conversationId]
|
||||
return next
|
||||
})
|
||||
const remaining = conversations.filter(
|
||||
(conversation) => conversation.id !== conversationId
|
||||
)
|
||||
@@ -2232,7 +2291,9 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
setActiveProjectId(conversation.projectId)
|
||||
if (project) {
|
||||
setWorkMode(project.defaultWorkMode)
|
||||
setWorkMode(
|
||||
normalizeInteractiveWorkMode(project.defaultWorkMode)
|
||||
)
|
||||
}
|
||||
}
|
||||
setActiveId(conversationId)
|
||||
@@ -3129,24 +3190,24 @@ function App(): React.JSX.Element {
|
||||
aria-label="工作模式"
|
||||
disabled={agentRuntimeSelected}
|
||||
onChange={(event) =>
|
||||
setWorkMode(event.target.value as WorkMode)
|
||||
setWorkMode(
|
||||
event.target.value as InteractiveWorkMode
|
||||
)
|
||||
}
|
||||
value={effectiveWorkMode}
|
||||
>
|
||||
{Object.entries(workModeLabels).map(
|
||||
([value, label]) => (
|
||||
<option
|
||||
disabled={
|
||||
value === 'execute' &&
|
||||
!runtime?.supportsToolExecution
|
||||
}
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
{interactiveWorkModes.map((value) => (
|
||||
<option
|
||||
disabled={
|
||||
value === 'execute' &&
|
||||
!runtime?.supportsToolExecution
|
||||
}
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{workModeLabels[value]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="runtime-picker">
|
||||
@@ -3288,10 +3349,8 @@ function App(): React.JSX.Element {
|
||||
: agentRuntimeSelected
|
||||
? `${runtime.label} 固定为 Execute,工具调用不会弹出 GoodBuddy 审批,并会记录到活动。`
|
||||
: effectiveWorkMode === 'ask'
|
||||
? 'Ask 模式:只读问答,不会调用工具或修改文件。'
|
||||
: effectiveWorkMode === 'plan'
|
||||
? 'Plan 模式:只读制定计划,不会调用工具或修改文件。'
|
||||
: 'Execute 模式:可执行工具,调用前请检查参数和权限。')}
|
||||
? 'Ask 模式:只读问答,不会调用工具或修改文件。'
|
||||
: 'Execute 模式:已启用工具自动授权,调用仍会记录到活动。')}
|
||||
{appInfo?.shortcut && ` 快捷唤起:${appInfo.shortcut}`}
|
||||
</p>
|
||||
</footer>
|
||||
@@ -3471,6 +3530,19 @@ function App(): React.JSX.Element {
|
||||
onClearLocalData={clearLocalData}
|
||||
onClose={() => setView('chat')}
|
||||
onCreateHeartbeat={createHeartbeat}
|
||||
onExpertsChanged={(experts) => {
|
||||
setAssistantExperts(experts)
|
||||
if (
|
||||
(selectedExpertId === 'team' && experts.length < 2) ||
|
||||
(selectedExpertId &&
|
||||
selectedExpertId !== 'team' &&
|
||||
!experts.some(
|
||||
(expert) => expert.id === selectedExpertId
|
||||
))
|
||||
) {
|
||||
setSelectedExpertId('')
|
||||
}
|
||||
}}
|
||||
onRemoveHeartbeat={removeHeartbeat}
|
||||
onRunHeartbeat={runHeartbeat}
|
||||
onSaved={(settings) => {
|
||||
@@ -3511,11 +3583,27 @@ function App(): React.JSX.Element {
|
||||
approvals={pendingSidebarApprovals}
|
||||
artifacts={sidebarArtifacts}
|
||||
attachments={attachments}
|
||||
browserState={browserStates[activeId]}
|
||||
enabledLibraries={enabledSidebarLibraries}
|
||||
heartbeatEntries={heartbeatEntries}
|
||||
heartbeats={assistantHeartbeats}
|
||||
memories={assistantMemories}
|
||||
onClose={() => setAssistantSidebarOpen(false)}
|
||||
onStopBrowser={async () => {
|
||||
if (!activeId) {
|
||||
return
|
||||
}
|
||||
const browserApi = window.goodbuddy.browser
|
||||
if (!browserApi) {
|
||||
setNotice('浏览器控制组件尚未加载,请重启 GoodBuddy')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await browserApi.stop(activeId)
|
||||
} catch {
|
||||
setNotice('停止浏览器失败,请重试')
|
||||
}
|
||||
}}
|
||||
onOpenHeartbeat={() => setView('heartbeat')}
|
||||
onCreateMemory={async (content) => {
|
||||
const memory = await window.goodbuddy.memory.create({
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import {
|
||||
CircleAlert,
|
||||
FlaskConical,
|
||||
Globe2,
|
||||
MonitorCog,
|
||||
Network,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Wrench,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { builtinModelTools } from '../../shared/builtin-model-tools'
|
||||
import type {
|
||||
CapabilityDiagnosticReport,
|
||||
CapabilityAssignments,
|
||||
CapabilitySnapshot,
|
||||
ComputerCapabilityId,
|
||||
McpServerInput,
|
||||
McpServerSummary,
|
||||
McpServerTestResult,
|
||||
@@ -23,6 +31,15 @@ const runtimeLabels: Record<RuntimeTarget, string> = {
|
||||
continue: 'Continue'
|
||||
}
|
||||
const configurableMcpTargets: RuntimeTarget[] = ['model']
|
||||
const diagnosticStatusLabels: Record<
|
||||
CapabilityDiagnosticReport['status'],
|
||||
string
|
||||
> = {
|
||||
available: '可用',
|
||||
degraded: '部分可用',
|
||||
unavailable: '不可用',
|
||||
disabled: '未启用'
|
||||
}
|
||||
|
||||
type McpEditor = {
|
||||
id?: string
|
||||
@@ -77,6 +94,13 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
const [testResults, setTestResults] = useState<
|
||||
Record<string, McpServerTestResult>
|
||||
>({})
|
||||
const [diagnostics, setDiagnostics] = useState<
|
||||
Partial<Record<ComputerCapabilityId, CapabilityDiagnosticReport>>
|
||||
>({})
|
||||
const [newProfileName, setNewProfileName] = useState('')
|
||||
const [profileNames, setProfileNames] = useState<Record<string, string>>(
|
||||
{}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void window.goodbuddy.capabilities
|
||||
@@ -97,13 +121,51 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
setSnapshot(await operation())
|
||||
return true
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'MCP 操作失败')
|
||||
setError(reason instanceof Error ? reason.message : '能力设置操作失败')
|
||||
return false
|
||||
} finally {
|
||||
setBusy(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const diagnose = async (
|
||||
capabilityId: ComputerCapabilityId
|
||||
): Promise<void> => {
|
||||
setBusy(`diagnose:${capabilityId}`)
|
||||
setError(undefined)
|
||||
try {
|
||||
const diagnoseCapability =
|
||||
window.goodbuddy.capabilities.diagnoseComputerCapability
|
||||
if (!diagnoseCapability) {
|
||||
throw new Error('当前版本不支持电脑控制能力诊断')
|
||||
}
|
||||
const report = await diagnoseCapability(capabilityId)
|
||||
setDiagnostics((current) => ({
|
||||
...current,
|
||||
[capabilityId]: report
|
||||
}))
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '能力诊断失败')
|
||||
} finally {
|
||||
setBusy(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const createProfile = async (): Promise<void> => {
|
||||
const name = newProfileName.trim()
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
await run('profile:create', () =>
|
||||
window.goodbuddy.capabilities.createBrowserProfile?.({ name }) ??
|
||||
Promise.reject(new Error('当前版本不支持托管浏览器配置'))
|
||||
)
|
||||
) {
|
||||
setNewProfileName('')
|
||||
}
|
||||
}
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (!editor) {
|
||||
return
|
||||
@@ -178,13 +240,19 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
})
|
||||
}
|
||||
|
||||
const computerCapabilities = snapshot?.computerCapabilities ?? []
|
||||
const browserProfiles = snapshot?.browserProfiles ?? {
|
||||
profiles: [],
|
||||
defaultProfileId: null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<div className="settings-section__title settings-section__title--actions">
|
||||
<Network size={17} />
|
||||
<div>
|
||||
<strong>MCP Servers</strong>
|
||||
<small>支持 stdio、Streamable HTTP 和兼容 SSE</small>
|
||||
<strong>工具与 MCP</strong>
|
||||
<small>查看直连模型内置工具并管理外部 MCP Server</small>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
@@ -198,11 +266,281 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
</div>
|
||||
|
||||
<p className="settings-notice">
|
||||
MCP Server 及其工具具有当前用户权限。请仅添加可信服务;远程访问令牌将由系统安全存储加密。
|
||||
当前版本仅由直连模型在 Execute 模式加载 MCP 工具,并在每次调用前请求 GoodBuddy 审批。
|
||||
内置工具由 GoodBuddy 提供,不属于 MCP Server。外部 MCP Server
|
||||
及其工具具有当前用户权限,请仅添加可信服务;远程访问令牌将由系统安全存储加密。
|
||||
当前版本仅由直连模型在 Execute 模式加载这些工具,并在每次调用前请求
|
||||
GoodBuddy 审批。
|
||||
</p>
|
||||
{error && <p className="settings-warning">{error}</p>}
|
||||
|
||||
<section
|
||||
aria-labelledby="computer-capabilities-heading"
|
||||
className="mcp-tool-section"
|
||||
>
|
||||
<div className="mcp-subsection-heading">
|
||||
<div>
|
||||
<MonitorCog size={15} />
|
||||
<strong id="computer-capabilities-heading">电脑控制能力</strong>
|
||||
</div>
|
||||
<small>默认停用,启用后仍遵循审批</small>
|
||||
</div>
|
||||
<div className="capability-list">
|
||||
{computerCapabilities.map((capability) => {
|
||||
const report = diagnostics[capability.id]
|
||||
return (
|
||||
<article className="capability-card" key={capability.id}>
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>{capability.name}</strong>
|
||||
<small>
|
||||
{capability.supported ? '当前设备支持' : '当前设备不支持'} ·{' '}
|
||||
{capability.enabled ? '已启用' : '已停用'}
|
||||
</small>
|
||||
</div>
|
||||
<label className="capability-switch">
|
||||
<input
|
||||
aria-label={`启用 ${capability.name}`}
|
||||
checked={capability.enabled}
|
||||
disabled={Boolean(busy) || !capability.supported}
|
||||
onChange={(event) =>
|
||||
void run(`computer:${capability.id}`, () =>
|
||||
window.goodbuddy.capabilities.setComputerCapabilityEnabled?.(
|
||||
capability.id,
|
||||
event.target.checked
|
||||
) ??
|
||||
Promise.reject(
|
||||
new Error('当前版本不支持电脑控制能力')
|
||||
)
|
||||
)
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{capability.enabled ? '已启用' : '已停用'}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p>{capability.description}</p>
|
||||
<p className="computer-capability-risk">
|
||||
<CircleAlert aria-hidden="true" size={13} />
|
||||
{capability.riskSummary}
|
||||
</p>
|
||||
{capability.id === 'host-browser-control' && (
|
||||
<label className="field computer-capability-profile">
|
||||
<span>托管浏览器配置</span>
|
||||
<select
|
||||
aria-label="浏览器控制使用的托管配置"
|
||||
disabled={Boolean(busy)}
|
||||
onChange={(event) =>
|
||||
void run('computer:profile', () =>
|
||||
window.goodbuddy.capabilities.setComputerCapabilityBrowserProfile?.(
|
||||
capability.id,
|
||||
event.target.value || null
|
||||
) ??
|
||||
Promise.reject(
|
||||
new Error('当前版本不支持托管浏览器配置')
|
||||
)
|
||||
)
|
||||
}
|
||||
value={capability.browserProfileId ?? ''}
|
||||
>
|
||||
<option value="">使用默认托管配置</option>
|
||||
{browserProfiles.profiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="capability-diagnostic">
|
||||
<button
|
||||
aria-label={`诊断 ${capability.name}`}
|
||||
className="secondary-button"
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => void diagnose(capability.id)}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
{busy === `diagnose:${capability.id}`
|
||||
? '诊断中…'
|
||||
: '运行诊断'}
|
||||
</button>
|
||||
{report && (
|
||||
<div aria-live="polite" className="capability-diagnostic__result">
|
||||
<strong>
|
||||
诊断结果:{diagnosticStatusLabels[report.status]}
|
||||
</strong>
|
||||
{report.checks.map((check) => (
|
||||
<p key={check.id}>
|
||||
{check.summary}
|
||||
{check.remedy ? ` 处理建议:${check.remedy}` : ''}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="browser-profiles-heading"
|
||||
className="mcp-tool-section"
|
||||
>
|
||||
<div className="mcp-subsection-heading">
|
||||
<div>
|
||||
<Globe2 size={15} />
|
||||
<strong id="browser-profiles-heading">托管浏览器配置</strong>
|
||||
</div>
|
||||
<small>{browserProfiles.profiles.length} 个</small>
|
||||
</div>
|
||||
<p className="settings-notice">
|
||||
每个配置使用 GoodBuddy 管理的隔离存储;界面不会接收或显示可执行路径、命令参数与环境变量。
|
||||
</p>
|
||||
<div className="browser-profile-create">
|
||||
<label className="field">
|
||||
<span>新配置名称</span>
|
||||
<input
|
||||
onChange={(event) => setNewProfileName(event.target.value)}
|
||||
placeholder="例如:工作网站"
|
||||
value={newProfileName}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={Boolean(busy) || !newProfileName.trim()}
|
||||
onClick={() => void createProfile()}
|
||||
type="button"
|
||||
>
|
||||
<Plus size={13} />
|
||||
创建托管配置
|
||||
</button>
|
||||
</div>
|
||||
<div className="browser-profile-list">
|
||||
{browserProfiles.profiles.length === 0 && (
|
||||
<p className="settings-empty">尚未创建托管浏览器配置</p>
|
||||
)}
|
||||
{browserProfiles.profiles.map((profile) => {
|
||||
const referenced = computerCapabilities.some(
|
||||
(capability) =>
|
||||
capability.browserProfileId === profile.id
|
||||
)
|
||||
return (
|
||||
<article className="browser-profile-row" key={profile.id}>
|
||||
<label className="field">
|
||||
<span>配置名称</span>
|
||||
<input
|
||||
aria-label={`配置名称 ${profile.name}`}
|
||||
onChange={(event) =>
|
||||
setProfileNames((current) => ({
|
||||
...current,
|
||||
[profile.id]: event.target.value
|
||||
}))
|
||||
}
|
||||
value={profileNames[profile.id] ?? profile.name}
|
||||
/>
|
||||
</label>
|
||||
<label className="browser-profile-default">
|
||||
<input
|
||||
aria-label={`设为默认配置 ${profile.name}`}
|
||||
checked={
|
||||
browserProfiles.defaultProfileId === profile.id
|
||||
}
|
||||
disabled={Boolean(busy)}
|
||||
name="default-browser-profile"
|
||||
onChange={() =>
|
||||
void run(`profile:default:${profile.id}`, () =>
|
||||
window.goodbuddy.capabilities.setDefaultBrowserProfile?.(
|
||||
profile.id
|
||||
) ??
|
||||
Promise.reject(
|
||||
new Error('当前版本不支持托管浏览器配置')
|
||||
)
|
||||
)
|
||||
}
|
||||
type="radio"
|
||||
/>
|
||||
默认
|
||||
</label>
|
||||
<button
|
||||
aria-label={`重命名配置 ${profile.name}`}
|
||||
className="secondary-button"
|
||||
disabled={
|
||||
Boolean(busy) ||
|
||||
!(profileNames[profile.id] ?? '').trim() ||
|
||||
profileNames[profile.id] === profile.name
|
||||
}
|
||||
onClick={() =>
|
||||
void run(`profile:rename:${profile.id}`, () =>
|
||||
window.goodbuddy.capabilities.renameBrowserProfile?.({
|
||||
profileId: profile.id,
|
||||
name: profileNames[profile.id] ?? profile.name
|
||||
}) ??
|
||||
Promise.reject(
|
||||
new Error('当前版本不支持托管浏览器配置')
|
||||
)
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
重命名
|
||||
</button>
|
||||
<button
|
||||
aria-label={`删除配置 ${profile.name}`}
|
||||
className="danger-ghost"
|
||||
disabled={Boolean(busy) || referenced}
|
||||
onClick={() =>
|
||||
void run(`profile:remove:${profile.id}`, () =>
|
||||
window.goodbuddy.capabilities.removeBrowserProfile?.(
|
||||
profile.id
|
||||
) ??
|
||||
Promise.reject(
|
||||
new Error('当前版本不支持托管浏览器配置')
|
||||
)
|
||||
)
|
||||
}
|
||||
title={referenced ? '此配置正被电脑控制能力使用' : undefined}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
删除
|
||||
</button>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mcp-tool-section">
|
||||
<div className="mcp-subsection-heading">
|
||||
<div>
|
||||
<Wrench size={15} />
|
||||
<strong>直连模型内置工具</strong>
|
||||
</div>
|
||||
<small>{builtinModelTools.length} 个</small>
|
||||
</div>
|
||||
<div className="capability-list capability-list--tools">
|
||||
{builtinModelTools.map((tool) => (
|
||||
<article className="capability-card" key={tool.name}>
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>{tool.displayName}</strong>
|
||||
<small>
|
||||
GoodBuddy 内置 ·{' '}
|
||||
{tool.access === 'write' ? '写入工具' : '只读工具'}
|
||||
</small>
|
||||
</div>
|
||||
<span className="builtin-tool-badge">直连模型</span>
|
||||
</div>
|
||||
<p>{tool.description}</p>
|
||||
<code>{tool.name}</code>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editor && (
|
||||
<div className="mcp-editor">
|
||||
<div className="mcp-editor__header">
|
||||
@@ -381,6 +719,16 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mcp-subsection-heading">
|
||||
<div>
|
||||
<Network size={15} />
|
||||
<strong>自定义 MCP Servers(高级)</strong>
|
||||
</div>
|
||||
<small>{snapshot?.mcpServers.length ?? 0} 个</small>
|
||||
</div>
|
||||
<p className="settings-notice">
|
||||
自定义 stdio MCP 会以受限环境启动,不会获得桌面会话变量。需要电脑控制时请使用上方经过诊断的内置能力。
|
||||
</p>
|
||||
<div className="capability-list">
|
||||
{snapshot?.mcpServers.length === 0 && !editor && (
|
||||
<p className="settings-empty">尚未配置 MCP Server</p>
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Archive, FolderOpen, Plus, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type {
|
||||
AssistantProject,
|
||||
InteractiveWorkMode,
|
||||
ProjectCreateInput,
|
||||
WorkMode
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { interactiveWorkModes } from '../../shared/assistant-contracts'
|
||||
|
||||
type ProjectSwitcherProps = {
|
||||
projects: AssistantProject[]
|
||||
@@ -15,9 +17,8 @@ type ProjectSwitcherProps = {
|
||||
onSelectRoot: () => Promise<string | undefined>
|
||||
}
|
||||
|
||||
export const workModeLabels: Record<WorkMode, string> = {
|
||||
export const workModeLabels: Record<InteractiveWorkMode, string> = {
|
||||
ask: 'Ask · 只读问答',
|
||||
plan: 'Plan · 先审计划',
|
||||
execute: 'Execute · 受控执行'
|
||||
}
|
||||
|
||||
@@ -215,9 +216,9 @@ export function ProjectSwitcher({
|
||||
}
|
||||
value={draft.defaultWorkMode}
|
||||
>
|
||||
{Object.entries(workModeLabels).map(([value, label]) => (
|
||||
{interactiveWorkModes.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
{workModeLabels[value]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RightAssistantSidebar } from './RightAssistantSidebar'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 1400
|
||||
})
|
||||
})
|
||||
|
||||
function renderSidebar(): HTMLElement {
|
||||
render(
|
||||
<RightAssistantSidebar
|
||||
activities={[]}
|
||||
approvals={[]}
|
||||
artifacts={[]}
|
||||
attachments={[]}
|
||||
enabledLibraries={[]}
|
||||
heartbeatEntries={[]}
|
||||
heartbeats={[]}
|
||||
memories={[]}
|
||||
onClose={vi.fn()}
|
||||
onCreateHeartbeat={vi.fn(async () => undefined)}
|
||||
onCreateMemory={vi.fn(async () => undefined)}
|
||||
onCreateSchedule={vi.fn(async () => undefined)}
|
||||
onImportArtifacts={vi.fn(async () => undefined)}
|
||||
onListWorkspaceDirectory={vi.fn(async (path: string) => ({
|
||||
path,
|
||||
entries: [],
|
||||
truncated: false
|
||||
}))}
|
||||
onLoadArtifact={vi.fn(async () => undefined)}
|
||||
onLoadWorkspaceFile={vi.fn()}
|
||||
onOpenConversation={vi.fn()}
|
||||
onOpenHeartbeat={vi.fn()}
|
||||
onRefreshChanges={vi.fn(async () => undefined)}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
onRemoveHeartbeat={vi.fn(async () => undefined)}
|
||||
onRemoveMemory={vi.fn(async () => undefined)}
|
||||
onRemoveSchedule={vi.fn(async () => undefined)}
|
||||
onRespondApproval={vi.fn()}
|
||||
onRunHeartbeat={vi.fn(async () => undefined)}
|
||||
onRunSchedule={vi.fn(async () => undefined)}
|
||||
onSetHeartbeatPaused={vi.fn(async () => undefined)}
|
||||
onSetMemoryStatus={vi.fn(async () => undefined)}
|
||||
onStopBrowser={vi.fn(async () => undefined)}
|
||||
onTabChange={vi.fn()}
|
||||
open
|
||||
schedules={[]}
|
||||
tab="context"
|
||||
tasks={[]}
|
||||
/>
|
||||
)
|
||||
|
||||
return screen.getByRole('complementary', {
|
||||
name: '助手工作栏'
|
||||
})
|
||||
}
|
||||
|
||||
describe('RightAssistantSidebar resizing', () => {
|
||||
it('resizes with pointer capture and clamps the resulting width', () => {
|
||||
const sidebar = renderSidebar()
|
||||
const separator = screen.getByRole('separator', {
|
||||
name: '调整助手工作栏宽度'
|
||||
})
|
||||
const setPointerCapture = vi.fn()
|
||||
const releasePointerCapture = vi.fn()
|
||||
Object.defineProperties(separator, {
|
||||
setPointerCapture: { value: setPointerCapture },
|
||||
hasPointerCapture: { value: () => true },
|
||||
releasePointerCapture: { value: releasePointerCapture }
|
||||
})
|
||||
|
||||
fireEvent.pointerDown(separator, {
|
||||
button: 0,
|
||||
clientX: 900,
|
||||
pointerId: 7
|
||||
})
|
||||
fireEvent.pointerMove(separator, {
|
||||
clientX: 600,
|
||||
pointerId: 7
|
||||
})
|
||||
|
||||
expect(setPointerCapture).toHaveBeenCalledWith(7)
|
||||
expect(sidebar).toHaveClass('assistant-sidebar--resizing')
|
||||
expect(
|
||||
sidebar.style.getPropertyValue('--assistant-sidebar-width')
|
||||
).toBe('640px')
|
||||
|
||||
fireEvent.pointerUp(separator, { pointerId: 7 })
|
||||
expect(releasePointerCapture).toHaveBeenCalledWith(7)
|
||||
expect(sidebar).not.toHaveClass('assistant-sidebar--resizing')
|
||||
})
|
||||
|
||||
it('supports arrow, Home, and End keyboard resizing', () => {
|
||||
const sidebar = renderSidebar()
|
||||
const separator = screen.getByRole('separator', {
|
||||
name: '调整助手工作栏宽度'
|
||||
})
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'ArrowLeft' })
|
||||
expect(
|
||||
sidebar.style.getPropertyValue('--assistant-sidebar-width')
|
||||
).toBe('366px')
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '366')
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'Home' })
|
||||
expect(
|
||||
sidebar.style.getPropertyValue('--assistant-sidebar-width')
|
||||
).toBe('300px')
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'End' })
|
||||
expect(
|
||||
sidebar.style.getPropertyValue('--assistant-sidebar-width')
|
||||
).toBe('640px')
|
||||
})
|
||||
|
||||
it('remains resizable when the sidebar overlays a medium window', () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 1024
|
||||
})
|
||||
const sidebar = renderSidebar()
|
||||
const separator = screen.getByRole('separator', {
|
||||
name: '调整助手工作栏宽度'
|
||||
})
|
||||
Object.defineProperties(separator, {
|
||||
setPointerCapture: { value: vi.fn() },
|
||||
hasPointerCapture: { value: () => true },
|
||||
releasePointerCapture: { value: vi.fn() }
|
||||
})
|
||||
|
||||
expect(separator).toHaveAttribute('tabindex', '0')
|
||||
fireEvent.pointerDown(separator, {
|
||||
button: 0,
|
||||
clientX: 674,
|
||||
pointerId: 8
|
||||
})
|
||||
fireEvent.pointerMove(separator, {
|
||||
clientX: 600,
|
||||
pointerId: 8
|
||||
})
|
||||
|
||||
expect(
|
||||
sidebar.style.getPropertyValue('--assistant-sidebar-width')
|
||||
).toBe('424px')
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FileText,
|
||||
FolderTree,
|
||||
Hourglass,
|
||||
Monitor,
|
||||
PanelRightClose,
|
||||
PlayCircle,
|
||||
RefreshCw,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
X,
|
||||
XCircle
|
||||
} from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type {
|
||||
AssistantMemory,
|
||||
AssistantSchedule,
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
BrowserLiveState,
|
||||
ContextAttachment,
|
||||
KnowledgeLibrary
|
||||
} from '../../shared/contracts'
|
||||
@@ -41,6 +43,7 @@ export type AssistantSidebarTab =
|
||||
| 'context'
|
||||
| 'artifacts'
|
||||
| 'changes'
|
||||
| 'browser'
|
||||
| 'preview'
|
||||
|
||||
export type SidebarArtifact = {
|
||||
@@ -75,7 +78,9 @@ type RightAssistantSidebarProps = {
|
||||
heartbeatEntries: AssistantHeartbeatEntry[]
|
||||
workspaceChanges?: WorkspaceChanges
|
||||
workspaceProjectId?: string
|
||||
browserState?: BrowserLiveState
|
||||
onClose: () => void
|
||||
onStopBrowser: () => Promise<void>
|
||||
onOpenHeartbeat: () => void
|
||||
onOpenConversation: (conversationId: string) => void
|
||||
onImportArtifacts: () => Promise<void>
|
||||
@@ -117,15 +122,44 @@ const tabs: Array<{
|
||||
{ id: 'context', label: '上下文' },
|
||||
{ id: 'artifacts', label: '成果' },
|
||||
{ id: 'changes', label: '更改' },
|
||||
{ id: 'browser', label: '浏览器' },
|
||||
{ id: 'preview', label: '预览' }
|
||||
]
|
||||
const emptyChangedFiles: WorkspaceChanges['files'] = []
|
||||
const defaultSidebarWidth = 350
|
||||
const minimumSidebarWidth = 300
|
||||
const maximumSidebarWidth = 640
|
||||
const minimumRemainingAppWidth = 520
|
||||
const compactSidebarBreakpoint = 720
|
||||
const keyboardResizeStep = 16
|
||||
const sidebarTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
|
||||
function getSidebarWidthLimits(viewportWidth: number): {
|
||||
minimum: number
|
||||
maximum: number
|
||||
} {
|
||||
return {
|
||||
minimum: minimumSidebarWidth,
|
||||
maximum: Math.max(
|
||||
minimumSidebarWidth,
|
||||
Math.min(
|
||||
maximumSidebarWidth,
|
||||
viewportWidth - minimumRemainingAppWidth
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function clampSidebarWidth(width: number, viewportWidth: number): number {
|
||||
const limits = getSidebarWidthLimits(viewportWidth)
|
||||
return Math.min(limits.maximum, Math.max(limits.minimum, width))
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number | string): string {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(timestamp))
|
||||
return sidebarTimeFormatter.format(new Date(timestamp))
|
||||
}
|
||||
|
||||
export function RightAssistantSidebar({
|
||||
@@ -143,7 +177,9 @@ export function RightAssistantSidebar({
|
||||
heartbeatEntries,
|
||||
workspaceChanges,
|
||||
workspaceProjectId,
|
||||
browserState,
|
||||
onClose,
|
||||
onStopBrowser,
|
||||
onOpenHeartbeat,
|
||||
onOpenConversation,
|
||||
onImportArtifacts,
|
||||
@@ -165,6 +201,12 @@ export function RightAssistantSidebar({
|
||||
onRespondApproval,
|
||||
onTabChange
|
||||
}: RightAssistantSidebarProps): React.JSX.Element {
|
||||
const [viewportWidth, setViewportWidth] = useState(window.innerWidth)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(defaultSidebarWidth)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const liveSidebarWidth = useRef(defaultSidebarWidth)
|
||||
const resizePointerId = useRef<number | undefined>(undefined)
|
||||
const [selectedArtifactId, setSelectedArtifactId] = useState<string>()
|
||||
const [workspacePreview, setWorkspacePreview] = useState<
|
||||
| {
|
||||
@@ -198,12 +240,20 @@ export function RightAssistantSidebar({
|
||||
const [scheduleRecurrence, setScheduleRecurrence] = useState<
|
||||
ScheduleCreateInput['recurrence']
|
||||
>('once')
|
||||
const recentTasks = activities
|
||||
.filter((activity) => activity.kind === 'request')
|
||||
.slice(0, 20)
|
||||
const changes = activities
|
||||
.filter((activity) => activity.kind === 'tool')
|
||||
.slice(0, 30)
|
||||
const recentTasks = useMemo(
|
||||
() =>
|
||||
activities
|
||||
.filter((activity) => activity.kind === 'request')
|
||||
.slice(0, 20),
|
||||
[activities]
|
||||
)
|
||||
const changes = useMemo(
|
||||
() =>
|
||||
activities
|
||||
.filter((activity) => activity.kind === 'tool')
|
||||
.slice(0, 30),
|
||||
[activities]
|
||||
)
|
||||
const artifactPreview =
|
||||
artifacts.find((artifact) => artifact.id === selectedArtifactId) ??
|
||||
artifacts[0]
|
||||
@@ -211,6 +261,86 @@ export function RightAssistantSidebar({
|
||||
workspacePreview?.projectId === workspaceProjectId
|
||||
? workspacePreview
|
||||
: undefined
|
||||
const sidebarWidthLimits = getSidebarWidthLimits(viewportWidth)
|
||||
const canResize =
|
||||
open && viewportWidth >= compactSidebarBreakpoint
|
||||
|
||||
useEffect(() => {
|
||||
const handleViewportResize = (): void => {
|
||||
setViewportWidth(window.innerWidth)
|
||||
setSidebarWidth((currentWidth) => {
|
||||
const width = clampSidebarWidth(
|
||||
currentWidth,
|
||||
window.innerWidth
|
||||
)
|
||||
liveSidebarWidth.current = width
|
||||
return width
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleViewportResize)
|
||||
return () => window.removeEventListener('resize', handleViewportResize)
|
||||
}, [])
|
||||
|
||||
const resizeFromClientX = (
|
||||
clientX: number,
|
||||
commit: boolean
|
||||
): void => {
|
||||
const width = clampSidebarWidth(
|
||||
window.innerWidth - clientX,
|
||||
window.innerWidth
|
||||
)
|
||||
liveSidebarWidth.current = width
|
||||
if (commit) {
|
||||
setSidebarWidth(width)
|
||||
return
|
||||
}
|
||||
sidebarRef.current?.style.setProperty(
|
||||
'--assistant-sidebar-width',
|
||||
`${width}px`
|
||||
)
|
||||
}
|
||||
|
||||
const finishResize = (
|
||||
event: React.PointerEvent<HTMLDivElement>
|
||||
): void => {
|
||||
if (resizePointerId.current !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
resizePointerId.current = undefined
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
setSidebarWidth(liveSidebarWidth.current)
|
||||
setIsResizing(false)
|
||||
}
|
||||
|
||||
const resizeWithKeyboard = (
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
): void => {
|
||||
if (!canResize) {
|
||||
return
|
||||
}
|
||||
const limits = getSidebarWidthLimits(window.innerWidth)
|
||||
const nextWidth =
|
||||
event.key === 'Home'
|
||||
? limits.minimum
|
||||
: event.key === 'End'
|
||||
? limits.maximum
|
||||
: event.key === 'ArrowLeft'
|
||||
? sidebarWidth + keyboardResizeStep
|
||||
: event.key === 'ArrowRight'
|
||||
? sidebarWidth - keyboardResizeStep
|
||||
: undefined
|
||||
|
||||
if (nextWidth === undefined) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
setSidebarWidth(
|
||||
clampSidebarWidth(nextWidth, window.innerWidth)
|
||||
)
|
||||
}
|
||||
|
||||
const openWorkspaceFile = (path: string): void => {
|
||||
const requestId = workspacePreviewRequest.current + 1
|
||||
@@ -276,15 +406,65 @@ export function RightAssistantSidebar({
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
aria-label="助手工作栏"
|
||||
aria-hidden={!open}
|
||||
className={
|
||||
open
|
||||
? 'assistant-sidebar assistant-sidebar--open'
|
||||
? `assistant-sidebar assistant-sidebar--open${isResizing && canResize ? ' assistant-sidebar--resizing' : ''}`
|
||||
: 'assistant-sidebar'
|
||||
}
|
||||
inert={!open}
|
||||
style={
|
||||
{
|
||||
'--assistant-sidebar-width': `${sidebarWidth}px`
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
aria-controls="assistant-sidebar-panel"
|
||||
aria-label="调整助手工作栏宽度"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={sidebarWidthLimits.maximum}
|
||||
aria-valuemin={sidebarWidthLimits.minimum}
|
||||
aria-valuenow={sidebarWidth}
|
||||
aria-valuetext={`${sidebarWidth} 像素`}
|
||||
aria-disabled={!canResize}
|
||||
className="assistant-sidebar__resize-handle"
|
||||
onKeyDown={resizeWithKeyboard}
|
||||
onLostPointerCapture={(event) => {
|
||||
if (resizePointerId.current === event.pointerId) {
|
||||
resizePointerId.current = undefined
|
||||
setSidebarWidth(liveSidebarWidth.current)
|
||||
setIsResizing(false)
|
||||
}
|
||||
}}
|
||||
onPointerCancel={finishResize}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0 || !canResize) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
resizePointerId.current = event.pointerId
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
resizeFromClientX(event.clientX, true)
|
||||
setIsResizing(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (resizePointerId.current !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
if (!canResize) {
|
||||
finishResize(event)
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
resizeFromClientX(event.clientX, false)
|
||||
}}
|
||||
onPointerUp={finishResize}
|
||||
role="separator"
|
||||
tabIndex={canResize ? 0 : -1}
|
||||
/>
|
||||
<header className="assistant-sidebar__header">
|
||||
<strong>工作栏</strong>
|
||||
<button
|
||||
@@ -800,6 +980,76 @@ export function RightAssistantSidebar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'browser' && (
|
||||
<section className="assistant-sidebar__browser">
|
||||
<header>
|
||||
<span>
|
||||
<Monitor size={15} />
|
||||
<strong>实时浏览器</strong>
|
||||
</span>
|
||||
{browserState &&
|
||||
browserState.status !== 'stopped' && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void onStopBrowser()}
|
||||
type="button"
|
||||
>
|
||||
停止浏览器
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
{!browserState ? (
|
||||
<p className="assistant-sidebar__empty">
|
||||
Agent 打开网页后,实时画面会显示在这里。
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={`assistant-sidebar__browser-status assistant-sidebar__browser-status--${browserState.status}`}
|
||||
role="status"
|
||||
>
|
||||
{browserState.status === 'creating'
|
||||
? '正在启动浏览器…'
|
||||
: browserState.status === 'loading'
|
||||
? '正在加载页面…'
|
||||
: browserState.status === 'acting'
|
||||
? 'Agent 正在操作页面…'
|
||||
: browserState.status === 'ready'
|
||||
? '浏览器已就绪'
|
||||
: browserState.status === 'failed'
|
||||
? browserState.error ?? '浏览器操作失败'
|
||||
: '浏览器已停止'}
|
||||
</div>
|
||||
{browserState.url && (
|
||||
<div
|
||||
className="assistant-sidebar__browser-url"
|
||||
title={browserState.url}
|
||||
>
|
||||
{browserState.url}
|
||||
</div>
|
||||
)}
|
||||
{browserState.frameDataUrl ? (
|
||||
<img
|
||||
alt="Agent 实时浏览器画面"
|
||||
className="assistant-sidebar__browser-frame"
|
||||
src={browserState.frameDataUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="assistant-sidebar__browser-placeholder">
|
||||
<Monitor size={28} />
|
||||
<span>
|
||||
{browserState.status === 'failed'
|
||||
? '未能获取页面画面'
|
||||
: '等待首个页面画面…'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'preview' && (
|
||||
<section className="assistant-sidebar__preview">
|
||||
{currentWorkspacePreview ? (
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { Bot, Plus, Save, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type {
|
||||
AssistantExpert,
|
||||
ExpertCreateInput
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { DestructiveConfirmActions } from './WorkspacePrimitives'
|
||||
|
||||
type ExpertDraft = ExpertCreateInput & {
|
||||
id?: string
|
||||
}
|
||||
|
||||
type RolePromptSettingsSectionProps = {
|
||||
onChanged: (experts: AssistantExpert[]) => void
|
||||
}
|
||||
|
||||
const emptyDraft: ExpertDraft = {
|
||||
name: '',
|
||||
description: '',
|
||||
systemInstructions: ''
|
||||
}
|
||||
|
||||
function draftFromExpert(expert: AssistantExpert): ExpertDraft {
|
||||
return {
|
||||
id: expert.id,
|
||||
name: expert.name,
|
||||
description: expert.description,
|
||||
systemInstructions: expert.systemInstructions
|
||||
}
|
||||
}
|
||||
|
||||
function sortExperts(experts: AssistantExpert[]): AssistantExpert[] {
|
||||
return [...experts].sort((left, right) =>
|
||||
left.name.localeCompare(right.name, 'zh-CN')
|
||||
)
|
||||
}
|
||||
|
||||
export function RolePromptSettingsSection({
|
||||
onChanged
|
||||
}: RolePromptSettingsSectionProps): React.JSX.Element {
|
||||
const [experts, setExperts] = useState<AssistantExpert[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string>()
|
||||
const [draft, setDraft] = useState<ExpertDraft>()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const [confirmingRemove, setConfirmingRemove] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void window.goodbuddy.experts
|
||||
.list()
|
||||
.then((items) => {
|
||||
const sorted = sortExperts(items)
|
||||
setExperts(sorted)
|
||||
if (sorted[0]) {
|
||||
setSelectedId(sorted[0].id)
|
||||
setDraft(draftFromExpert(sorted[0]))
|
||||
}
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : '读取角色失败'
|
||||
)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectExpert = (expert: AssistantExpert): void => {
|
||||
setSelectedId(expert.id)
|
||||
setDraft(draftFromExpert(expert))
|
||||
setConfirmingRemove(false)
|
||||
setError(undefined)
|
||||
}
|
||||
|
||||
const createDraft = (): void => {
|
||||
setSelectedId(undefined)
|
||||
setDraft({ ...emptyDraft })
|
||||
setConfirmingRemove(false)
|
||||
setError(undefined)
|
||||
}
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (!draft) {
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const input: ExpertCreateInput = {
|
||||
name: draft.name,
|
||||
description: draft.description,
|
||||
systemInstructions: draft.systemInstructions
|
||||
}
|
||||
const saved = draft.id
|
||||
? await window.goodbuddy.experts.update(draft.id, input)
|
||||
: await window.goodbuddy.experts.create(input)
|
||||
const next = sortExperts(
|
||||
draft.id
|
||||
? experts.map((expert) =>
|
||||
expert.id === saved.id ? saved : expert
|
||||
)
|
||||
: [...experts, saved]
|
||||
)
|
||||
setExperts(next)
|
||||
setSelectedId(saved.id)
|
||||
setDraft(draftFromExpert(saved))
|
||||
onChanged(next)
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : '保存角色失败'
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (): Promise<void> => {
|
||||
if (!draft?.id) {
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await window.goodbuddy.experts.remove(draft.id)
|
||||
const next = experts.filter((expert) => expert.id !== draft.id)
|
||||
setExperts(next)
|
||||
setConfirmingRemove(false)
|
||||
if (next[0]) {
|
||||
setSelectedId(next[0].id)
|
||||
setDraft(draftFromExpert(next[0]))
|
||||
} else {
|
||||
setSelectedId(undefined)
|
||||
setDraft(undefined)
|
||||
}
|
||||
onChanged(next)
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : '删除角色失败'
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<div className="settings-section__title settings-section__title--actions">
|
||||
<Bot size={17} />
|
||||
<div>
|
||||
<strong>角色与提示词</strong>
|
||||
<small>管理聊天角色及其受信任系统提示词</small>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button role-prompt-add"
|
||||
disabled={busy}
|
||||
onClick={createDraft}
|
||||
type="button"
|
||||
>
|
||||
<Plus size={14} />
|
||||
新建角色
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="settings-notice">
|
||||
选中的角色会把系统提示词加入本次文本对话。专家团队会并行使用最多
|
||||
3 个已启用角色;图像生成连接不使用角色提示词。
|
||||
</p>
|
||||
{error && <p className="settings-warning" role="alert">{error}</p>}
|
||||
|
||||
<div className="model-connection-manager role-prompt-manager">
|
||||
<aside
|
||||
aria-label="角色列表"
|
||||
className="model-connection-list"
|
||||
>
|
||||
<div className="model-connection-list__header">
|
||||
<strong>角色列表</strong>
|
||||
<span>{experts.length}</span>
|
||||
</div>
|
||||
<div role="list">
|
||||
{experts.map((expert) => (
|
||||
<div key={expert.id} role="listitem">
|
||||
<button
|
||||
aria-current={
|
||||
selectedId === expert.id ? 'page' : undefined
|
||||
}
|
||||
aria-label={`编辑角色 ${expert.name}`}
|
||||
onClick={() => selectExpert(expert)}
|
||||
type="button"
|
||||
>
|
||||
<span className="model-connection-list__name">
|
||||
<strong>{expert.name}</strong>
|
||||
<small>{expert.description || '暂无说明'}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{draft ? (
|
||||
<div className="model-connection-detail role-prompt-detail">
|
||||
<div className="settings-section__title">
|
||||
<div>
|
||||
<strong>{draft.id ? draft.name : '新建角色'}</strong>
|
||||
<small>角色详情</small>
|
||||
</div>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>角色名称</span>
|
||||
<input
|
||||
maxLength={80}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, name: event.target.value })
|
||||
}
|
||||
value={draft.name}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>角色说明</span>
|
||||
<textarea
|
||||
maxLength={500}
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
description: event.target.value
|
||||
})
|
||||
}
|
||||
rows={3}
|
||||
value={draft.description}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>系统提示词</span>
|
||||
<textarea
|
||||
aria-label="系统提示词"
|
||||
aria-describedby="role-system-prompt-help"
|
||||
className="role-prompt-detail__prompt"
|
||||
maxLength={20_000}
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
systemInstructions: event.target.value
|
||||
})
|
||||
}
|
||||
rows={12}
|
||||
value={draft.systemInstructions}
|
||||
/>
|
||||
<small id="role-system-prompt-help">
|
||||
作为受信任指令发送给文本模型,请勿写入 API Key 或私人数据。
|
||||
已输入 {draft.systemInstructions.length.toLocaleString()} /
|
||||
20,000 字符。
|
||||
</small>
|
||||
</label>
|
||||
<div className="role-prompt-detail__actions">
|
||||
{draft.id ? (
|
||||
<DestructiveConfirmActions
|
||||
confirmAriaLabel={`确认删除角色 ${draft.name}`}
|
||||
confirmLabel="删除角色"
|
||||
confirming={confirmingRemove}
|
||||
disabled={busy}
|
||||
icon={<Trash2 size={13} />}
|
||||
message="删除后,该角色将从聊天选择和专家团队中移除。"
|
||||
onCancel={() => setConfirmingRemove(false)}
|
||||
onConfirm={() => void remove()}
|
||||
onRequestConfirm={() => setConfirmingRemove(true)}
|
||||
triggerAriaLabel={`删除角色 ${draft.name}`}
|
||||
triggerLabel="删除角色"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
const first = experts[0]
|
||||
if (first) {
|
||||
selectExpert(first)
|
||||
} else {
|
||||
setDraft(undefined)
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={busy}
|
||||
onClick={() => void save()}
|
||||
type="button"
|
||||
>
|
||||
<Save size={14} />
|
||||
{busy ? '保存中…' : draft.id ? '保存角色' : '创建角色'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="settings-empty role-prompt-empty">
|
||||
还没有角色。新建角色后,可以为它配置系统提示词。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,13 +7,16 @@ import {
|
||||
within
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AssistantExpert } from '../../shared/assistant-contracts'
|
||||
import type {
|
||||
DesktopApi,
|
||||
RuntimeSettings
|
||||
} from '../../shared/contracts'
|
||||
import { builtinModelTools } from '../../shared/builtin-model-tools'
|
||||
import { SettingsPanel } from './SettingsPanel'
|
||||
|
||||
const modelProfileId = '00000000-0000-4000-8000-000000000001'
|
||||
const browserProfileId = '00000000-0000-4000-8000-000000000201'
|
||||
const runtimeSettings: RuntimeSettings = {
|
||||
provider: 'auto',
|
||||
modelBaseUrl: 'https://bigtoken.ai',
|
||||
@@ -116,7 +119,37 @@ const capabilitySnapshot = {
|
||||
)[]
|
||||
}
|
||||
],
|
||||
mcpServers: []
|
||||
mcpServers: [],
|
||||
computerCapabilities: [
|
||||
{
|
||||
id: 'host-browser-control' as const,
|
||||
name: '浏览器控制',
|
||||
description: '使用隔离的托管浏览器配置执行网页操作。',
|
||||
enabled: false,
|
||||
supported: true,
|
||||
browserProfileId: null,
|
||||
riskSummary: '可读取网页内容并代表用户操作网站。'
|
||||
},
|
||||
{
|
||||
id: 'linux-desktop-control' as const,
|
||||
name: 'Linux 桌面控制',
|
||||
description: '在受支持的 Linux 桌面会话中执行桌面操作。',
|
||||
enabled: false,
|
||||
supported: false,
|
||||
browserProfileId: null,
|
||||
riskSummary: '可观察并操作桌面应用。'
|
||||
}
|
||||
],
|
||||
browserProfiles: {
|
||||
profiles: [
|
||||
{
|
||||
id: browserProfileId,
|
||||
name: '工作网站',
|
||||
mode: 'managed-isolated' as const
|
||||
}
|
||||
],
|
||||
defaultProfileId: browserProfileId
|
||||
}
|
||||
}
|
||||
const getCapabilitySnapshot = vi.fn(async () => capabilitySnapshot)
|
||||
const setSkillEnabled = vi.fn(async (_skillId: string, enabled: boolean) => ({
|
||||
@@ -126,6 +159,34 @@ const setSkillEnabled = vi.fn(async (_skillId: string, enabled: boolean) => ({
|
||||
enabled
|
||||
}))
|
||||
}))
|
||||
const setComputerCapabilityEnabled = vi.fn(
|
||||
async (_capabilityId: string, enabled: boolean) => ({
|
||||
...capabilitySnapshot,
|
||||
computerCapabilities: capabilitySnapshot.computerCapabilities.map(
|
||||
(capability) =>
|
||||
capability.id === 'host-browser-control'
|
||||
? { ...capability, enabled }
|
||||
: capability
|
||||
)
|
||||
})
|
||||
)
|
||||
const diagnoseComputerCapability = vi.fn(async () => ({
|
||||
capabilityId: 'host-browser-control' as const,
|
||||
status: 'degraded' as const,
|
||||
checkedAt: '2026-08-05T12:00:00.000Z',
|
||||
checks: [
|
||||
{
|
||||
id: 'managed-profile-root',
|
||||
status: 'degraded' as const,
|
||||
summary: '托管配置可用,但尚未选择默认网站。',
|
||||
remedy: '先创建并选择托管配置。'
|
||||
}
|
||||
]
|
||||
}))
|
||||
const createBrowserProfile = vi.fn(async () => capabilitySnapshot)
|
||||
const renameBrowserProfile = vi.fn(async () => capabilitySnapshot)
|
||||
const setDefaultBrowserProfile = vi.fn(async () => capabilitySnapshot)
|
||||
const removeBrowserProfile = vi.fn(async () => capabilitySnapshot)
|
||||
const heartbeatSettingsProps = {
|
||||
heartbeats: [],
|
||||
onCreateHeartbeat: vi.fn(async () => {}),
|
||||
@@ -133,6 +194,39 @@ const heartbeatSettingsProps = {
|
||||
onRemoveHeartbeat: vi.fn(async () => {}),
|
||||
onRunHeartbeat: vi.fn(async () => {})
|
||||
}
|
||||
const assistantExpert: AssistantExpert = {
|
||||
id: '00000000-0000-4000-8000-000000000101',
|
||||
name: '研究分析专家',
|
||||
description: '负责资料分析',
|
||||
systemInstructions: 'Separate evidence from assumptions.',
|
||||
enabled: true,
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z'
|
||||
}
|
||||
const listExperts = vi.fn<DesktopApi['experts']['list']>(
|
||||
async () => [assistantExpert]
|
||||
)
|
||||
const createExpert = vi.fn<DesktopApi['experts']['create']>(
|
||||
async (input) => ({
|
||||
...input,
|
||||
id: '00000000-0000-4000-8000-000000000102',
|
||||
enabled: true,
|
||||
createdAt: '2026-08-04T00:00:00.000Z',
|
||||
updatedAt: '2026-08-04T00:00:00.000Z'
|
||||
})
|
||||
)
|
||||
const updateExpert = vi.fn<DesktopApi['experts']['update']>(
|
||||
async (expertId, input) => ({
|
||||
...input,
|
||||
id: expertId,
|
||||
enabled: true,
|
||||
createdAt: assistantExpert.createdAt,
|
||||
updatedAt: '2026-08-04T00:00:00.000Z'
|
||||
})
|
||||
)
|
||||
const removeExpert = vi.fn<DesktopApi['experts']['remove']>(
|
||||
async () => {}
|
||||
)
|
||||
|
||||
describe('SettingsPanel runtime files', () => {
|
||||
beforeEach(() => {
|
||||
@@ -165,7 +259,22 @@ describe('SettingsPanel runtime files', () => {
|
||||
testMcpServer: vi.fn(async () => ({
|
||||
toolCount: 0,
|
||||
tools: []
|
||||
}))
|
||||
})),
|
||||
setComputerCapabilityEnabled,
|
||||
setComputerCapabilityBrowserProfile: vi.fn(
|
||||
async () => capabilitySnapshot
|
||||
),
|
||||
diagnoseComputerCapability,
|
||||
createBrowserProfile,
|
||||
renameBrowserProfile,
|
||||
setDefaultBrowserProfile,
|
||||
removeBrowserProfile
|
||||
},
|
||||
experts: {
|
||||
list: listExperts,
|
||||
create: createExpert,
|
||||
update: updateExpert,
|
||||
remove: removeExpert
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
@@ -197,6 +306,47 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
|
||||
})
|
||||
|
||||
it('explains automatic Execute authorization and the deny-all policy', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
|
||||
const policy = await screen.findByLabelText(
|
||||
'直连模型工具安全策略'
|
||||
)
|
||||
expect(
|
||||
within(policy).getByRole('option', {
|
||||
name: 'Execute 自动授权已启用的工具'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(policy).getByRole('option', {
|
||||
name: '禁止所有工具执行'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/选择 Execute 即授权当前交互运行自动调用这些工具/)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/禁止策略会拒绝所有工具调用/)
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(policy, { target: { value: 'policy' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
await waitFor(() =>
|
||||
expect(updateRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ toolApproval: 'policy' })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('automatically detects runtimes and displays path, version, and detail', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
@@ -287,17 +437,38 @@ describe('SettingsPanel runtime files', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
await screen.findByDisplayValue('默认模型')
|
||||
expect(
|
||||
screen.getByLabelText('模型连接列表')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '编辑模型连接 默认模型'
|
||||
})
|
||||
).toHaveAttribute('aria-current', 'page')
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '添加自定义' })
|
||||
)
|
||||
const nameInputs = screen.getAllByLabelText('名称')
|
||||
fireEvent.change(nameInputs[1]!, {
|
||||
expect(screen.getAllByLabelText('名称')).toHaveLength(1)
|
||||
fireEvent.change(screen.getByLabelText('名称'), {
|
||||
target: { value: 'OpenCode 独立模型' }
|
||||
})
|
||||
const radios = screen.getAllByRole('radio', {
|
||||
name: '默认连接'
|
||||
})
|
||||
fireEvent.click(radios[1]!)
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '编辑模型连接 OpenCode 独立模型'
|
||||
})
|
||||
).toHaveAttribute('aria-current', 'page')
|
||||
fireEvent.click(screen.getByRole('radio', { name: '默认连接' }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '编辑模型连接 默认模型'
|
||||
})
|
||||
)
|
||||
expect(screen.getByLabelText('名称')).toHaveValue('默认模型')
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '编辑模型连接 OpenCode 独立模型'
|
||||
})
|
||||
)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Agent Runtime' }))
|
||||
const sourceSelect = screen.getAllByLabelText('模型连接')[0]!
|
||||
const sourceOptions = within(sourceSelect).getAllByRole('option')
|
||||
@@ -323,7 +494,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('adds the Ollama preset with OpenAI protocol and no authentication', async () => {
|
||||
it('moves the detail selection after deleting a model connection', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
@@ -335,73 +506,38 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
const preset = await screen.findByLabelText('模型预设')
|
||||
fireEvent.change(preset, { target: { value: 'ollama' } })
|
||||
await screen.findByDisplayValue('默认模型')
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '从预设添加' })
|
||||
screen.getByRole('button', { name: '添加自定义' })
|
||||
)
|
||||
expect(
|
||||
screen
|
||||
.getAllByLabelText('名称')
|
||||
.some((input) => (input as HTMLInputElement).value === 'Ollama(本机)')
|
||||
).toBe(true)
|
||||
expect(
|
||||
screen.getByDisplayValue('http://127.0.0.1:11434/v1')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByLabelText('接口协议 Ollama(本机)')
|
||||
).toHaveValue('openai-chat-completions')
|
||||
expect(
|
||||
screen.getByLabelText('认证方式 Ollama(本机)')
|
||||
).toHaveValue('none')
|
||||
expect(
|
||||
screen.getByText('无需认证,不会发送 API Key')
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
await waitFor(() =>
|
||||
expect(updateRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelProfiles: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'Ollama(本机)',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none',
|
||||
apiKey: { action: 'keep' }
|
||||
})
|
||||
])
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('uses Responses for the official OpenAI preset', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
fireEvent.change(await screen.findByLabelText('模型预设'), {
|
||||
target: { value: 'openai' }
|
||||
fireEvent.change(screen.getByLabelText('名称'), {
|
||||
target: { value: '备用模型' }
|
||||
})
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '从预设添加' })
|
||||
screen.getByRole('button', { name: '添加自定义' })
|
||||
)
|
||||
expect(screen.getByLabelText('名称')).toHaveValue('模型连接 3')
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '删除模型连接 模型连接 3'
|
||||
})
|
||||
)
|
||||
|
||||
const protocol = screen.getByLabelText('接口协议 OpenAI')
|
||||
expect(protocol).toHaveValue('openai-responses')
|
||||
expect(screen.getByLabelText('名称')).toHaveValue('备用模型')
|
||||
expect(
|
||||
within(protocol).getByRole('option', { name: 'OpenAI Responses' })
|
||||
).toBeInTheDocument()
|
||||
screen.queryByRole('button', {
|
||||
name: '编辑模型连接 模型连接 3'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '编辑模型连接 备用模型'
|
||||
})
|
||||
).toHaveAttribute('aria-current', 'page')
|
||||
})
|
||||
|
||||
it('marks the BigToken gpt-image-2 preset as image generation', async () => {
|
||||
it('uses only custom model connections and supports image generation', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
@@ -413,49 +549,17 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
const preset = await screen.findByLabelText('模型预设')
|
||||
fireEvent.change(preset, {
|
||||
target: { value: 'bigtoken-gpt-image-2' }
|
||||
})
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '从预设添加' })
|
||||
)
|
||||
|
||||
await screen.findByDisplayValue('默认模型')
|
||||
expect(screen.queryByLabelText('模型预设')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByLabelText('接口协议 BigToken GPT Image 2')
|
||||
).toHaveValue('openai-images-generations')
|
||||
screen.queryByRole('button', { name: '从预设添加' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('接口协议 默认模型'), {
|
||||
target: { value: 'openai-images-generations' }
|
||||
})
|
||||
expect(screen.getByText('图像生成', { selector: 'span' }))
|
||||
.toBeInTheDocument()
|
||||
|
||||
const defaultConnections = screen.getAllByRole('radio')
|
||||
fireEvent.click(defaultConnections.at(-1)!)
|
||||
vi.mocked(window.goodbuddy.settings.testRuntime).mockResolvedValueOnce({
|
||||
id: 'model',
|
||||
label: 'gpt-image-2',
|
||||
available: true,
|
||||
supportsToolExecution: false,
|
||||
detail: '图像接口将在发送提示词时实际验证',
|
||||
capability: 'image-generation'
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存并测试' }))
|
||||
await waitFor(() =>
|
||||
expect(updateRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelProfiles: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'BigToken GPT Image 2',
|
||||
modelName: 'gpt-image-2',
|
||||
protocol: 'openai-images-generations'
|
||||
})
|
||||
])
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(
|
||||
await screen.findByText('图像接口将在发送提示词时实际验证')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('连接成功:gpt-image-2'))
|
||||
.not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('manages heartbeat automation from Settings', async () => {
|
||||
@@ -609,16 +713,146 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
|
||||
expect(await screen.findByText('电脑控制能力')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('托管浏览器配置').length).toBeGreaterThan(0)
|
||||
expect(screen.getByLabelText('启用 Linux 桌面控制')).toBeDisabled()
|
||||
fireEvent.click(screen.getByLabelText('启用 浏览器控制'))
|
||||
await waitFor(() =>
|
||||
expect(setComputerCapabilityEnabled).toHaveBeenCalledWith(
|
||||
'host-browser-control',
|
||||
true
|
||||
)
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '诊断 浏览器控制' }))
|
||||
expect(
|
||||
await screen.findByText('诊断结果:部分可用')
|
||||
).toBeInTheDocument()
|
||||
expect(diagnoseComputerCapability).toHaveBeenCalledWith(
|
||||
'host-browser-control'
|
||||
)
|
||||
fireEvent.change(screen.getByLabelText('新配置名称'), {
|
||||
target: { value: '购物网站' }
|
||||
})
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '创建托管配置' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(createBrowserProfile).toHaveBeenCalledWith({
|
||||
name: '购物网站'
|
||||
})
|
||||
)
|
||||
fireEvent.change(screen.getByLabelText('配置名称 工作网站'), {
|
||||
target: { value: '工作站点' }
|
||||
})
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '重命名配置 工作网站' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(renameBrowserProfile).toHaveBeenCalledWith({
|
||||
profileId: browserProfileId,
|
||||
name: '工作站点'
|
||||
})
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '删除配置 工作网站' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(removeBrowserProfile).toHaveBeenCalledWith(browserProfileId)
|
||||
)
|
||||
expect(
|
||||
await screen.findByText('读取工作区文本')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('列出工作区目录')).toBeInTheDocument()
|
||||
expect(screen.getByText('写入工作区文本')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('直连模型')).toHaveLength(
|
||||
builtinModelTools.length
|
||||
)
|
||||
expect(
|
||||
await screen.findByText('尚未配置 MCP Server')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /添加 Server/ })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/自定义 stdio MCP 会以受限环境启动/)
|
||||
).toHaveTextContent('不会获得桌面会话变量')
|
||||
fireEvent.click(screen.getByRole('button', { name: /添加 Server/ }))
|
||||
expect(screen.getByLabelText('模型')).toBeChecked()
|
||||
expect(
|
||||
screen.queryByLabelText('OpenCode')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates, updates, and removes roles with system prompts', async () => {
|
||||
const onExpertsChanged = vi.fn()
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
onExpertsChanged={onExpertsChanged}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: '角色与提示词' })
|
||||
)
|
||||
await screen.findByRole('button', {
|
||||
name: '编辑角色 研究分析专家'
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('系统提示词'), {
|
||||
target: { value: 'Use evidence and state uncertainty.' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存角色' }))
|
||||
await waitFor(() =>
|
||||
expect(updateExpert).toHaveBeenCalledWith(
|
||||
assistantExpert.id,
|
||||
expect.objectContaining({
|
||||
systemInstructions: 'Use evidence and state uncertainty.'
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建角色' }))
|
||||
fireEvent.change(screen.getByLabelText('角色名称'), {
|
||||
target: { value: '代码审查专家' }
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('角色说明'), {
|
||||
target: { value: '检查代码正确性' }
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('系统提示词'), {
|
||||
target: { value: 'Review code and report actionable bugs.' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建角色' }))
|
||||
await waitFor(() =>
|
||||
expect(createExpert).toHaveBeenCalledWith({
|
||||
name: '代码审查专家',
|
||||
description: '检查代码正确性',
|
||||
systemInstructions: 'Review code and report actionable bugs.'
|
||||
})
|
||||
)
|
||||
expect(onExpertsChanged).toHaveBeenLastCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: '代码审查专家' })
|
||||
])
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '删除角色 代码审查专家'
|
||||
})
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '确认删除角色 代码审查专家'
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(removeExpert).toHaveBeenCalledWith(
|
||||
'00000000-0000-4000-8000-000000000102'
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type {
|
||||
AssistantExpert,
|
||||
AssistantHeartbeatConfig,
|
||||
HeartbeatCreateInput
|
||||
} from '../../shared/assistant-contracts'
|
||||
@@ -22,11 +23,8 @@ import type {
|
||||
RuntimeModelSource
|
||||
} from '../../shared/contracts'
|
||||
import { defaultRuntimeSettings } from '../../shared/contracts'
|
||||
import {
|
||||
modelProfilePresets,
|
||||
type ModelProfilePreset
|
||||
} from '../../shared/model-presets'
|
||||
import { McpSettingsSection } from './McpSettingsSection'
|
||||
import { RolePromptSettingsSection } from './RolePromptSettingsSection'
|
||||
import { SkillsSettingsSection } from './SkillsSettingsSection'
|
||||
import { HeartbeatSettings } from './HeartbeatSettings'
|
||||
import type { AppearanceTheme } from './theme'
|
||||
@@ -37,6 +35,7 @@ type SettingsTab =
|
||||
| 'runtime'
|
||||
| 'security'
|
||||
| 'automation'
|
||||
| 'roles'
|
||||
| 'skills'
|
||||
| 'mcp'
|
||||
type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
|
||||
@@ -49,6 +48,7 @@ type SettingsPanelProps = {
|
||||
presentation?: 'modal' | 'page'
|
||||
onClose: () => void
|
||||
onSaved: (settings: RuntimeSettings) => void
|
||||
onExpertsChanged?: (experts: AssistantExpert[]) => void
|
||||
onClearLocalData: () => Promise<void>
|
||||
heartbeats: AssistantHeartbeatConfig[]
|
||||
onCreateHeartbeat: (input: HeartbeatCreateInput) => Promise<void>
|
||||
@@ -92,6 +92,7 @@ export function SettingsPanel({
|
||||
onSetHeartbeatPaused,
|
||||
onRemoveHeartbeat,
|
||||
onRunHeartbeat,
|
||||
onExpertsChanged = () => {},
|
||||
appearanceTheme = 'system',
|
||||
onAppearanceThemeChange = () => {}
|
||||
}: SettingsPanelProps): React.JSX.Element | null {
|
||||
@@ -101,9 +102,8 @@ export function SettingsPanel({
|
||||
defaultRuntimeSettings.provider
|
||||
)
|
||||
const [modelProfiles, setModelProfiles] = useState<ModelProfileDraft[]>([])
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string>(
|
||||
modelProfilePresets[0].id
|
||||
)
|
||||
const [selectedModelProfileId, setSelectedModelProfileId] =
|
||||
useState('')
|
||||
const [defaultModelProfileId, setDefaultModelProfileId] = useState('')
|
||||
const [opencodeModelSource, setOpencodeModelSource] =
|
||||
useState<RuntimeModelSource>({ kind: 'platform' })
|
||||
@@ -176,6 +176,13 @@ export function SettingsPanel({
|
||||
setSettings(value)
|
||||
setProvider(value.provider)
|
||||
setModelProfiles(toModelProfileDrafts(value))
|
||||
setSelectedModelProfileId(
|
||||
value.modelProfiles.some(
|
||||
(profile) => profile.id === value.defaultModelProfileId
|
||||
)
|
||||
? value.defaultModelProfileId
|
||||
: value.modelProfiles[0]?.id ?? ''
|
||||
)
|
||||
setDefaultModelProfileId(value.defaultModelProfileId)
|
||||
setOpencodeModelSource(value.opencodeModelSource)
|
||||
setContinueModelSource(value.continueModelSource)
|
||||
@@ -281,6 +288,11 @@ export function SettingsPanel({
|
||||
})
|
||||
setSettings(value)
|
||||
setModelProfiles(toModelProfileDrafts(value))
|
||||
setSelectedModelProfileId((selectedId) =>
|
||||
value.modelProfiles.some((profile) => profile.id === selectedId)
|
||||
? selectedId
|
||||
: value.defaultModelProfileId
|
||||
)
|
||||
setDefaultModelProfileId(value.defaultModelProfileId)
|
||||
setOpencodeModelSource(value.opencodeModelSource)
|
||||
setContinueModelSource(value.continueModelSource)
|
||||
@@ -396,37 +408,7 @@ export function SettingsPanel({
|
||||
if (!defaultModelProfileId) {
|
||||
setDefaultModelProfileId(id)
|
||||
}
|
||||
}
|
||||
|
||||
const addPresetProfile = (preset: ModelProfilePreset): void => {
|
||||
const id = crypto.randomUUID()
|
||||
setModelProfiles((profiles) => {
|
||||
const usedNames = new Set(profiles.map((profile) => profile.name))
|
||||
let name = preset.name
|
||||
let suffix = 2
|
||||
while (usedNames.has(name)) {
|
||||
name = `${preset.name} ${suffix}`
|
||||
suffix += 1
|
||||
}
|
||||
return [
|
||||
...profiles,
|
||||
{
|
||||
id,
|
||||
name,
|
||||
baseUrl: preset.baseUrl,
|
||||
modelName: preset.modelName,
|
||||
protocol: preset.protocol,
|
||||
authentication: preset.authentication,
|
||||
apiKeyConfigured: false,
|
||||
credentialSource: 'none',
|
||||
apiKey: '',
|
||||
clearApiKey: false
|
||||
}
|
||||
]
|
||||
})
|
||||
if (!defaultModelProfileId) {
|
||||
setDefaultModelProfileId(id)
|
||||
}
|
||||
setSelectedModelProfileId(id)
|
||||
}
|
||||
|
||||
const removeModelProfile = (id: string): void => {
|
||||
@@ -434,8 +416,17 @@ export function SettingsPanel({
|
||||
setError('请至少保留一个模型连接')
|
||||
return
|
||||
}
|
||||
const removedIndex = modelProfiles.findIndex(
|
||||
(profile) => profile.id === id
|
||||
)
|
||||
const remaining = modelProfiles.filter((profile) => profile.id !== id)
|
||||
setModelProfiles(remaining)
|
||||
if (selectedModelProfileId === id) {
|
||||
setSelectedModelProfileId(
|
||||
remaining[Math.min(removedIndex, remaining.length - 1)]?.id ??
|
||||
remaining[0]!.id
|
||||
)
|
||||
}
|
||||
if (defaultModelProfileId === id) {
|
||||
setDefaultModelProfileId(remaining[0]!.id)
|
||||
}
|
||||
@@ -470,6 +461,11 @@ export function SettingsPanel({
|
||||
profile.protocol === 'anthropic-messages' ||
|
||||
profile.protocol === 'openai-chat-completions'
|
||||
|
||||
const selectedModelProfile =
|
||||
modelProfiles.find(
|
||||
(profile) => profile.id === selectedModelProfileId
|
||||
) ?? modelProfiles[0]
|
||||
|
||||
const detectionSummary = (
|
||||
value: AgentRuntimeDetection['opencode'] | undefined
|
||||
): React.JSX.Element => (
|
||||
@@ -564,7 +560,7 @@ export function SettingsPanel({
|
||||
type="button"
|
||||
>
|
||||
<strong>安全与数据</strong>
|
||||
<small>工具审批与本地隐私</small>
|
||||
<small>工具策略与本地隐私</small>
|
||||
</button>
|
||||
<button
|
||||
aria-label="自动化"
|
||||
@@ -576,6 +572,16 @@ export function SettingsPanel({
|
||||
<strong>自动化</strong>
|
||||
<small>智能心跳与周期回顾</small>
|
||||
</button>
|
||||
<button
|
||||
aria-label="角色与提示词"
|
||||
aria-selected={activeTab === 'roles'}
|
||||
onClick={() => setActiveTab('roles')}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<strong>角色与提示词</strong>
|
||||
<small>角色、说明与系统提示词</small>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Skills"
|
||||
aria-selected={activeTab === 'skills'}
|
||||
@@ -998,7 +1004,7 @@ export function SettingsPanel({
|
||||
{activeTab === 'model' && (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<div className="settings-section__title">
|
||||
<div className="settings-section__title settings-section__title--actions">
|
||||
<KeyRound size={17} />
|
||||
<div>
|
||||
<strong>模型连接</strong>
|
||||
@@ -1009,7 +1015,7 @@ export function SettingsPanel({
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
className="secondary-button model-connection-add"
|
||||
onClick={addModelProfile}
|
||||
type="button"
|
||||
>
|
||||
@@ -1017,52 +1023,65 @@ export function SettingsPanel({
|
||||
添加自定义
|
||||
</button>
|
||||
</div>
|
||||
<div className="runtime-note">
|
||||
<label className="field">
|
||||
<span>模型预设</span>
|
||||
<select
|
||||
aria-label="模型预设"
|
||||
onChange={(event) =>
|
||||
setSelectedPresetId(event.target.value)
|
||||
}
|
||||
value={selectedPresetId}
|
||||
>
|
||||
{modelProfilePresets.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>
|
||||
{preset.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>
|
||||
{
|
||||
modelProfilePresets.find(
|
||||
(preset) => preset.id === selectedPresetId
|
||||
)?.description
|
||||
}
|
||||
</small>
|
||||
</label>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
const preset = modelProfilePresets.find(
|
||||
(candidate) => candidate.id === selectedPresetId
|
||||
)
|
||||
if (preset) {
|
||||
addPresetProfile(preset)
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
<div className="model-connection-manager">
|
||||
<aside
|
||||
aria-label="模型连接列表"
|
||||
className="model-connection-list"
|
||||
>
|
||||
<Plus size={14} />
|
||||
从预设添加
|
||||
</button>
|
||||
</div>
|
||||
{modelProfiles.map((profile) => {
|
||||
<div className="model-connection-list__header">
|
||||
<strong>连接列表</strong>
|
||||
<span>{modelProfiles.length}</span>
|
||||
</div>
|
||||
<div role="list">
|
||||
{modelProfiles.map((profile) => (
|
||||
<div key={profile.id} role="listitem">
|
||||
<button
|
||||
aria-current={
|
||||
selectedModelProfile?.id === profile.id
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
aria-label={`编辑模型连接 ${profile.name}`}
|
||||
onClick={() =>
|
||||
setSelectedModelProfileId(profile.id)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span className="model-connection-list__name">
|
||||
<strong>{profile.name}</strong>
|
||||
<small>{profile.modelName}</small>
|
||||
</span>
|
||||
<span className="model-connection-list__badges">
|
||||
{defaultModelProfileId === profile.id && (
|
||||
<span>默认</span>
|
||||
)}
|
||||
{profile.protocol ===
|
||||
'openai-images-generations' && (
|
||||
<span>图像</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
{selectedModelProfile && (() => {
|
||||
const profile = selectedModelProfile
|
||||
const environmentManaged =
|
||||
profile.credentialSource === 'environment'
|
||||
return (
|
||||
<div className="runtime-note" key={profile.id}>
|
||||
<div
|
||||
aria-labelledby={`model-connection-${profile.id}`}
|
||||
className="model-connection-detail"
|
||||
key={profile.id}
|
||||
>
|
||||
<div className="settings-section__title">
|
||||
<div>
|
||||
<strong id={`model-connection-${profile.id}`}>
|
||||
{profile.name}
|
||||
</strong>
|
||||
<small>连接详情</small>
|
||||
</div>
|
||||
<label className="check-field">
|
||||
<input
|
||||
checked={defaultModelProfileId === profile.id}
|
||||
@@ -1248,7 +1267,7 @@ export function SettingsPanel({
|
||||
<span>无需认证,不会发送 API Key</span>
|
||||
</div>
|
||||
)}
|
||||
<small>
|
||||
<small className="model-connection-detail__compatibility">
|
||||
直连模型:
|
||||
{profile.protocol === 'openai-images-generations'
|
||||
? '图像生成'
|
||||
@@ -1262,7 +1281,8 @@ export function SettingsPanel({
|
||||
</small>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
})()}
|
||||
</div>
|
||||
{settings && !settings.secureStorageAvailable && (
|
||||
<p className="settings-warning">
|
||||
当前系统密钥服务不可用。为了避免明文落盘,请使用环境变量提供
|
||||
@@ -1299,6 +1319,7 @@ export function SettingsPanel({
|
||||
<label className="field">
|
||||
<span>直连模型工具安全策略</span>
|
||||
<select
|
||||
aria-label="直连模型工具安全策略"
|
||||
value={toolApproval}
|
||||
onChange={(event) =>
|
||||
setToolApproval(
|
||||
@@ -1306,13 +1327,16 @@ export function SettingsPanel({
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="always">调用时询问</option>
|
||||
<option value="always">
|
||||
Execute 自动授权已启用的工具
|
||||
</option>
|
||||
<option value="policy">禁止所有工具执行</option>
|
||||
</select>
|
||||
<small>
|
||||
直连模型的 Execute 模式可使用内置工作区工具及已分配的
|
||||
MCP 工具,每次调用均受此策略控制。OpenCode 与 Continue
|
||||
继续使用各自的工具系统。
|
||||
MCP 工具;选择 Execute 即授权当前交互运行自动调用这些工具,
|
||||
不再逐次询问。禁止策略会拒绝所有工具调用。OpenCode 与
|
||||
Continue 继续使用各自的工具系统。
|
||||
</small>
|
||||
</label>
|
||||
|
||||
@@ -1416,6 +1440,11 @@ export function SettingsPanel({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'roles' && (
|
||||
<RolePromptSettingsSection
|
||||
onChanged={onExpertsChanged}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'skills' && <SkillsSettingsSection />}
|
||||
{activeTab === 'mcp' && <McpSettingsSection />}
|
||||
</div>
|
||||
|
||||
+505
-13
@@ -567,6 +567,7 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.assistant-sidebar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 0;
|
||||
flex: 0 0 0;
|
||||
@@ -583,12 +584,55 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.assistant-sidebar--open {
|
||||
width: 350px;
|
||||
flex-basis: 350px;
|
||||
width: var(--assistant-sidebar-width, 350px);
|
||||
flex-basis: var(--assistant-sidebar-width, 350px);
|
||||
border-left-width: 1px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.assistant-sidebar--resizing {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.assistant-sidebar__resize-handle {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -5px;
|
||||
display: none;
|
||||
width: 10px;
|
||||
padding: 0;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.assistant-sidebar--open > .assistant-sidebar__resize-handle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.assistant-sidebar__resize-handle::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 4px;
|
||||
width: 2px;
|
||||
background: transparent;
|
||||
content: '';
|
||||
transition: background var(--motion-fast, 120ms) ease-out;
|
||||
}
|
||||
|
||||
.assistant-sidebar__resize-handle:hover::after,
|
||||
.assistant-sidebar__resize-handle:focus-visible::after,
|
||||
.assistant-sidebar--resizing > .assistant-sidebar__resize-handle::after {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.assistant-sidebar__resize-handle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.assistant-sidebar__header {
|
||||
display: flex;
|
||||
height: 58px;
|
||||
@@ -608,7 +652,7 @@ textarea:focus-visible {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
gap: 3px;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.assistant-sidebar__tab {
|
||||
@@ -1190,6 +1234,84 @@ textarea:focus-visible {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser > header > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser > header .secondary-button {
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
flex: none;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-status {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-status--failed {
|
||||
border-color: var(--danger-border);
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-url {
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-frame,
|
||||
.assistant-sidebar__browser-placeholder {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-frame {
|
||||
display: block;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-placeholder {
|
||||
display: flex;
|
||||
min-height: 220px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.assistant-sidebar__preview > header {
|
||||
display: flex;
|
||||
padding-bottom: 12px;
|
||||
@@ -1968,12 +2090,6 @@ textarea:focus-visible {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.composer__mode--plan {
|
||||
border-color: #b7eb8f;
|
||||
background: #f6ffed;
|
||||
color: #237804;
|
||||
}
|
||||
|
||||
.composer__mode--execute {
|
||||
border-color: #ffd591;
|
||||
background: #fff7e6;
|
||||
@@ -2182,7 +2298,7 @@ textarea:focus-visible {
|
||||
padding: 3px;
|
||||
border-radius: 9px;
|
||||
background: #f5f5f5;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
}
|
||||
|
||||
.settings-tabs button {
|
||||
@@ -2308,6 +2424,187 @@ textarea:focus-visible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-connection-manager {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-items: start;
|
||||
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.model-connection-list,
|
||||
.model-connection-detail {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.model-connection-list {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-connection-list__header {
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-3);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.model-connection-list__header > span {
|
||||
display: inline-flex;
|
||||
min-width: 22px;
|
||||
min-height: 22px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.model-connection-list > [role='list'] {
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.model-connection-list [role='listitem'] + [role='listitem'] {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.model-connection-list button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.model-connection-list button:hover {
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.model-connection-list button:focus-visible {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.model-connection-list button[aria-current='page'] {
|
||||
background: var(--accent-subtle);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.model-connection-list__name {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.model-connection-list__name strong,
|
||||
.model-connection-list__name small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-connection-list__name strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.model-connection-list__name small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.model-connection-list__badges {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.model-connection-list__badges > span {
|
||||
padding: 2px var(--space-1);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.model-connection-detail {
|
||||
display: flex;
|
||||
padding: var(--space-4);
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.model-connection-detail > .settings-section__title {
|
||||
min-height: 32px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.model-connection-detail > .settings-section__title > div:first-child {
|
||||
min-width: 120px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.model-connection-detail__compatibility {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.role-prompt-detail__prompt {
|
||||
min-height: 240px;
|
||||
font-family: inherit;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.role-prompt-detail__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.role-prompt-detail__actions > .danger-button,
|
||||
.role-prompt-detail__actions > .danger-confirm {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.role-prompt-detail__actions .primary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.role-prompt-empty {
|
||||
min-height: 180px;
|
||||
padding: var(--space-6);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2410,6 +2707,157 @@ textarea:focus-visible {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.capability-list--tools {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.mcp-tool-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.computer-capability-risk {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
color: var(--warning) !important;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.computer-capability-risk svg {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.computer-capability-profile {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.capability-diagnostic {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.capability-diagnostic > button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.capability-diagnostic__result {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-subtle);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.capability-diagnostic__result strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.browser-profile-create,
|
||||
.browser-profile-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.browser-profile-create .field,
|
||||
.browser-profile-row .field {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.browser-profile-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.browser-profile-row {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.browser-profile-default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.browser-profile-row .danger-ghost {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--danger-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-caption);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.browser-profile-row .danger-ghost:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.capability-diagnostic,
|
||||
.browser-profile-create,
|
||||
.browser-profile-row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.mcp-subsection-heading,
|
||||
.mcp-subsection-heading > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mcp-subsection-heading {
|
||||
justify-content: space-between;
|
||||
color: var(--text-secondary);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mcp-subsection-heading > div {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mcp-subsection-heading strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.mcp-subsection-heading small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.builtin-tool-badge {
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: 999px;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
font-size: var(--font-caption);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.capability-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -4800,7 +5248,6 @@ textarea:focus-visible {
|
||||
.nav-item--active,
|
||||
.brand__mark,
|
||||
.composer__mode--ask,
|
||||
.composer__mode--plan,
|
||||
.runtime-capability-badge,
|
||||
.model-capability-badge
|
||||
) {
|
||||
@@ -4968,6 +5415,17 @@ textarea:focus-visible {
|
||||
bottom: 0;
|
||||
box-shadow: -12px 0 30px rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
.assistant-sidebar--open {
|
||||
width: min(
|
||||
var(--assistant-sidebar-width, 350px),
|
||||
calc(100vw - 36px)
|
||||
);
|
||||
flex-basis: min(
|
||||
var(--assistant-sidebar-width, 350px),
|
||||
calc(100vw - 36px)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
@@ -4986,8 +5444,14 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.assistant-sidebar--open {
|
||||
width: min(390px, calc(100vw - 36px));
|
||||
flex-basis: min(390px, calc(100vw - 36px));
|
||||
width: min(
|
||||
var(--assistant-sidebar-width, 350px),
|
||||
calc(100vw - 36px)
|
||||
);
|
||||
flex-basis: min(
|
||||
var(--assistant-sidebar-width, 350px),
|
||||
calc(100vw - 36px)
|
||||
);
|
||||
}
|
||||
|
||||
.settings-page .settings-panel__body {
|
||||
@@ -5020,11 +5484,29 @@ textarea:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 719px) {
|
||||
.assistant-sidebar__resize-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.workspace-panel-scroll {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.model-connection-manager {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.model-connection-list > [role='list'] {
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.capability-list--tools {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-header:not(.page-header--compact) {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -5054,6 +5536,16 @@ textarea:focus-visible {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.model-connection-add {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.role-prompt-add {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.heartbeat-center__metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user