feat: add computer control and managed browser

This commit is contained in:
lofyer
2026-08-05 12:55:24 +08:00
parent 2f549387a6
commit 38ac2206f2
92 changed files with 21028 additions and 766 deletions
+131 -2
View File
@@ -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
View File
@@ -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({
+353 -5
View File
@@ -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> stdioStreamable 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>
+5 -4
View File
@@ -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')
})
})
+262 -12
View File
@@ -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>
)
}
+341 -107
View File
@@ -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'
)
)
})
})
+116 -87
View File
@@ -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
View File
@@ -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;
}