feat: add computer control and managed browser
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user