mirror of
https://github.com/Ryder-Sun/Meldwork.git
synced 2026-11-07 06:56:03 -10:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d1af3dd1f | ||
|
|
0ccaa47e3d | ||
|
|
fdd583dc82 | ||
|
|
af7f8f1954 | ||
|
|
d103b71e08 | ||
|
|
97aeb58e8d | ||
|
|
82e5d462fb | ||
|
|
d61234deca | ||
|
|
56d3f3fa9f | ||
|
|
a5557e5e11 | ||
|
|
e8e73776ce | ||
|
|
2e53181c21 | ||
|
|
f33882a5b2 | ||
|
|
56925dd83b | ||
|
|
b89a38ee5a | ||
|
|
ad31238944 | ||
|
|
8701ae9f63 |
No files matched your search
@@ -26,101 +26,128 @@ The current preview is a local work cell for supported Agent CLIs. It keeps part
|
||||
· <a href="LICENSE">License</a>
|
||||
</p>
|
||||
|
||||
## The missing layer in the Agent workforce
|
||||
## Who it is for
|
||||
|
||||
Agent supply is growing quickly. The organization around it is not.
|
||||
Use Meldwork when you:
|
||||
|
||||
Today, people still choose every Agent, move every piece of context, explain every role, reconcile conflicting answers, and carry the final accountability. Parallel managers improve throughput. Agent frameworks let developers program workflows. Single-Agent products improve one worker.
|
||||
- already have multiple local Agent CLIs and need one task to span them;
|
||||
- need independent review for code, research, or product analysis before choosing a path;
|
||||
- need inspectable traces, evidence, and human review before workspace writes.
|
||||
|
||||
Meldwork is focused on the work between them:
|
||||
## Workflow
|
||||
|
||||
- one shared goal and acceptance boundary;
|
||||
- independent viewpoints instead of hidden routing;
|
||||
- explicit authority, responsibility, handoffs, and stopping conditions;
|
||||
- artifacts and evidence that survive Agent changes;
|
||||
- a human-controlled adoption decision.
|
||||
1. **Select** the local Agents and participants.
|
||||
2. **Scope** the goal, working directory, context, and permissions.
|
||||
3. **Run** Direct, Concurrent Responses, or Auto Discussion V4.
|
||||
4. **Review and adopt** the result, evidence, and any Human Gate before changing the workspace.
|
||||
|
||||
## What Meldwork is building
|
||||
## Collaboration modes
|
||||
|
||||
The collaboration mechanism is not a hidden preset A → B → C assignment. The user selects the participants; those Agents expose the solution space, negotiate responsibilities, and execute inside Harness-enforced context, permission, budget, recovery, and evidence boundaries.
|
||||
|
||||
The selected-Agent proposal, challenge, responsibility, work, integration, and verification loop is available today. Automatic participant selection from a broader roster, remote Agents, enterprise governance, and outcome-driven reputation remain future direction.
|
||||
|
||||
### Demo
|
||||
|
||||
| Local Agent discovery | Direct multimodal work | Concurrent multi-Agent work |
|
||||
| Mode | What happens | Best for |
|
||||
| --- | --- | --- |
|
||||
| [](assets/meldwork-agent-discovery.png) | [](assets/meldwork-direct-multimodal.png) | [](assets/meldwork-group-collaboration.png) |
|
||||
| See which supported Agents are ready. | Keep prompts, files, permissions, and compatible sessions together. | Run concurrent replies or an Agent-negotiated collaboration with the participants you selected. |
|
||||
| **Direct** | One selected Agent keeps its conversation and native session when supported. | Focused work with one Agent. |
|
||||
| **Concurrent Responses** | Selected Agents receive the same frozen task snapshot and return independent replies in stable order. | Comparing approaches before choosing one. |
|
||||
| **Auto Discussion V4** | Selected Agents propose, challenge, negotiate responsibilities, execute dependency-aware work, then verify the result. | Multi-round work that needs explicit responsibility and review. |
|
||||
|
||||
## What makes Meldwork different
|
||||
Participants are always selected by the user. Automatic selection from a larger roster is outside the current preview.
|
||||
|
||||
### Harness design
|
||||
## How Meldwork differs
|
||||
|
||||
The Harness is the control plane that keeps conversations, context, adapters, streams, and durable state aligned in one workspace. It is what makes a run continuous across sessions, timeouts, recovery, and Agent changes.
|
||||
Meldwork is a local-first AI Agent workspace for multi-Agent review and decision traceability, not a terminal, cloud Agent fleet, communication network, or programmable orchestration framework. It connects the local Agent tools you already use to a decision-ready review workflow and keeps independent findings, evidence, responsibility, and the human adoption decision visible in one local work cell.
|
||||
|
||||
<p align="center">
|
||||
<img src="frontend/public/logos/Harness-readme.png" alt="Meldwork Harness" width="100%">
|
||||
</p>
|
||||
| Project | Category | What it focuses on | Meldwork's difference |
|
||||
| --- | --- | --- | --- |
|
||||
| [Buzz](https://github.com/block/buzz) | Agent communication network | Identity, channels, events, and persistent collaboration. | Case-scoped independent judgments with evidence-backed Decision and Disposition. |
|
||||
| [Pragma](https://github.com/pqpo/pragma) | Method and workflow runtime | Reusable Expert, Flow, Memory, Evaluation, and DSL assets. | Keeps disagreement, re-checks, and adoption records visible before a workflow is fixed. |
|
||||
| [Munder Difflin](https://github.com/chaitanyagiri/munder-difflin) | Visual Agent company | Boss -> Manager -> Workers, with task and activity status. | Tracks Finding -> Evidence -> Decision -> Disposition without hiding dissent behind a central manager. |
|
||||
| [Superset](https://github.com/superset-sh/superset) / [Conductor](https://www.conductor.build/) / [Vibe Kanban](https://github.com/BloopAI/vibe-kanban) | Parallel coding workspaces | Parallel coding Agents, isolated workspaces, diffs, and merges. | Local-first review across heterogeneous CLIs, not terminal count, worktree throughput, or cloud sandboxes. |
|
||||
| [cmux](https://github.com/manaflow-ai/cmux) | Agent-native terminal | Low-friction terminals, notifications, and multitasking. | Adds frozen context, independent results, and a human adoption gate. |
|
||||
| [Nimbalyst](https://github.com/nimbalyst/nimbalyst) | Agent and artifact workspace | Parallel Agents with Markdown, mockups, and diagrams. | Places evidence and accountable decisions alongside artifacts. |
|
||||
| [Paperclip](https://github.com/paperclipai/paperclip) | Agent organization management | Companies, roles, budgets, approvals, and org charts. | Grounds responsibility in real findings and outcomes, not a virtual company. |
|
||||
| [MCP Agent Mail](https://github.com/Dicklesworthstone/mcp_agent_mail) | Agent communication infrastructure | Identity, inboxes, threads, and advisory file leases. | Preserves Case and review semantics above the transport layer. |
|
||||
| LangGraph / CrewAI / AutoGen / OpenAI Agents SDK / Google ADK | Programmable multi-Agent frameworks | Graphs, roles, routing, tools, and approvals for developers. | Delivers a desktop review workflow without requiring teams to build an orchestration framework. |
|
||||
| Warp Oz / Devin / Factory / OpenHands Cloud | Cloud Agent control planes | Hosted sandboxes, remote execution, and Agent fleets. | Runs in the user's local Electron work cell with existing CLIs and local data boundaries. |
|
||||
|
||||
This is a comparison of working models, not a feature-scorecard claiming Meldwork should replace every tool.
|
||||
## See it in action
|
||||
|
||||
| Approach | Examples | Best at | Main tradeoff | Meldwork's choice |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| One Agent CLI or AI IDE | Claude Code, Codex, Cursor, Windsurf | Deep work inside one Agent or product ecosystem | Moving to another Agent still requires a manual handoff | Preserve work across heterogeneous Agents |
|
||||
| Parallel coding-Agent managers | Superset, Vibe Kanban, Nimbalyst, Conductor | Running multiple coding tasks and isolated workspaces in parallel | Primarily optimized for throughput and code delivery | Optimize proposal quality, responsibility, evidence, and adoption |
|
||||
| Cloud Agent control planes | GitHub Agent HQ, Warp Oz, Devin, Factory, OpenHands Cloud | Dispatching and observing remote jobs, sandboxes, and Agent fleets | Work is organized around cloud or platform infrastructure | Start local-first and bring your existing Agents |
|
||||
| Programmable Agent frameworks | LangGraph, CrewAI, AutoGen, Agents SDK, ADK | Building custom graphs, roles, routing, and automation | Developers must design and maintain the orchestration | Deliver an organization and acceptance experience to users |
|
||||
| Multiple terminals and scripts | tmux, shell scripts, manual copy and paste | Maximum access to native Agent capabilities | The user becomes the router, context bus, and final judge | Make the work, boundaries, and decisions durable |
|
||||
| **Meldwork** | Supported local Agent CLIs; remote supply is direction | Concurrent independent responses and selected-Agent proposal, negotiation, responsibility, evidence, and verification | Local single-user preview today; participants are still selected by the user | Build the organization layer for the Agent workforce |
|
||||
<table>
|
||||
<tr>
|
||||
<th>Local Agent discovery</th>
|
||||
<th>Multi-agent review</th>
|
||||
<th>Direct multimodal work</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="assets/meldwork-agent-discovery.png"><img src="assets/meldwork-agent-discovery.png" alt="Meldwork local-first multi-agent workspace detecting Agent CLIs" width="320" height="205"></a></td>
|
||||
<td align="center"><a href="assets/meldwork-multi-agent-review.png"><img src="assets/meldwork-multi-agent-review.png" alt="Meldwork multi-agent collaboration with evidence and human review" width="320" height="205"></a></td>
|
||||
<td align="center"><a href="assets/meldwork-direct-multimodal.png"><img src="assets/meldwork-direct-multimodal.png" alt="Meldwork direct Agent workspace with local files and media" width="320" height="205"></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
If one Agent and one work surface are enough, use the native tool. Use Meldwork when handoff, independent judgment, authority, evidence, and acceptance become part of the problem.
|
||||
Example: give Codex, Claude Code, and Gemini CLI the same change-review context, compare their independent findings, then adopt only the evidence-backed result you approve.
|
||||
|
||||
## Install
|
||||
## Supported local Agent CLIs
|
||||
|
||||
The current release-validated target is Apple silicon macOS.
|
||||
Meldwork detects and invokes an installed command when its adapter and the CLI version are compatible:
|
||||
|
||||
1. Open the [Meldwork V1.0.3 prerelease](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.3).
|
||||
2. Download Meldwork-0.1.3-arm64.dmg.
|
||||
3. Drag Meldwork into Applications and try to open it once.
|
||||
4. This prerelease is ad-hoc signed, not signed with an Apple Developer ID, and not Apple-notarized. If macOS blocks it, open **System Settings → Privacy & Security**, find the Meldwork warning, choose **Open Anyway**, and confirm.
|
||||
5. Connect a supported Agent CLI already installed on the computer, or configure an independent Provider profile where supported.
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/codex.svg" alt="Codex logo" width="32" height="32"><br><strong>Codex</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/hermes.png" alt="Hermes logo" width="32" height="32"><br><strong>Hermes</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/openclaw-transparent.png" alt="OpenClaw logo" width="32" height="32"><br><strong>OpenClaw</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/workbuddy.png" alt="WorkBuddy logo" width="32" height="32"><br><strong>WorkBuddy</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/pi.svg" alt="Pi Agent logo" width="32" height="32"><br><strong>Pi Agent</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/kimi.png" alt="Kimi Code logo" width="32" height="32"><br><strong>Kimi Code</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/mimo.svg" alt="MiMo Code logo" width="32" height="32"><br><strong>MiMo Code</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/claude.png" alt="Claude Code logo" width="32" height="32"><br><strong>Claude Code</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/gemini.svg" alt="Gemini CLI logo" width="32" height="32"><br><strong>Gemini CLI</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/opencode.svg" alt="OpenCode logo" width="32" height="32"><br><strong>OpenCode</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/qwen.svg" alt="Qwen Code logo" width="32" height="32"><br><strong>Qwen Code</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/opencodereview.svg" alt="OpenCodeReview logo" width="32" height="32"><br><strong>OpenCodeReview</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Use only artifacts downloaded from the official Release.
|
||||
Approved Agent Connectors can be added through the [Agent Connector SDK](docs/agent-connector-sdk.md); custom executable Agents use the desktop custom-Agent path. See the [desktop guide](desktop/README.md) for the full adapter and capability matrix.
|
||||
|
||||
<details>
|
||||
<summary><strong>Build from source</strong></summary>
|
||||
## Quick start
|
||||
|
||||
Requires Node.js 22.12 or newer and npm.
|
||||
### Apple silicon macOS preview
|
||||
|
||||
~~~bash
|
||||
Download [`Meldwork-0.1.3-arm64.dmg`](https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.3/Meldwork-0.1.3-arm64.dmg) from the [official V1.0.3 prerelease](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.3), move Meldwork to Applications, and install at least one supported local Agent CLI. The prerelease is ad-hoc signed and not notarized; macOS may require **Open Anyway** in **System Settings -> Privacy & Security** on first launch.
|
||||
|
||||
### Run from source
|
||||
|
||||
Prerequisites: Node.js `22.12+` and npm.
|
||||
|
||||
```bash
|
||||
npm --prefix frontend ci
|
||||
npm --prefix desktop ci
|
||||
npm --prefix desktop run dev
|
||||
~~~
|
||||
```
|
||||
|
||||
Run the repository checks before distributing a build:
|
||||
Before distributing a build:
|
||||
|
||||
~~~bash
|
||||
```bash
|
||||
npm --prefix frontend test
|
||||
npm --prefix frontend run build
|
||||
npm --prefix frontend run build:desktop
|
||||
npm --prefix desktop test
|
||||
~~~
|
||||
```
|
||||
|
||||
</details>
|
||||
## Current boundary
|
||||
|
||||
## Trust boundaries
|
||||
Meldwork is a local Electron app, not a hosted Agent fleet or a general Agent framework. The current preview does not provide remote/cloud/channel Agent execution, automatic participant selection, enterprise SSO/RBAC/governance, or an Outcome Network. Local-first does not mean fully offline: a selected Agent may send prompts, attachments, or Skills to its configured Provider. Workspace writes are opt-in workflow controls, not an operating-system sandbox.
|
||||
|
||||
- Conversations and Meldwork orchestration state stay local.
|
||||
- Model requests still follow the Agent and Provider you choose; local-first does not mean every model runs offline.
|
||||
- Credentials use operating-system-backed secure storage where supported.
|
||||
- Workspace writes are explicit and opt-in.
|
||||
- Raw chain-of-thought, executable paths, secrets, and unrestricted command output are not exposed to the renderer.
|
||||
- Cloud, Channel, automatic participant selection, enterprise governance, and an Outcome Network are not current release promises.
|
||||
## Docs
|
||||
|
||||
Read the [architecture](architecture.md) and [Agent Connector contract](docs/agent-connector-sdk.md) for the public technical boundary.
|
||||
- [Architecture and product boundary](architecture.md)
|
||||
- [Desktop setup and Agent matrix](desktop/README.md)
|
||||
- [Agent Connector SDK](docs/agent-connector-sdk.md)
|
||||
- [AI discoverability index](docs/ai-discoverability.md)
|
||||
- [Contributing](CONTRIBUTING.md)
|
||||
- [Security policy](SECURITY.md)
|
||||
|
||||
## License
|
||||
|
||||
Meldwork is source-available under the [Meldwork Non-Commercial Source License](LICENSE). You may inspect, run, modify, and share it for non-commercial purposes. Commercial use requires prior written permission; see [COMMERCIAL_USE.md](COMMERCIAL_USE.md).
|
||||
Meldwork is source-available under the [Meldwork Non-Commercial Source License 1.0](LICENSE). You may inspect, run, modify, and share it for non-commercial purposes. Commercial use requires prior written permission; see [COMMERCIAL_USE.md](COMMERCIAL_USE.md).
|
||||
+87
-62
@@ -26,103 +26,128 @@
|
||||
· <a href="LICENSE">许可证</a>
|
||||
</p>
|
||||
|
||||
## Agent workforce 缺少的那一层
|
||||
## 适合谁
|
||||
|
||||
Agent 的供给正在快速增长,但围绕它们工作的组织能力并没有跟上。
|
||||
当你有以下需求时,Meldwork 更适合:
|
||||
|
||||
今天,人类仍然需要自己选 Agent、搬运上下文、解释角色、处理冲突答案,并承担最终责任。并行管理器优化吞吐量,多 Agent 框架让开发者编写 Workflow,单 Agent 产品优化一个 Worker。
|
||||
- 已经安装多个本地 Agent CLI,希望让同一任务跨 Agent 协作;
|
||||
- 需要在代码评审、研究或产品分析中获得独立判断,再选择方案;
|
||||
- 需要在写入工作区前检查可追溯的运行轨迹、证据并保留人工复核。
|
||||
|
||||
Meldwork 关注的是中间那层工作:
|
||||
## 工作流程
|
||||
|
||||
- 一个共享 Goal 和明确验收边界;
|
||||
- 独立判断,而不是隐藏路由;
|
||||
- 明确的权限、责任、交接和停止条件;
|
||||
- 不随 Agent 切换而丢失的 Artifact 与 Evidence;
|
||||
- 由人类掌握的最终采用决定。
|
||||
1. **选择**本地 Agent 和参与者。
|
||||
2. **限定**目标、工作目录、上下文和权限。
|
||||
3. **运行**直接会话、并发回复或 Auto Discussion V4。
|
||||
4. **复核并采用**结果、证据和待处理的人工审批门(Human Gate),再写入工作区。
|
||||
|
||||
## 协作模式
|
||||
|
||||
## Meldwork 正在构建什么
|
||||
|
||||
当前协作机制不是 Harness 暗中预设的 A → B → C 分配链。用户选定参与者后,Agent 先展开解空间、自主协商职责,再在 Harness 管理的上下文、权限、预算、恢复和证据边界内执行。
|
||||
|
||||
已选 Agent 的独立提案、交叉质询、职责协商、分工执行、整合和复核现在已经可用。从更大候选池自动选人组队、远程 Agent、企业治理和 Outcome 驱动的声誉系统仍是未来方向。
|
||||
|
||||
### 效果演示
|
||||
|
||||
| 本地 Agent 检测 | 单聊中的多模态工作 | 并发多 Agent 协作 |
|
||||
| 模式 | 运行方式 | 适合场景 |
|
||||
| --- | --- | --- |
|
||||
| [](assets/meldwork-agent-discovery.png) | [](assets/meldwork-direct-multimodal.png) | [](assets/meldwork-group-collaboration.png) |
|
||||
| 查看哪些受支持 Agent 已就绪。 | Prompt、文件、权限和兼容 Session 留在一起。 | 让已选 Agent 并发回复,或进入由 Agent 自主协商职责的协作流程。 |
|
||||
| **直接会话** | 一个已选 Agent 在适配器支持时保持对话和原生会话。 | 使用单个 Agent 完成聚焦工作。 |
|
||||
| **并发回复** | 已选 Agent 收到同一份冻结任务快照,并按稳定顺序返回独立回复。 | 在选择方案前比较不同路径。 |
|
||||
| **Auto Discussion V4** | 已选 Agent 先提案、质询并协商责任,按依赖执行工作,再独立复核结果。 | 需要明确责任和复核的多轮工作。 |
|
||||
|
||||
参与者始终由用户选择。从更大候选池自动选人不属于当前预览版。
|
||||
|
||||
## Meldwork 和别的产品有什么区别
|
||||
## Meldwork 与相邻产品的区别
|
||||
|
||||
### Harness设计
|
||||
Meldwork 是面向多 Agent 评审与决策可追溯性的本地优先 AI Agent 工作空间,不是终端、云端 Agent Fleet、通信网络或可编程编排框架。它把你已经在使用的本地 Agent 工具连接到可形成决定的评审流程,并在一个本地工作单元中保留独立发现、证据、责任和人工采用决定。
|
||||
|
||||
Harness 是把会话、上下文、适配器、流和持久化状态拴在一起的控制平面。它让一次任务可以跨会话、超时、恢复和 Agent 切换继续下去。
|
||||
| 项目 | 类别 | 主要解决什么 | Meldwork 的区别 |
|
||||
| --- | --- | --- | --- |
|
||||
| [Buzz](https://github.com/block/buzz) | Agent 通信网络 | 身份、频道、事件和持续协作。 | 面向单个 Case 的独立判断,以及有证据支持的 Decision 和 Disposition。 |
|
||||
| [Pragma](https://github.com/pqpo/pragma) | 方法与工作流运行时 | 可复用的 Expert、Flow、Memory、Evaluation 和 DSL 资产。 | 在流程固定前保留分歧、复验和采用记录。 |
|
||||
| [Munder Difflin](https://github.com/chaitanyagiri/munder-difflin) | 可视化 Agent 公司 | Boss -> Manager -> Workers,以及任务和活动状态。 | 追踪 Finding -> Evidence -> Decision -> Disposition,不让中心经理隐藏分歧。 |
|
||||
| [Superset](https://github.com/superset-sh/superset) / [Conductor](https://www.conductor.build/) / [Vibe Kanban](https://github.com/BloopAI/vibe-kanban) | 并行编码工作台 | 并行 Coding Agent、隔离工作区、Diff 和 Merge。 | 面向异构 CLI 的本地优先评审,不以终端数量、Worktree 吞吐量或云 Sandbox 竞争。 |
|
||||
| [cmux](https://github.com/manaflow-ai/cmux) | Agent 原生终端 | 低摩擦终端、通知和多任务处理。 | 增加冻结上下文、独立结果和人工采用门。 |
|
||||
| [Nimbalyst](https://github.com/nimbalyst/nimbalyst) | Agent 与 Artifact 工作空间 | 并行 Agent,以及 Markdown、Mockup 和 Diagram。 | 在 Artifact 旁边保留证据和可追责的决定。 |
|
||||
| [Paperclip](https://github.com/paperclipai/paperclip) | Agent 组织管理 | 公司、岗位、预算、审批和组织图。 | 将责任落在真实 Finding 和结果上,而不是虚拟公司。 |
|
||||
| [MCP Agent Mail](https://github.com/Dicklesworthstone/mcp_agent_mail) | Agent 通信基础设施 | 身份、收件箱、Thread 和 advisory file lease。 | 在传输层之上保留 Case 和评审语义。 |
|
||||
| LangGraph / CrewAI / AutoGen / OpenAI Agents SDK / Google ADK | 可编程多 Agent 框架 | 面向开发者的 Graph、角色、路由、工具和审批。 | 团队无需编写编排框架代码即可获得桌面评审流程。 |
|
||||
| Warp Oz / Devin / Factory / OpenHands Cloud | 云端 Agent 控制面 | 托管 Sandbox、远程执行和 Agent Fleet。 | 在本地 Electron 工作单元中使用现有 CLI,并保持本地数据边界。 |
|
||||
|
||||
<p align="center">
|
||||
<img src="frontend/public/logos/Harness-readme.png" alt="Meldwork Harness" width="100%">
|
||||
</p>
|
||||
## 运行示例
|
||||
|
||||
这张表比较的是不同工作范式,不是一张声称 Meldwork 应当替代所有工具的功能勾选表。
|
||||
<table>
|
||||
<tr>
|
||||
<th>本地 Agent 检测</th>
|
||||
<th>多 Agent 评审</th>
|
||||
<th>直接会话中的多模态工作</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="assets/meldwork-agent-discovery.png"><img src="assets/meldwork-agent-discovery.png" alt="Meldwork 本地优先多 Agent 工作空间检测 Agent CLI" width="320" height="205"></a></td>
|
||||
<td align="center"><a href="assets/meldwork-multi-agent-review.png"><img src="assets/meldwork-multi-agent-review.png" alt="Meldwork 多 Agent 协作、证据与人工复核" width="320" height="205"></a></td>
|
||||
<td align="center"><a href="assets/meldwork-direct-multimodal.png"><img src="assets/meldwork-direct-multimodal.png" alt="Meldwork 直接 Agent 工作空间处理本地文件和媒体" width="320" height="205"></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
| 范式 | 例子 | 最擅长什么 | 主要代价 | Meldwork 的选择 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 单 Agent CLI / AI IDE | Claude Code、Codex、Cursor、Windsurf | 在一个 Agent 或产品生态内完成深度工作 | 换 Agent 时仍需手工交接 | 保留跨异构 Agent 的工作 |
|
||||
| 并行 Coding-Agent 工作台 | Superset、Vibe Kanban、Nimbalyst、Conductor | 并行运行多个 Coding 任务和隔离工作区 | 主要优化吞吐量和代码交付 | 优化方案质量、责任、证据和采用 |
|
||||
| Cloud Agent 控制面 | GitHub Agent HQ、Warp Oz、Devin、Factory、OpenHands Cloud | 派发和观察远程 Job、Sandbox 与 Agent Fleet | 工作围绕云基础设施和平台边界 | 先从 Local-first 和 BYO Agent 切入 |
|
||||
| 可编程多 Agent 框架 | LangGraph、CrewAI、AutoGen、Agents SDK、ADK | 开发者自定义 Graph、角色、路由和自动化 | 需要开发者设计并维护编排 | 面向使用者交付组织与验收体验 |
|
||||
| 多终端与脚本 | tmux、Shell、手工复制粘贴 | 最大化原生 Agent 访问权 | 人类成为 Router、Context Bus 和最终裁决者 | 让工作、边界和决定保持持续 |
|
||||
| **Meldwork** | 已支持本地 Agent CLI;远程供给属于方向 | 并发独立回复,以及已选 Agent 之间的提案、协商、责任、Evidence 和复核 | 当前仍是本地单用户预览;参与者仍由用户选定 | 构建 Agent workforce 的组织层 |
|
||||
示例:把同一份变更评审上下文交给 Codex、Claude Code 和 Gemini CLI,比较各自的独立发现,再只采用你批准且有证据支持的结果。
|
||||
|
||||
如果一个 Agent 和一个工作面已经足够,直接使用原生工具。只有当交接、独立判断、权限、证据和验收成为问题时,才需要 Meldwork。
|
||||
## 支持的本地 Agent CLI
|
||||
|
||||
## 安装
|
||||
当适配器和 CLI 版本兼容时,Meldwork 会检测并调用已安装的命令:
|
||||
|
||||
当前经过发布验证的目标是 Apple 芯片 macOS。
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/codex.svg" alt="Codex logo" width="32" height="32"><br><strong>Codex</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/hermes.png" alt="Hermes logo" width="32" height="32"><br><strong>Hermes</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/openclaw-transparent.png" alt="OpenClaw logo" width="32" height="32"><br><strong>OpenClaw</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/workbuddy.png" alt="WorkBuddy logo" width="32" height="32"><br><strong>WorkBuddy</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/pi.svg" alt="Pi Agent logo" width="32" height="32"><br><strong>Pi Agent</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/kimi.png" alt="Kimi Code logo" width="32" height="32"><br><strong>Kimi Code</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/mimo.svg" alt="MiMo Code logo" width="32" height="32"><br><strong>MiMo Code</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/claude.png" alt="Claude Code logo" width="32" height="32"><br><strong>Claude Code</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/gemini.svg" alt="Gemini CLI logo" width="32" height="32"><br><strong>Gemini CLI</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/opencode.svg" alt="OpenCode logo" width="32" height="32"><br><strong>OpenCode</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/qwen.svg" alt="Qwen Code logo" width="32" height="32"><br><strong>Qwen Code</strong></td>
|
||||
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/opencodereview.svg" alt="OpenCodeReview logo" width="32" height="32"><br><strong>OpenCodeReview</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
1. 打开 [Meldwork V1.0.3 预发布页](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.3)。
|
||||
2. 下载 Meldwork-0.1.3-arm64.dmg。
|
||||
3. 将 Meldwork 拖入“应用程序”,然后先尝试打开一次。
|
||||
4. 当前预发布版使用临时签名,没有 Apple Developer ID 签名,也尚未经过 Apple 公证。如果 macOS 阻止启动,请打开“系统设置 → 隐私与安全性”,找到 Meldwork 提示,点击“仍要打开”并确认。
|
||||
5. 连接电脑上已安装的受支持 Agent CLI,或在兼容情况下单独配置 Provider。
|
||||
已批准的 Agent Connector 可通过 [Agent Connector SDK](docs/agent-connector-sdk.md) 接入;自定义可执行 Agent 使用桌面端的自定义 Agent 入口。完整适配器和能力矩阵见[桌面端指南](desktop/README.md)。
|
||||
|
||||
请只使用官方 Release 下载的产物。
|
||||
## 快速开始
|
||||
|
||||
<details>
|
||||
<summary><strong>从源码运行</strong></summary>
|
||||
### Apple 芯片 macOS 预览版
|
||||
|
||||
需要 Node.js 22.12 或更高版本以及 npm。
|
||||
从[官方 V1.0.3 预发布页](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.3)下载 [`Meldwork-0.1.3-arm64.dmg`](https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.3/Meldwork-0.1.3-arm64.dmg),将 Meldwork 移入“应用程序”,并至少安装一个受支持的本地 Agent CLI。该预发布版使用 ad-hoc 临时签名且未公证;首次启动时,macOS 可能要求你在“系统设置 -> 隐私与安全性”中选择“仍要打开 / Open Anyway”。
|
||||
|
||||
~~~bash
|
||||
### 从源码运行
|
||||
|
||||
前置条件:Node.js `22.12+` 和 npm。
|
||||
|
||||
```bash
|
||||
npm --prefix frontend ci
|
||||
npm --prefix desktop ci
|
||||
npm --prefix desktop run dev
|
||||
~~~
|
||||
```
|
||||
|
||||
分发前运行:
|
||||
分发构建前运行:
|
||||
|
||||
~~~bash
|
||||
```bash
|
||||
npm --prefix frontend test
|
||||
npm --prefix frontend run build
|
||||
npm --prefix frontend run build:desktop
|
||||
npm --prefix desktop test
|
||||
~~~
|
||||
```
|
||||
|
||||
</details>
|
||||
## 当前边界
|
||||
|
||||
## 信任边界
|
||||
Meldwork 是本地 Electron 应用,不是托管式 Agent Fleet,也不是通用 Agent 框架。当前预览版不提供远程、云端或频道 Agent 执行,不提供自动选人组队、企业 SSO/RBAC/治理或 Outcome Network。Local-first 不等于完全离线:选定的 Agent 仍可能把 Prompt、附件或 Skill(技能)发送到其配置的 Provider(模型服务商)。工作区写入是可选的工作流控制,不是操作系统级沙箱。
|
||||
|
||||
- Meldwork 会话和编排状态保存在本地。
|
||||
- 模型请求仍然按照你选择的 Agent 和 Provider 发出;Local-first 不等于所有模型离线运行。
|
||||
- 兼容环境使用操作系统安全存储保存凭据。
|
||||
- 工作目录写入必须显式开启。
|
||||
- Renderer 不暴露 raw chain-of-thought、可执行路径、Secret 或不受限命令输出。
|
||||
- Cloud、Channel、自动选人组队、企业治理和 Outcome Network 不是当前发布承诺。
|
||||
## 文档
|
||||
|
||||
公开技术边界见[架构](architecture.md)和 [Agent Connector 契约](docs/agent-connector-sdk.md)。
|
||||
- [架构与产品边界](architecture.md)
|
||||
- [桌面端设置与 Agent 矩阵](desktop/README.md)
|
||||
- [Agent Connector SDK](docs/agent-connector-sdk.md)
|
||||
- [AI 可发现性索引](docs/ai-discoverability.md)
|
||||
- [贡献指南](CONTRIBUTING.md)
|
||||
- [安全策略](SECURITY.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
Meldwork 采用 [Meldwork 非商用源码许可](LICENSE)。你可以为非商用目的查看、运行、修改和分享;商业使用需要事先书面许可,详见 [COMMERCIAL_USE.md](COMMERCIAL_USE.md)。
|
||||
Meldwork 采用 [Meldwork 非商用源码许可 1.0](LICENSE)。你可以出于非商业目的查看、运行、修改和分享。商业使用需要事先书面许可,详见 [COMMERCIAL_USE.md](COMMERCIAL_USE.md)。
|
||||
@@ -210,6 +210,15 @@ function normalizeExternalRunRef(value) {
|
||||
return EXTERNAL_RUN_REF.test(ref) ? ref : ''
|
||||
}
|
||||
|
||||
function terminalAuthenticationDiagnostic(value) {
|
||||
const text = String(value || '').replace(/\s+/g, ' ').trim()
|
||||
if (!text || text.length > 2000) return ''
|
||||
return /^HTTP\s+(?:401|403)\s*:\s*(?:(?:unauthorized|forbidden)\b|(?:invalid|expired|missing)\b[\s\S]*(?:token|credential|api[ _-]?key|auth))/i
|
||||
.test(text)
|
||||
? text
|
||||
: ''
|
||||
}
|
||||
|
||||
function failureCategory(code) {
|
||||
const value = String(code || '')
|
||||
if (value === 'LOCAL_AGENT_SESSION_INVALID') return 'session'
|
||||
@@ -299,6 +308,10 @@ function requireTerminalAgentResult(result) {
|
||||
failure,
|
||||
)
|
||||
}
|
||||
const authenticationDiagnostic = terminalAuthenticationDiagnostic(normalized.text)
|
||||
if (authenticationDiagnostic) {
|
||||
throw agentRuntimeError('LOCAL_AGENT_AUTH_REQUIRED', authenticationDiagnostic)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -332,4 +345,5 @@ module.exports = {
|
||||
normalizeOutcome,
|
||||
outcomeForAcpStopReason,
|
||||
requireTerminalAgentResult,
|
||||
terminalAuthenticationDiagnostic,
|
||||
}
|
||||
@@ -745,7 +745,7 @@ function publicSessionRef(value, childEnv) {
|
||||
return safe.includes('[redacted]') ? '' : safe
|
||||
}
|
||||
|
||||
async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
|
||||
async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
if (runtime.closed || runtime.failureError) {
|
||||
throw runtime.failureError || agentExecutionError('LOCAL_AGENT_PROCESS_FAILED')
|
||||
}
|
||||
@@ -812,9 +812,6 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
|
||||
|
||||
try {
|
||||
if (!protocolSessionRef) {
|
||||
if (persistent && options.sessionRef) {
|
||||
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
|
||||
}
|
||||
if (spec.acpSessionStrategy !== 'new' && options.sessionRef) {
|
||||
protocolSessionRef = String(options.sessionRef)
|
||||
await operation(runtime.connection.resumeSession({
|
||||
@@ -955,9 +952,6 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
|
||||
runtime = null
|
||||
if (options.sessionRef) throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
|
||||
}
|
||||
if (!runtime && options.sessionRef) {
|
||||
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
|
||||
}
|
||||
if (!runtime) {
|
||||
if (shutdownGeneration !== acpShutdownGeneration) {
|
||||
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
@@ -969,7 +963,7 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
|
||||
}
|
||||
runtime.active = true
|
||||
try {
|
||||
return await runAcpTurn(runtime, prompt, options, spec, profile, true)
|
||||
return await runAcpTurn(runtime, prompt, options, spec, profile)
|
||||
} catch (error) {
|
||||
acpSessionRuntimes.delete(key)
|
||||
await closeAcpRuntime(runtime)
|
||||
@@ -1003,7 +997,7 @@ async function runDisposableAcpAgent(agent, prompt, workdir, options, spec, prof
|
||||
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
}
|
||||
acpDisposableRuntimes.add(runtime)
|
||||
return await runAcpTurn(runtime, prompt, options, spec, profile, false)
|
||||
return await runAcpTurn(runtime, prompt, options, spec, profile)
|
||||
} finally {
|
||||
if (runtime) {
|
||||
acpDisposableRuntimes.delete(runtime)
|
||||
|
||||
@@ -416,6 +416,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
})
|
||||
let result = structuredOutput.end({
|
||||
sessionRef: nextSessionRef,
|
||||
prompt,
|
||||
})
|
||||
const rawStdout = stdout.text()
|
||||
const structuredDetail = structuredCliError(rawStdout)
|
||||
|
||||
@@ -91,10 +91,49 @@ function appendStructuredText(state, value, separator = '\n') {
|
||||
}
|
||||
|
||||
function stripPiInternalPromptEcho(value) {
|
||||
const text = String(value || '').trim()
|
||||
let text = String(value || '').trim()
|
||||
if (!text) return ''
|
||||
const preamble = /^You are participating in a local multi-agent discussion\.[\s\S]*?^Answer the user naturally(?: in Markdown)?\.?\s*/im
|
||||
return text.replace(preamble, '').trim()
|
||||
text = text.replace(preamble, '').trim()
|
||||
// Pi may resume from a compact continuation prompt and echo only the
|
||||
// protocol instructions or the selected Harness records. Remove those
|
||||
// internal lines while preserving any user-facing text on the same line.
|
||||
text = text.replace(/^Do not output JSON, XML, receipt markers, protocol labels, hidden orchestration instructions, or a fixed response template\.\s*/im, '')
|
||||
text = text.replace(/^Do not claim another Agent performed work, and do not modify shared workspace state unless this turn explicitly grants that permission\.\s*/im, '')
|
||||
const lines = text.split(/\r?\n/)
|
||||
const cleaned = []
|
||||
let inRecords = false
|
||||
for (const line of lines) {
|
||||
const valueLine = line.trim()
|
||||
if (/^Selected records from completed phases:\s*$/i.test(valueLine)) {
|
||||
inRecords = true
|
||||
continue
|
||||
}
|
||||
if (inRecords) {
|
||||
if (/^MELDWORK_V4_COLLABORATION_PACKAGE_V1$/i.test(valueLine)
|
||||
|| /^\[index\]\s+\S+/i.test(valueLine)
|
||||
|| /^Artifact:\s+\S+/i.test(valueLine)
|
||||
|| /^Evidence:\s+\S+/i.test(valueLine)) continue
|
||||
const phasePrefix = /^(?:\[(?:challenge|proposal)\]\s+agent=\S+\s+Completed the (?:challenge|proposal) phase\.\s*)/i
|
||||
const remainder = line.replace(phasePrefix, '')
|
||||
if (remainder !== line) {
|
||||
if (remainder.trim()) cleaned.push(remainder)
|
||||
continue
|
||||
}
|
||||
inRecords = false
|
||||
}
|
||||
cleaned.push(line)
|
||||
}
|
||||
return cleaned.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
function stripPiPromptEcho(value, prompt) {
|
||||
const text = String(value || '').trim()
|
||||
const expected = String(prompt || '').trim()
|
||||
if (expected && text.startsWith(expected)) {
|
||||
return text.slice(expected.length).trim()
|
||||
}
|
||||
return stripPiInternalPromptEcho(text)
|
||||
}
|
||||
|
||||
function stripExactMeldworkPromptEcho(value, prompt) {
|
||||
@@ -197,7 +236,6 @@ function ingestStructuredEvent(state, event) {
|
||||
}
|
||||
if (event.type === 'agent_end') state.outcome = 'completed'
|
||||
if (state.outcome === 'failed') markStructuredFailure(state)
|
||||
state.text = stripPiInternalPromptEcho(state.text)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -320,7 +358,7 @@ function openCodeReviewTerminal(status, manifest, manifestPresent = manifest !=
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeStructuredState(state) {
|
||||
function finalizeStructuredState(state, context = {}) {
|
||||
if (state.parseError) {
|
||||
return {
|
||||
text: '',
|
||||
@@ -362,7 +400,9 @@ function finalizeStructuredState(state) {
|
||||
if (!state.seen && !state.text && !state.outcome) return null
|
||||
const outcome = state.outcome || (state.text ? 'partial' : 'failed')
|
||||
const result = {
|
||||
text: state.kind === 'pi' ? stripPiInternalPromptEcho(state.text) : state.text.trim(),
|
||||
text: state.kind === 'pi'
|
||||
? stripPiPromptEcho(state.text, context.prompt)
|
||||
: state.text.trim(),
|
||||
sessionRef: state.sessionRef,
|
||||
outcome,
|
||||
}
|
||||
@@ -395,7 +435,7 @@ function createStructuredOutputAccumulator(kind, sessionRef = '') {
|
||||
return {
|
||||
format: 'jsonl',
|
||||
ingest: event => ingestStructuredEvent(state, event),
|
||||
end: () => finalizeStructuredState(state),
|
||||
end: context => finalizeStructuredState(state, context),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ function normalizeAttachmentPickLimit(value) {
|
||||
return Math.max(1, Math.min(MAX_ATTACHMENT_PICK_REQUEST, Math.floor(limit)))
|
||||
}
|
||||
|
||||
function createAttachmentService({ getStore, getSnapshot, nativeImage, openPath }) {
|
||||
function createAttachmentService({ getStore, getSnapshot, nativeImage, openPath, showSaveDialog }) {
|
||||
function availableStore() {
|
||||
const store = getStore()
|
||||
if (!store) throw new Error('LOCAL_ATTACHMENT_STORAGE_UNAVAILABLE')
|
||||
@@ -205,6 +205,18 @@ function createAttachmentService({ getStore, getSnapshot, nativeImage, openPath
|
||||
return true
|
||||
}
|
||||
|
||||
async function save(id) {
|
||||
if (typeof showSaveDialog !== 'function') throw new Error('LOCAL_ATTACHMENT_SAVE_UNAVAILABLE')
|
||||
const entry = availableStore().resolve([String(id || '')])[0]
|
||||
const result = await showSaveDialog({
|
||||
defaultPath: entry.name,
|
||||
showOverwriteConfirmation: true,
|
||||
})
|
||||
if (result?.canceled || !result?.filePath) return { saved: false }
|
||||
fs.writeFileSync(result.filePath, availableStore().readWithMetadata(entry.id).bytes)
|
||||
return { saved: true }
|
||||
}
|
||||
|
||||
return {
|
||||
availableStore,
|
||||
discardUnreferenced,
|
||||
@@ -212,6 +224,7 @@ function createAttachmentService({ getStore, getSnapshot, nativeImage, openPath
|
||||
importFiles,
|
||||
normalizePickLimit: normalizeAttachmentPickLimit,
|
||||
open,
|
||||
save,
|
||||
preview,
|
||||
registerProtocol: protocol => protocol?.handle?.(MEDIA_SCHEME, request => mediaResponse(request)),
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ const V4_TEMPLATES = new Set(['concurrent-batch', 'discussion'])
|
||||
const V4_WORKFLOWS = new Set(['manual', 'auto', 'concurrent-batch', 'discussion'])
|
||||
const V4_PHASES = new Set([
|
||||
'prepare', 'dispatch', 'running', 'reconcile',
|
||||
'proposal', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
|
||||
'proposal', 'discussion', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
|
||||
'commit', 'committed', 'completed', 'failed', 'stopped', 'human-gate',
|
||||
])
|
||||
const V4_SLOT_STATUSES = new Set([
|
||||
@@ -60,7 +60,7 @@ const MESSAGE_ID = /^message-[a-f0-9]{64}$/u
|
||||
const EVIDENCE_ID = /^evidence-[a-f0-9]{64}$/u
|
||||
|
||||
const ORCHESTRATION_FIELDS = new Set([
|
||||
'version', 'workflow', 'template', 'phase', 'batchId', 'round',
|
||||
'version', 'workflow', 'template', 'phase', 'discussionStyle', 'batchId', 'round',
|
||||
'currentKind', 'currentKinds', 'pendingKinds', 'activeKinds',
|
||||
'successfulKinds', 'agreementKinds', 'attachmentRecipients',
|
||||
'totalSuccesses', 'terminalFailureOccurred', 'collaboration', 'taskGraph',
|
||||
@@ -1784,6 +1784,14 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
}
|
||||
const { workflow, template } = normalizeWorkflow(input.workflow, input.template)
|
||||
const phase = phaseForTemplate(template, String(input.phase || ''))
|
||||
const discussionStyle = hasOwn(input, 'discussionStyle')
|
||||
? String(input.discussionStyle || '')
|
||||
: ''
|
||||
if (discussionStyle && (
|
||||
workflow !== 'auto'
|
||||
|| template !== 'discussion'
|
||||
|| !['sequential', 'agent-led'].includes(discussionStyle)
|
||||
)) fail('ORCHESTRATION_V4_SCHEMA_INVALID')
|
||||
const batchId = cleanId(input.batchId)
|
||||
const snapshotHash = String(input.snapshotHash || '')
|
||||
if (!batchId || !SHA256.test(snapshotHash)) fail('ORCHESTRATION_V4_SCHEMA_INVALID')
|
||||
@@ -2017,6 +2025,7 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
workflow,
|
||||
template,
|
||||
phase,
|
||||
...(discussionStyle ? { discussionStyle } : {}),
|
||||
batchId,
|
||||
currentKinds,
|
||||
activeKinds,
|
||||
|
||||
+1
-45
@@ -47,8 +47,6 @@ const {
|
||||
resolveNativeShellEnvironment,
|
||||
} = require('./agents/local-agent-readiness.cjs')
|
||||
const { LocalWorkspace } = require('./workspace/local-workspace.cjs')
|
||||
const { MediaGenerationRuntime } = require('./media/media-generation-runtime.cjs')
|
||||
const { resolveMediaProvider } = require('./media/media-provider-resolution.cjs')
|
||||
const { registerDesktopIpc } = require('./shell/main-ipc.cjs')
|
||||
const {
|
||||
isAllowedExternalUrl,
|
||||
@@ -135,6 +133,7 @@ const attachments = createAttachmentService({
|
||||
getSnapshot: () => workspace?.snapshot(),
|
||||
nativeImage: electron.nativeImage,
|
||||
openPath: filename => shell.openPath(filename),
|
||||
showSaveDialog: options => dialog.showSaveDialog(mainWindow, options),
|
||||
})
|
||||
|
||||
protocol?.registerSchemesAsPrivileged?.([{
|
||||
@@ -253,37 +252,6 @@ async function detectLocalAgents() {
|
||||
return detectAgents({ env: shellEnvironment.env, home: shellEnvironment.home })
|
||||
}
|
||||
|
||||
function mediaFallbackProviders(env = process.env) {
|
||||
const apiKey = String(
|
||||
env.ZGCI_MEDIA_API_KEY
|
||||
|| env.ZGCI_API_KEY
|
||||
|| env.ZCGI_API_KEY
|
||||
|| env.ZGCI_LLM_API_KEY
|
||||
|| '',
|
||||
).trim()
|
||||
if (!apiKey) return []
|
||||
const baseUrl = String(env.ZGCI_MEDIA_BASE_URL || 'https://hub.zgci.org/v1')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\/chat\/completions$/i, '')
|
||||
let parsed
|
||||
try { parsed = new URL(baseUrl) } catch { return [] }
|
||||
if (parsed.protocol !== 'https:' || parsed.username || parsed.password
|
||||
|| parsed.search || parsed.hash) {
|
||||
return []
|
||||
}
|
||||
return [{
|
||||
kind: 'zgci-media',
|
||||
status: {
|
||||
configured: true,
|
||||
provider: 'ZGCI Media',
|
||||
baseUrl,
|
||||
model: 'glm',
|
||||
},
|
||||
credentials: { OPENAI_API_KEY: apiKey },
|
||||
}]
|
||||
}
|
||||
|
||||
function workspaceStoragePath(userData = app.getPath('userData')) {
|
||||
return path.join(userData, 'meldwork-workspace.json')
|
||||
}
|
||||
@@ -533,17 +501,6 @@ function createWorkspace() {
|
||||
}),
|
||||
connectors: [],
|
||||
})
|
||||
const mediaGeneration = new MediaGenerationRuntime({
|
||||
getProvider: (kind, type, excludedKinds) => resolveMediaProvider({
|
||||
requestedKind: kind,
|
||||
type,
|
||||
kinds: EXTERNAL_PROVIDER_KINDS,
|
||||
excludedKinds,
|
||||
statusFor: candidateKind => providerStore.status(candidateKind),
|
||||
credentialsFor: candidateKind => providerStore.envForAgent(candidateKind),
|
||||
fallbackProviders: mediaFallbackProviders(),
|
||||
}),
|
||||
})
|
||||
const localWorkspace = new LocalWorkspace({
|
||||
storagePath: workspaceStoragePath(),
|
||||
contentBlobStore,
|
||||
@@ -589,7 +546,6 @@ function createWorkspace() {
|
||||
sharedProviderReady: kind => EXTERNAL_PROVIDER_KINDS.has(kind) && providerStore.status(kind).configured,
|
||||
connectorRuntime: agentConnectors,
|
||||
naturalAgentResponses: true,
|
||||
generateMedia: input => mediaGeneration.generate(input),
|
||||
runAgent: async (agent, prompt, workdir, options = {}) => {
|
||||
if (customAgentStore.has(agent.kind)) {
|
||||
return customAgentStore.run(agent.kind, prompt, workdir, {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
const { cleanText } = require('../workspace/local-workspace-contracts.cjs')
|
||||
|
||||
const MEDIA_TERMS = Object.freeze([
|
||||
['video', /(?:视频|影片|短片|video|film)/i],
|
||||
['audio', /(?:音频|语音|配音|音乐|audio|voice|speech|music)/i],
|
||||
['image', /(?:图片|图像|照片|插画|海报|image|photo|illustration|poster)/i],
|
||||
])
|
||||
const REQUEST_TERM = /(?:请|帮我|给我|生成|创建|制作|画(?:一|个)?|做(?:一|个)?|create|generate|make|draw|render|produce)/i
|
||||
const QUESTION_PREFIX = /^(?:能否|可以(?:吗)?|是否|怎么|如何|为什么|解释|说明|介绍|分析|can\s+(?:you|codex)|how\s+(?:do|to)|why\b)/i
|
||||
|
||||
function mediaGenerationRequest(value) {
|
||||
const prompt = cleanText(value, 12000)
|
||||
if (!prompt || QUESTION_PREFIX.test(prompt) || /(?:吗|?|\?)\s*$/.test(prompt)
|
||||
|| !REQUEST_TERM.test(prompt)) return null
|
||||
const match = MEDIA_TERMS.find(([, expression]) => expression.test(prompt))
|
||||
return match ? { type: match[0], prompt } : null
|
||||
}
|
||||
|
||||
module.exports = { mediaGenerationRequest }
|
||||
@@ -1,491 +0,0 @@
|
||||
const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const OUTPUT_DIRECTORY = '.meldwork-output'
|
||||
const MAX_MEDIA_BYTES = 128 * 1024 * 1024
|
||||
const POLL_INTERVAL_MS = 10_000
|
||||
const PROVIDER_ATTEMPTS = 3
|
||||
const RETRYABLE_PROVIDER_STATUSES = new Set([429, 502, 503, 504])
|
||||
const SUPPORTED_TYPES = new Set(['image', 'audio', 'video'])
|
||||
|
||||
function mediaError(code, detail = '') {
|
||||
const error = new Error(code)
|
||||
error.code = code
|
||||
if (detail) error.diagnostic = detail.slice(0, 800)
|
||||
return error
|
||||
}
|
||||
|
||||
function requestUrl(baseUrl, suffix) {
|
||||
const base = String(baseUrl || '').replace(/\/+$/, '')
|
||||
if (!base) throw mediaError('MEDIA_GENERATION_PROVIDER_UNAVAILABLE')
|
||||
return `${base}${suffix}`
|
||||
}
|
||||
|
||||
function responseExtension(type, contentType = '', sourceUrl = '') {
|
||||
const normalized = String(contentType || '').toLowerCase().split(';')[0]
|
||||
const byContentType = {
|
||||
'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif',
|
||||
'audio/mpeg': 'mp3', 'audio/wav': 'wav', 'audio/mp4': 'm4a',
|
||||
'video/mp4': 'mp4', 'video/quicktime': 'mov', 'video/webm': 'webm',
|
||||
}
|
||||
if (byContentType[normalized]) return byContentType[normalized]
|
||||
try {
|
||||
const extension = path.extname(new URL(sourceUrl).pathname).slice(1).toLowerCase()
|
||||
if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'mp3', 'wav', 'm4a', 'mp4', 'mov', 'webm']
|
||||
.includes(extension)) return extension === 'jpeg' ? 'jpg' : extension
|
||||
} catch { /* no usable source URL */ }
|
||||
return { image: 'png', audio: 'mp3', video: 'mp4' }[type]
|
||||
}
|
||||
|
||||
function base64Bytes(value) {
|
||||
if (typeof value !== 'string' || !value || value.length > Math.ceil(MAX_MEDIA_BYTES * 1.4)) {
|
||||
throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
|
||||
}
|
||||
const normalized = value.replace(/\s+/g, '')
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) {
|
||||
throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
|
||||
}
|
||||
const bytes = Buffer.from(normalized, 'base64')
|
||||
if (!bytes.length || bytes.length > MAX_MEDIA_BYTES) {
|
||||
throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function responsePayload(payload) {
|
||||
const item = Array.isArray(payload?.data) ? payload.data[0] : payload
|
||||
if (!item || typeof item !== 'object') return null
|
||||
if (typeof item.b64_json === 'string') return { bytes: base64Bytes(item.b64_json) }
|
||||
if (typeof item.base64 === 'string') return { bytes: base64Bytes(item.base64) }
|
||||
if (typeof item.url === 'string') return { url: item.url }
|
||||
if (typeof payload?.url === 'string') return { url: payload.url }
|
||||
return null
|
||||
}
|
||||
|
||||
function firstValue(payload, keys) {
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
const found = firstValue(item, keys)
|
||||
if (found != null && found !== '') return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
for (const key of keys) {
|
||||
if (payload[key] != null && payload[key] !== '') return payload[key]
|
||||
}
|
||||
for (const value of Object.values(payload)) {
|
||||
const found = firstValue(value, keys)
|
||||
if (found != null && found !== '') return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function nestedResponsePayload(payload) {
|
||||
const direct = responsePayload(payload)
|
||||
if (direct) return direct
|
||||
const encoded = firstValue(payload, ['b64_json', 'base64', 'video_base64'])
|
||||
if (typeof encoded === 'string') return { bytes: base64Bytes(encoded) }
|
||||
const url = firstValue(payload, ['url', 'download_url', 'downloadUrl', 'video_url', 'videoUrl'])
|
||||
return typeof url === 'string' ? { url } : null
|
||||
}
|
||||
|
||||
function outputDirectory(workdir) {
|
||||
if (typeof workdir !== 'string' || !path.isAbsolute(workdir)) {
|
||||
throw mediaError('MEDIA_GENERATION_WORKDIR_INVALID')
|
||||
}
|
||||
let realWorkdir
|
||||
try { realWorkdir = fs.realpathSync(workdir) } catch { throw mediaError('MEDIA_GENERATION_WORKDIR_INVALID') }
|
||||
const directory = path.join(realWorkdir, OUTPUT_DIRECTORY)
|
||||
try {
|
||||
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
|
||||
const stat = fs.lstatSync(directory)
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('unsafe output directory')
|
||||
const realDirectory = fs.realpathSync(directory)
|
||||
const relative = path.relative(realWorkdir, realDirectory)
|
||||
if (!relative || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new Error('outside workdir')
|
||||
}
|
||||
return realDirectory
|
||||
} catch {
|
||||
throw mediaError('MEDIA_GENERATION_OUTPUT_UNAVAILABLE')
|
||||
}
|
||||
}
|
||||
|
||||
function officialModel(type, provider) {
|
||||
let host = ''
|
||||
try { host = new URL(provider.baseUrl).hostname.toLowerCase() } catch { /* validated upstream */ }
|
||||
if (host === 'api.openai.com') {
|
||||
return { image: 'gpt-image-1', audio: 'gpt-4o-mini-tts', video: 'sora-2' }[type]
|
||||
}
|
||||
if (host === 'hub.zgci.org') {
|
||||
return {
|
||||
image: 'qwen-image',
|
||||
audio: 'cosy-voice',
|
||||
video: 'minimax-h3',
|
||||
}[type]
|
||||
}
|
||||
return provider.model
|
||||
}
|
||||
|
||||
function h3Provider(provider) {
|
||||
let host = ''
|
||||
try { host = new URL(provider.baseUrl).hostname.toLowerCase() } catch { return false }
|
||||
return host === 'helper.ihainan.me' && /(?:^|[-_/])(?:minimax-)?h3(?:$|[-_/])/i.test(provider.model)
|
||||
}
|
||||
|
||||
function zgciProvider(provider) {
|
||||
try { return new URL(provider.baseUrl).hostname.toLowerCase() === 'hub.zgci.org' } catch { return false }
|
||||
}
|
||||
|
||||
function providerModelUnavailable(error) {
|
||||
const diagnostic = String(error?.diagnostic || '').toLowerCase()
|
||||
return diagnostic.includes('model_not_found') || diagnostic.includes('no available channel for model')
|
||||
}
|
||||
|
||||
function providerRetryExhausted(error) {
|
||||
const match = /^MEDIA_GENERATION_HTTP_(\d+)$/.exec(String(error?.code || error?.message || ''))
|
||||
return RETRYABLE_PROVIDER_STATUSES.has(Number(match?.[1]))
|
||||
}
|
||||
|
||||
function sleep(delayMs, signal) {
|
||||
if (signal?.aborted) return Promise.reject(mediaError('LOCAL_AGENT_EXECUTION_STOPPED'))
|
||||
return new Promise((resolve, reject) => {
|
||||
const done = () => {
|
||||
signal?.removeEventListener('abort', abort)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(done, delayMs)
|
||||
const abort = () => {
|
||||
clearTimeout(timer)
|
||||
reject(mediaError('LOCAL_AGENT_EXECUTION_STOPPED'))
|
||||
}
|
||||
signal?.addEventListener('abort', abort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
async function boundedResponseBytes(response, maximum = MAX_MEDIA_BYTES) {
|
||||
const advertisedLength = Number(response.headers?.get?.('content-length'))
|
||||
if (Number.isFinite(advertisedLength) && advertisedLength > maximum) {
|
||||
throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
|
||||
}
|
||||
const reader = response.body?.getReader?.()
|
||||
if (!reader) {
|
||||
const bytes = Buffer.from(await response.arrayBuffer())
|
||||
if (bytes.length > maximum) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
|
||||
return bytes
|
||||
}
|
||||
const chunks = []
|
||||
let total = 0
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const chunk = Buffer.from(value)
|
||||
total += chunk.length
|
||||
if (total > maximum) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
|
||||
chunks.push(chunk)
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock?.()
|
||||
}
|
||||
return Buffer.concat(chunks, total)
|
||||
}
|
||||
|
||||
class MediaGenerationRuntime {
|
||||
constructor({
|
||||
getProvider,
|
||||
fetchFn = globalThis.fetch,
|
||||
createId = crypto.randomUUID,
|
||||
pollIntervalMs = POLL_INTERVAL_MS,
|
||||
} = {}) {
|
||||
if (typeof getProvider !== 'function' || typeof fetchFn !== 'function' || typeof createId !== 'function') {
|
||||
throw mediaError('MEDIA_GENERATION_RUNTIME_INVALID')
|
||||
}
|
||||
this.getProvider = getProvider
|
||||
this.fetch = fetchFn
|
||||
this.createId = createId
|
||||
this.pollIntervalMs = Math.max(0, Number(pollIntervalMs) || 0)
|
||||
}
|
||||
|
||||
emit(onEvent, type, status, id, summary = '') {
|
||||
try {
|
||||
onEvent?.({
|
||||
id: `media-${id}`,
|
||||
type: status === 'running' ? 'tool_start' : 'tool_result_summary',
|
||||
status,
|
||||
title: `${type}_generation`,
|
||||
summary,
|
||||
})
|
||||
} catch { /* trace delivery is best effort */ }
|
||||
}
|
||||
|
||||
async post(provider, suffix, body, signal) {
|
||||
for (let attempt = 1; attempt <= PROVIDER_ATTEMPTS; attempt += 1) {
|
||||
let response
|
||||
try {
|
||||
response = await this.fetch(requestUrl(provider.baseUrl, suffix), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: h3Provider(provider)
|
||||
? `Basic ${Buffer.from(provider.apiKey, 'utf8').toString('base64')}`
|
||||
: `Bearer ${provider.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, image/*, audio/*, video/*',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw mediaError('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
throw mediaError('MEDIA_GENERATION_NETWORK_FAILED', error?.message || '')
|
||||
}
|
||||
if (response?.ok) return response
|
||||
|
||||
const status = Number(response?.status) || 0
|
||||
let detail = ''
|
||||
try { detail = await response.text() } catch { /* no response detail */ }
|
||||
const error = mediaError(`MEDIA_GENERATION_HTTP_${status}`, detail)
|
||||
if (providerModelUnavailable(error)
|
||||
|| !RETRYABLE_PROVIDER_STATUSES.has(status)
|
||||
|| attempt === PROVIDER_ATTEMPTS) throw error
|
||||
await sleep(Math.min(this.pollIntervalMs, 1_000) * attempt, signal)
|
||||
}
|
||||
throw mediaError('MEDIA_GENERATION_NETWORK_FAILED')
|
||||
}
|
||||
|
||||
async getJson(provider, suffix, signal) {
|
||||
let response
|
||||
try {
|
||||
response = await this.fetch(requestUrl(provider.baseUrl, suffix), {
|
||||
headers: {
|
||||
Authorization: h3Provider(provider)
|
||||
? `Basic ${Buffer.from(provider.apiKey, 'utf8').toString('base64')}`
|
||||
: `Bearer ${provider.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw mediaError('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
throw mediaError('MEDIA_GENERATION_NETWORK_FAILED', error?.message || '')
|
||||
}
|
||||
if (!response?.ok) {
|
||||
let detail = ''
|
||||
try { detail = await response.text() } catch { /* no response detail */ }
|
||||
throw mediaError(`MEDIA_GENERATION_HTTP_${Number(response?.status) || 0}`, detail)
|
||||
}
|
||||
try { return await response.json() } catch { throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE') }
|
||||
}
|
||||
|
||||
async download(url, signal, provider = null) {
|
||||
let parsed
|
||||
try { parsed = new URL(url) } catch { throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE') }
|
||||
const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(parsed.hostname)
|
||||
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) {
|
||||
throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
|
||||
}
|
||||
let response
|
||||
const providerHost = (() => {
|
||||
try { return new URL(provider?.baseUrl || '').hostname.toLowerCase() } catch { return '' }
|
||||
})()
|
||||
const headers = providerHost && parsed.hostname.toLowerCase() === providerHost
|
||||
? { Authorization: `Bearer ${provider.apiKey}` }
|
||||
: undefined
|
||||
try { response = await this.fetch(parsed, { headers, signal }) } catch (error) {
|
||||
if (signal?.aborted) throw mediaError('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
throw mediaError('MEDIA_GENERATION_DOWNLOAD_FAILED', error?.message || '')
|
||||
}
|
||||
if (!response?.ok) throw mediaError(`MEDIA_GENERATION_DOWNLOAD_HTTP_${Number(response.status) || 0}`)
|
||||
const bytes = await boundedResponseBytes(response)
|
||||
if (!bytes.length || bytes.length > MAX_MEDIA_BYTES) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
|
||||
return { bytes, contentType: response.headers?.get?.('content-type') || '', sourceUrl: parsed.href }
|
||||
}
|
||||
|
||||
async videoResponse(provider, body, signal, submitSuffix = '/videos') {
|
||||
let response = await this.post(provider, submitSuffix, body, signal)
|
||||
const contentType = response.headers?.get?.('content-type') || ''
|
||||
if (!/application\/json/i.test(contentType)) {
|
||||
const bytes = await boundedResponseBytes(response)
|
||||
return { bytes, contentType }
|
||||
}
|
||||
let payload
|
||||
try { payload = await response.json() } catch { throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE') }
|
||||
let output = responsePayload(payload)
|
||||
while (!output && payload?.id && ['queued', 'in_progress', 'processing'].includes(payload.status)) {
|
||||
await sleep(this.pollIntervalMs, signal)
|
||||
payload = await this.getJson(provider, `/videos/${encodeURIComponent(payload.id)}`, signal)
|
||||
if (payload?.status === 'failed') throw mediaError('MEDIA_GENERATION_FAILED')
|
||||
output = responsePayload(payload)
|
||||
}
|
||||
if (!output && payload?.id && ['completed', 'succeeded', 'success'].includes(payload.status)) {
|
||||
output = { url: requestUrl(provider.baseUrl, `/videos/${encodeURIComponent(payload.id)}/content`) }
|
||||
}
|
||||
if (!output) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
|
||||
return output
|
||||
}
|
||||
|
||||
async h3VideoResponse(provider, body, signal, onEvent, id) {
|
||||
const submitted = await this.post(provider, '/v1/videogen', body, signal)
|
||||
let task
|
||||
try { task = await submitted.json() } catch { throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE') }
|
||||
const taskId = typeof task?.task_id === 'string' ? task.task_id : ''
|
||||
if (!taskId) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
|
||||
|
||||
let status = String(task.status || 'queued')
|
||||
while (['queued', 'in_progress'].includes(status)) {
|
||||
const progress = Number(task.progress)
|
||||
const progressText = Number.isFinite(progress) ? ` ${Math.max(0, Math.min(100, progress))}%` : ''
|
||||
this.emit(onEvent, 'video', 'running', id, `Generating video: ${status}${progressText}`)
|
||||
await sleep(this.pollIntervalMs, signal)
|
||||
task = await this.getJson(provider, `/v1/videogen/${encodeURIComponent(taskId)}`, signal)
|
||||
status = String(task?.status || '')
|
||||
}
|
||||
if (status !== 'completed') throw mediaError('MEDIA_GENERATION_FAILED')
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await this.fetch(requestUrl(provider.baseUrl, `/v1/videogen/${encodeURIComponent(taskId)}/content`), {
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(provider.apiKey, 'utf8').toString('base64')}`,
|
||||
Accept: 'video/mp4',
|
||||
},
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw mediaError('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
throw mediaError('MEDIA_GENERATION_DOWNLOAD_FAILED', error?.message || '')
|
||||
}
|
||||
if (!response?.ok) throw mediaError(`MEDIA_GENERATION_DOWNLOAD_HTTP_${Number(response?.status) || 0}`)
|
||||
return {
|
||||
bytes: await boundedResponseBytes(response),
|
||||
contentType: response.headers?.get?.('content-type') || 'video/mp4',
|
||||
}
|
||||
}
|
||||
|
||||
async zgciH3VideoResponse(provider, prompt, signal, onEvent, id) {
|
||||
const submitted = await this.post(provider, '/video_generation', {
|
||||
model: 'H3',
|
||||
prompt,
|
||||
duration_seconds: 8,
|
||||
size: '1280x720',
|
||||
fps: 24,
|
||||
}, signal)
|
||||
let task
|
||||
try { task = await submitted.json() } catch { throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE') }
|
||||
const taskId = firstValue(task, ['task_id', 'taskId', 'id'])
|
||||
if (typeof taskId !== 'string' || !taskId) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
|
||||
|
||||
const successStates = new Set(['success', 'succeeded', 'completed', 'complete', 'done'])
|
||||
const failureStates = new Set(['failed', 'error', 'cancelled', 'canceled'])
|
||||
let status = String(firstValue(task, ['status', 'state', 'task_status', 'taskStatus']) || '').toLowerCase()
|
||||
while (!successStates.has(status)) {
|
||||
if (failureStates.has(status)) throw mediaError('MEDIA_GENERATION_FAILED')
|
||||
this.emit(onEvent, 'video', 'running', id, status ? `Generating video: ${status}` : 'Generating video')
|
||||
await sleep(this.pollIntervalMs, signal)
|
||||
const response = await this.post(provider, '/query/video_generation', { task_id: taskId }, signal)
|
||||
try { task = await response.json() } catch { throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE') }
|
||||
status = String(firstValue(task, ['status', 'state', 'task_status', 'taskStatus']) || '').toLowerCase()
|
||||
}
|
||||
|
||||
const direct = nestedResponsePayload(task)
|
||||
if (direct) return direct
|
||||
const fileId = firstValue(task, ['file_id', 'fileId', 'video_file_id', 'videoFileId'])
|
||||
if (typeof fileId !== 'string' || !fileId) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
|
||||
const retrieved = await this.post(provider, '/files/retrieve', { file_id: fileId }, signal)
|
||||
const contentType = retrieved.headers?.get?.('content-type') || ''
|
||||
if (!/application\/json/i.test(contentType)) {
|
||||
return { bytes: await boundedResponseBytes(retrieved), contentType }
|
||||
}
|
||||
let payload
|
||||
try { payload = await retrieved.json() } catch { throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE') }
|
||||
const output = nestedResponsePayload(payload)
|
||||
if (!output) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
|
||||
return output
|
||||
}
|
||||
|
||||
async generate({ kind, request, workdir, signal, onEvent } = {}) {
|
||||
const type = String(request?.type || '')
|
||||
const prompt = String(request?.prompt || '').trim()
|
||||
if (!kind || !SUPPORTED_TYPES.has(type) || !prompt || prompt.length > 12000) {
|
||||
throw mediaError('MEDIA_GENERATION_REQUEST_INVALID')
|
||||
}
|
||||
const id = String(this.createId()).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 48) || 'media'
|
||||
this.emit(onEvent, type, 'running', id, `Generating ${type}`)
|
||||
try {
|
||||
let output
|
||||
let provider
|
||||
let providerFallbackError = null
|
||||
const excludedKinds = []
|
||||
while (!output) {
|
||||
try {
|
||||
provider = await this.getProvider(kind, type, excludedKinds)
|
||||
} catch (error) {
|
||||
if (providerModelUnavailable(providerFallbackError)) {
|
||||
throw mediaError('MEDIA_GENERATION_MODEL_UNAVAILABLE', providerFallbackError.diagnostic)
|
||||
}
|
||||
if (providerFallbackError) throw providerFallbackError
|
||||
throw error
|
||||
}
|
||||
if (!provider?.apiKey || !provider?.baseUrl || !provider?.model) {
|
||||
throw mediaError('MEDIA_GENERATION_PROVIDER_UNAVAILABLE')
|
||||
}
|
||||
const mediaModel = officialModel(type, provider)
|
||||
const body = type === 'image'
|
||||
? { model: mediaModel, prompt, response_format: 'b64_json' }
|
||||
: type === 'audio'
|
||||
? zgciProvider(provider)
|
||||
? {
|
||||
model: mediaModel,
|
||||
input: prompt,
|
||||
voice: 'mm_Southern_Young_Man',
|
||||
response_format: 'wav',
|
||||
}
|
||||
: { model: mediaModel, input: prompt, voice: 'alloy', response_format: 'mp3' }
|
||||
: { model: mediaModel, prompt, seconds: '4', size: '1280x720' }
|
||||
try {
|
||||
if (type === 'video') {
|
||||
output = zgciProvider(provider)
|
||||
? await this.videoResponse(provider, body, signal, '/video/generations')
|
||||
: h3Provider(provider)
|
||||
? await this.h3VideoResponse(provider, { prompt, seconds: 4, steps: 20, size: '1280x720' }, signal, onEvent, id)
|
||||
: await this.videoResponse(provider, body, signal)
|
||||
}
|
||||
else {
|
||||
const suffix = type === 'image' ? '/images/generations' : '/audio/speech'
|
||||
const response = await this.post(provider, suffix, body, signal)
|
||||
const contentType = response.headers?.get?.('content-type') || ''
|
||||
if (/application\/json/i.test(contentType)) {
|
||||
let payload
|
||||
try { payload = await response.json() } catch { throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE') }
|
||||
output = responsePayload(payload)
|
||||
if (!output) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
|
||||
} else {
|
||||
output = { bytes: await boundedResponseBytes(response), contentType }
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if ((!providerModelUnavailable(error) && !providerRetryExhausted(error)) || !provider.sourceKind
|
||||
|| excludedKinds.includes(provider.sourceKind)) throw error
|
||||
providerFallbackError = error
|
||||
excludedKinds.push(provider.sourceKind)
|
||||
}
|
||||
}
|
||||
const downloaded = output.url ? await this.download(output.url, signal, provider) : output
|
||||
const bytes = Buffer.from(downloaded.bytes || [])
|
||||
if (!bytes.length || bytes.length > MAX_MEDIA_BYTES) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
|
||||
const extension = responseExtension(type, downloaded.contentType, downloaded.sourceUrl || output.url)
|
||||
const filename = `generated-${type}-${id}.${extension}`
|
||||
const destination = path.join(outputDirectory(workdir), filename)
|
||||
fs.writeFileSync(destination, bytes, { flag: 'wx', mode: 0o600 })
|
||||
this.emit(onEvent, type, 'completed', id, filename)
|
||||
return { filename, type }
|
||||
} catch (error) {
|
||||
this.emit(onEvent, type, 'failed', id, String(error?.code || error?.message || 'generation failed'))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { MediaGenerationRuntime, mediaError }
|
||||
@@ -1,68 +0,0 @@
|
||||
function providerHost(baseUrl) {
|
||||
try { return new URL(baseUrl).hostname.toLowerCase() } catch { return '' }
|
||||
}
|
||||
|
||||
function mediaProviderScore(type, requestedKind, candidateKind, status = {}) {
|
||||
const host = providerHost(status.baseUrl)
|
||||
const model = String(status.model || '').toLowerCase()
|
||||
let score = candidateKind === requestedKind ? 20 : 0
|
||||
if (candidateKind === 'codex') score += 5
|
||||
if (host === 'api.openai.com') score += 80
|
||||
if (host === 'hub.zgci.org') score += type === 'video' ? 120 : 70
|
||||
if (host === 'helper.ihainan.me' && /h3/.test(model)) score += type === 'video' ? 110 : -100
|
||||
if (type === 'video' && /(h3|sora|video|hailuo|minimax)/.test(model)) score += 30
|
||||
if (type === 'image' && /(image|dall-e|flux|qwen)/.test(model)) score += 30
|
||||
if (type === 'audio' && /(audio|speech|tts|voice)/.test(model)) score += 30
|
||||
return score
|
||||
}
|
||||
|
||||
function resolveMediaProvider({
|
||||
requestedKind, type, kinds, statusFor, credentialsFor, excludedKinds = [],
|
||||
fallbackProviders = [],
|
||||
}) {
|
||||
const excluded = new Set(excludedKinds)
|
||||
const configuredFallbacks = (Array.isArray(fallbackProviders) ? fallbackProviders : [])
|
||||
.map((provider) => {
|
||||
const kind = String(provider?.kind || '').trim()
|
||||
if (!kind || excluded.has(kind)) return null
|
||||
const status = provider?.status
|
||||
const credentials = provider?.credentials
|
||||
return status?.configured && credentials?.OPENAI_API_KEY
|
||||
? { kind, status, credentials, fallback: true }
|
||||
: null
|
||||
})
|
||||
.filter(Boolean)
|
||||
const candidates = [
|
||||
...[...new Set([requestedKind, 'codex', ...(kinds || [])])]
|
||||
.filter(kind => kind && !excluded.has(kind))
|
||||
.map((kind) => {
|
||||
try {
|
||||
const status = statusFor(kind)
|
||||
return status?.configured ? { kind, status } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter(Boolean),
|
||||
...configuredFallbacks,
|
||||
]
|
||||
.sort((left, right) => (
|
||||
mediaProviderScore(type, requestedKind, right.kind, right.status)
|
||||
- mediaProviderScore(type, requestedKind, left.kind, left.status)
|
||||
))
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const credentials = candidate.fallback ? candidate.credentials : credentialsFor(candidate.kind)
|
||||
if (!credentials?.OPENAI_API_KEY) continue
|
||||
return {
|
||||
apiKey: credentials.OPENAI_API_KEY,
|
||||
baseUrl: candidate.status.baseUrl,
|
||||
model: candidate.status.model,
|
||||
sourceKind: candidate.kind,
|
||||
}
|
||||
} catch { /* try the next configured secure profile */ }
|
||||
}
|
||||
throw new Error('MEDIA_GENERATION_PROVIDER_UNAVAILABLE')
|
||||
}
|
||||
|
||||
module.exports = { mediaProviderScore, resolveMediaProvider }
|
||||
@@ -174,6 +174,7 @@ if (isLocalDocument) Object.assign(desktopApi, {
|
||||
),
|
||||
preview: id => ipcRenderer.invoke('local-attachments:preview', id),
|
||||
open: id => ipcRenderer.invoke('local-attachments:open', id),
|
||||
save: id => ipcRenderer.invoke('local-attachments:save', id),
|
||||
discard: ids => ipcRenderer.invoke('local-attachments:discard', ids),
|
||||
}),
|
||||
localAgentProvider: Object.freeze({
|
||||
|
||||
@@ -252,6 +252,10 @@ function normalizeContextStats(input) {
|
||||
const value = String(input[field] || '')
|
||||
if (SHA256.test(value)) context[field] = value
|
||||
}
|
||||
const operationId = cleanId(input.operationId)
|
||||
if (operationId) context.operationId = operationId
|
||||
const snapshotHash = String(input.snapshotHash || '')
|
||||
if (SHA256.test(snapshotHash)) context.snapshotHash = snapshotHash
|
||||
if (input.sessionRotated === true) context.sessionRotated = true
|
||||
const contextPackId = normalizeContextPackId(input.contextPackId)
|
||||
if (contextPackId) context.contextPackId = contextPackId
|
||||
|
||||
@@ -28,7 +28,7 @@ const CONTEXT_FIELDS = new Set([
|
||||
'includedCount', 'omittedCount', 'charCount', 'sessionRotated', 'externalRunRef',
|
||||
'contextMode', 'promptChars', 'contextPackId', 'contextPackState', 'deliveryRecordIds',
|
||||
'sessionProvenance', 'outcomeRefs', 'sourceCount', 'sourceHash', 'promptBytes',
|
||||
'promptHash', 'wirePayloadBytes', 'wirePayloadHash',
|
||||
'promptHash', 'wirePayloadBytes', 'wirePayloadHash', 'operationId', 'snapshotHash',
|
||||
'connector', 'connectorEventState',
|
||||
])
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ const CONTEXT_FIELDS = new Set([
|
||||
'includedCount', 'omittedCount', 'charCount', 'sessionRotated', 'externalRunRef',
|
||||
'contextMode', 'promptChars', 'contextPackId', 'contextPackState', 'deliveryRecordIds',
|
||||
'sessionProvenance', 'outcomeRefs', 'sourceCount', 'sourceHash', 'promptBytes',
|
||||
'promptHash', 'wirePayloadBytes', 'wirePayloadHash',
|
||||
'promptHash', 'wirePayloadBytes', 'wirePayloadHash', 'operationId', 'snapshotHash',
|
||||
'connector', 'connectorEventState',
|
||||
])
|
||||
const CONTEXT_PACK_STATES = new Set(['captured', 'legacy-unavailable'])
|
||||
@@ -62,7 +62,7 @@ const CONTINUATION_FIELDS = new Set([
|
||||
])
|
||||
const CONTINUATION_INVOCATION_FIELDS = ['phase', 'slotId', 'operationId', 'snapshotHash']
|
||||
const CONTINUATION_V4_PHASES = new Set([
|
||||
'proposal', 'challenge', 'work', 'synthesis', 'verification',
|
||||
'proposal', 'discussion', 'challenge', 'work', 'synthesis', 'verification',
|
||||
])
|
||||
const CONTINUATION_GATE_TYPES = new Set(['permission', 'budget', 'decision', 'retry', 'input'])
|
||||
const CONTINUATION_RESUME_KINDS = new Set([
|
||||
@@ -75,8 +75,9 @@ const ACTIVE_CONTINUATION_STATES = new Set(['pending', 'ready', 'resuming'])
|
||||
const ORCHESTRATION_FIELDS = new Set([
|
||||
'version', 'workflow', 'currentKind', 'pendingKinds', 'activeKinds',
|
||||
'successfulKinds', 'agreementKinds', 'attachmentRecipients',
|
||||
'totalSuccesses', 'terminalFailureOccurred', 'collaboration', 'template', 'taskGraph',
|
||||
'phase', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
|
||||
'totalSuccesses', 'terminalFailureOccurred', 'executionSequence',
|
||||
'collaboration', 'template', 'taskGraph',
|
||||
'phase', 'discussionStyle', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
|
||||
'slots', 'deliveryWatermarks', 'deliveryState', 'commitState', 'challengeBindings', 'synthesisBinding',
|
||||
'synthesisRecovery', 'convergence', 'coordinationPlan', 'workReceipts', 'candidateCommit',
|
||||
])
|
||||
@@ -239,7 +240,7 @@ function normalizeAgentRun(input, parent, fallbackTimestamp) {
|
||||
'includedCount', 'omittedCount', 'charCount', 'sessionRotated', 'externalRunRef',
|
||||
'contextMode', 'promptChars', 'contextPackId', 'deliveryRecordIds', 'sessionProvenance', 'outcomeRefs',
|
||||
'sourceCount', 'sourceHash', 'promptBytes', 'promptHash', 'wirePayloadBytes', 'wirePayloadHash',
|
||||
'connector', 'connectorEventState',
|
||||
'operationId', 'snapshotHash', 'connector', 'connectorEventState',
|
||||
].some(key => hasOwn(input.context, key))
|
||||
const rawEvents = Array.isArray(input.events) ? input.events : []
|
||||
const rawSourceIds = Array.isArray(input.sourceMessageIds) ? input.sourceMessageIds : []
|
||||
@@ -489,8 +490,9 @@ function normalizeOrchestration(input) {
|
||||
const agreementKinds = normalizeKinds(input.agreementKinds)
|
||||
const attachmentRecipients = normalizeKinds(input.attachmentRecipients)
|
||||
const totalSuccesses = boundedNumber(input.totalSuccesses, 0, 1000000)
|
||||
const executionSequence = boundedNumber(input.executionSequence, 0, 1000000)
|
||||
const v4OnlyFields = [
|
||||
'phase', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
|
||||
'phase', 'discussionStyle', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
|
||||
'slots', 'deliveryWatermarks', 'deliveryState', 'commitState', 'challengeBindings',
|
||||
'synthesisBinding', 'synthesisRecovery', 'convergence', 'candidateCommit',
|
||||
]
|
||||
@@ -498,6 +500,10 @@ function normalizeOrchestration(input) {
|
||||
|| (version === 1 && hasOwn(input, 'collaboration'))
|
||||
|| (version === 2 && workflow !== 'auto')
|
||||
|| (version < 3 && (hasOwn(input, 'template') || hasOwn(input, 'taskGraph')))
|
||||
|| (hasOwn(input, 'executionSequence')
|
||||
&& (!Number.isInteger(input.executionSequence)
|
||||
|| input.executionSequence < 0
|
||||
|| input.executionSequence > 1000000))
|
||||
|| (version < 4 && v4OnlyFields.some(field => hasOwn(input, field)))
|
||||
|| (version === 3 && (workflow !== 'auto' || input.template !== 'task-graph'))) {
|
||||
return undefined
|
||||
@@ -529,6 +535,7 @@ function normalizeOrchestration(input) {
|
||||
attachmentRecipients,
|
||||
totalSuccesses,
|
||||
terminalFailureOccurred: input.terminalFailureOccurred === true,
|
||||
...(hasOwn(input, 'executionSequence') ? { executionSequence } : {}),
|
||||
...(version >= 2 ? { collaboration } : {}),
|
||||
...(version === 3 ? { template: 'task-graph', taskGraph } : {}),
|
||||
}
|
||||
@@ -694,6 +701,11 @@ function hasValidStoredRecordShape(input) {
|
||||
&& (typeof agentRun.context[field] !== 'string'
|
||||
|| !SHA256.test(agentRun.context[field]))) return false
|
||||
}
|
||||
if (hasOwn(agentRun.context, 'operationId')
|
||||
&& cleanId(agentRun.context.operationId) !== agentRun.context.operationId) return false
|
||||
if (hasOwn(agentRun.context, 'snapshotHash')
|
||||
&& (typeof agentRun.context.snapshotHash !== 'string'
|
||||
|| !SHA256.test(agentRun.context.snapshotHash))) return false
|
||||
if (hasOwn(agentRun.context, 'contextPackId')
|
||||
&& normalizeContextPackId(agentRun.context.contextPackId)
|
||||
!== agentRun.context.contextPackId) return false
|
||||
|
||||
@@ -659,6 +659,7 @@ function registerDesktopIpc(options) {
|
||||
registerTrustedHandle('local-attachments:import', input => attachments.importBuffer(input))
|
||||
registerTrustedHandle('local-attachments:preview', id => attachments.preview(String(id || '')))
|
||||
registerTrustedHandle('local-attachments:open', id => attachments.open(String(id || '')))
|
||||
registerTrustedHandle('local-attachments:save', id => attachments.save(String(id || '')))
|
||||
registerTrustedHandle('local-attachments:discard', ids => attachments.discardUnreferenced(ids))
|
||||
registerTrustedHandle('local-agent-provider:status', kind => (
|
||||
providerStore.status(providerAgentKind(kind))
|
||||
|
||||
@@ -69,21 +69,11 @@ function mergeOutcomeRefs(sources, options = {}) {
|
||||
return normalizeOutcomeRefs(merged)
|
||||
}
|
||||
|
||||
function canUseNativeMediaFallback(error) {
|
||||
const code = String(error?.code || error?.message || '')
|
||||
return /^(?:MEDIA_GENERATION_(?:PROVIDER_UNAVAILABLE|MODEL_UNAVAILABLE|NETWORK_FAILED|INVALID_RESPONSE|FAILED|DOWNLOAD_FAILED)|MEDIA_GENERATION_(?:HTTP|DOWNLOAD_HTTP)_\d+)$/.test(code)
|
||||
}
|
||||
|
||||
function agentReturnedMediaProviderFailure(text) {
|
||||
const normalized = String(text || '').replace(/\s+/g, ' ').trim()
|
||||
if (!normalized) return false
|
||||
return /configured providers do not offer the required media model/i.test(normalized)
|
||||
|| /use a provider credential with access to (?:that|the) image, audio, or video model/i.test(normalized)
|
||||
|| /provider.+(?:does not|do not).+(?:offer|support).+(?:media|image|audio|video).+model/i.test(normalized)
|
||||
}
|
||||
const OPERATION_ID = /^[A-Za-z0-9._:-]{1,120}$/
|
||||
const SHA256 = /^[a-f0-9]{64}$/
|
||||
const V4_PHASES = new Set(['proposal', 'challenge', 'work', 'synthesis', 'verification'])
|
||||
const V4_PHASES = new Set([
|
||||
'proposal', 'discussion', 'challenge', 'work', 'synthesis', 'verification',
|
||||
])
|
||||
const V4_RECEIPT_MARKER_PREFIX = '[[MELDWORK_COLLABORATION'
|
||||
const V4_RECEIPT_MARKER = /\s*\[\[MELDWORK_COLLABORATION(?:_JSON)?:([\s\S]*?)\]\]/i
|
||||
const V4_RECEIPT_BLOCK = /\s*\[\[MELDWORK_COLLABORATION(?:_JSON)?\]\]([\s\S]*?)\[\[\/MELDWORK_COLLABORATION(?:_JSON)?\]\]/i
|
||||
@@ -326,7 +316,8 @@ function attachInvocationFailure(error, input) {
|
||||
}
|
||||
|
||||
function isolatedInvocationContext(context) {
|
||||
if (!['isolated', 'frozen'].includes(context?.sessionPolicy)) return null
|
||||
if (!['isolated', 'frozen'].includes(context?.sessionPolicy)
|
||||
&& !(context?.v4 === true && typeof context.promptOverride === 'string')) return null
|
||||
const promptOverride = typeof context.promptOverride === 'string'
|
||||
? context.promptOverride
|
||||
: ''
|
||||
@@ -339,7 +330,7 @@ function isolatedInvocationContext(context) {
|
||||
return {
|
||||
promptOverride,
|
||||
contextPackId,
|
||||
policy: context.sessionPolicy,
|
||||
policy: ['isolated', 'frozen'].includes(context?.sessionPolicy) ? context.sessionPolicy : '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,7 +517,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
this.completeHumanGateContinuation = options.completeHumanGateContinuation
|
||||
this.connectorRuntime = options.connectorRuntime || null
|
||||
this.attachmentSupport = options.attachmentSupport || (() => ({}))
|
||||
this.generateMedia = typeof options.generateMedia === 'function' ? options.generateMedia : null
|
||||
this.activeAgentSnapshots = new Map()
|
||||
}
|
||||
|
||||
commitSessionState(mutator) {
|
||||
@@ -593,7 +584,24 @@ class LocalWorkspaceAgentInvocation {
|
||||
async invokeLeased(
|
||||
group, kind, mode, signal, threadRootId = '', context = {}, invocation = {},
|
||||
) {
|
||||
const agent = this.detectedAgents().find(item => item.kind === kind && item.available)
|
||||
const detected = this.detectedAgents()
|
||||
const activeRun = this.activeRuns.get(group.id)
|
||||
const candidate = detected.find(item => item.kind === kind)
|
||||
const snapshotKey = `${group.id}:${activeRun?.runId || 'active'}:${kind}`
|
||||
if (candidate) this.activeAgentSnapshots.set(snapshotKey, { ...candidate })
|
||||
const snapshot = this.activeAgentSnapshots.get(snapshotKey)
|
||||
const agent = detected.find(item => item.kind === kind && item.available)
|
||||
|| (activeRun?.targetKinds?.includes(kind)
|
||||
&& candidate
|
||||
&& candidate.installed !== false
|
||||
&& candidate.compatibilityState !== 'incompatible'
|
||||
&& candidate.credentialState !== 'missing'
|
||||
&& candidate.availabilitySource !== 'runtime-auth-failure'
|
||||
&& candidate.availabilitySource !== 'none'
|
||||
? candidate
|
||||
: activeRun?.targetKinds?.includes(kind) && !candidate && snapshot
|
||||
? snapshot
|
||||
: null)
|
||||
if (!agent) throw new Error('LOCAL_AGENT_UNAVAILABLE')
|
||||
const reviewOnly = isCodeReviewAgentKind(kind)
|
||||
const invocationContext = isolatedInvocationContext(context)
|
||||
@@ -631,7 +639,6 @@ class LocalWorkspaceAgentInvocation {
|
||||
})
|
||||
}
|
||||
const state = this.state()
|
||||
const activeRun = this.activeRuns.get(group.id)
|
||||
const taskId = cleanText(activeRun?.taskId || context.taskId || threadRootId, 120)
|
||||
const round = mode === 'auto' ? (activeRun?.currentRound || 1) : 0
|
||||
const responseVersionRootId = cleanText(context.responseVersionRootId, 100)
|
||||
@@ -695,7 +702,12 @@ class LocalWorkspaceAgentInvocation {
|
||||
&& !resumedConnectorGate
|
||||
? context.resumedGate
|
||||
: null
|
||||
const sessionNeedsRotation = sessionRef && shouldRotateSession(sessionMeta)
|
||||
const preserveV4TaskSession = context.v4 === true
|
||||
&& group.conversationType !== 'direct'
|
||||
&& Boolean(taskId)
|
||||
const sessionNeedsRotation = sessionRef
|
||||
&& shouldRotateSession(sessionMeta)
|
||||
&& !preserveV4TaskSession
|
||||
if (resumedPermission) {
|
||||
const requestHash = sha256(canonicalJson(resumedPermission.request))
|
||||
const persistedBinding = {
|
||||
@@ -1103,8 +1115,6 @@ class LocalWorkspaceAgentInvocation {
|
||||
let outputBaseline = null
|
||||
let artifactOutputBaseline = null
|
||||
let stagedInputs = null
|
||||
let generatedMedia = null
|
||||
let nativeMediaFallback = false
|
||||
let result
|
||||
let operationStarted = false
|
||||
let sideEffectsStarted = false
|
||||
@@ -1155,6 +1165,12 @@ class LocalWorkspaceAgentInvocation {
|
||||
...(Number.isInteger(context.executionSequence)
|
||||
? { executionSequence: context.executionSequence }
|
||||
: {}),
|
||||
...(v4AgentSlotBinding
|
||||
? {
|
||||
operationId: v4AgentSlotBinding.operationId,
|
||||
snapshotHash: v4AgentSlotBinding.snapshotHash,
|
||||
}
|
||||
: {}),
|
||||
...runtimeContext,
|
||||
...deliveryContext,
|
||||
...connectorContext,
|
||||
@@ -1208,21 +1224,6 @@ class LocalWorkspaceAgentInvocation {
|
||||
/* artifact output capture is best effort */
|
||||
}
|
||||
}
|
||||
if (allowWrite && context.mediaRequest && this.generateMedia) {
|
||||
sideEffectsStarted = true
|
||||
try {
|
||||
generatedMedia = await abortableOperation(() => this.generateMedia({
|
||||
kind,
|
||||
request: context.mediaRequest,
|
||||
workdir: group.workdir,
|
||||
signal: agentController.signal,
|
||||
onEvent: emitRuntimeEvent,
|
||||
}), agentController.signal)
|
||||
} catch (error) {
|
||||
if (agentController.signal.aborted || !canUseNativeMediaFallback(error)) throw error
|
||||
nativeMediaFallback = true
|
||||
}
|
||||
}
|
||||
const nativeImageLimit = Math.max(
|
||||
0,
|
||||
Math.floor(Number(this.attachmentSupport(kind)?.image) || 0),
|
||||
@@ -1244,23 +1245,15 @@ class LocalWorkspaceAgentInvocation {
|
||||
const buildPrompt = (afterKind, contextPackage) => isolated || frozen
|
||||
? v4Prompt
|
||||
: [
|
||||
this.promptFor(
|
||||
group, kind, context.completionPolicy === 'typed' ? 'manual' : mode,
|
||||
threadRootId, context.skillHints || [],
|
||||
context.knowledgeBaseHints || [], afterKind, contextPackage, promptMode,
|
||||
activeRun?.unlimitedRounds === true,
|
||||
),
|
||||
context.v4 === true && v4Prompt
|
||||
? v4Prompt
|
||||
: this.promptFor(
|
||||
group, kind, context.completionPolicy === 'typed' ? 'manual' : mode,
|
||||
threadRootId, context.skillHints || [],
|
||||
context.knowledgeBaseHints || [], afterKind, contextPackage, promptMode,
|
||||
activeRun?.unlimitedRounds === true,
|
||||
),
|
||||
stagedAgentInputPrompt(stagedInputs),
|
||||
generatedMedia
|
||||
? `Meldwork generated and will attach ${generatedMedia.filename}. Confirm the delivered media briefly; do not claim a different file was created.`
|
||||
: '',
|
||||
nativeMediaFallback
|
||||
? [
|
||||
'Meldwork shared media generator was unavailable. Use your native media-generation tools or installed local skills to fulfill the current request.',
|
||||
'Write or copy every real generated media file into .meldwork-output/ so Meldwork can validate and attach it to the reply.',
|
||||
'Do not return only a prompt and do not claim success unless a real media file was created. If no native media tool is available, explain that accurately.',
|
||||
].join('\n')
|
||||
: '',
|
||||
runtimeInstruction ? `Harness recovery task:\n${runtimeInstruction}` : '',
|
||||
regenerationInstruction
|
||||
? `Fresh response instruction:\n${regenerationInstruction}`
|
||||
@@ -1507,11 +1500,12 @@ class LocalWorkspaceAgentInvocation {
|
||||
? { maxTurns: context.maxInternalTurns }
|
||||
: {}),
|
||||
attachments: isolated || frozen ? [] : (stagedInputs?.nativeImagePaths || []),
|
||||
// ACP runtimes are shared by the stable group/task session key. OpenClaw
|
||||
// is excluded because its gateway wrapper owns the process lifetime and
|
||||
// is intentionally torn down after each invocation.
|
||||
...(kind !== 'openclaw' ? { acpPersistenceKey: key } : {}),
|
||||
...(kind === 'hermes'
|
||||
? {
|
||||
acpPersistenceKey: key,
|
||||
hermesAcpAvailable: HERMES_WORKSPACE_ACP_ENABLED && agent.acpAvailable !== false,
|
||||
}
|
||||
? { hermesAcpAvailable: HERMES_WORKSPACE_ACP_ENABLED && agent.acpAvailable !== false }
|
||||
: {}),
|
||||
}
|
||||
const runCurrentSession = () => {
|
||||
@@ -1722,18 +1716,6 @@ class LocalWorkspaceAgentInvocation {
|
||||
? parseAutoReply(collaborationReply.text)
|
||||
: { text: collaborationReply.text, consensus: false }
|
||||
if (!reply.text) throw new Error('LOCAL_AGENT_EMPTY_RESPONSE')
|
||||
if (allowWrite && context.mediaRequest && this.generateMedia && nativeMediaFallback
|
||||
&& !generatedMedia && agentReturnedMediaProviderFailure(reply.text)) {
|
||||
sideEffectsStarted = true
|
||||
generatedMedia = await abortableOperation(() => this.generateMedia({
|
||||
kind,
|
||||
request: context.mediaRequest,
|
||||
workdir: group.workdir,
|
||||
signal: agentController.signal,
|
||||
onEvent: emitRuntimeEvent,
|
||||
}), agentController.signal)
|
||||
reply.text = `Meldwork generated and attached ${generatedMedia.filename}.`
|
||||
}
|
||||
const progress = attemptProgress.length ? attemptProgress : result.progress
|
||||
const toolCalls = cleanProgressSteps(progress).map(step => ({
|
||||
...step,
|
||||
|
||||
@@ -14,7 +14,6 @@ const {
|
||||
normalizeFailureOutcome,
|
||||
retryDecision,
|
||||
} = require('../runs/failure-policy.cjs')
|
||||
const { mediaGenerationRequest } = require('../media/media-generation-request.cjs')
|
||||
const {
|
||||
appendBlackboardEntry,
|
||||
collaborationPackageIndexText,
|
||||
@@ -70,6 +69,7 @@ const {
|
||||
terminalRunStatusForReason,
|
||||
unauthorizedFailure,
|
||||
} = require('./local-workspace-auto-runner-support.cjs')
|
||||
const { traceCapsuleFromAgentRun } = require('../runs/run-harness-normalization.cjs')
|
||||
const { v4OutcomeMethods } = require('./local-workspace-auto-runner-v4-outcomes.cjs')
|
||||
|
||||
class LocalWorkspaceAutoRunner {
|
||||
@@ -1202,12 +1202,10 @@ class LocalWorkspaceAutoRunner {
|
||||
let rootAttachments = preparedContext?.attachments
|
||||
let rootSkillsByKind = preparedContext?.skillHintsByKind
|
||||
let rootKnowledgeBasesByKind = preparedContext?.knowledgeBaseHintsByKind
|
||||
let rootMediaRequest = preparedContext?.mediaRequest || null
|
||||
if (!rootAttachments || !rootSkillsByKind || !rootKnowledgeBasesByKind) {
|
||||
const rootMessage = this.state().messages.find(message => (
|
||||
message.id === threadRootId && message.groupId === group.id && message.role === 'user'
|
||||
))
|
||||
rootMediaRequest = mediaGenerationRequest(rootMessage?.content || '')
|
||||
rootAttachments = await this.resolveAttachments(rootMessage?.attachments || [])
|
||||
rootSkillsByKind = new Map()
|
||||
for (const kind of controller.targetKinds) {
|
||||
@@ -1247,7 +1245,6 @@ class LocalWorkspaceAutoRunner {
|
||||
rootAttachments,
|
||||
rootSkillsByKind,
|
||||
rootKnowledgeBasesByKind,
|
||||
rootMediaRequest,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1473,14 +1470,16 @@ class LocalWorkspaceAutoRunner {
|
||||
)
|
||||
}
|
||||
|
||||
v4OperationId(controller, kind, phase, slotId = '') {
|
||||
v4OperationId(
|
||||
controller, kind, phase, slotId = '', round = controller.currentRound || 0,
|
||||
) {
|
||||
const digest = createHash('sha256').update(JSON.stringify([
|
||||
controller.runId,
|
||||
controller.taskId,
|
||||
kind,
|
||||
slotId,
|
||||
phase,
|
||||
controller.currentRound || 0,
|
||||
round,
|
||||
])).digest('hex')
|
||||
return `operation-${digest}`
|
||||
}
|
||||
@@ -2037,7 +2036,7 @@ class LocalWorkspaceAutoRunner {
|
||||
}
|
||||
|
||||
v4CheckpointRole(phase, slot, coordinationPlan, workAssignment = null) {
|
||||
if (['proposal', 'challenge'].includes(phase)) return 'participant'
|
||||
if (['proposal', 'discussion', 'challenge'].includes(phase)) return 'participant'
|
||||
const assignment = phase === 'work'
|
||||
? (workAssignment || this.v4CoordinationAssignment(coordinationPlan, slot.agentKind))
|
||||
: (phase === 'synthesis'
|
||||
@@ -2052,7 +2051,7 @@ class LocalWorkspaceAutoRunner {
|
||||
}
|
||||
|
||||
v4PromptRole(phase, kind, coordinationPlan, workAssignment = null) {
|
||||
if (['proposal', 'challenge'].includes(phase)) return 'participant'
|
||||
if (['proposal', 'discussion', 'challenge'].includes(phase)) return 'participant'
|
||||
if (phase === 'verification') {
|
||||
if (!coordinationPlan?.verifierKinds?.includes(kind)) {
|
||||
throw new Error('LOCAL_RUN_V4_COORDINATION_PLAN_INVALID')
|
||||
@@ -2425,6 +2424,443 @@ class LocalWorkspaceAutoRunner {
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
v4NaturalRouteDecision(text, activeKinds) {
|
||||
const participants = Array.isArray(activeKinds) ? activeKinds : []
|
||||
const finalLine = String(text || '').split(/\r?\n/u)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.at(-1) || ''
|
||||
if (!finalLine || !participants.length) return { status: 'none', kinds: [] }
|
||||
const selected = new Set()
|
||||
const mentionPattern = /@([A-Za-z0-9][A-Za-z0-9_-]{0,39})/gu
|
||||
let cursor = 0
|
||||
for (const match of finalLine.matchAll(mentionPattern)) {
|
||||
const separator = finalLine.slice(cursor, match.index)
|
||||
if (separator.includes('@') || !/^[\s\p{P}]*$/u.test(separator)) {
|
||||
return { status: 'invalid', kinds: [] }
|
||||
}
|
||||
const kind = match[1]
|
||||
if (!participants.includes(kind)) return { status: 'invalid', kinds: [] }
|
||||
selected.add(kind)
|
||||
cursor = match.index + match[0].length
|
||||
}
|
||||
const tail = finalLine.slice(cursor)
|
||||
if (!selected.size) {
|
||||
return finalLine.includes('@')
|
||||
? { status: 'invalid', kinds: [] }
|
||||
: { status: 'none', kinds: [] }
|
||||
}
|
||||
if (tail.includes('@') || !/^[\s\p{P}]*$/u.test(tail)) {
|
||||
return { status: 'invalid', kinds: [] }
|
||||
}
|
||||
return { status: 'valid', kinds: participants.filter(kind => selected.has(kind)) }
|
||||
}
|
||||
|
||||
v4MentionedNextKinds(text, activeKinds) {
|
||||
return this.v4NaturalRouteDecision(text, activeKinds).kinds
|
||||
}
|
||||
|
||||
v4NaturalMessageMatchesBinding(message, controller) {
|
||||
const phase = String(message?.trace?.phase || '')
|
||||
const kind = String(message?.agentKind || '')
|
||||
const round = Number(message?.trace?.round) || 0
|
||||
if (!['proposal', 'discussion'].includes(phase) || !kind || round < 1) return false
|
||||
if (message.trace?.runId !== controller.runId) return false
|
||||
const slot = controller.orchestration?.slots?.find(candidate => candidate.agentKind === kind)
|
||||
const snapshotHash = String(controller.orchestration?.snapshotHash || '')
|
||||
if (!slot?.slotId || !snapshotHash) return false
|
||||
const operationPhase = phase === 'discussion' ? `discussion:${round}` : phase
|
||||
const expectedOperationId = this.v4OperationId(
|
||||
controller, kind, operationPhase, slot.slotId, round,
|
||||
)
|
||||
return message.trace?.context?.operationId === expectedOperationId
|
||||
&& message.trace?.context?.snapshotHash === snapshotHash
|
||||
}
|
||||
|
||||
v4NaturalThreadTranscript(group, threadRootId, options = {}) {
|
||||
const rounds = Array.isArray(options.rounds) ? new Set(options.rounds) : null
|
||||
const blocks = this.state().messages.filter(message => (
|
||||
message.groupId === group.id
|
||||
&& message.threadRootId === threadRootId
|
||||
&& message.role === 'agent'
|
||||
&& message.agentKind
|
||||
&& (!rounds || rounds.has(Number(message.trace?.round) || 0))
|
||||
&& (!options.controller || this.v4NaturalMessageMatchesBinding(
|
||||
message, options.controller,
|
||||
))
|
||||
)).map(message => [
|
||||
`Round ${Number(message.trace?.round) || 0} - @${message.agentKind}`,
|
||||
String(message.content || '').trim(),
|
||||
].filter(Boolean).join('\n'))
|
||||
return this.v4SanitizeDeliveryText(blocks.join('\n\n'), Number.MAX_SAFE_INTEGER)
|
||||
}
|
||||
|
||||
v4NaturalRecoveredPhaseResult(group, controller, threadRootId, phase, kind, slot) {
|
||||
if (!this.naturalAgentResponses || !slot || controller.currentRound < 1) return null
|
||||
if (!['proposal', 'discussion'].includes(phase)) return null
|
||||
const operationId = String(slot.operationId || '')
|
||||
const snapshotHash = String(slot.snapshotHash || '')
|
||||
if (!operationId || !snapshotHash) return null
|
||||
const matchesBinding = context => (
|
||||
context?.operationId === operationId && context?.snapshotHash === snapshotHash
|
||||
)
|
||||
const scopedMessages = [...this.state().messages].reverse().filter(message => (
|
||||
message.groupId === group.id
|
||||
&& message.threadRootId === threadRootId
|
||||
&& message.role === 'agent'
|
||||
&& message.agentKind === kind
|
||||
&& message.trace?.runId === controller.runId
|
||||
&& message.trace?.phase === phase
|
||||
&& Number(message.trace?.round) === controller.currentRound
|
||||
))
|
||||
if (scopedMessages.some(message => !matchesBinding(message.trace?.context))) {
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_SCOPE_INVALID')
|
||||
}
|
||||
const existingMessage = scopedMessages.find(message => matchesBinding(message.trace?.context))
|
||||
let content = String(existingMessage?.content || '')
|
||||
let trace = existingMessage?.trace || null
|
||||
let metadata = existingMessage ? {
|
||||
...(existingMessage.elapsedMs != null ? { elapsedMs: existingMessage.elapsedMs } : {}),
|
||||
...(Array.isArray(existingMessage.toolCalls) ? { toolCalls: existingMessage.toolCalls } : {}),
|
||||
...(Array.isArray(existingMessage.attachments)
|
||||
? { attachments: existingMessage.attachments } : {}),
|
||||
...(existingMessage.responseVersionRootId
|
||||
? { responseVersionRootId: existingMessage.responseVersionRootId } : {}),
|
||||
trace,
|
||||
} : null
|
||||
if (!content || !trace?.agentRunId) {
|
||||
const scopedRuns = [...(controller.harness?.snapshot?.() || [])].reverse().filter(run => (
|
||||
run.kind === kind
|
||||
&& run.round === controller.currentRound
|
||||
&& run.status === 'completed'
|
||||
&& String(run.output || '')
|
||||
))
|
||||
if (scopedRuns.some(run => !matchesBinding(run.context))) {
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_SCOPE_INVALID')
|
||||
}
|
||||
const recoveredRun = scopedRuns.find(run => matchesBinding(run.context))
|
||||
if (!recoveredRun) return null
|
||||
content = String(recoveredRun.output)
|
||||
trace = traceCapsuleFromAgentRun(recoveredRun, {
|
||||
runId: controller.runId,
|
||||
status: recoveredRun.status,
|
||||
phase,
|
||||
})
|
||||
if (!trace?.agentRunId) return null
|
||||
metadata = { trace }
|
||||
}
|
||||
return {
|
||||
text: content,
|
||||
pendingMessage: {
|
||||
groupId: group.id,
|
||||
role: 'agent',
|
||||
content,
|
||||
agentKind: kind,
|
||||
threadRootId,
|
||||
system: null,
|
||||
metadata,
|
||||
},
|
||||
collaboration: null,
|
||||
outcomeRefs: trace.context?.outcomeRefs || {},
|
||||
operationId: slot.operationId,
|
||||
consensus: false,
|
||||
}
|
||||
}
|
||||
|
||||
v4NaturalRecoveredDiscussionResult(group, controller, threadRootId, kind, slot) {
|
||||
return this.v4NaturalRecoveredPhaseResult(
|
||||
group, controller, threadRootId, 'discussion', kind, slot,
|
||||
)
|
||||
}
|
||||
|
||||
v4NaturalPhasePrompt(group, kind, phase, snapshot, receiptRecords, activeKinds, options = {}) {
|
||||
const transcript = String(options.transcript || '')
|
||||
const agentList = activeKinds.map(agentKind => `@${agentKind}`).join(', ')
|
||||
return [
|
||||
v4Prompt({
|
||||
group, kind, phase, snapshot, role: 'participant',
|
||||
skillHints: options.skillHints, naturalResponse: true,
|
||||
}),
|
||||
transcript ? `Completed peer turns:\n${transcript}` : '',
|
||||
phase === 'discussion' && options.allowRouting === true
|
||||
? [
|
||||
'Decide which Agent or Agents should contribute next based on the discussion and the work still needed.',
|
||||
`The final non-empty line may contain only the exact Agent mentions that should respond next: ${agentList}.`,
|
||||
'Use one mention for a single next Agent or multiple mentions for a concurrent batch.',
|
||||
'End without any Agent mention only when you accept the current result and made no substantive change. If you made a substantive change, mention at least one different Agent for review.',
|
||||
].join('\n')
|
||||
: '',
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
v4NaturalDiscussionOperationIds(controller, slots, kinds, round) {
|
||||
return new Map(kinds.map((kind) => {
|
||||
const slot = slots.find(candidate => candidate.agentKind === kind)
|
||||
return [kind, this.v4OperationId(
|
||||
controller, kind, `discussion:${round}`, slot?.slotId || '', round,
|
||||
)]
|
||||
}))
|
||||
}
|
||||
|
||||
v4NaturalDiscussionRoundMessages(group, controller, threadRootId, round) {
|
||||
return this.state().messages.filter(message => (
|
||||
message.groupId === group.id
|
||||
&& message.threadRootId === threadRootId
|
||||
&& message.role === 'agent'
|
||||
&& message.trace?.runId === controller.runId
|
||||
&& message.trace?.phase === 'discussion'
|
||||
&& Number(message.trace?.round) === round
|
||||
&& this.v4NaturalMessageMatchesBinding(message, controller)
|
||||
))
|
||||
}
|
||||
|
||||
v4NaturalRoundHasPeerConfirmation(
|
||||
group, controller, threadRootId, round, activeKinds,
|
||||
) {
|
||||
if (round <= 2) return false
|
||||
const currentMessages = this.v4NaturalDiscussionRoundMessages(
|
||||
group, controller, threadRootId, round,
|
||||
)
|
||||
if (!currentMessages.length) return false
|
||||
const currentDecisions = currentMessages.map(message => (
|
||||
this.v4NaturalRouteDecision(message.content, activeKinds)
|
||||
))
|
||||
if (currentDecisions.some(decision => decision.status !== 'none')) return false
|
||||
const previousMessages = this.v4NaturalDiscussionRoundMessages(
|
||||
group, controller, threadRootId, round - 1,
|
||||
)
|
||||
if (!previousMessages.length) return false
|
||||
const previousDecisions = previousMessages.map(message => ({
|
||||
agentKind: message.agentKind,
|
||||
decision: this.v4NaturalRouteDecision(message.content, activeKinds),
|
||||
}))
|
||||
const peerSelected = currentMessages.some(message => previousDecisions.some(previous => (
|
||||
previous.agentKind !== message.agentKind
|
||||
&& previous.decision.status === 'valid'
|
||||
&& previous.decision.kinds.includes(message.agentKind)
|
||||
)))
|
||||
if (peerSelected) return true
|
||||
return previousDecisions.every(previous => previous.decision.status === 'none')
|
||||
&& currentMessages.some(message => (
|
||||
previousMessages.every(previous => previous.agentKind !== message.agentKind)
|
||||
))
|
||||
}
|
||||
|
||||
v4NaturalConfirmationKind(activeKinds, currentKinds) {
|
||||
const current = new Set(currentKinds)
|
||||
return activeKinds.find(kind => !current.has(kind)) || ''
|
||||
}
|
||||
|
||||
async runV4SequentialDiscussion(group, controller, threadRootId, context, input) {
|
||||
const {
|
||||
targetKinds, activeKinds: initialActiveKinds, writerKind, batchId,
|
||||
snapshot, snapshotRecord, snapshotHash, slots: initialSlots, receiptRecords,
|
||||
checkpointPhase, removePhaseFailures,
|
||||
} = input
|
||||
let activeKinds = [...initialActiveKinds]
|
||||
let slots = initialSlots
|
||||
let round = Math.max(1, Number(controller.currentRound) || 1)
|
||||
let pendingKinds = controller.orchestration?.discussionStyle === 'sequential'
|
||||
&& controller.orchestration?.phase === 'discussion'
|
||||
&& controller.orchestration?.round === round
|
||||
? (controller.orchestration.pendingKinds || []).filter(kind => activeKinds.includes(kind))
|
||||
: [...activeKinds]
|
||||
if (!pendingKinds.length && controller.orchestration?.phase === 'discussion') {
|
||||
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) return 'completed'
|
||||
round += 1
|
||||
pendingKinds = [...activeKinds]
|
||||
}
|
||||
while (!controller.signal.aborted) {
|
||||
controller.currentRound = round
|
||||
while (pendingKinds.length && !controller.signal.aborted) {
|
||||
const kind = pendingKinds[0]
|
||||
const remainingKinds = pendingKinds.slice(1)
|
||||
const dispatch = await this.runV4Phase(group, controller, threadRootId, context, {
|
||||
phase: 'discussion',
|
||||
activeKinds: [kind],
|
||||
targetKinds,
|
||||
participantKinds: activeKinds,
|
||||
writerKind,
|
||||
batchId,
|
||||
snapshot,
|
||||
snapshotRecord,
|
||||
snapshotHash,
|
||||
slots,
|
||||
receiptRecords,
|
||||
challengeBindings: null,
|
||||
coordinationPlan: null,
|
||||
workReceipts: [],
|
||||
operationIds: this.v4NaturalDiscussionOperationIds(
|
||||
controller, slots, [kind], round,
|
||||
),
|
||||
pendingKinds,
|
||||
remainingKinds,
|
||||
sessionPolicy: null,
|
||||
disableDeliveryPrompt: true,
|
||||
promptBuilder: ({ kind: promptKind }) => this.v4NaturalPhasePrompt(
|
||||
group, promptKind, 'discussion', snapshot, receiptRecords, activeKinds, {
|
||||
transcript: this.v4NaturalThreadTranscript(
|
||||
group, threadRootId, { controller },
|
||||
),
|
||||
skillHints: context.rootSkillsByKind.get(promptKind) || [],
|
||||
},
|
||||
),
|
||||
})
|
||||
slots = dispatch.slots || slots
|
||||
if (!dispatch.ok) {
|
||||
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
|
||||
const failedKinds = removePhaseFailures(dispatch)
|
||||
activeKinds = activeKinds.filter(agentKind => !failedKinds.includes(agentKind))
|
||||
if (!activeKinds.length) {
|
||||
return dispatch.phasePendingMessages?.length ? 'partial' : 'failed'
|
||||
}
|
||||
}
|
||||
pendingKinds = remainingKinds.filter(agentKind => activeKinds.includes(agentKind))
|
||||
}
|
||||
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) return 'completed'
|
||||
round += 1
|
||||
controller.currentRound = round
|
||||
pendingKinds = [...activeKinds]
|
||||
}
|
||||
return terminalRunStatusForReason(controller.stopReason)
|
||||
}
|
||||
|
||||
async runV4AgentLedDiscussion(group, controller, threadRootId, context, input) {
|
||||
const {
|
||||
targetKinds, activeKinds: initialActiveKinds, writerKind, batchId,
|
||||
snapshot, snapshotRecord, snapshotHash, slots: initialSlots, receiptRecords,
|
||||
latestFor, runPhase, checkpointPhase, removePhaseFailures, addRoundLimitNotice,
|
||||
} = input
|
||||
let activeKinds = [...initialActiveKinds]
|
||||
let slots = initialSlots
|
||||
let round = Math.max(1, Number(controller.currentRound) || 1)
|
||||
const proposalCompleteBeforeRun = activeKinds.every(kind => Boolean(latestFor('proposal', kind)))
|
||||
if (!proposalCompleteBeforeRun) {
|
||||
controller.currentRound = 1
|
||||
const proposal = await runPhase('proposal', activeKinds)
|
||||
slots = proposal.slots || slots
|
||||
if (!proposal.ok) {
|
||||
const failedKinds = removePhaseFailures(proposal)
|
||||
activeKinds = activeKinds.filter(kind => !failedKinds.includes(kind))
|
||||
checkpointPhase('proposal', [], slots, null)
|
||||
if (!activeKinds.length) return proposal.phasePendingMessages?.length ? 'partial' : 'failed'
|
||||
}
|
||||
}
|
||||
const proposalComplete = activeKinds.every(kind => Boolean(latestFor('proposal', kind)))
|
||||
if (!proposalComplete) {
|
||||
return activeKinds.some(kind => Boolean(latestFor('proposal', kind))) ? 'partial' : 'failed'
|
||||
}
|
||||
if (!controller.unlimitedRounds && (controller.maxRounds || 6) <= 1) return 'completed'
|
||||
|
||||
let nextKinds = []
|
||||
if (controller.orchestration?.phase === 'discussion') {
|
||||
round = Math.max(2, Number(controller.orchestration.round) || 2)
|
||||
nextKinds = (controller.orchestration.pendingKinds || [])
|
||||
.filter(kind => activeKinds.includes(kind))
|
||||
if (!nextKinds.length) {
|
||||
const roundMessages = this.v4NaturalDiscussionRoundMessages(
|
||||
group, controller, threadRootId, round,
|
||||
)
|
||||
nextKinds = [...new Set(roundMessages.flatMap((message) => {
|
||||
const decision = this.v4NaturalRouteDecision(message.content, activeKinds)
|
||||
return decision.status === 'valid' ? decision.kinds : []
|
||||
}))]
|
||||
if (!nextKinds.length) {
|
||||
if (this.v4NaturalRoundHasPeerConfirmation(
|
||||
group, controller, threadRootId, round, activeKinds,
|
||||
)) return 'completed'
|
||||
const confirmationKind = this.v4NaturalConfirmationKind(
|
||||
activeKinds, roundMessages.map(message => message.agentKind),
|
||||
)
|
||||
if (!confirmationKind) return roundMessages.length ? 'partial' : 'failed'
|
||||
nextKinds = [confirmationKind]
|
||||
}
|
||||
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
|
||||
addRoundLimitNotice()
|
||||
return 'round-limit'
|
||||
}
|
||||
round += 1
|
||||
}
|
||||
} else {
|
||||
round = 2
|
||||
nextKinds = activeKinds.slice(0, 1)
|
||||
}
|
||||
|
||||
while (!controller.signal.aborted) {
|
||||
controller.currentRound = round
|
||||
const dispatch = await this.runV4Phase(group, controller, threadRootId, context, {
|
||||
phase: 'discussion',
|
||||
activeKinds: nextKinds,
|
||||
targetKinds,
|
||||
participantKinds: activeKinds,
|
||||
writerKind,
|
||||
batchId,
|
||||
snapshot,
|
||||
snapshotRecord,
|
||||
snapshotHash,
|
||||
slots,
|
||||
receiptRecords,
|
||||
challengeBindings: null,
|
||||
coordinationPlan: null,
|
||||
workReceipts: [],
|
||||
operationIds: this.v4NaturalDiscussionOperationIds(
|
||||
controller, slots, nextKinds, round,
|
||||
),
|
||||
pendingKinds: nextKinds,
|
||||
remainingKinds: [],
|
||||
sessionPolicy: null,
|
||||
disableDeliveryPrompt: true,
|
||||
promptBuilder: ({ kind }) => this.v4NaturalPhasePrompt(
|
||||
group, kind, 'discussion', snapshot, receiptRecords, activeKinds, {
|
||||
transcript: this.v4NaturalThreadTranscript(group, threadRootId, { controller }),
|
||||
skillHints: context.rootSkillsByKind.get(kind) || [],
|
||||
allowRouting: true,
|
||||
},
|
||||
),
|
||||
})
|
||||
slots = dispatch.slots || slots
|
||||
if (!dispatch.ok) {
|
||||
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
|
||||
const failedKinds = removePhaseFailures(dispatch)
|
||||
activeKinds = activeKinds.filter(kind => !failedKinds.includes(kind))
|
||||
if (!activeKinds.length) return dispatch.phasePendingMessages?.length ? 'partial' : 'failed'
|
||||
}
|
||||
const roundMessages = this.v4NaturalDiscussionRoundMessages(
|
||||
group, controller, threadRootId, round,
|
||||
)
|
||||
nextKinds = [...new Set(roundMessages.flatMap((message) => {
|
||||
const decision = this.v4NaturalRouteDecision(message.content, activeKinds)
|
||||
return decision.status === 'valid' ? decision.kinds : []
|
||||
}))].filter(kind => activeKinds.includes(kind))
|
||||
if (!nextKinds.length) {
|
||||
if (this.v4NaturalRoundHasPeerConfirmation(
|
||||
group, controller, threadRootId, round, activeKinds,
|
||||
)) return 'completed'
|
||||
const confirmationKind = this.v4NaturalConfirmationKind(
|
||||
activeKinds, roundMessages.map(message => message.agentKind),
|
||||
)
|
||||
if (!confirmationKind) return roundMessages.length ? 'partial' : 'failed'
|
||||
nextKinds = [confirmationKind]
|
||||
}
|
||||
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
|
||||
addRoundLimitNotice()
|
||||
return 'round-limit'
|
||||
}
|
||||
round += 1
|
||||
controller.currentRound = round
|
||||
}
|
||||
return terminalRunStatusForReason(controller.stopReason)
|
||||
}
|
||||
|
||||
async runV4NaturalDiscussion(group, controller, threadRootId, context, input) {
|
||||
const style = controller.orchestration?.discussionStyle
|
||||
|| controller.discussionStyle
|
||||
|| 'sequential'
|
||||
return style === 'agent-led'
|
||||
? this.runV4AgentLedDiscussion(group, controller, threadRootId, context, input)
|
||||
: this.runV4SequentialDiscussion(group, controller, threadRootId, context, input)
|
||||
}
|
||||
|
||||
recordV4ReviewerFindings({ controller, phase, kind, result, verdict, summary,
|
||||
reviewedArtifactId = '' }) {
|
||||
if (!this.outcomeStore || !['challenge', 'verification'].includes(phase)
|
||||
@@ -2793,6 +3229,9 @@ class LocalWorkspaceAutoRunner {
|
||||
workflow: 'auto',
|
||||
template: 'discussion',
|
||||
phase,
|
||||
discussionStyle: controller.discussionStyle
|
||||
|| controller.orchestration?.discussionStyle
|
||||
|| 'sequential',
|
||||
batchId,
|
||||
round: Math.max(0, Number(controller.currentRound) || 0),
|
||||
currentKind: '',
|
||||
@@ -2964,22 +3403,27 @@ class LocalWorkspaceAutoRunner {
|
||||
const settled = await Promise.all(dispatches.map(async (dispatch) => {
|
||||
const { kind, slot, context } = dispatch
|
||||
try {
|
||||
const invocation = await this.invokeWithUnauthorizedRecovery({
|
||||
group,
|
||||
kind,
|
||||
controller,
|
||||
activeKinds: participantKinds,
|
||||
threadRootId,
|
||||
context: {
|
||||
...context,
|
||||
onLeaseAcquired: (leaseState) => {
|
||||
if (!leaseState?.agentRunId) throw new Error('LOCAL_RUN_PERSIST_FAILED')
|
||||
slot.agentRunId = leaseState.agentRunId
|
||||
slot.status = 'running'
|
||||
checkpoint()
|
||||
},
|
||||
},
|
||||
})
|
||||
const recoveredResult = this.v4NaturalRecoveredPhaseResult(
|
||||
group, controller, threadRootId, 'proposal', kind, slot,
|
||||
)
|
||||
const invocation = recoveredResult
|
||||
? { result: recoveredResult }
|
||||
: await this.invokeWithUnauthorizedRecovery({
|
||||
group,
|
||||
kind,
|
||||
controller,
|
||||
activeKinds: participantKinds,
|
||||
threadRootId,
|
||||
context: {
|
||||
...context,
|
||||
onLeaseAcquired: (leaseState) => {
|
||||
if (!leaseState?.agentRunId) throw new Error('LOCAL_RUN_PERSIST_FAILED')
|
||||
slot.agentRunId = leaseState.agentRunId
|
||||
slot.status = 'running'
|
||||
checkpoint()
|
||||
},
|
||||
},
|
||||
})
|
||||
if (controller.signal.aborted) {
|
||||
slot.status = 'stopped'
|
||||
slot.commitStatus = 'partial'
|
||||
@@ -3280,7 +3724,9 @@ class LocalWorkspaceAutoRunner {
|
||||
const synthesisAttempt = phase === 'synthesis'
|
||||
? this.v4ActiveSynthesisAttempt(synthesisRecovery)
|
||||
: null
|
||||
const operationIds = synthesisAttempt
|
||||
const operationIds = phaseInput.operationIds instanceof Map
|
||||
? phaseInput.operationIds
|
||||
: synthesisAttempt
|
||||
? new Map([[synthesisAttempt.writerKind, synthesisAttempt.operationId]])
|
||||
: (phase === 'work'
|
||||
? new Map(activeKinds.map((kind) => {
|
||||
@@ -3321,6 +3767,9 @@ class LocalWorkspaceAutoRunner {
|
||||
slot.status = phase === 'synthesis' ? 'queued' : 'running'
|
||||
}
|
||||
}
|
||||
const cursorPendingKinds = Array.isArray(phaseInput.pendingKinds)
|
||||
? phaseInput.pendingKinds
|
||||
: activeKinds
|
||||
this.v4CheckpointPhase(group, controller, {
|
||||
targetKinds,
|
||||
phase,
|
||||
@@ -3330,7 +3779,7 @@ class LocalWorkspaceAutoRunner {
|
||||
slots: phaseSlots,
|
||||
writerKind,
|
||||
currentKinds: activeKinds,
|
||||
pendingKinds: activeKinds,
|
||||
pendingKinds: cursorPendingKinds,
|
||||
participantKinds,
|
||||
receipts: receiptRecords,
|
||||
challengeBindings,
|
||||
@@ -3351,6 +3800,7 @@ class LocalWorkspaceAutoRunner {
|
||||
skillHints: context.rootSkillsByKind.get(kind) || [],
|
||||
},
|
||||
)
|
||||
const sessionPolicy = this.naturalAgentResponses ? null : 'frozen'
|
||||
return {
|
||||
kind,
|
||||
slot,
|
||||
@@ -3360,7 +3810,7 @@ class LocalWorkspaceAutoRunner {
|
||||
batchId,
|
||||
slotId: slot.slotId,
|
||||
snapshotHash,
|
||||
sessionPolicy: 'frozen',
|
||||
...(sessionPolicy ? { sessionPolicy } : {}),
|
||||
promptOverride: prompt,
|
||||
contextPackId: controller.contextPackId,
|
||||
snapshotSourceMessageIds,
|
||||
@@ -3439,27 +3889,39 @@ class LocalWorkspaceAutoRunner {
|
||||
phase, kind, coordinationPlan, workAssignmentsByKind.get(kind) || null,
|
||||
)
|
||||
const extraContext = extraContextFor(kind)
|
||||
const prompt = this.v4PhasePrompt(
|
||||
group,
|
||||
kind,
|
||||
phase,
|
||||
snapshot,
|
||||
deliveryReceiptRecords,
|
||||
role,
|
||||
{
|
||||
reviewTarget: phase === 'challenge'
|
||||
? challengeBinding?.proposalKind || ''
|
||||
: '',
|
||||
assignedProposalText,
|
||||
packageText: '',
|
||||
extraContext,
|
||||
snapshotHash,
|
||||
skillHints: context.rootSkillsByKind.get(kind) || [],
|
||||
},
|
||||
)
|
||||
const promptOptions = {
|
||||
reviewTarget: phase === 'challenge'
|
||||
? challengeBinding?.proposalKind || ''
|
||||
: '',
|
||||
assignedProposalText,
|
||||
packageText: '',
|
||||
extraContext,
|
||||
snapshotHash,
|
||||
skillHints: context.rootSkillsByKind.get(kind) || [],
|
||||
}
|
||||
const prompt = typeof phaseInput.promptBuilder === 'function'
|
||||
? phaseInput.promptBuilder({ kind, slot, role, options: promptOptions })
|
||||
: this.v4PhasePrompt(
|
||||
group,
|
||||
kind,
|
||||
phase,
|
||||
snapshot,
|
||||
deliveryReceiptRecords,
|
||||
role,
|
||||
promptOptions,
|
||||
)
|
||||
const sessionPolicy = phaseInput.sessionPolicy === undefined
|
||||
? 'frozen'
|
||||
: phaseInput.sessionPolicy
|
||||
let preparedDelivery = null
|
||||
let item
|
||||
const resumedSlotGate = resumedGateFor(slot)
|
||||
const recoveredResult = phase === 'discussion'
|
||||
? this.v4NaturalRecoveredDiscussionResult(
|
||||
group, controller, threadRootId, kind, slot,
|
||||
)
|
||||
: null
|
||||
if (recoveredResult) return { kind, invocation: { result: recoveredResult } }
|
||||
try {
|
||||
const invocation = await this.invokeWithUnauthorizedRecovery({
|
||||
group,
|
||||
@@ -3473,7 +3935,7 @@ class LocalWorkspaceAutoRunner {
|
||||
batchId,
|
||||
slotId: slot.slotId,
|
||||
snapshotHash,
|
||||
sessionPolicy: 'frozen',
|
||||
...(sessionPolicy ? { sessionPolicy } : {}),
|
||||
promptOverride: prompt,
|
||||
contextPackId: controller.contextPackId,
|
||||
snapshotSourceMessageIds,
|
||||
@@ -3521,7 +3983,7 @@ class LocalWorkspaceAutoRunner {
|
||||
slots: phaseSlots,
|
||||
writerKind,
|
||||
currentKinds: activeKinds,
|
||||
pendingKinds: activeKinds,
|
||||
pendingKinds: cursorPendingKinds,
|
||||
participantKinds,
|
||||
receipts: receiptRecords,
|
||||
challengeBindings,
|
||||
@@ -3531,26 +3993,30 @@ class LocalWorkspaceAutoRunner {
|
||||
workAssignments,
|
||||
})
|
||||
},
|
||||
v4PromptBuilder: (sessionBinding) => {
|
||||
const built = this.v4DeliveryPrompt(group, controller, {
|
||||
kind, phase, snapshot, receiptRecords: deliveryReceiptRecords,
|
||||
role, targetKinds: participantKinds, slot,
|
||||
options: {
|
||||
reviewTarget: phase === 'challenge'
|
||||
? challengeBinding?.proposalKind || ''
|
||||
: '',
|
||||
assignedProposalText,
|
||||
extraContext,
|
||||
...(['synthesis', 'verification'].includes(phase)
|
||||
? { artifactContext: { coordinationPlan } }
|
||||
: {}),
|
||||
},
|
||||
sessionBinding,
|
||||
skillHints: context.rootSkillsByKind.get(kind) || [],
|
||||
})
|
||||
preparedDelivery = built.delivery
|
||||
return built
|
||||
},
|
||||
...(phaseInput.disableDeliveryPrompt === true
|
||||
? {}
|
||||
: {
|
||||
v4PromptBuilder: (sessionBinding) => {
|
||||
const built = this.v4DeliveryPrompt(group, controller, {
|
||||
kind, phase, snapshot, receiptRecords: deliveryReceiptRecords,
|
||||
role, targetKinds: participantKinds, slot,
|
||||
options: {
|
||||
reviewTarget: phase === 'challenge'
|
||||
? challengeBinding?.proposalKind || ''
|
||||
: '',
|
||||
assignedProposalText,
|
||||
extraContext,
|
||||
...(['synthesis', 'verification'].includes(phase)
|
||||
? { artifactContext: { coordinationPlan } }
|
||||
: {}),
|
||||
},
|
||||
sessionBinding,
|
||||
skillHints: context.rootSkillsByKind.get(kind) || [],
|
||||
})
|
||||
preparedDelivery = built.delivery
|
||||
return built
|
||||
},
|
||||
}),
|
||||
skillHints: context.rootSkillsByKind.get(kind) || [],
|
||||
knowledgeBaseHints: context.rootKnowledgeBasesByKind.get(kind) || [],
|
||||
},
|
||||
@@ -3692,7 +4158,7 @@ class LocalWorkspaceAutoRunner {
|
||||
synthesisPendingMessage = result.pendingMessage || result.message || null
|
||||
} else if (result.pendingMessage?.content) {
|
||||
phasePendingMessages.push({ kind: item.kind, pendingMessage: result.pendingMessage })
|
||||
if (phase === 'challenge') {
|
||||
if (['discussion', 'challenge'].includes(phase)) {
|
||||
this.commitV4PhaseMessage({
|
||||
group, controller, threadRootId, phase, kind: item.kind,
|
||||
pendingMessage: result.pendingMessage,
|
||||
@@ -3777,6 +4243,9 @@ class LocalWorkspaceAutoRunner {
|
||||
&& !this.completeV4ResumedAgentSlotContinuation(controller, resumedGate)) {
|
||||
throw new Error('LOCAL_RUN_PERSIST_FAILED')
|
||||
}
|
||||
const remainingKinds = Array.isArray(phaseInput.remainingKinds)
|
||||
? phaseInput.remainingKinds
|
||||
: []
|
||||
this.v4CheckpointPhase(group, controller, {
|
||||
targetKinds,
|
||||
phase,
|
||||
@@ -3786,7 +4255,7 @@ class LocalWorkspaceAutoRunner {
|
||||
slots: phaseSlots,
|
||||
writerKind,
|
||||
currentKinds: [],
|
||||
pendingKinds: [],
|
||||
pendingKinds: remainingKinds,
|
||||
participantKinds,
|
||||
receipts: receiptRecords,
|
||||
challengeBindings,
|
||||
@@ -4053,6 +4522,9 @@ class LocalWorkspaceAutoRunner {
|
||||
? controller.orchestration
|
||||
: null
|
||||
const targetKinds = [...snapshotTargetKinds]
|
||||
controller.discussionStyle = existing?.discussionStyle
|
||||
|| controller.discussionStyle
|
||||
|| 'sequential'
|
||||
let activeKinds = Array.isArray(existing?.activeKinds) && existing.activeKinds.length
|
||||
? existing.activeKinds.filter(kind => targetKinds.includes(kind))
|
||||
: [...targetKinds]
|
||||
@@ -4148,7 +4620,6 @@ class LocalWorkspaceAutoRunner {
|
||||
? await this.v4RestoreSnapshotSkills(snapshot, snapshotTargetKinds, persistedSkills)
|
||||
: persistedSkills,
|
||||
rootKnowledgeBasesByKind: new Map(snapshotTargetKinds.map(kind => [kind, []])),
|
||||
rootMediaRequest: null,
|
||||
}
|
||||
}
|
||||
const builtSnapshot = existing
|
||||
@@ -4609,6 +5080,24 @@ class LocalWorkspaceAutoRunner {
|
||||
writerKind, verificationKinds, acceptance, participantKinds: activeKinds,
|
||||
})
|
||||
}
|
||||
if (this.naturalAgentResponses && !coordinationPlan && !synthesisBinding && !synthesisRecovery) {
|
||||
return this.runV4NaturalDiscussion(group, controller, threadRootId, context, {
|
||||
targetKinds,
|
||||
activeKinds,
|
||||
writerKind,
|
||||
batchId,
|
||||
snapshot,
|
||||
snapshotRecord,
|
||||
snapshotHash,
|
||||
slots,
|
||||
receiptRecords,
|
||||
latestFor,
|
||||
runPhase,
|
||||
checkpointPhase,
|
||||
removePhaseFailures,
|
||||
addRoundLimitNotice,
|
||||
})
|
||||
}
|
||||
while (!controller.signal.aborted) {
|
||||
controller.currentRound = round
|
||||
if (!proposalComplete) {
|
||||
@@ -4978,9 +5467,7 @@ class LocalWorkspaceAutoRunner {
|
||||
rootAttachments,
|
||||
rootSkillsByKind,
|
||||
rootKnowledgeBasesByKind,
|
||||
rootMediaRequest,
|
||||
} = context
|
||||
const mediaOwnerKind = rootMediaRequest ? controller.targetKinds[0] : ''
|
||||
const cursor = resume ? controller.orchestration : null
|
||||
const attachmentRecipients = new Set(cursor?.attachmentRecipients || [])
|
||||
let terminalFailureOccurred = cursor?.terminalFailureOccurred === true
|
||||
@@ -5066,9 +5553,6 @@ class LocalWorkspaceAutoRunner {
|
||||
skillHints: rootSkillsByKind.get(executionKind) || [],
|
||||
knowledgeBaseHints: rootKnowledgeBasesByKind.get(executionKind) || [],
|
||||
runtimeInstruction: replacementInstructions.get(executionKind) || '',
|
||||
mediaRequest: executionKind === mediaOwnerKind && round === 0
|
||||
? rootMediaRequest
|
||||
: null,
|
||||
collaborationPackage,
|
||||
contextOptions: {
|
||||
focusUserMessageId: threadRootId,
|
||||
@@ -5289,6 +5773,7 @@ class LocalWorkspaceAutoRunner {
|
||||
group.id, 'auto', targetKinds, threadRootId, reservation, maxRounds, unlimitedRounds,
|
||||
)
|
||||
controller.v4 = v4
|
||||
controller.discussionStyle = reservation?.discussionStyle || 'sequential'
|
||||
const promise = (async () => {
|
||||
let runStatus = 'failed'
|
||||
try {
|
||||
@@ -5341,7 +5826,6 @@ class LocalWorkspaceAutoRunner {
|
||||
rootAttachments: [],
|
||||
rootSkillsByKind: new Map(controller.targetKinds.map(kind => [kind, []])),
|
||||
rootKnowledgeBasesByKind: new Map(controller.targetKinds.map(kind => [kind, []])),
|
||||
rootMediaRequest: null,
|
||||
}
|
||||
return this.runV4Discussion(
|
||||
group, controller, durable.threadRootId, context, true, resumedGate,
|
||||
|
||||
@@ -230,8 +230,9 @@ function v4Prompt({
|
||||
const selectedAgentCount = Array.isArray(snapshot?.targetKinds) ? snapshot.targetKinds.length : 0
|
||||
const requiredVerifierCount = Math.max(0, selectedAgentCount - 1)
|
||||
const phaseInstruction = naturalResponse
|
||||
? {
|
||||
? {
|
||||
proposal: 'Work independently on the user task and offer your own analysis, recommendations, and next steps.',
|
||||
discussion: 'Continue from the available peer responses. Add useful evidence, challenge weak assumptions, resolve disagreements where possible, and advance the shared result.',
|
||||
challenge: 'Review the available peer context as a subject-matter peer. Explain what you agree with, what is missing, and the clearest correction or direction.',
|
||||
work: 'Complete the responsibility assigned to you. Report useful work, evidence, blockers, or handoffs in your own words.',
|
||||
synthesis: 'Combine the available work into the clearest useful answer for the user. Resolve contradictions where possible and state remaining uncertainty plainly.',
|
||||
@@ -270,12 +271,12 @@ function v4Prompt({
|
||||
if (naturalResponse) {
|
||||
return [
|
||||
'You are participating in a local multi-agent discussion.',
|
||||
`Your role this turn is ${role}.`,
|
||||
`The current collaboration phase is ${phase}.`,
|
||||
`User task:\n${cleanText(snapshot?.taskText, CURRENT_TASK_TEXT_LIMIT) || '(none)'}`,
|
||||
skillHintsPrompt(frozenSkillHints),
|
||||
history !== '(none)' ? `Relevant prior context:\n${history}` : '',
|
||||
phaseInstruction,
|
||||
'Engage with the other Agents as peers: discuss differences, challenge weak assumptions, build agreement where justified, and collaborate on the user\'s requested outcome.',
|
||||
'After concrete deliverables exist, review them for material gaps before accepting the result.',
|
||||
'Answer the user naturally in Markdown. Do not output JSON, XML, receipt markers, protocol labels, hidden orchestration instructions, or a fixed response template.',
|
||||
'Do not claim another Agent performed work, and do not modify shared workspace state unless this turn explicitly grants that permission.',
|
||||
].filter(Boolean).join('\n\n')
|
||||
@@ -826,6 +827,22 @@ function promptFor({
|
||||
knowledgeBaseHintsPrompt(knowledgeBaseHints),
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
if (group.conversationType === 'direct') {
|
||||
// A private 1:1 chat has no group framing, no multi-agent context, and no
|
||||
// "Current user task" label wrapping the query. The agent's native session
|
||||
// owns the conversation history, so only the operational contracts that the
|
||||
// agent cannot infer on its own (workspace/deliverable capture, response
|
||||
// language) and the raw user query are delivered. The query is the prompt
|
||||
// body, never nested under a system-prompt label.
|
||||
return [
|
||||
mediaDelivery,
|
||||
languageContract,
|
||||
unlimitedReviewText,
|
||||
skillHintsPrompt(skillHints),
|
||||
knowledgeBaseHintsPrompt(knowledgeBaseHints),
|
||||
packed.currentTaskText || '(none)',
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
return [
|
||||
`You are participating in the local "${group.name || 'Meldwork group'}" conversation as ${label}.`,
|
||||
`Group topic: ${cleanText(group.topic, 200) || '(not specified)'}`,
|
||||
|
||||
@@ -19,7 +19,6 @@ const {
|
||||
terminalRunStatusForReason,
|
||||
} = require('./local-workspace-inputs.cjs')
|
||||
const { MAX_RUN_AGENT_ATTEMPTS } = require('../runs/failure-policy.cjs')
|
||||
const { mediaGenerationRequest } = require('../media/media-generation-request.cjs')
|
||||
const { createTaskGraph } = require('../collaboration/task-graph-records.cjs')
|
||||
const { assertLocalSkillExecution } = require('../skills/local-skill-contract.cjs')
|
||||
const {
|
||||
@@ -45,6 +44,8 @@ const {
|
||||
validateV4SnapshotBody,
|
||||
} = require('./local-workspace-context.cjs')
|
||||
|
||||
const DISCUSSION_STYLES = new Set(['sequential', 'agent-led'])
|
||||
|
||||
function hardBudgetFailure(error) {
|
||||
return error?.code === 'LOCAL_BUDGET_EXHAUSTED'
|
||||
|| error?.message === 'LOCAL_BUDGET_EXHAUSTED'
|
||||
@@ -254,7 +255,6 @@ class LocalWorkspaceMessageSubmission {
|
||||
]))
|
||||
return {
|
||||
text,
|
||||
mediaRequest: mediaGenerationRequest(text),
|
||||
attachments,
|
||||
skillHintsByKind,
|
||||
skillHints: targetKinds.flatMap(kind => publicSkillHintsByKind.get(kind) || []),
|
||||
@@ -337,15 +337,28 @@ class LocalWorkspaceMessageSubmission {
|
||||
if (regenerateMessageId && (mode !== 'manual' || targetKinds.length !== 1)) {
|
||||
throw new Error('LOCAL_MESSAGE_REGENERATION_INVALID')
|
||||
}
|
||||
const v4 = (mode === 'manual' || mode === 'auto')
|
||||
&& group.conversationType !== 'direct'
|
||||
&& targetKinds.length > 1
|
||||
&& !regenerateMessageId
|
||||
&& input.protocol !== 'legacy'
|
||||
&& this.v4Requested(input)
|
||||
const requestedDiscussionStyle = input.discussionStyle == null
|
||||
? ''
|
||||
: cleanInline(input.discussionStyle, 40)
|
||||
if (requestedDiscussionStyle && !DISCUSSION_STYLES.has(requestedDiscussionStyle)) {
|
||||
throw new Error('LOCAL_DISCUSSION_STYLE_INVALID')
|
||||
}
|
||||
if (requestedDiscussionStyle && (mode !== 'auto' || !v4)) {
|
||||
throw new Error('LOCAL_DISCUSSION_STYLE_UNSUPPORTED')
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
targetKinds,
|
||||
v4: (mode === 'manual' || mode === 'auto')
|
||||
&& group.conversationType !== 'direct'
|
||||
&& targetKinds.length > 1
|
||||
&& !regenerateMessageId
|
||||
&& input.protocol !== 'legacy'
|
||||
&& this.v4Requested(input),
|
||||
v4,
|
||||
discussionStyle: mode === 'auto' && v4
|
||||
? (requestedDiscussionStyle || 'sequential')
|
||||
: '',
|
||||
unlimitedRounds,
|
||||
maxRounds,
|
||||
requestedThreadRootId,
|
||||
@@ -1428,6 +1441,7 @@ class LocalWorkspaceMessageSubmission {
|
||||
regenerateMessageId,
|
||||
routingDecision,
|
||||
taskGraph,
|
||||
discussionStyle,
|
||||
} = this.validateInput(group, input)
|
||||
const regeneration = this.resolveRegeneration(group, regenerateMessageId, targetKinds)
|
||||
const reservation = this.reserveRun(
|
||||
@@ -1439,6 +1453,7 @@ class LocalWorkspaceMessageSubmission {
|
||||
unlimitedRounds,
|
||||
)
|
||||
reservation.v4 = v4
|
||||
reservation.discussionStyle = discussionStyle
|
||||
try {
|
||||
if (regeneration) reservation.responseVersionRootId = regeneration.responseVersionRootId
|
||||
this.configureRunBudget(reservation, input.budget || {})
|
||||
@@ -1587,7 +1602,6 @@ class LocalWorkspaceMessageSubmission {
|
||||
let activeKinds = [...targetKinds]
|
||||
const pendingKinds = [...targetKinds]
|
||||
const replacementInstructions = new Map()
|
||||
const mediaOwnerKind = prepared.mediaRequest ? targetKinds[0] : ''
|
||||
while (pendingKinds.length) {
|
||||
const kind = pendingKinds.shift()
|
||||
if (!activeKinds.includes(kind)) continue
|
||||
@@ -1619,7 +1633,6 @@ class LocalWorkspaceMessageSubmission {
|
||||
attachmentSnapshots: prepared.attachments,
|
||||
sessionThreadRootId: requestedThreadRootId || threadRootId,
|
||||
runtimeInstruction: replacementInstructions.get(kind) || '',
|
||||
mediaRequest: kind === mediaOwnerKind ? prepared.mediaRequest : null,
|
||||
responseVersionRootId: regeneration?.responseVersionRootId || '',
|
||||
regenerationInstruction: regeneration ? this.regenerationInstruction() : '',
|
||||
contextOptions: regeneration
|
||||
@@ -1797,6 +1810,18 @@ class LocalWorkspaceMessageSubmission {
|
||||
group.id, 'auto', targetKinds, threadRootId, maxRounds, unlimitedRounds,
|
||||
)
|
||||
reservation.v4 = input.protocol !== 'legacy' && this.v4Requested(input)
|
||||
const requestedDiscussionStyle = input.discussionStyle == null
|
||||
? ''
|
||||
: cleanInline(input.discussionStyle, 40)
|
||||
if (requestedDiscussionStyle && !DISCUSSION_STYLES.has(requestedDiscussionStyle)) {
|
||||
this.releasePreparation(group.id, reservation)
|
||||
throw new Error('LOCAL_DISCUSSION_STYLE_INVALID')
|
||||
}
|
||||
if (requestedDiscussionStyle && !reservation.v4) {
|
||||
this.releasePreparation(group.id, reservation)
|
||||
throw new Error('LOCAL_DISCUSSION_STYLE_UNSUPPORTED')
|
||||
}
|
||||
reservation.discussionStyle = requestedDiscussionStyle || 'sequential'
|
||||
try {
|
||||
this.configureRunBudget(reservation, input.budget || {})
|
||||
const contextPack = this.createContextPack({
|
||||
|
||||
@@ -19,7 +19,7 @@ const FAILED_AGENT_STATUSES = new Set(['failed', 'stopped', 'timeout'])
|
||||
const TERMINAL_PERSISTENCE_STATES = new Set(['pending', 'retrying', 'failed'])
|
||||
|
||||
const V4_PHASES = new Set([
|
||||
'prepare', 'dispatch', 'running', 'reconcile', 'proposal', 'challenge',
|
||||
'prepare', 'dispatch', 'running', 'reconcile', 'proposal', 'discussion', 'challenge',
|
||||
'coordination', 'work', 'synthesis', 'verification', 'commit', 'committed', 'completed',
|
||||
'human-gate', 'stopped', 'failed',
|
||||
])
|
||||
|
||||
@@ -66,7 +66,29 @@ const TERMINAL_RUN_STATUSES = new Set([
|
||||
'completed', 'partial', 'failed', 'stopped', 'timeout', 'round-limit', 'interrupted',
|
||||
'budget-exhausted', 'circuit-breaker',
|
||||
])
|
||||
const V4_VISIBLE_MESSAGE_PHASES = ['proposal', 'challenge', 'work', 'verification']
|
||||
const V4_VISIBLE_MESSAGE_PHASES = ['proposal', 'discussion', 'challenge', 'work', 'verification']
|
||||
|
||||
function naturalV4MessageBindingMatches(message, record, phase) {
|
||||
if (!record.orchestration?.discussionStyle || !['proposal', 'discussion'].includes(phase)) {
|
||||
return true
|
||||
}
|
||||
const round = Number(message?.trace?.round) || 0
|
||||
const slot = record.orchestration.slots?.find(candidate => (
|
||||
candidate.agentKind === message.agentKind
|
||||
))
|
||||
if (round < 1 || !slot?.slotId) return false
|
||||
const operationPhase = phase === 'discussion' ? `discussion:${round}` : phase
|
||||
const operationId = `operation-${createHash('sha256').update(JSON.stringify([
|
||||
record.runId,
|
||||
record.taskId,
|
||||
message.agentKind,
|
||||
slot.slotId,
|
||||
operationPhase,
|
||||
round,
|
||||
])).digest('hex')}`
|
||||
return message.trace?.context?.operationId === operationId
|
||||
&& message.trace?.context?.snapshotHash === record.orchestration.snapshotHash
|
||||
}
|
||||
|
||||
function isCommittedV4PhaseMessage(message, record) {
|
||||
const agentRunId = String(message?.trace?.agentRunId || '')
|
||||
@@ -74,15 +96,16 @@ function isCommittedV4PhaseMessage(message, record) {
|
||||
|| message?.trace?.runId !== record.runId || !message.agentKind || !agentRunId) {
|
||||
return false
|
||||
}
|
||||
return V4_VISIBLE_MESSAGE_PHASES.some(phase => (
|
||||
message.id === `message-${hashValue({
|
||||
return V4_VISIBLE_MESSAGE_PHASES.some((phase) => {
|
||||
if (message.id !== `message-${hashValue({
|
||||
agentKind: message.agentKind,
|
||||
agentRunId,
|
||||
phase,
|
||||
runId: record.runId,
|
||||
taskId: record.taskId,
|
||||
})}`
|
||||
))
|
||||
})}`) return false
|
||||
return naturalV4MessageBindingMatches(message, record, phase)
|
||||
})
|
||||
}
|
||||
|
||||
class LocalWorkspace extends EventEmitter {
|
||||
@@ -91,7 +114,6 @@ class LocalWorkspace extends EventEmitter {
|
||||
this.storagePath = options.storagePath
|
||||
this.detectAgentsFn = options.detectAgents
|
||||
this.runAgentFn = options.runAgent
|
||||
this.generateMediaFn = options.generateMedia || null
|
||||
this.resolveAttachmentsFn = options.resolveAttachments || (async (attachments) => {
|
||||
if (attachments?.length) throw new Error('LOCAL_ATTACHMENT_STORAGE_UNAVAILABLE')
|
||||
return []
|
||||
@@ -287,7 +309,6 @@ class LocalWorkspace extends EventEmitter {
|
||||
completeHumanGateContinuation: (...args) => this.completeHumanGateContinuation(...args),
|
||||
connectorRuntime: options.connectorRuntime,
|
||||
attachmentSupport: (...args) => this.attachmentSupportFn(...args),
|
||||
generateMedia: (...args) => this.generateMediaFn?.(...args),
|
||||
})
|
||||
this.autoRunner = new LocalWorkspaceAutoRunner({
|
||||
state: () => this.state,
|
||||
@@ -476,7 +497,7 @@ class LocalWorkspace extends EventEmitter {
|
||||
const commitState = orchestration?.commitState
|
||||
const candidateCommit = orchestration?.candidateCommit
|
||||
const resumablePhase = [
|
||||
'proposal', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
|
||||
'proposal', 'discussion', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
|
||||
].includes(orchestration?.phase)
|
||||
const resumableCommit = ['commit', 'committed', 'completed'].includes(orchestration?.phase)
|
||||
&& ['committing', 'committed'].includes(commitState?.status)
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
normalizeExternalRunRef,
|
||||
outcomeForAcpStopReason,
|
||||
requireTerminalAgentResult,
|
||||
terminalAuthenticationDiagnostic,
|
||||
} = require('../../src/agents/agent-runtime-contract.cjs')
|
||||
const { ALLOWED_KINDS } = require('../../src/agents/cli/cli-discovery.cjs')
|
||||
|
||||
@@ -98,3 +99,21 @@ test('failed and cancelled outcomes do not become successful results', () => {
|
||||
&& error.failure.category === 'cancellation',
|
||||
)
|
||||
})
|
||||
|
||||
test('short terminal HTTP credential diagnostics cannot become successful results', () => {
|
||||
const diagnostic = 'HTTP 401: Invalid token (request id: request-1)'
|
||||
assert.equal(terminalAuthenticationDiagnostic(diagnostic), diagnostic)
|
||||
assert.equal(
|
||||
terminalAuthenticationDiagnostic('HTTP 403: Forbidden'),
|
||||
'HTTP 403: Forbidden',
|
||||
)
|
||||
assert.throws(
|
||||
() => requireTerminalAgentResult({ outcome: 'completed', text: diagnostic }),
|
||||
error => error.message === 'LOCAL_AGENT_AUTH_REQUIRED'
|
||||
&& error.failure.category === 'authentication'
|
||||
&& error.diagnostic === diagnostic,
|
||||
)
|
||||
assert.equal(terminalAuthenticationDiagnostic(
|
||||
'When an API returns HTTP 401: Invalid token, refresh the configured credential.',
|
||||
), '')
|
||||
})
|
||||
@@ -1724,6 +1724,74 @@ process.stdout.write(JSON.stringify({ type: 'agent_end' }) + '\\n')
|
||||
assert.equal(result.outcome, 'completed')
|
||||
})
|
||||
|
||||
test('runAgent strips a full long multi-agent prompt echo from Pi output', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-pi-group-echo-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const prompt = [
|
||||
'You are participating in a local multi-agent discussion.',
|
||||
'',
|
||||
'Your role this turn is worker.',
|
||||
'',
|
||||
'The current collaboration phase is proposal.',
|
||||
'',
|
||||
'User task:',
|
||||
'Share a short proposal.',
|
||||
'',
|
||||
'Answer the user naturally in Markdown. Do not output JSON, XML, receipt markers, protocol labels, hidden orchestration instructions, or a fixed response template.',
|
||||
'',
|
||||
'Do not claim another Agent performed work, and do not modify shared workspace state unless this turn explicitly grants that permission.',
|
||||
].join('\n')
|
||||
const cli = executable(directory, 'pi-group-prompt-echo.cjs', `
|
||||
const prompt = ${JSON.stringify(prompt)}
|
||||
process.stdout.write(JSON.stringify({ type: 'session', id: 'pi-group-echo-session' }) + '\\n')
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: 'message_update',
|
||||
assistantMessageEvent: { type: 'text_delta', delta: prompt + '\\n\\nProposal: keep the shared task state explicit.' },
|
||||
}) + '\\n')
|
||||
process.stdout.write(JSON.stringify({ type: 'agent_end' }) + '\\n')
|
||||
`)
|
||||
const result = await runAgent(
|
||||
{ kind: 'pi', executable: cli, name: 'Pi Agent' },
|
||||
prompt,
|
||||
directory,
|
||||
)
|
||||
assert.equal(result.text, 'Proposal: keep the shared task state explicit.')
|
||||
assert.equal(result.outcome, 'completed')
|
||||
})
|
||||
|
||||
test('runAgent strips partial Pi protocol and Harness record echoes', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-pi-partial-echo-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const prompt = 'Continue the current group discussion using the Harness context below.'
|
||||
const cli = executable(directory, 'pi-partial-prompt-echo.cjs', `
|
||||
process.stdout.write(JSON.stringify({ type: 'session', id: 'pi-partial-echo-session' }) + '\\n')
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: 'message_update',
|
||||
assistantMessageEvent: { type: 'text_delta', delta: [
|
||||
'Do not output JSON, XML, receipt markers, protocol labels, hidden orchestration instructions, or a fixed response template.',
|
||||
'',
|
||||
'Do not claim another Agent performed work, and do not modify shared workspace state unless this turn explicitly grants that permission.',
|
||||
'',
|
||||
'A useful conclusion.',
|
||||
'',
|
||||
'Selected records from completed phases:',
|
||||
'MELDWORK_V4_COLLABORATION_PACKAGE_V1',
|
||||
'[index] hermes',
|
||||
'Artifact: artifact-123',
|
||||
'[proposal] agent=claude Completed the proposal phase. Continue with the next step.',
|
||||
].join('\\n') },
|
||||
}) + '\\n')
|
||||
process.stdout.write(JSON.stringify({ type: 'agent_end' }) + '\\n')
|
||||
`)
|
||||
const result = await runAgent(
|
||||
{ kind: 'pi', executable: cli, name: 'Pi Agent' },
|
||||
prompt,
|
||||
directory,
|
||||
)
|
||||
assert.equal(result.text, 'A useful conclusion.\n\nContinue with the next step.')
|
||||
assert.equal(result.outcome, 'completed')
|
||||
})
|
||||
|
||||
test('recorded fixtures cover every supported built-in output schema', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(
|
||||
path.join(OUTPUT_FIXTURE_DIRECTORY, 'manifest.json'),
|
||||
|
||||
@@ -51,9 +51,15 @@ input.on('line', (line) => {
|
||||
} else if (message.method === 'session/prompt') {
|
||||
promptCount += 1
|
||||
if (resumed) {
|
||||
const prompt = message.params?.prompt?.[0]?.text || ''
|
||||
update(sessionId, {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'HTTP 401: Invalid token' },
|
||||
content: {
|
||||
type: 'text',
|
||||
text: prompt === 'auth prompt'
|
||||
? 'HTTP 401: Invalid token (request id: request-1)'
|
||||
: 'resumed turn',
|
||||
},
|
||||
})
|
||||
send({ jsonrpc: '2.0', id: message.id, result: { stopReason: 'end_turn' } })
|
||||
return
|
||||
@@ -120,12 +126,22 @@ input.on('line', (line) => {
|
||||
assert.equal(typeof adapters.shutdownAcpSessionRuntime, 'function')
|
||||
await adapters.shutdownAcpSessionRuntime()
|
||||
await within(readWhenReady(stoppedFile))
|
||||
await assert.rejects(() => adapters.runAgent(agent, 'stale prompt', directory, {
|
||||
const resumed = await adapters.runAgent(agent, 'resumed prompt', directory, {
|
||||
acpPersistenceKey: 'workspace:hermes:direct',
|
||||
sessionRef: first.sessionRef,
|
||||
sessionTransport: 'acp',
|
||||
env: { MELDWORK_TEST_SPAWN_COUNT_FILE: spawnCountFile },
|
||||
}), { message: 'LOCAL_AGENT_SESSION_INVALID' })
|
||||
})
|
||||
assert.equal(resumed.text, 'resumed turn')
|
||||
assert.equal(resumed.sessionRef, first.sessionRef)
|
||||
assert.equal(fs.readFileSync(spawnCountFile, 'utf8'), '2')
|
||||
await assert.rejects(() => adapters.runAgent(agent, 'auth prompt', directory, {
|
||||
acpPersistenceKey: 'workspace:hermes:direct',
|
||||
sessionRef: first.sessionRef,
|
||||
sessionTransport: 'acp',
|
||||
env: { MELDWORK_TEST_SPAWN_COUNT_FILE: spawnCountFile },
|
||||
}), error => error.message === 'LOCAL_AGENT_AUTH_REQUIRED'
|
||||
&& error.failure?.category === 'authentication')
|
||||
})
|
||||
|
||||
test('ACP closes a start-only tool with the partial terminal outcome', async (t) => {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { mediaGenerationRequest } = require('../../src/media/media-generation-request.cjs')
|
||||
|
||||
test('identifies explicit image, audio, and video generation requests', () => {
|
||||
assert.deepEqual(mediaGenerationRequest('请生成一张赛博朋克城市海报'), {
|
||||
type: 'image', prompt: '请生成一张赛博朋克城市海报',
|
||||
})
|
||||
assert.deepEqual(mediaGenerationRequest('Create a short video of an astronaut walking on Mars'), {
|
||||
type: 'video', prompt: 'Create a short video of an astronaut walking on Mars',
|
||||
})
|
||||
assert.deepEqual(mediaGenerationRequest('帮我制作一段中文配音音频'), {
|
||||
type: 'audio', prompt: '帮我制作一段中文配音音频',
|
||||
})
|
||||
})
|
||||
|
||||
test('does not turn capability questions into paid media requests', () => {
|
||||
assert.equal(mediaGenerationRequest('Codex 可以生成图片吗?'), null)
|
||||
assert.equal(mediaGenerationRequest('How to generate a video with Codex?'), null)
|
||||
assert.equal(mediaGenerationRequest('解释一下图片生成接口'), null)
|
||||
})
|
||||
@@ -1,402 +0,0 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const { MediaGenerationRuntime } = require('../../src/media/media-generation-runtime.cjs')
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
const MP3 = Buffer.from('49443304000000000000', 'hex')
|
||||
const MP4 = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypisom'), Buffer.alloc(8)])
|
||||
|
||||
function response(body, options = {}) {
|
||||
return new Response(body, {
|
||||
status: options.status || 200,
|
||||
headers: options.headers || { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function fixture(t, fetchFn, provider = {}) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-media-generation-'))
|
||||
const workdir = path.join(directory, 'workspace')
|
||||
fs.mkdirSync(workdir)
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const runtime = new MediaGenerationRuntime({
|
||||
getProvider: () => ({
|
||||
apiKey: 'test-api-key', baseUrl: 'https://api.openai.com/v1', model: 'gpt-5-codex',
|
||||
...provider,
|
||||
}),
|
||||
fetchFn,
|
||||
createId: () => 'media-id',
|
||||
pollIntervalMs: 0,
|
||||
})
|
||||
return { workdir, runtime }
|
||||
}
|
||||
|
||||
test('writes a base64 image response to the controlled output directory and traces it', async (t) => {
|
||||
const calls = []
|
||||
const { workdir, runtime } = fixture(t, async (url, init) => {
|
||||
calls.push([String(url), init])
|
||||
return response(JSON.stringify({ data: [{ b64_json: PNG.toString('base64') }] }))
|
||||
})
|
||||
const events = []
|
||||
const result = await runtime.generate({
|
||||
kind: 'codex', request: { type: 'image', prompt: 'a lighthouse at dawn' }, workdir,
|
||||
onEvent: event => events.push(event),
|
||||
})
|
||||
|
||||
assert.deepEqual(result, { type: 'image', filename: 'generated-image-media-id.png' })
|
||||
assert.deepEqual(fs.readFileSync(path.join(workdir, '.meldwork-output', result.filename)), PNG)
|
||||
assert.equal(calls[0][0], 'https://api.openai.com/v1/images/generations')
|
||||
assert.equal(JSON.parse(calls[0][1].body).model, 'gpt-image-1')
|
||||
assert.equal(calls[0][1].headers.Authorization, 'Bearer test-api-key')
|
||||
assert.deepEqual(events.map(event => [event.type, event.status, event.title]), [
|
||||
['tool_start', 'running', 'image_generation'],
|
||||
['tool_result_summary', 'completed', 'image_generation'],
|
||||
])
|
||||
})
|
||||
|
||||
test('accepts non-Codex Agents and resolves media independently from the chat runtime', async (t) => {
|
||||
const providerRequests = []
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-media-generation-'))
|
||||
const workdir = path.join(directory, 'workspace')
|
||||
fs.mkdirSync(workdir)
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const runtime = new MediaGenerationRuntime({
|
||||
getProvider: (kind, type) => {
|
||||
providerRequests.push([kind, type])
|
||||
return { apiKey: 'shared-key', baseUrl: 'https://api.openai.com/v1', model: 'chat-model' }
|
||||
},
|
||||
fetchFn: async () => response(JSON.stringify({ data: [{ b64_json: PNG.toString('base64') }] })),
|
||||
createId: () => 'media-id',
|
||||
})
|
||||
|
||||
await runtime.generate({
|
||||
kind: 'hermes', request: { type: 'image', prompt: 'a geometric poster' }, workdir,
|
||||
})
|
||||
|
||||
assert.deepEqual(providerRequests, [['hermes', 'image']])
|
||||
})
|
||||
|
||||
test('maps ZGCI image requests to the advertised qwen-image model', async (t) => {
|
||||
const calls = []
|
||||
const { workdir, runtime } = fixture(t, async (url, init = {}) => {
|
||||
calls.push([String(url), init])
|
||||
return response(JSON.stringify({ data: [{ b64_json: PNG.toString('base64') }] }))
|
||||
}, {
|
||||
apiKey: 'stored-zgci-credential', baseUrl: 'https://hub.zgci.org/v1', model: 'glm',
|
||||
})
|
||||
|
||||
const result = await runtime.generate({
|
||||
kind: 'codex', request: { type: 'image', prompt: 'a geometric poster' }, workdir,
|
||||
})
|
||||
|
||||
assert.equal(result.filename, 'generated-image-media-id.png')
|
||||
assert.equal(calls[0][0], 'https://hub.zgci.org/v1/images/generations')
|
||||
assert.equal(JSON.parse(calls[0][1].body).model, 'qwen-image')
|
||||
})
|
||||
|
||||
test('writes binary audio output and uses the audio endpoint', async (t) => {
|
||||
const { workdir, runtime } = fixture(t, async (url) => {
|
||||
assert.equal(String(url), 'https://api.openai.com/v1/audio/speech')
|
||||
return response(MP3, { headers: { 'content-type': 'audio/mpeg' } })
|
||||
})
|
||||
const result = await runtime.generate({
|
||||
kind: 'codex', request: { type: 'audio', prompt: 'hello world' }, workdir,
|
||||
})
|
||||
|
||||
assert.equal(result.filename, 'generated-audio-media-id.mp3')
|
||||
assert.deepEqual(fs.readFileSync(path.join(workdir, '.meldwork-output', result.filename)), MP3)
|
||||
})
|
||||
|
||||
test('polls an async video job and downloads the completed media locally', async (t) => {
|
||||
const calls = []
|
||||
const { workdir, runtime } = fixture(t, async (url, init = {}) => {
|
||||
calls.push([String(url), init.method || 'GET'])
|
||||
if (String(url).endsWith('/videos')) {
|
||||
return response(JSON.stringify({ id: 'video-1', status: 'queued' }))
|
||||
}
|
||||
if (String(url).endsWith('/videos/video-1')) {
|
||||
return response(JSON.stringify({ status: 'completed', url: 'https://download.example/video.mp4' }))
|
||||
}
|
||||
return response(MP4, { headers: { 'content-type': 'video/mp4' } })
|
||||
})
|
||||
const result = await runtime.generate({
|
||||
kind: 'codex', request: { type: 'video', prompt: 'a sunset over water' }, workdir,
|
||||
})
|
||||
|
||||
assert.equal(result.filename, 'generated-video-media-id.mp4')
|
||||
assert.deepEqual(fs.readFileSync(path.join(workdir, '.meldwork-output', result.filename)), MP4)
|
||||
assert.deepEqual(calls.map(call => call[0]), [
|
||||
'https://api.openai.com/v1/videos',
|
||||
'https://api.openai.com/v1/videos/video-1',
|
||||
'https://download.example/video.mp4',
|
||||
])
|
||||
})
|
||||
|
||||
test('uses the legacy H3 submit, status, and content endpoints with secure provider credentials', async (t) => {
|
||||
const calls = []
|
||||
const { workdir, runtime } = fixture(t, async (url, init = {}) => {
|
||||
calls.push([String(url), init])
|
||||
if (String(url).endsWith('/v1/videogen')) {
|
||||
return response(JSON.stringify({ task_id: 'h3-task', status: 'queued', progress: 0 }))
|
||||
}
|
||||
if (String(url).endsWith('/v1/videogen/h3-task')) {
|
||||
return response(JSON.stringify({ task_id: 'h3-task', status: 'completed', progress: 100 }))
|
||||
}
|
||||
return response(MP4, { headers: { 'content-type': 'video/mp4' } })
|
||||
}, {
|
||||
apiKey: 'stored-h3-credential',
|
||||
baseUrl: 'https://helper.ihainan.me',
|
||||
model: 'minimax-h3',
|
||||
})
|
||||
|
||||
const result = await runtime.generate({
|
||||
kind: 'codex', request: { type: 'video', prompt: 'a sunlit city street' }, workdir,
|
||||
})
|
||||
|
||||
assert.equal(result.filename, 'generated-video-media-id.mp4')
|
||||
assert.deepEqual(fs.readFileSync(path.join(workdir, '.meldwork-output', result.filename)), MP4)
|
||||
assert.deepEqual(calls.map(([url]) => url), [
|
||||
'https://helper.ihainan.me/v1/videogen',
|
||||
'https://helper.ihainan.me/v1/videogen/h3-task',
|
||||
'https://helper.ihainan.me/v1/videogen/h3-task/content',
|
||||
])
|
||||
assert.deepEqual(JSON.parse(calls[0][1].body), {
|
||||
prompt: 'a sunlit city street', seconds: 4, steps: 20, size: '1280x720',
|
||||
})
|
||||
for (const [, init] of calls) {
|
||||
assert.equal(init.headers.Authorization, `Basic ${Buffer.from('stored-h3-credential').toString('base64')}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('uses the ZGCI OpenAI-compatible H3 workflow and advertised model', async (t) => {
|
||||
const calls = []
|
||||
const { workdir, runtime } = fixture(t, async (url, init = {}) => {
|
||||
calls.push([String(url), init])
|
||||
if (String(url).endsWith('/video/generations')) {
|
||||
return response(JSON.stringify({ id: 'zgci-task', status: 'queued' }))
|
||||
}
|
||||
if (String(url).endsWith('/videos/zgci-task')) {
|
||||
return response(JSON.stringify({
|
||||
id: 'zgci-task', status: 'completed',
|
||||
}))
|
||||
}
|
||||
return response(MP4, { headers: { 'content-type': 'video/mp4' } })
|
||||
}, {
|
||||
apiKey: 'stored-zgci-credential',
|
||||
baseUrl: 'https://hub.zgci.org/v1',
|
||||
model: 'glm',
|
||||
})
|
||||
|
||||
const result = await runtime.generate({
|
||||
kind: 'openclaw', request: { type: 'video', prompt: 'a calm city sunrise' }, workdir,
|
||||
})
|
||||
|
||||
assert.equal(result.filename, 'generated-video-media-id.mp4')
|
||||
assert.deepEqual(calls.map(([url]) => url), [
|
||||
'https://hub.zgci.org/v1/video/generations',
|
||||
'https://hub.zgci.org/v1/videos/zgci-task',
|
||||
'https://hub.zgci.org/v1/videos/zgci-task/content',
|
||||
])
|
||||
assert.deepEqual(JSON.parse(calls[0][1].body), {
|
||||
model: 'minimax-h3', prompt: 'a calm city sunrise', seconds: '4', size: '1280x720',
|
||||
})
|
||||
assert.equal(calls[2][1].headers.Authorization, 'Bearer stored-zgci-credential')
|
||||
})
|
||||
|
||||
test('maps ZGCI audio requests to CosyVoice', async (t) => {
|
||||
const calls = []
|
||||
const { workdir, runtime } = fixture(t, async (url, init = {}) => {
|
||||
calls.push([String(url), init])
|
||||
return response(Buffer.from('RIFFaudio'), { headers: { 'content-type': 'audio/wav' } })
|
||||
}, {
|
||||
apiKey: 'stored-zgci-credential',
|
||||
baseUrl: 'https://hub.zgci.org/v1',
|
||||
model: 'glm',
|
||||
})
|
||||
|
||||
const result = await runtime.generate({
|
||||
kind: 'hermes', request: { type: 'audio', prompt: '欢迎使用 Meldwork' }, workdir,
|
||||
})
|
||||
|
||||
assert.equal(result.filename, 'generated-audio-media-id.wav')
|
||||
assert.equal(calls[0][0], 'https://hub.zgci.org/v1/audio/speech')
|
||||
assert.deepEqual(JSON.parse(calls[0][1].body), {
|
||||
model: 'cosy-voice',
|
||||
input: '欢迎使用 Meldwork',
|
||||
voice: 'mm_Southern_Young_Man',
|
||||
response_format: 'wav',
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps provider errors secret-free and does not write a false attachment', async (t) => {
|
||||
const { workdir, runtime } = fixture(t, async () => (
|
||||
response('invalid token test-api-key', { status: 401, headers: { 'content-type': 'text/plain' } })
|
||||
))
|
||||
await assert.rejects(
|
||||
runtime.generate({
|
||||
kind: 'codex', request: { type: 'image', prompt: 'a test image' }, workdir,
|
||||
}),
|
||||
{ message: 'MEDIA_GENERATION_HTTP_401' },
|
||||
)
|
||||
assert.equal(fs.existsSync(path.join(workdir, '.meldwork-output', 'generated-image-media-id.png')), false)
|
||||
})
|
||||
|
||||
test('retries transient provider failures before writing generated media', async (t) => {
|
||||
let attempts = 0
|
||||
const { workdir, runtime } = fixture(t, async () => {
|
||||
attempts += 1
|
||||
if (attempts < 3) return response('temporarily unavailable', { status: 503 })
|
||||
return response(JSON.stringify({ data: [{ b64_json: PNG.toString('base64') }] }))
|
||||
})
|
||||
|
||||
const result = await runtime.generate({
|
||||
kind: 'hermes', request: { type: 'image', prompt: 'a calm lake' }, workdir,
|
||||
})
|
||||
|
||||
assert.equal(attempts, 3)
|
||||
assert.equal(result.filename, 'generated-image-media-id.png')
|
||||
assert.deepEqual(fs.readFileSync(path.join(workdir, '.meldwork-output', result.filename)), PNG)
|
||||
})
|
||||
|
||||
test('stops after three transient provider failures without writing an attachment', async (t) => {
|
||||
let attempts = 0
|
||||
const { workdir, runtime } = fixture(t, async () => {
|
||||
attempts += 1
|
||||
return response('still unavailable', { status: 503 })
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
runtime.generate({
|
||||
kind: 'hermes', request: { type: 'image', prompt: 'a calm lake' }, workdir,
|
||||
}),
|
||||
{ message: 'MEDIA_GENERATION_HTTP_503' },
|
||||
)
|
||||
assert.equal(attempts, 3)
|
||||
assert.equal(fs.existsSync(path.join(workdir, '.meldwork-output', 'generated-image-media-id.png')), false)
|
||||
})
|
||||
|
||||
test('falls back to another secure Provider when the first lacks the media model', async (t) => {
|
||||
const providerRequests = []
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-media-generation-'))
|
||||
const workdir = path.join(directory, 'workspace')
|
||||
fs.mkdirSync(workdir)
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const providers = [
|
||||
{
|
||||
apiKey: 'unsupported-key', baseUrl: 'https://hub.zgci.org/v1', model: 'glm',
|
||||
sourceKind: 'openclaw',
|
||||
},
|
||||
{
|
||||
apiKey: 'media-key', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4.1',
|
||||
sourceKind: 'opencodereview',
|
||||
},
|
||||
]
|
||||
const runtime = new MediaGenerationRuntime({
|
||||
getProvider: (_kind, _type, excludedKinds = []) => {
|
||||
providerRequests.push([...excludedKinds])
|
||||
return providers.find(provider => !excludedKinds.includes(provider.sourceKind))
|
||||
},
|
||||
fetchFn: async (_url, init = {}) => {
|
||||
if (init.headers.Authorization === 'Bearer unsupported-key') {
|
||||
return response(JSON.stringify({
|
||||
error: { code: 'model_not_found', message: 'No available channel for model minimax-h3' },
|
||||
}), { status: 503 })
|
||||
}
|
||||
if (String(_url).endsWith('/videos')) {
|
||||
return response(JSON.stringify({ id: 'video-1', status: 'completed' }))
|
||||
}
|
||||
return response(MP4, { headers: { 'content-type': 'video/mp4' } })
|
||||
},
|
||||
createId: () => 'media-id',
|
||||
pollIntervalMs: 0,
|
||||
})
|
||||
|
||||
const result = await runtime.generate({
|
||||
kind: 'hermes', request: { type: 'video', prompt: 'a calm lake' }, workdir,
|
||||
})
|
||||
|
||||
assert.deepEqual(providerRequests, [[], ['openclaw']])
|
||||
assert.equal(result.filename, 'generated-video-media-id.mp4')
|
||||
assert.deepEqual(fs.readFileSync(path.join(workdir, '.meldwork-output', result.filename)), MP4)
|
||||
})
|
||||
|
||||
test('reports a stable model-unavailable error after all secure Providers are exhausted', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-media-generation-'))
|
||||
const workdir = path.join(directory, 'workspace')
|
||||
fs.mkdirSync(workdir)
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const runtime = new MediaGenerationRuntime({
|
||||
getProvider: (_kind, _type, excludedKinds = []) => {
|
||||
if (excludedKinds.includes('openclaw')) {
|
||||
throw new Error('MEDIA_GENERATION_PROVIDER_UNAVAILABLE')
|
||||
}
|
||||
return {
|
||||
apiKey: 'unsupported-key', baseUrl: 'https://hub.zgci.org/v1', model: 'glm',
|
||||
sourceKind: 'openclaw',
|
||||
}
|
||||
},
|
||||
fetchFn: async () => response(JSON.stringify({
|
||||
error: { code: 'model_not_found', message: 'No available channel for model minimax-h3' },
|
||||
}), { status: 503 }),
|
||||
createId: () => 'media-id',
|
||||
pollIntervalMs: 0,
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
runtime.generate({
|
||||
kind: 'hermes', request: { type: 'video', prompt: 'a calm lake' }, workdir,
|
||||
}),
|
||||
{ code: 'MEDIA_GENERATION_MODEL_UNAVAILABLE' },
|
||||
)
|
||||
})
|
||||
|
||||
test('falls back to another secure Provider after transient retries are exhausted', async (t) => {
|
||||
const providerRequests = []
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-media-generation-'))
|
||||
const workdir = path.join(directory, 'workspace')
|
||||
fs.mkdirSync(workdir)
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const providers = [
|
||||
{
|
||||
apiKey: 'unavailable-key', baseUrl: 'https://hub.zgci.org/v1', model: 'glm',
|
||||
sourceKind: 'openclaw',
|
||||
},
|
||||
{
|
||||
apiKey: 'media-key', baseUrl: 'https://hub.zgci.org/v1', model: 'glm',
|
||||
sourceKind: 'opencodereview',
|
||||
},
|
||||
]
|
||||
let unavailableAttempts = 0
|
||||
const runtime = new MediaGenerationRuntime({
|
||||
getProvider: (_kind, _type, excludedKinds = []) => {
|
||||
providerRequests.push([...excludedKinds])
|
||||
const provider = providers.find(candidate => !excludedKinds.includes(candidate.sourceKind))
|
||||
if (!provider) throw new Error('MEDIA_GENERATION_PROVIDER_UNAVAILABLE')
|
||||
return provider
|
||||
},
|
||||
fetchFn: async (_url, init = {}) => {
|
||||
if (init.headers.Authorization === 'Bearer unavailable-key') {
|
||||
unavailableAttempts += 1
|
||||
return response('temporarily unavailable', { status: 503 })
|
||||
}
|
||||
if (String(_url).endsWith('/images/generations')) {
|
||||
return response(JSON.stringify({ data: [{ b64_json: PNG.toString('base64') }] }))
|
||||
}
|
||||
throw new Error(`Unexpected request: ${_url}`)
|
||||
},
|
||||
createId: () => 'media-id',
|
||||
pollIntervalMs: 0,
|
||||
})
|
||||
|
||||
const result = await runtime.generate({
|
||||
kind: 'hermes', request: { type: 'image', prompt: 'a calm lake' }, workdir,
|
||||
})
|
||||
|
||||
assert.equal(unavailableAttempts, 3)
|
||||
assert.deepEqual(providerRequests, [[], ['openclaw']])
|
||||
assert.equal(result.filename, 'generated-image-media-id.png')
|
||||
assert.deepEqual(fs.readFileSync(path.join(workdir, '.meldwork-output', result.filename)), PNG)
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { resolveMediaProvider } = require('../../src/media/media-provider-resolution.cjs')
|
||||
|
||||
test('all Agents can fall back to one securely configured media Provider', () => {
|
||||
const statuses = {
|
||||
openclaw: {
|
||||
configured: true, baseUrl: 'https://hub.zgci.org/v1', model: 'glm',
|
||||
},
|
||||
}
|
||||
const provider = resolveMediaProvider({
|
||||
requestedKind: 'hermes',
|
||||
type: 'video',
|
||||
kinds: ['codex', 'hermes', 'openclaw'],
|
||||
statusFor: kind => statuses[kind] || { configured: false },
|
||||
credentialsFor: kind => kind === 'openclaw' ? { OPENAI_API_KEY: 'secure-key' } : {},
|
||||
})
|
||||
|
||||
assert.deepEqual(provider, {
|
||||
apiKey: 'secure-key',
|
||||
baseUrl: 'https://hub.zgci.org/v1',
|
||||
model: 'glm',
|
||||
sourceKind: 'openclaw',
|
||||
})
|
||||
})
|
||||
|
||||
test('video resolution prefers an H3-capable profile over the target chat profile', () => {
|
||||
const statuses = {
|
||||
hermes: { configured: true, baseUrl: 'https://api.openai.com/v1', model: 'gpt-4.1' },
|
||||
openclaw: { configured: true, baseUrl: 'https://hub.zgci.org/v1', model: 'glm' },
|
||||
}
|
||||
const provider = resolveMediaProvider({
|
||||
requestedKind: 'hermes',
|
||||
type: 'video',
|
||||
kinds: ['hermes', 'openclaw'],
|
||||
statusFor: kind => statuses[kind] || { configured: false },
|
||||
credentialsFor: kind => ({ OPENAI_API_KEY: `${kind}-key` }),
|
||||
})
|
||||
|
||||
assert.equal(provider.sourceKind, 'openclaw')
|
||||
assert.equal(provider.apiKey, 'openclaw-key')
|
||||
})
|
||||
|
||||
test('excludes a configured Provider after its media model is unavailable', () => {
|
||||
const statuses = {
|
||||
openclaw: { configured: true, baseUrl: 'https://hub.zgci.org/v1', model: 'glm' },
|
||||
opencodereview: { configured: true, baseUrl: 'https://hub.zgci.org/v1', model: 'glm' },
|
||||
}
|
||||
const provider = resolveMediaProvider({
|
||||
requestedKind: 'hermes',
|
||||
type: 'video',
|
||||
kinds: ['openclaw', 'opencodereview'],
|
||||
excludedKinds: ['openclaw'],
|
||||
statusFor: kind => statuses[kind] || { configured: false },
|
||||
credentialsFor: kind => ({ OPENAI_API_KEY: `${kind}-key` }),
|
||||
})
|
||||
|
||||
assert.equal(provider.sourceKind, 'opencodereview')
|
||||
assert.equal(provider.apiKey, 'opencodereview-key')
|
||||
})
|
||||
|
||||
test('uses a secure global ZGCI media fallback without requiring per-Agent provider binding', () => {
|
||||
const provider = resolveMediaProvider({
|
||||
requestedKind: 'codex',
|
||||
type: 'video',
|
||||
kinds: ['codex', 'hermes'],
|
||||
statusFor: () => ({ configured: false }),
|
||||
credentialsFor: () => ({}),
|
||||
fallbackProviders: [{
|
||||
kind: 'zgci-media',
|
||||
status: {
|
||||
configured: true,
|
||||
provider: 'ZGCI Media',
|
||||
baseUrl: 'https://hub.zgci.org/v1',
|
||||
model: 'glm',
|
||||
},
|
||||
credentials: { OPENAI_API_KEY: 'zgci-key' },
|
||||
}],
|
||||
})
|
||||
|
||||
assert.deepEqual(provider, {
|
||||
apiKey: 'zgci-key',
|
||||
baseUrl: 'https://hub.zgci.org/v1',
|
||||
model: 'glm',
|
||||
sourceKind: 'zgci-media',
|
||||
})
|
||||
})
|
||||
@@ -923,6 +923,7 @@ test('attachment storage initialization failure does not block text chat startup
|
||||
['local-attachments:import', [{ name: 'x.png', mimeType: 'image/png', bytes: [1] }]],
|
||||
['local-attachments:preview', ['attachment-1']],
|
||||
['local-attachments:open', ['attachment-1']],
|
||||
['local-attachments:save', ['attachment-1']],
|
||||
['local-attachments:discard', [['attachment-1']]],
|
||||
]) {
|
||||
await assert.rejects(
|
||||
@@ -1781,8 +1782,10 @@ test('attachment IPC returns bounded metadata, opens private files, and never ex
|
||||
const first = path.join(directory, 'diagram.png')
|
||||
const second = path.join(directory, 'flow.png')
|
||||
const third = path.join(directory, 'overflow.png')
|
||||
const saved = path.join(directory, 'saved.png')
|
||||
const { harness } = loadMain(directory, {
|
||||
dialogResult: { canceled: false, filePaths: [first, second, third] },
|
||||
saveDialogResult: { canceled: false, filePath: saved },
|
||||
})
|
||||
await harness.ready()
|
||||
|
||||
@@ -1838,6 +1841,15 @@ test('attachment IPC returns bounded metadata, opens private files, and never ex
|
||||
assert.deepEqual(harness.openPathCalls, [
|
||||
path.join(directory, 'attachments', picked.attachments[0].id, 'image.png'),
|
||||
])
|
||||
const savedResult = await harness.ipcHandlers.get('local-attachments:save')(
|
||||
harness.event(), picked.attachments[0].id,
|
||||
)
|
||||
assert.deepEqual(savedResult, { saved: true })
|
||||
assert.deepEqual(harness.dialogCalls[1][1], {
|
||||
defaultPath: 'diagram.png',
|
||||
showOverwriteConfirmation: true,
|
||||
})
|
||||
assert.deepEqual(fs.readFileSync(saved), pngHeader())
|
||||
assert.equal(JSON.stringify(opened).includes(directory), false)
|
||||
})
|
||||
|
||||
|
||||
@@ -418,7 +418,7 @@ test('local preload exposes attachment import and open without filesystem read o
|
||||
assert.equal(Object.isFrozen(api.localAttachments), true)
|
||||
assert.deepEqual(
|
||||
Object.keys(api.localAttachments).sort(),
|
||||
['discard', 'importAttachment', 'open', 'pickAttachments', 'preview'],
|
||||
['discard', 'importAttachment', 'open', 'pickAttachments', 'preview', 'save'],
|
||||
)
|
||||
assert.equal('read' in api.localAttachments, false)
|
||||
assert.equal('resolve' in api.localAttachments, false)
|
||||
@@ -440,6 +440,7 @@ test('local preload exposes attachment import and open without filesystem read o
|
||||
archiveBytes[0] = 0
|
||||
await api.localAttachments.preview('attachment-1')
|
||||
await api.localAttachments.open('attachment-1')
|
||||
await api.localAttachments.save('attachment-1')
|
||||
await api.localAttachments.discard(['attachment-1'])
|
||||
assert.deepEqual(invocations, [
|
||||
{ channel: 'local-attachments:pick', args: [2] },
|
||||
@@ -465,6 +466,7 @@ test('local preload exposes attachment import and open without filesystem read o
|
||||
},
|
||||
{ channel: 'local-attachments:preview', args: ['attachment-1'] },
|
||||
{ channel: 'local-attachments:open', args: ['attachment-1'] },
|
||||
{ channel: 'local-attachments:save', args: ['attachment-1'] },
|
||||
{ channel: 'local-attachments:discard', args: [['attachment-1']] },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -73,6 +73,7 @@ const LOCAL_IPC_CHANNELS = Object.freeze([
|
||||
'local-attachments:import',
|
||||
'local-attachments:preview',
|
||||
'local-attachments:open',
|
||||
'local-attachments:save',
|
||||
'local-attachments:discard',
|
||||
'local-agent-provider:status',
|
||||
'local-agent-provider:probe',
|
||||
@@ -746,6 +747,10 @@ function loadMain(userData, options = {}) {
|
||||
dialogCalls.push(args)
|
||||
return options.dialogResult || { canceled: true, filePaths: [] }
|
||||
},
|
||||
showSaveDialog: async (...args) => {
|
||||
dialogCalls.push(args)
|
||||
return options.saveDialogResult || { canceled: true, filePath: '' }
|
||||
},
|
||||
showMessageBox: async (...args) => {
|
||||
dialogCalls.push(args)
|
||||
return options.messageBoxResult || { response: 1 }
|
||||
|
||||
@@ -1422,8 +1422,11 @@ test('V4 invocation boundary resumes delta delivery from durable acknowledgement
|
||||
workspace.save()
|
||||
const rotatedA = await deliver(workspace, runA, receiptsFor(runA, 3), 'wave-3-rotated')
|
||||
assert.ok(rotatedA.result)
|
||||
assert.equal(calls.find(call => call.tag === 'A' && call.attempt === 'wave-3-rotated').sessionRef, '')
|
||||
assert.equal(workspace.state.sessions[sessionKeyA], 'A-codex-native-2')
|
||||
assert.equal(
|
||||
calls.find(call => call.tag === 'A' && call.attempt === 'wave-3-rotated').sessionRef,
|
||||
'A-codex-native-1',
|
||||
)
|
||||
assert.equal(workspace.state.sessions[sessionKeyA], 'A-codex-native-1')
|
||||
|
||||
const durableAAtRestart = ledger.get(runA.controller.runId)
|
||||
const durableBAtRestart = ledger.get(runB.controller.runId)
|
||||
@@ -1471,7 +1474,7 @@ test('V4 invocation boundary resumes delta delivery from durable acknowledgement
|
||||
watermark: entry.watermark,
|
||||
})), [{ sourceAgentKind: 'kimi', watermark: 4 }])
|
||||
const postRestartCall = calls.find(call => call.attempt === 'post-restart-delta')
|
||||
assert.equal(postRestartCall.sessionRef, 'A-codex-native-2')
|
||||
assert.equal(postRestartCall.sessionRef, 'A-codex-native-1')
|
||||
assert.match(postRestartCall.prompt, /A WAVE_4_kimi_LATEST/)
|
||||
assert.doesNotMatch(postRestartCall.prompt, /A WAVE_3_/)
|
||||
assert.deepEqual(
|
||||
@@ -1487,7 +1490,7 @@ test('V4 invocation boundary resumes delta delivery from durable acknowledgement
|
||||
.map(entry => ({ watermark: entry.watermark, status: entry.status })),
|
||||
[{ watermark: 4, status: 'acknowledged' }],
|
||||
)
|
||||
assert.equal(restarted.state.sessions[sessionKeyA], 'A-codex-native-2')
|
||||
assert.equal(restarted.state.sessions[sessionKeyA], 'A-codex-native-1')
|
||||
restarted.activeRuns.delete(recoveredRunA.group.id)
|
||||
})
|
||||
|
||||
@@ -3318,7 +3321,6 @@ test('V4 Auto checkpoints a completed proposal before a queued peer and recovers
|
||||
rootAttachments: [],
|
||||
rootSkillsByKind: new Map(partial.targetKinds.map(kind => [kind, []])),
|
||||
rootKnowledgeBasesByKind: new Map(partial.targetKinds.map(kind => [kind, []])),
|
||||
rootMediaRequest: null,
|
||||
}, true,
|
||||
)
|
||||
|
||||
@@ -4052,6 +4054,35 @@ test('automatic dialogue keeps Hermes on one persistent ACP session across round
|
||||
assert.equal(workspace.state.sessionMeta[hermesSessionKey].transport, 'acp')
|
||||
})
|
||||
|
||||
test('automatic dialogue keeps an active Agent runnable through a transient catalog outage', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
calls.push({ agent, prompt, workdir, runOptions })
|
||||
if (calls.length === 2) {
|
||||
workspace.detectedAgents = workspace.detectedAgents.filter(item => item.kind !== 'openclaw')
|
||||
}
|
||||
const count = calls.filter(call => call.agent.kind === agent.kind).length
|
||||
return {
|
||||
text: `${agent.kind} ${count > 1 ? 'agree' : 'continue'}\n[[MELDWORK_CONSENSUS:${count > 1 ? 'agree' : 'continue'}]]`,
|
||||
sessionRef: runOptions.sessionRef || `${agent.kind}-session`,
|
||||
}
|
||||
}
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Transient catalog outage', agentKinds: ['codex', 'openclaw'], workdir: directory,
|
||||
})
|
||||
|
||||
await workspace.sendMessage({ groupId: group.id, text: 'Continue across a transient refresh', mode: 'auto', maxRounds: 2 })
|
||||
await workspace.activeRuns.get(group.id).promise
|
||||
|
||||
assert.deepEqual(calls.map(call => call.agent.kind), ['codex', 'openclaw', 'codex', 'openclaw'])
|
||||
assert.equal(workspace.snapshot().messages.some(message => (
|
||||
message.agentKind === 'openclaw' && message.system?.key === 'system.agentCallFailed'
|
||||
)), false)
|
||||
})
|
||||
|
||||
test('automatic dialogue queues only the explicitly targeted group members', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
@@ -1557,7 +1557,7 @@ test('direct Harness rebuilds compressed context once when a reused legacy sessi
|
||||
assert.deepEqual(calls.map(call => call.runOptions.sessionRef), ['codex-stale-session', ''])
|
||||
assert.deepEqual(calls.map(call => call.runOptions.sessionTransport), ['legacy', ''])
|
||||
assert.doesNotMatch(calls[0].prompt, /Previous Codex conclusion/)
|
||||
assert.match(calls[1].prompt, /Previous Codex conclusion/)
|
||||
assert.doesNotMatch(calls[1].prompt, /Previous Codex conclusion/)
|
||||
assert.match(calls[1].prompt, /Continue after session recovery/)
|
||||
const snapshot = workspace.snapshot()
|
||||
const task = snapshot.messages.find(message => (
|
||||
|
||||
@@ -391,6 +391,7 @@ async function verifyAutomaticGateRecovery(t, gateIndex) {
|
||||
attachmentRecipients: [],
|
||||
totalSuccesses: 0,
|
||||
terminalFailureOccurred: false,
|
||||
executionSequence: gateIndex + 1,
|
||||
})
|
||||
assert.equal(collaboration.version, 1)
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -3,7 +3,10 @@ const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const { runAgent } = require('../../src/agents/cli/cli-adapters.cjs')
|
||||
const {
|
||||
runAgent,
|
||||
shutdownAcpSessionRuntime,
|
||||
} = require('../../src/agents/cli/cli-adapters.cjs')
|
||||
const { LocalWorkspace } = require('../../src/workspace/local-workspace.cjs')
|
||||
const { createLegacyOutboundPayload } = require('../../src/collaboration/outbound-payload.cjs')
|
||||
const { RunLedger } = require('../../src/runs/run-ledger.cjs')
|
||||
@@ -460,7 +463,10 @@ test('LocalWorkspace persists and resumes ACP permission approval and rejection
|
||||
|
||||
test('ACP permission Gate survives shutdown and resumes the persisted Session after approval', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
t.after(async () => {
|
||||
await shutdownAcpSessionRuntime()
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
const ledgerPath = path.join(directory, 'run-ledger.json')
|
||||
const cli = acpPermissionExecutable(directory)
|
||||
const ledger = new RunLedger({ storagePath: ledgerPath })
|
||||
|
||||
@@ -39,6 +39,103 @@ test('direct Hermes conversations reuse one persistent ACP runtime key across me
|
||||
assert.equal(calls[1].runOptions.acpPersistenceKey, calls[0].runOptions.acpPersistenceKey)
|
||||
})
|
||||
|
||||
test('group OpenCode conversations reuse one persistent ACP runtime key across rounds', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
options.detectAgents = async () => [
|
||||
{ kind: 'codex', name: 'Codex CLI', executable: '/tmp/codex', version: '1' },
|
||||
{ kind: 'opencode', name: 'OpenCode CLI', executable: '/tmp/opencode', version: '1' },
|
||||
]
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
calls.push({ agent, prompt, workdir, runOptions })
|
||||
const sessionRef = runOptions.sessionRef || `${agent.kind}-acp-session`
|
||||
if (agent.kind === 'opencode') {
|
||||
await runOptions.onSessionRef(sessionRef, { transport: 'acp' })
|
||||
}
|
||||
return { text: `reply ${calls.length}`, sessionRef, outcome: 'completed' }
|
||||
}
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'OpenCode ACP group', agentKinds: ['codex', 'opencode'], workdir: directory,
|
||||
})
|
||||
|
||||
await workspace.sendMessage({ groupId: group.id, text: 'first', mode: 'auto', maxRounds: 2 })
|
||||
await workspace.activeRuns.get(group.id)?.promise
|
||||
|
||||
const opencodeCalls = calls.filter(call => call.agent.kind === 'opencode')
|
||||
assert.deepEqual(opencodeCalls.map(call => call.runOptions.sessionRef), ['', 'opencode-acp-session'])
|
||||
assert.deepEqual(opencodeCalls.map(call => call.runOptions.sessionTransport), ['', 'acp'])
|
||||
assert.equal(typeof opencodeCalls[0].runOptions.acpPersistenceKey, 'string')
|
||||
assert.equal(opencodeCalls[1].runOptions.acpPersistenceKey, opencodeCalls[0].runOptions.acpPersistenceKey)
|
||||
})
|
||||
|
||||
test('Natural V4 keeps Hermes, OpenCode, and Claude Code on one Session per group task',
|
||||
async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
options.naturalAgentResponses = true
|
||||
options.detectAgents = async () => [
|
||||
{
|
||||
kind: 'hermes', name: 'Hermes CLI', executable: '/tmp/hermes', version: '2',
|
||||
acpAvailable: true,
|
||||
},
|
||||
{ kind: 'opencode', name: 'OpenCode CLI', executable: '/tmp/opencode', version: '1' },
|
||||
{ kind: 'claude', name: 'Claude Code', executable: '/tmp/claude', version: '1' },
|
||||
]
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
calls.push({ agent, prompt, workdir, runOptions })
|
||||
const sessionRef = runOptions.sessionRef || `${agent.kind}-native-session`
|
||||
if (['hermes', 'opencode'].includes(agent.kind)) {
|
||||
await runOptions.onSessionRef(sessionRef, { transport: 'acp' })
|
||||
}
|
||||
return { text: `${agent.kind} round response`, sessionRef, outcome: 'completed' }
|
||||
}
|
||||
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Native Session continuity',
|
||||
agentKinds: ['hermes', 'opencode', 'claude'],
|
||||
workdir: directory,
|
||||
})
|
||||
|
||||
await workspace.sendMessage({
|
||||
groupId: group.id,
|
||||
text: 'Continue one shared task for three rounds.',
|
||||
mode: 'auto',
|
||||
discussionStyle: 'sequential',
|
||||
targetKinds: group.agentKinds,
|
||||
maxRounds: 3,
|
||||
protocol: 'v4',
|
||||
})
|
||||
const controller = workspace.activeRuns.get(group.id)
|
||||
assert.ok(controller)
|
||||
await controller.promise
|
||||
|
||||
for (const kind of group.agentKinds) {
|
||||
const agentCalls = calls.filter(call => call.agent.kind === kind)
|
||||
assert.deepEqual(agentCalls.map(call => call.runOptions.sessionRef), [
|
||||
'', `${kind}-native-session`, `${kind}-native-session`,
|
||||
])
|
||||
assert.equal(typeof agentCalls[0].runOptions.acpPersistenceKey, 'string')
|
||||
assert.equal(agentCalls[0].runOptions.acpPersistenceKey.length > 0, true)
|
||||
assert.equal(agentCalls.every(call => (
|
||||
call.runOptions.acpPersistenceKey === agentCalls[0].runOptions.acpPersistenceKey
|
||||
)), true)
|
||||
}
|
||||
assert.deepEqual(
|
||||
calls.filter(call => call.agent.kind === 'hermes')
|
||||
.map(call => call.runOptions.sessionTransport),
|
||||
['', 'acp', 'acp'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
calls.filter(call => call.agent.kind === 'opencode')
|
||||
.map(call => call.runOptions.sessionTransport),
|
||||
['', 'acp', 'acp'],
|
||||
)
|
||||
})
|
||||
|
||||
test('Workspace stream bridge redacts split credentials and paths while preserving tools', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
@@ -376,55 +376,27 @@ async function createManualPermissionRestart(t, restartOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
test('Manual V4 native ACP permission restart requires whole-slot recovery and a fresh Gate', async (t) => {
|
||||
test('Manual V4 native ACP permission restart resumes the persisted Session without a fresh Gate',
|
||||
async (t) => {
|
||||
const recovery = await createManualPermissionRestart(t)
|
||||
const {
|
||||
calls, originalGate, restarted, restartedLedger, beforeApprovalCalls, waiting, writerSlot,
|
||||
calls, originalGate, restarted, restartedLedger, beforeApprovalCalls, writerSlot,
|
||||
} = recovery
|
||||
const recoveryGate = await pendingGate(restarted, gate => (
|
||||
gate.gateId !== originalGate.gateId && gate.type === 'retry'
|
||||
))
|
||||
const recoveryRequest = restarted.humanGateStore.request(recoveryGate.gateId)
|
||||
const recovered = restartedLedger.get(originalGate.runId)
|
||||
const recoveredWriter = recovered.orchestration.slots.find(slot => slot.agentKind === 'hermes')
|
||||
const terminal = await terminalRun(restartedLedger, originalGate.runId)
|
||||
const recoveredWriter = terminal.orchestration.slots.find(slot => slot.agentKind === 'hermes')
|
||||
const hermesCalls = calls.filter(call => call.kind === 'hermes')
|
||||
|
||||
assert.equal(calls.length, beforeApprovalCalls + 1)
|
||||
assert.equal(calls.at(-1).sessionRef, 'hermes-v4-native-session')
|
||||
assert.equal(recovered.status, 'waiting')
|
||||
assert.equal(recoveredWriter.status, 'running')
|
||||
assert.equal(terminal.status, 'completed', terminal.reason)
|
||||
assert.equal(recoveredWriter.status, 'completed')
|
||||
assert.equal(recoveredWriter.attempt, writerSlot.attempt)
|
||||
assert.equal(recoveredWriter.operationId, writerSlot.operationId)
|
||||
assert.equal(recoveredWriter.commitStatus, 'pending')
|
||||
assert.deepEqual(recoveredWriter.resultRefs.workflowOutcomeRefs || [], [])
|
||||
assert.deepEqual(recoveryRequest, {
|
||||
phase: 'manual-writer-recovery',
|
||||
batchId: waiting.orchestration.batchId,
|
||||
slotId: writerSlot.slotId,
|
||||
operationId: writerSlot.operationId,
|
||||
attempt: writerSlot.attempt,
|
||||
outcomeCertainty: 'unknown_outcome',
|
||||
sideEffectsPossible: true,
|
||||
})
|
||||
for (const kind of ['codex', 'workbuddy']) {
|
||||
assert.equal(calls.filter(call => call.kind === kind).length, 1)
|
||||
}
|
||||
|
||||
restarted.decideHumanGate(recoveryGate.gateId, {
|
||||
status: 'approved', optionId: 'retry-once', actorId: 'local-user',
|
||||
})
|
||||
const freshPermissionGate = await pendingGate(restarted, gate => (
|
||||
gate.type === 'permission' && ![originalGate.gateId, recoveryGate.gateId].includes(gate.gateId)
|
||||
))
|
||||
assert.equal(calls.filter(call => call.kind === 'hermes').length, 3)
|
||||
assert.equal(calls.at(-1).sessionRef, '')
|
||||
assert.notEqual(freshPermissionGate.gateId, originalGate.gateId)
|
||||
|
||||
restarted.decideHumanGate(freshPermissionGate.gateId, {
|
||||
status: 'approved', optionId: 'allow-once', actorId: 'local-user',
|
||||
})
|
||||
const terminal = await terminalRun(restartedLedger, originalGate.runId)
|
||||
assert.equal(terminal.status, 'completed', terminal.reason)
|
||||
assert.equal(calls.filter(call => call.kind === 'hermes').length, 3)
|
||||
assert.equal(hermesCalls.length, 2)
|
||||
assert.deepEqual(hermesCalls.map(call => call.operationId), [
|
||||
writerSlot.operationId, writerSlot.operationId,
|
||||
])
|
||||
assert.equal(restarted.listHumanGates({ pendingOnly: true }).length, 0)
|
||||
for (const kind of ['codex', 'workbuddy']) {
|
||||
assert.equal(calls.filter(call => call.kind === kind).length, 1)
|
||||
}
|
||||
@@ -494,13 +466,28 @@ test('Manual V4 native permission rejects an already accepted bound operation be
|
||||
assert.equal(calls.length, beforeApprovalCalls)
|
||||
})
|
||||
|
||||
test('Manual V4 native permission restart recovers a missing or naturally rotated Session', async (t) => {
|
||||
test('Manual V4 native permission restart recovers a missing Session and preserves a V4 Session',
|
||||
async (t) => {
|
||||
for (const sessionState of ['missing', 'rotated']) {
|
||||
await t.test(sessionState, async (subtest) => {
|
||||
const recovery = await createManualPermissionRestart(subtest, { sessionState })
|
||||
const {
|
||||
calls, originalGate, restarted, restartedLedger, beforeApprovalCalls, writerSlot,
|
||||
} = recovery
|
||||
if (sessionState === 'rotated') {
|
||||
const terminal = await terminalRun(restartedLedger, originalGate.runId)
|
||||
const recoveredWriter = terminal.orchestration.slots.find(slot => (
|
||||
slot.agentKind === writerSlot.agentKind
|
||||
))
|
||||
assert.equal(calls.length, beforeApprovalCalls + 1)
|
||||
assert.equal(calls.at(-1).sessionRef, 'hermes-v4-native-session')
|
||||
assert.equal(terminal.status, 'completed', terminal.reason)
|
||||
assert.equal(recoveredWriter.status, 'completed')
|
||||
assert.equal(recoveredWriter.operationId, writerSlot.operationId)
|
||||
assert.equal(recoveredWriter.attempt, writerSlot.attempt)
|
||||
assert.equal(restarted.listHumanGates({ pendingOnly: true }).length, 0)
|
||||
return
|
||||
}
|
||||
const recoveryGate = await pendingGate(restarted, gate => (
|
||||
gate.type === 'retry' && gate.gateId !== originalGate.gateId
|
||||
))
|
||||
@@ -526,7 +513,7 @@ test('Manual V4 native permission restart recovers a missing or naturally rotate
|
||||
}
|
||||
})
|
||||
|
||||
test('Auto V4 read-only native ACP permission restart completes through a fresh Gate', async (t) => {
|
||||
test('Auto V4 read-only native ACP permission restart resumes the persisted Session', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
const cli = hermesAcpExecutable(directory)
|
||||
const ledger = new RunLedger({
|
||||
@@ -551,7 +538,7 @@ test('Auto V4 read-only native ACP permission restart completes through a fresh
|
||||
})
|
||||
const send = workspace.sendMessage({
|
||||
groupId: group.id,
|
||||
text: 'Resume one native read-only proposal through a fresh permission Gate.',
|
||||
text: 'Resume one native read-only proposal through its persisted Session.',
|
||||
mode: 'auto',
|
||||
maxRounds: 3,
|
||||
targetKinds: ['codex', 'hermes', 'workbuddy'],
|
||||
@@ -591,23 +578,19 @@ test('Auto V4 read-only native ACP permission restart completes through a fresh
|
||||
status: 'approved', optionId: 'allow-once', actorId: 'local-user',
|
||||
})
|
||||
|
||||
const freshGate = await pendingGate(restarted, gate => (
|
||||
gate.type === 'permission' && gate.gateId !== originalGate.gateId
|
||||
))
|
||||
const terminal = await terminalRun(restartedLedger, originalGate.runId)
|
||||
const resumedCalls = calls.filter(call => (
|
||||
call.kind === 'hermes' && call.phase === 'proposal'
|
||||
))
|
||||
assert.equal(calls.length, beforeApprovalCalls + 2)
|
||||
assert.equal(calls[beforeApprovalCalls].kind, 'hermes')
|
||||
assert.equal(calls[beforeApprovalCalls].phase, 'proposal')
|
||||
assert.equal(calls[beforeApprovalCalls].sessionRef, 'hermes-v4-native-session')
|
||||
assert.deepEqual(resumedCalls.map(call => call.operationId), [
|
||||
gatedSlot.operationId, gatedSlot.operationId, gatedSlot.operationId,
|
||||
gatedSlot.operationId, gatedSlot.operationId,
|
||||
])
|
||||
assert.equal(resumedCalls.at(-1).sessionRef, '')
|
||||
restarted.decideHumanGate(freshGate.gateId, {
|
||||
status: 'approved', optionId: 'allow-once', actorId: 'local-user',
|
||||
})
|
||||
|
||||
const terminal = await terminalRun(restartedLedger, originalGate.runId)
|
||||
assert.equal(resumedCalls.at(-1).sessionRef, 'hermes-v4-native-session')
|
||||
assert.equal(terminal.status, 'completed', terminal.reason)
|
||||
assert.equal(restarted.listHumanGates({ pendingOnly: true }).length, 0)
|
||||
for (const kind of ['codex', 'workbuddy']) {
|
||||
assert.equal(calls.filter(call => call.kind === kind && call.phase === 'proposal').length, 1)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -346,7 +346,10 @@ test('Natural V4 prompts carry task context without exposing the receipt templat
|
||||
|
||||
assert.match(prompt, /Compare the available skills/)
|
||||
assert.match(prompt, /Answer the user naturally in Markdown/)
|
||||
assert.doesNotMatch(prompt, /Receipt JSON shape|MELDWORK_COLLABORATION|MELDWORK_V4_FROZEN_SNAPSHOT_V1/)
|
||||
assert.doesNotMatch(
|
||||
prompt,
|
||||
/current collaboration phase|your role|Receipt JSON shape|MELDWORK_COLLABORATION|MELDWORK_V4_FROZEN_SNAPSHOT_V1/i,
|
||||
)
|
||||
})
|
||||
|
||||
test('Natural V4 challenge responses receive a deterministic internal responsibility graph', () => {
|
||||
|
||||
@@ -236,7 +236,8 @@ test('Custom Agent kinds keep their dynamic label across execution and reload',
|
||||
|
||||
await workspace.sendMessage({ groupId: group.id, text: 'Review this change', targetKinds: [kind] })
|
||||
|
||||
assert.match(calls[0].prompt, /as Repository Reviewer\./)
|
||||
assert.match(calls[0].prompt, /Review this change/)
|
||||
assert.doesNotMatch(calls[0].prompt, /participating in the local|as Repository Reviewer\./)
|
||||
assert.equal(calls[0].runOptions.sandbox, 'workspace-write')
|
||||
const reply = workspace.snapshot().messages.find(message => message.role === 'agent')
|
||||
assert.equal(reply.agentKind, kind)
|
||||
@@ -1436,6 +1437,8 @@ test('writable conversations persist validated Agent media outputs and enforce t
|
||||
assert.equal(importCalls[0].workdir, directory)
|
||||
assert.deepEqual(importCalls[0].baseline, { marker: 'before-run' })
|
||||
assert.equal(Number.isFinite(importCalls[0].startedAt), true)
|
||||
assert.match(calls[0].prompt, /生成一张图、一段音频和一个视频/)
|
||||
assert.doesNotMatch(calls[0].prompt, /shared media generator|generated-media|Meldwork generated and will attach/i)
|
||||
assert.match(calls[0].prompt, /\.meldwork-output\//)
|
||||
assert.match(calls[0].prompt, /do not claim it was generated unless a real file exists/i)
|
||||
const reply = workspace.snapshot().messages.find(message => message.role === 'agent')
|
||||
@@ -1447,266 +1450,6 @@ test('writable conversations persist validated Agent media outputs and enforce t
|
||||
assert.deepEqual(restoredReply.attachments, generated)
|
||||
})
|
||||
|
||||
test('every writable built-in conversational Agent can use the shared main-process media generator', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const kinds = [
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'pi', 'kimi',
|
||||
'mimo', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
]
|
||||
options.detectAgents = async () => kinds.map((kind, index) => ({
|
||||
kind,
|
||||
name: `${kind} CLI`,
|
||||
executable: `/tmp/${kind}`,
|
||||
version: String(index + 1),
|
||||
}))
|
||||
const requests = [
|
||||
{ type: 'image', prompt: '请生成一张日出山谷图片', extension: 'png', mimeType: 'image/png' },
|
||||
{ type: 'audio', prompt: '请生成一段日出山谷旁白音频', extension: 'wav', mimeType: 'audio/wav' },
|
||||
{ type: 'video', prompt: '请生成一段日出山谷短视频', extension: 'mp4', mimeType: 'video/mp4' },
|
||||
]
|
||||
const generationCalls = []
|
||||
let generated = null
|
||||
options.generateMedia = async (input) => {
|
||||
generationCalls.push(input)
|
||||
const request = requests.find(item => item.type === input.request.type)
|
||||
generated = {
|
||||
id: `generated-${input.kind}-${request.type}`,
|
||||
name: `generated-${request.type}-${input.kind}.${request.extension}`,
|
||||
mimeType: request.mimeType,
|
||||
size: 128,
|
||||
}
|
||||
input.onEvent({
|
||||
id: 'media-test', type: 'tool_start', status: 'running', title: `${request.type}_generation`,
|
||||
})
|
||||
input.onEvent({
|
||||
id: 'media-test', type: 'tool_result_summary', status: 'completed', title: `${request.type}_generation`,
|
||||
})
|
||||
return { type: request.type, filename: generated.name }
|
||||
}
|
||||
options.captureAgentOutputs = async () => ({ marker: 'before-media' })
|
||||
options.importAgentOutputs = async () => generated ? [{ ...generated }] : []
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
for (const kind of kinds) {
|
||||
const direct = workspace.createGroup({
|
||||
name: `${kind} media`, workdir: directory, allowWrite: true,
|
||||
conversationType: 'direct', directAgentKind: kind, agentKinds: [kind],
|
||||
})
|
||||
for (const request of requests) {
|
||||
await workspace.sendMessage({ groupId: direct.id, text: request.prompt })
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(generationCalls.map(call => call.kind), kinds.flatMap(kind => (
|
||||
requests.map(() => kind)
|
||||
)))
|
||||
assert.deepEqual(generationCalls.map(call => call.request.type), kinds.flatMap(() => (
|
||||
requests.map(request => request.type)
|
||||
)))
|
||||
assert.equal(generationCalls.every(call => (
|
||||
requests.some(request => (
|
||||
request.type === call.request.type && request.prompt === call.request.prompt
|
||||
))
|
||||
)), true)
|
||||
assert.equal(calls.every(call => /generated-(?:image|audio|video)-/.test(call.prompt)), true)
|
||||
const replies = workspace.snapshot().messages.filter(message => message.role === 'agent')
|
||||
assert.equal(replies.length, kinds.length * requests.length)
|
||||
assert.deepEqual(replies.map(reply => reply.attachments?.[0]?.mimeType), kinds.flatMap(() => (
|
||||
requests.map(request => request.mimeType)
|
||||
)))
|
||||
})
|
||||
|
||||
test('every built-in conversational Agent can generate each media type when targeted in a group', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const kinds = [
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'pi', 'kimi',
|
||||
'mimo', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
]
|
||||
options.detectAgents = async () => kinds.map((kind, index) => ({
|
||||
kind,
|
||||
name: `${kind} CLI`,
|
||||
executable: `/tmp/${kind}`,
|
||||
version: String(index + 1),
|
||||
}))
|
||||
const requests = [
|
||||
{ type: 'image', prompt: 'Generate a group image preview', extension: 'png', mimeType: 'image/png' },
|
||||
{ type: 'audio', prompt: 'Generate a group audio preview', extension: 'wav', mimeType: 'audio/wav' },
|
||||
{ type: 'video', prompt: 'Generate a group video preview', extension: 'mp4', mimeType: 'video/mp4' },
|
||||
]
|
||||
let generated = null
|
||||
options.captureAgentOutputs = async () => ({ marker: 'before-group-run' })
|
||||
options.importAgentOutputs = async () => generated ? [{ ...generated }] : []
|
||||
const generationCalls = []
|
||||
options.generateMedia = async (input) => {
|
||||
generationCalls.push(input)
|
||||
const request = requests.find(item => item.type === input.request.type)
|
||||
generated = {
|
||||
id: `group-${input.kind}-${request.type}`,
|
||||
name: `group-${request.type}-${input.kind}.${request.extension}`,
|
||||
mimeType: request.mimeType,
|
||||
size: 128,
|
||||
}
|
||||
return { type: request.type, filename: generated.name }
|
||||
}
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Media group', workdir: directory, allowWrite: true, conversationType: 'group',
|
||||
agentKinds: kinds,
|
||||
})
|
||||
|
||||
for (const kind of kinds) {
|
||||
for (const request of requests) {
|
||||
await workspace.sendMessage({
|
||||
groupId: group.id,
|
||||
text: request.prompt,
|
||||
targetKinds: [kind],
|
||||
mode: 'manual',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(generationCalls.map(call => call.kind), kinds.flatMap(kind => (
|
||||
requests.map(() => kind)
|
||||
)))
|
||||
assert.deepEqual(generationCalls.map(call => call.request.type), kinds.flatMap(() => (
|
||||
requests.map(request => request.type)
|
||||
)))
|
||||
const replies = workspace.snapshot().messages.filter(message => message.role === 'agent')
|
||||
assert.equal(replies.length, kinds.length * requests.length)
|
||||
assert.deepEqual(replies.map(reply => reply.agentKind), kinds.flatMap(kind => (
|
||||
requests.map(() => kind)
|
||||
)))
|
||||
assert.deepEqual(replies.map(reply => reply.attachments?.[0]?.mimeType), kinds.flatMap(() => (
|
||||
requests.map(request => request.mimeType)
|
||||
)))
|
||||
assert.equal(JSON.stringify(replies).includes(directory), false)
|
||||
})
|
||||
|
||||
test('group media requests fall back to the target Agent when the shared Provider lacks a media model', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const generated = {
|
||||
id: 'native-agent-image', name: 'native-agent-image.png', mimeType: 'image/png', size: 128,
|
||||
}
|
||||
const error = new Error('MEDIA_GENERATION_MODEL_UNAVAILABLE')
|
||||
error.code = 'MEDIA_GENERATION_MODEL_UNAVAILABLE'
|
||||
options.generateMedia = async () => { throw error }
|
||||
options.captureAgentOutputs = async () => ({ marker: 'before-native-media' })
|
||||
options.importAgentOutputs = async () => [generated]
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Native media fallback', workdir: directory, allowWrite: true,
|
||||
conversationType: 'group', agentKinds: ['codex', 'hermes'],
|
||||
})
|
||||
|
||||
await workspace.sendMessage({
|
||||
groupId: group.id, text: '请生成一张赛博朋克城市图片',
|
||||
targetKinds: ['codex'], mode: 'manual',
|
||||
})
|
||||
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].agent.kind, 'codex')
|
||||
assert.match(calls[0].prompt, /shared media generator was unavailable/i)
|
||||
assert.match(calls[0].prompt, /native media-generation tools or installed local skills/i)
|
||||
assert.match(calls[0].prompt, /\.meldwork-output\//)
|
||||
const reply = workspace.snapshot().messages.find(message => message.role === 'agent')
|
||||
assert.deepEqual(reply.attachments, [generated])
|
||||
assert.equal(workspace.snapshot().messages.some(message => (
|
||||
message.role === 'system' && /MEDIA_GENERATION_MODEL_UNAVAILABLE/.test(message.content)
|
||||
)), false)
|
||||
})
|
||||
|
||||
test('group media requests recover when native Agent media fallback reports provider model failure', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const generated = {
|
||||
id: 'recovered-group-video', name: 'recovered-group-video.mp4', mimeType: 'video/mp4', size: 128,
|
||||
}
|
||||
let generationCount = 0
|
||||
options.generateMedia = async (input) => {
|
||||
generationCount += 1
|
||||
if (generationCount === 1) {
|
||||
const error = new Error('MEDIA_GENERATION_MODEL_UNAVAILABLE')
|
||||
error.code = 'MEDIA_GENERATION_MODEL_UNAVAILABLE'
|
||||
throw error
|
||||
}
|
||||
input.onEvent({
|
||||
id: 'media-recovered',
|
||||
type: 'tool_result_summary',
|
||||
status: 'completed',
|
||||
title: 'video_generation',
|
||||
})
|
||||
return { type: 'video', filename: generated.name }
|
||||
}
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
calls.push({ agent, prompt, workdir, runOptions })
|
||||
return {
|
||||
text: 'Codex failed: The configured Providers do not offer the required media model. Use a Provider credential with access to that image, audio, or video model.',
|
||||
sessionRef: runOptions.sessionRef || 'codex-session',
|
||||
outcome: 'completed',
|
||||
}
|
||||
}
|
||||
options.captureAgentOutputs = async () => ({ marker: 'before-recovery' })
|
||||
options.importAgentOutputs = async () => (generationCount >= 2 ? [generated] : [])
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Recovered group media', workdir: directory, allowWrite: true,
|
||||
conversationType: 'group', agentKinds: ['codex', 'hermes'],
|
||||
})
|
||||
|
||||
await workspace.sendMessage({
|
||||
groupId: group.id,
|
||||
text: 'Generate a short product demo video',
|
||||
targetKinds: ['codex'],
|
||||
mode: 'manual',
|
||||
})
|
||||
|
||||
assert.equal(generationCount, 2)
|
||||
assert.equal(calls.length, 1)
|
||||
const reply = workspace.snapshot().messages.find(message => message.role === 'agent')
|
||||
assert.equal(reply.content, `Meldwork generated and attached ${generated.name}.`)
|
||||
assert.deepEqual(reply.attachments, [generated])
|
||||
})
|
||||
|
||||
test('direct media requests fall back to the chat Agent when the shared Provider lacks a media model', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const generated = {
|
||||
id: 'native-direct-image', name: 'native-direct-image.png', mimeType: 'image/png', size: 128,
|
||||
}
|
||||
const error = new Error('MEDIA_GENERATION_MODEL_UNAVAILABLE')
|
||||
error.code = 'MEDIA_GENERATION_MODEL_UNAVAILABLE'
|
||||
options.generateMedia = async () => { throw error }
|
||||
options.captureAgentOutputs = async () => ({ marker: 'before-native-media' })
|
||||
options.importAgentOutputs = async () => [generated]
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const direct = workspace.createGroup({
|
||||
name: 'Native direct media fallback', workdir: directory, allowWrite: true,
|
||||
conversationType: 'direct', directAgentKind: 'hermes', agentKinds: ['hermes'],
|
||||
})
|
||||
|
||||
await workspace.sendMessage({
|
||||
groupId: direct.id, text: '请生成一张赛博朋克城市图片',
|
||||
})
|
||||
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].agent.kind, 'hermes')
|
||||
assert.match(calls[0].prompt, /shared media generator was unavailable/i)
|
||||
assert.match(calls[0].prompt, /native media-generation tools or installed local skills/i)
|
||||
assert.match(calls[0].prompt, /\.meldwork-output\//)
|
||||
const reply = workspace.snapshot().messages.find(message => message.role === 'agent')
|
||||
assert.deepEqual(reply.attachments, [generated])
|
||||
assert.equal(workspace.snapshot().messages.some(message => (
|
||||
message.role === 'system' && /MEDIA_GENERATION_MODEL_UNAVAILABLE/.test(message.content)
|
||||
)), false)
|
||||
})
|
||||
|
||||
test('new direct and group conversations default to workspace-write permission', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
@@ -1733,10 +1476,8 @@ test('read-only conversations forbid false media claims and do not scan for gene
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
let captureCount = 0
|
||||
let importCount = 0
|
||||
let generationCount = 0
|
||||
options.captureAgentOutputs = async () => { captureCount += 1; return {} }
|
||||
options.importAgentOutputs = async () => { importCount += 1; return [] }
|
||||
options.generateMedia = async () => { generationCount += 1; return { filename: 'unexpected.png' } }
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const direct = workspace.createGroup({
|
||||
@@ -1749,7 +1490,6 @@ test('read-only conversations forbid false media claims and do not scan for gene
|
||||
|
||||
assert.equal(captureCount, 0)
|
||||
assert.equal(importCount, 0)
|
||||
assert.equal(generationCount, 0)
|
||||
assert.match(calls[0].prompt, /read-only/i)
|
||||
assert.match(calls[0].prompt, /do not claim that a media file was generated/i)
|
||||
})
|
||||
@@ -1832,57 +1572,6 @@ test('a new targeted group task discards a legacy conversation Session and stays
|
||||
assert.equal(Object.hasOwn(workspace.state.sessions, globalKey), false)
|
||||
})
|
||||
|
||||
test('a targeted non-Codex group image task replaces legacy research context and returns media', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const generationCalls = []
|
||||
options.generateMedia = async (input) => {
|
||||
generationCalls.push(input)
|
||||
return { type: 'image', filename: 'new-city-poster.png' }
|
||||
}
|
||||
options.captureAgentOutputs = async () => ({ marker: 'before-targeted-image' })
|
||||
options.importAgentOutputs = async () => ([
|
||||
{ id: 'new-city-poster', name: 'new-city-poster.png', mimeType: 'image/png', size: 128 },
|
||||
])
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Targeted media isolation', agentKinds: ['codex', 'hermes'], workdir: directory,
|
||||
allowWrite: true,
|
||||
})
|
||||
const oldRoot = workspace.addMessage(group.id, 'user', 'OLD_RESEARCH_TASK: 调研 GEO 市场')
|
||||
workspace.addMessage(group.id, 'agent', 'OLD_RESEARCH_CONCLUSION', 'codex', oldRoot.id)
|
||||
const legacyKey = workspace.sessionKey(group.id, 'hermes')
|
||||
workspace.state.sessions[legacyKey] = 'legacy-hermes-research-session'
|
||||
workspace.state.sessionMeta[legacyKey] = { turns: 6, estimatedChars: 8000 }
|
||||
workspace.save()
|
||||
|
||||
await workspace.sendMessage({
|
||||
groupId: group.id,
|
||||
text: 'NEW_IMAGE_TASK: 请生成一张未来城市海报',
|
||||
targetKinds: ['hermes'],
|
||||
mode: 'manual',
|
||||
})
|
||||
|
||||
assert.deepEqual(generationCalls.map(call => call.kind), ['hermes'])
|
||||
assert.equal(generationCalls[0].request.type, 'image')
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].agent.kind, 'hermes')
|
||||
assert.equal(calls[0].runOptions.sessionRef, '')
|
||||
assert.match(calls[0].prompt, /Current user task \(authoritative\):\nNEW_IMAGE_TASK/)
|
||||
assert.match(calls[0].prompt, /new-city-poster\.png/)
|
||||
assert.match(calls[0].prompt, /Final response scope:[\s\S]*Do not append an answer to an older task/)
|
||||
assert.ok(calls[0].prompt.lastIndexOf('Final response scope:') > calls[0].prompt.lastIndexOf('OLD_RESEARCH_TASK'))
|
||||
assert.doesNotMatch(calls[0].prompt, /Continue this group Session/)
|
||||
assert.equal(Object.hasOwn(workspace.state.sessions, legacyKey), false)
|
||||
const reply = workspace.snapshot().messages.find(message => (
|
||||
message.role === 'agent' && message.threadRootId && message.agentKind === 'hermes'
|
||||
))
|
||||
assert.deepEqual(reply.attachments, [
|
||||
{ id: 'new-city-poster', name: 'new-city-poster.png', mimeType: 'image/png', size: 128 },
|
||||
])
|
||||
})
|
||||
|
||||
test('every built-in conversational Agent starts a newly targeted group task outside its legacy Session', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Meldwork AI Discoverability Index
|
||||
|
||||
This page is a factual index for search systems and AI assistants. Statements below are based on the repository source and documentation; they do not describe capabilities that are not implemented here.
|
||||
|
||||
## Canonical identity
|
||||
|
||||
- **Project:** Meldwork
|
||||
- **Type:** Local-first Electron desktop application with a Vue renderer (`desktop/package.json`, `frontend/package.json`, `desktop/src/main.cjs`, `frontend/src/main.js`).
|
||||
- **Primary subject:** A local workspace for direct conversations and user-selected multi-Agent conversations. The Electron main process discovers and invokes local Agent CLIs, while the renderer receives a constrained preload API.
|
||||
- **Runtime boundary:** Conversations, orchestration state, run checkpoints, attachments, Skill snapshots, Provider metadata, and selected knowledge-source state are stored locally. The repository does not require an application server, account/tenant system, or Meldwork-hosted remote conversation store (`architecture.md`).
|
||||
- **Repository terms:** local-first AI workspace; multi-Agent collaboration; Agent CLI orchestration; Electron desktop; Vue renderer; Harness; evidence-aware runs; human-in-the-loop decisions; Agent Connector SDK.
|
||||
|
||||
## Search intents this repository answers
|
||||
|
||||
Use these phrases when looking for this project or related implementation evidence:
|
||||
|
||||
- local-first multi-Agent desktop workspace / 本地优先多 Agent 桌面工作空间
|
||||
- Electron app for multiple AI Agent CLIs / Electron 多 AI Agent CLI 客户端
|
||||
- direct and group AI Agent conversations / AI Agent 直接会话与群聊
|
||||
- concurrent multi-Agent responses with frozen context / 共享快照与并发 Agent 回复
|
||||
- Auto Discussion V4 proposal, challenge, responsibility, synthesis, verification / 多 Agent 提案、质询、职责协商、整合与复核
|
||||
- Agent Client Protocol (ACP) desktop integration / ACP(Agent Client Protocol)桌面集成
|
||||
- JSONL and stream-json Agent runtime normalization / JSONL、stream-json Agent 事件归一化
|
||||
- local Agent Connector SDK with content-addressed manifests / 内容寻址的本地 Agent Connector SDK
|
||||
- durable sanitized run ledger, evidence-aware runs, and human-in-the-loop Human Gate recovery / 脱敏运行账本、Evidence-aware 运行与 human-in-the-loop 人类审批门恢复
|
||||
- local Skills, image/file attachments, Provider profiles, and Obsidian knowledge selection / 本地 Skill、附件、Provider 与 Obsidian 知识源
|
||||
- deterministic local Eval Harness for Agent workflows / 本地确定性 Agent Eval Harness
|
||||
|
||||
## Main execution path
|
||||
|
||||
1. Electron starts at `desktop/src/main.cjs` and loads the built renderer through a local `file:` document.
|
||||
2. `desktop/src/preload.cjs` exposes the named `window.meldworkDesktop` IPC surface; the renderer does not receive arbitrary filesystem, shell, executable-path, credential, or native-session access.
|
||||
3. `desktop/src/agents/cli/cli-discovery.cjs` scans fixed executable names and known platform paths. `desktop/src/agents/cli/cli-adapters.cjs` validates capability and invokes the selected executable using code-defined arguments.
|
||||
4. `desktop/src/workspace/local-workspace.cjs` coordinates conversations, direct/group targets, session references, persistence, and run lifecycle. `local-workspace-message-submission.cjs` accepts a message and starts direct, manual, or automatic execution.
|
||||
5. `desktop/src/agents/cli/cli-output-parsers.cjs` and `cli-runtime-event-mappers.cjs` reduce Agent output to final text, bounded status/progress, plans/reasoning summaries where available, tool lifecycle summaries, terminal outcomes, and a main-only session reference.
|
||||
6. `desktop/src/runs/run-ledger.cjs`, `run-scheduler.cjs`, and `failure-policy.cjs` checkpoint bounded sanitized state, enforce budgets/limits, classify failures, and support stop/recovery semantics.
|
||||
7. `frontend/src/App.vue`, `frontend/src/components/ConversationTimelineView.vue`, and `RunTracePanel.vue` render messages and sanitized run events. Final messages and compact evidence are persisted in the local workspace.
|
||||
|
||||
## Collaboration modes
|
||||
|
||||
- **Direct conversation:** One selected Agent with conversation-and-Agent session continuity when the adapter supports it.
|
||||
- **Concurrent Responses (`manual`):** A selected group runs from one frozen task snapshot; selected members are retained and results are published in stable member order after the batch barrier (`architecture.md`, `docs/flows.md`).
|
||||
- **Auto Discussion (`auto`, V4 `discussion`):** Selected Agents produce independent proposals, challenge and negotiate one normalized responsibility plan, execute dependency-aware work packages, use one synthesis writer when workspace writes are enabled, and independently verify the candidate. The Harness validates receipts, permissions, watermarks, Human Gates, and idempotent commits; it does not privately assign roles (`desktop/src/collaboration/orchestration-v4-records.cjs`, `desktop/src/workspace/local-workspace-auto-runner.cjs`).
|
||||
- **Context:** Messages may carry up to four target-scoped Skill snapshots and up to four knowledge-source selections. Attachment validation and per-Agent image limits occur before execution (`docs/automation.md`, `docs/flows.md`).
|
||||
|
||||
## Built-in Agent adapters and wire protocols
|
||||
|
||||
The fixed catalog is defined in `desktop/src/agents/cli/cli-discovery.cjs`. Command aliases are discovery names, not claims that the executable is bundled.
|
||||
|
||||
| Agent kind | Display name / command aliases | Invocation or event protocol in code | Runtime notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `codex` | Codex / `codex` | Codex App Server JSONL (`codex exec --json`) | Streaming answer deltas, plans, reasoning, tool start/result, sessions; read-only or workspace-write sandbox. |
|
||||
| `hermes` | Hermes / `hermes` | ACP JSON-RPC (`hermes acp`), with legacy terminal-text fallback (`hermes chat --quiet`) | Session continuity; legacy path includes result recovery from a local message watermark when available. |
|
||||
| `openclaw` | OpenClaw / `openclaw` | ACP JSON-RPC (`openclaw ... acp`), with legacy terminal-document JSON fallback | Managed local runtime and explicit tool policy; live behavior depends on the installed CLI and Provider. |
|
||||
| `workbuddy` | WorkBuddy / `codebuddy` | `stream-json` invocation with bounded nested turn count | Read-only/plan or workspace-write/acceptEdits permission mode; session resume supported by the invocation. |
|
||||
| `pi` | Pi Agent / `pi`, `pi-agent`, `piagent` | Pi JSONL (`--mode json --print`) | Streaming answer, reasoning, and tool lifecycle events; session resume supported. |
|
||||
| `kimi` | Kimi / `kimi` | ACP `plan` mode for read-only; native `stream-json --prompt` for workspace-write | The two permission paths use different transports. |
|
||||
| `mimo` | MiMo / `mimo` | ACP JSON-RPC (`mimo acp --pure`), JSON fallback (`mimo run --format json`) | Plan/build mode follows permission; session resume supported. |
|
||||
| `claude` | Claude Code / `claude` | Anthropic-style `stream-json` | Streaming answer, reasoning, plans, tool lifecycle, and session resume. |
|
||||
| `gemini` | Gemini CLI / `gemini` | Gemini `stream-json` | Streaming answer and tool start/result events; session resume supported. |
|
||||
| `opencode` | OpenCode / `opencode` | ACP JSON-RPC (`opencode acp --pure`), JSON fallback (`opencode run --format json`) | Plan/build mode follows permission; session resume supported; image/file arguments are validated. |
|
||||
| `qwen` | Qwen Code / `qwen` | Anthropic-style `stream-json` | Plan/auto-edit approval mode; optional configured Provider and session resume. |
|
||||
| `opencodereview` | OpenCodeReview / `ocr` | Terminal JSON document (`ocr review --format json`) | Read-only code-review adapter; output is final text/evidence without session or tool lifecycle events. |
|
||||
|
||||
The event profile mapping is explicit in `desktop/src/agents/cli/cli-event-profiles.cjs`; ACP transport is implemented in `desktop/src/agents/cli/cli-acp-runner.cjs`. The runtime capability contract (`desktop/src/agents/agent-runtime-contract.cjs`) recognizes domains including `software-development`, `software-review`, `research`, `document-production`, `tool-use`, and `automation`, input types including text/image/audio/video/file/structured-data, and permission modes `read-only` and `workspace-write`.
|
||||
|
||||
Custom local Agents are also supported through `desktop/src/agents/custom-agent-contract.cjs` and `custom-agent-store.cjs`. A custom definition uses an absolute executable path, allowlisted arguments, and either `stdin` or `argument` prompt mode; custom execution currently requires `workspace-write` and is not part of the fixed built-in protocol table.
|
||||
|
||||
## Core modules and public technical documents
|
||||
|
||||
| Area | Source of truth |
|
||||
| --- | --- |
|
||||
| Electron bootstrap, IPC trust boundary, persistence wiring | `desktop/src/main.cjs`, `desktop/src/shell/main-ipc.cjs`, `desktop/src/preload.cjs` |
|
||||
| Vue renderer and desktop lifecycle | `frontend/src/main.js`, `frontend/src/App.vue`, `frontend/src/desktop.js` |
|
||||
| Agent discovery, invocation, parsing, runtime events | `desktop/src/agents/cli/cli-discovery.cjs`, `cli-invocations.cjs`, `cli-adapters.cjs`, `cli-output-parsers.cjs`, `cli-runtime-event-mappers.cjs` |
|
||||
| Conversations, Context Packs, sessions, message submission | `desktop/src/workspace/local-workspace.cjs`, `local-workspace-context-packs.cjs`, `local-workspace-message-submission.cjs` |
|
||||
| V4 orchestration and recovery | `desktop/src/collaboration/orchestration-v4-records.cjs`, `desktop/src/workspace/local-workspace-auto-runner.cjs`, `desktop/src/runs/run-ledger.cjs` |
|
||||
| Agent Connector packages | `desktop/src/agents/connectors/`, `docs/agent-connector-sdk.md` |
|
||||
| Local Skill discovery and trust | `desktop/src/skills/`, `docs/local-skill-contracts.md` |
|
||||
| Knowledge sources | `desktop/src/knowledge/`, `docs/flows.md` |
|
||||
| Optional Cloud/Channel runtimes | `desktop/src/agents/cloud/`, `desktop/src/channels/`, `docs/integrations/cloud-agent-bridge.md`, `docs/automation.md` |
|
||||
| Tests and CI | `desktop/test/`, `frontend/src/__tests__/meldwork/`, `.github/workflows/ci.yml`, `docs/tests.md` |
|
||||
|
||||
## Installation and verification commands
|
||||
|
||||
Prerequisite: Node.js `>=22.12.0` and npm (`desktop/package.json`, `frontend/package.json`). From the repository root:
|
||||
|
||||
```bash
|
||||
npm --prefix frontend ci
|
||||
npm --prefix desktop ci
|
||||
npm --prefix desktop run dev
|
||||
```
|
||||
|
||||
Focused and full checks:
|
||||
|
||||
```bash
|
||||
npm --prefix frontend test
|
||||
npm --prefix frontend run build
|
||||
npm --prefix frontend run build:desktop
|
||||
npm --prefix desktop test:agents
|
||||
npm --prefix desktop test:runs
|
||||
npm --prefix desktop test:workspace
|
||||
npm --prefix desktop test:security
|
||||
npm --prefix desktop test
|
||||
npm --prefix desktop run eval:deterministic
|
||||
npm --prefix desktop run connector:conformance
|
||||
npm --prefix desktop run pack
|
||||
```
|
||||
|
||||
`desktop/test/README.md` documents suite discovery. CI runs frontend tests/builds on Ubuntu and desktop tests, deterministic Eval Harness, and Electron packaging on macOS (`.github/workflows/ci.yml`).
|
||||
|
||||
## Boundaries and explicit non-goals
|
||||
|
||||
- The current packaged release target is Apple silicon macOS. The V1.0.3 prerelease is ad-hoc signed, not Apple Developer ID signed or notarized (`docs/releases/Meldwork-V1.0.3.md`, `docs/tests.md`).
|
||||
- Automatic participant selection from a larger roster, cross-user remote collaboration, enterprise RBAC/governance, a production Cloud/Channel Agent service, and an Outcome Network are outside the current release boundary.
|
||||
- Cloud and Channel Connector runtimes exist as optional main-process contracts, but no concrete Cloud or Channel Connector is enabled by default. The documented SSH bridge currently supports Codex read-only only; remote writes, attachments, and session resume are not supported (`docs/integrations/cloud-agent-bridge.md`).
|
||||
- Agent output and model access depend on the locally installed CLI, authentication, Provider, declared capabilities, and upstream protocol behavior. Repository fixtures and unit tests do not certify every Agent installation, version, operating system, or live Provider.
|
||||
- Workspace writes default to disabled. Agent processes run with the local operating-system user's permissions; permission flags and read-only instructions are not an OS sandbox (`architecture.md`, `docs/permissions.md`).
|
||||
- The repository is source-available under the **Meldwork Non-Commercial Source License 1.0**. Commercial use requires prior written permission (`LICENSE`, `COMMERCIAL_USE.md`).
|
||||
|
||||
## Aliases and keyword index
|
||||
|
||||
`Meldwork`, `meldwork`, `local-first AI workspace`, `multi-Agent collaboration`, `multi-agent orchestration`, `AI Agent work cell`, `Electron Agent desktop`, `Vue Agent workspace`, `Agent CLI manager`, `evidence-aware`, `human-in-the-loop`, `Codex`, `Hermes`, `OpenClaw`, `WorkBuddy`, `Pi Agent`, `Kimi Code`, `MiMo Code`, `Claude Code`, `Gemini CLI`, `OpenCode`, `Qwen Code`, `OpenCodeReview`, `ACP`, `Agent Client Protocol`, `JSONL`, `stream-json`, `Agent Connector SDK`, `Context Pack`, `Run Ledger`, `Human Gate`, `Auto Discussion V4`, `Concurrent Responses`, `frozen task snapshot`, `responsibility graph`, `typed Artifact`, `Evidence`, `Skill snapshot`, `Provider profile`, `Obsidian knowledge source`, `local Electron multi-agent app`, `本地优先`, `多 Agent 协作`, `Agent CLI 编排`, `桌面端 Agent 工作空间`, `多 Agent 群聊`, `并发回复`, `自动讨论`, `职责协商`, `运行账本`, `人类审批门`, `Agent 连接器 SDK`.
|
||||
@@ -80,7 +80,8 @@ describe('Meldwork workbench', () => {
|
||||
})
|
||||
expect(wrapper.get('.composer-attachment img').attributes('src')).toBe('data:image/png;base64,AQID')
|
||||
expect(wrapper.get('.composer-attachment').classes()).toContain('is-image')
|
||||
expect(wrapper.get('.composer-attachment').text()).toBe('')
|
||||
expect(wrapper.get('.composer-attachment').text()).toContain('diagram.png')
|
||||
expect(wrapper.get('.composer-attachment').text()).toContain('3 B')
|
||||
expect(wrapper.get('.send-button').attributes()).not.toHaveProperty('disabled')
|
||||
|
||||
await wrapper.get('.send-button').trigger('click')
|
||||
@@ -174,7 +175,7 @@ describe('Meldwork workbench', () => {
|
||||
'report.pdf', 'notes.md', 'preview.html',
|
||||
])
|
||||
expect(wrapper.findAll('.composer-attachment small').map(item => item.text())).toEqual([
|
||||
'PDF', 'MD', 'HTML',
|
||||
'9 B', '8 B', '16 B',
|
||||
])
|
||||
expect(wrapper.find('.composer-drop-overlay').exists()).toBe(false)
|
||||
expect(wrapper.find('.toast-message').exists()).toBe(false)
|
||||
@@ -212,7 +213,8 @@ describe('Meldwork workbench', () => {
|
||||
name: 'diagram.png',
|
||||
mimeType: 'image/png',
|
||||
}))
|
||||
expect(wrapper.get('.composer-attachment').text()).toBe('')
|
||||
expect(wrapper.get('.composer-attachment').text()).toContain('diagram.png')
|
||||
expect(wrapper.get('.composer-attachment').text()).toContain('7 B')
|
||||
expect(wrapper.get('.composer-attachment').classes()).toContain('is-image')
|
||||
expect(wrapper.find('.toast-message').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
@@ -663,11 +665,18 @@ describe('Meldwork workbench', () => {
|
||||
expect(wrapper.get('.message-attachment-grid video').attributes('src'))
|
||||
.toBe('meldwork-media://attachment/demo-video')
|
||||
expect(wrapper.find('.message-attachment-grid figcaption').exists()).toBe(false)
|
||||
const audioCardInfo = wrapper.get('.message-audio-card-info')
|
||||
const inlineAudio = wrapper.get('.message-audio-card audio').element
|
||||
inlineAudio.play = vi.fn(async () => {})
|
||||
await audioCardInfo.trigger('click')
|
||||
await flushPromises()
|
||||
expect(inlineAudio.play).toHaveBeenCalledTimes(1)
|
||||
expect(document.querySelector('.attachment-media-preview-dialog')).toBeNull()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('opens uploaded images and generated videos in the original-media preview', async () => {
|
||||
const { wrapper } = await mountApp(({ state }) => {
|
||||
const { wrapper, bridge } = await mountApp(({ state }) => {
|
||||
state.groups.push({
|
||||
id: 'direct-codex',
|
||||
conversationType: 'direct',
|
||||
@@ -720,6 +729,11 @@ describe('Meldwork workbench', () => {
|
||||
const previewVideo = document.querySelector('.attachment-media-preview-dialog video')
|
||||
expect(previewVideo?.getAttribute('src')).toBe('meldwork-media://attachment/generated-video-id')
|
||||
expect(previewVideo?.hasAttribute('autoplay')).toBe(true)
|
||||
document.querySelector('[aria-label="Download result.mp4"]')?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(bridge.localAttachments.save).toHaveBeenCalledWith('generated-video-id')
|
||||
|
||||
document.querySelector('.attachment-media-preview-backdrop')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
@@ -831,7 +845,7 @@ describe('Meldwork workbench', () => {
|
||||
await flushPromises()
|
||||
const documentCard = wrapper.get('.message-document-attachment')
|
||||
expect(documentCard.text()).toContain('report.pdf')
|
||||
expect(documentCard.text()).toContain('PDF · 2 KB')
|
||||
expect(documentCard.text()).toContain('2 KB')
|
||||
expect(documentCard.get('[data-document-icon]').attributes('data-document-icon')).toBe('pdf')
|
||||
expect(documentCard.html()).not.toContain('/tmp/')
|
||||
await documentCard.trigger('click')
|
||||
@@ -863,7 +877,7 @@ describe('Meldwork workbench', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('[data-document-icon]').map(item => item.attributes('data-document-icon')))
|
||||
.toEqual(['code', 'spreadsheet', 'presentation', 'archive'])
|
||||
.toEqual(['default', 'excel', 'ppt', 'zip'])
|
||||
expect(wrapper.findAll('.message-document-list')).toHaveLength(1)
|
||||
expect(wrapper.get('.message-document-list').findAll('.message-document-attachment')).toHaveLength(4)
|
||||
expect(wrapper.get('.message-document-list').findAll('figure')).toHaveLength(0)
|
||||
|
||||
@@ -196,7 +196,7 @@ describe('Meldwork workbench', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows the Auto discussion Beta mode with an explanation placeholder and a coming-soon guard', async () => {
|
||||
it('sends the Auto discussion Beta mode as an Agent-led V4 discussion', async () => {
|
||||
const { wrapper, bridge } = await mountApp(({ state }) => {
|
||||
state.groups.push({
|
||||
id: 'group-auto-beta',
|
||||
@@ -228,8 +228,18 @@ describe('Meldwork workbench', () => {
|
||||
await wrapper.get('.send-button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(bridge.localWorkspace.send).not.toHaveBeenCalled()
|
||||
expect(wrapper.get('.toast-message').text()).toContain('coming soon')
|
||||
expect(bridge.localWorkspace.send).toHaveBeenCalledWith({
|
||||
groupId: 'group-auto-beta',
|
||||
text: 'Plan a launch',
|
||||
targetKinds: ['codex', 'hermes'],
|
||||
skillHints: [],
|
||||
knowledgeBaseHints: [],
|
||||
attachments: [],
|
||||
mode: 'auto',
|
||||
discussionStyle: 'agent-led',
|
||||
protocol: 'v4',
|
||||
maxRounds: 6,
|
||||
})
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
|
||||
@@ -876,7 +876,7 @@ describe('Meldwork workbench', () => {
|
||||
expect(source).toMatch(/\.message-row\.agent \.message-attachment-grid\s*\{[^}]*align-items:\s*flex-start;/s)
|
||||
expect(source).not.toMatch(/\.message-attachment-grid\s*\{[^}]*flex-wrap:\s*wrap;/s)
|
||||
expect(source).toMatch(/\.message-document-list\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*nowrap;[^}]*gap:\s*6px;[^}]*overflow-x:\s*auto;/s)
|
||||
expect(source).toMatch(/\.message-document-attachment\s*\{[^}]*width:\s*168px;[^}]*min-width:\s*148px;[^}]*min-height:\s*44px;[^}]*border:\s*0;/s)
|
||||
expect(source).toMatch(/\.message-document-attachment\s*\{[^}]*width:\s*192px;[^}]*min-width:\s*168px;[^}]*min-height:\s*52px;[^}]*border:\s*0;/s)
|
||||
expect(source).toMatch(/\.direct-session-open\s*\{[^}]*grid-column:\s*1;/s)
|
||||
expect(source).toMatch(/\.direct-session-action\s*\{[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;/s)
|
||||
expect(source).toMatch(/\.direct-session-row:hover \.direct-session-action,[^{]+\.direct-session-row:focus-within \.direct-session-action\s*\{[^}]*opacity:\s*0\.62;[^}]*pointer-events:\s*auto;/s)
|
||||
@@ -1670,7 +1670,7 @@ describe('Meldwork workbench', () => {
|
||||
await range.setValue(6)
|
||||
await wrapper.get('.round-unlimited-button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.confirmation-modal-body').text()).toContain('until consensus or manual stop')
|
||||
expect(wrapper.get('.confirmation-modal-body').text()).toContain('until collaboration ends or you stop it')
|
||||
expect(wrapper.find('.round-range-input').exists()).toBe(true)
|
||||
await wrapper.get('.confirmation-modal-footer .primary-button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
@@ -15,7 +15,7 @@ afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Unlimited-round review mode', () => {
|
||||
describe('Unlimited-round discussion mode', () => {
|
||||
it('keeps the normal composer surface while retaining the infinity cue', () => {
|
||||
const styles = readStylesSource()
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('Unlimited-round review mode', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('explains strict peer review in the confirmation and composer', async () => {
|
||||
it('explains natural collaboration in the confirmation and composer', async () => {
|
||||
const { wrapper, state, emitWorkspaceChanged } = await mountApp(({ state }) => {
|
||||
state.groups.push({
|
||||
id: 'group-1',
|
||||
@@ -84,8 +84,9 @@ describe('Unlimited-round review mode', () => {
|
||||
await wrapper.get('.round-unlimited-button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.confirmation-modal-body').text()).toContain('strict peer review')
|
||||
expect(wrapper.get('.confirmation-modal-body').text()).toContain('independently verify claims')
|
||||
expect(wrapper.get('.confirmation-modal-body').text()).toContain('discuss, divide work, collaborate')
|
||||
expect(wrapper.get('.confirmation-modal-body').text()).toContain('once concrete results exist')
|
||||
expect(wrapper.get('.confirmation-modal-body').text()).not.toContain('strict peer review')
|
||||
|
||||
await wrapper.get('.confirmation-modal-footer .primary-button').trigger('click')
|
||||
await flushPromises()
|
||||
@@ -95,9 +96,9 @@ describe('Unlimited-round review mode', () => {
|
||||
expect(wrapper.get('.round-settings-trigger').classes()).toContain('unlimited')
|
||||
expect(wrapper.get('.round-unlimited-symbol').text()).toBe('∞')
|
||||
expect(wrapper.get('.round-settings-trigger').text()).toContain('No round limit')
|
||||
expect(wrapper.get('.round-unlimited-active').text()).toContain('report material defects')
|
||||
expect(wrapper.get('.round-unlimited-active').text()).toContain('review material gaps')
|
||||
expect(wrapper.get('.composer-box textarea').attributes('placeholder'))
|
||||
.toBe('Message Review with strict peer review enabled')
|
||||
.toBe('Message Review')
|
||||
|
||||
state.runningGroupIds = ['group-1']
|
||||
state.runs = [{
|
||||
|
||||
@@ -252,6 +252,7 @@ export function createBridge() {
|
||||
previewDataUrl: 'data:image/png;base64,AQID',
|
||||
})),
|
||||
open: vi.fn(async () => true),
|
||||
save: vi.fn(async () => ({ saved: true })),
|
||||
discard: vi.fn(async ids => ({ discardedIds: [...ids], retainedIds: [] })),
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
activeMediaGenerationForRun,
|
||||
fileCardIconKey,
|
||||
formatFileCardSize,
|
||||
mediaGenerationFromRunEvents,
|
||||
} from '../../mediaFileCard.js'
|
||||
|
||||
describe('file card icon mapping', () => {
|
||||
it('maps document extensions to Ant Design X preset icons', () => {
|
||||
expect(fileCardIconKey({ name: 'report.pdf' })).toBe('pdf')
|
||||
expect(fileCardIconKey({ name: 'sheet.xlsx' })).toBe('excel')
|
||||
expect(fileCardIconKey({ name: 'slides.pptx' })).toBe('ppt')
|
||||
expect(fileCardIconKey({ name: 'memo.docx' })).toBe('word')
|
||||
expect(fileCardIconKey({ name: 'bundle.zip' })).toBe('zip')
|
||||
expect(fileCardIconKey({ name: 'notes.md' })).toBe('markdown')
|
||||
expect(fileCardIconKey({ name: 'server.ts' })).toBe('default')
|
||||
})
|
||||
|
||||
it('prefers explicit media kinds and falls back to mime types', () => {
|
||||
expect(fileCardIconKey({ name: 'blob', kind: 'video' })).toBe('video')
|
||||
expect(fileCardIconKey({ name: 'blob', mimeType: 'audio/mpeg' })).toBe('audio')
|
||||
expect(fileCardIconKey({ name: 'blob', mimeType: 'image/png' })).toBe('image')
|
||||
expect(fileCardIconKey({ name: 'unknown' })).toBe('default')
|
||||
})
|
||||
|
||||
it('formats sizes the way the FileCard description does', () => {
|
||||
expect(formatFileCardSize(0)).toBe('0 B')
|
||||
expect(formatFileCardSize(512)).toBe('512 B')
|
||||
expect(formatFileCardSize(2048)).toBe('2 KB')
|
||||
expect(formatFileCardSize(5 * 1024 * 1024)).toBe('5 MB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('media generation process state', () => {
|
||||
it('reports the running phase while a generation event is active', () => {
|
||||
const activity = mediaGenerationFromRunEvents([
|
||||
{ title: 'image_generation', status: 'running', summary: 'Generating image' },
|
||||
])
|
||||
expect(activity).toEqual({ type: 'image', status: 'running', phase: 'running' })
|
||||
})
|
||||
|
||||
it('reports the complete phase once the generation event completes', () => {
|
||||
const activity = mediaGenerationFromRunEvents([
|
||||
{ title: 'video_generation', status: 'running', summary: 'Generating video: 42%' },
|
||||
{ title: 'video_generation', status: 'completed', summary: 'generated-video-1.mp4' },
|
||||
])
|
||||
expect(activity).toEqual({ type: 'video', status: 'completed', phase: 'complete' })
|
||||
})
|
||||
|
||||
it('drops failed generations so no stuck card renders', () => {
|
||||
expect(mediaGenerationFromRunEvents([
|
||||
{ title: 'image_generation', status: 'failed' },
|
||||
])).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores unrelated trace events', () => {
|
||||
expect(mediaGenerationFromRunEvents([
|
||||
{ title: 'tool_call', status: 'running' },
|
||||
{ title: 'plan_update', status: 'completed' },
|
||||
])).toBeNull()
|
||||
})
|
||||
|
||||
it('skips agent runs that already finished', () => {
|
||||
const activity = activeMediaGenerationForRun([
|
||||
{ kind: 'codex', status: 'completed', events: [{ title: 'image_generation', status: 'running' }] },
|
||||
{ kind: 'codex', status: 'running', events: [{ title: 'image_generation', status: 'running' }] },
|
||||
])
|
||||
expect(activity.phase).toBe('running')
|
||||
expect(activity.agentKind).toBe('codex')
|
||||
})
|
||||
|
||||
it('returns null when every run is terminal', () => {
|
||||
expect(activeMediaGenerationForRun([
|
||||
{ kind: 'codex', status: 'failed', events: [{ title: 'image_generation', status: 'running' }] },
|
||||
])).toBeNull()
|
||||
expect(activeMediaGenerationForRun([])).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -8,22 +8,61 @@
|
||||
>
|
||||
<section
|
||||
class="attachment-media-preview-dialog"
|
||||
:class="[{ 'is-zoomed': zoomed }, `is-${type}`]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="attachment.name"
|
||||
>
|
||||
<button
|
||||
ref="closeButton"
|
||||
class="attachment-media-preview-close"
|
||||
type="button"
|
||||
:aria-label="closeLabel"
|
||||
:title="closeLabel"
|
||||
@click="close"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<div class="attachment-media-preview-toolbar">
|
||||
<button
|
||||
v-if="canZoom"
|
||||
type="button"
|
||||
class="attachment-media-preview-action"
|
||||
:title="zoomed ? zoomOutLabel : zoomInLabel"
|
||||
:aria-label="zoomed ? zoomOutLabel : zoomInLabel"
|
||||
:aria-pressed="zoomed ? 'true' : 'false'"
|
||||
@click="zoomed = !zoomed"
|
||||
>
|
||||
<RemoveOutline v-if="zoomed" aria-hidden="true" />
|
||||
<SearchOutline v-else aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment-media-preview-action"
|
||||
:title="downloadLabel"
|
||||
:aria-label="downloadLabel"
|
||||
@click="emit('save')"
|
||||
>
|
||||
<DownloadOutline aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
ref="closeButton"
|
||||
type="button"
|
||||
class="attachment-media-preview-action"
|
||||
:aria-label="closeLabel"
|
||||
:title="closeLabel"
|
||||
@click="close"
|
||||
>
|
||||
<CloseOutline aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="type === 'audio'">
|
||||
<div class="attachment-media-preview-audio-card">
|
||||
<FileTypeIcon icon-key="audio" class="attachment-media-preview-audio-icon" />
|
||||
<span class="attachment-media-preview-audio-copy">
|
||||
<strong :title="attachment.name">{{ attachment.name }}</strong>
|
||||
<small>{{ formatFileCardSize(attachment.size) }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<audio
|
||||
:src="source"
|
||||
:aria-label="attachment.name"
|
||||
controls
|
||||
preload="auto"
|
||||
/>
|
||||
</template>
|
||||
<img
|
||||
v-if="type === 'image'"
|
||||
v-else-if="type === 'image'"
|
||||
:src="source"
|
||||
:alt="attachment.name"
|
||||
/>
|
||||
@@ -42,24 +81,40 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { CloseOutline, DownloadOutline, RemoveOutline, SearchOutline } from '@vicons/ionicons5'
|
||||
import FileTypeIcon from './FileTypeIcon.vue'
|
||||
import { formatFileCardSize } from '../mediaFileCard.js'
|
||||
|
||||
const props = defineProps({
|
||||
attachment: { type: Object, required: true },
|
||||
closeLabel: { type: String, required: true },
|
||||
downloadLabel: { type: String, required: true },
|
||||
zoomInLabel: { type: String, default: '' },
|
||||
zoomOutLabel: { type: String, default: '' },
|
||||
source: { type: String, required: true },
|
||||
type: { type: String, required: true },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
const backdrop = ref(null)
|
||||
const closeButton = ref(null)
|
||||
const zoomed = ref(false)
|
||||
let returnFocus = null
|
||||
|
||||
const canZoom = computed(() => ['image', 'video'].includes(props.type))
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function focusableElements() {
|
||||
const root = backdrop.value
|
||||
if (!root) return []
|
||||
return [...root.querySelectorAll('button, video, audio')]
|
||||
.filter(element => element instanceof HTMLElement)
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
@@ -67,8 +122,18 @@ function handleKeydown(event) {
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab') return
|
||||
const elements = focusableElements()
|
||||
if (!elements.length) return
|
||||
event.preventDefault()
|
||||
closeButton.value?.focus()
|
||||
const current = document.activeElement
|
||||
const index = elements.indexOf(current)
|
||||
if (event.shiftKey) {
|
||||
const previous = index <= 0 ? elements.length - 1 : index - 1
|
||||
elements[previous]?.focus()
|
||||
return
|
||||
}
|
||||
const next = index < 0 || index >= elements.length - 1 ? 0 : index + 1
|
||||
elements[next]?.focus()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -106,18 +171,59 @@ body.media-preview-open {
|
||||
.attachment-media-preview-dialog {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: min(1100px, 100%);
|
||||
max-height: 100%;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.attachment-media-preview-toolbar {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.attachment-media-preview-action {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(0 0 0 / 62%);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attachment-media-preview-action svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.attachment-media-preview-action:hover,
|
||||
.attachment-media-preview-action:focus-visible {
|
||||
background: rgb(0 0 0 / 82%);
|
||||
}
|
||||
|
||||
.attachment-media-preview-dialog img,
|
||||
.attachment-media-preview-dialog video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-width: min(1100px, calc(100vw - 48px));
|
||||
max-height: calc(100vh - 48px);
|
||||
background: #080b10;
|
||||
border-radius: 10px;
|
||||
object-fit: contain;
|
||||
transition: max-width 0.22s ease, max-height 0.22s ease;
|
||||
}
|
||||
|
||||
.attachment-media-preview-dialog.is-zoomed img,
|
||||
.attachment-media-preview-dialog.is-zoomed video {
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: calc(100vh - 24px);
|
||||
}
|
||||
|
||||
.attachment-media-preview-dialog img {
|
||||
@@ -129,25 +235,60 @@ body.media-preview-open {
|
||||
width: min(1100px, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.attachment-media-preview-close {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(0 0 0 / 62%);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 28px;
|
||||
line-height: 28px;
|
||||
.attachment-media-preview-dialog.is-zoomed video {
|
||||
width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
.attachment-media-preview-close:hover,
|
||||
.attachment-media-preview-close:focus-visible {
|
||||
background: rgb(0 0 0 / 82%);
|
||||
.attachment-media-preview-dialog.is-audio {
|
||||
width: min(430px, 100%);
|
||||
padding: 52px 16px 16px;
|
||||
border-radius: 14px;
|
||||
background: rgb(14 18 24 / 92%);
|
||||
}
|
||||
|
||||
.attachment-media-preview-audio-card {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgb(255 255 255 / 7%);
|
||||
}
|
||||
|
||||
.attachment-media-preview-audio-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.attachment-media-preview-audio-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.attachment-media-preview-audio-copy strong,
|
||||
.attachment-media-preview-audio-copy small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-media-preview-audio-copy strong {
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.attachment-media-preview-audio-copy small {
|
||||
color: rgb(255 255 255 / 64%);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.attachment-media-preview-dialog audio {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -214,24 +214,29 @@
|
||||
class="composer-attachment"
|
||||
:class="`is-${attachmentKind(attachment)}`"
|
||||
>
|
||||
<img
|
||||
v-if="isImageAttachment(attachment)"
|
||||
:src="attachment.previewDataUrl || attachmentMediaUrl(attachment)"
|
||||
:alt="attachment.name"
|
||||
/>
|
||||
<span v-if="isImageAttachment(attachment)" class="composer-attachment-thumb" aria-hidden="true">
|
||||
<img
|
||||
:src="attachment.previewDataUrl || attachmentMediaUrl(attachment)"
|
||||
:alt="attachment.name"
|
||||
/>
|
||||
</span>
|
||||
<template v-else>
|
||||
<span
|
||||
class="composer-attachment-media-icon"
|
||||
:data-attachment-icon="composerAttachmentIconName(attachment)"
|
||||
:data-attachment-icon="composerAttachmentIconKey(attachment)"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<component :is="composerAttachmentIcon(attachment)" />
|
||||
<FileTypeIcon :icon-key="composerAttachmentIconKey(attachment)" />
|
||||
</span>
|
||||
<span class="composer-attachment-file-copy">
|
||||
<strong :title="attachment.name">{{ attachment.name }}</strong>
|
||||
<small>{{ attachmentTypeLabel(attachment) }}</small>
|
||||
<small>{{ formatAttachmentSize(attachment) }}</small>
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="isImageAttachment(attachment)" class="composer-attachment-file-copy">
|
||||
<strong :title="attachment.name">{{ attachment.name }}</strong>
|
||||
<small>{{ formatAttachmentSize(attachment) }}</small>
|
||||
</span>
|
||||
<button
|
||||
class="composer-attachment-remove"
|
||||
type="button"
|
||||
@@ -240,7 +245,7 @@
|
||||
:disabled="Boolean(activeRun) || sending"
|
||||
@click="removeAttachment(attachment.id)"
|
||||
>
|
||||
<CloseCircleOutline />
|
||||
<CloseOutline aria-hidden="true" />
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
@@ -437,66 +442,30 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
AddOutline,
|
||||
ArchiveOutline,
|
||||
AttachOutline,
|
||||
AtOutline,
|
||||
ChevronDownOutline,
|
||||
CloseCircleOutline,
|
||||
CloseOutline,
|
||||
CodeOutline,
|
||||
DocumentOutline,
|
||||
DocumentTextOutline,
|
||||
EaselOutline,
|
||||
GridOutline,
|
||||
LibraryOutline,
|
||||
MusicalNotesOutline,
|
||||
ReaderOutline,
|
||||
RefreshOutline,
|
||||
SendOutline,
|
||||
StopCircleOutline,
|
||||
VideocamOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import FileTypeIcon from './FileTypeIcon.vue'
|
||||
import { agentLabel, agentLogo } from '../catalog.js'
|
||||
import { fileCardIconKey } from '../mediaFileCard.js'
|
||||
import { MAX_SKILLS, skillKey } from '../composables/useComposerContext.js'
|
||||
|
||||
const props = defineProps({
|
||||
controller: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const COMPOSER_FILE_ICON_COMPONENTS = {
|
||||
archive: ArchiveOutline,
|
||||
code: CodeOutline,
|
||||
document: DocumentOutline,
|
||||
pdf: DocumentTextOutline,
|
||||
presentation: EaselOutline,
|
||||
spreadsheet: GridOutline,
|
||||
text: ReaderOutline,
|
||||
}
|
||||
const COMPOSER_FILE_ICON_BY_EXTENSION = {
|
||||
'7z': 'archive', gz: 'archive', tar: 'archive', tgz: 'archive', zip: 'archive',
|
||||
c: 'code', cc: 'code', cjs: 'code', cpp: 'code', css: 'code', go: 'code', h: 'code',
|
||||
hpp: 'code', html: 'code', java: 'code', js: 'code', json: 'code', md: 'code', mjs: 'code',
|
||||
py: 'code', rs: 'code', sh: 'code', ts: 'code', tsx: 'code', vue: 'code', xml: 'code', yaml: 'code', yml: 'code',
|
||||
csv: 'spreadsheet', ods: 'spreadsheet', xls: 'spreadsheet', xlsx: 'spreadsheet',
|
||||
doc: 'text', docx: 'text', odt: 'text', rtf: 'text', txt: 'text',
|
||||
key: 'presentation', odp: 'presentation', ppt: 'presentation', pptx: 'presentation',
|
||||
pdf: 'pdf',
|
||||
}
|
||||
|
||||
function composerAttachmentIcon(attachment) {
|
||||
const name = composerAttachmentIconName(attachment)
|
||||
if (name === 'audio') return MusicalNotesOutline
|
||||
if (name === 'video') return VideocamOutline
|
||||
if (name === 'attachment') return AttachOutline
|
||||
return COMPOSER_FILE_ICON_COMPONENTS[name] || DocumentOutline
|
||||
}
|
||||
|
||||
function composerAttachmentIconName(attachment) {
|
||||
const type = attachmentKind(attachment)
|
||||
if (type === 'audio' || type === 'video') return type
|
||||
if (type !== 'file') return 'attachment'
|
||||
const extension = String(attachment?.name || '').split('.').pop()?.toLowerCase() || ''
|
||||
return COMPOSER_FILE_ICON_BY_EXTENSION[extension] || 'document'
|
||||
function composerAttachmentIconKey(attachment) {
|
||||
return fileCardIconKey({
|
||||
name: attachment?.name,
|
||||
mimeType: attachment?.mimeType,
|
||||
kind: attachmentKind(attachment),
|
||||
})
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -507,7 +476,6 @@ const {
|
||||
attachmentActionLabel,
|
||||
attachmentKind,
|
||||
attachmentMediaUrl,
|
||||
attachmentTypeLabel,
|
||||
canSendMessage,
|
||||
composerAttachmentSupported,
|
||||
composerAttachments,
|
||||
@@ -524,6 +492,7 @@ const {
|
||||
composerMenuOptionTitle,
|
||||
discussionMode,
|
||||
draft,
|
||||
formatAttachmentSize,
|
||||
groupName,
|
||||
handleComposerDragEnter,
|
||||
handleComposerDragLeave,
|
||||
|
||||
@@ -446,46 +446,74 @@
|
||||
v-attachment-preview="isImageAttachment(attachment) ? attachment : null"
|
||||
:class="`media-${attachmentKind(attachment)}`"
|
||||
>
|
||||
<button
|
||||
v-if="isImageAttachment(attachment) && (attachmentPreviewUrl(attachment) || attachmentMediaUrl(attachment))"
|
||||
class="message-media-preview-trigger"
|
||||
type="button"
|
||||
:title="t('attachment.preview', { name: attachment.name })"
|
||||
:aria-label="t('attachment.preview', { name: attachment.name })"
|
||||
@click="openMediaPreview(attachment)"
|
||||
<div
|
||||
v-if="attachmentKind(attachment) !== 'audio'"
|
||||
class="message-media-frame"
|
||||
:class="{ loaded: isMediaLoaded(attachment.id) }"
|
||||
>
|
||||
<img
|
||||
:src="attachmentPreviewUrl(attachment) || attachmentMediaUrl(attachment)"
|
||||
:alt="attachment.name"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</button>
|
||||
<audio
|
||||
v-else-if="attachmentKind(attachment) === 'audio'"
|
||||
:src="attachmentMediaUrl(attachment)"
|
||||
:aria-label="attachment.name"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
<button
|
||||
v-else-if="attachmentKind(attachment) === 'video'"
|
||||
class="message-media-preview-trigger"
|
||||
type="button"
|
||||
:title="t('attachment.preview', { name: attachment.name })"
|
||||
:aria-label="t('attachment.preview', { name: attachment.name })"
|
||||
@click="openMediaPreview(attachment)"
|
||||
>
|
||||
<video
|
||||
<span class="message-media-shimmer" aria-hidden="true" />
|
||||
<button
|
||||
v-if="isImageAttachment(attachment) && (attachmentPreviewUrl(attachment) || attachmentMediaUrl(attachment))"
|
||||
class="message-media-preview-trigger"
|
||||
type="button"
|
||||
:title="t('attachment.preview', { name: attachment.name })"
|
||||
:aria-label="t('attachment.preview', { name: attachment.name })"
|
||||
@click="openMediaPreview(attachment)"
|
||||
>
|
||||
<img
|
||||
:src="attachmentPreviewUrl(attachment) || attachmentMediaUrl(attachment)"
|
||||
:alt="attachment.name"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@load="markMediaLoaded(attachment.id)"
|
||||
@error="markMediaLoaded(attachment.id)"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="attachmentKind(attachment) === 'video'"
|
||||
class="message-media-preview-trigger"
|
||||
type="button"
|
||||
:title="t('attachment.preview', { name: attachment.name })"
|
||||
:aria-label="t('attachment.preview', { name: attachment.name })"
|
||||
@click="openMediaPreview(attachment)"
|
||||
>
|
||||
<video
|
||||
:src="attachmentMediaUrl(attachment)"
|
||||
:aria-label="attachment.name"
|
||||
muted
|
||||
preload="metadata"
|
||||
playsinline
|
||||
@loadeddata="markMediaLoaded(attachment.id)"
|
||||
@error="markMediaLoaded(attachment.id)"
|
||||
/>
|
||||
</button>
|
||||
<span v-else class="message-media-fallback" aria-hidden="true">
|
||||
<FileTypeIcon :icon-key="attachmentFileCardKey(attachment)" />
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="message-audio-card">
|
||||
<button
|
||||
class="message-audio-card-info"
|
||||
type="button"
|
||||
:title="t('attachment.play', { name: attachment.name })"
|
||||
:aria-label="t('attachment.play', { name: attachment.name })"
|
||||
@click="playInlineAudio"
|
||||
>
|
||||
<FileTypeIcon icon-key="audio" class="message-audio-icon" />
|
||||
<span class="message-audio-copy">
|
||||
<strong :title="attachment.name">{{ attachment.name }}</strong>
|
||||
<small>{{ formatAttachmentSize(attachment) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
<audio
|
||||
:src="attachmentMediaUrl(attachment)"
|
||||
:aria-label="attachment.name"
|
||||
muted
|
||||
controls
|
||||
preload="metadata"
|
||||
playsinline
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<figcaption
|
||||
v-if="attachmentKind(attachment) !== 'file' && message.role !== 'agent'"
|
||||
v-if="attachmentKind(attachment) === 'image' && message.role !== 'agent'"
|
||||
:title="attachment.name"
|
||||
>
|
||||
{{ attachment.name }}
|
||||
@@ -504,15 +532,15 @@
|
||||
:aria-label="t('attachment.open', { name: attachment.name })"
|
||||
@click="openAttachment(attachment)"
|
||||
>
|
||||
<component
|
||||
:is="attachmentDocumentIcon(attachment)"
|
||||
<FileTypeIcon
|
||||
class="message-document-icon"
|
||||
:data-document-icon="attachmentDocumentIconName(attachment)"
|
||||
:icon-key="attachmentFileCardKey(attachment)"
|
||||
:data-document-icon="attachmentFileCardKey(attachment)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>{{ attachment.name }}</strong>
|
||||
<small>{{ attachmentTypeLabel(attachment) }} · {{ formatAttachmentSize(attachment) }}</small>
|
||||
<small>{{ formatAttachmentSize(attachment) }}</small>
|
||||
</span>
|
||||
<OpenOutline aria-hidden="true" />
|
||||
</button>
|
||||
@@ -749,6 +777,42 @@
|
||||
<TerminalOutline />
|
||||
</button>
|
||||
</header>
|
||||
<div
|
||||
v-if="activeMediaGeneration"
|
||||
class="media-generation-card"
|
||||
:class="[`is-${activeMediaGeneration.type}`, `is-${activeMediaGeneration.phase}`]"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span
|
||||
v-if="activeMediaGeneration.phase === 'running'"
|
||||
class="media-generation-shimmer"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
v-if="activeMediaGeneration.phase === 'running'"
|
||||
class="media-generation-star"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<StarOutline />
|
||||
</span>
|
||||
<FileTypeIcon
|
||||
v-else
|
||||
class="media-generation-icon"
|
||||
:icon-key="activeMediaGeneration.type"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="media-generation-copy">
|
||||
<strong>{{ mediaGenerationLabel(activeMediaGeneration) }}</strong>
|
||||
<small>{{ runStatusLabel(activeMediaGeneration.status) }}</small>
|
||||
</span>
|
||||
<CheckmarkCircleOutline
|
||||
v-if="activeMediaGeneration.phase === 'complete'"
|
||||
class="media-generation-check"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-else class="media-generation-spinner" aria-hidden="true" />
|
||||
</div>
|
||||
<div v-if="isDisplayedCoordinatedRun" class="run-agent-list" :aria-label="t('run.agents')">
|
||||
<button
|
||||
v-for="(kind, index) in displayedRunTargetKinds"
|
||||
@@ -788,14 +852,14 @@
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="activeRunProgress.length && !activeRunHasAgentRuns" class="execution-details run-progress-details">
|
||||
<div v-if="displayedRunProgress.length && !activeRunHasAgentRuns" class="execution-details run-progress-details">
|
||||
<div class="execution-progress-header">
|
||||
<TerminalOutline />
|
||||
<span>{{ t('run.progress') }}</span>
|
||||
<small>{{ activeRunProgress.length }}</small>
|
||||
<small>{{ displayedRunProgress.length }}</small>
|
||||
</div>
|
||||
<ol>
|
||||
<li v-for="(step, index) in activeRunProgress" :key="`${step.title}-${index}`">
|
||||
<li v-for="(step, index) in displayedRunProgress" :key="`${step.title}-${index}`">
|
||||
<span>{{ localizedStepTitle(step, index) }}</span>
|
||||
<small :class="runStatusTone(step.status)">{{ runStatusLabel(step.status) }}</small>
|
||||
</li>
|
||||
@@ -822,9 +886,13 @@
|
||||
v-if="mediaPreviewAttachment"
|
||||
:attachment="mediaPreviewAttachment"
|
||||
:close-label="t('attachment.closePreview')"
|
||||
:download-label="t('attachment.download', { name: mediaPreviewAttachment.name })"
|
||||
:zoom-in-label="t('attachment.zoomIn')"
|
||||
:zoom-out-label="t('attachment.zoomOut')"
|
||||
:source="attachmentMediaUrl(mediaPreviewAttachment)"
|
||||
:type="attachmentKind(mediaPreviewAttachment)"
|
||||
@close="closeMediaPreview"
|
||||
@save="saveAttachment(mediaPreviewAttachment)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -832,8 +900,6 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
ArrowDownOutline,
|
||||
AttachOutline,
|
||||
ArchiveOutline,
|
||||
CheckmarkCircleOutline,
|
||||
ChevronBackOutline,
|
||||
ChevronDownOutline,
|
||||
@@ -841,16 +907,11 @@ import {
|
||||
CloudOutline,
|
||||
CloseCircleOutline,
|
||||
CloseOutline,
|
||||
CodeOutline,
|
||||
CopyOutline,
|
||||
DocumentOutline,
|
||||
DocumentTextOutline,
|
||||
EaselOutline,
|
||||
GridOutline,
|
||||
LibraryOutline,
|
||||
OpenOutline,
|
||||
ReaderOutline,
|
||||
RefreshOutline,
|
||||
StarOutline,
|
||||
TerminalOutline,
|
||||
TrashOutline,
|
||||
WarningOutline,
|
||||
@@ -859,9 +920,11 @@ import { agentLabel, agentLogo, isCloudAgentKind } from '../catalog.js'
|
||||
import { skillKey } from '../composables/useComposerContext.js'
|
||||
import { orchestrationSlotCompleted } from '../desktop-normalization.js'
|
||||
import { locale } from '../i18n.js'
|
||||
import { fileCardIconKey } from '../mediaFileCard.js'
|
||||
import { messageKnowledgeBases, messageSkills, messageTargetKinds } from '../messageContext.js'
|
||||
import { isAgentFailureMessage, responseVersionRootId } from '../conversationTimelineModel.js'
|
||||
import AttachmentMediaPreview from './AttachmentMediaPreview.vue'
|
||||
import FileTypeIcon from './FileTypeIcon.vue'
|
||||
import MarkdownMessage from './MarkdownMessage.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -870,6 +933,7 @@ const props = defineProps({
|
||||
|
||||
const {
|
||||
activeGroup,
|
||||
activeMediaGeneration,
|
||||
activeRun,
|
||||
activeRunHasAgentRuns,
|
||||
activeRunProgress,
|
||||
@@ -877,7 +941,6 @@ const {
|
||||
attachmentKind,
|
||||
attachmentMediaUrl,
|
||||
attachmentPreviewUrl,
|
||||
attachmentTypeLabel,
|
||||
conversationEmptyVisible,
|
||||
copyMessageContent,
|
||||
deletingMessageId,
|
||||
@@ -945,6 +1008,7 @@ const {
|
||||
runRoundProgress,
|
||||
runStatusLabel,
|
||||
runStatusTone,
|
||||
saveAttachment,
|
||||
scrollToLatest,
|
||||
selectResponseVersion,
|
||||
showScrollToLatest,
|
||||
@@ -984,24 +1048,44 @@ const expandedUserMessageIds = ref(new Set())
|
||||
let userMessageResizeObserver = null
|
||||
let userMessageMeasurementQueued = false
|
||||
const mediaPreviewAttachment = ref(null)
|
||||
const DOCUMENT_ICON_COMPONENTS = {
|
||||
archive: ArchiveOutline,
|
||||
code: CodeOutline,
|
||||
document: DocumentOutline,
|
||||
pdf: DocumentTextOutline,
|
||||
presentation: EaselOutline,
|
||||
spreadsheet: GridOutline,
|
||||
text: ReaderOutline,
|
||||
const mediaLoadedIds = ref(new Set())
|
||||
const MEDIA_GENERATION_STEP_TITLE = /^(image|audio|video)[_-]generation$/i
|
||||
const MAX_MEDIA_LOADED_ENTRIES = 512
|
||||
|
||||
const displayedRunProgress = computed(() => activeRunProgress.value.filter(step => (
|
||||
!MEDIA_GENERATION_STEP_TITLE.test(String(step?.title || '').trim())
|
||||
)))
|
||||
|
||||
function attachmentFileCardKey(attachment) {
|
||||
return fileCardIconKey({
|
||||
name: attachment?.name,
|
||||
mimeType: attachment?.mimeType,
|
||||
kind: attachmentKind(attachment),
|
||||
})
|
||||
}
|
||||
const DOCUMENT_ICON_BY_EXTENSION = {
|
||||
'7z': 'archive', gz: 'archive', tar: 'archive', tgz: 'archive', zip: 'archive',
|
||||
c: 'code', cc: 'code', cjs: 'code', cpp: 'code', css: 'code', go: 'code', h: 'code',
|
||||
hpp: 'code', html: 'code', java: 'code', js: 'code', json: 'code', md: 'code', mjs: 'code',
|
||||
py: 'code', rs: 'code', sh: 'code', ts: 'code', tsx: 'code', vue: 'code', xml: 'code', yaml: 'code', yml: 'code',
|
||||
csv: 'spreadsheet', ods: 'spreadsheet', xls: 'spreadsheet', xlsx: 'spreadsheet',
|
||||
doc: 'text', docx: 'text', odt: 'text', rtf: 'text', txt: 'text',
|
||||
key: 'presentation', odp: 'presentation', ppt: 'presentation', pptx: 'presentation',
|
||||
pdf: 'pdf',
|
||||
|
||||
function isMediaLoaded(id) {
|
||||
return mediaLoadedIds.value.has(String(id || ''))
|
||||
}
|
||||
|
||||
function markMediaLoaded(id) {
|
||||
const key = String(id || '')
|
||||
if (!key || mediaLoadedIds.value.has(key)) return
|
||||
const next = new Set(mediaLoadedIds.value)
|
||||
next.add(key)
|
||||
mediaLoadedIds.value = next.size > MAX_MEDIA_LOADED_ENTRIES ? new Set() : next
|
||||
}
|
||||
|
||||
// Start-loading / load-complete labels only. Provider summaries are never
|
||||
// rendered, so no progress number can surface.
|
||||
function mediaGenerationLabel(activity) {
|
||||
if (activity?.phase === 'complete') return t('run.mediaGenerationComplete')
|
||||
const key = {
|
||||
image: 'run.mediaGeneratingImage',
|
||||
audio: 'run.mediaGeneratingAudio',
|
||||
video: 'run.mediaGeneratingVideo',
|
||||
}[String(activity?.type || '')]
|
||||
return key ? t(key) : t('run.mediaGenerating')
|
||||
}
|
||||
|
||||
function sameIds(left, right) {
|
||||
@@ -1081,6 +1165,12 @@ function openMediaPreview(attachment) {
|
||||
mediaPreviewAttachment.value = attachment
|
||||
}
|
||||
|
||||
function playInlineAudio(event) {
|
||||
const audio = event.currentTarget?.closest('.message-audio-card')?.querySelector('audio')
|
||||
if (!audio) return
|
||||
void audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
function closeMediaPreview() {
|
||||
mediaPreviewAttachment.value = null
|
||||
}
|
||||
@@ -1096,15 +1186,6 @@ function openAgentMessageTrace(message, event) {
|
||||
openTraceForMessage(message, event?.currentTarget || null)
|
||||
}
|
||||
|
||||
function attachmentDocumentIconName(attachment) {
|
||||
const extension = String(attachment?.name || '').split('.').pop()?.toLowerCase() || ''
|
||||
return DOCUMENT_ICON_BY_EXTENSION[extension] || 'document'
|
||||
}
|
||||
|
||||
function attachmentDocumentIcon(attachment) {
|
||||
return DOCUMENT_ICON_COMPONENTS[attachmentDocumentIconName(attachment)] || DocumentOutline
|
||||
}
|
||||
|
||||
function mediaMessageAttachments(message) {
|
||||
return messageAttachments(message).filter(attachment => attachmentKind(attachment) !== 'file')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<svg
|
||||
class="file-type-icon"
|
||||
:class="`is-${resolvedKey}`"
|
||||
:viewBox="art.viewBox"
|
||||
:style="{ color }"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path v-for="(segment, index) in art.paths" :key="index" :d="segment" fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { fileCardIconArt, fileCardIconColor } from '../mediaFileCard.js'
|
||||
|
||||
const props = defineProps({
|
||||
iconKey: { type: String, default: 'default' },
|
||||
color: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const resolvedKey = computed(() => props.iconKey || 'default')
|
||||
const art = computed(() => fileCardIconArt(resolvedKey.value))
|
||||
const color = computed(() => props.color || fileCardIconColor(resolvedKey.value))
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.file-type-icon {
|
||||
display: block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { errorCode } from '../desktop.js'
|
||||
import { formatFileCardSize } from '../mediaFileCard.js'
|
||||
import { useAttachmentPreviews } from './useAttachmentPreviews.js'
|
||||
|
||||
export const MAX_ATTACHMENTS = 32
|
||||
@@ -177,11 +178,21 @@ export function useComposerAttachments({
|
||||
return String(attachment?.mimeType || 'FILE').split('/').pop()?.toUpperCase() || 'FILE'
|
||||
}
|
||||
|
||||
async function saveAttachment(attachment) {
|
||||
const save = attachmentsApi.value?.save
|
||||
const id = String(attachment?.id || '')
|
||||
if (typeof save !== 'function' || !id) {
|
||||
notify(t('composer.attachmentsUnavailable'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await save(id)
|
||||
if (result?.saved) notify(t('attachment.downloadSaved', { name: attachment?.name || '' }))
|
||||
} catch (error) { showError(error) }
|
||||
}
|
||||
|
||||
function formatAttachmentSize(attachment) {
|
||||
const size = Math.max(0, Number(attachment?.size) || 0)
|
||||
if (size < 1024) return `${size} B`
|
||||
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`
|
||||
return `${(size / (1024 * 1024)).toFixed(size < 10 * 1024 * 1024 ? 1 : 0)} MB`
|
||||
return formatFileCardSize(attachment?.size)
|
||||
}
|
||||
|
||||
async function openAttachment(attachment) {
|
||||
@@ -457,6 +468,7 @@ export function useComposerAttachments({
|
||||
pickAttachments,
|
||||
removeAttachment,
|
||||
safeAttachmentPayload,
|
||||
saveAttachment,
|
||||
vAttachmentPreview,
|
||||
}
|
||||
}
|
||||
@@ -62,20 +62,17 @@ export function useConversationExecution({
|
||||
const text = draft.value.trim()
|
||||
const attachments = composerAttachments.value.map(safeAttachmentPayload)
|
||||
const mode = composerMode.value
|
||||
const backendMode = mode === 'auto-beta' ? 'auto' : mode
|
||||
if (!text && !attachments.length) {
|
||||
notify(t('composer.messageRequired'))
|
||||
return
|
||||
}
|
||||
if (mode === 'auto-beta') {
|
||||
notify(`${t('composer.autoBetaLockTitle')} — ${t('composer.autoBetaLockBody')}`)
|
||||
return
|
||||
}
|
||||
const targets = [...composerTargetKinds.value]
|
||||
if (!targets.length) {
|
||||
notify(t('composer.selectTarget'))
|
||||
return
|
||||
}
|
||||
if (mode === 'auto' && targets.length < 2) {
|
||||
if (backendMode === 'auto' && targets.length < 2) {
|
||||
notify(t('error.autoAgentCount'))
|
||||
return
|
||||
}
|
||||
@@ -91,7 +88,7 @@ export function useConversationExecution({
|
||||
const previousComposerContext = captureComposerContext()
|
||||
const previousAttachments = composerAttachments.value.map(attachment => ({ ...attachment }))
|
||||
const v4GroupRun = group.conversationType !== 'direct'
|
||||
&& (mode === 'manual' || mode === 'auto')
|
||||
&& (backendMode === 'manual' || backendMode === 'auto')
|
||||
&& (targets.length > 1 || automaticTeamFormation.value)
|
||||
clearComposerContext()
|
||||
composerAttachments.value = []
|
||||
@@ -107,10 +104,11 @@ export function useConversationExecution({
|
||||
skillHints,
|
||||
knowledgeBaseHints,
|
||||
attachments,
|
||||
mode,
|
||||
mode: backendMode,
|
||||
...(mode === 'auto-beta' ? { discussionStyle: 'agent-led' } : {}),
|
||||
...(v4GroupRun ? { protocol: 'v4' } : {}),
|
||||
maxRounds: maxRounds.value,
|
||||
...(mode === 'auto' && unlimitedRounds.value ? { unlimitedRounds: true } : {}),
|
||||
...(backendMode === 'auto' && unlimitedRounds.value ? { unlimitedRounds: true } : {}),
|
||||
})
|
||||
snapshot.value = normalizeSnapshot(await workspace.value.get())
|
||||
} catch (error) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
traceRound,
|
||||
} from '../conversationTimelineModel.js'
|
||||
import { orchestrationPhase, orchestrationSlotCompleted } from '../desktop-normalization.js'
|
||||
import { activeMediaGenerationForRun } from '../mediaFileCard.js'
|
||||
import { messageScopedTargetKinds } from '../messageContext.js'
|
||||
import { useConversationTimelineLabels } from './useConversationTimelineLabels.js'
|
||||
import { useConversationTimelineUiState } from './useConversationTimelineUiState.js'
|
||||
@@ -95,6 +96,7 @@ export function useConversationTimeline({
|
||||
))
|
||||
const activeRunHasAgentRuns = computed(() => activeRunAgentRuns.value.length > 0)
|
||||
const activeRunProgress = computed(() => Array.isArray(activeRun.value?.progress) ? activeRun.value.progress.slice(0, 8) : [])
|
||||
const activeMediaGeneration = computed(() => activeMediaGenerationForRun(activeRunAgentRuns.value))
|
||||
const liveOutputSignature = computed(() => activeRunAgentRuns.value.map(agent => (
|
||||
`${agent.agentRunId}:${String(agent.output || '').length}:${agent.events?.at(-1)?.seq || 0}`
|
||||
)).join('\u0000'))
|
||||
@@ -610,6 +612,7 @@ export function useConversationTimeline({
|
||||
|
||||
return {
|
||||
activeMessages,
|
||||
activeMediaGeneration,
|
||||
activeRun,
|
||||
activeRunAgentKind,
|
||||
activeRunAgentRuns,
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createConversationControllers({
|
||||
}) {
|
||||
const timelineController = {
|
||||
activeGroup: app.activeGroup,
|
||||
activeMediaGeneration: timeline.activeMediaGeneration,
|
||||
activeRun: app.activeRun,
|
||||
activeRunHasAgentRuns: timeline.activeRunHasAgentRuns,
|
||||
activeRunProgress: timeline.activeRunProgress,
|
||||
@@ -18,7 +19,6 @@ export function createConversationControllers({
|
||||
attachmentKind: attachments.attachmentKind,
|
||||
attachmentMediaUrl: attachments.attachmentMediaUrl,
|
||||
attachmentPreviewUrl: attachments.attachmentPreviewUrl,
|
||||
attachmentTypeLabel: attachments.attachmentTypeLabel,
|
||||
conversationEmptyVisible: timeline.conversationEmptyVisible,
|
||||
copyMessageContent: messageActions.copyMessageContent,
|
||||
deletingMessageId: messageActions.deletingMessageId,
|
||||
@@ -86,6 +86,7 @@ export function createConversationControllers({
|
||||
runRoundProgress: timeline.runRoundProgress,
|
||||
runStatusLabel: timeline.runStatusLabel,
|
||||
runStatusTone: timeline.runStatusTone,
|
||||
saveAttachment: attachments.saveAttachment,
|
||||
scrollToLatest: app.scrollToLatest,
|
||||
selectResponseVersion: timeline.selectResponseVersion,
|
||||
showScrollToLatest: app.showScrollToLatest,
|
||||
@@ -140,7 +141,6 @@ export function createConversationControllers({
|
||||
attachmentActionLabel: attachments.attachmentActionLabel,
|
||||
attachmentKind: attachments.attachmentKind,
|
||||
attachmentMediaUrl: attachments.attachmentMediaUrl,
|
||||
attachmentTypeLabel: attachments.attachmentTypeLabel,
|
||||
canSendMessage: app.canSendMessage,
|
||||
composerAttachmentSupported: attachments.composerAttachmentSupported,
|
||||
composerAttachments: attachments.composerAttachments,
|
||||
@@ -157,6 +157,7 @@ export function createConversationControllers({
|
||||
composerMenuOptionTitle: composerContext.composerMenuOptionTitle,
|
||||
discussionMode: composerContext.discussionMode,
|
||||
draft: composerContext.draft,
|
||||
formatAttachmentSize: attachments.formatAttachmentSize,
|
||||
groupName: app.groupName,
|
||||
handleComposerInput: composerContext.handleComposerInput,
|
||||
handleComposerKeydown: composerContext.handleComposerKeydown,
|
||||
|
||||
@@ -40,7 +40,7 @@ const EVENT_STATUSES = new Set([
|
||||
])
|
||||
const ORCHESTRATION_PHASES = new Set([
|
||||
'prepare', 'dispatch', 'running', 'reconcile',
|
||||
'proposal', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
|
||||
'proposal', 'discussion', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
|
||||
'commit', 'committed', 'failed', 'stopped', 'human-gate',
|
||||
])
|
||||
const TRACE_PHASES = new Set([
|
||||
|
||||
@@ -375,6 +375,7 @@ export default {
|
||||
'trace.eventConnectorLimited': 'Connector provided only the final answer; structured tool activity was unavailable.',
|
||||
'trace.eventOther': 'Event',
|
||||
'run.phase.proposal': 'Parallel proposals',
|
||||
'run.phase.discussion': 'Discussion',
|
||||
'run.phase.prepare': 'Preparing orchestration',
|
||||
'run.phase.dispatch': 'Dispatching Agents',
|
||||
'run.phase.running': 'Running',
|
||||
@@ -401,8 +402,13 @@ export default {
|
||||
'run.role.participant': 'Participant',
|
||||
'run.phaseSlots': '{completed}/{total} Agents',
|
||||
'run.phaseFeedback': 'Current phase',
|
||||
'run.mediaGeneratingImage': 'Generating image',
|
||||
'run.mediaGeneratingAudio': 'Generating audio',
|
||||
'run.mediaGeneratingVideo': 'Generating video',
|
||||
'run.mediaGenerating': 'Generating media',
|
||||
'run.mediaGenerationComplete': 'Generation complete',
|
||||
'composer.placeholder': 'Message {name}',
|
||||
'composer.unlimitedPlaceholder': 'Message {name} with strict peer review enabled',
|
||||
'composer.unlimitedPlaceholder': 'Message {name}',
|
||||
'composer.targets': 'Replying Agents',
|
||||
'composer.send': 'Send',
|
||||
'composer.stop': 'Stop',
|
||||
@@ -415,8 +421,6 @@ export default {
|
||||
'composer.autoBeta': 'Auto discussion',
|
||||
'composer.autoBetaHint': 'Beta: Agents @ each other into the discussion and the model orchestrates the rounds autonomously.',
|
||||
'composer.autoBetaBadge': 'Beta',
|
||||
'composer.autoBetaLockTitle': 'Auto discussion is a Beta feature',
|
||||
'composer.autoBetaLockBody': 'True auto discussion — Agents autonomously @ each other to collaborate — is coming soon.',
|
||||
'composer.smartTeam': 'Smart team',
|
||||
'composer.smartTeamOn': 'On',
|
||||
'composer.smartTeamOff': 'Off',
|
||||
@@ -429,9 +433,9 @@ export default {
|
||||
'composer.autoRounds': '{count} rounds',
|
||||
'composer.maxRounds': 'Maximum rounds',
|
||||
'composer.unlimitedRounds': 'No round limit',
|
||||
'composer.unlimitedHint': 'Runs until consensus or manual stop. Agents must independently verify claims, challenge unsupported suggestions, and report material defects during acceptance. Agent timeouts and a mandatory circuit breaker still apply.',
|
||||
'composer.unlimitedHint': 'The discussion continues until collaboration ends or you stop it. Agents can discuss, divide work, collaborate, and review material gaps once concrete results exist. Agent timeouts and the mandatory circuit breaker still apply.',
|
||||
'composer.unlimitedConfirmTitle': 'Continue without a round limit?',
|
||||
'composer.unlimitedConfirm': 'The discussion can run until consensus or manual stop and consume more Provider quota. Unlimited mode also applies strict peer review: Agents must independently verify claims, challenge unsupported suggestions, and report material defects during acceptance. Agent timeouts and the mandatory circuit breaker still apply. Continue?',
|
||||
'composer.unlimitedConfirm': 'The discussion can continue until collaboration ends or you stop it and may consume more Provider quota. Agents can discuss, divide work, collaborate, and review material gaps once concrete results exist. Agent timeouts and the mandatory circuit breaker still apply. Continue?',
|
||||
'composer.useBoundedRounds': 'Use bounded rounds',
|
||||
'composer.addTarget': 'Include {agent}',
|
||||
'composer.removeTarget': 'Exclude {agent}',
|
||||
@@ -473,7 +477,12 @@ export default {
|
||||
'composer.pastedAttachment': 'Pasted attachment',
|
||||
'attachment.open': 'Open {name}',
|
||||
'attachment.preview': 'Preview {name}',
|
||||
'attachment.play': 'Play {name}',
|
||||
'attachment.closePreview': 'Close preview',
|
||||
'attachment.download': 'Download {name}',
|
||||
'attachment.downloadSaved': 'Saved {name}',
|
||||
'attachment.zoomIn': 'Zoom in',
|
||||
'attachment.zoomOut': 'Zoom out',
|
||||
'group.newTitle': 'New Agent group',
|
||||
'group.name': 'Name',
|
||||
'group.namePlaceholder': 'Design review',
|
||||
|
||||
@@ -375,6 +375,7 @@ export default {
|
||||
'trace.eventConnectorLimited': '连接器只提供最终答案,未暴露结构化工具过程。',
|
||||
'trace.eventOther': '事件',
|
||||
'run.phase.proposal': '并行提案',
|
||||
'run.phase.discussion': '讨论',
|
||||
'run.phase.prepare': '准备编排',
|
||||
'run.phase.dispatch': '分发 Agent',
|
||||
'run.phase.running': '运行中',
|
||||
@@ -401,8 +402,13 @@ export default {
|
||||
'run.role.participant': '参与者',
|
||||
'run.phaseSlots': '{completed}/{total} 个 Agent',
|
||||
'run.phaseFeedback': '当前阶段',
|
||||
'run.mediaGeneratingImage': '正在生成图片',
|
||||
'run.mediaGeneratingAudio': '正在生成音频',
|
||||
'run.mediaGeneratingVideo': '正在生成视频',
|
||||
'run.mediaGenerating': '正在生成媒体文件',
|
||||
'run.mediaGenerationComplete': '生成完成',
|
||||
'composer.placeholder': '发消息给 {name}',
|
||||
'composer.unlimitedPlaceholder': '发消息给 {name}(已启用严格互审)',
|
||||
'composer.unlimitedPlaceholder': '发消息给 {name}',
|
||||
'composer.targets': '回复 Agent',
|
||||
'composer.send': '发送',
|
||||
'composer.stop': '停止',
|
||||
@@ -415,8 +421,6 @@ export default {
|
||||
'composer.autoBeta': '自动讨论',
|
||||
'composer.autoBetaHint': 'Beta:Agent 可相互 @ 拉入讨论,由模型自主编排各轮协作。',
|
||||
'composer.autoBetaBadge': 'Beta',
|
||||
'composer.autoBetaLockTitle': '自动讨论为 Beta 功能',
|
||||
'composer.autoBetaLockBody': 'Agent 自主 @ 协作的真正自动讨论即将上线,敬请期待。',
|
||||
'composer.smartTeam': '智能组队',
|
||||
'composer.smartTeamOn': '开启',
|
||||
'composer.smartTeamOff': '关闭',
|
||||
@@ -429,9 +433,9 @@ export default {
|
||||
'composer.autoRounds': '{count} 轮',
|
||||
'composer.maxRounds': '最大轮数',
|
||||
'composer.unlimitedRounds': '不限轮数',
|
||||
'composer.unlimitedHint': '群聊会持续到达成共识或手动停止。各 Agent 必须独立核验信息、质疑缺乏依据的建议,并在验收阶段当场指出重大问题;Agent 超时与不可关闭的熔断器仍然生效。',
|
||||
'composer.unlimitedHint': '讨论会持续到协作自然结束或你手动停止。Agent 可以讨论、分工和协作,在形成具体结果后再检查重要缺口;Agent 超时与不可关闭的熔断器仍然生效。',
|
||||
'composer.unlimitedConfirmTitle': '继续使用不限制轮数吗?',
|
||||
'composer.unlimitedConfirm': '讨论会持续到达成共识或手动停止,并可能消耗更多 Provider 额度。不限轮数还会启用严格互审:各 Agent 必须独立核验信息、质疑缺乏依据的建议,并在验收阶段当场指出重大问题;Agent 超时与不可关闭的熔断器仍然生效。是否继续?',
|
||||
'composer.unlimitedConfirm': '讨论会持续到协作自然结束或你手动停止,并可能消耗更多 Provider 额度。Agent 可以讨论、分工和协作,在形成具体结果后再检查重要缺口;Agent 超时与不可关闭的熔断器仍然生效。是否继续?',
|
||||
'composer.useBoundedRounds': '改回固定轮数',
|
||||
'composer.addTarget': '加入 {agent}',
|
||||
'composer.removeTarget': '移除 {agent}',
|
||||
@@ -473,7 +477,12 @@ export default {
|
||||
'composer.pastedAttachment': '粘贴的附件',
|
||||
'attachment.open': '打开 {name}',
|
||||
'attachment.preview': '预览 {name}',
|
||||
'attachment.play': '播放 {name}',
|
||||
'attachment.closePreview': '关闭预览',
|
||||
'attachment.download': '下载 {name}',
|
||||
'attachment.downloadSaved': '已保存 {name}',
|
||||
'attachment.zoomIn': '放大',
|
||||
'attachment.zoomOut': '缩小',
|
||||
'group.newTitle': '新建 Agent 群聊',
|
||||
'group.name': '名称',
|
||||
'group.namePlaceholder': '方案评审',
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// FileCard-style attachment presentation model, ported from Ant Design X
|
||||
// (https://x.ant.design/components/file-card). Meldwork's renderer is Vue, so
|
||||
// the component library design is ported rather than installed: preset file
|
||||
// icons, extension grouping, and the generation-loading semantics all mirror
|
||||
// the upstream FileCard behavior.
|
||||
|
||||
export const FILE_CARD_IMAGE_EXT = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'jfif']
|
||||
export const FILE_CARD_AUDIO_EXT = ['mp3', 'wav', 'flac', 'ape', 'aac', 'ogg', 'm4a']
|
||||
export const FILE_CARD_VIDEO_EXT = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm']
|
||||
|
||||
// SVG path data ported from @ant-design/icons (filled file glyphs) and
|
||||
// Ant Design X file-card icons (audio/video glyphs).
|
||||
const FILE_CARD_ICON_ART = Object.freeze({
|
||||
excel: {
|
||||
viewBox: '0 0 1024 1024',
|
||||
paths: [
|
||||
'M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM575.34 477.84l-61.22 102.3L452.3 477.8a12 12 0 0 0-10.27-5.79h-38.44a12 12 0 0 0-6.4 1.85 12 12 0 0 0-3.75 16.56l82.34 130.42-83.45 132.78a12 12 0 0 0-1.84 6.39 12 12 0 0 0 12 12h34.46a12 12 0 0 0 10.21-5.7l62.7-101.47 62.3 101.45a12 12 0 0 0 10.23 5.72h37.48a12 12 0 0 0 6.48-1.9 12 12 0 0 0 3.62-16.58l-83.83-130.55 85.3-132.47a12 12 0 0 0 1.9-6.5 12 12 0 0 0-12-12h-35.7a12 12 0 0 0-10.29 5.84z',
|
||||
],
|
||||
},
|
||||
image: {
|
||||
viewBox: '0 0 1024 1024',
|
||||
paths: [
|
||||
'M854.6 288.7L639.4 73.4c-6-6-14.2-9.4-22.7-9.4H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V311.3c0-8.5-3.4-16.6-9.4-22.6zM400 402c22.1 0 40 17.9 40 40s-17.9 40-40 40-40-17.9-40-40 17.9-40 40-40zm296 294H328c-6.7 0-10.4-7.7-6.3-12.9l99.8-127.2a8 8 0 0 1 12.6 0l41.1 52.4 77.8-99.2a8 8 0 0 1 12.6 0l136.5 174c4.3 5.2.5 12.9-6.1 12.9zm-94-370V137.8L790.2 326H602z',
|
||||
],
|
||||
},
|
||||
markdown: {
|
||||
viewBox: '0 0 1024 1024',
|
||||
paths: [
|
||||
'M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM426.13 600.93l59.11 132.97a16 16 0 0 0 14.62 9.5h24.06a16 16 0 0 0 14.63-9.51l59.1-133.35V758a16 16 0 0 0 16.01 16H641a16 16 0 0 0 16-16V486a16 16 0 0 0-16-16h-34.75a16 16 0 0 0-14.67 9.62L512.1 662.2l-79.48-182.59a16 16 0 0 0-14.67-9.61H383a16 16 0 0 0-16 16v272a16 16 0 0 0 16 16h27.13a16 16 0 0 0 16-16V600.93z',
|
||||
],
|
||||
},
|
||||
pdf: {
|
||||
viewBox: '0 0 1024 1024',
|
||||
paths: [
|
||||
'M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM633.22 637.26c-15.18-.5-31.32.67-49.65 2.96-24.3-14.99-40.66-35.58-52.28-65.83l1.07-4.38 1.24-5.18c4.3-18.13 6.61-31.36 7.3-44.7.52-10.07-.04-19.36-1.83-27.97-3.3-18.59-16.45-29.46-33.02-30.13-15.45-.63-29.65 8-33.28 21.37-5.91 21.62-2.45 50.07 10.08 98.59-15.96 38.05-37.05 82.66-51.2 107.54-18.89 9.74-33.6 18.6-45.96 28.42-16.3 12.97-26.48 26.3-29.28 40.3-1.36 6.49.69 14.97 5.36 21.92 5.3 7.88 13.28 13 22.85 13.74 24.15 1.87 53.83-23.03 86.6-79.26 3.29-1.1 6.77-2.26 11.02-3.7l11.9-4.02c7.53-2.54 12.99-4.36 18.39-6.11 23.4-7.62 41.1-12.43 57.2-15.17 27.98 14.98 60.32 24.8 82.1 24.8 17.98 0 30.13-9.32 34.52-23.99 3.85-12.88.8-27.82-7.48-36.08-8.56-8.41-24.3-12.43-45.65-13.12zM385.23 765.68v-.36l.13-.34a54.86 54.86 0 0 1 5.6-10.76c4.28-6.58 10.17-13.5 17.47-20.87 3.92-3.95 8-7.8 12.79-12.12 1.07-.96 7.91-7.05 9.19-8.25l11.17-10.4-8.12 12.93c-12.32 19.64-23.46 33.78-33 43-3.51 3.4-6.6 5.9-9.1 7.51a16.43 16.43 0 0 1-2.61 1.42c-.41.17-.77.27-1.13.3a2.2 2.2 0 0 1-1.12-.15 2.07 2.07 0 0 1-1.27-1.91zM511.17 547.4l-2.26 4-1.4-4.38c-3.1-9.83-5.38-24.64-6.01-38-.72-15.2.49-24.32 5.29-24.32 6.74 0 9.83 10.8 10.07 27.05.22 14.28-2.03 29.14-5.7 35.65zm-5.81 58.46l1.53-4.05 2.09 3.8c11.69 21.24 26.86 38.96 43.54 51.31l3.6 2.66-4.39.9c-16.33 3.38-31.54 8.46-52.34 16.85 2.17-.88-21.62 8.86-27.64 11.17l-5.25 2.01 2.8-4.88c12.35-21.5 23.76-47.32 36.05-79.77zm157.62 76.26c-7.86 3.1-24.78.33-54.57-12.39l-7.56-3.22 8.2-.6c23.3-1.73 39.8-.45 49.42 3.07 4.1 1.5 6.83 3.39 8.04 5.55a4.64 4.64 0 0 1-1.36 6.31 6.7 6.7 0 0 1-2.17 1.28z',
|
||||
],
|
||||
},
|
||||
ppt: {
|
||||
viewBox: '0 0 1024 1024',
|
||||
paths: [
|
||||
'M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM468.53 760v-91.54h59.27c60.57 0 100.2-39.65 100.2-98.12 0-58.22-39.58-98.34-99.98-98.34H424a12 12 0 0 0-12 12v276a12 12 0 0 0 12 12h32.53a12 12 0 0 0 12-12zm0-139.33h34.9c47.82 0 67.19-12.93 67.19-50.33 0-32.05-18.12-50.12-49.87-50.12h-52.22v100.45z',
|
||||
],
|
||||
},
|
||||
word: {
|
||||
viewBox: '0 0 1024 1024',
|
||||
paths: [
|
||||
'M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM512 566.1l52.81 197a12 12 0 0 0 11.6 8.9h31.77a12 12 0 0 0 11.6-8.88l74.37-276a12 12 0 0 0 .4-3.12 12 12 0 0 0-12-12h-35.57a12 12 0 0 0-11.7 9.31l-45.78 199.1-49.76-199.32A12 12 0 0 0 528.1 472h-32.2a12 12 0 0 0-11.64 9.1L434.6 680.01 388.5 481.3a12 12 0 0 0-11.68-9.29h-35.39a12 12 0 0 0-3.11.41 12 12 0 0 0-8.47 14.7l74.17 276A12 12 0 0 0 415.6 772h31.99a12 12 0 0 0 11.59-8.9l52.81-197z',
|
||||
],
|
||||
},
|
||||
zip: {
|
||||
viewBox: '0 0 1024 1024',
|
||||
paths: [
|
||||
'M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM296 136v64h64v-64h-64zm64 64v64h64v-64h-64zm-64 64v64h64v-64h-64zm64 64v64h64v-64h-64zm-64 64v64h64v-64h-64zm64 64v64h64v-64h-64zm-64 64v64h64v-64h-64zm0 64v160h128V584H296zm48 48h32v64h-32v-64z',
|
||||
],
|
||||
},
|
||||
default: {
|
||||
viewBox: '0 0 1024 1024',
|
||||
paths: [
|
||||
'M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM320 482a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h384a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8H320zm0 136a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h184a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8H320z',
|
||||
],
|
||||
},
|
||||
java: {
|
||||
viewBox: '0 0 1024 1024',
|
||||
paths: [
|
||||
'M212.68 692.99s-34.325 19.95 24.343 26.6c71.1 8.05 107.351 7 185.632-7.874 0 0 20.665 12.949 49.385 24.149-175.475 75.074-397.184-4.375-259.36-42.875m-21.366-98.173s-38.352 28.35 20.315 34.475c75.83 7.875 135.897 8.4 239.571-11.55 0 0 14.36 14.525 36.952 22.4-212.427 62.123-448.846 5.075-296.838-45.325m180.73-166.422c43.256 49.699-11.384 94.498-11.384 94.498s109.804-56.7 59.368-127.573c-47.11-66.15-83.185-99.049 112.255-212.273.175 0-306.819 76.65-160.24 245.348M604.26 765.439s25.393 20.825-27.846 37.1c-101.397 30.625-421.7 39.9-510.664 1.225-32.048-13.825 28.02-33.25 46.934-37.275 19.613-4.2 30.997-3.5 30.997-3.5-35.551-25.025-229.94 49.175-98.771 70.35 357.605 58.1 652.165-26.075 559.35-67.9M229.142 493.144S66.1 531.818 171.35 545.818c44.482 5.95 133.095 4.55 215.58-2.275 67.423-5.6 135.196-17.85 135.196-17.85s-23.818 10.15-40.98 21.875C315.653 591.143-3.95 570.843 87.99 526.393c77.93-37.45 141.151-33.25 141.151-33.25M521.6 656.416c168.296-87.324 90.365-171.322 36.077-159.948-13.31 2.8-19.264 5.25-19.264 5.25s4.903-7.7 14.36-11.025c107.351-37.8 190.01 111.299-34.675 170.273 0-.175 2.627-2.45 3.502-4.55M420.028 0s93.166 93.1-88.438 236.246c-145.53 114.8-33.274 180.424 0 255.148-84.936-76.65-147.28-144.024-105.425-206.848C287.634 192.672 457.68 147.873 420.028 0m-174.25 893.188c161.466 10.325 409.443-5.775 415.222-82.075 0 0-11.208 28.875-133.445 51.975-137.824 25.9-307.87 22.925-408.567 6.3 0-.175 20.665 16.975 126.79 23.8',
|
||||
],
|
||||
},
|
||||
javascript: {
|
||||
viewBox: '0 0 1024 1024',
|
||||
paths: [
|
||||
'M353 16H192.545v425.615c0 105.313-36.166 134.718-99.104 134.718-29.494 0-56.055-5.05-76.718-12.144L0 688.784C29.484 698.924 74.73 705 110.126 705 254.631 705 353 637.16 353 442.658zM702.49 0C547.26 0 449 88.126 449 204.609c0 100.313 75.67 163.12 185.696 203.629 79.577 28.358 111.03 53.695 111.03 95.218 0 45.579-36.358 74.96-105.13 74.96-63.868 0-121.849-21.311-161.146-42.573v-.042L449 662.427C486.361 683.735 556.12 705 631.741 705 813.52 705 898 607.753 898 493.293c0-97.243-54.036-160.035-170.937-204.627-86.47-34.432-122.813-53.669-122.813-97.227 0-34.45 31.446-65.834 96.3-65.834 63.834 0 107.728 21.445 133.307 34.632L872.193 32.05C832.103 14.461 778.109 0 702.49 0',
|
||||
],
|
||||
},
|
||||
python: {
|
||||
viewBox: '0 0 1024 1024',
|
||||
paths: [
|
||||
'M443 678.5c0 15.74 12.76 28.5 28.5 28.5s28.5-12.76 28.5-28.5-12.76-28.5-28.5-28.5-28.5 12.76-28.5 28.5M300 121.5c0 15.74 12.76 28.5 28.5 28.5s28.5-12.76 28.5-28.5S344.24 93 328.5 93 300 105.76 300 121.5',
|
||||
'M709.524 185.714h-95.238V90.476C614.286 40.571 573.714 0 523.81 0H276.19c-49.904 0-90.476 40.571-90.476 90.476v95.238H90.476C40.571 185.714 0 226.286 0 276.19v247.62c0 49.904 40.571 90.476 90.476 90.476h95.238v95.238c0 49.905 40.572 90.476 90.476 90.476h247.62c49.904 0 90.476-40.571 90.476-90.476v-95.238h95.238c49.905 0 90.476-40.572 90.476-90.476V276.19c0-49.904-40.571-90.476-90.476-90.476M90.476 557.143c-18.38 0-33.333-14.953-33.333-33.333V276.19c0-18.38 14.952-33.333 33.333-33.333h278.572c15.81 0 28.571-12.762 28.571-28.571 0-15.81-12.762-28.572-28.571-28.572h-126.19V90.476c0-18.38 14.952-33.333 33.332-33.333h247.62c18.38 0 33.333 14.952 33.333 33.333v256.476c0 13.524-10.953 24.477-24.476 24.477H267.333c-45.047 0-81.619 36.666-81.619 81.619v104.095zm652.381-33.333c0 18.38-14.952 33.333-33.333 33.333H430.952c-15.81 0-28.571 12.762-28.571 28.571 0 15.81 12.762 28.572 28.571 28.572h126.19v95.238c0 18.38-14.952 33.333-33.332 33.333H276.19c-18.38 0-33.333-14.952-33.333-33.333V453.048c0-13.524 10.953-24.477 24.476-24.477h265.334c45.047 0 81.619-36.666 81.619-81.619V242.857h95.238c18.38 0 33.333 14.953 33.333 33.333z',
|
||||
],
|
||||
},
|
||||
audio: {
|
||||
viewBox: '0 0 16 16',
|
||||
paths: [
|
||||
'M14.1178571,4.0125 C14.225,4.11964286 14.2857143,4.26428571 14.2857143,4.41607143 L14.2857143,15.4285714 C14.2857143,15.7446429 14.0303571,16 13.7142857,16 L2.28571429,16 C1.96964286,16 1.71428571,15.7446429 1.71428571,15.4285714 L1.71428571,0.571428571 C1.71428571,0.255357143 1.96964286,0 2.28571429,0 L9.86964286,0 C10.0214286,0 10.1678571,0.0607142857 10.275,0.167857143 L14.1178571,4.0125 Z M10.7315824,7.11216117 C10.7428131,7.15148751 10.7485063,7.19218979 10.7485063,7.23309113 L10.7485063,8.07742614 C10.7484199,8.27364959 10.6183424,8.44607275 10.4296853,8.50003683 L8.32984514,9.09986306 L8.32984514,11.7071803 C8.32986605,12.5367078 7.67249692,13.217028 6.84345686,13.2454634 L6.79068592,13.2463395 C6.12766108,13.2463395 5.53916361,12.8217001 5.33010655,12.1924966 C5.1210495,11.563293 5.33842118,10.8709227 5.86959669,10.4741173 C6.40077221,10.0773119 7.12636292,10.0652587 7.67042486,10.4442027 L7.67020842,7.74937024 L7.68449368,7.74937024 C7.72405122,7.59919041 7.83988806,7.48101083 7.98924584,7.4384546 L10.1880418,6.81004755 C10.42156,6.74340323 10.6648954,6.87865515 10.7315824,7.11216117 Z M9.60714286,1.31785714 L12.9678571,4.67857143 L9.60714286,4.67857143 L9.60714286,1.31785714 Z',
|
||||
],
|
||||
},
|
||||
video: {
|
||||
viewBox: '0 0 16 16',
|
||||
paths: [
|
||||
'M14.1178571,4.0125 C14.225,4.11964286 14.2857143,4.26428571 14.2857143,4.41607143 L14.2857143,15.4285714 C14.2857143,15.7446429 14.0303571,16 13.7142857,16 L2.28571429,16 C1.96964286,16 1.71428571,15.7446429 1.71428571,15.4285714 L1.71428571,0.571428571 C1.71428571,0.255357143 1.96964286,0 2.28571429,0 L9.86964286,0 C10.0214286,0 10.1678571,0.0607142857 10.275,0.167857143 L14.1178571,4.0125 Z M12.9678571,4.67857143 L9.60714286,1.31785714 L9.60714286,4.67857143 L12.9678571,4.67857143 Z M10.5379461,10.3101106 L6.68957555,13.0059749 C6.59910784,13.0693494 6.47439406,13.0473861 6.41101953,12.9569184 C6.3874624,12.9232903 6.37482581,12.8832269 6.37482581,12.8421686 L6.37482581,7.45043999 C6.37482581,7.33998304 6.46436886,7.25043999 6.57482581,7.25043999 C6.61588409,7.25043999 6.65594753,7.26307658 6.68957555,7.28663371 L10.5379461,9.98249803 C10.6284138,10.0458726 10.6503772,10.1705863 10.5870027,10.2610541 C10.5736331,10.2801392 10.5570312,10.2967411 10.5379461,10.3101106 Z',
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// Mirrors PRESET_FILE_ICONS in Ant Design X FileCard, including upstream icon
|
||||
// colors. Meldwork adds a few office-format extensions (ods/odp/odt/csv, iWork)
|
||||
// to the matching semantic group so every supported attachment gets a colored
|
||||
// glyph instead of the generic fallback.
|
||||
export const FILE_CARD_PRESETS = Object.freeze([
|
||||
{ key: 'excel', color: '#22b35e', ext: ['xlsx', 'xls', 'ods', 'csv', 'numbers'] },
|
||||
{ key: 'image', color: '#8c8c8c', ext: FILE_CARD_IMAGE_EXT },
|
||||
{ key: 'markdown', color: '#8c8c8c', ext: ['md', 'mdx'] },
|
||||
{ key: 'pdf', color: '#ff4d4f', ext: ['pdf'] },
|
||||
{ key: 'ppt', color: '#ff6e31', ext: ['ppt', 'pptx', 'odp', 'key'] },
|
||||
{ key: 'word', color: '#1677ff', ext: ['doc', 'docx', 'odt', 'rtf', 'pages'] },
|
||||
{ key: 'zip', color: '#fab714', ext: ['zip', 'rar', '7z', 'tar', 'gz', 'tgz'] },
|
||||
{ key: 'video', color: '#ff4d4f', ext: FILE_CARD_VIDEO_EXT },
|
||||
{ key: 'audio', color: '#ff6e31', ext: FILE_CARD_AUDIO_EXT },
|
||||
{ key: 'java', color: '#1677ff', ext: ['java'] },
|
||||
{ key: 'javascript', color: '#fab714', ext: ['js', 'mjs', 'cjs', 'jsx'] },
|
||||
{ key: 'python', color: '#fab714', ext: ['py', 'pyi'] },
|
||||
])
|
||||
|
||||
export const FILE_CARD_DEFAULT = Object.freeze({ key: 'default', color: '#8c8c8c' })
|
||||
|
||||
export function fileCardExtension(name) {
|
||||
const value = String(name || '')
|
||||
const match = value.match(/\.([^.]+)$/)
|
||||
return match ? match[1].toLowerCase() : ''
|
||||
}
|
||||
|
||||
export function fileCardIconArt(key) {
|
||||
return FILE_CARD_ICON_ART[key] || FILE_CARD_ICON_ART.default
|
||||
}
|
||||
|
||||
export function fileCardIconKey({ name = '', mimeType = '', kind = '' } = {}) {
|
||||
const mediaKind = String(kind || '').toLowerCase()
|
||||
if (['image', 'audio', 'video'].includes(mediaKind)) return mediaKind
|
||||
const extension = fileCardExtension(name)
|
||||
if (!extension) {
|
||||
const mime = String(mimeType || '').toLowerCase()
|
||||
if (mime.startsWith('image/')) return 'image'
|
||||
if (mime.startsWith('audio/')) return 'audio'
|
||||
if (mime.startsWith('video/')) return 'video'
|
||||
return FILE_CARD_DEFAULT.key
|
||||
}
|
||||
for (const preset of FILE_CARD_PRESETS) {
|
||||
if (preset.ext.includes(extension)) return preset.key
|
||||
}
|
||||
return FILE_CARD_DEFAULT.key
|
||||
}
|
||||
|
||||
export function fileCardIconColor(key) {
|
||||
const preset = FILE_CARD_PRESETS.find(item => item.key === key)
|
||||
return preset?.color || FILE_CARD_DEFAULT.color
|
||||
}
|
||||
|
||||
export function formatFileCardSize(bytes) {
|
||||
const size = Math.max(0, Number(bytes) || 0)
|
||||
if (size < 1024) return `${Math.round(size)} B`
|
||||
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`
|
||||
if (size < 1024 * 1024 * 1024) {
|
||||
const mb = (size / (1024 * 1024)).toFixed(size < 10 * 1024 * 1024 ? 1 : 0)
|
||||
return `${Number(mb)} MB`
|
||||
}
|
||||
return `${Number((size / (1024 * 1024 * 1024)).toFixed(1))} GB`
|
||||
}
|
||||
|
||||
// --- Media generation process state ---------------------------------------
|
||||
// Agent runtimes emit trace events titled `${type}_generation` while a media
|
||||
// skill or tool is in flight. The UI only needs start/complete
|
||||
// semantics: progress percentages carried in event summaries are intentionally
|
||||
// ignored so the surface stays stable across providers.
|
||||
|
||||
// The Agent runtime emits one trace event per state change while a media skill
|
||||
// or tool is in flight:
|
||||
// { id, type: 'tool_start' | 'tool_result_summary',
|
||||
// status: 'running' | 'completed' | 'failed', title: '<type>_generation', summary }
|
||||
// `running` summaries may contain tool progress text. The UI deliberately
|
||||
// renders only start/complete semantics and never the summary, so no progress
|
||||
// number ever reaches the surface.
|
||||
const MEDIA_GENERATION_TITLE = /^(image|audio|video)[_-]generation$/i
|
||||
const MEDIA_GENERATION_ACTIVE = new Set([
|
||||
'', 'queued', 'pending', 'planned', 'running', 'in_progress', 'streaming', 'waiting', 'processing',
|
||||
])
|
||||
const MEDIA_GENERATION_COMPLETE = new Set(['completed', 'complete', 'succeeded', 'success', 'done', 'finished'])
|
||||
|
||||
// Returns the most recent generation activity for a run, or null when there is
|
||||
// nothing to surface. `phase` is 'running' or 'complete'; failures are dropped
|
||||
// so a broken generation never leaves a stuck card on screen.
|
||||
export function mediaGenerationFromRunEvents(events) {
|
||||
const values = Array.isArray(events) ? events : []
|
||||
for (let index = values.length - 1; index >= 0; index -= 1) {
|
||||
const event = values[index]
|
||||
const match = MEDIA_GENERATION_TITLE.exec(String(event?.title || '').trim())
|
||||
if (!match) continue
|
||||
const type = match[1].toLowerCase()
|
||||
const status = String(event?.status || '').trim().toLowerCase()
|
||||
if (MEDIA_GENERATION_ACTIVE.has(status)) return { type, status: 'running', phase: 'running' }
|
||||
if (MEDIA_GENERATION_COMPLETE.has(status)) return { type, status: 'completed', phase: 'complete' }
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Agent-run statuses that mean the run is over, so its generation card should
|
||||
// no longer be surfaced.
|
||||
const RUN_TERMINAL_STATUSES = new Set([
|
||||
'completed', 'succeeded', 'failed', 'cancelled', 'stopped', 'partial', 'timeout', 'interrupted', 'error',
|
||||
])
|
||||
|
||||
export function activeMediaGenerationForRun(agentRuns) {
|
||||
const runs = Array.isArray(agentRuns) ? agentRuns : []
|
||||
for (let index = runs.length - 1; index >= 0; index -= 1) {
|
||||
const agent = runs[index]
|
||||
if (RUN_TERMINAL_STATUSES.has(String(agent?.status || '').trim().toLowerCase())) continue
|
||||
const activity = mediaGenerationFromRunEvents(agent?.events)
|
||||
if (activity) return { ...activity, agentKind: String(agent?.kind || '') }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -580,9 +580,9 @@
|
||||
.composer-attachment-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding: 9px 12px 4px;
|
||||
padding: 6px 8px 2px;
|
||||
border-bottom: 0;
|
||||
background: transparent;
|
||||
}
|
||||
@@ -591,75 +591,53 @@
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
min-width: 0;
|
||||
min-width: 56px;
|
||||
flex: 0 0 56px;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.composer-attachment > img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.composer-attachment.is-file,
|
||||
.composer-attachment.is-audio,
|
||||
.composer-attachment.is-video {
|
||||
width: 184px;
|
||||
min-width: 184px;
|
||||
flex-basis: 184px;
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
gap: 9px;
|
||||
padding: 6px 28px 6px 7px;
|
||||
background: color-mix(in srgb, var(--surface-hover) 70%, transparent);
|
||||
width: 176px;
|
||||
height: 50px;
|
||||
min-width: 176px;
|
||||
flex-basis: 176px;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
gap: 7px;
|
||||
padding: 3px 24px 3px 3px;
|
||||
}
|
||||
|
||||
.composer-attachment-thumb,
|
||||
.composer-attachment-media-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--surface-raised) 78%, var(--surface-active) 22%);
|
||||
color: var(--accent);
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.composer-attachment-media-icon[data-attachment-icon="spreadsheet"] {
|
||||
color: #3f7d5b;
|
||||
.composer-attachment-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.composer-attachment-media-icon[data-attachment-icon="presentation"] {
|
||||
color: #b15d3b;
|
||||
}
|
||||
|
||||
.composer-attachment-media-icon[data-attachment-icon="pdf"] {
|
||||
color: #c14f4f;
|
||||
}
|
||||
|
||||
.composer-attachment-media-icon[data-attachment-icon="code"] {
|
||||
color: #5866a9;
|
||||
}
|
||||
|
||||
.composer-attachment-media-icon[data-attachment-icon="archive"] {
|
||||
color: #8b6841;
|
||||
}
|
||||
|
||||
.composer-attachment-media-icon[data-attachment-icon="audio"] {
|
||||
color: #8b5aa5;
|
||||
}
|
||||
|
||||
.composer-attachment-media-icon[data-attachment-icon="video"] {
|
||||
color: #397d89;
|
||||
.composer-attachment-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.composer-attachment-media-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.composer-attachment-file-copy {
|
||||
@@ -684,7 +662,6 @@
|
||||
.composer-attachment-file-copy small {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.composer-attachment > button,
|
||||
@@ -702,18 +679,28 @@
|
||||
|
||||
.composer-attachment > button {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
top: 1px;
|
||||
right: 1px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.16s ease, color 0.16s ease;
|
||||
}
|
||||
|
||||
.composer-attachment-remove svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.composer-attachment > button:hover,
|
||||
.composer-attachment:hover > .composer-attachment-remove,
|
||||
.composer-attachment:focus-within > .composer-attachment-remove,
|
||||
.composer-attachment > .composer-attachment-remove:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.composer-attachment > .composer-attachment-remove:hover,
|
||||
.selected-skill button:hover,
|
||||
.selected-agent-tag button:hover {
|
||||
color: var(--text);
|
||||
|
||||
@@ -800,6 +800,75 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.message-attachment-grid video {
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
aspect-ratio: 16 / 9;
|
||||
display: block;
|
||||
background: var(--surface-raised);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.message-media-frame {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.message-media-shimmer {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: linear-gradient(
|
||||
100deg,
|
||||
transparent 32%,
|
||||
color-mix(in srgb, var(--surface-raised) 82%, transparent) 50%,
|
||||
transparent 68%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
background-repeat: no-repeat;
|
||||
animation: message-media-shimmer-sweep 1.5s ease-in-out infinite;
|
||||
transition: opacity 0.35s ease, visibility 0.35s ease;
|
||||
}
|
||||
|
||||
.message-media-frame.loaded .message-media-shimmer {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@keyframes message-media-shimmer-sweep {
|
||||
from { background-position: 130% 0; }
|
||||
to { background-position: -30% 0; }
|
||||
}
|
||||
|
||||
.message-media-frame .message-media-preview-trigger,
|
||||
.message-media-frame .message-media-fallback {
|
||||
opacity: 0;
|
||||
transform: scale(0.985);
|
||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||
}
|
||||
|
||||
.message-media-frame.loaded .message-media-preview-trigger,
|
||||
.message-media-frame.loaded .message-media-fallback {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.message-media-fallback {
|
||||
aspect-ratio: 4 / 3;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.message-media-fallback svg {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.message-media-preview-trigger {
|
||||
width: 100%;
|
||||
display: block;
|
||||
@@ -815,33 +884,62 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.message-attachment-grid audio {
|
||||
width: 100%;
|
||||
min-width: 260px;
|
||||
display: block;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.message-attachment-grid video {
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
aspect-ratio: 16 / 9;
|
||||
display: block;
|
||||
background: var(--surface-raised);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.message-attachment-placeholder {
|
||||
aspect-ratio: 4 / 3;
|
||||
.message-audio-card {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--surface-hover);
|
||||
color: var(--faint);
|
||||
gap: 7px;
|
||||
padding: 7px 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.message-attachment-placeholder svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
.message-audio-card-info {
|
||||
display: grid;
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 1px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-audio-card-info:hover .message-audio-copy strong {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.message-audio-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.message-audio-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.message-audio-copy strong,
|
||||
.message-audio-copy small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-audio-copy strong {
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.message-audio-copy small {
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.message-audio-card audio {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message-document-list {
|
||||
@@ -859,30 +957,31 @@
|
||||
}
|
||||
|
||||
.message-document-attachment {
|
||||
width: 168px;
|
||||
min-width: 148px;
|
||||
min-height: 44px;
|
||||
width: 192px;
|
||||
min-width: 168px;
|
||||
min-height: 52px;
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr) 14px;
|
||||
grid-template-columns: 26px minmax(0, 1fr) 14px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 5px 6px;
|
||||
gap: 8px;
|
||||
padding: 7px 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--surface-hover) 72%, transparent);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
|
||||
.message-document-attachment:hover {
|
||||
color: var(--accent-hover);
|
||||
.message-document-attachment:hover,
|
||||
.message-document-attachment:focus-visible {
|
||||
background: var(--surface-active);
|
||||
}
|
||||
|
||||
.message-document-icon {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
color: var(--accent);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.message-document-attachment > svg:last-child {
|
||||
@@ -923,6 +1022,27 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.message-media-shimmer {
|
||||
animation: none;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.message-media-frame.loaded .message-media-shimmer {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.message-media-frame .message-media-preview-trigger,
|
||||
.message-media-frame .message-media-fallback {
|
||||
transform: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.message-document-attachment {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-body > :first-child { margin-top: 0; }
|
||||
.markdown-body > :last-child { margin-bottom: 0; }
|
||||
.markdown-body p,
|
||||
|
||||
@@ -26,6 +26,128 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.media-generation-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr) 22px;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: min(340px, 100%);
|
||||
padding: 11px 13px;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--surface-hover) 68%, transparent);
|
||||
}
|
||||
|
||||
.media-generation-shimmer {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: linear-gradient(
|
||||
100deg,
|
||||
transparent 32%,
|
||||
color-mix(in srgb, var(--surface-raised) 85%, transparent) 50%,
|
||||
transparent 68%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
background-repeat: no-repeat;
|
||||
animation: media-generation-shimmer-sweep 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes media-generation-shimmer-sweep {
|
||||
from { background-position: 130% 0; }
|
||||
to { background-position: -30% 0; }
|
||||
}
|
||||
|
||||
.media-generation-icon,
|
||||
.media-generation-star {
|
||||
z-index: 1;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.media-generation-star {
|
||||
color: var(--accent);
|
||||
animation: media-generation-star-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.media-generation-star svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.media-generation-copy {
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.media-generation-copy strong {
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-generation-copy small {
|
||||
color: var(--muted);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.media-generation-spinner {
|
||||
z-index: 1;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid color-mix(in srgb, var(--border-strong) 45%, transparent);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: media-generation-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes media-generation-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes media-generation-star-pulse {
|
||||
0%, 100% { opacity: 0.58; transform: scale(0.86) rotate(-12deg); }
|
||||
50% { opacity: 1; transform: scale(1) rotate(12deg); }
|
||||
}
|
||||
|
||||
.media-generation-card.is-complete {
|
||||
border-color: color-mix(in srgb, #347a66 36%, transparent);
|
||||
background: color-mix(in srgb, #347a66 10%, var(--surface-hover) 62%);
|
||||
}
|
||||
|
||||
.media-generation-card.is-complete .media-generation-icon,
|
||||
.media-generation-card.is-complete .media-generation-star {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.media-generation-card.is-complete .media-generation-copy strong {
|
||||
color: #347a66;
|
||||
}
|
||||
|
||||
.media-generation-check {
|
||||
z-index: 1;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: #347a66;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-generation-shimmer { animation: none; opacity: 0.5; }
|
||||
.media-generation-icon,
|
||||
.media-generation-star { animation: none; }
|
||||
.media-generation-spinner { animation-duration: 1.6s; }
|
||||
}
|
||||
|
||||
.run-status-header {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
|
||||
Reference in new issue
Block a user