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
23 changes: 22 additions & 1 deletion apps/api/src/opendirector_api/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from anyio import CancelScope
from fastapi import APIRouter, Header
from fastapi.responses import StreamingResponse
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel, ConfigDict, Field, JsonValue

from opendirector.agent.catalog import model_choices
Expand Down Expand Up @@ -153,6 +153,27 @@ def agent_routes(projects, generation, configs, provider=None) -> APIRouter:
router = APIRouter(prefix='/api/creation/projects/{project_id}/assistant')
conversations = Conversations(projects)
runtimes = AgentRuntimes(projects, generation, configs, provider)

@router.get('/traces/{turn_id}/input')
def trace_input(project_id: UUID, turn_id: UUID):
file = child(child(conversations.path(project_id), 'agent-runs'), 'conversation.json')
with projects.local.transaction():
conversation = read_json(file) if file.is_file() else {}
for item in [conversation, *conversation.get('archivedConversations', [])]:
for message in item.get('messages', []):
if message['role'] == 'user' and message['id'] == str(turn_id):
return {'text': message['text']}
raise ProjectError('该轮用户消息不存在。', 404)

@router.get('/traces/{turn_id}/{source}')
def trace(project_id: UUID, turn_id: UUID,
source: Literal['opencode', 'codex', 'claude', 'jiuwenswarm', 'deepseek', 'model']):
folder = child(conversations.path(project_id), 'agent-runs')
file = child(folder, f'{turn_id}.{source}.jsonl')
if not file.is_file():
raise ProjectError('轨迹文件不存在。', 404)
return FileResponse(file, media_type='text/plain', headers={'Cache-Control': 'no-store'})

active: dict[UUID, tuple[UUID, asyncio.Task]] = {}
admission: dict[UUID, asyncio.Lock] = {}

Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
| [验证指南](development/validation.md) | 如何复现检查,什么证据能够支持结论? | 工具、运行要求或验证入口改变时 |
| [macOS 安装验证](development/macos-install-validation.md) | Apple Silicon Mac 上实际验证了哪些安装与使用场景? | 重新执行对应安装验收时 |
| [中英双语界面](development/i18n.md) | 语言如何选择,词条如何组织,新增文案和检查怎么做? | 语言策略、词条结构或检查方式改变时 |
| [业务 CLI](guides/cli.md) | 如何通过终端或 Agent Shell 查询、编辑和执行生成任务? | 命令、授权或回执语义变化时 |
| [视频模型能力配置](guides/video-model-capabilities.md) | 如何声明服务实际支持的输出规格? | 配置字段或模型能力接入行为改变时 |

