diff --git a/apps/api/src/opendirector_api/agent.py b/apps/api/src/opendirector_api/agent.py index 3ded0ab2..57bf3fd1 100644 --- a/apps/api/src/opendirector_api/agent.py +++ b/apps/api/src/opendirector_api/agent.py @@ -12,6 +12,7 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel, ConfigDict, Field, JsonValue +from opendirector.agent.catalog import model_choices from opendirector.agent.context import RunContext, RuntimeId from opendirector.agent.runtime import AgentRuntimes, runtime_available, configured_runtimes, runtime_spec from opendirector.agent.skills import skills @@ -183,6 +184,11 @@ async def available_runtimes(project_id: UUID): await asyncio.to_thread(conversations.path, project_id) return await configured_runtimes() + @router.get('/runtimes/{runtime}/models') + async def runtime_models(project_id: UUID, runtime: Literal['opencode', 'codex', 'claude']): + await asyncio.to_thread(conversations.path, project_id) + return await model_choices(runtime) + @router.get('/skills') async def catalog(project_id: UUID): directory = await asyncio.to_thread(conversations.path, project_id) diff --git a/docs/architecture/agent-runtime.md b/docs/architecture/agent-runtime.md index 1ffdb780..ea414bbf 100644 --- a/docs/architecture/agent-runtime.md +++ b/docs/architecture/agent-runtime.md @@ -8,6 +8,14 @@ Project changes go through `read_project`, `command_schema` and `execute_command Conversation drafts and messages persist separately from project objects. Only one reply per project is admitted; stop and stream disconnect terminate the process group, drain accepted business operations and save the partial reply. Native events and business-tool events are recorded in `agent-runs/.opencode.jsonl`. -Image reading requires both a compatible model/API and enabled image input in model settings. Native tool availability is not proof that a model can understand a video; inspect actual output. ComfyUI execution and character-replacement quality are outside this change. External CLI selection and the richer Skill and preview UI follow separately. +Image reading requires both a compatible model/API and enabled image input in model settings. Native tool availability is not proof that a model can understand a video; inspect actual output. ComfyUI execution and character-replacement quality are outside this change. The richer Skill and preview UI follow separately. Validation covers the real bundled OpenCode process with a simulated model transport, shared MCP calls, cancellation, persisted conversation state, and actual FFmpeg outputs published through business commands. Simulated model tests do not establish real model planning or visual quality. + +## External CLI selection + +The assistant picker separates installed OpenCode CLI, Codex and Claude Code from Default. Default continues to use bundled OpenCode and OpenDirector model settings; external harnesses use their installed binary, native authentication and model catalog. Unavailable harnesses provide setup guidance. Model and effort choices persist with each conversation, with a browser preference used while the draft is saving. + +OpenCode CLI reads its configured model, recent selection or native provider default. Codex reads its native model catalog and configured default before an isolated execution; Claude Code reads its native initialization catalog. The server inherits authentication environment variables, including third-party API configuration, so launch it from the configured shell environment. + +All harnesses receive the same turn-scoped business gateway and execution directory. Codex runs with workspace-write sandboxing; Claude Code receives the explicit Skill, Bash, Read and OpenDirector tool allowlist. Native events are recorded under the corresponding runtime name. Automated CLI fixtures validate routing and process behavior; each installed CLI and real model still require separate acceptance testing. diff --git a/packages/core/src/opendirector/agent/catalog.py b/packages/core/src/opendirector/agent/catalog.py new file mode 100644 index 00000000..bb79168f --- /dev/null +++ b/packages/core/src/opendirector/agent/catalog.py @@ -0,0 +1,152 @@ +"""Read model choices from the installed harness without starting a model turn.""" +import asyncio +import json +import os +import re +import secrets +from pathlib import Path + +import httpx +from pydantic import BaseModel, Field + +from opendirector.agent.native import executable +from opendirector.agent.process import terminate +from opendirector.domain.projects import ProjectError + + +class ModelChoice(BaseModel): + id: str + label: str + efforts: list[str] = Field(default_factory=list) + isDefault: bool = Field(default=False, exclude_if=lambda value: not value) + + +async def opencode_fallback(binary: str, configured_providers: list[str]) -> str | None: + """Use the native provider order and model ranking, without a model turn.""" + password = secrets.token_urlsafe(24) + process = await asyncio.create_subprocess_exec( + binary, 'serve', '--pure', '--hostname', '127.0.0.1', '--port', '0', + env={**os.environ, 'OPENCODE_SERVER_PASSWORD': password}, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, start_new_session=True) + try: + async with asyncio.timeout(15): + while line := await process.stdout.readline(): + address = re.search(rb'http://127\.0\.0\.1:\d+', line) + if not address: + continue + async with httpx.AsyncClient(trust_env=False, auth=( + os.environ.get('OPENCODE_SERVER_USERNAME', 'opencode'), password)) as client: + response = await client.get(address[0].decode() + '/config/providers') + response.raise_for_status() + data = response.json() + provider = next((item for item in data['providers'] + if not configured_providers or item['id'] in configured_providers), None) + if provider and (model := data['default'].get(provider['id'])): + return f"{provider['id']}/{model}" + return None + return None + finally: + await terminate(process, group=True) + + +async def model_choices(runtime: str) -> list[dict]: + if runtime not in {'opencode', 'codex', 'claude'}: + raise ProjectError('此引擎使用模型设置中的模型。') + binary = executable(runtime) + if binary is None: + raise ProjectError('CLI 尚未安装。', 503) + if runtime == 'opencode': + process = await asyncio.create_subprocess_exec(binary, 'models', '--pure', + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, start_new_session=True) + try: + output, _ = await asyncio.wait_for(process.communicate(), 15) + if process.returncode != 0: + raise ProjectError('读取 OpenCode 模型列表失败,请检查本机配置。', 503) + rows = [ModelChoice(id=line, label=line) for line in output.decode().splitlines() if '/' in line] + config_process = await asyncio.create_subprocess_exec(binary, 'debug', 'config', + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, start_new_session=True) + try: + data, _ = await asyncio.wait_for(config_process.communicate(), 10) + configuration = json.loads(data) if config_process.returncode == 0 else {} + configured = configuration.get('model') + finally: + await terminate(config_process, group=True) + if not configured: + state = Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'opencode/model.json' + if state.is_file(): + recent = json.loads(state.read_text()).get('recent', []) + configured = next((f"{item['providerID']}/{item['modelID']}" for item in recent + if any(row.id == f"{item['providerID']}/{item['modelID']}" for row in rows)), None) + if not configured: + configured = await opencode_fallback(binary, list(configuration.get('provider', {}))) + for row in rows: + row.isDefault = row.id == configured + if configured and not any(row.id == configured for row in rows): + rows.insert(0, ModelChoice(id=configured, label=configured, isDefault=True)) + return [row.model_dump() for row in rows] + except (TimeoutError, UnicodeError, OSError, ValueError, httpx.HTTPError) as error: + raise ProjectError('读取 OpenCode 模型列表失败,请重试。', 503) from error + finally: + await terminate(process, group=True) + args = ['app-server'] if runtime == 'codex' else [ + '-p', '--verbose', '--input-format', 'stream-json', '--output-format', 'stream-json'] + try: + process = await asyncio.create_subprocess_exec( + binary, *args, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, start_new_session=True, limit=4 * 1024 * 1024) + except OSError as error: + raise ProjectError('CLI 无法启动,请检查安装。', 503) from error + + async def request(message, request_id): + process.stdin.write((json.dumps(message) + '\n').encode()) + await process.stdin.drain() + while line := await process.stdout.readline(): + event = json.loads(line) + if runtime == 'codex' and event.get('id') == request_id: + if 'error' in event: + raise ProjectError('CLI 无法提供模型列表。', 503) + return event['result'] + if runtime == 'claude' and event.get('type') == 'control_response': + response = event['response'] + if response.get('request_id') == request_id: + if response['subtype'] != 'success': + raise ProjectError('CLI 无法提供模型列表。', 503) + return response['response'] + raise ProjectError('CLI 未返回模型列表。', 503) + + try: + async with asyncio.timeout(15): + if runtime == 'codex': + await request({'id': 1, 'method': 'initialize', 'params': { + 'clientInfo': {'name': 'opendirector', 'version': '0.1'}, + 'capabilities': {'experimentalApi': True}}}, 1) + process.stdin.write(b'{"method":"initialized"}\n') + try: + configuration = await request({'id': 3, 'method': 'config/read', 'params': {'includeLayers': False}}, 3) + configured = configuration.get('config', {}).get('model') + except ProjectError: + configured = None + rows, cursor = [], None + while True: + result = await request({'id': 2, 'method': 'model/list', + 'params': {'cursor': cursor, 'limit': 100}}, 2) + rows.extend(result['data']) + cursor = result.get('nextCursor') + if not cursor: + break + choices = [ModelChoice(id=item['model'], label=item['displayName'], + efforts=[effort['reasoningEffort'] for effort in item.get('supportedReasoningEfforts', [])], + isDefault=item['model'] == configured if configured else item.get('isDefault', False)) + for item in rows if not item.get('hidden', False)] + if configured and not any(item.id == configured for item in choices): + choices.insert(0, ModelChoice(id=configured, label=configured, isDefault=True)) + return [item.model_dump() for item in choices] + result = await request({'type': 'control_request', 'request_id': 'model-catalog', + 'request': {'subtype': 'initialize'}}, 'model-catalog') + return [ModelChoice(id=item['value'], label=(item.get('resolvedModel') if item['value'] == 'default' else None) or item.get('displayName') or item['value'], + efforts=item.get('supportedEffortLevels', []), isDefault=item['value'] == 'default' or item.get('isDefault', False)).model_dump() + for item in result['models'] if not item.get('disabled', False)] + except (TimeoutError, OSError, ValueError, KeyError, TypeError) as error: + raise ProjectError('读取 CLI 模型列表失败,请重试或检查 CLI 配置。', 503) from error + finally: + await terminate(process, group=True) diff --git a/packages/core/src/opendirector/agent/native.py b/packages/core/src/opendirector/agent/native.py index c86f1273..a59d6a69 100644 --- a/packages/core/src/opendirector/agent/native.py +++ b/packages/core/src/opendirector/agent/native.py @@ -69,7 +69,29 @@ def command(runtime: str, work: Path, socket: str, token: str, model: str | None if model: args += ['--model', f'opendirector/{model}' if bundled else model] return args, env - raise ProjectError('未知 Agent 引擎。') + if runtime == 'codex': + settings = [f'mcp_servers.opendirector.command={json.dumps(bridge["command"])}', + f'mcp_servers.opendirector.args={json.dumps(bridge["args"])}', 'mcp_servers.opendirector.required=true', + 'mcp_servers.opendirector.default_tools_approval_mode="approve"', + 'features.shell_tool=true', 'features.apply_patch_freeform=false', 'web_search="disabled"', + 'skills.bundled.enabled=false', f'developer_instructions={json.dumps(SYSTEM)}'] + settings += [f'mcp_servers.opendirector.env.{key}={json.dumps(value)}' for key, value in bridge['env'].items()] + args = [binary, 'exec', '--json', '--skip-git-repo-check', '--ignore-user-config', '--ignore-rules', '--sandbox', 'workspace-write'] + for setting in settings: + args += ['-c', setting] + else: + plugin = work / 'plugin' + (plugin / '.claude-plugin').mkdir(parents=True) + (plugin / '.claude-plugin/plugin.json').write_text(json.dumps({'name': 'opendirector', 'version': '0.1.0'})) + (plugin / 'skills').symlink_to(work / '.agents/skills', target_is_directory=True) + args = [binary, '-p', '--verbose', '--system-prompt', SYSTEM, + '--output-format', 'stream-json', '--plugin-dir', str(plugin), '--tools', 'Skill,Bash,Read', + '--strict-mcp-config', '--mcp-config', json.dumps({'mcpServers': {'opendirector': bridge}}), + '--allowedTools', 'mcp__opendirector__*', 'Skill(opendirector:*)', 'Bash', 'Read', + '--permission-mode', 'dontAsk', '--setting-sources', ''] + if model: + args += ['--model', model] + return args, env def text_events(runtime: str, raw: dict) -> list[dict]: diff --git a/packages/core/src/opendirector/agent/runtime.py b/packages/core/src/opendirector/agent/runtime.py index e58e46ea..2c54f225 100644 --- a/packages/core/src/opendirector/agent/runtime.py +++ b/packages/core/src/opendirector/agent/runtime.py @@ -25,6 +25,9 @@ class RuntimeSpec: RUNTIMES = { 'default': RuntimeSpec(id='default', label='Default', group='default', modelSource='settings', modelOverride=False, engine='default'), + 'opencode': RuntimeSpec(id='opencode', label='OpenCode CLI', group='harness', modelSource='cli', modelOverride=True, engine='opencode'), + 'codex': RuntimeSpec(id='codex', label='Codex', group='harness', modelSource='cli', modelOverride=True, engine='codex'), + 'claude': RuntimeSpec(id='claude', label='Claude Code', group='harness', modelSource='cli', modelOverride=True, engine='claude'), } @@ -86,6 +89,13 @@ def __init__(self, projects, generation, configs, provider=None): async def stream(self, run: RunContext) -> AsyncIterator[AgentEvent]: runtime_spec(run.runtime) + if run.runtime == 'codex' and not run.runtime_model: + # Codex launch isolates user config; carry only its resolved model into this turn. + from opendirector.agent.catalog import model_choices + choices = await model_choices('codex') + default = next((item['id'] for item in choices if item.get('isDefault')), None) + if default: + run = replace(run, runtime_model=default) gateway = ToolGateway(self.business, run) try: async for event in self.adapter.stream(run, gateway): diff --git a/src/canvas/AgentPicker.tsx b/src/canvas/AgentPicker.tsx new file mode 100644 index 00000000..d75f76b3 --- /dev/null +++ b/src/canvas/AgentPicker.tsx @@ -0,0 +1,628 @@ +import "./agent-picker.css"; +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type KeyboardEvent, +} from "react"; +import { + runtimeModels, + type RuntimeModel, + type AgentRuntime, + type Conversation, + type RuntimeInfo, +} from "../backend/chat"; +import { isGenerationModel, type ModelConfig } from "../backend/rules"; +import { CanvasIcon } from "./CanvasIcon"; + +type Harness = Exclude; + +export function AgentPicker({ + projectId, + value, + models, + runtimes, + disabled, + onChange, + onConfigure, + onRefresh, +}: { + projectId: string; + value: Conversation; + models: ModelConfig[]; + runtimes: RuntimeInfo[]; + disabled: boolean; + onChange: (patch: Partial) => void; + onConfigure: () => void; + onRefresh: () => Promise; +}) { + const harnesses = runtimes.filter( + (item): item is RuntimeInfo & { id: Harness } => + item.group === "harness" && item.id !== "default", + ); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [setup, setSetup] = useState(null); + const [checking, setChecking] = useState(false); + const [checkError, setCheckError] = useState(""); + const [catalogs, setCatalogs] = useState< + Partial> + >({}); + const [choices, setChoices] = useState([]); + const [loading, setLoading] = useState(false); + const [modelError, setModelError] = useState(""); + const submenu = useRef(null); + const root = useRef(null); + const trigger = useRef(null); + const menu = useRef(null); + const options = models.filter( + (item) => item.kind === "agent" && isGenerationModel(item), + ); + const agent = options.find((item) => item.id === value.modelSelections.agent); + function configuredModel(id: Harness) { + return ( + options.find((item) => item.id === value.harnessModels?.[id]) ?? + agent ?? + options[0] + ); + } + const ready = (item: RuntimeInfo) => + item.status === "ready" && + (item.modelSource !== "settings" || options.length > 0); + const setupSpec = harnesses.find((item) => item.id === setup); + const reason = (item: RuntimeInfo) => + item.status === "not_installed" + ? "尚未安装" + : item.status === "needs_login" + ? "尚未登录" + : item.status === "unknown" + ? "无法确认登录状态" + : "尚未配置模型"; + const setupCommand = + setup === "codex" + ? "codex login" + : setup === "claude" + ? "claude auth login" + : "opencode"; + const setupLink = + setup === "codex" + ? "https://developers.openai.com/codex/cli/" + : setup === "claude" + ? "https://code.claude.com/docs/en/setup" + : "https://opencode.ai/docs/"; + const editingSpec = harnesses.find((item) => item.id === editing); + const runtime = value.runtime ?? "default"; + const harness = harnesses.find((item) => item.id === runtime); + const title = !runtimes.some((item) => item.id === runtime) + ? "重新选择模型" + : runtime === "default" + ? (agent?.modelId ?? "选择模型") + : harness?.modelSource === "settings" + ? (configuredModel(harness.id)?.modelId ?? harness.label) + : harness + ? modelLabel(harness.id) + : "默认模型"; + + function selectedModel(id: Harness) { + return id in (value.harnessModels ?? {}) + ? value.harnessModels?.[id] + : id === runtime + ? value.runtimeModel + : null; + } + function modelLabel(id: Harness) { + const selected = selectedModel(id); + const rows = catalogs[id] ?? []; + return selected + ? (rows.find((item) => item.id === selected)?.label ?? selected) + : (rows.find((item) => item.isDefault)?.label ?? "CLI 默认模型"); + } + useEffect(() => { + let active = true; + for (const item of runtimes) { + if ( + item.id === "default" || + item.modelSource !== "cli" || + item.status !== "ready" + ) + continue; + const id = item.id; + void runtimeModels(projectId, id) + .then((rows) => { + if (active) setCatalogs((previous) => ({ ...previous, [id]: rows })); + }) + .catch(() => { + // Discovery is optional for selection; opening the editor exposes errors and retry. + }); + } + return () => { + active = false; + }; + }, [projectId, runtimes]); + function close() { + setOpen(false); + setEditing(null); + setSetup(null); + trigger.current?.focus(); + } + function chooseHarness(id: Harness, override?: string | null) { + const spec = harnesses.find((item) => item.id === id)!; + const fromSettings = spec.modelSource === "settings"; + const modelId = configuredModel(id)?.id; + if (fromSettings && !modelId) { + onConfigure(); + close(); + return; + } + const configured = fromSettings + ? (modelId ?? null) + : override === undefined + ? selectedModel(id) + : override; + onChange({ + runtime: id, + runtimeModel: fromSettings ? null : configured, + harnessModels: { ...value.harnessModels, [id]: configured }, + ...(fromSettings + ? { modelSelections: { ...value.modelSelections, agent: modelId } } + : {}), + }); + close(); + } + function edit(id: Harness) { + setSetup(null); + setEditing(id); + } + useEffect(() => { + if (!editing || editingSpec?.modelSource !== "cli") return; + let active = true; + setLoading(true); + setChoices([]); + setModelError(""); + runtimeModels(projectId, editing) + .then((items) => { + if (active) { + setChoices(items); + setCatalogs((previous) => ({ ...previous, [editing]: items })); + } + }) + .catch(() => { + if (active) setModelError("无法读取模型列表,请检查 CLI 配置后重试。"); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [editing, editingSpec?.modelSource, projectId, checking]); + useEffect(() => { + if (!open || disabled) return; + const outside = (event: Event) => { + if (!root.current?.contains(event.target as Node)) { + setOpen(false); + setEditing(null); + } + }; + document.addEventListener("pointerdown", outside); + document.addEventListener("focusin", outside); + return () => { + document.removeEventListener("pointerdown", outside); + document.removeEventListener("focusin", outside); + }; + }, [open, disabled]); + useEffect(() => { + if (!open) return; + if (editing) + submenu.current + ?.querySelector( + '[aria-checked="true"], button:not(:disabled)', + ) + ?.focus(); + else + ( + menu.current?.querySelector( + '[aria-checked="true"]', + ) ?? + menu.current?.querySelector("button:not(:disabled)") + )?.focus(); + }, [open, editing, setup]); + useLayoutEffect(() => { + if (!editing || !submenu.current || !menu.current) return; + const bounds = menu.current.getBoundingClientRect(); + const layer = submenu.current; + layer.style.left = `${Math.max(8, bounds.left - 310)}px`; + layer.style.bottom = `${window.innerHeight - bounds.bottom}px`; + layer.showPopover(); + const reposition = () => setEditing(null); + window.addEventListener("resize", reposition); + return () => { + window.removeEventListener("resize", reposition); + }; + }, [editing]); + function keys(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + if (setup) setSetup(null); + else if (editing) setEditing(null); + else close(); + return; + } + if (event.key === "ArrowLeft" && editing) { + event.preventDefault(); + setEditing(null); + return; + } + const scope = editing ? submenu.current : menu.current; + const rows = [ + ...(scope?.querySelectorAll("button:not(:disabled)") ?? + []), + ]; + const current = rows.indexOf(document.activeElement as HTMLButtonElement); + let next: number; + if (event.key === "ArrowDown") next = (current + 1) % rows.length; + else if (event.key === "ArrowUp") + next = (current - 1 + rows.length) % rows.length; + else if (event.key === "Home") next = 0; + else if (event.key === "End") next = rows.length - 1; + else return; + event.preventDefault(); + rows[next]?.focus(); + } + return ( +
+ + {open && !disabled && ( +
+ {setupSpec ? ( + <> + +

配置 {setupSpec.label}

+

{ready(setupSpec) ? "已就绪" : reason(setupSpec)}

+

在启动 OpenDirector 的机器上完成配置,再重新检查。

+ {setupSpec.modelSource === "settings" && setupSpec.available ? ( + + ) : ( + <> + {setupSpec.status === "not_installed" ? ( + + 查看安装指南 ↗ + + ) : ( + <> +

在终端执行:

+ {setupCommand} + + )} + + )} +
+ + {ready(setupSpec) && ( + + )} +
+ {checkError &&

{checkError}

} + + ) : ( + <> +

Harnesses

+ {harnesses.map((item) => ( +
+ + {ready(item) && + (item.modelSource === "settings" || item.modelOverride) && ( + + )} +
+ ))} +
+

+ Default · OpenCode bundled +

+ {options.map((item) => ( +
+ +
+ ))} + {!options.length &&

尚未配置对话模型

} + +
+ + )} + {editingSpec && ( +
+ +

Models

+ {editingSpec.modelSource === "settings" ? ( + options.map((item) => ( +
+ +
+ )) + ) : ( + <> +
+ +
+ {loading &&

正在读取模型…

} + {choices + .filter((item) => item.id !== "default") + .map((item) => ( +
+ +
+ ))} + {modelError && ( + <> +

{modelError}

+ + + )} + {!loading && !modelError && !choices.length && ( +

CLI 未提供可选模型

+ )} + + )} +
+

Effort

+ {(["auto", "low", "medium", "high"] as const) + .filter((effort) => { + const selected = choices.find( + (item) => + item.id === + (selectedModel(editingSpec.id) ?? + choices.find((item) => item.isDefault)?.id), + ); + return ( + effort === "auto" || + editingSpec.modelSource === "settings" || + editingSpec.id === "claude" || + !!selected?.efforts.includes(effort) + ); + }) + .map((effort) => ( +
+ +
+ ))} +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/canvas/AssistantPanel.tsx b/src/canvas/AssistantPanel.tsx index 18caabc8..926d702c 100644 --- a/src/canvas/AssistantPanel.tsx +++ b/src/canvas/AssistantPanel.tsx @@ -6,6 +6,7 @@ import { fingerprint } from "../backend/generation.ts"; import type { BackendModels } from "../backend/models"; import { loadReferenceCatalog } from "../backend/referenceCatalog.ts"; import "./assistant-panel.css"; +import { AgentPicker } from "./AgentPicker"; import { ChoicePicker, ModelPicker } from "./AssistantPickers"; import { CanvasIcon } from "./CanvasIcon"; import { @@ -602,7 +603,18 @@ export function AssistantPanel({ > @ + item.id === (value.runtime ?? "default"))?.label ?? "请选择 Agent"}` )}

