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) => (