产品启动和常用操作见根目录 [README](../README.md)。根目录 README 仅面向使用者,维护安装、启动、配置、创作与问题处理,不包含架构实现、开发测试、研究来源或迭代历史。源码及自动生成契约说明实际实现;架构与路线描述目标,不能据此推断某项能力已经可用。
Expand Down
22 changes: 13 additions & 9 deletions docs/architecture/agent-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,19 @@ Default 与 OpenCode CLI 分别配置。外部 CLI 不读取 OpenDirector 保存
Runtime 适配层
↓
Harness 执行循环与原生工具
↓ MCP
↓ Shell 调用 opendirector
ToolGateway
↓
共用业务命令与应用服务
↓
工程回执与界面状态刷新
```

适配层为每轮创建 `agent-runs/<turn-id>/work/`,启动 harness 并连接本轮 MCP 工具入口。原生工具以此目录为工作目录,通过继承的 PATH 查找 FFmpeg、ffprobe 等程序。
适配层为每轮创建 `agent-runs/<turn-id>/work/`,启动 harness 并注入本轮 CLI 执行入口。子进程 cwd、PWD 和 ACP 工作区始终指向本轮执行目录;会话身份单独持久化。原生工具以此目录为工作目录,通过继承的 PATH 查找 FFmpeg、ffprobe 等程序。

业务工具分为工程与模型概览 `read_project`、对象/状态/素材查询 `query`、技能或文本素材读取 `read`、命令契约查询 `command_schema` 和命令执行 `execute_command`。Gateway 按调用身份保留任务和结果:相同身份、相同输入复用结果,不同输入拒绝执行。
业务入口使用 Typer CLI,通过 `opendirector --help` 和子命令的 `--help` 渐进发现操作。查询、编辑及生成命令调用共用业务服务。Agent 通过受令牌保护的本轮连接继承工程范围、编辑会话、执行目录和操作回执;CLI 子进程退出不终止已接受的后台任务。普通终端通过现有 HTTP API 连接服务,写入申请独立编辑会话并遵守工程锁,查询不申请租约。`--session` 可使用已有授权会话。`--operation-id` 提供稳定修改身份,同身份相同输入重放返回原回执,不同输入拒绝。

新文件通过 `artifact.publish` 登记,再根据需要调用 `canvas.show` 展示。发布回执、素材登记和画布展示分别表达,不以创建节点代替文件交付。
新文件通过 `opendirector asset import` 登记,再根据需要调用 `opendirector canvas add-material` 展示。发布回执、素材登记和画布展示分别表达,不以创建节点代替文件交付。

## Skill 加载与媒体输入

Expand All @@ -51,16 +51,20 @@ Default 将模型配置中的输入模态传入 bundled OpenCode。外部 harnes

同一工程同时只接受一个回复运行。运行事件转换为回复正文和工具活动,保存到对应会话;原生事件与业务工具事件追加到 `agent-runs/<turn-id>.<runtime>.jsonl`。Default 使用 `opencode` 作为轨迹文件的 runtime 名称。

Default 的模型网关在 `agent-runs/<turn-id>.model.jsonl` 记录实际转发的请求体,包括生效的系统消息、会话上下文、工具 Schema 与模型参数。请求 ID 关联上游 HTTP 状态、响应头等待耗时和传输错误。该文件仅供本地调试,创建权限为 `0600`,不记录 HTTP 鉴权头。回复片段、工具结果和 token 用量仍见原生轨迹。

停止或流连接断开时,适配层终止进程组,Gateway 关闭新调用入口并等待已接受操作结束;API 保存部分回复并释放运行占用。业务操作以各自回执确认,远端生成任务仍通过任务取消命令处理。

## 原生工具权限

| Harness | 执行约束 |
| --- | --- |
| OpenCode | 工具权限配置开放 Shell、文件读取、已发现技能和业务工具 |
| Codex | workspace-write 沙箱,开放 Shell 和业务工具 |
| Claude Code | 明确允许 Skill、Bash、Read 和业务工具 |
| JiuwenSwarm | 明确允许 skill_tool、list_skill、read_file、bash 和业务工具 |
| DeepSeek Harness | 沿用原生工具与权限;Skill 限于本轮快照,业务工具通过 MCP 接入,未承接的审批不自动批准 |
| OpenCode | 工具权限配置开放 Shell、文件读取、已发现技能和业务 CLI |
| Codex | workspace-write 沙箱,开放 Shell 和业务 CLI |
| Claude Code | 明确允许 Skill、Bash、Read 和业务 CLI |
| JiuwenSwarm | 明确允许 skill_tool、list_skill、read_file、bash 和业务 CLI |
| DeepSeek Harness | 沿用原生工具与权限;Skill 限于本轮快照,业务操作通过 Shell 调用 opendirector,未承接的审批不自动批准 |

工具允许列表与操作系统沙箱提供不同约束。业务工具独立校验工程范围和输入;原生文件访问权限不授予直接改写工程存储的业务权限。

CLI 通过环境变量中的本轮本地 Socket 地址连接业务网关,不使用连接令牌或凭据文件。网关继续校验工程范围、轮次生命周期及业务输入。
8 changes: 8 additions & 0 deletions docs/architecture/unified-project.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ UI、Agent 和后台完成写入共享可恢复变更通道。通知提示对象

交互设计以尽可能简化用户操作为原则。在保留创作能力和用户控制权的前提下,减少不必要的点击、重复输入、面板切换和人工等待,让用户完成当前操作后可以自然进入下一步。涉及创作取舍和授权的决定由用户掌握。

#### 参数发现与错误反馈

命令帮助从共用业务契约派生,所有命令及其联合类型使用一致的渐进展开规则。联合分支返回用途、选择条件、必填字段及可继续查询的字段路径,调用者可以据此选择分支并构造完整参数。素材引用、模型配置和业务对象的身份说明应给出其查询或回执来源。

参数校验失败的回执应包含具体原因、出错字段及对应命令帮助路径。联合类型错误同时返回允许的分支和分支帮助入口;嵌套对象错误指向可展开的字段或最近有效父级。CLI 回执同时给出可直接执行的子命令帮助入口及对应字段;结构化字段路径必须能在同一命令帮助中定位,不能要求调用者从内部校验路径猜测转换规则。错误反馈与命令帮助复用业务契约,适用于 UI、CLI 和 Agent 入口。

画布元素的位置由用户或 Agent 根据当前工程状态安排。创建与修改回执应提供对应提交版本的元素身份、位置、尺寸、父级和版本信息,并明确位置所在坐标系,使 Agent 可以观察结果、判断遮挡并继续调整布局。补充信息读取失败时,应分别表达业务提交状态和读取错误,避免将已经提交的修改描述为执行失败。

#### 选择与输入焦点

新建或选中节点时,键盘焦点留在画布,复制、粘贴和删除快捷键作用于画布。用户点击提示词输入框、通过键盘导航进入或主动展开生成面板时,输入框接收焦点。空输入框的光标位于正文起点,placeholder 不参与正文排版。
Expand Down
2 changes: 1 addition & 1 deletion docs/development/agent-collaboration.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Runtime 适配层 → 原生 harness Loop、工具与 Skill
| `packages/core/src/opendirector/agent/skills.py`、`packages/agent/skills/` | 技能发现与内置技能资源 |
| `packages/core/src/opendirector/agent/model_gateway.py` | Default 的模型请求代理 |
| `apps/api/src/opendirector_api/agent.py` | 会话、运行入口、停止及回复持久化 |
| `scripts/agent-mcp.py` | 原生 harness 与本轮业务工具的通信桥接 |
| `scripts/opendirector.py` | Typer CLI 源码入口,通过 HTTP 或本轮受限连接调用共用服务 |
| `src/canvas/AgentPicker.tsx`、`SkillPicker.tsx`、`ChatReferenceEditor.tsx` | 模型和技能选择、输入引用 |
| `src/canvas/AssistantPanel.tsx`、`AssistantReply.tsx`、`AssistantMedia.tsx` | 对话、工具详情与素材预览 |
| `src/canvas/useAssistantChat.ts`、`agentPreferences.ts`、`src/backend/chat.ts` | 运行事件、选择偏好和会话接口 |
Expand Down
47 changes: 47 additions & 0 deletions docs/guides/cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# OpenDirector CLI

安装核心包后使用 `opendirector`。源码开发可直接执行 `python3 scripts/opendirector.py`,该入口使用 checkout 的核心包及项目虚拟环境。

```bash
pip install -e packages/core
opendirector --help
opendirector serve --port 18000
opendirector dev --port 18000 --web-port 5173
opendirector canvas --help
opendirector canvas create-generation --help
```

普通终端默认连接 `http://127.0.0.1:18000`。使用 `--url` 或 `OPENDIRECTOR_API_URL` 指定服务,使用 `--project` 或 `OPENDIRECTOR_PROJECT_ID` 选择工程。全局选项放在子命令前。

```bash
opendirector project list
opendirector --project PROJECT_UUID project show
opendirector model list --kind video --search H3-Lite
opendirector model show MiniMax-H3-Lite
opendirector --project PROJECT_UUID asset import ./after.png --kind image
opendirector --project PROJECT_UUID canvas add-material --asset ASSET_UUID --x 0 --y 0
opendirector --project PROJECT_UUID video create --model MODEL_CONFIG_UUID --prompt "人物缓缓转头" --duration 5 --first-frame SOURCE_CANVAS_ITEM_UUID
```

`canvas add-material` 和 `video create` 通过必填的 `--x`、`--y` 明确创建位置。Agent 先读取画布对象的位置、尺寸及分组,再安排新元素布局。`video create` 创建可编辑的生成节点与参考关系,返回 `specId` 和 `itemId`。所有画布修改命令以及批量事务通过共用返回层提供 `canvasItems`,包含元素身份、版本、位置、尺寸、父级、坐标系及可见性。位置未变化的操作也返回目标元素的几何信息,供后续布局判断。几何信息读取失败时,回执保留已提交状态并返回 `geometryReadError`。模型可通过配置 UUID 或唯一匹配的完整模型名指定。重名配置返回候选 UUID 和端点,由调用者明确选择。模型的输入模式必须支持所选参数。`--reference-item` 支持重复指定参考节点。提交生成时使用创建回执中的对象版本,后台任务由服务端继续执行。

```bash
opendirector --project PROJECT_UUID generation submit --spec-id SPEC_UUID --expected-spec-revision 1 --origin-item-id ITEM_UUID --expected-version generation_spec:SPEC_UUID:1 --expected-version canvas_item:ITEM_UUID:1
opendirector --project PROJECT_UUID job show JOB_UUID
opendirector --project PROJECT_UUID job wait JOB_UUID --timeout 300
```

业务命令按对象分组,参数名使用短横线。复杂参数可使用 JSON 参数,或通过 `--file command.json` 读取含 `payload`、`expectedVersions` 和可选 `expectedPreview` 的对象。`--file -` 从标准输入读取。命令帮助包含从 Pydantic 业务契约派生的参数说明与示例。`--help-field PATH` 展开帮助中返回的结构化字段路径,例如 `opendirector canvas show --help-field payload.item@image.binding`。

CLI 输出 JSON。修改回执附带对应的 assetId、itemId、specId 及其列表,视频节点创建回执同时返回包含当前版本的下一步提交命令。参数解析错误直接显示该子命令的完整用法。成功退出码为 0,拒绝或执行失败为 1,参数解析错误及等待任务超时为 2。生成接受回执会在 `next.commands` 返回对应的 `job wait` 命令,带入服务地址、工程和任务身份。建议按回执等待;60 秒窗口超时后可以再次执行同一等待命令。`job wait` 超时结束等待,任务继续由后台执行。

修改命令可在子命令前传 `--operation-id UUID`。相同身份及输入重试复用原回执,输入不同则拒绝。文件导入重放也核对登记文件的内容指纹。需要跨进程恢复暂存上传时,预先通过 `--session UUID` 保持相同会话身份。结果未知时先查询回执,再决定后续操作。

普通终端写入申请独立编辑租约,完成后释放。工程已在其他窗口编辑时会返回占用错误。使用 `--session UUID` 表示沿用已有授权会话,CLI 完成后保留该会话。

Agent 的 Shell 通过 PATH 使用同一个 `opendirector`。宿主提供受令牌保护的本轮连接、工程范围、编辑会话和执行目录,文件登记限制在本轮执行目录。每次调用产生业务活动及回执,供聊天与轨迹页展示。


## 检查已有生成结果

`job show` / `job wait` 在任务成功后返回 `delivery`:已登记的 AssetRef、当前采用状态及展示该素材的画布项。生成成功不等于质量验收;Agent 应检查现有素材。复制到执行目录做媒体探测、抽帧和审看不需要重新发布,也不应为同一次交付新增画布项。编辑产生的新交付文件才需要登记;独立副本展示应有用户要求。
5 changes: 4 additions & 1 deletion packages/core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ build-backend = "setuptools.build_meta"
name = "opendirector-core"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["PyYAML>=6,<7", "filelock>=3.16,<4", "httpx>=0.27,<1", "anyio>=4,<5", "pydantic>=2,<3", "Pillow>=12,<14"]
dependencies = ["PyYAML>=6,<7", "filelock>=3.16,<4", "httpx>=0.27,<1", "anyio>=4,<5", "pydantic>=2,<3", "typer>=0.27.3,<0.28", "Pillow>=12,<14"]

[project.scripts]
opendirector = "opendirector.cli:main"

[tool.setuptools.packages.find]
where = ["src"]
Expand Down
80 changes: 80 additions & 0 deletions packages/core/src/opendirector/agent/cli_transport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Turn-scoped transport and executable environment for the business CLI."""
import asyncio
from contextlib import asynccontextmanager
import json
import os
from pathlib import Path
import shlex
import sys

from opendirector.agent.tools import ROOT
from opendirector.domain.projects import ProjectError


def environment(work, socket, bridge_env=None):
env = dict(os.environ)
env['PWD'] = str(work)
env.update(bridge_env or {'OPENDIRECTOR_AGENT_SOCKET': socket})
cli_bin = work / '.opendirector-bin'
cli_bin.mkdir(exist_ok=True)
if os.name == 'nt':
(cli_bin / 'opendirector.cmd').write_text(f'@"{sys.executable}" "{ROOT / "scripts/opendirector.py"}" %*\n')
else:
launcher = cli_bin / 'opendirector'
launcher.write_text('#!/bin/sh\nexec ' + shlex.join([sys.executable, str(ROOT / 'scripts/opendirector.py')]) + ' "$@"\n')
launcher.chmod(0o700)
env['PATH'] = str(cli_bin) + os.pathsep + env.get('PATH', '')
return env


async def start_server(gateway, queue, endpoint, pending):
async def connection(reader, writer):
task = asyncio.current_task()
pending.add(task)
try:
try:
request = json.loads(await asyncio.wait_for(reader.readline(), 15))
if gateway.closed:
raise ProjectError('Agent run is closed', 409)
if request.get('projectId') and request['projectId'] != str(gateway.run.project_id):
raise ProjectError('CLI project does not match the active agent project.', 403)
key, name, arguments = str(request['id']), request['name'], request.get('arguments', {})
display_name = request['cliCommand']
await queue.put({'type': 'tool', 'id': 'cli:' + key, 'name': display_name,
'status': 'running', 'arguments': arguments})
outcome = await gateway.call('cli:' + key, name, arguments)
response = {'value': outcome.value, 'error': outcome.error}
detail = json.dumps(outcome.value, ensure_ascii=False, indent=2)
await queue.put({**outcome.event(display_name), 'id': 'cli:' + key,
'arguments': arguments, 'result': detail[:65536],
'resultTruncated': len(detail) > 65536})
except (ProjectError, ValueError, KeyError, OSError, TimeoutError) as failure:
response = {'error': str(failure)}
writer.write(json.dumps(response, ensure_ascii=False).encode() + b'\n')
await writer.drain()
finally:
writer.close()
await writer.wait_closed()
pending.discard(task)

if hasattr(asyncio, 'start_unix_server'):
return await asyncio.start_unix_server(connection, endpoint, limit=4 * 1024 * 1024), None
server = await asyncio.start_server(connection, '127.0.0.1', 0, limit=4 * 1024 * 1024)
host, port = server.sockets[0].getsockname()[:2]
return server, {'OPENDIRECTOR_AGENT_HOST': str(host), 'OPENDIRECTOR_AGENT_PORT': str(port)}


@asynccontextmanager
async def turn_environment(work, gateway, queue, temporary):
endpoint = str(Path(temporary) / 'cli.sock')
calls = set()
server, bridge_env = await start_server(gateway, queue, endpoint, calls)
try:
env = environment(work, endpoint, bridge_env)
env['OPENDIRECTOR_PROJECT_ID'] = str(gateway.run.project_id)
yield env
finally:
await gateway.close()
server.close()
await server.wait_closed()
await asyncio.gather(*calls, return_exceptions=True)
Loading