diff --git a/src/canvas/AssistantPickers.tsx b/src/canvas/AssistantPickers.tsx index 01850dc3..21fed320 100644 --- a/src/canvas/AssistantPickers.tsx +++ b/src/canvas/AssistantPickers.tsx @@ -47,20 +47,24 @@ export function ModelPicker({ onChange, onConfigure, disabled, + includeAgent = true, }: { models: ModelConfig[]; selections: ModelSelections; onChange: (kind: AssistantKind, id: string) => void; onConfigure: () => void; disabled: boolean; + includeAgent?: boolean; }) { + const kinds = includeAgent ? MODEL_KINDS : MODEL_KINDS.filter(item => item.id !== "agent"); const [open, setOpen] = useState(false); - const [kind, setKind] = useState("agent"); + const [selection, setKind] = useState("agent"); + const kind = !includeAgent && selection === "agent" ? "image" : selection; const root = useRef(null); const trigger = useRef(null); const panel = useRef(null); const id = useId(); - const category = MODEL_KINDS.find((item) => item.id === kind)!; + const category = kinds.find((item) => item.id === kind)!; const options = modelOptions(models, kind); const agent = modelOptions(models, "agent").find( (item) => item.id === selections.agent, @@ -106,17 +110,17 @@ export function ModelPicker({ ); const next = event.key === "ArrowRight" - ? (current + 1) % MODEL_KINDS.length + ? (current + 1) % kinds.length : event.key === "ArrowLeft" - ? (current - 1 + MODEL_KINDS.length) % MODEL_KINDS.length + ? (current - 1 + kinds.length) % kinds.length : event.key === "Home" ? 0 : event.key === "End" - ? MODEL_KINDS.length - 1 + ? kinds.length - 1 : null; if (next !== null) { event.preventDefault(); - setKind(MODEL_KINDS[next].id); + setKind(kinds[next].id); buttons[next].focus(); } if (event.key === "ArrowDown") { @@ -159,8 +163,8 @@ export function ModelPicker({ type="button" className="chat-picker-trigger" disabled={disabled} - aria-label={`模型配置:${agent?.label ?? "未配置 Agent"}`} - title="选择 Agent、图片、视频和音频模型" + aria-label={`模型配置:${includeAgent ? agent?.label ?? "未配置 Agent" : "媒体模型"}`} + title={includeAgent ? "选择 Agent、图片、视频和音频模型" : "选择图片、视频和音频模型"} aria-haspopup="dialog" aria-expanded={open} aria-controls={open ? id : undefined} @@ -173,7 +177,7 @@ export function ModelPicker({ }} > - {agent?.label ?? "选择模型"} + {includeAgent ? agent?.label ?? "选择模型" : "媒体模型"} {open && ( @@ -210,7 +214,7 @@ export function ModelPicker({ aria-label="模型类型" onKeyDown={tabKeys} > - {MODEL_KINDS.map((tab) => ( + {kinds.map((tab) => (