mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
version 2 initial commit
This commit is contained in:
commit
c817a02c50
1268 changed files with 295344 additions and 0 deletions
93
.gitignore
vendored
Normal file
93
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Development-only repository folders
|
||||
docs/
|
||||
tests/
|
||||
scripts/
|
||||
|
||||
# Local agent/project memory
|
||||
OPENSPACE.md
|
||||
OPENSPACE.local.md
|
||||
|
||||
# Distribution / packaging
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
openspace/packaged/
|
||||
|
||||
# Environment files (ignore .env but keep .env.example)
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
# MCP config files
|
||||
openspace/config/config_mcp.json
|
||||
|
||||
# Communication config files
|
||||
openspace/config/config_communication.json
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
|
||||
# GDPVal reference file cache (pre-downloaded from HuggingFace)
|
||||
benchmarks/gdpval/ref_cache/
|
||||
|
||||
# Embedding cache - root level fully ignored
|
||||
/.openspace/
|
||||
# GDPVal benchmark cache
|
||||
benchmarks/gdpval/.openspace/*
|
||||
!benchmarks/gdpval/.openspace/*.db
|
||||
|
||||
# Embedding cache
|
||||
embedding_cache/
|
||||
tool_quality/
|
||||
|
||||
# MCP tool cache
|
||||
mcp_tool_cache.json
|
||||
mcp_tool_cache_sanitized.json
|
||||
|
||||
# Config files
|
||||
openspace/config/config_dev.json
|
||||
|
||||
# LLM keys
|
||||
openspace/llm/remote_client/
|
||||
|
||||
# Local server temp files
|
||||
openspace/local_server/temp/
|
||||
|
||||
# Example app local state and build artifacts
|
||||
examples/*/.openspace/*
|
||||
!examples/*/.openspace/*.db
|
||||
examples/*/dist/
|
||||
examples/*/node_modules/
|
||||
|
||||
# Skills
|
||||
openspace/skills/*
|
||||
!openspace/skills/README.md
|
||||
!openspace/skills/remember/
|
||||
!openspace/skills/remember/SKILL.md
|
||||
!openspace/skills/remember/.skill_id
|
||||
|
||||
# Frontend dependencies
|
||||
node_modules/
|
||||
# App local dependency links
|
||||
apps/*/node_modules/
|
||||
5
COMMUNICATION.md
Normal file
5
COMMUNICATION.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
We provide QR codes for joining the HKUDS discussion groups on **WeChat** and **Feishu**.
|
||||
|
||||
You can join by scanning the QR codes below:
|
||||
|
||||
<img src="https://github.com/HKUDS/.github/blob/main/profile/QR.png" alt="WeChat QR Code" width="400"/>
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 ✨Data Intelligence Lab@HKU✨
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
17
MANIFEST.in
Normal file
17
MANIFEST.in
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
prune tests
|
||||
prune docs
|
||||
prune scripts
|
||||
|
||||
recursive-exclude openspace/packaged/tui/__tests__ *
|
||||
recursive-exclude openspace/packaged/tui */__tests__/*
|
||||
recursive-exclude openspace/packaged/dashboard/__tests__ *
|
||||
recursive-exclude openspace/packaged/dashboard */__tests__/*
|
||||
recursive-exclude openspace/packaged/tui *.test.*
|
||||
recursive-exclude openspace/packaged/tui *.spec.*
|
||||
recursive-exclude openspace/packaged/dashboard *.test.*
|
||||
recursive-exclude openspace/packaged/dashboard *.spec.*
|
||||
exclude openspace/packaged/tui/node_modules/@types/node/test.d.ts
|
||||
|
||||
global-exclude __pycache__
|
||||
global-exclude *.py[cod]
|
||||
global-exclude .DS_Store
|
||||
672
README.md
Normal file
672
README.md
Normal file
|
|
@ -0,0 +1,672 @@
|
|||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<img src="assets/logo.png" width="320px" style="border: none; box-shadow: none;" alt="OpenSpace Logo">
|
||||
</picture>
|
||||
|
||||
## ✨ OpenSpace: Make Your Agents: Smarter, Low-Cost, Self-Evolving ✨
|
||||
|
||||
| 🔋 **46% Fewer Tokens** | **💰 $11K earned in 6 Hours** | 🧬 **Self-Evolving Skills** | 🌐 **Agents Experience Sharing** |
|
||||
|
||||
[](https://modelcontextprotocol.io/)
|
||||
[](https://www.python.org/)
|
||||
[](https://opensource.org/licenses/MIT/)
|
||||
[](./COMMUNICATION.md)
|
||||
[](./COMMUNICATION.md)
|
||||
[](./README_CN.md)
|
||||
|
||||
**One Command to Evolve All Your AI Agents**: OpenClaw, nanobot, Claude Code, Codex, Cursor and etc.
|
||||
|
||||
<img src="assets/cli-typing.gif" width="500px" alt="openspace --query your task">
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📢 News
|
||||
|
||||
- **2026-04-09** 💬 Multi-channel **communication gateway**. OpenSpace can now receive and respond to messages from external platforms. Ships with **WhatsApp** (Baileys bridge + QR auth) and **Feishu** (HTTP webhook) adapters, session management, attachment caching, and allowlist-based access control. See [`openspace/config/README.md`](openspace/config/README.md) for setup.
|
||||
- **2026-04-07** 🌐 OpenSpace MCP now supports standalone **SSE** and **streamable HTTP** startup, making it easier for remote hosts to connect over HTTP instead of stdio and bypass stdio-bound MCP server timeout bottlenecks. See the [host integration guide](openspace/host_skills/README.md) for setup details.
|
||||
- **2026-04-06** 🛠️ Fixed multiple runtime issues across grounding, MCP serving, skill evolution, and persistence, improving execution stability and recovery in long-running workflows.
|
||||
- **2026-04-05** 🧭 Cleaned up LLM credential resolution: centralized `.env` loading, improved host config auto-detection, and made provider-native env handling more consistent.
|
||||
- **2026-04-03** 🚀 Released **v0.1.0** — Skill quality monitoring: structural patterns extracted from high-quality skills now evaluate every new submission daily. Faster, more relevant cloud search. Production-grade vertical skill clusters emerging organically from the community. Frontend now supports Chinese (zh) i18n.
|
||||
- **2026-04-02** ⚡ Cloud search upgraded for higher relevance and lower latency.
|
||||
- **2026-03-31** 🛡️ Security hardening: hardened zip extraction and `import_skill` against path traversal. CLI now respects `OPENSPACE_MODEL` and `OPENSPACE_LLM_*` env vars; MiniMax compatibility; workflow ID collision fixes.
|
||||
- **2026-03-29** 🔒 Pinned litellm to <1.82.7 to avoid PYSEC-2026-2 supply-chain attack.
|
||||
- **2026-03-28** 🔧 Idempotent skill registration — `register_skill_dir` now returns existing `SkillMeta` for already-registered skills. Updated OpenClaw setup docs.
|
||||
- **2026-03-27** 🪟 Fixed stdio deadlock on Windows; improved evolver confirmation parsing with stem-style keyword matching.
|
||||
- **2026-03-26** 🌱 Dynamic skill directory re-scanning on each call, lightweight local skill search, and streamlined documentation.
|
||||
- **2026-03-25** 🎉 OpenSpace is now open source!
|
||||
|
||||
---
|
||||
|
||||
## The Problem with Today's AI Agents
|
||||
|
||||
Today's AI agents — [OpenClaw](https://github.com/openclaw/openclaw), [nanobot](https://github.com/HKUDS/nanobot), [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [Cursor](https://cursor.com), etc. — are powerful, but they have a critical weakness: they never **Learn**, **Adapt**, and **Evolve** from real-world experience — let alone **Share** with each other.
|
||||
- **❌ Massive Token Waste** - How to reuse successful task patterns instead of reasoning from scratch and burning tokens every time?
|
||||
- **❌ Repeated Costly Failures** - How to share solutions across agents instead of repeating the same costly exploration and mistakes?
|
||||
- **❌ Poor and Unreliable Skills** - How to maintain skill reliability as tools and APIs evolve — while ensuring community-contributed skills meet rigorous quality standards?
|
||||
|
||||
## 🎯 What is OpenSpace?
|
||||
|
||||
**🚀 🚀 The self-evolving engine where every task makes every agent smarter and more cost-efficient.**
|
||||
|
||||
https://github.com/user-attachments/assets/c50f70ab-f6db-47bf-9498-3210c0f0abae
|
||||
|
||||
OpenSpace plugs into any agent as skills and evolves it with three superpowers:
|
||||
|
||||
### 🧬 Self-Evolution
|
||||
Skills that learn and improve themselves automatically
|
||||
- ✅ **AUTO-FIX** — When a skill breaks, it fixes itself instantly
|
||||
- ✅ **AUTO-IMPROVE** — Successful patterns become better skill versions
|
||||
- ✅ **AUTO-LEARN** — Captures winning workflows from actual usage
|
||||
- ✅ **Quality monitoring** — Tracks skill performance, error rates, and execution success across all tasks.
|
||||
|
||||
**Skills that continuously evolve — turning every failure into improvement, every success into optimization.**
|
||||
|
||||
### 🌐 Collective Agent Intelligence
|
||||
Turn individual agents into a shared brain
|
||||
- ✅ **Shared evolution**: One agent's improvement becomes every agent's upgrade
|
||||
- ✅ **Network effects**: More agents → richer data → faster evolution for every agent
|
||||
- ✅ **Easy sharing** — Upload and download evolved skills with one simple command
|
||||
- ✅ **Access control** — Choose public, private, or team-only access for each skill
|
||||
|
||||
**One agent learns, all agents benefit — collective intelligence at scale.**
|
||||
|
||||
### 💰 Token Efficiency
|
||||
Smarter agents, dramatically lower costs
|
||||
- ✅ **Stop repeating work** → Reuse successful solutions instead of starting from zero each time
|
||||
- ✅ **Tasks get cheaper** → As skills improve, similar work costs less and less
|
||||
- ✅ **Small updates only** → Fix what's broken, don't rebuild everything
|
||||
- ✅ **Real savings**: 4.2× better performance with 46% fewer tokens on real-world tasks, delivering measurable economic value. ([GDPVal](#-benchmark-gdpval))
|
||||
|
||||
Do more, spend less — agents that actually save you money over time.
|
||||
|
||||
---
|
||||
|
||||
### The Difference
|
||||
|
||||
**❌ Current Agents**
|
||||
- Skills degrade silently as tools evolve
|
||||
- Failed patterns repeat with no learning mechanism
|
||||
- Knowledge remains trapped in individual agents
|
||||
|
||||
**✅ OpenSpace-Powered Agents**
|
||||
- Multi-layer monitoring catches problems and auto-triggers repairs
|
||||
- Successful workflows become reusable, shareable skills
|
||||
- When one agent learns something useful, all agents get that knowledge instantly
|
||||
|
||||
### 📊 OpenSpace: Turn Your Agent into a Money-Making Coworker
|
||||
|
||||
**🎯 Real-World Results That Matter**
|
||||
On 50 professional tasks (**📈 [GDPVal Economic Benchmark](#-benchmark-gdpval)**) across 6 industries, OpenSpace agents earn **4.2× more money** than baseline ([ClawWork](https://github.com/HKUDS/ClawWork)) agents using the same backbone LLM (Qwen 3.5-Plus). While cutting 46% of costly tokens through skill evolution.
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/benchmark_kpi.png" width="100%" alt="GDPVal Benchmark — Key Results" />
|
||||
</div>
|
||||
|
||||
**💼 These Aren't Toy Problems**
|
||||
- Building payroll calculators from complex union contracts
|
||||
- Preparing tax returns from 15 scattered PDF documents
|
||||
- Drafting legal memoranda on California privacy regulations
|
||||
- Creating compliance forms and engineering specifications
|
||||
|
||||
**📈 Consistent Wins Across All Fields**
|
||||
- Compliance work: +18.5% higher earnings
|
||||
- Engineering projects: +8.7% better performance
|
||||
- Professional documents: 56% fewer tokens needed
|
||||
- Every category improved — no exceptions
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/benchmark_task_showcase.png" width="100%" alt="GDPVal Benchmark — Task Showcase by Category" />
|
||||
</div>
|
||||
|
||||
**OpenSpace doesn't just make agents smarter** — it makes them economically viable. Real work, real money, measurable results.
|
||||
|
||||
## Use Case for Autonomous System Development with OpenSpace
|
||||
|
||||
**🖥️ [My Daily Monitor](examples/my-daily-monitor/README.md)** — OpenSpace empowers your agent to complete large-scale system development. This personal behavior monitoring system with 20+ live dashboard panels lives in [`examples/my-daily-monitor`](examples/my-daily-monitor) and was built entirely by the agent — 60+ skills evolved from scratch through OpenSpace, demonstrating autonomous end-to-end software development capabilities.
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/my_daily_monitor_dark.png" width="100%" alt="My Daily Monitor – Dark Mode" />
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [⚡ Quick Start](#-quick-start)
|
||||
- [🤖 Path A: For Your Agent](#-path-a-for-your-agent)
|
||||
- [👤 Path B: As Your Co-Worker](#-path-b-as-your-co-worker)
|
||||
- [📊 Local Dashboard](#-local-dashboard)
|
||||
- [📈 Benchmark: GDPVal](#-benchmark-gdpval)
|
||||
- [📊 Showcase: My Daily Monitor](#-showcase-my-daily-monitor)
|
||||
- [🏗️ Framework](#️-framework)
|
||||
- [🧬 Self-Evolution Engine](#-self-evolution-engine)
|
||||
- [🌐 Cloud Skill Community](#-cloud-skill-community)
|
||||
- [🔧 Advanced Configuration](#-advanced-configuration)
|
||||
- [📖 Code Structure](#-code-structure)
|
||||
- [🧪 Testing Layout](#-testing-layout)
|
||||
- [🤝 Contribute & Roadmap](#-contribute--roadmap)
|
||||
- [🔗 Related Projects](#-related-projects)
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
🌐 **Just want to explore?** Browse community skills, evolution lineage at **[open-space.cloud](https://open-space.cloud)** — no installation needed.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/OpenSpace.git && cd OpenSpace
|
||||
pip install -e .
|
||||
openspace-mcp --help # verify installation
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Slow clone?** The `assets/` folder (~50 MB of images) makes the default clone large. Use this lightweight alternative to skip it:
|
||||
> ```bash
|
||||
> git clone --filter=blob:none --sparse https://github.com/HKUDS/OpenSpace.git
|
||||
> cd OpenSpace
|
||||
> git sparse-checkout set '/*' '!assets/'
|
||||
> pip install -e .
|
||||
> ```
|
||||
|
||||
**Choose your path:**
|
||||
- **[Path A](#-path-a-for-your-agent)** — Plug OpenSpace into your agent
|
||||
- **[Path B](#-path-b-as-your-co-worker)** — Use OpenSpace directly as your AI co-worker
|
||||
|
||||
### 🤖 Path A: For Your Agent
|
||||
|
||||
Works with any host that can launch an MCP server and read skills (`SKILL.md`). OpenSpace ships host helpers for OpenClaw and nanobot, and can be wired manually from Claude Code, Codex, Cursor, or other MCP-capable agents.
|
||||
|
||||
**① Add OpenSpace to your agent's MCP config:**
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"openspace": {
|
||||
"command": "openspace-mcp",
|
||||
"toolTimeout": 600,
|
||||
"env": {
|
||||
"OPENSPACE_HOST_SKILL_DIRS": "/path/to/your/agent/skills",
|
||||
"OPENSPACE_WORKSPACE": "/path/to/OpenSpace",
|
||||
"OPENSPACE_CLOUD_MODE": "live",
|
||||
"OPENSPACE_CLOUD_API_KEY": "sk-xxx (optional, for cloud)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Credentials (API key, model) are auto-detected from nanobot and OpenClaw configs. Other hosts should set `OPENSPACE_LLM_API_KEY` / `OPENSPACE_MODEL`, or rely on `openspace/.env`.
|
||||
|
||||
> [!NOTE]
|
||||
> OpenSpace supports 3 launch modes:
|
||||
> - **stdio**: keep `command: "openspace-mcp"` in the host config.
|
||||
> - **SSE**: start `openspace-mcp --transport sse --host 127.0.0.1 --port 8080`.
|
||||
> - **streamable HTTP**: start `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081`.
|
||||
>
|
||||
> Common remote endpoints:
|
||||
> - SSE endpoint: `http://127.0.0.1:8080/sse`
|
||||
> - streamable HTTP endpoint: `http://127.0.0.1:8081/mcp`
|
||||
>
|
||||
> `stdio` is the simplest option. HTTP modes keep OpenSpace as a standalone server, but **host-specific registration syntax** and **host-side timeouts** still apply.
|
||||
|
||||
**② Copy skills** into your agent's skills directory:
|
||||
|
||||
```bash
|
||||
cp -r OpenSpace/openspace/host_skills/delegate-task/ /path/to/your/agent/skills/
|
||||
cp -r OpenSpace/openspace/host_skills/skill-discovery/ /path/to/your/agent/skills/
|
||||
```
|
||||
|
||||
Done. These two skills teach your agent when and how to use OpenSpace — no additional prompting needed. Your agent can now self-evolve skills, execute complex tasks, and access the cloud skill community. You can also add your own custom skills — see [`openspace/skills/README.md`](openspace/skills/README.md).
|
||||
|
||||
> [!NOTE]
|
||||
> **Cloud community (optional):** Run `openspace-cloud-auth bootstrap-agent-key --email you@example.com --agent-name openspace-local-agent` to provision an owner-scoped cloud agent key. The command stores `OPENSPACE_CLOUD_MODE=live` and `OPENSPACE_CLOUD_API_KEY` locally without printing the raw key. Without it, all local capabilities (task execution, evolution, local skill search) work normally.
|
||||
|
||||
📖 Per-agent config (OpenClaw / nanobot), all env vars, advanced settings: [`openspace/host_skills/README.md`](openspace/host_skills/README.md)
|
||||
|
||||
### 👤 Path B: As Your Co-Worker
|
||||
|
||||
Use OpenSpace directly — coding, search, tool use, and more — with self-evolving skills and cloud community built in.
|
||||
|
||||
> [!NOTE]
|
||||
> Create a `.env` file with your LLM API key. For cloud community access, provision the agent key with `openspace-cloud-auth bootstrap-agent-key` (refer to [`openspace/.env.example`](openspace/.env.example)).
|
||||
|
||||
```bash
|
||||
# Interactive mode
|
||||
openspace
|
||||
|
||||
# Execute task
|
||||
openspace --model "anthropic/claude-sonnet-4-5" --query "Create a monitoring dashboard for my Docker containers"
|
||||
```
|
||||
|
||||
Add your own custom skills: [`openspace/skills/README.md`](openspace/skills/README.md).
|
||||
|
||||
**Cloud CLI** — manage skills from the command line:
|
||||
|
||||
```bash
|
||||
openspace-download-skill <skill_id> # download a skill from the cloud
|
||||
openspace-upload-skill /path/to/skill/dir # upload a skill to the cloud
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Python API</b></summary>
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from openspace import OpenSpace
|
||||
from openspace.runtime import ExecutionRequest
|
||||
|
||||
async def main():
|
||||
async with OpenSpace() as cs:
|
||||
result = await cs.execute(
|
||||
ExecutionRequest(
|
||||
prompt="Analyze GitHub trending repos and create a report",
|
||||
)
|
||||
)
|
||||
print(result.text)
|
||||
|
||||
for skill in result.evolved_skills:
|
||||
print(f" Evolved: {skill['name']} ({skill['origin']})")
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 📊 Local Dashboard
|
||||
|
||||
See how your skills evolve — browse skills, track lineage, compare diffs.
|
||||
|
||||
> Requires **Node.js ≥ 20**.
|
||||
|
||||
```bash
|
||||
# Terminal 1. Start backend API
|
||||
openspace-dashboard --port 7788
|
||||
|
||||
# Terminal 2: Start frontend dev server
|
||||
cd apps/dashboard
|
||||
npm install # only needed once
|
||||
npm run dev
|
||||
```
|
||||
|
||||
📖 **Frontend setup guide**: [`apps/dashboard/README.md`](apps/dashboard/README.md)
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="assets/frontend_1.gif" width="100%" alt="Skill Classes" /></td>
|
||||
<td width="50%"><img src="assets/frontend_2.gif" width="100%" alt="Cloud Skill Records" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><sub>Skill Classes — Browse, Search & Sort</sub></td>
|
||||
<td align="center"><sub>Cloud — Browse & Discover Skill Records</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="assets/frontend_3.gif" width="100%" alt="Version Lineage" /></td>
|
||||
<td width="50%"><img src="assets/frontend_4.gif" width="100%" alt="Workflow Sessions" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><sub>Version Lineage — Skill Evolution Graph</sub></td>
|
||||
<td align="center"><sub>Workflow Sessions — Execution History & Metrics</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📈 Benchmark: GDPVal
|
||||
|
||||
We evaluate OpenSpace on [GDPVal](https://huggingface.co/datasets/openai/gdpval) — 220 real-world professional tasks spanning 44 occupations — using the [ClawWork](https://github.com/HKUDS/ClawWork) evaluation protocol with identical productivity tools and LLM-based scoring. Our two-phase design (Cold Start → Warm Rerun) demonstrates how accumulated skills reduce token consumption over time.
|
||||
|
||||
Fair Benchmark: OpenSpace uses Qwen 3.5-Plus as its backbone LLM — identical to a ClawWork baseline agent — ensuring that performance differences stem purely from skill evolution, not model capabilities.
|
||||
|
||||
Real Economic Value: Tasks range from building payroll calculators to preparing tax returns to drafting legal memoranda — the same professional work that generates actual GDP, evaluated on both quality and cost efficiency.
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/benchmark_income.png" width="100%" alt="GDPVal Benchmark — Income Comparison" />
|
||||
</div>
|
||||
|
||||
- **4.2× Higher Income** vs ClawWork with the same backbone LLM (Qwen 3.5-Plus)
|
||||
- **72.8% Value Capture** — $11,484 earned out of $15,764 task value, outperforming all agents
|
||||
- **70.8% Average Quality** — +30pp above the best ClawWork agent (40.8%)
|
||||
− **45.9% Token Usage** in Phase 2 vs Phase 1 — better results with dramatically lower costs
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/benchmark_quality_tokens.png" width="100%" alt="GDPVal Benchmark — Quality & Token Efficiency" />
|
||||
</div>
|
||||
|
||||
### What Real-World Tasks Can OpenSpace Handle?
|
||||
|
||||
The 50 GDPVal tasks span 6 real-world work categories.
|
||||
- **Phase 1 (Cold Start)** runs all 50 tasks sequentially — skills accumulate in a shared database as each task completes.
|
||||
- **Phase 2 (Warm Rerun)** re-executes the same 50 tasks with the full evolved skill database from Phase 1.
|
||||
|
||||
Income Capture = actual payment earned ÷ maximum possible task value
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/benchmark_task_showcase.png" width="100%" alt="GDPVal Benchmark — Task Showcase by Category" />
|
||||
</div>
|
||||
|
||||
## 🎯 Where Evolution Delivers Maximum Impact — And Why:
|
||||
|
||||
| Category | Income Δ | Token Δ | Why |
|
||||
|---|---|---|---|
|
||||
| **📝 Documents & Correspondence** (7) | 71→74% (+3.3pp) | −56% | Polished formal output — California privacy law memoranda, surveillance investigation reports, child support case reports. The `document-gen-fallback` skill family evolved through 13 versions, making structure and error recovery near-automatic. |
|
||||
| **📋 Compliance & Form** (11) | 51→70% (+18.5pp) | −51% | Structured PDFs — tax returns from 15 source documents, pharmacy compliance checklists, clinical handoff templates. The PDF skill chain (checklist logic → reportlab layout → verification) evolves once, then all form tasks reuse the full pipeline. |
|
||||
| **🎬 Media Production** (3) | 53→58% (+5.8pp) | −46% | Audio/video via Python and ffmpeg — bossa-nova instrumental from drum reference, bass stem editing from 5 tracks, CGI show reel from 13 source videos. Evolved skills encode working ffmpeg flags and codec fallbacks, eliminating sandbox trial-and-error. |
|
||||
| **🛠️ Engineering** (4) | 70→78% (+8.7pp) | −43% | Multi-deliverable technical projects — Web3 full-stack (Solidity + React + tests), CNC workcell safety system (report + layout + hardware table), aerospace CFD report. Coordination skills transfer universally across these diverse tasks. |
|
||||
| **📊 Spreadsheets** (15) | 63→70% (+7.3pp) | −37% | Functional .xlsx tools — payroll calculators from union contracts, sales forecasts from historical data, pricing models with competitor benchmarking. Spreadsheet patterns (formulas, merged cells, validation) are identical across domains. |
|
||||
| **📈 Strategy & Analysis** (10) | 88→89% (+1.0pp) | −32% | Strategic recommendations — supplier negotiation strategies, nonprofit program evaluations, energy trading analysis for a $300M desk. Already highest quality (88%); savings from reusing document structure and multi-file orchestration. |
|
||||
|
||||
### What Did Evolution Produce? (165 Skills)
|
||||
|
||||
Across 50 Phase 1 tasks, OpenSpace autonomously evolved **165 skills**. The breakthrough insight: these aren't just domain knowledge — they're **resilient execution patterns** and **quality assurance workflows**. The agent learned how to reliably deliver results in an imperfect, real-world environment.
|
||||
|
||||
**Key Discovery**: Most skills focus on tool reliability and error recovery, not task-specific knowledge.
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/benchmark_skill_taxonomy.png" width="100%" alt="GDPVal Benchmark — Evolved Skill Taxonomy" />
|
||||
</div>
|
||||
|
||||
| Purpose | Count | What It Teaches the Agent |
|
||||
|---|---|---|
|
||||
| **File Format I/O** | 44 | PDF extraction fallbacks, DOCX parsing, Excel merged-cell handling, PPTX creation. 32/44 *captured* from real failures — each one is a production bug solved. |
|
||||
| **Execution Recovery** | 29 | Layered fallback: sandbox fails → shell → file-write-then-run → heredoc. 28/29 *captured* from actual crashes. The foundation that makes everything else reliable. |
|
||||
| **Document Generation** | 26 | End-to-end doc pipeline. `document-gen-fallback` evolved from 1 imported skill into **13 derived versions** — the most deeply iterated skill family. |
|
||||
| **Quality Assurance** | 23 | Post-write verification: check Excel row counts, validate PDF pages, proof-gate spreadsheet formulas. Why P2 quality improves — the agent *verifies*, not just produces. |
|
||||
| **Task Orchestration** | 17 | Multi-file tracking, ZIP packaging, zero-iteration failure detection. Meta-skills that help across all task types with multiple deliverables. |
|
||||
| **Domain Workflow** | 13 | SOAP notes, audio production (**4 generations** from 1 template), video pipelines. Small count but deep evolution within each domain. |
|
||||
| **Web & Research** | 11 | SSL/proxy debugging, search fallbacks, JS-heavy page handling. Includes 2 *fixed* skills — web access is inherently unstable. |
|
||||
|
||||
**Reproduce experiments, analysis tools, and results**: [`benchmarks/gdpval/README.md`](benchmarks/gdpval/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Showcase: My Daily Monitor
|
||||
|
||||
> **Zero human code was written.** 60+ skills evolved from scratch to build a fully working live dashboard.
|
||||
|
||||
**My Daily Monitor** is an always-on dashboard streaming processes, servers, news, markets, email, and schedules — with a built-in AI agent.
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/my_daily_monitor_light.png" width="90%" alt="My Daily Monitor – Light Mode" />
|
||||
</div>
|
||||
|
||||
### How OpenSpace Built It (From Zero)
|
||||
|
||||
| Phase | What Happened | Skills |
|
||||
|-------|--------------|--------|
|
||||
| 🌱 **Seed** | Analyzed open-source [WorldMonitor](https://github.com/koala73/worldmonitor), extracted reference patterns | 6 initial skills |
|
||||
| 🏗️ **Scaffold** | Generated project structure, Vite config, TypeScript setup | +8 skills |
|
||||
| 🎨 **Build** | Created 20+ panels with data services, API routes, grid layout | +25 skills |
|
||||
| 🔧 **Fix** | Auto-repaired broken TypeScript, API mismatches, CSS conflicts | +12 FIX evolutions |
|
||||
| 🧬 **Evolve** | Derived enhanced patterns, merged complementary skills | +15 DERIVED skills |
|
||||
| 📦 **Capture** | Extracted reusable patterns from successful executions | +8 CAPTURED skills |
|
||||
|
||||
### 📈 Skill Evolution Graph
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/my_daily_monitor_evograph.png" width="90%" alt="Skill Evolution Graph" />
|
||||
</div>
|
||||
|
||||
> Each node is a skill that OpenSpace learned, extracted, or refined. The full evolution history is open-sourced in [`examples/my-daily-monitor/.openspace/openspace.db`](examples/my-daily-monitor/.openspace/openspace.db), and the generated app source lives in [`examples/my-daily-monitor`](examples/my-daily-monitor) — load the SQLite DB in any browser to explore lineage, diffs, and quality metrics.
|
||||
|
||||
**Full details**: [`examples/my-daily-monitor/README.md`](examples/my-daily-monitor/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ OpenSpace's Framework
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/framework.png" width="90%" alt="OpenSpace Framework" />
|
||||
</div>
|
||||
|
||||
### 🧬 Self-Evolution Engine
|
||||
|
||||
The core of OpenSpace. Skills aren't static files — they're living entities that automatically select, apply, monitor, analyze, and evolve themselves.
|
||||
|
||||
#### 🔄 Autonomous & Continuous Evolution
|
||||
|
||||
- **Full Lifecycle Management**: From discovery to application to evolution — all without human intervention. OpenSpace completes tasks regardless of whether matching skills exist.
|
||||
|
||||
**Three Evolution Modes**:
|
||||
- 🔧 FIX — Repair broken or outdated instructions in-place. Same skill, new version.
|
||||
- 🚀 DERIVED — Create enhanced or specialized versions from parent skills. New skill directory, coexists with parents.
|
||||
- ✨ CAPTURED — Extract novel reusable patterns from successful executions. Brand new skill, no parent.
|
||||
|
||||
**Three Independent Triggers**: Multiple lines of defense against skill degradation — both successful and failed executions drive evolution.
|
||||
- **📈 Post-Execution Analysis** — Runs after every task. Analyzes full recordings and suggests FIX/DERIVED/CAPTURED for involved skills.
|
||||
- **⚠️ Tool Degradation** — When tool success rates drop, quality monitor finds all dependent skills and batch-evolves them.
|
||||
- **📊 Metric Monitor** — Periodically scans skill health metrics (applied rate, completion rate, fallback rate) and evolves underperformers.
|
||||
|
||||
#### 📊 Full-Stack Quality Monitoring
|
||||
Multi-Layer Tracking: Quality monitoring covers the entire execution stack — from high-level workflows to individual tool calls:
|
||||
- **🎯 Skills** — applied rate, completion rate, effective rate, fallback rate
|
||||
- **🔨 Tool Calls** — success rate, latency, flagged issues
|
||||
- **⚡ Code Execution** — execution status, error patterns
|
||||
|
||||
**Cascade Evolution**: When any component degrades — skill workflow or single tool call — evolution automatically triggers for all upstream dependent skills, maintaining system-wide coherence.
|
||||
|
||||
#### 🔧 Intelligent & Safe Evolution
|
||||
**🤖 Autonomous Evolution**: Each evolution explores the codebase, discovers root causes, and decides fixes autonomously — gathering real evidence before making changes, not generating blindly.
|
||||
|
||||
**⚡ Diff-Based & Token-Efficient**: Produces minimal, targeted diffs rather than full rewrites, with automatic retry on failure. Every version stored in a version DAG with full lineage tracking.
|
||||
|
||||
**🛡️ Built-in Safeguards**:
|
||||
- Confirmation gates reduce false-positive triggers
|
||||
- Anti-loop guards prevent runaway evolution cycles
|
||||
- Safety checks flag dangerous patterns (prompt injection, credential exfiltration)
|
||||
- Evolved skills are validated before replacing predecessors
|
||||
|
||||
**🌐 Collaborative Skill Community**
|
||||
A collaborative registry where agents share evolved skills. When one agent evolves an improvement, every connected agent can discover, import, and build on it — turning individual progress into collective intelligence.
|
||||
|
||||
- **🔐 Flexible Sharing**: Share skills publicly, within groups, or keep them private. Smart search finds what you need and auto-imports it. Every evolution is lineage-tracked with full diffs.
|
||||
|
||||
- **☁️ Collaborative Platform**: open-space.cloud — register for an API key, browse community skills, and manage your groups.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Advanced Configuration
|
||||
|
||||
For most users, [Quick Start](#-quick-start) is all you need. For advanced options (persistent `settings.json`, environment variables, execution modes, security policies, etc.), see [`openspace/config/README.md`](openspace/config/README.md).
|
||||
|
||||
### Skill Evolution Runtime
|
||||
|
||||
OpenSpace enables the evolution engine by default in `autonomous` mode:
|
||||
|
||||
```env
|
||||
OPENSPACE_EVOLUTION_ENGINE_ENABLED=1
|
||||
OPENSPACE_EVOLUTION_MODE=autonomous
|
||||
```
|
||||
|
||||
Modes:
|
||||
|
||||
- `audit_only`: record jobs, packets, decisions, and admissions only; no skill writes.
|
||||
- `fix_only`: explicit/manual FIX jobs such as MCP `fix_skill` can commit after evidence, admission, staged authoring, validation, and commit. DERIVED and CAPTURED actions are recorded as policy-blocked candidates/proposals instead of being committed or auto-rechecked.
|
||||
- `autonomous`: default; all admission-approved and validated FIX/DERIVED/CAPTURED actions may commit.
|
||||
|
||||
Set `OPENSPACE_EVOLUTION_ENGINE_ENABLED=0` only when you want to pause all evolution job processing.
|
||||
|
||||
---
|
||||
|
||||
<a id="-code-structure"></a>
|
||||
<details>
|
||||
<summary><b>📖 Code Structure</b></summary>
|
||||
|
||||
> **Legend**: ⚡ Core modules | 🧬 Skill evolution | 🌐 Cloud | 🔧 Supporting modules
|
||||
|
||||
```
|
||||
OpenSpace/
|
||||
├── openspace/
|
||||
│ ├── runtime/ # Runtime-owned services, state, session/workspace orchestration, execution lifecycle
|
||||
│ ├── application.py # Public OpenSpace/OpenSpaceConfig facade; delegates lifecycle to runtime
|
||||
│ ├── entrypoints/ # CLI, TUI, MCP, gateway, and dashboard process entrypoints
|
||||
│ │
|
||||
│ ├── ⚡ agents/ # Agent System
|
||||
│ │ ├── base.py # Base agent class
|
||||
│ │ └── grounding_agent.py # Execution agent (tool calling, iteration, skill injection)
|
||||
│ │
|
||||
│ ├── ⚡ grounding/ # Unified Backend System
|
||||
│ │ ├── core/
|
||||
│ │ │ ├── grounding_client.py # Unified interface across all backends
|
||||
│ │ │ ├── search_tools.py # Smart Tool RAG (BM25 + embedding + LLM)
|
||||
│ │ │ ├── quality/ # Tool quality tracking & self-evolution
|
||||
│ │ │ ├── security/ # Policies, sandboxing, E2B
|
||||
│ │ │ ├── meta/ # Meta provider & tools
|
||||
│ │ │ ├── transport/ # Connectors & task managers
|
||||
│ │ │ └── tool/ # Tool abstraction (base, local, remote)
|
||||
│ │ └── backends/
|
||||
│ │ ├── shell/ # Shell command execution
|
||||
│ │ ├── gui/ # Anthropic Computer Use
|
||||
│ │ ├── mcp/ # Model Context Protocol (stdio, HTTP, WebSocket)
|
||||
│ │ └── web/ # Web search & browsing
|
||||
│ │
|
||||
│ ├── 🧬 skill_engine/ # Self-Evolving Skill System
|
||||
│ │ ├── registry.py # Skill catalog, frontmatter parsing, content loading
|
||||
│ │ ├── protocol.py # Skill/DiscoverSkills tools and listing attachments
|
||||
│ │ ├── analyzer.py # Post-execution analysis (agent loop + tool access)
|
||||
│ │ ├── evolver.py # FIX / DERIVED / CAPTURED evolution (3 triggers)
|
||||
│ │ ├── patch.py # Multi-file FULL / DIFF / PATCH application
|
||||
│ │ ├── store.py # SQLite persistence, version DAG, quality metrics
|
||||
│ │ ├── skill_ranker.py # BM25 + embedding hybrid ranking
|
||||
│ │ ├── fuzzy_match.py # Fuzzy matching for skill discovery
|
||||
│ │ ├── conversation_formatter.py # Format execution history for analysis
|
||||
│ │ ├── skill_utils.py # Shared skill utilities
|
||||
│ │ └── types.py # SkillRecord, SkillLineage, EvolutionSuggestion
|
||||
│ │
|
||||
│ ├── 🌐 cloud/ # Cloud Skill Community
|
||||
│ │ ├── client.py # HTTP client (upload, download, search)
|
||||
│ │ ├── account.py # User registration and agent-key lifecycle client
|
||||
│ │ ├── auth_flow.py # High-level account bootstrap and verification flow
|
||||
│ │ ├── search.py # Hybrid search engine
|
||||
│ │ ├── embedding.py # Embedding generation for skill search
|
||||
│ │ └── cli/ # CLI tools (auth, download_skill, upload_skill)
|
||||
│ │
|
||||
│ ├── 💬 communication/ # Multi-channel gateway runtime support
|
||||
│ │ ├── gateway_runtime.py # Gateway locks and runtime status
|
||||
│ │ ├── runtime_manager.py # Per-channel OpenSpace runtime lifecycle
|
||||
│ │ ├── adapters/ # Platform adapters (WhatsApp, Feishu)
|
||||
│ │ ├── bridges/ # Non-Python runtimes (WhatsApp Baileys bridge)
|
||||
│ │ ├── config.py # Communication config loader
|
||||
│ │ ├── session_store.py # Per-channel session persistence
|
||||
│ │ └── types.py # ChannelMessage, ChannelSource, SendResult
|
||||
│ │
|
||||
│ ├── 🚪 entrypoints/ # Public CLI/server entrypoints
|
||||
│ │ ├── cli/main.py # `openspace`
|
||||
│ │ ├── dashboard/server.py # `openspace-dashboard`
|
||||
│ │ ├── gateway/server.py # `openspace-gateway`
|
||||
│ │ ├── mcp/server.py # `openspace-mcp`
|
||||
│ │ └── tui/controller.py # TypeScript TUI bridge controller
|
||||
│ │
|
||||
│ ├── 🔧 platforms/ # Platform abstraction (system info, screenshots)
|
||||
│ ├── 🔧 host_detection/ # Auto-detect nanobot / openclaw credentials
|
||||
│ ├── 🔧 host_skills/ # SKILL.md definitions for agent integration
|
||||
│ │ ├── delegate-task/SKILL.md # Teaches agent: execute, fix, upload
|
||||
│ │ └── skill-discovery/SKILL.md # Teaches agent: search & discover skills
|
||||
│ ├── 🔧 prompts/ # LLM prompt templates (grounding + skill engine)
|
||||
│ ├── 🔧 llm/ # LiteLLM wrapper with retry & rate limiting
|
||||
│ ├── 🔧 config/ # Layered configuration system
|
||||
│ ├── 🔧 local_server/ # GUI backend Flask server; shell backend is local-only
|
||||
│ ├── 🔧 recording/ # Execution recording, screenshots & video capture
|
||||
│ ├── 🔧 utils/ # Logging, UI, telemetry
|
||||
│ └── 📦 skills/ # Built-in skills (lowest priority, user can add here)
|
||||
│
|
||||
├── apps/
|
||||
│ ├── dashboard/ # Dashboard UI (React + Tailwind)
|
||||
│ └── tui/ # TypeScript terminal UI
|
||||
├── benchmarks/
|
||||
│ └── gdpval/ # GDPVal benchmark experiments & results
|
||||
├── examples/
|
||||
│ └── my-daily-monitor/ # Generated example app, skills, and evolution DB
|
||||
├── tests/
|
||||
│ ├── architecture/ # Import, entrypoint, packaging, manifest gates
|
||||
│ ├── contracts/ # Frozen public payload contracts
|
||||
│ ├── unit/ # Owner-aligned unit tests
|
||||
│ └── integration/ # CLI/MCP/TUI/session/tool/skill/communication flows
|
||||
├── .openspace/ # Runtime: embedding cache + skill DB
|
||||
└── logs/ # Execution logs & recordings
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Layout
|
||||
|
||||
Python tests are grouped as focused top-level regressions under
|
||||
`tests/test_*.py`, architecture gates under `tests/architecture/`, and
|
||||
unit-level owner checks under `tests/unit/`. `tests/contracts/` and
|
||||
`tests/integration/` are reserved for restored suites and should not be treated
|
||||
as passing gates unless they contain `test_*.py` files.
|
||||
|
||||
```bash
|
||||
python -m unittest discover -s tests -p 'test_*.py' -v
|
||||
python -m unittest discover -s tests/architecture -p 'test_*.py' -v
|
||||
python -m unittest discover -s tests/unit/grounding -p 'test_*.py' -v
|
||||
```
|
||||
|
||||
For local refactor gates, prefer the targeted suites that match the touched
|
||||
runtime surface:
|
||||
|
||||
```bash
|
||||
python -m unittest tests.architecture.test_public_entrypoint_convergence -v
|
||||
python -m unittest tests.test_direct_tool_pipeline_context tests.test_tool_runtime_permission_hooks -v
|
||||
python -m unittest tests.test_skill_hook_runtime_e2e tests.test_bash_hook_sandbox -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contribute & Roadmap
|
||||
|
||||
We welcome contributions! OpenSpace today evolves *how to do X*. The next frontier: **evolving how agents organize doing X together**.
|
||||
|
||||
Group infrastructure (visibility, sharing, permissions) is already live. What comes next:
|
||||
|
||||
- [ ] **[Kanban](https://github.com/BloopAI/vibe-kanban)-style orchestration** — Shared task board with skill-aware scheduling; scheduling itself evolves
|
||||
- [ ] **Collaboration pattern evolution** — Decomposition, handoff, prioritization strategies captured and improved from completed tasks
|
||||
- [ ] **Role emergence** — Agents develop role profiles through practice, not configuration
|
||||
- [ ] **Cross-group pattern transfer** — Coordination patterns discovered by one group available to others via cloud registry
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Projects
|
||||
|
||||
OpenSpace builds upon the following open-source projects. We sincerely thank their authors and contributors:
|
||||
|
||||
- **[AnyTool](https://github.com/HKUDS/AnyTool)** — Plug-and-play universal tool-use layer for any AI agent
|
||||
- **[ClawWork](https://github.com/HKUDS/ClawWork)** - Transforms AI assistants into true AI coworkers
|
||||
- **[WorldMonitor](https://github.com/koala73/worldmonitor)** - Real-time global intelligence dashboard
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
If you find OpenSpace helpful, please consider giving us a star! ⭐
|
||||
|
||||
<div align="center">
|
||||
<a href="https://star-history.com/#HKUDS/OpenSpace&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=HKUDS/OpenSpace&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=HKUDS/OpenSpace&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=HKUDS/OpenSpace&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
**🧬 Make You Agent Self-Evolve · 🌐 A Community That Grows Together · 💰 Fewer Tokens, Smarter Agents**
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<em> ❤️ Thanks for visiting ✨ OpenSpace!</em><br><br>
|
||||
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.OpenSpace&style=for-the-badge&color=00d4ff"
|
||||
alt="Views">
|
||||
</p>
|
||||
654
README_CN.md
Normal file
654
README_CN.md
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<img src="assets/logo.png" width="320px" style="border: none; box-shadow: none;" alt="OpenSpace Logo">
|
||||
</picture>
|
||||
|
||||
## ✨ OpenSpace:让你的 Agent 更聪明、更省钱、自我进化 ✨
|
||||
|
||||
| 🔋 **Token 用量减少 46%** | **💰 6 小时赚取 $11K** | 🧬 **Skill 自我进化** | 🌐 **Agent 经验共享** |
|
||||
|
||||
[](https://modelcontextprotocol.io/)
|
||||
[](https://www.python.org/)
|
||||
[](https://opensource.org/licenses/MIT/)
|
||||
[](./COMMUNICATION.md)
|
||||
[](./COMMUNICATION.md)
|
||||
|
||||
**一条命令,进化你所有的 AI Agent**:OpenClaw、nanobot、Claude Code、Codex、Cursor 等
|
||||
|
||||
<img src="assets/cli-typing.gif" width="500px" alt="openspace --query your task">
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📢 最新动态
|
||||
|
||||
- **2026-04-09** 💬 多渠道**通信网关**上线。OpenSpace 现可接收并回复外部平台消息。内置 **WhatsApp**(Baileys bridge + 扫码认证)与**飞书**(HTTP webhook)适配器,支持会话管理、附件缓存和白名单访问控制。配置方式见 [`openspace/config/README.md`](openspace/config/README.md)。
|
||||
- **2026-04-07** 🌐 OpenSpace MCP 新增独立 **SSE** 与 **streamable HTTP** 启动方式,便于远端 host 通过 HTTP 接入,绕过基于 stdio 的 MCP server timeout 瓶颈。具体接入方式见 [host integration 文档](openspace/host_skills/README.md)。
|
||||
- **2026-04-06** 🛠️ 修复多项运行时问题,覆盖 grounding、MCP 服务、skill 进化与持久化链路,长流程执行的稳定性与恢复能力进一步提升。
|
||||
- **2026-04-05** 🧭 LLM 凭证解析清理完成:统一 `.env` 加载逻辑,改进宿主配置自动识别,并让 provider 原生环境变量处理更一致。
|
||||
- **2026-04-03** 🚀 发布 **v0.1.0** — Skill 质量监控上线:从优质 Skill 中提取结构模式,每日自动评估所有新提交;云端搜索全面升级,匹配更准、响应更快;社区自发形成生产级垂直 Skill 集群。前端新增中文(zh)国际化支持。
|
||||
- **2026-04-02** ⚡ 云端搜索升级,提升匹配质量、降低响应延迟。
|
||||
- **2026-03-31** 🛡️ 安全加固:zip 解压与 `import_skill` 新增路径穿越防护;CLI 启动时读取 `OPENSPACE_MODEL` 及 `OPENSPACE_LLM_*` 环境变量;修复 MiniMax 兼容性问题与 workflow ID 冲突。
|
||||
- **2026-03-29** 🔒 锁定 litellm 版本至 <1.82.7,规避 PYSEC-2026-2 供应链投毒。
|
||||
- **2026-03-28** 🔧 Skill 注册幂等化——`register_skill_dir` 对已注册目录直接返回已有 `SkillMeta`,不再重复创建。同步更新 OpenClaw 部署文档。
|
||||
- **2026-03-27** 🪟 修复 Windows 下 stdio 死锁;evolver 确认解析改用词干匹配,消除误判。
|
||||
- **2026-03-26** 🌱 Skill 目录支持每次调用时动态重扫描,本地搜索更轻量,文档同步精简。
|
||||
- **2026-03-25** 🎉 OpenSpace 正式开源!
|
||||
|
||||
---
|
||||
|
||||
## 当前 AI Agent 面临的问题
|
||||
|
||||
如今的 AI Agent——[OpenClaw](https://github.com/openclaw/openclaw)、[nanobot](https://github.com/HKUDS/nanobot)、[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Codex](https://github.com/openai/codex)、[Cursor](https://cursor.com) 等——能力强大,但有一个致命弱点:它们从不从真实世界的经验中**学习**、**适应**和**进化**——更不用说相互之间的**共享**了。
|
||||
- **❌ 大量 Token 浪费** - 如何复用成功的任务模式,而非每次都从零推理、烧掉大量 Token?
|
||||
- **❌ 重复犯下高代价的错误** - 如何在 Agent 之间共享解决方案,而非反复进行同样昂贵的探索和犯同样的错?
|
||||
- **❌ Skill 质量差且不可靠** - 当工具和 API 持续演变时,如何保证 Skill 的可靠性——同时确保社区贡献的 Skill 达到严格的质量标准?
|
||||
|
||||
## 🎯 什么是 OpenSpace?
|
||||
|
||||
**🚀 🚀 一个自我进化引擎,让每一次任务都能使每个 Agent 变得更聪明、更高效。**
|
||||
|
||||
https://github.com/user-attachments/assets/c50f70ab-f6db-47bf-9498-3210c0f0abae
|
||||
|
||||
OpenSpace 以 Skill 的形式接入任意 Agent,并赋予其三大超能力:
|
||||
|
||||
### 🧬 自我进化
|
||||
Skill 能够自动学习并持续提升
|
||||
- ✅ **自动修复(AUTO-FIX)** — Skill 出错时,自行即时修复
|
||||
- ✅ **自动改进(AUTO-IMPROVE)** — 成功模式自动升级为更优版本
|
||||
- ✅ **自动学习(AUTO-LEARN)** — 从实际使用中捕获高效工作流
|
||||
- ✅ **质量监控** — 跟踪所有任务中的 Skill 表现、错误率和执行成功率
|
||||
|
||||
**Skill 持续进化——将每次失败转化为改进,将每次成功转化为优化。**
|
||||
|
||||
### 🌐 Agent 集体智慧
|
||||
将独立的 Agent 联结为共享大脑
|
||||
- ✅ **共享进化**:一个 Agent 的改进即成为所有 Agent 的升级
|
||||
- ✅ **网络效应**:更多 Agent → 更丰富的数据 → 每个 Agent 更快进化
|
||||
- ✅ **便捷共享** — 一行命令即可上传或下载进化后的 Skill
|
||||
- ✅ **访问控制** — 每项 Skill 可选择公开、私有或仅团队可见
|
||||
|
||||
**一个 Agent 学会,所有 Agent 受益——大规模集体智慧。**
|
||||
|
||||
### 💰 Token 效率
|
||||
更聪明的 Agent,显著更低的成本
|
||||
- ✅ **不再重复劳动** → 复用成功方案,而非每次从零开始
|
||||
- ✅ **任务越做越便宜** → 随着 Skill 改进,类似工作的成本持续下降
|
||||
- ✅ **只做小幅更新** → 修复损坏的部分,无需全部重建
|
||||
- ✅ **实际节省**:在真实任务上实现 4.2 倍性能提升、Token 消耗减少 46%,带来可衡量的经济价值。([GDPVal](#-基准测试gdpval))
|
||||
|
||||
事半功倍——Agent 真正帮你省钱。
|
||||
|
||||
---
|
||||
|
||||
### 核心差异
|
||||
|
||||
**❌ 当前的 Agent**
|
||||
- 随着工具更迭,Skill 默默退化
|
||||
- 失败模式反复重演,缺乏学习机制
|
||||
- 知识封锁在单个 Agent 内
|
||||
|
||||
**✅ OpenSpace 赋能的 Agent**
|
||||
- 多层监控捕捉问题并自动触发修复
|
||||
- 成功的工作流转化为可复用、可共享的 Skill
|
||||
- 一个 Agent 学到有用的东西,所有 Agent 即刻获得
|
||||
|
||||
### 📊 OpenSpace:让你的 Agent 成为能赚钱的同事
|
||||
|
||||
**🎯 真实世界的硬核结果**
|
||||
在 6 个行业的 50 项专业任务(**📈 [GDPVal 经济基准测试](#-基准测试gdpval)**)上,OpenSpace Agent 使用相同的骨干 LLM(Qwen 3.5-Plus),收入是基线([ClawWork](https://github.com/HKUDS/ClawWork))Agent 的 **4.2 倍**,同时通过 Skill 进化节省了 46% 的 Token 开销。
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/benchmark_kpi.png" width="100%" alt="GDPVal 基准测试 — 核心指标" />
|
||||
</div>
|
||||
|
||||
**💼 这些不是玩具级别的问题**
|
||||
- 根据复杂的工会合同构建工资计算器
|
||||
- 从 15 份散落的 PDF 文档中准备纳税申报表
|
||||
- 起草关于加州隐私法规的法律备忘录
|
||||
- 创建合规表格和工程技术规格书
|
||||
|
||||
**📈 在所有领域全面胜出**
|
||||
- 合规类工作:收入提升 +18.5%
|
||||
- 工程类项目:性能提升 +8.7%
|
||||
- 专业文档类:Token 需求减少 56%
|
||||
- 所有类别均有提升——无一例外
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/benchmark_task_showcase.png" width="100%" alt="GDPVal 基准测试 — 各类别任务展示" />
|
||||
</div>
|
||||
|
||||
**OpenSpace 不仅让 Agent 更聪明** —— 更让它们具备经济可行性。真实工作、真实收入、可衡量的成果。
|
||||
|
||||
## OpenSpace 自主系统开发案例
|
||||
|
||||
**🖥️ [My Daily Monitor](examples/my-daily-monitor/README.md)** — OpenSpace 赋能你的 Agent 完成大规模系统开发。这个拥有 20 多个实时仪表盘面板的个人行为监控系统位于 [`examples/my-daily-monitor`](examples/my-daily-monitor),完全由 Agent 构建——通过 OpenSpace 从零进化出 60 多项 Skill,展示了自主端到端软件开发能力。
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/my_daily_monitor_dark.png" width="100%" alt="My Daily Monitor – 深色模式" />
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📋 目录
|
||||
|
||||
- [⚡ 快速开始](#-快速开始)
|
||||
- [🤖 路径 A:为你的 Agent 接入](#-路径-a为你的-agent-接入)
|
||||
- [👤 路径 B:作为你的 AI 协作者](#-路径-b作为你的-ai-协作者)
|
||||
- [📊 本地仪表盘](#-本地仪表盘)
|
||||
- [📈 基准测试:GDPVal](#-基准测试gdpval)
|
||||
- [📊 案例展示:My Daily Monitor](#-案例展示my-daily-monitor)
|
||||
- [🏗️ 框架](#️-框架)
|
||||
- [🧬 自我进化引擎](#-自我进化引擎)
|
||||
- [🌐 云端 Skill 社区](#-云端-skill-社区)
|
||||
- [🔧 高级配置](#-高级配置)
|
||||
- [📖 代码结构](#-代码结构)
|
||||
- [🧪 测试结构](#-测试结构)
|
||||
- [🔗 相关项目](#-相关项目)
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 快速开始
|
||||
|
||||
🌐 **只想看看?** 在 **[open-space.cloud](https://open-space.cloud)** 浏览社区 Skill 和进化谱系——无需安装。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/OpenSpace.git && cd OpenSpace
|
||||
pip install -e .
|
||||
openspace-mcp --help # 验证安装
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Clone 太慢?** `assets/` 目录包含约 50 MB 的图片文件,导致仓库较大。使用以下轻量方式跳过它:
|
||||
> ```bash
|
||||
> git clone --filter=blob:none --sparse https://github.com/HKUDS/OpenSpace.git
|
||||
> cd OpenSpace
|
||||
> git sparse-checkout set '/*' '!assets/'
|
||||
> pip install -e .
|
||||
> ```
|
||||
|
||||
**选择你的路径:**
|
||||
- **[路径 A](#-路径-a为你的-agent-接入)** — 将 OpenSpace 接入你的 Agent
|
||||
- **[路径 B](#-路径-b作为你的-ai-协作者)** — 直接使用 OpenSpace 作为你的 AI 协作者
|
||||
|
||||
### 🤖 路径 A:为你的 Agent 接入
|
||||
|
||||
适用于任何可启动 MCP server 并读取 Skill(`SKILL.md`)的宿主。OpenSpace 内置 OpenClaw 与 nanobot 的宿主辅助能力,也可以手动接入 Claude Code、Codex、Cursor 或其他支持 MCP 的 Agent。
|
||||
|
||||
**① 将 OpenSpace 添加到你的 Agent 的 MCP 配置中:**
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"openspace": {
|
||||
"command": "openspace-mcp",
|
||||
"toolTimeout": 600,
|
||||
"env": {
|
||||
"OPENSPACE_HOST_SKILL_DIRS": "/path/to/your/agent/skills",
|
||||
"OPENSPACE_WORKSPACE": "/path/to/OpenSpace",
|
||||
"OPENSPACE_CLOUD_MODE": "live",
|
||||
"OPENSPACE_CLOUD_API_KEY": "sk-xxx(可选,用于云端)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> 凭证(API 密钥、模型)会从 nanobot 和 OpenClaw 配置中自动检测。其他宿主请设置 `OPENSPACE_LLM_API_KEY` / `OPENSPACE_MODEL`,或使用 `openspace/.env`。
|
||||
|
||||
> [!NOTE]
|
||||
> OpenSpace 支持 3 种启动方式:
|
||||
> - **stdio**:在宿主配置里保留 `command: "openspace-mcp"`。
|
||||
> - **SSE**:先启动 `openspace-mcp --transport sse --host 127.0.0.1 --port 8080`。
|
||||
> - **streamable HTTP**:先启动 `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081`。
|
||||
>
|
||||
> 通用远端 endpoint:
|
||||
> - SSE: `http://127.0.0.1:8080/sse`
|
||||
> - streamable HTTP: `http://127.0.0.1:8081/mcp`
|
||||
>
|
||||
> `stdio` 最简单。HTTP 模式会把 OpenSpace 作为独立服务常驻,但 **不同宿主的注册写法不同**,而且 **调用方自己的 timeout 仍然生效**。
|
||||
|
||||
**② 将 Skill 复制**到你的 Agent Skill 目录:
|
||||
|
||||
```bash
|
||||
cp -r OpenSpace/openspace/host_skills/delegate-task/ /path/to/your/agent/skills/
|
||||
cp -r OpenSpace/openspace/host_skills/skill-discovery/ /path/to/your/agent/skills/
|
||||
```
|
||||
|
||||
完成。这两项 Skill 会教你的 Agent 何时以及如何使用 OpenSpace——无需额外提示。你的 Agent 现在可以自我进化 Skill、执行复杂任务、访问云端 Skill 社区。你也可以添加自定义 Skill——参见 [`openspace/skills/README.md`](openspace/skills/README.md)。
|
||||
|
||||
> [!NOTE]
|
||||
> **云端社区(可选):** 运行 `openspace-cloud-auth bootstrap-agent-key --email you@example.com --agent-name openspace-local-agent` 来创建 owner 作用域的 cloud agent key。命令会在本地保存 `OPENSPACE_CLOUD_MODE=live` 和 `OPENSPACE_CLOUD_API_KEY`,不会打印原始 key。即使没有云端 key,所有本地功能(任务执行、进化、本地 Skill 搜索)也能正常运行。
|
||||
|
||||
📖 各 Agent 配置(OpenClaw / nanobot)、所有环境变量、高级设置:[`openspace/host_skills/README.md`](openspace/host_skills/README.md)
|
||||
|
||||
### 👤 路径 B:作为你的 AI 协作者
|
||||
|
||||
直接使用 OpenSpace——编码、搜索、工具调用等——内置自我进化 Skill 和云端社区。
|
||||
|
||||
> [!NOTE]
|
||||
> 创建 `.env` 文件并填入你的 LLM API 密钥;如需访问云端社区,用 `openspace-cloud-auth bootstrap-agent-key` 创建并保存 agent key(参考 [`openspace/.env.example`](openspace/.env.example))。
|
||||
|
||||
```bash
|
||||
# 交互模式
|
||||
openspace
|
||||
|
||||
# 执行任务
|
||||
openspace --model "anthropic/claude-sonnet-4-5" --query "Create a monitoring dashboard for my Docker containers"
|
||||
```
|
||||
|
||||
添加自定义 Skill:[`openspace/skills/README.md`](openspace/skills/README.md)。
|
||||
|
||||
**Cloud CLI** — 通过命令行管理 Skill:
|
||||
|
||||
```bash
|
||||
openspace-download-skill <skill_id> # 从云端下载 Skill
|
||||
openspace-upload-skill /path/to/skill/dir # 上传 Skill 到云端
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Python API</b></summary>
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from openspace import OpenSpace
|
||||
from openspace.runtime import ExecutionRequest
|
||||
|
||||
async def main():
|
||||
async with OpenSpace() as cs:
|
||||
result = await cs.execute(
|
||||
ExecutionRequest(
|
||||
prompt="Analyze GitHub trending repos and create a report",
|
||||
)
|
||||
)
|
||||
print(result.text)
|
||||
|
||||
for skill in result.evolved_skills:
|
||||
print(f" Evolved: {skill['name']} ({skill['origin']})")
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 📊 本地仪表盘
|
||||
|
||||
查看你的 Skill 如何进化——浏览 Skill、追踪谱系、比较差异。
|
||||
|
||||
> 需要 **Node.js ≥ 20**。
|
||||
|
||||
```bash
|
||||
# 终端 1:启动后端 API
|
||||
openspace-dashboard --port 7788
|
||||
|
||||
# 终端 2:启动前端开发服务器
|
||||
cd apps/dashboard
|
||||
npm install # 仅首次需要
|
||||
npm run dev
|
||||
```
|
||||
|
||||
📖 **前端设置指南**:[`apps/dashboard/README.md`](apps/dashboard/README.md)
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="assets/frontend_1.gif" width="100%" alt="Skill 类别" /></td>
|
||||
<td width="50%"><img src="assets/frontend_2.gif" width="100%" alt="云端 Skill 记录" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><sub>Skill 类别 — 浏览、搜索与排序</sub></td>
|
||||
<td align="center"><sub>云端 — 浏览与发现 Skill 记录</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="assets/frontend_3.gif" width="100%" alt="版本谱系" /></td>
|
||||
<td width="50%"><img src="assets/frontend_4.gif" width="100%" alt="工作流会话" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><sub>版本谱系 — Skill 进化图谱</sub></td>
|
||||
<td align="center"><sub>工作流会话 — 执行历史与指标</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📈 基准测试:GDPVal
|
||||
|
||||
我们在 [GDPVal](https://huggingface.co/datasets/openai/gdpval) 上评估 OpenSpace——该数据集包含 220 项真实世界的专业任务,涵盖 44 个职业——采用 [ClawWork](https://github.com/HKUDS/ClawWork) 评测协议,使用相同的生产力工具和基于 LLM 的评分方式。我们的两阶段设计(Cold Start → Warm Rerun)展示了积累的 Skill 如何随时间降低 Token 消耗。
|
||||
|
||||
公平基准:OpenSpace 使用 Qwen 3.5-Plus 作为骨干 LLM——与 ClawWork 基线 Agent 完全相同——确保性能差异纯粹来源于 Skill 进化,而非模型能力差异。
|
||||
|
||||
真实经济价值:任务涵盖构建工资计算器、准备纳税申报表、起草法律备忘录等——这些都是产生真实 GDP 的专业工作,同时从质量和成本效率两个维度进行评估。
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/benchmark_income.png" width="100%" alt="GDPVal 基准测试 — 收入对比" />
|
||||
</div>
|
||||
|
||||
- **收入提升 4.2 倍** — 相比使用相同骨干 LLM(Qwen 3.5-Plus)的 ClawWork
|
||||
- **72.8% 价值捕获率** — 在 $15,764 的任务总价值中赚取 $11,484,超越所有 Agent
|
||||
- **70.8% 平均质量** — 比最佳 ClawWork Agent(40.8%)高出 30 个百分点
|
||||
- **Phase 2 的 Token 用量仅为 Phase 1 的 45.9%** — 更好的结果,显著更低的成本
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/benchmark_quality_tokens.png" width="100%" alt="GDPVal 基准测试 — 质量与 Token 效率" />
|
||||
</div>
|
||||
|
||||
### OpenSpace 能处理哪些真实任务?
|
||||
|
||||
50 项 GDPVal 任务涵盖 6 个真实工作类别。
|
||||
- **Phase 1(Cold Start)** 按顺序执行全部 50 项任务——每项任务完成后,Skill 积累到共享数据库中。
|
||||
- **Phase 2(Warm Rerun)** 使用 Phase 1 中完整的进化 Skill 库,重新执行相同的 50 项任务。
|
||||
|
||||
收入捕获率 = 实际获得报酬 ÷ 任务最大可能价值
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/benchmark_task_showcase.png" width="100%" alt="GDPVal 基准测试 — 各类别任务展示" />
|
||||
</div>
|
||||
|
||||
## 🎯 进化在何处产生最大影响——以及原因:
|
||||
|
||||
| 类别 | 收入变化 | Token 变化 | 原因 |
|
||||
|---|---|---|---|
|
||||
| **📝 文档与通信** (7) | 71→74% (+3.3pp) | −56% | 规范的正式输出——加州隐私法备忘录、监控调查报告、子女抚养案例报告。`document-gen-fallback` Skill 族历经 13 个版本进化,使结构化输出和错误恢复接近全自动。 |
|
||||
| **📋 合规与表单** (11) | 51→70% (+18.5pp) | −51% | 结构化 PDF——从 15 份源文档生成纳税申报表、药房合规检查清单、临床交接模板。PDF Skill 链(检查清单逻辑 → reportlab 排版 → 验证)只需进化一次,所有表单任务即可复用完整流水线。 |
|
||||
| **🎬 媒体制作** (3) | 53→58% (+5.8pp) | −46% | 通过 Python 和 ffmpeg 处理音视频——根据鼓点参考生成巴萨诺瓦器乐、从 5 轨中编辑低音分轨、从 13 段源视频制作 CGI 集锦。进化的 Skill 编码了可用的 ffmpeg 参数和编解码器回退策略,消除了沙箱中的反复试错。 |
|
||||
| **🛠️ 工程** (4) | 70→78% (+8.7pp) | −43% | 多交付物技术项目——Web3 全栈(Solidity + React + 测试)、CNC 工作站安全系统(报告 + 布局图 + 硬件表)、航空航天 CFD 报告。协调类 Skill 在这些多样化任务之间通用迁移。 |
|
||||
| **📊 电子表格** (15) | 63→70% (+7.3pp) | −37% | 功能性 .xlsx 工具——根据工会合同构建工资计算器、基于历史数据预测销售、含竞品对标的定价模型。电子表格模式(公式、合并单元格、数据验证)在各领域完全通用。 |
|
||||
| **📈 战略与分析** (10) | 88→89% (+1.0pp) | −32% | 战略建议——供应商谈判策略、非营利项目评估、3 亿美元交易台的能源交易分析。质量已处最高水平(88%);节省来自于文档结构和多文件编排的复用。 |
|
||||
|
||||
### 进化产出了什么?(165 项 Skill)
|
||||
|
||||
在 50 项 Phase 1 任务中,OpenSpace 自主进化出 **165 项 Skill**。突破性发现:这些不仅是领域知识——它们是**鲁棒的执行模式**和**质量保障工作流**。Agent 学会了如何在不完美的真实世界环境中可靠地交付成果。
|
||||
|
||||
**关键发现**:大多数 Skill 聚焦于工具可靠性和错误恢复,而非特定任务知识。
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/benchmark_skill_taxonomy.png" width="100%" alt="GDPVal 基准测试 — 进化 Skill 分类" />
|
||||
</div>
|
||||
|
||||
| 用途 | 数量 | Skill 教会 Agent 什么 |
|
||||
|---|---|---|
|
||||
| **文件格式 I/O** | 44 | PDF 解析回退、DOCX 解析、Excel 合并单元格处理、PPTX 创建。其中 32/44 从真实失败中*捕获*——每一条都是生产环境中解决的 Bug。 |
|
||||
| **执行恢复** | 29 | 分层回退:沙箱失败 → Shell → 写文件后运行 → heredoc。28/29 从实际崩溃中*捕获*。这是使一切其他 Skill 可靠运行的基础。 |
|
||||
| **文档生成** | 26 | 端到端文档流水线。`document-gen-fallback` 从 1 项导入 Skill 进化为 **13 个衍生版本**——进化最深入的 Skill 族。 |
|
||||
| **质量保障** | 23 | 写后验证:检查 Excel 行数、验证 PDF 页数、校验电子表格公式。Phase 2 质量提升的关键——Agent 不仅*生产*,还*验证*。 |
|
||||
| **任务编排** | 17 | 多文件追踪、ZIP 打包、零迭代失败检测。适用于所有多交付物任务类型的元 Skill。 |
|
||||
| **领域工作流** | 13 | SOAP 病历记录、音频制作(从 1 个模板衍生 **4 代**)、视频流水线。数量虽少,但在各自领域内进化深度显著。 |
|
||||
| **网络与研究** | 11 | SSL/代理调试、搜索回退、JS 重页面处理。包含 2 项*修复* Skill——网络访问本质上不稳定。 |
|
||||
|
||||
**复现实验、分析工具与结果**:[`benchmarks/gdpval/README.md`](benchmarks/gdpval/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 📊 案例展示:My Daily Monitor
|
||||
|
||||
> **零行人工编写的代码。** 60 多项 Skill 从零进化,构建出一个完整可用的实时仪表盘。
|
||||
|
||||
**My Daily Monitor** 是一个常驻运行的仪表盘,实时展示进程、服务器、新闻、市场、邮件和日程——内置 AI Agent。
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/my_daily_monitor_light.png" width="90%" alt="My Daily Monitor – 浅色模式" />
|
||||
</div>
|
||||
|
||||
### OpenSpace 如何从零构建它
|
||||
|
||||
| 阶段 | 发生了什么 | Skill |
|
||||
|-------|------------|-------|
|
||||
| 🌱 **种子期** | 分析开源项目 [WorldMonitor](https://github.com/koala73/worldmonitor),提取参考模式 | 6 项初始 Skill |
|
||||
| 🏗️ **脚手架** | 生成项目结构、Vite 配置、TypeScript 设置 | +8 项 Skill |
|
||||
| 🎨 **构建** | 创建 20 多个面板,配合数据服务、API 路由、网格布局 | +25 项 Skill |
|
||||
| 🔧 **修复** | 自动修复 TypeScript 错误、API 不匹配、CSS 冲突 | +12 项 FIX 进化 |
|
||||
| 🧬 **进化** | 衍生增强模式,合并互补 Skill | +15 项 DERIVED Skill |
|
||||
| 📦 **捕获** | 从成功执行中提取可复用模式 | +8 项 CAPTURED Skill |
|
||||
|
||||
### 📈 Skill 进化图谱
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/my_daily_monitor_evograph.png" width="90%" alt="Skill 进化图谱" />
|
||||
</div>
|
||||
|
||||
> 每个节点代表 OpenSpace 学习、提取或精炼的一项 Skill。完整的进化历史已在 [`examples/my-daily-monitor/.openspace/openspace.db`](examples/my-daily-monitor/.openspace/openspace.db) 中开源,生成的应用源码位于 [`examples/my-daily-monitor`](examples/my-daily-monitor)——可用任意 SQLite 浏览器加载数据库,探索谱系、差异和质量指标。
|
||||
|
||||
**完整详情**:[`examples/my-daily-monitor/README.md`](examples/my-daily-monitor/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ OpenSpace 框架
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/framework.png" width="90%" alt="OpenSpace 框架" />
|
||||
</div>
|
||||
|
||||
### 🧬 自我进化引擎
|
||||
|
||||
OpenSpace 的核心。Skill 不是静态文件——它们是能够自动选择、应用、监控、分析和进化自身的"活"实体。
|
||||
|
||||
#### 🔄 自主与持续进化
|
||||
|
||||
- **全生命周期管理**:从发现到应用到进化——全程无需人工干预。无论是否存在匹配的 Skill,OpenSpace 都能完成任务。
|
||||
|
||||
**三种进化模式**:
|
||||
- 🔧 FIX — 就地修复损坏或过时的指令。同一 Skill,新版本。
|
||||
- 🚀 DERIVED — 从父 Skill 创建增强版或专用版。新 Skill 目录,与父 Skill 共存。
|
||||
- ✨ CAPTURED — 从成功执行中提取全新的可复用模式。全新 Skill,无父级。
|
||||
|
||||
**三个独立触发器**:多层防线抵御 Skill 退化——无论执行成功还是失败都驱动进化。
|
||||
- **📈 执行后分析** — 每次任务完成后运行。分析完整记录,为相关 Skill 建议 FIX/DERIVED/CAPTURED。
|
||||
- **⚠️ 工具退化检测** — 当工具成功率下降时,质量监控器找到所有依赖的 Skill 并批量进化。
|
||||
- **📊 指标监控** — 定期扫描 Skill 健康指标(应用率、完成率、回退率),进化表现不佳者。
|
||||
|
||||
#### 📊 全栈质量监控
|
||||
多层追踪:质量监控覆盖整个执行栈——从高层工作流到单个工具调用:
|
||||
- **🎯 Skill** — 应用率、完成率、有效率、回退率
|
||||
- **🔨 工具调用** — 成功率、延迟、标记的问题
|
||||
- **⚡ 代码执行** — 执行状态、错误模式
|
||||
|
||||
**级联进化**:当任何组件退化时——无论是 Skill 工作流还是单个工具调用——上游所有依赖的 Skill 自动触发进化,维持系统级一致性。
|
||||
|
||||
#### 🔧 智能且安全的进化
|
||||
**🤖 自主进化**:每次进化都会探索代码库、发现根因、自主决定修复——在做出改变之前收集真实证据,而非盲目生成。
|
||||
|
||||
**⚡ 基于 Diff 且节省 Token**:生成最小化的、有针对性的 Diff,而非全量重写,失败时自动重试。每个版本存储在版本 DAG 中,支持完整的谱系追踪。
|
||||
|
||||
**🛡️ 内置安全防护**:
|
||||
- 确认门控减少误触发
|
||||
- 反循环守卫防止进化失控
|
||||
- 安全检查标记危险模式(Prompt Injection、凭证窃取)
|
||||
- 进化后的 Skill 经验证后才替换前代
|
||||
|
||||
**🌐 协作 Skill 社区**
|
||||
一个协作式注册中心,Agent 在此共享进化后的 Skill。当一个 Agent 完成改进,所有连接的 Agent 都可以发现、导入并在此基础上构建——将个体进步转化为集体智慧。
|
||||
|
||||
- **🔐 灵活共享**:可选择公开分享、团队内分享或保持私有。智能搜索帮你找到所需并自动导入。每次进化都有完整 Diff 的谱系追踪。
|
||||
|
||||
- **☁️ 协作平台**:open-space.cloud — 注册获取 API 密钥、浏览社区 Skill、管理你的团队。
|
||||
|
||||
---
|
||||
|
||||
## 🔧 高级配置
|
||||
|
||||
对大多数用户而言,[快速开始](#-快速开始)就是你所需的全部。如需高级选项(持久化 `settings.json`、环境变量、执行模式、安全策略等),请参见 [`openspace/config/README.md`](openspace/config/README.md)。
|
||||
|
||||
### Skill Evolution Runtime
|
||||
|
||||
OpenSpace 默认以 `autonomous` 模式启用进化引擎:
|
||||
|
||||
```env
|
||||
OPENSPACE_EVOLUTION_ENGINE_ENABLED=1
|
||||
OPENSPACE_EVOLUTION_MODE=autonomous
|
||||
```
|
||||
|
||||
模式:
|
||||
|
||||
- `audit_only`:只记录 job、packet、decision 和 admission,不写入 Skill。
|
||||
- `fix_only`:显式/手动 FIX job(例如 MCP `fix_skill`)可在完成 evidence、admission、staged authoring、validation 和 commit 后提交。DERIVED 和 CAPTURED action 会记录为 policy-blocked candidate/proposal,不会直接提交,也不会自动复审。
|
||||
- `autonomous`:默认模式;所有通过 admission 且完成验证的 FIX/DERIVED/CAPTURED action 都可以提交。
|
||||
只有在你想暂停所有进化 job 处理时,才设置 `OPENSPACE_EVOLUTION_ENGINE_ENABLED=0`。
|
||||
|
||||
---
|
||||
|
||||
<a id="-代码结构"></a>
|
||||
<details>
|
||||
<summary><b>📖 代码结构</b></summary>
|
||||
|
||||
> **图例**:⚡ 核心模块 | 🧬 Skill 进化 | 🌐 云端 | 🔧 支撑模块
|
||||
|
||||
```
|
||||
OpenSpace/
|
||||
├── openspace/
|
||||
│ ├── runtime/ # Runtime 拥有 services、状态、session/workspace 编排和执行生命周期
|
||||
│ ├── application.py # OpenSpace/OpenSpaceConfig 公共 facade;生命周期委托给 runtime
|
||||
│ ├── entrypoints/ # CLI、TUI、MCP、gateway 和 dashboard 进程入口
|
||||
│ │
|
||||
│ ├── ⚡ agents/ # Agent 系统
|
||||
│ │ ├── base.py # 基础 Agent 类
|
||||
│ │ └── grounding_agent.py # 执行 Agent(工具调用、迭代、Skill 注入)
|
||||
│ │
|
||||
│ ├── ⚡ grounding/ # 统一后端系统
|
||||
│ │ ├── core/
|
||||
│ │ │ ├── grounding_client.py # 跨所有后端的统一接口
|
||||
│ │ │ ├── search_tools.py # 智能工具 RAG(BM25 + embedding + LLM)
|
||||
│ │ │ ├── quality/ # 工具质量追踪与自我进化
|
||||
│ │ │ ├── security/ # 策略、沙箱、E2B
|
||||
│ │ │ ├── meta/ # Meta provider 与工具
|
||||
│ │ │ ├── transport/ # 连接器与任务管理器
|
||||
│ │ │ └── tool/ # 工具抽象(基础、本地、远程)
|
||||
│ │ └── backends/
|
||||
│ │ ├── shell/ # Shell 命令执行
|
||||
│ │ ├── gui/ # Anthropic Computer Use
|
||||
│ │ ├── mcp/ # Model Context Protocol(stdio、HTTP、WebSocket)
|
||||
│ │ └── web/ # 网络搜索与浏览
|
||||
│ │
|
||||
│ ├── 🧬 skill_engine/ # 自我进化 Skill 系统
|
||||
│ │ ├── registry.py # Skill 目录、frontmatter 解析、内容加载
|
||||
│ │ ├── protocol.py # Skill/DiscoverSkills 工具与 listing attachment
|
||||
│ │ ├── analyzer.py # 执行后分析(Agent 循环 + 工具访问)
|
||||
│ │ ├── evolver.py # FIX / DERIVED / CAPTURED 进化(3 种触发器)
|
||||
│ │ ├── patch.py # 多文件 FULL / DIFF / PATCH 应用
|
||||
│ │ ├── store.py # SQLite 持久化、版本 DAG、质量指标
|
||||
│ │ ├── skill_ranker.py # BM25 + embedding 混合排序
|
||||
│ │ ├── fuzzy_match.py # Skill 发现的模糊匹配
|
||||
│ │ ├── conversation_formatter.py # 格式化执行历史以供分析
|
||||
│ │ ├── skill_utils.py # 共享 Skill 工具函数
|
||||
│ │ └── types.py # SkillRecord、SkillLineage、EvolutionSuggestion
|
||||
│ │
|
||||
│ ├── 🌐 cloud/ # 云端 Skill 社区
|
||||
│ │ ├── client.py # HTTP 客户端(上传、下载、搜索)
|
||||
│ │ ├── account.py # 用户注册与 agent key 生命周期客户端
|
||||
│ │ ├── auth_flow.py # 账号 bootstrap 与校验流程
|
||||
│ │ ├── search.py # 混合搜索引擎
|
||||
│ │ ├── embedding.py # Skill 搜索的向量生成
|
||||
│ │ └── cli/ # CLI 工具(auth、download_skill、upload_skill)
|
||||
│ │
|
||||
│ ├── 💬 communication/ # 多渠道网关运行时支撑
|
||||
│ │ ├── gateway_runtime.py # 网关锁与运行状态
|
||||
│ │ ├── runtime_manager.py # 按频道的 OpenSpace runtime 生命周期
|
||||
│ │ ├── adapters/ # 平台适配器(WhatsApp、飞书)
|
||||
│ │ ├── bridges/ # 非 Python 运行时(WhatsApp Baileys bridge)
|
||||
│ │ ├── config.py # 通信配置加载
|
||||
│ │ ├── session_store.py # 按频道的会话持久化
|
||||
│ │ └── types.py # ChannelMessage, ChannelSource, SendResult
|
||||
│ │
|
||||
│ ├── 🚪 entrypoints/ # 公开 CLI / server 入口
|
||||
│ │ ├── cli/main.py # `openspace`
|
||||
│ │ ├── dashboard/server.py # `openspace-dashboard`
|
||||
│ │ ├── gateway/server.py # `openspace-gateway`
|
||||
│ │ ├── mcp/server.py # `openspace-mcp`
|
||||
│ │ └── tui/controller.py # TypeScript TUI bridge controller
|
||||
│ │
|
||||
│ ├── 🔧 platforms/ # 平台抽象(系统信息、截图)
|
||||
│ ├── 🔧 host_detection/ # 自动检测 nanobot / openclaw 凭证
|
||||
│ ├── 🔧 host_skills/ # 面向 Agent 集成的 SKILL.md 定义
|
||||
│ │ ├── delegate-task/SKILL.md # 教 Agent:执行、修复、上传
|
||||
│ │ └── skill-discovery/SKILL.md # 教 Agent:搜索与发现 Skill
|
||||
│ ├── 🔧 prompts/ # LLM Prompt 模板(grounding + Skill 引擎)
|
||||
│ ├── 🔧 llm/ # LiteLLM 封装,含重试与限流
|
||||
│ ├── 🔧 config/ # 分层配置系统
|
||||
│ ├── 🔧 local_server/ # GUI 后端 Flask 服务器;Shell 后端仅支持本地模式
|
||||
│ ├── 🔧 recording/ # 执行录制、截图与视频捕获
|
||||
│ ├── 🔧 utils/ # 日志、UI、遥测
|
||||
│ └── 📦 skills/ # 内置 Skill(最低优先级,用户可在此添加)
|
||||
│
|
||||
├── apps/
|
||||
│ ├── dashboard/ # 仪表盘 UI(React + Tailwind)
|
||||
│ └── tui/ # TypeScript 终端 UI
|
||||
├── benchmarks/
|
||||
│ └── gdpval/ # GDPVal 基准测试实验与结果
|
||||
├── examples/
|
||||
│ └── my-daily-monitor/ # 生成的示例应用、Skill 与进化数据库
|
||||
├── tests/
|
||||
│ ├── architecture/ # import、入口、packaging、manifest 门禁
|
||||
│ ├── contracts/ # 冻结的公开 payload contract
|
||||
│ ├── unit/ # 按架构 owner 分组的单元测试
|
||||
│ └── integration/ # CLI/MCP/TUI/session/tool/skill/communication 流程
|
||||
├── .openspace/ # 运行时:embedding 缓存 + Skill 数据库
|
||||
└── logs/ # 执行日志与录制
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试结构
|
||||
|
||||
Python 测试主要分为顶层 `tests/test_*.py` 重点回归、`tests/architecture/`
|
||||
架构门禁、以及 `tests/unit/` owner 级单元测试。`tests/contracts/` 和
|
||||
`tests/integration/` 是恢复套件的保留目录,目录里没有 `test_*.py` 时不能把
|
||||
discover 空跑当作通过信号。
|
||||
|
||||
```bash
|
||||
python -m unittest discover -s tests -p 'test_*.py' -v
|
||||
python -m unittest discover -s tests/architecture -p 'test_*.py' -v
|
||||
python -m unittest discover -s tests/unit/grounding -p 'test_*.py' -v
|
||||
```
|
||||
|
||||
本地重构验证优先使用和本次改动面匹配的 targeted suite:
|
||||
|
||||
```bash
|
||||
python -m unittest tests.architecture.test_public_entrypoint_convergence -v
|
||||
python -m unittest tests.test_direct_tool_pipeline_context tests.test_tool_runtime_permission_hooks -v
|
||||
python -m unittest tests.test_skill_hook_runtime_e2e tests.test_bash_hook_sandbox -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关项目
|
||||
|
||||
OpenSpace 构建于以下开源项目之上。我们衷心感谢其作者和贡献者:
|
||||
|
||||
- **[AnyTool](https://github.com/HKUDS/AnyTool)** — 面向任意 AI Agent 的即插即用通用工具层
|
||||
- **[ClawWork](https://github.com/HKUDS/ClawWork)** — 将 AI 助手转变为真正的 AI 同事
|
||||
- **[WorldMonitor](https://github.com/koala73/worldmonitor)** — 实时全球情报仪表盘
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
## ⭐ Star 历史
|
||||
|
||||
如果 OpenSpace 对你有帮助,请给我们一颗 Star!⭐
|
||||
|
||||
<div align="center">
|
||||
<a href="https://star-history.com/#HKUDS/OpenSpace&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=HKUDS/OpenSpace&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=HKUDS/OpenSpace&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=HKUDS/OpenSpace&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
**🧬 让你的 Agent 自我进化 · 🌐 一个共同成长的社区 · 💰 更少 Token,更聪明的 Agent**
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<em> ❤️ 感谢访问 ✨ OpenSpace!</em><br><br>
|
||||
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.OpenSpace&style=for-the-badge&color=00d4ff"
|
||||
alt="Views">
|
||||
</p>
|
||||
4
apps/dashboard/.env.example
Normal file
4
apps/dashboard/.env.example
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
VITE_HOST=127.0.0.1
|
||||
VITE_PORT=3888
|
||||
VITE_API_PROXY_TARGET=http://127.0.0.1:7788
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
98
apps/dashboard/README.md
Normal file
98
apps/dashboard/README.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# OpenSpace Frontend
|
||||
|
||||
A dashboard frontend for the OpenSpace, providing skill browsing, lineage visualization, and workflow inspection.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js ≥ 20**
|
||||
|
||||
## First-time Setup
|
||||
|
||||
1. **Copy the environment file**
|
||||
|
||||
```bash
|
||||
cd apps/dashboard
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` if your backend runs on a different host/port:
|
||||
|
||||
```dotenv
|
||||
VITE_HOST=127.0.0.1
|
||||
VITE_PORT=3888
|
||||
VITE_API_PROXY_TARGET=http://127.0.0.1:7788
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Start the backend** (in a separate terminal)
|
||||
|
||||
```bash
|
||||
# option A – CLI entry point
|
||||
openspace-dashboard --host 127.0.0.1 --port 7788
|
||||
|
||||
# option B – from the repo root
|
||||
python -m openspace.entrypoints.dashboard.server --host 127.0.0.1 --port 7788
|
||||
```
|
||||
|
||||
> Requires Python ≥ 3.12 with `flask` installed.
|
||||
|
||||
4. **Start the frontend**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The dev server will be available at `http://127.0.0.1:3888` (or whatever `VITE_PORT` you set).
|
||||
|
||||
## Subsequent Starts
|
||||
|
||||
Once `.env` is configured and dependencies are installed, you only need:
|
||||
|
||||
```bash
|
||||
# terminal 1 – backend
|
||||
openspace-dashboard --host 127.0.0.1 --port 7788
|
||||
|
||||
# terminal 2 – frontend
|
||||
cd apps/dashboard
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Default URLs
|
||||
|
||||
| Service | URL |
|
||||
| ------------------- | ------------------------ |
|
||||
| Frontend dev server | `http://127.0.0.1:3888` |
|
||||
| Dashboard API | `http://127.0.0.1:7788` |
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Bypass the Vite proxy
|
||||
|
||||
If you prefer to call the backend directly (e.g. for debugging), you can set `VITE_API_BASE_URL` to the full backend URL:
|
||||
|
||||
```bash
|
||||
VITE_API_BASE_URL=http://127.0.0.1:7788/api/v1 npm run dev
|
||||
```
|
||||
|
||||
## Production Build
|
||||
|
||||
```bash
|
||||
cd apps/dashboard
|
||||
npm run build
|
||||
```
|
||||
|
||||
After building, start the backend and it will serve `apps/dashboard/dist` as static files automatically — no need to run the dev server.
|
||||
|
||||
## Main Pages
|
||||
|
||||
- **Dashboard** – overall health, pipeline stages, top skills, recent workflows
|
||||
- **Skills** – searchable skill list and score breakdown
|
||||
- **Skill Detail** – source preview, lineage graph, scoring metrics, recent analyses
|
||||
- **Workflows** – recorded workflow sessions from `logs/recordings` and `logs/trajectories`
|
||||
- **Workflow Detail** – timeline, artifacts, metadata, selected skills, plans, and decisions
|
||||
16
apps/dashboard/index.html
Normal file
16
apps/dashboard/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/webp" href="/openspace_icon.webp" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Neuton:ital,wght@0,400;0,700;1,400&family=Cabin:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
<title>OpenSpace Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3580
apps/dashboard/package-lock.json
generated
Normal file
3580
apps/dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
apps/dashboard/package.json
Normal file
32
apps/dashboard/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "openspace-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"copy:packaged": "node ../../scripts/copy-dist-to-packaged.mjs dist ../../openspace/packaged/dashboard",
|
||||
"build:packaged": "npm run build && npm run copy:packaged",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"i18next": "^26.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-force-graph-2d": "^1.25.4",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-router-dom": "^7.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
6
apps/dashboard/postcss.config.js
Normal file
6
apps/dashboard/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
apps/dashboard/public/openspace_icon.webp
Normal file
BIN
apps/dashboard/public/openspace_icon.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
28
apps/dashboard/src/App.tsx
Normal file
28
apps/dashboard/src/App.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Navigate, RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import EvolutionPage from './pages/EvolutionPage';
|
||||
import SkillsPage from './pages/SkillsPage';
|
||||
import SkillDetailPage from './pages/SkillDetailPage';
|
||||
import WorkflowsPage from './pages/WorkflowsPage';
|
||||
import WorkflowDetailPage from './pages/WorkflowDetailPage';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <MainLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <DashboardPage /> },
|
||||
{ path: 'evolution', element: <EvolutionPage /> },
|
||||
{ path: 'skills', element: <SkillsPage /> },
|
||||
{ path: 'skills/:skillId', element: <SkillDetailPage /> },
|
||||
{ path: 'workflows', element: <WorkflowsPage /> },
|
||||
{ path: 'workflows/:workflowId', element: <WorkflowDetailPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export default function App() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
11
apps/dashboard/src/api/client.ts
Normal file
11
apps/dashboard/src/api/client.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import axios from 'axios';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api/v1',
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
export default apiClient;
|
||||
136
apps/dashboard/src/api/evolution.ts
Normal file
136
apps/dashboard/src/api/evolution.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import apiClient from './client';
|
||||
import type {
|
||||
CandidateRecheckResult,
|
||||
EvidenceRef,
|
||||
EvidenceRefPreview,
|
||||
EvolutionAction,
|
||||
EvolutionCandidate,
|
||||
EvolutionJob,
|
||||
EvolutionReviewItem,
|
||||
QualitySignalAuditRow,
|
||||
} from './types';
|
||||
|
||||
export const evolutionApi = {
|
||||
async listJobs(params?: { status?: string; limit?: number }): Promise<EvolutionJob[]> {
|
||||
const response = await apiClient.get<{ items: EvolutionJob[] }>('/evolution/jobs', {
|
||||
params: {
|
||||
status: params?.status ?? '',
|
||||
limit: params?.limit ?? 100,
|
||||
},
|
||||
});
|
||||
return response.data.items;
|
||||
},
|
||||
|
||||
async getJob(jobId: string): Promise<EvolutionJob> {
|
||||
const response = await apiClient.get<EvolutionJob>(`/evolution/jobs/${encodeURIComponent(jobId)}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getPacket(packetId: string): Promise<Record<string, unknown>> {
|
||||
const response = await apiClient.get<Record<string, unknown>>(
|
||||
`/evolution/packets/${encodeURIComponent(packetId)}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getDecision(decisionId: string): Promise<Record<string, unknown>> {
|
||||
const response = await apiClient.get<Record<string, unknown>>(
|
||||
`/evolution/decisions/${encodeURIComponent(decisionId)}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async listCandidates(params?: { status?: string; limit?: number }): Promise<EvolutionCandidate[]> {
|
||||
const response = await apiClient.get<{ items: EvolutionCandidate[] }>('/evolution/candidates', {
|
||||
params: {
|
||||
status: params?.status ?? 'pending',
|
||||
limit: params?.limit ?? 100,
|
||||
},
|
||||
});
|
||||
return response.data.items;
|
||||
},
|
||||
|
||||
async listReviewItems(params?: { limit?: number }): Promise<EvolutionReviewItem[]> {
|
||||
const response = await apiClient.get<{ items: EvolutionReviewItem[] }>('/evolution/review-items', {
|
||||
params: {
|
||||
limit: params?.limit ?? 100,
|
||||
},
|
||||
});
|
||||
return response.data.items;
|
||||
},
|
||||
|
||||
async listQualitySignals(params?: {
|
||||
actionability?: string;
|
||||
subjectType?: string;
|
||||
subjectId?: string;
|
||||
notTriggerable?: boolean;
|
||||
limit?: number;
|
||||
}): Promise<QualitySignalAuditRow[]> {
|
||||
const response = await apiClient.get<{ items: QualitySignalAuditRow[] }>('/quality-signals', {
|
||||
params: {
|
||||
actionability: params?.actionability ?? '',
|
||||
subject_type: params?.subjectType ?? '',
|
||||
subject_id: params?.subjectId ?? '',
|
||||
not_triggerable: params?.notTriggerable ?? false,
|
||||
limit: params?.limit ?? 100,
|
||||
},
|
||||
});
|
||||
return response.data.items;
|
||||
},
|
||||
|
||||
async listQualitySignalJobs(params?: { limit?: number }): Promise<QualitySignalAuditRow[]> {
|
||||
const response = await apiClient.get<{ items: QualitySignalAuditRow[] }>('/quality-signals/jobs', {
|
||||
params: {
|
||||
limit: params?.limit ?? 100,
|
||||
},
|
||||
});
|
||||
return response.data.items;
|
||||
},
|
||||
|
||||
async getCandidate(candidateId: string): Promise<EvolutionCandidate> {
|
||||
const response = await apiClient.get<EvolutionCandidate>(
|
||||
`/evolution/candidates/${encodeURIComponent(candidateId)}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async rejectCandidate(candidateId: string, reason: string): Promise<EvolutionCandidate> {
|
||||
const response = await apiClient.post<EvolutionCandidate>(
|
||||
`/evolution/candidates/${encodeURIComponent(candidateId)}/reject`,
|
||||
{ reason },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async requestCandidateRecheck(candidateId: string, runNow = true): Promise<CandidateRecheckResult> {
|
||||
const response = await apiClient.post<CandidateRecheckResult>(
|
||||
`/evolution/candidates/${encodeURIComponent(candidateId)}/request-recheck`,
|
||||
null,
|
||||
{ params: { run_now: runNow } },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getAction(actionId: string): Promise<EvolutionAction> {
|
||||
const response = await apiClient.get<EvolutionAction>(
|
||||
`/evolution/actions/${encodeURIComponent(actionId)}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getEvidenceRef(refId: string, includePreview = true): Promise<EvidenceRef> {
|
||||
const response = await apiClient.get<EvidenceRef>(
|
||||
`/evidence/refs/${encodeURIComponent(refId)}`,
|
||||
{ params: { include_preview: includePreview } },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async previewEvidenceRef(refId: string, maxChars = 2000): Promise<EvidenceRefPreview> {
|
||||
const response = await apiClient.get<EvidenceRefPreview>(
|
||||
`/evidence/refs/${encodeURIComponent(refId)}/preview`,
|
||||
{ params: { max_chars: maxChars } },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
30
apps/dashboard/src/api/index.ts
Normal file
30
apps/dashboard/src/api/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export { default as apiClient } from './client';
|
||||
export { evolutionApi } from './evolution';
|
||||
export { overviewApi } from './overview';
|
||||
export { skillsApi } from './skills';
|
||||
export { workflowsApi } from './workflows';
|
||||
export type {
|
||||
CandidateRecheckResult,
|
||||
EvidenceRef,
|
||||
EvidenceRefPreview,
|
||||
ExecutionAnalysis,
|
||||
EvolutionAction,
|
||||
EvolutionCandidate,
|
||||
EvolutionJob,
|
||||
EvolutionReviewItem,
|
||||
OverviewResponse,
|
||||
PipelineStage,
|
||||
QualitySignalAuditRow,
|
||||
Skill,
|
||||
SkillDetail,
|
||||
SkillLineage,
|
||||
SkillLineageEdge,
|
||||
SkillLineageMeta,
|
||||
SkillLineageNode,
|
||||
SkillSource,
|
||||
SkillStats,
|
||||
WorkflowArtifact,
|
||||
WorkflowDetail,
|
||||
WorkflowSummary,
|
||||
WorkflowTimelineEvent,
|
||||
} from './types';
|
||||
9
apps/dashboard/src/api/overview.ts
Normal file
9
apps/dashboard/src/api/overview.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import apiClient from './client';
|
||||
import type { OverviewResponse } from './types';
|
||||
|
||||
export const overviewApi = {
|
||||
async getOverview(): Promise<OverviewResponse> {
|
||||
const response = await apiClient.get<OverviewResponse>('/overview');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
31
apps/dashboard/src/api/skills.ts
Normal file
31
apps/dashboard/src/api/skills.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import apiClient from './client';
|
||||
import type { Skill, SkillDetail, SkillLineage, SkillStats } from './types';
|
||||
|
||||
export const skillsApi = {
|
||||
async listSkills(params?: { activeOnly?: boolean; sort?: string; limit?: number; query?: string }): Promise<Skill[]> {
|
||||
const response = await apiClient.get<{ items: Skill[] }>('/skills', {
|
||||
params: {
|
||||
active_only: params?.activeOnly ?? true,
|
||||
sort: params?.sort ?? 'score',
|
||||
limit: params?.limit ?? 200,
|
||||
query: params?.query ?? '',
|
||||
},
|
||||
});
|
||||
return response.data.items;
|
||||
},
|
||||
|
||||
async getSkillStats(): Promise<SkillStats> {
|
||||
const response = await apiClient.get<SkillStats>('/skills/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getSkill(skillId: string): Promise<SkillDetail> {
|
||||
const response = await apiClient.get<SkillDetail>(`/skills/${skillId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getLineage(skillId: string): Promise<SkillLineage> {
|
||||
const response = await apiClient.get<SkillLineage>(`/skills/${skillId}/lineage`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
353
apps/dashboard/src/api/types.ts
Normal file
353
apps/dashboard/src/api/types.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
export interface OverviewResponse {
|
||||
health: {
|
||||
status: string;
|
||||
db_path: string;
|
||||
evidence_db_path: string;
|
||||
workflow_count: number;
|
||||
frontend_dist_exists: boolean;
|
||||
};
|
||||
pipeline: PipelineStage[];
|
||||
skills: {
|
||||
summary: SkillStats;
|
||||
average_score: number;
|
||||
top: Skill[];
|
||||
recent: Skill[];
|
||||
};
|
||||
workflows: {
|
||||
total: number;
|
||||
average_success_rate: number;
|
||||
recent: WorkflowSummary[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface PipelineStage {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ExecutionAnalysis {
|
||||
task_id: string;
|
||||
timestamp: string;
|
||||
task_completed: boolean;
|
||||
execution_note: string;
|
||||
tool_issues: string[];
|
||||
evolution_suggestions: Array<Record<string, unknown>>;
|
||||
analyzed_by: string;
|
||||
analyzed_at: string;
|
||||
}
|
||||
|
||||
export interface SkillLineageMeta {
|
||||
origin: string;
|
||||
generation: number;
|
||||
parent_skill_ids: string[];
|
||||
change_summary: string;
|
||||
content_diff?: string;
|
||||
content_snapshot?: Record<string, string>;
|
||||
evolution_action_id?: string | null;
|
||||
provenance_refs?: string[];
|
||||
source_task_id?: string | null;
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
}
|
||||
|
||||
export interface SkillLineageNode {
|
||||
skill_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
origin: string;
|
||||
generation: number;
|
||||
created_at: string;
|
||||
visibility: string;
|
||||
is_active: boolean;
|
||||
tags: string[];
|
||||
score: number;
|
||||
effective_rate: number;
|
||||
total_selections: number;
|
||||
}
|
||||
|
||||
export interface SkillLineageEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface SkillLineage {
|
||||
skill_id: string;
|
||||
nodes: SkillLineageNode[];
|
||||
edges: SkillLineageEdge[];
|
||||
total_nodes: number;
|
||||
}
|
||||
|
||||
export interface SkillSource {
|
||||
exists: boolean;
|
||||
path: string;
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
skill_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
skill_dir: string;
|
||||
is_active: boolean;
|
||||
category: string;
|
||||
tags: string[];
|
||||
visibility: string;
|
||||
creator_id: string;
|
||||
lineage: SkillLineageMeta;
|
||||
origin: string;
|
||||
generation: number;
|
||||
parent_skill_ids: string[];
|
||||
total_selections: number;
|
||||
total_applied: number;
|
||||
total_completions: number;
|
||||
total_fallbacks: number;
|
||||
applied_rate: number;
|
||||
completion_rate: number;
|
||||
effective_rate: number;
|
||||
fallback_rate: number;
|
||||
score: number;
|
||||
first_seen: string;
|
||||
last_updated: string;
|
||||
recent_analyses?: ExecutionAnalysis[];
|
||||
source?: SkillSource;
|
||||
critical_tools?: string[];
|
||||
tool_dependencies?: string[];
|
||||
latest_evolution_action_id?: string | null;
|
||||
evolution_provenance_refs?: string[];
|
||||
}
|
||||
|
||||
export interface SkillDetail extends Skill {
|
||||
recent_analyses: ExecutionAnalysis[];
|
||||
source: SkillSource;
|
||||
}
|
||||
|
||||
export interface SkillStats {
|
||||
total_skills: number;
|
||||
total_skills_all: number;
|
||||
by_category: Record<string, number>;
|
||||
by_origin: Record<string, number>;
|
||||
total_analyses: number;
|
||||
evolution_candidates: number;
|
||||
total_selections: number;
|
||||
total_applied: number;
|
||||
total_completions: number;
|
||||
total_fallbacks: number;
|
||||
average_score: number;
|
||||
skills_with_activity: number;
|
||||
skills_with_recent_analysis: number;
|
||||
top_by_effective_rate: Skill[];
|
||||
}
|
||||
|
||||
export interface EvolutionJob {
|
||||
job_id: string;
|
||||
trigger_type: string;
|
||||
status: string;
|
||||
reason: string;
|
||||
reason_tags: string[];
|
||||
scope: Record<string, unknown>;
|
||||
idempotency_key: string;
|
||||
evidence_profile: string;
|
||||
subprofile: string;
|
||||
manifest_watermark?: number;
|
||||
attempts?: number;
|
||||
locked_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
result_ref?: string | null;
|
||||
error?: string | null;
|
||||
created_at: string;
|
||||
updated_at?: string | null;
|
||||
packet_ids: string[];
|
||||
decision_ids: string[];
|
||||
admission_ids: string[];
|
||||
candidate_ids: string[];
|
||||
validation_ids: string[];
|
||||
action_ids: string[];
|
||||
}
|
||||
|
||||
export interface EvolutionCandidate {
|
||||
candidate_id: string;
|
||||
proposed_action: string;
|
||||
status: string;
|
||||
admission_id: string;
|
||||
source_task_ids: string[];
|
||||
target_skill_ids: string[];
|
||||
decision_id: string;
|
||||
decision_snapshot: Record<string, unknown>;
|
||||
evidence_refs: string[];
|
||||
similar_skill_ids: string[];
|
||||
recurrence: string;
|
||||
recurrence_count: number;
|
||||
merge_key: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
promoted_action_id?: string | null;
|
||||
rejection_reason?: string | null;
|
||||
last_recheck_result?: Record<string, unknown> | null;
|
||||
blocked_reason?: string | null;
|
||||
needed_evidence?: string[];
|
||||
}
|
||||
|
||||
export interface EvolutionReviewItem {
|
||||
item_id: string;
|
||||
item_type: 'candidate' | 'admission' | 'validation';
|
||||
status: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
candidate_id?: string;
|
||||
decision_id?: string;
|
||||
admission_id?: string;
|
||||
packet_id?: string;
|
||||
validation_id?: string;
|
||||
action_kind: 'request_recheck' | 'inspect';
|
||||
approval_available: boolean;
|
||||
blocking_stage?: string;
|
||||
review_note?: string;
|
||||
}
|
||||
|
||||
export interface QualitySignalAuditRow {
|
||||
signal_ref: string;
|
||||
signal_type: string;
|
||||
subject_type: string;
|
||||
subject_id: string;
|
||||
tool_key: string;
|
||||
skill_id: string;
|
||||
actionability: string;
|
||||
evidence_status: string;
|
||||
merge_key: string;
|
||||
raw_backref_count: number;
|
||||
job_id: string;
|
||||
job_status: string;
|
||||
admission_status: string;
|
||||
admission_hard_failures: string[];
|
||||
admission_warnings: string[];
|
||||
not_triggerable_reason: string;
|
||||
}
|
||||
|
||||
export interface EvolutionAction {
|
||||
action_id: string;
|
||||
decision_id: string;
|
||||
trigger_job_id: string;
|
||||
authoring_id: string;
|
||||
validation_id: string;
|
||||
action_type: string;
|
||||
commit_status: string;
|
||||
skill_id?: string | null;
|
||||
parent_skill_ids: string[];
|
||||
changed_files: string[];
|
||||
evidence_refs: string[];
|
||||
staging_dir: string;
|
||||
active_target_dir: string;
|
||||
backup_dir?: string | null;
|
||||
failure_reason?: string | null;
|
||||
created_at: string;
|
||||
committed_at?: string | null;
|
||||
validation?: Record<string, unknown> | null;
|
||||
decision?: Record<string, unknown> | null;
|
||||
failures?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface EvidenceRef {
|
||||
ref_id: string;
|
||||
ref_type: string;
|
||||
uri: string;
|
||||
session_id?: string | null;
|
||||
task_id?: string | null;
|
||||
producer: string;
|
||||
created_at: string;
|
||||
reliability: string;
|
||||
role: string;
|
||||
preview: string;
|
||||
metadata: Record<string, unknown>;
|
||||
contains_secret?: boolean;
|
||||
}
|
||||
|
||||
export interface EvidenceRefPreview {
|
||||
ref_id: string;
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
max_chars: number;
|
||||
}
|
||||
|
||||
export interface CandidateRecheckResult {
|
||||
operation?: string;
|
||||
job_id: string;
|
||||
status: string;
|
||||
job?: EvolutionJob | null;
|
||||
run_now: boolean;
|
||||
engine_available: boolean;
|
||||
recheck_status?: 'executed_committed' | 'executed_no_commit' | 'queued_recheck' | 'queued_no_engine' | 'needs_recovery';
|
||||
recovery_required?: boolean;
|
||||
candidate?: EvolutionCandidate | null;
|
||||
outcomes: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface WorkflowSummary {
|
||||
id: string;
|
||||
path: string;
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
instruction: string;
|
||||
status: string;
|
||||
iterations: number;
|
||||
execution_time: number;
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
total_steps: number;
|
||||
success_count: number;
|
||||
success_rate: number;
|
||||
backend_counts: Record<string, number>;
|
||||
tool_counts: Record<string, number>;
|
||||
agent_action_count: number;
|
||||
has_video: boolean;
|
||||
video_url: string | null;
|
||||
screenshot_count: number;
|
||||
selected_skills: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowArtifact {
|
||||
name: string;
|
||||
path: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface WorkflowTimelineEvent {
|
||||
timestamp: string;
|
||||
type: 'agent_action' | 'tool_execution';
|
||||
step?: number;
|
||||
label: string;
|
||||
agent_name?: string;
|
||||
agent_type?: string;
|
||||
backend?: string;
|
||||
status?: string;
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WorkflowDetail extends WorkflowSummary {
|
||||
metadata: Record<string, unknown>;
|
||||
statistics: {
|
||||
total_steps: number;
|
||||
success_count: number;
|
||||
success_rate: number;
|
||||
backends: Record<string, number>;
|
||||
tools: Record<string, number>;
|
||||
};
|
||||
trajectory: Array<Record<string, unknown>>;
|
||||
plans: Array<Record<string, unknown>>;
|
||||
decisions: string[];
|
||||
agent_actions: Array<Record<string, unknown>>;
|
||||
agent_statistics: {
|
||||
total_actions: number;
|
||||
by_agent: Record<string, number>;
|
||||
by_type: Record<string, number>;
|
||||
};
|
||||
timeline: WorkflowTimelineEvent[];
|
||||
artifacts: {
|
||||
init_screenshot_url: string | null;
|
||||
screenshots: WorkflowArtifact[];
|
||||
video_url: string | null;
|
||||
};
|
||||
}
|
||||
14
apps/dashboard/src/api/workflows.ts
Normal file
14
apps/dashboard/src/api/workflows.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import apiClient from './client';
|
||||
import type { WorkflowDetail, WorkflowSummary } from './types';
|
||||
|
||||
export const workflowsApi = {
|
||||
async listWorkflows(): Promise<WorkflowSummary[]> {
|
||||
const response = await apiClient.get<{ items: WorkflowSummary[] }>('/workflows');
|
||||
return response.data.items;
|
||||
},
|
||||
|
||||
async getWorkflow(workflowId: string): Promise<WorkflowDetail> {
|
||||
const response = await apiClient.get<WorkflowDetail>(`/workflows/${workflowId}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
13
apps/dashboard/src/components/EmptyState.tsx
Normal file
13
apps/dashboard/src/components/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function EmptyState({ title, description }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="panel-surface p-8 text-center space-y-2">
|
||||
<div className="text-lg font-bold font-serif">{title}</div>
|
||||
<div className="text-sm text-muted">{description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
apps/dashboard/src/components/ErrorBoundary.tsx
Normal file
65
apps/dashboard/src/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import i18n from '../i18n';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
const t = i18n.t.bind(i18n);
|
||||
return (
|
||||
this.props.fallback || (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[color:var(--color-bg-page)]">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-[color:var(--color-danger)] mb-4">
|
||||
{t('errorBoundary.title')}
|
||||
</h1>
|
||||
<p className="text-[color:var(--color-muted)] mb-6">
|
||||
{t('errorBoundary.message')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/dashboard'}
|
||||
className="btn-primary"
|
||||
>
|
||||
{t('errorBoundary.goToDashboard')}
|
||||
</button>
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<details className="mt-4 text-left text-xs text-[color:var(--color-muted)]">
|
||||
<summary>{t('errorBoundary.details')}</summary>
|
||||
<pre className="mt-2 p-4 bg-[color:var(--color-surface)] overflow-auto">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
91
apps/dashboard/src/components/LineageGraph.tsx
Normal file
91
apps/dashboard/src/components/LineageGraph.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { useMemo, useRef } from 'react';
|
||||
import ForceGraph2D from 'react-force-graph-2d';
|
||||
import type { SkillLineage } from '../api';
|
||||
|
||||
interface LineageGraphProps {
|
||||
lineage: SkillLineage;
|
||||
onNodeClick?: (skillId: string) => void;
|
||||
}
|
||||
|
||||
type GraphNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
score: number;
|
||||
generation: number;
|
||||
origin: string;
|
||||
is_active: boolean;
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
|
||||
type GraphLink = {
|
||||
source: string;
|
||||
target: string;
|
||||
};
|
||||
|
||||
export default function LineageGraph({ lineage, onNodeClick }: LineageGraphProps) {
|
||||
const fgRef = useRef<any>(null);
|
||||
|
||||
const graphData = useMemo<{ nodes: GraphNode[]; links: GraphLink[] }>(
|
||||
() => ({
|
||||
nodes: lineage.nodes.map((node) => ({
|
||||
id: node.skill_id,
|
||||
name: node.name,
|
||||
score: node.score,
|
||||
generation: node.generation,
|
||||
origin: node.origin,
|
||||
is_active: node.is_active,
|
||||
})),
|
||||
links: lineage.edges.map((edge) => ({
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
})),
|
||||
}),
|
||||
[lineage],
|
||||
);
|
||||
|
||||
if (graphData.nodes.length === 0) {
|
||||
return <div className="text-sm text-muted">No lineage graph data.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel-surface overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[color:var(--color-border)] text-sm font-bold">
|
||||
Skill Lineage
|
||||
</div>
|
||||
<div className="h-[420px]">
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
graphData={graphData}
|
||||
cooldownTicks={120}
|
||||
linkColor={() => 'rgba(20, 20, 19, 0.18)'}
|
||||
linkWidth={1.5}
|
||||
nodeLabel={(node) => {
|
||||
const skillNode = node as GraphNode;
|
||||
return `${skillNode.name}\nscore: ${skillNode.score.toFixed(1)}\norigin: ${skillNode.origin}`;
|
||||
}}
|
||||
onNodeClick={(node) => onNodeClick?.((node as GraphNode).id)}
|
||||
nodeCanvasObject={(node, ctx, globalScale) => {
|
||||
const skillNode = node as GraphNode;
|
||||
const label = skillNode.name;
|
||||
const size = 8 + Math.max(0, skillNode.score / 20);
|
||||
const fontSize = 12 / globalScale;
|
||||
const isActive = skillNode.is_active;
|
||||
ctx.beginPath();
|
||||
ctx.arc(skillNode.x ?? 0, skillNode.y ?? 0, size, 0, 2 * Math.PI, false);
|
||||
ctx.fillStyle = isActive ? '#D97757' : '#B8B4A8';
|
||||
ctx.fill();
|
||||
ctx.lineWidth = 2 / globalScale;
|
||||
ctx.strokeStyle = '#141413';
|
||||
ctx.stroke();
|
||||
ctx.font = `${fontSize}px ui-monospace`;
|
||||
ctx.fillStyle = '#141413';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(label, skillNode.x ?? 0, (skillNode.y ?? 0) + size + 4 / globalScale);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
apps/dashboard/src/components/MetricCard.tsx
Normal file
22
apps/dashboard/src/components/MetricCard.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
interface MetricCardProps {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export default function MetricCard({ label, value, hint }: MetricCardProps) {
|
||||
return (
|
||||
<div className="metric-card">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{label}</div>
|
||||
<div className="text-[2.75rem] font-semibold font-serif leading-none tracking-[-0.04em] mt-3">{value}</div>
|
||||
{hint ? (
|
||||
<>
|
||||
<div className="w-8 h-px bg-[color:var(--color-border-dark)] my-4" />
|
||||
<div className="text-sm text-muted font-serif">{hint}</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
apps/dashboard/src/components/ProgressBar.tsx
Normal file
21
apps/dashboard/src/components/ProgressBar.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
interface ProgressBarProps {
|
||||
label: string;
|
||||
value: number;
|
||||
colorClass?: string;
|
||||
}
|
||||
|
||||
export default function ProgressBar({ label, value, colorClass = 'bg-primary' }: ProgressBarProps) {
|
||||
const percent = Math.max(0, Math.min(100, Math.round(value * 1000) / 10));
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-muted">
|
||||
<span>{label}</span>
|
||||
<span>{percent}%</span>
|
||||
</div>
|
||||
<div className="h-3 rounded-full bg-[color:var(--color-border)] overflow-hidden">
|
||||
<div className={`h-full ${colorClass}`} style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
apps/dashboard/src/components/skill-detail/DiffViewer.tsx
Normal file
250
apps/dashboard/src/components/skill-detail/DiffViewer.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { useEffect, useMemo, useState, type KeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { DiffFile, DiffLine } from '../../utils/diffParser';
|
||||
|
||||
interface DiffViewerProps {
|
||||
files: DiffFile[];
|
||||
}
|
||||
|
||||
interface SplitDiffRow {
|
||||
leftType: DiffLine['type'] | null;
|
||||
leftText: string;
|
||||
leftLineNumber: number | null;
|
||||
rightType: DiffLine['type'] | null;
|
||||
rightText: string;
|
||||
rightLineNumber: number | null;
|
||||
}
|
||||
|
||||
function lineClassName(type: DiffLine['type'] | null): string {
|
||||
switch (type) {
|
||||
case 'add':
|
||||
return 'bg-[color:var(--color-diff-add)] text-[color:var(--color-ink)]';
|
||||
case 'del':
|
||||
return 'bg-[color:var(--color-diff-del)] text-[color:var(--color-ink)]';
|
||||
case 'ctx':
|
||||
return 'text-[color:var(--color-ink)]';
|
||||
default:
|
||||
return 'bg-[rgba(20,20,19,0.03)] text-[color:var(--color-muted)]';
|
||||
}
|
||||
}
|
||||
|
||||
function linePrefix(type: DiffLine['type'] | null): string {
|
||||
switch (type) {
|
||||
case 'add':
|
||||
return '+';
|
||||
case 'del':
|
||||
return '-';
|
||||
case 'ctx':
|
||||
return ' ';
|
||||
default:
|
||||
return ' ';
|
||||
}
|
||||
}
|
||||
|
||||
function parseHunkHeader(header: string): { oldLine: number; newLine: number } {
|
||||
const match = /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/.exec(header);
|
||||
if (!match) {
|
||||
return { oldLine: 1, newLine: 1 };
|
||||
}
|
||||
return {
|
||||
oldLine: Number.parseInt(match[1], 10),
|
||||
newLine: Number.parseInt(match[2], 10),
|
||||
};
|
||||
}
|
||||
|
||||
function pairChangedLines(
|
||||
deletions: DiffLine[],
|
||||
additions: DiffLine[],
|
||||
state: { oldLine: number; newLine: number },
|
||||
): SplitDiffRow[] {
|
||||
const rows: SplitDiffRow[] = [];
|
||||
const maxLength = Math.max(deletions.length, additions.length);
|
||||
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const left = deletions[index] ?? null;
|
||||
const right = additions[index] ?? null;
|
||||
rows.push({
|
||||
leftType: left?.type ?? null,
|
||||
leftText: left?.text ?? '',
|
||||
leftLineNumber: left ? state.oldLine++ : null,
|
||||
rightType: right?.type ?? null,
|
||||
rightText: right?.text ?? '',
|
||||
rightLineNumber: right ? state.newLine++ : null,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildSplitRows(lines: DiffLine[], header: string): SplitDiffRow[] {
|
||||
const state = parseHunkHeader(header);
|
||||
const rows: SplitDiffRow[] = [];
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (line.type === 'ctx') {
|
||||
rows.push({
|
||||
leftType: 'ctx',
|
||||
leftText: line.text,
|
||||
leftLineNumber: state.oldLine++,
|
||||
rightType: 'ctx',
|
||||
rightText: line.text,
|
||||
rightLineNumber: state.newLine++,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.type === 'del') {
|
||||
const deletions: DiffLine[] = [];
|
||||
while (index < lines.length && lines[index]?.type === 'del') {
|
||||
deletions.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
const additions: DiffLine[] = [];
|
||||
while (index < lines.length && lines[index]?.type === 'add') {
|
||||
additions.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
rows.push(...pairChangedLines(deletions, additions, state));
|
||||
index -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.type === 'add') {
|
||||
const additions: DiffLine[] = [];
|
||||
while (index < lines.length && lines[index]?.type === 'add') {
|
||||
additions.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
rows.push(...pairChangedLines([], additions, state));
|
||||
index -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export default function DiffViewer({ files }: DiffViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const renderableFiles = useMemo(
|
||||
() => files.filter((file) => file.hunks.some((hunk) => hunk.lines.length > 0)),
|
||||
[files],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [renderableFiles]);
|
||||
|
||||
if (renderableFiles.length === 0) {
|
||||
return <p className="text-[color:var(--color-muted)] text-sm">{t('diffViewer.noFiles')}</p>;
|
||||
}
|
||||
|
||||
const activeIndex = selectedIndex < renderableFiles.length ? selectedIndex : 0;
|
||||
const activeFile = renderableFiles[activeIndex];
|
||||
const activeHunks = activeFile.hunks.filter((hunk) => hunk.lines.length > 0);
|
||||
const activeTabId = `diff-file-tab-${activeIndex}`;
|
||||
const activePanelId = 'diff-file-panel';
|
||||
|
||||
function focusTab(index: number) {
|
||||
const tab = document.querySelector<HTMLButtonElement>(`button[data-diff-tab-index="${index}"]`);
|
||||
tab?.focus();
|
||||
}
|
||||
|
||||
function handleTabKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||
if (renderableFiles.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextIndex: number | null = null;
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
case 'ArrowRight':
|
||||
nextIndex = (index + 1) % renderableFiles.length;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case 'ArrowLeft':
|
||||
nextIndex = (index - 1 + renderableFiles.length) % renderableFiles.length;
|
||||
break;
|
||||
case 'Home':
|
||||
nextIndex = 0;
|
||||
break;
|
||||
case 'End':
|
||||
nextIndex = renderableFiles.length - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setSelectedIndex(nextIndex);
|
||||
focusTab(nextIndex);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex overflow-hidden rounded-[16px] border border-[color:var(--color-ink)] bg-[color:var(--color-surface)]" style={{ maxHeight: 460 }}>
|
||||
<nav className="w-[200px] min-w-[200px] border-r-2 border-[color:var(--color-border-dark)] overflow-y-auto bg-[color:var(--color-surface)] p-3">
|
||||
<ul className="flex flex-col gap-2 text-xs font-mono" role="tablist" aria-label="Diff files" aria-orientation="vertical">
|
||||
{renderableFiles.map((file, idx) => (
|
||||
<li key={file.path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedIndex(idx)}
|
||||
onKeyDown={(event) => handleTabKeyDown(event, idx)}
|
||||
id={`diff-file-tab-${idx}`}
|
||||
data-diff-tab-index={idx}
|
||||
role="tab"
|
||||
aria-selected={idx === activeIndex}
|
||||
aria-controls={activePanelId}
|
||||
tabIndex={idx === activeIndex ? 0 : -1}
|
||||
className={`w-full truncate rounded-full border px-3 py-2 text-left transition-all duration-200 ${
|
||||
idx === activeIndex
|
||||
? 'border-[color:var(--color-border-dark)] bg-[color:var(--color-border-dark)] font-bold text-[color:var(--color-ink)] shadow-[inset_0_0_0_1px_var(--color-ink)]'
|
||||
: 'border-transparent bg-[color:var(--color-bg-page)] text-[color:var(--color-muted)] hover:border-[color:var(--color-border-dark)] hover:text-[color:var(--color-ink)]'
|
||||
}`}
|
||||
title={file.path}
|
||||
>
|
||||
{file.path}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="flex-1 overflow-auto bg-[color:var(--color-bg-page)]" id={activePanelId} role="tabpanel" aria-labelledby={activeTabId}>
|
||||
<div className="text-xs font-mono leading-5 min-w-[720px]">
|
||||
{activeHunks.map((hunk, hunkIdx) => {
|
||||
const rows = buildSplitRows(hunk.lines, hunk.header);
|
||||
return (
|
||||
<div key={`${activeFile.path}-hunk-${hunkIdx}`}>
|
||||
<div className="sticky top-0 z-10 grid grid-cols-[1fr_1fr] border-y border-[color:var(--color-ink)] bg-[#CBCADB] px-3 py-1.5 text-[color:var(--color-muted)] select-none">
|
||||
<div>{t('diffViewer.old')}</div>
|
||||
<div>{t('diffViewer.new')}</div>
|
||||
</div>
|
||||
<div className="sticky top-[29px] z-10 border-b border-[color:var(--color-border-dark)] bg-[color:var(--color-surface)] px-3 py-1 text-[color:var(--color-muted)] select-none">
|
||||
{hunk.header}
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_1fr]">
|
||||
{rows.map((row, rowIdx) => (
|
||||
<div key={`${activeFile.path}-h${hunkIdx}-r${rowIdx}`} className="contents">
|
||||
<div className={`grid grid-cols-[3rem_1.5rem_minmax(0,1fr)] border-r border-b border-[color:var(--color-border)] px-2 ${lineClassName(row.leftType)}`}>
|
||||
<span className="select-none opacity-60 text-right pr-2">{row.leftLineNumber ?? ''}</span>
|
||||
<span className="select-none opacity-60">{linePrefix(row.leftType)}</span>
|
||||
<span className="whitespace-pre-wrap break-all py-0.5">{row.leftText || ' '}</span>
|
||||
</div>
|
||||
<div className={`grid grid-cols-[3rem_1.5rem_minmax(0,1fr)] border-b border-[color:var(--color-border)] px-2 ${lineClassName(row.rightType)}`}>
|
||||
<span className="select-none opacity-60 text-right pr-2">{row.rightLineNumber ?? ''}</span>
|
||||
<span className="select-none opacity-60">{linePrefix(row.rightType)}</span>
|
||||
<span className="whitespace-pre-wrap break-all py-0.5">{row.rightText || ' '}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ForceGraph2D from 'react-force-graph-2d';
|
||||
import type { SkillGraphNode } from '../../hooks/useSkillEvolutionGraphData';
|
||||
|
||||
interface SkillGraphLink {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
interface SkillEvolutionGraphProps {
|
||||
graphData: {
|
||||
nodes: SkillGraphNode[];
|
||||
links: SkillGraphLink[];
|
||||
};
|
||||
selectedNodeId?: string | null;
|
||||
onNodeClick: (node: SkillGraphNode) => void;
|
||||
onBackgroundClick?: () => void;
|
||||
}
|
||||
|
||||
const GRAPH_BG = '#FAF9F5';
|
||||
|
||||
export default function SkillEvolutionGraph({
|
||||
graphData,
|
||||
selectedNodeId,
|
||||
onNodeClick,
|
||||
onBackgroundClick,
|
||||
}: SkillEvolutionGraphProps) {
|
||||
const { t } = useTranslation();
|
||||
const graphContainerRef = useRef<HTMLDivElement>(null);
|
||||
const fgRef = useRef<any>(null);
|
||||
const [graphDim, setGraphDim] = useState({ width: 0, height: 0 });
|
||||
const forceConfigured = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!graphContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width > 0 && height > 0) {
|
||||
setGraphDim({ width, height });
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(graphContainerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fgRef.current || forceConfigured.current) {
|
||||
return;
|
||||
}
|
||||
forceConfigured.current = true;
|
||||
|
||||
const fg = fgRef.current;
|
||||
fg.d3Force('charge')?.strength(-100);
|
||||
fg.d3Force('link')?.distance(40);
|
||||
fg.d3Force('center')?.strength(0.05);
|
||||
}, [graphDim]);
|
||||
|
||||
const paintNode = useCallback((node: object, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const graphNode = node as SkillGraphNode;
|
||||
const normalizedScore = Math.max(0, Math.min(1, graphNode.score / 100));
|
||||
const baseRadius = 7 + graphNode.usageRatio * 7;
|
||||
const fontSize = 12 / globalScale;
|
||||
const x = graphNode.x ?? 0;
|
||||
const y = graphNode.y ?? 0;
|
||||
const isSelected = graphNode.id === selectedNodeId;
|
||||
|
||||
const red = 184 + Math.round(71 * normalizedScore);
|
||||
const green = 92 + Math.round(99 * normalizedScore);
|
||||
const blue = 80 + Math.round(7 * normalizedScore);
|
||||
const nodeColor = graphNode.isActive
|
||||
? `rgba(${red}, ${green}, ${blue}, 0.92)`
|
||||
: `rgba(184, 180, 168, 0.82)`;
|
||||
const glowColor = graphNode.isActive
|
||||
? `rgba(${red}, ${green}, ${blue}, 0.45)`
|
||||
: 'rgba(184, 180, 168, 0.3)';
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, baseRadius + 1.5, 0, 2 * Math.PI, false);
|
||||
ctx.fillStyle = GRAPH_BG;
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, baseRadius, 0, 2 * Math.PI, false);
|
||||
ctx.fillStyle = nodeColor;
|
||||
ctx.shadowColor = glowColor;
|
||||
ctx.shadowBlur = isSelected ? 22 : 14;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, Math.max(2.6, baseRadius * 0.45), 0, 2 * Math.PI, false);
|
||||
ctx.fillStyle = graphNode.isActive ? 'rgba(255, 255, 255, 0.96)' : 'rgba(255, 255, 255, 0.72)';
|
||||
ctx.fill();
|
||||
|
||||
if (isSelected) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, baseRadius + 4, 0, 2 * Math.PI, false);
|
||||
ctx.strokeStyle = '#141413';
|
||||
ctx.lineWidth = 2 / globalScale;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.font = `${fontSize}px ui-monospace`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillStyle = '#4A3B2A';
|
||||
ctx.fillText(graphNode.name, x, y + baseRadius + 5 / globalScale);
|
||||
}, [selectedNodeId]);
|
||||
|
||||
const paintLink = useCallback((link: object, ctx: CanvasRenderingContext2D) => {
|
||||
const source = (link as any).source as SkillGraphNode | undefined;
|
||||
const target = (link as any).target as SkillGraphNode | undefined;
|
||||
if (!source || !target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sx = source.x;
|
||||
const sy = source.y;
|
||||
const tx = target.x;
|
||||
const ty = target.y;
|
||||
if (sx === undefined || sy === undefined || tx === undefined || ty === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = tx - sx;
|
||||
const dy = ty - sy;
|
||||
const dist = Math.hypot(dx, dy) || 1;
|
||||
const nx = -dy / dist;
|
||||
const ny = dx / dist;
|
||||
|
||||
const curveKey = `${source.id}->${target.id}`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < curveKey.length; i += 1) {
|
||||
hash = ((hash << 5) - hash + curveKey.charCodeAt(i)) | 0;
|
||||
}
|
||||
const curveSign = hash % 2 === 0 ? 1 : -1;
|
||||
const baseOffset = Math.min(14, dist * 0.08) * curveSign;
|
||||
|
||||
const c1x = sx + dx * 0.33 + nx * baseOffset;
|
||||
const c1y = sy + dy * 0.33 + ny * baseOffset;
|
||||
const c2x = sx + dx * 0.67 + nx * baseOffset;
|
||||
const c2y = sy + dy * 0.67 + ny * baseOffset;
|
||||
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round';
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sx, sy);
|
||||
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, tx, ty);
|
||||
ctx.strokeStyle = 'rgba(138, 90, 68, 0.07)';
|
||||
ctx.lineWidth = 5;
|
||||
ctx.shadowColor = 'rgba(202, 103, 2, 0.15)';
|
||||
ctx.shadowBlur = 3;
|
||||
ctx.shadowOffsetX = 1;
|
||||
ctx.shadowOffsetY = 1;
|
||||
ctx.stroke();
|
||||
|
||||
const gradient = ctx.createLinearGradient(sx, sy, tx, ty);
|
||||
gradient.addColorStop(0, 'rgba(189, 140, 99, 0.55)');
|
||||
gradient.addColorStop(1, 'rgba(148, 104, 70, 0.4)');
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sx, sy);
|
||||
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, tx, ty);
|
||||
ctx.strokeStyle = gradient;
|
||||
ctx.lineWidth = 2.4;
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}, []);
|
||||
|
||||
if (graphData.nodes.length === 0) {
|
||||
return <div className="text-sm text-muted p-4">{t('graph.noGraphData')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[540px] bg-bg-page" ref={graphContainerRef}>
|
||||
{graphDim.width > 0 && graphDim.height > 0 ? (
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
width={graphDim.width}
|
||||
height={graphDim.height}
|
||||
graphData={graphData}
|
||||
cooldownTicks={120}
|
||||
nodeRelSize={6}
|
||||
nodeLabel={(node) => {
|
||||
const graphNode = node as SkillGraphNode;
|
||||
return [
|
||||
graphNode.name,
|
||||
t('graph.tooltipScore', { value: graphNode.score.toFixed(1) }),
|
||||
t('graph.tooltipGeneration', { value: graphNode.generation }),
|
||||
t('graph.tooltipOrigin', { value: graphNode.origin }),
|
||||
].join('\n');
|
||||
}}
|
||||
onNodeClick={(node) => {
|
||||
const graphNode = node as SkillGraphNode;
|
||||
if (fgRef.current && typeof graphNode.x === 'number' && typeof graphNode.y === 'number') {
|
||||
fgRef.current.centerAt(graphNode.x, graphNode.y, 600);
|
||||
fgRef.current.zoom(2.3, 600);
|
||||
}
|
||||
onNodeClick(graphNode);
|
||||
}}
|
||||
onBackgroundClick={() => onBackgroundClick?.()}
|
||||
onNodeHover={(node) => {
|
||||
document.body.style.cursor = node ? 'pointer' : 'default';
|
||||
}}
|
||||
nodeCanvasObject={paintNode}
|
||||
linkCanvasObject={paintLink}
|
||||
linkCanvasObjectMode={() => 'replace'}
|
||||
d3AlphaDecay={0.02}
|
||||
d3VelocityDecay={0.3}
|
||||
warmupTicks={80}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
import { useLayoutEffect, useMemo, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SkillDetail } from '../../api';
|
||||
import { parseDiff } from '../../utils/diffParser';
|
||||
import EmptyState from '../EmptyState';
|
||||
import ProgressBar from '../ProgressBar';
|
||||
import DiffViewer from './DiffViewer';
|
||||
import { formatDate, formatPercent, truncate } from '../../utils/format';
|
||||
|
||||
interface SkillVersionDrawerProps {
|
||||
skill: SkillDetail | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DRAWER_ANIMATION_DURATION_MS = 300;
|
||||
const MAX_RENDERABLE_DIFF_LENGTH = 250_000;
|
||||
const APP_ROOT_SELECTOR = '#root';
|
||||
const SKILL_MD_FILENAME = 'SKILL.md';
|
||||
|
||||
function resolveSourcePreview(skill: SkillDetail) {
|
||||
const snapshot = skill.lineage.content_snapshot;
|
||||
if (snapshot && Object.prototype.hasOwnProperty.call(snapshot, SKILL_MD_FILENAME)) {
|
||||
return {
|
||||
path: `Version snapshot - ${SKILL_MD_FILENAME}`,
|
||||
content: snapshot[SKILL_MD_FILENAME] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
if (skill.source?.exists && skill.source.content !== null) {
|
||||
return {
|
||||
path: skill.source.path || skill.path || SKILL_MD_FILENAME,
|
||||
content: skill.source.content,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function lockScroll() {
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
const appRoot = document.querySelector<HTMLElement>(APP_ROOT_SELECTOR);
|
||||
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const supportsStableScrollbarGutter = typeof CSS !== 'undefined' && CSS.supports?.('scrollbar-gutter: stable');
|
||||
|
||||
const bodyScrollbarWidth = supportsStableScrollbarGutter ? 0 : Math.max(0, window.innerWidth - html.clientWidth);
|
||||
|
||||
html.classList.add('drawer-open');
|
||||
body.classList.add('drawer-open');
|
||||
if (appRoot) {
|
||||
appRoot.inert = true;
|
||||
appRoot.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
if (bodyScrollbarWidth > 0) {
|
||||
body.style.paddingRight = `${bodyScrollbarWidth}px`;
|
||||
}
|
||||
|
||||
return () => {
|
||||
html.classList.remove('drawer-open');
|
||||
body.classList.remove('drawer-open');
|
||||
body.style.removeProperty('padding-right');
|
||||
if (appRoot) {
|
||||
appRoot.inert = false;
|
||||
appRoot.removeAttribute('aria-hidden');
|
||||
}
|
||||
if (previouslyFocused && previouslyFocused !== body && previouslyFocused.isConnected) {
|
||||
previouslyFocused.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVersionDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const rawDiff = skill?.lineage.content_diff ?? '';
|
||||
const isOversizedDiff = rawDiff.length > MAX_RENDERABLE_DIFF_LENGTH;
|
||||
const diffFiles = useMemo(() => (isOversizedDiff ? [] : parseDiff(rawDiff)), [isOversizedDiff, rawDiff]);
|
||||
const canShowDiff = rawDiff.trim().length > 0;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!skill) {
|
||||
return;
|
||||
}
|
||||
return lockScroll();
|
||||
}, [skill]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
closeButtonRef.current?.focus();
|
||||
}, [isOpen]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!skill) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose, skill]);
|
||||
|
||||
if (!skill) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourcePreview = resolveSourcePreview(skill);
|
||||
|
||||
const drawerContent = (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close version detail"
|
||||
className={`fixed inset-0 z-30 bg-[rgba(20,20,19,0.22)] transition-opacity duration-300 ${isOpen ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="skill-version-drawer-title"
|
||||
className={`fixed top-0 right-0 z-40 flex h-full min-w-[28rem] max-w-[65vw] border-l-2 border-[color:var(--color-ink)] bg-[color:var(--color-surface)] shadow-lg will-change-transform ${isOpen ? 'pointer-events-auto' : 'pointer-events-none'}`}
|
||||
style={{
|
||||
width: '65vw',
|
||||
animation: `${isOpen ? 'drawer-slide-in' : 'drawer-slide-out'} ${DRAWER_ANIMATION_DURATION_MS}ms ease-in-out forwards`,
|
||||
}}
|
||||
>
|
||||
<div className="drawer-scroll flex h-full w-full flex-col overflow-hidden overscroll-contain">
|
||||
<header className="p-4 border-b-2 border-[color:var(--color-border)] flex items-start justify-between gap-3 shrink-0">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-wide text-muted">{t('drawer.skillVersion')}</p>
|
||||
<h2 id="skill-version-drawer-title" className="font-bold text-lg truncate">{skill.name}</h2>
|
||||
<p className="text-xs text-muted font-mono break-all">{skill.skill_id}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to={`/skills/${encodeURIComponent(skill.skill_id)}`} className="btn-outline-ink text-sm">
|
||||
{t('drawer.openAsMain')}
|
||||
</Link>
|
||||
<button type="button" onClick={onClose} ref={closeButtonRef} className="btn-outline-ink text-sm">
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="drawer-scroll drawer-scroll-region flex-1 overflow-y-auto overscroll-contain space-y-4 bg-bg-page p-4">
|
||||
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2 min-w-0">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.versionSummary')}</div>
|
||||
<div className="text-sm text-muted">{skill.description || t('drawer.noDescriptionAvailable')}</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="tag px-2 py-1">{skill.category}</span>
|
||||
<span className="tag px-2 py-1">{skill.origin}</span>
|
||||
<span className="tag px-2 py-1">{t('drawer.gen', { generation: skill.generation })}</span>
|
||||
<span className="tag px-2 py-1">{skill.is_active ? t('common.active') : t('common.inactive')}</span>
|
||||
{skill.tags.map((tag) => (
|
||||
<span key={tag} className="tag px-2 py-1">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-4xl font-bold font-serif leading-none">{skill.score.toFixed(1)}</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted mt-2">{t('drawer.versionScore')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.metrics')}</div>
|
||||
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.executionQuality')}</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<ProgressBar label={t('drawer.effectiveRate')} value={skill.effective_rate} colorClass="bg-primary" />
|
||||
<ProgressBar label={t('drawer.completionRate')} value={skill.completion_rate} colorClass="bg-accent" />
|
||||
<ProgressBar label={t('drawer.appliedRate')} value={skill.applied_rate} colorClass="bg-teal" />
|
||||
<ProgressBar label={t('drawer.fallbackRate')} value={skill.fallback_rate} colorClass="bg-danger" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-muted">
|
||||
<div><div className="font-bold text-ink">{t('drawer.selectionsLabel')}</div><div>{skill.total_selections}</div></div>
|
||||
<div><div className="font-bold text-ink">{t('drawer.appliedLabel')}</div><div>{skill.total_applied}</div></div>
|
||||
<div><div className="font-bold text-ink">{t('drawer.completionsLabel')}</div><div>{skill.total_completions}</div></div>
|
||||
<div><div className="font-bold text-ink">{t('drawer.fallbacksLabel')}</div><div>{skill.total_fallbacks}</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 text-sm space-y-2">
|
||||
<h3 className="font-bold">{t('drawer.versionMetadata')}</h3>
|
||||
<p><strong>{t('drawer.origin')}</strong> {skill.origin}</p>
|
||||
<p><strong>{t('drawer.generation')}</strong> {skill.generation}</p>
|
||||
<p><strong>{t('drawer.visibility')}</strong> {skill.visibility}</p>
|
||||
<p><strong>{t('drawer.created')}</strong> {formatDate(skill.lineage.created_at)}</p>
|
||||
<p><strong>{t('drawer.firstSeen')}</strong> {formatDate(skill.first_seen)}</p>
|
||||
<p><strong>{t('drawer.lastUpdated')}</strong> {formatDate(skill.last_updated)}</p>
|
||||
<p><strong>{t('drawer.skillPath')}</strong> <span className="break-all">{skill.path || t('common.unavailable')}</span></p>
|
||||
<p><strong>{t('drawer.skillDir')}</strong> <span className="break-all">{skill.skill_dir || t('common.unavailable')}</span></p>
|
||||
<p><strong>{t('drawer.parentIds')}</strong> {skill.parent_skill_ids.length ? skill.parent_skill_ids.join(', ') : t('common.none')}</p>
|
||||
<p><strong>{t('drawer.evolutionAction')}</strong> <span className="break-all">{skill.latest_evolution_action_id || t('common.none')}</span></p>
|
||||
<p><strong>{t('drawer.provenanceRefs')}</strong> <span className="break-all">{skill.evolution_provenance_refs?.length ? skill.evolution_provenance_refs.join(', ') : t('common.none')}</span></p>
|
||||
<p><strong>{t('drawer.changeSummary')}</strong> {skill.lineage.change_summary || t('common.none')}</p>
|
||||
<p><strong>{t('drawer.effectiveScore')}</strong> {formatPercent(skill.effective_rate)}</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.diff')}</div>
|
||||
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.contentDiff')}</h3>
|
||||
</div>
|
||||
{isOversizedDiff ? (
|
||||
<EmptyState title={t('drawer.diffTooLarge')} description={t('drawer.diffTooLargeDesc')} />
|
||||
) : canShowDiff ? (
|
||||
diffFiles.length > 0 ? (
|
||||
<DiffViewer files={diffFiles} />
|
||||
) : (
|
||||
<EmptyState title={t('drawer.diffUnavailable')} description={t('drawer.diffUnavailableDesc')} />
|
||||
)
|
||||
) : (
|
||||
<EmptyState title={t('drawer.noContentDiff')} description={t('drawer.noContentDiffDesc')} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.source')}</div>
|
||||
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.skillMdPreview')}</h3>
|
||||
</div>
|
||||
{sourcePreview ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-muted break-all">{sourcePreview.path}</div>
|
||||
<pre className="field-surface p-4 text-xs overflow-auto max-h-[320px] whitespace-pre-wrap">{sourcePreview.content}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title={t('drawer.sourceUnavailable')} description={t('drawer.sourceUnavailableDesc')} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[var(--radius)] border-2 border-[color:var(--color-border-dark)] bg-surface p-4 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('drawer.analyses')}</div>
|
||||
<h3 className="text-xl font-bold font-serif mt-1">{t('drawer.recentAnalyses')}</h3>
|
||||
</div>
|
||||
{skill.recent_analyses.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{skill.recent_analyses.map((analysis) => (
|
||||
<div key={`${analysis.task_id}-${analysis.timestamp}`} className="panel-subtle p-4 bg-surface space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-bold truncate">{analysis.task_id}</div>
|
||||
<div className="text-xs text-muted">{formatDate(analysis.timestamp)}</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted">{truncate(analysis.execution_note || t('drawer.noExecutionNote'), 220)}</div>
|
||||
<div className="text-xs text-muted">
|
||||
{t('drawer.analysisCompleted', {
|
||||
value: analysis.task_completed ? t('common.yes') : t('common.no'),
|
||||
toolIssues: analysis.tool_issues.length,
|
||||
suggestions: analysis.evolution_suggestions.length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title={t('drawer.noAnalysesYet')} description={t('drawer.noAnalysesDesc')} />
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return drawerContent;
|
||||
}
|
||||
|
||||
return createPortal(drawerContent, document.body);
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SkillVersionFilterBarProps {
|
||||
originFilter: string;
|
||||
onOriginFilterChange: (value: string) => void;
|
||||
tagFilter: string;
|
||||
onTagFilterChange: (value: string) => void;
|
||||
allOrigins: string[];
|
||||
allTags: string[];
|
||||
}
|
||||
|
||||
export default function SkillVersionFilterBar({
|
||||
originFilter,
|
||||
onOriginFilterChange,
|
||||
tagFilter,
|
||||
onTagFilterChange,
|
||||
allOrigins,
|
||||
allTags,
|
||||
}: SkillVersionFilterBarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium">{t('filter.origin')}</label>
|
||||
<select
|
||||
value={originFilter}
|
||||
onChange={(event) => onOriginFilterChange(event.target.value)}
|
||||
className="border border-[color:var(--color-ink)] bg-transparent px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="all">{t('filter.allOrigins')}</option>
|
||||
{allOrigins.map((origin) => (
|
||||
<option key={origin} value={origin}>{origin}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium">{t('filter.tags')}</label>
|
||||
<select
|
||||
value={tagFilter}
|
||||
onChange={(event) => onTagFilterChange(event.target.value)}
|
||||
className="border border-[color:var(--color-ink)] bg-transparent px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="all">{t('filter.allTags')}</option>
|
||||
{allTags.map((tag) => (
|
||||
<option key={tag} value={tag}>{tag}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
apps/dashboard/src/hooks/useSkillEvolutionGraphData.ts
Normal file
197
apps/dashboard/src/hooks/useSkillEvolutionGraphData.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import type { SkillLineage, SkillLineageNode } from '../api';
|
||||
|
||||
export interface SkillGraphNode {
|
||||
id: string;
|
||||
name: string;
|
||||
score: number;
|
||||
origin: string;
|
||||
generation: number;
|
||||
created_at: string;
|
||||
visibility: string;
|
||||
totalSelections: number;
|
||||
effectiveRate: number;
|
||||
isActive: boolean;
|
||||
tags: string[];
|
||||
usageRatio: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
vx?: number;
|
||||
vy?: number;
|
||||
fx?: number;
|
||||
fy?: number;
|
||||
}
|
||||
|
||||
interface SkillGraphLink {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
interface UseSkillEvolutionGraphDataResult {
|
||||
allOrigins: string[];
|
||||
allTags: string[];
|
||||
graphData: {
|
||||
nodes: SkillGraphNode[];
|
||||
links: SkillGraphLink[];
|
||||
};
|
||||
}
|
||||
|
||||
function createGraphNode(node: SkillLineageNode, maxSelections: number): SkillGraphNode {
|
||||
const usageRatio = maxSelections > 0 ? 0.25 + 0.75 * (node.total_selections / maxSelections) : 0.35;
|
||||
return {
|
||||
id: node.skill_id,
|
||||
name: node.name || node.skill_id.slice(0, 8),
|
||||
score: node.score,
|
||||
origin: node.origin,
|
||||
generation: node.generation,
|
||||
created_at: node.created_at,
|
||||
visibility: node.visibility,
|
||||
totalSelections: node.total_selections,
|
||||
effectiveRate: node.effective_rate,
|
||||
isActive: node.is_active,
|
||||
tags: node.tags,
|
||||
usageRatio,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSkillEvolutionGraphData(
|
||||
lineage: SkillLineage | null,
|
||||
originFilter: string,
|
||||
tagFilter: string,
|
||||
alwaysVisibleSkillIds: string[] = [],
|
||||
): UseSkillEvolutionGraphDataResult {
|
||||
const cachedNodesByLineageRef = useRef(new Map<string, Map<string, SkillGraphNode>>());
|
||||
|
||||
const allOrigins = useMemo(() => {
|
||||
if (!lineage) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(new Set(lineage.nodes.map((node) => node.origin))).sort();
|
||||
}, [lineage]);
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
if (!lineage) {
|
||||
return [];
|
||||
}
|
||||
const tags = new Set<string>();
|
||||
lineage.nodes.forEach((node) => {
|
||||
node.tags.forEach((tag) => tags.add(tag));
|
||||
});
|
||||
return Array.from(tags).sort();
|
||||
}, [lineage]);
|
||||
|
||||
const filteredLineage = useMemo(() => {
|
||||
if (!lineage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pinnedSkillIds = new Set(alwaysVisibleSkillIds.filter(Boolean));
|
||||
const visibleNodeIds = new Set<string>();
|
||||
const filteredNodes = lineage.nodes.filter((node) => {
|
||||
if (pinnedSkillIds.has(node.skill_id)) {
|
||||
return true;
|
||||
}
|
||||
if (originFilter !== 'all' && node.origin !== originFilter) {
|
||||
return false;
|
||||
}
|
||||
if (tagFilter !== 'all' && !node.tags.includes(tagFilter)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
filteredNodes.forEach((node) => visibleNodeIds.add(node.skill_id));
|
||||
|
||||
const filteredEdges = lineage.edges.filter(
|
||||
(edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target),
|
||||
);
|
||||
|
||||
return {
|
||||
skill_id: lineage.skill_id,
|
||||
nodes: filteredNodes,
|
||||
edges: filteredEdges,
|
||||
total_nodes: filteredNodes.length,
|
||||
};
|
||||
}, [alwaysVisibleSkillIds, lineage, originFilter, tagFilter]);
|
||||
|
||||
const lineageNodeIds = useMemo(
|
||||
() => new Set((lineage?.nodes ?? []).map((node) => node.skill_id)),
|
||||
[lineage],
|
||||
);
|
||||
|
||||
const currentLineageId = filteredLineage?.skill_id ?? null;
|
||||
|
||||
const { graphData, visibleNodes } = useMemo(() => {
|
||||
if (!filteredLineage) {
|
||||
return {
|
||||
graphData: { nodes: [], links: [] },
|
||||
visibleNodes: new Map<string, SkillGraphNode>(),
|
||||
};
|
||||
}
|
||||
|
||||
const activeCache = currentLineageId
|
||||
? cachedNodesByLineageRef.current.get(currentLineageId) ?? new Map<string, SkillGraphNode>()
|
||||
: new Map<string, SkillGraphNode>();
|
||||
const maxSelections = Math.max(...filteredLineage.nodes.map((node) => node.total_selections), 0);
|
||||
const nextVisibleNodes = new Map<string, SkillGraphNode>();
|
||||
|
||||
const nodes = filteredLineage.nodes.map((node) => {
|
||||
const existingNode = activeCache.get(node.skill_id);
|
||||
const graphNode = existingNode ?? createGraphNode(node, maxSelections);
|
||||
const freshNode = createGraphNode(node, maxSelections);
|
||||
|
||||
graphNode.id = freshNode.id;
|
||||
graphNode.name = freshNode.name;
|
||||
graphNode.score = freshNode.score;
|
||||
graphNode.origin = freshNode.origin;
|
||||
graphNode.generation = freshNode.generation;
|
||||
graphNode.created_at = freshNode.created_at;
|
||||
graphNode.visibility = freshNode.visibility;
|
||||
graphNode.totalSelections = freshNode.totalSelections;
|
||||
graphNode.effectiveRate = freshNode.effectiveRate;
|
||||
graphNode.isActive = freshNode.isActive;
|
||||
graphNode.tags = freshNode.tags;
|
||||
graphNode.usageRatio = freshNode.usageRatio;
|
||||
|
||||
nextVisibleNodes.set(node.skill_id, graphNode);
|
||||
return graphNode;
|
||||
});
|
||||
|
||||
return {
|
||||
graphData: {
|
||||
nodes,
|
||||
links: filteredLineage.edges.map((edge) => ({
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
})),
|
||||
},
|
||||
visibleNodes: nextVisibleNodes,
|
||||
};
|
||||
}, [currentLineageId, filteredLineage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLineageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLineageCache = new Map(
|
||||
cachedNodesByLineageRef.current.get(currentLineageId) ?? new Map<string, SkillGraphNode>(),
|
||||
);
|
||||
|
||||
visibleNodes.forEach((node, nodeId) => {
|
||||
nextLineageCache.set(nodeId, node);
|
||||
});
|
||||
|
||||
for (const nodeId of nextLineageCache.keys()) {
|
||||
if (!lineageNodeIds.has(nodeId)) {
|
||||
nextLineageCache.delete(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
const nextCaches = new Map(cachedNodesByLineageRef.current);
|
||||
nextCaches.set(currentLineageId, nextLineageCache);
|
||||
cachedNodesByLineageRef.current = nextCaches;
|
||||
}, [currentLineageId, lineageNodeIds, visibleNodes]);
|
||||
|
||||
return { allOrigins, allTags, graphData };
|
||||
}
|
||||
357
apps/dashboard/src/i18n/en.json
Normal file
357
apps/dashboard/src/i18n/en.json
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
{
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"evolution": "Evolution",
|
||||
"skills": "Skills",
|
||||
"workflows": "Workflows"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading…",
|
||||
"yes": "yes",
|
||||
"no": "no",
|
||||
"none": "None",
|
||||
"unavailable": "Unavailable",
|
||||
"unknown": "Unknown",
|
||||
"close": "Close",
|
||||
"score": "score",
|
||||
"success": "success",
|
||||
"active": "active",
|
||||
"inactive": "inactive",
|
||||
"noDescription": "No description",
|
||||
"steps_one": "{{count}} step",
|
||||
"steps_other": "{{count}} steps",
|
||||
"agentActions_one": "{{count}} agent action",
|
||||
"agentActions_other": "{{count}} agent actions",
|
||||
"tags": "+{{count}} tags"
|
||||
},
|
||||
"langSwitch": {
|
||||
"label": "Language"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
"message": "An unexpected error occurred",
|
||||
"goToDashboard": "Go to Dashboard",
|
||||
"details": "Error details"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"loadingDashboard": "Loading dashboard…",
|
||||
"failedToLoad": "Failed to load overview",
|
||||
"dashboardUnavailable": "Dashboard unavailable",
|
||||
"totalSkills": "Total Skills",
|
||||
"activeHint": "Active: {{count}}",
|
||||
"avgSkillScore": "Average Skill Score",
|
||||
"avgScoreHint": "Primary metric = effective rate × 100",
|
||||
"workflowSessions": "Workflow Sessions",
|
||||
"localRepo": "local repo",
|
||||
"workspace": "workspace",
|
||||
"recordedUnder": "Recorded under {{location}}",
|
||||
"workflowSuccess": "Workflow Success",
|
||||
"avgSuccessHint": "Average session success rate",
|
||||
"health": "Health",
|
||||
"runtimeSnapshot": "Runtime snapshot",
|
||||
"status": "Status",
|
||||
"dbPath": "DB Path",
|
||||
"evidenceDbPath": "Evidence DB Path",
|
||||
"workflowCount": "Workflow Count",
|
||||
"builtFrontend": "Built Frontend",
|
||||
"skillsSection": "Skills",
|
||||
"topScoredSkills": "Top scored skills",
|
||||
"noSkillsYet": "No skills yet",
|
||||
"noSkillsDesc": "Run OpenSpace tasks or sync skills into the local registry first.",
|
||||
"effective": "effective {{value}}",
|
||||
"applied": "applied {{value}}",
|
||||
"selections": "selections {{count}}",
|
||||
"workflowsSection": "Workflows",
|
||||
"recentSessions": "Recent sessions",
|
||||
"noWorkflowSessions": "No workflow sessions",
|
||||
"noWorkflowDesc": "Recordings will appear after a task is executed with recording enabled."
|
||||
},
|
||||
"skills": {
|
||||
"title": "Skill classes",
|
||||
"searchPlaceholder": "Search by name, id, description, tag, or origin",
|
||||
"sortByScore": "Sort by best score",
|
||||
"sortByUpdated": "Sort by updated time",
|
||||
"sortByName": "Sort by name",
|
||||
"skillClasses": "Skill Classes",
|
||||
"versionsHint": "Versions: {{count}}",
|
||||
"activeVersions": "Active Versions",
|
||||
"withActivity": "With activity: {{count}}",
|
||||
"avgBestScore": "Average Best Score",
|
||||
"bestNodeScoreHint": "Best node score per class",
|
||||
"selections": "Selections",
|
||||
"completionsHint": "Completions: {{count}}",
|
||||
"loadingSkills": "Loading skills…",
|
||||
"failedToLoad": "Failed to load skills",
|
||||
"noSkillsMatch": "No skills match",
|
||||
"noSkillsMatchDesc": "Try another keyword, or execute tasks so new skill telemetry lands in SQLite.",
|
||||
"bestScore": "best score",
|
||||
"noClassDescription": "No class description",
|
||||
"versions": "{{count}} versions",
|
||||
"active": "{{count}} active",
|
||||
"selectionsCount": "{{count}} selections"
|
||||
},
|
||||
"skillDetail": {
|
||||
"loadingDetail": "Loading skill detail…",
|
||||
"failedToLoad": "Failed to load skill class",
|
||||
"skillNotFound": "Skill not found",
|
||||
"backToSkills": "← Back to Skills",
|
||||
"anchoredOn": "Skill class anchored on {{id}}",
|
||||
"skillClass": "Skill Class",
|
||||
"evolutionOverview": "Evolution overview",
|
||||
"activeTip": "active tip",
|
||||
"inactiveAnchor": "inactive anchor",
|
||||
"bestVersionScore": "best version score",
|
||||
"skillDirectory": "Skill directory",
|
||||
"latestVersionCreated": "Latest version created",
|
||||
"representativeVersion": "Representative version",
|
||||
"representativeUpdate": "Representative update",
|
||||
"versions": "Versions",
|
||||
"maxGeneration": "Max generation {{count}}",
|
||||
"activeVersions": "Active Versions",
|
||||
"originsCount": "Origins: {{count}}",
|
||||
"averageScore": "Average Score",
|
||||
"acrossAllVersions": "Across all versions in this lineage",
|
||||
"selections": "Selections",
|
||||
"representativeScore": "Representative score {{score}}",
|
||||
"evolutionGraph": "Evolution Graph",
|
||||
"versionLineage": "Version lineage",
|
||||
"loadingDrawer": "Loading version drawer…",
|
||||
"noLineageGraph": "No lineage graph",
|
||||
"noLineageGraphDesc": "This skill does not yet have lineage data to visualize."
|
||||
},
|
||||
"workflows": {
|
||||
"title": "Recorded sessions",
|
||||
"searchPlaceholder": "Search by task name or instruction",
|
||||
"workflowSessions": "Workflow Sessions",
|
||||
"scannedFrom": "Scanned from logs/recordings and logs/trajectories",
|
||||
"averageSuccess": "Average Success",
|
||||
"meanSuccessRate": "Mean success rate across sessions",
|
||||
"loadingWorkflows": "Loading workflows…",
|
||||
"failedToLoad": "Failed to load workflows",
|
||||
"noSessions": "No workflow sessions",
|
||||
"noSessionsDesc": "Run `openspace` with recording enabled, then refresh this page.",
|
||||
"more": "+{{count}} more"
|
||||
},
|
||||
"workflowDetail": {
|
||||
"loadingDetail": "Loading workflow detail…",
|
||||
"failedToLoad": "Failed to load workflow",
|
||||
"workflowNotFound": "Workflow not found",
|
||||
"backToWorkflows": "← Back to Workflows",
|
||||
"workflowDetail": "Workflow detail",
|
||||
"skillsSelected_one": "{{count}} skill selected",
|
||||
"skillsSelected_other": "{{count}} skills selected",
|
||||
"mergedEvent_one": "{{count}} merged event",
|
||||
"mergedEvent_other": "{{count}} merged events",
|
||||
"runDescription": "{{status}} run with {{iterations}} and {{actions}}.",
|
||||
"started": "Started",
|
||||
"duration": "Duration",
|
||||
"stepsLabel": "Steps",
|
||||
"latestEvent": "Latest event",
|
||||
"successRate": "Success rate",
|
||||
"successRateHint_one": "{{count}} successful iteration out of {{iterations}}",
|
||||
"successRateHint_other": "{{count}} successful iterations out of {{iterations}}",
|
||||
"iterations": "Iterations",
|
||||
"totalRuntime": "{{duration}} total runtime",
|
||||
"activeBackends": "Active backends",
|
||||
"mostActive": "Most active {{backend}} · {{count}} events",
|
||||
"noRecordedActivity": "No recorded tool activity",
|
||||
"timelineEvents": "Timeline events",
|
||||
"agentToolHint": "{{agentCount}} agent actions · {{toolCount}} tool events",
|
||||
"noTimelineData": "No timeline data",
|
||||
"noTimelineDesc": "This session does not yet contain trajectory or agent action records.",
|
||||
"rawEventJson": "Raw event JSON",
|
||||
"selection": "Selection",
|
||||
"selectedSkills": "Selected skills",
|
||||
"noSelectedSkills": "No selected skills",
|
||||
"noSelectedSkillsDesc": "No skills were selected or recorded for this run.",
|
||||
"session": "Session",
|
||||
"overview": "Overview",
|
||||
"taskId": "Task ID",
|
||||
"runtime": "Runtime",
|
||||
"window": "Window",
|
||||
"ended": "Ended {{date}}",
|
||||
"selectionMethod": "Selection method",
|
||||
"notRecorded": "Not recorded",
|
||||
"enabledBackends": "Enabled backends",
|
||||
"backendActivity": "Backend activity",
|
||||
"iteration_one": "{{count}} iteration",
|
||||
"iteration_other": "{{count}} iterations",
|
||||
"step_one": "{{count}} step",
|
||||
"step_other": "{{count}} steps",
|
||||
"agentAction_one": "{{count}} agent action",
|
||||
"agentAction_other": "{{count}} agent actions",
|
||||
"successfulIteration_one": "{{count}} successful iteration",
|
||||
"successfulIteration_other": "{{count}} successful iterations"
|
||||
},
|
||||
"drawer": {
|
||||
"skillVersion": "Skill Version",
|
||||
"openAsMain": "Open as main",
|
||||
"versionSummary": "Version Summary",
|
||||
"noDescriptionAvailable": "No description available for this version.",
|
||||
"gen": "gen {{generation}}",
|
||||
"versionScore": "version score",
|
||||
"metrics": "Metrics",
|
||||
"executionQuality": "Execution quality",
|
||||
"effectiveRate": "Effective rate",
|
||||
"completionRate": "Completion rate",
|
||||
"appliedRate": "Applied rate",
|
||||
"fallbackRate": "Fallback rate",
|
||||
"selectionsLabel": "Selections",
|
||||
"appliedLabel": "Applied",
|
||||
"completionsLabel": "Completions",
|
||||
"fallbacksLabel": "Fallbacks",
|
||||
"versionMetadata": "Version Metadata",
|
||||
"origin": "Origin:",
|
||||
"generation": "Generation:",
|
||||
"visibility": "Visibility:",
|
||||
"created": "Created:",
|
||||
"firstSeen": "First seen:",
|
||||
"lastUpdated": "Last updated:",
|
||||
"skillPath": "Skill path:",
|
||||
"skillDir": "Skill dir:",
|
||||
"parentIds": "Parent IDs:",
|
||||
"evolutionAction": "Evolution action:",
|
||||
"provenanceRefs": "Provenance refs:",
|
||||
"changeSummary": "Change summary:",
|
||||
"effectiveScore": "Effective score:",
|
||||
"diff": "Diff",
|
||||
"contentDiff": "Content diff",
|
||||
"diffTooLarge": "Diff too large",
|
||||
"diffTooLargeDesc": "This version has a very large content diff, so the inline viewer is disabled.",
|
||||
"diffUnavailable": "Diff unavailable",
|
||||
"diffUnavailableDesc": "This version has a diff payload, but it could not be parsed as a unified diff.",
|
||||
"noContentDiff": "No content diff",
|
||||
"noContentDiffDesc": "This version does not have a stored content diff.",
|
||||
"source": "Source",
|
||||
"skillMdPreview": "SKILL.md preview",
|
||||
"sourceUnavailable": "Source unavailable",
|
||||
"sourceUnavailableDesc": "This version points to a missing or unreadable SKILL.md path.",
|
||||
"analyses": "Analyses",
|
||||
"recentAnalyses": "Recent execution analyses",
|
||||
"noExecutionNote": "No execution note",
|
||||
"analysisCompleted": "completed: {{value}} · tool issues: {{toolIssues}} · suggestions: {{suggestions}}",
|
||||
"noAnalysesYet": "No analyses yet",
|
||||
"noAnalysesDesc": "Execution analyses will appear after recorded task runs are persisted into SQLite."
|
||||
},
|
||||
"evolution": {
|
||||
"title": "Evolution audit",
|
||||
"refresh": "Refresh",
|
||||
"failedToLoad": "Failed to load evolution audit data",
|
||||
"failedToLoadSignals": "Failed to load quality signal audit data",
|
||||
"failedToLoadDetails": "Failed to load audit details",
|
||||
"failedToLoadRef": "Failed to load evidence ref",
|
||||
"failedToUpdateCandidate": "Failed to update candidate",
|
||||
"openJobs": "Open Jobs",
|
||||
"visibleJobs": "{{count}} visible jobs",
|
||||
"failedJobs": "Failed Jobs",
|
||||
"jobFilter": "Job filter: {{status}}",
|
||||
"pendingCandidates": "Pending Candidates",
|
||||
"candidateFilter": "Candidate filter: {{status}}",
|
||||
"pendingReviewItems": "Review Items",
|
||||
"reviewQueueHint": "Actionable candidate approvals and inspect-only blockers",
|
||||
"linkedActions": "Linked Actions",
|
||||
"actionsFromJobs": "Action refs from visible jobs",
|
||||
"qualitySignalAudit": "Quality signal audit",
|
||||
"qualitySignals": "Quality signals",
|
||||
"noQualitySignals": "No quality signals",
|
||||
"noQualitySignalsDesc": "Quality signal rows will appear after evidence reconciliation writes signal refs.",
|
||||
"qualitySignalLabel": "Quality signal",
|
||||
"signalOnly": "signal-only",
|
||||
"triggerable": "triggerable",
|
||||
"admissionShort": "admission:",
|
||||
"inspectSignal": "Inspect signal",
|
||||
"reviewEntrance": "Review and approval entry",
|
||||
"reviewQueue": "Manual review queue",
|
||||
"noReviewItems": "No approval items",
|
||||
"noReviewItemsDesc": "Actionable candidates and inspect-only admission or validation blockers will appear here. The connected evidence DB has no pending review items.",
|
||||
"inspectReview": "Inspect",
|
||||
"approveForRecheck": "Approve recheck",
|
||||
"inspectOnly": "Inspect only",
|
||||
"inspectJob": "Inspect job",
|
||||
"auditJobs": "Audit jobs",
|
||||
"triggerJobs": "Trigger jobs",
|
||||
"noJobs": "No trigger jobs",
|
||||
"noJobsDesc": "Evolution trigger jobs will appear after evidence-backed evolution runs.",
|
||||
"packets": "{{count}} packets",
|
||||
"decisions": "{{count}} decisions",
|
||||
"candidates": "Candidates",
|
||||
"admissionQueue": "Admission queue",
|
||||
"noCandidates": "No candidates",
|
||||
"noCandidatesDesc": "Admission candidates will appear when a decision needs review.",
|
||||
"recurrence": "recurrence {{count}}",
|
||||
"targets": "{{count}} targets",
|
||||
"requestRecheck": "Request review",
|
||||
"recheckExecutedCommitted": "Candidate review ran and committed an evolution action.",
|
||||
"recheckExecutedNoCommit": "Candidate review ran; no skill was committed. Check the generated job for the decision.",
|
||||
"recheckQueuedNoEngine": "Candidate review was queued. This dashboard has no live evolution engine, so it cannot run now.",
|
||||
"recheckQueued": "Candidate review was queued.",
|
||||
"recheckNeedsRecovery": "Candidate review needs commit recovery. Check the linked action before retrying.",
|
||||
"reject": "Reject",
|
||||
"details": "Details",
|
||||
"auditDetail": "Audit detail",
|
||||
"noSelection": "No selection",
|
||||
"noSelectionDesc": "Select a candidate, action, or evidence ref to inspect it.",
|
||||
"selectedCandidate": "Selected candidate",
|
||||
"selectedReviewItem": "Selected review item",
|
||||
"selectedJob": "Selected job",
|
||||
"selectedQualitySignal": "Selected quality signal",
|
||||
"signalType": "Signal type:",
|
||||
"subject": "Subject:",
|
||||
"toolKey": "Tool key:",
|
||||
"actionability": "Actionability:",
|
||||
"notTriggerableReason": "Not triggerable:",
|
||||
"job": "Job:",
|
||||
"admission": "Admission:",
|
||||
"hardFailures": "Hard failures:",
|
||||
"warnings": "Warnings:",
|
||||
"rawBackrefs": "Raw backrefs:",
|
||||
"mergeKey": "Merge key:",
|
||||
"reviewType": "Type:",
|
||||
"reviewSummary": "Summary:",
|
||||
"approvalState": "Approval state:",
|
||||
"approvalAvailable": "Review can be approved",
|
||||
"reviewNote": "Note:",
|
||||
"trigger": "Trigger:",
|
||||
"profile": "Profile:",
|
||||
"watermark": "Watermark:",
|
||||
"error": "Error:",
|
||||
"linkedRecords": "Linked records",
|
||||
"selectedPayload": "Selected payload",
|
||||
"decision": "Decision:",
|
||||
"action": "Action:",
|
||||
"targetsLabel": "Targets:",
|
||||
"blockedReason": "Blocked reason:",
|
||||
"neededEvidence": "Needed evidence:",
|
||||
"evidenceRefs": "Evidence refs",
|
||||
"decisionPayload": "Decision payload",
|
||||
"lastRecheckResult": "Last recheck result",
|
||||
"selectedAction": "Selected action",
|
||||
"skillId": "Skill ID:",
|
||||
"targetDir": "Target dir:",
|
||||
"stagingDir": "Staging dir:",
|
||||
"changedFiles": "Changed files:",
|
||||
"failure": "Failure:",
|
||||
"refPreview": "Evidence ref preview"
|
||||
},
|
||||
"filter": {
|
||||
"origin": "Origin:",
|
||||
"allOrigins": "All Origins",
|
||||
"tags": "Tags:",
|
||||
"allTags": "All Tags"
|
||||
},
|
||||
"graph": {
|
||||
"noGraphData": "No lineage graph data.",
|
||||
"tooltipScore": "score: {{value}}",
|
||||
"tooltipGeneration": "generation: {{value}}",
|
||||
"tooltipOrigin": "origin: {{value}}"
|
||||
},
|
||||
"diffViewer": {
|
||||
"noFiles": "No files in diff",
|
||||
"old": "Old",
|
||||
"new": "New"
|
||||
},
|
||||
"format": {
|
||||
"noInstruction": "No instruction captured"
|
||||
}
|
||||
}
|
||||
34
apps/dashboard/src/i18n/index.ts
Normal file
34
apps/dashboard/src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import en from './en.json';
|
||||
import zh from './zh.json';
|
||||
|
||||
const STORAGE_KEY = 'openspace-lang';
|
||||
|
||||
function getSavedLanguage(): string {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) || 'en';
|
||||
} catch {
|
||||
return 'en';
|
||||
}
|
||||
}
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
zh: { translation: zh },
|
||||
},
|
||||
lng: getSavedLanguage(),
|
||||
fallbackLng: 'en',
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
i18n.on('languageChanged', (lng) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, lng);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
357
apps/dashboard/src/i18n/zh.json
Normal file
357
apps/dashboard/src/i18n/zh.json
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
{
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
"evolution": "演化审计",
|
||||
"skills": "Skills",
|
||||
"workflows": "工作流"
|
||||
},
|
||||
"common": {
|
||||
"loading": "加载中…",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"none": "无",
|
||||
"unavailable": "不可用",
|
||||
"unknown": "未知",
|
||||
"close": "关闭",
|
||||
"score": "评分",
|
||||
"success": "成功率",
|
||||
"active": "活跃",
|
||||
"inactive": "未激活",
|
||||
"noDescription": "暂无描述",
|
||||
"steps_one": "{{count}} 步",
|
||||
"steps_other": "{{count}} 步",
|
||||
"agentActions_one": "{{count}} 个 Agent 操作",
|
||||
"agentActions_other": "{{count}} 个 Agent 操作",
|
||||
"tags": "+{{count}} 个标签"
|
||||
},
|
||||
"langSwitch": {
|
||||
"label": "语言"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "出现了错误",
|
||||
"message": "发生了意外错误",
|
||||
"goToDashboard": "返回仪表盘",
|
||||
"details": "错误详情"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "仪表盘",
|
||||
"loadingDashboard": "正在加载仪表盘…",
|
||||
"failedToLoad": "加载概览失败",
|
||||
"dashboardUnavailable": "仪表盘不可用",
|
||||
"totalSkills": "Skills 总数",
|
||||
"activeHint": "活跃:{{count}}",
|
||||
"avgSkillScore": "Skill 平均评分",
|
||||
"avgScoreHint": "主要指标 = 有效率 × 100",
|
||||
"workflowSessions": "工作流会话",
|
||||
"localRepo": "本地仓库",
|
||||
"workspace": "工作区",
|
||||
"recordedUnder": "记录于{{location}}",
|
||||
"workflowSuccess": "工作流成功率",
|
||||
"avgSuccessHint": "平均会话成功率",
|
||||
"health": "健康状态",
|
||||
"runtimeSnapshot": "运行时快照",
|
||||
"status": "状态",
|
||||
"dbPath": "数据库路径",
|
||||
"evidenceDbPath": "证据数据库路径",
|
||||
"workflowCount": "工作流数量",
|
||||
"builtFrontend": "前端构建",
|
||||
"skillsSection": "Skills",
|
||||
"topScoredSkills": "评分最高的 Skills",
|
||||
"noSkillsYet": "暂无 Skills",
|
||||
"noSkillsDesc": "先运行 OpenSpace 任务或将 Skills 同步到本地注册表中。",
|
||||
"effective": "有效率 {{value}}",
|
||||
"applied": "应用率 {{value}}",
|
||||
"selections": "选择次数 {{count}}",
|
||||
"workflowsSection": "工作流",
|
||||
"recentSessions": "近期会话",
|
||||
"noWorkflowSessions": "暂无工作流会话",
|
||||
"noWorkflowDesc": "启用录制后执行任务,录制数据将在此显示。"
|
||||
},
|
||||
"skills": {
|
||||
"title": "Skill 类别",
|
||||
"searchPlaceholder": "按名称、ID、描述、标签或来源搜索",
|
||||
"sortByScore": "按最佳评分排序",
|
||||
"sortByUpdated": "按更新时间排序",
|
||||
"sortByName": "按名称排序",
|
||||
"skillClasses": "Skill 类别",
|
||||
"versionsHint": "版本数:{{count}}",
|
||||
"activeVersions": "活跃版本",
|
||||
"withActivity": "有活动记录:{{count}}",
|
||||
"avgBestScore": "平均最佳评分",
|
||||
"bestNodeScoreHint": "每个类别的最佳节点评分",
|
||||
"selections": "选择次数",
|
||||
"completionsHint": "完成次数:{{count}}",
|
||||
"loadingSkills": "正在加载 Skills…",
|
||||
"failedToLoad": "加载 Skills 失败",
|
||||
"noSkillsMatch": "没有匹配的 Skills",
|
||||
"noSkillsMatchDesc": "尝试其他关键词,或执行任务以将新的 Skill 遥测数据写入 SQLite。",
|
||||
"bestScore": "最佳评分",
|
||||
"noClassDescription": "暂无类别描述",
|
||||
"versions": "{{count}} 个版本",
|
||||
"active": "{{count}} 个活跃",
|
||||
"selectionsCount": "{{count}} 次选择"
|
||||
},
|
||||
"skillDetail": {
|
||||
"loadingDetail": "正在加载 Skill 详情…",
|
||||
"failedToLoad": "加载 Skill 类别失败",
|
||||
"skillNotFound": "未找到 Skill",
|
||||
"backToSkills": "← 返回 Skills",
|
||||
"anchoredOn": "Skill 类别锚定于 {{id}}",
|
||||
"skillClass": "Skill 类别",
|
||||
"evolutionOverview": "演化概览",
|
||||
"activeTip": "活跃端点",
|
||||
"inactiveAnchor": "未激活锚点",
|
||||
"bestVersionScore": "最佳版本评分",
|
||||
"skillDirectory": "Skill 目录",
|
||||
"latestVersionCreated": "最新版本创建时间",
|
||||
"representativeVersion": "代表版本",
|
||||
"representativeUpdate": "代表版本更新时间",
|
||||
"versions": "版本数",
|
||||
"maxGeneration": "最大世代 {{count}}",
|
||||
"activeVersions": "活跃版本",
|
||||
"originsCount": "来源数:{{count}}",
|
||||
"averageScore": "平均评分",
|
||||
"acrossAllVersions": "该谱系中所有版本的平均值",
|
||||
"selections": "选择次数",
|
||||
"representativeScore": "代表评分 {{score}}",
|
||||
"evolutionGraph": "演化图",
|
||||
"versionLineage": "版本谱系",
|
||||
"loadingDrawer": "正在加载版本抽屉…",
|
||||
"noLineageGraph": "暂无谱系图",
|
||||
"noLineageGraphDesc": "该 Skill 尚无可供可视化的谱系数据。"
|
||||
},
|
||||
"workflows": {
|
||||
"title": "录制会话",
|
||||
"searchPlaceholder": "按任务名称或指令搜索",
|
||||
"workflowSessions": "工作流会话",
|
||||
"scannedFrom": "从 logs/recordings 和 logs/trajectories 扫描",
|
||||
"averageSuccess": "平均成功率",
|
||||
"meanSuccessRate": "各会话的平均成功率",
|
||||
"loadingWorkflows": "正在加载工作流…",
|
||||
"failedToLoad": "加载工作流失败",
|
||||
"noSessions": "暂无工作流会话",
|
||||
"noSessionsDesc": "启用录制运行 `openspace` 后刷新此页面。",
|
||||
"more": "+{{count}} 更多"
|
||||
},
|
||||
"workflowDetail": {
|
||||
"loadingDetail": "正在加载工作流详情…",
|
||||
"failedToLoad": "加载工作流失败",
|
||||
"workflowNotFound": "未找到工作流",
|
||||
"backToWorkflows": "← 返回工作流列表",
|
||||
"workflowDetail": "工作流详情",
|
||||
"skillsSelected_one": "已选择 {{count}} 个 Skill",
|
||||
"skillsSelected_other": "已选择 {{count}} 个 Skills",
|
||||
"mergedEvent_one": "{{count}} 个合并事件",
|
||||
"mergedEvent_other": "{{count}} 个合并事件",
|
||||
"runDescription": "{{status}}运行,包含 {{iterations}} 和 {{actions}}。",
|
||||
"started": "开始时间",
|
||||
"duration": "持续时间",
|
||||
"stepsLabel": "步骤",
|
||||
"latestEvent": "最新事件",
|
||||
"successRate": "成功率",
|
||||
"successRateHint_one": "{{iterations}}中 {{count}} 次成功迭代",
|
||||
"successRateHint_other": "{{iterations}}中 {{count}} 次成功迭代",
|
||||
"iterations": "迭代次数",
|
||||
"totalRuntime": "总运行时间 {{duration}}",
|
||||
"activeBackends": "活跃后端",
|
||||
"mostActive": "最活跃 {{backend}} · {{count}} 个事件",
|
||||
"noRecordedActivity": "无已记录的工具活动",
|
||||
"timelineEvents": "时间线事件",
|
||||
"agentToolHint": "{{agentCount}} 个 Agent 操作 · {{toolCount}} 个工具事件",
|
||||
"noTimelineData": "暂无时间线数据",
|
||||
"noTimelineDesc": "此会话尚未包含轨迹或 Agent 操作记录。",
|
||||
"rawEventJson": "原始事件 JSON",
|
||||
"selection": "Skill 选择",
|
||||
"selectedSkills": "已选 Skills",
|
||||
"noSelectedSkills": "未选择 Skills",
|
||||
"noSelectedSkillsDesc": "此次运行未选择或记录任何 Skills。",
|
||||
"session": "会话",
|
||||
"overview": "概览",
|
||||
"taskId": "任务 ID",
|
||||
"runtime": "运行时",
|
||||
"window": "时间窗口",
|
||||
"ended": "结束于 {{date}}",
|
||||
"selectionMethod": "选择方法",
|
||||
"notRecorded": "未记录",
|
||||
"enabledBackends": "已启用后端",
|
||||
"backendActivity": "后端活动",
|
||||
"iteration_one": "{{count}} 次迭代",
|
||||
"iteration_other": "{{count}} 次迭代",
|
||||
"step_one": "{{count}} 步",
|
||||
"step_other": "{{count}} 步",
|
||||
"agentAction_one": "{{count}} 个 Agent 操作",
|
||||
"agentAction_other": "{{count}} 个 Agent 操作",
|
||||
"successfulIteration_one": "{{count}} 次成功迭代",
|
||||
"successfulIteration_other": "{{count}} 次成功迭代"
|
||||
},
|
||||
"drawer": {
|
||||
"skillVersion": "Skill 版本",
|
||||
"openAsMain": "作为主视图打开",
|
||||
"versionSummary": "版本摘要",
|
||||
"noDescriptionAvailable": "此版本暂无描述信息。",
|
||||
"gen": "第 {{generation}} 代",
|
||||
"versionScore": "版本评分",
|
||||
"metrics": "指标",
|
||||
"executionQuality": "执行质量",
|
||||
"effectiveRate": "有效率",
|
||||
"completionRate": "完成率",
|
||||
"appliedRate": "应用率",
|
||||
"fallbackRate": "回退率",
|
||||
"selectionsLabel": "选择次数",
|
||||
"appliedLabel": "应用次数",
|
||||
"completionsLabel": "完成次数",
|
||||
"fallbacksLabel": "回退次数",
|
||||
"versionMetadata": "版本元数据",
|
||||
"origin": "来源:",
|
||||
"generation": "世代:",
|
||||
"visibility": "可见性:",
|
||||
"created": "创建时间:",
|
||||
"firstSeen": "首次出现:",
|
||||
"lastUpdated": "最后更新:",
|
||||
"skillPath": "Skill 路径:",
|
||||
"skillDir": "Skill 目录:",
|
||||
"parentIds": "父级 ID:",
|
||||
"evolutionAction": "演化 Action:",
|
||||
"provenanceRefs": "证据引用:",
|
||||
"changeSummary": "变更摘要:",
|
||||
"effectiveScore": "有效评分:",
|
||||
"diff": "Diff",
|
||||
"contentDiff": "内容 Diff",
|
||||
"diffTooLarge": "Diff 过大",
|
||||
"diffTooLargeDesc": "此版本的内容 Diff 过大,内联查看器已禁用。",
|
||||
"diffUnavailable": "Diff 不可用",
|
||||
"diffUnavailableDesc": "此版本有 Diff 数据,但无法解析为统一 Diff 格式。",
|
||||
"noContentDiff": "无内容 Diff",
|
||||
"noContentDiffDesc": "此版本没有存储的内容 Diff。",
|
||||
"source": "源代码",
|
||||
"skillMdPreview": "SKILL.md 预览",
|
||||
"sourceUnavailable": "源代码不可用",
|
||||
"sourceUnavailableDesc": "此版本指向的 SKILL.md 路径缺失或不可读。",
|
||||
"analyses": "分析",
|
||||
"recentAnalyses": "近期执行分析",
|
||||
"noExecutionNote": "无执行备注",
|
||||
"analysisCompleted": "已完成:{{value}} · 工具问题:{{toolIssues}} · 建议:{{suggestions}}",
|
||||
"noAnalysesYet": "暂无分析",
|
||||
"noAnalysesDesc": "执行分析将在已记录的任务运行持久化到 SQLite 后显示。"
|
||||
},
|
||||
"evolution": {
|
||||
"title": "演化审计",
|
||||
"refresh": "刷新",
|
||||
"failedToLoad": "加载演化审计数据失败",
|
||||
"failedToLoadSignals": "加载 quality signal 审计数据失败",
|
||||
"failedToLoadDetails": "加载审计详情失败",
|
||||
"failedToLoadRef": "加载证据引用失败",
|
||||
"failedToUpdateCandidate": "更新候选项失败",
|
||||
"openJobs": "开放 Jobs",
|
||||
"visibleJobs": "当前显示 {{count}} 个 Jobs",
|
||||
"failedJobs": "失败 Jobs",
|
||||
"jobFilter": "Job 过滤:{{status}}",
|
||||
"pendingCandidates": "待处理候选项",
|
||||
"candidateFilter": "候选项过滤:{{status}}",
|
||||
"pendingReviewItems": "审核项",
|
||||
"reviewQueueHint": "可审批候选项和只读阻塞项",
|
||||
"linkedActions": "关联 Actions",
|
||||
"actionsFromJobs": "来自当前 Jobs 的 Action 引用",
|
||||
"qualitySignalAudit": "Quality signal 审计",
|
||||
"qualitySignals": "Quality signals",
|
||||
"noQualitySignals": "暂无 quality signals",
|
||||
"noQualitySignalsDesc": "证据 reconciler 写入 signal refs 后会显示 quality signal 行。",
|
||||
"qualitySignalLabel": "Quality signal",
|
||||
"signalOnly": "signal-only",
|
||||
"triggerable": "可触发",
|
||||
"admissionShort": "准入:",
|
||||
"inspectSignal": "查看 signal",
|
||||
"reviewEntrance": "审核与审批入口",
|
||||
"reviewQueue": "人工审核队列",
|
||||
"noReviewItems": "暂无待审批项",
|
||||
"noReviewItemsDesc": "可审批的 candidate,以及只读的 admission / validation 阻塞项会显示在这里。当前连接的 evidence DB 没有待处理项。",
|
||||
"inspectReview": "查看",
|
||||
"approveForRecheck": "批准复审",
|
||||
"inspectOnly": "仅可查看",
|
||||
"inspectJob": "查看 Job",
|
||||
"auditJobs": "审计 Jobs",
|
||||
"triggerJobs": "Trigger jobs",
|
||||
"noJobs": "暂无 Trigger jobs",
|
||||
"noJobsDesc": "基于证据链的演化运行后会显示 Trigger jobs。",
|
||||
"packets": "{{count}} 个 packets",
|
||||
"decisions": "{{count}} 个 decisions",
|
||||
"candidates": "候选项",
|
||||
"admissionQueue": "准入队列",
|
||||
"noCandidates": "暂无候选项",
|
||||
"noCandidatesDesc": "当某个决策需要审阅时,准入候选项会显示在这里。",
|
||||
"recurrence": "出现 {{count}} 次",
|
||||
"targets": "{{count}} 个目标",
|
||||
"requestRecheck": "请求复审",
|
||||
"recheckExecutedCommitted": "候选项复审已执行,并提交了一个演化 action。",
|
||||
"recheckExecutedNoCommit": "候选项复审已执行,但没有提交 skill。请查看生成的 job 决策。",
|
||||
"recheckQueuedNoEngine": "候选项复审已排队。当前 dashboard 没有 live evolution engine,不能立即执行。",
|
||||
"recheckQueued": "候选项复审已排队。",
|
||||
"recheckNeedsRecovery": "候选项复审需要提交恢复。重试前请先检查关联 action。",
|
||||
"reject": "拒绝",
|
||||
"details": "详情",
|
||||
"auditDetail": "审计详情",
|
||||
"noSelection": "未选择",
|
||||
"noSelectionDesc": "选择候选项、Action 或证据引用以查看详情。",
|
||||
"selectedCandidate": "已选候选项",
|
||||
"selectedReviewItem": "已选审核项",
|
||||
"selectedJob": "已选 Job",
|
||||
"selectedQualitySignal": "已选 quality signal",
|
||||
"signalType": "Signal 类型:",
|
||||
"subject": "Subject:",
|
||||
"toolKey": "Tool key:",
|
||||
"actionability": "可执行性:",
|
||||
"notTriggerableReason": "不可触发原因:",
|
||||
"job": "Job:",
|
||||
"admission": "准入:",
|
||||
"hardFailures": "Hard failures:",
|
||||
"warnings": "Warnings:",
|
||||
"rawBackrefs": "Raw backrefs:",
|
||||
"mergeKey": "Merge key:",
|
||||
"reviewType": "类型:",
|
||||
"reviewSummary": "摘要:",
|
||||
"approvalState": "审批状态:",
|
||||
"approvalAvailable": "可批准复审",
|
||||
"reviewNote": "说明:",
|
||||
"trigger": "Trigger:",
|
||||
"profile": "Profile:",
|
||||
"watermark": "Watermark:",
|
||||
"error": "Error:",
|
||||
"linkedRecords": "关联记录",
|
||||
"selectedPayload": "已选 payload",
|
||||
"decision": "Decision:",
|
||||
"action": "Action:",
|
||||
"targetsLabel": "目标:",
|
||||
"blockedReason": "阻塞原因:",
|
||||
"neededEvidence": "需要的证据:",
|
||||
"evidenceRefs": "证据引用",
|
||||
"decisionPayload": "Decision payload",
|
||||
"lastRecheckResult": "最近复审结果",
|
||||
"selectedAction": "已选 Action",
|
||||
"skillId": "Skill ID:",
|
||||
"targetDir": "目标目录:",
|
||||
"stagingDir": "Staging 目录:",
|
||||
"changedFiles": "变更文件:",
|
||||
"failure": "失败原因:",
|
||||
"refPreview": "证据引用预览"
|
||||
},
|
||||
"filter": {
|
||||
"origin": "来源:",
|
||||
"allOrigins": "所有来源",
|
||||
"tags": "标签:",
|
||||
"allTags": "所有标签"
|
||||
},
|
||||
"graph": {
|
||||
"noGraphData": "暂无谱系图数据。",
|
||||
"tooltipScore": "评分:{{value}}",
|
||||
"tooltipGeneration": "Generation:{{value}}",
|
||||
"tooltipOrigin": "Origin:{{value}}"
|
||||
},
|
||||
"diffViewer": {
|
||||
"noFiles": "Diff 中无文件",
|
||||
"old": "旧版",
|
||||
"new": "新版"
|
||||
},
|
||||
"format": {
|
||||
"noInstruction": "未捕获到指令"
|
||||
}
|
||||
}
|
||||
644
apps/dashboard/src/index.css
Normal file
644
apps/dashboard/src/index.css
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-bg-page: #FAF9F5;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-primary: #D97757;
|
||||
--color-ink: #141413;
|
||||
--color-muted: #5E5D59;
|
||||
--color-accent: #0D9488;
|
||||
--color-danger: #DC2626;
|
||||
--color-oxide: #B85C50;
|
||||
--color-teal: #78B4AC;
|
||||
--color-gold: #FFDB58;
|
||||
--color-red: #9B3833;
|
||||
--color-diff-add: #C1D5CD;
|
||||
--color-diff-del: #E5AC93;
|
||||
--color-border: #E8E6DC;
|
||||
--color-border-dark: #D1CFC5;
|
||||
--color-mid-gray: #B0AEA5;
|
||||
--color-blue: #6A9BCC;
|
||||
--color-green: #788C5D;
|
||||
--color-paper: #F5F4ED;
|
||||
--color-warm-gray: #F0EEE6;
|
||||
--color-sage: #BCD2CB;
|
||||
--color-lavender: #CBCADB;
|
||||
--color-sand: #E3DACC;
|
||||
--color-selection: #ECBBAB;
|
||||
--radius: 8px;
|
||||
--radius-chip: 999px;
|
||||
--radius-card: 32px;
|
||||
--radius-card-sm: 18px;
|
||||
--font-serif: 'Neuton', 'Noto Serif CJK SC', 'Songti SC', 'SimSun', Georgia, serif;
|
||||
--font-sans: 'Cabin', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', 'Courier New', monospace;
|
||||
--shadow-hard: 4px 4px 0px 0px rgba(74, 59, 42, 0.1);
|
||||
--shadow-hard-sm: 2px 2px 0px 0px rgba(74, 59, 42, 0.1);
|
||||
--shadow-button: 3px 3px 0px 0px var(--color-ink);
|
||||
--shadow-soft:
|
||||
0 1px 2px rgba(74, 59, 42, 0.04),
|
||||
0 4px 12px rgba(74, 59, 42, 0.06),
|
||||
0 8px 24px rgba(74, 59, 42, 0.04);
|
||||
--bg-scanlines: linear-gradient(
|
||||
to bottom,
|
||||
transparent 50%,
|
||||
rgba(74, 59, 42, 0.03) 50%
|
||||
);
|
||||
--bg-vignette: radial-gradient(
|
||||
circle at center,
|
||||
transparent 60%,
|
||||
rgba(74, 59, 42, 0.12) 120%
|
||||
);
|
||||
--bg-noise: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.15'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: #ECBBAB;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--color-bg-page);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-mono);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
html.drawer-open,
|
||||
body.drawer-open {
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
h1{
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font-family: inherit;
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid var(--color-border);
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
input[type="radio"] {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--color-border-dark);
|
||||
border-radius: 50%;
|
||||
background-color: #FFFFFF;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input[type="radio"]:checked {
|
||||
border-color: #BCD2CB;
|
||||
background-color: #BCD2CB;
|
||||
box-shadow: 0 0 8px 2px rgba(188, 210, 203, 0.6);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--color-border-dark);
|
||||
border-radius: 4px;
|
||||
background-color: #FFFFFF;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:checked {
|
||||
border-color: #BCD2CB;
|
||||
background-color: #BCD2CB;
|
||||
box-shadow: 0 0 8px 2px rgba(188, 210, 203, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.field-surface {
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
.panel-surface {
|
||||
border: none;
|
||||
border-radius: var(--radius-card);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
.metrics-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 1.5rem 1.625rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.metric-card + .metric-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 12%;
|
||||
height: 76%;
|
||||
width: 1px;
|
||||
background-color: var(--color-border-dark);
|
||||
}
|
||||
|
||||
.panel-subtle {
|
||||
border: 1px solid var(--color-border-dark);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.record-card {
|
||||
border: none;
|
||||
border-radius: var(--radius-card);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.record-card:hover {
|
||||
transform: scale(1.005);
|
||||
box-shadow:
|
||||
0 1px 4px rgba(74, 59, 42, 0.06),
|
||||
0 3px 10px rgba(74, 59, 42, 0.04);
|
||||
}
|
||||
|
||||
.panel-surface .record-card {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 16px;
|
||||
background-color: transparent;
|
||||
animation: none 0s ease 0s 1 normal none running;
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.panel-surface .record-card:hover {
|
||||
transform: scale(1.005);
|
||||
border-color: var(--color-border-dark);
|
||||
background-color: var(--color-bg-page);
|
||||
box-shadow:
|
||||
0 2px 6px rgba(74, 59, 42, 0.08),
|
||||
0 6px 20px rgba(74, 59, 42, 0.06);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary text-bg-page px-4 py-2 font-bold btn-press disabled:opacity-50;
|
||||
box-shadow: 2px 3px 0px 0px #1b1b1a;
|
||||
}
|
||||
.btn-primary:active {
|
||||
box-shadow: 1px 1px 0px 0px #141413;
|
||||
}
|
||||
.btn-outline {
|
||||
@apply border-2 px-3 py-1 hover:bg-surface btn-press;
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.btn-outline-ink {
|
||||
@apply border-2 px-3 py-1 btn-press;
|
||||
border-color: var(--color-border-dark);
|
||||
}
|
||||
.btn-outline-ink:hover {
|
||||
background-color: var(--color-bg-page);
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
border: 1px solid var(--chip-border, var(--color-border));
|
||||
border-radius: 7.5px;
|
||||
background-color: var(--chip-bg, var(--color-surface));
|
||||
padding: 0.4rem 0.75rem;
|
||||
line-height: 1;
|
||||
color: var(--chip-text, var(--color-muted));
|
||||
}
|
||||
|
||||
.tag {
|
||||
border-radius: var(--radius-chip);
|
||||
border: 1.5px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* === shared global templates === */
|
||||
|
||||
.card-panel {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
.card-soft {
|
||||
border: none;
|
||||
border-radius: var(--radius-card-sm);
|
||||
background-color: var(--color-surface);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.tab-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-chip);
|
||||
background-color: var(--color-surface);
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.tab-pill:hover {
|
||||
border-color: var(--color-border-dark);
|
||||
background-color: var(--color-paper);
|
||||
}
|
||||
.tab-pill.is-active {
|
||||
border-color: var(--color-ink);
|
||||
background-color: var(--color-ink);
|
||||
color: var(--color-bg-page);
|
||||
}
|
||||
|
||||
.model-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
background-color: var(--color-surface);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.model-card-title {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.model-card-subtitle {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.model-card-features {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.news-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
background-color: var(--color-surface);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
.news-card:hover {
|
||||
border-color: var(--color-border-dark);
|
||||
}
|
||||
.news-card-body {
|
||||
padding: 1.25rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.news-card-title {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.news-card-label {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.feature-card-title {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.feature-card-desc {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.prompt-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
background-color: var(--color-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
.prompt-card-header {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.prompt-card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* === end shared global templates === */
|
||||
|
||||
.workflow-detail-page {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.workflow-detail-page h1,
|
||||
.workflow-detail-page h2,
|
||||
.workflow-detail-page h3 {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-copy {
|
||||
font-family: var(--font-serif);
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-kicker {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-hero {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-panel {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-soft-card {
|
||||
border: none;
|
||||
border-radius: var(--radius-card-sm);
|
||||
background-color: var(--color-surface);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-metrics-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-metric-card {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 1.5rem 1.625rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-metric-card + .workflow-metric-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 12%;
|
||||
height: 76%;
|
||||
width: 1px;
|
||||
background-color: var(--color-border-dark);
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-metric-card .workflow-kicker {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-metric-header {
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-metric-value {
|
||||
font-size: 2.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-metric-divider {
|
||||
width: 2rem;
|
||||
height: 1px;
|
||||
background-color: var(--color-border-dark);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-metric-hint {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-muted);
|
||||
font-family: var(--font-serif);
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
border: 1px solid var(--workflow-chip-border, var(--color-border));
|
||||
border-radius: 7.5px;
|
||||
background-color: var(--workflow-chip-bg, var(--color-surface));
|
||||
padding: 0.4rem 0.75rem;
|
||||
line-height: 1;
|
||||
color: var(--workflow-chip-text, var(--color-muted));
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-json {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-inline-label {
|
||||
margin-right: 0.45rem;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-accordion-item {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-accordion-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-expand-summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-muted);
|
||||
transition: border-color 0.3s ease, background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-expand.is-opened .workflow-expand-summary {
|
||||
border-color: var(--color-border-dark);
|
||||
background-color: var(--color-bg-page);
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-expand-icon {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-toggle-line {
|
||||
position: absolute;
|
||||
width: 50%;
|
||||
height: 1.5px;
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-toggle-line.is-2 {
|
||||
transform: rotate(90deg);
|
||||
transition: transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-expand.is-opened .workflow-toggle-line.is-2 {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-accordion-content {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.workflow-detail-page .workflow-expand.is-opened .workflow-accordion-content {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.btn-press {
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.btn-press:active {
|
||||
transform: translate(2px, 2px);
|
||||
box-shadow: 1px 1px 0px 0px var(--color-ink);
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes drawer-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drawer-slide-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.app-scroll-region {
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
|
||||
.drawer-scroll {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.drawer-scroll-region {
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
76
apps/dashboard/src/layouts/MainLayout.tsx
Normal file
76
apps/dashboard/src/layouts/MainLayout.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
isActive
|
||||
? 'font-bold text-primary underline decoration-2 underline-offset-4'
|
||||
: 'hover:text-primary';
|
||||
|
||||
const envHost = import.meta.env.VITE_HOST || '127.0.0.1';
|
||||
const envPort = import.meta.env.VITE_PORT || '3888';
|
||||
const apiProxyTarget = import.meta.env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:7788';
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1';
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
function hostLabel(value: string) {
|
||||
try {
|
||||
return new URL(value).host;
|
||||
} catch {
|
||||
return value.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
const viteHost = envHost === '0.0.0.0' ? '127.0.0.1' : envHost;
|
||||
const viteLabel = `${viteHost}:${envPort}`;
|
||||
const apiLabel = apiBaseUrl.startsWith('http')
|
||||
? hostLabel(apiBaseUrl)
|
||||
: (isDev ? hostLabel(apiProxyTarget) : window.location.host);
|
||||
|
||||
export default function MainLayout() {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const toggleLang = () => {
|
||||
i18n.changeLanguage(i18n.language === 'zh' ? 'en' : 'zh');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen min-w-[1180px] relative flex flex-col overflow-x-auto overflow-y-hidden bg-bg-page text-ink">
|
||||
<nav className="relative z-10 flex justify-between items-center px-4 py-3 border-b border-[color:var(--color-border)] bg-bg-page">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="font-bold text-3xl tracking-tighter font-serif">OpenSpace</div>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<NavLink to="/dashboard" className={linkClass}>
|
||||
{t('nav.dashboard')}
|
||||
</NavLink>
|
||||
<NavLink to="/evolution" className={linkClass}>
|
||||
{t('nav.evolution')}
|
||||
</NavLink>
|
||||
<NavLink to="/skills" className={linkClass}>
|
||||
{t('nav.skills')}
|
||||
</NavLink>
|
||||
<NavLink to="/workflows" className={linkClass}>
|
||||
{t('nav.workflows')}
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLang}
|
||||
className="px-2.5 py-1 text-xs border border-[color:var(--color-border-dark)] rounded hover:bg-[color:var(--color-surface)] transition-colors cursor-pointer bg-transparent text-ink"
|
||||
>
|
||||
{i18n.language === 'zh' ? 'EN' : '中文'}
|
||||
</button>
|
||||
<div className="text-xs text-muted">
|
||||
API: {apiLabel}
|
||||
{isDev ? ` · Vite: ${viteLabel}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="app-scroll-region relative z-10 min-h-0 flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
apps/dashboard/src/main.tsx
Normal file
14
apps/dashboard/src/main.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './i18n';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
142
apps/dashboard/src/pages/DashboardPage.tsx
Normal file
142
apps/dashboard/src/pages/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { overviewApi, type OverviewResponse } from '../api';
|
||||
import MetricCard from '../components/MetricCard';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import { formatDate, formatInstruction, formatPercent, truncate } from '../utils/format';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useState<OverviewResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const overview = await overviewApi.getOverview();
|
||||
if (!cancelled) {
|
||||
setData(overview);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : t('dashboard.failedToLoad'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6 text-sm text-muted">{t('dashboard.loadingDashboard')}</div>;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return <div className="p-6 text-sm text-danger">{error ?? t('dashboard.dashboardUnavailable')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<h1 className="text-3xl font-bold font-serif">{t('dashboard.title')}</h1>
|
||||
<section className="metrics-row">
|
||||
<MetricCard label={t('dashboard.totalSkills')} value={data.skills.summary.total_skills_all} hint={t('dashboard.activeHint', { count: data.skills.summary.total_skills })} />
|
||||
<MetricCard label={t('dashboard.avgSkillScore')} value={data.skills.average_score.toFixed(1)} hint={t('dashboard.avgScoreHint')} />
|
||||
<MetricCard label={t('dashboard.workflowSessions')} value={data.workflows.total} hint={t('dashboard.recordedUnder', { location: data.health.db_path.includes('.openspace') ? t('dashboard.localRepo') : t('dashboard.workspace') })} />
|
||||
<MetricCard label={t('dashboard.workflowSuccess')} value={`${data.workflows.average_success_rate.toFixed(1)}%`} hint={t('dashboard.avgSuccessHint')} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('dashboard.health')}</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">{t('dashboard.runtimeSnapshot')}</h2>
|
||||
</div>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.status')}</span><span>{data.health.status}</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.dbPath')}</span><span className="text-right break-all">{data.health.db_path}</span></div>
|
||||
<div className="flex items-center justify-between gap-4"><span className="text-muted">{t('dashboard.evidenceDbPath')}</span><span className="text-right break-all">{data.health.evidence_db_path}</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.workflowCount')}</span><span>{data.health.workflow_count}</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-muted">{t('dashboard.builtFrontend')}</span><span>{data.health.frontend_dist_exists ? t('common.yes') : t('common.no')}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-2 gap-6">
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('dashboard.skillsSection')}</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">{t('dashboard.topScoredSkills')}</h2>
|
||||
</div>
|
||||
{data.skills.top.length === 0 ? (
|
||||
<EmptyState title={t('dashboard.noSkillsYet')} description={t('dashboard.noSkillsDesc')} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.skills.top.map((skill) => (
|
||||
<Link key={skill.skill_id} to={`/skills/${encodeURIComponent(skill.skill_id)}`} className="record-card block p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="font-bold truncate">{skill.name}</div>
|
||||
<div className="text-sm text-muted">{truncate(skill.description || t('common.noDescription'), 110)}</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-2xl font-bold font-serif">{skill.score.toFixed(1)}</div>
|
||||
<div className="text-xs text-muted">{t('common.score')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-3 text-xs text-muted">
|
||||
<span>{t('dashboard.effective', { value: formatPercent(skill.effective_rate) })}</span>
|
||||
<span>{t('dashboard.applied', { value: formatPercent(skill.applied_rate) })}</span>
|
||||
<span>{t('dashboard.selections', { count: skill.total_selections })}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('dashboard.workflowsSection')}</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">{t('dashboard.recentSessions')}</h2>
|
||||
</div>
|
||||
{data.workflows.recent.length === 0 ? (
|
||||
<EmptyState title={t('dashboard.noWorkflowSessions')} description={t('dashboard.noWorkflowDesc')} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.workflows.recent.map((workflow) => (
|
||||
<Link key={workflow.id} to={`/workflows/${encodeURIComponent(workflow.id)}`} className="record-card block p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="font-bold truncate">{workflow.task_name}</div>
|
||||
<div className="text-sm text-muted line-clamp-2">{formatInstruction(workflow.instruction, 160, t('format.noInstruction'))}</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-lg font-bold font-serif">{(workflow.success_rate * 100).toFixed(1)}%</div>
|
||||
<div className="text-xs text-muted">{t('common.success')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-3 text-xs text-muted">
|
||||
<span>{t('common.steps', { count: workflow.total_steps })}</span>
|
||||
<span>{t('common.agentActions', { count: workflow.agent_action_count })}</span>
|
||||
<span>{formatDate(workflow.start_time)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
738
apps/dashboard/src/pages/EvolutionPage.tsx
Normal file
738
apps/dashboard/src/pages/EvolutionPage.tsx
Normal file
|
|
@ -0,0 +1,738 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
evolutionApi,
|
||||
type CandidateRecheckResult,
|
||||
type EvidenceRefPreview,
|
||||
type EvolutionAction,
|
||||
type EvolutionCandidate,
|
||||
type EvolutionJob,
|
||||
type EvolutionReviewItem,
|
||||
type QualitySignalAuditRow,
|
||||
} from '../api';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import MetricCard from '../components/MetricCard';
|
||||
import { formatDate, truncate } from '../utils/format';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const JOB_STATUSES = ['all', 'pending', 'running', 'failed_retryable', 'failed', 'completed'];
|
||||
const CANDIDATE_STATUSES = ['pending', 'all', 'promoted', 'rejected', 'superseded'];
|
||||
|
||||
function statusTone(status: string) {
|
||||
if (['completed', 'committed', 'committed_reconciled', 'promoted'].includes(status)) {
|
||||
return 'text-accent';
|
||||
}
|
||||
if (['failed', 'failed_needs_review', 'rejected'].includes(status)) {
|
||||
return 'text-danger';
|
||||
}
|
||||
if (['failed_retryable', 'running', 'committing'].includes(status)) {
|
||||
return 'text-primary';
|
||||
}
|
||||
return 'text-muted';
|
||||
}
|
||||
|
||||
function JsonPreview({ value }: { value: unknown }) {
|
||||
return (
|
||||
<pre className="field-surface max-h-[320px] overflow-auto p-3 text-xs whitespace-pre-wrap">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function candidateRecheckMessageKey(result: CandidateRecheckResult) {
|
||||
switch (result.recheck_status) {
|
||||
case 'needs_recovery':
|
||||
return 'evolution.recheckNeedsRecovery';
|
||||
case 'executed_committed':
|
||||
return 'evolution.recheckExecutedCommitted';
|
||||
case 'executed_no_commit':
|
||||
return 'evolution.recheckExecutedNoCommit';
|
||||
case 'queued_no_engine':
|
||||
return 'evolution.recheckQueuedNoEngine';
|
||||
case 'queued_recheck':
|
||||
default:
|
||||
return 'evolution.recheckQueued';
|
||||
}
|
||||
}
|
||||
|
||||
export default function EvolutionPage() {
|
||||
const { t } = useTranslation();
|
||||
const [jobs, setJobs] = useState<EvolutionJob[]>([]);
|
||||
const [candidates, setCandidates] = useState<EvolutionCandidate[]>([]);
|
||||
const [reviewItems, setReviewItems] = useState<EvolutionReviewItem[]>([]);
|
||||
const [qualitySignals, setQualitySignals] = useState<QualitySignalAuditRow[]>([]);
|
||||
const [jobStatus, setJobStatus] = useState('all');
|
||||
const [candidateStatus, setCandidateStatus] = useState('pending');
|
||||
const [selectedCandidate, setSelectedCandidate] = useState<EvolutionCandidate | null>(null);
|
||||
const [selectedReviewItem, setSelectedReviewItem] = useState<EvolutionReviewItem | null>(null);
|
||||
const [selectedJob, setSelectedJob] = useState<EvolutionJob | null>(null);
|
||||
const [selectedQualitySignal, setSelectedQualitySignal] = useState<QualitySignalAuditRow | null>(null);
|
||||
const [selectedDecision, setSelectedDecision] = useState<JsonRecord | null>(null);
|
||||
const [selectedAction, setSelectedAction] = useState<EvolutionAction | null>(null);
|
||||
const [selectedRef, setSelectedRef] = useState<EvidenceRefPreview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [busyCandidateId, setBusyCandidateId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [qualitySignalError, setQualitySignalError] = useState<string | null>(null);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [candidateNotice, setCandidateNotice] = useState<string | null>(null);
|
||||
|
||||
const reload = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setQualitySignalError(null);
|
||||
try {
|
||||
const [nextJobs, nextCandidates, nextReviewItems] = await Promise.all([
|
||||
evolutionApi.listJobs({ status: jobStatus, limit: 100 }),
|
||||
evolutionApi.listCandidates({ status: candidateStatus, limit: 100 }),
|
||||
evolutionApi.listReviewItems({ limit: 100 }),
|
||||
]);
|
||||
setJobs(nextJobs);
|
||||
setCandidates(nextCandidates);
|
||||
setReviewItems(nextReviewItems);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('evolution.failedToLoad'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
try {
|
||||
setQualitySignals(await evolutionApi.listQualitySignals({ limit: 100 }));
|
||||
} catch (err) {
|
||||
setQualitySignals([]);
|
||||
setQualitySignalError(err instanceof Error ? err.message : t('evolution.failedToLoadSignals'));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [candidateStatus, jobStatus]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const openJobs = jobs.filter((job) => ['pending', 'running', 'failed_retryable'].includes(job.status)).length;
|
||||
const failedJobs = jobs.filter((job) => job.status.startsWith('failed')).length;
|
||||
const pendingCandidates = candidates.filter((candidate) => candidate.status === 'pending').length;
|
||||
const pendingReviews = reviewItems.length;
|
||||
return { openJobs, failedJobs, pendingCandidates, pendingReviews };
|
||||
}, [candidates, jobs, reviewItems]);
|
||||
|
||||
const loadCandidate = async (candidate: EvolutionCandidate) => {
|
||||
setDetailLoading(true);
|
||||
setDetailError(null);
|
||||
setCandidateNotice(null);
|
||||
setSelectedReviewItem(null);
|
||||
setSelectedJob(null);
|
||||
setSelectedQualitySignal(null);
|
||||
setSelectedRef(null);
|
||||
setSelectedAction(null);
|
||||
try {
|
||||
const [detail, decision] = await Promise.all([
|
||||
evolutionApi.getCandidate(candidate.candidate_id),
|
||||
evolutionApi.getDecision(candidate.decision_id),
|
||||
]);
|
||||
setSelectedCandidate(detail);
|
||||
setSelectedDecision(decision);
|
||||
} catch (err) {
|
||||
setDetailError(err instanceof Error ? err.message : t('evolution.failedToLoadDetails'));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAction = async (actionId: string) => {
|
||||
setDetailLoading(true);
|
||||
setDetailError(null);
|
||||
setCandidateNotice(null);
|
||||
setSelectedReviewItem(null);
|
||||
setSelectedJob(null);
|
||||
setSelectedQualitySignal(null);
|
||||
setSelectedCandidate(null);
|
||||
setSelectedDecision(null);
|
||||
setSelectedRef(null);
|
||||
try {
|
||||
setSelectedAction(await evolutionApi.getAction(actionId));
|
||||
} catch (err) {
|
||||
setDetailError(err instanceof Error ? err.message : t('evolution.failedToLoadDetails'));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRefPreview = async (refId: string) => {
|
||||
setDetailLoading(true);
|
||||
setDetailError(null);
|
||||
setCandidateNotice(null);
|
||||
try {
|
||||
setSelectedRef(await evolutionApi.previewEvidenceRef(refId, 2000));
|
||||
} catch (err) {
|
||||
setDetailError(err instanceof Error ? err.message : t('evolution.failedToLoadRef'));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadJob = async (job: EvolutionJob) => {
|
||||
setDetailLoading(true);
|
||||
setDetailError(null);
|
||||
setCandidateNotice(null);
|
||||
setSelectedCandidate(null);
|
||||
setSelectedReviewItem(null);
|
||||
setSelectedQualitySignal(null);
|
||||
setSelectedAction(null);
|
||||
setSelectedRef(null);
|
||||
try {
|
||||
const detail = await evolutionApi.getJob(job.job_id);
|
||||
setSelectedJob(detail);
|
||||
setSelectedDecision(detail.decision_ids[0] ? await evolutionApi.getDecision(detail.decision_ids[0]) : null);
|
||||
} catch (err) {
|
||||
setDetailError(err instanceof Error ? err.message : t('evolution.failedToLoadDetails'));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadReviewItem = async (item: EvolutionReviewItem) => {
|
||||
if (item.item_type === 'candidate' && item.candidate_id) {
|
||||
const candidate = candidates.find((candidateItem) => candidateItem.candidate_id === item.candidate_id);
|
||||
if (candidate) {
|
||||
await loadCandidate(candidate);
|
||||
setSelectedReviewItem(item);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setDetailLoading(true);
|
||||
setDetailError(null);
|
||||
setCandidateNotice(null);
|
||||
setSelectedCandidate(null);
|
||||
setSelectedJob(null);
|
||||
setSelectedQualitySignal(null);
|
||||
setSelectedAction(null);
|
||||
setSelectedRef(null);
|
||||
setSelectedReviewItem(item);
|
||||
try {
|
||||
setSelectedDecision(item.decision_id ? await evolutionApi.getDecision(item.decision_id) : null);
|
||||
} catch (err) {
|
||||
setDetailError(err instanceof Error ? err.message : t('evolution.failedToLoadDetails'));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectQualitySignal = (signal: QualitySignalAuditRow) => {
|
||||
setDetailLoading(false);
|
||||
setDetailError(null);
|
||||
setCandidateNotice(null);
|
||||
setSelectedCandidate(null);
|
||||
setSelectedReviewItem(null);
|
||||
setSelectedJob(null);
|
||||
setSelectedDecision(null);
|
||||
setSelectedAction(null);
|
||||
setSelectedRef(null);
|
||||
setSelectedQualitySignal(signal);
|
||||
};
|
||||
|
||||
const rejectCandidate = async (candidate: EvolutionCandidate) => {
|
||||
setBusyCandidateId(candidate.candidate_id);
|
||||
setDetailError(null);
|
||||
setCandidateNotice(null);
|
||||
try {
|
||||
const updated = await evolutionApi.rejectCandidate(candidate.candidate_id, 'manual reject from dashboard');
|
||||
setSelectedCandidate(updated);
|
||||
setSelectedReviewItem(null);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setDetailError(err instanceof Error ? err.message : t('evolution.failedToUpdateCandidate'));
|
||||
} finally {
|
||||
setBusyCandidateId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const requestCandidateRecheck = async (candidate: EvolutionCandidate) => {
|
||||
setBusyCandidateId(candidate.candidate_id);
|
||||
setDetailError(null);
|
||||
try {
|
||||
const result = await evolutionApi.requestCandidateRecheck(candidate.candidate_id, true);
|
||||
setCandidateNotice(t(candidateRecheckMessageKey(result), { jobId: result.job_id }));
|
||||
await reload();
|
||||
const updated = await evolutionApi.getCandidate(candidate.candidate_id);
|
||||
setSelectedCandidate(updated);
|
||||
} catch (err) {
|
||||
setDetailError(err instanceof Error ? err.message : t('evolution.failedToUpdateCandidate'));
|
||||
} finally {
|
||||
setBusyCandidateId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold font-serif">{t('evolution.title')}</h1>
|
||||
</div>
|
||||
<button type="button" className="btn-outline-ink text-sm" onClick={() => void reload()}>
|
||||
{t('evolution.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="metrics-row">
|
||||
<MetricCard label={t('evolution.openJobs')} value={summary.openJobs} hint={t('evolution.visibleJobs', { count: jobs.length })} />
|
||||
<MetricCard label={t('evolution.failedJobs')} value={summary.failedJobs} hint={t('evolution.jobFilter', { status: jobStatus })} />
|
||||
<MetricCard label={t('evolution.pendingReviewItems')} value={summary.pendingReviews} hint={t('evolution.reviewQueueHint')} />
|
||||
<MetricCard label={t('evolution.pendingCandidates')} value={summary.pendingCandidates} hint={t('evolution.candidateFilter', { status: candidateStatus })} />
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-[1.1fr_0.9fr] gap-6">
|
||||
<div className="space-y-6">
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('evolution.qualitySignalAudit')}</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">{t('evolution.qualitySignals')}</h2>
|
||||
</div>
|
||||
{qualitySignalError ? <div className="text-sm text-danger">{qualitySignalError}</div> : null}
|
||||
{!loading && !error && !qualitySignalError && qualitySignals.length === 0 ? (
|
||||
<EmptyState title={t('evolution.noQualitySignals')} description={t('evolution.noQualitySignalsDesc')} />
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
{qualitySignals.map((signal) => (
|
||||
<article key={signal.signal_ref || signal.job_id || signal.merge_key} className="record-card p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 bg-transparent p-0 text-left"
|
||||
onClick={() => selectQualitySignal(signal)}
|
||||
>
|
||||
<div className="font-bold truncate">
|
||||
{signal.signal_type || signal.actionability || t('evolution.qualitySignalLabel')}
|
||||
</div>
|
||||
<div className="text-xs text-muted font-mono break-all">
|
||||
{signal.signal_ref || signal.job_id || signal.merge_key || t('common.none')}
|
||||
</div>
|
||||
</button>
|
||||
<div className={`text-sm font-bold shrink-0 ${statusTone(signal.job_status || signal.admission_status)}`}>
|
||||
{signal.job_status || signal.admission_status || t('evolution.signalOnly')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-xs text-muted">
|
||||
<div className="break-all">
|
||||
{signal.subject_type || t('common.unknown')}: {signal.subject_id || signal.tool_key || signal.skill_id || t('common.none')}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{signal.not_triggerable_reason || t('evolution.triggerable')}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t('evolution.admissionShort')} {signal.admission_status || t('common.none')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline-ink text-xs"
|
||||
onClick={() => selectQualitySignal(signal)}
|
||||
>
|
||||
{t('evolution.inspectSignal')}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('evolution.reviewEntrance')}</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">{t('evolution.reviewQueue')}</h2>
|
||||
</div>
|
||||
{!loading && !error && reviewItems.length === 0 ? (
|
||||
<EmptyState title={t('evolution.noReviewItems')} description={t('evolution.noReviewItemsDesc')} />
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
{reviewItems.map((item) => (
|
||||
<article key={item.item_id} className="record-card p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 bg-transparent p-0 text-left"
|
||||
onClick={() => void loadReviewItem(item)}
|
||||
>
|
||||
<div className="font-bold truncate">{item.title}</div>
|
||||
<div className="text-xs text-muted font-mono break-all">{item.item_id}</div>
|
||||
</button>
|
||||
<div className={`text-sm font-bold shrink-0 ${statusTone(item.status)}`}>{item.status}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-xs text-muted">
|
||||
<div>{item.item_type}</div>
|
||||
<div>{item.summary || item.review_note || t('common.none')}</div>
|
||||
<div>{formatDate(item.updated_at || item.created_at)}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline-ink text-xs"
|
||||
onClick={() => void loadReviewItem(item)}
|
||||
>
|
||||
{t('evolution.inspectReview')}
|
||||
</button>
|
||||
{item.approval_available && item.action_kind === 'request_recheck' && item.candidate_id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline-ink text-xs"
|
||||
disabled={busyCandidateId === item.candidate_id}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
const candidate = candidates.find((candidateItem) => candidateItem.candidate_id === item.candidate_id)
|
||||
?? await evolutionApi.getCandidate(item.candidate_id || '');
|
||||
await requestCandidateRecheck(candidate);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('evolution.approveForRecheck')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="tag px-2 py-1 text-xs text-muted">
|
||||
{t('evolution.inspectOnly')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('evolution.auditJobs')}</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">{t('evolution.triggerJobs')}</h2>
|
||||
</div>
|
||||
<select value={jobStatus} onChange={(event) => setJobStatus(event.target.value)} className="px-3 py-2 text-sm">
|
||||
{JOB_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>{status}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{loading ? <div className="text-sm text-muted">{t('common.loading')}</div> : null}
|
||||
{error ? <div className="text-sm text-danger">{error}</div> : null}
|
||||
{!loading && !error && jobs.length === 0 ? (
|
||||
<EmptyState title={t('evolution.noJobs')} description={t('evolution.noJobsDesc')} />
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
{jobs.map((job) => (
|
||||
<article key={job.job_id} className="record-card p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 bg-transparent p-0 text-left"
|
||||
onClick={() => void loadJob(job)}
|
||||
>
|
||||
<div className="font-bold truncate">{job.trigger_type} - {job.reason}</div>
|
||||
<div className="text-xs text-muted font-mono break-all">{job.job_id}</div>
|
||||
</button>
|
||||
<div className={`text-sm font-bold shrink-0 ${statusTone(job.status)}`}>{job.status}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-xs text-muted">
|
||||
<div>{formatDate(job.created_at)}</div>
|
||||
<div>{t('evolution.packets', { count: job.packet_ids.length })}</div>
|
||||
<div>{t('evolution.decisions', { count: job.decision_ids.length })}</div>
|
||||
</div>
|
||||
{job.action_ids.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{job.action_ids.map((actionId) => (
|
||||
<button
|
||||
key={actionId}
|
||||
type="button"
|
||||
className="tag px-2 py-1 hover:border-[color:var(--color-border-dark)]"
|
||||
onClick={() => void loadAction(actionId)}
|
||||
>
|
||||
{truncate(actionId, 32)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline-ink text-xs"
|
||||
onClick={() => void loadJob(job)}
|
||||
>
|
||||
{t('evolution.inspectJob')}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('evolution.candidates')}</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">{t('evolution.admissionQueue')}</h2>
|
||||
</div>
|
||||
<select value={candidateStatus} onChange={(event) => setCandidateStatus(event.target.value)} className="px-3 py-2 text-sm">
|
||||
{CANDIDATE_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>{status}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{!loading && !error && candidates.length === 0 ? (
|
||||
<EmptyState title={t('evolution.noCandidates')} description={t('evolution.noCandidatesDesc')} />
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
{candidates.map((candidate) => (
|
||||
<article key={candidate.candidate_id} className="record-card p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 bg-transparent p-0 text-left"
|
||||
onClick={() => void loadCandidate(candidate)}
|
||||
>
|
||||
<div className="font-bold truncate">{candidate.proposed_action}</div>
|
||||
<div className="text-xs text-muted font-mono break-all">{candidate.candidate_id}</div>
|
||||
</button>
|
||||
<div className={`text-sm font-bold shrink-0 ${statusTone(candidate.status)}`}>{candidate.status}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-xs text-muted">
|
||||
<div>{t('evolution.recurrence', { count: candidate.recurrence_count })}</div>
|
||||
<div>{t('evolution.targets', { count: candidate.target_skill_ids.length })}</div>
|
||||
<div>{formatDate(candidate.updated_at)}</div>
|
||||
</div>
|
||||
{candidate.status === 'pending' ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline-ink text-xs"
|
||||
disabled={busyCandidateId === candidate.candidate_id}
|
||||
onClick={() => void requestCandidateRecheck(candidate)}
|
||||
>
|
||||
{t('evolution.requestRecheck')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline-ink text-xs"
|
||||
disabled={busyCandidateId === candidate.candidate_id}
|
||||
onClick={() => void rejectCandidate(candidate)}
|
||||
>
|
||||
{t('evolution.reject')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="panel-surface p-5 space-y-5 self-start sticky top-6">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('evolution.details')}</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">{t('evolution.auditDetail')}</h2>
|
||||
</div>
|
||||
{detailLoading ? <div className="text-sm text-muted">{t('common.loading')}</div> : null}
|
||||
{detailError ? <div className="text-sm text-danger">{detailError}</div> : null}
|
||||
{candidateNotice ? <div className="text-sm text-primary">{candidateNotice}</div> : null}
|
||||
{!selectedCandidate && !selectedAction && !selectedReviewItem && !selectedJob && !selectedQualitySignal ? (
|
||||
<EmptyState title={t('evolution.noSelection')} description={t('evolution.noSelectionDesc')} />
|
||||
) : null}
|
||||
|
||||
{selectedQualitySignal ? (
|
||||
<section className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-bold">{t('evolution.selectedQualitySignal')}</h3>
|
||||
<span className={statusTone(selectedQualitySignal.job_status || selectedQualitySignal.admission_status)}>
|
||||
{selectedQualitySignal.job_status || selectedQualitySignal.admission_status || t('evolution.signalOnly')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs break-all">
|
||||
{selectedQualitySignal.signal_ref || selectedQualitySignal.job_id || t('common.none')}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div><strong>{t('evolution.signalType')}</strong> {selectedQualitySignal.signal_type || t('common.none')}</div>
|
||||
<div><strong>{t('evolution.subject')}</strong> {selectedQualitySignal.subject_type || t('common.unknown')} / <span className="font-mono break-all">{selectedQualitySignal.subject_id || t('common.none')}</span></div>
|
||||
<div><strong>{t('evolution.toolKey')}</strong> <span className="font-mono break-all">{selectedQualitySignal.tool_key || t('common.none')}</span></div>
|
||||
<div><strong>{t('evolution.skillId')}</strong> <span className="font-mono break-all">{selectedQualitySignal.skill_id || t('common.none')}</span></div>
|
||||
<div><strong>{t('evolution.actionability')}</strong> {selectedQualitySignal.actionability || t('common.none')} / {selectedQualitySignal.evidence_status || t('common.none')}</div>
|
||||
<div><strong>{t('evolution.notTriggerableReason')}</strong> {selectedQualitySignal.not_triggerable_reason || t('evolution.triggerable')}</div>
|
||||
<div><strong>{t('evolution.job')}</strong> <span className="font-mono break-all">{selectedQualitySignal.job_id || t('common.none')}</span> / {selectedQualitySignal.job_status || t('common.none')}</div>
|
||||
<div><strong>{t('evolution.admission')}</strong> {selectedQualitySignal.admission_status || t('common.none')}</div>
|
||||
<div><strong>{t('evolution.hardFailures')}</strong> {selectedQualitySignal.admission_hard_failures.length ? selectedQualitySignal.admission_hard_failures.join(', ') : t('common.none')}</div>
|
||||
<div><strong>{t('evolution.warnings')}</strong> {selectedQualitySignal.admission_warnings.length ? selectedQualitySignal.admission_warnings.join(', ') : t('common.none')}</div>
|
||||
<div><strong>{t('evolution.rawBackrefs')}</strong> {selectedQualitySignal.raw_backref_count}</div>
|
||||
<div><strong>{t('evolution.mergeKey')}</strong> <span className="font-mono break-all">{selectedQualitySignal.merge_key || t('common.none')}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{selectedJob ? (
|
||||
<section className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-bold">{t('evolution.selectedJob')}</h3>
|
||||
<span className={statusTone(selectedJob.status)}>{selectedJob.status}</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs break-all">{selectedJob.job_id}</div>
|
||||
<div className="space-y-2">
|
||||
<div><strong>{t('evolution.trigger')}</strong> {selectedJob.trigger_type} / {selectedJob.reason}</div>
|
||||
<div><strong>{t('evolution.profile')}</strong> {selectedJob.evidence_profile} / {selectedJob.subprofile}</div>
|
||||
<div><strong>{t('evolution.watermark')}</strong> {selectedJob.manifest_watermark ?? t('common.none')}</div>
|
||||
<div><strong>{t('evolution.error')}</strong> {selectedJob.error || t('common.none')}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold">{t('evolution.linkedRecords')}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedJob.packet_ids.map((packetId) => (
|
||||
<button
|
||||
key={packetId}
|
||||
type="button"
|
||||
className="tag max-w-full px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
setSelectedDecision(await evolutionApi.getPacket(packetId));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{truncate(packetId, 32)}
|
||||
</button>
|
||||
))}
|
||||
{selectedJob.decision_ids.map((decisionId) => (
|
||||
<button
|
||||
key={decisionId}
|
||||
type="button"
|
||||
className="tag max-w-full px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
setSelectedDecision(await evolutionApi.getDecision(decisionId));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{truncate(decisionId, 32)}
|
||||
</button>
|
||||
))}
|
||||
{selectedJob.action_ids.map((actionId) => (
|
||||
<button
|
||||
key={actionId}
|
||||
type="button"
|
||||
className="tag max-w-full px-2 py-1 text-xs"
|
||||
onClick={() => void loadAction(actionId)}
|
||||
>
|
||||
{truncate(actionId, 32)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{selectedDecision ? (
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold">{t('evolution.selectedPayload')}</div>
|
||||
<JsonPreview value={selectedDecision} />
|
||||
</div>
|
||||
) : null}
|
||||
<JsonPreview value={selectedJob} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{selectedReviewItem && !selectedCandidate ? (
|
||||
<section className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-bold">{t('evolution.selectedReviewItem')}</h3>
|
||||
<span className={statusTone(selectedReviewItem.status)}>{selectedReviewItem.status}</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs break-all">{selectedReviewItem.item_id}</div>
|
||||
<div className="space-y-2">
|
||||
<div><strong>{t('evolution.reviewType')}</strong> {selectedReviewItem.item_type}</div>
|
||||
<div><strong>{t('evolution.reviewSummary')}</strong> {selectedReviewItem.summary || t('common.none')}</div>
|
||||
<div><strong>{t('evolution.approvalState')}</strong> {selectedReviewItem.approval_available ? t('evolution.approvalAvailable') : t('evolution.inspectOnly')}</div>
|
||||
<div><strong>{t('evolution.reviewNote')}</strong> {selectedReviewItem.review_note || t('common.none')}</div>
|
||||
<div><strong>{t('evolution.decision')}</strong> <span className="font-mono break-all">{selectedReviewItem.decision_id || t('common.none')}</span></div>
|
||||
</div>
|
||||
{selectedDecision ? (
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold">{t('evolution.decisionPayload')}</div>
|
||||
<JsonPreview value={selectedDecision} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{selectedCandidate ? (
|
||||
<section className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-bold">{t('evolution.selectedCandidate')}</h3>
|
||||
<span className={statusTone(selectedCandidate.status)}>{selectedCandidate.status}</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs break-all">{selectedCandidate.candidate_id}</div>
|
||||
<div className="space-y-2">
|
||||
<div><strong>{t('evolution.decision')}</strong> <span className="font-mono break-all">{selectedCandidate.decision_id}</span></div>
|
||||
<div><strong>{t('evolution.action')}</strong> {selectedCandidate.proposed_action}</div>
|
||||
<div><strong>{t('evolution.targetsLabel')}</strong> {selectedCandidate.target_skill_ids.length ? selectedCandidate.target_skill_ids.join(', ') : t('common.none')}</div>
|
||||
<div><strong>{t('evolution.blockedReason')}</strong> {selectedCandidate.blocked_reason || t('common.none')}</div>
|
||||
<div><strong>{t('evolution.neededEvidence')}</strong> {selectedCandidate.needed_evidence?.length ? selectedCandidate.needed_evidence.join(', ') : t('common.none')}</div>
|
||||
</div>
|
||||
{selectedCandidate.evidence_refs.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold">{t('evolution.evidenceRefs')}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedCandidate.evidence_refs.map((refId) => (
|
||||
<button
|
||||
key={refId}
|
||||
type="button"
|
||||
className="tag max-w-full px-2 py-1 text-xs"
|
||||
onClick={() => void loadRefPreview(refId)}
|
||||
>
|
||||
<span className="block max-w-[300px] truncate">{refId}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedDecision ? (
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold">{t('evolution.decisionPayload')}</div>
|
||||
<JsonPreview value={selectedDecision} />
|
||||
</div>
|
||||
) : null}
|
||||
{selectedCandidate.last_recheck_result ? (
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold">{t('evolution.lastRecheckResult')}</div>
|
||||
<JsonPreview value={selectedCandidate.last_recheck_result} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{selectedAction ? (
|
||||
<section className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-bold">{t('evolution.selectedAction')}</h3>
|
||||
<span className={statusTone(selectedAction.commit_status)}>{selectedAction.commit_status}</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs break-all">{selectedAction.action_id}</div>
|
||||
<div className="space-y-2">
|
||||
<div><strong>{t('evolution.skillId')}</strong> <span className="font-mono break-all">{selectedAction.skill_id || t('common.none')}</span></div>
|
||||
<div><strong>{t('evolution.targetDir')}</strong> <span className="break-all">{selectedAction.active_target_dir}</span></div>
|
||||
<div><strong>{t('evolution.stagingDir')}</strong> <span className="break-all">{selectedAction.staging_dir}</span></div>
|
||||
<div><strong>{t('evolution.changedFiles')}</strong> {selectedAction.changed_files.length ? selectedAction.changed_files.join(', ') : t('common.none')}</div>
|
||||
{selectedAction.failure_reason ? (
|
||||
<div className="text-danger"><strong>{t('evolution.failure')}</strong> {selectedAction.failure_reason}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<JsonPreview value={selectedAction} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{selectedRef ? (
|
||||
<section className="space-y-2 text-sm">
|
||||
<div className="font-bold">{t('evolution.refPreview')}</div>
|
||||
<div className="font-mono text-xs break-all">{selectedRef.ref_id}</div>
|
||||
<pre className="field-surface max-h-[260px] overflow-auto p-3 text-xs whitespace-pre-wrap">{selectedRef.content}</pre>
|
||||
</section>
|
||||
) : null}
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
319
apps/dashboard/src/pages/SkillDetailPage.tsx
Normal file
319
apps/dashboard/src/pages/SkillDetailPage.tsx
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { skillsApi, type SkillDetail, type SkillLineage } from '../api';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import MetricCard from '../components/MetricCard';
|
||||
import SkillEvolutionGraph from '../components/skill-detail/SkillEvolutionGraph';
|
||||
import SkillVersionDrawer from '../components/skill-detail/SkillVersionDrawer';
|
||||
import SkillVersionFilterBar from '../components/skill-detail/SkillVersionFilterBar';
|
||||
import { useSkillEvolutionGraphData } from '../hooks/useSkillEvolutionGraphData';
|
||||
import { formatDate } from '../utils/format';
|
||||
|
||||
const DRAWER_ANIMATION_DURATION_MS = 300;
|
||||
|
||||
export default function SkillDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const { skillId = '' } = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [skillClass, setSkillClass] = useState<SkillDetail | null>(null);
|
||||
const [lineageGraph, setLineageGraph] = useState<SkillLineage | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedVersion, setSelectedVersion] = useState<SkillDetail | null>(null);
|
||||
const [drawerVersion, setDrawerVersion] = useState<SkillDetail | null>(null);
|
||||
const [drawerLoading, setDrawerLoading] = useState(false);
|
||||
const [drawerError, setDrawerError] = useState<string | null>(null);
|
||||
const [originFilter, setOriginFilter] = useState('all');
|
||||
const [tagFilter, setTagFilter] = useState('all');
|
||||
|
||||
const selectedVersionId = searchParams.get('version');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSkillClass(null);
|
||||
setLineageGraph(null);
|
||||
try {
|
||||
const [detail, lineage] = await Promise.all([
|
||||
skillsApi.getSkill(skillId),
|
||||
skillsApi.getLineage(skillId),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setSkillClass(detail);
|
||||
setLineageGraph(lineage);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : t('skillDetail.failedToLoad'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (skillId) {
|
||||
void load();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [skillId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedVersionId) {
|
||||
setSelectedVersion(null);
|
||||
setDrawerError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (skillClass && selectedVersionId === skillClass.skill_id) {
|
||||
setSelectedVersion(skillClass);
|
||||
setDrawerError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const loadSelectedVersion = async () => {
|
||||
setDrawerLoading(true);
|
||||
setDrawerError(null);
|
||||
try {
|
||||
const detail = await skillsApi.getSkill(selectedVersionId);
|
||||
if (!cancelled) {
|
||||
setSelectedVersion(detail);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setSelectedVersion(null);
|
||||
setDrawerError(err instanceof Error ? err.message : t('skillDetail.failedToLoad'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setDrawerLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadSelectedVersion();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedVersionId, skillClass, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedVersion) {
|
||||
setDrawerVersion(selectedVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!drawerVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setDrawerVersion(null);
|
||||
}, DRAWER_ANIMATION_DURATION_MS);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [drawerVersion, selectedVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lineageGraph || !selectedVersionId) {
|
||||
return;
|
||||
}
|
||||
const exists = lineageGraph.nodes.some((node) => node.skill_id === selectedVersionId);
|
||||
if (!exists && skillClass && selectedVersionId !== skillClass.skill_id) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete('version');
|
||||
setSearchParams(next);
|
||||
}
|
||||
}, [lineageGraph, searchParams, selectedVersionId, setSearchParams, skillClass]);
|
||||
|
||||
const alwaysVisibleSkillIds = useMemo(
|
||||
() => [skillId, selectedVersionId].filter((value): value is string => Boolean(value)),
|
||||
[selectedVersionId, skillId],
|
||||
);
|
||||
|
||||
const { allOrigins, allTags, graphData } = useSkillEvolutionGraphData(
|
||||
lineageGraph,
|
||||
originFilter,
|
||||
tagFilter,
|
||||
alwaysVisibleSkillIds,
|
||||
);
|
||||
|
||||
const classSummary = useMemo(() => {
|
||||
const nodes = lineageGraph?.nodes ?? [];
|
||||
if (nodes.length === 0) {
|
||||
return {
|
||||
versionCount: 0,
|
||||
activeCount: 0,
|
||||
bestScore: 0,
|
||||
averageScore: 0,
|
||||
maxGeneration: 0,
|
||||
totalSelections: 0,
|
||||
latestCreatedAt: null as string | null,
|
||||
tags: [] as string[],
|
||||
origins: [] as string[],
|
||||
};
|
||||
}
|
||||
|
||||
const tags = new Set<string>();
|
||||
const origins = new Set<string>();
|
||||
let totalSelections = 0;
|
||||
let bestScore = 0;
|
||||
let maxGeneration = 0;
|
||||
let latestCreatedAt: string | null = null;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
node.tags.forEach((tag) => tags.add(tag));
|
||||
origins.add(node.origin);
|
||||
totalSelections += node.total_selections;
|
||||
bestScore = Math.max(bestScore, node.score);
|
||||
maxGeneration = Math.max(maxGeneration, node.generation);
|
||||
if (!latestCreatedAt || Date.parse(node.created_at) > Date.parse(latestCreatedAt)) {
|
||||
latestCreatedAt = node.created_at;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
versionCount: nodes.length,
|
||||
activeCount: nodes.filter((node) => node.is_active).length,
|
||||
bestScore,
|
||||
averageScore: nodes.reduce((sum, node) => sum + node.score, 0) / nodes.length,
|
||||
maxGeneration,
|
||||
totalSelections,
|
||||
latestCreatedAt,
|
||||
tags: Array.from(tags).sort(),
|
||||
origins: Array.from(origins).sort(),
|
||||
};
|
||||
}, [lineageGraph]);
|
||||
|
||||
const openVersion = (nextSkillId: string) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set('version', nextSkillId);
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete('version');
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6 text-sm text-muted">{t('skillDetail.loadingDetail')}</div>;
|
||||
}
|
||||
|
||||
if (error || !skillClass) {
|
||||
return <div className="p-6 text-sm text-danger">{error ?? t('skillDetail.skillNotFound')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 relative">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/skills" className="chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink">{t('skillDetail.backToSkills')}</Link>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-3xl font-bold font-serif truncate">{skillClass.name}</h1>
|
||||
<div className="text-sm text-muted mt-1">{t('skillDetail.anchoredOn', { id: skillClass.skill_id })}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="panel-surface p-5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="space-y-3 min-w-0 flex-1">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('skillDetail.skillClass')}</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">{t('skillDetail.evolutionOverview')}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="tag px-2 py-1">{skillClass.category}</span>
|
||||
<span className="tag px-2 py-1">{skillClass.visibility}</span>
|
||||
<span className="tag px-2 py-1">{skillClass.is_active ? t('skillDetail.activeTip') : t('skillDetail.inactiveAnchor')}</span>
|
||||
{classSummary.origins.map((origin) => (
|
||||
<span key={origin} className="tag px-2 py-1">{origin}</span>
|
||||
))}
|
||||
{classSummary.tags.slice(0, 8).map((tag) => (
|
||||
<span key={tag} className="tag px-2 py-1">{tag}</span>
|
||||
))}
|
||||
{classSummary.tags.length > 8 ? (
|
||||
<span className="tag px-2 py-1">{t('common.tags', { count: classSummary.tags.length - 8 })}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-5xl font-bold font-serif leading-none">{classSummary.bestScore.toFixed(1)}</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted mt-2">{t('skillDetail.bestVersionScore')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm text-muted">
|
||||
<div>
|
||||
<div className="font-bold text-ink">{t('skillDetail.skillDirectory')}</div>
|
||||
<div className="break-all">{skillClass.skill_dir || t('common.unavailable')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-ink">{t('skillDetail.latestVersionCreated')}</div>
|
||||
<div>{formatDate(classSummary.latestCreatedAt)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-ink">{t('skillDetail.representativeVersion')}</div>
|
||||
<div className="break-all">{skillClass.skill_id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-ink">{t('skillDetail.representativeUpdate')}</div>
|
||||
<div>{formatDate(skillClass.last_updated)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="metrics-row">
|
||||
<MetricCard label={t('skillDetail.versions')} value={classSummary.versionCount} hint={t('skillDetail.maxGeneration', { count: classSummary.maxGeneration })} />
|
||||
<MetricCard label={t('skillDetail.activeVersions')} value={classSummary.activeCount} hint={t('skillDetail.originsCount', { count: classSummary.origins.length })} />
|
||||
<MetricCard label={t('skillDetail.averageScore')} value={classSummary.averageScore.toFixed(1)} hint={t('skillDetail.acrossAllVersions')} />
|
||||
<MetricCard label={t('skillDetail.selections')} value={classSummary.totalSelections} hint={t('skillDetail.representativeScore', { score: skillClass.score.toFixed(1) })} />
|
||||
</section>
|
||||
|
||||
<section className="panel-surface overflow-hidden relative min-h-[620px]">
|
||||
<div className="px-5 py-4 border-b border-[color:var(--color-border)] bg-surface flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('skillDetail.evolutionGraph')}</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">{t('skillDetail.versionLineage')}</h2>
|
||||
</div>
|
||||
<SkillVersionFilterBar
|
||||
originFilter={originFilter}
|
||||
onOriginFilterChange={setOriginFilter}
|
||||
tagFilter={tagFilter}
|
||||
onTagFilterChange={setTagFilter}
|
||||
allOrigins={allOrigins}
|
||||
allTags={allTags}
|
||||
/>
|
||||
</div>
|
||||
<SkillEvolutionGraph
|
||||
graphData={graphData}
|
||||
selectedNodeId={selectedVersionId}
|
||||
onNodeClick={(node) => openVersion(node.id)}
|
||||
onBackgroundClick={closeDrawer}
|
||||
/>
|
||||
{drawerLoading ? (
|
||||
<div className="absolute bottom-4 left-4 text-xs text-muted">{t('skillDetail.loadingDrawer')}</div>
|
||||
) : null}
|
||||
{drawerError ? (
|
||||
<div className="absolute bottom-4 left-4 text-xs text-danger">{drawerError}</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{lineageGraph && lineageGraph.nodes.length === 0 ? (
|
||||
<EmptyState title={t('skillDetail.noLineageGraph')} description={t('skillDetail.noLineageGraphDesc')} />
|
||||
) : null}
|
||||
|
||||
<SkillVersionDrawer skill={drawerVersion} isOpen={Boolean(selectedVersion)} onClose={closeDrawer} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
apps/dashboard/src/pages/SkillsPage.tsx
Normal file
174
apps/dashboard/src/pages/SkillsPage.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { skillsApi, type Skill, type SkillStats } from '../api';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import MetricCard from '../components/MetricCard';
|
||||
import { formatDate, truncate } from '../utils/format';
|
||||
import { buildSkillClasses } from '../utils/skillClasses';
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [stats, setStats] = useState<SkillStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [sort, setSort] = useState<'score' | 'updated' | 'name'>('score');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [skillItems, skillStats] = await Promise.all([
|
||||
skillsApi.listSkills({ activeOnly: false, sort, limit: 200 }),
|
||||
skillsApi.getSkillStats(),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setSkills(skillItems);
|
||||
setStats(skillStats);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : t('skills.failedToLoad'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sort, t]);
|
||||
|
||||
const skillClasses = useMemo(() => buildSkillClasses(skills), [skills]);
|
||||
|
||||
const filteredClasses = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
const base = !normalized
|
||||
? skillClasses
|
||||
: skillClasses.filter((skillClass) => {
|
||||
const searchCorpus = [
|
||||
skillClass.representative.name,
|
||||
skillClass.representative.skill_id,
|
||||
skillClass.representative.description,
|
||||
...skillClass.tags,
|
||||
...skillClass.origins,
|
||||
...skillClass.versions.flatMap((version) => [version.skill_id, version.name, version.description, ...version.tags]),
|
||||
].join('\n').toLowerCase();
|
||||
return searchCorpus.includes(normalized);
|
||||
});
|
||||
|
||||
return [...base].sort((left, right) => {
|
||||
if (sort === 'name') {
|
||||
return left.representative.name.localeCompare(right.representative.name);
|
||||
}
|
||||
if (sort === 'updated') {
|
||||
return Date.parse(right.latest_updated) - Date.parse(left.latest_updated);
|
||||
}
|
||||
return right.best_score - left.best_score;
|
||||
});
|
||||
}, [query, skillClasses, sort]);
|
||||
|
||||
const totalActiveVersions = useMemo(
|
||||
() => skillClasses.reduce((sum, skillClass) => sum + skillClass.active_count, 0),
|
||||
[skillClasses],
|
||||
);
|
||||
|
||||
const averageBestScore = useMemo(() => {
|
||||
if (skillClasses.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return skillClasses.reduce((sum, skillClass) => sum + skillClass.best_score, 0) / skillClasses.length;
|
||||
}, [skillClasses]);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold font-serif">{t('skills.title')}</h1>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('skills.searchPlaceholder')}
|
||||
className="px-3 py-2 min-w-[320px]"
|
||||
/>
|
||||
<select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)} className="px-3 py-2">
|
||||
<option value="score">{t('skills.sortByScore')}</option>
|
||||
<option value="updated">{t('skills.sortByUpdated')}</option>
|
||||
<option value="name">{t('skills.sortByName')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats ? (
|
||||
<section className="metrics-row">
|
||||
<MetricCard label={t('skills.skillClasses')} value={skillClasses.length} hint={t('skills.versionsHint', { count: stats.total_skills_all })} />
|
||||
<MetricCard label={t('skills.activeVersions')} value={totalActiveVersions} hint={t('skills.withActivity', { count: stats.skills_with_activity })} />
|
||||
<MetricCard label={t('skills.avgBestScore')} value={averageBestScore.toFixed(1)} hint={t('skills.bestNodeScoreHint')} />
|
||||
<MetricCard label={t('skills.selections')} value={stats.total_selections} hint={t('skills.completionsHint', { count: stats.total_completions })} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{loading ? <div className="text-sm text-muted">{t('skills.loadingSkills')}</div> : null}
|
||||
{error ? <div className="text-sm text-danger">{error}</div> : null}
|
||||
|
||||
{!loading && !error && filteredClasses.length === 0 ? (
|
||||
<EmptyState title={t('skills.noSkillsMatch')} description={t('skills.noSkillsMatchDesc')} />
|
||||
) : null}
|
||||
|
||||
{!loading && !error && filteredClasses.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{filteredClasses.map((skillClass) => (
|
||||
<Link
|
||||
key={skillClass.class_id}
|
||||
to={`/skills/${encodeURIComponent(skillClass.representative.skill_id)}`}
|
||||
className="record-card bg-surface p-4 space-y-4 hover:border-primary transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="font-bold truncate">{skillClass.representative.name}</div>
|
||||
<div className="text-xs text-muted truncate">{skillClass.representative.skill_id}</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-3xl font-bold font-serif leading-none">{skillClass.best_score.toFixed(1)}</div>
|
||||
<div className="text-xs text-muted">{t('skills.bestScore')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted">
|
||||
{truncate(skillClass.representative.description || t('skills.noClassDescription'), 160)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 text-xs text-muted">
|
||||
<div>{t('skills.versions', { count: skillClass.version_count })}</div>
|
||||
<div>{t('skills.active', { count: skillClass.active_count })}</div>
|
||||
<div>{t('skills.selectionsCount', { count: skillClass.total_selections })}</div>
|
||||
<div>{formatDate(skillClass.latest_updated)}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{skillClass.origins.map((origin) => (
|
||||
<span key={`${skillClass.class_id}-${origin}`} className="tag px-2 py-1">{origin}</span>
|
||||
))}
|
||||
{skillClass.tags.slice(0, 5).map((tag) => (
|
||||
<span key={`${skillClass.class_id}-${tag}`} className="tag px-2 py-1">{tag}</span>
|
||||
))}
|
||||
{skillClass.tags.length > 5 ? (
|
||||
<span className="tag px-2 py-1">{t('common.tags', { count: skillClass.tags.length - 5 })}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
780
apps/dashboard/src/pages/WorkflowDetailPage.tsx
Normal file
780
apps/dashboard/src/pages/WorkflowDetailPage.tsx
Normal file
|
|
@ -0,0 +1,780 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { workflowsApi, type WorkflowDetail, type WorkflowTimelineEvent } from '../api';
|
||||
import { formatDate, formatInstruction } from '../utils/format';
|
||||
|
||||
function stringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
||||
}
|
||||
|
||||
function formatDurationSeconds(value?: number | null): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
return '—';
|
||||
}
|
||||
if (value === 0) {
|
||||
return '0s';
|
||||
}
|
||||
if (value < 60) {
|
||||
return `${value.toFixed(value >= 10 ? 0 : 1)}s`;
|
||||
}
|
||||
|
||||
const totalSeconds = Math.round(value);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function getString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function collapseWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function truncateText(value: string, max = 180): string {
|
||||
const compact = collapseWhitespace(value);
|
||||
if (compact.length <= max) {
|
||||
return compact;
|
||||
}
|
||||
return `${compact.slice(0, max).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function firstMeaningfulLine(value: string): string | null {
|
||||
const lines = value
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith('```'));
|
||||
|
||||
return lines[0] ?? null;
|
||||
}
|
||||
|
||||
function humanizeToken(value: string): string {
|
||||
const normalized = value.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
return normalized.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function parseTimestamp(value?: string | null): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.replace(/(\.\d{3})\d+/, '$1');
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function formatTimeLabel(value?: string | null): string {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/);
|
||||
if (match) {
|
||||
const [, , month, day, hour, minute, second = '00'] = match;
|
||||
return `${month}/${day} ${hour}:${minute}:${second}`;
|
||||
}
|
||||
|
||||
const date = parseTimestamp(value);
|
||||
if (!date) {
|
||||
return value;
|
||||
}
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getTimestampSortValue(value?: string | null): number | null {
|
||||
const date = parseTimestamp(value);
|
||||
return date ? date.getTime() : null;
|
||||
}
|
||||
|
||||
function getCommandTitle(command?: string | null): string | null {
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const compact = collapseWhitespace(command);
|
||||
if (!compact || compact.startsWith('```') || compact.length > 48 || /[\\/]/.test(compact)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return /^[a-z0-9_-]+$/i.test(compact) ? humanizeToken(compact) : compact;
|
||||
}
|
||||
|
||||
function getCommandPreview(command?: string | null, max = 140): string | null {
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return truncateText(firstMeaningfulLine(command) ?? command, max);
|
||||
}
|
||||
|
||||
type TimelineTone = 'default' | 'primary' | 'accent' | 'danger';
|
||||
|
||||
interface TimelineFact {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TimelinePresentation {
|
||||
title: string;
|
||||
summary?: string;
|
||||
secondary?: string;
|
||||
primaryFact?: TimelineFact;
|
||||
secondaryFact?: TimelineFact;
|
||||
facts: TimelineFact[];
|
||||
}
|
||||
|
||||
interface TimelineSummary {
|
||||
total: number;
|
||||
byType: Record<string, number>;
|
||||
byAgentType: Record<string, number>;
|
||||
byBackend: Record<string, number>;
|
||||
firstTimestamp: string | null;
|
||||
lastTimestamp: string | null;
|
||||
}
|
||||
|
||||
interface SummaryMetricProps {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
function SummaryMetric({ label, value, hint }: SummaryMetricProps) {
|
||||
return (
|
||||
<div className="workflow-metric-card">
|
||||
<div className="workflow-metric-header">
|
||||
<div className="workflow-kicker">{label}</div>
|
||||
</div>
|
||||
<div className="workflow-metric-value">{value}</div>
|
||||
<div className="workflow-metric-divider" />
|
||||
<div className="workflow-metric-hint">{hint}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarRowProps {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function SidebarRow({ label, children }: SidebarRowProps) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="workflow-kicker">{label}</div>
|
||||
<div className="min-w-0 text-sm leading-6 text-ink">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkflowChipProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function WorkflowChip({ children, className = '' }: WorkflowChipProps) {
|
||||
return <span className={`workflow-chip text-xs ${className}`.trim()}>{children}</span>;
|
||||
}
|
||||
|
||||
interface QuietEmptyStateProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function QuietEmptyState({ title, description }: QuietEmptyStateProps) {
|
||||
return (
|
||||
<div className="workflow-soft-card p-6 space-y-2">
|
||||
<div className="text-lg font-semibold tracking-[-0.02em] text-ink">{title}</div>
|
||||
<p className="workflow-copy text-sm leading-6 text-muted">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function incrementCount(counter: Record<string, number>, key?: string | null): void {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = key.trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
counter[normalized] = (counter[normalized] ?? 0) + 1;
|
||||
}
|
||||
|
||||
function getStatusTone(status?: string): TimelineTone {
|
||||
if (status === 'success') {
|
||||
return 'accent';
|
||||
}
|
||||
if (status === 'error') {
|
||||
return 'danger';
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function getEventTone(event: WorkflowTimelineEvent): TimelineTone {
|
||||
if (event.type === 'agent_action') {
|
||||
return 'primary';
|
||||
}
|
||||
return getStatusTone(event.status);
|
||||
}
|
||||
|
||||
function getMarkerClasses(tone: TimelineTone): string {
|
||||
switch (tone) {
|
||||
case 'primary':
|
||||
return 'border-[color:var(--color-primary)] bg-[color:var(--color-bg-page)] text-primary';
|
||||
case 'accent':
|
||||
return 'border-[color:var(--color-diff-add)] bg-[color:var(--color-diff-add)] text-ink';
|
||||
case 'danger':
|
||||
return 'border-[color:var(--color-diff-del)] bg-[color:var(--color-diff-del)] text-ink';
|
||||
default:
|
||||
return 'border-[color:var(--color-border-dark)] bg-surface text-muted';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusChipClasses(status?: string): string {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return '[--workflow-chip-border:var(--color-diff-add)] [--workflow-chip-bg:var(--color-diff-add)] [--workflow-chip-text:var(--color-ink)]';
|
||||
case 'error':
|
||||
return '[--workflow-chip-border:var(--color-diff-del)] [--workflow-chip-bg:var(--color-diff-del)] [--workflow-chip-text:var(--color-ink)]';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isLowSignalPreview(value: string): boolean {
|
||||
const compact = collapseWhitespace(value);
|
||||
return compact.length <= 3 && /^(\/\*\*?|\/\/|#|[{[(])$/.test(compact);
|
||||
}
|
||||
|
||||
function describeTimelineEvent(event: WorkflowTimelineEvent): TimelinePresentation {
|
||||
const details = isRecord(event.details) ? event.details : null;
|
||||
|
||||
if (event.type === 'agent_action') {
|
||||
const input = details && isRecord(details.input) ? details.input : null;
|
||||
const reasoning = details && isRecord(details.reasoning) ? details.reasoning : null;
|
||||
const instruction = getString(input?.instruction);
|
||||
const response = getString(reasoning?.response);
|
||||
const thought = getString(reasoning?.thought);
|
||||
const responsePreview = response ? firstMeaningfulLine(response) ?? response : null;
|
||||
const thoughtPreview = thought ? firstMeaningfulLine(thought) ?? thought : null;
|
||||
|
||||
const facts: TimelineFact[] = [];
|
||||
const primaryFact = event.agent_name ? { label: 'Agent', value: event.agent_name } : undefined;
|
||||
const secondaryFact = event.agent_type ? { label: 'Type', value: humanizeToken(event.agent_type) } : undefined;
|
||||
|
||||
return {
|
||||
title: humanizeToken(event.label || 'agent_action'),
|
||||
summary: instruction ? truncateText(instruction, 220) : undefined,
|
||||
secondary: responsePreview
|
||||
? `Response · ${truncateText(responsePreview, 180)}`
|
||||
: thoughtPreview
|
||||
? `Thought · ${truncateText(thoughtPreview, 180)}`
|
||||
: undefined,
|
||||
primaryFact,
|
||||
secondaryFact,
|
||||
facts,
|
||||
};
|
||||
}
|
||||
|
||||
const result = details && isRecord(details.result) ? details.result : null;
|
||||
const command = getString(details?.command);
|
||||
const commandTitle = getCommandTitle(command);
|
||||
const commandPreview = getCommandPreview(command);
|
||||
const toolName = event.label ? humanizeToken(event.label) : null;
|
||||
const stdout = getString(result?.stdout);
|
||||
const stderr = getString(result?.stderr);
|
||||
const output = getString(result?.output);
|
||||
const content = getString(result?.content);
|
||||
const outputPreviewSource = stderr ?? output ?? stdout ?? content;
|
||||
const outputPreview = outputPreviewSource
|
||||
? firstMeaningfulLine(outputPreviewSource) ?? outputPreviewSource
|
||||
: null;
|
||||
const resolvedTitle = commandTitle ?? toolName ?? humanizeToken(event.backend ? `${event.backend}_execution` : 'tool_execution');
|
||||
const primaryFact = toolName && toolName !== commandTitle
|
||||
? { label: 'Tool', value: toolName }
|
||||
: event.backend
|
||||
? { label: 'Backend', value: humanizeToken(event.backend) }
|
||||
: undefined;
|
||||
const secondaryFact = commandPreview && commandPreview !== resolvedTitle
|
||||
? { label: 'Command', value: truncateText(commandPreview, 72) }
|
||||
: undefined;
|
||||
const shouldHideSummary = outputPreview ? isLowSignalPreview(outputPreview) : false;
|
||||
|
||||
const facts: TimelineFact[] = [];
|
||||
if (event.backend) {
|
||||
facts.push({ label: 'Backend', value: humanizeToken(event.backend) });
|
||||
}
|
||||
if (toolName && toolName !== commandTitle && primaryFact?.value !== toolName) {
|
||||
facts.push({ label: 'Tool', value: toolName });
|
||||
}
|
||||
if (typeof result?.exit_code === 'number') {
|
||||
facts.push({ label: 'Exit', value: String(result.exit_code) });
|
||||
}
|
||||
|
||||
return {
|
||||
title: resolvedTitle,
|
||||
summary: outputPreview && !shouldHideSummary
|
||||
? `${stderr ? 'stderr · ' : ''}${truncateText(outputPreview, 220)}`
|
||||
: undefined,
|
||||
secondary: undefined,
|
||||
primaryFact,
|
||||
secondaryFact,
|
||||
facts,
|
||||
};
|
||||
}
|
||||
|
||||
export default function WorkflowDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const { workflowId = '' } = useParams();
|
||||
const [workflow, setWorkflow] = useState<WorkflowDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const detail = await workflowsApi.getWorkflow(workflowId);
|
||||
if (!cancelled) {
|
||||
setWorkflow(detail);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : t('workflowDetail.failedToLoad'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (workflowId) {
|
||||
void load();
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workflowId, t]);
|
||||
|
||||
const timeline = useMemo(() => {
|
||||
const events = workflow?.timeline ?? [];
|
||||
|
||||
return events
|
||||
.map((event, originalIndex) => ({
|
||||
event,
|
||||
originalIndex,
|
||||
timestampValue: getTimestampSortValue(event.timestamp),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (left.timestampValue !== null && right.timestampValue !== null && left.timestampValue !== right.timestampValue) {
|
||||
return left.timestampValue - right.timestampValue;
|
||||
}
|
||||
if (left.timestampValue !== null && right.timestampValue === null) {
|
||||
return -1;
|
||||
}
|
||||
if (left.timestampValue === null && right.timestampValue !== null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const rawTimestampDiff = (left.event.timestamp ?? '').localeCompare(right.event.timestamp ?? '');
|
||||
if (rawTimestampDiff !== 0) {
|
||||
return rawTimestampDiff;
|
||||
}
|
||||
|
||||
return left.originalIndex - right.originalIndex;
|
||||
})
|
||||
.map(({ event }) => event);
|
||||
}, [workflow]);
|
||||
|
||||
const timelineSummary = useMemo<TimelineSummary>(() => {
|
||||
const byType: Record<string, number> = {};
|
||||
const byAgentType: Record<string, number> = {};
|
||||
const byBackend: Record<string, number> = {};
|
||||
let firstTimestamp: string | null = null;
|
||||
let lastTimestamp: string | null = null;
|
||||
|
||||
for (const event of timeline) {
|
||||
incrementCount(byType, event.type);
|
||||
incrementCount(byAgentType, event.agent_type);
|
||||
incrementCount(byBackend, event.backend);
|
||||
if (getTimestampSortValue(event.timestamp) !== null) {
|
||||
if (!firstTimestamp) {
|
||||
firstTimestamp = event.timestamp;
|
||||
}
|
||||
lastTimestamp = event.timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: timeline.length,
|
||||
byType,
|
||||
byAgentType,
|
||||
byBackend,
|
||||
firstTimestamp,
|
||||
lastTimestamp,
|
||||
};
|
||||
}, [timeline]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="workflow-detail-page p-6">
|
||||
<div className="mx-auto max-w-[1480px]">
|
||||
<div className="workflow-panel p-6 text-sm text-muted">{t('workflowDetail.loadingDetail')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !workflow) {
|
||||
return (
|
||||
<div className="workflow-detail-page p-6">
|
||||
<div className="mx-auto max-w-[1480px]">
|
||||
<div className="workflow-panel p-6 text-sm text-danger">{error ?? t('workflowDetail.workflowNotFound')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = workflow.metadata ?? {};
|
||||
const enabledBackends = getStringArray(metadata.backends).sort((left, right) => left.localeCompare(right));
|
||||
const activityEntries = Object.entries(workflow.backend_counts).sort(([, left], [, right]) => right - left);
|
||||
const topBackendEntry = activityEntries[0];
|
||||
const skillSelection = isRecord(metadata.skill_selection) ? metadata.skill_selection : null;
|
||||
const selectionMethod = skillSelection && typeof skillSelection.method === 'string' ? skillSelection.method : null;
|
||||
const executionDurationLabel = formatDurationSeconds(workflow.execution_time);
|
||||
const selectedSkillLabel = t('workflowDetail.skillsSelected', { count: workflow.selected_skills.length });
|
||||
const iterationsLabel = t('workflowDetail.iteration', { count: workflow.iterations });
|
||||
const totalStepLabel = t('workflowDetail.step', { count: workflow.total_steps });
|
||||
const actionCountLabel = t('workflowDetail.agentAction', { count: workflow.agent_action_count });
|
||||
const agentActionCount = timelineSummary.byType.agent_action ?? 0;
|
||||
const toolExecutionCount = timelineSummary.byType.tool_execution ?? 0;
|
||||
const latestEventLabel = timelineSummary.lastTimestamp ? formatTimeLabel(timelineSummary.lastTimestamp) : '—';
|
||||
const timelineEventLabel = t('workflowDetail.mergedEvent', { count: timelineSummary.total });
|
||||
const statusLabel = humanizeToken(workflow.status || 'unknown');
|
||||
const selectionMethodLabel = selectionMethod ? humanizeToken(selectionMethod) : t('workflowDetail.notRecorded');
|
||||
const successRateLabel = formatPercent(workflow.success_rate);
|
||||
|
||||
return (
|
||||
<div className="workflow-detail-page p-6">
|
||||
<div className="mx-auto max-w-[1480px] space-y-6">
|
||||
<section className="workflow-hero p-6 lg:p-8 space-y-8">
|
||||
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
to="/workflows"
|
||||
className="workflow-chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink"
|
||||
>
|
||||
{t('workflowDetail.backToWorkflows')}
|
||||
</Link>
|
||||
<WorkflowChip className={getStatusChipClasses(workflow.status)}>{statusLabel}</WorkflowChip>
|
||||
<WorkflowChip>{selectedSkillLabel}</WorkflowChip>
|
||||
<WorkflowChip>{timelineEventLabel}</WorkflowChip>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="workflow-kicker">{t('workflowDetail.workflowDetail')}</div>
|
||||
<h1 className="max-w-5xl text-4xl font-semibold leading-[1.05] tracking-[-0.05em] text-ink lg:text-5xl xl:text-[3.6rem]">
|
||||
{workflow.task_name}
|
||||
</h1>
|
||||
<p className="workflow-copy max-w-4xl text-lg leading-8 text-muted line-clamp-4">
|
||||
{formatInstruction(workflow.instruction, 480, t('format.noInstruction'))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="workflow-soft-card w-full max-w-sm shrink-0 p-5 space-y-5">
|
||||
<p className="workflow-copy text-base leading-7 text-muted">
|
||||
{t('workflowDetail.runDescription', { status: statusLabel, iterations: iterationsLabel, actions: actionCountLabel })}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<div className="workflow-kicker">{t('workflowDetail.started')}</div>
|
||||
<div className="text-base font-medium text-ink">{formatDate(workflow.start_time)}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="workflow-kicker">{t('workflowDetail.duration')}</div>
|
||||
<div className="text-base font-medium text-ink">{executionDurationLabel}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="workflow-kicker">{t('workflowDetail.stepsLabel')}</div>
|
||||
<div className="text-base font-medium text-ink">{totalStepLabel}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="workflow-kicker">{t('workflowDetail.latestEvent')}</div>
|
||||
<div className="text-base font-medium text-ink">{latestEventLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="workflow-metrics-row">
|
||||
<SummaryMetric
|
||||
label={t('workflowDetail.successRate')}
|
||||
value={successRateLabel}
|
||||
hint={t('workflowDetail.successRateHint', { count: workflow.success_count, iterations: iterationsLabel })}
|
||||
/>
|
||||
<SummaryMetric
|
||||
label={t('workflowDetail.iterations')}
|
||||
value={workflow.iterations}
|
||||
hint={t('workflowDetail.totalRuntime', { duration: executionDurationLabel })}
|
||||
/>
|
||||
<SummaryMetric
|
||||
label={t('workflowDetail.activeBackends')}
|
||||
value={activityEntries.length}
|
||||
hint={topBackendEntry ? t('workflowDetail.mostActive', { backend: humanizeToken(topBackendEntry[0]), count: topBackendEntry[1] }) : t('workflowDetail.noRecordedActivity')}
|
||||
/>
|
||||
<SummaryMetric
|
||||
label={t('workflowDetail.timelineEvents')}
|
||||
value={timelineSummary.total}
|
||||
hint={t('workflowDetail.agentToolHint', { agentCount: agentActionCount, toolCount: toolExecutionCount })}
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.72fr)]">
|
||||
<div className="workflow-panel p-5 space-y-4">
|
||||
{timeline.length === 0 ? (
|
||||
<QuietEmptyState
|
||||
title={t('workflowDetail.noTimelineData')}
|
||||
description={t('workflowDetail.noTimelineDesc')}
|
||||
/>
|
||||
) : (
|
||||
<div role="list" aria-label="Workflow timeline events">
|
||||
{timeline.map((event, index) => {
|
||||
const isOpen = openIndex === index;
|
||||
const presentation = describeTimelineEvent(event);
|
||||
const markerClasses = getMarkerClasses(getEventTone(event));
|
||||
const visibleIdentity = presentation.primaryFact?.value ?? null;
|
||||
const visibleMeta = presentation.secondaryFact ?? null;
|
||||
|
||||
return (
|
||||
<article key={`${event.timestamp}-${event.type}-${index}`} className="workflow-accordion-item flex gap-3" role="listitem">
|
||||
<div className="flex w-10 shrink-0 flex-col items-center self-stretch">
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-full border text-[11px] font-semibold ${markerClasses}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
{index < timeline.length - 1 ? <div className="mt-2.5 w-px flex-1 bg-[color:var(--color-border)]" /> : null}
|
||||
</div>
|
||||
|
||||
<div className={`workflow-expand flex-1${isOpen ? ' is-opened' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full cursor-pointer border-0 bg-transparent p-0 text-left font-inherit"
|
||||
onClick={() => setOpenIndex(isOpen ? null : index)}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="workflow-kicker">{humanizeToken(event.type)}</div>
|
||||
{event.status ? (
|
||||
<WorkflowChip className={getStatusChipClasses(event.status)}>
|
||||
{humanizeToken(event.status)}
|
||||
</WorkflowChip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-lg font-semibold leading-tight tracking-[-0.03em] text-ink">
|
||||
{presentation.title}
|
||||
</h3>
|
||||
{(visibleIdentity || visibleMeta) ? (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm leading-6 text-muted">
|
||||
{visibleIdentity ? (
|
||||
<span className="font-medium text-ink">{visibleIdentity}</span>
|
||||
) : null}
|
||||
{visibleMeta ? (
|
||||
<span>
|
||||
<span className="workflow-inline-label">{visibleMeta.label}</span>
|
||||
{visibleMeta.value}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 text-left lg:text-right">
|
||||
<div className="text-sm text-muted">{formatTimeLabel(event.timestamp)}</div>
|
||||
</div>
|
||||
<span className="workflow-expand-summary">
|
||||
<span className="workflow-expand-icon" aria-hidden="true">
|
||||
<span className="workflow-toggle-line" />
|
||||
<span className="workflow-toggle-line is-2" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="workflow-accordion-content">
|
||||
<div className="overflow-hidden">
|
||||
<div className="workflow-expand-body mt-3 space-y-3 border-t border-[color:var(--color-border)] pt-3">
|
||||
{presentation.summary ? (
|
||||
<p className="workflow-copy text-[15px] leading-6 text-ink">{presentation.summary}</p>
|
||||
) : null}
|
||||
{presentation.secondary ? (
|
||||
<p className="text-sm leading-6 text-muted">{presentation.secondary}</p>
|
||||
) : null}
|
||||
|
||||
{presentation.facts.length > 0 ? (
|
||||
<dl className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
{presentation.facts.map((fact) => (
|
||||
<div key={`${fact.label}-${fact.value}`} className="flex min-w-0 items-baseline gap-2">
|
||||
<dt className="text-[11px] uppercase tracking-[0.16em] text-muted whitespace-nowrap">{fact.label}</dt>
|
||||
<dd className="break-words text-sm leading-6 text-ink">{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
|
||||
<div className="workflow-soft-card p-3.5">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-muted">{t('workflowDetail.rawEventJson')}</div>
|
||||
<pre className="workflow-json mt-3 whitespace-pre-wrap break-all text-xs leading-6 text-muted">
|
||||
{stringify(event.details)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4 xl:sticky xl:top-6">
|
||||
<section className="workflow-panel p-5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="workflow-kicker">{t('workflowDetail.selection')}</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">{t('workflowDetail.selectedSkills')}</h2>
|
||||
</div>
|
||||
{workflow.selected_skills.length > 0 ? <WorkflowChip>{workflow.selected_skills.length}</WorkflowChip> : null}
|
||||
</div>
|
||||
|
||||
{workflow.selected_skills.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{workflow.selected_skills.map((skillId, index) => (
|
||||
<Link
|
||||
key={`${skillId}-${index}`}
|
||||
to={`/skills/${encodeURIComponent(skillId)}`}
|
||||
title={skillId}
|
||||
className="workflow-chip inline-flex max-w-full items-center transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink"
|
||||
>
|
||||
<span className="block max-w-[240px] truncate">{skillId}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-lg font-semibold tracking-[-0.02em] text-ink">{t('workflowDetail.noSelectedSkills')}</div>
|
||||
<p className="workflow-copy text-sm leading-6 text-muted">{t('workflowDetail.noSelectedSkillsDesc')}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="workflow-panel p-5 space-y-5">
|
||||
<div>
|
||||
<div className="workflow-kicker">{t('workflowDetail.session')}</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">{t('workflowDetail.overview')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<SidebarRow label={t('workflowDetail.taskId')}>
|
||||
<div className="break-all">{workflow.task_id}</div>
|
||||
</SidebarRow>
|
||||
|
||||
<SidebarRow label={t('workflowDetail.runtime')}>
|
||||
<div>{executionDurationLabel}</div>
|
||||
<div className="text-xs leading-6 text-muted">
|
||||
{iterationsLabel} · {totalStepLabel} · {actionCountLabel}
|
||||
</div>
|
||||
</SidebarRow>
|
||||
|
||||
<SidebarRow label={t('workflowDetail.window')}>
|
||||
<div>{formatDate(workflow.start_time)}</div>
|
||||
<div className="text-xs leading-6 text-muted">{t('workflowDetail.ended', { date: formatDate(workflow.end_time) })}</div>
|
||||
</SidebarRow>
|
||||
|
||||
<SidebarRow label={t('workflowDetail.selectionMethod')}>
|
||||
<div>{selectionMethodLabel}</div>
|
||||
<div className="text-xs leading-6 text-muted">{selectedSkillLabel}</div>
|
||||
</SidebarRow>
|
||||
|
||||
{enabledBackends.length > 0 ? (
|
||||
<SidebarRow label={t('workflowDetail.enabledBackends')}>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{enabledBackends.map((backend) => (
|
||||
<WorkflowChip key={backend}>{humanizeToken(backend)}</WorkflowChip>
|
||||
))}
|
||||
</div>
|
||||
</SidebarRow>
|
||||
) : null}
|
||||
|
||||
{activityEntries.length > 0 ? (
|
||||
<SidebarRow label={t('workflowDetail.backendActivity')}>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{activityEntries.map(([backend, count]) => (
|
||||
<WorkflowChip key={backend}>{`${humanizeToken(backend)} ${count}`}</WorkflowChip>
|
||||
))}
|
||||
</div>
|
||||
</SidebarRow>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
apps/dashboard/src/pages/WorkflowsPage.tsx
Normal file
130
apps/dashboard/src/pages/WorkflowsPage.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { workflowsApi, type WorkflowSummary } from '../api';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import { formatDate, formatInstruction } from '../utils/format';
|
||||
|
||||
export default function WorkflowsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const items = await workflowsApi.listWorkflows();
|
||||
if (!cancelled) {
|
||||
setWorkflows(items);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : t('workflows.failedToLoad'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
const list = normalized
|
||||
? workflows.filter((workflow) =>
|
||||
workflow.task_name.toLowerCase().includes(normalized) ||
|
||||
workflow.task_id.toLowerCase().includes(normalized) ||
|
||||
workflow.instruction.toLowerCase().includes(normalized),
|
||||
)
|
||||
: [...workflows];
|
||||
return list.sort((a, b) => new Date(b.start_time ?? 0).getTime() - new Date(a.start_time ?? 0).getTime());
|
||||
}, [query, workflows]);
|
||||
|
||||
const averageSuccess = workflows.length > 0
|
||||
? ((workflows.reduce((sum, item) => sum + item.success_rate, 0) / workflows.length) * 100).toFixed(1)
|
||||
: '0.0';
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold font-serif">{t('workflows.title')}</h1>
|
||||
</div>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('workflows.searchPlaceholder')}
|
||||
className="px-3 py-2 min-w-[320px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('workflows.workflowSessions')}</div>
|
||||
<div className="text-3xl font-bold font-serif leading-none">{workflows.length}</div>
|
||||
<div className="text-xs text-muted">{t('workflows.scannedFrom')}</div>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">{t('workflows.averageSuccess')}</div>
|
||||
<div className="text-3xl font-bold font-serif leading-none">{averageSuccess}%</div>
|
||||
<div className="text-xs text-muted">{t('workflows.meanSuccessRate')}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading ? <div className="text-sm text-muted">{t('workflows.loadingWorkflows')}</div> : null}
|
||||
{error ? <div className="text-sm text-danger">{error}</div> : null}
|
||||
|
||||
{!loading && !error && filtered.length === 0 ? (
|
||||
<EmptyState title={t('workflows.noSessions')} description={t('workflows.noSessionsDesc')} />
|
||||
) : null}
|
||||
|
||||
{!loading && !error && filtered.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{filtered.map((workflow) => (
|
||||
<Link key={workflow.id} to={`/workflows/${encodeURIComponent(workflow.id)}`} className="record-card bg-surface block p-4 space-y-3 hover:border-primary transition-colors">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="font-bold truncate">{workflow.task_name}</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-lg font-bold font-serif">{(workflow.success_rate * 100).toFixed(1)}%</div>
|
||||
<div className="text-xs text-muted">{t('common.success')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted line-clamp-2">{formatInstruction(workflow.instruction, 220, t('format.noInstruction'))}</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-xs text-muted">
|
||||
<div>{t('common.steps', { count: workflow.total_steps })}</div>
|
||||
<div>{t('common.agentActions', { count: workflow.agent_action_count })}</div>
|
||||
<div>{formatDate(workflow.start_time)}</div>
|
||||
</div>
|
||||
{workflow.selected_skills.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{workflow.selected_skills.slice(0, 3).map((skillId, index) => (
|
||||
<span key={`${skillId}-${index}`} title={skillId} className="tag inline-flex max-w-full items-center px-2 py-1">
|
||||
<span className="block max-w-[220px] truncate">{skillId}</span>
|
||||
</span>
|
||||
))}
|
||||
{workflow.selected_skills.length > 3 ? (
|
||||
<span className="tag px-2 py-1 text-muted">
|
||||
{t('workflows.more', { count: workflow.selected_skills.length - 3 })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
apps/dashboard/src/utils/diffParser.ts
Normal file
85
apps/dashboard/src/utils/diffParser.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
export interface DiffLine {
|
||||
type: 'add' | 'del' | 'ctx';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface DiffHunk {
|
||||
header: string;
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
export interface DiffFile {
|
||||
path: string;
|
||||
hunks: DiffHunk[];
|
||||
}
|
||||
|
||||
function hasRenderableHunks(file: DiffFile): boolean {
|
||||
return file.hunks.some((hunk) => hunk.lines.length > 0);
|
||||
}
|
||||
|
||||
export function parseDiff(raw: string | null | undefined): DiffFile[] {
|
||||
if (!raw || typeof raw !== 'string' || raw.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files: DiffFile[] = [];
|
||||
let currentFile: DiffFile | null = null;
|
||||
let awaitingNewFilePath = false;
|
||||
|
||||
const finalizeCurrentFile = () => {
|
||||
if (currentFile && hasRenderableHunks(currentFile)) {
|
||||
files.push(currentFile);
|
||||
}
|
||||
currentFile = null;
|
||||
awaitingNewFilePath = false;
|
||||
};
|
||||
|
||||
for (const line of raw.split('\n')) {
|
||||
if (line.startsWith('--- a/')) {
|
||||
finalizeCurrentFile();
|
||||
currentFile = { path: line.slice(6), hunks: [] };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line === '--- /dev/null') {
|
||||
finalizeCurrentFile();
|
||||
awaitingNewFilePath = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('+++ b/')) {
|
||||
if (awaitingNewFilePath || !currentFile) {
|
||||
currentFile = { path: line.slice(6), hunks: [] };
|
||||
}
|
||||
awaitingNewFilePath = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line === '+++ /dev/null') {
|
||||
awaitingNewFilePath = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('@@') && currentFile) {
|
||||
currentFile.hunks.push({ header: line, lines: [] });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentFile || currentFile.hunks.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hunk = currentFile.hunks[currentFile.hunks.length - 1];
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
hunk.lines.push({ type: 'add', text: line.slice(1) });
|
||||
} else if (line.startsWith('-')) {
|
||||
hunk.lines.push({ type: 'del', text: line.slice(1) });
|
||||
} else if (line.startsWith(' ')) {
|
||||
hunk.lines.push({ type: 'ctx', text: line.slice(1) });
|
||||
}
|
||||
}
|
||||
|
||||
finalizeCurrentFile();
|
||||
return files;
|
||||
}
|
||||
73
apps/dashboard/src/utils/format.ts
Normal file
73
apps/dashboard/src/utils/format.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
export function formatPercent(value: number, digits = 1): string {
|
||||
return `${(value * 100).toFixed(digits)}%`;
|
||||
}
|
||||
|
||||
export function formatDate(value?: string | null): string {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function truncate(value: string, max = 120): string {
|
||||
if (value.length <= max) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten absolute file paths in instruction text for display.
|
||||
*
|
||||
* "/Users/foo/bar/project/src/components/Panel.tsx"
|
||||
* → "…/src/components/Panel.tsx"
|
||||
*
|
||||
* Keeps the last `keep` path segments so context is preserved.
|
||||
*/
|
||||
function shortenPaths(text: string, keep = 3): string {
|
||||
// Match absolute paths: /Xxx/Yyy/.../file or dir
|
||||
return text.replace(
|
||||
/\/(?:Users|home|tmp|var|opt)\/[^\s,;)}\]]+/g,
|
||||
(match) => {
|
||||
const parts = match.split('/').filter(Boolean);
|
||||
if (parts.length <= keep) return match;
|
||||
return '…/' + parts.slice(-keep).join('/');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a raw instruction string for display.
|
||||
*
|
||||
* 1. Shortens long absolute file paths to last 3 segments
|
||||
* 2. Collapses excessive whitespace / newlines
|
||||
* 3. Trims to `maxLen` characters (with ellipsis)
|
||||
*/
|
||||
export function formatInstruction(
|
||||
raw: string | null | undefined,
|
||||
maxLen?: number,
|
||||
fallback = 'No instruction captured',
|
||||
): string {
|
||||
if (!raw) return fallback;
|
||||
|
||||
let text = shortenPaths(raw);
|
||||
|
||||
// Collapse newlines → spaces, normalise whitespace
|
||||
text = text.replace(/\n+/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||
|
||||
if (maxLen && text.length > maxLen) {
|
||||
text = text.slice(0, maxLen) + '…';
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
115
apps/dashboard/src/utils/skillClasses.ts
Normal file
115
apps/dashboard/src/utils/skillClasses.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import type { Skill } from '../api';
|
||||
|
||||
export interface SkillClassSummary {
|
||||
class_id: string;
|
||||
representative: Skill;
|
||||
versions: Skill[];
|
||||
version_count: number;
|
||||
active_count: number;
|
||||
best_score: number;
|
||||
average_score: number;
|
||||
latest_updated: string;
|
||||
origins: string[];
|
||||
tags: string[];
|
||||
total_selections: number;
|
||||
}
|
||||
|
||||
function getUpdatedAtTimestamp(skill: Skill): number {
|
||||
const parsed = Date.parse(skill.last_updated);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function chooseRepresentative(versions: Skill[]): Skill {
|
||||
return [...versions].sort((left, right) => {
|
||||
if (left.is_active !== right.is_active) {
|
||||
return left.is_active ? -1 : 1;
|
||||
}
|
||||
if (left.generation !== right.generation) {
|
||||
return right.generation - left.generation;
|
||||
}
|
||||
if (left.score !== right.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
return getUpdatedAtTimestamp(right) - getUpdatedAtTimestamp(left);
|
||||
})[0];
|
||||
}
|
||||
|
||||
export function buildSkillClasses(skills: Skill[]): SkillClassSummary[] {
|
||||
const skillsById = new Map(skills.map((skill) => [skill.skill_id, skill]));
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
|
||||
skills.forEach((skill) => {
|
||||
skill.parent_skill_ids.forEach((parentSkillId) => {
|
||||
const children = childrenByParent.get(parentSkillId) ?? [];
|
||||
children.push(skill.skill_id);
|
||||
childrenByParent.set(parentSkillId, children);
|
||||
});
|
||||
});
|
||||
|
||||
const visited = new Set<string>();
|
||||
const classes: SkillClassSummary[] = [];
|
||||
|
||||
for (const skill of skills) {
|
||||
if (visited.has(skill.skill_id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stack = [skill.skill_id];
|
||||
const versions: Skill[] = [];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const currentSkillId = stack.pop();
|
||||
if (!currentSkillId || visited.has(currentSkillId)) {
|
||||
continue;
|
||||
}
|
||||
const currentSkill = skillsById.get(currentSkillId);
|
||||
if (!currentSkill) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.add(currentSkillId);
|
||||
versions.push(currentSkill);
|
||||
|
||||
currentSkill.parent_skill_ids.forEach((parentSkillId) => {
|
||||
if (skillsById.has(parentSkillId) && !visited.has(parentSkillId)) {
|
||||
stack.push(parentSkillId);
|
||||
}
|
||||
});
|
||||
|
||||
(childrenByParent.get(currentSkillId) ?? []).forEach((childSkillId) => {
|
||||
if (!visited.has(childSkillId)) {
|
||||
stack.push(childSkillId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const representative = chooseRepresentative(versions);
|
||||
const latestUpdated = [...versions]
|
||||
.sort((left, right) => getUpdatedAtTimestamp(right) - getUpdatedAtTimestamp(left))[0]?.last_updated ?? representative.last_updated;
|
||||
const tagSet = new Set<string>();
|
||||
const originSet = new Set<string>();
|
||||
let totalSelections = 0;
|
||||
|
||||
versions.forEach((version) => {
|
||||
version.tags.forEach((tag) => tagSet.add(tag));
|
||||
originSet.add(version.origin);
|
||||
totalSelections += version.total_selections;
|
||||
});
|
||||
|
||||
classes.push({
|
||||
class_id: representative.skill_id,
|
||||
representative,
|
||||
versions,
|
||||
version_count: versions.length,
|
||||
active_count: versions.filter((version) => version.is_active).length,
|
||||
best_score: Math.max(...versions.map((version) => version.score)),
|
||||
average_score: versions.reduce((sum, version) => sum + version.score, 0) / versions.length,
|
||||
latest_updated: latestUpdated,
|
||||
origins: Array.from(originSet).sort(),
|
||||
tags: Array.from(tagSet).sort(),
|
||||
total_selections: totalSelections,
|
||||
});
|
||||
}
|
||||
|
||||
return classes;
|
||||
}
|
||||
1
apps/dashboard/src/vite-env.d.ts
vendored
Normal file
1
apps/dashboard/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
58
apps/dashboard/tailwind.config.js
Normal file
58
apps/dashboard/tailwind.config.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
serif: 'var(--font-serif)',
|
||||
sans: 'var(--font-sans)',
|
||||
mono: 'var(--font-mono)',
|
||||
},
|
||||
colors: {
|
||||
'bg-page': 'var(--color-bg-page)',
|
||||
'surface': 'var(--color-surface)',
|
||||
'primary': 'var(--color-primary)',
|
||||
'ink': 'var(--color-ink)',
|
||||
'muted': 'var(--color-muted)',
|
||||
'accent': 'var(--color-accent)',
|
||||
'danger': 'var(--color-danger)',
|
||||
'oxide': 'var(--color-oxide)',
|
||||
'teal': 'var(--color-teal)',
|
||||
'gold': 'var(--color-gold)',
|
||||
'red': 'var(--color-red)',
|
||||
'diff-add': 'var(--color-diff-add)',
|
||||
'diff-del': 'var(--color-diff-del)',
|
||||
'border': 'var(--color-border)',
|
||||
'border-dark': 'var(--color-border-dark)',
|
||||
'mid-gray': 'var(--color-mid-gray)',
|
||||
'blue': 'var(--color-blue)',
|
||||
'green': 'var(--color-green)',
|
||||
'paper': 'var(--color-paper)',
|
||||
'warm-gray': 'var(--color-warm-gray)',
|
||||
'sage': 'var(--color-sage)',
|
||||
'lavender': 'var(--color-lavender)',
|
||||
'sand': 'var(--color-sand)',
|
||||
'selection': 'var(--color-selection)',
|
||||
},
|
||||
backgroundImage: {
|
||||
'scanlines': 'linear-gradient(to bottom, transparent 50%, rgba(74, 59, 42, 0.03) 50%)',
|
||||
'vignette': 'radial-gradient(circle at center, transparent 60%, rgba(74, 59, 42, 0.12) 120%)',
|
||||
'noise': 'url("data:image/svg+xml,%3Csvg viewBox=\'0 0 200 200\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cfilter id=\'noiseFilter\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.8\' numOctaves=\'3\' stitchTiles=\'stitch\'/%3E%3C/filter%3E%3Crect width=\'100%25\' height=\'100%25\' filter=\'url(%23noiseFilter)\' opacity=\'0.15\'/%3E%3C/svg%3E")',
|
||||
},
|
||||
borderRadius: {
|
||||
DEFAULT: 'var(--radius)',
|
||||
chip: 'var(--radius-chip)',
|
||||
card: 'var(--radius-card)',
|
||||
'card-sm': 'var(--radius-card-sm)',
|
||||
},
|
||||
boxShadow: {
|
||||
'button': '4px 6px 0px 0px #4A3B2A',
|
||||
'soft': 'var(--shadow-soft)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
apps/dashboard/tsconfig.json
Normal file
25
apps/dashboard/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
apps/dashboard/tsconfig.node.json
Normal file
10
apps/dashboard/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
35
apps/dashboard/vite.config.ts
Normal file
35
apps/dashboard/vite.config.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, __dirname, '');
|
||||
const port = Number(env.VITE_PORT || 3888);
|
||||
const host = env.VITE_HOST || '127.0.0.1';
|
||||
const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:7788';
|
||||
const proxy = {
|
||||
'/api': {
|
||||
target: apiProxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host,
|
||||
port,
|
||||
proxy,
|
||||
},
|
||||
preview: {
|
||||
host,
|
||||
port,
|
||||
proxy,
|
||||
},
|
||||
};
|
||||
});
|
||||
628
apps/tui/package-lock.json
generated
Normal file
628
apps/tui/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,628 @@
|
|||
{
|
||||
"name": "openspace-tui",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openspace-tui",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"ink": "^5.1.0",
|
||||
"react": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz",
|
||||
"integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"is-fullwidth-code-point": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
||||
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
|
||||
"integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
|
||||
"integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"environment": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/auto-bind": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
|
||||
"integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-boxes": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
|
||||
"integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
|
||||
"integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"restore-cursor": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz",
|
||||
"integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"slice-ansi": "^5.0.0",
|
||||
"string-width": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate/node_modules/slice-ansi": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz",
|
||||
"integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.0.0",
|
||||
"is-fullwidth-code-point": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/code-excerpt": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz",
|
||||
"integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"convert-to-spaces": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-to-spaces": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz",
|
||||
"integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/environment": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
|
||||
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
|
||||
"integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
|
||||
"integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
|
||||
"integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ink": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz",
|
||||
"integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.1.3",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"ansi-styles": "^6.2.1",
|
||||
"auto-bind": "^5.0.1",
|
||||
"chalk": "^5.3.0",
|
||||
"cli-boxes": "^3.0.0",
|
||||
"cli-cursor": "^4.0.0",
|
||||
"cli-truncate": "^4.0.0",
|
||||
"code-excerpt": "^4.0.0",
|
||||
"es-toolkit": "^1.22.0",
|
||||
"indent-string": "^5.0.0",
|
||||
"is-in-ci": "^1.0.0",
|
||||
"patch-console": "^2.0.0",
|
||||
"react-reconciler": "^0.29.0",
|
||||
"scheduler": "^0.23.0",
|
||||
"signal-exit": "^3.0.7",
|
||||
"slice-ansi": "^7.1.0",
|
||||
"stack-utils": "^2.0.6",
|
||||
"string-width": "^7.2.0",
|
||||
"type-fest": "^4.27.0",
|
||||
"widest-line": "^5.0.0",
|
||||
"wrap-ansi": "^9.0.0",
|
||||
"ws": "^8.18.0",
|
||||
"yoga-layout": "~3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"react": ">=18.0.0",
|
||||
"react-devtools-core": "^4.19.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-devtools-core": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ink/node_modules/react-reconciler": {
|
||||
"version": "0.29.2",
|
||||
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
|
||||
"integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
|
||||
"integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-in-ci": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz",
|
||||
"integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-in-ci": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-fn": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/onetime": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
||||
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-fn": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-console": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz",
|
||||
"integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
|
||||
"integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"onetime": "^5.1.0",
|
||||
"signal-exit": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/slice-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"is-fullwidth-code-point": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/slice-ansi/node_modules/is-fullwidth-code-point": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
|
||||
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stack-utils": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||
"integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/widest-line": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz",
|
||||
"integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"string-width": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yoga-layout": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
||||
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
27
apps/tui/package.json
Normal file
27
apps/tui/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "openspace-tui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"copy:packaged": "node ../../scripts/copy-dist-to-packaged.mjs dist ../../openspace/packaged/tui --node-modules node_modules --package-json package.json",
|
||||
"build:packaged": "npm run build && npm run copy:packaged",
|
||||
"dev": "tsc --watch",
|
||||
"start": "node dist/index.js",
|
||||
"test": "node --test dist/bridge/*.test.js dist/commands/*.test.js dist/keybindings/*.test.js dist/state/*.test.js dist/utils/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "^5.1.0",
|
||||
"react": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
48
apps/tui/src/bridge/__tests__/echo_agent.ts
Normal file
48
apps/tui/src/bridge/__tests__/echo_agent.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Minimal TS "agent" that reads NDJSON from stdin, echoes every message
|
||||
* back with type prefixed by "echo_", and also sends a startup greeting.
|
||||
*
|
||||
* Used by the Python integration test to verify the IPC round-trip.
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { ndjsonParse, ndjsonSafeStringify } from "../ndjson.js";
|
||||
|
||||
function write(obj: unknown): void {
|
||||
process.stdout.write(ndjsonSafeStringify(obj) + "\n");
|
||||
}
|
||||
|
||||
write({
|
||||
type: "status_update",
|
||||
data: { model: "test-model", session_id: "test-session" },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
|
||||
for await (const line of rl) {
|
||||
const msg = ndjsonParse<{
|
||||
type: string;
|
||||
data: unknown;
|
||||
timestamp?: number;
|
||||
}>(line);
|
||||
if (!msg) continue;
|
||||
|
||||
if (msg.type === "permission_request") {
|
||||
write({
|
||||
type: "permission_response",
|
||||
data: {
|
||||
tool_use_id: (msg.data as Record<string, unknown>).tool_use_id,
|
||||
decision: "allow",
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
write({
|
||||
type: `echo_${msg.type}`,
|
||||
data: msg.data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
81
apps/tui/src/bridge/__tests__/scenario_agent.ts
Normal file
81
apps/tui/src/bridge/__tests__/scenario_agent.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import type { IPCMessage, PermissionRequestData } from "../protocol.js";
|
||||
import { StructuredIO } from "../structuredIO.js";
|
||||
|
||||
const io = new StructuredIO();
|
||||
const receivedTypes: string[] = [];
|
||||
const permissionCounts = new Map<string, number>();
|
||||
|
||||
function send(message: IPCMessage | { type: string; data: unknown; timestamp?: number }): void {
|
||||
process.stdout.write(JSON.stringify({
|
||||
...message,
|
||||
timestamp: message.timestamp ?? Date.now(),
|
||||
}) + "\n");
|
||||
}
|
||||
|
||||
function emitReport(type: string): void {
|
||||
send({
|
||||
type,
|
||||
data: {
|
||||
received_types: [...receivedTypes],
|
||||
permission_counts: Object.fromEntries(permissionCounts.entries()),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
process.stdout.write("not-json\n");
|
||||
process.stdout.write("\n");
|
||||
send({
|
||||
type: "query",
|
||||
data: {
|
||||
text: "List the workspace status and explain any blockers.",
|
||||
attachments: ["/tmp/spec.md", "/tmp/screenshot.png"],
|
||||
},
|
||||
});
|
||||
|
||||
for await (const message of io.receive()) {
|
||||
receivedTypes.push(message.type);
|
||||
|
||||
if (message.type === "permission_request") {
|
||||
const data = message.data as PermissionRequestData;
|
||||
permissionCounts.set(data.tool_use_id, (permissionCounts.get(data.tool_use_id) ?? 0) + 1);
|
||||
void io.waitForPermissionDecision(data).catch(() => undefined);
|
||||
|
||||
if (!data.tool_use_id.startsWith("hang-")) {
|
||||
setTimeout(() => {
|
||||
io.resolvePermission({
|
||||
tool_use_id: data.tool_use_id,
|
||||
decision: "allow",
|
||||
});
|
||||
}, 25);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.type === "notification") {
|
||||
const data = message.data as Record<string, unknown>;
|
||||
if (data.title === "report-permissions") {
|
||||
emitReport("permission_report");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.type === "task_complete") {
|
||||
emitReport("scenario_complete");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.type === "cancel") {
|
||||
send({
|
||||
type: "cancel_ack",
|
||||
data: {
|
||||
pending_count: io.pendingPermissions.size,
|
||||
received_types: [...receivedTypes],
|
||||
},
|
||||
});
|
||||
io.rejectAllPending("Cancelled by core");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
io.close();
|
||||
128
apps/tui/src/bridge/askUserQuestionState.test.ts
Normal file
128
apps/tui/src/bridge/askUserQuestionState.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildAskUserQuestionAllowResponse,
|
||||
buildAskUserQuestionDenyResponse,
|
||||
getAskUserQuestionQuestions,
|
||||
isAskUserQuestionRequest,
|
||||
} from "./askUserQuestionState.js";
|
||||
import type { PermissionRequestData } from "./protocol.js";
|
||||
|
||||
function makeRequest(
|
||||
overrides: Partial<PermissionRequestData> = {},
|
||||
): PermissionRequestData {
|
||||
return {
|
||||
tool_use_id: "ask-1",
|
||||
tool_name: "ask_user_question",
|
||||
tool_input: {
|
||||
questions: [
|
||||
{
|
||||
question: "Pick a branch",
|
||||
options: [
|
||||
{ label: "A", preview: "<p>A preview</p>" },
|
||||
{ label: "B" },
|
||||
],
|
||||
},
|
||||
],
|
||||
annotations: {
|
||||
"Pick a branch": {
|
||||
existing: "kept",
|
||||
},
|
||||
"Earlier question": "legacy note",
|
||||
},
|
||||
},
|
||||
response_channel: "tool_permission_response",
|
||||
options: [{ option_id: "allow_once", label: "Allow once" }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("detects AskUserQuestion requests from interaction and tool aliases", () => {
|
||||
assert.equal(
|
||||
isAskUserQuestionRequest(
|
||||
makeRequest({ tool_name: "Bash", interaction: "ask_user_question" }),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isAskUserQuestionRequest(makeRequest({ tool_name: "AskUserQuestion" })),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isAskUserQuestionRequest(makeRequest({ tool_name: "Bash" })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("coerces valid questions and caps the native panel payload at four", () => {
|
||||
const questions = getAskUserQuestionQuestions(
|
||||
makeRequest({
|
||||
questions: [
|
||||
{
|
||||
question: "One",
|
||||
options: [{ label: "A" }, { label: "B" }],
|
||||
},
|
||||
{
|
||||
question: "",
|
||||
options: [{ label: "A" }, { label: "B" }],
|
||||
},
|
||||
{
|
||||
question: "Two",
|
||||
options: [{ label: "C" }, { label: "D" }],
|
||||
multiSelect: true,
|
||||
},
|
||||
{
|
||||
question: "Three",
|
||||
options: [{ label: "E" }, { label: "F" }],
|
||||
},
|
||||
{
|
||||
question: "Four",
|
||||
options: [{ label: "G" }, { label: "H" }],
|
||||
},
|
||||
{
|
||||
question: "Five",
|
||||
options: [{ label: "I" }, { label: "J" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
questions.map(question => question.question),
|
||||
["One", "Two", "Three", "Four"],
|
||||
);
|
||||
assert.equal(questions[1]?.multiSelect, true);
|
||||
});
|
||||
|
||||
test("allow response merges answers, previews, notes, and existing annotations", () => {
|
||||
const request = makeRequest();
|
||||
const response = buildAskUserQuestionAllowResponse(
|
||||
request,
|
||||
{ "Pick a branch": "A" },
|
||||
{ "Pick a branch": "take this path" },
|
||||
);
|
||||
|
||||
assert.equal(response.tool_use_id, "ask-1");
|
||||
assert.equal(response.option_id, "allow_once");
|
||||
assert.deepEqual(response.updated_input?.answers, {
|
||||
"Pick a branch": "A",
|
||||
});
|
||||
assert.deepEqual(response.updated_input?.annotations, {
|
||||
"Pick a branch": {
|
||||
existing: "kept",
|
||||
preview: "<p>A preview</p>",
|
||||
notes: "take this path",
|
||||
},
|
||||
"Earlier question": "legacy note",
|
||||
});
|
||||
});
|
||||
|
||||
test("deny response is fail-closed and does not include updated input", () => {
|
||||
const response = buildAskUserQuestionDenyResponse(makeRequest(), "cancelled");
|
||||
|
||||
assert.deepEqual(response, {
|
||||
tool_use_id: "ask-1",
|
||||
option_id: "deny",
|
||||
message: "cancelled",
|
||||
});
|
||||
});
|
||||
170
apps/tui/src/bridge/askUserQuestionState.ts
Normal file
170
apps/tui/src/bridge/askUserQuestionState.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import type {
|
||||
AskUserQuestionData,
|
||||
AskUserQuestionOptionData,
|
||||
PermissionRequestData,
|
||||
ToolPermissionResponseData,
|
||||
} from "./protocol.js";
|
||||
|
||||
export type AskUserQuestionAnswers = Record<string, string>;
|
||||
export type AskUserQuestionNotes = Record<string, string>;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function coerceOption(value: unknown): AskUserQuestionOptionData | null {
|
||||
const record = asRecord(value);
|
||||
const label = nonEmptyString(record?.label);
|
||||
if (!record || !label) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
label,
|
||||
description: nonEmptyString(record.description) ?? undefined,
|
||||
preview: nonEmptyString(record.preview) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function coerceQuestion(value: unknown): AskUserQuestionData | null {
|
||||
const record = asRecord(value);
|
||||
const question = nonEmptyString(record?.question);
|
||||
const rawOptions = Array.isArray(record?.options) ? record.options : [];
|
||||
if (!record || !question || rawOptions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = rawOptions
|
||||
.map(coerceOption)
|
||||
.filter((option): option is AskUserQuestionOptionData => option !== null);
|
||||
if (options.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
header: nonEmptyString(record.header) ?? undefined,
|
||||
question,
|
||||
options,
|
||||
multiSelect: record.multiSelect === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAskUserQuestionQuestions(
|
||||
request: PermissionRequestData,
|
||||
): AskUserQuestionData[] {
|
||||
const source = Array.isArray(request.questions)
|
||||
? request.questions
|
||||
: Array.isArray(request.tool_input.questions)
|
||||
? request.tool_input.questions
|
||||
: [];
|
||||
return source
|
||||
.map(coerceQuestion)
|
||||
.filter((question): question is AskUserQuestionData => question !== null)
|
||||
.slice(0, 4);
|
||||
}
|
||||
|
||||
export function isAskUserQuestionRequest(
|
||||
request: PermissionRequestData,
|
||||
): boolean {
|
||||
if (request.interaction === "ask_user_question") {
|
||||
return true;
|
||||
}
|
||||
const toolName = request.tool_name.toLowerCase();
|
||||
return (
|
||||
toolName === "askuserquestion" ||
|
||||
toolName === "ask_user_question" ||
|
||||
toolName === "ask-user-question"
|
||||
);
|
||||
}
|
||||
|
||||
function cloneExistingAnnotations(
|
||||
input: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const annotations = asRecord(input.annotations);
|
||||
if (!annotations) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(annotations).map(([key, value]) => {
|
||||
const record = asRecord(value);
|
||||
return [key, record ? { ...record } : value];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAskUserQuestionUpdatedInput(
|
||||
request: PermissionRequestData,
|
||||
answers: AskUserQuestionAnswers,
|
||||
notes: AskUserQuestionNotes = {},
|
||||
): Record<string, unknown> {
|
||||
const questions = getAskUserQuestionQuestions(request);
|
||||
const annotations = cloneExistingAnnotations(request.tool_input);
|
||||
|
||||
for (const question of questions) {
|
||||
const answer = answers[question.question];
|
||||
const selectedOption = answer
|
||||
? question.options.find(option => option.label === answer)
|
||||
: undefined;
|
||||
const note = notes[question.question]?.trim();
|
||||
const currentValue = annotations[question.question];
|
||||
const current = asRecord(currentValue);
|
||||
const next: Record<string, unknown> =
|
||||
currentValue === undefined
|
||||
? {}
|
||||
: current
|
||||
? { ...current }
|
||||
: { value: currentValue };
|
||||
|
||||
if (selectedOption?.preview) {
|
||||
next.preview = selectedOption.preview;
|
||||
}
|
||||
if (note) {
|
||||
next.notes = note;
|
||||
}
|
||||
|
||||
if (Object.keys(next).length > 0) {
|
||||
annotations[question.question] = next;
|
||||
}
|
||||
}
|
||||
|
||||
const nextInput: Record<string, unknown> = {
|
||||
...request.tool_input,
|
||||
answers,
|
||||
};
|
||||
|
||||
if (Object.keys(annotations).length > 0) {
|
||||
nextInput.annotations = annotations;
|
||||
}
|
||||
|
||||
return nextInput;
|
||||
}
|
||||
|
||||
export function buildAskUserQuestionAllowResponse(
|
||||
request: PermissionRequestData,
|
||||
answers: AskUserQuestionAnswers,
|
||||
notes: AskUserQuestionNotes = {},
|
||||
): ToolPermissionResponseData {
|
||||
return {
|
||||
tool_use_id: request.tool_use_id,
|
||||
option_id: "allow_once",
|
||||
updated_input: buildAskUserQuestionUpdatedInput(request, answers, notes),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAskUserQuestionDenyResponse(
|
||||
request: PermissionRequestData,
|
||||
message = "Question prompt cancelled by user.",
|
||||
): ToolPermissionResponseData {
|
||||
return {
|
||||
tool_use_id: request.tool_use_id,
|
||||
option_id: "deny",
|
||||
message,
|
||||
};
|
||||
}
|
||||
25
apps/tui/src/bridge/index.ts
Normal file
25
apps/tui/src/bridge/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export { StructuredIO } from "./structuredIO.js";
|
||||
export type { StructuredIOOptions } from "./structuredIO.js";
|
||||
|
||||
export { ndjsonSafeStringify, ndjsonParse } from "./ndjson.js";
|
||||
|
||||
export type {
|
||||
EventType,
|
||||
TuiToCoreMsgType,
|
||||
CoreToTuiMsgType,
|
||||
IPCMessage,
|
||||
EventDataMap,
|
||||
QueryData,
|
||||
CancelData,
|
||||
PermissionResponseData,
|
||||
PermissionRequestData,
|
||||
LLMStartData,
|
||||
LLMTokenData,
|
||||
LLMCompleteData,
|
||||
ToolStartData,
|
||||
ToolProgressData,
|
||||
ToolCompleteData,
|
||||
ToolErrorData,
|
||||
StatusUpdateData,
|
||||
NotificationData,
|
||||
} from "./protocol.js";
|
||||
33
apps/tui/src/bridge/ndjson.ts
Normal file
33
apps/tui/src/bridge/ndjson.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// JSON.stringify emits U+2028/U+2029 raw (valid per ECMA-404). When the
|
||||
// output is a single NDJSON line, any receiver that uses JavaScript
|
||||
// line-terminator semantics (ECMA-262 §11.3 — \n \r U+2028 U+2029) to
|
||||
// split the stream will cut the JSON mid-string. The \uXXXX form is
|
||||
// equivalent JSON but can never be mistaken for a line terminator.
|
||||
|
||||
const JS_LINE_TERMINATORS = /\u2028|\u2029/g;
|
||||
|
||||
function escapeJsLineTerminators(json: string): string {
|
||||
return json.replace(
|
||||
JS_LINE_TERMINATORS,
|
||||
(c) => (c === "\u2028" ? "\\u2028" : "\\u2029"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON.stringify for one-message-per-line transports. Escapes U+2028
|
||||
* LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR so the serialized output
|
||||
* cannot be broken by a line-splitting receiver.
|
||||
*/
|
||||
export function ndjsonSafeStringify(value: unknown): string {
|
||||
return escapeJsLineTerminators(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single NDJSON line. Returns undefined on empty/whitespace-only
|
||||
* lines instead of throwing.
|
||||
*/
|
||||
export function ndjsonParse<T = unknown>(line: string): T | undefined {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return JSON.parse(trimmed) as T;
|
||||
}
|
||||
72
apps/tui/src/bridge/permissionRequestState.test.ts
Normal file
72
apps/tui/src/bridge/permissionRequestState.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildPendingSandboxRequest,
|
||||
buildPendingWorkerRequest,
|
||||
buildWorkerSandboxQueue,
|
||||
getAllowAlwaysLabel,
|
||||
getPermissionRequestSummary,
|
||||
} from "./permissionRequestState.js";
|
||||
import type { PermissionRequestData } from "./protocol.js";
|
||||
|
||||
test("permission request helpers classify worker network requests", () => {
|
||||
const request: PermissionRequestData = {
|
||||
tool_use_id: "perm-worker-network",
|
||||
tool_name: "web_fetch",
|
||||
tool_input: { url: "https://api.github.com/repos/openai/openai-python" },
|
||||
request_kind: "network",
|
||||
host: "api.github.com",
|
||||
origin: "worker",
|
||||
agent_id: "worker-1",
|
||||
agent_name: "reviewer",
|
||||
agent_color: "cyan",
|
||||
};
|
||||
|
||||
assert.deepEqual(buildPendingSandboxRequest(request), {
|
||||
requestId: "perm-worker-network",
|
||||
host: "api.github.com",
|
||||
requestKind: "network",
|
||||
});
|
||||
assert.deepEqual(buildPendingWorkerRequest(request), {
|
||||
toolName: "web_fetch",
|
||||
toolUseId: "perm-worker-network",
|
||||
description:
|
||||
"Worker reviewer requests network access to api.github.com via web_fetch",
|
||||
workerId: "worker-1",
|
||||
workerName: "reviewer",
|
||||
workerColor: "cyan",
|
||||
host: "api.github.com",
|
||||
requestKind: "network",
|
||||
});
|
||||
assert.deepEqual(buildWorkerSandboxQueue([request]), [
|
||||
{
|
||||
requestId: "perm-worker-network",
|
||||
workerId: "worker-1",
|
||||
workerName: "reviewer",
|
||||
workerColor: "cyan",
|
||||
host: "api.github.com",
|
||||
createdAt: 0,
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
getPermissionRequestSummary(request),
|
||||
"Worker reviewer requests network access to api.github.com via web_fetch",
|
||||
);
|
||||
assert.equal(getAllowAlwaysLabel(request), "allow always for api.github.com");
|
||||
});
|
||||
|
||||
test("permission request helpers fall back to generic tool prompts", () => {
|
||||
const request: PermissionRequestData = {
|
||||
tool_use_id: "perm-tool",
|
||||
tool_name: "bash",
|
||||
tool_input: { command: "pwd" },
|
||||
};
|
||||
|
||||
assert.equal(buildPendingSandboxRequest(request), null);
|
||||
assert.equal(buildPendingWorkerRequest(request), null);
|
||||
assert.equal(
|
||||
getPermissionRequestSummary(request),
|
||||
"Primary session requests permission for bash",
|
||||
);
|
||||
assert.equal(getAllowAlwaysLabel(request), "allow always for bash");
|
||||
});
|
||||
145
apps/tui/src/bridge/permissionRequestState.ts
Normal file
145
apps/tui/src/bridge/permissionRequestState.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import type { PermissionRequestData } from "./protocol.js";
|
||||
|
||||
export type WorkerPermissionQueueItem = {
|
||||
requestId: string;
|
||||
workerId: string;
|
||||
workerName: string;
|
||||
workerColor?: string;
|
||||
host: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type PendingSandboxPermissionState = {
|
||||
requestId: string;
|
||||
host: string;
|
||||
requestKind: "network" | "sandbox";
|
||||
} | null;
|
||||
|
||||
export type PendingWorkerPermissionState = {
|
||||
toolName: string;
|
||||
toolUseId: string;
|
||||
description: string;
|
||||
workerId: string;
|
||||
workerName: string;
|
||||
workerColor?: string;
|
||||
host?: string;
|
||||
requestKind: "tool" | "network" | "sandbox";
|
||||
} | null;
|
||||
|
||||
function nonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function isWorkerPermissionRequest(
|
||||
request: PermissionRequestData | null | undefined,
|
||||
): request is PermissionRequestData {
|
||||
if (!request) {
|
||||
return false;
|
||||
}
|
||||
if (request.origin === "worker") {
|
||||
return true;
|
||||
}
|
||||
const agentId = nonEmptyString(request.agent_id);
|
||||
return agentId !== null && agentId !== "primary";
|
||||
}
|
||||
|
||||
export function isSandboxPermissionRequest(
|
||||
request: PermissionRequestData | null | undefined,
|
||||
): request is PermissionRequestData & {
|
||||
request_kind: "network" | "sandbox";
|
||||
} {
|
||||
return request?.request_kind === "network" || request?.request_kind === "sandbox";
|
||||
}
|
||||
|
||||
export function getPermissionRequestSummary(
|
||||
request: PermissionRequestData,
|
||||
): string {
|
||||
if (request.description?.trim()) {
|
||||
return request.description.trim();
|
||||
}
|
||||
|
||||
const workerName = request.agent_name ?? request.agent_id;
|
||||
const prefix =
|
||||
isWorkerPermissionRequest(request) && workerName
|
||||
? `Worker ${workerName} `
|
||||
: "";
|
||||
|
||||
if (request.request_kind === "network" && request.host) {
|
||||
return `${prefix}requests network access to ${request.host} via ${request.tool_name}`;
|
||||
}
|
||||
if (request.request_kind === "sandbox" && request.host) {
|
||||
return `${prefix}requests sandbox access to ${request.host} via ${request.tool_name}`;
|
||||
}
|
||||
if (request.request_kind === "sandbox") {
|
||||
return `${prefix}requests sandbox access via ${request.tool_name}`;
|
||||
}
|
||||
return prefix
|
||||
? `${prefix}requests permission for ${request.tool_name}`
|
||||
: `Primary session requests permission for ${request.tool_name}`;
|
||||
}
|
||||
|
||||
export function getAllowAlwaysLabel(
|
||||
request: PermissionRequestData,
|
||||
): string {
|
||||
if (request.host) {
|
||||
return `allow always for ${request.host}`;
|
||||
}
|
||||
return `allow always for ${request.tool_name}`;
|
||||
}
|
||||
|
||||
export function buildPendingSandboxRequest(
|
||||
request: PermissionRequestData | null | undefined,
|
||||
): PendingSandboxPermissionState {
|
||||
if (!isSandboxPermissionRequest(request)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
requestId: request.tool_use_id,
|
||||
host: request.host ?? request.tool_name,
|
||||
requestKind: request.request_kind,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPendingWorkerRequest(
|
||||
request: PermissionRequestData | null | undefined,
|
||||
): PendingWorkerPermissionState {
|
||||
if (!isWorkerPermissionRequest(request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workerId = nonEmptyString(request.agent_id) ?? "worker";
|
||||
const workerName = nonEmptyString(request.agent_name) ?? workerId;
|
||||
return {
|
||||
toolName: request.tool_name,
|
||||
toolUseId: request.tool_use_id,
|
||||
description: getPermissionRequestSummary(request),
|
||||
workerId,
|
||||
workerName,
|
||||
workerColor: nonEmptyString(request.agent_color) ?? undefined,
|
||||
host: nonEmptyString(request.host) ?? undefined,
|
||||
requestKind: request.request_kind ?? "tool",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorkerSandboxQueue(
|
||||
requests: PermissionRequestData[],
|
||||
): WorkerPermissionQueueItem[] {
|
||||
return requests
|
||||
.filter(
|
||||
request =>
|
||||
isWorkerPermissionRequest(request) &&
|
||||
(request.host !== undefined || request.request_kind === "sandbox"),
|
||||
)
|
||||
.map((request, index) => {
|
||||
const workerId = nonEmptyString(request.agent_id) ?? "worker";
|
||||
const workerName = nonEmptyString(request.agent_name) ?? workerId;
|
||||
return {
|
||||
requestId: request.tool_use_id,
|
||||
workerId,
|
||||
workerName,
|
||||
workerColor: nonEmptyString(request.agent_color) ?? undefined,
|
||||
host: nonEmptyString(request.host) ?? request.tool_name,
|
||||
createdAt: index,
|
||||
};
|
||||
});
|
||||
}
|
||||
172
apps/tui/src/bridge/protocol.test.ts
Normal file
172
apps/tui/src/bridge/protocol.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import {
|
||||
CORE_TO_TUI_MSG_TYPES,
|
||||
TUI_TO_CORE_MSG_TYPES,
|
||||
isKnownEventType,
|
||||
isToolPermissionPromptForCancel,
|
||||
makeProtocolWarningMessage,
|
||||
} from "./protocol.js";
|
||||
import type {
|
||||
AutoDreamEventData,
|
||||
CompactEventData,
|
||||
EventDataMap,
|
||||
IPCMessage,
|
||||
MemoryExtractionCompleteData,
|
||||
MemoryExtractionErrorData,
|
||||
MemoryExtractionStartData,
|
||||
MemoryLoggedEventData,
|
||||
MemorySavedEventData,
|
||||
MemorySelectorData,
|
||||
TokenWarningEventData,
|
||||
ToolPermissionAskData,
|
||||
ToolPermissionCancelData,
|
||||
ToolPermissionResponseData,
|
||||
} from "./protocol.js";
|
||||
|
||||
type IsEqual<Left, Right> =
|
||||
(<T>() => T extends Left ? 1 : 2) extends
|
||||
(<T>() => T extends Right ? 1 : 2)
|
||||
? true
|
||||
: false;
|
||||
|
||||
type Assert<T extends true> = T;
|
||||
|
||||
const _toolPermissionPayloadAssertions: [
|
||||
Assert<IsEqual<EventDataMap["tool_permission_ask"], ToolPermissionAskData>>,
|
||||
Assert<IsEqual<IPCMessage<"tool_permission_ask">["data"], ToolPermissionAskData>>,
|
||||
Assert<IsEqual<EventDataMap["tool_permission_response"], ToolPermissionResponseData>>,
|
||||
Assert<IsEqual<IPCMessage<"tool_permission_response">["data"], ToolPermissionResponseData>>,
|
||||
Assert<IsEqual<EventDataMap["tool_permission_cancel"], ToolPermissionCancelData>>,
|
||||
Assert<IsEqual<IPCMessage<"tool_permission_cancel">["data"], ToolPermissionCancelData>>,
|
||||
Assert<IsEqual<EventDataMap["compact_start"], CompactEventData>>,
|
||||
Assert<IsEqual<EventDataMap["compact_complete"], CompactEventData>>,
|
||||
Assert<IsEqual<EventDataMap["token_warning"], TokenWarningEventData>>,
|
||||
Assert<IsEqual<EventDataMap["memory_selector"], MemorySelectorData>>,
|
||||
Assert<IsEqual<EventDataMap["memory_saved"], MemorySavedEventData>>,
|
||||
Assert<IsEqual<EventDataMap["memory_logged"], MemoryLoggedEventData>>,
|
||||
Assert<IsEqual<EventDataMap["memory_extraction_start"], MemoryExtractionStartData>>,
|
||||
Assert<IsEqual<EventDataMap["memory_extraction_complete"], MemoryExtractionCompleteData>>,
|
||||
Assert<IsEqual<EventDataMap["memory_extraction_error"], MemoryExtractionErrorData>>,
|
||||
Assert<IsEqual<EventDataMap["auto_dream_start"], AutoDreamEventData>>,
|
||||
Assert<IsEqual<EventDataMap["auto_dream_progress"], AutoDreamEventData>>,
|
||||
Assert<IsEqual<EventDataMap["auto_dream_complete"], AutoDreamEventData>>,
|
||||
Assert<IsEqual<EventDataMap["auto_dream_error"], AutoDreamEventData>>,
|
||||
Assert<IsEqual<EventDataMap["auto_dream_cancelled"], AutoDreamEventData>>,
|
||||
] = [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
];
|
||||
|
||||
type EventManifest = {
|
||||
tui_to_core: string[];
|
||||
core_to_tui: string[];
|
||||
payload_types: Record<string, string>;
|
||||
payload_schemas: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function readEventManifest(): EventManifest {
|
||||
const url = new URL(
|
||||
"../../../../openspace/protocol/schema/events.json",
|
||||
import.meta.url,
|
||||
);
|
||||
return JSON.parse(readFileSync(url, "utf8")) as EventManifest;
|
||||
}
|
||||
|
||||
test("event type runtime lists match the shared manifest", () => {
|
||||
const manifest = readEventManifest();
|
||||
assert.deepEqual([...TUI_TO_CORE_MSG_TYPES], manifest.tui_to_core);
|
||||
assert.deepEqual([...CORE_TO_TUI_MSG_TYPES], manifest.core_to_tui);
|
||||
assert.equal(isKnownEventType("tool_permission_ask"), true);
|
||||
assert.equal(isKnownEventType("background_housekeeping_idle"), true);
|
||||
assert.equal(
|
||||
isKnownEventType("background_housekeeping_cleanup_complete"),
|
||||
true,
|
||||
);
|
||||
assert.equal(isKnownEventType("not_a_protocol_event"), false);
|
||||
assert.ok(manifest.payload_schemas.tool_permission_response);
|
||||
assert.ok(manifest.payload_schemas.resume_session);
|
||||
});
|
||||
|
||||
test("unknown event strategy produces a unified warning event", () => {
|
||||
const warning = makeProtocolWarningMessage(
|
||||
"not_a_protocol_event",
|
||||
"Unknown protocol event type",
|
||||
);
|
||||
assert.equal(warning.type, "notification");
|
||||
assert.equal(warning.data.level, "warn");
|
||||
assert.equal(warning.data.title, "Protocol warning");
|
||||
assert.equal(warning.data.event_type, "not_a_protocol_event");
|
||||
});
|
||||
|
||||
test("tool permission cancel prompt matching is idempotent", () => {
|
||||
const cancel = {
|
||||
tool_use_id: "abc-123",
|
||||
reason: "timed out",
|
||||
};
|
||||
const queue = [
|
||||
"tool-permission-abc-123-choice",
|
||||
"tool-permission-abc-123-edit",
|
||||
"tool-permission-other-choice",
|
||||
"normal-prompt",
|
||||
];
|
||||
|
||||
const dismissed = queue.filter(
|
||||
promptId => !isToolPermissionPromptForCancel(promptId, cancel),
|
||||
);
|
||||
const dismissedAgain = dismissed.filter(
|
||||
promptId => !isToolPermissionPromptForCancel(promptId, cancel),
|
||||
);
|
||||
|
||||
assert.deepEqual(dismissed, [
|
||||
"tool-permission-other-choice",
|
||||
"normal-prompt",
|
||||
]);
|
||||
assert.deepEqual(dismissedAgain, dismissed);
|
||||
});
|
||||
|
||||
test("tool permission cancel ignores empty ids", () => {
|
||||
assert.equal(
|
||||
isToolPermissionPromptForCancel(
|
||||
"tool-permission-abc-123-choice",
|
||||
{ tool_use_id: " " },
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("tool permission cancel does not match prefix-colliding ids", () => {
|
||||
assert.equal(
|
||||
isToolPermissionPromptForCancel(
|
||||
"tool-permission-abc-123-choice",
|
||||
{ tool_use_id: "abc" },
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isToolPermissionPromptForCancel(
|
||||
"tool-permission-abc-123-edit",
|
||||
{ tool_use_id: "abc-123" },
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
1089
apps/tui/src/bridge/protocol.ts
Normal file
1089
apps/tui/src/bridge/protocol.ts
Normal file
File diff suppressed because it is too large
Load diff
154
apps/tui/src/bridge/structuredIO.test.ts
Normal file
154
apps/tui/src/bridge/structuredIO.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { ndjsonParse } from "./ndjson.js";
|
||||
import { StructuredIO } from "./structuredIO.js";
|
||||
|
||||
test("send stamps a timestamp on outgoing messages", async () => {
|
||||
const output = new PassThrough();
|
||||
const input = new PassThrough();
|
||||
const io = new StructuredIO({ stdin: input, stdout: output });
|
||||
|
||||
io.send({ type: "query", data: { text: "hello" } });
|
||||
const line = output.read()?.toString("utf8") ?? "";
|
||||
|
||||
const parsed = ndjsonParse<{ type: string; timestamp: number; data: { text: string } }>(
|
||||
line,
|
||||
);
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.type, "query");
|
||||
assert.equal(parsed.data.text, "hello");
|
||||
assert.equal(typeof parsed.timestamp, "number");
|
||||
|
||||
output.destroy();
|
||||
input.destroy();
|
||||
});
|
||||
|
||||
test("receive deduplicates permission requests by tool_use_id", async () => {
|
||||
const output = new PassThrough();
|
||||
const input = new PassThrough();
|
||||
const io = new StructuredIO({ stdin: input, stdout: output });
|
||||
const received: Array<{ type: string }> = [];
|
||||
|
||||
const reader = (async () => {
|
||||
for await (const message of io.receive()) {
|
||||
received.push(message);
|
||||
}
|
||||
})();
|
||||
|
||||
const line = JSON.stringify({
|
||||
type: "permission_request",
|
||||
data: {
|
||||
tool_use_id: "dup-1",
|
||||
tool_name: "bash",
|
||||
tool_input: { command: "ls" },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
input.write(`${line}\n`);
|
||||
input.write(`${line}\n`);
|
||||
input.end();
|
||||
|
||||
await reader;
|
||||
|
||||
assert.equal(received.length, 1);
|
||||
assert.equal(io.seenToolUseIds.size, 1);
|
||||
assert.ok(io.seenToolUseIds.has("dup-1"));
|
||||
|
||||
output.destroy();
|
||||
input.destroy();
|
||||
});
|
||||
|
||||
test("resolvePermission clears pending state and writes a response", async () => {
|
||||
const output = new PassThrough();
|
||||
const input = new PassThrough();
|
||||
const io = new StructuredIO({ stdin: input, stdout: output });
|
||||
|
||||
const pending = io.waitForPermissionDecision({
|
||||
tool_use_id: "perm-1",
|
||||
tool_name: "bash",
|
||||
tool_input: { command: "pwd" },
|
||||
});
|
||||
|
||||
let line = "";
|
||||
output.on("data", (chunk) => {
|
||||
line += chunk.toString("utf8");
|
||||
});
|
||||
|
||||
io.resolvePermission({
|
||||
tool_use_id: "perm-1",
|
||||
decision: "allow",
|
||||
});
|
||||
|
||||
const result = await pending;
|
||||
assert.ok("decision" in result);
|
||||
assert.equal(result.decision, "allow");
|
||||
assert.equal(io.pendingPermissions.size, 0);
|
||||
|
||||
const parsed = ndjsonParse<{ type: string; data: { tool_use_id: string } }>(
|
||||
output.read()?.toString("utf8") ?? line,
|
||||
);
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.type, "permission_response");
|
||||
assert.equal(parsed.data.tool_use_id, "perm-1");
|
||||
|
||||
output.destroy();
|
||||
input.destroy();
|
||||
});
|
||||
|
||||
test("resolveToolPermission clears pending state and writes a response", async () => {
|
||||
const output = new PassThrough();
|
||||
const input = new PassThrough();
|
||||
const io = new StructuredIO({ stdin: input, stdout: output });
|
||||
|
||||
const pending = io.waitForPermissionDecision({
|
||||
tool_use_id: "tool-perm-1",
|
||||
tool_name: "bash",
|
||||
tool_input: { command: "git status" },
|
||||
response_channel: "tool_permission_response",
|
||||
options: [{ option_id: "allow_once", label: "Allow" }],
|
||||
});
|
||||
|
||||
let line = "";
|
||||
output.on("data", (chunk) => {
|
||||
line += chunk.toString("utf8");
|
||||
});
|
||||
|
||||
io.resolveToolPermission({
|
||||
tool_use_id: "tool-perm-1",
|
||||
option_id: "allow_once",
|
||||
});
|
||||
|
||||
const result = await pending;
|
||||
assert.equal(result.tool_use_id, "tool-perm-1");
|
||||
assert.equal(io.pendingPermissions.size, 0);
|
||||
|
||||
const parsed = ndjsonParse<{ type: string; data: { tool_use_id: string } }>(
|
||||
output.read()?.toString("utf8") ?? line,
|
||||
);
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.type, "tool_permission_response");
|
||||
assert.equal(parsed.data.tool_use_id, "tool-perm-1");
|
||||
|
||||
output.destroy();
|
||||
input.destroy();
|
||||
});
|
||||
|
||||
test("close rejects outstanding permission promises", async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const io = new StructuredIO({ stdin: input, stdout: output });
|
||||
|
||||
const pending = io.waitForPermissionDecision({
|
||||
tool_use_id: "perm-close",
|
||||
tool_name: "bash",
|
||||
tool_input: { command: "sleep 10" },
|
||||
});
|
||||
|
||||
io.close();
|
||||
|
||||
await assert.rejects(pending, /StructuredIO closed/);
|
||||
input.destroy();
|
||||
output.destroy();
|
||||
});
|
||||
282
apps/tui/src/bridge/structuredIO.ts
Normal file
282
apps/tui/src/bridge/structuredIO.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import { createInterface } from "node:readline";
|
||||
import { ndjsonParse, ndjsonSafeStringify } from "./ndjson.js";
|
||||
import { isKnownEventType, makeProtocolWarningMessage } from "./protocol.js";
|
||||
import type {
|
||||
EventType,
|
||||
IPCMessage,
|
||||
PermissionRequestData,
|
||||
PermissionResponseData,
|
||||
ToolPermissionResponseData,
|
||||
} from "./protocol.js";
|
||||
|
||||
const MAX_RESOLVED_TOOL_USE_IDS = 1000;
|
||||
const RECENT_MESSAGE_LIMIT = 200;
|
||||
const RECEIVE_YIELD_EVERY_MESSAGES = 32;
|
||||
const RECEIVE_YIELD_EVERY_MS = 8;
|
||||
|
||||
export const STRUCTURED_IO_SEQUENCE = Symbol("structured_io_sequence");
|
||||
|
||||
export type StructuredIOListener = (message: IPCMessage) => void;
|
||||
|
||||
type SequencedIPCMessage = IPCMessage & {
|
||||
[STRUCTURED_IO_SEQUENCE]?: number;
|
||||
};
|
||||
|
||||
type PermissionDecisionResponse =
|
||||
| PermissionResponseData
|
||||
| ToolPermissionResponseData;
|
||||
|
||||
export function getStructuredIOSequence(
|
||||
message: IPCMessage,
|
||||
): number | null {
|
||||
return (
|
||||
(message as SequencedIPCMessage)[STRUCTURED_IO_SEQUENCE] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export interface StructuredIOOptions {
|
||||
stdin?: NodeJS.ReadableStream;
|
||||
stdout?: NodeJS.WritableStream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bidirectional NDJSON IPC channel between the TS TUI (this process)
|
||||
* and the Python Core (parent process).
|
||||
*
|
||||
* - Reads NDJSON from stdin (events sent by Python Core)
|
||||
* - Writes NDJSON to stdout (events sent to Python Core)
|
||||
* - Manages pending permission requests with Future-like resolution
|
||||
* - Deduplicates tool_use_ids
|
||||
*/
|
||||
export class StructuredIO {
|
||||
private readonly input: NodeJS.ReadableStream;
|
||||
private readonly output: NodeJS.WritableStream;
|
||||
private readonly listeners = new Set<StructuredIOListener>();
|
||||
private readonly recentMessages: IPCMessage[] = [];
|
||||
private sequence = 0;
|
||||
|
||||
/**
|
||||
* Map of tool_use_id → { resolve, reject } for outstanding
|
||||
* permission_request events awaiting a permission_response from the TUI.
|
||||
* Used on the TUI side to track which permission prompts are still pending.
|
||||
*/
|
||||
readonly pendingPermissions = new Map<
|
||||
string,
|
||||
{
|
||||
data: PermissionRequestData;
|
||||
resolve: (response: PermissionDecisionResponse) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
/**
|
||||
* Set of tool_use_ids that have already been resolved. Prevents
|
||||
* duplicate permission_request events from spawning multiple prompts.
|
||||
*/
|
||||
readonly seenToolUseIds = new Set<string>();
|
||||
|
||||
private closed = false;
|
||||
|
||||
constructor(opts?: StructuredIOOptions) {
|
||||
this.input = opts?.stdin ?? process.stdin;
|
||||
this.output = opts?.stdout ?? process.stdout;
|
||||
}
|
||||
|
||||
subscribe(
|
||||
listener: StructuredIOListener,
|
||||
opts?: { replayRecent?: boolean },
|
||||
): () => void {
|
||||
this.listeners.add(listener);
|
||||
|
||||
if (opts?.replayRecent) {
|
||||
for (const message of this.recentMessages) {
|
||||
listener(message);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
// ── Writing ───────────────────────────────────────────────────
|
||||
|
||||
send<T extends EventType>(message: IPCMessage<T>): void {
|
||||
if (this.closed) return;
|
||||
const stamped = { ...message, timestamp: message.timestamp ?? Date.now() };
|
||||
const line = ndjsonSafeStringify(stamped) + "\n";
|
||||
this.output.write(line);
|
||||
}
|
||||
|
||||
// ── Reading ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Async generator that yields parsed IPC messages from stdin.
|
||||
* Terminates when stdin closes.
|
||||
*/
|
||||
async *receive(): AsyncGenerator<IPCMessage, void, undefined> {
|
||||
const rl = createInterface({ input: this.input, crlfDelay: Infinity });
|
||||
let messagesSinceYield = 0;
|
||||
let lastYieldAt = Date.now();
|
||||
const yieldToRendererIfNeeded = async (): Promise<void> => {
|
||||
messagesSinceYield += 1;
|
||||
if (
|
||||
messagesSinceYield < RECEIVE_YIELD_EVERY_MESSAGES &&
|
||||
Date.now() - lastYieldAt < RECEIVE_YIELD_EVERY_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
messagesSinceYield = 0;
|
||||
lastYieldAt = Date.now();
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
};
|
||||
|
||||
for await (const line of rl) {
|
||||
const msg = ndjsonParse<IPCMessage>(line);
|
||||
if (!msg || typeof msg.type !== "string" || msg.type.length === 0) {
|
||||
const warning = makeProtocolWarningMessage(
|
||||
"<missing>",
|
||||
"Malformed protocol event",
|
||||
);
|
||||
this.emit(warning);
|
||||
yield warning;
|
||||
await yieldToRendererIfNeeded();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isKnownEventType(msg.type)) {
|
||||
const warning = makeProtocolWarningMessage(
|
||||
msg.type,
|
||||
"Unknown protocol event type",
|
||||
);
|
||||
this.emit(warning);
|
||||
yield warning;
|
||||
await yieldToRendererIfNeeded();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.type === "permission_request" || msg.type === "tool_permission_ask") {
|
||||
const data = msg.data as PermissionRequestData;
|
||||
if (this.seenToolUseIds.has(data.tool_use_id)) continue;
|
||||
this.trackToolUseId(data.tool_use_id);
|
||||
}
|
||||
|
||||
if (msg.type === "permission_response") {
|
||||
const data = msg.data as PermissionResponseData;
|
||||
this.resolvePermission(data);
|
||||
}
|
||||
|
||||
if (msg.type === "cancel") {
|
||||
this.rejectAllPending("Cancelled by core");
|
||||
}
|
||||
|
||||
this.emit(msg);
|
||||
yield msg;
|
||||
await yieldToRendererIfNeeded();
|
||||
}
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
// ── Permission management ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register an incoming permission_request and return a Promise that
|
||||
* resolves when the TUI user makes a decision. The REPL screen should
|
||||
* display the prompt and call `resolvePermission()` with the answer.
|
||||
*/
|
||||
waitForPermissionDecision(
|
||||
data: PermissionRequestData,
|
||||
): Promise<PermissionDecisionResponse> {
|
||||
return new Promise<PermissionDecisionResponse>((resolve, reject) => {
|
||||
this.pendingPermissions.set(data.tool_use_id, {
|
||||
data,
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pending permission prompt and send the response back to
|
||||
* the Python Core.
|
||||
*/
|
||||
resolvePermission(response: PermissionResponseData): void {
|
||||
this.finalizePermission(response);
|
||||
this.send({ type: "permission_response", data: response });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a native tool-permission response for Core tool_permission_ask events.
|
||||
*/
|
||||
resolveToolPermission(response: ToolPermissionResponseData): void {
|
||||
this.finalizePermission(response);
|
||||
this.send({ type: "tool_permission_response", data: response });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject all pending permission requests (e.g. on cancel/shutdown).
|
||||
*/
|
||||
rejectAllPending(reason: string): void {
|
||||
for (const [id, pending] of this.pendingPermissions) {
|
||||
pending.reject(new Error(reason));
|
||||
this.pendingPermissions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.listeners.clear();
|
||||
this.rejectAllPending("StructuredIO closed");
|
||||
}
|
||||
|
||||
get isClosed(): boolean {
|
||||
return this.closed;
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────
|
||||
|
||||
private trackToolUseId(id: string): void {
|
||||
this.seenToolUseIds.add(id);
|
||||
if (this.seenToolUseIds.size > MAX_RESOLVED_TOOL_USE_IDS) {
|
||||
const first = this.seenToolUseIds.values().next().value;
|
||||
if (first !== undefined) {
|
||||
this.seenToolUseIds.delete(first);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private finalizePermission(response: PermissionDecisionResponse): void {
|
||||
const pending = this.pendingPermissions.get(response.tool_use_id);
|
||||
if (!pending) return;
|
||||
|
||||
this.pendingPermissions.delete(response.tool_use_id);
|
||||
pending.resolve(response);
|
||||
}
|
||||
|
||||
private emit(message: IPCMessage): void {
|
||||
const sequenced = message as SequencedIPCMessage;
|
||||
|
||||
if (sequenced[STRUCTURED_IO_SEQUENCE] === undefined) {
|
||||
Object.defineProperty(sequenced, STRUCTURED_IO_SEQUENCE, {
|
||||
value: ++this.sequence,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
});
|
||||
}
|
||||
|
||||
this.recentMessages.push(message);
|
||||
if (this.recentMessages.length > RECENT_MESSAGE_LIMIT) {
|
||||
this.recentMessages.shift();
|
||||
}
|
||||
|
||||
for (const listener of this.listeners) {
|
||||
listener(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
79
apps/tui/src/commands/registry.test.ts
Normal file
79
apps/tui/src/commands/registry.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import {
|
||||
formatSlashCommandDetailText,
|
||||
formatSlashCommandHelpText,
|
||||
getCommandCompletions,
|
||||
getSlashCommandDefinition,
|
||||
parseSlashCommandInput,
|
||||
} from "./registry.js";
|
||||
|
||||
type SlashCommandManifest = {
|
||||
commands: Array<{
|
||||
name: string;
|
||||
handler: "local" | "core";
|
||||
summary: string;
|
||||
usage: string;
|
||||
aliases?: string[];
|
||||
category: string;
|
||||
args?: Array<{
|
||||
name: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
}>;
|
||||
tui_visible?: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
function readSlashCommandManifest(): SlashCommandManifest {
|
||||
const url = new URL(
|
||||
"../../../../openspace/protocol/schema/slash_commands.json",
|
||||
import.meta.url,
|
||||
);
|
||||
return JSON.parse(readFileSync(url, "utf8")) as SlashCommandManifest;
|
||||
}
|
||||
|
||||
test("registry exposes migrated core commands in help and completion", () => {
|
||||
const completions = getCommandCompletions("su").map(command => command.name);
|
||||
assert.deepEqual(completions, ["summary"]);
|
||||
|
||||
const help = formatSlashCommandHelpText();
|
||||
assert.match(help, /\/summary — Update session memory/);
|
||||
assert.match(help, /\/effort — View or set reasoning effort/);
|
||||
assert.match(help, /\/config \(\/settings\) — View or modify local TUI settings/);
|
||||
});
|
||||
|
||||
test("TUI exposes shared core slash command metadata", () => {
|
||||
const manifest = readSlashCommandManifest();
|
||||
for (const command of manifest.commands.filter(command => command.handler === "core")) {
|
||||
const definition = getSlashCommandDefinition(command.name);
|
||||
assert.ok(definition, `missing ${command.name}`);
|
||||
assert.equal(definition.summary, command.summary);
|
||||
assert.equal(definition.usage, command.usage);
|
||||
assert.deepEqual(definition.aliases ?? [], command.aliases ?? []);
|
||||
assert.equal(definition.category, command.category);
|
||||
assert.deepEqual(definition.args ?? [], command.args ?? []);
|
||||
}
|
||||
});
|
||||
|
||||
test("settings aliases to config and detailed help includes args", () => {
|
||||
const parsed = parseSlashCommandInput("/settings theme light");
|
||||
assert.equal(parsed?.command, "config");
|
||||
assert.deepEqual(parsed?.args, ["theme", "light"]);
|
||||
assert.equal(parsed?.definition?.handler, "core");
|
||||
|
||||
const definition = getSlashCommandDefinition("config");
|
||||
assert.ok(definition);
|
||||
assert.equal(definition.handler, "core");
|
||||
const detail = formatSlashCommandDetailText(definition);
|
||||
assert.match(detail, /\/config \[key\] \[value\]/);
|
||||
assert.match(detail, /Aliases: \/settings/);
|
||||
assert.match(detail, /key \(optional\)/);
|
||||
});
|
||||
|
||||
test("permissions command is routed to core", () => {
|
||||
const parsed = parseSlashCommandInput("/permissions");
|
||||
assert.equal(parsed?.command, "permissions");
|
||||
assert.equal(parsed?.definition?.handler, "core");
|
||||
});
|
||||
885
apps/tui/src/commands/registry.ts
Normal file
885
apps/tui/src/commands/registry.ts
Normal file
|
|
@ -0,0 +1,885 @@
|
|||
import type { SlashCommandData } from "../bridge/protocol.js";
|
||||
import type { Command } from "../types/command.js";
|
||||
import { getCommandName, isCommandEnabled } from "../types/command.js";
|
||||
|
||||
// <openspace-slash-commands:generated>
|
||||
// Generated by python -m openspace.protocol.codegen --write
|
||||
export type SlashCommandName =
|
||||
| "help"
|
||||
| "clear"
|
||||
| "status"
|
||||
| "compact"
|
||||
| "plan"
|
||||
| "summary"
|
||||
| "history"
|
||||
| "model"
|
||||
| "effort"
|
||||
| "tools"
|
||||
| "skills"
|
||||
| "save"
|
||||
| "load"
|
||||
| "exit"
|
||||
| "doctor"
|
||||
| "resume"
|
||||
| "cost"
|
||||
| "agent"
|
||||
| "background"
|
||||
| "agents"
|
||||
| "tasks"
|
||||
| "mcp"
|
||||
| "permissions"
|
||||
| "sandbox"
|
||||
| "copy"
|
||||
| "keybindings"
|
||||
| "vim"
|
||||
| "config"
|
||||
| "theme"
|
||||
| "review"
|
||||
| "diff"
|
||||
| "export"
|
||||
| "init"
|
||||
| "memory"
|
||||
| "dream";
|
||||
|
||||
export type SlashCommandCategory =
|
||||
| "session"
|
||||
| "navigation"
|
||||
| "tools"
|
||||
| "display"
|
||||
| "project";
|
||||
|
||||
export type SlashCommandArg = {
|
||||
name: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type SlashCommandDefinition = Command & {
|
||||
name: SlashCommandName;
|
||||
summary: string;
|
||||
usage: string;
|
||||
category: SlashCommandCategory;
|
||||
args?: SlashCommandArg[];
|
||||
hidden?: boolean;
|
||||
implemented?: boolean;
|
||||
};
|
||||
|
||||
export type ParsedSlashCommand = SlashCommandData & {
|
||||
args: string[];
|
||||
raw: string;
|
||||
definition: SlashCommandDefinition | null;
|
||||
isSupported: boolean;
|
||||
};
|
||||
|
||||
const SLASH_COMMAND_REGISTRY: readonly SlashCommandDefinition[] = [
|
||||
{
|
||||
"handler": "local",
|
||||
"availability": [
|
||||
"local"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "help",
|
||||
"description": "Show available commands.",
|
||||
"summary": "Show available commands.",
|
||||
"usage": "/help [command]",
|
||||
"category": "navigation",
|
||||
"aliases": [
|
||||
"h",
|
||||
"?"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "command",
|
||||
"required": false,
|
||||
"description": "Command name for detailed help"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "local",
|
||||
"availability": [
|
||||
"local"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "clear",
|
||||
"description": "Clear screen and message history.",
|
||||
"summary": "Clear screen and message history.",
|
||||
"usage": "/clear",
|
||||
"category": "navigation",
|
||||
"aliases": [
|
||||
"cls"
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "local",
|
||||
"availability": [
|
||||
"local"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "status",
|
||||
"description": "Show session stats (tokens, cost, model, iterations).",
|
||||
"summary": "Show session stats (tokens, cost, model, iterations).",
|
||||
"usage": "/status",
|
||||
"category": "session"
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "compact",
|
||||
"description": "Manually trigger context compaction.",
|
||||
"summary": "Manually trigger context compaction.",
|
||||
"usage": "/compact [instructions]",
|
||||
"category": "session",
|
||||
"args": [
|
||||
{
|
||||
"name": "instructions",
|
||||
"required": false,
|
||||
"description": "Optional compaction instructions"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "plan",
|
||||
"description": "Enter plan mode for the next turn.",
|
||||
"summary": "Enter plan mode for the next turn.",
|
||||
"usage": "/plan",
|
||||
"category": "session"
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "summary",
|
||||
"description": "Update session memory for the active session.",
|
||||
"summary": "Update session memory for this session.",
|
||||
"usage": "/summary",
|
||||
"category": "session"
|
||||
},
|
||||
{
|
||||
"handler": "local",
|
||||
"availability": [
|
||||
"local"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "history",
|
||||
"description": "Show prompt command history.",
|
||||
"summary": "Show prompt command history.",
|
||||
"usage": "/history [count]",
|
||||
"category": "session",
|
||||
"args": [
|
||||
{
|
||||
"name": "count",
|
||||
"required": false,
|
||||
"description": "Number of entries to show"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "model",
|
||||
"description": "View or switch the current model.",
|
||||
"summary": "View or switch the current model.",
|
||||
"usage": "/model [name]",
|
||||
"category": "tools",
|
||||
"aliases": [
|
||||
"m"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "name",
|
||||
"required": false,
|
||||
"description": "Model name to switch to"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "effort",
|
||||
"description": "View or set reasoning effort.",
|
||||
"summary": "View or set reasoning effort.",
|
||||
"usage": "/effort [low|medium|high|max|auto]",
|
||||
"category": "tools",
|
||||
"args": [
|
||||
{
|
||||
"name": "level",
|
||||
"required": false,
|
||||
"description": "low, medium, high, max, or auto"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "tools",
|
||||
"description": "List available tools and their status.",
|
||||
"summary": "List available tools and their status.",
|
||||
"usage": "/tools [filter]",
|
||||
"category": "tools",
|
||||
"args": [
|
||||
{
|
||||
"name": "filter",
|
||||
"required": false,
|
||||
"description": "Filter tools by name"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "skills",
|
||||
"description": "List available skills and review runtime overlay suggestions.",
|
||||
"summary": "List skills or review runtime overlays.",
|
||||
"usage": "/skills [filter] | /skills overlay review [skill_id] | /skills overlay approve|reject <skill_id> [field ...]",
|
||||
"category": "tools",
|
||||
"args": [
|
||||
{
|
||||
"name": "filter",
|
||||
"required": false,
|
||||
"description": "Filter skills, or use overlay review/approve/reject"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "save",
|
||||
"description": "Persist the current session snapshot.",
|
||||
"summary": "Persist the current session snapshot.",
|
||||
"usage": "/save [name]",
|
||||
"category": "session",
|
||||
"args": [
|
||||
{
|
||||
"name": "name",
|
||||
"required": false,
|
||||
"description": "Optional session name"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "load",
|
||||
"description": "Load a saved session by ID.",
|
||||
"summary": "Load a saved session by ID.",
|
||||
"usage": "/load <session>",
|
||||
"category": "session",
|
||||
"args": [
|
||||
{
|
||||
"name": "session",
|
||||
"required": true,
|
||||
"description": "Session name or ID to load"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "local",
|
||||
"availability": [
|
||||
"local"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "exit",
|
||||
"description": "Exit the TUI.",
|
||||
"summary": "Exit the TUI.",
|
||||
"usage": "/exit",
|
||||
"category": "navigation",
|
||||
"aliases": [
|
||||
"q",
|
||||
"quit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "doctor",
|
||||
"description": "Run diagnostic checks.",
|
||||
"summary": "Run diagnostic checks.",
|
||||
"usage": "/doctor",
|
||||
"category": "tools"
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "resume",
|
||||
"description": "List sessions or restore a previous session.",
|
||||
"summary": "List sessions or restore a previous session.",
|
||||
"usage": "/resume [session-id]",
|
||||
"category": "session",
|
||||
"args": [
|
||||
{
|
||||
"name": "session-id",
|
||||
"required": false,
|
||||
"description": "Session ID to restore"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "cost",
|
||||
"description": "Show the current accumulated cost.",
|
||||
"summary": "Show the current accumulated cost.",
|
||||
"usage": "/cost",
|
||||
"category": "session",
|
||||
"aliases": [
|
||||
"$$"
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "local",
|
||||
"availability": [
|
||||
"local"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "agent",
|
||||
"description": "Send input to the viewed or specified background agent.",
|
||||
"summary": "Send input to a background agent.",
|
||||
"usage": "/agent [agent-id] <message>",
|
||||
"category": "tools",
|
||||
"args": [
|
||||
{
|
||||
"name": "agent-id",
|
||||
"required": false,
|
||||
"description": "Optional target agent id"
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"required": true,
|
||||
"description": "Input to send"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "local",
|
||||
"availability": [
|
||||
"local"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "background",
|
||||
"description": "Control or refresh the background runtime.",
|
||||
"summary": "Control background runtime state.",
|
||||
"usage": "/background [start|stop|pause|resume|focus] [title|agent-id]",
|
||||
"category": "session",
|
||||
"aliases": [
|
||||
"bg"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "action",
|
||||
"required": false,
|
||||
"description": "start, stop, pause, resume, or focus"
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"required": false,
|
||||
"description": "Optional title for start or agent id for focus"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "local",
|
||||
"availability": [
|
||||
"local"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "agents",
|
||||
"description": "Toggle the agent runtime panel.",
|
||||
"summary": "Open or close the agent runtime panel.",
|
||||
"usage": "/agents",
|
||||
"category": "tools"
|
||||
},
|
||||
{
|
||||
"handler": "local",
|
||||
"availability": [
|
||||
"local"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "tasks",
|
||||
"description": "Toggle the tasks panel.",
|
||||
"summary": "Open or close the tasks panel.",
|
||||
"usage": "/tasks",
|
||||
"category": "session"
|
||||
},
|
||||
{
|
||||
"handler": "local",
|
||||
"availability": [
|
||||
"local"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "mcp",
|
||||
"description": "Toggle the MCP panel or reconnect a server.",
|
||||
"summary": "Inspect MCP status or reconnect a server.",
|
||||
"usage": "/mcp [reconnect <server>]",
|
||||
"category": "tools",
|
||||
"args": [
|
||||
{
|
||||
"name": "action",
|
||||
"required": false,
|
||||
"description": "Optional action such as reconnect"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"required": false,
|
||||
"description": "Server name to reconnect"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "permissions",
|
||||
"description": "Show permission mode and rules.",
|
||||
"summary": "Inspect permission mode and rules.",
|
||||
"usage": "/permissions",
|
||||
"category": "display"
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "sandbox",
|
||||
"description": "Inspect or update process sandbox settings.",
|
||||
"summary": "Inspect or update sandbox settings.",
|
||||
"usage": "/sandbox [status|doctor|enable|disable|toggle|exclude]",
|
||||
"category": "display",
|
||||
"args": [
|
||||
{
|
||||
"name": "action",
|
||||
"required": false,
|
||||
"description": "status, doctor, enable, disable, toggle, exclude, or unexclude"
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"required": false,
|
||||
"description": "Optional mode, on/off value, or command pattern"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "local",
|
||||
"availability": [
|
||||
"local"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "copy",
|
||||
"description": "Copy the latest message or full transcript to the clipboard.",
|
||||
"summary": "Copy transcript text to the clipboard.",
|
||||
"usage": "/copy [last|all]",
|
||||
"category": "display",
|
||||
"args": [
|
||||
{
|
||||
"name": "scope",
|
||||
"required": false,
|
||||
"description": "last or all"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "local",
|
||||
"availability": [
|
||||
"local"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "keybindings",
|
||||
"description": "Show keybinding file location and load status.",
|
||||
"summary": "Inspect keybinding configuration.",
|
||||
"usage": "/keybindings",
|
||||
"category": "display"
|
||||
},
|
||||
{
|
||||
"handler": "local",
|
||||
"availability": [
|
||||
"local"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "vim",
|
||||
"description": "Show or change the current Vim input mode.",
|
||||
"summary": "Inspect or toggle Vim mode.",
|
||||
"usage": "/vim [insert|normal|toggle]",
|
||||
"category": "display",
|
||||
"args": [
|
||||
{
|
||||
"name": "mode",
|
||||
"required": false,
|
||||
"description": "insert, normal, or toggle"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "config",
|
||||
"description": "View or modify local TUI settings.",
|
||||
"summary": "View or modify local TUI settings.",
|
||||
"usage": "/config [key] [value]",
|
||||
"category": "display",
|
||||
"aliases": [
|
||||
"settings"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"description": "Setting key"
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"required": false,
|
||||
"description": "New value"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "local",
|
||||
"availability": [
|
||||
"local"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "theme",
|
||||
"description": "Switch color theme.",
|
||||
"summary": "Switch color theme.",
|
||||
"usage": "/theme [name]",
|
||||
"category": "display",
|
||||
"args": [
|
||||
{
|
||||
"name": "name",
|
||||
"required": false,
|
||||
"description": "Theme name to apply"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "review",
|
||||
"description": "Start a code review on staged changes.",
|
||||
"summary": "Start a code review on staged changes.",
|
||||
"usage": "/review [path]",
|
||||
"category": "project",
|
||||
"aliases": [
|
||||
"cr"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "path",
|
||||
"required": false,
|
||||
"description": "Limit review to a specific path"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "diff",
|
||||
"description": "Show diff of recent file changes.",
|
||||
"summary": "Show diff of recent file changes.",
|
||||
"usage": "/diff [path]",
|
||||
"category": "project",
|
||||
"args": [
|
||||
{
|
||||
"name": "path",
|
||||
"required": false,
|
||||
"description": "Path to diff"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "export",
|
||||
"description": "Export conversation to file.",
|
||||
"summary": "Export conversation to file.",
|
||||
"usage": "/export [format] [path]",
|
||||
"category": "session",
|
||||
"args": [
|
||||
{
|
||||
"name": "format",
|
||||
"required": false,
|
||||
"description": "Output format (md, json, txt)"
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"required": false,
|
||||
"description": "Output file path"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "init",
|
||||
"description": "Initialize project description file.",
|
||||
"summary": "Initialize project description file.",
|
||||
"usage": "/init",
|
||||
"category": "project"
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "memory",
|
||||
"description": "List or edit OpenSpace memory files.",
|
||||
"summary": "List or edit memory files.",
|
||||
"usage": "/memory [list|edit|read|logs]",
|
||||
"category": "project",
|
||||
"args": [
|
||||
{
|
||||
"name": "action",
|
||||
"required": false,
|
||||
"description": "list, edit, read, or logs"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handler": "core",
|
||||
"availability": [
|
||||
"core"
|
||||
],
|
||||
"implemented": true,
|
||||
"name": "dream",
|
||||
"description": "Manually consolidate auto-memory.",
|
||||
"summary": "Manually consolidate memory.",
|
||||
"usage": "/dream [--logs] [context]",
|
||||
"category": "project",
|
||||
"args": [
|
||||
{
|
||||
"name": "context",
|
||||
"required": false,
|
||||
"description": "Optional dream context or --logs"
|
||||
}
|
||||
]
|
||||
}
|
||||
] as const;
|
||||
|
||||
const CATEGORY_LABELS: Record<SlashCommandCategory, string> = {
|
||||
"session": "Session",
|
||||
"navigation": "Navigation",
|
||||
"tools": "Tools & Models",
|
||||
"display": "Display & Settings",
|
||||
"project": "Project"
|
||||
};
|
||||
// </openspace-slash-commands:generated>
|
||||
|
||||
const SLASH_COMMAND_BY_NAME = new Map<SlashCommandName, SlashCommandDefinition>(
|
||||
SLASH_COMMAND_REGISTRY.map(
|
||||
definition => [definition.name, definition] as const,
|
||||
),
|
||||
);
|
||||
|
||||
const ALIAS_TO_NAME = new Map<string, SlashCommandName>();
|
||||
for (const def of SLASH_COMMAND_REGISTRY) {
|
||||
if (def.aliases) {
|
||||
for (const alias of def.aliases) {
|
||||
ALIAS_TO_NAME.set(alias, def.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAlias(input: string): string {
|
||||
return ALIAS_TO_NAME.get(input) ?? input;
|
||||
}
|
||||
|
||||
export function isSlashCommandName(
|
||||
command: string,
|
||||
): command is SlashCommandName {
|
||||
return SLASH_COMMAND_BY_NAME.has(command as SlashCommandName);
|
||||
}
|
||||
|
||||
export function getSlashCommandDefinition(
|
||||
command: string,
|
||||
): SlashCommandDefinition | null {
|
||||
const resolved = resolveAlias(command);
|
||||
return isSlashCommandName(resolved)
|
||||
? SLASH_COMMAND_BY_NAME.get(resolved) ?? null
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getSlashCommandSummaries(): readonly SlashCommandDefinition[] {
|
||||
return SLASH_COMMAND_REGISTRY.filter(
|
||||
command => !command.isHidden && isCommandEnabled(command),
|
||||
);
|
||||
}
|
||||
|
||||
export function getCommandsByCategory(
|
||||
category: SlashCommandCategory,
|
||||
): SlashCommandDefinition[] {
|
||||
return SLASH_COMMAND_REGISTRY.filter(
|
||||
command =>
|
||||
command.category === category &&
|
||||
!command.isHidden &&
|
||||
isCommandEnabled(command),
|
||||
);
|
||||
}
|
||||
|
||||
export function getCommandCompletions(
|
||||
partial: string,
|
||||
): SlashCommandDefinition[] {
|
||||
const lower = partial.toLowerCase();
|
||||
const seen = new Set<SlashCommandName>();
|
||||
const results: SlashCommandDefinition[] = [];
|
||||
|
||||
for (const def of getSlashCommandSummaries()) {
|
||||
if (getCommandName(def).startsWith(lower)) {
|
||||
seen.add(def.name);
|
||||
results.push(def);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (def.aliases?.some(alias => alias.startsWith(lower))) {
|
||||
if (!seen.has(def.name)) {
|
||||
seen.add(def.name);
|
||||
results.push(def);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function getLocalSlashCommands(): SlashCommandDefinition[] {
|
||||
return getSlashCommandSummaries().filter(command => command.handler === "local");
|
||||
}
|
||||
|
||||
export function getCoreSlashCommands(): SlashCommandDefinition[] {
|
||||
return getSlashCommandSummaries().filter(command => command.handler === "core");
|
||||
}
|
||||
|
||||
export function parseSlashCommandInput(raw: string): ParsedSlashCommand | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = trimmed.slice(1).trim().split(/\s+/).filter(Boolean);
|
||||
const [rawCommand = "", ...args] = parts;
|
||||
const command = resolveAlias(rawCommand);
|
||||
const definition = getSlashCommandDefinition(command);
|
||||
|
||||
return {
|
||||
raw: trimmed,
|
||||
command,
|
||||
args,
|
||||
definition,
|
||||
isSupported: definition !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatSlashCommandHelpText(): string {
|
||||
const categories: SlashCommandCategory[] = [
|
||||
"session",
|
||||
"navigation",
|
||||
"tools",
|
||||
"display",
|
||||
"project",
|
||||
];
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const category of categories) {
|
||||
const commands = getCommandsByCategory(category);
|
||||
if (commands.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push(`\n ${CATEGORY_LABELS[category]}`);
|
||||
for (const command of commands) {
|
||||
const aliasStr =
|
||||
command.aliases && command.aliases.length > 0
|
||||
? ` (${command.aliases.map(alias => `/${alias}`).join(", ")})`
|
||||
: "";
|
||||
const implementation =
|
||||
command.implemented === false ? " [planned]" : "";
|
||||
lines.push(
|
||||
` /${command.name}${aliasStr}${implementation} — ${command.summary}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return `Available commands:${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
export function formatSlashCommandDetailText(
|
||||
command: SlashCommandDefinition,
|
||||
): string {
|
||||
const lines = [
|
||||
command.usage,
|
||||
command.description || command.summary,
|
||||
];
|
||||
if (command.aliases && command.aliases.length > 0) {
|
||||
lines.push(`Aliases: ${command.aliases.map(alias => `/${alias}`).join(", ")}`);
|
||||
}
|
||||
if (command.args && command.args.length > 0) {
|
||||
lines.push("Arguments:");
|
||||
for (const arg of command.args) {
|
||||
const marker = arg.required ? "required" : "optional";
|
||||
lines.push(` ${arg.name} (${marker}) — ${arg.description}`);
|
||||
}
|
||||
}
|
||||
lines.push(`Handler: ${command.handler}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function formatSlashCommandStatus(parsed: ParsedSlashCommand): string {
|
||||
const commandText = parsed.command ? `/${parsed.command}` : "/";
|
||||
const argsText = parsed.args.length ? ` ${parsed.args.join(" ")}` : "";
|
||||
return `Sent ${commandText}${argsText}`;
|
||||
}
|
||||
216
apps/tui/src/components/AgentEventFeed.tsx
Normal file
216
apps/tui/src/components/AgentEventFeed.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { getColor } from "./design-system/theme.js";
|
||||
|
||||
type EventRecord = Record<string, unknown> | null | undefined;
|
||||
|
||||
type Props = {
|
||||
events: EventRecord[];
|
||||
title?: string;
|
||||
emptyLabel?: string;
|
||||
maxEvents?: number;
|
||||
selectedEventIndex?: number | null;
|
||||
actionHints?: string[];
|
||||
};
|
||||
|
||||
function getString(record: EventRecord, keys: string[]): string | undefined {
|
||||
if (!record || typeof record !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getNumber(record: EventRecord, keys: string[]): number | undefined {
|
||||
if (!record || typeof record !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, Math.max(0, max - 1))}…`;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number | undefined): string {
|
||||
if (timestamp === undefined) {
|
||||
return "--:--:--";
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(new Date(timestamp));
|
||||
} catch {
|
||||
return "--:--:--";
|
||||
}
|
||||
}
|
||||
|
||||
function agentLabel(record: EventRecord): string {
|
||||
return (
|
||||
getString(record, ["agent_id", "agentId", "agent", "id"]) ?? "agent"
|
||||
);
|
||||
}
|
||||
|
||||
function eventLabel(record: EventRecord): string {
|
||||
return getString(record, ["event", "type", "name"]) ?? "event";
|
||||
}
|
||||
|
||||
function eventTone(label: string): string {
|
||||
const normalized = label.toLowerCase();
|
||||
if (normalized.includes("error") || normalized.includes("fail")) {
|
||||
return getColor("error");
|
||||
}
|
||||
if (normalized.includes("complete") || normalized.includes("ready")) {
|
||||
return getColor("success");
|
||||
}
|
||||
if (normalized.includes("start") || normalized.includes("update")) {
|
||||
return getColor("primary");
|
||||
}
|
||||
if (normalized.includes("transcript")) {
|
||||
return getColor("accent");
|
||||
}
|
||||
return getColor("text");
|
||||
}
|
||||
|
||||
function summarizePayload(record: EventRecord): string | undefined {
|
||||
if (!record || typeof record !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payload = record["payload"];
|
||||
if (payload === undefined || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof payload === "string") {
|
||||
return truncate(payload, 96);
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return truncate(JSON.stringify(payload), 96);
|
||||
}
|
||||
|
||||
if (typeof payload === "object") {
|
||||
const entries = Object.entries(payload as Record<string, unknown>);
|
||||
const summary = entries
|
||||
.slice(0, 3)
|
||||
.map(([key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
return `${key}=${truncate(value, 24)}`;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return `${key}=${String(value)}`;
|
||||
}
|
||||
return key;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
return summary.length > 0 ? summary : truncate(JSON.stringify(payload), 96);
|
||||
}
|
||||
|
||||
return String(payload);
|
||||
}
|
||||
|
||||
export function AgentEventFeed({
|
||||
events,
|
||||
title = "Recent Agent Events",
|
||||
emptyLabel = "No agent events yet",
|
||||
maxEvents = 10,
|
||||
selectedEventIndex = null,
|
||||
actionHints = [],
|
||||
}: Props): React.ReactElement {
|
||||
const visibleEvents = events
|
||||
.filter((event): event is Record<string, unknown> => Boolean(event))
|
||||
.slice(-maxEvents);
|
||||
const offset = Math.max(0, events.length - visibleEvents.length);
|
||||
|
||||
if (visibleEvents.length === 0) {
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={getColor("border")} paddingX={1}>
|
||||
<Text color={getColor("textDim")}>{emptyLabel}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={getColor("border")}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text bold color={getColor("primary")}>
|
||||
{title} ({events.length})
|
||||
</Text>
|
||||
|
||||
{actionHints.length > 0 ? (
|
||||
<Text color={getColor("textDim")}>
|
||||
{actionHints.join(" | ")}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{visibleEvents.map((record, index) => {
|
||||
const absoluteIndex = offset + index;
|
||||
const selected = selectedEventIndex === absoluteIndex;
|
||||
const time = formatTime(getNumber(record, ["timestamp", "updated_at"]));
|
||||
const agent = agentLabel(record);
|
||||
const label = eventLabel(record);
|
||||
const payload = summarizePayload(record);
|
||||
|
||||
return (
|
||||
<Box key={`${agent}:${label}:${index}`} flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text color={selected ? getColor("primary") : getColor("textDim")}>
|
||||
{selected ? "›" : " "}
|
||||
</Text>
|
||||
<Text color={getColor("textDim")}> </Text>
|
||||
<Text color={getColor("textDim")}>{time}</Text>
|
||||
<Text color={getColor("textDim")}> </Text>
|
||||
<Text bold color={getColor("secondary")}>
|
||||
{agent}
|
||||
</Text>
|
||||
<Text color={getColor("textDim")}> · </Text>
|
||||
<Text color={selected ? getColor("primary") : eventTone(label)}>
|
||||
{truncate(label, 32)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{payload ? (
|
||||
<Text color={selected ? getColor("text") : getColor("textDim")}>
|
||||
{payload}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
228
apps/tui/src/components/AgentListPanel.tsx
Normal file
228
apps/tui/src/components/AgentListPanel.tsx
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { getColor } from "./design-system/theme.js";
|
||||
|
||||
type AgentRecord = Record<string, unknown> | null | undefined;
|
||||
|
||||
type Props = {
|
||||
agents: AgentRecord[];
|
||||
title?: string;
|
||||
emptyLabel?: string;
|
||||
selectedAgentId?: string | null;
|
||||
maxAgents?: number;
|
||||
};
|
||||
|
||||
type StatusTone = {
|
||||
icon: string;
|
||||
color: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function getString(record: AgentRecord, keys: string[]): string | undefined {
|
||||
if (!record || typeof record !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getNumber(record: AgentRecord, keys: string[]): number | undefined {
|
||||
if (!record || typeof record !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, Math.max(0, max - 1))}…`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: number | undefined): string {
|
||||
if (timestamp === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const diffSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
|
||||
if (diffSeconds < 60) return `${diffSeconds}s ago`;
|
||||
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
|
||||
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`;
|
||||
return `${Math.floor(diffSeconds / 86400)}d ago`;
|
||||
}
|
||||
|
||||
function statusTone(record: AgentRecord): StatusTone {
|
||||
const rawStatus = (getString(record, [
|
||||
"status",
|
||||
"state",
|
||||
"phase",
|
||||
"health",
|
||||
]) ?? "unknown").toLowerCase();
|
||||
|
||||
switch (rawStatus) {
|
||||
case "running":
|
||||
case "active":
|
||||
case "connected":
|
||||
case "ready":
|
||||
return { icon: "●", color: getColor("success"), label: rawStatus };
|
||||
case "waiting":
|
||||
case "pending":
|
||||
case "paused":
|
||||
return { icon: "◐", color: getColor("warning"), label: rawStatus };
|
||||
case "error":
|
||||
case "failed":
|
||||
return { icon: "✗", color: getColor("error"), label: rawStatus };
|
||||
case "idle":
|
||||
case "stopped":
|
||||
case "disconnected":
|
||||
return { icon: "○", color: getColor("muted"), label: rawStatus };
|
||||
default:
|
||||
return { icon: "•", color: getColor("textDim"), label: rawStatus };
|
||||
}
|
||||
}
|
||||
|
||||
function buildSummary(record: AgentRecord): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
const type = getString(record, ["type", "agent_type"]);
|
||||
const mode = getString(record, ["mode"]);
|
||||
const model = getString(record, ["model"]);
|
||||
const sessionId = getString(record, ["session_id", "sessionId"]);
|
||||
const taskId = getString(record, ["task_id", "taskId"]);
|
||||
const updatedAt = formatRelativeTime(
|
||||
getNumber(record, ["updated_at", "updatedAt"]),
|
||||
);
|
||||
|
||||
if (type) parts.push(type);
|
||||
if (mode) parts.push(mode);
|
||||
if (model) parts.push(model);
|
||||
if (sessionId) parts.push(`session ${truncate(sessionId, 18)}`);
|
||||
if (taskId) parts.push(`task ${truncate(taskId, 18)}`);
|
||||
if (updatedAt) parts.push(updatedAt);
|
||||
|
||||
const summary = getString(record, ["summary", "preview", "description"]);
|
||||
if (summary) {
|
||||
parts.push(truncate(summary, 72));
|
||||
}
|
||||
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function agentLabel(record: AgentRecord): string {
|
||||
return (
|
||||
getString(record, [
|
||||
"name",
|
||||
"title",
|
||||
"label",
|
||||
"agent_name",
|
||||
"agent_id",
|
||||
"id",
|
||||
]) ?? "Unnamed agent"
|
||||
);
|
||||
}
|
||||
|
||||
function agentId(record: AgentRecord): string | undefined {
|
||||
return getString(record, ["agent_id", "agentId", "id"]);
|
||||
}
|
||||
|
||||
function agentError(record: AgentRecord): string | undefined {
|
||||
return getString(record, ["error", "message", "last_error"]);
|
||||
}
|
||||
|
||||
export function AgentListPanel({
|
||||
agents,
|
||||
title = "Agents",
|
||||
emptyLabel = "No agents reported yet",
|
||||
selectedAgentId,
|
||||
maxAgents = 8,
|
||||
}: Props): React.ReactElement {
|
||||
const visibleAgents = agents
|
||||
.filter((agent): agent is Record<string, unknown> => Boolean(agent))
|
||||
.slice(-maxAgents);
|
||||
const hiddenCount = Math.max(0, agents.length - visibleAgents.length);
|
||||
|
||||
if (visibleAgents.length === 0) {
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={getColor("border")} paddingX={1}>
|
||||
<Text color={getColor("textDim")}>{emptyLabel}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={getColor("border")}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text bold color={getColor("primary")}>
|
||||
{title} ({agents.length})
|
||||
</Text>
|
||||
|
||||
{hiddenCount > 0 ? (
|
||||
<Text color={getColor("textDim")}>
|
||||
Showing the latest {visibleAgents.length} agent{visibleAgents.length === 1 ? "" : "s"}.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{visibleAgents.map(record => {
|
||||
const id = agentId(record) ?? agentLabel(record);
|
||||
const selected = selectedAgentId !== null && selectedAgentId === id;
|
||||
const tone = statusTone(record);
|
||||
const summary = buildSummary(record);
|
||||
const error = agentError(record);
|
||||
|
||||
return (
|
||||
<Box key={id} flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
{selected ? (
|
||||
<Text color={tone.color} bold>
|
||||
›{" "}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text color={tone.color}>
|
||||
{tone.icon}{" "}
|
||||
</Text>
|
||||
<Text bold>{agentLabel(record)}</Text>
|
||||
<Text color={getColor("textDim")}>
|
||||
{" "}— {tone.label}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{summary ? (
|
||||
<Text color={getColor("textDim")}> {summary}</Text>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<Text color={getColor("error")}> Error: {truncate(error, 96)}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
217
apps/tui/src/components/AgentRuntimePane.tsx
Normal file
217
apps/tui/src/components/AgentRuntimePane.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import type { AppMessage } from "../state/AppStateStore.js";
|
||||
import { getColor } from "./design-system/theme.js";
|
||||
import { AgentEventFeed } from "./AgentEventFeed.js";
|
||||
import { AgentListPanel } from "./AgentListPanel.js";
|
||||
import {
|
||||
AgentTranscriptPanel,
|
||||
type AgentTranscriptHandle,
|
||||
} from "./AgentTranscriptPanel.js";
|
||||
import { AgentTranscriptPreview } from "./AgentTranscriptPreview.js";
|
||||
import { BackgroundSessionSummary } from "./BackgroundSessionSummary.js";
|
||||
|
||||
export type RuntimeTab = "list" | "events" | "transcript";
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
selectedTab: RuntimeTab;
|
||||
agents: Array<Record<string, unknown>>;
|
||||
events: Array<Record<string, unknown>>;
|
||||
transcriptMessages: AppMessage[];
|
||||
backgroundSession?: Record<string, unknown> | null;
|
||||
selectedAgentId?: string | null;
|
||||
selectedEventIndex?: number | null;
|
||||
transcriptCursor?: number | null;
|
||||
selectedMessageId?: string | null;
|
||||
agentLabel?: string;
|
||||
listTitle?: string;
|
||||
eventTitle?: string;
|
||||
transcriptTitle?: string;
|
||||
transcriptEmptyLabel?: string;
|
||||
actionHints?: string[];
|
||||
maxAgents?: number;
|
||||
maxEvents?: number;
|
||||
maxTranscriptMessages?: number;
|
||||
transcriptPanelRef?: React.Ref<AgentTranscriptHandle | null>;
|
||||
transcriptSearchVisible?: boolean;
|
||||
transcriptSearchQuery?: string;
|
||||
transcriptSearchMatchCount?: number;
|
||||
transcriptSearchCurrentMatch?: number;
|
||||
onTranscriptSearchMatchesChange?: (count: number, current: number) => void;
|
||||
onTranscriptCursorChange?: (messageId: string | null, index: number | null) => void;
|
||||
};
|
||||
|
||||
const TAB_LABELS: Record<RuntimeTab, string> = {
|
||||
list: "Agents",
|
||||
events: "Events",
|
||||
transcript: "Transcript",
|
||||
};
|
||||
|
||||
function tabColor(tab: RuntimeTab, selectedTab: RuntimeTab): string {
|
||||
return tab === selectedTab ? getColor("primary") : getColor("textDim");
|
||||
}
|
||||
|
||||
function tabWeight(tab: RuntimeTab, selectedTab: RuntimeTab): boolean {
|
||||
return tab === selectedTab;
|
||||
}
|
||||
|
||||
function selectionStatus(props: {
|
||||
selectedTab: RuntimeTab;
|
||||
selectedAgentId?: string | null;
|
||||
selectedEventIndex?: number | null;
|
||||
transcriptCursor?: number | null;
|
||||
selectedMessageId?: string | null;
|
||||
}): string {
|
||||
switch (props.selectedTab) {
|
||||
case "list":
|
||||
return props.selectedAgentId
|
||||
? `Selected agent: ${props.selectedAgentId}`
|
||||
: "Selected agent: none";
|
||||
case "events":
|
||||
return props.selectedEventIndex !== null &&
|
||||
props.selectedEventIndex !== undefined
|
||||
? `Selected event: ${props.selectedEventIndex + 1}`
|
||||
: "Selected event: none";
|
||||
case "transcript":
|
||||
if (props.selectedMessageId) {
|
||||
return `Selected message: ${props.selectedMessageId}`;
|
||||
}
|
||||
return props.transcriptCursor !== null &&
|
||||
props.transcriptCursor !== undefined
|
||||
? `Transcript cursor: ${props.transcriptCursor + 1}`
|
||||
: "Transcript cursor: none";
|
||||
default:
|
||||
return "Selection: none";
|
||||
}
|
||||
}
|
||||
|
||||
function renderTabStrip(selectedTab: RuntimeTab): React.ReactElement {
|
||||
return (
|
||||
<Box>
|
||||
{(Object.keys(TAB_LABELS) as RuntimeTab[]).map(tab => (
|
||||
<Text
|
||||
key={tab}
|
||||
color={tabColor(tab, selectedTab)}
|
||||
bold={tabWeight(tab, selectedTab)}
|
||||
>
|
||||
{tab === selectedTab ? "›" : " "} {TAB_LABELS[tab]}
|
||||
{" "}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentRuntimePane({
|
||||
title = "Runtime",
|
||||
selectedTab,
|
||||
agents,
|
||||
events,
|
||||
transcriptMessages,
|
||||
backgroundSession = null,
|
||||
selectedAgentId = null,
|
||||
selectedEventIndex = null,
|
||||
transcriptCursor = null,
|
||||
selectedMessageId = null,
|
||||
agentLabel = "Viewed agent",
|
||||
listTitle,
|
||||
eventTitle,
|
||||
transcriptTitle,
|
||||
transcriptEmptyLabel,
|
||||
actionHints = [],
|
||||
maxAgents = 8,
|
||||
maxEvents = 10,
|
||||
maxTranscriptMessages = 5,
|
||||
transcriptPanelRef,
|
||||
transcriptSearchVisible = false,
|
||||
transcriptSearchQuery = "",
|
||||
transcriptSearchMatchCount = 0,
|
||||
transcriptSearchCurrentMatch = 0,
|
||||
onTranscriptSearchMatchesChange,
|
||||
onTranscriptCursorChange,
|
||||
}: Props): React.ReactElement {
|
||||
const activePanel =
|
||||
selectedTab === "list" ? (
|
||||
<AgentListPanel
|
||||
agents={agents}
|
||||
title={listTitle}
|
||||
selectedAgentId={selectedAgentId}
|
||||
maxAgents={maxAgents}
|
||||
/>
|
||||
) : selectedTab === "events" ? (
|
||||
<AgentEventFeed
|
||||
events={events}
|
||||
title={eventTitle}
|
||||
maxEvents={maxEvents}
|
||||
selectedEventIndex={selectedEventIndex}
|
||||
actionHints={actionHints}
|
||||
/>
|
||||
) : (
|
||||
<AgentTranscriptPanel
|
||||
ref={transcriptPanelRef}
|
||||
messages={transcriptMessages}
|
||||
agentLabel={agentLabel}
|
||||
title={transcriptTitle}
|
||||
emptyLabel={transcriptEmptyLabel}
|
||||
cursor={transcriptCursor}
|
||||
selectedMessageId={selectedMessageId}
|
||||
actionHints={actionHints}
|
||||
searchVisible={transcriptSearchVisible}
|
||||
searchQuery={transcriptSearchQuery}
|
||||
searchMatchCount={transcriptSearchMatchCount}
|
||||
searchCurrentMatch={transcriptSearchCurrentMatch}
|
||||
onSearchMatchesChange={onTranscriptSearchMatchesChange}
|
||||
onCursorChange={onTranscriptCursorChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const transcriptPreview =
|
||||
selectedTab !== "transcript" && transcriptMessages.length > 0 ? (
|
||||
<Box marginTop={1}>
|
||||
<AgentTranscriptPreview
|
||||
messages={transcriptMessages}
|
||||
agentLabel={agentLabel}
|
||||
maxMessages={Math.min(3, maxTranscriptMessages)}
|
||||
/>
|
||||
</Box>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={getColor("primary")}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>{renderTabStrip(selectedTab)}</Box>
|
||||
|
||||
<Text color={getColor("textDim")}>
|
||||
{selectionStatus({
|
||||
selectedTab,
|
||||
selectedAgentId,
|
||||
selectedEventIndex,
|
||||
transcriptCursor,
|
||||
selectedMessageId,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
{backgroundSession ? (
|
||||
<Box marginTop={1}>
|
||||
<BackgroundSessionSummary session={backgroundSession} />
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box marginTop={1}>{activePanel}</Box>
|
||||
|
||||
{transcriptPreview}
|
||||
|
||||
{actionHints.length > 0 ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={getColor("textDim")}>
|
||||
{actionHints.join(" | ")}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
228
apps/tui/src/components/AgentTranscriptPanel.tsx
Normal file
228
apps/tui/src/components/AgentTranscriptPanel.tsx
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import type { JumpHandle } from "./VirtualMessageList.js";
|
||||
import type { MessageActionsNav, MessageActionsState } from "./messageActions.js";
|
||||
import type { AppMessage } from "../state/AppStateStore.js";
|
||||
import { estimateMessagesContentHeight, Messages } from "./Messages.js";
|
||||
import ScrollBox, { type ScrollBoxHandle } from "../ink/components/ScrollBox.js";
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize.js";
|
||||
import { getColor } from "./design-system/theme.js";
|
||||
import { TranscriptSearchBar } from "./transcript/TranscriptSearchBar.js";
|
||||
|
||||
export type AgentTranscriptHandle = JumpHandle & {
|
||||
enterCursor: () => void;
|
||||
navigatePrev: () => void;
|
||||
navigateNext: () => void;
|
||||
navigateTop: () => void;
|
||||
navigateBottom: () => void;
|
||||
scrollToBottom: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
messages: AppMessage[];
|
||||
agentLabel?: string;
|
||||
title?: string;
|
||||
emptyLabel?: string;
|
||||
maxRows?: number;
|
||||
selectedMessageId?: string | null;
|
||||
cursor?: number | null;
|
||||
actionHints?: string[];
|
||||
searchVisible?: boolean;
|
||||
searchQuery?: string;
|
||||
searchMatchCount?: number;
|
||||
searchCurrentMatch?: number;
|
||||
onSearchMatchesChange?: (count: number, current: number) => void;
|
||||
onCursorChange?: (messageId: string | null, index: number | null) => void;
|
||||
};
|
||||
|
||||
function normalizeIndex(
|
||||
index: number | null | undefined,
|
||||
length: number,
|
||||
): number | null {
|
||||
if (length === 0 || index === null || index === undefined || Number.isNaN(index)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(length - 1, index));
|
||||
}
|
||||
|
||||
export const AgentTranscriptPanel = React.forwardRef<
|
||||
AgentTranscriptHandle | null,
|
||||
Props
|
||||
>(function AgentTranscriptPanel(
|
||||
{
|
||||
messages,
|
||||
agentLabel = "Viewed agent",
|
||||
title = "Agent Transcript",
|
||||
emptyLabel = "No transcript available",
|
||||
maxRows,
|
||||
selectedMessageId = null,
|
||||
cursor = null,
|
||||
actionHints = [],
|
||||
searchVisible = false,
|
||||
searchQuery = "",
|
||||
searchMatchCount = 0,
|
||||
searchCurrentMatch = 0,
|
||||
onSearchMatchesChange,
|
||||
onCursorChange,
|
||||
}: Props,
|
||||
ref,
|
||||
): React.ReactElement {
|
||||
const terminalSize = useTerminalSize();
|
||||
const scrollRef = React.useRef<ScrollBoxHandle | null>(null);
|
||||
const jumpRef = React.useRef<JumpHandle | null>(null);
|
||||
const cursorNavRef = React.useRef<MessageActionsNav | null>(null);
|
||||
const lastSyncedSelectionRef = React.useRef<string | null>(null);
|
||||
|
||||
const maxScrollableRows =
|
||||
maxRows ?? Math.max(8, Math.min(18, Math.floor(terminalSize.rows * 0.3)));
|
||||
const contentHeight = estimateMessagesContentHeight(
|
||||
messages,
|
||||
Math.max(24, terminalSize.columns - 12),
|
||||
);
|
||||
|
||||
const handleCursorChange = React.useCallback(
|
||||
(cursorState: MessageActionsState | null): void => {
|
||||
const messageId = cursorState?.id ?? null;
|
||||
if (messageId === null) {
|
||||
lastSyncedSelectionRef.current = null;
|
||||
onCursorChange?.(null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
const index = messages.findIndex(message => message.id === messageId);
|
||||
lastSyncedSelectionRef.current = messageId;
|
||||
onCursorChange?.(messageId, index >= 0 ? index : null);
|
||||
},
|
||||
[messages, onCursorChange],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!jumpRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedMessageId) {
|
||||
if (lastSyncedSelectionRef.current === selectedMessageId) {
|
||||
return;
|
||||
}
|
||||
const index = messages.findIndex(message => message.id === selectedMessageId);
|
||||
if (index >= 0) {
|
||||
lastSyncedSelectionRef.current = selectedMessageId;
|
||||
jumpRef.current.jumpToIndex(index);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedCursor = normalizeIndex(cursor, messages.length);
|
||||
if (normalizedCursor !== null) {
|
||||
const messageId = messages[normalizedCursor]?.id ?? null;
|
||||
if (messageId !== null && lastSyncedSelectionRef.current !== messageId) {
|
||||
lastSyncedSelectionRef.current = messageId;
|
||||
jumpRef.current.jumpToIndex(normalizedCursor);
|
||||
}
|
||||
}
|
||||
}, [cursor, messages, selectedMessageId]);
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
jumpToIndex(index: number) {
|
||||
jumpRef.current?.jumpToIndex(index);
|
||||
},
|
||||
setSearchQuery(query: string) {
|
||||
jumpRef.current?.setSearchQuery(query);
|
||||
},
|
||||
nextMatch() {
|
||||
jumpRef.current?.nextMatch();
|
||||
},
|
||||
prevMatch() {
|
||||
jumpRef.current?.prevMatch();
|
||||
},
|
||||
setAnchor() {
|
||||
jumpRef.current?.setAnchor();
|
||||
},
|
||||
warmSearchIndex() {
|
||||
return jumpRef.current?.warmSearchIndex() ?? Promise.resolve(0);
|
||||
},
|
||||
disarmSearch() {
|
||||
jumpRef.current?.disarmSearch();
|
||||
},
|
||||
enterCursor() {
|
||||
cursorNavRef.current?.enterCursor();
|
||||
},
|
||||
navigatePrev() {
|
||||
cursorNavRef.current?.navigatePrev();
|
||||
},
|
||||
navigateNext() {
|
||||
cursorNavRef.current?.navigateNext();
|
||||
},
|
||||
navigateTop() {
|
||||
cursorNavRef.current?.navigateTop();
|
||||
},
|
||||
navigateBottom() {
|
||||
cursorNavRef.current?.navigateBottom();
|
||||
},
|
||||
scrollToBottom() {
|
||||
scrollRef.current?.scrollToBottom();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={getColor("border")}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text bold color={getColor("primary")}>
|
||||
{title} ({messages.length})
|
||||
</Text>
|
||||
<Text color={getColor("textDim")}>
|
||||
{agentLabel}
|
||||
</Text>
|
||||
{actionHints.length > 0 ? (
|
||||
<Text color={getColor("textDim")}>
|
||||
{actionHints.join(" | ")}
|
||||
</Text>
|
||||
) : null}
|
||||
{searchVisible ? (
|
||||
<TranscriptSearchBar
|
||||
query={searchQuery}
|
||||
matchCount={searchMatchCount}
|
||||
currentMatch={searchCurrentMatch}
|
||||
/>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
{/*
|
||||
Transitional: this embeds the existing primary transcript message surface
|
||||
inside a nested ScrollBox so the agent pane can reuse `Messages`,
|
||||
`VirtualMessageList`, `messageActions`, and jump/search infrastructure
|
||||
before the app moves to a single shared multi-pane scroll coordinator.
|
||||
*/}
|
||||
<ScrollBox
|
||||
ref={scrollRef}
|
||||
height={maxScrollableRows}
|
||||
contentHeight={Math.max(maxScrollableRows, contentHeight)}
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
>
|
||||
<Messages
|
||||
messages={messages}
|
||||
maxRows={maxScrollableRows}
|
||||
scrollRef={scrollRef}
|
||||
jumpRef={jumpRef}
|
||||
cursorNavRef={cursorNavRef}
|
||||
onCursorChange={handleCursorChange}
|
||||
onSearchMatchesChange={onSearchMatchesChange}
|
||||
trackStickyPrompt={false}
|
||||
emptyLabel={emptyLabel}
|
||||
/>
|
||||
</ScrollBox>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
100
apps/tui/src/components/AgentTranscriptPreview.tsx
Normal file
100
apps/tui/src/components/AgentTranscriptPreview.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import type { AppMessage } from "../state/AppStateStore.js";
|
||||
import { getMessageText } from "../screens/shared.js";
|
||||
import {
|
||||
MESSAGE_ROLE_LABELS,
|
||||
MESSAGE_ROLE_TOKENS,
|
||||
} from "./Message.js";
|
||||
import { MessageTimestamp } from "./MessageTimestamp.js";
|
||||
import { getColor } from "./design-system/theme.js";
|
||||
|
||||
type Props = {
|
||||
messages: AppMessage[];
|
||||
agentLabel?: string;
|
||||
title?: string;
|
||||
emptyLabel?: string;
|
||||
maxMessages?: number;
|
||||
};
|
||||
|
||||
function isRenderableMessage(message: AppMessage): boolean {
|
||||
if (message.meta?.hidden === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.meta?.budget === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, Math.max(0, max - 1))}…`;
|
||||
}
|
||||
|
||||
function summarizeMessage(message: AppMessage): string {
|
||||
const text = getMessageText(message).replace(/\s+/g, " ").trim();
|
||||
return truncate(text || " ", 100);
|
||||
}
|
||||
|
||||
export function AgentTranscriptPreview({
|
||||
messages,
|
||||
agentLabel = "Viewed agent",
|
||||
title = "Agent Transcript",
|
||||
emptyLabel = "No transcript available",
|
||||
maxMessages = 5,
|
||||
}: Props): React.ReactElement {
|
||||
const visibleMessages = messages.filter(isRenderableMessage).slice(-maxMessages);
|
||||
|
||||
if (visibleMessages.length === 0) {
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={getColor("border")} paddingX={1}>
|
||||
<Text color={getColor("textDim")}>{emptyLabel}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const latestMessage = visibleMessages[visibleMessages.length - 1]!;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={getColor("border")}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text bold color={getColor("primary")}>
|
||||
{title} ({messages.length})
|
||||
</Text>
|
||||
<Text color={getColor("textDim")}>
|
||||
{agentLabel} · {visibleMessages.length} message{visibleMessages.length === 1 ? "" : "s"}
|
||||
</Text>
|
||||
<Box>
|
||||
<MessageTimestamp message={latestMessage} />
|
||||
<Text color={getColor("textDim")}> </Text>
|
||||
<Text color={getColor(MESSAGE_ROLE_TOKENS[latestMessage.role])} bold>
|
||||
[{MESSAGE_ROLE_LABELS[latestMessage.role]}]
|
||||
</Text>
|
||||
<Text color={getColor("textDim")}> </Text>
|
||||
<Text color={getColor("text")}>{summarizeMessage(latestMessage)}</Text>
|
||||
</Box>
|
||||
|
||||
{visibleMessages.slice(0, -1).map(message => (
|
||||
<Box key={message.id} marginTop={1}>
|
||||
<Text color={getColor("textDim")}>
|
||||
{MESSAGE_ROLE_LABELS[message.role]}:
|
||||
</Text>
|
||||
<Text color={getColor("textDim")}> </Text>
|
||||
<Text color={getColor(MESSAGE_ROLE_TOKENS[message.role])}>
|
||||
{summarizeMessage(message)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
97
apps/tui/src/components/App.tsx
Normal file
97
apps/tui/src/components/App.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import React from "react";
|
||||
import type { StructuredIO } from "../bridge/structuredIO.js";
|
||||
import { FpsMetricsProvider } from "../context/fpsMetrics.js";
|
||||
import { StatsProvider } from "../context/stats.js";
|
||||
import instances from "../ink/instances.js";
|
||||
import { KeybindingSetup } from "../keybindings/KeybindingProviderSetup.js";
|
||||
import {
|
||||
type AppState,
|
||||
type AppStateStore,
|
||||
type ScreenName,
|
||||
} from "../state/AppStateStore.js";
|
||||
import { AppStateProvider } from "../state/AppState.js";
|
||||
import { FpsTracker } from "../utils/fpsTracker.js";
|
||||
import { Doctor } from "../screens/Doctor.js";
|
||||
import { REPL } from "../screens/REPL.js";
|
||||
import { ResumeConversation } from "../screens/ResumeConversation.js";
|
||||
|
||||
type Props = {
|
||||
screen: ScreenName;
|
||||
io: StructuredIO | null;
|
||||
store: AppStateStore;
|
||||
initialState?: AppState;
|
||||
stdout: NodeJS.WriteStream;
|
||||
};
|
||||
|
||||
export function App({
|
||||
screen,
|
||||
io,
|
||||
store,
|
||||
initialState,
|
||||
stdout,
|
||||
}: Props): React.ReactElement {
|
||||
const fpsTrackerRef = React.useRef<FpsTracker | null>(null);
|
||||
const [, setRenderEpoch] = React.useState(0);
|
||||
|
||||
if (fpsTrackerRef.current === null) {
|
||||
fpsTrackerRef.current = new FpsTracker();
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const controls = {
|
||||
invalidatePrevFrame: () => {
|
||||
setRenderEpoch(epoch => epoch + 1);
|
||||
},
|
||||
forceRedraw: () => {
|
||||
setRenderEpoch(epoch => epoch + 1);
|
||||
},
|
||||
};
|
||||
|
||||
instances.set(stdout, controls);
|
||||
instances.set(process.stdout, controls);
|
||||
instances.set(process.stderr, controls);
|
||||
|
||||
return () => {
|
||||
instances.delete(stdout);
|
||||
instances.delete(process.stdout);
|
||||
instances.delete(process.stderr);
|
||||
};
|
||||
}, [stdout]);
|
||||
|
||||
const content = (() => {
|
||||
switch (screen) {
|
||||
case "doctor":
|
||||
return <Doctor io={io} />;
|
||||
case "resume":
|
||||
return <ResumeConversation io={io} />;
|
||||
case "repl":
|
||||
default:
|
||||
return (
|
||||
<KeybindingSetup>
|
||||
<REPL io={io} />
|
||||
</KeybindingSetup>
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<StatsProvider>
|
||||
<AppStateProvider
|
||||
store={store}
|
||||
initialState={initialState}
|
||||
>
|
||||
{/*
|
||||
Transitional: OpenSpace scopes prompt overlays inside the fullscreen
|
||||
message surface. OpenSpace now mounts that provider there instead of at
|
||||
the app root, but still relies on upstream Ink for frame tracking.
|
||||
*/}
|
||||
<FpsMetricsProvider
|
||||
getFpsMetrics={() => fpsTrackerRef.current?.getMetrics()}
|
||||
recordFrame={durationMs => fpsTrackerRef.current?.record(durationMs)}
|
||||
>
|
||||
{content}
|
||||
</FpsMetricsProvider>
|
||||
</AppStateProvider>
|
||||
</StatsProvider>
|
||||
);
|
||||
}
|
||||
79
apps/tui/src/components/BackgroundControlsPanel.tsx
Normal file
79
apps/tui/src/components/BackgroundControlsPanel.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { getColor } from "./design-system/theme.js";
|
||||
import { BackgroundSessionSummary } from "./BackgroundSessionSummary.js";
|
||||
import type { RuntimeTab } from "./AgentRuntimePane.js";
|
||||
|
||||
type ControlHint = {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
session: Record<string, unknown> | null;
|
||||
title?: string;
|
||||
emptyLabel?: string;
|
||||
selectedTab?: RuntimeTab | null;
|
||||
actionHints?: string[];
|
||||
controls?: ControlHint[];
|
||||
};
|
||||
|
||||
function tabLabel(tab: RuntimeTab | null | undefined): string {
|
||||
switch (tab) {
|
||||
case "list":
|
||||
return "agents";
|
||||
case "events":
|
||||
return "events";
|
||||
case "transcript":
|
||||
return "transcript";
|
||||
default:
|
||||
return "idle";
|
||||
}
|
||||
}
|
||||
|
||||
export function BackgroundControlsPanel({
|
||||
session,
|
||||
title = "Background Controls",
|
||||
emptyLabel = "No background session active",
|
||||
selectedTab = null,
|
||||
actionHints = [],
|
||||
controls = [],
|
||||
}: Props): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={getColor("primary")}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<BackgroundSessionSummary
|
||||
session={session}
|
||||
title="Session"
|
||||
emptyLabel={emptyLabel}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Text color={getColor("textDim")}>
|
||||
Active pane: {tabLabel(selectedTab)}
|
||||
</Text>
|
||||
|
||||
{actionHints.length > 0 ? (
|
||||
<Text color={getColor("textDim")}>
|
||||
{actionHints.join(" | ")}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{controls.length > 0 ? (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{controls.map(control => (
|
||||
<Text key={control.key} color={getColor("textDim")}>
|
||||
[{control.key}] {control.label}
|
||||
{control.description ? ` - ${control.description}` : ""}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
170
apps/tui/src/components/BackgroundSessionSummary.tsx
Normal file
170
apps/tui/src/components/BackgroundSessionSummary.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { getColor } from "./design-system/theme.js";
|
||||
|
||||
type BackgroundSessionLike = Record<string, unknown> | null | undefined;
|
||||
|
||||
type BackgroundSessionFields = {
|
||||
sessionId?: string;
|
||||
session_id?: string;
|
||||
taskId?: string;
|
||||
task_id?: string;
|
||||
status?: string;
|
||||
title?: string;
|
||||
updatedAt?: number;
|
||||
updated_at?: number | string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
session: BackgroundSessionLike;
|
||||
title?: string;
|
||||
emptyLabel?: string;
|
||||
};
|
||||
|
||||
function getString(
|
||||
record: BackgroundSessionLike,
|
||||
keys: string[],
|
||||
): string | undefined {
|
||||
if (!record || typeof record !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const value = (record as BackgroundSessionFields)[key as keyof BackgroundSessionFields];
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getNumber(
|
||||
record: BackgroundSessionLike,
|
||||
keys: string[],
|
||||
): number | undefined {
|
||||
if (!record || typeof record !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const value = (record as BackgroundSessionFields)[key as keyof BackgroundSessionFields];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, Math.max(0, max - 1))}…`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: number | undefined): string {
|
||||
if (timestamp === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const diffSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
|
||||
if (diffSeconds < 60) return `${diffSeconds}s ago`;
|
||||
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
|
||||
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`;
|
||||
return `${Math.floor(diffSeconds / 86400)}d ago`;
|
||||
}
|
||||
|
||||
function statusTone(status: string | undefined): {
|
||||
icon: string;
|
||||
color: string;
|
||||
label: string;
|
||||
} {
|
||||
const normalized = (status ?? "unknown").toLowerCase();
|
||||
|
||||
switch (normalized) {
|
||||
case "running":
|
||||
case "active":
|
||||
case "open":
|
||||
case "focused":
|
||||
return { icon: "●", color: getColor("success"), label: normalized };
|
||||
case "queued":
|
||||
case "waiting":
|
||||
case "pending":
|
||||
return { icon: "◐", color: getColor("warning"), label: normalized };
|
||||
case "stopped":
|
||||
case "idle":
|
||||
case "closed":
|
||||
return { icon: "○", color: getColor("muted"), label: normalized };
|
||||
case "error":
|
||||
case "failed":
|
||||
return { icon: "✗", color: getColor("error"), label: normalized };
|
||||
default:
|
||||
return { icon: "•", color: getColor("textDim"), label: normalized };
|
||||
}
|
||||
}
|
||||
|
||||
export function BackgroundSessionSummary({
|
||||
session,
|
||||
title = "Background Session",
|
||||
emptyLabel = "No background session active",
|
||||
}: Props): React.ReactElement {
|
||||
if (!session) {
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={getColor("border")} paddingX={1}>
|
||||
<Text color={getColor("textDim")}>{emptyLabel}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const sessionId = getString(session, ["sessionId", "session_id"]);
|
||||
const taskId = getString(session, ["taskId", "task_id"]);
|
||||
const status = statusTone(getString(session, ["status"]));
|
||||
const sessionTitle = getString(session, ["title"]);
|
||||
const updatedAt = formatRelativeTime(
|
||||
getNumber(session, ["updatedAt", "updated_at"]),
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={getColor("border")}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text bold color={getColor("primary")}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={status.color}>
|
||||
{status.icon}{" "}
|
||||
</Text>
|
||||
<Text bold>{sessionTitle ?? sessionId ?? "Background work"}</Text>
|
||||
<Text color={getColor("textDim")}>
|
||||
{" "}— {status.label}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{sessionId ? (
|
||||
<Text color={getColor("textDim")}>Session: {truncate(sessionId, 40)}</Text>
|
||||
) : null}
|
||||
|
||||
{taskId ? (
|
||||
<Text color={getColor("textDim")}>Task: {truncate(taskId, 40)}</Text>
|
||||
) : null}
|
||||
|
||||
{updatedAt ? (
|
||||
<Text color={getColor("textDim")}>Updated {updatedAt}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
57
apps/tui/src/components/CoordinatorStatusBar.tsx
Normal file
57
apps/tui/src/components/CoordinatorStatusBar.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import React from "react";
|
||||
import { Text } from "ink";
|
||||
import type {
|
||||
BackgroundAgentTaskState,
|
||||
CoordinatorRuntimeState,
|
||||
} from "../state/AppStateStore.js";
|
||||
import { getColor } from "./design-system/theme.js";
|
||||
|
||||
type Props = {
|
||||
coordinator: CoordinatorRuntimeState;
|
||||
backgroundTasks: Record<string, BackgroundAgentTaskState>;
|
||||
};
|
||||
|
||||
function countRunningTeamTasks(
|
||||
tasks: Record<string, BackgroundAgentTaskState>,
|
||||
teamName: string | undefined,
|
||||
): number {
|
||||
return Object.values(tasks).filter(task => {
|
||||
if (teamName && task.teamName !== teamName) {
|
||||
return false;
|
||||
}
|
||||
return ["running", "pending", "starting"].includes(task.status.toLowerCase());
|
||||
}).length;
|
||||
}
|
||||
|
||||
export function CoordinatorStatusBar({
|
||||
coordinator,
|
||||
backgroundTasks,
|
||||
}: Props): React.ReactElement | null {
|
||||
const derivedRunning = countRunningTeamTasks(
|
||||
backgroundTasks,
|
||||
coordinator.teamName,
|
||||
);
|
||||
const runningWorkers = Math.max(
|
||||
coordinator.runningWorkers,
|
||||
derivedRunning,
|
||||
);
|
||||
const totalWorkers = Math.max(
|
||||
coordinator.totalWorkers,
|
||||
Object.values(backgroundTasks).filter(task =>
|
||||
coordinator.teamName ? task.teamName === coordinator.teamName : Boolean(task.teamName),
|
||||
).length,
|
||||
);
|
||||
|
||||
if (!coordinator.teamName && runningWorkers === 0 && totalWorkers === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const teamLabel = coordinator.teamName ?? "default";
|
||||
const status = coordinator.status ? ` ${coordinator.status}` : "";
|
||||
|
||||
return (
|
||||
<Text color={getColor("accent")}>
|
||||
Coordinator: {teamLabel}{status} | {runningWorkers}/{totalWorkers} workers running
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
238
apps/tui/src/components/FullscreenLayout.tsx
Normal file
238
apps/tui/src/components/FullscreenLayout.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { QueuedMessageProvider } from "../context/QueuedMessageContext.js";
|
||||
import { useRecordFpsFrame } from "../context/fpsMetrics.js";
|
||||
import { ModalContext } from "../context/modalContext.js";
|
||||
import {
|
||||
PromptOverlayProvider,
|
||||
usePromptOverlay,
|
||||
usePromptOverlayDialog,
|
||||
} from "../context/promptOverlayContext.js";
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize.js";
|
||||
import ScrollBox, {
|
||||
type ScrollBoxHandle,
|
||||
} from "../ink/components/ScrollBox.js";
|
||||
import { SlashCommandComplete } from "./PromptInput/SlashCommandComplete.js";
|
||||
import { truncateToDisplayWidth } from "../utils/textWidth.js";
|
||||
|
||||
export type StickyPrompt = {
|
||||
sourceId?: string;
|
||||
text: string;
|
||||
scrollTo?: () => void;
|
||||
};
|
||||
|
||||
export const ScrollChromeContext = React.createContext<{
|
||||
setStickyPrompt: (prompt: StickyPrompt | null) => void;
|
||||
} | null>(null);
|
||||
|
||||
type Props = {
|
||||
messages: React.ReactNode;
|
||||
afterMessages?: React.ReactNode;
|
||||
bottom: React.ReactNode;
|
||||
overlay?: React.ReactNode;
|
||||
bottomFloat?: React.ReactNode;
|
||||
modal?: React.ReactNode;
|
||||
modalScrollRef?: React.RefObject<ScrollBoxHandle | null>;
|
||||
scrollRef?: React.RefObject<ScrollBoxHandle | null>;
|
||||
contentHeight?: number;
|
||||
scrollRows?: number;
|
||||
};
|
||||
|
||||
function estimatePromptOverlayRows(
|
||||
suggestionsCount: number,
|
||||
): number {
|
||||
if (suggestionsCount === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(10, suggestionsCount + 2);
|
||||
}
|
||||
|
||||
function truncateStickyPrompt(text: string, columns: number): string {
|
||||
const safeWidth = Math.max(12, columns - 6);
|
||||
return truncateToDisplayWidth(text, safeWidth);
|
||||
}
|
||||
|
||||
function FullscreenLayoutBody({
|
||||
messages,
|
||||
afterMessages,
|
||||
bottom,
|
||||
overlay,
|
||||
bottomFloat,
|
||||
modal,
|
||||
modalScrollRef,
|
||||
scrollRef,
|
||||
contentHeight,
|
||||
scrollRows: scrollRowsProp,
|
||||
}: Props): React.ReactElement {
|
||||
const size = useTerminalSize();
|
||||
const promptOverlay = usePromptOverlay();
|
||||
const promptOverlayDialog = usePromptOverlayDialog();
|
||||
const recordFrame = useRecordFpsFrame();
|
||||
const lastCommitRef = React.useRef<number | null>(null);
|
||||
const internalModalScrollRef = React.useRef<ScrollBoxHandle | null>(null);
|
||||
const resolvedModalScrollRef = modalScrollRef ?? internalModalScrollRef;
|
||||
const [stickyPrompt, setStickyPrompt] = React.useState<StickyPrompt | null>(
|
||||
null,
|
||||
);
|
||||
const updateStickyPrompt = React.useCallback(
|
||||
(nextPrompt: StickyPrompt | null) => {
|
||||
setStickyPrompt(current => {
|
||||
if (current === null && nextPrompt === null) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (
|
||||
current !== null &&
|
||||
nextPrompt !== null &&
|
||||
current.sourceId === nextPrompt.sourceId &&
|
||||
current.text === nextPrompt.text
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return nextPrompt;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const scrollChromeValue = React.useMemo(
|
||||
() => ({ setStickyPrompt: updateStickyPrompt }),
|
||||
[updateStickyPrompt],
|
||||
);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const now = performance.now();
|
||||
if (lastCommitRef.current !== null) {
|
||||
recordFrame?.(now - lastCommitRef.current);
|
||||
}
|
||||
lastCommitRef.current = now;
|
||||
});
|
||||
|
||||
const promptOverlayRows = promptOverlayDialog
|
||||
? Math.max(6, Math.floor(size.rows * 0.25))
|
||||
: estimatePromptOverlayRows(promptOverlay?.suggestions.length ?? 0);
|
||||
const modalRows = modal ? Math.max(8, Math.floor(size.rows * 0.35)) : 0;
|
||||
const scrollRows = scrollRowsProp ?? Math.max(
|
||||
6,
|
||||
size.rows - 8 - promptOverlayRows - modalRows,
|
||||
);
|
||||
const maxOverlayRows = Math.max(4, size.rows - 12);
|
||||
const suggestionCount = promptOverlay?.suggestions.length ?? 0;
|
||||
const maxOverlayItems = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
suggestionCount,
|
||||
suggestionCount > maxOverlayRows
|
||||
? Math.max(1, maxOverlayRows - 1)
|
||||
: maxOverlayRows,
|
||||
),
|
||||
);
|
||||
const stickyPromptHeader =
|
||||
stickyPrompt && !overlay ? (
|
||||
<Box
|
||||
flexShrink={0}
|
||||
marginTop={1}
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
paddingX={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text color="gray">
|
||||
↟ {truncateStickyPrompt(stickyPrompt.text, size.columns)}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null;
|
||||
|
||||
const surfaceContent = (
|
||||
<ScrollChromeContext.Provider value={scrollChromeValue}>
|
||||
<Box flexDirection="column">
|
||||
{messages}
|
||||
{afterMessages ? <Box flexDirection="column">{afterMessages}</Box> : null}
|
||||
{overlay ? (
|
||||
<Box marginTop={1}>
|
||||
<QueuedMessageProvider isFirst={true}>
|
||||
{overlay}
|
||||
</QueuedMessageProvider>
|
||||
</Box>
|
||||
) : null}
|
||||
{bottomFloat ? <Box marginTop={1}>{bottomFloat}</Box> : null}
|
||||
</Box>
|
||||
</ScrollChromeContext.Provider>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1} width="100%">
|
||||
{stickyPromptHeader}
|
||||
<ScrollBox
|
||||
ref={scrollRef}
|
||||
flexGrow={1}
|
||||
height={scrollRows}
|
||||
contentHeight={contentHeight}
|
||||
stickyScroll={true}
|
||||
overflowY="hidden"
|
||||
>
|
||||
{surfaceContent}
|
||||
</ScrollBox>
|
||||
|
||||
{modal ? (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color="gray">▔</Text>
|
||||
<ModalContext.Provider
|
||||
value={{
|
||||
rows: Math.max(3, modalRows - 2),
|
||||
columns: Math.max(24, size.columns - 4),
|
||||
scrollRef: resolvedModalScrollRef,
|
||||
}}
|
||||
>
|
||||
{/*
|
||||
Transitional: OpenSpace paints this in an absolute modal slot over
|
||||
the fullscreen scroll region. Upstream Ink in OpenSpace still lacks
|
||||
the same slotting primitives, so this keeps the modal anchored in a
|
||||
dedicated section while preserving the modal context contract.
|
||||
*/}
|
||||
<ScrollBox
|
||||
ref={resolvedModalScrollRef}
|
||||
height={modalRows}
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
>
|
||||
{modal}
|
||||
</ScrollBox>
|
||||
</ModalContext.Provider>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box flexDirection="column" flexShrink={0} width="100%" overflowY="hidden">
|
||||
{promptOverlayDialog ? (
|
||||
<Box width="100%">
|
||||
{promptOverlayDialog}
|
||||
</Box>
|
||||
) : promptOverlay?.suggestions.length ? (
|
||||
<Box paddingX={2} width="100%">
|
||||
<SlashCommandComplete
|
||||
items={promptOverlay.suggestions}
|
||||
selectedIndex={promptOverlay.selectedSuggestion}
|
||||
visible={promptOverlay.suggestions.length > 0}
|
||||
maxVisibleItems={maxOverlayItems}
|
||||
maxColumnWidth={promptOverlay.maxColumnWidth}
|
||||
bordered={false}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
{bottom}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function FullscreenLayout(props: Props): React.ReactElement {
|
||||
return (
|
||||
<PromptOverlayProvider>
|
||||
<FullscreenLayoutBody {...props} />
|
||||
</PromptOverlayProvider>
|
||||
);
|
||||
}
|
||||
327
apps/tui/src/components/Message.tsx
Normal file
327
apps/tui/src/components/Message.tsx
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import type {
|
||||
AppMessage,
|
||||
AppMessageRole,
|
||||
} from "../state/AppStateStore.js";
|
||||
import type { StructuredMessageContentBlock } from "../bridge/protocol.js";
|
||||
import { getMessageText, stringifyUnknown } from "../screens/shared.js";
|
||||
import {
|
||||
getColor,
|
||||
type ColorToken,
|
||||
} from "./design-system/theme.js";
|
||||
import { CollapsibleToolCall } from "./messages/CollapsibleToolCall.js";
|
||||
import { StreamingText } from "./messages/StreamingText.js";
|
||||
import { formatStructuredTextForDisplay } from "../utils/structuredDisplay.js";
|
||||
|
||||
export const MESSAGE_ROLE_TOKENS: Record<AppMessageRole, ColorToken> = {
|
||||
system: "systemMessage",
|
||||
user: "userMessage",
|
||||
assistant: "assistantMessage",
|
||||
tool: "toolMessage",
|
||||
status: "statusMessage",
|
||||
error: "errorMessage",
|
||||
};
|
||||
|
||||
export const MESSAGE_ROLE_LABELS: Record<AppMessageRole, string> = {
|
||||
system: "SYS",
|
||||
user: "YOU",
|
||||
assistant: "AI",
|
||||
tool: "TOOL",
|
||||
status: "INFO",
|
||||
error: "ERR",
|
||||
};
|
||||
|
||||
type ToolMessageShape = {
|
||||
toolName: string;
|
||||
input: string;
|
||||
result?: string;
|
||||
error?: string;
|
||||
status?: "pending" | "running" | "complete" | "error";
|
||||
progress?: string;
|
||||
collapsed?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
message: AppMessage;
|
||||
expanded?: boolean;
|
||||
};
|
||||
|
||||
function isToolUseBlock(
|
||||
block: StructuredMessageContentBlock,
|
||||
): block is Extract<StructuredMessageContentBlock, { type: "tool_use" }> {
|
||||
return block.type === "tool_use";
|
||||
}
|
||||
|
||||
function isFieldBlock(
|
||||
block: StructuredMessageContentBlock,
|
||||
): block is Extract<StructuredMessageContentBlock, { type: "field" }> {
|
||||
return block.type === "field";
|
||||
}
|
||||
|
||||
function getToolMessageShape(
|
||||
meta: AppMessage["meta"],
|
||||
): ToolMessageShape | null {
|
||||
if (!meta || typeof meta !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolName =
|
||||
typeof meta.toolName === "string" ? meta.toolName : undefined;
|
||||
const input =
|
||||
typeof meta.input === "string" ? meta.input : undefined;
|
||||
|
||||
if (!toolName || !input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
toolName,
|
||||
input,
|
||||
result:
|
||||
typeof meta.result === "string" ? meta.result : undefined,
|
||||
error:
|
||||
typeof meta.error === "string" ? meta.error : undefined,
|
||||
status:
|
||||
meta.status === "pending" ||
|
||||
meta.status === "running" ||
|
||||
meta.status === "complete" ||
|
||||
meta.status === "error"
|
||||
? meta.status
|
||||
: undefined,
|
||||
progress:
|
||||
typeof meta.progress === "string" ? meta.progress : undefined,
|
||||
collapsed:
|
||||
typeof meta.collapsed === "boolean" ? meta.collapsed : true,
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
);
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function summarizeNames(names: string[]): string {
|
||||
if (names.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const visible = names.slice(0, 4).join(", ");
|
||||
return names.length > 4 ? `${visible}, ...` : visible;
|
||||
}
|
||||
|
||||
function attachmentSummaryFromMeta(
|
||||
meta: AppMessage["meta"],
|
||||
): string | null {
|
||||
if (!isRecord(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attachmentType =
|
||||
typeof meta.attachment_type === "string"
|
||||
? meta.attachment_type
|
||||
: undefined;
|
||||
const type = typeof meta.type === "string" ? meta.type : undefined;
|
||||
if (type !== "attachment" && !attachmentType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attachment = isRecord(meta.attachment) ? meta.attachment : {};
|
||||
const resolvedType =
|
||||
attachmentType ??
|
||||
(typeof attachment.type === "string" ? attachment.type : undefined);
|
||||
|
||||
if (resolvedType === "agent_listing_delta") {
|
||||
const names = stringArray(attachment.addedTypes);
|
||||
const detail = summarizeNames(names);
|
||||
return `Context: ${names.length} agent${names.length === 1 ? "" : "s"} available${detail ? ` (${detail})` : ""}`;
|
||||
}
|
||||
|
||||
if (resolvedType === "skill_listing") {
|
||||
const names = stringArray(attachment.skillNames);
|
||||
const count =
|
||||
typeof attachment.skillCount === "number"
|
||||
? attachment.skillCount
|
||||
: names.length;
|
||||
const detail = summarizeNames(names);
|
||||
return `Context: ${count} skill${count === 1 ? "" : "s"} available${detail ? ` (${detail})` : ""}`;
|
||||
}
|
||||
|
||||
if (resolvedType === "deferred_tools_delta") {
|
||||
const names = stringArray(attachment.addedNames);
|
||||
const detail = summarizeNames(names);
|
||||
return `Context: ${names.length} tool${names.length === 1 ? "" : "s"} available${detail ? ` (${detail})` : ""}`;
|
||||
}
|
||||
|
||||
if (resolvedType) {
|
||||
return `Context: ${resolvedType.replaceAll("_", " ")}`;
|
||||
}
|
||||
|
||||
return "Context update";
|
||||
}
|
||||
|
||||
function activitySummaryFromMeta(
|
||||
meta: AppMessage["meta"],
|
||||
text: string,
|
||||
): string | null {
|
||||
if (!isRecord(meta) || meta.activity !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label =
|
||||
typeof meta.activityLabel === "string" && meta.activityLabel.length > 0
|
||||
? meta.activityLabel
|
||||
: "Activity";
|
||||
const cleaned = text.replace(/\s+/g, " ").trim();
|
||||
return cleaned ? `${label}: ${cleaned}` : label;
|
||||
}
|
||||
|
||||
function systemReminderSummary(text: string): string | null {
|
||||
const trimmed = text.trimStart();
|
||||
if (!trimmed.startsWith("<system-reminder>")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.includes("Available agent types")) {
|
||||
return "Context: agents available";
|
||||
}
|
||||
|
||||
if (trimmed.includes("Available skills")) {
|
||||
return "Context: skills available";
|
||||
}
|
||||
|
||||
if (trimmed.includes("deferred tools")) {
|
||||
return "Context: tools available";
|
||||
}
|
||||
|
||||
return "Context update";
|
||||
}
|
||||
|
||||
export function isContextAttachmentMessage(
|
||||
message: Pick<AppMessage, "meta" | "text" | "content">,
|
||||
): boolean {
|
||||
return getContextAttachmentSummary(message) !== null;
|
||||
}
|
||||
|
||||
export function getContextAttachmentSummary(
|
||||
message: Pick<AppMessage, "meta" | "text" | "content">,
|
||||
): string | null {
|
||||
const text = getMessageText(message);
|
||||
return (
|
||||
activitySummaryFromMeta(message.meta, text) ??
|
||||
attachmentSummaryFromMeta(message.meta) ??
|
||||
systemReminderSummary(text) ??
|
||||
(message.meta?.hasReasoning === true && text.trim().length === 0
|
||||
? "Thinking"
|
||||
: null)
|
||||
);
|
||||
}
|
||||
|
||||
export function Message({
|
||||
message,
|
||||
expanded = false,
|
||||
}: Props): React.ReactElement {
|
||||
const color = getColor(MESSAGE_ROLE_TOKENS[message.role]);
|
||||
const contentText = getMessageText(message);
|
||||
const attachmentSummary = getContextAttachmentSummary(message);
|
||||
const contentToolBlock = message.content.find(isToolUseBlock);
|
||||
const toolMessageShape =
|
||||
message.role === "tool"
|
||||
? getToolMessageShape(message.meta) ??
|
||||
(contentToolBlock
|
||||
? {
|
||||
toolName:
|
||||
typeof contentToolBlock.tool_name === "string"
|
||||
? contentToolBlock.tool_name
|
||||
: "tool",
|
||||
input: stringifyUnknown(contentToolBlock.tool_input ?? {}),
|
||||
result:
|
||||
typeof contentToolBlock.result === "string"
|
||||
? contentToolBlock.result
|
||||
: undefined,
|
||||
error:
|
||||
typeof contentToolBlock.error === "string"
|
||||
? contentToolBlock.error
|
||||
: undefined,
|
||||
status: contentToolBlock.status,
|
||||
progress:
|
||||
typeof contentToolBlock.summary === "string"
|
||||
? contentToolBlock.summary
|
||||
: undefined,
|
||||
collapsed: true,
|
||||
}
|
||||
: null)
|
||||
: null;
|
||||
|
||||
if (attachmentSummary) {
|
||||
return (
|
||||
<Text color={getColor("textDim") as never}>
|
||||
{attachmentSummary}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolMessageShape) {
|
||||
return (
|
||||
<CollapsibleToolCall
|
||||
toolName={toolMessageShape.toolName}
|
||||
input={toolMessageShape.input}
|
||||
result={toolMessageShape.result}
|
||||
error={toolMessageShape.error}
|
||||
status={toolMessageShape.status}
|
||||
progress={toolMessageShape.progress}
|
||||
collapsed={expanded ? false : (toolMessageShape.collapsed ?? true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const structuredDisplayText =
|
||||
message.role === "user"
|
||||
? null
|
||||
: formatStructuredTextForDisplay(contentText, {
|
||||
allowGenericRecord: message.role !== "assistant",
|
||||
});
|
||||
const displayText = structuredDisplayText ?? contentText;
|
||||
|
||||
if (message.meta?.streaming === true) {
|
||||
return (
|
||||
<StreamingText
|
||||
text={displayText.trim().length > 0 ? displayText : "Thinking"}
|
||||
streaming={true}
|
||||
color={color}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldBlocks = message.content.filter(isFieldBlock);
|
||||
|
||||
if (fieldBlocks.length > 0 && displayText.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{fieldBlocks.map((block, index) => (
|
||||
<Text
|
||||
key={`${message.id}:field:${index}`}
|
||||
color={color as never}
|
||||
>
|
||||
{block.label}: {block.value}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text color={color as never}>
|
||||
{displayText.length === 0 ? " " : displayText}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
36
apps/tui/src/components/MessageTimestamp.tsx
Normal file
36
apps/tui/src/components/MessageTimestamp.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import type { AppMessage } from "../state/AppStateStore.js";
|
||||
import { getColor } from "./design-system/theme.js";
|
||||
|
||||
const TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
type Props = {
|
||||
message: AppMessage;
|
||||
};
|
||||
|
||||
function formatTimestamp(timestamp: number): string {
|
||||
try {
|
||||
return TIME_FORMATTER.format(new Date(timestamp));
|
||||
} catch {
|
||||
return "--:--:--";
|
||||
}
|
||||
}
|
||||
|
||||
export function MessageTimestamp({
|
||||
message,
|
||||
}: Props): React.ReactElement {
|
||||
const formattedTimestamp = formatTimestamp(message.timestamp);
|
||||
|
||||
return (
|
||||
<Box minWidth={formattedTimestamp.length}>
|
||||
<Text color={getColor("textDim")}>
|
||||
{formattedTimestamp}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
607
apps/tui/src/components/Messages.tsx
Normal file
607
apps/tui/src/components/Messages.tsx
Normal file
|
|
@ -0,0 +1,607 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize.js";
|
||||
import { useRegisterKeybindingContext } from "../keybindings/KeybindingContext.js";
|
||||
import type { ScrollBoxHandle } from "../ink/components/ScrollBox.js";
|
||||
import type { AppMessage } from "../state/AppStateStore.js";
|
||||
import { getMessageText } from "../screens/shared.js";
|
||||
import { ScrollChromeContext } from "./FullscreenLayout.js";
|
||||
import { ScrollKeybindingHandler } from "./ScrollKeybindingHandler.js";
|
||||
import {
|
||||
MessageActionsBar,
|
||||
MessageActionsKeybindings,
|
||||
useMessageActions,
|
||||
type MessageActionsState,
|
||||
type MessageActionsNav,
|
||||
} from "./messageActions.js";
|
||||
import { getColor } from "./design-system/theme.js";
|
||||
import type { JumpHandle } from "./VirtualMessageList.js";
|
||||
import {
|
||||
buildTranscriptRows,
|
||||
estimateTranscriptRows,
|
||||
type TranscriptRow,
|
||||
} from "./messages/transcriptRows.js";
|
||||
|
||||
const STICKY_PROMPT_CAP = 180;
|
||||
|
||||
type Props = {
|
||||
messages: AppMessage[];
|
||||
maxRows: number;
|
||||
scrollRef?: React.RefObject<ScrollBoxHandle | null>;
|
||||
jumpRef?: React.RefObject<JumpHandle | null>;
|
||||
trackStickyPrompt?: boolean;
|
||||
onSearchMatchesChange?: (count: number, current: number) => void;
|
||||
cursorNavRef?: React.RefObject<MessageActionsNav | null>;
|
||||
onCursorChange?: (cursor: MessageActionsState | null) => void;
|
||||
emptyLabel?: string;
|
||||
showAll?: boolean;
|
||||
};
|
||||
|
||||
export function getMessageEstimateColumns(columns: number): number {
|
||||
return Math.max(24, columns - 2);
|
||||
}
|
||||
|
||||
function hasVisibleMessageContent(message: AppMessage): boolean {
|
||||
if (message.meta?.hasReasoning === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
message.content.some(
|
||||
block => block.type === "field" || block.type === "tool_use",
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return getMessageText(message).trim().length > 0;
|
||||
}
|
||||
|
||||
function isRenderableMessage(message: AppMessage): boolean {
|
||||
if (message.meta?.hidden === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.meta?.budget === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hasVisibleMessageContent(message);
|
||||
}
|
||||
|
||||
export function estimateMessagesContentHeight(
|
||||
messages: AppMessage[],
|
||||
columns: number,
|
||||
options?: {
|
||||
showAll?: boolean;
|
||||
},
|
||||
): number {
|
||||
const allRenderableMessages = messages.filter(isRenderableMessage);
|
||||
const renderableMessages = allRenderableMessages;
|
||||
|
||||
if (renderableMessages.length === 0) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
const estimateColumns = getMessageEstimateColumns(columns);
|
||||
return estimateTranscriptRows(renderableMessages, estimateColumns, {
|
||||
showAll: options?.showAll,
|
||||
});
|
||||
}
|
||||
|
||||
function createMessageOffsets(
|
||||
rows: TranscriptRow[],
|
||||
): Map<string, number> {
|
||||
const offsets = new Map<string, number>();
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
if (row.messageId && !offsets.has(row.messageId)) {
|
||||
offsets.set(row.messageId, index);
|
||||
}
|
||||
});
|
||||
|
||||
return offsets;
|
||||
}
|
||||
|
||||
function clampIndex(index: number, total: number): number {
|
||||
return Math.max(0, Math.min(total - 1, index));
|
||||
}
|
||||
|
||||
function createStickyPromptText(message: AppMessage): string | null {
|
||||
if (message.role !== "user") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = getMessageText(message).trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return text.length <= STICKY_PROMPT_CAP
|
||||
? text
|
||||
: `${text.slice(0, STICKY_PROMPT_CAP - 1)}…`;
|
||||
}
|
||||
|
||||
export function Messages({
|
||||
messages,
|
||||
maxRows,
|
||||
scrollRef,
|
||||
jumpRef,
|
||||
trackStickyPrompt = true,
|
||||
onSearchMatchesChange,
|
||||
cursorNavRef,
|
||||
onCursorChange,
|
||||
emptyLabel = "No messages yet. Type a prompt and press Enter.",
|
||||
showAll = false,
|
||||
}: Props): React.ReactElement {
|
||||
const scrollChrome = React.useContext(ScrollChromeContext);
|
||||
const setStickyPrompt = scrollChrome?.setStickyPrompt;
|
||||
const terminalSize = useTerminalSize();
|
||||
const allRenderableMessages = React.useMemo(
|
||||
() => messages.filter(isRenderableMessage),
|
||||
[messages],
|
||||
);
|
||||
const renderableMessages = React.useMemo(
|
||||
() => allRenderableMessages,
|
||||
[allRenderableMessages],
|
||||
);
|
||||
const internalCursorNavRef = React.useRef<MessageActionsNav | null>(null);
|
||||
const resolvedCursorNavRef: React.RefObject<MessageActionsNav | null> =
|
||||
cursorNavRef ?? internalCursorNavRef;
|
||||
const [messageCursor, setMessageCursor] =
|
||||
React.useState<MessageActionsState | null>(null);
|
||||
const { handlers } = useMessageActions(
|
||||
messageCursor,
|
||||
setMessageCursor,
|
||||
resolvedCursorNavRef,
|
||||
);
|
||||
const [, forceScrollRefresh] = React.useReducer(
|
||||
(value: number) => value + 1,
|
||||
0,
|
||||
);
|
||||
const unsubscribeRef = React.useRef<(() => void) | null>(null);
|
||||
const subscribedHandleRef =
|
||||
React.useRef<ScrollBoxHandle | null>(null);
|
||||
const searchQueryRef = React.useRef("");
|
||||
const searchMatchesRef = React.useRef<number[]>([]);
|
||||
const currentSearchMatchRef = React.useRef(-1);
|
||||
|
||||
React.useEffect(() => {
|
||||
onCursorChange?.(messageCursor);
|
||||
}, [messageCursor, onCursorChange]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const handle = scrollRef?.current ?? null;
|
||||
if (handle === subscribedHandleRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
unsubscribeRef.current?.();
|
||||
subscribedHandleRef.current = handle;
|
||||
unsubscribeRef.current =
|
||||
handle?.subscribe(() => {
|
||||
forceScrollRefresh();
|
||||
}) ?? null;
|
||||
|
||||
return () => {
|
||||
unsubscribeRef.current?.();
|
||||
unsubscribeRef.current = null;
|
||||
subscribedHandleRef.current = null;
|
||||
};
|
||||
});
|
||||
|
||||
useRegisterKeybindingContext(
|
||||
"MessageActions",
|
||||
messageCursor !== null,
|
||||
);
|
||||
|
||||
const estimateColumns = getMessageEstimateColumns(terminalSize.columns);
|
||||
const expandedMessageIds = React.useMemo(() => {
|
||||
const expanded = new Set<string>();
|
||||
if (messageCursor?.expanded === true) {
|
||||
expanded.add(messageCursor.id);
|
||||
}
|
||||
return expanded;
|
||||
}, [messageCursor?.expanded, messageCursor?.id]);
|
||||
const rows = React.useMemo(
|
||||
() =>
|
||||
buildTranscriptRows(renderableMessages, estimateColumns, {
|
||||
showAll,
|
||||
expandedMessageIds,
|
||||
}),
|
||||
[estimateColumns, expandedMessageIds, renderableMessages, showAll],
|
||||
);
|
||||
const messageOffsets = React.useMemo(
|
||||
() => createMessageOffsets(rows),
|
||||
[rows],
|
||||
);
|
||||
const loweredSearchTexts = React.useMemo(
|
||||
() =>
|
||||
renderableMessages.map(message =>
|
||||
`${message.role} ${getMessageText(message)}`.toLowerCase(),
|
||||
),
|
||||
[renderableMessages],
|
||||
);
|
||||
const scrollHandle = scrollRef?.current ?? null;
|
||||
const isStickyToBottom = scrollHandle?.isSticky() ?? true;
|
||||
const viewportRows = Math.max(1, maxRows);
|
||||
const maxScrollTop = Math.max(0, rows.length - viewportRows);
|
||||
const scrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
maxScrollTop,
|
||||
isStickyToBottom
|
||||
? maxScrollTop
|
||||
: scrollHandle?.getScrollTop() ?? 0,
|
||||
),
|
||||
);
|
||||
const visibleRows = rows.slice(scrollTop, scrollTop + viewportRows);
|
||||
const firstVisibleMessageIndex =
|
||||
visibleRows.find(
|
||||
(row): row is TranscriptRow & { messageIndex: number } =>
|
||||
typeof row.messageIndex === "number",
|
||||
)?.messageIndex ?? null;
|
||||
const leadingBlankRows =
|
||||
rows.length < viewportRows ? viewportRows - rows.length : 0;
|
||||
const emptyBlankRows =
|
||||
renderableMessages.length === 0 ? Math.max(0, viewportRows - 1) : 0;
|
||||
|
||||
const scrollToIndex = React.useCallback(
|
||||
(index: number) => {
|
||||
const handle = scrollRef?.current;
|
||||
if (!handle || renderableMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boundedIndex = clampIndex(index, renderableMessages.length);
|
||||
const message = renderableMessages[boundedIndex];
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
handle.scrollTo(Math.max(0, (messageOffsets.get(message.id) ?? 0) - 1));
|
||||
},
|
||||
[messageOffsets, renderableMessages, scrollRef],
|
||||
);
|
||||
|
||||
const selectIndex = React.useCallback(
|
||||
(index: number, preserveExpanded = false) => {
|
||||
if (renderableMessages.length === 0) {
|
||||
setMessageCursor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const boundedIndex = clampIndex(index, renderableMessages.length);
|
||||
const message = renderableMessages[boundedIndex];
|
||||
if (!message) {
|
||||
setMessageCursor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setMessageCursor(prev => ({
|
||||
id: message.id,
|
||||
expanded:
|
||||
preserveExpanded && prev?.id === message.id ? prev.expanded : false,
|
||||
}));
|
||||
},
|
||||
[renderableMessages],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!messageCursor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!renderableMessages.some(message => message.id === messageCursor.id)) {
|
||||
setMessageCursor(null);
|
||||
}
|
||||
}, [messageCursor, renderableMessages]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!setStickyPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!trackStickyPrompt ||
|
||||
isStickyToBottom ||
|
||||
scrollTop <= 0 ||
|
||||
firstVisibleMessageIndex === null
|
||||
) {
|
||||
setStickyPrompt(null);
|
||||
return;
|
||||
}
|
||||
|
||||
for (
|
||||
let index = Math.min(
|
||||
firstVisibleMessageIndex,
|
||||
renderableMessages.length - 1,
|
||||
);
|
||||
index >= 0;
|
||||
index -= 1
|
||||
) {
|
||||
const message = renderableMessages[index];
|
||||
if (!message) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const promptText = createStickyPromptText(message);
|
||||
if (!promptText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
setStickyPrompt({
|
||||
sourceId: message.id,
|
||||
text: promptText,
|
||||
scrollTo: () => {
|
||||
scrollToIndex(index);
|
||||
selectIndex(index, true);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setStickyPrompt(null);
|
||||
}, [
|
||||
firstVisibleMessageIndex,
|
||||
renderableMessages,
|
||||
isStickyToBottom,
|
||||
setStickyPrompt,
|
||||
scrollToIndex,
|
||||
scrollTop,
|
||||
selectIndex,
|
||||
trackStickyPrompt,
|
||||
]);
|
||||
|
||||
const recomputeSearchMatches = React.useCallback(
|
||||
(query: string) => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
searchQueryRef.current = normalized;
|
||||
|
||||
if (!normalized) {
|
||||
searchMatchesRef.current = [];
|
||||
currentSearchMatchRef.current = -1;
|
||||
onSearchMatchesChange?.(0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const matches: number[] = [];
|
||||
loweredSearchTexts.forEach((text, index) => {
|
||||
if (text.includes(normalized)) {
|
||||
matches.push(index);
|
||||
}
|
||||
});
|
||||
|
||||
searchMatchesRef.current = matches;
|
||||
currentSearchMatchRef.current = matches.length > 0 ? 0 : -1;
|
||||
onSearchMatchesChange?.(
|
||||
matches.length,
|
||||
matches.length > 0 ? 1 : 0,
|
||||
);
|
||||
|
||||
if (matches.length > 0) {
|
||||
const matchIndex = matches[0]!;
|
||||
scrollToIndex(matchIndex);
|
||||
selectIndex(matchIndex, true);
|
||||
}
|
||||
},
|
||||
[loweredSearchTexts, onSearchMatchesChange, scrollToIndex, selectIndex],
|
||||
);
|
||||
|
||||
React.useImperativeHandle(
|
||||
jumpRef,
|
||||
() => ({
|
||||
jumpToIndex(index: number) {
|
||||
scrollToIndex(index);
|
||||
selectIndex(index, true);
|
||||
},
|
||||
setSearchQuery(query: string) {
|
||||
recomputeSearchMatches(query);
|
||||
},
|
||||
nextMatch() {
|
||||
const matches = searchMatchesRef.current;
|
||||
if (matches.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentSearchMatchRef.current =
|
||||
(currentSearchMatchRef.current + 1) % matches.length;
|
||||
const matchIndex =
|
||||
matches[currentSearchMatchRef.current] ?? matches[0]!;
|
||||
onSearchMatchesChange?.(
|
||||
matches.length,
|
||||
currentSearchMatchRef.current + 1,
|
||||
);
|
||||
scrollToIndex(matchIndex);
|
||||
selectIndex(matchIndex, true);
|
||||
},
|
||||
prevMatch() {
|
||||
const matches = searchMatchesRef.current;
|
||||
if (matches.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentSearchMatchRef.current =
|
||||
(currentSearchMatchRef.current - 1 + matches.length) %
|
||||
matches.length;
|
||||
const matchIndex =
|
||||
matches[currentSearchMatchRef.current] ??
|
||||
matches[matches.length - 1]!;
|
||||
onSearchMatchesChange?.(
|
||||
matches.length,
|
||||
currentSearchMatchRef.current + 1,
|
||||
);
|
||||
scrollToIndex(matchIndex);
|
||||
selectIndex(matchIndex, true);
|
||||
},
|
||||
setAnchor() {
|
||||
},
|
||||
async warmSearchIndex() {
|
||||
const start = performance.now();
|
||||
loweredSearchTexts.length;
|
||||
return Math.round(performance.now() - start);
|
||||
},
|
||||
disarmSearch() {
|
||||
currentSearchMatchRef.current = -1;
|
||||
if (searchQueryRef.current.length > 0) {
|
||||
onSearchMatchesChange?.(
|
||||
searchMatchesRef.current.length,
|
||||
0,
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[
|
||||
jumpRef,
|
||||
loweredSearchTexts.length,
|
||||
onSearchMatchesChange,
|
||||
recomputeSearchMatches,
|
||||
scrollToIndex,
|
||||
selectIndex,
|
||||
],
|
||||
);
|
||||
|
||||
React.useImperativeHandle(
|
||||
resolvedCursorNavRef,
|
||||
() => ({
|
||||
enterCursor() {
|
||||
const visibleMessageIndexes = new Set(
|
||||
visibleRows
|
||||
.map(row => row.messageIndex)
|
||||
.filter((index): index is number => typeof index === "number"),
|
||||
);
|
||||
const candidateIndexes =
|
||||
visibleMessageIndexes.size > 0
|
||||
? [
|
||||
...Array.from(visibleMessageIndexes).sort((a, b) => b - a),
|
||||
...Array.from(
|
||||
{ length: renderableMessages.length },
|
||||
(_, offset) => renderableMessages.length - offset - 1,
|
||||
),
|
||||
]
|
||||
: Array.from(
|
||||
{ length: renderableMessages.length },
|
||||
(_, offset) => renderableMessages.length - offset - 1,
|
||||
);
|
||||
|
||||
for (const index of candidateIndexes) {
|
||||
if (!renderableMessages[index]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selectIndex(index, true);
|
||||
scrollToIndex(index);
|
||||
return;
|
||||
}
|
||||
},
|
||||
navigatePrev() {
|
||||
const currentIndex = messageCursor
|
||||
? renderableMessages.findIndex(message => message.id === messageCursor.id)
|
||||
: renderableMessages.length;
|
||||
|
||||
for (let index = currentIndex - 1; index >= 0; index -= 1) {
|
||||
if (!renderableMessages[index]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selectIndex(index);
|
||||
scrollToIndex(index);
|
||||
return;
|
||||
}
|
||||
},
|
||||
navigateNext() {
|
||||
const currentIndex = messageCursor
|
||||
? renderableMessages.findIndex(message => message.id === messageCursor.id)
|
||||
: -1;
|
||||
|
||||
for (let index = currentIndex + 1; index < renderableMessages.length; index += 1) {
|
||||
if (!renderableMessages[index]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selectIndex(index);
|
||||
scrollToIndex(index);
|
||||
return;
|
||||
}
|
||||
},
|
||||
navigateTop() {
|
||||
if (renderableMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectIndex(0);
|
||||
scrollToIndex(0);
|
||||
},
|
||||
navigateBottom() {
|
||||
if (renderableMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastIndex = renderableMessages.length - 1;
|
||||
selectIndex(lastIndex);
|
||||
scrollToIndex(lastIndex);
|
||||
},
|
||||
getSelected() {
|
||||
return messageCursor
|
||||
? renderableMessages.find(message => message.id === messageCursor.id) ?? null
|
||||
: null;
|
||||
},
|
||||
}),
|
||||
[
|
||||
messageCursor,
|
||||
renderableMessages,
|
||||
resolvedCursorNavRef,
|
||||
scrollToIndex,
|
||||
selectIndex,
|
||||
visibleRows,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<ScrollKeybindingHandler
|
||||
scrollRef={scrollRef}
|
||||
isActive={true}
|
||||
/>
|
||||
<MessageActionsKeybindings
|
||||
handlers={handlers}
|
||||
isActive={messageCursor !== null}
|
||||
/>
|
||||
<Box flexDirection="column">
|
||||
{renderableMessages.length === 0 ? (
|
||||
<>
|
||||
{Array.from({ length: emptyBlankRows }, (_, index) => (
|
||||
<Text key={`empty-blank:${index}`}> </Text>
|
||||
))}
|
||||
<Text color={getColor("textDim")}>
|
||||
{emptyLabel}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{Array.from({ length: leadingBlankRows }, (_, index) => (
|
||||
<Text key={`blank:${index}`}> </Text>
|
||||
))}
|
||||
{visibleRows.map(row => {
|
||||
const selected = row.messageId === messageCursor?.id;
|
||||
return (
|
||||
<Text
|
||||
key={row.key}
|
||||
color={getColor(row.colorToken) as never}
|
||||
bold={row.bold}
|
||||
dimColor={row.dim}
|
||||
backgroundColor={selected ? (getColor("bgHighlight") as never) : undefined}
|
||||
>
|
||||
{row.text.length > 0 ? row.text : " "}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{messageCursor ? (
|
||||
<MessageActionsBar cursor={messageCursor} />
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
49
apps/tui/src/components/NotificationBanner.tsx
Normal file
49
apps/tui/src/components/NotificationBanner.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import type { Notification } from "../context/notifications.js";
|
||||
import { getColor } from "./design-system/theme.js";
|
||||
|
||||
type NotificationBannerProps = {
|
||||
notification: Notification | null;
|
||||
};
|
||||
|
||||
function resolveNotificationColor(notification: Notification): string {
|
||||
if ("color" in notification && notification.color) {
|
||||
return notification.color;
|
||||
}
|
||||
|
||||
switch (notification.priority) {
|
||||
case "immediate":
|
||||
case "high":
|
||||
return getColor("error");
|
||||
case "medium":
|
||||
return getColor("warning");
|
||||
case "low":
|
||||
default:
|
||||
return getColor("primary");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNotificationText(notification: Notification): string | null {
|
||||
if ("text" in notification) {
|
||||
return notification.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function NotificationBanner({
|
||||
notification,
|
||||
}: NotificationBannerProps): React.ReactElement | null {
|
||||
if (!notification) return null;
|
||||
|
||||
const text = resolveNotificationText(notification);
|
||||
if (!text) return null;
|
||||
|
||||
const color = resolveNotificationColor(notification);
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={color} paddingX={1}>
|
||||
<Text color={color}>{text}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
10
apps/tui/src/components/PressEnterToContinue.tsx
Normal file
10
apps/tui/src/components/PressEnterToContinue.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from "react";
|
||||
import { Text } from "ink";
|
||||
|
||||
export function PressEnterToContinue(): React.ReactElement {
|
||||
return (
|
||||
<Text dimColor>
|
||||
Press Enter to continue
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
145
apps/tui/src/components/PromptInput/PromptInput.tsx
Normal file
145
apps/tui/src/components/PromptInput/PromptInput.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { useRegisterOverlay } from "../../context/overlayContext.js";
|
||||
import { useSetPromptOverlay } from "../../context/promptOverlayContext.js";
|
||||
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
||||
import type { InputMode } from "../../state/AppStateStore.js";
|
||||
import type { SandboxStatusData } from "../../bridge/protocol.js";
|
||||
import type { VimMode } from "../../types/textInputTypes.js";
|
||||
import type { CompletionItem } from "./SlashCommandComplete.js";
|
||||
import { SlashCommandComplete } from "./SlashCommandComplete.js";
|
||||
import PromptInputFooter from "./PromptInputFooter.js";
|
||||
import { PromptInputModeIndicator } from "./PromptInputModeIndicator.js";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
busy?: boolean;
|
||||
inputMode: InputMode;
|
||||
vimMode?: VimMode;
|
||||
cursorOffset?: number;
|
||||
placeholder?: string;
|
||||
suggestions?: CompletionItem[];
|
||||
selectedSuggestion?: number;
|
||||
showSuggestions?: boolean;
|
||||
renderSuggestionsInline?: boolean;
|
||||
publishSuggestionsOverlay?: boolean;
|
||||
sandbox?: SandboxStatusData;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function renderWithCursor(
|
||||
value: string,
|
||||
cursorOffset: number,
|
||||
disabled: boolean,
|
||||
): React.ReactElement {
|
||||
const boundedOffset = clamp(cursorOffset, 0, value.length);
|
||||
const before = value.slice(0, boundedOffset);
|
||||
const cursor = value[boundedOffset] ?? " ";
|
||||
const after =
|
||||
boundedOffset < value.length ? value.slice(boundedOffset + 1) : "";
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text>{before}</Text>
|
||||
<Text inverse={!disabled}>{cursor}</Text>
|
||||
<Text>{after}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PromptInput({
|
||||
value,
|
||||
disabled,
|
||||
busy = false,
|
||||
inputMode,
|
||||
vimMode,
|
||||
cursorOffset = value.length,
|
||||
placeholder,
|
||||
suggestions = [],
|
||||
selectedSuggestion = 0,
|
||||
showSuggestions = false,
|
||||
renderSuggestionsInline = true,
|
||||
publishSuggestionsOverlay = true,
|
||||
sandbox,
|
||||
}: Props): React.ReactElement {
|
||||
const terminalSize = useTerminalSize();
|
||||
const color =
|
||||
inputMode === "command"
|
||||
? "cyan"
|
||||
: vimMode === "NORMAL"
|
||||
? "yellow"
|
||||
: "green";
|
||||
const shouldRenderOverlay =
|
||||
publishSuggestionsOverlay &&
|
||||
showSuggestions &&
|
||||
!renderSuggestionsInline &&
|
||||
suggestions.length > 0;
|
||||
const emptyPlaceholder =
|
||||
placeholder ??
|
||||
(disabled ? "Waiting for the current task..." : "Type a prompt and press Enter");
|
||||
|
||||
useRegisterOverlay("autocomplete", shouldRenderOverlay);
|
||||
useSetPromptOverlay(
|
||||
shouldRenderOverlay
|
||||
? {
|
||||
suggestions,
|
||||
selectedSuggestion,
|
||||
maxColumnWidth: Math.max(24, Math.floor(terminalSize.columns * 0.7)),
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={disabled ? "gray" : (color as never)}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderBottom={false}
|
||||
paddingX={1}
|
||||
marginTop={1}
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Box alignItems="flex-start" width="100%">
|
||||
<PromptInputModeIndicator
|
||||
inputMode={inputMode}
|
||||
disabled={disabled}
|
||||
vimMode={vimMode}
|
||||
/>
|
||||
<Box flexDirection="column" flexGrow={1} flexShrink={1}>
|
||||
{value ? (
|
||||
renderWithCursor(value, cursorOffset, disabled)
|
||||
) : (
|
||||
<Text color="gray" wrap="truncate">{emptyPlaceholder}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{showSuggestions && renderSuggestionsInline ? (
|
||||
<Box marginTop={1}>
|
||||
<SlashCommandComplete
|
||||
items={suggestions}
|
||||
selectedIndex={selectedSuggestion}
|
||||
visible={showSuggestions}
|
||||
bordered={false}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<PromptInputFooter
|
||||
disabled={disabled}
|
||||
busy={busy}
|
||||
inputMode={inputMode}
|
||||
vimMode={vimMode}
|
||||
suggestionsVisible={showSuggestions}
|
||||
sandbox={sandbox}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
82
apps/tui/src/components/PromptInput/PromptInputFooter.tsx
Normal file
82
apps/tui/src/components/PromptInput/PromptInputFooter.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { useShortcutDisplay } from "../../keybindings/useShortcutDisplay.js";
|
||||
import type { InputMode } from "../../state/AppStateStore.js";
|
||||
import type { SandboxStatusData } from "../../bridge/protocol.js";
|
||||
import type { VimMode } from "../../types/textInputTypes.js";
|
||||
import { sandboxHint } from "../../utils/sandboxPromptFooter.js";
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
busy?: boolean;
|
||||
inputMode: InputMode;
|
||||
vimMode?: VimMode;
|
||||
suggestionsVisible: boolean;
|
||||
sandbox?: SandboxStatusData;
|
||||
};
|
||||
|
||||
export default function PromptInputFooter({
|
||||
disabled,
|
||||
busy = false,
|
||||
inputMode,
|
||||
vimMode,
|
||||
suggestionsVisible,
|
||||
sandbox,
|
||||
}: Props): React.ReactElement {
|
||||
const submitShortcut = useShortcutDisplay("chat:submit", "Chat", "Enter");
|
||||
const newlineShortcut = useShortcutDisplay("chat:newline", "Chat", "shift+Enter");
|
||||
const cancelShortcut = useShortcutDisplay("app:interrupt", "Global", "ctrl+c");
|
||||
const autocompleteAcceptShortcut = useShortcutDisplay(
|
||||
"autocomplete:accept",
|
||||
"Autocomplete",
|
||||
"Tab",
|
||||
);
|
||||
const autocompletePrevShortcut = useShortcutDisplay(
|
||||
"autocomplete:previous",
|
||||
"Autocomplete",
|
||||
"Up",
|
||||
);
|
||||
const autocompleteNextShortcut = useShortcutDisplay(
|
||||
"autocomplete:next",
|
||||
"Autocomplete",
|
||||
"Down",
|
||||
);
|
||||
const dismissShortcut = useShortcutDisplay(
|
||||
"chat:cancel",
|
||||
"Chat",
|
||||
"Esc",
|
||||
);
|
||||
|
||||
let hint = disabled
|
||||
? "Waiting for the current task to finish"
|
||||
: busy
|
||||
? `Task running | ${cancelShortcut} cancel`
|
||||
: `${submitShortcut} send | ${newlineShortcut} newline | ${cancelShortcut} cancel`;
|
||||
|
||||
if (!disabled && busy && inputMode === "command") {
|
||||
hint = suggestionsVisible
|
||||
? `${autocompleteAcceptShortcut} complete | ${autocompletePrevShortcut}/${autocompleteNextShortcut} select | ${cancelShortcut} cancel`
|
||||
: `Task running | ${autocompleteAcceptShortcut} complete | ${cancelShortcut} cancel`;
|
||||
} else if (!disabled && inputMode === "command") {
|
||||
hint = suggestionsVisible
|
||||
? `${autocompleteAcceptShortcut} complete | ${autocompletePrevShortcut}/${autocompleteNextShortcut} select | ${submitShortcut} run`
|
||||
: `${submitShortcut} run | ${autocompleteAcceptShortcut} complete | ${dismissShortcut} clear`;
|
||||
}
|
||||
const sandboxStatus = sandboxHint(sandbox);
|
||||
|
||||
return (
|
||||
<Box marginTop={1} width="100%" height={2} justifyContent="space-between">
|
||||
<Box flexDirection="column" flexGrow={1} flexShrink={1}>
|
||||
<Text color="gray" wrap="truncate">{hint}</Text>
|
||||
{sandboxStatus ? (
|
||||
<Text color={sandboxStatus.color} wrap="truncate">
|
||||
{sandboxStatus.text}
|
||||
</Text>
|
||||
) : (
|
||||
<Text> </Text>
|
||||
)}
|
||||
</Box>
|
||||
{vimMode ? <Text color="gray">vim {vimMode}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import type { InputMode } from "../../state/AppStateStore.js";
|
||||
import type { VimMode } from "../../types/textInputTypes.js";
|
||||
|
||||
type Props = {
|
||||
inputMode: InputMode;
|
||||
disabled: boolean;
|
||||
vimMode?: VimMode;
|
||||
};
|
||||
|
||||
export function PromptInputModeIndicator({
|
||||
inputMode,
|
||||
disabled,
|
||||
vimMode,
|
||||
}: Props): React.ReactElement {
|
||||
const prefix =
|
||||
vimMode === "NORMAL"
|
||||
? "N"
|
||||
: ">";
|
||||
const color =
|
||||
inputMode === "command"
|
||||
? "cyan"
|
||||
: vimMode === "NORMAL"
|
||||
? "yellow"
|
||||
: "green";
|
||||
|
||||
return (
|
||||
<Box marginRight={1}>
|
||||
<Text color={disabled ? "gray" : (color as never)} dimColor={disabled}>
|
||||
{prefix}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
121
apps/tui/src/components/PromptInput/SlashCommandComplete.tsx
Normal file
121
apps/tui/src/components/PromptInput/SlashCommandComplete.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { getColor } from "../design-system/theme.js";
|
||||
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
||||
import {
|
||||
stringDisplayWidth,
|
||||
truncateToDisplayWidth,
|
||||
} from "../../utils/textWidth.js";
|
||||
|
||||
export type CompletionItem = {
|
||||
name: string;
|
||||
summary: string;
|
||||
category?: string;
|
||||
};
|
||||
|
||||
type SlashCommandCompleteProps = {
|
||||
items: CompletionItem[];
|
||||
selectedIndex: number;
|
||||
visible: boolean;
|
||||
maxVisibleItems?: number;
|
||||
maxColumnWidth?: number;
|
||||
bordered?: boolean;
|
||||
};
|
||||
|
||||
function oneLine(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function padRight(value: string, width: number): string {
|
||||
const padding = Math.max(0, width - stringDisplayWidth(value));
|
||||
return `${value}${" ".repeat(padding)}`;
|
||||
}
|
||||
|
||||
export function SlashCommandComplete({
|
||||
items,
|
||||
selectedIndex,
|
||||
visible,
|
||||
maxVisibleItems,
|
||||
maxColumnWidth,
|
||||
bordered = false,
|
||||
}: SlashCommandCompleteProps): React.ReactElement | null {
|
||||
const { columns } = useTerminalSize();
|
||||
|
||||
if (!visible || items.length === 0) return null;
|
||||
|
||||
const visibleCount = Math.max(
|
||||
1,
|
||||
Math.min(items.length, maxVisibleItems ?? items.length),
|
||||
);
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(items.length - visibleCount, selectedIndex - Math.floor(visibleCount / 2)),
|
||||
);
|
||||
const visibleItems = items.slice(startIndex, startIndex + visibleCount);
|
||||
const hasMore = visibleItems.length < items.length;
|
||||
const contentWidth = Math.max(20, columns - (bordered ? 4 : 2));
|
||||
const longestNameWidth = Math.max(
|
||||
...items.map(item => stringDisplayWidth(`/${item.name}`)),
|
||||
);
|
||||
const nameColumnWidth = Math.min(
|
||||
Math.max(12, longestNameWidth + 2),
|
||||
maxColumnWidth ?? Math.max(12, Math.floor(contentWidth * 0.4)),
|
||||
);
|
||||
const summaryWidth = Math.max(0, contentWidth - nameColumnWidth - 5);
|
||||
const list = (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{visibleItems.map((item, offset) => {
|
||||
const absoluteIndex = startIndex + offset;
|
||||
const isSelected = absoluteIndex === selectedIndex;
|
||||
const commandName = truncateToDisplayWidth(`/${item.name}`, nameColumnWidth - 1);
|
||||
const paddedName = padRight(commandName, nameColumnWidth);
|
||||
const summary = summaryWidth > 0
|
||||
? truncateToDisplayWidth(oneLine(item.summary), summaryWidth)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Text key={item.name} wrap="truncate">
|
||||
<Text
|
||||
color={isSelected ? getColor("primary") : getColor("text")}
|
||||
bold={isSelected}
|
||||
dimColor={!isSelected}
|
||||
>
|
||||
{isSelected ? "> " : " "}
|
||||
{paddedName}
|
||||
</Text>
|
||||
{summary ? (
|
||||
<Text
|
||||
color={isSelected ? getColor("primary") : getColor("textDim")}
|
||||
dimColor={!isSelected}
|
||||
>
|
||||
{" - "}
|
||||
{summary}
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasMore ? (
|
||||
<Text color={getColor("textDim")} wrap="truncate">
|
||||
Showing {startIndex + 1}-{startIndex + visibleItems.length} of{" "}
|
||||
{items.length}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return bordered ? (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="single"
|
||||
borderColor={getColor("borderFocused")}
|
||||
paddingX={1}
|
||||
width="100%"
|
||||
>
|
||||
{list}
|
||||
</Box>
|
||||
) : (
|
||||
list
|
||||
);
|
||||
}
|
||||
7
apps/tui/src/components/PromptInput/index.ts
Normal file
7
apps/tui/src/components/PromptInput/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export { default as PromptInput } from "./PromptInput.js";
|
||||
export { PromptInputModeIndicator } from "./PromptInputModeIndicator.js";
|
||||
export { default as PromptInputFooter } from "./PromptInputFooter.js";
|
||||
export {
|
||||
SlashCommandComplete,
|
||||
type CompletionItem,
|
||||
} from "./SlashCommandComplete.js";
|
||||
5
apps/tui/src/components/PromptInput/inputModes.ts
Normal file
5
apps/tui/src/components/PromptInput/inputModes.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import type { InputMode } from "../../state/AppStateStore.js";
|
||||
|
||||
export function getModeFromInput(value: string): InputMode {
|
||||
return value.trimStart().startsWith("/") ? "command" : "insert";
|
||||
}
|
||||
94
apps/tui/src/components/ScrollKeybindingHandler.tsx
Normal file
94
apps/tui/src/components/ScrollKeybindingHandler.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import React from "react";
|
||||
import { useKeybindings } from "../keybindings/useKeybinding.js";
|
||||
import type { ScrollBoxHandle } from "../ink/components/ScrollBox.js";
|
||||
|
||||
type Props = {
|
||||
scrollRef?: React.RefObject<ScrollBoxHandle | null>;
|
||||
isActive: boolean;
|
||||
onScroll?: (sticky: boolean, handle: ScrollBoxHandle) => void;
|
||||
};
|
||||
|
||||
function withHandle(
|
||||
scrollRef: React.RefObject<ScrollBoxHandle | null> | undefined,
|
||||
run: (handle: ScrollBoxHandle) => void,
|
||||
): void {
|
||||
const handle = scrollRef?.current;
|
||||
if (!handle) {
|
||||
return;
|
||||
}
|
||||
|
||||
run(handle);
|
||||
}
|
||||
|
||||
export function ScrollKeybindingHandler({
|
||||
scrollRef,
|
||||
isActive,
|
||||
onScroll,
|
||||
}: Props): null {
|
||||
const scrollBy = React.useCallback(
|
||||
(delta: number) => {
|
||||
withHandle(scrollRef, handle => {
|
||||
handle.scrollBy(delta);
|
||||
onScroll?.(handle.isSticky(), handle);
|
||||
});
|
||||
},
|
||||
[onScroll, scrollRef],
|
||||
);
|
||||
|
||||
const pageSize = React.useCallback(
|
||||
(handle: ScrollBoxHandle): number =>
|
||||
Math.max(1, handle.getViewportHeight() - 2),
|
||||
[],
|
||||
);
|
||||
|
||||
const handlers = React.useMemo(
|
||||
() => ({
|
||||
"scroll:pageUp": () => {
|
||||
withHandle(scrollRef, handle => {
|
||||
handle.scrollBy(-pageSize(handle));
|
||||
onScroll?.(handle.isSticky(), handle);
|
||||
});
|
||||
},
|
||||
"scroll:pageDown": () => {
|
||||
withHandle(scrollRef, handle => {
|
||||
handle.scrollBy(pageSize(handle));
|
||||
onScroll?.(handle.isSticky(), handle);
|
||||
});
|
||||
},
|
||||
"scroll:top": () => {
|
||||
withHandle(scrollRef, handle => {
|
||||
handle.scrollTo(0);
|
||||
onScroll?.(handle.isSticky(), handle);
|
||||
});
|
||||
},
|
||||
"scroll:bottom": () => {
|
||||
withHandle(scrollRef, handle => {
|
||||
handle.scrollToBottom();
|
||||
onScroll?.(handle.isSticky(), handle);
|
||||
});
|
||||
},
|
||||
"scroll:wheelUp": () => {
|
||||
scrollBy(-3);
|
||||
},
|
||||
"scroll:wheelDown": () => {
|
||||
scrollBy(3);
|
||||
},
|
||||
}),
|
||||
[onScroll, pageSize, scrollBy, scrollRef],
|
||||
);
|
||||
|
||||
useKeybindings(handlers, {
|
||||
context: "Chat",
|
||||
isActive,
|
||||
});
|
||||
useKeybindings(handlers, {
|
||||
context: "MessageActions",
|
||||
isActive,
|
||||
});
|
||||
useKeybindings(handlers, {
|
||||
context: "Transcript",
|
||||
isActive,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
43
apps/tui/src/components/Spinner.tsx
Normal file
43
apps/tui/src/components/Spinner.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React from "react";
|
||||
import { Text } from "ink";
|
||||
|
||||
const FRAMES = ["-", "\\", "|", "/"];
|
||||
|
||||
type SpinnerWithVerbProps = {
|
||||
active: boolean;
|
||||
message?: string;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export function SpinnerWithVerb({
|
||||
active,
|
||||
message = "Query running",
|
||||
color = "yellow",
|
||||
}: SpinnerWithVerbProps): React.ReactElement | null {
|
||||
const [index, setIndex] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
setIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setIndex(current => (current + 1) % FRAMES.length);
|
||||
}, 90);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text color={color as never}>
|
||||
{FRAMES[index]} {message}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
81
apps/tui/src/components/StatusBar.tsx
Normal file
81
apps/tui/src/components/StatusBar.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { getColor } from "./design-system/theme.js";
|
||||
import type { RuntimeState, TaskState } from "../state/AppStateStore.js";
|
||||
|
||||
type StatusBarProps = {
|
||||
runtime: RuntimeState;
|
||||
isQuerying: boolean;
|
||||
tasks?: Record<string, TaskState>;
|
||||
};
|
||||
|
||||
function formatUsd(cost: number | undefined): string {
|
||||
if (cost === undefined || Number.isNaN(cost)) return "—";
|
||||
return `$${cost.toFixed(4)}`;
|
||||
}
|
||||
|
||||
function formatTokens(value: number | undefined): string {
|
||||
if (value === undefined || Number.isNaN(value)) return "—";
|
||||
return value.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
function formatActiveTaskPill(tasks: Record<string, TaskState>): string | null {
|
||||
const running = Object.values(tasks).filter(t => t.status === "running");
|
||||
if (running.length === 0) return null;
|
||||
if (running.length === 1) return running[0]!.title ?? running[0]!.id;
|
||||
return `${running.length} tasks`;
|
||||
}
|
||||
|
||||
export function StatusBar({
|
||||
runtime,
|
||||
isQuerying,
|
||||
tasks,
|
||||
}: StatusBarProps): React.ReactElement {
|
||||
const taskPill = tasks ? formatActiveTaskPill(tasks) : null;
|
||||
const phaseColor = isQuerying ? getColor("warning") : getColor("muted");
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={getColor("primary")} bold>
|
||||
Model:{" "}
|
||||
</Text>
|
||||
<Text>{runtime.model ?? "n/a"}</Text>
|
||||
<Text color={getColor("muted")}> │ </Text>
|
||||
<Text color={getColor("primary")} bold>
|
||||
Session:{" "}
|
||||
</Text>
|
||||
<Text>{runtime.sessionId ? runtime.sessionId.slice(0, 12) : "n/a"}</Text>
|
||||
<Text color={getColor("muted")}> │ </Text>
|
||||
<Text color={getColor("primary")} bold>
|
||||
Cost:{" "}
|
||||
</Text>
|
||||
<Text>{formatUsd(runtime.costUsd)}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={getColor("primary")} bold>
|
||||
Tokens:{" "}
|
||||
</Text>
|
||||
<Text>
|
||||
{formatTokens(runtime.inputTokens)} in / {formatTokens(runtime.outputTokens)} out
|
||||
</Text>
|
||||
<Text color={getColor("muted")}> │ </Text>
|
||||
<Text color={phaseColor} bold>
|
||||
{isQuerying ? "● " : "○ "}
|
||||
</Text>
|
||||
<Text color={phaseColor}>{runtime.phase ?? "idle"}</Text>
|
||||
{runtime.totalIterations !== undefined && runtime.maxIterations !== undefined ? (
|
||||
<Text color={getColor("muted")}>
|
||||
{" "}({runtime.totalIterations}/{runtime.maxIterations})
|
||||
</Text>
|
||||
) : null}
|
||||
{taskPill ? (
|
||||
<>
|
||||
<Text color={getColor("muted")}> │ </Text>
|
||||
<Text color={getColor("accent")}>{taskPill}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
94
apps/tui/src/components/StatusLine.tsx
Normal file
94
apps/tui/src/components/StatusLine.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import type {
|
||||
AgentRuntimeState,
|
||||
MCPClientState,
|
||||
RuntimeState,
|
||||
} from "../state/AppStateStore.js";
|
||||
import type { VimMode } from "../types/textInputTypes.js";
|
||||
import { formatTokens, formatUsd } from "../screens/shared.js";
|
||||
import { CoordinatorStatusBar } from "./CoordinatorStatusBar.js";
|
||||
|
||||
type Props = {
|
||||
runtime: RuntimeState;
|
||||
mcpClientStates?: MCPClientState[];
|
||||
agents?: AgentRuntimeState;
|
||||
vimMode?: VimMode;
|
||||
};
|
||||
|
||||
function formatSandboxSummary(runtime: RuntimeState): string {
|
||||
const sandbox = runtime.sandbox;
|
||||
if (!sandbox) {
|
||||
return "n/a";
|
||||
}
|
||||
if (sandbox.sandboxing_enabled) {
|
||||
return sandbox.mode === "auto-allow"
|
||||
? "on auto"
|
||||
: sandbox.mode === "regular"
|
||||
? "on regular"
|
||||
: "on";
|
||||
}
|
||||
if (sandbox.enabled_in_settings) {
|
||||
return sandbox.status === "fail" ? "fail" : "warn";
|
||||
}
|
||||
return "off";
|
||||
}
|
||||
|
||||
export function StatusLine({
|
||||
runtime,
|
||||
mcpClientStates = [],
|
||||
agents,
|
||||
vimMode,
|
||||
}: Props): React.ReactElement {
|
||||
const connectedMcp = mcpClientStates.filter(
|
||||
client => client.status === "connected",
|
||||
).length;
|
||||
const failingMcp = mcpClientStates.filter(
|
||||
client => client.status === "error",
|
||||
).length;
|
||||
const tokenWarning = runtime.tokenWarning;
|
||||
const showTokenWarning =
|
||||
tokenWarning?.is_above_warning_threshold === true;
|
||||
const tokenWarningColor =
|
||||
tokenWarning?.is_above_error_threshold === true ||
|
||||
tokenWarning?.is_at_blocking_limit === true
|
||||
? "red"
|
||||
: "yellow";
|
||||
const tokenWarningText = tokenWarning
|
||||
? tokenWarning.is_above_auto_compact_threshold
|
||||
? `Context compacting (${tokenWarning.percent_left}% left)`
|
||||
: `Context low (${tokenWarning.percent_left}% left)`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height={4} overflowY="hidden">
|
||||
<Text bold color="cyan">
|
||||
OpenSpace | {runtime.model ?? "model n/a"} | {runtime.phase ?? "idle"} | Cost{" "}
|
||||
{formatUsd(runtime.costUsd)}
|
||||
</Text>
|
||||
<Text color="gray">
|
||||
Session {runtime.sessionId ?? "n/a"} | Task {runtime.activeTaskId ?? "n/a"} | Tokens{" "}
|
||||
{formatTokens(runtime.inputTokens)} / {formatTokens(runtime.outputTokens)} |{" "}
|
||||
Iterations {runtime.totalIterations ?? 0}
|
||||
{runtime.maxIterations !== undefined ? ` / ${runtime.maxIterations}` : ""} | MCP{" "}
|
||||
{connectedMcp}/{mcpClientStates.length}
|
||||
{failingMcp > 0 ? ` (${failingMcp} error)` : ""}
|
||||
{" | "}Sandbox {formatSandboxSummary(runtime)}
|
||||
{vimMode ? ` | Vim ${vimMode}` : ""}
|
||||
</Text>
|
||||
<Box height={1}>
|
||||
{showTokenWarning && tokenWarningText ? (
|
||||
<Text color={tokenWarningColor as never}>{tokenWarningText}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box height={1}>
|
||||
{agents ? (
|
||||
<CoordinatorStatusBar
|
||||
coordinator={agents.coordinator}
|
||||
backgroundTasks={agents.backgroundTasks}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
572
apps/tui/src/components/VirtualMessageList.tsx
Normal file
572
apps/tui/src/components/VirtualMessageList.tsx
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
import React from "react";
|
||||
import { Box } from "ink";
|
||||
import { ScrollChromeContext } from "./FullscreenLayout.js";
|
||||
import {
|
||||
InVirtualListContext,
|
||||
isNavigableMessage as defaultIsNavigableMessage,
|
||||
MessageActionsSelectedContext,
|
||||
type MessageActionsNav,
|
||||
type MessageActionsState,
|
||||
} from "./messageActions.js";
|
||||
import type { AppMessage } from "../state/AppStateStore.js";
|
||||
import type { ScrollBoxHandle } from "../ink/components/ScrollBox.js";
|
||||
import { getMessageText } from "../screens/shared.js";
|
||||
|
||||
const WINDOW_OVERSCAN_ROWS = 6;
|
||||
const STICKY_PROMPT_CAP = 180;
|
||||
const SCROLL_HEADROOM = 2;
|
||||
|
||||
export type JumpHandle = {
|
||||
jumpToIndex: (index: number) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
nextMatch: () => void;
|
||||
prevMatch: () => void;
|
||||
setAnchor: () => void;
|
||||
warmSearchIndex: () => Promise<number>;
|
||||
disarmSearch: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
messages: AppMessage[];
|
||||
scrollRef?: React.RefObject<ScrollBoxHandle | null>;
|
||||
maxRows: number;
|
||||
itemKey: (message: AppMessage) => string;
|
||||
estimateItemHeight: (message: AppMessage, index: number) => number;
|
||||
renderItem: (message: AppMessage, index: number) => React.ReactNode;
|
||||
isItemNavigable?: (message: AppMessage) => boolean;
|
||||
cursor: MessageActionsState | null;
|
||||
setCursor: React.Dispatch<
|
||||
React.SetStateAction<MessageActionsState | null>
|
||||
>;
|
||||
cursorNavRef?: React.Ref<MessageActionsNav | null>;
|
||||
jumpRef?: React.Ref<JumpHandle | null>;
|
||||
extractSearchText?: (message: AppMessage) => string;
|
||||
onSearchMatchesChange?: (count: number, current: number) => void;
|
||||
trackStickyPrompt?: boolean;
|
||||
};
|
||||
|
||||
function VerticalSpacer({
|
||||
rows,
|
||||
}: {
|
||||
rows: number;
|
||||
}): React.ReactElement | null {
|
||||
if (rows <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Box height={rows} flexShrink={0} />;
|
||||
}
|
||||
|
||||
function findStartIndex(
|
||||
offsets: number[],
|
||||
heights: number[],
|
||||
topRow: number,
|
||||
): number {
|
||||
let low = 0;
|
||||
let high = offsets.length - 1;
|
||||
let result = offsets.length;
|
||||
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
const rowTop = offsets[middle] ?? 0;
|
||||
const rowBottom = rowTop + (heights[middle] ?? 0);
|
||||
|
||||
if (rowBottom > topRow) {
|
||||
result = middle;
|
||||
high = middle - 1;
|
||||
} else {
|
||||
low = middle + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function findEndIndex(
|
||||
offsets: number[],
|
||||
heights: number[],
|
||||
bottomRow: number,
|
||||
): number {
|
||||
let low = 0;
|
||||
let high = offsets.length - 1;
|
||||
let result = offsets.length;
|
||||
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
const rowTop = offsets[middle] ?? 0;
|
||||
|
||||
if (rowTop >= bottomRow) {
|
||||
result = middle;
|
||||
high = middle - 1;
|
||||
} else if (rowTop + (heights[middle] ?? 0) > bottomRow) {
|
||||
result = middle + 1;
|
||||
high = middle - 1;
|
||||
} else {
|
||||
low = middle + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function clampIndex(index: number, total: number): number {
|
||||
return Math.max(0, Math.min(total - 1, index));
|
||||
}
|
||||
|
||||
function createStickyPromptText(
|
||||
message: AppMessage,
|
||||
): string | null {
|
||||
if (message.role !== "user") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = getMessageText(message).trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (text.length <= STICKY_PROMPT_CAP) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, STICKY_PROMPT_CAP - 1)}…`;
|
||||
}
|
||||
|
||||
export function VirtualMessageList({
|
||||
messages,
|
||||
scrollRef,
|
||||
maxRows,
|
||||
itemKey,
|
||||
estimateItemHeight,
|
||||
renderItem,
|
||||
isItemNavigable = defaultIsNavigableMessage,
|
||||
cursor,
|
||||
setCursor,
|
||||
cursorNavRef,
|
||||
jumpRef,
|
||||
extractSearchText,
|
||||
onSearchMatchesChange,
|
||||
trackStickyPrompt = false,
|
||||
}: Props): React.ReactElement {
|
||||
const scrollChrome = React.useContext(ScrollChromeContext);
|
||||
const [, forceWindowRefresh] = React.useReducer(
|
||||
(value: number) => value + 1,
|
||||
0,
|
||||
);
|
||||
const unsubscribeRef = React.useRef<(() => void) | null>(null);
|
||||
const subscribedHandleRef =
|
||||
React.useRef<ScrollBoxHandle | null>(null);
|
||||
const searchQueryRef = React.useRef("");
|
||||
const searchAnchorRef = React.useRef<number | null>(null);
|
||||
const searchMatchesRef = React.useRef<number[]>([]);
|
||||
const currentSearchMatchRef = React.useRef<number>(-1);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const handle = scrollRef?.current ?? null;
|
||||
if (handle === subscribedHandleRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
unsubscribeRef.current?.();
|
||||
subscribedHandleRef.current = handle;
|
||||
unsubscribeRef.current =
|
||||
handle?.subscribe(() => {
|
||||
forceWindowRefresh();
|
||||
}) ?? null;
|
||||
|
||||
return () => {
|
||||
unsubscribeRef.current?.();
|
||||
unsubscribeRef.current = null;
|
||||
subscribedHandleRef.current = null;
|
||||
};
|
||||
});
|
||||
|
||||
const rowHeights = React.useMemo(
|
||||
() =>
|
||||
messages.map((message, index) =>
|
||||
estimateItemHeight(message, index),
|
||||
),
|
||||
[estimateItemHeight, messages],
|
||||
);
|
||||
const rowOffsets = React.useMemo(() => {
|
||||
const offsets: number[] = [];
|
||||
let total = 0;
|
||||
|
||||
for (const height of rowHeights) {
|
||||
offsets.push(total);
|
||||
total += height;
|
||||
}
|
||||
|
||||
return offsets;
|
||||
}, [rowHeights]);
|
||||
const totalRows = rowHeights.reduce(
|
||||
(sum, height) => sum + height,
|
||||
0,
|
||||
);
|
||||
const scrollHandle = scrollRef?.current ?? null;
|
||||
const viewportRows =
|
||||
scrollHandle?.getViewportHeight() ??
|
||||
Math.max(1, maxRows);
|
||||
const maxScrollTop = Math.max(0, totalRows - viewportRows);
|
||||
const shouldStickToBottom = scrollHandle?.isSticky() ?? true;
|
||||
const scrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
maxScrollTop,
|
||||
shouldStickToBottom
|
||||
? maxScrollTop
|
||||
: (scrollHandle?.getScrollTop() ?? maxScrollTop) +
|
||||
(scrollHandle?.getPendingDelta() ?? 0),
|
||||
),
|
||||
);
|
||||
const windowTop = scrollTop;
|
||||
const windowBottom = scrollTop + viewportRows + WINDOW_OVERSCAN_ROWS;
|
||||
const startIndex = findStartIndex(rowOffsets, rowHeights, windowTop);
|
||||
const endIndex = Math.max(
|
||||
startIndex,
|
||||
findEndIndex(rowOffsets, rowHeights, windowBottom),
|
||||
);
|
||||
const visibleRows = rowHeights
|
||||
.slice(startIndex, endIndex)
|
||||
.reduce((sum, height) => sum + height, 0);
|
||||
const topSpacerRows =
|
||||
startIndex < rowOffsets.length ? (rowOffsets[startIndex] ?? 0) : totalRows;
|
||||
const bottomSpacerRows = Math.max(
|
||||
0,
|
||||
totalRows - topSpacerRows - visibleRows,
|
||||
);
|
||||
const visibleMessages = messages.slice(startIndex, endIndex);
|
||||
const loweredSearchTexts = React.useMemo(
|
||||
() =>
|
||||
messages.map(message =>
|
||||
(extractSearchText?.(message) ?? getMessageText(message)).toLowerCase(),
|
||||
),
|
||||
[extractSearchText, messages],
|
||||
);
|
||||
|
||||
const scrollToIndex = React.useCallback(
|
||||
(index: number) => {
|
||||
const handle = scrollRef?.current;
|
||||
if (!handle || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boundedIndex = clampIndex(index, messages.length);
|
||||
const targetTop =
|
||||
Math.max(0, (rowOffsets[boundedIndex] ?? 0) - SCROLL_HEADROOM);
|
||||
handle.scrollTo(targetTop);
|
||||
},
|
||||
[messages.length, rowOffsets, scrollRef],
|
||||
);
|
||||
|
||||
const selectIndex = React.useCallback(
|
||||
(index: number, preserveExpanded = false) => {
|
||||
if (messages.length === 0) {
|
||||
setCursor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const boundedIndex = clampIndex(index, messages.length);
|
||||
const message = messages[boundedIndex];
|
||||
if (!message) {
|
||||
setCursor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setCursor(prev => ({
|
||||
id: message.id,
|
||||
expanded: preserveExpanded && prev?.id === message.id ? prev.expanded : false,
|
||||
}));
|
||||
},
|
||||
[messages, setCursor],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!cursor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!messages.some(message => message.id === cursor.id)) {
|
||||
setCursor(null);
|
||||
}
|
||||
}, [cursor, messages, setCursor]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!scrollChrome) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trackStickyPrompt) {
|
||||
scrollChrome.setStickyPrompt(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollTop <= 0) {
|
||||
scrollChrome.setStickyPrompt(null);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let index = Math.max(0, startIndex - 1); index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (!message) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const promptText = createStickyPromptText(message);
|
||||
if (!promptText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
scrollChrome.setStickyPrompt({
|
||||
text: promptText,
|
||||
scrollTo: () => {
|
||||
scrollToIndex(index);
|
||||
selectIndex(index, true);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
scrollChrome.setStickyPrompt(null);
|
||||
}, [
|
||||
messages,
|
||||
scrollChrome,
|
||||
scrollToIndex,
|
||||
scrollTop,
|
||||
selectIndex,
|
||||
startIndex,
|
||||
trackStickyPrompt,
|
||||
]);
|
||||
|
||||
const recomputeSearchMatches = React.useCallback(
|
||||
(query: string) => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
searchQueryRef.current = normalized;
|
||||
|
||||
if (!normalized) {
|
||||
searchMatchesRef.current = [];
|
||||
currentSearchMatchRef.current = -1;
|
||||
onSearchMatchesChange?.(0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const matches: number[] = [];
|
||||
loweredSearchTexts.forEach((text, index) => {
|
||||
if (text.includes(normalized)) {
|
||||
matches.push(index);
|
||||
}
|
||||
});
|
||||
|
||||
searchMatchesRef.current = matches;
|
||||
currentSearchMatchRef.current = matches.length > 0 ? 0 : -1;
|
||||
onSearchMatchesChange?.(
|
||||
matches.length,
|
||||
matches.length > 0 ? 1 : 0,
|
||||
);
|
||||
|
||||
if (matches.length > 0) {
|
||||
const matchIndex = matches[0]!;
|
||||
scrollToIndex(matchIndex);
|
||||
selectIndex(matchIndex, true);
|
||||
}
|
||||
},
|
||||
[loweredSearchTexts, onSearchMatchesChange, scrollToIndex, selectIndex],
|
||||
);
|
||||
|
||||
React.useImperativeHandle(
|
||||
jumpRef,
|
||||
() => ({
|
||||
jumpToIndex(index: number) {
|
||||
scrollToIndex(index);
|
||||
selectIndex(index, true);
|
||||
},
|
||||
setSearchQuery(query: string) {
|
||||
recomputeSearchMatches(query);
|
||||
},
|
||||
nextMatch() {
|
||||
const matches = searchMatchesRef.current;
|
||||
if (matches.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentSearchMatchRef.current =
|
||||
(currentSearchMatchRef.current + 1) % matches.length;
|
||||
const matchIndex =
|
||||
matches[currentSearchMatchRef.current] ?? matches[0]!;
|
||||
onSearchMatchesChange?.(
|
||||
matches.length,
|
||||
currentSearchMatchRef.current + 1,
|
||||
);
|
||||
scrollToIndex(matchIndex);
|
||||
selectIndex(matchIndex, true);
|
||||
},
|
||||
prevMatch() {
|
||||
const matches = searchMatchesRef.current;
|
||||
if (matches.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentSearchMatchRef.current =
|
||||
(currentSearchMatchRef.current - 1 + matches.length) %
|
||||
matches.length;
|
||||
const matchIndex =
|
||||
matches[currentSearchMatchRef.current] ??
|
||||
matches[matches.length - 1]!;
|
||||
onSearchMatchesChange?.(
|
||||
matches.length,
|
||||
currentSearchMatchRef.current + 1,
|
||||
);
|
||||
scrollToIndex(matchIndex);
|
||||
selectIndex(matchIndex, true);
|
||||
},
|
||||
setAnchor() {
|
||||
searchAnchorRef.current =
|
||||
scrollRef?.current?.getScrollTop() ?? null;
|
||||
},
|
||||
async warmSearchIndex() {
|
||||
const start = performance.now();
|
||||
loweredSearchTexts.length;
|
||||
return Math.round(performance.now() - start);
|
||||
},
|
||||
disarmSearch() {
|
||||
currentSearchMatchRef.current = -1;
|
||||
if (searchQueryRef.current.length > 0) {
|
||||
onSearchMatchesChange?.(
|
||||
searchMatchesRef.current.length,
|
||||
0,
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[
|
||||
jumpRef,
|
||||
loweredSearchTexts.length,
|
||||
onSearchMatchesChange,
|
||||
recomputeSearchMatches,
|
||||
scrollRef,
|
||||
scrollToIndex,
|
||||
selectIndex,
|
||||
],
|
||||
);
|
||||
|
||||
React.useImperativeHandle(
|
||||
cursorNavRef,
|
||||
() => ({
|
||||
enterCursor() {
|
||||
const candidateIndexes =
|
||||
scrollTop > 0
|
||||
? [
|
||||
...Array.from(
|
||||
{ length: Math.max(0, endIndex - startIndex) },
|
||||
(_, offset) => startIndex + offset,
|
||||
),
|
||||
...Array.from(
|
||||
{ length: startIndex },
|
||||
(_, offset) => startIndex - offset - 1,
|
||||
),
|
||||
]
|
||||
: Array.from(
|
||||
{ length: messages.length },
|
||||
(_, offset) => messages.length - offset - 1,
|
||||
);
|
||||
|
||||
for (const index of candidateIndexes) {
|
||||
if (isItemNavigable(messages[index]!)) {
|
||||
selectIndex(index, true);
|
||||
scrollToIndex(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
navigatePrev() {
|
||||
const currentIndex = cursor
|
||||
? messages.findIndex(message => message.id === cursor.id)
|
||||
: messages.length;
|
||||
|
||||
for (let index = currentIndex - 1; index >= 0; index -= 1) {
|
||||
if (!isItemNavigable(messages[index]!)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selectIndex(index);
|
||||
scrollToIndex(index);
|
||||
return;
|
||||
}
|
||||
},
|
||||
navigateNext() {
|
||||
const currentIndex = cursor
|
||||
? messages.findIndex(message => message.id === cursor.id)
|
||||
: -1;
|
||||
|
||||
for (let index = currentIndex + 1; index < messages.length; index += 1) {
|
||||
if (!isItemNavigable(messages[index]!)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selectIndex(index);
|
||||
scrollToIndex(index);
|
||||
return;
|
||||
}
|
||||
},
|
||||
navigateTop() {
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
if (!isItemNavigable(messages[index]!)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selectIndex(index);
|
||||
scrollToIndex(index);
|
||||
return;
|
||||
}
|
||||
},
|
||||
navigateBottom() {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (!isItemNavigable(messages[index]!)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selectIndex(index);
|
||||
scrollToIndex(index);
|
||||
return;
|
||||
}
|
||||
},
|
||||
getSelected() {
|
||||
return cursor
|
||||
? messages.find(message => message.id === cursor.id) ?? null
|
||||
: null;
|
||||
},
|
||||
}),
|
||||
[
|
||||
cursor,
|
||||
cursorNavRef,
|
||||
endIndex,
|
||||
isItemNavigable,
|
||||
messages,
|
||||
scrollTop,
|
||||
scrollToIndex,
|
||||
selectIndex,
|
||||
startIndex,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<InVirtualListContext.Provider value={true}>
|
||||
<Box flexDirection="column">
|
||||
<VerticalSpacer rows={topSpacerRows} />
|
||||
{visibleMessages.map((message, index) => {
|
||||
const absoluteIndex = startIndex + index;
|
||||
const isSelected = cursor?.id === message.id;
|
||||
return (
|
||||
<MessageActionsSelectedContext.Provider
|
||||
key={itemKey(message)}
|
||||
value={Boolean(isSelected)}
|
||||
>
|
||||
{renderItem(message, absoluteIndex)}
|
||||
</MessageActionsSelectedContext.Provider>
|
||||
);
|
||||
})}
|
||||
<VerticalSpacer rows={bottomSpacerRows} />
|
||||
</Box>
|
||||
</InVirtualListContext.Provider>
|
||||
);
|
||||
}
|
||||
22
apps/tui/src/components/design-system/Pane.tsx
Normal file
22
apps/tui/src/components/design-system/Pane.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import React from "react";
|
||||
import { Box } from "ink";
|
||||
import { getColor } from "./theme.js";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function Pane({
|
||||
children,
|
||||
}: Props): React.ReactElement {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={getColor("border")}
|
||||
paddingX={1}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
32
apps/tui/src/components/design-system/ThemedBox.tsx
Normal file
32
apps/tui/src/components/design-system/ThemedBox.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React from "react";
|
||||
import { Box, type DOMElement } from "ink";
|
||||
import { getColor, getSpacing, type ColorToken, type ThemeSpacing } from "./theme.js";
|
||||
|
||||
type InkBoxProps = React.ComponentProps<typeof Box>;
|
||||
|
||||
type ThemedBoxProps = Omit<InkBoxProps, "borderColor" | "paddingX" | "paddingY" | "marginTop" | "marginBottom"> & {
|
||||
borderToken?: ColorToken;
|
||||
paddingSize?: keyof ThemeSpacing;
|
||||
marginTopSize?: keyof ThemeSpacing;
|
||||
marginBottomSize?: keyof ThemeSpacing;
|
||||
ref?: React.Ref<DOMElement>;
|
||||
};
|
||||
|
||||
export function ThemedBox({
|
||||
borderToken,
|
||||
paddingSize,
|
||||
marginTopSize,
|
||||
marginBottomSize,
|
||||
children,
|
||||
...rest
|
||||
}: ThemedBoxProps): React.ReactElement {
|
||||
const props: InkBoxProps = {
|
||||
...rest,
|
||||
...(borderToken ? { borderColor: getColor(borderToken) } : {}),
|
||||
...(paddingSize ? { paddingX: getSpacing(paddingSize) } : {}),
|
||||
...(marginTopSize ? { marginTop: getSpacing(marginTopSize) } : {}),
|
||||
...(marginBottomSize ? { marginBottom: getSpacing(marginBottomSize) } : {}),
|
||||
};
|
||||
|
||||
return <Box {...props}>{children}</Box>;
|
||||
}
|
||||
25
apps/tui/src/components/design-system/ThemedText.tsx
Normal file
25
apps/tui/src/components/design-system/ThemedText.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import React from "react";
|
||||
import { Text } from "ink";
|
||||
import { getColor, type ColorToken } from "./theme.js";
|
||||
|
||||
type InkTextProps = React.ComponentProps<typeof Text>;
|
||||
|
||||
type ThemedTextProps = Omit<InkTextProps, "color"> & {
|
||||
colorToken?: ColorToken;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export function ThemedText({
|
||||
colorToken,
|
||||
color,
|
||||
children,
|
||||
...rest
|
||||
}: ThemedTextProps): React.ReactElement {
|
||||
const resolvedColor = colorToken ? getColor(colorToken) : color;
|
||||
|
||||
return (
|
||||
<Text color={resolvedColor} {...rest}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
15
apps/tui/src/components/design-system/index.ts
Normal file
15
apps/tui/src/components/design-system/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export {
|
||||
type ColorToken,
|
||||
type Theme,
|
||||
type ThemeColors,
|
||||
type ThemeSpacing,
|
||||
THEMES,
|
||||
getTheme,
|
||||
setTheme,
|
||||
getColor,
|
||||
getSpacing,
|
||||
} from "./theme.js";
|
||||
|
||||
export { ThemedBox } from "./ThemedBox.js";
|
||||
export { ThemedText } from "./ThemedText.js";
|
||||
export { Pane } from "./Pane.js";
|
||||
143
apps/tui/src/components/design-system/theme.ts
Normal file
143
apps/tui/src/components/design-system/theme.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
export type ColorToken =
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "accent"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "muted"
|
||||
| "text"
|
||||
| "textDim"
|
||||
| "textInverse"
|
||||
| "border"
|
||||
| "borderFocused"
|
||||
| "borderDim"
|
||||
| "bg"
|
||||
| "bgHighlight"
|
||||
| "bgOverlay"
|
||||
| "userMessage"
|
||||
| "assistantMessage"
|
||||
| "toolMessage"
|
||||
| "systemMessage"
|
||||
| "statusMessage"
|
||||
| "errorMessage"
|
||||
| "spinner"
|
||||
| "permissionBorder"
|
||||
| "elicitationBorder"
|
||||
| "inputBorder"
|
||||
| "inputBorderFocused"
|
||||
| "inputBorderDisabled";
|
||||
|
||||
export type ThemeColors = Record<ColorToken, string>;
|
||||
|
||||
export type ThemeSpacing = {
|
||||
none: 0;
|
||||
xs: 1;
|
||||
sm: 1;
|
||||
md: 2;
|
||||
lg: 3;
|
||||
xl: 4;
|
||||
};
|
||||
|
||||
export type Theme = {
|
||||
name: string;
|
||||
colors: ThemeColors;
|
||||
spacing: ThemeSpacing;
|
||||
};
|
||||
|
||||
const DARK_COLORS: ThemeColors = {
|
||||
primary: "cyan",
|
||||
secondary: "blue",
|
||||
accent: "magenta",
|
||||
success: "green",
|
||||
warning: "yellow",
|
||||
error: "red",
|
||||
muted: "gray",
|
||||
text: "white",
|
||||
textDim: "gray",
|
||||
textInverse: "black",
|
||||
border: "gray",
|
||||
borderFocused: "cyan",
|
||||
borderDim: "gray",
|
||||
bg: "",
|
||||
bgHighlight: "gray",
|
||||
bgOverlay: "",
|
||||
userMessage: "cyan",
|
||||
assistantMessage: "green",
|
||||
toolMessage: "magenta",
|
||||
systemMessage: "gray",
|
||||
statusMessage: "blue",
|
||||
errorMessage: "red",
|
||||
spinner: "yellow",
|
||||
permissionBorder: "yellow",
|
||||
elicitationBorder: "magenta",
|
||||
inputBorder: "green",
|
||||
inputBorderFocused: "green",
|
||||
inputBorderDisabled: "gray",
|
||||
};
|
||||
|
||||
const LIGHT_COLORS: ThemeColors = {
|
||||
primary: "blue",
|
||||
secondary: "cyan",
|
||||
accent: "magenta",
|
||||
success: "green",
|
||||
warning: "yellow",
|
||||
error: "red",
|
||||
muted: "gray",
|
||||
text: "black",
|
||||
textDim: "gray",
|
||||
textInverse: "white",
|
||||
border: "gray",
|
||||
borderFocused: "blue",
|
||||
borderDim: "gray",
|
||||
bg: "",
|
||||
bgHighlight: "gray",
|
||||
bgOverlay: "",
|
||||
userMessage: "blue",
|
||||
assistantMessage: "green",
|
||||
toolMessage: "magenta",
|
||||
systemMessage: "gray",
|
||||
statusMessage: "cyan",
|
||||
errorMessage: "red",
|
||||
spinner: "yellow",
|
||||
permissionBorder: "yellow",
|
||||
elicitationBorder: "magenta",
|
||||
inputBorder: "green",
|
||||
inputBorderFocused: "blue",
|
||||
inputBorderDisabled: "gray",
|
||||
};
|
||||
|
||||
const SPACING: ThemeSpacing = {
|
||||
none: 0,
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 2,
|
||||
lg: 3,
|
||||
xl: 4,
|
||||
};
|
||||
|
||||
export const THEMES: Record<string, Theme> = {
|
||||
dark: { name: "dark", colors: DARK_COLORS, spacing: SPACING },
|
||||
light: { name: "light", colors: LIGHT_COLORS, spacing: SPACING },
|
||||
};
|
||||
|
||||
let activeTheme: Theme = THEMES.dark!;
|
||||
|
||||
export function getTheme(): Theme {
|
||||
return activeTheme;
|
||||
}
|
||||
|
||||
export function setTheme(name: string): void {
|
||||
const theme = THEMES[name];
|
||||
if (theme) {
|
||||
activeTheme = theme;
|
||||
}
|
||||
}
|
||||
|
||||
export function getColor(token: ColorToken): string {
|
||||
return activeTheme.colors[token];
|
||||
}
|
||||
|
||||
export function getSpacing(size: keyof ThemeSpacing): number {
|
||||
return activeTheme.spacing[size];
|
||||
}
|
||||
125
apps/tui/src/components/diff/DiffView.tsx
Normal file
125
apps/tui/src/components/diff/DiffView.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { getColor } from "../design-system/theme.js";
|
||||
|
||||
export type DiffLine = {
|
||||
type: "add" | "remove" | "context" | "header";
|
||||
content: string;
|
||||
oldLineNo?: number;
|
||||
newLineNo?: number;
|
||||
};
|
||||
|
||||
export type DiffHunk = {
|
||||
header: string;
|
||||
lines: DiffLine[];
|
||||
};
|
||||
|
||||
export type DiffFile = {
|
||||
path: string;
|
||||
hunks: DiffHunk[];
|
||||
isBinary?: boolean;
|
||||
isNew?: boolean;
|
||||
isDeleted?: boolean;
|
||||
isRenamed?: { from: string; to: string };
|
||||
};
|
||||
|
||||
type DiffViewProps = {
|
||||
file: DiffFile;
|
||||
maxLines?: number;
|
||||
collapsed?: boolean;
|
||||
};
|
||||
|
||||
function lineTypeColor(type: DiffLine["type"]): string {
|
||||
switch (type) {
|
||||
case "add":
|
||||
return getColor("success");
|
||||
case "remove":
|
||||
return getColor("error");
|
||||
case "header":
|
||||
return getColor("primary");
|
||||
case "context":
|
||||
default:
|
||||
return getColor("text");
|
||||
}
|
||||
}
|
||||
|
||||
function linePrefix(type: DiffLine["type"]): string {
|
||||
switch (type) {
|
||||
case "add":
|
||||
return "+";
|
||||
case "remove":
|
||||
return "-";
|
||||
case "header":
|
||||
return "@";
|
||||
case "context":
|
||||
default:
|
||||
return " ";
|
||||
}
|
||||
}
|
||||
|
||||
function formatLineNo(n: number | undefined, width: number): string {
|
||||
if (n === undefined) return " ".repeat(width);
|
||||
return String(n).padStart(width);
|
||||
}
|
||||
|
||||
export function DiffView({
|
||||
file,
|
||||
maxLines,
|
||||
collapsed,
|
||||
}: DiffViewProps): React.ReactElement {
|
||||
const allLines = file.hunks.flatMap(hunk => [
|
||||
{ type: "header" as const, content: hunk.header },
|
||||
...hunk.lines,
|
||||
]);
|
||||
|
||||
const visibleLines = maxLines ? allLines.slice(0, maxLines) : allLines;
|
||||
const truncated = maxLines !== undefined && allLines.length > maxLines;
|
||||
const lineNoWidth = Math.max(
|
||||
3,
|
||||
String(Math.max(...allLines.map(l => l.newLineNo ?? l.oldLineNo ?? 0))).length,
|
||||
);
|
||||
|
||||
const label = file.isNew
|
||||
? "(new file)"
|
||||
: file.isDeleted
|
||||
? "(deleted)"
|
||||
: file.isRenamed
|
||||
? `(renamed from ${file.isRenamed.from})`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={getColor("primary")} bold>
|
||||
{collapsed ? "▸ " : "▾ "}
|
||||
{file.path}
|
||||
</Text>
|
||||
{label ? <Text color={getColor("textDim")}> {label}</Text> : null}
|
||||
</Box>
|
||||
|
||||
{file.isBinary ? (
|
||||
<Text color={getColor("textDim")}> Binary file</Text>
|
||||
) : collapsed ? null : (
|
||||
<Box flexDirection="column" marginLeft={1}>
|
||||
{visibleLines.map((line, i) => (
|
||||
<Box key={i}>
|
||||
<Text color={getColor("textDim")}>
|
||||
{formatLineNo(line.oldLineNo, lineNoWidth)}{" "}
|
||||
{formatLineNo(line.newLineNo, lineNoWidth)}{" "}
|
||||
</Text>
|
||||
<Text color={lineTypeColor(line.type)}>
|
||||
{linePrefix(line.type)} {line.content}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{truncated ? (
|
||||
<Text color={getColor("textDim")}>
|
||||
… {allLines.length - visibleLines.length} more lines
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
6
apps/tui/src/components/diff/index.ts
Normal file
6
apps/tui/src/components/diff/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export {
|
||||
DiffView,
|
||||
type DiffFile,
|
||||
type DiffHunk,
|
||||
type DiffLine,
|
||||
} from "./DiffView.js";
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue