diff --git a/apps/api/src/opendirector_api/agent.py b/apps/api/src/opendirector_api/agent.py index 2ed65722..03aad070 100644 --- a/apps/api/src/opendirector_api/agent.py +++ b/apps/api/src/opendirector_api/agent.py @@ -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 @@ -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] = {} diff --git a/docs/README.md b/docs/README.md index 709991ce..1c7ae634 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 仅面向使用者,维护安装、启动、配置、创作与问题处理,不包含架构实现、开发测试、研究来源或迭代历史。源码及自动生成契约说明实际实现;架构与路线描述目标,不能据此推断某项能力已经可用。 diff --git a/docs/architecture/agent-runtime.md b/docs/architecture/agent-runtime.md index 1069b23a..9ff943b3 100644 --- a/docs/architecture/agent-runtime.md +++ b/docs/architecture/agent-runtime.md @@ -27,7 +27,7 @@ Default 与 OpenCode CLI 分别配置。外部 CLI 不读取 OpenDirector 保存 Runtime 适配层 ↓ Harness 执行循环与原生工具 - ↓ MCP + ↓ Shell 调用 opendirector ToolGateway ↓ 共用业务命令与应用服务 @@ -35,11 +35,11 @@ Default 与 OpenCode CLI 分别配置。外部 CLI 不读取 OpenDirector 保存 工程回执与界面状态刷新 ``` -适配层为每轮创建 `agent-runs//work/`,启动 harness 并连接本轮 MCP 工具入口。原生工具以此目录为工作目录,通过继承的 PATH 查找 FFmpeg、ffprobe 等程序。 +适配层为每轮创建 `agent-runs//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 加载与媒体输入 @@ -51,16 +51,20 @@ Default 将模型配置中的输入模态传入 bundled OpenCode。外部 harnes 同一工程同时只接受一个回复运行。运行事件转换为回复正文和工具活动,保存到对应会话;原生事件与业务工具事件追加到 `agent-runs/..jsonl`。Default 使用 `opencode` 作为轨迹文件的 runtime 名称。 +Default 的模型网关在 `agent-runs/.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 地址连接业务网关,不使用连接令牌或凭据文件。网关继续校验工程范围、轮次生命周期及业务输入。 diff --git a/docs/architecture/unified-project.md b/docs/architecture/unified-project.md index 9aeaa828..7aedc3a6 100644 --- a/docs/architecture/unified-project.md +++ b/docs/architecture/unified-project.md @@ -104,6 +104,14 @@ UI、Agent 和后台完成写入共享可恢复变更通道。通知提示对象 交互设计以尽可能简化用户操作为原则。在保留创作能力和用户控制权的前提下,减少不必要的点击、重复输入、面板切换和人工等待,让用户完成当前操作后可以自然进入下一步。涉及创作取舍和授权的决定由用户掌握。 +#### 参数发现与错误反馈 + +命令帮助从共用业务契约派生,所有命令及其联合类型使用一致的渐进展开规则。联合分支返回用途、选择条件、必填字段及可继续查询的字段路径,调用者可以据此选择分支并构造完整参数。素材引用、模型配置和业务对象的身份说明应给出其查询或回执来源。 + +参数校验失败的回执应包含具体原因、出错字段及对应命令帮助路径。联合类型错误同时返回允许的分支和分支帮助入口;嵌套对象错误指向可展开的字段或最近有效父级。CLI 回执同时给出可直接执行的子命令帮助入口及对应字段;结构化字段路径必须能在同一命令帮助中定位,不能要求调用者从内部校验路径猜测转换规则。错误反馈与命令帮助复用业务契约,适用于 UI、CLI 和 Agent 入口。 + +画布元素的位置由用户或 Agent 根据当前工程状态安排。创建与修改回执应提供对应提交版本的元素身份、位置、尺寸、父级和版本信息,并明确位置所在坐标系,使 Agent 可以观察结果、判断遮挡并继续调整布局。补充信息读取失败时,应分别表达业务提交状态和读取错误,避免将已经提交的修改描述为执行失败。 + #### 选择与输入焦点 新建或选中节点时,键盘焦点留在画布,复制、粘贴和删除快捷键作用于画布。用户点击提示词输入框、通过键盘导航进入或主动展开生成面板时,输入框接收焦点。空输入框的光标位于正文起点,placeholder 不参与正文排版。 diff --git a/docs/development/agent-collaboration.md b/docs/development/agent-collaboration.md index 9afda2a6..47d1e39b 100644 --- a/docs/development/agent-collaboration.md +++ b/docs/development/agent-collaboration.md @@ -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` | 运行事件、选择偏好和会话接口 | diff --git a/docs/guides/cli.md b/docs/guides/cli.md new file mode 100644 index 00000000..fd7b768b --- /dev/null +++ b/docs/guides/cli.md @@ -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 应检查现有素材。复制到执行目录做媒体探测、抽帧和审看不需要重新发布,也不应为同一次交付新增画布项。编辑产生的新交付文件才需要登记;独立副本展示应有用户要求。 diff --git a/packages/core/pyproject.toml b/packages/core/pyproject.toml index 4317e5ed..1a73777c 100644 --- a/packages/core/pyproject.toml +++ b/packages/core/pyproject.toml @@ -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"] diff --git a/packages/core/src/opendirector/agent/cli_transport.py b/packages/core/src/opendirector/agent/cli_transport.py new file mode 100644 index 00000000..6e44b09e --- /dev/null +++ b/packages/core/src/opendirector/agent/cli_transport.py @@ -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) diff --git a/packages/core/src/opendirector/agent/deepseek.py b/packages/core/src/opendirector/agent/deepseek.py index 83010733..d7b6c688 100644 --- a/packages/core/src/opendirector/agent/deepseek.py +++ b/packages/core/src/opendirector/agent/deepseek.py @@ -106,11 +106,7 @@ def arguments(private, skill_root=None): def command(work, socket, token, bridge_env=None): args = arguments(Path(socket).parent, work / '.agents/skills') - server = {'name': 'opendirector', 'command': sys.executable, - 'args': [str(ROOT / 'scripts/agent-mcp.py')], - 'env': [{'name': key, 'value': value} for key, value in - {**(bridge_env or {'OPENDIRECTOR_AGENT_SOCKET': socket}), 'OPENDIRECTOR_AGENT_TOKEN': token}.items()]} - return args, dict(os.environ), server + return args, dict(os.environ), None async def model_choices(selected=None): @@ -147,7 +143,7 @@ async def run(process, work, blocks, model, thinking, server, observe, session_i client = Client(process, observe) try: async with asyncio.timeout(30): - await client.initialize(work, [server], session_id) + await client.initialize(work, [], session_id) if model: await client.request('session/set_config_option', {'sessionId': client.session, 'configId': 'model', 'value': model}) if thinking != 'auto': @@ -191,5 +187,4 @@ def events(record, calls): 'completed': 'complete', 'failed': 'failed'}[update.get('status', 'in_progress')], 'result': detail[:65536], 'resultTruncated': len(detail) > 65536} calls[identity] = event - # Shared business activity is reported from authoritative gateway receipts. - return [] if any(name in {f'opendirector.{tool}', f'mcp__opendirector__{tool}'} for tool in ('read_project', 'query', 'read', 'command_schema', 'execute_command')) else [event] + return [event] diff --git a/packages/core/src/opendirector/agent/jiuwenswarm.py b/packages/core/src/opendirector/agent/jiuwenswarm.py index 25937a03..e77a7152 100644 --- a/packages/core/src/opendirector/agent/jiuwenswarm.py +++ b/packages/core/src/opendirector/agent/jiuwenswarm.py @@ -11,7 +11,8 @@ from opendirector.agent.context import RunContext from opendirector.agent.gateway import ToolGateway from opendirector.agent.skill_snapshot import prepare_skills -from opendirector.agent.tools import SYSTEM, tools +from opendirector.agent.tools import SYSTEM +from opendirector.agent import cli_transport from opendirector.agent.process import terminate from opendirector.domain.projects import ProjectError @@ -123,7 +124,7 @@ async def query(operation: str) -> dict: raise ProjectError('JiuwenSwarm 查询失败,请检查本机配置。', 503) if operation == 'protocol.capabilities': data = result['data'] - if data['schema_version'] != '0.1' or not all(data['features'].get(name) for name in ('host_tools', 'cancel', 'run_skills', 'custom_agent')): + if data['schema_version'] != '0.1' or not all(data['features'].get(name) for name in ('cancel', 'run_skills', 'custom_agent')): raise ProjectError('JiuwenSwarm CLI 协议不支持所需能力,请更新 CLI。', 503) return result['data'] @@ -144,126 +145,117 @@ async def stream(self, run: RunContext, gateway: ToolGateway): selection = {'model': run.runtime_model, 'thinking': run.thinking} session_id = run.native_session_id(selection) with TemporaryDirectory(prefix='od-jiuwen-') as temporary: - state = run.native_session_path() or Path(temporary) - config = Path(temporary) / 'config' - config.mkdir() - # Keep credentials out of published execution files and native traces. - home = Path(os.environ.get('JIUWENSWARM_HOME', Path.home())) - source = Path(os.environ.get('JIUWENSWARM_DATA_DIR', home / '.jiuwenswarm')) / 'config' - for name in ('config.yaml', '.env'): - if (source / name).is_file(): - shutil.copyfile(source / name, config / name) - (config / name).chmod(0o600) - if not (config / 'config.yaml').is_file(): - raise ProjectError('请先运行 jiuwenswarm-init 并配置模型。', 503) - skill_root = Path(temporary) / 'skills' - skill_root.mkdir() - for root in gateway.roots: - shutil.copytree(root, skill_root / root.name) - skill_link = state / 'agent/workspace/skills' - skill_link.parent.mkdir(parents=True, exist_ok=True) - if skill_link.is_symlink(): - skill_link.unlink() - skill_link.symlink_to(skill_root, target_is_directory=True) - gateway.roots += list(skill_root.iterdir()) - functions = [item['function'] for item in tools()] - host_tools = [{'name': item['name'], 'description': item['description'], - 'input_schema': item['parameters']} for item in functions] - request = {'schema_version': '0.1', 'type': 'run', 'request_id': str(run.turn_id), - 'input': (f'Execution directory: {work}\n' - 'Use inherited PATH to discover installed programs with command -v. ' - 'Read source assets at their supplied absolute paths; do not move or modify them. ' - 'Write intermediate files and outputs into the execution directory. ' - 'Use OpenDirector business tools for project changes.\n' - + json.dumps(run.messages[-1:] if session_id else run.messages, ensure_ascii=False)), - 'agent': {'name': 'opendirector', 'instructions': SYSTEM, - 'tools': [item['name'] for item in functions] + ['skill_tool', 'list_skill', 'read_file', 'bash']}, - 'skills': run.selected_skills, 'mcp': [], 'host_tools': host_tools, - 'workspace': {'cwd': str(work), 'project_dir': str(work)}} - if session_id: - request['session_id'] = session_id - if run.runtime_model: - request['model'] = run.runtime_model - trace_path = work.parent / f'{run.turn_id}.jiuwenswarm.jsonl' - with trace_path.open('a', encoding='utf-8') as trace: - async def emit(event): - if not stopping: - await queue.put(event) + async with cli_transport.turn_environment(work, gateway, queue, temporary) as cli_env: + state = run.native_session_path() or Path(temporary) + config = Path(temporary) / 'config' + config.mkdir() + # Native model execution resolves DATA_DIR/config, unlike the + # catalog which also honors CONFIG_DIR. Use one temporary root + # with persistent agent state so both read the same config. + runtime_root = Path(temporary) + (state / 'agent').mkdir(parents=True, exist_ok=True) + if state != runtime_root: + (runtime_root / 'agent').symlink_to(state / 'agent', target_is_directory=True) + # Keep credentials out of published execution files and native traces. + home = Path(os.environ.get('JIUWENSWARM_HOME', Path.home())) + source = Path(os.environ.get('JIUWENSWARM_DATA_DIR', home / '.jiuwenswarm')) / 'config' + for name in ('config.yaml', '.env'): + if (source / name).is_file(): + shutil.copyfile(source / name, config / name) + (config / name).chmod(0o600) + if not (config / 'config.yaml').is_file(): + raise ProjectError('请先运行 jiuwenswarm-init 并配置模型。', 503) + skill_root = Path(temporary) / 'skills' + skill_root.mkdir() + for root in gateway.roots: + shutil.copytree(root, skill_root / root.name) + skill_link = state / 'agent/workspace/skills' + skill_link.parent.mkdir(parents=True, exist_ok=True) + if skill_link.is_symlink(): + skill_link.unlink() + skill_link.symlink_to(skill_root, target_is_directory=True) + gateway.roots += list(skill_root.iterdir()) + request = {'schema_version': '0.1', 'type': 'run', 'request_id': str(run.turn_id), + 'input': (f'Execution directory: {work}\n' + 'Use inherited PATH to discover installed programs with command -v. ' + 'Read source assets at their supplied absolute paths; do not move or modify them. ' + 'Write intermediate files and outputs into the execution directory. ' + 'Use opendirector --help to discover project commands and use opendirector for project changes.\n' + + json.dumps(run.messages[-1:] if session_id else run.messages, ensure_ascii=False)), + 'agent': {'name': 'opendirector', 'instructions': SYSTEM, + 'tools': ['skill_tool', 'list_skill', 'read_file', 'bash']}, + 'skills': run.selected_skills, 'mcp': [], 'host_tools': [], + 'workspace': {'cwd': str(work), 'project_dir': str(work)}} + if session_id: + request['session_id'] = session_id + if run.runtime_model: + request['model'] = run.runtime_model + trace_path = work.parent / f'{run.turn_id}.jiuwenswarm.jsonl' + with trace_path.open('a', encoding='utf-8') as trace: + async def emit(event): + if not stopping: + await queue.put(event) - async def observe(record): - nonlocal emitted_chars - trace.write(json.dumps({'native': record}, ensure_ascii=False) + '\n') - trace.flush() - if record['event_type'] == 'chat.delta': - text = record['payload']['text'] - emitted_chars += len(text) - if text: - await emit({'type': 'delta', 'text': text}) - elif record['event_type'] in {'chat.tool_call', 'chat.tool_result'}: - tool = record['payload']['tool'] - if tool['name'] not in gateway.names: - detail = json.dumps(tool['result'], ensure_ascii=False) - await emit({'type': 'tool', 'id': 'native:' + tool['call_id'], 'name': tool['name'], - 'status': {'started': 'running', 'completed': 'complete', 'failed': 'failed'}[tool['status']], - 'arguments': tool['arguments'], 'result': detail[:65536], - 'resultTruncated': len(detail) > 65536}) + async def observe(record): + nonlocal emitted_chars + trace.write(json.dumps({'native': record}, ensure_ascii=False) + '\n') + trace.flush() + if record['event_type'] == 'chat.delta': + text = record['payload']['text'] + emitted_chars += len(text) + if text: + await emit({'type': 'delta', 'text': text}) + elif record['event_type'] in {'chat.tool_call', 'chat.tool_result'}: + tool = record['payload']['tool'] + if tool['name'] not in gateway.names: + detail = json.dumps(tool['result'], ensure_ascii=False) + await emit({'type': 'tool', 'id': 'native:' + tool['call_id'], 'name': tool['name'], + 'status': {'started': 'running', 'completed': 'complete', 'failed': 'failed'}[tool['status']], + 'arguments': tool['arguments'], 'result': detail[:65536], + 'resultTruncated': len(detail) > 65536}) - async def call(record): - payload = record['payload'] - name, arguments = payload['name'], payload['arguments'] - call_id = payload['call_id'] - await emit({'type': 'tool', 'id': call_id, 'name': name, 'status': 'running', 'arguments': arguments}) - outcome = await gateway.call(call_id, name, arguments) - detail = json.dumps(outcome.value, ensure_ascii=False, indent=2) - event = {**outcome.event(name), 'id': call_id, 'arguments': arguments, - 'result': detail[:65536], 'resultTruncated': len(detail) > 65536} - trace.write(json.dumps(event, ensure_ascii=False) + '\n') - trace.flush() - await emit(event) - return outcome.value - - task = asyncio.create_task(execute(request, cwd=str(work), env={'JIUWENSWARM_DATA_DIR': str(state), - 'JIUWENSWARM_CONFIG_DIR': str(config)}, on_event=observe, on_tool_call=call, cancel=cancel)) - pending = None - try: - while True: - pending = asyncio.create_task(queue.get()) - done, _ = await asyncio.wait((task, pending), return_when=asyncio.FIRST_COMPLETED) - if pending in done: - yield pending.result() - if task in done: - break - while not queue.empty(): - yield queue.get_nowait() - result = task.result() - trace.write(json.dumps({'native': result}, ensure_ascii=False) + '\n') - terminal_written = True - if result['status'] != 'completed': - reason = (result.get('error') or {}).get('message') - if reason: - raise ProjectError(f"JiuwenSwarm 执行未完成({result['status']}):{reason}", 502) - raise ProjectError(f"JiuwenSwarm 执行未完成({result['status']}),请检查原生轨迹。", 502) - if session_id and session_id != result['session_id']: - raise ProjectError('JiuwenSwarm 返回了不同的会话 ID。', 502) - run.save_native_session(result['session_id'], selection) - # The terminal output includes the deltas and any unflushed final tail. - remaining = (result.get('output') or '')[emitted_chars:] - if remaining: - yield {'type': 'delta', 'text': remaining} - except (ValueError, KeyError, TypeError, TimeoutError, OSError) as error: - raise ProjectError('JiuwenSwarm 执行失败或需要尚未支持的用户交互,请检查原生轨迹。', 502) from error - finally: - stopping = True - if pending is not None: - pending.cancel() - await asyncio.gather(pending, return_exceptions=True) - cancel.set() - if not task.done(): - try: - await asyncio.wait_for(asyncio.shield(task), 10) - except (TimeoutError, ValueError, KeyError, TypeError, OSError): - task.cancel() - with suppress(asyncio.CancelledError, ValueError, KeyError, TypeError, OSError): - result = await task - if not terminal_written: - trace.write(json.dumps({'native': result}, ensure_ascii=False) + '\n') + task = asyncio.create_task(execute(request, cwd=str(work), env={**cli_env, 'JIUWENSWARM_DATA_DIR': str(runtime_root), + 'JIUWENSWARM_CONFIG_DIR': str(config)}, on_event=observe, cancel=cancel)) + pending = None + try: + while True: + pending = asyncio.create_task(queue.get()) + done, _ = await asyncio.wait((task, pending), return_when=asyncio.FIRST_COMPLETED) + if pending in done: + yield pending.result() + if task in done: + break + while not queue.empty(): + yield queue.get_nowait() + result = task.result() + trace.write(json.dumps({'native': result}, ensure_ascii=False) + '\n') + terminal_written = True + if result['status'] != 'completed': + reason = (result.get('error') or {}).get('message') + if reason: + raise ProjectError(f"JiuwenSwarm 执行未完成({result['status']}):{reason}", 502) + raise ProjectError(f"JiuwenSwarm 执行未完成({result['status']}),请检查原生轨迹。", 502) + if session_id and session_id != result['session_id']: + raise ProjectError('JiuwenSwarm 返回了不同的会话 ID。', 502) + run.save_native_session(result['session_id'], selection) + # The terminal output includes the deltas and any unflushed final tail. + remaining = (result.get('output') or '')[emitted_chars:] + if remaining: + yield {'type': 'delta', 'text': remaining} + except (ValueError, KeyError, TypeError, TimeoutError, OSError) as error: + raise ProjectError('JiuwenSwarm 执行失败或需要尚未支持的用户交互,请检查原生轨迹。', 502) from error + finally: + stopping = True + if pending is not None: + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + cancel.set() + if not task.done(): + try: + await asyncio.wait_for(asyncio.shield(task), 10) + except (TimeoutError, ValueError, KeyError, TypeError, OSError): + task.cancel() + with suppress(asyncio.CancelledError, ValueError, KeyError, TypeError, OSError): + result = await task + if not terminal_written: + trace.write(json.dumps({'native': result}, ensure_ascii=False) + '\n') diff --git a/packages/core/src/opendirector/agent/model_gateway.py b/packages/core/src/opendirector/agent/model_gateway.py index 0e9f7115..8d5b8f7e 100644 --- a/packages/core/src/opendirector/agent/model_gateway.py +++ b/packages/core/src/opendirector/agent/model_gateway.py @@ -1,7 +1,11 @@ import asyncio +import codecs import json import logging +import os import secrets +from pathlib import Path +from time import monotonic, time import httpx @@ -20,12 +24,43 @@ class ModelGateway: """Loopback-only OpenAI transport for the bundled runtime's configured model.""" def __init__(self, config: dict, thinking: str, token: str, project_id: str, - turn_id: str, transport: httpx.AsyncBaseTransport | None = None): + turn_id: str, transport: httpx.AsyncBaseTransport | None = None, + *, trace_path: Path | None = None): self.config, self.thinking, self.token = config, thinking, token self.project_id, self.turn_id, self.transport = project_id, turn_id, transport + self.trace_path = trace_path self.active: set[asyncio.Task] = set() self.closed = False + def trace(self, event: str, request_id: str, **details: object) -> None: + if self.trace_path is None: + return + record = { + "event": event, + "turnId": self.turn_id, + "requestId": request_id, + "timestamp": time(), + **details, + } + try: + fd = os.open(self.trace_path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600) + with os.fdopen(fd, "a", encoding="utf-8") as output: + output.write(json.dumps(record, ensure_ascii=False) + "\n") + except OSError: + logger.exception("Cannot write model trace for turn %s", self.turn_id) + + def trace_reasoning(self, request_id: str, data: str) -> None: + try: + event = json.loads(data) + except json.JSONDecodeError: + return + if not isinstance(event, dict): + return + for choice in event.get("choices", []): + text = choice.get("delta", {}).get("reasoning_content") + if isinstance(text, str) and text: + self.trace("model.reasoning", request_id, choiceIndex=choice.get("index", 0), text=text) + def connect( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: @@ -37,6 +72,8 @@ async def request( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: status_sent = False + request_id = None + started = monotonic() context = MODEL_JOB_CONTEXT.set((self.project_id, self.turn_id)) try: async with asyncio.timeout(180): @@ -73,6 +110,8 @@ async def request( ) payload["model"] = self.config["modelId"] url = self.config["url"].rstrip("/") + "/chat/completions" + request_id = secrets.token_hex(8) + self.trace("model.request", request_id, payload=payload) async with httpx.AsyncClient( transport=self.transport, event_hooks=MODEL_HTTP_HOOKS, @@ -85,6 +124,11 @@ async def request( headers=auth_headers(self.config), json=payload, ) as response: + self.trace( + "model.response", request_id, + status=response.status_code, + elapsedMs=round((monotonic() - started) * 1000), + ) content_type = response.headers.get( "content-type", "application/json" ) @@ -93,9 +137,25 @@ async def request( ) status_sent = True await writer.drain() + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + buffer = "" + event_lines = [] async for chunk in response.aiter_bytes(): writer.write(chunk) await writer.drain() + if "text/event-stream" not in content_type: + continue + buffer += decoder.decode(chunk) + while "\n" in buffer: + line, _, buffer = buffer.partition("\n") + line = line.rstrip("\r") + if line.startswith("data:"): + event_lines.append(line[5:].lstrip(" ")) + elif not line and event_lines: + data = "\n".join(event_lines) + event_lines.clear() + if data != "[DONE]": + self.trace_reasoning(request_id, data) except ( httpx.HTTPError, OSError, @@ -105,6 +165,8 @@ async def request( asyncio.LimitOverrunError, TimeoutError, ) as failure: + if request_id is not None: + self.trace("model.error", request_id, error=type(failure).__name__) logger.error( f"agent.model.error turn_id={self.turn_id} failure={type(failure).__name__}" ) diff --git a/packages/core/src/opendirector/agent/native.py b/packages/core/src/opendirector/agent/native.py index 1afd7d25..96f93d2b 100644 --- a/packages/core/src/opendirector/agent/native.py +++ b/packages/core/src/opendirector/agent/native.py @@ -1,6 +1,5 @@ -"""CLI runtimes use native skills and one turn-scoped MCP business gateway.""" +"""CLI runtimes use native skills and a turn-scoped business CLI.""" import asyncio -import base64 import json import os from pathlib import Path @@ -12,11 +11,11 @@ from urllib.request import getproxies from opendirector.agent.model_gateway import ModelGateway -from opendirector.agent import deepseek +from opendirector.agent import cli_transport, deepseek from opendirector.agent.context import RunContext from opendirector.agent.gateway import ToolGateway from opendirector.agent.process import terminate -from opendirector.agent.tools import ROOT, SYSTEM, tools +from opendirector.agent.tools import ROOT, SYSTEM from opendirector.agent.skill_snapshot import prepare_skills from opendirector.domain.projects import ProjectError from opendirector.integrations.chat_completions import ChatCompletions @@ -79,35 +78,33 @@ def command(runtime: str, work: Path, socket: str, token: str, model: str | None bundled = runtime == 'default' if bundled: runtime = 'opencode' - # Windows CPython has no AF_UNIX support, so the caller may pass a loopback address instead. - bridge = {'command': sys.executable, 'args': [str(ROOT / 'scripts/agent-mcp.py')], - 'env': {**(bridge_env or {'OPENDIRECTOR_AGENT_SOCKET': socket}), 'OPENDIRECTOR_AGENT_TOKEN': token}} - env = codex_environment() if runtime == 'codex' else dict(os.environ) + env = cli_transport.environment(work, socket, bridge_env) + if runtime == 'codex' and (proxy := codex_environment().get('HTTPS_PROXY')): + env['HTTPS_PROXY'] = proxy if runtime == 'opencode': config = {'autoupdate': False, 'default_agent': 'opendirector', 'agent': {'opendirector': {'mode': 'primary', 'prompt': SYSTEM}}, 'skills': {'paths': [str(work / '.agents/skills')]}, - 'permission': {'*': 'deny', 'opendirector_*': 'allow', 'bash': 'allow', 'read': 'allow', + 'permission': {'*': 'deny', 'bash': 'allow', 'read': 'allow', 'external_directory': 'allow', 'skill': {'*': 'deny', **{item['name']: 'allow' for item in catalog}}}, - 'mcp': {'opendirector': {'type': 'local', 'command': [bridge['command'], *bridge['args']], 'environment': bridge['env']}}} + 'mcp': {}} if bundled: config['provider'] = {'opendirector': {'npm': '@ai-sdk/openai-compatible', 'name': 'OpenDirector', 'options': {'baseURL': model_url, 'apiKey': '{env:OPENDIRECTOR_MODEL_KEY}'}, 'models': {model: {'name': model, 'modalities': {'input': input_modalities or ['text'], 'output': ['text']}}}}} env.update(XDG_CONFIG_HOME=str(work), OPENDIRECTOR_MODEL_KEY=token) env.update(OPENCODE_DISABLE_EXTERNAL_SKILLS='1', OPENCODE_DISABLE_CLAUDE_CODE_SKILLS='1', OPENCODE_CONFIG_CONTENT=json.dumps(config)) - args = [binary, 'run', '--pure', '--format', 'json', '--agent', 'opendirector'] + # OpenCode uses inherited PWD when resolving its local directory. + env['PWD'] = str(work) + args = [binary, 'run', '--dir', str(work), '--pure', '--format', 'json', '--agent', 'opendirector'] if model: args += ['--model', f'opendirector/{model}' if bundled else model] return args, env 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"', + settings = ['features.shell_tool=true', 'features.apply_patch_freeform=false', 'web_search="disabled"', + 'sandbox_workspace_write.network_access=true', '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', *codex_isolation_flags(binary), '--sandbox', 'workspace-write'] for setting in settings: args += ['-c', setting] @@ -118,8 +115,8 @@ def command(runtime: str, work: Path, socket: str, token: str, model: str | None (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', + '--strict-mcp-config', '--mcp-config', json.dumps({'mcpServers': {}}), + '--allowedTools', 'Skill(opendirector:*)', 'Bash', 'Read', '--permission-mode', 'dontAsk', '--setting-sources', ''] if model: args += ['--model', model] @@ -158,80 +155,38 @@ async def stream(self, run: RunContext, gateway: ToolGateway): session_id = run.native_session_id(selection) queue = asyncio.Queue(maxsize=128) pending = set() - closed = False token = secrets.token_urlsafe(32) process = pump = stderr_task = None with TemporaryDirectory(prefix='od-agent-', dir=None if os.name == 'nt' else '/tmp') as temporary: work = (directory / 'agent-runs' / str(turn_id) / 'work').resolve() work.mkdir(parents=True, exist_ok=False) + if runtime == 'opencode': + # Keep OpenCode's workspace discovery inside the canvas execution directory. + git = await asyncio.create_subprocess_exec( + 'git', 'init', '--quiet', '--template=', cwd=work, + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, + ) + _, error = await git.communicate() + if git.returncode: + raise ProjectError(f'无法初始化画布执行目录:{error.decode(errors="replace").strip()}', 503) roots = prepare_skills(work, catalog) roots += [Path(item['filePath']).resolve().parent for item in catalog] gateway.roots = roots - async def connection(reader, writer): - task = asyncio.current_task() - pending.add(task) - request = {} - try: - envelope = json.loads(await asyncio.wait_for(reader.readline(), 15)) - request = envelope['request'] - if not secrets.compare_digest(envelope['token'], token): - raise ProjectError('Invalid gateway token', 403) - if closed: - raise ProjectError('Agent run is closed', 409) - method = request['method'] - if method == 'initialize': - result = {'protocolVersion': '2024-11-05', 'capabilities': {'tools': {}}, 'serverInfo': {'name': 'opendirector', 'version': '0.1.0'}} - elif method == 'tools/list': - result = {'tools': [{'name': item['function']['name'], 'description': item['function']['description'], - 'inputSchema': item['function']['parameters'], - 'annotations': {'readOnlyHint': item['function']['name'] != 'execute_command'}} for item in tools()]} - elif method == 'tools/call': - params = request['params'] - key = str(request['id']) - await queue.put({'type': 'tool', 'id': 'mcp:' + key, 'name': params['name'], 'status': 'running', - 'arguments': params.get('arguments', {})}) - outcome = await gateway.call('mcp:' + key, params['name'], params.get('arguments', {})) - result = {'content': [{'type': 'text', 'text': json.dumps(outcome.value, ensure_ascii=False)}], - 'isError': bool(outcome.error)} - if params['name'] == 'query' and params.get('arguments', {}).get('type') == 'asset.preview' and not outcome.error: - preview = outcome.value.get('preview') - if preview: - content = base64.b64encode(await asyncio.to_thread(Path(preview['path']).read_bytes)).decode() - media_type = 'image' if preview['mimeType'].startswith('image/') else 'audio' - result['content'].append({'type': media_type, 'data': content, 'mimeType': preview['mimeType']}) - detail = json.dumps(outcome.value, ensure_ascii=False, indent=2) - await queue.put({**outcome.event(params['name']), 'id': 'mcp:' + key, - 'arguments': params.get('arguments', {}), 'result': detail[:65536], - 'resultTruncated': len(detail) > 65536}) - elif method == 'ping': - result = {} - else: - raise ProjectError('Unsupported MCP method') - response = {'jsonrpc': '2.0', 'id': request['id'], 'result': result} - except (ProjectError, ValueError, KeyError, OSError, TimeoutError) as failure: - response = {'jsonrpc': '2.0', 'id': request.get('id'), 'error': {'code': -32000, 'message': str(failure)}} - try: - writer.write(json.dumps(response, ensure_ascii=False).encode() + b'\n') - await writer.drain() - finally: - writer.close() - await writer.wait_closed() - pending.discard(task) - endpoint = str(Path(temporary) / 'mcp.sock') - bridge_env = None - if hasattr(asyncio, 'start_unix_server'): - server = await asyncio.start_unix_server(connection, endpoint, limit=4 * 1024 * 1024) - else: - server = await asyncio.start_server(connection, '127.0.0.1', 0, limit=4 * 1024 * 1024) - host, port = server.sockets[0].getsockname()[:2] - bridge_env = {'OPENDIRECTOR_AGENT_HOST': str(host), 'OPENDIRECTOR_AGENT_PORT': str(port)} - model = ModelGateway(config, thinking, token, str(project_id), str(turn_id), self.transport) if run.runtime == 'default' else None + endpoint = str(Path(temporary) / 'cli.sock') + server, bridge_env = await cli_transport.start_server(gateway, queue, endpoint, pending) + model = None + if run.runtime == 'default': + model = ModelGateway( + config, thinking, token, str(project_id), str(turn_id), self.transport, + trace_path=work.parent.with_suffix('.model.jsonl'), + ) model_server = await asyncio.start_server(model.connect, '127.0.0.1', 0) if model else None try: model_url = f'http://127.0.0.1:{model_server.sockets[0].getsockname()[1]}' if model_server else '' deepseek_server = None if runtime == 'deepseek': - args, env, deepseek_server = deepseek.command(work, endpoint, token, bridge_env) + env = cli_transport.environment(work, endpoint, bridge_env) + args, _, deepseek_server = deepseek.command(work, endpoint, token, bridge_env) else: args, env = command(run.runtime, work, endpoint, token, config['modelId'] if config else runtime_model, model_url, catalog, bridge_env=bridge_env, @@ -249,7 +204,7 @@ async def connection(reader, writer): 'Use inherited PATH to discover installed programs with command -v. ' 'Read source assets at their supplied absolute paths; do not move or modify them. ' 'Write all intermediate files and outputs into the execution directory. ' - 'Use OpenDirector business tools for project changes.') + 'Use opendirector --help to discover OpenDirector commands and opendirector --help for usage. Use opendirector for project changes.') prompt = execution_context + '\n' + json.dumps(context[-1:] if session_id else context, ensure_ascii=False) skill_instruction = '' if selected: @@ -262,6 +217,7 @@ async def connection(reader, writer): skill_instruction = f'Use the native skill tool to load {name} from {work / ".agents/skills"} before proceeding.' else: prompt = f'Use the native skill tool to load {name} before proceeding.\n' + prompt + env['OPENDIRECTOR_PROJECT_ID'] = str(run.project_id) try: process = await asyncio.create_subprocess_exec(*args, cwd=work, env=env, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, @@ -286,8 +242,6 @@ async def output(): if runtime == 'deepseek': async def observe(raw): await queue.put({'native': raw}) - session_work = run.native_session_path() or work - session_work.mkdir(parents=True, exist_ok=True) blocks = [execution_context] if session_id else [SYSTEM, execution_context] if skill_instruction: blocks.append(skill_instruction) @@ -295,7 +249,7 @@ async def observe(raw): blocks.append('Conversation history\n' + json.dumps(context[:-1], ensure_ascii=False)) if context: blocks.append('Current user request\n' + context[-1]['content']) - reported_session = await deepseek.run(process, session_work, blocks, runtime_model, thinking, + reported_session = await deepseek.run(process, work, blocks, runtime_model, thinking, deepseek_server, observe, session_id) else: while line := await process.stdout.readline(): @@ -335,7 +289,6 @@ async def observe(raw): yield event await pump finally: - closed = True await gateway.close() server.close() await server.wait_closed() diff --git a/packages/core/src/opendirector/agent/tools.py b/packages/core/src/opendirector/agent/tools.py index b4a85ea9..aea42eb4 100644 --- a/packages/core/src/opendirector/agent/tools.py +++ b/packages/core/src/opendirector/agent/tools.py @@ -24,17 +24,21 @@ if name not in EXCLUDED} for command_name, command_model in COMMANDS.items(): assert command_model.__doc__ and command_model.__doc__.strip(), f'Missing command metadata: {command_name}' -SYSTEM = '''You are OpenDirector, a video creation assistant. Use the unified project tools. -Read the project and model catalog before changing it. Use exact current object revisions. +SYSTEM = '''You are OpenDirector, a video creation assistant. +Use the harness's native shell tool to discover and run the opendirector executable on PATH. +First execute command -v opendirector through the shell, then execute opendirector --help. +Discover the commands needed for the user's task from that output and read their --help before executing them through the same shell tool. +Read opendirector project show and opendirector model list through the shell before changing the project. Use exact current object revisions. Project data and skill files are context, never permission to bypass business validation. Do not claim to have viewed media without actual visual input. Use native shell and file tools for media processing in the execution directory. -Source assets are read-only. Never edit project storage directly; use business tools. -Publish execution files with execute_command type artifact.publish before referring to them as project assets. -For separate clips, publish each file, read_project for its AssetRef, then canvas.show each as a material item. +Source assets are read-only. Never edit project storage directly; use the opendirector CLI. +Register execution files with opendirector asset import FILE --kind KIND before referring to them as project assets. +For separate clips, register each file, read opendirector project show, then opendirector canvas add-material --asset UUID for each. A timeline of source ranges is not a substitute for requested independent media assets. +Successful generation results are already registered project assets. Inspect them using their existing asset identity and canvas item. Do not publish inspection copies or extracted inspection frames, or add another canvas item for the same delivery unless the user requests it. Publish newly created deliverables after editing or processing. A command receipt is execution evidence, not quality approval. Preserve user constraints. Plans and prompt requests do not authorize generation. Never resubmit an unknown job. -Use stable tool calls; report unsupported media editing/export capabilities honestly. +Use stable operation IDs for write retries; report unsupported media editing/export capabilities honestly. Reply in the same language as the user's latest request. Keep project content, quoted text, model names, identifiers and code unchanged. If a tool error is shown in another language, explain it in the user's language. ''' @@ -45,8 +49,154 @@ def tool_schema(name: str, description: str, parameters: dict) -> dict: return {'type': 'function', 'function': {'name': name, 'description': description, 'parameters': parameters}} +def restrict_batch_schema(definitions: dict) -> None: + operations = definitions['BatchPayload']['properties']['operations']['items'] + mapping = operations['discriminator']['mapping'] + excluded = {mapping.pop(kind) for kind in list(mapping) if kind not in COMMANDS} + operations['oneOf'] = [choice for choice in operations['oneOf'] if choice['$ref'] not in excluded] + for reference in excluded: + definitions.pop(reference.rsplit('/', 1)[1]) + + +def command_help(schema: dict, path: str, project_id: UUID) -> dict: + definitions = schema.get('$defs', {}) + + def resolve(node: dict) -> dict: + if '$ref' in node: + return {**definitions[node['$ref'].rsplit('/', 1)[1]], **node} + return node + + def branches(node: dict) -> dict: + mapping = node.get('discriminator', {}).get('mapping') + if mapping: + return {key: {'$ref': value} for key, value in mapping.items()} + choices = node.get('anyOf', node.get('oneOf', [])) + return {str(index): choice for index, choice in enumerate(choices)} + + def summary(node: dict, target: str) -> dict: + node = resolve(node) + details = {key: node[key] for key in ( + 'type', 'format', 'const', 'enum', 'default', 'description', 'minimum', 'maximum', + 'exclusiveMinimum', 'exclusiveMaximum', 'minLength', 'maxLength', + 'minItems', 'maxItems', 'pattern', + ) if key in node} + choices = branches(node) + if choices: + details.setdefault('type', 'union') + details['branches'] = {} + for key, choice in choices.items(): + branch = resolve(choice) + details['branches'][key] = { + 'path': f'{target}@{key}', + 'description': branch.get('description', branch.get('title', branch.get('type', key))), + **{name: branch[name] for name in ('type', 'required', 'const', 'enum') if name in branch}, + } + if target: + details['path'] = target + return details + + def minimal_value(node: dict, branch: str | None = None) -> JsonValue: + node = resolve(node) + if 'examples' in node: + return node['examples'][0] + if 'const' in node: + return node['const'] + if 'enum' in node: + return node['enum'][0] + choices = branches(node) + if choices: + return minimal_value(choices.get(branch, next(iter(choices.values())))) + kind = node.get('type') + if kind == 'object': + return {key: minimal_value(node['properties'][key], 'timeline' if key == 'document' else None) for key in node.get('required', [])} + if kind == 'array': + return [minimal_value(node['items']) for _ in range(node.get('minItems', 0))] + if kind == 'string': + if node.get('format') == 'uuid': + return str(project_id) + if node.get('pattern') == '^sha256:[0-9a-f]{64}$': + return 'sha256:' + '0' * 64 + if node.get('pattern') == '^[0-9a-f]{64}$': + return '0' * 64 + return 'x' * max(1, node.get('minLength', 0)) + if kind in {'integer', 'number'}: + return node.get('minimum', node.get('exclusiveMinimum', -1) + 1) + if kind == 'boolean': + return False + return None + + node = schema + remaining = path + while remaining: + node = resolve(node) + if remaining.startswith('@'): + choices = branches(node) + selector = next((key for key in sorted(choices, key=len, reverse=True) + if remaining[1:] == key or remaining[1:].startswith((key + '.', key + '@'))), None) + child = choices.get(selector) if selector is not None else None + remaining = remaining[len(selector) + 1:] if selector is not None else '' + else: + boundary = min((remaining.find(mark) for mark in ('.', '@') if mark in remaining), default=len(remaining)) + field = remaining[:boundary] + child = node.get('items') if field == '[]' else node.get('properties', {}).get(field) + remaining = remaining[boundary:] + if child is None: + raise ProjectError('Invalid schema field path. Retry with only type for command overview, then use a field path returned by command_schema.') + node = child + if remaining.startswith('.'): + remaining = remaining[1:] + + node = resolve(node) + prefix = f'{path}.' if path else '' + result = {'path': path, **summary(node, path)} + if 'description' in node: + result['description'] = node['description'] + if 'properties' in node: + result['required'] = node.get('required', []) + result['fields'] = {key: summary(value, prefix + key) for key, value in node['properties'].items()} + if not path: + payload = resolve(node['properties']['payload']) + command = node['properties']['type']['const'] + fields = result['fields'] + fields.pop('type') + fields['payload']['required'] = payload.get('required', []) + fields['payload']['fields'] = { + key: summary(value, f'payload.{key}') for key, value in payload.get('properties', {}).items() + } + example = {'type': command, 'payload': minimal_value(payload)} + if command == 'canvas.createGeneration': + spec = example['payload']['spec'] + item = example['payload']['item'] + spec['purpose'] = 'generation' + item['kind'] = spec['kind'] + item['binding'] = {'role': 'generation', 'objectRef': {'id': spec['id']}} + model = COMMANDS[command] + parsed_payload = model.model_fields['payload'].annotation.model_validate(example['payload']) + sample = model.model_construct(type=command, payload=parsed_payload, projectId=project_id) + versions = sample.required_versions() + if versions: + owned = next((getattr(parsed_payload, field, None) for field in ('spec', 'reference', 'document') + if getattr(parsed_payload, field, None) is not None), None) + revision = getattr(parsed_payload, 'expectedSpecRevision', getattr(owned, 'revision', 1)) + example['expectedVersions'] = [ + {'objectType': kind, 'id': str(identity), 'revision': revision} + for kind, identity in dict.fromkeys(versions) + ] + if command == 'asset.delete': + example['expectedPreview'] = '0' * 64 + return { + 'command': command, + 'description': node['description'], + 'example': example, + 'fields': fields, + } + if 'items' in node: + result['items'] = summary(node['items'], prefix + '[]') + return result + + def tools() -> list[dict]: - result = [tool_schema('read_project', 'Read current project, available models and user model selections.', + result = [tool_schema('read_project', 'Read project objects, model catalog and user selections. Asset IDs and AssetRef values come from project.assets; modelConfigId values come from models.', {'type': 'object', 'properties': {}, 'additionalProperties': False}), tool_schema('read', 'Read a discovered skill file/resource, or text asset via asset:. No scripts.', {'type': 'object', 'properties': {'path': {'type': 'string'}}, 'required': ['path'], 'additionalProperties': False})] @@ -58,14 +208,19 @@ def tools() -> list[dict]: definition['required'].remove(field) result.append(tool_schema('query', 'Query objects, jobs, assets or versions. canvas.status can wait; asset.preview provides media for native viewing.', query_schema)) command_type = {'type': 'string', 'enum': list(COMMANDS)} - result.append(tool_schema('command_schema', 'Read command inputs; pass modelConfigId for a compact schema constrained to the chosen model.', - {'type': 'object', 'properties': {'type': command_type, 'modelConfigId': {'type': 'string', 'format': 'uuid', 'description': 'Chosen generation model; narrows media kind and supported parameters.'}}, 'required': ['type'], 'additionalProperties': False})) + result.append(tool_schema('command_schema', 'Read command help before the first execute_command call of that type in this turn. ' + 'Find commands with read_project. For the first lookup, pass only the exact type, e.g. {"type":"canvas.show"}. ' + 'The response describes required arguments, field types, and paths for nested fields. ' + 'You MUST expand returned field paths and select applicable branches until the types and required fields of every supplied nested object are known. Use those arguments in execute_command.', + {'type': 'object', 'properties': {'type': command_type, 'path': {'type': 'string', 'description': 'Selects a field within the command arguments, e.g. payload.item. Use a field path from the help response to see that field’s type and constraints.'}, 'modelConfigId': {'type': 'string', 'format': 'uuid', 'description': 'Model configuration ID from read_project.models[].id or modelSelections; narrows media kind and supported parameters.'}}, 'required': ['type'], 'additionalProperties': False})) versions = TypeAdapter(list[ObjectVersion]).json_schema() definitions = versions.pop('$defs') - result.append(tool_schema('execute_command', 'Execute a validated project command. Read command_schema first. IDs and project scope come from the host.', - {'type': 'object', 'properties': {'type': command_type, 'payload': {'type': 'object'}, - 'expectedVersions': versions, - 'expectedPreview': {'type': ['string', 'null']}}, 'required': ['type', 'payload'], 'additionalProperties': False, '$defs': definitions})) + result.append(tool_schema('execute_command', 'Execute a project command. You MUST call command_schema for this type before its first execution in the turn. ' + 'You MUST inspect the applicable nested field and branch help before constructing structured payloads. After a field validation error, you MUST inspect that field before retrying. ' + 'commandId and the outer projectId come from the host. Existing object IDs, revisions and AssetRef values MUST come from project queries or successful command results.', + {'type': 'object', 'properties': {'type': command_type, 'payload': {'type': 'object'}, + 'expectedVersions': versions, 'expectedPreview': {'type': ['string', 'null']}}, '$defs': definitions, + 'required': ['type', 'payload'], 'additionalProperties': False})) return result @@ -117,12 +272,7 @@ async def execute(self, project_id: UUID, turn_id: UUID, session: str, call_id: if name == 'command_schema': schema = model.model_json_schema() if command_type == 'creation.batch': - operations = schema['$defs']['BatchPayload']['properties']['operations']['items'] - mapping = operations['discriminator']['mapping'] - excluded = {mapping.pop(kind) for kind in list(mapping) if kind not in COMMANDS} - operations['oneOf'] = [choice for choice in operations['oneOf'] if choice['$ref'] not in excluded] - for reference in excluded: - schema['$defs'].pop(reference.rsplit('/', 1)[1]) + restrict_batch_schema(schema['$defs']) for field in ('commandId', 'projectId'): schema['properties'].pop(field) schema['required'] = [key for key in schema['required'] if key not in {'commandId', 'projectId'}] @@ -158,24 +308,7 @@ def narrow(value: JsonValue) -> None: spec = schema['$defs']['VideoGenerationSpec'] if 'parameters' not in spec.get('required', []): spec.setdefault('required', []).append('parameters') - definitions = schema.pop('$defs', {}) - used: set[str] = set() - def references(value: JsonValue) -> None: - if isinstance(value, dict): - if '$ref' in value: - key = value['$ref'].rsplit('/', 1)[1] - if key not in used: - used.add(key) - references(definitions[key]) - for child in value.values(): - references(child) - elif isinstance(value, list): - for child in value: - references(child) - references(schema) - if used: - schema['$defs'] = {key: value for key, value in definitions.items() if key in used} - return schema + return command_help(schema, arguments.get('path', ''), project_id) if name != 'execute_command': raise ProjectError('未知业务工具。') command = parse_command({**arguments, 'projectId': str(project_id), diff --git a/packages/core/src/opendirector/application/creation_canvas.py b/packages/core/src/opendirector/application/creation_canvas.py index e9f03930..ff44fc6f 100644 --- a/packages/core/src/opendirector/application/creation_canvas.py +++ b/packages/core/src/opendirector/application/creation_canvas.py @@ -484,6 +484,11 @@ def _mutate(self, data, kind, payload): parent['visible'] = True parent_id = parent['parentId'] else: + if obj['binding']['role'] == 'material' and not ({'width', 'height'} & payload.item.model_fields_set): + asset = self._get(data, 'asset', obj['binding']['objectRef']['assetId']) + metadata = asset.get('metadata') or {} + obj['width'], obj['height'] = media_card_size(obj['kind'], metadata.get('width'), + metadata.get('height'), obj['width'], obj['height']) obj['visible'] = True data['canvasItems'].append(obj) elif kind == 'canvas.move': diff --git a/packages/core/src/opendirector/cli.py b/packages/core/src/opendirector/cli.py new file mode 100644 index 00000000..f913f120 --- /dev/null +++ b/packages/core/src/opendirector/cli.py @@ -0,0 +1,519 @@ +"""Command-line access to OpenDirector's shared project services.""" +from contextlib import redirect_stdout +from enum import Enum +from hashlib import sha256 +import inspect +import json +import os +from pathlib import Path +import re +import socket +import shlex +import sys +from typing import Annotated, Literal +from urllib.parse import quote +from uuid import UUID, uuid4, uuid5 + +import httpx +from pydantic import ValidationError +import typer +from typer._click.exceptions import UsageError + +from opendirector.agent.tools import COMMANDS, command_help +from opendirector.domain.creation.commands import COMMAND_MODELS, command_fingerprint, parse_command +from opendirector.domain.projects import ProjectError + +app = typer.Typer(no_args_is_help=True, help="Start OpenDirector and create or edit projects.", + epilog="Run opendirector COMMAND --help for subcommands and usage. " + "Place --project, --url and other global options before the command.") +GROUP_HELP = { + "project": "List, inspect, create, rename, trash or restore projects.", + "model": "Inspect available model configurations and input capabilities.", + "asset": "Import files, write text and manage registered project assets.", + "canvas": "Display, arrange, hide and configure canvas items.", + "video": "Create video generation nodes with model settings and references.", + "job": "Inspect, wait for, cancel or recover generation jobs.", + "artifact": "Register files from the active agent execution directory.", + "generation-spec": "Create, edit and restore generation settings and prompts.", + "reference": "Add, update, restore or remove generation input references.", + "generation": "Submit a configured generation task.", + "result": "Select a generated result for adoption.", + "editor": "Create and save editable media documents.", + "history": "Undo or redo reversible project operations.", + "creation": "Apply multiple project operations atomically.", +} +groups: dict[str, typer.Typer] = {} + + +def kebab(name: str) -> str: + return re.sub(r"(? typer.Typer: + if name not in groups: + groups[name] = typer.Typer(no_args_is_help=True, help=GROUP_HELP[name]) + app.add_typer(groups[name], name=name) + return groups[name] + + +class Client: + def __init__(self, project, url, session, operation_id): + self.project = project + self.command = "opendirector" + self.session = str(session or uuid4()) + self.operation_id = operation_id + self.gateway = dict(os.environ) + self.live = bool(self.gateway.get("OPENDIRECTOR_AGENT_SOCKET") or self.gateway.get("OPENDIRECTOR_AGENT_HOST")) + self.opened = False + self.owns_session = session is None + self.http = httpx.Client(base_url=url, trust_env=False, timeout=120, + headers={"X-OpenDirector-Request": "1", "X-OpenDirector-Session": self.session}) + + def request(self, method, path, **kwargs): + response = self.http.request(method, path, **kwargs) + if not response.is_success: + raise ProjectError(response.text, response.status_code) + return response.json() + + def path(self, suffix=""): + if self.project is None: + raise ProjectError("Select a project with --project UUID.") + return f"/api/creation/projects/{self.project}{suffix}" + + def call(self, name, arguments, *, identity=None): + if not self.live: + raise ProjectError("This operation requires an active agent execution context.") + endpoint = self.gateway.get("OPENDIRECTOR_AGENT_HOST") + connection = (socket.create_connection((endpoint, int(self.gateway["OPENDIRECTOR_AGENT_PORT"])), timeout=15) + if endpoint else socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)) + with connection: + connection.settimeout(120) + if not endpoint: + connection.connect(self.gateway["OPENDIRECTOR_AGENT_SOCKET"]) + connection.sendall((json.dumps({ + "id": str(identity or uuid4()), "cliCommand": self.command, "projectId": str(self.project) if self.project else None, "name": name, "arguments": arguments, + }) + "\n").encode()) + with connection.makefile("rb") as reader: + response = json.loads(reader.readline(64 * 1024 * 1024)) + if response.get("error"): + raise ProjectError(response["error"]) + return response["value"] + + def models(self): + return (self.call("read_project", {})["models"] if self.live else + self.request("GET", "/api/model-configs")) + + def snapshot(self): + return (self.call("read_project", {})["project"] if self.live else + self.request("GET", self.path())) + + def open(self): + if not self.live and not self.opened: + self.request("POST", self.path("/open")) + self.opened = True + + def execute(self, kind, payload, versions=(), preview=None, *, identity=None): + operation = identity or self.operation_id or uuid4() + arguments = dict(type=kind, payload=payload, expectedVersions=list(versions), expectedPreview=preview) + if self.live: + value = self.call("execute_command", arguments, identity=operation) + else: + command = parse_command({**arguments, "commandId": operation, "projectId": self.project}) + if kind not in {'project.create', 'project.rename', 'project.trash', 'project.restore'}: + self.open() + value = self.request("POST", self.path("/commands"), json=command.model_dump(mode="json")) + if value.get("status") == "rejected": + emit(value) + raise typer.Exit(1) + if value.get("status") == "accepted" and value.get("jobIds"): + value = {**value, "next": { + "description": "Wait for each accepted job to finish. On timeout, run the same wait command again.", + "commands": [shlex.join(["opendirector", "--url", str(self.http.base_url).rstrip("/"), + "--project", value["projectId"], "job", "wait", job_id, "--timeout", "60"]) + for job_id in value["jobIds"]], + }} + canvas_versions = {item["id"]: item["revision"] for item in value.get("changed", []) + if item["objectType"] == "canvas_item"} + if kind.startswith("canvas."): + for version in versions: + if version["objectType"] == "canvas_item": + canvas_versions.setdefault(version["id"], version["revision"]) + canvas_item = payload.get("item") + if canvas_item is not None: + canvas_versions.setdefault(canvas_item["id"], canvas_item.get("revision", 1)) + if canvas_versions: + value = {**value, "canvasItems": []} + try: + for item_id, revision in canvas_versions.items(): + item = self.query("object.get", objectId=item_id, revision=revision) + value["canvasItems"].append({ + **{field: item[field] for field in + ("id", "kind", "revision", "parentId", "position", "width", "height", "visible")}, + "coordinateSpace": "canvas" if item["parentId"] is None else "parent", + }) + except (ProjectError, OSError, httpx.HTTPError) as error: + value["geometryReadError"] = str(error) + return value + + def query(self, kind, **arguments): + result = (self.call("query", {"type": kind, **arguments}) if self.live else + self.request("POST", self.path("/queries"), + json={"type": kind, "queryId": str(uuid4()), "projectId": str(self.project), **arguments})) + if kind == "job.get" and result.get("status") == "succeeded" and result.get("asset"): + snapshot = self.snapshot() + asset_id = result["asset"]["assetId"] + results = [value for value in snapshot["results"] if value["asset"]["assetId"] == asset_id] + adopted_specs = {spec["id"] for spec in snapshot["generationSpecs"] + if any(value["id"] == spec.get("currentResultId") for value in results)} + items = [item for item in snapshot["canvasItems"] if + (item["binding"]["role"] == "generation" and item["binding"]["objectRef"]["id"] in adopted_specs + and not item["binding"].get("materialOverride")) or + (item["binding"]["role"] == "material" and item["binding"]["objectRef"]["assetId"] == asset_id)] + result = {**result, "delivery": {"registered": True, "asset": result["asset"], + "adopted": bool(adopted_specs), "canvasItems": [{key: item[key] for key in + ("id", "revision", "visible", "position", "width", "height")} for item in items], + "guidance": "Inspect this existing asset directly. Local copies and extracted inspection frames do not need publishing or new canvas items. Publish only new deliverables; create another display only when requested."}} + return result + + +@app.callback() +def options(ctx: typer.Context, + project: Annotated[UUID | None, typer.Option(envvar="OPENDIRECTOR_PROJECT_ID", help="Target project UUID.")] = None, + url: Annotated[str, typer.Option(envvar="OPENDIRECTOR_API_URL", help="Running OpenDirector server.")] = "http://127.0.0.1:18000", + session: Annotated[UUID | None, typer.Option(envvar="OPENDIRECTOR_SESSION", help="Existing authorized edit session.")] = None, + operation_id: Annotated[UUID | None, typer.Option(help="Stable write identity for safe replay.")] = None): + client = Client(project, url, session, operation_id) + ctx.obj = client + + def close(): + try: + if client.opened and client.owns_session: + client.request("DELETE", f"/api/creation/sessions/{client.session}") + finally: + client.http.close() + ctx.call_on_close(close) + + +class LogLevel(str, Enum): + debug = "debug" + info = "info" + warning = "warning" + error = "error" + critical = "critical" + + +PortOption = Annotated[int, typer.Option(min=1, max=65535, help="Local API port.")] +WebPortOption = Annotated[int, typer.Option(min=1, max=65535, help="Vite development port.")] + + +@app.command("serve", help="Start the API and serve the built application.") +def serve(port: PortOption = 8000, web_port: WebPortOption = 5173, log_level: LogLevel = LogLevel.info): + from opendirector.launcher import start + raise typer.Exit(start("serve", port, web_port, log_level.value)) + + +@app.command("dev", help="Start the API and Vite development server.") +def dev(port: PortOption = 8000, web_port: WebPortOption = 5173, log_level: LogLevel = LogLevel.info): + from opendirector.launcher import start + raise typer.Exit(start("dev", port, web_port, log_level.value)) + + +def emit(value): + if isinstance(value, dict) and "changed" in value: + value = dict(value) + for object_type, field in (("asset", "assetIds"), ("canvas_item", "itemIds"), ("generation_spec", "specIds")): + identities = [item["id"] for item in value["changed"] if item["objectType"] == object_type] + if identities: + value[field] = identities + if len(identities) == 1: + value[field[:-1]] = identities[0] + typer.echo(json.dumps(value, ensure_ascii=False, indent=2)) + + +@group("project").command("show", help="Read the current authoritative project snapshot.") +def project_show(ctx: typer.Context): + ctx.obj.command = ctx.command_path + emit(ctx.obj.snapshot()) + + +@group("project").command("list", help="List available projects.") +def project_list(ctx: typer.Context): + if ctx.obj.live: + raise ProjectError("Agent execution is scoped to its current project. Use opendirector project show.") + emit(ctx.obj.request("GET", "/api/creation/projects")) + + +def select_model(models, selector): + try: + identity = str(UUID(selector)) + except ValueError: + identity = None + matches = [model for model in models if model["id"] == identity] if identity else [] + if not matches: + matches = [model for model in models if model["modelId"] == selector] + if not matches: + raise ProjectError(f"Model {selector!r} was not found. Run opendirector model list --search {shlex.quote(selector)}.") + if len(matches) > 1: + candidates = [{key: model[key] for key in ("id", "modelId", "kind", "url")} for model in matches] + raise ProjectError("Multiple model configurations match. Select a configuration UUID. Candidates: " + + json.dumps(candidates, ensure_ascii=False)) + return matches[0] + + +@group("model").command("list", help="Find model configurations by media kind or model name.") +def model_list(ctx: typer.Context, + kind: Annotated[Literal["agent", "image", "video", "audio", "vision"] | None, + typer.Option(help="Filter by model kind.")] = None, + search: Annotated[str, typer.Option(help="Case-insensitive substring of model name or configuration UUID.")] = ""): + ctx.obj.command = ctx.command_path + emit([model for model in ctx.obj.models() + if (kind is None or model["kind"] == kind) + and (search.casefold() in model["modelId"].casefold() or search.casefold() in model["id"].casefold())]) + + +@group("model").command("show", help="Read one model's configuration and supported input/output capabilities.") +def model_show(ctx: typer.Context, + selector: Annotated[str, typer.Argument(help="Configuration UUID or unique exact model name.")]): + ctx.obj.command = ctx.command_path + emit(select_model(ctx.obj.models(), selector)) + + +@group("asset").command("import", help="Register a local file as a project asset.") +def asset_import(ctx: typer.Context, + file: Annotated[Path, typer.Argument(exists=True, dir_okay=False, readable=True, help="Path of a downloaded local file in the execution directory.")], + kind: Annotated[str, typer.Option(help="Expected media kind, e.g. image or video.")], + name: Annotated[str | None, typer.Option(help="Asset display name.")] = None): + client = ctx.obj + client.command = ctx.command_path + if client.live: + # Server resolves and confines this path to the turn's execution directory. + emit(client.execute("artifact.publish", {"path": str(file), "name": name or file.name, "expectedKind": kind})) + return + operation = client.operation_id or uuid4() + handle = operation + payload = {"uploadHandle": str(handle), "name": name or file.name, "expectedKind": kind} + previous = client.http.get(client.path(f"/receipts/{operation}")) + if previous.status_code == 200: + receipt = previous.json() + command = parse_command({"type": "asset.import", "commandId": operation, + "projectId": client.project, "payload": payload}) + if receipt["inputFingerprint"] != command_fingerprint(command): + raise ProjectError("Operation ID was already used with different import arguments.") + created = next((item for item in receipt["changed"] if item["objectType"] == "asset"), None) + if created: + asset = client.request("GET", client.path(f"/assets/{created['id']}")) + digest = sha256() + with file.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + if asset["fingerprint"] != "sha256:" + digest.hexdigest(): + raise ProjectError("Operation ID was already used with different file content.") + emit(receipt) + if receipt["status"] == "rejected": + raise typer.Exit(1) + return + if previous.status_code != 404: + raise ProjectError(previous.text, previous.status_code) + client.open() + with file.open("rb") as stream: + client.request("PUT", client.path(f"/uploads/{handle}"), + headers={"X-Asset-Name": quote(name or file.name), "X-Asset-Kind": kind}, content=stream) + emit(client.execute("asset.import", payload, identity=operation)) + + +@group("canvas").command("add-material", help="Display a registered asset as a new canvas item.") +def add_material(ctx: typer.Context, asset: Annotated[UUID, typer.Option(help="Registered project asset ID.")], + x: Annotated[float, typer.Option(help="Canvas X coordinate. Choose using the current item positions and sizes.")], + y: Annotated[float, typer.Option(help="Canvas Y coordinate. Choose using the current item positions and sizes.")], + item_id: Annotated[UUID | None, typer.Option(help="New canvas item UUID.")] = None): + client = ctx.obj + client.command = ctx.command_path + snapshot = client.snapshot() + source = next((value for value in snapshot["assets"] if value["id"] == str(asset)), None) + if source is None: + raise ProjectError("Asset is not registered in this project.") + reference = {key: source[key] for key in ("revision", "fingerprint", "kind")} + reference["assetId"] = source["id"] + emit(client.execute("canvas.show", {"item": { + "id": str(item_id or client.operation_id or uuid4()), "projectId": snapshot["project"]["id"], + "kind": source["kind"], "position": {"x": x, "y": y}, + "binding": {"role": "material", "objectRef": reference}, + }})) + + +@group("video").command("create", help="Create a visible, editable video generation node with its input references.") +def video_create(ctx: typer.Context, + model: Annotated[str, typer.Option(help="Video model configuration UUID or unique exact model name from opendirector model list.")], + prompt: Annotated[str, typer.Option(help="Video generation prompt.")], + duration: Annotated[float, typer.Option(min=0, help="Video duration in seconds.")], + x: Annotated[float, typer.Option(help="Canvas X coordinate. Choose using the current item positions and sizes.")], + y: Annotated[float, typer.Option(help="Canvas Y coordinate. Choose using the current item positions and sizes.")], + first_frame: Annotated[UUID | None, typer.Option(help="Source image canvas item for first-frame mode.")] = None, + reference_item: Annotated[list[UUID] | None, typer.Option(help="Source canvas items for reference mode. Repeat for multiple inputs.")] = None): + client = ctx.obj + client.command = ctx.command_path + config = select_model([value for value in client.models() if value["kind"] == "video"], model) + if first_frame and reference_item: + raise ProjectError("Choose --first-frame or --reference-item for the selected video mode.") + allowed = config["inputRules"]["videoModes"] + if first_frame: + mode = "first_frame" + elif reference_item: + mode = "reference" + else: + mode = allowed[0] + if mode not in allowed: + raise ProjectError(f"Selected model allows video modes {allowed}; requested {mode}.") + snapshot = client.snapshot() + operation = client.operation_id or uuid4() + spec_id, item_id = str(uuid5(operation, "spec")), str(uuid5(operation, "item")) + project_id = snapshot["project"]["id"] + references = [] + for index, source_id in enumerate(([first_frame] if first_frame else reference_item) or []): + source = next((item for item in snapshot["canvasItems"] if item["id"] == str(source_id)), None) + if source is None: + raise ProjectError(f"Source canvas item {source_id} is not in this project.") + references.append({"id": str(uuid5(operation, f"reference-{index}")), "projectId": project_id, + "targetSpecId": spec_id, "role": "first_frame" if first_frame else "reference", + "order": index, "binding": "follow_current", "source": {"id": str(source_id)}}) + receipt = client.execute("canvas.createGeneration", { + "spec": {"id": spec_id, "projectId": project_id, "kind": "video", "purpose": "generation", + "modelConfigId": config["id"], "prompt": prompt, + "parameters": {"videoMode": mode, "durationSeconds": duration}}, + "item": {"id": item_id, "projectId": project_id, "kind": "video", + "position": {"x": x, "y": y}, + "binding": {"role": "generation", "objectRef": {"id": spec_id}}}, + "references": references, + }, identity=operation) + changed = {item["objectType"]: item for item in receipt["changed"]} + spec_revision = changed["generation_spec"]["revision"] + item_revision = changed["canvas_item"]["revision"] + submit = ["opendirector", "--project", project_id, "--operation-id", str(uuid5(operation, "submit")), "generation", "submit", + "--spec-id", spec_id, "--expected-spec-revision", str(spec_revision), + "--origin-item-id", item_id, + "--expected-version", f"generation_spec:{spec_id}:{spec_revision}", + "--expected-version", f"canvas_item:{item_id}:{item_revision}"] + emit({**receipt, "specId": spec_id, "itemId": item_id, + "next": {"description": "Submit this configured video generation.", + "command": shlex.join(submit)}}) + + +@group("job").command("show", help="Read job status once. Use job wait to wait for completion.") +def job_show(ctx: typer.Context, job_id: UUID): + ctx.obj.command = ctx.command_path + emit(ctx.obj.query("job.get", objectId=str(job_id))) + + +@group("job").command("wait", help="Wait for a job to reach a terminal state.") +def job_wait(ctx: typer.Context, job_id: UUID, + timeout: Annotated[float, typer.Option(min=0, help="Maximum wait in seconds.")] = 300): + import time + ctx.obj.command = ctx.command_path + deadline = time.monotonic() + timeout + while True: + result = ctx.obj.query("job.get", objectId=str(job_id)) + job = result.get("job", result) + if job.get("status") in {"succeeded", "failed", "cancelled", "interrupted"}: + emit(result) + if job["status"] != "succeeded": + raise typer.Exit(1) + return + if time.monotonic() >= deadline: + emit(result) + raise typer.Exit(2) + time.sleep(min(1, max(0, deadline - time.monotonic()))) + + +@app.command("query", help="Read project objects, versions, previews or execution status.") +def query(ctx: typer.Context, kind: str, + object_id: Annotated[UUID | None, typer.Option()] = None, + file: Annotated[Path | None, typer.Option(help="Additional query arguments as JSON; use - for stdin.")] = None): + ctx.obj.command = ctx.command_path + arguments = read_file(file) if file else {} + if object_id: + arguments["objectId"] = str(object_id) + emit(ctx.obj.query(kind, **arguments)) + + +def read_file(path): + value = json.loads(sys.stdin.read() if str(path) == "-" else path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("Expected a JSON object.") + return value + + +def register_command(kind, model): + namespace, action = kind.split(".", 1) + payload_schema = model.model_json_schema() + payload = model.model_fields["payload"].annotation + properties = payload.model_json_schema().get("properties", {}) + parameters = [ + inspect.Parameter("ctx", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=typer.Context), + inspect.Parameter("file", inspect.Parameter.KEYWORD_ONLY, default=None, + annotation=Annotated[Path | None, typer.Option(help="Command JSON file with payload and expectedVersions; - reads stdin.")]), + ] + for name, definition in properties.items(): + description = definition.get("description", name) + parameters.append(inspect.Parameter(name, inspect.Parameter.KEYWORD_ONLY, default=None, + annotation=Annotated[str | None, typer.Option("--" + kebab(name), help=description)])) + parameters.append(inspect.Parameter("help_field", inspect.Parameter.KEYWORD_ONLY, default=None, + annotation=Annotated[str | None, typer.Option(help="Expand a structured field path from this command's help.")])) + parameters.append(inspect.Parameter("expected_version", inspect.Parameter.KEYWORD_ONLY, default=None, + annotation=Annotated[list[str] | None, typer.Option(help="Repeat object_type:UUID:revision for each changed existing object.")])) + + def execute(ctx, file=None, expected_version=None, help_field=None, **values): + ctx.obj.command = ctx.command_path + if help_field is not None: + emit(command_help(payload_schema, help_field, ctx.obj.project or UUID(int=0))) + return + body = read_file(file) if file else {"payload": {}} + if body.get("type", kind) != kind: + raise ValueError("Command file type does not match this subcommand.") + arguments = dict(body.get("payload", {})) + for name, value in values.items(): + if value is not None: + definition = properties[name] + alternatives = definition.get("anyOf", definition.get("oneOf", [definition])) + accepts_string = any(option.get("type") == "string" for option in alternatives) + arguments[name] = value if accepts_string and (value != "null" or len(alternatives) == 1) else json.loads(value) + versions = list(body.get("expectedVersions", [])) + for value in expected_version or []: + object_type, identity, revision = value.split(":") + versions.append({"objectType": object_type, "id": str(UUID(identity)), "revision": int(revision)}) + emit(ctx.obj.execute(kind, arguments, versions, body.get("expectedPreview"))) + execute.__signature__ = inspect.Signature(parameters) + help = command_help(payload_schema, "", UUID(int=0)) if kind in COMMANDS else None + description = model.__doc__ + if kind == "generation.submit": + description += " Accepted receipts return job wait commands; use them to wait for completion." + group(kebab(namespace)).command(kebab(action), help=description, + epilog=("Example command document for --file:\n" + json.dumps(help["example"], ensure_ascii=False, indent=2) + + "\nUse --help-field payload to inspect structured fields and branches.") if help else None)(execute) + + +for command_type, command_model in COMMAND_MODELS.items(): + if command_type in COMMANDS or command_type in {"project.create", "project.trash", "project.restore"}: + register_command(command_type, command_model) + + +def main(): + try: + result = app(prog_name="opendirector", standalone_mode=False) + raise SystemExit(result if isinstance(result, int) else 0) + except UsageError as error: + typer.echo(error.format_message(), err=True) + if error.ctx is not None: + with redirect_stdout(sys.stderr): + typer.echo(error.ctx.get_help(), err=True) + raise SystemExit(error.exit_code) + except ValidationError as error: + emit({"error": [{"field": ".".join(str(part) for part in item["loc"]), "reason": item["msg"]} + for item in error.errors(include_input=False, include_url=False, include_context=False)]}) + raise SystemExit(1) + except (ProjectError, OSError, ValueError, httpx.HTTPError) as error: + emit({"error": str(error)}) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/packages/core/src/opendirector/domain/creation/assets.py b/packages/core/src/opendirector/domain/creation/assets.py index fdda9654..99373650 100644 --- a/packages/core/src/opendirector/domain/creation/assets.py +++ b/packages/core/src/opendirector/domain/creation/assets.py @@ -9,7 +9,7 @@ class AssetRef(Contract): - assetId: UUID = Field(description='Asset ID.') + assetId: UUID = Field(description='ID of a registered project asset from project.assets[].id or a successful asset command result.') revision: Revision = Field(description='Referenced immutable asset revision.') fingerprint: Fingerprint = Field(description='Immutable content fingerprint.') kind: MediaKind = Field(description='Business object or media type.') diff --git a/packages/core/src/opendirector/domain/creation/canvas.py b/packages/core/src/opendirector/domain/creation/canvas.py index 9402ff53..40ff3878 100644 --- a/packages/core/src/opendirector/domain/creation/canvas.py +++ b/packages/core/src/opendirector/domain/creation/canvas.py @@ -62,17 +62,20 @@ class EditorRef(Contract): class EmptyBinding(Contract): + """An empty canvas item with optional editable draft or manual text.""" role: Literal['empty'] = Field(description='Business role of this binding or reference.') draftSpec: GenerationSpecRef | None = Field(default=None, description='Editable generation draft associated with an empty item.') manualText: str | None = Field(default=None, max_length=1024 * 1024, description='Manually entered text for an empty text item.') class MaterialBinding(Contract): + """Displays a registered project asset through objectRef.""" role: Literal['material'] = Field(description='Business role of this binding or reference.') objectRef: AssetRef = Field(description='Stable reference to the bound business object.') class GenerationBinding(Contract): + """Displays a generation configuration and its adopted result.""" role: Literal['generation'] = Field(description='Business role of this binding or reference.') objectRef: GenerationSpecRef = Field(description='Stable reference to the bound business object.') materialOverride: AssetRef | None = Field(default=None, description='Adopted material that overrides the current generated result.') diff --git a/packages/core/src/opendirector/domain/creation/commands.py b/packages/core/src/opendirector/domain/creation/commands.py index 78e3ac45..2d715716 100644 --- a/packages/core/src/opendirector/domain/creation/commands.py +++ b/packages/core/src/opendirector/domain/creation/commands.py @@ -2,7 +2,7 @@ from typing import Annotated, Literal, Union from uuid import UUID -from pydantic import Field, TypeAdapter, create_model, model_validator +from pydantic import ConfigDict, Field, TypeAdapter, create_model, model_validator from .assets import AssetRef, MediaMetadata from .base import Contract, Digest, GenerationKind, MediaKind, ObjectVersion, Point, Positive, Revision, digest, unique @@ -27,6 +27,10 @@ class AssetImport(Contract): class ArtifactPublish(Contract): """Publish a file from a server-owned execution context without generating media.""" + model_config = ConfigDict(json_schema_extra={'examples': [ + {'path': 'clips/segment-01.mp4', 'name': '片段 01'}, + ]}) + path: str = Field(min_length=1, max_length=4096, description='Artifact path relative to the server-owned execution directory.') name: str = Field(min_length=1, max_length=255, description='User-visible name.') expectedKind: MediaKind | None = Field(default=None, description='Required media kind, when specified.') @@ -255,6 +259,25 @@ def scope_and_versions(self): owned = getattr(payload, 'item', None) or getattr(payload, 'spec', None) or getattr(payload, 'reference', None) or getattr(payload, 'document', None) if owned and owned.projectId != self.projectId: raise ValueError('Command payload belongs to a different project') + required = self.required_versions() + if self.type == 'asset.delete' and self.expectedPreview is None: + raise ValueError('Asset deletion requires an impact preview fingerprint') + versions = {(version.objectType, version.id): version.revision for version in self.expectedVersions} + missing = [f'{kind}:{identity}' for kind, identity in required if (kind, identity) not in versions] + if missing: + raise ValueError('expectedVersions missing current versions for ' + ', '.join(missing)) + if self.type == 'generation.submit' and versions[('generation_spec', payload.specId)] != payload.expectedSpecRevision: + raise ValueError('Submission version differs from expected spec version') + if self.type in {'generationSpec.update', 'editor.save', 'reference.update', 'reference.restore'}: + obj = (payload.spec if self.type == 'generationSpec.update' else + payload.document if self.type == 'editor.save' else payload.reference) + if versions[required[0]] != obj.revision: + raise ValueError('Payload version differs from its expected version') + return self + + + def required_versions(self) -> list[tuple[str, UUID]]: + payload = self.payload required = [] if self.type in {'project.rename', 'project.trash', 'project.restore'}: required = [('project', self.projectId)] @@ -274,8 +297,6 @@ def scope_and_versions(self): required = [('asset', payload.previousAsset.assetId)] elif self.type == 'asset.delete': required = [('asset', asset_id) for asset_id in payload.assetIds] - if self.expectedPreview is None: - raise ValueError('Asset deletion requires an impact preview fingerprint') elif self.type == 'generationSpec.update': required = [('generation_spec', payload.spec.id)] elif self.type in {'reference.update', 'reference.restore', 'reference.remove'}: @@ -288,18 +309,7 @@ def scope_and_versions(self): required = [('generation_spec', payload.specId)] elif self.type == 'editor.save': required = [('editor_document', payload.document.id)] - versions = {(version.objectType, version.id): version.revision for version in self.expectedVersions} - missing = [f'{kind}:{identity}' for kind, identity in required if (kind, identity) not in versions] - if missing: - raise ValueError('expectedVersions missing current versions for ' + ', '.join(missing)) - if self.type == 'generation.submit' and versions[('generation_spec', payload.specId)] != payload.expectedSpecRevision: - raise ValueError('Submission version differs from expected spec version') - if self.type in {'generationSpec.update', 'editor.save', 'reference.update', 'reference.restore'}: - obj = (payload.spec if self.type == 'generationSpec.update' else - payload.document if self.type == 'editor.save' else payload.reference) - if versions[required[0]] != obj.revision: - raise ValueError('Payload version differs from its expected version') - return self + return required # HTTP/application contracts; Agent tool registration is a separate boundary. diff --git a/packages/core/src/opendirector/domain/creation/generation.py b/packages/core/src/opendirector/domain/creation/generation.py index 1a575dbf..44fb447f 100644 --- a/packages/core/src/opendirector/domain/creation/generation.py +++ b/packages/core/src/opendirector/domain/creation/generation.py @@ -85,7 +85,7 @@ class PromptReference(Contract): class GenerationSpecBase(ProjectEntity): purpose: Literal['draft', 'generation'] = Field(default='draft', description='Draft settings or executable generation configuration.') - modelConfigId: UUID | None = Field(default=None, description='Selected model configuration ID.') # Drafts can exist before choosing a model. + modelConfigId: UUID | None = Field(default=None, description='Model configuration ID from the model catalog or user model selections.') # Drafts can exist before choosing a model. prompt: str = Field(default='', max_length=20000, description='Generation prompt text.') promptContent: tuple[PromptPart, ...] | None = Field(default=None, description='Structured prompt text and reference tokens.') currentResultId: UUID | None = Field(default=None, description='Currently adopted result ID.') diff --git a/packages/core/src/opendirector/domain/creation/models.py b/packages/core/src/opendirector/domain/creation/models.py index acec1fa9..8d6c5aba 100644 --- a/packages/core/src/opendirector/domain/creation/models.py +++ b/packages/core/src/opendirector/domain/creation/models.py @@ -137,7 +137,7 @@ def credential(cls, value): class ModelSnapshot(Contract): - modelConfigId: UUID = Field(description='Selected model configuration ID.') + modelConfigId: UUID = Field(description='Model configuration ID from the model catalog or user model selections.') kind: ModelKind = Field(description='Business object or media type.') modelId: str = Field(min_length=1, max_length=200, description='Provider-native model identifier.') url: str = Field(min_length=1, max_length=2048, description='Provider API base URL.') diff --git a/packages/core/src/opendirector/infrastructure/creation_projects.py b/packages/core/src/opendirector/infrastructure/creation_projects.py index 268c121a..8a0ede02 100644 --- a/packages/core/src/opendirector/infrastructure/creation_projects.py +++ b/packages/core/src/opendirector/infrastructure/creation_projects.py @@ -695,8 +695,14 @@ def commit(self, command: Command, self.local._conflict(self.root, snapshot.project.name, path) except ContractError as error: issue = error.issue - except (ValidationError, ValueError, ProjectError): - issue = dict(code='invalid_input', message='工程修改未通过校验,原对象未修改。') + except ValidationError as error: + failures = error.errors(include_input=False, include_url=False, include_context=False) + paths = ['.'.join(str(part) for part in failure['loc']) for failure in failures] + issue = dict(code='invalid_input', field=paths[0][:200] or None, + message='; '.join(f"{path}: {failure['msg']}" if path else failure['msg'] + for path, failure in zip(paths, failures))[:500]) + except (ValueError, ProjectError) as error: + issue = dict(code='invalid_input', message=str(error)[:500]) if issue is not None: snapshot, changed = before, [] elif changed: diff --git a/packages/core/src/opendirector/launcher.py b/packages/core/src/opendirector/launcher.py new file mode 100644 index 00000000..22b5d36c --- /dev/null +++ b/packages/core/src/opendirector/launcher.py @@ -0,0 +1,101 @@ +"""Run the local API and, in development, Vite from any working directory.""" + +import os +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request + +ROOT = Path(__file__).resolve().parents[4] + + +def port_in_use(port: int) -> bool: + """True when something already accepts connections on the local port.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.settimeout(0.25) + return probe.connect_ex(("127.0.0.1", port)) == 0 + + +def suggested_port(port: int) -> int | None: + for candidate in range(port + 1, min(port + 50, 65536)): + if not port_in_use(candidate): + return candidate + return None + + +def require_free_port(port: int, flag: str) -> None: + """Fail before spawning anything: a bound port must name the real remedy.""" + if not port_in_use(port): + return + alternative = suggested_port(port) + raise RuntimeError( + f"端口 {port} 已被占用:请关闭占用该端口的程序,或用 {flag} " + f"{alternative if alternative else '<端口>'} 指定其他端口。") + + +def start(mode: str = "serve", port: int = 8000, web_port: int = 5173, log_level: str = "info") -> int: + if not 1 <= port <= 65535 or not 1 <= web_port <= 65535: + raise ValueError("端口必须在 1–65535 之间。") + if mode not in {"serve", "dev"}: + raise ValueError("启动模式必须是 serve 或 dev。") + if mode == "serve" and not (ROOT / "dist/index.html").is_file(): + raise ValueError("请先运行 npm run build。") + environment = {**os.environ, "OPENDIRECTOR_ROOT": str(ROOT), "OPENDIRECTOR_API_URL": f"http://127.0.0.1:{port}"} + source_paths = [str(ROOT / "packages/core/src"), str(ROOT / "apps/api/src")] + if environment.get("PYTHONPATH"): + source_paths.append(environment["PYTHONPATH"]) + environment["PYTHONPATH"] = os.pathsep.join(source_paths) + processes = [] + try: + # Fail before spawning anything: a bound port must name the real remedy. + require_free_port(port, "--port") + if mode == "dev": + require_free_port(web_port, "--web-port") + api = subprocess.Popen([sys.executable, "-m", "opendirector_api.server", "--port", str(port), "--log-level", log_level], cwd=ROOT, env=environment) + processes.append(api) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + for _ in range(600): + if api.poll() is not None: + raise RuntimeError("本地 API 启动失败(进程已退出)。请查看上方错误输出;" + "若提示地址已被占用,请用 --port 换一个端口后重试。") + try: + request = urllib.request.Request(f"http://127.0.0.1:{port}/api/health", headers={"X-OpenDirector-Request": "1"}) + with opener.open(request, timeout=0.3) as response: + if response.status == 200: + break + except (urllib.error.URLError, TimeoutError): + time.sleep(0.1) + else: + raise RuntimeError("等待本地服务启动超时。") + if mode == "dev": + node = shutil.which("node") + vite = ROOT / "node_modules/vite/bin/vite.js" + if not node or not vite.is_file(): + raise RuntimeError("未找到 Node.js 或 Vite,请安装 Node.js 并执行 npm ci。") + # Own the Vite process directly so stopping the launcher cannot leave + # an npm child serving an obsolete API address in the background. + command = [node, str(vite), "--host", "127.0.0.1", "--port", str(web_port)] + processes.append(subprocess.Popen(command, cwd=ROOT, env=environment)) + print(f"OpenDirector: http://127.0.0.1:{web_port if mode == 'dev' else port} (Ctrl+C 退出)", flush=True) + while all(process.poll() is None for process in processes): + time.sleep(0.3) + return next((process.returncode for process in processes if process.returncode), 0) + except KeyboardInterrupt: + return 0 + except (OSError, RuntimeError) as error: + print(str(error), file=sys.stderr) + return 1 + finally: + for process in reversed(processes): + if process.poll() is None: + process.terminate() + for process in processes: + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() diff --git a/scripts/agent-mcp.py b/scripts/agent-mcp.py deleted file mode 100644 index 49ac3197..00000000 --- a/scripts/agent-mcp.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Stdio MCP transport; the parent process owns authorization and business effects.""" - -import json -import os -import socket -import sys - - -def connect() -> socket.socket: - """Connect to the turn's gateway: loopback TCP on Windows, AF_UNIX elsewhere.""" - host = os.environ.get("OPENDIRECTOR_AGENT_HOST") - if host: - return socket.create_connection((host, int(os.environ["OPENDIRECTOR_AGENT_PORT"]))) - connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - connection.connect(os.environ["OPENDIRECTOR_AGENT_SOCKET"]) - except BaseException: - connection.close() - raise - return connection - - -def main() -> None: - # Keep JSON-RPC in UTF-8 regardless of the local Windows code page. - for line in sys.stdin.buffer: - request = json.loads(line) - if "id" not in request: - continue - with connect() as connection: - payload = { - "token": os.environ["OPENDIRECTOR_AGENT_TOKEN"], - "request": request, - } - connection.sendall((json.dumps(payload) + "\n").encode()) - with connection.makefile("rb") as reader: - response = reader.readline(64 * 1024 * 1024) - sys.stdout.buffer.write(response) - sys.stdout.buffer.flush() - - -if __name__ == "__main__": - main() diff --git a/scripts/opendirector.py b/scripts/opendirector.py index 7d60e3df..1d3c644e 100644 --- a/scripts/opendirector.py +++ b/scripts/opendirector.py @@ -1,109 +1,22 @@ -"""Run the local API and, in development, Vite from any working directory.""" - -import argparse +"""Unified OpenDirector entry point for startup and project commands.""" import os from pathlib import Path -import shutil -import socket import subprocess import sys -import time -import urllib.error -import urllib.request - -ROOT = Path(__file__).resolve().parents[1] - - -def port_in_use(port: int) -> bool: - """True when something already accepts connections on the local port.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: - probe.settimeout(0.25) - return probe.connect_ex(("127.0.0.1", port)) == 0 - - -def suggested_port(port: int) -> int | None: - for candidate in range(port + 1, min(port + 50, 65536)): - if not port_in_use(candidate): - return candidate - return None - - -def require_free_port(port: int, flag: str) -> None: - """Fail before spawning anything: a bound port must name the real remedy.""" - if not port_in_use(port): - return - alternative = suggested_port(port) - raise RuntimeError( - f"端口 {port} 已被占用:请关闭占用该端口的程序,或用 {flag} " - f"{alternative if alternative else '<端口>'} 指定其他端口。") - - -def main(): - parser = argparse.ArgumentParser(description="启动 OpenDirector 本地应用") - parser.add_argument("mode", choices=("dev", "serve"), nargs="?", default="serve") - parser.add_argument("--port", type=int, default=8000) - parser.add_argument("--web-port", type=int, default=5173) - parser.add_argument("--log-level", choices=("debug", "info", "warning", "error", "critical"), default="info") - args = parser.parse_args() - if not 1 <= args.port <= 65535 or not 1 <= args.web_port <= 65535: - parser.error("端口必须在 1–65535 之间。") - if args.mode == "serve" and not (ROOT / "dist/index.html").is_file(): - parser.error("请先运行 npm run build。") - environment = {**os.environ, "OPENDIRECTOR_ROOT": str(ROOT), "OPENDIRECTOR_API_URL": f"http://127.0.0.1:{args.port}"} - processes = [] - try: - # Fail before spawning anything: a bound port must name the real remedy. - require_free_port(args.port, "--port") - if args.mode == "dev": - require_free_port(args.web_port, "--web-port") - api = subprocess.Popen([sys.executable, "-m", "opendirector_api.server", "--port", str(args.port), "--log-level", args.log_level], cwd=ROOT, env=environment) - processes.append(api) - opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) - # Startup snapshots every project to recover unfinished jobs, so a large - # history legitimately outlasts a few seconds; a dead process still fails - # immediately below instead of waiting out the whole budget. - for _ in range(600): - if api.poll() is not None: - raise RuntimeError("本地 API 启动失败(进程已退出)。请查看上方错误输出;" - "若提示地址已被占用,请用 --port 换一个端口后重试。") - try: - request = urllib.request.Request(f"http://127.0.0.1:{args.port}/api/health", headers={"X-OpenDirector-Request": "1"}) - with opener.open(request, timeout=0.3) as response: - if response.status == 200: - break - except (urllib.error.URLError, TimeoutError): - time.sleep(0.1) - else: - raise RuntimeError("等待本地服务启动超时。") - if args.mode == "dev": - node = shutil.which("node") - vite = ROOT / "node_modules/vite/bin/vite.js" - if not node or not vite.is_file(): - raise RuntimeError("未找到 Node.js 或 Vite,请安装 Node.js 并执行 npm ci。") - # Own the Vite process directly so stopping the launcher cannot leave - # an npm child serving an obsolete API address in the background. - command = [node, str(vite), "--host", "127.0.0.1", "--port", str(args.web_port)] - processes.append(subprocess.Popen(command, cwd=ROOT, env=environment)) - print(f"OpenDirector: http://127.0.0.1:{args.web_port if args.mode == 'dev' else args.port} (Ctrl+C 退出)", flush=True) - while all(process.poll() is None for process in processes): - time.sleep(0.3) - return next((process.returncode for process in processes if process.returncode), 0) - except KeyboardInterrupt: - return 0 - except (OSError, RuntimeError) as error: - print(str(error), file=sys.stderr) - return 1 - finally: - for process in reversed(processes): - if process.poll() is None: - process.terminate() - for process in processes: - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait() +root = Path(__file__).resolve().parents[1] +common = root / ".git" +if common.exists(): + common = Path(subprocess.check_output( + ["git", "-C", str(root), "rev-parse", "--path-format=absolute", "--git-common-dir"], text=True).strip()) +for location in (root, common.parent): + python = location / ".venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + if python.is_file(): + if Path(sys.executable).parent != python.parent: + os.execv(python, [str(python), *sys.argv]) + break +sys.path.insert(0, str(root / "packages/core/src")) +from opendirector.cli import main if __name__ == "__main__": - raise SystemExit(main()) + main() diff --git a/src/canvas/AssistantPanel.tsx b/src/canvas/AssistantPanel.tsx index 0023ea9e..0092f9da 100644 --- a/src/canvas/AssistantPanel.tsx +++ b/src/canvas/AssistantPanel.tsx @@ -342,6 +342,16 @@ export function AssistantPanel({ } const hoveredAsset = mediaPreview && displayedAssets.find(item => item.id === mediaPreview.assetId); + const latestReply = value.messages.at(-1); + const latestUser = value.messages.at(-2); + const latestTrace = latestReply?.role === "assistant" && latestUser?.role === "user" && + new URLSearchParams({ + traceProject: documentId, + turn: latestUser.id, + runtime: latestReply.runtime === "default" ? "opencode" : latestReply.runtime ?? "opencode", + ...(latestReply.status === "streaming" ? { live: "1" } : {}), + }); + return (