Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 28 additions & 17 deletions apps/api/src/opendirector_api/agent.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Project-scoped assistant persistence and native runtime streaming."""
import asyncio
from contextlib import suppress
from contextlib import aclosing, suppress
import json
import logging
from pathlib import Path
Expand All @@ -14,7 +14,7 @@

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.runtime import AgentRuntimes, runtime_available, configured_runtimes, runtime_spec, runtime_catalog, runtime_readiness
from opendirector.agent.skills import skills
from opendirector.domain.projects import ProjectError
from opendirector.infrastructure.creation_projects import durable_json
Expand Down Expand Up @@ -180,9 +180,19 @@ async def draft(project_id: UUID, value: Draft, x_opendirector_session: UUID = H
return await asyncio.to_thread(conversations.update, project_id, str(x_opendirector_session), draft=value)

@router.get('/runtimes')
async def available_runtimes(project_id: UUID):
async def available_runtimes(project_id: UUID, probe: bool = True):
await asyncio.to_thread(conversations.path, project_id)
return await configured_runtimes()
if probe:
return await configured_runtimes()
return [{**item, 'status': 'not_installed' if not item['available'] else
'ready' if item['id'] in {'default', 'opencode'} else 'checking'}
for item in runtime_catalog()]

@router.get('/runtimes/{runtime}/status')
async def runtime_status(project_id: UUID, runtime: Runtime):
await asyncio.to_thread(conversations.path, project_id)
item = next(item for item in runtime_catalog() if item['id'] == runtime)
return {**item, 'status': await runtime_readiness(runtime)}

@router.get('/runtimes/{runtime}/models')
async def runtime_models(project_id: UUID, runtime: Literal['opencode', 'codex', 'claude', 'jiuwenswarm']):
Expand Down Expand Up @@ -256,19 +266,20 @@ async def produce() -> None:
session=session, directory=directory, config=config, messages=context,
selected_skills=value.skills, thinking=value.thinking,
runtime_model=value.runtimeModel, skills=available)
async for event in runtimes.stream(run):
if event['type'] == 'delta':
text += event['text']
if event['type'] == 'tool':
previous = next((item for item in activities if item['id'] == event['id']), None)
activity = {**{key: item for key, item in event.items() if key != 'type'},
'textOffset': previous['textOffset'] if previous else len(text)}
if previous is None:
activities.append(activity)
else:
activities[activities.index(previous)] = activity
event = {'type': 'tool', **activity}
await queue.put(event)
async with aclosing(runtimes.stream(run)) as stream:
async for event in stream:
if event['type'] == 'delta':
text += event['text']
if event['type'] == 'tool':
previous = next((item for item in activities if item['id'] == event['id']), None)
activity = {**{key: item for key, item in event.items() if key != 'type'},
'textOffset': previous['textOffset'] if previous else len(text)}
if previous is None:
activities.append(activity)
else:
activities[activities.index(previous)] = activity
event = {'type': 'tool', **activity}
await queue.put(event)
except asyncio.CancelledError:
status = 'stopped'
raise
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture/agent-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Harness 指承担模型调用、工具执行和上下文管理的 Agent 执行
| OpenCode CLI | PATH 中的 opencode | CLI 原生配置与模型目录 |
| Codex | PATH 中的 codex | 原生认证与模型目录;隔离启动前解析所选模型或默认模型 |
| Claude Code | PATH 中的 claude | 原生认证、环境变量与初始化模型目录 |
| JiuwenSwarm | PATH 中的 jiuwenswarm-process,通过 stdin/stdout JSONL 直接调用 | 本机 Jiuwen 模型目录、配置与环境变量 |

Default 与 OpenCode CLI 分别配置。外部 CLI 不读取 OpenDirector 保存的模型凭证。进程继承服务端环境,包括 PATH 和第三方 API 配置。

Expand Down Expand Up @@ -58,5 +59,6 @@ Default 将模型配置中的输入模态传入 bundled OpenCode。外部 harnes
| OpenCode | 工具权限配置开放 Shell、文件读取、已发现技能和业务工具 |
| Codex | workspace-write 沙箱,开放 Shell 和业务工具 |
| Claude Code | 明确允许 Skill、Bash、Read 和业务工具 |
| JiuwenSwarm | 明确允许 skill_tool、list_skill、read_file、bash 和业务工具 |

工具允许列表与操作系统沙箱提供不同约束。业务工具独立校验工程范围和输入;原生文件访问权限不授予直接改写工程存储的业务权限。
10 changes: 6 additions & 4 deletions packages/core/src/opendirector/agent/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import asyncio
import json
from collections.abc import AsyncIterator
from contextlib import aclosing
from dataclasses import asdict, dataclass, replace
from typing import Literal

Expand All @@ -27,10 +28,10 @@ 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'),
'opencode': RuntimeSpec(id='opencode', label='OpenCode', group='harness', modelSource='cli', modelOverride=True, engine='opencode'),
'jiuwenswarm': RuntimeSpec(id='jiuwenswarm', label='JiuwenSwarm', group='harness', modelSource='cli', modelOverride=True, engine='jiuwenswarm'),
'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'),
'jiuwenswarm': RuntimeSpec(id='jiuwenswarm', label='JiuwenSwarm', group='harness', modelSource='cli', modelOverride=True, engine='jiuwenswarm'),
}


Expand Down Expand Up @@ -107,7 +108,8 @@ async def stream(self, run: RunContext) -> AsyncIterator[AgentEvent]:
gateway = ToolGateway(self.business, run)
try:
adapter = self.jiuwenswarm if run.runtime == 'jiuwenswarm' else self.adapter
async for event in adapter.stream(run, gateway):
yield event
async with aclosing(adapter.stream(run, gateway)) as stream:
async for event in stream:
yield event
finally:
await gateway.close()
20 changes: 18 additions & 2 deletions src/backend/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface RuntimeInfo {
modelOverride: boolean;
available: boolean;
status:
| "checking"
| "ready"
| "not_installed"
| "needs_login"
Expand Down Expand Up @@ -124,8 +125,23 @@ export const activateConversation = (
(create ? "/conversations" : `/conversations/${id}/activate`),
create ? json("POST", { id }) : json("POST", {}),
);
export const chatRuntimes = (id: string) =>
request<RuntimeInfo[]>(path(id) + "/runtimes");
export const chatRuntimeStatus = (id: string, runtime: AgentRuntime) =>
request<RuntimeInfo>(path(id) + `/runtimes/${runtime}/status`);

export async function chatRuntimes(id: string, onUpdate?: (items: RuntimeInfo[]) => void) {
const rows = await request<RuntimeInfo[]>(path(id) + "/runtimes?probe=false");
onUpdate?.([...rows]);
await Promise.all(rows.map(async (item, index) => {
if (item.status !== "checking") return;
try {
rows[index] = await chatRuntimeStatus(id, item.id);
} catch {
rows[index] = {...item, status: "unknown"};
}
onUpdate?.([...rows]);
}));
return rows;
}
export const conversation = (id: string) => request<Conversation>(path(id));
export const saveChatDraft = (id: string, draft: ChatDraft) =>
request(path(id) + "/draft", json("PUT", draft));
Expand Down
45 changes: 37 additions & 8 deletions src/canvas/AgentPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function AgentPicker({
models,
runtimes,
disabled,
initializing = false,
onChange,
onConfigure,
onRefresh,
Expand All @@ -33,9 +34,10 @@ export function AgentPicker({
models: ModelConfig[];
runtimes: RuntimeInfo[];
disabled: boolean;
initializing?: boolean;
onChange: (patch: Partial<Conversation>) => void;
onConfigure: () => void;
onRefresh: () => Promise<void>;
onRefresh: (runtime?: AgentRuntime) => Promise<void>;
}) {
const harnesses = runtimes.filter(
(item): item is RuntimeInfo & { id: Harness } =>
Expand All @@ -46,6 +48,7 @@ export function AgentPicker({
const [setup, setSetup] = useState<Harness | null>(null);
const [checking, setChecking] = useState(false);
const [checkError, setCheckError] = useState("");
const [commandCopied, setCommandCopied] = useState(false);
const [catalogs, setCatalogs] = useState<
Partial<Record<Harness, RuntimeModel[]>>
>({});
Expand All @@ -72,7 +75,9 @@ export function AgentPicker({
(item.modelSource !== "settings" || options.length > 0);
const setupSpec = harnesses.find((item) => item.id === setup);
const reason = (item: RuntimeInfo) =>
item.status === "not_installed"
item.status === "checking"
? "检查中…"
: item.status === "not_installed"
? "尚未安装"
: item.status === "needs_login"
? "尚未登录"
Expand Down Expand Up @@ -110,8 +115,10 @@ export function AgentPicker({
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)
? "重新选择模型"
const title = initializing
? "正在加载引擎…"
: !runtimes.some((item) => item.id === runtime)
? "重新选择模型"
: runtime === "default"
? (agent?.modelId ?? "选择模型")
: harness?.modelSource === "settings"
Expand Down Expand Up @@ -140,7 +147,8 @@ export function AgentPicker({
if (
item.id === "default" ||
item.modelSource !== "cli" ||
item.status !== "ready"
item.status !== "ready" ||
!!catalogs[item.id]
)
continue;
const id = item.id;
Expand Down Expand Up @@ -317,6 +325,7 @@ export function AgentPicker({
{open && !disabled && (
<div
className="cw-agent-menu"
tabIndex={-1}
role="menu"
aria-label="选择 Agent"
ref={menu}
Expand Down Expand Up @@ -362,7 +371,26 @@ export function AgentPicker({
) : (
<>
<p>在终端执行:</p>
<code className="cw-agent-command">{setupCommand}</code>
<div className="cw-agent-command">
<code>{setupCommand}</code>
<button
type="button"
className="cw-agent-command-copy"
aria-label={commandCopied ? "已复制" : "复制命令"}
title={commandCopied ? "已复制" : "复制命令"}
onClick={async () => {
try {
await navigator.clipboard.writeText(setupCommand);
setCommandCopied(true);
} catch {
setCheckError("复制失败,请选中命令手动复制。");
}
}}
>
<CanvasIcon name={commandCopied ? "check" : "copy"} size={14} />
{commandCopied && <span role="status">已复制</span>}
</button>
</div>
</>
)}
</>
Expand All @@ -375,7 +403,7 @@ export function AgentPicker({
setChecking(true);
setCheckError("");
try {
await onRefresh();
await onRefresh(setupSpec.id);
} catch {
setCheckError("检查失败,请确认本地服务正在运行。");
} finally {
Expand Down Expand Up @@ -420,6 +448,7 @@ export function AgentPicker({
if (ready(item)) chooseHarness(item.id);
else {
setEditing(null);
setCommandCopied(false);
setSetup(item.id);
}
}}
Expand All @@ -439,7 +468,7 @@ export function AgentPicker({
aria-expanded={editing === item.id}
onClick={() => edit(item.id)}
>
编辑
Edit
</button>
)}
</div>
Expand Down
11 changes: 8 additions & 3 deletions src/canvas/AssistantPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@ export function AssistantPanel({
disabled={referenceDisabled || value.assets.length >= 64}
onClick={() => fileInput.current?.click()}
>
<CanvasIcon name={chat.adding ? "upload" : "plus"} size={20} />
<span className="cw-assistant-symbol cw-assistant-symbol-plus" aria-hidden="true">+</span>
</button>
<button
type="button"
Expand All @@ -621,7 +621,7 @@ export function AssistantPanel({
onMouseDown={(event) => event.preventDefault()}
onClick={() => editor.current?.openPicker()}
>
@
<span className="cw-assistant-symbol cw-assistant-symbol-at" aria-hidden="true">@</span>
</button>
<AgentPicker
projectId={documentId}
Expand All @@ -630,6 +630,7 @@ export function AssistantPanel({
runtimes={chat.runtimes}
onRefresh={chat.refreshRuntimes}
disabled={!chat.loaded || chat.sending}
initializing={!chat.loaded || !chat.runtimes.length}
onChange={chat.selectAgent}
onConfigure={onOpenSettings}
/>
Expand Down Expand Up @@ -692,7 +693,11 @@ export function AssistantPanel({
</div>
</form>
<p className="cw-assistant-footnote">
{chat.adding ? (
{!chat.loaded ? (
chat.error ? "对话加载失败" : "正在加载对话…"
) : chat.runtimes.find((item) => item.id === (value.runtime ?? "default"))?.status === "checking" ? (
"正在检查当前引擎…"
) : chat.adding ? (
"正在添加参考素材…"
) : (
`${chat.runtimes.find((item) => item.id === (value.runtime ?? "default"))?.label ?? "请选择 Agent"}`
Expand Down
19 changes: 14 additions & 5 deletions src/canvas/agent-picker.css
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
.cw-agent-picker .chat-picker-trigger { background: var(--chat-surface); }
.cw-agent-menu {
position: absolute; z-index: 90; left: 0; bottom: calc(100% + 10px);
width: min(330px, calc(100cqw - 96px)); max-height: min(480px, 65dvh); overflow: visible;
padding: 10px; border: 1px solid var(--chat-border); border-radius: 14px;
background: var(--chat-background); color: var(--chat-text); box-shadow: 0 10px 36px #0005;
}
.cw-agent-menu h3 { margin: 6px 8px 8px; font-size: 11px; font-weight: 500; color: var(--chat-muted); }
.cw-agent-row { display: flex; align-items: center; gap: 3px; }
.cw-agent-row { display: flex; align-items: center; gap: 3px; border-radius: 7px; }
.cw-assistant-panel .cw-agent-row > button[role] {
display: flex; align-items: center; flex: 1; min-width: 0; gap: 8px;
padding: 10px 8px; color: var(--chat-text); text-align: left; border-radius: 7px;
}
.cw-agent-row strong { font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cw-agent-row small { margin-left: auto; min-width: 0; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--chat-muted); font-size: 11px; }
.cw-agent-row svg { width: 14px; height: 14px; flex-shrink: 0; }
.cw-assistant-panel .cw-agent-edit { padding: 7px 5px; color: var(--chat-muted); font-size: 11px; border-radius: 6px; }
.cw-agent-row button:hover, .cw-agent-row button:focus-visible, .cw-agent-row [aria-checked="true"] { background: var(--chat-hover); }
.cw-assistant-panel .cw-agent-edit { padding: 10px 8px; color: var(--chat-muted); font-size: 11px; border-radius: 7px; opacity: 0; text-underline-offset: 2px; }
.cw-agent-row:has(> [aria-checked="true"]), .cw-agent-row:hover, .cw-agent-row:focus-within { background: var(--chat-hover); }
.cw-agent-row:has(> [aria-checked="true"]) > .cw-agent-edit,
.cw-agent-row:hover > .cw-agent-edit,
.cw-agent-row:focus-within > .cw-agent-edit,
.cw-agent-row > .cw-agent-edit[aria-expanded="true"] { opacity: 1; }
.cw-assistant-panel .cw-agent-edit:hover { color: var(--chat-text); text-decoration: underline; }
@media (hover: none) { .cw-assistant-panel .cw-agent-edit { opacity: 1; } }
.cw-agent-default { border-top: 1px solid var(--chat-border); margin-top: 8px; padding-top: 8px; }
.cw-agent-menu p { margin: 12px 8px; color: var(--chat-muted); font-size: 12px; }
.cw-assistant-panel :is(.cw-agent-configure, .cw-agent-back) { padding: 8px; color: var(--chat-muted); font-size: 11px; }
Expand All @@ -24,7 +29,11 @@
.cw-agent-edit-actions { display: flex; justify-content: space-between; padding: 8px; gap: 8px; }
.cw-assistant-panel .cw-agent-edit-actions button { padding: 7px 10px; border: 1px solid var(--chat-border); border-radius: 7px; color: var(--chat-text); font-size: 11px; }

.cw-agent-command { display: block; margin: 8px; padding: 10px; background: var(--chat-composer); border: 1px solid var(--chat-border); border-radius: 7px; font-size: 12px; user-select: text; }
.cw-agent-command { display: flex; align-items: center; gap: 8px; margin: 8px; padding: 10px; background: var(--chat-composer); border: 1px solid var(--chat-border); border-radius: 7px; font-size: 12px; user-select: text; }
.cw-agent-command code { flex: 1; min-width: 0; overflow-wrap: anywhere; padding: 0; background: transparent; color: inherit; font-size: inherit; }
.cw-agent-command ::selection { background: #80808060; color: var(--chat-text); }
.cw-assistant-panel .cw-agent-command-copy { display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; padding: 4px; border-radius: 4px; color: var(--chat-muted); font-size: 11px; }
.cw-agent-command-copy:hover, .cw-agent-command-copy:focus-visible { background: var(--chat-hover); color: var(--chat-text); }
.cw-agent-guide { display: inline-block; margin: 8px; color: var(--chat-text); font-size: 12px; text-decoration: underline; }

.cw-agent-submenu { position: fixed; inset: auto; margin: 0; z-index: 100; width: 280px; max-height: min(480px, 65dvh); overflow-y: auto; padding: 10px; border: 1px solid var(--chat-border); border-radius: 14px; background: var(--chat-background); color: var(--chat-text); box-shadow: 0 10px 36px #0005; }
Expand Down
Loading