mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-09-08 22:21:03 +00:00
Merge branch 'main' into main
This commit is contained in:
commit
2dba6a2916
77 changed files with 7185 additions and 918 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -28,9 +28,12 @@ build/
|
|||
.env
|
||||
!.env.example
|
||||
|
||||
# MCP files
|
||||
# MCP config files
|
||||
openspace/config/config_mcp.json
|
||||
|
||||
# Communication config files
|
||||
openspace/config/config_communication.json
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
|
||||
|
|
|
|||
64
README.md
64
README.md
|
|
@ -15,10 +15,31 @@
|
|||
[](./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.
|
||||
|
|
@ -140,6 +161,15 @@ 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
|
||||
|
|
@ -169,6 +199,18 @@ Works with any agent that supports skills (`SKILL.md`) — [Claude Code](https:/
|
|||
> [!TIP]
|
||||
> Credentials (API key, model) are **auto-detected** from your agent's config; you usually don't need to set them manually.
|
||||
|
||||
> [!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
|
||||
|
|
@ -480,6 +522,14 @@ OpenSpace/
|
|||
│ │ ├── auth.py # API key management
|
||||
│ │ └── cli/ # CLI tools (download_skill, upload_skill)
|
||||
│ │
|
||||
│ ├── 💬 communication/ # Multi-Channel Communication Gateway
|
||||
│ │ ├── gateway.py # Message routing, session management, reply dispatch
|
||||
│ │ ├── 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
|
||||
│ │
|
||||
│ ├── 🔧 platform/ # Platform abstraction (system info, screenshots)
|
||||
│ ├── 🔧 host_detection/ # Auto-detect nanobot / openclaw credentials
|
||||
│ ├── 🔧 host_skills/ # SKILL.md definitions for agent integration
|
||||
|
|
@ -531,7 +581,19 @@ OpenSpace builds upon the following open-source projects. We sincerely thank the
|
|||
|
||||
<div align="center">
|
||||
|
||||
**🌟 Star us if OpenSpace helps your agent!**
|
||||
## ⭐ 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**
|
||||
|
||||
|
|
|
|||
50
README_CN.md
50
README_CN.md
|
|
@ -15,10 +15,31 @@
|
|||
[](./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) 等——能力强大,但有一个致命弱点:它们从不从真实世界的经验中**学习**、**适应**和**进化**——更不用说相互之间的**共享**了。
|
||||
|
|
@ -140,6 +161,15 @@ 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 协作者
|
||||
|
|
@ -169,6 +199,18 @@ openspace-mcp --help # 验证安装
|
|||
> [!TIP]
|
||||
> 凭证(API 密钥、模型)会从你的 Agent 配置中**自动检测**,通常无需手动设置。
|
||||
|
||||
> [!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
|
||||
|
|
@ -480,6 +522,14 @@ OpenSpace/
|
|||
│ │ ├── auth.py # API 密钥管理
|
||||
│ │ └── cli/ # CLI 工具(download_skill、upload_skill)
|
||||
│ │
|
||||
│ ├── 💬 communication/ # 多渠道通信网关
|
||||
│ │ ├── gateway.py # 消息路由、会话管理、回复分发
|
||||
│ │ ├── adapters/ # 平台适配器(WhatsApp、飞书)
|
||||
│ │ ├── bridges/ # 非 Python 运行时(WhatsApp Baileys bridge)
|
||||
│ │ ├── config.py # 通信配置加载
|
||||
│ │ ├── session_store.py # 按频道的会话持久化
|
||||
│ │ └── types.py # ChannelMessage, ChannelSource, SendResult
|
||||
│ │
|
||||
│ ├── 🔧 platform/ # 平台抽象(系统信息、截图)
|
||||
│ ├── 🔧 host_detection/ # 自动检测 nanobot / openclaw 凭证
|
||||
│ ├── 🔧 host_skills/ # 面向 Agent 集成的 SKILL.md 定义
|
||||
|
|
|
|||
BIN
assets/cli-typing.gif
Normal file
BIN
assets/cli-typing.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 436 KiB |
98
frontend/package-lock.json
generated
98
frontend/package-lock.json
generated
|
|
@ -9,9 +9,11 @@
|
|||
"version": "0.1.0",
|
||||
"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": {
|
||||
|
|
@ -272,6 +274,15 @@
|
|||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
|
|
@ -2297,6 +2308,46 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.0.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.3.tgz",
|
||||
"integrity": "sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/index-array-by": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz",
|
||||
|
|
@ -2916,6 +2967,33 @@
|
|||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.2.tgz",
|
||||
"integrity": "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.0.1",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
|
@ -3319,7 +3397,7 @@
|
|||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
||||
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
|
@ -3360,6 +3438,15 @@
|
|||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
|
@ -3473,6 +3560,15 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@
|
|||
},
|
||||
"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": {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import i18n from '../i18n';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
|
|
@ -21,7 +22,6 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// Log error to reporting service
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
}
|
||||
|
|
@ -29,25 +29,26 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||
|
||||
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">
|
||||
Something went wrong
|
||||
{t('errorBoundary.title')}
|
||||
</h1>
|
||||
<p className="text-[color:var(--color-muted)] mb-6">
|
||||
An unexpected error occurred
|
||||
{t('errorBoundary.message')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/dashboard'}
|
||||
className="btn-primary"
|
||||
>
|
||||
Go to Dashboard
|
||||
{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>Error details</summary>
|
||||
<summary>{t('errorBoundary.details')}</summary>
|
||||
<pre className="mt-2 p-4 bg-[color:var(--color-surface)] overflow-auto">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useMemo, useState, type KeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { DiffFile, DiffLine } from '../../utils/diffParser';
|
||||
|
||||
interface DiffViewerProps {
|
||||
|
|
@ -124,6 +125,7 @@ function buildSplitRows(lines: DiffLine[], header: string): SplitDiffRow[] {
|
|||
}
|
||||
|
||||
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)),
|
||||
|
|
@ -135,7 +137,7 @@ export default function DiffViewer({ files }: DiffViewerProps) {
|
|||
}, [renderableFiles]);
|
||||
|
||||
if (renderableFiles.length === 0) {
|
||||
return <p className="text-[color:var(--color-muted)] text-sm">No files in diff</p>;
|
||||
return <p className="text-[color:var(--color-muted)] text-sm">{t('diffViewer.noFiles')}</p>;
|
||||
}
|
||||
|
||||
const activeIndex = selectedIndex < renderableFiles.length ? selectedIndex : 0;
|
||||
|
|
@ -216,8 +218,8 @@ export default function DiffViewer({ files }: DiffViewerProps) {
|
|||
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>Old</div>
|
||||
<div>New</div>
|
||||
<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}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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';
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ export default function SkillEvolutionGraph({
|
|||
onNodeClick,
|
||||
onBackgroundClick,
|
||||
}: SkillEvolutionGraphProps) {
|
||||
const { t } = useTranslation();
|
||||
const graphContainerRef = useRef<HTMLDivElement>(null);
|
||||
const fgRef = useRef<any>(null);
|
||||
const [graphDim, setGraphDim] = useState({ width: 0, height: 0 });
|
||||
|
|
@ -180,7 +182,7 @@ export default function SkillEvolutionGraph({
|
|||
}, []);
|
||||
|
||||
if (graphData.nodes.length === 0) {
|
||||
return <div className="text-sm text-muted p-4">No lineage graph data.</div>;
|
||||
return <div className="text-sm text-muted p-4">{t('graph.noGraphData')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -197,9 +199,9 @@ export default function SkillEvolutionGraph({
|
|||
const graphNode = node as SkillGraphNode;
|
||||
return [
|
||||
graphNode.name,
|
||||
`score: ${graphNode.score.toFixed(1)}`,
|
||||
`generation: ${graphNode.generation}`,
|
||||
`origin: ${graphNode.origin}`,
|
||||
t('graph.tooltipScore', { value: graphNode.score.toFixed(1) }),
|
||||
t('graph.tooltipGeneration', { value: graphNode.generation }),
|
||||
t('graph.tooltipOrigin', { value: graphNode.origin }),
|
||||
].join('\n');
|
||||
}}
|
||||
onNodeClick={(node) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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';
|
||||
|
|
@ -72,6 +73,7 @@ function lockScroll() {
|
|||
}
|
||||
|
||||
export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVersionDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const rawDiff = skill?.lineage.content_diff ?? '';
|
||||
|
|
@ -135,16 +137,16 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
|
|||
<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">Skill Version</p>
|
||||
<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">
|
||||
Open as main
|
||||
{t('drawer.openAsMain')}
|
||||
</Link>
|
||||
<button type="button" onClick={onClose} ref={closeButtonRef} className="btn-outline-ink text-sm">
|
||||
Close
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -153,13 +155,13 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
|
|||
<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">Version Summary</div>
|
||||
<div className="text-sm text-muted">{skill.description || 'No description available for this version.'}</div>
|
||||
<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">gen {skill.generation}</span>
|
||||
<span className="tag px-2 py-1">{skill.is_active ? 'active' : 'inactive'}</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>
|
||||
))}
|
||||
|
|
@ -167,67 +169,67 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
|
|||
</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">version score</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">Metrics</div>
|
||||
<h3 className="text-xl font-bold font-serif mt-1">Execution quality</h3>
|
||||
<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="Effective rate" value={skill.effective_rate} colorClass="bg-primary" />
|
||||
<ProgressBar label="Completion rate" value={skill.completion_rate} colorClass="bg-accent" />
|
||||
<ProgressBar label="Applied rate" value={skill.applied_rate} colorClass="bg-teal" />
|
||||
<ProgressBar label="Fallback rate" value={skill.fallback_rate} colorClass="bg-danger" />
|
||||
<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">Selections</div><div>{skill.total_selections}</div></div>
|
||||
<div><div className="font-bold text-ink">Applied</div><div>{skill.total_applied}</div></div>
|
||||
<div><div className="font-bold text-ink">Completions</div><div>{skill.total_completions}</div></div>
|
||||
<div><div className="font-bold text-ink">Fallbacks</div><div>{skill.total_fallbacks}</div></div>
|
||||
<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">Version Metadata</h3>
|
||||
<p><strong>Origin:</strong> {skill.origin}</p>
|
||||
<p><strong>Generation:</strong> {skill.generation}</p>
|
||||
<p><strong>Visibility:</strong> {skill.visibility}</p>
|
||||
<p><strong>Created:</strong> {formatDate(skill.lineage.created_at)}</p>
|
||||
<p><strong>First seen:</strong> {formatDate(skill.first_seen)}</p>
|
||||
<p><strong>Last updated:</strong> {formatDate(skill.last_updated)}</p>
|
||||
<p><strong>Skill path:</strong> <span className="break-all">{skill.path || 'Unavailable'}</span></p>
|
||||
<p><strong>Skill dir:</strong> <span className="break-all">{skill.skill_dir || 'Unavailable'}</span></p>
|
||||
<p><strong>Parent IDs:</strong> {skill.parent_skill_ids.length ? skill.parent_skill_ids.join(', ') : 'None'}</p>
|
||||
<p><strong>Change summary:</strong> {skill.lineage.change_summary || 'None'}</p>
|
||||
<p><strong>Effective score:</strong> {formatPercent(skill.effective_rate)}</p>
|
||||
<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.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">Diff</div>
|
||||
<h3 className="text-xl font-bold font-serif mt-1">Content diff</h3>
|
||||
<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="Diff too large" description="This version has a very large content diff, so the inline viewer is disabled." />
|
||||
<EmptyState title={t('drawer.diffTooLarge')} description={t('drawer.diffTooLargeDesc')} />
|
||||
) : canShowDiff ? (
|
||||
diffFiles.length > 0 ? (
|
||||
<DiffViewer files={diffFiles} />
|
||||
) : (
|
||||
<EmptyState title="Diff unavailable" description="This version has a diff payload, but it could not be parsed as a unified diff." />
|
||||
<EmptyState title={t('drawer.diffUnavailable')} description={t('drawer.diffUnavailableDesc')} />
|
||||
)
|
||||
) : (
|
||||
<EmptyState title="No content diff" description="This version does not have a stored content diff." />
|
||||
<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">Source</div>
|
||||
<h3 className="text-xl font-bold font-serif mt-1">SKILL.md preview</h3>
|
||||
<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">
|
||||
|
|
@ -235,14 +237,14 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
|
|||
<pre className="field-surface p-4 text-xs overflow-auto max-h-[320px] whitespace-pre-wrap">{sourcePreview.content}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Source unavailable" description="This version points to a missing or unreadable SKILL.md path." />
|
||||
<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">Analyses</div>
|
||||
<h3 className="text-xl font-bold font-serif mt-1">Recent execution analyses</h3>
|
||||
<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">
|
||||
|
|
@ -252,15 +254,19 @@ export default function SkillVersionDrawer({ skill, isOpen, onClose }: SkillVers
|
|||
<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 || 'No execution note', 220)}</div>
|
||||
<div className="text-sm text-muted">{truncate(analysis.execution_note || t('drawer.noExecutionNote'), 220)}</div>
|
||||
<div className="text-xs text-muted">
|
||||
completed: {analysis.task_completed ? 'yes' : 'no'} · tool issues: {analysis.tool_issues.length} · suggestions: {analysis.evolution_suggestions.length}
|
||||
{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="No analyses yet" description="Execution analyses will appear after recorded task runs are persisted into SQLite." />
|
||||
<EmptyState title={t('drawer.noAnalysesYet')} description={t('drawer.noAnalysesDesc')} />
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SkillVersionFilterBarProps {
|
||||
originFilter: string;
|
||||
onOriginFilterChange: (value: string) => void;
|
||||
|
|
@ -15,16 +17,18 @@ export default function SkillVersionFilterBar({
|
|||
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">Origin:</label>
|
||||
<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">All Origins</option>
|
||||
<option value="all">{t('filter.allOrigins')}</option>
|
||||
{allOrigins.map((origin) => (
|
||||
<option key={origin} value={origin}>{origin}</option>
|
||||
))}
|
||||
|
|
@ -32,13 +36,13 @@ export default function SkillVersionFilterBar({
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium">Tags:</label>
|
||||
<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">All Tags</option>
|
||||
<option value="all">{t('filter.allTags')}</option>
|
||||
{allTags.map((tag) => (
|
||||
<option key={tag} value={tag}>{tag}</option>
|
||||
))}
|
||||
|
|
|
|||
253
frontend/src/i18n/en.json
Normal file
253
frontend/src/i18n/en.json
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
{
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"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",
|
||||
"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:",
|
||||
"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."
|
||||
},
|
||||
"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
frontend/src/i18n/index.ts
Normal file
34
frontend/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;
|
||||
253
frontend/src/i18n/zh.json
Normal file
253
frontend/src/i18n/zh.json
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
{
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
"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": "数据库路径",
|
||||
"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:",
|
||||
"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 后显示。"
|
||||
},
|
||||
"filter": {
|
||||
"origin": "来源:",
|
||||
"allOrigins": "所有来源",
|
||||
"tags": "标签:",
|
||||
"allTags": "所有标签"
|
||||
},
|
||||
"graph": {
|
||||
"noGraphData": "暂无谱系图数据。",
|
||||
"tooltipScore": "评分:{{value}}",
|
||||
"tooltipGeneration": "Generation:{{value}}",
|
||||
"tooltipOrigin": "Origin:{{value}}"
|
||||
},
|
||||
"diffViewer": {
|
||||
"noFiles": "Diff 中无文件",
|
||||
"old": "旧版",
|
||||
"new": "新版"
|
||||
},
|
||||
"format": {
|
||||
"noInstruction": "未捕获到指令"
|
||||
}
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
--radius-chip: 999px;
|
||||
--radius-card: 32px;
|
||||
--radius-card-sm: 18px;
|
||||
--font-serif: 'Neuton', Georgia, serif;
|
||||
--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);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
isActive
|
||||
|
|
@ -6,6 +7,12 @@ const linkClass = ({ isActive }: { isActive: boolean }) =>
|
|||
: 'hover:text-primary';
|
||||
|
||||
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">
|
||||
|
|
@ -13,17 +20,26 @@ export default function MainLayout() {
|
|||
<div className="font-bold text-3xl tracking-tighter font-serif">OpenSpace</div>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<NavLink to="/dashboard" className={linkClass}>
|
||||
Dashboard
|
||||
{t('nav.dashboard')}
|
||||
</NavLink>
|
||||
<NavLink to="/skills" className={linkClass}>
|
||||
Skills
|
||||
{t('nav.skills')}
|
||||
</NavLink>
|
||||
<NavLink to="/workflows" className={linkClass}>
|
||||
Workflows
|
||||
{t('nav.workflows')}
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted">API: `localhost:7788` · Vite: `localhost:3888`</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: `localhost:7788` · Vite: `localhost:3888`</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="app-scroll-region relative z-10 min-h-0 flex-1 overflow-auto">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './i18n';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
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);
|
||||
|
|
@ -22,7 +24,7 @@ export default function DashboardPage() {
|
|||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load overview');
|
||||
setError(err instanceof Error ? err.message : t('dashboard.failedToLoad'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
|
|
@ -34,37 +36,37 @@ export default function DashboardPage() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6 text-sm text-muted">Loading dashboard…</div>;
|
||||
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 ?? 'Dashboard unavailable'}</div>;
|
||||
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">Dashboard</h1>
|
||||
<h1 className="text-3xl font-bold font-serif">{t('dashboard.title')}</h1>
|
||||
<section className="metrics-row">
|
||||
<MetricCard label="Total Skills" value={data.skills.summary.total_skills_all} hint={`Active: ${data.skills.summary.total_skills}`} />
|
||||
<MetricCard label="Average Skill Score" value={data.skills.average_score.toFixed(1)} hint="Primary metric = effective rate × 100" />
|
||||
<MetricCard label="Workflow Sessions" value={data.workflows.total} hint={`Recorded under ${data.health.db_path.includes('.openspace') ? 'local repo' : 'workspace'}`} />
|
||||
<MetricCard label="Workflow Success" value={`${data.workflows.average_success_rate.toFixed(1)}%`} hint="Average session success rate" />
|
||||
<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">Health</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">Runtime snapshot</h2>
|
||||
<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">Status</span><span>{data.health.status}</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-muted">DB Path</span><span className="text-right break-all">{data.health.db_path}</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-muted">Workflow Count</span><span>{data.health.workflow_count}</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-muted">Built Frontend</span><span>{data.health.frontend_dist_exists ? 'yes' : 'no'}</span></div>
|
||||
<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"><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>
|
||||
|
|
@ -72,11 +74,11 @@ export default function DashboardPage() {
|
|||
<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">Skills</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">Top scored skills</h2>
|
||||
<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="No skills yet" description="Run OpenSpace tasks or sync skills into the local registry first." />
|
||||
<EmptyState title={t('dashboard.noSkillsYet')} description={t('dashboard.noSkillsDesc')} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.skills.top.map((skill) => (
|
||||
|
|
@ -84,17 +86,17 @@ export default function DashboardPage() {
|
|||
<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 || 'No description', 110)}</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">score</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>effective {formatPercent(skill.effective_rate)}</span>
|
||||
<span>applied {formatPercent(skill.applied_rate)}</span>
|
||||
<span>selections {skill.total_selections}</span>
|
||||
<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>
|
||||
))}
|
||||
|
|
@ -104,11 +106,11 @@ export default function DashboardPage() {
|
|||
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted">Workflows</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">Recent sessions</h2>
|
||||
<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="No workflow sessions" description="Recordings will appear after a task is executed with recording enabled." />
|
||||
<EmptyState title={t('dashboard.noWorkflowSessions')} description={t('dashboard.noWorkflowDesc')} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.workflows.recent.map((workflow) => (
|
||||
|
|
@ -116,16 +118,16 @@ export default function DashboardPage() {
|
|||
<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)}</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">success</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>{workflow.total_steps} steps</span>
|
||||
<span>{workflow.agent_action_count} agent actions</span>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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';
|
||||
|
|
@ -26,6 +27,7 @@ function resolveLineageGraph(skill: SkillDetail | null): SkillLineage | null {
|
|||
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);
|
||||
|
|
@ -52,7 +54,7 @@ export default function SkillDetailPage() {
|
|||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load skill class');
|
||||
setError(err instanceof Error ? err.message : t('skillDetail.failedToLoad'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
|
|
@ -68,7 +70,7 @@ export default function SkillDetailPage() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [skillId]);
|
||||
}, [skillId, t]);
|
||||
|
||||
const lineageGraph = useMemo(() => resolveLineageGraph(skillClass), [skillClass]);
|
||||
|
||||
|
|
@ -97,7 +99,7 @@ export default function SkillDetailPage() {
|
|||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setSelectedVersion(null);
|
||||
setDrawerError(err instanceof Error ? err.message : 'Failed to load selected version');
|
||||
setDrawerError(err instanceof Error ? err.message : t('skillDetail.failedToLoad'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
|
|
@ -110,7 +112,7 @@ export default function SkillDetailPage() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedVersionId, skillClass]);
|
||||
}, [selectedVersionId, skillClass, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedVersion) {
|
||||
|
|
@ -215,20 +217,20 @@ export default function SkillDetailPage() {
|
|||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6 text-sm text-muted">Loading skill detail…</div>;
|
||||
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 ?? 'Skill not found'}</div>;
|
||||
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">← Back to Skills</Link>
|
||||
<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">Skill class anchored on {skillClass.skill_id}</div>
|
||||
<div className="text-sm text-muted mt-1">{t('skillDetail.anchoredOn', { id: skillClass.skill_id })}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -236,13 +238,13 @@ export default function SkillDetailPage() {
|
|||
<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">Skill Class</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">Evolution overview</h2>
|
||||
<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 ? 'active tip' : 'inactive anchor'}</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>
|
||||
))}
|
||||
|
|
@ -250,48 +252,48 @@ export default function SkillDetailPage() {
|
|||
<span key={tag} className="tag px-2 py-1">{tag}</span>
|
||||
))}
|
||||
{classSummary.tags.length > 8 ? (
|
||||
<span className="tag px-2 py-1">+{classSummary.tags.length - 8} tags</span>
|
||||
<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">best version score</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">Skill directory</div>
|
||||
<div className="break-all">{skillClass.skill_dir || 'Unavailable'}</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">Latest version created</div>
|
||||
<div className="font-bold text-ink">{t('skillDetail.latestVersionCreated')}</div>
|
||||
<div>{formatDate(classSummary.latestCreatedAt)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-ink">Representative version</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">Representative update</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="Versions" value={classSummary.versionCount} hint={`Max generation ${classSummary.maxGeneration}`} />
|
||||
<MetricCard label="Active Versions" value={classSummary.activeCount} hint={`Origins: ${classSummary.origins.length}`} />
|
||||
<MetricCard label="Average Score" value={classSummary.averageScore.toFixed(1)} hint="Across all versions in this lineage" />
|
||||
<MetricCard label="Selections" value={classSummary.totalSelections} hint={`Representative score ${skillClass.score.toFixed(1)}`} />
|
||||
<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">Evolution Graph</div>
|
||||
<h2 className="text-2xl font-bold font-serif mt-1">Version lineage</h2>
|
||||
<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}
|
||||
|
|
@ -309,7 +311,7 @@ export default function SkillDetailPage() {
|
|||
onBackgroundClick={closeDrawer}
|
||||
/>
|
||||
{drawerLoading ? (
|
||||
<div className="absolute bottom-4 left-4 text-xs text-muted">Loading version drawer…</div>
|
||||
<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>
|
||||
|
|
@ -317,7 +319,7 @@ export default function SkillDetailPage() {
|
|||
</section>
|
||||
|
||||
{lineageGraph && lineageGraph.nodes.length === 0 ? (
|
||||
<EmptyState title="No lineage graph" description="This skill does not yet have lineage data to visualize." />
|
||||
<EmptyState title={t('skillDetail.noLineageGraph')} description={t('skillDetail.noLineageGraphDesc')} />
|
||||
) : null}
|
||||
|
||||
<SkillVersionDrawer skill={drawerVersion} isOpen={Boolean(selectedVersion)} onClose={closeDrawer} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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';
|
||||
|
|
@ -7,6 +8,7 @@ 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);
|
||||
|
|
@ -30,7 +32,7 @@ export default function SkillsPage() {
|
|||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load skills');
|
||||
setError(err instanceof Error ? err.message : t('skills.failedToLoad'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
|
|
@ -42,7 +44,7 @@ export default function SkillsPage() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sort]);
|
||||
}, [sort, t]);
|
||||
|
||||
const skillClasses = useMemo(() => buildSkillClasses(skills), [skills]);
|
||||
|
||||
|
|
@ -89,37 +91,37 @@ export default function SkillsPage() {
|
|||
<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">Skill classes</h1>
|
||||
<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="Search by name, id, description, tag, or origin"
|
||||
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">Sort by best score</option>
|
||||
<option value="updated">Sort by updated time</option>
|
||||
<option value="name">Sort by name</option>
|
||||
<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="Skill Classes" value={skillClasses.length} hint={`Versions: ${stats.total_skills_all}`} />
|
||||
<MetricCard label="Active Versions" value={totalActiveVersions} hint={`With activity: ${stats.skills_with_activity}`} />
|
||||
<MetricCard label="Average Best Score" value={averageBestScore.toFixed(1)} hint="Best node score per class" />
|
||||
<MetricCard label="Selections" value={stats.total_selections} hint={`Completions: ${stats.total_completions}`} />
|
||||
<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">Loading skills…</div> : 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="No skills match" description="Try another keyword, or execute tasks so new skill telemetry lands in SQLite." />
|
||||
<EmptyState title={t('skills.noSkillsMatch')} description={t('skills.noSkillsMatchDesc')} />
|
||||
) : null}
|
||||
|
||||
{!loading && !error && filteredClasses.length > 0 ? (
|
||||
|
|
@ -137,18 +139,18 @@ export default function SkillsPage() {
|
|||
</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">best score</div>
|
||||
<div className="text-xs text-muted">{t('skills.bestScore')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted">
|
||||
{truncate(skillClass.representative.description || 'No class description', 160)}
|
||||
{truncate(skillClass.representative.description || t('skills.noClassDescription'), 160)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 text-xs text-muted">
|
||||
<div>{skillClass.version_count} versions</div>
|
||||
<div>{skillClass.active_count} active</div>
|
||||
<div>{skillClass.total_selections} selections</div>
|
||||
<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>
|
||||
|
||||
|
|
@ -160,7 +162,7 @@ export default function SkillsPage() {
|
|||
<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">+{skillClass.tags.length - 5} tags</span>
|
||||
<span className="tag px-2 py-1">{t('common.tags', { count: skillClass.tags.length - 5 })}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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';
|
||||
|
||||
|
|
@ -48,10 +49,6 @@ function formatPercent(value: number): string {
|
|||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function pluralize(value: number, singular: string, plural = `${singular}s`): string {
|
||||
return `${value} ${value === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
function getString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
|
@ -367,6 +364,7 @@ function describeTimelineEvent(event: WorkflowTimelineEvent): TimelinePresentati
|
|||
}
|
||||
|
||||
export default function WorkflowDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const { workflowId = '' } = useParams();
|
||||
const [workflow, setWorkflow] = useState<WorkflowDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -385,7 +383,7 @@ export default function WorkflowDetailPage() {
|
|||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load workflow');
|
||||
setError(err instanceof Error ? err.message : t('workflowDetail.failedToLoad'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
|
|
@ -399,7 +397,7 @@ export default function WorkflowDetailPage() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workflowId]);
|
||||
}, [workflowId, t]);
|
||||
|
||||
const timeline = useMemo(() => {
|
||||
const events = workflow?.timeline ?? [];
|
||||
|
|
@ -464,7 +462,7 @@ export default function WorkflowDetailPage() {
|
|||
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">Loading workflow detail…</div>
|
||||
<div className="workflow-panel p-6 text-sm text-muted">{t('workflowDetail.loadingDetail')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -474,7 +472,7 @@ export default function WorkflowDetailPage() {
|
|||
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 ?? 'Workflow not found'}</div>
|
||||
<div className="workflow-panel p-6 text-sm text-danger">{error ?? t('workflowDetail.workflowNotFound')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -487,16 +485,16 @@ export default function WorkflowDetailPage() {
|
|||
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 = `${pluralize(workflow.selected_skills.length, 'skill')} selected`;
|
||||
const iterationsLabel = pluralize(workflow.iterations, 'iteration');
|
||||
const totalStepLabel = pluralize(workflow.total_steps, 'step');
|
||||
const actionCountLabel = pluralize(workflow.agent_action_count, 'agent action');
|
||||
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 = pluralize(timelineSummary.total, 'merged event');
|
||||
const timelineEventLabel = t('workflowDetail.mergedEvent', { count: timelineSummary.total });
|
||||
const statusLabel = humanizeToken(workflow.status || 'unknown');
|
||||
const selectionMethodLabel = selectionMethod ? humanizeToken(selectionMethod) : 'Not recorded';
|
||||
const selectionMethodLabel = selectionMethod ? humanizeToken(selectionMethod) : t('workflowDetail.notRecorded');
|
||||
const successRateLabel = formatPercent(workflow.success_rate);
|
||||
|
||||
return (
|
||||
|
|
@ -510,7 +508,7 @@ export default function WorkflowDetailPage() {
|
|||
to="/workflows"
|
||||
className="workflow-chip text-sm transition-colors hover:border-[color:var(--color-border-dark)] hover:text-ink"
|
||||
>
|
||||
← Back to Workflows
|
||||
{t('workflowDetail.backToWorkflows')}
|
||||
</Link>
|
||||
<WorkflowChip className={getStatusChipClasses(workflow.status)}>{statusLabel}</WorkflowChip>
|
||||
<WorkflowChip>{selectedSkillLabel}</WorkflowChip>
|
||||
|
|
@ -518,36 +516,36 @@ export default function WorkflowDetailPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="workflow-kicker">Workflow detail</div>
|
||||
<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)}
|
||||
{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">
|
||||
{`${statusLabel} run with ${iterationsLabel} and ${actionCountLabel}.`}
|
||||
{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">Started</div>
|
||||
<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">Duration</div>
|
||||
<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">Steps</div>
|
||||
<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">Latest event</div>
|
||||
<div className="workflow-kicker">{t('workflowDetail.latestEvent')}</div>
|
||||
<div className="text-base font-medium text-ink">{latestEventLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -556,24 +554,24 @@ export default function WorkflowDetailPage() {
|
|||
|
||||
<section className="workflow-metrics-row">
|
||||
<SummaryMetric
|
||||
label="Success rate"
|
||||
label={t('workflowDetail.successRate')}
|
||||
value={successRateLabel}
|
||||
hint={`${pluralize(workflow.success_count, 'successful iteration')} out of ${iterationsLabel}`}
|
||||
hint={t('workflowDetail.successRateHint', { count: workflow.success_count, iterations: iterationsLabel })}
|
||||
/>
|
||||
<SummaryMetric
|
||||
label="Iterations"
|
||||
label={t('workflowDetail.iterations')}
|
||||
value={workflow.iterations}
|
||||
hint={`${executionDurationLabel} total runtime`}
|
||||
hint={t('workflowDetail.totalRuntime', { duration: executionDurationLabel })}
|
||||
/>
|
||||
<SummaryMetric
|
||||
label="Active backends"
|
||||
label={t('workflowDetail.activeBackends')}
|
||||
value={activityEntries.length}
|
||||
hint={topBackendEntry ? `Most active ${humanizeToken(topBackendEntry[0])} · ${topBackendEntry[1]} events` : 'No recorded tool activity'}
|
||||
hint={topBackendEntry ? t('workflowDetail.mostActive', { backend: humanizeToken(topBackendEntry[0]), count: topBackendEntry[1] }) : t('workflowDetail.noRecordedActivity')}
|
||||
/>
|
||||
<SummaryMetric
|
||||
label="Timeline events"
|
||||
label={t('workflowDetail.timelineEvents')}
|
||||
value={timelineSummary.total}
|
||||
hint={`${agentActionCount} agent actions · ${toolExecutionCount} tool events`}
|
||||
hint={t('workflowDetail.agentToolHint', { agentCount: agentActionCount, toolCount: toolExecutionCount })}
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
|
|
@ -582,8 +580,8 @@ export default function WorkflowDetailPage() {
|
|||
<div className="workflow-panel p-5 space-y-4">
|
||||
{timeline.length === 0 ? (
|
||||
<QuietEmptyState
|
||||
title="No timeline data"
|
||||
description="This session does not yet contain trajectory or agent action records."
|
||||
title={t('workflowDetail.noTimelineData')}
|
||||
description={t('workflowDetail.noTimelineDesc')}
|
||||
/>
|
||||
) : (
|
||||
<div role="list" aria-label="Workflow timeline events">
|
||||
|
|
@ -677,7 +675,7 @@ export default function WorkflowDetailPage() {
|
|||
) : null}
|
||||
|
||||
<div className="workflow-soft-card p-3.5">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-muted">Raw event JSON</div>
|
||||
<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>
|
||||
|
|
@ -697,8 +695,8 @@ export default function WorkflowDetailPage() {
|
|||
<section className="workflow-panel p-5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="workflow-kicker">Selection</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">Selected skills</h2>
|
||||
<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>
|
||||
|
|
@ -718,42 +716,42 @@ export default function WorkflowDetailPage() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-lg font-semibold tracking-[-0.02em] text-ink">No selected skills</div>
|
||||
<p className="workflow-copy text-sm leading-6 text-muted">No skills were selected or recorded for this run.</p>
|
||||
<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">Session</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-ink">Overview</h2>
|
||||
<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="Task ID">
|
||||
<SidebarRow label={t('workflowDetail.taskId')}>
|
||||
<div className="break-all">{workflow.task_id}</div>
|
||||
</SidebarRow>
|
||||
|
||||
<SidebarRow label="Runtime">
|
||||
<SidebarRow label={t('workflowDetail.runtime')}>
|
||||
<div>{executionDurationLabel}</div>
|
||||
<div className="text-xs leading-6 text-muted">
|
||||
{iterationsLabel} · {totalStepLabel} · {actionCountLabel}
|
||||
</div>
|
||||
</SidebarRow>
|
||||
|
||||
<SidebarRow label="Window">
|
||||
<SidebarRow label={t('workflowDetail.window')}>
|
||||
<div>{formatDate(workflow.start_time)}</div>
|
||||
<div className="text-xs leading-6 text-muted">Ended {formatDate(workflow.end_time)}</div>
|
||||
<div className="text-xs leading-6 text-muted">{t('workflowDetail.ended', { date: formatDate(workflow.end_time) })}</div>
|
||||
</SidebarRow>
|
||||
|
||||
<SidebarRow label="Selection method">
|
||||
<SidebarRow label={t('workflowDetail.selectionMethod')}>
|
||||
<div>{selectionMethodLabel}</div>
|
||||
<div className="text-xs leading-6 text-muted">{selectedSkillLabel}</div>
|
||||
</SidebarRow>
|
||||
|
||||
{enabledBackends.length > 0 ? (
|
||||
<SidebarRow label="Enabled backends">
|
||||
<SidebarRow label={t('workflowDetail.enabledBackends')}>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{enabledBackends.map((backend) => (
|
||||
<WorkflowChip key={backend}>{humanizeToken(backend)}</WorkflowChip>
|
||||
|
|
@ -763,7 +761,7 @@ export default function WorkflowDetailPage() {
|
|||
) : null}
|
||||
|
||||
{activityEntries.length > 0 ? (
|
||||
<SidebarRow label="Backend activity">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
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);
|
||||
|
|
@ -22,7 +24,7 @@ export default function WorkflowsPage() {
|
|||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load workflows');
|
||||
setError(err instanceof Error ? err.message : t('workflows.failedToLoad'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
|
|
@ -34,7 +36,7 @@ export default function WorkflowsPage() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
|
|
@ -56,34 +58,34 @@ export default function WorkflowsPage() {
|
|||
<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">Recorded sessions</h1>
|
||||
<h1 className="text-3xl font-bold font-serif">{t('workflows.title')}</h1>
|
||||
</div>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search by task name or instruction"
|
||||
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">Workflow Sessions</div>
|
||||
<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">Scanned from logs/recordings and logs/trajectories</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">Average Success</div>
|
||||
<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">Mean success rate across sessions</div>
|
||||
<div className="text-xs text-muted">{t('workflows.meanSuccessRate')}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading ? <div className="text-sm text-muted">Loading workflows…</div> : null}
|
||||
{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="No workflow sessions" description="Run `openspace` with recording enabled, then refresh this page." />
|
||||
<EmptyState title={t('workflows.noSessions')} description={t('workflows.noSessionsDesc')} />
|
||||
) : null}
|
||||
|
||||
{!loading && !error && filtered.length > 0 ? (
|
||||
|
|
@ -96,13 +98,13 @@ export default function WorkflowsPage() {
|
|||
</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">success</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)}</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>{workflow.total_steps} steps</div>
|
||||
<div>{workflow.agent_action_count} agent actions</div>
|
||||
<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 ? (
|
||||
|
|
@ -114,7 +116,7 @@ export default function WorkflowsPage() {
|
|||
))}
|
||||
{workflow.selected_skills.length > 3 ? (
|
||||
<span className="tag px-2 py-1 text-muted">
|
||||
+{workflow.selected_skills.length - 3} more
|
||||
{t('workflows.more', { count: workflow.selected_skills.length - 3 })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -56,8 +56,9 @@ function shortenPaths(text: string, keep = 3): string {
|
|||
export function formatInstruction(
|
||||
raw: string | null | undefined,
|
||||
maxLen?: number,
|
||||
fallback = 'No instruction captured',
|
||||
): string {
|
||||
if (!raw) return 'No instruction captured';
|
||||
if (!raw) return fallback;
|
||||
|
||||
let text = shortenPaths(raw);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,24 @@
|
|||
# Copy this file to .env and fill in your keys
|
||||
# ============================================
|
||||
|
||||
# ---- LLM API Keys ----
|
||||
# At least one LLM API key is required for OpenSpace to function.
|
||||
# OpenSpace uses LiteLLM for model routing, so the key you need depends on your chosen model.
|
||||
# See https://docs.litellm.ai/docs/providers for supported providers.
|
||||
# ── LLM Credentials ──────────────────────────────────────
|
||||
#
|
||||
# OpenSpace resolves LLM credentials in this order (first match wins):
|
||||
#
|
||||
# 1. OPENSPACE_LLM_* — explicit override, always highest priority
|
||||
# 2. Provider-native vars — OPENROUTER_API_KEY, OPENAI_API_KEY, etc.
|
||||
# 3. ~/.nanobot/config.json or ~/.openclaw/openclaw.json — fallback (only when no explicit or provider key found)
|
||||
#
|
||||
# For most users, setting ONE of the provider-native keys below is enough.
|
||||
# LiteLLM reads them automatically. See https://docs.litellm.ai/docs/providers
|
||||
#
|
||||
# Full configuration guide: openspace/config/README.md
|
||||
|
||||
# --- Option A: Provider-native key (simplest) ---
|
||||
# Set the key that matches your model's provider:
|
||||
|
||||
# OpenRouter (for openrouter/* models, e.g. openrouter/anthropic/claude-sonnet-4.5)
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# Anthropic (for anthropic/claude-* models)
|
||||
# ANTHROPIC_API_KEY=
|
||||
|
|
@ -14,8 +28,22 @@
|
|||
# OpenAI (for openai/gpt-* models)
|
||||
# OPENAI_API_KEY=
|
||||
|
||||
# OpenRouter (for openrouter/* models, e.g. openrouter/anthropic/claude-sonnet-4.5)
|
||||
OPENROUTER_API_KEY=
|
||||
# DeepSeek (for deepseek/* models)
|
||||
# DEEPSEEK_API_KEY=
|
||||
|
||||
# --- Option B: Explicit OpenSpace override (takes priority over Option A) ---
|
||||
# Use these when you need full control, e.g. custom API base or non-standard provider.
|
||||
|
||||
# OPENSPACE_MODEL=openrouter/anthropic/claude-sonnet-4.5
|
||||
# OPENSPACE_LLM_API_KEY=sk-xxx
|
||||
# OPENSPACE_LLM_API_BASE=https://openrouter.ai/api/v1
|
||||
|
||||
# --- Option C: Local Ollama ---
|
||||
# For ollama/* models, set OPENSPACE_MODEL and the local Ollama endpoint.
|
||||
#
|
||||
# OPENSPACE_MODEL=ollama/qwen3-coder:30b
|
||||
# OLLAMA_API_BASE=http://127.0.0.1:11434
|
||||
# OLLAMA_API_KEY=ollama
|
||||
|
||||
# ── OpenSpace Cloud (optional) ──────────────────────────────
|
||||
# Register at https://open-space.cloud to get your key.
|
||||
|
|
@ -23,28 +51,25 @@ OPENROUTER_API_KEY=
|
|||
|
||||
OPENSPACE_API_KEY=sk_xxxxxxxxxxxxxxxx
|
||||
|
||||
# ---- GUI Backend (Anthropic Computer Use) ----
|
||||
# Required only if using the GUI backend. Uses the same ANTHROPIC_API_KEY above.
|
||||
|
||||
# ── GUI Backend (optional) ──────────────────────────────────
|
||||
# Required only if using the GUI backend (Anthropic Computer Use).
|
||||
# Uses the same ANTHROPIC_API_KEY above.
|
||||
# Optional backup key for rate limit fallback:
|
||||
# ANTHROPIC_API_KEY_BACKUP=
|
||||
|
||||
# ---- Web Backend (Deep Research) ----
|
||||
# Required only if using the Web backend for deep research.
|
||||
# Uses OpenRouter API by default:
|
||||
# OPENROUTER_API_KEY=
|
||||
|
||||
# ---- Embedding (Optional) ----
|
||||
# ── Embedding (optional) ────────────────────────────────────
|
||||
# For remote embedding API instead of local model.
|
||||
# If not set, OpenSpace uses a local embedding model (BAAI/bge-small-en-v1.5).
|
||||
# EMBEDDING_BASE_URL=
|
||||
# EMBEDDING_API_KEY=
|
||||
# EMBEDDING_MODEL= "openai/text-embedding-3-small"
|
||||
# EMBEDDING_MODEL=openai/text-embedding-3-small
|
||||
|
||||
# ---- E2B Sandbox (Optional) ----
|
||||
# ── E2B Sandbox (optional) ──────────────────────────────────
|
||||
# Required only if sandbox mode is enabled in security config.
|
||||
# E2B_API_KEY=
|
||||
|
||||
# ---- Local Server (Optional) ----
|
||||
# ── Local Server (optional) ─────────────────────────────────
|
||||
# Override the default local server URL (default: http://127.0.0.1:5000)
|
||||
# Useful for remote VM integration (e.g., OSWorld).
|
||||
# LOCAL_SERVER_URL=http://127.0.0.1:5000
|
||||
|
|
|
|||
|
|
@ -158,6 +158,43 @@ def _create_argument_parser() -> argparse.ArgumentParser:
|
|||
'--config', '-c', type=str,
|
||||
help='MCP configuration file path'
|
||||
)
|
||||
|
||||
communication_parser = subparsers.add_parser(
|
||||
'communication',
|
||||
help='Run the communication gateway'
|
||||
)
|
||||
communication_parser.add_argument(
|
||||
'--config',
|
||||
type=str,
|
||||
dest='communication_config',
|
||||
help='Communication configuration file path'
|
||||
)
|
||||
communication_subparsers = communication_parser.add_subparsers(
|
||||
dest='communication_command',
|
||||
help='Communication gateway commands'
|
||||
)
|
||||
communication_run_parser = communication_subparsers.add_parser(
|
||||
'run',
|
||||
help='Start the communication gateway'
|
||||
)
|
||||
communication_run_parser.add_argument(
|
||||
'--config',
|
||||
type=str,
|
||||
dest='communication_config',
|
||||
help='Communication configuration file path'
|
||||
)
|
||||
communication_health_parser = communication_subparsers.add_parser(
|
||||
'health',
|
||||
help='Check the communication gateway health endpoint'
|
||||
)
|
||||
communication_health_parser.add_argument(
|
||||
'--config',
|
||||
type=str,
|
||||
dest='communication_config',
|
||||
help='Communication configuration file path'
|
||||
)
|
||||
communication_health_parser.add_argument('--host', type=str, default=None)
|
||||
communication_health_parser.add_argument('--port', type=int, default=None)
|
||||
|
||||
# Basic arguments (for run mode)
|
||||
parser.add_argument('--config', '-c', type=str, help='Configuration file path (JSON format)')
|
||||
|
|
@ -321,16 +358,47 @@ async def refresh_mcp_cache(config_path: Optional[str] = None):
|
|||
|
||||
def _load_config(args) -> OpenSpaceConfig:
|
||||
"""Load configuration"""
|
||||
import os
|
||||
from openspace.host_detection import (
|
||||
build_grounding_config_path,
|
||||
build_llm_kwargs,
|
||||
load_runtime_env,
|
||||
)
|
||||
|
||||
load_runtime_env()
|
||||
|
||||
cli_overrides = {}
|
||||
if args.model:
|
||||
cli_overrides['llm_model'] = args.model
|
||||
if args.max_iterations is not None:
|
||||
cli_overrides['grounding_max_iterations'] = args.max_iterations
|
||||
if args.timeout is not None:
|
||||
cli_overrides['llm_timeout'] = args.timeout
|
||||
if args.log_level:
|
||||
cli_overrides['log_level'] = args.log_level
|
||||
|
||||
|
||||
# Resolve LLM model & credentials
|
||||
# CLI --model > OPENSPACE_MODEL env > host-agent auto-detect > default
|
||||
env_model = args.model or os.environ.get("OPENSPACE_MODEL", "")
|
||||
model, llm_kwargs = build_llm_kwargs(env_model)
|
||||
cli_overrides['llm_model'] = model
|
||||
cli_overrides['llm_kwargs'] = llm_kwargs
|
||||
|
||||
max_iter = int(os.environ.get("OPENSPACE_MAX_ITERATIONS", "20"))
|
||||
enable_rec = os.environ.get("OPENSPACE_ENABLE_RECORDING", "true").lower() in ("true", "1", "yes")
|
||||
backend_scope_raw = os.environ.get("OPENSPACE_BACKEND_SCOPE")
|
||||
backend_scope = (
|
||||
[b.strip() for b in backend_scope_raw.split(",") if b.strip()]
|
||||
if backend_scope_raw else None
|
||||
)
|
||||
config_path = build_grounding_config_path()
|
||||
|
||||
if 'grounding_max_iterations' not in cli_overrides:
|
||||
cli_overrides['grounding_max_iterations'] = max_iter
|
||||
cli_overrides['enable_recording'] = enable_rec
|
||||
if backend_scope is not None:
|
||||
cli_overrides['backend_scope'] = backend_scope
|
||||
if config_path:
|
||||
cli_overrides['grounding_config_path'] = config_path
|
||||
|
||||
try:
|
||||
# Load from config file if provided
|
||||
if args.config:
|
||||
|
|
@ -338,18 +406,17 @@ def _load_config(args) -> OpenSpaceConfig:
|
|||
with open(args.config, 'r', encoding='utf-8') as f:
|
||||
config_dict = json.load(f)
|
||||
|
||||
# Apply CLI overrides
|
||||
# Apply CLI / env overrides
|
||||
config_dict.update(cli_overrides)
|
||||
config = OpenSpaceConfig(**config_dict)
|
||||
|
||||
print(f"✓ Loaded from config file: {args.config}")
|
||||
else:
|
||||
# Use default config + CLI overrides
|
||||
config = OpenSpaceConfig(**cli_overrides)
|
||||
print("✓ Using default configuration")
|
||||
|
||||
if cli_overrides:
|
||||
print(f"✓ CLI overrides: {', '.join(cli_overrides.keys())}")
|
||||
if args.model:
|
||||
print(f"✓ CLI overrides: llm_model")
|
||||
|
||||
if args.log_level:
|
||||
Logger.set_level(args.log_level)
|
||||
|
|
@ -414,6 +481,20 @@ async def main():
|
|||
if args.command == 'refresh-cache':
|
||||
await refresh_mcp_cache(args.config)
|
||||
return 0
|
||||
if args.command == 'communication':
|
||||
from openspace.communication.gateway import main as communication_main
|
||||
|
||||
communication_argv = []
|
||||
if args.communication_config:
|
||||
communication_argv.extend(['--config', args.communication_config])
|
||||
if args.communication_command:
|
||||
communication_argv.append(args.communication_command)
|
||||
if args.communication_command == 'health':
|
||||
if args.host:
|
||||
communication_argv.extend(['--host', args.host])
|
||||
if args.port is not None:
|
||||
communication_argv.extend(['--port', str(args.port)])
|
||||
return await communication_main(communication_argv)
|
||||
|
||||
# Load configuration
|
||||
config = _load_config(args)
|
||||
|
|
@ -470,4 +551,4 @@ def run_main():
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_main()
|
||||
run_main()
|
||||
|
|
|
|||
|
|
@ -5,8 +5,15 @@ import json
|
|||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
from openspace.agents.base import BaseAgent
|
||||
from openspace.agents.message_utils import (
|
||||
ITERATION_GUIDANCE_PREFIX,
|
||||
build_channel_context_message,
|
||||
cap_message_content,
|
||||
normalize_external_history,
|
||||
truncate_messages,
|
||||
)
|
||||
from openspace.agents.visual_analyzer import VisualAnalyzer
|
||||
from openspace.grounding.core.types import BackendType, ToolResult
|
||||
from openspace.platform.screenshot import ScreenshotClient
|
||||
from openspace.prompts import GroundingAgentPrompts
|
||||
from openspace.utils.logging import Logger
|
||||
|
||||
|
|
@ -20,6 +27,7 @@ logger = Logger.get_logger(__name__)
|
|||
|
||||
|
||||
class GroundingAgent(BaseAgent):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "GroundingAgent",
|
||||
|
|
@ -58,9 +66,12 @@ class GroundingAgent(BaseAgent):
|
|||
|
||||
self._system_prompt = system_prompt or self._default_system_prompt()
|
||||
self._max_iterations = max_iterations
|
||||
self._visual_analysis_timeout = visual_analysis_timeout
|
||||
self._tool_retrieval_llm = tool_retrieval_llm
|
||||
self._visual_analysis_model = visual_analysis_model
|
||||
self._visual_analyzer = VisualAnalyzer(
|
||||
llm_client=llm_client,
|
||||
visual_analysis_model=visual_analysis_model,
|
||||
visual_analysis_timeout=visual_analysis_timeout,
|
||||
)
|
||||
|
||||
# Skill context injection (set externally before process())
|
||||
self._skill_context: Optional[str] = None
|
||||
|
|
@ -75,7 +86,7 @@ class GroundingAgent(BaseAgent):
|
|||
logger.info(f"Grounding Agent initialized: {name}")
|
||||
logger.info(f"Backend scope: {self._backend_scope}")
|
||||
logger.info(f"Max iterations: {self._max_iterations}")
|
||||
logger.info(f"Visual analysis timeout: {self._visual_analysis_timeout}s")
|
||||
logger.info(f"Visual analysis timeout: {visual_analysis_timeout}s")
|
||||
if tool_retrieval_llm:
|
||||
logger.info(f"Tool retrieval model: {tool_retrieval_llm.model}")
|
||||
if visual_analysis_model:
|
||||
|
|
@ -119,82 +130,6 @@ class GroundingAgent(BaseAgent):
|
|||
count = len(registry.list_skills())
|
||||
logger.info(f"Skill registry attached ({count} skill(s) available for mid-iteration retrieval)")
|
||||
|
||||
_MAX_SINGLE_CONTENT_CHARS = 30_000
|
||||
|
||||
@classmethod
|
||||
def _cap_message_content(cls, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Truncate oversized individual message contents in-place.
|
||||
|
||||
Targets tool-result messages and assistant messages that can
|
||||
carry enormous file contents (read_file on large CSVs/scripts).
|
||||
System messages and the first user instruction are never touched.
|
||||
"""
|
||||
cap = cls._MAX_SINGLE_CONTENT_CHARS
|
||||
trimmed = 0
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str) or len(content) <= cap:
|
||||
continue
|
||||
if msg.get("role") == "system":
|
||||
continue
|
||||
original_len = len(content)
|
||||
msg["content"] = (
|
||||
content[: cap // 2]
|
||||
+ f"\n\n... [truncated {original_len - cap:,} chars] ...\n\n"
|
||||
+ content[-(cap // 2):]
|
||||
)
|
||||
trimmed += 1
|
||||
if trimmed:
|
||||
logger.info(f"Capped {trimmed} oversized message(s) to {cap:,} chars each")
|
||||
return messages
|
||||
|
||||
def _truncate_messages(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
keep_recent: int = 8,
|
||||
max_tokens_estimate: int = 120000
|
||||
) -> List[Dict[str, Any]]:
|
||||
# First: cap any single oversized message to prevent one huge
|
||||
# tool-result from dominating the context window.
|
||||
messages = self._cap_message_content(messages)
|
||||
|
||||
if len(messages) <= keep_recent + 2: # +2 for system and initial user
|
||||
return messages
|
||||
|
||||
total_text = json.dumps(messages, ensure_ascii=False)
|
||||
estimated_tokens = len(total_text) // 4
|
||||
|
||||
if estimated_tokens < max_tokens_estimate:
|
||||
return messages
|
||||
|
||||
logger.info(f"Truncating message history: {len(messages)} messages, "
|
||||
f"~{estimated_tokens:,} tokens -> keeping recent {keep_recent} rounds")
|
||||
|
||||
system_messages = []
|
||||
user_instruction = None
|
||||
conversation_messages = []
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
if role == "system":
|
||||
system_messages.append(msg)
|
||||
elif role == "user" and user_instruction is None:
|
||||
user_instruction = msg
|
||||
else:
|
||||
conversation_messages.append(msg)
|
||||
|
||||
recent_messages = conversation_messages[-(keep_recent * 2):] if conversation_messages else []
|
||||
|
||||
truncated = system_messages.copy()
|
||||
if user_instruction:
|
||||
truncated.append(user_instruction)
|
||||
truncated.extend(recent_messages)
|
||||
|
||||
logger.info(f"After truncation: {len(truncated)} messages, "
|
||||
f"~{len(json.dumps(truncated, ensure_ascii=False))//4:,} tokens (estimated)")
|
||||
|
||||
return truncated
|
||||
|
||||
async def process(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a task execution request with multi-round iteration control.
|
||||
|
|
@ -276,6 +211,14 @@ class GroundingAgent(BaseAgent):
|
|||
tools=tools,
|
||||
)
|
||||
|
||||
async def _va_callback(
|
||||
result: ToolResult, tool_name: str, tool_call: Dict, backend: str
|
||||
) -> ToolResult:
|
||||
return await self._visual_analyzer.analyze_tool_result(
|
||||
result, tool_name, tool_call, backend,
|
||||
task_description=instruction,
|
||||
)
|
||||
|
||||
try:
|
||||
while current_iteration < max_iterations:
|
||||
current_iteration += 1
|
||||
|
|
@ -295,15 +238,15 @@ class GroundingAgent(BaseAgent):
|
|||
# Cap oversized individual messages every iteration to prevent
|
||||
# a single huge tool result from ballooning all subsequent calls.
|
||||
if current_iteration >= 2:
|
||||
messages = self._cap_message_content(messages)
|
||||
messages = cap_message_content(messages)
|
||||
|
||||
# Truncate message history to prevent context length issues
|
||||
# Start truncating after 5 iterations to keep context manageable
|
||||
if current_iteration >= 5:
|
||||
messages = self._truncate_messages(
|
||||
messages,
|
||||
messages = truncate_messages(
|
||||
messages,
|
||||
keep_recent=8,
|
||||
max_tokens_estimate=120000
|
||||
max_tokens_estimate=120000,
|
||||
)
|
||||
|
||||
messages_input_snapshot = copy.deepcopy(messages)
|
||||
|
|
@ -325,7 +268,7 @@ class GroundingAgent(BaseAgent):
|
|||
tools=tools if context.get("auto_execute", True) else None,
|
||||
execute_tools=context.get("auto_execute", True),
|
||||
summary_prompt=None, # Disabled
|
||||
tool_result_callback=self._visual_analysis_callback
|
||||
tool_result_callback=_va_callback,
|
||||
)
|
||||
|
||||
# Update messages with LLM response
|
||||
|
|
@ -349,7 +292,7 @@ class GroundingAgent(BaseAgent):
|
|||
f"Tool results: {len(tool_results_this_iteration)}, "
|
||||
f"Content length: {len(assistant_content)} chars")
|
||||
|
||||
if len(assistant_content) > 0:
|
||||
if len(assistant_content.strip()) > 0:
|
||||
logger.info(f"Iteration {current_iteration} - Assistant content preview: {repr(assistant_content[:300])}")
|
||||
consecutive_empty_responses = 0 # Reset counter on valid response
|
||||
else:
|
||||
|
|
@ -414,13 +357,19 @@ class GroundingAgent(BaseAgent):
|
|||
|
||||
# Remove previous iteration guidance to avoid accumulation
|
||||
messages = [
|
||||
msg for msg in messages
|
||||
if not (msg.get("role") == "system" and "Iteration" in msg.get("content", "") and "complete" in msg.get("content", ""))
|
||||
msg for msg in messages
|
||||
if not (
|
||||
isinstance(msg.get("content"), str)
|
||||
and msg.get("content", "").startswith(ITERATION_GUIDANCE_PREFIX)
|
||||
)
|
||||
]
|
||||
|
||||
# MiniMax rejects system messages injected mid-conversation,
|
||||
# so runtime guidance is sent as an internal user note.
|
||||
guidance_msg = {
|
||||
"role": "system",
|
||||
"content": f"Iteration {current_iteration} complete. "
|
||||
"role": "user",
|
||||
"content": f"{ITERATION_GUIDANCE_PREFIX}\n"
|
||||
f"Iteration {current_iteration} complete. "
|
||||
f"Check if task is finished - if yes, output {GroundingAgentPrompts.TASK_COMPLETE}. "
|
||||
f"If not, continue with next action."
|
||||
}
|
||||
|
|
@ -516,7 +465,16 @@ class GroundingAgent(BaseAgent):
|
|||
"role": "system",
|
||||
"content": artifact_msg
|
||||
})
|
||||
|
||||
|
||||
channel_context_msg = build_channel_context_message(
|
||||
context.get("channel_context")
|
||||
)
|
||||
if channel_context_msg:
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": channel_context_msg,
|
||||
})
|
||||
|
||||
# Skill injection — only active (selected) skills, full content
|
||||
if self._skill_context:
|
||||
messages.append({
|
||||
|
|
@ -524,10 +482,20 @@ class GroundingAgent(BaseAgent):
|
|||
"content": self._skill_context
|
||||
})
|
||||
logger.info(f"Injected active skill context ({len(self._active_skill_ids)} skill(s))")
|
||||
|
||||
|
||||
external_history = normalize_external_history(
|
||||
context.get("conversation_history")
|
||||
)
|
||||
if external_history:
|
||||
messages.extend(external_history)
|
||||
logger.info(
|
||||
"Injected %d external conversation message(s)",
|
||||
len(external_history),
|
||||
)
|
||||
|
||||
# User instruction
|
||||
messages.append({"role": "user", "content": instruction})
|
||||
|
||||
|
||||
return messages
|
||||
|
||||
async def _get_available_tools(self, task_description: Optional[str]) -> List:
|
||||
|
|
@ -613,218 +581,6 @@ class GroundingAgent(BaseAgent):
|
|||
)
|
||||
return all_tools
|
||||
|
||||
async def _visual_analysis_callback(
|
||||
self,
|
||||
result: ToolResult,
|
||||
tool_name: str,
|
||||
tool_call: Dict,
|
||||
backend: str
|
||||
) -> ToolResult:
|
||||
"""
|
||||
Callback for LLMClient to handle visual analysis after tool execution.
|
||||
"""
|
||||
# 1. Check if LLM requested to skip visual analysis
|
||||
skip_visual_analysis = False
|
||||
try:
|
||||
arguments = tool_call.function.arguments
|
||||
if isinstance(arguments, str):
|
||||
args = json.loads(arguments.strip() or "{}")
|
||||
else:
|
||||
args = arguments
|
||||
|
||||
if isinstance(args, dict) and args.get("skip_visual_analysis"):
|
||||
skip_visual_analysis = True
|
||||
logger.info(f"Visual analysis skipped for {tool_name} (meta-parameter set by LLM)")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not parse tool arguments: {e}")
|
||||
|
||||
# 2. If skip requested, return original result
|
||||
if skip_visual_analysis:
|
||||
return result
|
||||
|
||||
# 3. Check if this backend needs visual analysis
|
||||
if backend != "gui":
|
||||
return result
|
||||
|
||||
# 4. Check if tool has visual data
|
||||
metadata = getattr(result, 'metadata', None)
|
||||
has_screenshots = metadata and (metadata.get("screenshot") or metadata.get("screenshots"))
|
||||
|
||||
# 5. If no visual data, try to capture a screenshot
|
||||
if not has_screenshots:
|
||||
try:
|
||||
logger.info(f"No visual data from {tool_name}, capturing screenshot...")
|
||||
screenshot_client = ScreenshotClient()
|
||||
screenshot_bytes = await screenshot_client.capture()
|
||||
|
||||
if screenshot_bytes:
|
||||
# Add screenshot to result metadata
|
||||
if metadata is None:
|
||||
result.metadata = {}
|
||||
metadata = result.metadata
|
||||
metadata["screenshot"] = screenshot_bytes
|
||||
has_screenshots = True
|
||||
logger.info(f"Screenshot captured for visual analysis")
|
||||
else:
|
||||
logger.warning("Failed to capture screenshot")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error capturing screenshot: {e}")
|
||||
|
||||
# 6. If still no screenshots, return original result
|
||||
if not has_screenshots:
|
||||
logger.debug(f"No visual data available for {tool_name}")
|
||||
return result
|
||||
|
||||
# 7. Perform visual analysis
|
||||
return await self._enhance_result_with_visual_context(result, tool_name)
|
||||
|
||||
async def _enhance_result_with_visual_context(
|
||||
self,
|
||||
result: ToolResult,
|
||||
tool_name: str
|
||||
) -> ToolResult:
|
||||
"""
|
||||
Enhance tool result with visual analysis for grounding agent workflows.
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import litellm
|
||||
|
||||
try:
|
||||
metadata = getattr(result, 'metadata', None)
|
||||
if not metadata:
|
||||
return result
|
||||
|
||||
# Collect all screenshots
|
||||
screenshots_bytes = []
|
||||
|
||||
# Check for multiple screenshots first
|
||||
if metadata.get("screenshots"):
|
||||
screenshots_list = metadata["screenshots"]
|
||||
if isinstance(screenshots_list, list):
|
||||
screenshots_bytes = [s for s in screenshots_list if s]
|
||||
# Fall back to single screenshot
|
||||
elif metadata.get("screenshot"):
|
||||
screenshots_bytes = [metadata["screenshot"]]
|
||||
|
||||
if not screenshots_bytes:
|
||||
return result
|
||||
|
||||
# Select key screenshots if there are too many
|
||||
selected_screenshots = self._select_key_screenshots(screenshots_bytes, max_count=3)
|
||||
|
||||
# Convert to base64
|
||||
visual_b64_list = []
|
||||
for visual_data in selected_screenshots:
|
||||
if isinstance(visual_data, bytes):
|
||||
visual_b64_list.append(base64.b64encode(visual_data).decode('utf-8'))
|
||||
else:
|
||||
visual_b64_list.append(visual_data) # Already base64
|
||||
|
||||
# Build prompt based on number of screenshots
|
||||
num_screenshots = len(visual_b64_list)
|
||||
|
||||
prompt = GroundingAgentPrompts.visual_analysis(
|
||||
tool_name=tool_name,
|
||||
num_screenshots=num_screenshots,
|
||||
task_description=getattr(self, '_current_instruction', '')
|
||||
)
|
||||
|
||||
# Build content with text prompt + all images
|
||||
content = [{"type": "text", "text": prompt}]
|
||||
for visual_b64 in visual_b64_list:
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/png;base64,{visual_b64}"
|
||||
}
|
||||
})
|
||||
|
||||
# Use dedicated visual analysis model if configured, otherwise use main LLM model
|
||||
visual_model = self._visual_analysis_model or (self._llm_client.model if self._llm_client else "openrouter/anthropic/claude-sonnet-4.5")
|
||||
response = await asyncio.wait_for(
|
||||
litellm.acompletion(
|
||||
model=visual_model,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": content
|
||||
}],
|
||||
timeout=self._visual_analysis_timeout
|
||||
),
|
||||
timeout=self._visual_analysis_timeout + 5
|
||||
)
|
||||
|
||||
analysis = response.choices[0].message.content.strip()
|
||||
|
||||
# Inject visual analysis into content
|
||||
original_content = result.content or "(no text output)"
|
||||
enhanced_content = f"{original_content}\n\n**Visual content**: {analysis}"
|
||||
|
||||
# Create enhanced result
|
||||
enhanced_result = ToolResult(
|
||||
status=result.status,
|
||||
content=enhanced_content,
|
||||
error=result.error,
|
||||
metadata={**metadata, "visual_analyzed": True, "visual_analysis": analysis},
|
||||
execution_time=result.execution_time
|
||||
)
|
||||
|
||||
logger.info(f"Enhanced {tool_name} result with visual analysis ({num_screenshots} screenshot(s))")
|
||||
return enhanced_result
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"Visual analysis timed out for {tool_name}, returning original result")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to analyze visual content for {tool_name}: {e}")
|
||||
return result
|
||||
|
||||
def _select_key_screenshots(
|
||||
self,
|
||||
screenshots: List[bytes],
|
||||
max_count: int = 3
|
||||
) -> List[bytes]:
|
||||
"""
|
||||
Select key screenshots if there are too many.
|
||||
"""
|
||||
if len(screenshots) <= max_count:
|
||||
return screenshots
|
||||
|
||||
selected_indices = set()
|
||||
|
||||
# Always include last (final state)
|
||||
selected_indices.add(len(screenshots) - 1)
|
||||
|
||||
# If room, include first (initial state)
|
||||
if max_count >= 2:
|
||||
selected_indices.add(0)
|
||||
|
||||
# Fill remaining slots with evenly spaced middle screenshots
|
||||
remaining_slots = max_count - len(selected_indices)
|
||||
if remaining_slots > 0:
|
||||
# Calculate spacing
|
||||
available_indices = [
|
||||
i for i in range(1, len(screenshots) - 1)
|
||||
if i not in selected_indices
|
||||
]
|
||||
|
||||
if available_indices:
|
||||
step = max(1, len(available_indices) // (remaining_slots + 1))
|
||||
for i in range(remaining_slots):
|
||||
idx = min((i + 1) * step, len(available_indices) - 1)
|
||||
if idx < len(available_indices):
|
||||
selected_indices.add(available_indices[idx])
|
||||
|
||||
# Return screenshots in original order
|
||||
selected = [screenshots[i] for i in sorted(selected_indices)]
|
||||
|
||||
logger.debug(
|
||||
f"Selected {len(selected)} screenshots at indices {sorted(selected_indices)} "
|
||||
f"from total of {len(screenshots)}"
|
||||
)
|
||||
|
||||
return selected
|
||||
|
||||
def _get_workspace_path(self, context: Dict[str, Any]) -> Optional[str]:
|
||||
"""
|
||||
Get workspace directory path from context.
|
||||
|
|
@ -1209,4 +965,4 @@ class GroundingAgent(BaseAgent):
|
|||
"step": self.step,
|
||||
"instruction": instruction,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
|
|||
227
openspace/agents/message_utils.py
Normal file
227
openspace/agents/message_utils.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from openspace.prompts import GroundingAgentPrompts
|
||||
from openspace.utils.logging import Logger
|
||||
|
||||
logger = Logger.get_logger(__name__)
|
||||
|
||||
SUPPORTED_EXTERNAL_HISTORY_ROLES: Set[str] = {"user", "assistant"}
|
||||
MAX_SINGLE_CONTENT_CHARS: int = 30_000
|
||||
ITERATION_GUIDANCE_PREFIX: str = "[INTERNAL ORCHESTRATION NOTE]"
|
||||
|
||||
|
||||
def cap_message_content(
|
||||
messages: List[Dict[str, Any]],
|
||||
max_chars: int = MAX_SINGLE_CONTENT_CHARS,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Truncate oversized individual message contents in-place.
|
||||
|
||||
Targets tool-result messages and assistant messages that can
|
||||
carry enormous file contents (read_file on large CSVs/scripts).
|
||||
System messages and the first user instruction are never touched.
|
||||
"""
|
||||
trimmed = 0
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str) or len(content) <= max_chars:
|
||||
continue
|
||||
if msg.get("role") == "system":
|
||||
continue
|
||||
original_len = len(content)
|
||||
msg["content"] = (
|
||||
content[: max_chars // 2]
|
||||
+ f"\n\n... [truncated {original_len - max_chars:,} chars] ...\n\n"
|
||||
+ content[-(max_chars // 2) :]
|
||||
)
|
||||
trimmed += 1
|
||||
if trimmed:
|
||||
logger.info(f"Capped {trimmed} oversized message(s) to {max_chars:,} chars each")
|
||||
return messages
|
||||
|
||||
|
||||
def truncate_messages(
|
||||
messages: List[Dict[str, Any]],
|
||||
keep_recent: int = 8,
|
||||
max_tokens_estimate: int = 120_000,
|
||||
guidance_prefix: str = ITERATION_GUIDANCE_PREFIX,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Truncate conversation history to fit within token budget.
|
||||
|
||||
Preserves system messages and the first user instruction while
|
||||
keeping only the most recent conversation turns.
|
||||
"""
|
||||
messages = cap_message_content(messages)
|
||||
|
||||
if len(messages) <= keep_recent + 2: # +2 for system and initial user
|
||||
return messages
|
||||
|
||||
total_text = json.dumps(messages, ensure_ascii=False)
|
||||
estimated_tokens = len(total_text) // 4
|
||||
|
||||
if estimated_tokens < max_tokens_estimate:
|
||||
return messages
|
||||
|
||||
logger.info(
|
||||
f"Truncating message history: {len(messages)} messages, "
|
||||
f"~{estimated_tokens:,} tokens -> keeping recent {keep_recent} rounds"
|
||||
)
|
||||
|
||||
system_messages: List[Dict[str, Any]] = []
|
||||
user_instruction: Optional[Dict[str, Any]] = None
|
||||
conversation_messages: List[Dict[str, Any]] = []
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
if role == "system":
|
||||
system_messages.append(msg)
|
||||
elif role == "user" and user_instruction is None:
|
||||
user_instruction = msg
|
||||
else:
|
||||
conversation_messages.append(msg)
|
||||
|
||||
recent_messages = (
|
||||
conversation_messages[-(keep_recent * 2) :] if conversation_messages else []
|
||||
)
|
||||
|
||||
truncated = system_messages.copy()
|
||||
dropped = len(conversation_messages) - len(recent_messages)
|
||||
if dropped > 0:
|
||||
truncated.append(
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
f"{guidance_prefix} {dropped} earlier messages were "
|
||||
"truncated to save context. The original task instruction "
|
||||
"is preserved below."
|
||||
),
|
||||
}
|
||||
)
|
||||
if user_instruction:
|
||||
truncated.append(user_instruction)
|
||||
truncated.extend(recent_messages)
|
||||
|
||||
logger.info(
|
||||
f"After truncation: {len(truncated)} messages, "
|
||||
f"~{len(json.dumps(truncated, ensure_ascii=False)) // 4:,} tokens (estimated)"
|
||||
)
|
||||
|
||||
return truncated
|
||||
|
||||
|
||||
def normalize_external_history(
|
||||
conversation_history: Any,
|
||||
supported_roles: Set[str] = SUPPORTED_EXTERNAL_HISTORY_ROLES,
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Normalize external conversation history into ``{role, content}`` dicts."""
|
||||
if not isinstance(conversation_history, list):
|
||||
return []
|
||||
|
||||
normalized: List[Dict[str, str]] = []
|
||||
for entry in conversation_history:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
role = str(entry.get("role", "")).strip().lower()
|
||||
if role not in supported_roles:
|
||||
continue
|
||||
|
||||
content = entry.get("content")
|
||||
if isinstance(content, list):
|
||||
parts: List[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text")
|
||||
if isinstance(text, str) and text.strip():
|
||||
parts.append(text.strip())
|
||||
elif isinstance(item, str) and item.strip():
|
||||
parts.append(item.strip())
|
||||
content = "\n".join(parts).strip()
|
||||
elif content is not None:
|
||||
content = str(content).strip()
|
||||
|
||||
if not content:
|
||||
continue
|
||||
|
||||
normalized.append({"role": role, "content": content})
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def build_channel_context_message(channel_context: Any) -> Optional[str]:
|
||||
"""Build a system message describing the communication channel context."""
|
||||
if not isinstance(channel_context, dict):
|
||||
return None
|
||||
|
||||
lines = [
|
||||
"## Channel Context",
|
||||
]
|
||||
|
||||
platform = str(channel_context.get("platform", "")).strip()
|
||||
chat_type = str(channel_context.get("chat_type", "")).strip()
|
||||
chat_id = str(channel_context.get("chat_id", "")).strip()
|
||||
chat_name = str(channel_context.get("chat_name", "")).strip()
|
||||
thread_id = str(channel_context.get("thread_id", "")).strip()
|
||||
user_name = str(channel_context.get("user_name", "")).strip()
|
||||
user_id = str(channel_context.get("user_id", "")).strip()
|
||||
session_key = str(channel_context.get("session_key", "")).strip()
|
||||
message_id = str(channel_context.get("message_id", "")).strip()
|
||||
reply_to_message_id = str(channel_context.get("reply_to_message_id", "")).strip()
|
||||
reply_to_text = str(channel_context.get("reply_to_text", "")).strip()
|
||||
|
||||
if platform:
|
||||
lines.append(f"- Platform: {platform}")
|
||||
if chat_type:
|
||||
lines.append(f"- Chat type: {chat_type}")
|
||||
if chat_id:
|
||||
lines.append(f"- Chat ID: {chat_id}")
|
||||
if chat_name:
|
||||
lines.append(f"- Chat name: {chat_name}")
|
||||
if thread_id:
|
||||
lines.append(f"- Thread ID: {thread_id}")
|
||||
if user_name:
|
||||
lines.append(f"- User: {user_name}")
|
||||
elif user_id:
|
||||
lines.append(f"- User ID: {user_id}")
|
||||
if session_key:
|
||||
lines.append(f"- Session key: {session_key}")
|
||||
if message_id:
|
||||
lines.append(f"- Message ID: {message_id}")
|
||||
if reply_to_message_id:
|
||||
lines.append(f"- Reply-to message ID: {reply_to_message_id}")
|
||||
if reply_to_text:
|
||||
lines.append(f"- Reply context: {reply_to_text[:500]}")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Chat Reply Policy",
|
||||
"- If the user is making simple conversation, answer directly in natural language.",
|
||||
"- Do not call tools for greetings, acknowledgements, thanks, or brief "
|
||||
"clarifications that can be answered from the current context.",
|
||||
f"- When you reply directly without tools, include "
|
||||
f"`{GroundingAgentPrompts.TASK_COMPLETE}` at the end of your response.",
|
||||
]
|
||||
)
|
||||
|
||||
attachments = channel_context.get("attachments")
|
||||
if isinstance(attachments, list) and attachments:
|
||||
lines.append("- Attachments:")
|
||||
for attachment in attachments:
|
||||
if not isinstance(attachment, dict):
|
||||
continue
|
||||
path = str(attachment.get("path", "")).strip()
|
||||
if not path:
|
||||
continue
|
||||
kind = str(attachment.get("kind", "file")).strip() or "file"
|
||||
name = str(attachment.get("name", "")).strip()
|
||||
label = f"{kind}: {path}"
|
||||
if name:
|
||||
label += f" ({name})"
|
||||
lines.append(f" - {label}")
|
||||
|
||||
if len(lines) == 1:
|
||||
return None
|
||||
|
||||
return "\n".join(lines)
|
||||
250
openspace/agents/visual_analyzer.py
Normal file
250
openspace/agents/visual_analyzer.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
from openspace.grounding.core.types import ToolResult
|
||||
from openspace.platforms.screenshot import ScreenshotClient
|
||||
from openspace.prompts import GroundingAgentPrompts
|
||||
from openspace.utils.logging import Logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openspace.llm import LLMClient
|
||||
|
||||
logger = Logger.get_logger(__name__)
|
||||
|
||||
|
||||
class VisualAnalyzer:
|
||||
"""Handles screenshot capture and LLM-based visual analysis of tool results."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm_client: Optional[LLMClient] = None,
|
||||
visual_analysis_model: Optional[str] = None,
|
||||
visual_analysis_timeout: float = 30.0,
|
||||
) -> None:
|
||||
self._llm_client = llm_client
|
||||
self._visual_analysis_model = visual_analysis_model
|
||||
self._visual_analysis_timeout = visual_analysis_timeout
|
||||
|
||||
async def analyze_tool_result(
|
||||
self,
|
||||
result: ToolResult,
|
||||
tool_name: str,
|
||||
tool_call: Dict,
|
||||
backend: str,
|
||||
task_description: str = "",
|
||||
) -> ToolResult:
|
||||
"""Callback for LLMClient to handle visual analysis after tool execution."""
|
||||
skip_visual_analysis = False
|
||||
try:
|
||||
arguments = tool_call.function.arguments
|
||||
if isinstance(arguments, str):
|
||||
args = json.loads(arguments.strip() or "{}")
|
||||
else:
|
||||
args = arguments
|
||||
|
||||
if isinstance(args, dict) and args.get("skip_visual_analysis"):
|
||||
skip_visual_analysis = True
|
||||
logger.info(f"Visual analysis skipped for {tool_name} (meta-parameter set by LLM)")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not parse tool arguments: {e}")
|
||||
|
||||
if skip_visual_analysis:
|
||||
return result
|
||||
|
||||
if backend != "gui":
|
||||
return result
|
||||
|
||||
metadata = getattr(result, "metadata", None)
|
||||
has_screenshots = metadata and (
|
||||
metadata.get("screenshot") or metadata.get("screenshots")
|
||||
)
|
||||
|
||||
if not has_screenshots:
|
||||
try:
|
||||
logger.info(f"No visual data from {tool_name}, capturing screenshot...")
|
||||
screenshot_client = ScreenshotClient()
|
||||
screenshot_bytes = await screenshot_client.capture()
|
||||
|
||||
if screenshot_bytes:
|
||||
if metadata is None:
|
||||
result.metadata = {}
|
||||
metadata = result.metadata
|
||||
metadata["screenshot"] = screenshot_bytes
|
||||
has_screenshots = True
|
||||
logger.info("Screenshot captured for visual analysis")
|
||||
else:
|
||||
logger.warning("Failed to capture screenshot")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error capturing screenshot: {e}")
|
||||
|
||||
if not has_screenshots:
|
||||
logger.debug(f"No visual data available for {tool_name}")
|
||||
return result
|
||||
|
||||
return await self._enhance_result(result, tool_name, task_description)
|
||||
|
||||
async def _enhance_result(
|
||||
self,
|
||||
result: ToolResult,
|
||||
tool_name: str,
|
||||
task_description: str = "",
|
||||
) -> ToolResult:
|
||||
"""Enhance tool result with LLM-based visual analysis."""
|
||||
import asyncio
|
||||
import base64
|
||||
import litellm
|
||||
|
||||
try:
|
||||
metadata = getattr(result, "metadata", None)
|
||||
if not metadata:
|
||||
return result
|
||||
|
||||
screenshots_bytes: List[bytes] = []
|
||||
if metadata.get("screenshots"):
|
||||
screenshots_list = metadata["screenshots"]
|
||||
if isinstance(screenshots_list, list):
|
||||
screenshots_bytes = [s for s in screenshots_list if s]
|
||||
elif metadata.get("screenshot"):
|
||||
screenshots_bytes = [metadata["screenshot"]]
|
||||
|
||||
if not screenshots_bytes:
|
||||
return result
|
||||
|
||||
selected_screenshots = self._select_key_screenshots(
|
||||
screenshots_bytes, max_count=3
|
||||
)
|
||||
|
||||
visual_b64_list = []
|
||||
for visual_data in selected_screenshots:
|
||||
if isinstance(visual_data, bytes):
|
||||
visual_b64_list.append(
|
||||
base64.b64encode(visual_data).decode("utf-8")
|
||||
)
|
||||
else:
|
||||
visual_b64_list.append(visual_data)
|
||||
|
||||
num_screenshots = len(visual_b64_list)
|
||||
|
||||
prompt = GroundingAgentPrompts.visual_analysis(
|
||||
tool_name=tool_name,
|
||||
num_screenshots=num_screenshots,
|
||||
task_description=task_description,
|
||||
)
|
||||
|
||||
content: List[Dict[str, Any]] = [{"type": "text", "text": prompt}]
|
||||
for visual_b64 in visual_b64_list:
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/png;base64,{visual_b64}"},
|
||||
}
|
||||
)
|
||||
|
||||
visual_model = self._visual_analysis_model or (
|
||||
self._llm_client.model
|
||||
if self._llm_client
|
||||
else "openrouter/anthropic/claude-sonnet-4.5"
|
||||
)
|
||||
_llm_extra: Dict[str, Any] = {}
|
||||
if self._llm_client and visual_model == self._llm_client.model:
|
||||
_llm_extra = (
|
||||
getattr(self._llm_client, "litellm_kwargs", {}) or {}
|
||||
)
|
||||
elif self._visual_analysis_model:
|
||||
try:
|
||||
from openspace.host_detection import build_llm_kwargs
|
||||
|
||||
visual_model, _llm_extra = build_llm_kwargs(visual_model)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Failed to resolve dedicated visual model credentials: {e}"
|
||||
)
|
||||
_llm_extra = {}
|
||||
|
||||
response = await asyncio.wait_for(
|
||||
litellm.acompletion(
|
||||
model=visual_model,
|
||||
messages=[{"role": "user", "content": content}],
|
||||
timeout=self._visual_analysis_timeout,
|
||||
**_llm_extra,
|
||||
),
|
||||
timeout=self._visual_analysis_timeout + 5,
|
||||
)
|
||||
|
||||
analysis = response.choices[0].message.content.strip()
|
||||
|
||||
original_content = result.content or "(no text output)"
|
||||
enhanced_content = (
|
||||
f"{original_content}\n\n**Visual content**: {analysis}"
|
||||
)
|
||||
|
||||
enhanced_result = ToolResult(
|
||||
status=result.status,
|
||||
content=enhanced_content,
|
||||
error=result.error,
|
||||
metadata={
|
||||
**metadata,
|
||||
"visual_analyzed": True,
|
||||
"visual_analysis": analysis,
|
||||
},
|
||||
execution_time=result.execution_time,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Enhanced {tool_name} result with visual analysis "
|
||||
f"({num_screenshots} screenshot(s))"
|
||||
)
|
||||
return enhanced_result
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"Visual analysis timed out for {tool_name}, returning original result"
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to analyze visual content for {tool_name}: {e}"
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _select_key_screenshots(
|
||||
screenshots: List[bytes],
|
||||
max_count: int = 3,
|
||||
) -> List[bytes]:
|
||||
"""Select key screenshots from a sequence, preferring first/last/evenly-spaced."""
|
||||
if len(screenshots) <= max_count:
|
||||
return screenshots
|
||||
|
||||
selected_indices: set[int] = set()
|
||||
|
||||
selected_indices.add(len(screenshots) - 1)
|
||||
|
||||
if max_count >= 2:
|
||||
selected_indices.add(0)
|
||||
|
||||
remaining_slots = max_count - len(selected_indices)
|
||||
if remaining_slots > 0:
|
||||
available_indices = [
|
||||
i
|
||||
for i in range(1, len(screenshots) - 1)
|
||||
if i not in selected_indices
|
||||
]
|
||||
|
||||
if available_indices:
|
||||
step = max(1, len(available_indices) // (remaining_slots + 1))
|
||||
for i in range(remaining_slots):
|
||||
idx = min((i + 1) * step, len(available_indices) - 1)
|
||||
if idx < len(available_indices):
|
||||
selected_indices.add(available_indices[idx])
|
||||
|
||||
selected = [screenshots[i] for i in sorted(selected_indices)]
|
||||
|
||||
logger.debug(
|
||||
f"Selected {len(selected)} screenshots at indices "
|
||||
f"{sorted(selected_indices)} from total of {len(screenshots)}"
|
||||
)
|
||||
|
||||
return selected
|
||||
|
|
@ -5,6 +5,7 @@ All methods are **synchronous** (use ``urllib``). In async contexts
|
|||
|
||||
Provides both low-level HTTP operations and higher-level workflows:
|
||||
- ``fetch_record`` / ``download_artifact`` / ``fetch_metadata``
|
||||
- ``search_record_embeddings``
|
||||
- ``stage_artifact`` / ``create_record``
|
||||
- ``upload_skill`` (stage → diff → create — full workflow)
|
||||
- ``import_skill`` (fetch → download → extract — full workflow)
|
||||
|
|
@ -29,6 +30,7 @@ logger = logging.getLogger("openspace.cloud")
|
|||
|
||||
SKILL_FILENAME = "SKILL.md"
|
||||
SKILL_ID_FILENAME = ".skill_id"
|
||||
RECORD_EMBEDDING_SEARCH_MAX_LIMIT = 300
|
||||
|
||||
_TEXT_EXTENSIONS = frozenset({
|
||||
".md", ".txt", ".yaml", ".yml", ".json", ".py", ".sh", ".toml",
|
||||
|
|
@ -99,9 +101,26 @@ class OpenSpaceClient:
|
|||
_, data = self._request("GET", path, timeout=timeout)
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_visibility_value(value: Any) -> Any:
|
||||
"""Treat legacy/group-shared non-public skills as private locally."""
|
||||
if value == "group_only":
|
||||
return "private"
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def _normalize_record_payload(cls, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
normalized = dict(payload)
|
||||
if "visibility" in normalized:
|
||||
normalized["visibility"] = cls._normalize_visibility_value(
|
||||
normalized.get("visibility")
|
||||
)
|
||||
return normalized
|
||||
|
||||
def fetch_record(self, record_id: str) -> Dict[str, Any]:
|
||||
"""GET /records/{record_id} — fetch record metadata."""
|
||||
return self._get_json(f"/records/{urllib.parse.quote(record_id)}")
|
||||
data = self._get_json(f"/records/{urllib.parse.quote(record_id)}")
|
||||
return self._normalize_record_payload(data)
|
||||
|
||||
def download_artifact(self, record_id: str) -> bytes:
|
||||
"""GET /records/{record_id}/download — download artifact zip bytes."""
|
||||
|
|
@ -132,7 +151,10 @@ class OpenSpaceClient:
|
|||
path = f"/records/metadata?{urllib.parse.urlencode(params)}"
|
||||
data = self._get_json(path, timeout=15)
|
||||
|
||||
all_items.extend(data.get("items", []))
|
||||
all_items.extend(
|
||||
self._normalize_record_payload(item)
|
||||
for item in data.get("items", [])
|
||||
)
|
||||
|
||||
if not data.get("has_more"):
|
||||
break
|
||||
|
|
@ -142,6 +164,34 @@ class OpenSpaceClient:
|
|||
|
||||
return all_items
|
||||
|
||||
def search_record_embeddings(
|
||||
self,
|
||||
*,
|
||||
query: str,
|
||||
limit: int = RECORD_EMBEDDING_SEARCH_MAX_LIMIT,
|
||||
level: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""POST /records/embeddings/search — fetch server-ranked embedding rows."""
|
||||
search_request_payload: Dict[str, Any] = {
|
||||
"query": query,
|
||||
"limit": limit,
|
||||
}
|
||||
if level:
|
||||
search_request_payload["level"] = level
|
||||
if tags:
|
||||
search_request_payload["tags"] = tags
|
||||
|
||||
_, response_body = self._request(
|
||||
"POST",
|
||||
"/records/embeddings/search",
|
||||
body=json.dumps(search_request_payload).encode("utf-8"),
|
||||
extra_headers={"Content-Type": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
items = json.loads(response_body.decode("utf-8"))
|
||||
return [self._normalize_record_payload(item) for item in items]
|
||||
|
||||
def stage_artifact(self, skill_dir: Path) -> tuple[str, int]:
|
||||
"""POST /artifacts/stage — upload skill files.
|
||||
|
||||
|
|
@ -266,7 +316,7 @@ class OpenSpaceClient:
|
|||
parents = parent_skill_ids or []
|
||||
self._validate_origin_parents(origin, parents)
|
||||
|
||||
api_visibility = "group_only" if visibility == "private" else "public"
|
||||
api_visibility = visibility
|
||||
|
||||
# Step 1: Stage
|
||||
logger.info(f"upload_skill: staging files for '{name}'")
|
||||
|
|
@ -340,7 +390,11 @@ class OpenSpaceClient:
|
|||
record_data = self.fetch_record(skill_id)
|
||||
skill_name = record_data.get("name", skill_id)
|
||||
|
||||
skill_dir = target_dir / skill_name
|
||||
if "/" in skill_name or "\\" in skill_name or skill_name.startswith("."):
|
||||
skill_name = skill_id
|
||||
skill_dir = (target_dir / skill_name).resolve()
|
||||
if not skill_dir.is_relative_to(target_dir.resolve()):
|
||||
raise CloudError(f"Skill name {skill_name!r} escapes target directory")
|
||||
|
||||
# Check if already exists locally
|
||||
if skill_dir.exists() and (skill_dir / SKILL_FILENAME).exists():
|
||||
|
|
@ -401,6 +455,7 @@ class OpenSpaceClient:
|
|||
def _extract_zip(zip_data: bytes, target_dir: Path) -> List[str]:
|
||||
"""Extract zip bytes to target directory with path traversal protection."""
|
||||
extracted: List[str] = []
|
||||
resolved_target = target_dir.resolve()
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
||||
for info in zf.infolist():
|
||||
|
|
@ -409,7 +464,9 @@ class OpenSpaceClient:
|
|||
clean_name = Path(info.filename).as_posix()
|
||||
if clean_name.startswith("..") or clean_name.startswith("/"):
|
||||
continue
|
||||
target_path = target_dir / clean_name
|
||||
target_path = (target_dir / clean_name).resolve()
|
||||
if not target_path.is_relative_to(resolved_target):
|
||||
continue
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_bytes(zf.read(info))
|
||||
extracted.append(clean_name)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import re
|
|||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("openspace.cloud")
|
||||
CLOUD_EMBEDDING_SEARCH_MAX_LIMIT = 300
|
||||
|
||||
|
||||
def _check_safety(text: str) -> list[str]:
|
||||
|
|
@ -159,36 +160,43 @@ class SkillSearchEngine:
|
|||
from openspace.cloud.embedding import cosine_similarity
|
||||
|
||||
scored = []
|
||||
for c in candidates:
|
||||
name = c.get("name", "")
|
||||
slug = c.get("skill_id", name).split("__")[0].replace(":", "-")
|
||||
for candidate in candidates:
|
||||
candidate_name = candidate.get("name", "")
|
||||
candidate_slug = candidate.get("skill_id", candidate_name).split("__")[0].replace(":", "-")
|
||||
|
||||
# Vector score
|
||||
vector_score = 0.0
|
||||
# Vector score. If client-side query embeddings are unavailable,
|
||||
# reuse the server-side cloud rank so cloud results keep semantic signal.
|
||||
vector_score: Optional[float] = None
|
||||
ranking_signal_score = 0.0
|
||||
if query_embedding:
|
||||
skill_emb = c.get("_embedding")
|
||||
if skill_emb and isinstance(skill_emb, list):
|
||||
vector_score = cosine_similarity(query_embedding, skill_emb)
|
||||
candidate_embedding = candidate.get("_embedding")
|
||||
if candidate_embedding and isinstance(candidate_embedding, list):
|
||||
vector_score = cosine_similarity(query_embedding, candidate_embedding)
|
||||
ranking_signal_score = vector_score
|
||||
elif isinstance(candidate.get("_search_rank"), (int, float)):
|
||||
ranking_signal_score = float(candidate["_search_rank"])
|
||||
|
||||
# Lexical boost
|
||||
lexical = _lexical_boost(query_tokens, name, slug)
|
||||
lexical_boost = _lexical_boost(query_tokens, candidate_name, candidate_slug)
|
||||
|
||||
final_score = vector_score + lexical
|
||||
final_score = ranking_signal_score + lexical_boost
|
||||
|
||||
entry: Dict[str, Any] = {
|
||||
"skill_id": c.get("skill_id", ""),
|
||||
"name": name,
|
||||
"description": c.get("description", ""),
|
||||
"source": c.get("source", ""),
|
||||
result_entry: Dict[str, Any] = {
|
||||
"skill_id": candidate.get("skill_id", ""),
|
||||
"name": candidate_name,
|
||||
"description": candidate.get("description", ""),
|
||||
"source": candidate.get("source", ""),
|
||||
"score": round(final_score, 4),
|
||||
}
|
||||
if vector_score > 0:
|
||||
entry["vector_score"] = round(vector_score, 4)
|
||||
if vector_score is not None and vector_score > 0:
|
||||
result_entry["vector_score"] = round(vector_score, 4)
|
||||
if isinstance(candidate.get("_search_rank"), (int, float)):
|
||||
result_entry["server_search_rank"] = round(float(candidate["_search_rank"]), 4)
|
||||
# Include optional fields
|
||||
for key in ("path", "visibility", "created_by", "origin", "tags", "quality", "safety_flags"):
|
||||
if c.get(key):
|
||||
entry[key] = c[key]
|
||||
scored.append(entry)
|
||||
if candidate.get(key):
|
||||
result_entry[key] = candidate[key]
|
||||
scored.append(result_entry)
|
||||
|
||||
scored.sort(key=lambda x: -x["score"])
|
||||
return scored
|
||||
|
|
@ -275,47 +283,85 @@ def build_local_candidates(
|
|||
|
||||
|
||||
def build_cloud_candidates(
|
||||
items: List[Dict[str, Any]],
|
||||
cloud_items: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Build search candidate dicts from cloud metadata items.
|
||||
"""Build search candidate dicts from cloud metadata/search items.
|
||||
|
||||
Args:
|
||||
items: Items from ``OpenSpaceClient.fetch_metadata()``.
|
||||
cloud_items: Items from cloud metadata or embedding search endpoints.
|
||||
|
||||
Returns:
|
||||
List of candidate dicts (with safety filtering applied).
|
||||
"""
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
for item in items:
|
||||
name = item.get("name", "")
|
||||
desc = item.get("description", "")
|
||||
tags = item.get("tags", [])
|
||||
safety_text = f"{name}\n{desc}\n{' '.join(tags)}"
|
||||
for item in cloud_items:
|
||||
candidate_name = item.get("name", "")
|
||||
candidate_description = item.get("description", "")
|
||||
candidate_tags = item.get("tags", [])
|
||||
safety_text = f"{candidate_name}\n{candidate_description}\n{' '.join(candidate_tags)}"
|
||||
flags = _check_safety(safety_text)
|
||||
if not _is_safe(flags):
|
||||
continue
|
||||
|
||||
c_entry: Dict[str, Any] = {
|
||||
candidate_entry: Dict[str, Any] = {
|
||||
"skill_id": item.get("record_id", ""),
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"name": candidate_name,
|
||||
"description": candidate_description,
|
||||
"source": "cloud",
|
||||
"visibility": item.get("visibility", "public"),
|
||||
"is_local": False,
|
||||
"created_by": item.get("created_by", ""),
|
||||
"origin": item.get("origin", ""),
|
||||
"tags": tags,
|
||||
"tags": candidate_tags,
|
||||
"safety_flags": flags if flags else None,
|
||||
}
|
||||
# Carry pre-computed embedding
|
||||
platform_emb = item.get("embedding")
|
||||
if platform_emb and isinstance(platform_emb, list):
|
||||
c_entry["_embedding"] = platform_emb
|
||||
candidates.append(c_entry)
|
||||
server_embedding = item.get("embedding")
|
||||
if server_embedding and isinstance(server_embedding, list):
|
||||
candidate_entry["_embedding"] = server_embedding
|
||||
server_search_rank = item.get("search_rank")
|
||||
if isinstance(server_search_rank, (int, float)):
|
||||
candidate_entry["_search_rank"] = float(server_search_rank)
|
||||
candidates.append(candidate_entry)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def build_cloud_results(
|
||||
cloud_search_items: List[Dict[str, Any]],
|
||||
*,
|
||||
limit: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Map server-ranked cloud search rows to MCP search result shape."""
|
||||
results: List[Dict[str, Any]] = []
|
||||
seen_names: set[str] = set()
|
||||
|
||||
for candidate in build_cloud_candidates(cloud_search_items):
|
||||
candidate_name = candidate.get("name", "")
|
||||
dedupe_name = candidate_name or candidate.get("skill_id", "")
|
||||
if dedupe_name in seen_names:
|
||||
continue
|
||||
seen_names.add(dedupe_name)
|
||||
|
||||
entry: Dict[str, Any] = {
|
||||
"skill_id": candidate.get("skill_id", ""),
|
||||
"name": candidate_name,
|
||||
"description": candidate.get("description", ""),
|
||||
"source": "cloud",
|
||||
"score": round(float(candidate.get("_search_rank", 0.0)), 4),
|
||||
}
|
||||
if isinstance(candidate.get("_search_rank"), (int, float)):
|
||||
entry["server_search_rank"] = round(float(candidate["_search_rank"]), 4)
|
||||
for key in ("visibility", "created_by", "origin", "tags", "safety_flags"):
|
||||
if candidate.get(key):
|
||||
entry[key] = candidate[key]
|
||||
results.append(entry)
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def hybrid_search_skills(
|
||||
query: str,
|
||||
local_skills: list = None,
|
||||
|
|
@ -341,8 +387,8 @@ async def hybrid_search_skills(
|
|||
"""
|
||||
from openspace.cloud.embedding import generate_embedding
|
||||
|
||||
q = query.strip()
|
||||
if not q:
|
||||
normalized_query = query.strip()
|
||||
if not normalized_query:
|
||||
return []
|
||||
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
|
|
@ -357,16 +403,16 @@ async def hybrid_search_skills(
|
|||
|
||||
auth_headers, api_base = get_openspace_auth()
|
||||
if auth_headers:
|
||||
client = OpenSpaceClient(auth_headers, api_base)
|
||||
try:
|
||||
from openspace.cloud.embedding import resolve_embedding_api
|
||||
has_emb = bool(resolve_embedding_api()[0])
|
||||
except Exception:
|
||||
has_emb = False
|
||||
items = await asyncio.to_thread(
|
||||
client.fetch_metadata, include_embedding=has_emb, limit=200,
|
||||
cloud_client = OpenSpaceClient(auth_headers, api_base)
|
||||
cloud_result_limit = limit if source == "cloud" else CLOUD_EMBEDDING_SEARCH_MAX_LIMIT
|
||||
cloud_search_items = await asyncio.to_thread(
|
||||
cloud_client.search_record_embeddings,
|
||||
query=normalized_query,
|
||||
limit=cloud_result_limit,
|
||||
)
|
||||
candidates.extend(build_cloud_candidates(items))
|
||||
if source == "cloud":
|
||||
return build_cloud_results(cloud_search_items, limit=limit)
|
||||
candidates.extend(build_cloud_candidates(cloud_search_items))
|
||||
except Exception as e:
|
||||
logger.warning(f"hybrid_search_skills: cloud unavailable: {e}")
|
||||
|
||||
|
|
@ -376,18 +422,17 @@ async def hybrid_search_skills(
|
|||
# query embedding (optional — key/URL resolved inside generate_embedding)
|
||||
query_embedding: Optional[List[float]] = None
|
||||
try:
|
||||
query_embedding = await asyncio.to_thread(generate_embedding, q)
|
||||
query_embedding = await asyncio.to_thread(generate_embedding, normalized_query)
|
||||
if query_embedding:
|
||||
for c in candidates:
|
||||
if not c.get("_embedding") and c.get("_embedding_text"):
|
||||
emb = await asyncio.to_thread(
|
||||
generate_embedding, c["_embedding_text"],
|
||||
for candidate in candidates:
|
||||
if not candidate.get("_embedding") and candidate.get("_embedding_text"):
|
||||
candidate_embedding = await asyncio.to_thread(
|
||||
generate_embedding, candidate["_embedding_text"],
|
||||
)
|
||||
if emb:
|
||||
c["_embedding"] = emb
|
||||
if candidate_embedding:
|
||||
candidate["_embedding"] = candidate_embedding
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
engine = SkillSearchEngine()
|
||||
return engine.search(q, candidates, query_embedding=query_embedding, limit=limit)
|
||||
|
||||
return engine.search(normalized_query, candidates, query_embedding=query_embedding, limit=limit)
|
||||
|
|
|
|||
27
openspace/communication/__init__.py
Normal file
27
openspace/communication/__init__.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from openspace.communication.config import CommunicationConfig, load_communication_config
|
||||
from openspace.communication.session_store import SessionStore, build_session_key
|
||||
from openspace.communication.types import (
|
||||
AttachmentKind,
|
||||
ChannelAttachment,
|
||||
ChannelMessage,
|
||||
ChannelPlatform,
|
||||
ChannelReply,
|
||||
ChannelSession,
|
||||
ChannelSource,
|
||||
SendResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AttachmentKind",
|
||||
"ChannelAttachment",
|
||||
"ChannelMessage",
|
||||
"ChannelPlatform",
|
||||
"ChannelReply",
|
||||
"ChannelSession",
|
||||
"ChannelSource",
|
||||
"CommunicationConfig",
|
||||
"SendResult",
|
||||
"SessionStore",
|
||||
"build_session_key",
|
||||
"load_communication_config",
|
||||
]
|
||||
9
openspace/communication/adapters/__init__.py
Normal file
9
openspace/communication/adapters/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from openspace.communication.adapters.base import BaseChannelAdapter
|
||||
from openspace.communication.adapters.feishu import FeishuAdapter
|
||||
from openspace.communication.adapters.whatsapp import WhatsAppAdapter
|
||||
|
||||
__all__ = [
|
||||
"BaseChannelAdapter",
|
||||
"FeishuAdapter",
|
||||
"WhatsAppAdapter",
|
||||
]
|
||||
63
openspace/communication/adapters/base.py
Normal file
63
openspace/communication/adapters/base.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
from openspace.utils.logging import Logger
|
||||
|
||||
from openspace.communication.types import ChannelMessage, ChannelPlatform, SendResult
|
||||
|
||||
logger = Logger.get_logger(__name__)
|
||||
|
||||
MessageHandler = Callable[[ChannelMessage], Awaitable[None]]
|
||||
|
||||
|
||||
class BaseChannelAdapter(ABC):
|
||||
platform: ChannelPlatform
|
||||
|
||||
def __init__(self, platform: ChannelPlatform):
|
||||
self.platform = platform
|
||||
self._message_handler: Optional[MessageHandler] = None
|
||||
self._connected = False
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def set_message_handler(self, handler: MessageHandler) -> None:
|
||||
self._message_handler = handler
|
||||
|
||||
async def dispatch_message(self, message: ChannelMessage) -> None:
|
||||
if self._message_handler is None:
|
||||
logger.warning("Dropping %s message because no handler is attached", self.platform.value)
|
||||
return
|
||||
await self._message_handler(message)
|
||||
|
||||
def register_http_routes(self, app: Any) -> None:
|
||||
"""Optional hook for adapters that need inbound HTTP routes."""
|
||||
|
||||
def validate_configuration(self) -> None:
|
||||
"""Optional hook for adapter-specific startup validation."""
|
||||
|
||||
def get_lock_identity(self) -> Optional[tuple[str, str]]:
|
||||
"""Return an optional (scope, identity) tuple for gateway-scoped locking."""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def disconnect(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def send_text(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
*,
|
||||
reply_to_message_id: Optional[str] = None,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
raise NotImplementedError
|
||||
901
openspace/communication/adapters/feishu.py
Normal file
901
openspace/communication/adapters/feishu.py
Normal file
|
|
@ -0,0 +1,901 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict, deque
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from openspace.communication.adapters.base import BaseChannelAdapter
|
||||
from openspace.communication.attachment_cache import AttachmentCache
|
||||
from openspace.communication.config import FeishuConfig
|
||||
from openspace.communication.policy import is_authorized
|
||||
from openspace.communication.types import (
|
||||
AttachmentKind,
|
||||
ChannelMessage,
|
||||
ChannelPlatform,
|
||||
ChannelSource,
|
||||
SendResult,
|
||||
)
|
||||
from openspace.utils.logging import Logger
|
||||
|
||||
logger = Logger.get_logger(__name__)
|
||||
|
||||
_FEISHU_WEBHOOK_MAX_BODY_BYTES = 1 * 1024 * 1024
|
||||
_FEISHU_WEBHOOK_READ_TIMEOUT_SECONDS = 30
|
||||
_FEISHU_WEBHOOK_RATE_WINDOW_SECONDS = 60
|
||||
_FEISHU_WEBHOOK_RATE_LIMIT_MAX = 120
|
||||
_FEISHU_WEBHOOK_RATE_MAX_KEYS = 4096
|
||||
_FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS = 6 * 60 * 60
|
||||
_FEISHU_DEDUP_CACHE_SIZE = 2048
|
||||
_FEISHU_DEDUP_TTL_SECONDS = 24 * 60 * 60
|
||||
|
||||
try:
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateMessageRequest,
|
||||
CreateMessageRequestBody,
|
||||
GetMessageRequest,
|
||||
GetMessageResourceRequest,
|
||||
ReplyMessageRequest,
|
||||
ReplyMessageRequestBody,
|
||||
)
|
||||
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
||||
|
||||
FEISHU_AVAILABLE = True
|
||||
except ImportError:
|
||||
FEISHU_AVAILABLE = False
|
||||
lark = None # type: ignore[assignment]
|
||||
CreateMessageRequest = None # type: ignore[assignment]
|
||||
CreateMessageRequestBody = None # type: ignore[assignment]
|
||||
GetMessageRequest = None # type: ignore[assignment]
|
||||
GetMessageResourceRequest = None # type: ignore[assignment]
|
||||
ReplyMessageRequest = None # type: ignore[assignment]
|
||||
ReplyMessageRequestBody = None # type: ignore[assignment]
|
||||
FEISHU_DOMAIN = None # type: ignore[assignment]
|
||||
LARK_DOMAIN = None # type: ignore[assignment]
|
||||
|
||||
|
||||
class FeishuAdapter(BaseChannelAdapter):
|
||||
MAX_MESSAGE_LENGTH = 8000
|
||||
_REPLY_CONTEXT_MAX_LEN = 200
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: FeishuConfig,
|
||||
attachment_cache: AttachmentCache,
|
||||
*,
|
||||
runtime_dir: Optional[Path] = None,
|
||||
):
|
||||
super().__init__(ChannelPlatform.FEISHU)
|
||||
self.config = config
|
||||
self.attachment_cache = attachment_cache
|
||||
self.runtime_dir = (
|
||||
Path(runtime_dir).expanduser().resolve()
|
||||
if runtime_dir is not None
|
||||
else attachment_cache.base_dir.parent.resolve()
|
||||
)
|
||||
self._client: Any = None
|
||||
self._bot_open_id = config.bot_open_id
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._ws_client: Any = None
|
||||
self._ws_thread: Optional[threading.Thread] = None
|
||||
self._running = False
|
||||
self._dedup_state_path = self.runtime_dir / "feishu_seen_message_ids.json"
|
||||
self._seen_message_ids: OrderedDict[str, float] = OrderedDict()
|
||||
self._recent_sent_message_ids: OrderedDict[str, None] = OrderedDict()
|
||||
self._rate_windows: dict[str, deque[float]] = {}
|
||||
self._webhook_anomalies: dict[str, tuple[int, str, float]] = {}
|
||||
self._dedup_dirty = False
|
||||
self._load_seen_message_ids()
|
||||
|
||||
def register_http_routes(self, app: Any) -> None:
|
||||
if self.config.connection_mode == "webhook":
|
||||
app.router.add_post(self.config.webhook_path, self._handle_webhook)
|
||||
|
||||
def validate_configuration(self) -> None:
|
||||
if self.config.connection_mode == "webhook" and not _optional_str(self.config.verification_token):
|
||||
raise ValueError("Feishu webhook mode requires verification_token")
|
||||
|
||||
def get_lock_identity(self) -> Optional[tuple[str, str]]:
|
||||
app_id = _optional_str(self.config.app_id)
|
||||
if not app_id:
|
||||
return None
|
||||
return ("feishu-app", app_id)
|
||||
|
||||
async def connect(self) -> bool:
|
||||
self.validate_configuration()
|
||||
if not FEISHU_AVAILABLE:
|
||||
logger.error("Feishu adapter requires lark-oapi")
|
||||
return False
|
||||
if not self.config.app_id or not self.config.app_secret:
|
||||
logger.error("Feishu adapter missing app_id/app_secret")
|
||||
return False
|
||||
domain = FEISHU_DOMAIN if self.config.domain != "lark" else LARK_DOMAIN
|
||||
self._client = (
|
||||
lark.Client.builder()
|
||||
.app_id(self.config.app_id)
|
||||
.app_secret(self.config.app_secret)
|
||||
.domain(domain)
|
||||
.log_level(lark.LogLevel.WARNING)
|
||||
.build()
|
||||
)
|
||||
self._running = True
|
||||
self._loop = asyncio.get_running_loop()
|
||||
if not self._bot_open_id:
|
||||
self._bot_open_id = await asyncio.to_thread(self._fetch_bot_open_id)
|
||||
if self.config.connection_mode == "websocket":
|
||||
self._start_websocket_client()
|
||||
self._connected = False
|
||||
else:
|
||||
self._connected = True
|
||||
logger.info(
|
||||
"Feishu adapter connected via %s mode",
|
||||
self.config.connection_mode,
|
||||
)
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._running = False
|
||||
self._connected = False
|
||||
if self._ws_thread is not None and self._ws_thread.is_alive():
|
||||
await asyncio.to_thread(self._ws_thread.join, 5)
|
||||
self._ws_thread = None
|
||||
self._persist_seen_message_ids()
|
||||
self._client = None
|
||||
|
||||
async def send_text(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
*,
|
||||
reply_to_message_id: Optional[str] = None,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
if not self._client:
|
||||
return SendResult(success=False, error="Feishu client not initialized")
|
||||
|
||||
last_message_id: Optional[str] = None
|
||||
for chunk in _split_text(content, self.MAX_MESSAGE_LENGTH):
|
||||
payload = json.dumps({"text": chunk}, ensure_ascii=False)
|
||||
if reply_to_message_id:
|
||||
body = (
|
||||
ReplyMessageRequestBody.builder()
|
||||
.msg_type("text")
|
||||
.content(payload)
|
||||
.build()
|
||||
)
|
||||
request = (
|
||||
ReplyMessageRequest.builder()
|
||||
.message_id(reply_to_message_id)
|
||||
.request_body(body)
|
||||
.build()
|
||||
)
|
||||
response = await asyncio.to_thread(self._client.im.v1.message.reply, request)
|
||||
else:
|
||||
body = (
|
||||
CreateMessageRequestBody.builder()
|
||||
.receive_id(chat_id)
|
||||
.msg_type("text")
|
||||
.content(payload)
|
||||
.build()
|
||||
)
|
||||
request = (
|
||||
CreateMessageRequest.builder()
|
||||
.receive_id_type("chat_id")
|
||||
.request_body(body)
|
||||
.build()
|
||||
)
|
||||
response = await asyncio.to_thread(self._client.im.v1.message.create, request)
|
||||
|
||||
if not response.success():
|
||||
return SendResult(
|
||||
success=False,
|
||||
error=f"[{response.code}] {response.msg}",
|
||||
raw_response=response,
|
||||
)
|
||||
last_message_id = getattr(getattr(response, "data", None), "message_id", None)
|
||||
if last_message_id:
|
||||
self._remember_sent_message_id(last_message_id)
|
||||
return SendResult(success=True, message_id=last_message_id)
|
||||
|
||||
async def _handle_webhook(self, request: web.Request) -> web.Response:
|
||||
remote_ip = _client_ip_from_request(request)
|
||||
rate_key = f"{self.config.app_id}:{self.config.webhook_path}:{remote_ip}"
|
||||
if not self._check_webhook_rate_limit(rate_key):
|
||||
self._record_webhook_anomaly(remote_ip, "429")
|
||||
return web.Response(status=429, text="Rate limit exceeded")
|
||||
|
||||
content_length = request.content_length or 0
|
||||
if content_length > _FEISHU_WEBHOOK_MAX_BODY_BYTES:
|
||||
self._record_webhook_anomaly(remote_ip, "413")
|
||||
return web.Response(status=413, text="Payload too large")
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(_FEISHU_WEBHOOK_READ_TIMEOUT_SECONDS):
|
||||
body_bytes = await request.read()
|
||||
except TimeoutError:
|
||||
self._record_webhook_anomaly(remote_ip, "408")
|
||||
return web.Response(status=408, text="Request timeout")
|
||||
|
||||
if len(body_bytes) > _FEISHU_WEBHOOK_MAX_BODY_BYTES:
|
||||
self._record_webhook_anomaly(remote_ip, "413")
|
||||
return web.Response(status=413, text="Payload too large")
|
||||
|
||||
try:
|
||||
payload = json.loads(body_bytes.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
self._record_webhook_anomaly(remote_ip, "400")
|
||||
return web.json_response({"code": 400, "msg": "invalid json"}, status=400)
|
||||
|
||||
incoming_token = str((payload.get("header") or {}).get("token") or payload.get("token") or "")
|
||||
if not incoming_token or not hmac.compare_digest(incoming_token, self.config.verification_token or ""):
|
||||
self._record_webhook_anomaly(remote_ip, "401-token")
|
||||
return web.Response(status=401, text="Invalid verification token")
|
||||
|
||||
if self.config.encrypt_key and not _is_webhook_signature_valid(
|
||||
encrypt_key=self.config.encrypt_key,
|
||||
headers=request.headers,
|
||||
body_bytes=body_bytes,
|
||||
):
|
||||
self._record_webhook_anomaly(remote_ip, "401-signature")
|
||||
return web.Response(status=401, text="Invalid signature")
|
||||
if payload.get("encrypt"):
|
||||
self._record_webhook_anomaly(remote_ip, "400-encrypted")
|
||||
return web.json_response(
|
||||
{"code": 400, "msg": "encrypted webhook payloads are not supported"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
self._clear_webhook_anomaly(remote_ip)
|
||||
|
||||
if payload.get("type") == "url_verification":
|
||||
return web.json_response({"challenge": payload.get("challenge", "")})
|
||||
|
||||
event_type = str((payload.get("header") or {}).get("event_type") or "")
|
||||
if event_type == "im.message.receive_v1":
|
||||
await self._handle_message_event(payload)
|
||||
return web.json_response({"code": 0, "msg": "ok"})
|
||||
|
||||
def _start_websocket_client(self) -> None:
|
||||
assert lark is not None
|
||||
|
||||
handler = (
|
||||
lark.EventDispatcherHandler.builder(
|
||||
self.config.encrypt_key or "",
|
||||
self.config.verification_token or "",
|
||||
)
|
||||
.register_p2_im_message_receive_v1(self._on_message_sync)
|
||||
.build()
|
||||
)
|
||||
self._ws_client = lark.ws.Client(
|
||||
self.config.app_id,
|
||||
self.config.app_secret,
|
||||
event_handler=handler,
|
||||
log_level=lark.LogLevel.INFO,
|
||||
)
|
||||
|
||||
def _run_ws_forever() -> None:
|
||||
import lark_oapi.ws.client as lark_ws_client
|
||||
|
||||
ws_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(ws_loop)
|
||||
lark_ws_client.loop = ws_loop
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
self._ws_client.start()
|
||||
except Exception as exc:
|
||||
self._connected = False
|
||||
logger.warning("Feishu WebSocket client error: %s", exc)
|
||||
if self._running:
|
||||
time.sleep(5)
|
||||
finally:
|
||||
self._connected = False
|
||||
ws_loop.close()
|
||||
|
||||
self._ws_thread = threading.Thread(
|
||||
target=_run_ws_forever,
|
||||
daemon=True,
|
||||
name="openspace-feishu-ws",
|
||||
)
|
||||
self._ws_thread.start()
|
||||
|
||||
def _on_message_sync(self, data: Any) -> None:
|
||||
if not self._loop or not self._running:
|
||||
return
|
||||
self._connected = True
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._handle_websocket_message(data),
|
||||
self._loop,
|
||||
)
|
||||
|
||||
async def _handle_message_event(self, payload: dict[str, Any]) -> None:
|
||||
normalized = await self._normalize_webhook_payload(payload)
|
||||
if normalized is not None:
|
||||
await self.dispatch_message(normalized)
|
||||
|
||||
async def _handle_websocket_message(self, data: Any) -> None:
|
||||
normalized = await self._normalize_websocket_event(data)
|
||||
if normalized is not None:
|
||||
await self.dispatch_message(normalized)
|
||||
|
||||
async def _normalize_webhook_payload(self, payload: dict[str, Any]) -> Optional[ChannelMessage]:
|
||||
event = payload.get("event") or {}
|
||||
message = event.get("message") or {}
|
||||
sender = event.get("sender") or {}
|
||||
sender_id = sender.get("sender_id") or {}
|
||||
if str(sender.get("sender_type", "")).lower() == "bot":
|
||||
return None
|
||||
return await self._normalize_inbound_message(
|
||||
message_id=_optional_str(message.get("message_id")),
|
||||
chat_id=_optional_str(message.get("chat_id")),
|
||||
chat_type=_optional_str(message.get("chat_type")) or "p2p",
|
||||
sender_uid=(
|
||||
_optional_str(sender_id.get("open_id"))
|
||||
or _optional_str(sender_id.get("user_id"))
|
||||
or _optional_str(sender_id.get("union_id"))
|
||||
),
|
||||
sender_name=(
|
||||
_optional_str(sender_id.get("name"))
|
||||
or _optional_str(sender.get("sender_name"))
|
||||
),
|
||||
thread_id=_optional_str(message.get("thread_id")),
|
||||
message_type=_optional_str(message.get("message_type")) or "",
|
||||
content=_safe_json_loads(message.get("content", "{}")),
|
||||
mentions=list(message.get("mentions") or []),
|
||||
reply_to_message_id=(
|
||||
_optional_str(message.get("parent_id"))
|
||||
or _optional_str(message.get("upper_message_id"))
|
||||
),
|
||||
metadata={"webhook_payload": payload},
|
||||
resolve_mentions=False,
|
||||
)
|
||||
|
||||
async def _normalize_websocket_event(self, data: Any) -> Optional[ChannelMessage]:
|
||||
event = getattr(data, "event", None)
|
||||
message = getattr(event, "message", None)
|
||||
sender = getattr(event, "sender", None)
|
||||
if message is None or sender is None:
|
||||
return None
|
||||
if str(getattr(sender, "sender_type", "")).lower() == "bot":
|
||||
return None
|
||||
|
||||
sender_id = getattr(sender, "sender_id", None)
|
||||
return await self._normalize_inbound_message(
|
||||
message_id=_optional_str(getattr(message, "message_id", None)),
|
||||
chat_id=_optional_str(getattr(message, "chat_id", None)),
|
||||
chat_type=_optional_str(getattr(message, "chat_type", None)) or "p2p",
|
||||
sender_uid=(
|
||||
_optional_str(getattr(sender_id, "open_id", None))
|
||||
or _optional_str(getattr(sender_id, "user_id", None))
|
||||
or _optional_str(getattr(sender_id, "union_id", None))
|
||||
),
|
||||
sender_name=_optional_str(getattr(sender, "sender_name", None)),
|
||||
thread_id=_optional_str(getattr(message, "thread_id", None)),
|
||||
message_type=_optional_str(getattr(message, "message_type", None)) or "",
|
||||
content=_safe_json_loads(getattr(message, "content", "{}")),
|
||||
mentions=list(getattr(message, "mentions", None) or []),
|
||||
reply_to_message_id=(
|
||||
_optional_str(getattr(message, "parent_id", None))
|
||||
or _optional_str(getattr(message, "upper_message_id", None))
|
||||
),
|
||||
metadata={"websocket_event": True},
|
||||
resolve_mentions=True,
|
||||
)
|
||||
|
||||
async def _normalize_inbound_message(
|
||||
self,
|
||||
*,
|
||||
message_id: Optional[str],
|
||||
chat_id: Optional[str],
|
||||
chat_type: str,
|
||||
sender_uid: Optional[str],
|
||||
sender_name: Optional[str],
|
||||
thread_id: Optional[str],
|
||||
message_type: str,
|
||||
content: dict[str, Any],
|
||||
mentions: list[Any],
|
||||
reply_to_message_id: Optional[str],
|
||||
metadata: dict[str, Any],
|
||||
resolve_mentions: bool,
|
||||
) -> Optional[ChannelMessage]:
|
||||
if not message_id or not chat_id:
|
||||
return None
|
||||
if self._is_message_seen(message_id):
|
||||
logger.debug("Skipping duplicate Feishu message %s", message_id)
|
||||
return None
|
||||
|
||||
source = ChannelSource(
|
||||
platform=ChannelPlatform.FEISHU,
|
||||
chat_id=chat_id,
|
||||
chat_type="dm" if str(chat_type).lower() == "p2p" else "group",
|
||||
user_id=sender_uid,
|
||||
user_name=sender_name,
|
||||
chat_name=chat_id,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
session_key = _build_session_key_hint(source)
|
||||
normalized_type = str(message_type or "").strip().lower()
|
||||
mentions_bot = self._mentions_bot(mentions)
|
||||
|
||||
text = ""
|
||||
if normalized_type == "text":
|
||||
text = str(content.get("text", "")).strip()
|
||||
if resolve_mentions:
|
||||
text = _resolve_mentions(text, mentions)
|
||||
elif normalized_type == "post":
|
||||
text = _extract_post_text(content)
|
||||
|
||||
prefilter_message = ChannelMessage(
|
||||
source=source,
|
||||
text=text,
|
||||
message_id=message_id,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
mentions_bot=mentions_bot,
|
||||
metadata=metadata,
|
||||
)
|
||||
if not self._passes_prefilter(prefilter_message):
|
||||
self._remember_message_seen(message_id)
|
||||
return None
|
||||
|
||||
attachments = []
|
||||
if normalized_type == "image":
|
||||
attachment = await self._download_attachment(
|
||||
session_key=session_key,
|
||||
message_id=message_id,
|
||||
file_key=str(content.get("image_key", "")).strip(),
|
||||
file_name=str(content.get("image_key", "image")).strip() + ".png",
|
||||
kind=AttachmentKind.IMAGE,
|
||||
resource_type="image",
|
||||
)
|
||||
if attachment is not None:
|
||||
attachments.append(attachment)
|
||||
elif normalized_type == "file":
|
||||
attachment = await self._download_attachment(
|
||||
session_key=session_key,
|
||||
message_id=message_id,
|
||||
file_key=str(content.get("file_key", "")).strip(),
|
||||
file_name=str(content.get("file_name", "document")).strip(),
|
||||
kind=AttachmentKind.DOCUMENT,
|
||||
resource_type="file",
|
||||
)
|
||||
if attachment is not None:
|
||||
attachments.append(attachment)
|
||||
|
||||
prefilter_message.attachments = attachments
|
||||
prefilter_message.reply_to_text = await self._fetch_message_text(reply_to_message_id)
|
||||
self._remember_message_seen(message_id)
|
||||
return prefilter_message
|
||||
|
||||
def _passes_prefilter(self, message: ChannelMessage) -> bool:
|
||||
if not is_authorized(message, self.config):
|
||||
logger.info("Rejected Feishu message from unauthorized user %s", message.source.user_id)
|
||||
return False
|
||||
if message.source.chat_type == "dm":
|
||||
return self.config.allow_dm
|
||||
if not self.config.allow_groups:
|
||||
return False
|
||||
if self.config.group_policy == "disabled":
|
||||
return False
|
||||
if self.config.group_policy == "mention_only":
|
||||
return message.mentions_bot
|
||||
if self.config.group_policy == "reply_or_mention":
|
||||
return message.mentions_bot or self._is_reply_to_recent_bot_message(
|
||||
message.reply_to_message_id
|
||||
)
|
||||
return True
|
||||
|
||||
def _is_reply_to_recent_bot_message(self, message_id: Optional[str]) -> bool:
|
||||
if not message_id:
|
||||
return False
|
||||
return message_id in self._recent_sent_message_ids
|
||||
|
||||
def _remember_sent_message_id(self, message_id: str) -> None:
|
||||
self._recent_sent_message_ids.pop(message_id, None)
|
||||
self._recent_sent_message_ids[message_id] = None
|
||||
while len(self._recent_sent_message_ids) > _FEISHU_DEDUP_CACHE_SIZE:
|
||||
self._recent_sent_message_ids.popitem(last=False)
|
||||
|
||||
async def _download_attachment(
|
||||
self,
|
||||
*,
|
||||
session_key: str,
|
||||
message_id: str,
|
||||
file_key: str,
|
||||
file_name: str,
|
||||
kind: AttachmentKind,
|
||||
resource_type: str,
|
||||
):
|
||||
if not self._client or not file_key:
|
||||
return None
|
||||
request = (
|
||||
GetMessageResourceRequest.builder()
|
||||
.message_id(message_id)
|
||||
.file_key(file_key)
|
||||
.type(resource_type)
|
||||
.build()
|
||||
)
|
||||
response = await asyncio.to_thread(self._client.im.v1.message_resource.get, request)
|
||||
if not response.success():
|
||||
logger.warning(
|
||||
"Failed to download Feishu attachment: code=%s msg=%s",
|
||||
response.code,
|
||||
response.msg,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
file_data = await asyncio.to_thread(
|
||||
_read_attachment_body,
|
||||
response.file,
|
||||
self.attachment_cache.max_attachment_bytes,
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.warning(
|
||||
"Rejected Feishu attachment for session %s: %s",
|
||||
session_key,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
return self.attachment_cache.save_bytes(
|
||||
session_key=session_key,
|
||||
data=file_data,
|
||||
filename=file_name,
|
||||
kind=kind,
|
||||
)
|
||||
|
||||
def _fetch_bot_open_id(self) -> Optional[str]:
|
||||
if not self._client or not lark:
|
||||
return None
|
||||
try:
|
||||
request = (
|
||||
lark.BaseRequest.builder()
|
||||
.http_method(lark.HttpMethod.GET)
|
||||
.uri("/open-apis/bot/v3/info")
|
||||
.token_types({lark.AccessTokenType.APP})
|
||||
.build()
|
||||
)
|
||||
response = self._client.request(request)
|
||||
if not response.success():
|
||||
logger.warning(
|
||||
"Failed to fetch Feishu bot info: code=%s msg=%s",
|
||||
response.code,
|
||||
response.msg,
|
||||
)
|
||||
return None
|
||||
payload = json.loads(response.raw.content)
|
||||
bot = (payload.get("data") or payload).get("bot") or {}
|
||||
return _optional_str(bot.get("open_id"))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to resolve Feishu bot open_id: %s", exc)
|
||||
return None
|
||||
|
||||
async def _fetch_message_text(self, message_id: Optional[str]) -> Optional[str]:
|
||||
if not self._client or not message_id or GetMessageRequest is None:
|
||||
return None
|
||||
|
||||
request = GetMessageRequest.builder().message_id(message_id).build()
|
||||
try:
|
||||
response = await asyncio.to_thread(self._client.im.v1.message.get, request)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to fetch Feishu parent message %s: %s", message_id, exc)
|
||||
return None
|
||||
if not response.success():
|
||||
return None
|
||||
|
||||
data = getattr(response, "data", None)
|
||||
message_obj = None
|
||||
items = getattr(data, "items", None)
|
||||
if items:
|
||||
message_obj = items[0]
|
||||
elif data is not None:
|
||||
message_obj = getattr(data, "message", None) or data
|
||||
if message_obj is None:
|
||||
return None
|
||||
|
||||
body = getattr(message_obj, "body", None)
|
||||
raw_content = getattr(body, "content", None) if body is not None else getattr(message_obj, "content", None)
|
||||
message_type = (
|
||||
getattr(message_obj, "msg_type", None)
|
||||
or getattr(message_obj, "message_type", None)
|
||||
or ""
|
||||
)
|
||||
content = _safe_json_loads(raw_content)
|
||||
text = ""
|
||||
if str(message_type).lower() == "text":
|
||||
text = str(content.get("text", "")).strip()
|
||||
elif str(message_type).lower() == "post":
|
||||
text = _extract_post_text(content)
|
||||
if not text:
|
||||
return None
|
||||
if len(text) > self._REPLY_CONTEXT_MAX_LEN:
|
||||
text = text[: self._REPLY_CONTEXT_MAX_LEN] + "..."
|
||||
return text
|
||||
|
||||
def _mentions_bot(self, mentions: list[Any]) -> bool:
|
||||
if not mentions:
|
||||
return False
|
||||
if not self._bot_open_id:
|
||||
return False
|
||||
|
||||
for mention in mentions:
|
||||
mention_id = (mention.get("id") or {}) if isinstance(mention, dict) else getattr(mention, "id", None)
|
||||
open_id = (
|
||||
_optional_str(mention_id.get("open_id"))
|
||||
if isinstance(mention_id, dict)
|
||||
else _optional_str(getattr(mention_id, "open_id", None))
|
||||
)
|
||||
if open_id == self._bot_open_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _load_seen_message_ids(self) -> None:
|
||||
if not self._dedup_state_path.exists():
|
||||
return
|
||||
try:
|
||||
payload = json.loads(self._dedup_state_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("Failed to load Feishu dedup cache from %s", self._dedup_state_path)
|
||||
return
|
||||
entries = payload.get("message_ids", {}) if isinstance(payload, dict) else {}
|
||||
now = time.time()
|
||||
valid: list[tuple[str, float]] = []
|
||||
if isinstance(entries, dict):
|
||||
for message_id, seen_at in entries.items():
|
||||
normalized_id = _optional_str(message_id)
|
||||
if not normalized_id:
|
||||
continue
|
||||
try:
|
||||
timestamp = float(seen_at)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if now - timestamp <= _FEISHU_DEDUP_TTL_SECONDS:
|
||||
valid.append((normalized_id, timestamp))
|
||||
for message_id, seen_at in sorted(valid, key=lambda item: item[1])[-_FEISHU_DEDUP_CACHE_SIZE:]:
|
||||
self._seen_message_ids[message_id] = seen_at
|
||||
|
||||
def _persist_seen_message_ids(self) -> None:
|
||||
if not self._dedup_dirty:
|
||||
return
|
||||
self._dedup_state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"message_ids": dict(self._seen_message_ids)}
|
||||
try:
|
||||
self._dedup_state_path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
logger.warning("Failed to persist Feishu dedup cache to %s", self._dedup_state_path)
|
||||
return
|
||||
self._dedup_dirty = False
|
||||
|
||||
def _is_message_seen(self, message_id: str) -> bool:
|
||||
now = time.time()
|
||||
self._prune_seen_message_ids(now)
|
||||
return message_id in self._seen_message_ids
|
||||
|
||||
def _remember_message_seen(self, message_id: str) -> None:
|
||||
now = time.time()
|
||||
self._prune_seen_message_ids(now)
|
||||
if message_id in self._seen_message_ids:
|
||||
self._seen_message_ids.move_to_end(message_id)
|
||||
return
|
||||
self._seen_message_ids[message_id] = now
|
||||
self._seen_message_ids.move_to_end(message_id)
|
||||
while len(self._seen_message_ids) > _FEISHU_DEDUP_CACHE_SIZE:
|
||||
self._seen_message_ids.popitem(last=False)
|
||||
self._dedup_dirty = True
|
||||
self._persist_seen_message_ids()
|
||||
|
||||
def _mark_message_seen(self, message_id: str) -> bool:
|
||||
if self._is_message_seen(message_id):
|
||||
return True
|
||||
self._remember_message_seen(message_id)
|
||||
return False
|
||||
|
||||
def _prune_seen_message_ids(self, now: Optional[float] = None) -> None:
|
||||
current = now or time.time()
|
||||
stale = [
|
||||
message_id
|
||||
for message_id, seen_at in self._seen_message_ids.items()
|
||||
if current - seen_at > _FEISHU_DEDUP_TTL_SECONDS
|
||||
]
|
||||
for message_id in stale:
|
||||
self._seen_message_ids.pop(message_id, None)
|
||||
self._dedup_dirty = True
|
||||
|
||||
def _check_webhook_rate_limit(self, rate_key: str) -> bool:
|
||||
now = time.time()
|
||||
window = self._rate_windows.get(rate_key)
|
||||
if window is None:
|
||||
if len(self._rate_windows) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS:
|
||||
stale_keys = [
|
||||
key
|
||||
for key, timestamps in self._rate_windows.items()
|
||||
if not timestamps or now - timestamps[-1] > _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS
|
||||
]
|
||||
for key in stale_keys:
|
||||
self._rate_windows.pop(key, None)
|
||||
if rate_key not in self._rate_windows and len(self._rate_windows) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS:
|
||||
return False
|
||||
window = deque()
|
||||
self._rate_windows[rate_key] = window
|
||||
cutoff = now - _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS
|
||||
while window and window[0] < cutoff:
|
||||
window.popleft()
|
||||
if len(window) >= _FEISHU_WEBHOOK_RATE_LIMIT_MAX:
|
||||
return False
|
||||
window.append(now)
|
||||
return True
|
||||
|
||||
def _record_webhook_anomaly(self, remote_ip: str, status: str) -> None:
|
||||
now = time.time()
|
||||
current = self._webhook_anomalies.get(remote_ip)
|
||||
if current and now - current[2] < _FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS:
|
||||
self._webhook_anomalies[remote_ip] = (current[0] + 1, status, current[2])
|
||||
return
|
||||
self._webhook_anomalies[remote_ip] = (1, status, now)
|
||||
|
||||
def _clear_webhook_anomaly(self, remote_ip: str) -> None:
|
||||
self._webhook_anomalies.pop(remote_ip, None)
|
||||
|
||||
|
||||
def _safe_json_loads(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
try:
|
||||
return json.loads(str(value or "{}"))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
def _optional_str(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
value = str(value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _client_ip_from_request(request: web.Request) -> str:
|
||||
forwarded = str(request.headers.get("x-forwarded-for", "") or "").split(",")[0].strip()
|
||||
if forwarded:
|
||||
return forwarded
|
||||
peer = request.transport.get_extra_info("peername") if request.transport else None
|
||||
if isinstance(peer, tuple) and peer:
|
||||
return str(peer[0])
|
||||
return request.remote or "unknown"
|
||||
|
||||
|
||||
def _resolve_mentions(text: str, mentions: list[Any]) -> str:
|
||||
if not text or not mentions:
|
||||
return text
|
||||
|
||||
resolved = text
|
||||
for mention in mentions:
|
||||
key = _optional_str(
|
||||
mention.get("key") if isinstance(mention, dict) else getattr(mention, "key", None)
|
||||
)
|
||||
if not key or key not in resolved:
|
||||
continue
|
||||
name = _optional_str(
|
||||
mention.get("name") if isinstance(mention, dict) else getattr(mention, "name", None)
|
||||
) or "user"
|
||||
resolved = resolved.replace(key, f"@{name}")
|
||||
return resolved
|
||||
|
||||
|
||||
def _split_text(content: str, limit: int) -> list[str]:
|
||||
text = content.strip()
|
||||
if not text:
|
||||
return [""]
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
chunks = []
|
||||
remaining = text
|
||||
while remaining:
|
||||
chunk = remaining[:limit]
|
||||
if len(remaining) > limit:
|
||||
split_at = chunk.rfind("\n")
|
||||
if split_at < limit // 3:
|
||||
split_at = chunk.rfind(" ")
|
||||
if split_at >= limit // 3:
|
||||
chunk = chunk[:split_at]
|
||||
chunks.append(chunk.strip())
|
||||
remaining = remaining[len(chunk):].lstrip()
|
||||
return [chunk for chunk in chunks if chunk]
|
||||
|
||||
|
||||
def _is_webhook_signature_valid(*, encrypt_key: str, headers: Any, body_bytes: bytes) -> bool:
|
||||
timestamp = str(headers.get("x-lark-request-timestamp", "") or "")
|
||||
nonce = str(headers.get("x-lark-request-nonce", "") or "")
|
||||
signature = str(headers.get("x-lark-signature", "") or "")
|
||||
if not timestamp or not nonce or not signature:
|
||||
return False
|
||||
content = f"{timestamp}{nonce}{encrypt_key}{body_bytes.decode('utf-8', errors='replace')}"
|
||||
expected = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
return hmac.compare_digest(signature, expected)
|
||||
|
||||
|
||||
def _build_session_key_hint(source: ChannelSource) -> str:
|
||||
parts = [source.platform.value, source.chat_id]
|
||||
if source.thread_id:
|
||||
parts.append(source.thread_id)
|
||||
return "__".join(part.replace("/", "_") for part in parts if part)
|
||||
|
||||
|
||||
def _extract_post_text(content: dict[str, Any]) -> str:
|
||||
texts: list[str] = []
|
||||
|
||||
def _walk(value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
title = _optional_str(value.get("title"))
|
||||
if title:
|
||||
texts.append(title)
|
||||
tag = _optional_str(value.get("tag"))
|
||||
if tag == "at":
|
||||
user_name = _optional_str(value.get("user_name")) or "user"
|
||||
texts.append(f"@{user_name}")
|
||||
else:
|
||||
text = _optional_str(value.get("text"))
|
||||
if text:
|
||||
texts.append(text)
|
||||
for nested in value.values():
|
||||
_walk(nested)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
_walk(item)
|
||||
|
||||
_walk(content)
|
||||
deduped: list[str] = []
|
||||
for text in texts:
|
||||
if text not in deduped:
|
||||
deduped.append(text)
|
||||
return "\n".join(deduped).strip()
|
||||
|
||||
|
||||
def _read_attachment_body(raw_file: Any, max_bytes: int) -> bytes:
|
||||
if raw_file is None:
|
||||
return b""
|
||||
|
||||
if isinstance(raw_file, bytes):
|
||||
data = raw_file
|
||||
elif isinstance(raw_file, bytearray):
|
||||
data = bytes(raw_file)
|
||||
elif hasattr(raw_file, "read"):
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
try:
|
||||
while True:
|
||||
chunk = raw_file.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
if isinstance(chunk, str):
|
||||
chunk = chunk.encode("utf-8")
|
||||
elif isinstance(chunk, bytearray):
|
||||
chunk = bytes(chunk)
|
||||
elif not isinstance(chunk, bytes):
|
||||
chunk = bytes(chunk)
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise ValueError(
|
||||
f"attachment size {total} exceeds limit {max_bytes}"
|
||||
)
|
||||
chunks.append(chunk)
|
||||
finally:
|
||||
close = getattr(raw_file, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
data = b"".join(chunks)
|
||||
else:
|
||||
data = bytes(raw_file)
|
||||
|
||||
if len(data) > max_bytes:
|
||||
raise ValueError(f"attachment size {len(data)} exceeds limit {max_bytes}")
|
||||
return data
|
||||
462
openspace/communication/adapters/whatsapp.py
Normal file
462
openspace/communication/adapters/whatsapp.py
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from openspace.communication.adapters.base import BaseChannelAdapter
|
||||
from openspace.communication.attachment_cache import AttachmentCache
|
||||
from openspace.communication.config import WhatsAppConfig
|
||||
from openspace.communication.types import (
|
||||
AttachmentKind,
|
||||
ChannelMessage,
|
||||
ChannelPlatform,
|
||||
ChannelSource,
|
||||
SendResult,
|
||||
)
|
||||
from openspace.utils.logging import Logger
|
||||
|
||||
logger = Logger.get_logger(__name__)
|
||||
|
||||
|
||||
class WhatsAppAdapter(BaseChannelAdapter):
|
||||
def __init__(
|
||||
self,
|
||||
config: WhatsAppConfig,
|
||||
attachment_cache: AttachmentCache,
|
||||
*,
|
||||
runtime_dir: Optional[Path] = None,
|
||||
poll_interval_seconds: float = 1.0,
|
||||
):
|
||||
super().__init__(ChannelPlatform.WHATSAPP)
|
||||
self.config = config
|
||||
self.attachment_cache = attachment_cache
|
||||
self.runtime_dir = (
|
||||
Path(runtime_dir).expanduser().resolve()
|
||||
if runtime_dir is not None
|
||||
else attachment_cache.base_dir.parent.resolve()
|
||||
)
|
||||
self._poll_interval_seconds = poll_interval_seconds
|
||||
self._http_session: Optional[aiohttp.ClientSession] = None
|
||||
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
|
||||
self._receiver_task: Optional[asyncio.Task] = None
|
||||
self._bridge_process: Optional[subprocess.Popen] = None
|
||||
self._pending_requests: dict[str, asyncio.Future[dict[str, Any]]] = {}
|
||||
self._auth_event = asyncio.Event()
|
||||
self._status_event = asyncio.Event()
|
||||
self._bridge_state = "disconnected"
|
||||
|
||||
def validate_configuration(self) -> None:
|
||||
if self.config.bridge.enforce_loopback and self.config.bridge.host not in {"127.0.0.1", "localhost"}:
|
||||
raise ValueError("WhatsApp bridge host must stay on loopback")
|
||||
|
||||
def get_lock_identity(self) -> Optional[tuple[str, str]]:
|
||||
return ("whatsapp-session", str(self._session_dir().resolve()))
|
||||
|
||||
async def connect(self) -> bool:
|
||||
self.validate_configuration()
|
||||
if self._http_session is None:
|
||||
self._http_session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=20),
|
||||
)
|
||||
|
||||
for attempt in range(2):
|
||||
try:
|
||||
await self._open_control_socket()
|
||||
except Exception as exc:
|
||||
logger.info("WhatsApp bridge connection attempt %s failed: %s", attempt + 1, exc)
|
||||
if attempt == 0:
|
||||
await self._start_bridge_process()
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
return False
|
||||
break
|
||||
|
||||
for _ in range(20):
|
||||
if self._bridge_state == "connected":
|
||||
self._connected = True
|
||||
return True
|
||||
await asyncio.sleep(1)
|
||||
logger.error("WhatsApp bridge control channel opened but WhatsApp session did not connect")
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._connected = False
|
||||
self._bridge_state = "disconnected"
|
||||
self._status_event.clear()
|
||||
self._auth_event.clear()
|
||||
|
||||
if self._receiver_task is not None:
|
||||
self._receiver_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._receiver_task
|
||||
self._receiver_task = None
|
||||
|
||||
if self._ws is not None:
|
||||
await self._ws.close()
|
||||
self._ws = None
|
||||
|
||||
for future in self._pending_requests.values():
|
||||
if not future.done():
|
||||
future.set_exception(RuntimeError("WhatsApp bridge disconnected"))
|
||||
self._pending_requests.clear()
|
||||
|
||||
if self._http_session is not None:
|
||||
await self._http_session.close()
|
||||
self._http_session = None
|
||||
|
||||
if self._bridge_process is not None and self._bridge_process.poll() is None:
|
||||
self._bridge_process.terminate()
|
||||
try:
|
||||
self._bridge_process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._bridge_process.kill()
|
||||
self._bridge_process = None
|
||||
|
||||
async def send_text(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
*,
|
||||
reply_to_message_id: Optional[str] = None,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
if self._ws is None:
|
||||
return SendResult(success=False, error="WhatsApp bridge not initialized")
|
||||
|
||||
last_message_id: Optional[str] = None
|
||||
for chunk in _split_text(content, 60000):
|
||||
try:
|
||||
payload = await self._send_command(
|
||||
{
|
||||
"type": "send",
|
||||
"to": chat_id,
|
||||
"text": chunk,
|
||||
"replyToMessageId": reply_to_message_id,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
return SendResult(success=False, error=str(exc))
|
||||
last_message_id = _optional_str(payload.get("messageId")) or last_message_id
|
||||
return SendResult(success=True, message_id=last_message_id)
|
||||
|
||||
async def send_media(
|
||||
self,
|
||||
chat_id: str,
|
||||
*,
|
||||
file_path: str,
|
||||
mimetype: str,
|
||||
caption: Optional[str] = None,
|
||||
file_name: Optional[str] = None,
|
||||
reply_to_message_id: Optional[str] = None,
|
||||
) -> SendResult:
|
||||
if self._ws is None:
|
||||
return SendResult(success=False, error="WhatsApp bridge not initialized")
|
||||
try:
|
||||
payload = await self._send_command(
|
||||
{
|
||||
"type": "send_media",
|
||||
"to": chat_id,
|
||||
"filePath": file_path,
|
||||
"mimetype": mimetype,
|
||||
"caption": caption,
|
||||
"fileName": file_name,
|
||||
"replyToMessageId": reply_to_message_id,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
return SendResult(success=False, error=str(exc))
|
||||
return SendResult(success=True, message_id=_optional_str(payload.get("messageId")))
|
||||
|
||||
async def _open_control_socket(self) -> None:
|
||||
if self._http_session is None:
|
||||
raise RuntimeError("WhatsApp bridge HTTP session is not initialized")
|
||||
if self._receiver_task is not None:
|
||||
self._receiver_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._receiver_task
|
||||
self._receiver_task = None
|
||||
if self._ws is not None:
|
||||
await self._ws.close()
|
||||
self._ws = None
|
||||
self._auth_event.clear()
|
||||
self._status_event.clear()
|
||||
ws = await self._http_session.ws_connect(
|
||||
self.config.bridge.ws_url,
|
||||
heartbeat=20,
|
||||
autoping=True,
|
||||
max_msg_size=4 * 1024 * 1024,
|
||||
)
|
||||
self._ws = ws
|
||||
self._receiver_task = asyncio.create_task(self._receive_loop(ws))
|
||||
await self._send_ws_json({"type": "auth", "token": self._effective_bridge_token()})
|
||||
await asyncio.wait_for(self._auth_event.wait(), timeout=5)
|
||||
|
||||
async def _receive_loop(self, ws: aiohttp.ClientWebSocketResponse) -> None:
|
||||
try:
|
||||
async for msg in ws:
|
||||
if msg.type != aiohttp.WSMsgType.TEXT:
|
||||
if msg.type in {
|
||||
aiohttp.WSMsgType.CLOSE,
|
||||
aiohttp.WSMsgType.CLOSED,
|
||||
aiohttp.WSMsgType.ERROR,
|
||||
}:
|
||||
break
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(msg.data)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Ignoring invalid WhatsApp bridge JSON: %r", msg.data[:200])
|
||||
continue
|
||||
await self._handle_ws_payload(payload)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("WhatsApp bridge receive loop stopped: %s", exc)
|
||||
finally:
|
||||
if self._ws is ws:
|
||||
self._ws = None
|
||||
self._connected = False
|
||||
self._bridge_state = "disconnected"
|
||||
self._status_event.clear()
|
||||
for request_id, future in list(self._pending_requests.items()):
|
||||
if not future.done():
|
||||
future.set_exception(RuntimeError("WhatsApp bridge disconnected"))
|
||||
self._pending_requests.pop(request_id, None)
|
||||
|
||||
async def _handle_ws_payload(self, payload: dict[str, Any]) -> None:
|
||||
message_type = str(payload.get("type", "")).strip().lower()
|
||||
if message_type == "auth_ok":
|
||||
self._auth_event.set()
|
||||
return
|
||||
if message_type == "status":
|
||||
self._bridge_state = str(payload.get("status", "")).strip().lower() or "disconnected"
|
||||
self._connected = self._bridge_state == "connected"
|
||||
self._status_event.set()
|
||||
return
|
||||
if message_type == "qr":
|
||||
logger.info("WhatsApp bridge is waiting for QR scan")
|
||||
return
|
||||
if message_type == "ack":
|
||||
request_id = _optional_str(payload.get("requestId"))
|
||||
if request_id and request_id in self._pending_requests:
|
||||
future = self._pending_requests.pop(request_id)
|
||||
if not future.done():
|
||||
future.set_result(payload)
|
||||
return
|
||||
if message_type == "error":
|
||||
request_id = _optional_str(payload.get("requestId"))
|
||||
error = _optional_str(payload.get("error")) or "Unknown bridge error"
|
||||
if request_id and request_id in self._pending_requests:
|
||||
future = self._pending_requests.pop(request_id)
|
||||
if not future.done():
|
||||
future.set_exception(RuntimeError(error))
|
||||
else:
|
||||
logger.warning("WhatsApp bridge error: %s", error)
|
||||
return
|
||||
if message_type == "message":
|
||||
message = await self._normalize_event(payload)
|
||||
if message is not None:
|
||||
await self.dispatch_message(message)
|
||||
|
||||
async def _send_command(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if self._ws is None:
|
||||
raise RuntimeError("WhatsApp bridge control channel is not connected")
|
||||
request_id = uuid.uuid4().hex[:12]
|
||||
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
|
||||
self._pending_requests[request_id] = future
|
||||
try:
|
||||
await self._send_ws_json({**payload, "requestId": request_id})
|
||||
return await asyncio.wait_for(future, timeout=20)
|
||||
finally:
|
||||
self._pending_requests.pop(request_id, None)
|
||||
|
||||
async def _send_ws_json(self, payload: dict[str, Any]) -> None:
|
||||
if self._ws is None:
|
||||
raise RuntimeError("WhatsApp bridge control channel is not connected")
|
||||
await self._ws.send_str(json.dumps(payload, ensure_ascii=False))
|
||||
|
||||
async def _normalize_event(self, event: dict[str, Any]) -> Optional[ChannelMessage]:
|
||||
chat_id = str(event.get("chatId", "")).strip()
|
||||
message_id = str(event.get("messageId", "")).strip()
|
||||
sender_id = str(event.get("senderId", "")).strip()
|
||||
if not chat_id or not message_id:
|
||||
return None
|
||||
normalized_sender_id = _normalize_whatsapp_identifier(sender_id)
|
||||
|
||||
source = ChannelSource(
|
||||
platform=ChannelPlatform.WHATSAPP,
|
||||
chat_id=chat_id,
|
||||
chat_type="group" if event.get("isGroup") else "dm",
|
||||
user_id=normalized_sender_id or sender_id or None,
|
||||
user_name=_optional_str(event.get("senderName")),
|
||||
chat_name=_optional_str(event.get("chatName")),
|
||||
)
|
||||
|
||||
session_key = _build_session_key_hint(source)
|
||||
attachments = []
|
||||
media_type = str(event.get("mediaType", "")).strip().lower()
|
||||
attachment_kind = AttachmentKind.IMAGE if media_type == "image" else AttachmentKind.DOCUMENT
|
||||
for media_path in event.get("mediaUrls") or []:
|
||||
attachment = self.attachment_cache.copy_local_file(
|
||||
session_key=session_key,
|
||||
source_path=str(media_path),
|
||||
kind=attachment_kind,
|
||||
)
|
||||
if attachment is not None:
|
||||
attachments.append(attachment)
|
||||
|
||||
body = str(event.get("body", "") or "").strip()
|
||||
return ChannelMessage(
|
||||
source=source,
|
||||
text=body,
|
||||
message_id=message_id,
|
||||
attachments=attachments,
|
||||
reply_to_message_id=_optional_str(event.get("replyToMessageId")),
|
||||
mentions_bot=bool(event.get("mentionsBot")),
|
||||
metadata={
|
||||
"bridge_event": event,
|
||||
"raw_user_id": sender_id or None,
|
||||
"auth_candidates": [
|
||||
candidate
|
||||
for candidate in (
|
||||
sender_id or None,
|
||||
normalized_sender_id or None,
|
||||
f"+{normalized_sender_id}" if normalized_sender_id else None,
|
||||
)
|
||||
if candidate
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
async def _start_bridge_process(self) -> None:
|
||||
if self._bridge_process is not None and self._bridge_process.poll() is None:
|
||||
return
|
||||
|
||||
bridge_script = self._resolve_bridge_script()
|
||||
bridge_dir = bridge_script.parent
|
||||
session_dir = self._session_dir()
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._outbound_media_root().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if self.config.bridge.auto_install_dependencies and not (bridge_dir / "node_modules").exists():
|
||||
subprocess.run(
|
||||
["npm", "install", "--silent"],
|
||||
cwd=bridge_dir,
|
||||
check=True,
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["BRIDGE_TOKEN"] = self._effective_bridge_token()
|
||||
env["BRIDGE_MEDIA_ROOT"] = str(self._outbound_media_root())
|
||||
if self.config.allowed_users:
|
||||
env["WHATSAPP_ALLOWED_USERS"] = ",".join(self.config.allowed_users)
|
||||
if self.config.reply_prefix is not None:
|
||||
env["WHATSAPP_REPLY_PREFIX"] = self.config.reply_prefix
|
||||
|
||||
self._bridge_process = subprocess.Popen(
|
||||
[
|
||||
"node",
|
||||
str(bridge_script),
|
||||
"--host",
|
||||
self.config.bridge.host,
|
||||
"--port",
|
||||
str(self.config.bridge.port),
|
||||
"--session",
|
||||
str(session_dir),
|
||||
"--mode",
|
||||
self.config.bridge.mode,
|
||||
],
|
||||
cwd=str(bridge_dir),
|
||||
env=env,
|
||||
)
|
||||
|
||||
def _resolve_bridge_script(self) -> Path:
|
||||
if self.config.bridge.script_path:
|
||||
custom_path = Path(self.config.bridge.script_path).expanduser().resolve()
|
||||
return custom_path / "bridge.js" if custom_path.is_dir() else custom_path
|
||||
|
||||
source_dir = Path(__file__).resolve().parent.parent / "bridges" / "whatsapp"
|
||||
target_dir = self.runtime_dir / "whatsapp-bridge"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
for filename in ("bridge.js", "allowlist.js", "package.json"):
|
||||
shutil.copy2(source_dir / filename, target_dir / filename)
|
||||
return target_dir / "bridge.js"
|
||||
|
||||
def _effective_bridge_token(self) -> str:
|
||||
configured = _optional_str(self.config.bridge.token)
|
||||
if configured:
|
||||
return configured
|
||||
|
||||
token_path = self.runtime_dir / "bridge_tokens" / "whatsapp.token"
|
||||
if token_path.exists():
|
||||
token = token_path.read_text(encoding="utf-8").strip()
|
||||
if token:
|
||||
return token
|
||||
token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
token = secrets.token_urlsafe(32)
|
||||
token_path.write_text(token, encoding="utf-8")
|
||||
try:
|
||||
token_path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return token
|
||||
|
||||
def _session_dir(self) -> Path:
|
||||
if self.config.bridge.session_dir:
|
||||
return Path(self.config.bridge.session_dir).expanduser().resolve()
|
||||
return (self.runtime_dir / "whatsapp" / "session").resolve()
|
||||
|
||||
def _outbound_media_root(self) -> Path:
|
||||
return (self.runtime_dir / "outbound_media").resolve()
|
||||
|
||||
|
||||
def _split_text(content: str, limit: int) -> list[str]:
|
||||
text = content.strip()
|
||||
if not text:
|
||||
return [""]
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
chunks = []
|
||||
remaining = text
|
||||
while remaining:
|
||||
chunk = remaining[:limit]
|
||||
if len(remaining) > limit:
|
||||
split_at = chunk.rfind("\n")
|
||||
if split_at < limit // 3:
|
||||
split_at = chunk.rfind(" ")
|
||||
if split_at >= limit // 3:
|
||||
chunk = chunk[:split_at]
|
||||
chunks.append(chunk.strip())
|
||||
remaining = remaining[len(chunk):].lstrip()
|
||||
return [chunk for chunk in chunks if chunk]
|
||||
|
||||
|
||||
def _optional_str(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
value = str(value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _build_session_key_hint(source: ChannelSource) -> str:
|
||||
parts = [source.platform.value, source.chat_id]
|
||||
if source.thread_id:
|
||||
parts.append(source.thread_id)
|
||||
return "__".join(part.replace("/", "_") for part in parts if part)
|
||||
|
||||
|
||||
def _normalize_whatsapp_identifier(value: Any) -> str:
|
||||
normalized = re.sub(r":.*@", "@", str(value or "").strip())
|
||||
normalized = re.sub(r"@.*", "", normalized)
|
||||
return normalized.lstrip("+")
|
||||
123
openspace/communication/attachment_cache.py
Normal file
123
openspace/communication/attachment_cache.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from openspace.utils.logging import Logger
|
||||
|
||||
from .types import AttachmentKind, ChannelAttachment
|
||||
|
||||
logger = Logger.get_logger(__name__)
|
||||
|
||||
|
||||
class AttachmentCache:
|
||||
def __init__(
|
||||
self,
|
||||
base_dir: Path,
|
||||
*,
|
||||
max_attachment_bytes: int = 25 * 1024 * 1024,
|
||||
max_session_attachment_bytes: int = 100 * 1024 * 1024,
|
||||
):
|
||||
self.base_dir = base_dir
|
||||
self.max_attachment_bytes = max_attachment_bytes
|
||||
self.max_session_attachment_bytes = max_session_attachment_bytes
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def session_dir(self, session_key: str) -> Path:
|
||||
directory = self.base_dir / session_key / "attachments"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return directory
|
||||
|
||||
def save_bytes(
|
||||
self,
|
||||
*,
|
||||
session_key: str,
|
||||
data: bytes,
|
||||
filename: str,
|
||||
kind: AttachmentKind,
|
||||
mime_type: str = "",
|
||||
) -> Optional[ChannelAttachment]:
|
||||
data_size = len(data)
|
||||
if not self._within_limits(session_key, data_size):
|
||||
return None
|
||||
|
||||
directory = self.session_dir(session_key)
|
||||
safe_name = _safe_name(filename)
|
||||
target = directory / f"{uuid.uuid4().hex[:12]}_{safe_name}"
|
||||
target.write_bytes(data)
|
||||
return ChannelAttachment(
|
||||
kind=kind,
|
||||
path=str(target),
|
||||
name=safe_name,
|
||||
mime_type=mime_type,
|
||||
size_bytes=len(data),
|
||||
)
|
||||
|
||||
def copy_local_file(
|
||||
self,
|
||||
*,
|
||||
session_key: str,
|
||||
source_path: str,
|
||||
kind: AttachmentKind,
|
||||
preferred_name: Optional[str] = None,
|
||||
mime_type: str = "",
|
||||
) -> Optional[ChannelAttachment]:
|
||||
source = Path(source_path).expanduser()
|
||||
if not source.exists():
|
||||
logger.warning("Attachment source does not exist: %s", source)
|
||||
return None
|
||||
source_size = source.stat().st_size
|
||||
if not self._within_limits(session_key, source_size):
|
||||
return None
|
||||
|
||||
directory = self.session_dir(session_key)
|
||||
safe_name = _safe_name(preferred_name or source.name)
|
||||
target = directory / f"{uuid.uuid4().hex[:12]}_{safe_name}"
|
||||
shutil.copy2(source, target)
|
||||
return ChannelAttachment(
|
||||
kind=kind,
|
||||
path=str(target),
|
||||
name=safe_name,
|
||||
mime_type=mime_type,
|
||||
size_bytes=target.stat().st_size,
|
||||
metadata={"source_path": str(source)},
|
||||
)
|
||||
|
||||
def _within_limits(self, session_key: str, attachment_size: int) -> bool:
|
||||
if attachment_size > self.max_attachment_bytes:
|
||||
logger.warning(
|
||||
"Rejecting attachment for session %s because %d bytes exceeds limit %d",
|
||||
session_key,
|
||||
attachment_size,
|
||||
self.max_attachment_bytes,
|
||||
)
|
||||
return False
|
||||
|
||||
session_usage = self._session_usage_bytes(session_key)
|
||||
if session_usage + attachment_size > self.max_session_attachment_bytes:
|
||||
logger.warning(
|
||||
"Rejecting attachment for session %s because session quota would exceed %d bytes",
|
||||
session_key,
|
||||
self.max_session_attachment_bytes,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _session_usage_bytes(self, session_key: str) -> int:
|
||||
directory = self.base_dir / session_key / "attachments"
|
||||
if not directory.exists():
|
||||
return 0
|
||||
|
||||
total = 0
|
||||
for path in directory.iterdir():
|
||||
if path.is_file():
|
||||
total += path.stat().st_size
|
||||
return total
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
value = (name or "attachment").replace("\x00", "").strip()
|
||||
value = Path(value).name
|
||||
return value or "attachment"
|
||||
71
openspace/communication/bridges/whatsapp/allowlist.js
Normal file
71
openspace/communication/bridges/whatsapp/allowlist.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
|
||||
export function normalizeWhatsAppIdentifier(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/:.*@/, '@')
|
||||
.replace(/@.*/, '')
|
||||
.replace(/^\+/, '');
|
||||
}
|
||||
|
||||
export function parseAllowedUsers(rawValue) {
|
||||
return new Set(
|
||||
String(rawValue || '')
|
||||
.split(',')
|
||||
.map((value) => normalizeWhatsAppIdentifier(value))
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function readMappingFile(sessionDir, identifier, suffix = '') {
|
||||
const filePath = path.join(sessionDir, `lid-mapping-${identifier}${suffix}.json`);
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
const normalized = normalizeWhatsAppIdentifier(parsed);
|
||||
return normalized || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function expandWhatsAppIdentifiers(identifier, sessionDir) {
|
||||
const normalized = normalizeWhatsAppIdentifier(identifier);
|
||||
if (!normalized) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const resolved = new Set();
|
||||
const queue = [normalized];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current || resolved.has(current)) {
|
||||
continue;
|
||||
}
|
||||
resolved.add(current);
|
||||
for (const suffix of ['', '_reverse']) {
|
||||
const mapped = readMappingFile(sessionDir, current, suffix);
|
||||
if (mapped && !resolved.has(mapped)) {
|
||||
queue.push(mapped);
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function matchesAllowedUser(senderId, allowedUsers, sessionDir) {
|
||||
if (!allowedUsers || allowedUsers.size === 0) {
|
||||
return true;
|
||||
}
|
||||
const aliases = expandWhatsAppIdentifiers(senderId, sessionDir);
|
||||
for (const alias of aliases) {
|
||||
if (allowedUsers.has(alias)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
577
openspace/communication/bridges/whatsapp/bridge.js
Normal file
577
openspace/communication/bridges/whatsapp/bridge.js
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
#!/usr/bin/env node
|
||||
import {
|
||||
DisconnectReason,
|
||||
downloadMediaMessage,
|
||||
fetchLatestBaileysVersion,
|
||||
makeWASocket,
|
||||
useMultiFileAuthState,
|
||||
} from '@whiskeysockets/baileys';
|
||||
import { Boom } from '@hapi/boom';
|
||||
import pino from 'pino';
|
||||
import path from 'path';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
|
||||
import {
|
||||
matchesAllowedUser,
|
||||
normalizeWhatsAppIdentifier,
|
||||
parseAllowedUsers,
|
||||
} from './allowlist.js';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function getArg(name, defaultValue) {
|
||||
const index = args.indexOf(`--${name}`);
|
||||
return index !== -1 && args[index + 1] ? args[index + 1] : defaultValue;
|
||||
}
|
||||
|
||||
const PORT = parseInt(getArg('port', '3000'), 10);
|
||||
const HOST = getArg('host', '127.0.0.1');
|
||||
const BIND_HOST = HOST === 'localhost' ? '127.0.0.1' : HOST;
|
||||
const SESSION_DIR = path.resolve(
|
||||
getArg('session', path.join(process.env.HOME || '~', '.openspace', 'whatsapp', 'session'))
|
||||
);
|
||||
const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat');
|
||||
const BRIDGE_TOKEN = String(process.env.BRIDGE_TOKEN || '').trim();
|
||||
const DEFAULT_REPLY_PREFIX = 'OpenSpace\n────────────\n';
|
||||
const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined
|
||||
? DEFAULT_REPLY_PREFIX
|
||||
: process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n');
|
||||
const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || '');
|
||||
|
||||
const IMAGE_CACHE_DIR = path.join(SESSION_DIR, '..', 'image_cache');
|
||||
const DOCUMENT_CACHE_DIR = path.join(SESSION_DIR, '..', 'document_cache');
|
||||
const AUDIO_CACHE_DIR = path.join(SESSION_DIR, '..', 'audio_cache');
|
||||
|
||||
const MAX_RECENT_SENT = 50;
|
||||
const MAX_RECENT_INBOUND = 512;
|
||||
const AUTH_TIMEOUT_MS = 5000;
|
||||
|
||||
if (!BRIDGE_TOKEN) {
|
||||
console.error('BRIDGE_TOKEN is required');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!['127.0.0.1', 'localhost'].includes(HOST)) {
|
||||
console.error(`Refusing to bind WhatsApp bridge to non-loopback host: ${HOST}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(SESSION_DIR, { recursive: true });
|
||||
mkdirSync(IMAGE_CACHE_DIR, { recursive: true });
|
||||
mkdirSync(DOCUMENT_CACHE_DIR, { recursive: true });
|
||||
mkdirSync(AUDIO_CACHE_DIR, { recursive: true });
|
||||
|
||||
const logger = pino({ level: 'warn' });
|
||||
const clients = new Set();
|
||||
const recentlySentIds = new Set();
|
||||
const recentInboundById = new Map();
|
||||
|
||||
let sock = null;
|
||||
let connectionState = 'disconnected';
|
||||
let reconnectTimer = null;
|
||||
|
||||
function broadcast(payload) {
|
||||
const encoded = JSON.stringify(payload);
|
||||
for (const ws of clients) {
|
||||
if (ws.readyState === WebSocket.OPEN && ws._authed) {
|
||||
ws.send(encoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function recordRecentOutbound(messageId) {
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
recentlySentIds.delete(messageId);
|
||||
recentlySentIds.add(messageId);
|
||||
while (recentlySentIds.size > MAX_RECENT_SENT) {
|
||||
recentlySentIds.delete(recentlySentIds.values().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
function recordRecentInbound(messageId, rawMessage) {
|
||||
if (!messageId || !rawMessage) {
|
||||
return;
|
||||
}
|
||||
recentInboundById.delete(messageId);
|
||||
recentInboundById.set(messageId, rawMessage);
|
||||
while (recentInboundById.size > MAX_RECENT_INBOUND) {
|
||||
const firstKey = recentInboundById.keys().next().value;
|
||||
recentInboundById.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
function currentStatusPayload() {
|
||||
return { type: 'status', status: connectionState };
|
||||
}
|
||||
|
||||
function buildQuotedOptions(replyToMessageId) {
|
||||
if (!replyToMessageId) {
|
||||
return {};
|
||||
}
|
||||
const quoted = recentInboundById.get(String(replyToMessageId).trim());
|
||||
return quoted ? { quoted } : {};
|
||||
}
|
||||
|
||||
function formatOutgoingMessage(message) {
|
||||
if (WHATSAPP_MODE !== 'self-chat') {
|
||||
return message;
|
||||
}
|
||||
return REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : message;
|
||||
}
|
||||
|
||||
function buildLidMap() {
|
||||
const mapping = {};
|
||||
try {
|
||||
for (const fileName of readdirSync(SESSION_DIR)) {
|
||||
const match = fileName.match(/^lid-mapping-(.+)\.json$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const value = JSON.parse(readFileSync(path.join(SESSION_DIR, fileName), 'utf8'));
|
||||
const normalized = normalizeWhatsAppIdentifier(value);
|
||||
if (normalized) {
|
||||
mapping[normalized] = match[1];
|
||||
mapping[match[1]] = normalized;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
let lidToPhone = buildLidMap();
|
||||
|
||||
function normalizeId(value) {
|
||||
return normalizeWhatsAppIdentifier(value);
|
||||
}
|
||||
|
||||
function getMyIdentifiers() {
|
||||
const ids = new Set();
|
||||
if (sock?.user?.id) {
|
||||
ids.add(normalizeId(sock.user.id));
|
||||
}
|
||||
if (sock?.user?.lid) {
|
||||
ids.add(normalizeId(sock.user.lid));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function getMessageContainer(message) {
|
||||
return message?.message || {};
|
||||
}
|
||||
|
||||
function extractContextInfo(message) {
|
||||
const container = getMessageContainer(message);
|
||||
return (
|
||||
container.extendedTextMessage?.contextInfo
|
||||
|| container.imageMessage?.contextInfo
|
||||
|| container.videoMessage?.contextInfo
|
||||
|| container.documentMessage?.contextInfo
|
||||
|| container.audioMessage?.contextInfo
|
||||
|| container.conversation?.contextInfo
|
||||
|| null
|
||||
);
|
||||
}
|
||||
|
||||
async function cacheMedia(rawMessage, mediaMessage, targetDir, prefix, fallbackExt) {
|
||||
const buffer = await downloadMediaMessage(
|
||||
rawMessage,
|
||||
'buffer',
|
||||
{},
|
||||
{ logger, reuploadRequest: sock.updateMediaMessage }
|
||||
);
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
const mime = mediaMessage.mimetype || '';
|
||||
const ext = mime.includes('/') ? `.${mime.split('/')[1].split(';')[0]}` : fallbackExt;
|
||||
const filePath = path.join(targetDir, `${prefix}_${randomBytes(6).toString('hex')}${ext || fallbackExt}`);
|
||||
writeFileSync(filePath, buffer);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function resolveAllowedFilePath(filePath) {
|
||||
const mediaRootRaw = String(process.env.BRIDGE_MEDIA_ROOT || '').trim();
|
||||
if (!mediaRootRaw) {
|
||||
throw new Error('BRIDGE_MEDIA_ROOT is not configured');
|
||||
}
|
||||
const mediaRoot = realpathSync(mediaRootRaw);
|
||||
const resolvedPath = realpathSync(String(filePath || ''));
|
||||
const relative = path.relative(mediaRoot, resolvedPath);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`File path escapes bridge media root: ${filePath}`);
|
||||
}
|
||||
const stat = statSync(resolvedPath);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`File is not a regular file: ${filePath}`);
|
||||
}
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
async function sendText(to, text, replyToMessageId) {
|
||||
if (!sock || connectionState !== 'connected') {
|
||||
throw new Error('Not connected to WhatsApp');
|
||||
}
|
||||
const sent = await sock.sendMessage(
|
||||
to,
|
||||
{ text: formatOutgoingMessage(text) },
|
||||
buildQuotedOptions(replyToMessageId)
|
||||
);
|
||||
recordRecentOutbound(sent?.key?.id);
|
||||
return sent;
|
||||
}
|
||||
|
||||
async function sendMedia(to, filePath, mimetype, caption, fileName, replyToMessageId) {
|
||||
if (!sock || connectionState !== 'connected') {
|
||||
throw new Error('Not connected to WhatsApp');
|
||||
}
|
||||
|
||||
const resolvedPath = resolveAllowedFilePath(filePath);
|
||||
if (!existsSync(resolvedPath)) {
|
||||
throw new Error(`File not found: ${resolvedPath}`);
|
||||
}
|
||||
const buffer = readFileSync(resolvedPath);
|
||||
const normalizedMime = String(mimetype || '').toLowerCase();
|
||||
let payload;
|
||||
if (normalizedMime.startsWith('image/')) {
|
||||
payload = { image: buffer, caption: caption || undefined };
|
||||
} else if (normalizedMime.startsWith('video/')) {
|
||||
payload = { video: buffer, caption: caption || undefined };
|
||||
} else if (normalizedMime.startsWith('audio/')) {
|
||||
payload = {
|
||||
audio: buffer,
|
||||
mimetype: normalizedMime || 'audio/ogg; codecs=opus',
|
||||
ptt: normalizedMime.includes('ogg') || normalizedMime.includes('opus'),
|
||||
};
|
||||
} else {
|
||||
payload = {
|
||||
document: buffer,
|
||||
fileName: fileName || path.basename(resolvedPath),
|
||||
caption: caption || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const sent = await sock.sendMessage(
|
||||
to,
|
||||
payload,
|
||||
buildQuotedOptions(replyToMessageId)
|
||||
);
|
||||
recordRecentOutbound(sent?.key?.id);
|
||||
return sent;
|
||||
}
|
||||
|
||||
async function handleInboundMessage(rawMessage) {
|
||||
if (!rawMessage?.message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chatId = rawMessage.key?.remoteJid || '';
|
||||
const senderId = rawMessage.key?.participant || chatId;
|
||||
const isGroup = chatId.endsWith('@g.us');
|
||||
const senderNumber = senderId.replace(/@.*/, '');
|
||||
|
||||
if (rawMessage.key?.fromMe) {
|
||||
if (isGroup || chatId.includes('status')) {
|
||||
return;
|
||||
}
|
||||
if (WHATSAPP_MODE === 'bot') {
|
||||
return;
|
||||
}
|
||||
|
||||
const myIds = getMyIdentifiers();
|
||||
const chatNumber = normalizeId(chatId);
|
||||
if (!myIds.has(chatNumber)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawMessage.key?.fromMe && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = getMessageContainer(rawMessage);
|
||||
const contextInfo = extractContextInfo(rawMessage);
|
||||
const mentionedIds = (contextInfo?.mentionedJid || []).map((value) => normalizeId(value));
|
||||
const mentionsBot = mentionedIds.some((value) => getMyIdentifiers().has(value));
|
||||
const replyToMessageId = contextInfo?.stanzaId || null;
|
||||
|
||||
let body = '';
|
||||
let hasMedia = false;
|
||||
let mediaType = '';
|
||||
const mediaUrls = [];
|
||||
|
||||
if (container.conversation) {
|
||||
body = container.conversation;
|
||||
} else if (container.extendedTextMessage?.text) {
|
||||
body = container.extendedTextMessage.text;
|
||||
} else if (container.imageMessage) {
|
||||
body = container.imageMessage.caption || '';
|
||||
hasMedia = true;
|
||||
mediaType = 'image';
|
||||
try {
|
||||
mediaUrls.push(await cacheMedia(rawMessage, container.imageMessage, IMAGE_CACHE_DIR, 'img', '.jpg'));
|
||||
} catch (error) {
|
||||
console.error('[bridge] Failed to download image:', error.message);
|
||||
}
|
||||
} else if (container.videoMessage) {
|
||||
body = container.videoMessage.caption || '';
|
||||
hasMedia = true;
|
||||
mediaType = 'video';
|
||||
try {
|
||||
mediaUrls.push(await cacheMedia(rawMessage, container.videoMessage, DOCUMENT_CACHE_DIR, 'vid', '.mp4'));
|
||||
} catch (error) {
|
||||
console.error('[bridge] Failed to download video:', error.message);
|
||||
}
|
||||
} else if (container.audioMessage || container.pttMessage) {
|
||||
hasMedia = true;
|
||||
mediaType = container.pttMessage ? 'ptt' : 'audio';
|
||||
try {
|
||||
const audioMessage = container.pttMessage || container.audioMessage;
|
||||
mediaUrls.push(await cacheMedia(rawMessage, audioMessage, AUDIO_CACHE_DIR, 'aud', '.ogg'));
|
||||
} catch (error) {
|
||||
console.error('[bridge] Failed to download audio:', error.message);
|
||||
}
|
||||
} else if (container.documentMessage) {
|
||||
body = container.documentMessage.caption || '';
|
||||
hasMedia = true;
|
||||
mediaType = 'document';
|
||||
try {
|
||||
mediaUrls.push(
|
||||
await cacheMedia(
|
||||
rawMessage,
|
||||
container.documentMessage,
|
||||
DOCUMENT_CACHE_DIR,
|
||||
'doc',
|
||||
path.extname(container.documentMessage.fileName || '') || '.bin'
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[bridge] Failed to download document:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const messageId = rawMessage.key?.id;
|
||||
if (messageId && recentlySentIds.has(messageId)) {
|
||||
recentlySentIds.delete(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageId) {
|
||||
recordRecentInbound(messageId, rawMessage);
|
||||
}
|
||||
|
||||
const normalizedSenderId = lidToPhone[normalizeId(senderId)] || senderId;
|
||||
const event = {
|
||||
type: 'message',
|
||||
messageId,
|
||||
chatId,
|
||||
senderId: normalizedSenderId,
|
||||
senderName: rawMessage.pushName || senderNumber,
|
||||
chatName: isGroup ? chatId.split('@')[0] : (rawMessage.pushName || senderNumber),
|
||||
isGroup,
|
||||
body,
|
||||
hasMedia,
|
||||
mediaType,
|
||||
mediaUrls,
|
||||
replyToMessageId,
|
||||
mentionedIds,
|
||||
mentionsBot,
|
||||
timestamp: rawMessage.messageTimestamp,
|
||||
};
|
||||
broadcast(event);
|
||||
}
|
||||
|
||||
async function startSocket() {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
|
||||
sock = makeWASocket({
|
||||
version,
|
||||
auth: state,
|
||||
logger,
|
||||
printQRInTerminal: false,
|
||||
browser: ['OpenSpace', 'Chrome', '120.0'],
|
||||
syncFullHistory: false,
|
||||
markOnlineOnConnect: false,
|
||||
getMessage: async () => ({ conversation: '' }),
|
||||
});
|
||||
|
||||
sock.ev.on('creds.update', () => {
|
||||
saveCreds();
|
||||
lidToPhone = buildLidMap();
|
||||
});
|
||||
|
||||
sock.ev.on('connection.update', (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
if (qr) {
|
||||
console.log('\nScan this QR code with WhatsApp on your phone:\n');
|
||||
qrcode.generate(qr, { small: true });
|
||||
console.log('\nWaiting for scan...\n');
|
||||
broadcast({ type: 'qr', qr });
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
const reason = new Boom(lastDisconnect?.error)?.output?.statusCode;
|
||||
connectionState = 'disconnected';
|
||||
broadcast(currentStatusPayload());
|
||||
if (reason === DisconnectReason.loggedOut) {
|
||||
console.log('Logged out. Delete session and restart to re-authenticate.');
|
||||
process.exit(1);
|
||||
}
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(
|
||||
() => startSocket().catch((error) => console.error('WhatsApp reconnect failed:', error)),
|
||||
reason === 515 ? 1000 : 3000
|
||||
);
|
||||
} else if (connection === 'open') {
|
||||
connectionState = 'connected';
|
||||
console.log('WhatsApp connected');
|
||||
broadcast(currentStatusPayload());
|
||||
}
|
||||
});
|
||||
|
||||
sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
||||
if (type !== 'notify' && type !== 'append') {
|
||||
return;
|
||||
}
|
||||
for (const message of messages) {
|
||||
try {
|
||||
await handleInboundMessage(message);
|
||||
} catch (error) {
|
||||
console.error('[bridge] Failed to normalize inbound message:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startBridgeServer() {
|
||||
const wss = new WebSocketServer({ host: BIND_HOST, port: PORT });
|
||||
console.log(`OpenSpace WhatsApp bridge listening on ws://${BIND_HOST}:${PORT} (mode: ${WHATSAPP_MODE})`);
|
||||
|
||||
wss.on('connection', (ws, request) => {
|
||||
if (request.headers.origin) {
|
||||
ws.close(4003, 'Origin header is not allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
ws._authed = false;
|
||||
clients.add(ws);
|
||||
const authTimeout = setTimeout(() => {
|
||||
if (!ws._authed) {
|
||||
ws.close(4001, 'Authentication timeout');
|
||||
}
|
||||
}, AUTH_TIMEOUT_MS);
|
||||
|
||||
ws.once('message', (raw) => {
|
||||
try {
|
||||
const payload = JSON.parse(raw.toString());
|
||||
if (payload.type !== 'auth' || payload.token !== BRIDGE_TOKEN) {
|
||||
ws.close(4003, 'Invalid bridge token');
|
||||
return;
|
||||
}
|
||||
ws._authed = true;
|
||||
clearTimeout(authTimeout);
|
||||
ws.send(JSON.stringify({ type: 'auth_ok' }));
|
||||
ws.send(JSON.stringify(currentStatusPayload()));
|
||||
|
||||
ws.on('message', async (commandRaw) => {
|
||||
try {
|
||||
const command = JSON.parse(commandRaw.toString());
|
||||
const requestId = command.requestId || null;
|
||||
let sent = null;
|
||||
if (command.type === 'send') {
|
||||
sent = await sendText(command.to, command.text || '', command.replyToMessageId);
|
||||
} else if (command.type === 'send_media') {
|
||||
sent = await sendMedia(
|
||||
command.to,
|
||||
command.filePath,
|
||||
command.mimetype,
|
||||
command.caption,
|
||||
command.fileName,
|
||||
command.replyToMessageId
|
||||
);
|
||||
} else {
|
||||
throw new Error(`Unsupported bridge command: ${command.type}`);
|
||||
}
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ack',
|
||||
requestId,
|
||||
messageId: sent?.key?.id || null,
|
||||
}));
|
||||
} catch (error) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
requestId: (() => {
|
||||
try {
|
||||
return JSON.parse(commandRaw.toString()).requestId || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
error: error?.message || String(error),
|
||||
}));
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
ws.close(4003, 'Invalid auth payload');
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
clearTimeout(authTimeout);
|
||||
clients.delete(ws);
|
||||
});
|
||||
ws.on('error', () => {
|
||||
clearTimeout(authTimeout);
|
||||
clients.delete(ws);
|
||||
});
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
async function shutdown(server) {
|
||||
clearTimeout(reconnectTimer);
|
||||
for (const ws of clients) {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {}
|
||||
}
|
||||
clients.clear();
|
||||
if (server) {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
if (sock) {
|
||||
try {
|
||||
sock.end(new Error('Bridge shutdown'));
|
||||
} catch {}
|
||||
sock = null;
|
||||
}
|
||||
}
|
||||
|
||||
const server = startBridgeServer();
|
||||
startSocket().catch((error) => {
|
||||
console.error('Failed to start WhatsApp socket:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, async () => {
|
||||
try {
|
||||
await shutdown(server);
|
||||
} finally {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
17
openspace/communication/bridges/whatsapp/package.json
Normal file
17
openspace/communication/bridges/whatsapp/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "openspace-whatsapp-bridge",
|
||||
"version": "1.0.0",
|
||||
"description": "WhatsApp bridge for OpenSpace using Baileys",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node bridge.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hapi/boom": "^10.0.1",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"pino": "^9.0.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
375
openspace/communication/config.py
Normal file
375
openspace/communication/config.py
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from openspace.host_detection import load_runtime_env
|
||||
from openspace.utils.logging import Logger
|
||||
|
||||
logger = Logger.get_logger(__name__)
|
||||
|
||||
|
||||
class GatewayServerConfig(BaseModel):
|
||||
host: str = "127.0.0.1"
|
||||
port: int = Field(8765, ge=1, le=65535)
|
||||
health_path: str = "/health"
|
||||
|
||||
@field_validator("health_path")
|
||||
@classmethod
|
||||
def validate_health_path(cls, value: str) -> str:
|
||||
value = value.strip() or "/health"
|
||||
if not value.startswith("/"):
|
||||
value = "/" + value
|
||||
return value
|
||||
|
||||
|
||||
class AgentExecutionConfig(BaseModel):
|
||||
max_iterations: int = Field(20, ge=1, le=200)
|
||||
enable_recording: bool = True
|
||||
recording_backends: List[str] = Field(default_factory=lambda: ["shell"])
|
||||
backend_scope: Optional[List[str]] = None
|
||||
grounding_config_path: Optional[str] = None
|
||||
workspace_root: Optional[str] = None
|
||||
llm_timeout: float = Field(120.0, ge=1.0, le=3600.0)
|
||||
|
||||
|
||||
class SessionProcessingConfig(BaseModel):
|
||||
history_max_turns: int = Field(12, ge=1, le=100)
|
||||
max_parallel_sessions: int = Field(2, ge=1, le=64)
|
||||
idle_ttl_seconds: int = Field(900, ge=30, le=86400)
|
||||
per_session_queue_size: int = Field(32, ge=1, le=512)
|
||||
whatsapp_poll_interval_seconds: float = Field(1.0, ge=0.1, le=60.0)
|
||||
max_attachment_bytes: int = Field(25 * 1024 * 1024, ge=1, le=512 * 1024 * 1024)
|
||||
max_session_attachment_bytes: int = Field(
|
||||
100 * 1024 * 1024,
|
||||
ge=1,
|
||||
le=10 * 1024 * 1024 * 1024,
|
||||
)
|
||||
|
||||
|
||||
class ChannelAccessConfig(BaseModel):
|
||||
enabled: bool = False
|
||||
allow_all_users: bool = False
|
||||
allowed_users: List[str] = Field(default_factory=list)
|
||||
allow_dm: bool = True
|
||||
allow_groups: bool = True
|
||||
group_policy: Literal["disabled", "mention_only", "reply_or_mention", "all"] = "reply_or_mention"
|
||||
|
||||
|
||||
class WhatsAppBridgeConfig(BaseModel):
|
||||
host: str = "127.0.0.1"
|
||||
port: int = Field(3000, ge=1, le=65535)
|
||||
script_path: Optional[str] = None
|
||||
session_dir: Optional[str] = None
|
||||
mode: Literal["self-chat", "bot"] = "self-chat"
|
||||
auto_install_dependencies: bool = True
|
||||
token: Optional[str] = None
|
||||
enforce_loopback: bool = True
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_loopback_constraints(self) -> "WhatsAppBridgeConfig":
|
||||
host = self.host.strip().lower() or "127.0.0.1"
|
||||
if self.enforce_loopback and host not in {"127.0.0.1", "localhost"}:
|
||||
raise ValueError(
|
||||
"WhatsApp bridge host must be loopback when enforce_loopback is enabled"
|
||||
)
|
||||
self.host = host
|
||||
return self
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return f"http://{self.host}:{self.port}"
|
||||
|
||||
@property
|
||||
def ws_url(self) -> str:
|
||||
return f"ws://{self.listen_host}:{self.port}"
|
||||
|
||||
@property
|
||||
def listen_host(self) -> str:
|
||||
return "127.0.0.1" if self.host == "localhost" else self.host
|
||||
|
||||
|
||||
class WhatsAppConfig(ChannelAccessConfig):
|
||||
bridge: WhatsAppBridgeConfig = Field(default_factory=WhatsAppBridgeConfig)
|
||||
reply_prefix: Optional[str] = None
|
||||
|
||||
|
||||
class FeishuConfig(ChannelAccessConfig):
|
||||
app_id: Optional[str] = None
|
||||
app_secret: Optional[str] = None
|
||||
domain: Literal["feishu", "lark"] = "feishu"
|
||||
connection_mode: Literal["webhook", "websocket"] = "webhook"
|
||||
verification_token: Optional[str] = None
|
||||
encrypt_key: Optional[str] = None
|
||||
bot_open_id: Optional[str] = None
|
||||
webhook_path: str = "/feishu/webhook"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_webhook_requirements(self) -> "FeishuConfig":
|
||||
if self.enabled and self.connection_mode == "webhook" and not (self.verification_token or "").strip():
|
||||
raise ValueError("Feishu webhook mode requires verification_token")
|
||||
return self
|
||||
|
||||
@field_validator("webhook_path")
|
||||
@classmethod
|
||||
def validate_webhook_path(cls, value: str) -> str:
|
||||
value = value.strip() or "/feishu/webhook"
|
||||
if not value.startswith("/"):
|
||||
value = "/" + value
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_webhook_security(self) -> "FeishuConfig":
|
||||
if self.enabled and self.connection_mode == "webhook":
|
||||
token = (self.verification_token or "").strip()
|
||||
if not token:
|
||||
raise ValueError(
|
||||
"Feishu webhook mode requires verification_token when enabled"
|
||||
)
|
||||
self.verification_token = token
|
||||
if self.encrypt_key is not None:
|
||||
self.encrypt_key = self.encrypt_key.strip() or None
|
||||
if self.bot_open_id is not None:
|
||||
self.bot_open_id = self.bot_open_id.strip() or None
|
||||
return self
|
||||
|
||||
|
||||
class CommunicationConfig(BaseModel):
|
||||
data_dir: str = Field(
|
||||
default_factory=lambda: str(
|
||||
Path(__file__).resolve().parents[2] / "logs" / "communication"
|
||||
)
|
||||
)
|
||||
server: GatewayServerConfig = Field(default_factory=GatewayServerConfig)
|
||||
agent: AgentExecutionConfig = Field(default_factory=AgentExecutionConfig)
|
||||
sessions: SessionProcessingConfig = Field(default_factory=SessionProcessingConfig)
|
||||
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
|
||||
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
|
||||
|
||||
@property
|
||||
def openspace(self) -> AgentExecutionConfig:
|
||||
return self.agent
|
||||
|
||||
@property
|
||||
def runtime(self) -> SessionProcessingConfig:
|
||||
return self.sessions
|
||||
|
||||
@property
|
||||
def data_path(self) -> Path:
|
||||
return Path(self.data_dir).expanduser().resolve()
|
||||
|
||||
@property
|
||||
def sessions_dir(self) -> Path:
|
||||
return self.data_path / "sessions"
|
||||
|
||||
@property
|
||||
def bridge_assets_dir(self) -> Path:
|
||||
return Path(__file__).resolve().parent / "bridges" / "whatsapp"
|
||||
|
||||
@property
|
||||
def runtime_status_path(self) -> Path:
|
||||
return self.data_path / "runtime_status.json"
|
||||
|
||||
@property
|
||||
def locks_dir(self) -> Path:
|
||||
return self.data_path / "locks"
|
||||
|
||||
@property
|
||||
def bridge_tokens_dir(self) -> Path:
|
||||
return self.data_path / "bridge_tokens"
|
||||
|
||||
@property
|
||||
def whatsapp_bridge_token_path(self) -> Path:
|
||||
return self.bridge_tokens_dir / "whatsapp.token"
|
||||
|
||||
@property
|
||||
def outbound_media_dir(self) -> Path:
|
||||
return self.data_path / "outbound_media"
|
||||
|
||||
@property
|
||||
def feishu_seen_message_ids_path(self) -> Path:
|
||||
return self.data_path / "feishu_seen_message_ids.json"
|
||||
|
||||
@property
|
||||
def enabled_platforms(self) -> List[str]:
|
||||
platforms: List[str] = []
|
||||
if self.whatsapp.enabled:
|
||||
platforms.append("whatsapp")
|
||||
if self.feishu.enabled:
|
||||
platforms.append("feishu")
|
||||
return platforms
|
||||
|
||||
|
||||
def load_communication_config(path: Optional[str] = None) -> CommunicationConfig:
|
||||
load_runtime_env()
|
||||
config_path = _resolve_config_path(path)
|
||||
raw: Dict[str, Any] = {}
|
||||
|
||||
if config_path and config_path.is_file():
|
||||
with open(config_path, "r", encoding="utf-8") as handle:
|
||||
raw = json.load(handle) or {}
|
||||
raw = _normalize_legacy_keys(raw)
|
||||
logger.info("Loaded communication config: %s", config_path)
|
||||
|
||||
config = CommunicationConfig.model_validate(raw)
|
||||
_apply_env_overrides(config)
|
||||
return CommunicationConfig.model_validate(config.model_dump(mode="python"))
|
||||
|
||||
|
||||
def _resolve_config_path(path: Optional[str]) -> Optional[Path]:
|
||||
explicit_path = Path(path).expanduser() if path else None
|
||||
if explicit_path is not None:
|
||||
if not explicit_path.is_file():
|
||||
raise FileNotFoundError(f"Communication config file not found: {explicit_path}")
|
||||
return explicit_path
|
||||
|
||||
env_path = (
|
||||
Path(os.environ["OPENSPACE_COMMUNICATION_CONFIG"]).expanduser()
|
||||
if os.environ.get("OPENSPACE_COMMUNICATION_CONFIG")
|
||||
else None
|
||||
)
|
||||
if env_path is not None:
|
||||
if not env_path.is_file():
|
||||
raise FileNotFoundError(f"Communication config file not found: {env_path}")
|
||||
return env_path
|
||||
|
||||
default_path = Path(__file__).resolve().parents[1] / "config" / "config_communication.json"
|
||||
return default_path if default_path.is_file() else None
|
||||
|
||||
|
||||
def _apply_env_overrides(config: CommunicationConfig) -> None:
|
||||
_maybe_set_bool(config.whatsapp, "enabled", os.getenv("WHATSAPP_ENABLED"))
|
||||
_maybe_set_bool(config.whatsapp, "allow_all_users", os.getenv("WHATSAPP_ALLOW_ALL_USERS"))
|
||||
_maybe_set_list(config.whatsapp, "allowed_users", os.getenv("WHATSAPP_ALLOWED_USERS"))
|
||||
_maybe_set_bool(config.whatsapp, "allow_dm", os.getenv("WHATSAPP_ALLOW_DM"))
|
||||
_maybe_set_bool(config.whatsapp, "allow_groups", os.getenv("WHATSAPP_ALLOW_GROUPS"))
|
||||
_maybe_set_str(config.whatsapp, "group_policy", os.getenv("WHATSAPP_GROUP_POLICY"))
|
||||
_maybe_set_str(config.whatsapp.bridge, "host", os.getenv("WHATSAPP_BRIDGE_HOST"))
|
||||
_maybe_set_int(config.whatsapp.bridge, "port", os.getenv("WHATSAPP_BRIDGE_PORT"))
|
||||
_maybe_set_str(config.whatsapp.bridge, "script_path", os.getenv("WHATSAPP_BRIDGE_SCRIPT"))
|
||||
_maybe_set_str(config.whatsapp.bridge, "session_dir", os.getenv("WHATSAPP_SESSION_DIR"))
|
||||
_maybe_set_str(config.whatsapp.bridge, "mode", os.getenv("WHATSAPP_MODE"))
|
||||
_maybe_set_str(config.whatsapp.bridge, "token", os.getenv("WHATSAPP_BRIDGE_TOKEN"))
|
||||
_maybe_set_bool(config.whatsapp.bridge, "enforce_loopback", os.getenv("WHATSAPP_BRIDGE_ENFORCE_LOOPBACK"))
|
||||
_maybe_set_str(config.whatsapp, "reply_prefix", os.getenv("WHATSAPP_REPLY_PREFIX"))
|
||||
|
||||
_maybe_set_bool(config.feishu, "enabled", os.getenv("FEISHU_ENABLED"))
|
||||
_maybe_set_bool(config.feishu, "allow_all_users", os.getenv("FEISHU_ALLOW_ALL_USERS"))
|
||||
_maybe_set_list(config.feishu, "allowed_users", os.getenv("FEISHU_ALLOWED_USERS"))
|
||||
_maybe_set_bool(config.feishu, "allow_dm", os.getenv("FEISHU_ALLOW_DM"))
|
||||
_maybe_set_bool(config.feishu, "allow_groups", os.getenv("FEISHU_ALLOW_GROUPS"))
|
||||
_maybe_set_str(config.feishu, "group_policy", os.getenv("FEISHU_GROUP_POLICY"))
|
||||
_maybe_set_str(config.feishu, "app_id", os.getenv("FEISHU_APP_ID"))
|
||||
_maybe_set_str(config.feishu, "app_secret", os.getenv("FEISHU_APP_SECRET"))
|
||||
_maybe_set_str(config.feishu, "verification_token", os.getenv("FEISHU_VERIFICATION_TOKEN"))
|
||||
_maybe_set_str(config.feishu, "encrypt_key", os.getenv("FEISHU_ENCRYPT_KEY"))
|
||||
_maybe_set_str(config.feishu, "bot_open_id", os.getenv("FEISHU_BOT_OPEN_ID"))
|
||||
_maybe_set_str(config.feishu, "domain", os.getenv("FEISHU_DOMAIN"))
|
||||
_maybe_set_str(config.feishu, "connection_mode", os.getenv("FEISHU_CONNECTION_MODE"))
|
||||
_maybe_set_str(config.feishu, "webhook_path", os.getenv("FEISHU_WEBHOOK_PATH"))
|
||||
|
||||
_maybe_set_str(config, "data_dir", os.getenv("OPENSPACE_COMMUNICATION_DATA_DIR"))
|
||||
_maybe_set_str(config.server, "host", os.getenv("OPENSPACE_COMMUNICATION_HOST"))
|
||||
_maybe_set_int(config.server, "port", os.getenv("OPENSPACE_COMMUNICATION_PORT"))
|
||||
_maybe_set_int(
|
||||
config.agent,
|
||||
"max_iterations",
|
||||
os.getenv("OPENSPACE_COMMUNICATION_MAX_ITERATIONS") or os.getenv("OPENSPACE_MAX_ITERATIONS"),
|
||||
)
|
||||
_maybe_set_bool(
|
||||
config.agent,
|
||||
"enable_recording",
|
||||
os.getenv("OPENSPACE_COMMUNICATION_ENABLE_RECORDING") or os.getenv("OPENSPACE_ENABLE_RECORDING"),
|
||||
)
|
||||
_maybe_set_list(
|
||||
config.agent,
|
||||
"recording_backends",
|
||||
os.getenv("OPENSPACE_COMMUNICATION_RECORDING_BACKENDS"),
|
||||
)
|
||||
_maybe_set_list(
|
||||
config.agent,
|
||||
"backend_scope",
|
||||
os.getenv("OPENSPACE_COMMUNICATION_BACKEND_SCOPE") or os.getenv("OPENSPACE_BACKEND_SCOPE"),
|
||||
)
|
||||
_maybe_set_str(
|
||||
config.agent,
|
||||
"grounding_config_path",
|
||||
os.getenv("OPENSPACE_COMMUNICATION_GROUNDING_CONFIG_PATH") or os.getenv("OPENSPACE_CONFIG_PATH"),
|
||||
)
|
||||
_maybe_set_str(config.agent, "workspace_root", os.getenv("OPENSPACE_COMMUNICATION_WORKSPACE_ROOT"))
|
||||
_maybe_set_float(config.agent, "llm_timeout", os.getenv("OPENSPACE_COMMUNICATION_LLM_TIMEOUT"))
|
||||
_maybe_set_int(config.sessions, "history_max_turns", os.getenv("OPENSPACE_COMMUNICATION_HISTORY_TURNS"))
|
||||
_maybe_set_int(config.sessions, "max_parallel_sessions", os.getenv("OPENSPACE_COMMUNICATION_MAX_PARALLEL"))
|
||||
_maybe_set_int(config.sessions, "idle_ttl_seconds", os.getenv("OPENSPACE_COMMUNICATION_IDLE_TTL"))
|
||||
_maybe_set_int(config.sessions, "per_session_queue_size", os.getenv("OPENSPACE_COMMUNICATION_QUEUE_SIZE"))
|
||||
_maybe_set_int(
|
||||
config.sessions,
|
||||
"max_attachment_bytes",
|
||||
os.getenv("OPENSPACE_COMMUNICATION_MAX_ATTACHMENT_BYTES"),
|
||||
)
|
||||
_maybe_set_int(
|
||||
config.sessions,
|
||||
"max_session_attachment_bytes",
|
||||
os.getenv("OPENSPACE_COMMUNICATION_MAX_SESSION_ATTACHMENT_BYTES"),
|
||||
)
|
||||
_maybe_set_float(
|
||||
config.sessions,
|
||||
"whatsapp_poll_interval_seconds",
|
||||
os.getenv("OPENSPACE_COMMUNICATION_WHATSAPP_POLL_INTERVAL"),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_legacy_keys(raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||
normalized = dict(raw)
|
||||
if "agent" not in normalized and "openspace" in normalized:
|
||||
normalized["agent"] = normalized["openspace"]
|
||||
if "sessions" not in normalized and "runtime" in normalized:
|
||||
normalized["sessions"] = normalized["runtime"]
|
||||
return normalized
|
||||
|
||||
|
||||
def _maybe_set_bool(target: Any, field_name: str, raw: Optional[str]) -> None:
|
||||
if raw is None:
|
||||
return
|
||||
lowered = raw.strip().lower()
|
||||
if lowered in {"true", "1", "yes", "on"}:
|
||||
setattr(target, field_name, True)
|
||||
elif lowered in {"false", "0", "no", "off"}:
|
||||
setattr(target, field_name, False)
|
||||
|
||||
|
||||
def _maybe_set_int(target: Any, field_name: str, raw: Optional[str]) -> None:
|
||||
if raw is None or not raw.strip():
|
||||
return
|
||||
try:
|
||||
setattr(target, field_name, int(raw))
|
||||
except ValueError:
|
||||
logger.warning("Invalid integer for %s: %r", field_name, raw)
|
||||
|
||||
|
||||
def _maybe_set_list(target: Any, field_name: str, raw: Optional[str]) -> None:
|
||||
if raw is None:
|
||||
return
|
||||
values = [item.strip() for item in raw.split(",") if item.strip()]
|
||||
setattr(target, field_name, values)
|
||||
|
||||
|
||||
def _maybe_set_float(target: Any, field_name: str, raw: Optional[str]) -> None:
|
||||
if raw is None or not raw.strip():
|
||||
return
|
||||
try:
|
||||
setattr(target, field_name, float(raw))
|
||||
except ValueError:
|
||||
logger.warning("Invalid float for %s: %r", field_name, raw)
|
||||
|
||||
|
||||
def _maybe_set_str(target: Any, field_name: str, raw: Optional[str]) -> None:
|
||||
if raw is None:
|
||||
return
|
||||
value = raw.strip()
|
||||
if value:
|
||||
setattr(target, field_name, value)
|
||||
577
openspace/communication/gateway.py
Normal file
577
openspace/communication/gateway.py
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
from aiohttp import web
|
||||
|
||||
from openspace.communication.adapters import FeishuAdapter, WhatsAppAdapter
|
||||
from openspace.communication.adapters.base import BaseChannelAdapter
|
||||
from openspace.communication.attachment_cache import AttachmentCache
|
||||
from openspace.communication.config import CommunicationConfig, load_communication_config
|
||||
from openspace.communication.gateway_runtime import RuntimeStatusStore, ScopedLock, ScopedLockManager
|
||||
from openspace.communication.policy import (
|
||||
build_attachment_instruction,
|
||||
is_authorized,
|
||||
should_accept_message,
|
||||
)
|
||||
from openspace.communication.runtime_manager import SessionRuntimeManager
|
||||
from openspace.communication.session_store import SessionStore
|
||||
from openspace.communication.types import ChannelMessage, ChannelPlatform, ChannelSession
|
||||
from openspace.host_detection import build_grounding_config_path, build_llm_kwargs, load_runtime_env
|
||||
from openspace.tool_layer import OpenSpace, OpenSpaceConfig
|
||||
from openspace.utils.logging import Logger
|
||||
|
||||
logger = Logger.get_logger(__name__)
|
||||
|
||||
|
||||
def _append_no_proxy_hosts(*hosts: str) -> None:
|
||||
for env_name in ("NO_PROXY", "no_proxy"):
|
||||
current = os.environ.get(env_name, "")
|
||||
entries = [entry.strip() for entry in current.split(",") if entry.strip()]
|
||||
updated = False
|
||||
for host in hosts:
|
||||
if host not in entries:
|
||||
entries.append(host)
|
||||
updated = True
|
||||
if updated:
|
||||
os.environ[env_name] = ",".join(entries)
|
||||
|
||||
|
||||
def _configure_ollama_process_env(model: str) -> None:
|
||||
if not model.lower().startswith("ollama/"):
|
||||
return
|
||||
|
||||
_append_no_proxy_hosts("127.0.0.1", "localhost")
|
||||
for env_name in (
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
):
|
||||
if os.environ.get(env_name):
|
||||
logger.info("Clearing %s for local Ollama access", env_name)
|
||||
os.environ.pop(env_name, None)
|
||||
|
||||
|
||||
class CommunicationGateway:
|
||||
def __init__(self, config: CommunicationConfig):
|
||||
self.config = config
|
||||
workspace_root = (
|
||||
Path(config.agent.workspace_root).expanduser().resolve()
|
||||
if config.agent.workspace_root
|
||||
else None
|
||||
)
|
||||
self.session_store = SessionStore(
|
||||
config.sessions_dir,
|
||||
workspace_root=workspace_root,
|
||||
)
|
||||
self.attachment_cache = AttachmentCache(
|
||||
config.sessions_dir,
|
||||
max_attachment_bytes=config.sessions.max_attachment_bytes,
|
||||
max_session_attachment_bytes=config.sessions.max_session_attachment_bytes,
|
||||
)
|
||||
self.runtime_manager = SessionRuntimeManager(config, self._create_openspace_runtime)
|
||||
self._session_queues: Dict[str, asyncio.Queue[ChannelMessage]] = {}
|
||||
self._session_workers: Dict[str, asyncio.Task] = {}
|
||||
self._adapters: Dict[ChannelPlatform, BaseChannelAdapter] = {}
|
||||
self._web_app: Optional[web.Application] = None
|
||||
self._web_runner: Optional[web.AppRunner] = None
|
||||
self._web_site: Optional[web.TCPSite] = None
|
||||
self._running = False
|
||||
self._runtime_manager_started = False
|
||||
self._runtime_status = RuntimeStatusStore(self._runtime_status_path)
|
||||
self._lock_manager = ScopedLockManager(self._locks_dir)
|
||||
self._acquired_locks: list[ScopedLock] = []
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self.config.data_path.mkdir(parents=True, exist_ok=True)
|
||||
self._locks_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._bridge_tokens_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._outbound_media_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
self._build_adapters()
|
||||
for adapter in self._adapters.values():
|
||||
validate_configuration = getattr(adapter, "validate_configuration", None)
|
||||
if callable(validate_configuration):
|
||||
validate_configuration()
|
||||
self._acquire_adapter_locks()
|
||||
self._write_runtime_status("starting")
|
||||
await self.runtime_manager.start()
|
||||
self._runtime_manager_started = True
|
||||
|
||||
self._web_app = web.Application()
|
||||
self._web_app.router.add_get(self.config.server.health_path, self._handle_health)
|
||||
for adapter in self._adapters.values():
|
||||
adapter.register_http_routes(self._web_app)
|
||||
|
||||
self._web_runner = web.AppRunner(self._web_app)
|
||||
await self._web_runner.setup()
|
||||
self._web_site = web.TCPSite(
|
||||
self._web_runner,
|
||||
self.config.server.host,
|
||||
self.config.server.port,
|
||||
)
|
||||
await self._web_site.start()
|
||||
|
||||
for adapter in self._adapters.values():
|
||||
connected = await adapter.connect()
|
||||
if not connected:
|
||||
raise RuntimeError(
|
||||
f"Communication adapter failed to connect: {adapter.platform.value}"
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._write_runtime_status("running")
|
||||
logger.info(
|
||||
"Communication gateway started on %s:%s for platforms=%s",
|
||||
self.config.server.host,
|
||||
self.config.server.port,
|
||||
",".join(self.config.enabled_platforms) or "(none)",
|
||||
)
|
||||
except Exception as exc:
|
||||
await self._rollback_start(exc)
|
||||
raise
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._running and not self._has_live_resources():
|
||||
return
|
||||
|
||||
self._write_runtime_status("stopping")
|
||||
self._running = False
|
||||
await self._stop_session_workers()
|
||||
await self._disconnect_adapters()
|
||||
await self._cleanup_web_runner()
|
||||
await self._stop_runtime_manager()
|
||||
self._release_locks()
|
||||
self._write_runtime_status("stopped")
|
||||
logger.info("Communication gateway stopped")
|
||||
|
||||
def _build_adapters(self) -> None:
|
||||
adapters: Dict[ChannelPlatform, BaseChannelAdapter] = {}
|
||||
if self.config.whatsapp.enabled:
|
||||
adapter = self._instantiate_adapter(
|
||||
WhatsAppAdapter,
|
||||
self.config.whatsapp,
|
||||
self.attachment_cache,
|
||||
runtime_dir=self.config.data_path,
|
||||
poll_interval_seconds=self.config.sessions.whatsapp_poll_interval_seconds,
|
||||
)
|
||||
adapter.set_message_handler(self.handle_message)
|
||||
adapters[ChannelPlatform.WHATSAPP] = adapter
|
||||
if self.config.feishu.enabled:
|
||||
adapter = self._instantiate_adapter(
|
||||
FeishuAdapter,
|
||||
self.config.feishu,
|
||||
self.attachment_cache,
|
||||
runtime_dir=self.config.data_path,
|
||||
)
|
||||
adapter.set_message_handler(self.handle_message)
|
||||
adapters[ChannelPlatform.FEISHU] = adapter
|
||||
self._adapters = adapters
|
||||
|
||||
@staticmethod
|
||||
def _instantiate_adapter(adapter_cls: Any, *args: Any, **kwargs: Any) -> BaseChannelAdapter:
|
||||
try:
|
||||
return adapter_cls(*args, **kwargs)
|
||||
except TypeError as exc:
|
||||
if "unexpected keyword argument" not in str(exc):
|
||||
raise
|
||||
compatibility_kwargs = dict(kwargs)
|
||||
compatibility_kwargs.pop("runtime_dir", None)
|
||||
return adapter_cls(*args, **compatibility_kwargs)
|
||||
|
||||
def _acquire_adapter_locks(self) -> None:
|
||||
self._release_locks()
|
||||
for adapter in self._adapters.values():
|
||||
get_lock_identity = getattr(adapter, "get_lock_identity", None)
|
||||
binding = get_lock_identity() if callable(get_lock_identity) else None
|
||||
if binding is None:
|
||||
continue
|
||||
scope, identity = binding
|
||||
lock = self._lock_manager.acquire(
|
||||
scope=scope,
|
||||
identity=identity,
|
||||
metadata={"platform": adapter.platform.value},
|
||||
)
|
||||
self._acquired_locks.append(lock)
|
||||
|
||||
def _release_locks(self) -> None:
|
||||
while self._acquired_locks:
|
||||
self._lock_manager.release(self._acquired_locks.pop())
|
||||
|
||||
def _write_runtime_status(
|
||||
self,
|
||||
gateway_state: str,
|
||||
*,
|
||||
fatal_error: Optional[str] = None,
|
||||
) -> None:
|
||||
platform_states = {
|
||||
adapter.platform.value: {"connected": adapter.is_connected}
|
||||
for adapter in self._adapters.values()
|
||||
}
|
||||
self._runtime_status.write(
|
||||
gateway_state=gateway_state,
|
||||
platforms=platform_states,
|
||||
config_path=str(self.config.data_path),
|
||||
fatal_error=fatal_error,
|
||||
)
|
||||
|
||||
async def _rollback_start(self, exc: Exception) -> None:
|
||||
logger.error("Communication gateway startup failed: %s", exc, exc_info=True)
|
||||
self._running = False
|
||||
await self._disconnect_adapters()
|
||||
await self._cleanup_web_runner()
|
||||
try:
|
||||
await self._stop_runtime_manager()
|
||||
finally:
|
||||
self._release_locks()
|
||||
self._write_runtime_status("failed", fatal_error=str(exc))
|
||||
|
||||
async def _stop_session_workers(self) -> None:
|
||||
worker_tasks = list(self._session_workers.values())
|
||||
self._session_workers.clear()
|
||||
for task in worker_tasks:
|
||||
task.cancel()
|
||||
if worker_tasks:
|
||||
await asyncio.gather(*worker_tasks, return_exceptions=True)
|
||||
self._session_queues.clear()
|
||||
|
||||
async def _disconnect_adapters(self) -> None:
|
||||
adapters = list(self._adapters.values())
|
||||
self._adapters.clear()
|
||||
for adapter in adapters:
|
||||
try:
|
||||
await adapter.disconnect()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to disconnect adapter during cleanup: %s",
|
||||
getattr(adapter.platform, "value", "unknown"),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _cleanup_web_runner(self) -> None:
|
||||
if self._web_runner is None:
|
||||
return
|
||||
try:
|
||||
await self._web_runner.cleanup()
|
||||
finally:
|
||||
self._web_runner = None
|
||||
self._web_site = None
|
||||
self._web_app = None
|
||||
|
||||
async def _stop_runtime_manager(self) -> None:
|
||||
if not self._runtime_manager_started:
|
||||
return
|
||||
try:
|
||||
await self.runtime_manager.stop()
|
||||
finally:
|
||||
self._runtime_manager_started = False
|
||||
|
||||
def _has_live_resources(self) -> bool:
|
||||
return any(
|
||||
(
|
||||
self._runtime_manager_started,
|
||||
bool(self._adapters),
|
||||
self._web_runner is not None,
|
||||
bool(self._acquired_locks),
|
||||
bool(self._session_workers),
|
||||
bool(self._session_queues),
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def _runtime_status_path(self) -> Path:
|
||||
return getattr(self.config, "runtime_status_path", self.config.data_path / "runtime_status.json")
|
||||
|
||||
@property
|
||||
def _locks_dir(self) -> Path:
|
||||
return getattr(self.config, "locks_dir", self.config.data_path / "locks")
|
||||
|
||||
@property
|
||||
def _bridge_tokens_dir(self) -> Path:
|
||||
return getattr(self.config, "bridge_tokens_dir", self.config.data_path / "bridge_tokens")
|
||||
|
||||
@property
|
||||
def _outbound_media_dir(self) -> Path:
|
||||
return getattr(self.config, "outbound_media_dir", self.config.data_path / "outbound_media")
|
||||
|
||||
async def handle_message(self, message: ChannelMessage) -> None:
|
||||
session = self.session_store.get_or_create_session(message.source)
|
||||
queue = self._session_queues.get(session.session_key)
|
||||
if queue is None:
|
||||
queue = asyncio.Queue(maxsize=self.config.sessions.per_session_queue_size)
|
||||
self._session_queues[session.session_key] = queue
|
||||
worker = self._session_workers.get(session.session_key)
|
||||
if worker is None or worker.done():
|
||||
self._session_workers[session.session_key] = asyncio.create_task(
|
||||
self._session_worker(session, queue)
|
||||
)
|
||||
await queue.put(message)
|
||||
|
||||
async def _session_worker(
|
||||
self,
|
||||
session: ChannelSession,
|
||||
queue: asyncio.Queue[ChannelMessage],
|
||||
) -> None:
|
||||
session_key = session.session_key
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
message = await asyncio.wait_for(
|
||||
queue.get(),
|
||||
timeout=self.config.sessions.idle_ttl_seconds,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
if queue.empty():
|
||||
logger.info("Retiring idle communication worker: %s", session_key)
|
||||
return
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._process_message(session, message)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to process %s message for session %s: %s",
|
||||
message.source.platform.value,
|
||||
session.session_key,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
adapter = self._adapters.get(message.source.platform)
|
||||
if adapter:
|
||||
await adapter.send_text(
|
||||
message.source.chat_id,
|
||||
f"OpenSpace communication error: {exc}",
|
||||
)
|
||||
finally:
|
||||
queue.task_done()
|
||||
finally:
|
||||
current_task = asyncio.current_task()
|
||||
if self._session_workers.get(session_key) is current_task:
|
||||
self._session_workers.pop(session_key, None)
|
||||
if queue.empty():
|
||||
if (
|
||||
self._session_queues.get(session_key) is queue
|
||||
and session_key not in self._session_workers
|
||||
):
|
||||
self._session_queues.pop(session_key, None)
|
||||
elif self._running and session_key not in self._session_workers:
|
||||
self._session_workers[session_key] = asyncio.create_task(
|
||||
self._session_worker(session, queue)
|
||||
)
|
||||
|
||||
async def _process_message(self, session: ChannelSession, message: ChannelMessage) -> None:
|
||||
platform_config = self._get_platform_config(message.source.platform)
|
||||
if not is_authorized(message, platform_config):
|
||||
logger.info(
|
||||
"Rejected %s message from unauthorized user %s",
|
||||
message.source.platform.value,
|
||||
message.source.user_id,
|
||||
)
|
||||
return
|
||||
|
||||
reply_to_bot = self.session_store.is_reply_to_assistant(
|
||||
session,
|
||||
message.reply_to_message_id,
|
||||
)
|
||||
if not should_accept_message(message, platform_config, reply_to_bot):
|
||||
logger.debug(
|
||||
"Skipped %s group message that did not satisfy policy",
|
||||
message.source.platform.value,
|
||||
)
|
||||
return
|
||||
|
||||
history = self.session_store.load_history(
|
||||
session,
|
||||
self.config.sessions.history_max_turns,
|
||||
)
|
||||
if not message.text.strip():
|
||||
message.text = build_attachment_instruction(message)
|
||||
|
||||
self.session_store.append_user_message(session, message)
|
||||
|
||||
result = await self.runtime_manager.execute_turn(
|
||||
session=session,
|
||||
message=message,
|
||||
conversation_history=history,
|
||||
channel_context=message.to_channel_context(session.session_key),
|
||||
)
|
||||
response_text = self._extract_response_text(result)
|
||||
|
||||
adapter = self._adapters.get(message.source.platform)
|
||||
if adapter is None:
|
||||
raise RuntimeError(f"No adapter registered for {message.source.platform.value}")
|
||||
|
||||
send_result = await adapter.send_text(
|
||||
message.source.chat_id,
|
||||
response_text,
|
||||
reply_to_message_id=message.message_id,
|
||||
)
|
||||
if not send_result.success:
|
||||
logger.warning(
|
||||
"Failed to send %s response for session %s: %s",
|
||||
message.source.platform.value,
|
||||
session.session_key,
|
||||
send_result.error,
|
||||
)
|
||||
self.session_store.append_assistant_message(
|
||||
session,
|
||||
content=response_text,
|
||||
platform_message_id=send_result.message_id,
|
||||
metadata={
|
||||
"task_id": result.get("task_id"),
|
||||
"status": result.get("status"),
|
||||
"send_success": send_result.success,
|
||||
"send_error": send_result.error,
|
||||
},
|
||||
)
|
||||
|
||||
async def _handle_health(self, request: web.Request) -> web.Response:
|
||||
runtime_status = await self.runtime_manager.status()
|
||||
gateway_status = self._runtime_status.read() or {}
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "ok" if self._running else "starting",
|
||||
"gateway": gateway_status,
|
||||
"platforms": {
|
||||
platform.value: {
|
||||
"connected": adapter.is_connected,
|
||||
}
|
||||
for platform, adapter in self._adapters.items()
|
||||
},
|
||||
"runtime": runtime_status,
|
||||
"sessions": len(self.session_store.list_sessions()),
|
||||
}
|
||||
)
|
||||
|
||||
async def _create_openspace_runtime(self, session: ChannelSession) -> OpenSpace:
|
||||
load_runtime_env()
|
||||
env_model = os.environ.get("OPENSPACE_MODEL", "")
|
||||
model, llm_kwargs = build_llm_kwargs(env_model)
|
||||
llm_kwargs = dict(llm_kwargs)
|
||||
if model.lower().startswith("ollama/"):
|
||||
llm_kwargs["api_base"] = os.environ.get("OLLAMA_API_BASE", "").strip() or "http://127.0.0.1:11434"
|
||||
llm_kwargs["api_key"] = os.environ.get("OLLAMA_API_KEY", "").strip() or llm_kwargs.get("api_key") or "ollama"
|
||||
llm_kwargs.pop("extra_headers", None)
|
||||
backend_scope = self.config.agent.backend_scope
|
||||
grounding_config_path = (
|
||||
self.config.agent.grounding_config_path
|
||||
or build_grounding_config_path()
|
||||
)
|
||||
recording_dir = self.config.data_path / "recordings"
|
||||
openspace_config = OpenSpaceConfig(
|
||||
llm_model=model,
|
||||
llm_kwargs=llm_kwargs,
|
||||
workspace_dir=session.workspace_dir,
|
||||
grounding_max_iterations=self.config.agent.max_iterations,
|
||||
enable_recording=self.config.agent.enable_recording,
|
||||
recording_backends=self.config.agent.recording_backends,
|
||||
recording_log_dir=str(recording_dir),
|
||||
backend_scope=backend_scope,
|
||||
grounding_config_path=grounding_config_path,
|
||||
llm_timeout=self.config.agent.llm_timeout,
|
||||
)
|
||||
runtime = OpenSpace(openspace_config)
|
||||
await runtime.initialize()
|
||||
return runtime
|
||||
|
||||
def _get_platform_config(self, platform: ChannelPlatform) -> Any:
|
||||
if platform == ChannelPlatform.WHATSAPP:
|
||||
return self.config.whatsapp
|
||||
if platform == ChannelPlatform.FEISHU:
|
||||
return self.config.feishu
|
||||
raise ValueError(f"Unsupported platform: {platform}")
|
||||
|
||||
@staticmethod
|
||||
def _extract_response_text(result: Dict[str, Any]) -> str:
|
||||
response = str(result.get("response", "")).strip()
|
||||
if response:
|
||||
return response
|
||||
error = str(result.get("error", "")).strip()
|
||||
if error:
|
||||
return f"OpenSpace error: {error}"
|
||||
return "OpenSpace completed the task but returned no response."
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="OpenSpace communication gateway",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=str,
|
||||
help="Path to the communication JSON config file",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
run_parser = subparsers.add_parser("run", help="Start the communication gateway")
|
||||
run_parser.add_argument(
|
||||
"--config",
|
||||
type=str,
|
||||
help="Path to the communication JSON config file",
|
||||
)
|
||||
health_parser = subparsers.add_parser("health", help="Check the running gateway health endpoint")
|
||||
health_parser.add_argument(
|
||||
"--config",
|
||||
type=str,
|
||||
help="Path to the communication JSON config file",
|
||||
)
|
||||
health_parser.add_argument("--host", type=str, default=None)
|
||||
health_parser.add_argument("--port", type=int, default=None)
|
||||
return parser
|
||||
|
||||
|
||||
async def _run_gateway(config_path: Optional[str]) -> int:
|
||||
config = load_communication_config(config_path)
|
||||
_configure_ollama_process_env(os.environ.get("OPENSPACE_MODEL", ""))
|
||||
gateway = CommunicationGateway(config)
|
||||
try:
|
||||
await gateway.start()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to start communication gateway: %s", exc)
|
||||
return 1
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
except (asyncio.CancelledError, KeyboardInterrupt):
|
||||
pass
|
||||
finally:
|
||||
await gateway.stop()
|
||||
return 0
|
||||
|
||||
|
||||
def _check_health(config_path: Optional[str], host: Optional[str], port: Optional[int]) -> int:
|
||||
config = load_communication_config(config_path)
|
||||
url = f"http://{host or config.server.host}:{port or config.server.port}{config.server.health_path}"
|
||||
response = requests.get(url, timeout=5)
|
||||
response.raise_for_status()
|
||||
print(response.text)
|
||||
return 0
|
||||
|
||||
|
||||
async def main(argv: Optional[list[str]] = None) -> int:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
command = args.command or "run"
|
||||
if command == "health":
|
||||
return _check_health(args.config, args.host, args.port)
|
||||
return await _run_gateway(args.config)
|
||||
|
||||
|
||||
def run_main() -> None:
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_main()
|
||||
252
openspace/communication/gateway_runtime.py
Normal file
252
openspace/communication/gateway_runtime.py
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from openspace.utils.logging import Logger
|
||||
|
||||
logger = Logger.get_logger(__name__)
|
||||
|
||||
_GATEWAY_KIND = "openspace-communication-gateway"
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _scope_hash(identity: str) -> str:
|
||||
return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _process_start_time(pid: int) -> Optional[int]:
|
||||
stat_path = Path(f"/proc/{pid}/stat")
|
||||
try:
|
||||
return int(stat_path.read_text(encoding="utf-8").split()[21])
|
||||
except (FileNotFoundError, IndexError, PermissionError, ValueError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _is_pid_alive(pid: int) -> bool:
|
||||
if pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def _build_process_record() -> dict[str, Any]:
|
||||
pid = os.getpid()
|
||||
return {
|
||||
"pid": pid,
|
||||
"kind": _GATEWAY_KIND,
|
||||
"argv": list(sys.argv),
|
||||
"start_time": _process_start_time(pid),
|
||||
}
|
||||
|
||||
|
||||
def _record_matches_live_process(record: dict[str, Any]) -> bool:
|
||||
try:
|
||||
pid = int(record["pid"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
if not _is_pid_alive(pid):
|
||||
return False
|
||||
|
||||
recorded_start_time = record.get("start_time")
|
||||
live_start_time = _process_start_time(pid)
|
||||
if recorded_start_time is None or live_start_time is None:
|
||||
return True
|
||||
return live_start_time == recorded_start_time
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Optional[dict[str, Any]]:
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
class LockConflictError(RuntimeError):
|
||||
def __init__(self, scope: str, identity: str, record: Optional[dict[str, Any]] = None):
|
||||
super().__init__(f"Communication gateway lock is already held for {scope}:{identity}")
|
||||
self.scope = scope
|
||||
self.identity = identity
|
||||
self.record = record or {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScopedRuntimeLock:
|
||||
path: Path
|
||||
record: dict[str, Any]
|
||||
released: bool = False
|
||||
|
||||
@classmethod
|
||||
def acquire(
|
||||
cls,
|
||||
*,
|
||||
locks_dir: Path,
|
||||
scope: str,
|
||||
identity: str,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
) -> "ScopedRuntimeLock":
|
||||
locks_dir.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = locks_dir / f"{scope}-{_scope_hash(identity)}.lock"
|
||||
record = {
|
||||
**_build_process_record(),
|
||||
"scope": scope,
|
||||
"identity": identity,
|
||||
"metadata": metadata or {},
|
||||
"created_at": _utcnow_iso(),
|
||||
"updated_at": _utcnow_iso(),
|
||||
}
|
||||
|
||||
while True:
|
||||
existing = _read_json(lock_path)
|
||||
if existing is not None:
|
||||
if _record_matches_live_process(existing):
|
||||
raise LockConflictError(scope, identity, existing)
|
||||
try:
|
||||
lock_path.unlink()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Failed to remove stale gateway lock {lock_path}: {exc}") from exc
|
||||
elif lock_path.exists():
|
||||
try:
|
||||
lock_path.unlink()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to remove malformed gateway lock {lock_path}: {exc}"
|
||||
) from exc
|
||||
continue
|
||||
|
||||
try:
|
||||
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
except FileExistsError:
|
||||
continue
|
||||
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(record, handle, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
try:
|
||||
lock_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return cls(path=lock_path, record=record)
|
||||
|
||||
def release(self) -> None:
|
||||
if self.released:
|
||||
return
|
||||
try:
|
||||
current = _read_json(self.path)
|
||||
if current and current.get("pid") == self.record.get("pid"):
|
||||
self.path.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to release communication gateway lock %s: %s", self.path, exc)
|
||||
finally:
|
||||
self.released = True
|
||||
|
||||
|
||||
class GatewayRuntimeTracker:
|
||||
def __init__(self, status_path: Path):
|
||||
self.status_path = status_path
|
||||
|
||||
def write_status(
|
||||
self,
|
||||
*,
|
||||
gateway_state: str,
|
||||
platforms: dict[str, dict[str, Any]],
|
||||
fatal_error: Optional[str] = None,
|
||||
exit_reason: Optional[str] = None,
|
||||
config_path: Optional[str] = None,
|
||||
sessions: Optional[int] = None,
|
||||
) -> None:
|
||||
payload = {
|
||||
**_build_process_record(),
|
||||
"gateway_state": gateway_state,
|
||||
"fatal_error": fatal_error,
|
||||
"exit_reason": exit_reason,
|
||||
"config_path": config_path,
|
||||
"platforms": platforms,
|
||||
"sessions": sessions,
|
||||
"updated_at": _utcnow_iso(),
|
||||
}
|
||||
_write_json(self.status_path, payload)
|
||||
|
||||
def read_status(self) -> Optional[dict[str, Any]]:
|
||||
return _read_json(self.status_path)
|
||||
|
||||
|
||||
class ScopedLockManager:
|
||||
def __init__(self, locks_dir: Path):
|
||||
self.locks_dir = locks_dir
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
scope: str,
|
||||
identity: str,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
) -> "ScopedLock":
|
||||
return ScopedRuntimeLock.acquire(
|
||||
locks_dir=self.locks_dir,
|
||||
scope=scope,
|
||||
identity=identity,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def release(lock: "ScopedLock") -> None:
|
||||
lock.release()
|
||||
|
||||
|
||||
class RuntimeStatusStore:
|
||||
def __init__(self, status_path: Path):
|
||||
self._tracker = GatewayRuntimeTracker(status_path)
|
||||
|
||||
def write(
|
||||
self,
|
||||
*,
|
||||
gateway_state: str,
|
||||
platforms: dict[str, dict[str, Any]],
|
||||
fatal_error: Optional[str] = None,
|
||||
exit_reason: Optional[str] = None,
|
||||
config_path: Optional[str] = None,
|
||||
sessions: Optional[int] = None,
|
||||
) -> None:
|
||||
self._tracker.write_status(
|
||||
gateway_state=gateway_state,
|
||||
platforms=platforms,
|
||||
fatal_error=fatal_error,
|
||||
exit_reason=exit_reason,
|
||||
config_path=config_path,
|
||||
sessions=sessions,
|
||||
)
|
||||
|
||||
def read(self) -> Optional[dict[str, Any]]:
|
||||
return self._tracker.read_status()
|
||||
|
||||
|
||||
ScopedLock = ScopedRuntimeLock
|
||||
61
openspace/communication/policy.py
Normal file
61
openspace/communication/policy.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from openspace.communication.types import ChannelMessage
|
||||
|
||||
|
||||
def build_attachment_instruction(message: ChannelMessage) -> str:
|
||||
attachment_paths = ", ".join(attachment.path for attachment in message.attachments)
|
||||
return (
|
||||
"Please inspect the attached files and help the user based on their contents. "
|
||||
f"Attachment paths: {attachment_paths}"
|
||||
)
|
||||
|
||||
|
||||
def is_authorized(message: ChannelMessage, platform_config: Any) -> bool:
|
||||
if getattr(platform_config, "allow_all_users", False):
|
||||
return True
|
||||
|
||||
allowed_users = {
|
||||
entry.strip()
|
||||
for entry in getattr(platform_config, "allowed_users", [])
|
||||
if entry and entry.strip()
|
||||
}
|
||||
if not allowed_users:
|
||||
return False
|
||||
|
||||
user_candidates = {
|
||||
candidate.strip()
|
||||
for candidate in (
|
||||
message.source.user_id,
|
||||
message.source.user_name,
|
||||
message.metadata.get("raw_user_id") if isinstance(message.metadata, dict) else None,
|
||||
)
|
||||
if candidate and candidate.strip()
|
||||
}
|
||||
if isinstance(message.metadata, dict):
|
||||
for candidate in message.metadata.get("auth_candidates", []) or []:
|
||||
if isinstance(candidate, str) and candidate.strip():
|
||||
user_candidates.add(candidate.strip())
|
||||
return bool(user_candidates & allowed_users)
|
||||
|
||||
|
||||
def should_accept_message(
|
||||
message: ChannelMessage,
|
||||
platform_config: Any,
|
||||
reply_to_bot: bool,
|
||||
) -> bool:
|
||||
if message.source.chat_type == "dm":
|
||||
return bool(getattr(platform_config, "allow_dm", True))
|
||||
if not getattr(platform_config, "allow_groups", True):
|
||||
return False
|
||||
|
||||
group_policy = str(getattr(platform_config, "group_policy", "reply_or_mention"))
|
||||
if group_policy == "disabled":
|
||||
return False
|
||||
if group_policy == "all":
|
||||
return True
|
||||
if group_policy == "mention_only":
|
||||
return message.mentions_bot
|
||||
return message.mentions_bot or reply_to_bot
|
||||
149
openspace/communication/runtime_manager.py
Normal file
149
openspace/communication/runtime_manager.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import uuid
|
||||
from time import monotonic
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
from openspace.tool_layer import OpenSpace
|
||||
from openspace.utils.logging import Logger
|
||||
|
||||
from .config import CommunicationConfig
|
||||
from .types import ChannelMessage, ChannelSession
|
||||
|
||||
logger = Logger.get_logger(__name__)
|
||||
|
||||
|
||||
OpenSpaceFactory = Callable[[ChannelSession], Awaitable[OpenSpace]]
|
||||
|
||||
|
||||
class SessionRuntime:
|
||||
def __init__(self, session: ChannelSession, openspace_factory: OpenSpaceFactory):
|
||||
self.session = session
|
||||
self._openspace_factory = openspace_factory
|
||||
self._openspace: Optional[OpenSpace] = None
|
||||
self._lock = asyncio.Lock()
|
||||
self.last_used_monotonic = monotonic()
|
||||
|
||||
@property
|
||||
def openspace(self) -> Optional[OpenSpace]:
|
||||
return self._openspace
|
||||
|
||||
async def ensure_initialized(self) -> OpenSpace:
|
||||
if self._openspace is None:
|
||||
self._openspace = await self._openspace_factory(self.session)
|
||||
self.last_used_monotonic = monotonic()
|
||||
return self._openspace
|
||||
|
||||
async def execute_turn(
|
||||
self,
|
||||
*,
|
||||
message: ChannelMessage,
|
||||
conversation_history: list[dict[str, str]],
|
||||
channel_context: dict[str, Any],
|
||||
max_iterations: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
async with self._lock:
|
||||
openspace = await self.ensure_initialized()
|
||||
self.last_used_monotonic = monotonic()
|
||||
task_id = f"comm_{self.session.session_key}_{uuid.uuid4().hex[:10]}"
|
||||
result = await openspace.execute(
|
||||
task=message.text,
|
||||
context={
|
||||
"conversation_history": conversation_history,
|
||||
"channel_context": channel_context,
|
||||
"session_key": self.session.session_key,
|
||||
},
|
||||
workspace_dir=self.session.workspace_dir,
|
||||
max_iterations=max_iterations,
|
||||
task_id=task_id,
|
||||
)
|
||||
self.last_used_monotonic = monotonic()
|
||||
return result
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._openspace is not None:
|
||||
await self._openspace.cleanup()
|
||||
self._openspace = None
|
||||
|
||||
def is_idle(self, idle_ttl_seconds: int) -> bool:
|
||||
if self._lock.locked():
|
||||
return False
|
||||
return (monotonic() - self.last_used_monotonic) >= idle_ttl_seconds
|
||||
|
||||
|
||||
class SessionRuntimeManager:
|
||||
def __init__(
|
||||
self,
|
||||
config: CommunicationConfig,
|
||||
openspace_factory: OpenSpaceFactory,
|
||||
):
|
||||
self.config = config
|
||||
self._openspace_factory = openspace_factory
|
||||
self._runtimes: Dict[str, SessionRuntime] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._semaphore = asyncio.Semaphore(config.sessions.max_parallel_sessions)
|
||||
self._eviction_task: Optional[asyncio.Task] = None
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._eviction_task is None:
|
||||
self._eviction_task = asyncio.create_task(self._evict_idle_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._eviction_task is not None:
|
||||
self._eviction_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._eviction_task
|
||||
self._eviction_task = None
|
||||
|
||||
async with self._lock:
|
||||
runtimes = list(self._runtimes.values())
|
||||
self._runtimes.clear()
|
||||
for runtime in runtimes:
|
||||
await runtime.close()
|
||||
|
||||
async def execute_turn(
|
||||
self,
|
||||
*,
|
||||
session: ChannelSession,
|
||||
message: ChannelMessage,
|
||||
conversation_history: list[dict[str, str]],
|
||||
channel_context: dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
runtime = await self._get_or_create_runtime(session)
|
||||
async with self._semaphore:
|
||||
return await runtime.execute_turn(
|
||||
message=message,
|
||||
conversation_history=conversation_history,
|
||||
channel_context=channel_context,
|
||||
max_iterations=self.config.agent.max_iterations,
|
||||
)
|
||||
|
||||
async def status(self) -> Dict[str, Any]:
|
||||
async with self._lock:
|
||||
return {
|
||||
"active_runtimes": len(self._runtimes),
|
||||
"session_keys": sorted(self._runtimes.keys()),
|
||||
}
|
||||
|
||||
async def _get_or_create_runtime(self, session: ChannelSession) -> SessionRuntime:
|
||||
async with self._lock:
|
||||
runtime = self._runtimes.get(session.session_key)
|
||||
if runtime is None:
|
||||
runtime = SessionRuntime(session, self._openspace_factory)
|
||||
self._runtimes[session.session_key] = runtime
|
||||
return runtime
|
||||
|
||||
async def _evict_idle_loop(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
stale_keys: list[str] = []
|
||||
async with self._lock:
|
||||
for session_key, runtime in self._runtimes.items():
|
||||
if runtime.is_idle(self.config.sessions.idle_ttl_seconds):
|
||||
stale_keys.append(session_key)
|
||||
runtimes = [self._runtimes.pop(key) for key in stale_keys]
|
||||
for runtime in runtimes:
|
||||
logger.info("Evicting idle communication runtime: %s", runtime.session.session_key)
|
||||
await runtime.close()
|
||||
199
openspace/communication/session_store.py
Normal file
199
openspace/communication/session_store.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openspace.utils.logging import Logger
|
||||
|
||||
from .types import ChannelAttachment, ChannelMessage, ChannelSession, ChannelSource
|
||||
|
||||
logger = Logger.get_logger(__name__)
|
||||
|
||||
|
||||
class SessionStore:
|
||||
def __init__(
|
||||
self,
|
||||
sessions_dir: Path,
|
||||
*,
|
||||
workspace_root: Optional[Path] = None,
|
||||
):
|
||||
self.sessions_dir = sessions_dir
|
||||
self.workspace_root = workspace_root
|
||||
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
||||
if self.workspace_root is not None:
|
||||
self.workspace_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def get_or_create_session(self, source: ChannelSource) -> ChannelSession:
|
||||
session_key = build_session_key(source)
|
||||
session_dir = self.sessions_dir / session_key
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
metadata_path = session_dir / "session.json"
|
||||
transcript_path = session_dir / "transcript.jsonl"
|
||||
attachments_dir = session_dir / "attachments"
|
||||
workspace_dir = (
|
||||
self.workspace_root / session_key
|
||||
if self.workspace_root is not None
|
||||
else session_dir / "workspace"
|
||||
)
|
||||
attachments_dir.mkdir(parents=True, exist_ok=True)
|
||||
workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
now = _utcnow_iso()
|
||||
if metadata_path.exists():
|
||||
with open(metadata_path, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
session = ChannelSession.from_dict(data)
|
||||
session.source = source
|
||||
session.updated_at = now
|
||||
else:
|
||||
session = ChannelSession(
|
||||
session_key=session_key,
|
||||
source=source,
|
||||
session_dir=str(session_dir),
|
||||
workspace_dir=str(workspace_dir),
|
||||
attachments_dir=str(attachments_dir),
|
||||
transcript_path=str(transcript_path),
|
||||
metadata_path=str(metadata_path),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
self._write_session_metadata(session)
|
||||
return session
|
||||
|
||||
def append_user_message(self, session: ChannelSession, message: ChannelMessage) -> None:
|
||||
self._append_transcript_entry(
|
||||
session,
|
||||
{
|
||||
"entry_id": uuid.uuid4().hex,
|
||||
"role": "user",
|
||||
"content": message.text,
|
||||
"platform_message_id": message.message_id,
|
||||
"reply_to_message_id": message.reply_to_message_id,
|
||||
"reply_to_text": message.reply_to_text,
|
||||
"mentions_bot": message.mentions_bot,
|
||||
"attachments": [attachment.to_context_dict() for attachment in message.attachments],
|
||||
"source": message.source.to_dict(),
|
||||
"metadata": message.metadata,
|
||||
"timestamp": message.received_at.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
def append_assistant_message(
|
||||
self,
|
||||
session: ChannelSession,
|
||||
*,
|
||||
content: str,
|
||||
platform_message_id: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self._append_transcript_entry(
|
||||
session,
|
||||
{
|
||||
"entry_id": uuid.uuid4().hex,
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"platform_message_id": platform_message_id,
|
||||
"metadata": metadata or {},
|
||||
"timestamp": _utcnow_iso(),
|
||||
},
|
||||
)
|
||||
|
||||
def load_history(self, session: ChannelSession, max_turns: int) -> List[Dict[str, str]]:
|
||||
entries = self._read_transcript_entries(session)
|
||||
if not entries:
|
||||
return []
|
||||
|
||||
selected: List[Dict[str, str]] = []
|
||||
user_messages = 0
|
||||
for entry in reversed(entries):
|
||||
role = entry.get("role")
|
||||
if role not in {"user", "assistant"}:
|
||||
continue
|
||||
if role == "assistant" and not _assistant_entry_visible_in_history(entry):
|
||||
continue
|
||||
content = str(entry.get("content", "")).strip()
|
||||
if not content:
|
||||
continue
|
||||
selected.append({"role": role, "content": content})
|
||||
if role == "user":
|
||||
user_messages += 1
|
||||
if user_messages >= max_turns:
|
||||
break
|
||||
selected.reverse()
|
||||
return selected
|
||||
|
||||
def is_reply_to_assistant(self, session: ChannelSession, message_id: Optional[str]) -> bool:
|
||||
if not message_id:
|
||||
return False
|
||||
for entry in reversed(self._read_transcript_entries(session)):
|
||||
if entry.get("platform_message_id") == message_id:
|
||||
return entry.get("role") == "assistant" and _assistant_entry_visible_in_history(entry)
|
||||
return False
|
||||
|
||||
def list_sessions(self) -> List[ChannelSession]:
|
||||
sessions: List[ChannelSession] = []
|
||||
for metadata_path in sorted(self.sessions_dir.glob("*/session.json")):
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as handle:
|
||||
sessions.append(ChannelSession.from_dict(json.load(handle)))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load session metadata %s: %s", metadata_path, exc)
|
||||
return sessions
|
||||
|
||||
def _append_transcript_entry(self, session: ChannelSession, entry: Dict[str, Any]) -> None:
|
||||
transcript_path = Path(session.transcript_path)
|
||||
transcript_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(transcript_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
session.updated_at = _utcnow_iso()
|
||||
self._write_session_metadata(session)
|
||||
|
||||
def _write_session_metadata(self, session: ChannelSession) -> None:
|
||||
with open(session.metadata_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(session.to_dict(), handle, ensure_ascii=False, indent=2)
|
||||
|
||||
def _read_transcript_entries(self, session: ChannelSession) -> List[Dict[str, Any]]:
|
||||
transcript_path = Path(session.transcript_path)
|
||||
if not transcript_path.exists():
|
||||
return []
|
||||
entries: List[Dict[str, Any]] = []
|
||||
with open(transcript_path, "r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Skipping malformed transcript line in %s", transcript_path)
|
||||
return entries
|
||||
|
||||
|
||||
def build_session_key(source: ChannelSource) -> str:
|
||||
parts = [source.platform.value, _sanitize(source.chat_id)]
|
||||
if source.thread_id:
|
||||
parts.append(_sanitize(source.thread_id))
|
||||
return "__".join(part for part in parts if part)
|
||||
|
||||
|
||||
def _sanitize(value: str) -> str:
|
||||
value = re.sub(r"[^a-zA-Z0-9._-]+", "-", str(value).strip())
|
||||
value = value.strip("-._")
|
||||
return value or "unknown"
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _assistant_entry_visible_in_history(entry: Dict[str, Any]) -> bool:
|
||||
metadata = entry.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
return True
|
||||
return metadata.get("send_success") is not False
|
||||
163
openspace/communication/types.py
Normal file
163
openspace/communication/types.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class ChannelPlatform(str, Enum):
|
||||
WHATSAPP = "whatsapp"
|
||||
FEISHU = "feishu"
|
||||
|
||||
|
||||
class AttachmentKind(str, Enum):
|
||||
IMAGE = "image"
|
||||
DOCUMENT = "document"
|
||||
FILE = "file"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChannelAttachment:
|
||||
kind: AttachmentKind
|
||||
path: str
|
||||
name: str = ""
|
||||
mime_type: str = ""
|
||||
size_bytes: Optional[int] = None
|
||||
source_url: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_context_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"kind": self.kind.value,
|
||||
"path": self.path,
|
||||
"name": self.name,
|
||||
"mime_type": self.mime_type,
|
||||
"size_bytes": self.size_bytes,
|
||||
"source_url": self.source_url,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChannelSource:
|
||||
platform: ChannelPlatform
|
||||
chat_id: str
|
||||
chat_type: str = "dm"
|
||||
user_id: Optional[str] = None
|
||||
user_name: Optional[str] = None
|
||||
chat_name: Optional[str] = None
|
||||
thread_id: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"platform": self.platform.value,
|
||||
"chat_id": self.chat_id,
|
||||
"chat_type": self.chat_type,
|
||||
"user_id": self.user_id,
|
||||
"user_name": self.user_name,
|
||||
"chat_name": self.chat_name,
|
||||
"thread_id": self.thread_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ChannelSource":
|
||||
return cls(
|
||||
platform=ChannelPlatform(str(data["platform"])),
|
||||
chat_id=str(data["chat_id"]),
|
||||
chat_type=str(data.get("chat_type", "dm")),
|
||||
user_id=_optional_str(data.get("user_id")),
|
||||
user_name=_optional_str(data.get("user_name")),
|
||||
chat_name=_optional_str(data.get("chat_name")),
|
||||
thread_id=_optional_str(data.get("thread_id")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChannelMessage:
|
||||
source: ChannelSource
|
||||
text: str
|
||||
message_id: str
|
||||
attachments: List[ChannelAttachment] = field(default_factory=list)
|
||||
reply_to_message_id: Optional[str] = None
|
||||
reply_to_text: Optional[str] = None
|
||||
mentions_bot: bool = False
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
received_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def to_channel_context(self, session_key: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"platform": self.source.platform.value,
|
||||
"chat_id": self.source.chat_id,
|
||||
"chat_type": self.source.chat_type,
|
||||
"chat_name": self.source.chat_name,
|
||||
"thread_id": self.source.thread_id,
|
||||
"user_id": self.source.user_id,
|
||||
"user_name": self.source.user_name,
|
||||
"session_key": session_key,
|
||||
"message_id": self.message_id,
|
||||
"reply_to_message_id": self.reply_to_message_id,
|
||||
"reply_to_text": self.reply_to_text,
|
||||
"attachments": [attachment.to_context_dict() for attachment in self.attachments],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChannelReply:
|
||||
content: str
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SendResult:
|
||||
success: bool
|
||||
message_id: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
raw_response: Any = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChannelSession:
|
||||
session_key: str
|
||||
source: ChannelSource
|
||||
session_dir: str
|
||||
workspace_dir: str
|
||||
attachments_dir: str
|
||||
transcript_path: str
|
||||
metadata_path: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"session_key": self.session_key,
|
||||
"source": self.source.to_dict(),
|
||||
"session_dir": self.session_dir,
|
||||
"workspace_dir": self.workspace_dir,
|
||||
"attachments_dir": self.attachments_dir,
|
||||
"transcript_path": self.transcript_path,
|
||||
"metadata_path": self.metadata_path,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ChannelSession":
|
||||
return cls(
|
||||
session_key=str(data["session_key"]),
|
||||
source=ChannelSource.from_dict(data["source"]),
|
||||
session_dir=str(data["session_dir"]),
|
||||
workspace_dir=str(data["workspace_dir"]),
|
||||
attachments_dir=str(data["attachments_dir"]),
|
||||
transcript_path=str(data["transcript_path"]),
|
||||
metadata_path=str(data["metadata_path"]),
|
||||
created_at=str(data["created_at"]),
|
||||
updated_at=str(data["updated_at"]),
|
||||
)
|
||||
|
||||
|
||||
def _optional_str(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
value = str(value).strip()
|
||||
return value or None
|
||||
|
|
@ -1,40 +1,53 @@
|
|||
# 🔧 Configuration Guide
|
||||
|
||||
All configuration applies to both Path A (host agent) and Path B (standalone). Configure once before the first run.
|
||||
|
||||
## 1. API Keys (`.env`)
|
||||
## 1. LLM Credentials (`.env`)
|
||||
|
||||
> [!NOTE]
|
||||
> Create a `.env` file and add your API keys (refer to [`.env.example`](../../.env.example)). When used via host agent (Path A), LLM keys are auto-detected from your agent's config — `.env` is mainly needed for standalone mode.
|
||||
> Create `openspace/.env` from [`.env.example`](../../.env.example) and set at least one LLM API key.
|
||||
|
||||
Resolution priority (first match wins):
|
||||
|
||||
| Priority | Source | Example |
|
||||
|----------|--------|---------|
|
||||
| **Tier 1** | `OPENSPACE_LLM_*` env vars | `OPENSPACE_LLM_API_KEY=sk-xxx` |
|
||||
| **Tier 2** | Provider-native env vars | `OPENROUTER_API_KEY=sk-or-xxx` |
|
||||
| **Tier 3** | Host agent config | `~/.nanobot/config.json` / `~/.openclaw/openclaw.json` |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Tier 2 blocks Tier 3 — if `.env` has a provider key, host agent config is skipped.
|
||||
|
||||
```bash
|
||||
# Provider-native — litellm reads automatically
|
||||
OPENROUTER_API_KEY=sk-or-v1-xxx
|
||||
|
||||
# Or: OpenSpace-native — higher priority, same effect
|
||||
OPENSPACE_LLM_API_KEY=sk-or-v1-xxx
|
||||
```
|
||||
|
||||
## 2. Environment Variables
|
||||
|
||||
Set via `.env`, MCP config `env` block, or system environment. OpenSpace reads these at startup.
|
||||
Set via `.env`, MCP config `env` block, or system environment.
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `OPENSPACE_HOST_SKILL_DIRS` | Path A only | Your agent's skill directories (comma-separated). Auto-registered on startup. |
|
||||
| `OPENSPACE_WORKSPACE` | Recommended | OpenSpace project root. Used for recording logs and workspace resolution. |
|
||||
| `OPENSPACE_API_KEY` | No | Cloud API key (`sk-xxx`). Register at https://open-space.cloud. |
|
||||
| `OPENSPACE_MODEL` | No | LLM model override (default: auto-detected or `openrouter/anthropic/claude-sonnet-4.5`). |
|
||||
| `OPENSPACE_MAX_ITERATIONS` | No | Max agent iterations per task (default: `20`). |
|
||||
| `OPENSPACE_BACKEND_SCOPE` | No | Enabled backends, comma-separated (default: all — `shell,gui,mcp,web,system`). |
|
||||
|
||||
### Advanced env overrides (rarely needed)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `OPENSPACE_LLM_API_KEY` | LLM API key (auto-detected from host agent in Path A) |
|
||||
| `OPENSPACE_LLM_API_BASE` | LLM API base URL |
|
||||
| `OPENSPACE_LLM_EXTRA_HEADERS` | Extra HTTP headers for LLM requests (JSON string) |
|
||||
| `OPENSPACE_LLM_CONFIG` | Arbitrary litellm kwargs (JSON string) |
|
||||
| `OPENSPACE_API_BASE` | Cloud API base URL (default `https://open-space.cloud/api/v1`) |
|
||||
| `OPENSPACE_CONFIG_PATH` | Custom grounding config JSON (deep-merged with defaults) |
|
||||
| `OPENSPACE_SHELL_CONDA_ENV` | Conda environment for shell backend |
|
||||
| `OPENSPACE_SHELL_WORKING_DIR` | Working directory for shell backend |
|
||||
| `OPENSPACE_MCP_SERVERS_JSON` | MCP server definitions (JSON string, merged into `mcpServers`) |
|
||||
| `OPENSPACE_ENABLE_RECORDING` | Record execution traces (default: `true`) |
|
||||
| `OPENSPACE_LOG_LEVEL` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `OPENSPACE_MODEL` | LLM model | `openrouter/anthropic/claude-sonnet-4.5` |
|
||||
| `OPENSPACE_LLM_API_KEY` | LLM API key (Tier 1 override) | — |
|
||||
| `OPENSPACE_LLM_API_BASE` | LLM API base URL | — |
|
||||
| `OLLAMA_API_BASE` | Local Ollama endpoint for `ollama/*` models | `http://127.0.0.1:11434` |
|
||||
| `OLLAMA_API_KEY` | Placeholder key for Ollama-compatible clients | `ollama` |
|
||||
| `OPENSPACE_LLM_EXTRA_HEADERS` | Extra LLM headers (JSON) | — |
|
||||
| `OPENSPACE_LLM_CONFIG` | Arbitrary litellm kwargs (JSON) | — |
|
||||
| `OPENSPACE_API_KEY` | Cloud API key ([open-space.cloud](https://open-space.cloud)) | — |
|
||||
| `OPENSPACE_MAX_ITERATIONS` | Max agent iterations per task | `20` |
|
||||
| `OPENSPACE_BACKEND_SCOPE` | Enabled backends (comma-separated) | `shell,gui,mcp,web,system` |
|
||||
| `OPENSPACE_HOST_SKILL_DIRS` | Agent skill directories (comma-separated) | — |
|
||||
| `OPENSPACE_WORKSPACE` | Project root for logs/workspace | — |
|
||||
| `OPENSPACE_SHELL_CONDA_ENV` | Conda env for shell backend | — |
|
||||
| `OPENSPACE_SHELL_WORKING_DIR` | Working dir for shell backend | — |
|
||||
| `OPENSPACE_CONFIG_PATH` | Custom grounding config JSON | — |
|
||||
| `OPENSPACE_MCP_SERVERS_JSON` | MCP server definitions (JSON) | — |
|
||||
| `OPENSPACE_ENABLE_RECORDING` | Record execution traces | `true` |
|
||||
| `OPENSPACE_LOG_LEVEL` | Log level | `INFO` |
|
||||
|
||||
## 3. MCP Servers (`config_mcp.json`)
|
||||
|
||||
|
|
@ -67,7 +80,7 @@ Shell and GUI backends support two execution modes, set via `"mode"` in `config_
|
|||
| **How** | `asyncio.subprocess` in-process | HTTP → Flask → subprocess |
|
||||
|
||||
> [!TIP]
|
||||
> **Use local mode** for most use cases. For server mode setup (how to enable, platform-specific deps, remote VM control), see [`../local_server/README.md`](../local_server/README.md).
|
||||
> **Use local mode** for most use cases. For server mode setup, see [`../local_server/README.md`](../local_server/README.md).
|
||||
|
||||
## 5. Config Files (`openspace/config/`)
|
||||
|
||||
|
|
@ -80,6 +93,7 @@ Layered system — later files override earlier ones:
|
|||
| `config_mcp.json` | MCP servers OpenSpace connects to as a client |
|
||||
| `config_security.json` | Security policies, blocked commands, sandboxing |
|
||||
| `config_dev.json` | Dev overrides — copy from `config_dev.json.example` (highest priority) |
|
||||
| `config_communication.json` | Communication gateway settings for WhatsApp and Feishu. Use `agent` for per-message OpenSpace execution and `sessions` for queue/history limits. LLM model stays in `openspace/.env`. |
|
||||
|
||||
### Agent config (`config_agents.json`)
|
||||
|
||||
|
|
@ -113,3 +127,40 @@ Layered system — later files override earlier ones:
|
|||
| `sandbox_enabled` | Enable sandboxing for all operations | `false` |
|
||||
| Per-backend overrides | Shell, MCP, GUI, Web each have independent security policies | Inherit global |
|
||||
|
||||
## 6. Communication Gateway
|
||||
|
||||
The tracked communication config is safe-by-default: loopback-only, channels disabled, and deny-by-default access control. Copy the example config, fill in credentials and `allowed_users`, then explicitly enable the channels you want. The gateway model is not configured here; it inherits `OPENSPACE_MODEL` from `openspace/.env`.
|
||||
|
||||
```bash
|
||||
cp openspace/config/config_communication.json.example openspace/config/config_communication.json
|
||||
```
|
||||
|
||||
Install the Feishu SDK extra when you need Feishu support:
|
||||
|
||||
```bash
|
||||
pip install -e '.[communication]'
|
||||
```
|
||||
|
||||
Start the gateway with either entrypoint:
|
||||
|
||||
```bash
|
||||
openspace communication run --config openspace/config/config_communication.json
|
||||
openspace-gateway --config openspace/config/config_communication.json
|
||||
```
|
||||
|
||||
Check health:
|
||||
|
||||
```bash
|
||||
openspace communication health --config openspace/config/config_communication.json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The tracked `config_communication.json` now stays local-only and deny-by-default. Keep credentials out of git and populate them from a private working copy or environment variables.
|
||||
- Set `server.host` to `0.0.0.0` only when Feishu needs to reach the webhook from outside the machine, and pair that with a populated allowlist plus webhook verification secrets.
|
||||
- Feishu now supports both `webhook` and `websocket` modes. `websocket` matches nanobot's long-connection setup and does not require a public webhook URL.
|
||||
- WhatsApp requires Node.js and npm. The bundled bridge installs its dependencies on first start when `auto_install_dependencies` is enabled.
|
||||
- Set `feishu.bot_open_id` if you want strict group mention gating and automatic bot identity discovery is unavailable in your deployment.
|
||||
- Group chats are gated by `group_policy`. `reply_or_mention` is the default and only accepts messages that mention the bot or reply to a prior assistant message.
|
||||
- `allowed_users` is enforced when `allow_all_users` is `false`. The secure default is deny-by-default until you populate the allowlist.
|
||||
- Attachment caching is limited by `sessions.max_attachment_bytes` and `sessions.max_session_attachment_bytes` to bound disk usage per file and per session.
|
||||
|
|
|
|||
65
openspace/config/config_communication.json.example
Normal file
65
openspace/config/config_communication.json.example
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"data_dir": "./logs/communication",
|
||||
"server": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 8765,
|
||||
"health_path": "/health"
|
||||
},
|
||||
"agent": {
|
||||
"max_iterations": 20,
|
||||
"enable_recording": true,
|
||||
"recording_backends": [
|
||||
"shell"
|
||||
],
|
||||
"backend_scope": null,
|
||||
"grounding_config_path": null,
|
||||
"workspace_root": null,
|
||||
"llm_timeout": 120.0
|
||||
},
|
||||
"sessions": {
|
||||
"history_max_turns": 12,
|
||||
"max_parallel_sessions": 2,
|
||||
"idle_ttl_seconds": 900,
|
||||
"per_session_queue_size": 32,
|
||||
"whatsapp_poll_interval_seconds": 1.0,
|
||||
"max_attachment_bytes": 26214400,
|
||||
"max_session_attachment_bytes": 104857600
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": false,
|
||||
"allow_all_users": false,
|
||||
"allowed_users": [
|
||||
"15551234567"
|
||||
],
|
||||
"allow_dm": true,
|
||||
"allow_groups": true,
|
||||
"group_policy": "reply_or_mention",
|
||||
"reply_prefix": "OpenSpace\n────────────\n",
|
||||
"bridge": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 3000,
|
||||
"script_path": null,
|
||||
"session_dir": null,
|
||||
"mode": "self-chat",
|
||||
"auto_install_dependencies": true
|
||||
}
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"allow_all_users": false,
|
||||
"allowed_users": [
|
||||
"ou_xxxxxxxxxxxxx"
|
||||
],
|
||||
"allow_dm": true,
|
||||
"allow_groups": true,
|
||||
"group_policy": "reply_or_mention",
|
||||
"app_id": "cli_xxxxxxxxxxxxx",
|
||||
"app_secret": "xxxxxxxxxxxxx",
|
||||
"domain": "feishu",
|
||||
"connection_mode": "webhook",
|
||||
"verification_token": "",
|
||||
"encrypt_key": "",
|
||||
"bot_open_id": "",
|
||||
"webhook_path": "/feishu/webhook"
|
||||
}
|
||||
}
|
||||
|
|
@ -419,6 +419,18 @@ def _build_lineage_payload(skill_id: str, store: SkillStore) -> Dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def _workflow_id(workflow_dir: Path) -> str:
|
||||
"""Stable short ID for a workflow directory, unique across roots.
|
||||
|
||||
Uses a hash suffix derived from the resolved path to avoid collisions
|
||||
when directory names contain the separator character.
|
||||
"""
|
||||
import hashlib
|
||||
resolved = str(workflow_dir.resolve())
|
||||
path_hash = hashlib.sha256(resolved.encode()).hexdigest()[:8]
|
||||
return f"{workflow_dir.name}_{path_hash}"
|
||||
|
||||
|
||||
def _discover_workflow_dirs() -> List[Path]:
|
||||
discovered: Dict[str, Path] = {}
|
||||
for root in WORKFLOW_ROOTS:
|
||||
|
|
@ -439,14 +451,14 @@ def _scan_workflow_tree(directory: Path, discovered: Dict[str, Path], *, _depth:
|
|||
if not child.is_dir():
|
||||
continue
|
||||
if (child / "metadata.json").exists() or (child / "traj.jsonl").exists():
|
||||
discovered.setdefault(child.name, child)
|
||||
discovered.setdefault(str(child.resolve()), child)
|
||||
else:
|
||||
_scan_workflow_tree(child, discovered, _depth=_depth + 1, _max_depth=_max_depth)
|
||||
|
||||
|
||||
def _get_workflow_dir(workflow_id: str) -> Optional[Path]:
|
||||
for path in _discover_workflow_dirs():
|
||||
if path.name == workflow_id:
|
||||
if _workflow_id(path) == workflow_id:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
|
@ -464,7 +476,7 @@ def _build_workflow_summary(workflow_dir: Path) -> Dict[str, Any]:
|
|||
for candidate in video_candidates:
|
||||
if candidate.exists():
|
||||
rel = candidate.relative_to(workflow_dir).as_posix()
|
||||
video_url = url_for("workflow_artifact", workflow_id=workflow_dir.name, artifact_path=rel)
|
||||
video_url = url_for("workflow_artifact", workflow_id=_workflow_id(workflow_dir), artifact_path=rel)
|
||||
break
|
||||
|
||||
outcome = metadata.get("execution_outcome") or {}
|
||||
|
|
@ -514,7 +526,7 @@ def _build_workflow_summary(workflow_dir: Path) -> Dict[str, Any]:
|
|||
iterations = len(trajectory)
|
||||
|
||||
return {
|
||||
"id": workflow_dir.name,
|
||||
"id": _workflow_id(workflow_dir),
|
||||
"path": str(workflow_dir),
|
||||
"task_id": metadata.get("task_id") or metadata.get("task_name") or workflow_dir.name,
|
||||
"task_name": metadata.get("task_name") or metadata.get("task_id") or workflow_dir.name,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from openspace.grounding.core.provider import Provider
|
|||
from openspace.grounding.core.session import BaseSession
|
||||
from openspace.config import get_config
|
||||
from openspace.config.utils import get_config_value
|
||||
from openspace.platform import get_local_server_config
|
||||
from openspace.platforms import get_local_server_config
|
||||
from openspace.utils.logging import Logger
|
||||
from .transport.connector import GUIConnector
|
||||
from .transport.local_connector import LocalGUIConnector
|
||||
|
|
|
|||
|
|
@ -30,6 +30,14 @@ from openspace.grounding.backends.mcp.transport.connectors.base import MCPBaseCo
|
|||
logger = Logger.get_logger(__name__)
|
||||
|
||||
|
||||
def _build_sse_candidate_urls(base_url: str) -> list[str]:
|
||||
"""Try the common FastMCP `/sse` endpoint before the raw base URL."""
|
||||
normalized = base_url.rstrip("/")
|
||||
if normalized.endswith("/sse"):
|
||||
return [normalized]
|
||||
return [f"{normalized}/sse", normalized]
|
||||
|
||||
|
||||
class HttpConnector(MCPBaseConnector):
|
||||
"""Connector for MCP implementations using HTTP transport.
|
||||
|
||||
|
|
@ -210,69 +218,72 @@ class HttpConnector(MCPBaseConnector):
|
|||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
|
||||
# Try SSE fallback
|
||||
try:
|
||||
logger.debug(f"Attempting SSE fallback connection to: {self.base_url}")
|
||||
connection_manager = SseConnectionManager(
|
||||
self.base_url, self.headers, self.timeout, self.sse_read_timeout
|
||||
)
|
||||
|
||||
# Test the connection by starting it with built-in timeout
|
||||
read_stream, write_stream = await connection_manager.start(timeout=self.timeout)
|
||||
|
||||
# Create and verify ClientSession
|
||||
test_client = ClientSession(read_stream, write_stream, sampling_callback=None)
|
||||
|
||||
# Add timeout to __aenter__ - use asyncio.wait_for instead of anyio.fail_after
|
||||
# to avoid cancel scope conflicts with background tasks
|
||||
# Try SSE fallback. FastMCP commonly exposes legacy SSE on `/sse`,
|
||||
# but some callers may already pass the full endpoint.
|
||||
for sse_url in _build_sse_candidate_urls(self.base_url):
|
||||
connection_manager = None
|
||||
try:
|
||||
await asyncio.wait_for(test_client.__aenter__(), timeout=self.timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError(f"ClientSession enter timed out after {self.timeout}s")
|
||||
logger.debug(f"Attempting SSE fallback connection to: {sse_url}")
|
||||
connection_manager = SseConnectionManager(
|
||||
sse_url, self.headers, self.timeout, self.sse_read_timeout
|
||||
)
|
||||
|
||||
try:
|
||||
# Test the connection by starting it with built-in timeout
|
||||
read_stream, write_stream = await connection_manager.start(timeout=self.timeout)
|
||||
|
||||
# Create and verify ClientSession
|
||||
test_client = ClientSession(read_stream, write_stream, sampling_callback=None)
|
||||
|
||||
# Add timeout to __aenter__ - use asyncio.wait_for instead of anyio.fail_after
|
||||
# to avoid cancel scope conflicts with background tasks
|
||||
try:
|
||||
await asyncio.wait_for(test_client.initialize(), timeout=self.timeout)
|
||||
await asyncio.wait_for(test_client.__aenter__(), timeout=self.timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError(f"initialize() timed out after {self.timeout}s")
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(test_client.list_tools(), timeout=self.timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError(f"list_tools() timed out after {self.timeout}s")
|
||||
|
||||
# SUCCESS! Keep the client session (don't close it, closing destroys the streams)
|
||||
# Store it directly as the client_session for later use
|
||||
self.transport_type = "SSE"
|
||||
self._connection_manager = connection_manager
|
||||
self._connection = connection_manager.get_streams()
|
||||
self.client_session = test_client # Reuse the working session
|
||||
logger.debug("SSE transport selected")
|
||||
return
|
||||
except TimeoutError:
|
||||
try:
|
||||
await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
raise
|
||||
except Exception as init_error:
|
||||
# Clean up the test client only on error
|
||||
try:
|
||||
await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
raise init_error
|
||||
raise TimeoutError(f"ClientSession enter timed out after {self.timeout}s")
|
||||
|
||||
except Exception as e:
|
||||
sse_error = e
|
||||
logger.debug(f"SSE failed: {e}")
|
||||
|
||||
# Clean up the failed connection manager
|
||||
if connection_manager:
|
||||
try:
|
||||
await asyncio.wait_for(connection_manager.stop(), timeout=2)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
try:
|
||||
await asyncio.wait_for(test_client.initialize(), timeout=self.timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError(f"initialize() timed out after {self.timeout}s")
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(test_client.list_tools(), timeout=self.timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError(f"list_tools() timed out after {self.timeout}s")
|
||||
|
||||
# SUCCESS! Keep the client session (don't close it, closing destroys the streams)
|
||||
# Store it directly as the client_session for later use
|
||||
self.transport_type = "SSE"
|
||||
self._connection_manager = connection_manager
|
||||
self._connection = connection_manager.get_streams()
|
||||
self.client_session = test_client # Reuse the working session
|
||||
logger.debug("SSE transport selected")
|
||||
return
|
||||
except TimeoutError:
|
||||
try:
|
||||
await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
raise
|
||||
except Exception as init_error:
|
||||
# Clean up the test client only on error
|
||||
try:
|
||||
await asyncio.wait_for(test_client.__aexit__(None, None, None), timeout=2)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
raise init_error
|
||||
|
||||
except Exception as e:
|
||||
sse_error = e
|
||||
logger.debug(f"SSE failed for {sse_url}: {e}")
|
||||
|
||||
# Clean up the failed connection manager
|
||||
if connection_manager:
|
||||
try:
|
||||
await asyncio.wait_for(connection_manager.stop(), timeout=2)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
|
||||
# Both MCP transports failed, try simple JSON-RPC HTTP as last resort
|
||||
# This is useful for custom MCP servers that don't implement proper MCP transports
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from .transport.connector import ShellConnector
|
|||
from .transport.local_connector import LocalShellConnector
|
||||
from openspace.config import get_config
|
||||
from openspace.config.utils import get_config_value
|
||||
from openspace.platform.config import get_local_server_config
|
||||
from openspace.platforms.config import get_local_server_config
|
||||
from openspace.utils.logging import Logger
|
||||
|
||||
logger = Logger.get_logger(__name__)
|
||||
|
|
|
|||
|
|
@ -375,7 +375,7 @@ If you already have the exact command/script to run, use run_shell instead."""
|
|||
|
||||
if base_url is not None:
|
||||
try:
|
||||
from openspace.platform import SystemInfoClient
|
||||
from openspace.platforms import SystemInfoClient
|
||||
|
||||
async with SystemInfoClient(base_url=base_url, timeout=5) as client:
|
||||
info = await client.get_system_info(use_cache=False)
|
||||
|
|
|
|||
|
|
@ -337,13 +337,13 @@ class GroundingClient:
|
|||
def get_session_info(self, name: str) -> SessionInfo:
|
||||
"""Get session monitoring info"""
|
||||
if name not in self._session_info:
|
||||
raise ErrorCode.SESSION_NOT_FOUND(name)
|
||||
raise GroundingError(f"Session not found: {name}", code=ErrorCode.SESSION_NOT_FOUND)
|
||||
return self._session_info[name]
|
||||
|
||||
def get_session(self, name: str) -> BaseSession:
|
||||
"""Get session"""
|
||||
if name not in self._sessions:
|
||||
raise ErrorCode.SESSION_NOT_FOUND(name)
|
||||
raise GroundingError(f"Session not found: {name}", code=ErrorCode.SESSION_NOT_FOUND)
|
||||
return self._sessions[name]
|
||||
|
||||
|
||||
|
|
@ -479,7 +479,7 @@ class GroundingClient:
|
|||
# Session-level
|
||||
if session_name:
|
||||
if session_name not in self._sessions:
|
||||
raise ErrorCode.SESSION_NOT_FOUND(session_name)
|
||||
raise GroundingError(f"Session not found: {session_name}", code=ErrorCode.SESSION_NOT_FOUND)
|
||||
backend_type = self._session_info[session_name].backend_type
|
||||
return await self._fetch_tools(
|
||||
backend_type,
|
||||
|
|
@ -531,7 +531,7 @@ class GroundingClient:
|
|||
use_cache: bool = False
|
||||
) -> list[BaseTool]:
|
||||
if session_name not in self._session_info:
|
||||
raise ErrorCode.SESSION_NOT_FOUND(session_name)
|
||||
raise GroundingError(f"Session not found: {session_name}", code=ErrorCode.SESSION_NOT_FOUND)
|
||||
backend = self._session_info[session_name].backend_type
|
||||
return await self.list_tools(backend, session_name, use_cache)
|
||||
|
||||
|
|
@ -838,7 +838,7 @@ class GroundingClient:
|
|||
runtime_backend = backend
|
||||
else:
|
||||
if runtime_session not in self._session_info:
|
||||
raise ErrorCode.SESSION_NOT_FOUND(runtime_session)
|
||||
raise GroundingError(f"Session not found: {runtime_session}", code=ErrorCode.SESSION_NOT_FOUND)
|
||||
runtime_backend = self._session_info[
|
||||
runtime_session
|
||||
].backend_type
|
||||
|
|
|
|||
|
|
@ -21,7 +21,11 @@ Supported host agents:
|
|||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
from openspace.host_detection.resolver import build_llm_kwargs, build_grounding_config_path
|
||||
from openspace.host_detection.resolver import (
|
||||
build_grounding_config_path,
|
||||
build_llm_kwargs,
|
||||
load_runtime_env,
|
||||
)
|
||||
from openspace.host_detection.nanobot import (
|
||||
get_openai_api_key as _nanobot_get_openai_api_key,
|
||||
read_nanobot_mcp_env,
|
||||
|
|
@ -31,6 +35,7 @@ from openspace.host_detection.openclaw import (
|
|||
get_openclaw_openai_api_key as _openclaw_get_openai_api_key,
|
||||
is_openclaw_host,
|
||||
read_openclaw_skill_env,
|
||||
try_read_openclaw_config,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("openspace.host_detection")
|
||||
|
|
@ -80,12 +85,12 @@ def get_openai_api_key() -> Optional[str]:
|
|||
__all__ = [
|
||||
"build_llm_kwargs",
|
||||
"build_grounding_config_path",
|
||||
"load_runtime_env",
|
||||
"get_openai_api_key",
|
||||
"read_host_mcp_env",
|
||||
# legacy re-exports
|
||||
"read_nanobot_mcp_env",
|
||||
"try_read_nanobot_config",
|
||||
# openclaw-specific (for direct use if needed)
|
||||
"is_openclaw_host",
|
||||
"read_openclaw_skill_env",
|
||||
"try_read_openclaw_config",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
|
@ -31,23 +32,35 @@ PROVIDER_REGISTRY: List[tuple] = [
|
|||
("zhipu", ("zhipu", "glm", "zai"), ""),
|
||||
("dashscope", ("qwen", "dashscope"), ""),
|
||||
("moonshot", ("moonshot", "kimi"), "https://api.moonshot.ai/v1"),
|
||||
("minimax", ("minimax",), "https://api.minimax.io/v1"),
|
||||
("minimax", ("minimax",), "https://api.minimaxi.com/v1"),
|
||||
("groq", ("groq",), ""),
|
||||
]
|
||||
|
||||
NANOBOT_CONFIG_PATH = Path.home() / ".nanobot" / "config.json"
|
||||
|
||||
def _resolve_nanobot_config_path() -> Path:
|
||||
"""Resolve the nanobot config path from env overrides or defaults."""
|
||||
explicit = os.environ.get("NANOBOT_CONFIG_PATH", "").strip()
|
||||
if explicit:
|
||||
return Path(explicit).expanduser()
|
||||
|
||||
state_dir = os.environ.get("NANOBOT_STATE_DIR", "").strip()
|
||||
if state_dir:
|
||||
return Path(state_dir).expanduser() / "config.json"
|
||||
|
||||
return Path.home() / ".nanobot" / "config.json"
|
||||
|
||||
|
||||
def _load_nanobot_config() -> Optional[Dict[str, Any]]:
|
||||
"""Load and parse ``~/.nanobot/config.json``. Returns None on failure."""
|
||||
if not NANOBOT_CONFIG_PATH.is_file():
|
||||
"""Load and parse nanobot config.json. Returns None on failure."""
|
||||
config_path = _resolve_nanobot_config_path()
|
||||
if not config_path.is_file():
|
||||
return None
|
||||
try:
|
||||
with open(NANOBOT_CONFIG_PATH, encoding="utf-8") as f:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else None
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to read nanobot config %s: %s", NANOBOT_CONFIG_PATH, e)
|
||||
logger.warning("Failed to read nanobot config %s: %s", config_path, e)
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -154,10 +167,11 @@ def try_read_nanobot_config(model: str) -> Optional[Dict[str, Any]]:
|
|||
result["_forced_provider"] = forced_provider
|
||||
|
||||
if result:
|
||||
config_path = _resolve_nanobot_config_path()
|
||||
logger.info(
|
||||
"Auto-detected LLM credentials from nanobot config (%s), "
|
||||
"provider matched for model=%r",
|
||||
NANOBOT_CONFIG_PATH, match_model,
|
||||
config_path, match_model,
|
||||
)
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""OpenClaw host-agent config reader.
|
||||
|
||||
Reads ``~/.openclaw/openclaw.json`` to auto-detect:
|
||||
- LLM provider credentials (via ``auth-profiles`` — not yet implemented)
|
||||
- LLM provider credentials from env-style config blocks
|
||||
(``skills.entries.openspace.env`` and ``env.vars``)
|
||||
- Skill-level env block (``skills.entries.openspace.env``)
|
||||
- OpenAI API key for embedding generation
|
||||
|
||||
|
|
@ -17,20 +18,74 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from openspace.host_detection.nanobot import PROVIDER_REGISTRY
|
||||
|
||||
logger = logging.getLogger("openspace.host_detection")
|
||||
|
||||
_STATE_DIRNAMES = [".openclaw", ".clawdbot", ".moldbot", ".moltbot"]
|
||||
_CONFIG_FILENAMES = ["openclaw.json", "clawdbot.json", "moldbot.json", "moltbot.json"]
|
||||
_PROVIDER_ENV_VARS: Dict[str, Dict[str, tuple[str, ...]]] = {
|
||||
"openrouter": {
|
||||
"api_key": ("OPENROUTER_API_KEY", "OR_API_KEY"),
|
||||
"api_base": ("OPENROUTER_API_BASE",),
|
||||
},
|
||||
"aihubmix": {
|
||||
"api_key": ("AIHUBMIX_API_KEY",),
|
||||
"api_base": ("AIHUBMIX_API_BASE",),
|
||||
},
|
||||
"siliconflow": {
|
||||
"api_key": ("SILICONFLOW_API_KEY",),
|
||||
"api_base": ("SILICONFLOW_API_BASE",),
|
||||
},
|
||||
"volcengine": {
|
||||
"api_key": ("VOLCENGINE_API_KEY", "ARK_API_KEY"),
|
||||
"api_base": ("VOLCENGINE_API_BASE", "ARK_API_BASE"),
|
||||
},
|
||||
"anthropic": {
|
||||
"api_key": ("ANTHROPIC_API_KEY",),
|
||||
"api_base": ("ANTHROPIC_API_BASE",),
|
||||
},
|
||||
"openai": {
|
||||
"api_key": ("OPENAI_API_KEY",),
|
||||
"api_base": ("OPENAI_BASE_URL", "OPENAI_API_BASE"),
|
||||
},
|
||||
"deepseek": {
|
||||
"api_key": ("DEEPSEEK_API_KEY",),
|
||||
"api_base": ("DEEPSEEK_API_BASE",),
|
||||
},
|
||||
"gemini": {
|
||||
"api_key": ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
|
||||
"api_base": ("GEMINI_API_BASE", "GOOGLE_API_BASE"),
|
||||
},
|
||||
"zhipu": {
|
||||
"api_key": ("ZHIPU_API_KEY",),
|
||||
"api_base": ("ZHIPU_API_BASE",),
|
||||
},
|
||||
"dashscope": {
|
||||
"api_key": ("DASHSCOPE_API_KEY",),
|
||||
"api_base": ("DASHSCOPE_API_BASE",),
|
||||
},
|
||||
"moonshot": {
|
||||
"api_key": ("MOONSHOT_API_KEY",),
|
||||
"api_base": ("MOONSHOT_API_BASE",),
|
||||
},
|
||||
"minimax": {
|
||||
"api_key": ("MINIMAX_API_KEY",),
|
||||
"api_base": ("MINIMAX_API_BASE",),
|
||||
},
|
||||
"groq": {
|
||||
"api_key": ("GROQ_API_KEY",),
|
||||
"api_base": ("GROQ_API_BASE",),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_openclaw_config_path() -> Optional[Path]:
|
||||
"""Find the OpenClaw config file on disk."""
|
||||
import os
|
||||
|
||||
# 1. Explicit env override
|
||||
explicit = os.environ.get("OPENCLAW_CONFIG_PATH", "").strip()
|
||||
if explicit:
|
||||
p = Path(explicit).expanduser()
|
||||
|
|
@ -38,7 +93,6 @@ def _resolve_openclaw_config_path() -> Optional[Path]:
|
|||
return p
|
||||
return None
|
||||
|
||||
# 2. State dir override
|
||||
state_dir = os.environ.get("OPENCLAW_STATE_DIR", "").strip()
|
||||
if state_dir:
|
||||
for fname in _CONFIG_FILENAMES:
|
||||
|
|
@ -46,7 +100,6 @@ def _resolve_openclaw_config_path() -> Optional[Path]:
|
|||
if p.is_file():
|
||||
return p
|
||||
|
||||
# 3. Default locations
|
||||
home = Path.home()
|
||||
for dirname in _STATE_DIRNAMES:
|
||||
for fname in _CONFIG_FILENAMES:
|
||||
|
|
@ -71,6 +124,119 @@ def _load_openclaw_config() -> Optional[Dict[str, Any]]:
|
|||
return None
|
||||
|
||||
|
||||
def _coerce_env_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _pick_env(env_block: Dict[str, Any], names: tuple[str, ...]) -> str:
|
||||
for name in names:
|
||||
value = _coerce_env_value(env_block.get(name))
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _get_openclaw_env(skill_name: str = "openspace") -> Dict[str, Any]:
|
||||
"""Merge OpenClaw top-level env vars with skill-level env overrides."""
|
||||
merged: Dict[str, Any] = {}
|
||||
data = _load_openclaw_config()
|
||||
if data and isinstance(data, dict):
|
||||
env_section = data.get("env", {})
|
||||
if isinstance(env_section, dict):
|
||||
vars_block = env_section.get("vars", {})
|
||||
if isinstance(vars_block, dict):
|
||||
merged.update(vars_block)
|
||||
merged.update(read_openclaw_skill_env(skill_name))
|
||||
return merged
|
||||
|
||||
|
||||
def _extract_explicit_llm_kwargs(env_block: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Read OpenSpace-native LLM overrides from an env-like dict."""
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
api_key = _coerce_env_value(env_block.get("OPENSPACE_LLM_API_KEY"))
|
||||
if api_key:
|
||||
result["api_key"] = api_key
|
||||
|
||||
api_base = _coerce_env_value(env_block.get("OPENSPACE_LLM_API_BASE"))
|
||||
if api_base:
|
||||
result["api_base"] = api_base
|
||||
|
||||
extra_headers_raw = _coerce_env_value(env_block.get("OPENSPACE_LLM_EXTRA_HEADERS"))
|
||||
if extra_headers_raw:
|
||||
try:
|
||||
headers = json.loads(extra_headers_raw)
|
||||
if isinstance(headers, dict):
|
||||
result["extra_headers"] = headers
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"Invalid JSON in OpenClaw OPENSPACE_LLM_EXTRA_HEADERS: %r",
|
||||
extra_headers_raw,
|
||||
)
|
||||
|
||||
llm_config_raw = _coerce_env_value(env_block.get("OPENSPACE_LLM_CONFIG"))
|
||||
if llm_config_raw:
|
||||
try:
|
||||
llm_config = json.loads(llm_config_raw)
|
||||
if isinstance(llm_config, dict):
|
||||
result.update(llm_config)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"Invalid JSON in OpenClaw OPENSPACE_LLM_CONFIG: %r",
|
||||
llm_config_raw,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _extract_provider_env(
|
||||
env_block: Dict[str, Any],
|
||||
provider: str,
|
||||
default_base: str = "",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
spec = _PROVIDER_ENV_VARS.get(provider)
|
||||
if not spec:
|
||||
return None
|
||||
|
||||
api_key = _pick_env(env_block, spec["api_key"])
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
result: Dict[str, Any] = {"api_key": api_key}
|
||||
api_base = _pick_env(env_block, spec.get("api_base", ())) or default_base
|
||||
if api_base:
|
||||
result["api_base"] = api_base
|
||||
return result
|
||||
|
||||
|
||||
def _match_provider_env(model: str, env_block: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Resolve provider-native env vars from OpenClaw config for a model."""
|
||||
model_lower = model.lower()
|
||||
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
||||
normalized_prefix = model_prefix.replace("-", "_")
|
||||
|
||||
for name, _keywords, default_base in PROVIDER_REGISTRY:
|
||||
if model_prefix and normalized_prefix == name:
|
||||
result = _extract_provider_env(env_block, name, default_base)
|
||||
if result:
|
||||
return result
|
||||
|
||||
for name, keywords, default_base in PROVIDER_REGISTRY:
|
||||
if any(keyword in model_lower for keyword in keywords):
|
||||
result = _extract_provider_env(env_block, name, default_base)
|
||||
if result:
|
||||
return result
|
||||
|
||||
for name, _keywords, default_base in PROVIDER_REGISTRY:
|
||||
result = _extract_provider_env(env_block, name, default_base)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def read_openclaw_skill_env(skill_name: str = "openspace") -> Dict[str, str]:
|
||||
"""Read ``skills.entries.<skill_name>.env`` from OpenClaw config.
|
||||
|
||||
|
|
@ -104,34 +270,46 @@ def get_openclaw_openai_api_key() -> Optional[str]:
|
|||
|
||||
Returns the key string, or None.
|
||||
"""
|
||||
# Try skill-level env
|
||||
env = read_openclaw_skill_env("openspace")
|
||||
key = env.get("OPENAI_API_KEY", "").strip()
|
||||
env = _get_openclaw_env("openspace")
|
||||
key = _coerce_env_value(env.get("OPENAI_API_KEY"))
|
||||
if key:
|
||||
logger.debug("Using OpenAI API key from OpenClaw skill env config")
|
||||
return key
|
||||
|
||||
# Try top-level config env.vars
|
||||
data = _load_openclaw_config()
|
||||
if data:
|
||||
env_section = data.get("env", {})
|
||||
if isinstance(env_section, dict):
|
||||
vars_block = env_section.get("vars", {})
|
||||
if isinstance(vars_block, dict):
|
||||
key = vars_block.get("OPENAI_API_KEY", "").strip()
|
||||
if key:
|
||||
logger.debug("Using OpenAI API key from OpenClaw env.vars config")
|
||||
return key
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_openclaw_host() -> bool:
|
||||
"""Detect if the current environment is running under OpenClaw."""
|
||||
import os
|
||||
# Check OpenClaw-specific env vars
|
||||
if os.environ.get("OPENCLAW_STATE_DIR") or os.environ.get("OPENCLAW_CONFIG_PATH"):
|
||||
return True
|
||||
# Check if config exists
|
||||
return _resolve_openclaw_config_path() is not None
|
||||
|
||||
|
||||
def try_read_openclaw_config(model: str) -> Optional[Dict[str, Any]]:
|
||||
"""Read LLM credentials from OpenClaw's env-style config blocks."""
|
||||
env_block = _get_openclaw_env("openspace")
|
||||
if not env_block:
|
||||
return None
|
||||
|
||||
explicit_kwargs = _extract_explicit_llm_kwargs(env_block)
|
||||
provider_kwargs = _match_provider_env(model or "", env_block)
|
||||
|
||||
if not explicit_kwargs and not provider_kwargs:
|
||||
return None
|
||||
|
||||
result: Dict[str, Any] = {}
|
||||
if provider_kwargs:
|
||||
result.update(provider_kwargs)
|
||||
if explicit_kwargs:
|
||||
result.update(explicit_kwargs)
|
||||
|
||||
config_path = _resolve_openclaw_config_path()
|
||||
logger.info(
|
||||
"Auto-detected LLM credentials from OpenClaw config (%s), provider matched for model=%r",
|
||||
config_path,
|
||||
model,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,120 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger("openspace.host_detection")
|
||||
|
||||
_DEFAULT_MODEL = "openrouter/anthropic/claude-sonnet-4.5"
|
||||
|
||||
_PROVIDER_NATIVE_ENV_VARS: Dict[str, tuple[str, ...]] = {
|
||||
"openrouter": ("OPENROUTER_API_KEY", "OR_API_KEY"),
|
||||
"aihubmix": ("AIHUBMIX_API_KEY",),
|
||||
"siliconflow": ("SILICONFLOW_API_KEY",),
|
||||
"volcengine": ("VOLCENGINE_API_KEY", "ARK_API_KEY"),
|
||||
"anthropic": ("ANTHROPIC_API_KEY",),
|
||||
"openai": ("OPENAI_API_KEY",),
|
||||
"deepseek": ("DEEPSEEK_API_KEY",),
|
||||
"gemini": ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
|
||||
"zhipu": ("ZHIPU_API_KEY",),
|
||||
"dashscope": ("DASHSCOPE_API_KEY",),
|
||||
"moonshot": ("MOONSHOT_API_KEY",),
|
||||
"minimax": ("MINIMAX_API_KEY",),
|
||||
"groq": ("GROQ_API_KEY",),
|
||||
}
|
||||
|
||||
_env_loaded = False
|
||||
|
||||
|
||||
def _load_env_once() -> None:
|
||||
"""Load .env files once per process.
|
||||
|
||||
Search order (first-loaded wins for each key):
|
||||
1. ``openspace/.env`` (package root — works regardless of CWD)
|
||||
2. ``CWD/.env`` (project-level fallback)
|
||||
|
||||
Uses ``override=False`` so env vars already in the process (e.g. set
|
||||
by the host agent or the shell) are never overwritten.
|
||||
"""
|
||||
global _env_loaded
|
||||
if _env_loaded:
|
||||
return
|
||||
_env_loaded = True
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
pkg_env = Path(__file__).resolve().parent.parent / ".env"
|
||||
if pkg_env.is_file():
|
||||
load_dotenv(pkg_env)
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def load_runtime_env() -> None:
|
||||
"""Public wrapper for one-time runtime .env loading."""
|
||||
_load_env_once()
|
||||
|
||||
|
||||
def _pick_first_env(names: tuple[str, ...]) -> str:
|
||||
for name in names:
|
||||
value = os.environ.get(name, "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _ensure_local_no_proxy() -> None:
|
||||
required_hosts = ("127.0.0.1", "localhost")
|
||||
for env_name in ("NO_PROXY", "no_proxy"):
|
||||
current = os.environ.get(env_name, "")
|
||||
entries = [entry.strip() for entry in current.split(",") if entry.strip()]
|
||||
updated = False
|
||||
for host in required_hosts:
|
||||
if host not in entries:
|
||||
entries.append(host)
|
||||
updated = True
|
||||
if updated:
|
||||
os.environ[env_name] = ",".join(entries)
|
||||
|
||||
|
||||
def _infer_provider_name(model: str) -> Optional[str]:
|
||||
"""Infer the provider name from a model string using PROVIDER_REGISTRY."""
|
||||
from openspace.host_detection.nanobot import PROVIDER_REGISTRY
|
||||
|
||||
model_lower = (model or "").lower()
|
||||
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
||||
normalized_prefix = model_prefix.replace("-", "_")
|
||||
|
||||
for name, _keywords, _default_base in PROVIDER_REGISTRY:
|
||||
if model_prefix and normalized_prefix == name:
|
||||
return name
|
||||
|
||||
for name, keywords, _default_base in PROVIDER_REGISTRY:
|
||||
if any(keyword in model_lower for keyword in keywords):
|
||||
return name
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _has_provider_native_env(model: str) -> bool:
|
||||
"""Check if a provider-native API key (e.g. OPENROUTER_API_KEY) exists.
|
||||
|
||||
When True, the key from .env or the process environment is sufficient
|
||||
for litellm to authenticate — no need to read nanobot / host config.
|
||||
"""
|
||||
provider = _infer_provider_name(model)
|
||||
if not provider:
|
||||
return False
|
||||
|
||||
env_names = _PROVIDER_NATIVE_ENV_VARS.get(provider)
|
||||
if not env_names:
|
||||
return False
|
||||
|
||||
return bool(_pick_first_env(env_names))
|
||||
|
||||
|
||||
def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
|
||||
"""Build litellm kwargs and resolve model for OpenSpace's LLM client.
|
||||
|
|
@ -27,39 +137,63 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
|
|||
OPENSPACE_LLM_EXTRA_HEADERS → litellm ``extra_headers`` (JSON string)
|
||||
OPENSPACE_LLM_CONFIG → arbitrary litellm kwargs (JSON string)
|
||||
|
||||
Tier 2 — Auto-detect from host agent config file::
|
||||
Tier 2 — Provider-native env vars already present in the process
|
||||
(including values loaded from ``openspace/.env``)::
|
||||
|
||||
~/.nanobot/config.json → providers.{matched}.apiKey / apiBase
|
||||
OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / ...
|
||||
|
||||
Tier 3 — Provider-native env vars inherited from the parent process
|
||||
(e.g. ``OPENROUTER_API_KEY``). Read by litellm automatically.
|
||||
These take precedence over host-agent config so local/standalone
|
||||
launches are not hijacked by unrelated host config files.
|
||||
|
||||
Tier 3 — Host-agent config file fallback (only when Tier 1+2 absent)::
|
||||
|
||||
nanobot → ``~/.nanobot/config.json``
|
||||
openclaw → ``~/.openclaw/openclaw.json``
|
||||
|
||||
Returns:
|
||||
``(resolved_model, llm_kwargs_dict)``
|
||||
"""
|
||||
from openspace.host_detection.nanobot import try_read_nanobot_config
|
||||
_load_env_once()
|
||||
|
||||
kwargs: Dict[str, Any] = {}
|
||||
resolved_model = model
|
||||
source = "inherited env"
|
||||
|
||||
# --- Tier 2: auto-detect from host config (filled first, may be overridden) ---
|
||||
host_config = try_read_nanobot_config(model)
|
||||
has_explicit_llm_override = bool(
|
||||
os.environ.get("OPENSPACE_LLM_API_BASE")
|
||||
or os.environ.get("OPENSPACE_LLM_API_KEY")
|
||||
)
|
||||
provider_native_env_used = _has_provider_native_env(
|
||||
resolved_model or _DEFAULT_MODEL
|
||||
)
|
||||
|
||||
# --- Tier 3: host config fallback (only when no local keys) ---
|
||||
host_config = None
|
||||
host_source = None
|
||||
if not has_explicit_llm_override and not provider_native_env_used:
|
||||
from openspace.host_detection.nanobot import try_read_nanobot_config
|
||||
host_config = try_read_nanobot_config(model)
|
||||
if host_config:
|
||||
host_source = "nanobot config"
|
||||
else:
|
||||
from openspace.host_detection.openclaw import try_read_openclaw_config
|
||||
host_config = try_read_openclaw_config(model)
|
||||
if host_config:
|
||||
host_source = "openclaw config"
|
||||
|
||||
if host_config:
|
||||
host_model = host_config.pop("_model", None)
|
||||
forced_provider = host_config.pop("_forced_provider", None)
|
||||
if not resolved_model and host_model:
|
||||
resolved_model = host_model
|
||||
# If the host config forces a gateway provider (e.g. openrouter)
|
||||
# and the model name doesn't already carry that prefix, prepend
|
||||
# it so that litellm uses the correct request format (OpenAI-
|
||||
# compatible for gateways vs native for direct providers).
|
||||
|
||||
_GATEWAY_PROVIDERS = {"openrouter", "aihubmix", "siliconflow"}
|
||||
if (
|
||||
forced_provider
|
||||
and forced_provider in _GATEWAY_PROVIDERS
|
||||
and resolved_model
|
||||
and not resolved_model.lower().startswith(f"{forced_provider}/")
|
||||
and not (model and has_explicit_llm_override)
|
||||
):
|
||||
resolved_model = f"{forced_provider}/{resolved_model}"
|
||||
logger.info(
|
||||
|
|
@ -67,7 +201,7 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
|
|||
resolved_model, forced_provider,
|
||||
)
|
||||
kwargs.update(host_config)
|
||||
source = "nanobot config"
|
||||
source = host_source or "host config"
|
||||
|
||||
# --- Tier 1: explicit env vars override everything ---
|
||||
api_key = os.environ.get("OPENSPACE_LLM_API_KEY")
|
||||
|
|
@ -100,7 +234,38 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
|
|||
|
||||
# Default model fallback
|
||||
if not resolved_model:
|
||||
resolved_model = "openrouter/anthropic/claude-sonnet-4.5"
|
||||
resolved_model = _DEFAULT_MODEL
|
||||
|
||||
# Ollama models must use the Ollama-native API base, even when unrelated
|
||||
# OPENSPACE_LLM_* env vars are present for a different provider.
|
||||
if resolved_model.lower().startswith("ollama/"):
|
||||
ollama_base = os.environ.get("OLLAMA_API_BASE", "").strip() or "http://127.0.0.1:11434"
|
||||
_ensure_local_no_proxy()
|
||||
kwargs["api_base"] = ollama_base.rstrip("/")
|
||||
kwargs["api_key"] = os.environ.get("OLLAMA_API_KEY", "").strip() or kwargs.get("api_key") or "ollama"
|
||||
kwargs.pop("extra_headers", None)
|
||||
source = "ollama runtime"
|
||||
|
||||
# Provider-specific adjustments for litellm routing
|
||||
if resolved_model and "minimax" in resolved_model.lower():
|
||||
final_key = kwargs.get("api_key")
|
||||
final_base = kwargs.get("api_base", "")
|
||||
|
||||
if final_key:
|
||||
os.environ.setdefault("MINIMAX_API_KEY", final_key)
|
||||
if final_base:
|
||||
os.environ.setdefault("MINIMAX_API_BASE", final_base)
|
||||
|
||||
if (
|
||||
resolved_model.lower().startswith("minimax/")
|
||||
and "minimaxi.com" in final_base
|
||||
):
|
||||
original = resolved_model
|
||||
resolved_model = "openai/" + resolved_model.split("/", 1)[1]
|
||||
logger.info(
|
||||
"Switched model prefix for minimaxi.com compat: %s -> %s",
|
||||
original, resolved_model,
|
||||
)
|
||||
|
||||
if kwargs:
|
||||
safe = {
|
||||
|
|
@ -108,6 +273,11 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
|
|||
for k, v in kwargs.items()
|
||||
}
|
||||
logger.info("LLM kwargs resolved (source=%s): %s", source, safe)
|
||||
elif provider_native_env_used:
|
||||
logger.info(
|
||||
"LLM credentials resolved from provider-native env for model=%r",
|
||||
resolved_model,
|
||||
)
|
||||
|
||||
return resolved_model, kwargs
|
||||
|
||||
|
|
@ -125,6 +295,8 @@ def build_grounding_config_path() -> Optional[str]:
|
|||
Returns:
|
||||
Path to the resolved config file, or None.
|
||||
"""
|
||||
_load_env_once()
|
||||
|
||||
config_json_raw = os.environ.get("OPENSPACE_CONFIG_JSON", "").strip()
|
||||
overrides: Dict[str, Any] = {}
|
||||
if config_json_raw:
|
||||
|
|
@ -180,4 +352,3 @@ def build_grounding_config_path() -> Optional[str]:
|
|||
logger.warning("Failed to write config overrides: %s", e)
|
||||
|
||||
return os.environ.get("OPENSPACE_CONFIG_PATH")
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,17 @@
|
|||
|
||||
This guide covers **agent-specific setup** for integrating OpenSpace. For installation and general concepts, see the [main README](../../README.md#-quick-start).
|
||||
|
||||
**Quick recommendation:**
|
||||
- Use **stdio** if you want the simplest setup.
|
||||
- For **nanobot**, prefer **SSE** if you want OpenSpace to run as a standalone server.
|
||||
- For **openclaw**, prefer **streamable-http** for remote HTTP transport.
|
||||
|
||||
**Common remote endpoints:**
|
||||
- Start `openspace-mcp --transport sse --host 127.0.0.1 --port 8080` and use `http://127.0.0.1:8080/sse`
|
||||
- Start `openspace-mcp --transport streamable-http --host 127.0.0.1 --port 8081` and use `http://127.0.0.1:8081/mcp`
|
||||
|
||||
The endpoint is common; the **host config syntax is not**. nanobot uses `tools.mcpServers`, while openclaw uses `openclaw mcp set`.
|
||||
|
||||
**Pick your agent:**
|
||||
|
||||
| Agent | Setup Guide |
|
||||
|
|
@ -21,7 +32,7 @@ cp -r host_skills/skill-discovery/ /path/to/nanobot/nanobot/skills/
|
|||
cp -r host_skills/delegate-task/ /path/to/nanobot/nanobot/skills/
|
||||
```
|
||||
|
||||
### 2. Add MCP server to `~/.nanobot/config.json`
|
||||
### 2. Option A: stdio (simplest)
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -44,44 +55,75 @@ cp -r host_skills/delegate-task/ /path/to/nanobot/nanobot/skills/
|
|||
> [!TIP]
|
||||
> LLM credentials are auto-detected from nanobot's `providers.*` config — no need to set `OPENSPACE_LLM_API_KEY`.
|
||||
|
||||
---
|
||||
|
||||
## Setup for openclaw
|
||||
|
||||
openclaw ships with a built-in `skills/openspace/` skill — **no need to copy host_skills**.
|
||||
|
||||
> [!NOTE]
|
||||
> openclaw's built-in skill merges skill-discovery + delegate-task into a single SKILL.md with scenario sub-pages. It uses `mcporter call openspace.<tool>` syntax. The underlying MCP tools are identical.
|
||||
|
||||
### 1. Register MCP server
|
||||
|
||||
openclaw uses [mcporter](https://github.com/steipete/mcporter) as its MCP runtime:
|
||||
|
||||
```bash
|
||||
mcporter config add openspace --command "openspace-mcp"
|
||||
```
|
||||
|
||||
### 2. Configure env vars
|
||||
|
||||
Set in `~/.openclaw/openclaw.json`:
|
||||
### 3. Option B: remote HTTP transport
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"entries": {
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"openspace": {
|
||||
"env": {
|
||||
"OPENSPACE_HOST_SKILL_DIRS": "/path/to/openclaw/skills",
|
||||
"OPENSPACE_WORKSPACE": "/path/to/OpenSpace",
|
||||
"OPENSPACE_API_KEY": "sk-xxx"
|
||||
}
|
||||
"type": "sse",
|
||||
"url": "http://127.0.0.1:8080/sse",
|
||||
"toolTimeout": 1200
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or set as system env vars (e.g. `~/.openclaw/.env`).
|
||||
Or:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"openspace": {
|
||||
"type": "streamableHttp",
|
||||
"url": "http://127.0.0.1:8081/mcp",
|
||||
"toolTimeout": 1200
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`toolTimeout` still matters here. Changing transport to `sse` or `streamableHttp` does **not** remove nanobot's per-call timeout for slow MCP tools.
|
||||
|
||||
---
|
||||
|
||||
## Setup for openclaw
|
||||
|
||||
### 1. Copy host skills
|
||||
|
||||
```bash
|
||||
cp -r host_skills/skill-discovery/ /path/to/openclaw/skills/
|
||||
cp -r host_skills/delegate-task/ /path/to/openclaw/skills/
|
||||
```
|
||||
|
||||
### 2. Option A: stdio via mcporter
|
||||
|
||||
openclaw uses [mcporter](https://github.com/steipete/mcporter) as its MCP runtime. Register the server and pass env vars in one command:
|
||||
|
||||
```bash
|
||||
mcporter config add openspace --command "openspace-mcp" \
|
||||
--env OPENSPACE_HOST_SKILL_DIRS=/path/to/openclaw/skills \
|
||||
--env OPENSPACE_WORKSPACE=/path/to/OpenSpace \
|
||||
--env OPENSPACE_API_KEY=sk-xxx
|
||||
```
|
||||
|
||||
### 3. Option B: remote HTTP transport
|
||||
|
||||
```bash
|
||||
openclaw mcp set openspace '{"url":"http://127.0.0.1:8081/mcp","transport":"streamable-http","connectionTimeoutMs":10000}'
|
||||
```
|
||||
|
||||
If you specifically want legacy SSE instead, OpenClaw also supports:
|
||||
|
||||
```bash
|
||||
openclaw mcp set openspace '{"url":"http://127.0.0.1:8080","connectionTimeoutMs":10000}'
|
||||
```
|
||||
|
||||
`connectionTimeoutMs` controls connection establishment for the remote server. It does **not** guarantee unlimited runtime for a long-running MCP tool call.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -110,7 +152,7 @@ All tools default to `"all"` (local + cloud) and **automatically fall back** to
|
|||
```
|
||||
Your Agent (nanobot / openclaw / ...)
|
||||
│
|
||||
│ MCP protocol (stdio)
|
||||
│ MCP protocol (stdio | HTTP/SSE | streamable-http)
|
||||
▼
|
||||
openspace-mcp ← 4 tools exposed
|
||||
├── execute_task ← multi-step grounding agent loop
|
||||
|
|
@ -129,4 +171,4 @@ The two host skills teach the agent **when and how** to call these tools:
|
|||
Skills auto-evolve inside `execute_task` (**FIX** / **DERIVED** / **CAPTURED**). After every call, your agent reports results to the user via its messaging tool.
|
||||
|
||||
> [!NOTE]
|
||||
> For full parameter tables, examples, and decision trees, see each skill's SKILL.md directly.
|
||||
> For full parameter tables, examples, and decision trees, see each skill's SKILL.md directly.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ description: Delegate tasks to OpenSpace — a full-stack autonomous worker for
|
|||
|
||||
# Delegate Tasks to OpenSpace
|
||||
|
||||
OpenSpace is connected as an MCP server. You have 4 tools available: `execute_task`, `search_skills`, `fix_skill`, `upload_skill`.
|
||||
OpenSpace is connected as an MCP server. Whether the host uses `stdio`, `sse`, or `streamable-http`, you have the same 4 tools available: `execute_task`, `search_skills`, `fix_skill`, `upload_skill`.
|
||||
|
||||
## When to use
|
||||
|
||||
|
|
@ -127,5 +127,6 @@ upload_skill(
|
|||
## Notes
|
||||
|
||||
- `execute_task` may take minutes — this is expected for multi-step tasks.
|
||||
- If `execute_task` times out, first check the host's MCP timeout settings. Changing from `stdio` to HTTP (`sse` or `streamable-http`) does not remove host-side per-call time limits.
|
||||
- `upload_skill` requires a cloud API key; if it fails, the evolved skill is still saved locally.
|
||||
- After every OpenSpace call, **tell the user** what happened: task result, any evolved skills, and your upload decision.
|
||||
|
|
|
|||
|
|
@ -2,21 +2,16 @@ import litellm
|
|||
import json
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Sequence, Union, Dict, Optional
|
||||
from dotenv import load_dotenv
|
||||
from openai.types.chat import ChatCompletionToolParam
|
||||
|
||||
from openspace.grounding.core.types import ToolSchema, ToolResult, ToolStatus
|
||||
from openspace.grounding.core.tool import BaseTool
|
||||
from openspace.utils.logging import Logger
|
||||
|
||||
# Load .env from openspace package root (works regardless of CWD),
|
||||
# then fall back to CWD/.env. override=False (default) means first-loaded wins.
|
||||
_PKG_ENV = Path(__file__).resolve().parent.parent / ".env" # openspace/.env
|
||||
if _PKG_ENV.is_file():
|
||||
load_dotenv(_PKG_ENV)
|
||||
load_dotenv() # also try CWD/.env for any remaining vars
|
||||
# .env loading is centralized in host_detection.resolver.load_runtime_env().
|
||||
# CLI/MCP entrypoints call it before reading startup env vars, and the
|
||||
# resolver helpers also call it defensively.
|
||||
|
||||
# Disable LiteLLM verbose logging to prevent stdout blocking with large tool schemas
|
||||
litellm.set_verbose = False
|
||||
|
|
@ -175,9 +170,10 @@ def _infer_backend_from_tool_name(tool_name: str) -> Optional[str]:
|
|||
if not tool_name or not isinstance(tool_name, str):
|
||||
return None
|
||||
name = tool_name.strip()
|
||||
# Dedup format: "server__toolname" -> use suffix
|
||||
# Dedup format: "server__toolname" -> use suffix.
|
||||
# Use rsplit to handle server names that themselves contain "__".
|
||||
if "__" in name:
|
||||
name = name.split("__", 1)[-1]
|
||||
name = name.rsplit("__", 1)[-1]
|
||||
shell_tools = {"shell_agent", "read_file", "write_file", "list_dir", "run_shell"}
|
||||
if name in shell_tools:
|
||||
return "shell"
|
||||
|
|
@ -190,6 +186,37 @@ def _infer_backend_from_tool_name(tool_name: str) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def _resolve_tool_call_target(
|
||||
tool_name: str,
|
||||
tool_map: Dict[str, BaseTool],
|
||||
) -> tuple[Optional[BaseTool], List[str]]:
|
||||
"""Resolve a returned tool name to a concrete tool object.
|
||||
|
||||
The LLM is expected to return the deduped tool key from ``tool_map``.
|
||||
Some providers occasionally return the short schema name instead. In that
|
||||
case we only recover when exactly one tool shares that schema name; if
|
||||
multiple tools match, the call is ambiguous and should not be executed.
|
||||
"""
|
||||
tool_obj = tool_map.get(tool_name)
|
||||
if tool_obj is not None or not tool_name:
|
||||
return tool_obj, []
|
||||
|
||||
fallback_matches = [
|
||||
(llm_name, tool)
|
||||
for llm_name, tool in tool_map.items()
|
||||
if getattr(getattr(tool, "schema", None), "name", None) == tool_name
|
||||
]
|
||||
if len(fallback_matches) == 1:
|
||||
resolved_name, resolved_tool = fallback_matches[0]
|
||||
logger.info(
|
||||
f"[TOOL_FALLBACK] Resolved short tool name '{tool_name}' to '{resolved_name}'"
|
||||
)
|
||||
return resolved_tool, []
|
||||
if len(fallback_matches) > 1:
|
||||
return None, [llm_name for llm_name, _tool in fallback_matches]
|
||||
return None, []
|
||||
|
||||
|
||||
DEFAULT_SUMMARIZE_THRESHOLD_CHARS = 200000 # ~50K tokens, lowered from 400K to prevent context overflow
|
||||
MAX_TOOL_RESULT_CHARS = 200000 # Fallback truncation limit when summarization fails (~50K tokens)
|
||||
|
||||
|
|
@ -198,7 +225,8 @@ async def _summarize_tool_result(
|
|||
tool_name: str,
|
||||
task: str = "",
|
||||
model: str = "openrouter/anthropic/claude-sonnet-4.5",
|
||||
timeout: float = 120.0
|
||||
timeout: float = 120.0,
|
||||
litellm_kwargs: Optional[Dict] = None,
|
||||
) -> str:
|
||||
"""Use LLM to summarize large tool results."""
|
||||
try:
|
||||
|
|
@ -234,11 +262,13 @@ Content:
|
|||
|
||||
Concise summary:"""
|
||||
|
||||
_extra = litellm_kwargs or {}
|
||||
response = await asyncio.wait_for(
|
||||
litellm.acompletion(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
timeout=timeout
|
||||
timeout=timeout,
|
||||
**_extra,
|
||||
),
|
||||
timeout=timeout + 5
|
||||
)
|
||||
|
|
@ -265,7 +295,8 @@ async def _tool_result_to_message_async(
|
|||
task: str = "",
|
||||
summarize_threshold: int = DEFAULT_SUMMARIZE_THRESHOLD_CHARS,
|
||||
summarize_model: str = "openrouter/anthropic/claude-sonnet-4.5",
|
||||
enable_summarization: bool = True
|
||||
enable_summarization: bool = True,
|
||||
litellm_kwargs: Optional[Dict] = None,
|
||||
) -> Dict:
|
||||
"""Convert ToolResult to LLMClient usable message format with LLM summarization for large results.
|
||||
|
||||
|
|
@ -294,7 +325,7 @@ async def _tool_result_to_message_async(
|
|||
|
||||
# Use LLM summarization if content exceeds threshold
|
||||
if original_len > summarize_threshold and enable_summarization:
|
||||
summary = await _summarize_tool_result(text_content, tool_name, task, summarize_model)
|
||||
summary = await _summarize_tool_result(text_content, tool_name, task, summarize_model, litellm_kwargs=litellm_kwargs)
|
||||
if summary:
|
||||
text_content = summary
|
||||
elif original_len > MAX_TOOL_RESULT_CHARS:
|
||||
|
|
@ -406,6 +437,95 @@ class LLMClient:
|
|||
self._logger = Logger.get_logger(__name__)
|
||||
self._last_call_time = 0.0
|
||||
|
||||
@staticmethod
|
||||
def _merge_consecutive_system_messages(messages: List[Dict]) -> List[Dict]:
|
||||
"""Merge consecutive system messages into one.
|
||||
|
||||
Providers like MiniMax reject requests that contain multiple consecutive
|
||||
messages with the same role (error 2013 "invalid chat setting").
|
||||
Merging is safe for all providers — it simply concatenates the content.
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
merged: List[Dict] = []
|
||||
for msg in messages:
|
||||
if (
|
||||
merged
|
||||
and msg.get("role") == "system"
|
||||
and merged[-1].get("role") == "system"
|
||||
):
|
||||
merged[-1] = {
|
||||
"role": "system",
|
||||
"content": merged[-1].get("content", "") + "\n\n" + msg.get("content", ""),
|
||||
}
|
||||
else:
|
||||
merged.append(msg.copy())
|
||||
return merged
|
||||
|
||||
@staticmethod
|
||||
def _is_minimax_model(model: str) -> bool:
|
||||
return isinstance(model, str) and "minimax" in model.lower()
|
||||
|
||||
@classmethod
|
||||
def _rewrite_nonleading_system_messages_for_minimax(
|
||||
cls,
|
||||
messages: List[Dict],
|
||||
) -> List[Dict]:
|
||||
"""Rewrite non-leading system messages into internal user notes for MiniMax."""
|
||||
rewritten: List[Dict] = []
|
||||
rewritten_count = 0
|
||||
|
||||
for msg in messages:
|
||||
msg_copy = msg.copy()
|
||||
if msg_copy.get("role") == "system" and rewritten:
|
||||
content = msg_copy.get("content", "")
|
||||
if isinstance(content, str):
|
||||
msg_copy["content"] = (
|
||||
"[INTERNAL ORCHESTRATION NOTE]\n"
|
||||
"This note was originally injected as a system message by the "
|
||||
"agent runtime. Treat it as workflow guidance, not as a new "
|
||||
"end-user request.\n\n"
|
||||
f"{content}"
|
||||
)
|
||||
msg_copy["role"] = "user"
|
||||
rewritten_count += 1
|
||||
rewritten.append(msg_copy)
|
||||
|
||||
if rewritten_count:
|
||||
logger.info(
|
||||
"Rewrote %d non-leading system message(s) for MiniMax compatibility",
|
||||
rewritten_count,
|
||||
)
|
||||
|
||||
return rewritten
|
||||
|
||||
@classmethod
|
||||
def _normalize_messages_for_model(cls, messages: List[Dict], model: str) -> List[Dict]:
|
||||
"""Normalize message history only when a provider requires it."""
|
||||
if not cls._is_minimax_model(model):
|
||||
return messages
|
||||
|
||||
minimized_system_history = cls._merge_consecutive_system_messages(messages)
|
||||
return cls._rewrite_nonleading_system_messages_for_minimax(
|
||||
minimized_system_history
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_response_field(value):
|
||||
"""Convert provider response fields into plain Python containers."""
|
||||
if hasattr(value, "model_dump"):
|
||||
return value.model_dump(exclude_none=True)
|
||||
if isinstance(value, list):
|
||||
return [LLMClient._serialize_response_field(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return [LLMClient._serialize_response_field(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: LLMClient._serialize_response_field(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
return value
|
||||
|
||||
async def _rate_limit(self):
|
||||
"""Apply rate limiting by adding delay between API calls"""
|
||||
if self.rate_limit_delay > 0:
|
||||
|
|
@ -539,6 +659,7 @@ class LLMClient:
|
|||
"model": kwargs.get("model", self.model),
|
||||
**self.litellm_kwargs,
|
||||
}
|
||||
request_model = completion_kwargs["model"]
|
||||
|
||||
# Add thinking/reasoning_effort only if explicitly enabled and not using tools
|
||||
enable_thinking = kwargs.get("enable_thinking", self.enable_thinking)
|
||||
|
|
@ -561,10 +682,16 @@ class LLMClient:
|
|||
if enable_thinking:
|
||||
completion_kwargs["reasoning_effort"] = kwargs.get("reasoning_effort", "medium")
|
||||
|
||||
# 4. Apply rate limiting
|
||||
# 4. Normalize messages for providers with stricter role constraints.
|
||||
current_messages = self._normalize_messages_for_model(
|
||||
current_messages,
|
||||
request_model,
|
||||
)
|
||||
|
||||
# 5. Apply rate limiting
|
||||
await self._rate_limit()
|
||||
|
||||
# 5. Call LLM with retry (single round)
|
||||
# 6. Call LLM with retry (single round)
|
||||
completion_kwargs["messages"] = current_messages
|
||||
response = await self._call_with_retry(**completion_kwargs)
|
||||
|
||||
|
|
@ -578,6 +705,11 @@ class LLMClient:
|
|||
"role": "assistant",
|
||||
"content": response_message.content or "",
|
||||
}
|
||||
|
||||
for field_name in ("reasoning_details", "reasoning_content", "name"):
|
||||
field_value = getattr(response_message, field_name, None)
|
||||
if field_value:
|
||||
assistant_message[field_name] = self._serialize_response_field(field_value)
|
||||
|
||||
tool_calls = getattr(response_message, 'tool_calls', None)
|
||||
if tool_calls:
|
||||
|
|
@ -604,14 +736,9 @@ class LLMClient:
|
|||
for tool_call in tool_calls:
|
||||
tool_name = tool_call.function.name
|
||||
|
||||
# Resolve tool instance: key might differ from model response (e.g. API returns
|
||||
# "read_file" while we stored "server__read_file" for dedup), so fallback by schema.name
|
||||
tool_obj = tool_map.get(tool_name)
|
||||
if tool_obj is None and tool_name:
|
||||
for _k, _t in tool_map.items():
|
||||
if getattr(getattr(_t, "schema", None), "name", None) == tool_name:
|
||||
tool_obj = _t
|
||||
break
|
||||
# Resolve tool instance: some providers return the short schema
|
||||
# name instead of the deduped LLM-visible tool key.
|
||||
tool_obj, ambiguous_tool_names = _resolve_tool_call_target(tool_name, tool_map)
|
||||
|
||||
backend = None
|
||||
server_name = None
|
||||
|
|
@ -653,15 +780,24 @@ class LLMClient:
|
|||
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
||||
self._logger.debug(f"Failed to parse tool arguments for {tool_name}: {e}")
|
||||
|
||||
if tool_name not in tool_map:
|
||||
result = ToolResult(
|
||||
status=ToolStatus.ERROR,
|
||||
error=f"Tool '{tool_name}' not found"
|
||||
)
|
||||
if tool_obj is None:
|
||||
if ambiguous_tool_names:
|
||||
result = ToolResult(
|
||||
status=ToolStatus.ERROR,
|
||||
error=(
|
||||
f"Tool '{tool_name}' is ambiguous; matches: "
|
||||
f"{', '.join(ambiguous_tool_names)}"
|
||||
)
|
||||
)
|
||||
else:
|
||||
result = ToolResult(
|
||||
status=ToolStatus.ERROR,
|
||||
error=f"Tool '{tool_name}' not found"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
result = await _execute_tool_call(
|
||||
tool=tool_map[tool_name],
|
||||
tool=tool_obj,
|
||||
openai_tool_call={
|
||||
"id": tool_call.id,
|
||||
"type": "function",
|
||||
|
|
@ -697,7 +833,8 @@ class LLMClient:
|
|||
task=user_task,
|
||||
summarize_threshold=self.summarize_threshold_chars,
|
||||
summarize_model=self.model,
|
||||
enable_summarization=self.enable_tool_result_summarization
|
||||
enable_summarization=self.enable_tool_result_summarization,
|
||||
litellm_kwargs=self.litellm_kwargs,
|
||||
)
|
||||
current_messages.append(tool_message)
|
||||
|
||||
|
|
@ -722,6 +859,10 @@ class LLMClient:
|
|||
"content": summary_prompt
|
||||
}
|
||||
current_messages.append(summary_message)
|
||||
current_messages = self._normalize_messages_for_model(
|
||||
current_messages,
|
||||
request_model,
|
||||
)
|
||||
|
||||
# Apply rate limiting before summary call
|
||||
await self._rate_limit()
|
||||
|
|
@ -729,7 +870,7 @@ class LLMClient:
|
|||
# Call LLM to generate summary (without tools)
|
||||
summary_kwargs = {
|
||||
**self.litellm_kwargs,
|
||||
"model": self.model,
|
||||
"model": request_model,
|
||||
"messages": current_messages,
|
||||
"tools": [],
|
||||
"tool_choice": "none",
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ Exposes the following tools to MCP clients:
|
|||
upload_skill — Upload a local skill to cloud (pre-saved metadata, bot decides visibility)
|
||||
|
||||
Usage:
|
||||
python -m openspace.mcp_server # stdio (default)
|
||||
python -m openspace.mcp_server # auto (TTY -> SSE, MCP host -> stdio)
|
||||
python -m openspace.mcp_server --transport sse # SSE on port 8080
|
||||
python -m openspace.mcp_server --transport streamable-http # Streamable HTTP on port 8080
|
||||
python -m openspace.mcp_server --port 9090 # SSE on custom port
|
||||
|
||||
Environment variables: see ``openspace/host_detection/`` and ``openspace/cloud/auth.py``.
|
||||
|
|
@ -22,7 +23,6 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
|
@ -75,12 +75,27 @@ class _MCPSafeStdout:
|
|||
def seekable(self):
|
||||
return False
|
||||
|
||||
_real_stdout = sys.stdout
|
||||
sys.stdout = _MCPSafeStdout(_real_stdout, sys.stderr)
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._stderr, name)
|
||||
|
||||
_LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_real_stdout = sys.stdout
|
||||
|
||||
# Windows pipe buffers are small. When using stdio MCP transport,
|
||||
# the parent process only reads stdout for MCP messages and does NOT
|
||||
# drain stderr. Heavy log/print output during execute_task fills the stderr
|
||||
# pipe buffer, blocking this process on write() → deadlock → timeout.
|
||||
# Redirect stderr to a log file on Windows to prevent this.
|
||||
if os.name == "nt":
|
||||
_stderr_file = open(
|
||||
_LOG_DIR / "mcp_stderr.log", "a", encoding="utf-8", buffering=1
|
||||
)
|
||||
sys.stderr = _stderr_file
|
||||
|
||||
sys.stdout = _MCPSafeStdout(_real_stdout, sys.stderr)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
|
|
@ -123,7 +138,13 @@ async def _get_openspace():
|
|||
|
||||
logger.info("Initializing OpenSpace engine ...")
|
||||
from openspace.tool_layer import OpenSpace, OpenSpaceConfig
|
||||
from openspace.host_detection import build_llm_kwargs, build_grounding_config_path
|
||||
from openspace.host_detection import (
|
||||
build_grounding_config_path,
|
||||
build_llm_kwargs,
|
||||
load_runtime_env,
|
||||
)
|
||||
|
||||
load_runtime_env()
|
||||
|
||||
env_model = os.environ.get("OPENSPACE_MODEL", "")
|
||||
workspace = os.environ.get("OPENSPACE_WORKSPACE")
|
||||
|
|
@ -183,6 +204,61 @@ def _get_store():
|
|||
return _standalone_store
|
||||
|
||||
|
||||
def _get_local_skill_registry():
|
||||
"""Build a lightweight SkillRegistry for local-only skill search.
|
||||
|
||||
This avoids initializing the full OpenSpace engine when callers only
|
||||
want to inspect local skills. It mirrors the skill directory discovery
|
||||
order used by the full engine, but skips LLM / provider startup.
|
||||
The registry is rebuilt per call so later local searches can see
|
||||
newly added skills without requiring a process restart.
|
||||
"""
|
||||
from openspace.config import get_config
|
||||
from openspace.skill_engine import SkillRegistry
|
||||
|
||||
skill_paths: List[Path] = []
|
||||
|
||||
host_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "")
|
||||
if host_dirs_raw:
|
||||
for d in host_dirs_raw.split(","):
|
||||
d = d.strip()
|
||||
if not d:
|
||||
continue
|
||||
p = Path(d)
|
||||
if p.exists():
|
||||
skill_paths.append(p)
|
||||
else:
|
||||
logger.warning("Host skill dir does not exist: %s", d)
|
||||
|
||||
try:
|
||||
skill_cfg = get_config().skills
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load local skill config: %s", e)
|
||||
skill_cfg = None
|
||||
|
||||
if skill_cfg and skill_cfg.skill_dirs:
|
||||
for d in skill_cfg.skill_dirs:
|
||||
p = Path(d)
|
||||
if p in skill_paths:
|
||||
continue
|
||||
if p.exists():
|
||||
skill_paths.append(p)
|
||||
else:
|
||||
logger.warning("Configured skill dir does not exist: %s", d)
|
||||
|
||||
builtin_skills = Path(__file__).resolve().parent / "skills"
|
||||
if builtin_skills.exists():
|
||||
skill_paths.append(builtin_skills)
|
||||
|
||||
if not skill_paths:
|
||||
logger.debug("No local skill directories found")
|
||||
return None
|
||||
|
||||
registry = SkillRegistry(skill_dirs=skill_paths)
|
||||
registry.discover()
|
||||
return registry
|
||||
|
||||
|
||||
def _get_cloud_client():
|
||||
"""Get a OpenSpaceClient instance (raises CloudError if not configured)."""
|
||||
from openspace.cloud.auth import get_openspace_auth
|
||||
|
|
@ -259,16 +335,13 @@ def _read_upload_meta(skill_dir: Path) -> Dict[str, Any]:
|
|||
async def _auto_register_skill_dirs(skill_dirs: List[str]) -> int:
|
||||
"""Register bot skill directories into OpenSpace's SkillRegistry + DB.
|
||||
|
||||
Called automatically by ``execute_task`` when ``skill_dirs`` is provided.
|
||||
Already-registered directories are skipped (idempotent within a session).
|
||||
Called automatically by ``execute_task`` on every invocation. Directories
|
||||
are re-scanned each time so that skills created by the host bot since the last call are discovered immediately.
|
||||
"""
|
||||
global _registered_skill_dirs
|
||||
|
||||
new_dirs = [
|
||||
Path(d) for d in skill_dirs
|
||||
if d not in _registered_skill_dirs and Path(d).is_dir()
|
||||
]
|
||||
if not new_dirs:
|
||||
valid_dirs = [Path(d) for d in skill_dirs if Path(d).is_dir()]
|
||||
if not valid_dirs:
|
||||
return 0
|
||||
|
||||
openspace = await _get_openspace()
|
||||
|
|
@ -277,19 +350,21 @@ async def _auto_register_skill_dirs(skill_dirs: List[str]) -> int:
|
|||
logger.warning("_auto_register_skill_dirs: SkillRegistry not initialized")
|
||||
return 0
|
||||
|
||||
added = registry.discover_from_dirs(new_dirs)
|
||||
added = registry.discover_from_dirs(valid_dirs)
|
||||
|
||||
db_created = 0
|
||||
if added:
|
||||
store = _get_store()
|
||||
db_created = await store.sync_from_registry(added)
|
||||
|
||||
is_first = any(d not in _registered_skill_dirs for d in skill_dirs)
|
||||
for d in skill_dirs:
|
||||
_registered_skill_dirs.add(d)
|
||||
|
||||
if added:
|
||||
action = "Auto-registered" if is_first else "Re-scanned & found"
|
||||
logger.info(
|
||||
f"Auto-registered {len(added)} skill(s) from {len(new_dirs)} dir(s), "
|
||||
f"{action} {len(added)} skill(s) from {len(valid_dirs)} dir(s), "
|
||||
f"{db_created} new DB record(s)"
|
||||
)
|
||||
return len(added)
|
||||
|
|
@ -299,63 +374,48 @@ async def _cloud_search_and_import(task: str, limit: int = 8) -> List[Dict[str,
|
|||
"""Search cloud for skills relevant to *task* and auto-import top hits.
|
||||
|
||||
This is **stage 1** of a two-stage pipeline:
|
||||
Stage 1 (here): cloud BM25+embedding → pick top-N to import locally.
|
||||
Stage 1 (here): server-side embedding search → pick top-N to import locally.
|
||||
Stage 2 (tool_layer): local BM25 + LLM → select from ALL local skills
|
||||
(including ones just imported) for injection.
|
||||
|
||||
Stage 1 intentionally imports more than will be used (default: 8) so
|
||||
that stage 2 has a larger pool to choose from. The two BM25 passes
|
||||
are NOT redundant — stage 1 filters thousands of cloud candidates down
|
||||
that stage 2 has a larger pool to choose from. Stage 1 relies on the
|
||||
server's embedding search to filter thousands of cloud candidates down
|
||||
to a manageable import set; stage 2 makes the final task-specific choice.
|
||||
"""
|
||||
try:
|
||||
from openspace.cloud.search import (
|
||||
SkillSearchEngine, build_cloud_candidates,
|
||||
)
|
||||
from openspace.cloud.embedding import generate_embedding, resolve_embedding_api
|
||||
|
||||
client = _get_cloud_client()
|
||||
embedding_api_key, _ = resolve_embedding_api()
|
||||
has_embedding = bool(embedding_api_key)
|
||||
|
||||
items = await asyncio.to_thread(
|
||||
client.fetch_metadata, include_embedding=has_embedding, limit=200,
|
||||
)
|
||||
if not items:
|
||||
normalized_task_query = task.strip()
|
||||
if not normalized_task_query:
|
||||
return []
|
||||
|
||||
candidates = build_cloud_candidates(items)
|
||||
if not candidates:
|
||||
cloud_client = _get_cloud_client()
|
||||
cloud_search_results = await asyncio.to_thread(
|
||||
cloud_client.search_record_embeddings,
|
||||
query=normalized_task_query,
|
||||
limit=min(limit * 2, 300),
|
||||
)
|
||||
if not cloud_search_results:
|
||||
return []
|
||||
|
||||
query_embedding: Optional[List[float]] = None
|
||||
if has_embedding:
|
||||
query_embedding = await asyncio.to_thread(
|
||||
generate_embedding, task,
|
||||
)
|
||||
|
||||
engine = SkillSearchEngine()
|
||||
results = engine.search(task, candidates, query_embedding=query_embedding, limit=limit * 2)
|
||||
|
||||
cloud_hits = [
|
||||
r for r in results
|
||||
if r.get("source") == "cloud"
|
||||
and r.get("visibility", "public") == "public"
|
||||
and r.get("skill_id")
|
||||
public_cloud_hits = [
|
||||
cloud_result for cloud_result in cloud_search_results
|
||||
if cloud_result.get("visibility", "public") == "public"
|
||||
and cloud_result.get("record_id")
|
||||
][:limit]
|
||||
|
||||
import_results: List[Dict[str, Any]] = []
|
||||
for hit in cloud_hits:
|
||||
for cloud_hit in public_cloud_hits:
|
||||
try:
|
||||
imp = await _do_import_cloud_skill(skill_id=hit["skill_id"])
|
||||
skill_id = cloud_hit["record_id"]
|
||||
imp = await _do_import_cloud_skill(skill_id=skill_id)
|
||||
import_results.append({
|
||||
"skill_id": hit["skill_id"],
|
||||
"name": hit.get("name", ""),
|
||||
"skill_id": skill_id,
|
||||
"name": cloud_hit.get("name", ""),
|
||||
"import_status": imp.get("status", "error"),
|
||||
"local_path": imp.get("local_path", ""),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Cloud import failed for {hit['skill_id']}: {e}")
|
||||
logger.warning(f"Cloud import failed for {skill_id}: {e}")
|
||||
|
||||
if import_results:
|
||||
logger.info(f"Cloud search imported {len(import_results)} skill(s)")
|
||||
|
|
@ -495,8 +555,9 @@ async def execute_task(
|
|||
workspace_dir: Working directory. Defaults to OPENSPACE_WORKSPACE env.
|
||||
max_iterations: Max agent iterations (default: 20).
|
||||
skill_dirs: Bot's skill directories to auto-register so OpenSpace
|
||||
can select and track them. Already-registered dirs are
|
||||
silently skipped.
|
||||
can select and track them. Directories are re-scanned
|
||||
on every call to discover skills created since the last
|
||||
invocation.
|
||||
search_scope: Skill search scope before execution.
|
||||
"all" (default) — local + cloud; falls back to local
|
||||
if no API key is configured.
|
||||
|
|
@ -505,10 +566,32 @@ async def execute_task(
|
|||
try:
|
||||
openspace = await _get_openspace()
|
||||
|
||||
# Auto-register bot skill directories
|
||||
# Re-scan host skill directories (from env) to pick up skills
|
||||
# created by the host bot since the last call.
|
||||
host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "")
|
||||
if host_skill_dirs_raw:
|
||||
env_dirs = [d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()]
|
||||
if env_dirs:
|
||||
await _auto_register_skill_dirs(env_dirs)
|
||||
|
||||
# Auto-register bot skill directories (from call parameter)
|
||||
if skill_dirs:
|
||||
await _auto_register_skill_dirs(skill_dirs)
|
||||
|
||||
# Determine where CAPTURED skills should be written.
|
||||
# Prefer the explicit skill_dirs parameter (= calling host agent's dir),
|
||||
# then fall back to the first env-based host skill dir.
|
||||
capture_skill_dir: str | None = None
|
||||
if skill_dirs:
|
||||
capture_skill_dir = skill_dirs[0]
|
||||
elif host_skill_dirs_raw:
|
||||
first_env = next(
|
||||
(d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()),
|
||||
None,
|
||||
)
|
||||
if first_env:
|
||||
capture_skill_dir = first_env
|
||||
|
||||
# Cloud search + import (if requested)
|
||||
imported_skills: List[Dict[str, Any]] = []
|
||||
if search_scope == "all":
|
||||
|
|
@ -519,6 +602,7 @@ async def execute_task(
|
|||
task=task,
|
||||
workspace_dir=workspace_dir,
|
||||
max_iterations=max_iterations,
|
||||
capture_skill_dir=capture_skill_dir,
|
||||
)
|
||||
|
||||
# Write .upload_meta.json for each evolved skill
|
||||
|
|
@ -534,7 +618,7 @@ async def execute_task(
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"execute_task failed: {e}", exc_info=True)
|
||||
return _json_error(e, status="error", traceback=traceback.format_exc(limit=5))
|
||||
return _json_error(e, status="error")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
|
|
@ -571,11 +655,22 @@ async def search_skills(
|
|||
if not q:
|
||||
return _json_ok({"results": [], "count": 0})
|
||||
|
||||
# Resolve local skills + store
|
||||
# Re-scan host skill directories so newly created skills are searchable.
|
||||
local_skills = None
|
||||
store = None
|
||||
if source in ("all", "local"):
|
||||
if source == "local":
|
||||
registry = _get_local_skill_registry()
|
||||
if registry:
|
||||
local_skills = registry.list_skills()
|
||||
elif source == "all":
|
||||
openspace = await _get_openspace()
|
||||
|
||||
host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "")
|
||||
if host_skill_dirs_raw:
|
||||
env_dirs = [d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()]
|
||||
if env_dirs:
|
||||
await _auto_register_skill_dirs(env_dirs)
|
||||
|
||||
registry = openspace._skill_registry
|
||||
if registry:
|
||||
local_skills = registry.list_skills()
|
||||
|
|
@ -744,7 +839,7 @@ async def fix_skill(
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"fix_skill failed: {e}", exc_info=True)
|
||||
return _json_error(e, status="error", traceback=traceback.format_exc(limit=5))
|
||||
return _json_error(e, status="error")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
|
|
@ -815,20 +910,83 @@ async def upload_skill(
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"upload_skill failed: {e}", exc_info=True)
|
||||
return _json_error(e, status="error", traceback=traceback.format_exc(limit=5))
|
||||
return _json_error(e, status="error")
|
||||
|
||||
def run_mcp_server() -> None:
|
||||
"""Console-script entry point for ``openspace-mcp``."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="OpenSpace MCP Server")
|
||||
parser.add_argument("--transport", choices=["stdio", "sse"], default="stdio")
|
||||
parser.add_argument("--port", type=int, default=8080)
|
||||
args = parser.parse_args()
|
||||
def _port_flag_was_set(argv: list[str]) -> bool:
|
||||
return any(arg == "--port" or arg.startswith("--port=") for arg in argv)
|
||||
|
||||
if args.transport == "sse":
|
||||
mcp.run(transport="sse", sse_params={"port": args.port})
|
||||
def _parse_port_from_env(default: int = 8080) -> int:
|
||||
raw_port = os.environ.get("OPENSPACE_MCP_PORT", "").strip()
|
||||
if not raw_port:
|
||||
return default
|
||||
try:
|
||||
return int(raw_port)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Ignoring invalid OPENSPACE_MCP_PORT=%r; falling back to %d.",
|
||||
raw_port,
|
||||
default,
|
||||
)
|
||||
return default
|
||||
|
||||
def _parse_host_from_env(default: str = "127.0.0.1") -> str:
|
||||
return os.environ.get("OPENSPACE_MCP_HOST", "").strip() or default
|
||||
|
||||
def _resolve_transport(requested_transport: str, argv: list[str]) -> str:
|
||||
if requested_transport in ("stdio", "sse", "streamable-http"):
|
||||
return requested_transport
|
||||
|
||||
env_transport = os.environ.get("OPENSPACE_MCP_TRANSPORT", "").strip().lower()
|
||||
if env_transport:
|
||||
if env_transport in ("stdio", "sse", "streamable-http"):
|
||||
return env_transport
|
||||
logger.warning(
|
||||
"Ignoring invalid OPENSPACE_MCP_TRANSPORT=%r; expected 'stdio', 'sse', or 'streamable-http'.",
|
||||
env_transport,
|
||||
)
|
||||
|
||||
# Treat an explicit port override as an HTTP/SSE intent. This keeps the
|
||||
# CLI behavior aligned with the usage examples above.
|
||||
if _port_flag_was_set(argv):
|
||||
return "sse"
|
||||
|
||||
stdin_is_tty = hasattr(sys.stdin, "isatty") and sys.stdin.isatty()
|
||||
stdout_is_tty = _real_stdout.isatty()
|
||||
return "sse" if stdin_is_tty and stdout_is_tty else "stdio"
|
||||
|
||||
argv = sys.argv[1:]
|
||||
parser = argparse.ArgumentParser(description="OpenSpace MCP Server")
|
||||
parser.add_argument(
|
||||
"--transport",
|
||||
choices=["auto", "stdio", "sse", "streamable-http"],
|
||||
default="auto",
|
||||
)
|
||||
parser.add_argument("--host", default=_parse_host_from_env())
|
||||
parser.add_argument("--port", type=int, default=_parse_port_from_env())
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
transport = _resolve_transport(args.transport, argv)
|
||||
|
||||
if transport == "sse":
|
||||
mcp.settings.host = args.host
|
||||
mcp.settings.port = args.port
|
||||
logger.info("Starting OpenSpace MCP server with SSE transport on port %s", args.port)
|
||||
mcp.run(transport="sse")
|
||||
elif transport == "streamable-http":
|
||||
mcp.settings.host = args.host
|
||||
mcp.settings.port = args.port
|
||||
logger.info(
|
||||
"Starting OpenSpace MCP server with streamable HTTP transport on %s:%s",
|
||||
args.host,
|
||||
args.port,
|
||||
)
|
||||
mcp.run(transport="streamable-http")
|
||||
else:
|
||||
logger.info("Starting OpenSpace MCP server with stdio transport")
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -500,7 +500,7 @@ class RecordingManager:
|
|||
|
||||
# create video client (internal management)
|
||||
if self.enable_video:
|
||||
from openspace.platform import RecordingClient
|
||||
from openspace.platforms import RecordingClient
|
||||
self._recording_client = RecordingClient(base_url=self.server_url)
|
||||
success = await self._recording_client.start_recording()
|
||||
if success:
|
||||
|
|
@ -510,7 +510,7 @@ class RecordingManager:
|
|||
|
||||
# create screenshot client (internal management)
|
||||
if self.enable_screenshot:
|
||||
from openspace.platform import ScreenshotClient
|
||||
from openspace.platforms import ScreenshotClient
|
||||
self._screenshot_client = ScreenshotClient(base_url=self.server_url)
|
||||
logger.debug("Screenshot client ready")
|
||||
|
||||
|
|
@ -539,7 +539,7 @@ class RecordingManager:
|
|||
async def _check_server_availability(self):
|
||||
"""Check if local server is available"""
|
||||
try:
|
||||
from openspace.platform import SystemInfoClient
|
||||
from openspace.platforms import SystemInfoClient
|
||||
|
||||
# Use context manager to ensure aiohttp session is closed, avoiding warning of unclosed session
|
||||
async with SystemInfoClient(base_url=self.server_url) as client:
|
||||
|
|
@ -668,8 +668,9 @@ class RecordingManager:
|
|||
if not tool_name or not isinstance(tool_name, str):
|
||||
return None
|
||||
name = tool_name.strip()
|
||||
# Use rsplit to handle server names that themselves contain "__".
|
||||
if "__" in name:
|
||||
name = name.split("__", 1)[-1]
|
||||
name = name.rsplit("__", 1)[-1]
|
||||
shell_tools = {"shell_agent", "read_file", "write_file", "list_dir", "run_shell"}
|
||||
if name in shell_tools:
|
||||
return "shell"
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class TrajectoryRecorder:
|
|||
Args:
|
||||
task_name: task name (optional, will be saved in metadata)
|
||||
log_dir: log directory
|
||||
enable_screenshot: whether to save screenshots (through platform.ScreenshotClient)
|
||||
enable_screenshot: whether to save screenshots (through platforms.ScreenshotClient)
|
||||
enable_video: whether to enable video recording (through platform.RecordingClient)
|
||||
server_url: local_server address (None = read from config/environment variables)
|
||||
"""
|
||||
|
|
@ -91,7 +91,7 @@ class TrajectoryRecorder:
|
|||
parameters: tool parameters
|
||||
screenshot: screenshot bytes (if provided)
|
||||
extra: extra information (e.g. server field for MCP)
|
||||
auto_screenshot: whether to automatically capture screenshot (through platform.ScreenshotClient)
|
||||
auto_screenshot: whether to automatically capture screenshot (through platforms.ScreenshotClient)
|
||||
"""
|
||||
self.step_counter += 1
|
||||
step_num = self.step_counter
|
||||
|
|
@ -145,9 +145,9 @@ class TrajectoryRecorder:
|
|||
return step_info
|
||||
|
||||
async def _capture_screenshot(self) -> Optional[bytes]:
|
||||
"""Capture screenshot automatically through platform.ScreenshotClient"""
|
||||
"""Capture screenshot automatically through platforms.ScreenshotClient"""
|
||||
try:
|
||||
from openspace.platform import ScreenshotClient
|
||||
from openspace.platforms import ScreenshotClient
|
||||
|
||||
# Lazy initialization screenshot client
|
||||
if not hasattr(self, '_screenshot_client'):
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""
|
||||
Video Recorder
|
||||
|
||||
Communicates with local_server through platform.RecordingClient
|
||||
Communicates with local_server through platforms.RecordingClient
|
||||
Supports local and remote recording (through configuration LOCAL_SERVER_URL)
|
||||
"""
|
||||
|
||||
|
|
@ -9,7 +9,7 @@ from pathlib import Path
|
|||
from typing import Optional
|
||||
|
||||
from openspace.utils.logging import Logger
|
||||
from openspace.platform import RecordingClient
|
||||
from openspace.platforms import RecordingClient
|
||||
|
||||
logger = Logger.get_logger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -84,13 +84,17 @@ def _correct_skill_ids(
|
|||
if prefix and k.split("__")[0] == prefix
|
||||
]
|
||||
|
||||
best, best_dist = None, 4 # threshold: edit distance ≤ 3
|
||||
# Adaptive threshold: tighten when many candidates share the prefix
|
||||
max_dist = 2 if len(candidates) > 20 else 4 # ≤1 or ≤3
|
||||
best, best_dist, ambiguous = None, max_dist, False
|
||||
for cand in candidates:
|
||||
d = _edit_distance(raw_id, cand)
|
||||
if d < best_dist:
|
||||
best, best_dist = cand, d
|
||||
best, best_dist, ambiguous = cand, d, False
|
||||
elif d == best_dist and cand != best:
|
||||
ambiguous = True # multiple candidates at same distance
|
||||
|
||||
if best is not None:
|
||||
if best is not None and not ambiguous:
|
||||
logger.info(
|
||||
f"Corrected LLM skill ID: {raw_id!r} → {best!r} "
|
||||
f"(edit_distance={best_dist})"
|
||||
|
|
|
|||
|
|
@ -145,6 +145,11 @@ class EvolutionContext:
|
|||
# Available tools for agent loop (read_file, web_search, shell, MCP, etc.)
|
||||
available_tools: List["BaseTool"] = field(default_factory=list)
|
||||
|
||||
# For CAPTURED: preferred directory to write the new skill.
|
||||
# Set from the calling host agent's skill directory so captured skills
|
||||
# are written back to the correct host, not always to _skill_dirs[0].
|
||||
capture_dir: Optional[Path] = None
|
||||
|
||||
|
||||
class SkillEvolver:
|
||||
"""Execute skill evolution actions.
|
||||
|
|
@ -201,6 +206,7 @@ class SkillEvolver:
|
|||
# evolved for each degraded tool. Keyed by tool_key.
|
||||
# Pruned when a tool leaves the problematic list (= recovered).
|
||||
self._addressed_degradations: Dict[str, Set[str]] = {}
|
||||
self._degradation_lock = asyncio.Lock()
|
||||
|
||||
# Track background tasks so they can be awaited on shutdown.
|
||||
self._background_tasks: Set[asyncio.Task] = set()
|
||||
|
|
@ -219,7 +225,10 @@ class SkillEvolver:
|
|||
f"Waiting for {len(self._background_tasks)} background "
|
||||
f"evolution task(s) to finish..."
|
||||
)
|
||||
await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
||||
results = await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
||||
for r in results:
|
||||
if isinstance(r, BaseException):
|
||||
logger.warning(f"Background evolution task failed during shutdown: {r}")
|
||||
self._background_tasks.clear()
|
||||
|
||||
async def evolve(self, ctx: EvolutionContext) -> Optional[SkillRecord]:
|
||||
|
|
@ -255,13 +264,20 @@ class SkillEvolver:
|
|||
|
||||
# Trigger 1: post-analysis
|
||||
async def process_analysis(
|
||||
self, analysis: ExecutionAnalysis,
|
||||
self,
|
||||
analysis: ExecutionAnalysis,
|
||||
capture_dir: Optional[Path] = None,
|
||||
) -> List[SkillRecord]:
|
||||
"""Process all evolution suggestions from a completed analysis.
|
||||
|
||||
Called immediately after ``ExecutionAnalyzer.analyze_execution()``.
|
||||
Each suggestion becomes one evolution action, executed in parallel
|
||||
(throttled by semaphore).
|
||||
|
||||
Args:
|
||||
analysis: The completed execution analysis.
|
||||
capture_dir: Preferred directory for CAPTURED skills (host agent's
|
||||
skill dir). Falls back to ``registry._skill_dirs[0]`` when None.
|
||||
"""
|
||||
if not analysis.candidate_for_evolution:
|
||||
return []
|
||||
|
|
@ -269,7 +285,9 @@ class SkillEvolver:
|
|||
# Build contexts first (cheap, no LLM calls)
|
||||
contexts: List[EvolutionContext] = []
|
||||
for suggestion in analysis.evolution_suggestions:
|
||||
ctx = self._build_context_from_analysis(analysis, suggestion)
|
||||
ctx = self._build_context_from_analysis(
|
||||
analysis, suggestion, capture_dir=capture_dir,
|
||||
)
|
||||
if ctx is not None:
|
||||
contexts.append(ctx)
|
||||
|
||||
|
|
@ -306,6 +324,13 @@ class SkillEvolver:
|
|||
if not problematic_tools:
|
||||
return []
|
||||
|
||||
async with self._degradation_lock:
|
||||
return await self._process_tool_degradation_locked(problematic_tools)
|
||||
|
||||
async def _process_tool_degradation_locked(
|
||||
self, problematic_tools: List,
|
||||
) -> List[SkillRecord]:
|
||||
"""Inner body of process_tool_degradation, called under _degradation_lock."""
|
||||
# Prune recovered tools: if a tool_key used to be tracked but is
|
||||
# no longer in the current problematic list, it recovered — clear
|
||||
# its addressed set so future re-degradation gets a fresh pass.
|
||||
|
|
@ -652,10 +677,21 @@ class SkillEvolver:
|
|||
return bool(data.get("proceed", False))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
# Fallback: look for keywords
|
||||
if any(w in response for w in ("\"proceed\": true", "proceed: true", "yes", "confirm")):
|
||||
# Fallback: look for keywords.
|
||||
# - yes/no use strict word boundaries to avoid false positives
|
||||
# (e.g. "know" matching "no").
|
||||
# - confirm/reject/skip use stem-style matching so that common
|
||||
# LLM variants like "confirmed", "rejected", "skipping" still
|
||||
# parse correctly.
|
||||
_wb = re.search # shorthand
|
||||
if any(w in response for w in ("\"proceed\": true", "proceed: true")) \
|
||||
or _wb(r"\byes\b", response) \
|
||||
or _wb(r"\bconfirm\w*\b", response):
|
||||
return True
|
||||
if any(w in response for w in ("\"proceed\": false", "proceed: false", "no", "reject", "skip")):
|
||||
if any(w in response for w in ("\"proceed\": false", "proceed: false")) \
|
||||
or _wb(r"\bno\b", response) \
|
||||
or _wb(r"\breject\w*\b", response) \
|
||||
or _wb(r"\bskip\w*\b", response):
|
||||
return False
|
||||
# Default: skip — ambiguous response should not trigger costly evolution
|
||||
logger.debug("LLM confirmation response was ambiguous, defaulting to skip")
|
||||
|
|
@ -926,13 +962,23 @@ class SkillEvolver:
|
|||
new_content = _set_frontmatter_field(new_content, "name", new_name)
|
||||
|
||||
# Create new skill directory via create_skill (handles multi-file FULL)
|
||||
skill_dirs = self._registry._skill_dirs
|
||||
if not skill_dirs:
|
||||
logger.warning("CAPTURED: no skill directories configured")
|
||||
return None
|
||||
# Priority chain for choosing the target skill root:
|
||||
# 1. ctx.capture_dir — explicitly set from host agent's skill_dirs param
|
||||
# 2. Infer from analysis — if this task used a skill from dir B,
|
||||
# captured skills belong alongside it (same host agent context)
|
||||
# 3. registry._skill_dirs[0] — ultimate fallback
|
||||
base_dir: Optional[Path] = None
|
||||
if ctx.capture_dir and ctx.capture_dir.is_dir():
|
||||
base_dir = ctx.capture_dir
|
||||
else:
|
||||
base_dir = self._infer_capture_dir_from_analysis(ctx)
|
||||
|
||||
# Directory name always matches the skill name
|
||||
base_dir = skill_dirs[0] # Primary user skill directory
|
||||
if base_dir is None:
|
||||
skill_dirs = self._registry._skill_dirs
|
||||
if not skill_dirs:
|
||||
logger.warning("CAPTURED: no skill directories configured")
|
||||
return None
|
||||
base_dir = skill_dirs[0]
|
||||
target_dir = base_dir / new_name
|
||||
if target_dir.exists():
|
||||
new_name = f"{new_name}-{uuid.uuid4().hex[:6]}"
|
||||
|
|
@ -994,6 +1040,45 @@ class SkillEvolver:
|
|||
logger.info(f"CAPTURED: {new_name} [{new_id}]")
|
||||
return new_record
|
||||
|
||||
def _infer_capture_dir_from_analysis(
|
||||
self, ctx: EvolutionContext,
|
||||
) -> Optional[Path]:
|
||||
"""Infer the best skill root for a CAPTURED skill from analysis context.
|
||||
|
||||
When ``capture_dir`` is not explicitly set (no ``skill_dirs`` param
|
||||
from the host agent), we look at which skills were used during the
|
||||
task that triggered the capture. If a used skill lives under one
|
||||
of the registered skill roots, that root is a reasonable home for
|
||||
the new captured skill (same host agent context).
|
||||
"""
|
||||
if not ctx.recent_analyses:
|
||||
return None
|
||||
|
||||
registry_roots = self._registry._skill_dirs
|
||||
if not registry_roots:
|
||||
return None
|
||||
|
||||
for analysis in ctx.recent_analyses:
|
||||
for judgment in analysis.skill_judgments:
|
||||
if not judgment.skill_applied:
|
||||
continue
|
||||
rec = self._store.load_record(judgment.skill_id)
|
||||
if not rec or not rec.path:
|
||||
continue
|
||||
skill_path = Path(rec.path).parent # e.g. /A/foo/
|
||||
for root in registry_roots:
|
||||
try:
|
||||
skill_path.relative_to(root)
|
||||
logger.debug(
|
||||
"CAPTURED: inferred capture dir %s from "
|
||||
"applied skill %s", root, judgment.skill_id,
|
||||
)
|
||||
return root
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
async def _run_evolution_loop(
|
||||
self,
|
||||
prompt: str,
|
||||
|
|
@ -1340,13 +1425,15 @@ class SkillEvolver:
|
|||
self,
|
||||
analysis: ExecutionAnalysis,
|
||||
suggestion: EvolutionSuggestion,
|
||||
capture_dir: Optional[Path] = None,
|
||||
) -> Optional[EvolutionContext]:
|
||||
"""Build EvolutionContext from a single analysis suggestion.
|
||||
|
||||
Loads all target skills referenced by ``suggestion.target_skill_ids``.
|
||||
For FIX: exactly 1 parent required.
|
||||
For DERIVED: 1+ parents (multi-parent = merge).
|
||||
For CAPTURED: parents list is empty.
|
||||
For CAPTURED: parents list is empty; ``capture_dir`` controls where
|
||||
the new skill is written (defaults to registry's first skill root).
|
||||
"""
|
||||
records: List[SkillRecord] = []
|
||||
contents: List[str] = []
|
||||
|
|
@ -1390,6 +1477,7 @@ class SkillEvolver:
|
|||
source_task_id=analysis.task_id,
|
||||
recent_analyses=[analysis],
|
||||
available_tools=self._available_tools,
|
||||
capture_dir=capture_dir,
|
||||
)
|
||||
|
||||
def _load_skill_content(self, record: SkillRecord) -> str:
|
||||
|
|
|
|||
|
|
@ -299,8 +299,8 @@ class SkillRegistry:
|
|||
skill_dir: Path to a directory containing ``SKILL.md``.
|
||||
|
||||
Returns:
|
||||
:class:`SkillMeta` if newly registered, ``None`` if already
|
||||
present, the directory is invalid, or the skill fails safety checks.
|
||||
:class:`SkillMeta` if newly registered or already present,
|
||||
``None`` if the directory is invalid or the skill fails safety checks.
|
||||
"""
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
if not skill_file.exists():
|
||||
|
|
@ -321,7 +321,7 @@ class SkillRegistry:
|
|||
meta = self._parse_skill(skill_dir.name, skill_dir, skill_file, content)
|
||||
if meta.skill_id in self._skills:
|
||||
logger.debug(f"register_skill_dir: {meta.skill_id} already exists")
|
||||
return None
|
||||
return self._skills[meta.skill_id]
|
||||
self._skills[meta.skill_id] = meta
|
||||
self._content_cache[meta.skill_id] = content
|
||||
logger.info(f"Hot-registered skill: {meta.skill_id}")
|
||||
|
|
|
|||
|
|
@ -104,7 +104,6 @@ class OpenSpace:
|
|||
return
|
||||
|
||||
logger.info("Initializing OpenSpace...")
|
||||
|
||||
try:
|
||||
self._llm_client = LLMClient(
|
||||
model=self.config.llm_model,
|
||||
|
|
@ -307,18 +306,25 @@ class OpenSpace:
|
|||
workspace_dir: Optional[str] = None,
|
||||
max_iterations: Optional[int] = None,
|
||||
task_id: Optional[str] = None,
|
||||
capture_skill_dir: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute a task with OpenSpace.
|
||||
|
||||
Args:
|
||||
task: Task instruction
|
||||
context: Additional context
|
||||
context: Additional context. Communication callers may pass:
|
||||
- conversation_history: prior user/assistant turns
|
||||
- channel_context: platform/chat metadata and attachments
|
||||
- session_key: stable external session identifier
|
||||
workspace_dir: Working directory
|
||||
max_iterations: Max iterations override
|
||||
task_id: External task ID for recording/logging. If None, generates a random one.
|
||||
This allows external callers (e.g., OSWorld) to specify their own task ID
|
||||
so recordings can be easily matched with benchmark results.
|
||||
capture_skill_dir: Preferred directory for CAPTURED skills. In multi-host-agent
|
||||
scenarios, this should be the calling host agent's skill directory so
|
||||
newly captured skills are written to the correct location.
|
||||
"""
|
||||
if not self._initialized:
|
||||
raise RuntimeError(
|
||||
|
|
@ -350,6 +356,9 @@ class OpenSpace:
|
|||
self._task_done.clear()
|
||||
self._last_evolved_skills = [] # Reset per-execution tracking
|
||||
start_time = asyncio.get_running_loop().time()
|
||||
self._capture_skill_dir = capture_skill_dir
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
|
||||
# Use external task_id if provided, otherwise generate one
|
||||
if task_id is None:
|
||||
task_id = f"task_{uuid.uuid4().hex[:12]}"
|
||||
|
|
@ -357,9 +366,11 @@ class OpenSpace:
|
|||
|
||||
# Populated inside the try block; used by finally for analysis
|
||||
result: Dict[str, Any] = {}
|
||||
execution_time = 0.0
|
||||
cancelled_exc: Optional[asyncio.CancelledError] = None
|
||||
|
||||
try:
|
||||
execution_context = context or {}
|
||||
execution_context = dict(context) if context else {}
|
||||
execution_context["task_id"] = task_id
|
||||
execution_context["instruction"] = task
|
||||
|
||||
|
|
@ -507,6 +518,7 @@ class OpenSpace:
|
|||
f"Executing with GroundingAgent "
|
||||
f"(max {max_iterations} iterations, no skills)..."
|
||||
)
|
||||
execution_context["max_iterations"] = max_iterations
|
||||
result = await self._grounding_agent.process(execution_context)
|
||||
|
||||
execution_time = asyncio.get_event_loop().time() - start_time
|
||||
|
|
@ -530,6 +542,20 @@ class OpenSpace:
|
|||
logger.error(f"Task failed: {result.get('error', 'Unknown error')}")
|
||||
logger.info("="*60)
|
||||
|
||||
except asyncio.CancelledError as exc:
|
||||
execution_time = asyncio.get_event_loop().time() - start_time
|
||||
logger.warning("Task execution cancelled")
|
||||
result = {
|
||||
"status": "cancelled",
|
||||
"error": "Task execution cancelled",
|
||||
"response": "",
|
||||
"execution_time": execution_time,
|
||||
"task_id": task_id,
|
||||
"iterations": 0,
|
||||
"tool_executions": [],
|
||||
}
|
||||
cancelled_exc = exc
|
||||
|
||||
except Exception as e:
|
||||
execution_time = asyncio.get_event_loop().time() - start_time
|
||||
tb = traceback.format_exc(limit=10)
|
||||
|
|
@ -568,14 +594,15 @@ class OpenSpace:
|
|||
except Exception as e:
|
||||
logger.warning(f"Failed to stop recording: {e}")
|
||||
|
||||
# Run execution analysis + evolution BEFORE building the return
|
||||
# value, so evolved_skills is populated.
|
||||
await self._maybe_analyze_execution(
|
||||
task_id, recording_dir, result
|
||||
)
|
||||
if cancelled_exc is None:
|
||||
# Run execution analysis + evolution BEFORE building the return
|
||||
# value, so evolved_skills is populated.
|
||||
await self._maybe_analyze_execution(
|
||||
task_id, recording_dir, result
|
||||
)
|
||||
|
||||
# Trigger quality evolution periodically
|
||||
await self._maybe_evolve_quality()
|
||||
# Trigger quality evolution periodically
|
||||
await self._maybe_evolve_quality()
|
||||
|
||||
final_result = {
|
||||
**result,
|
||||
|
|
@ -587,8 +614,10 @@ class OpenSpace:
|
|||
|
||||
self._running = False
|
||||
self._task_done.set()
|
||||
|
||||
return final_result
|
||||
|
||||
if cancelled_exc is not None:
|
||||
raise cancelled_exc
|
||||
return final_result
|
||||
|
||||
# Skills helpers
|
||||
def _init_skill_registry(self) -> Optional[SkillRegistry]:
|
||||
|
|
@ -784,7 +813,17 @@ class OpenSpace:
|
|||
for s in analysis.evolution_suggestions
|
||||
)
|
||||
logger.info(f"[Skill Evolution] Suggestions: {evo_summary}")
|
||||
evolved_records = await self._skill_evolver.process_analysis(analysis)
|
||||
|
||||
capture_dir = None
|
||||
if getattr(self, "_capture_skill_dir", None):
|
||||
from pathlib import Path as _P
|
||||
_cd = _P(self._capture_skill_dir)
|
||||
if _cd.is_dir():
|
||||
capture_dir = _cd
|
||||
|
||||
evolved_records = await self._skill_evolver.process_analysis(
|
||||
analysis, capture_dir=capture_dir,
|
||||
)
|
||||
|
||||
# Track evolved skills for the caller
|
||||
for rec in evolved_records:
|
||||
|
|
|
|||
|
|
@ -233,6 +233,23 @@ class Logger:
|
|||
|
||||
cls._configured = True
|
||||
|
||||
@classmethod
|
||||
def set_level(cls, level: str) -> None:
|
||||
"""Set log level by name (e.g. ``"DEBUG"``, ``"INFO"``, ``"WARNING"``)."""
|
||||
resolved = getattr(logging, level.upper(), None)
|
||||
if resolved is None or not isinstance(resolved, int):
|
||||
raise ValueError(f"Unknown log level: {level!r}")
|
||||
if not cls._configured:
|
||||
cls.configure(level=resolved, attach_to_root=True)
|
||||
return
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(resolved)
|
||||
for handler in root_logger.handlers:
|
||||
handler.setLevel(resolved)
|
||||
|
||||
cls._update_level(resolved)
|
||||
|
||||
@classmethod
|
||||
def set_debug(cls, debug_level: int = 2) -> None:
|
||||
"""Dynamically switch debug level: 0 = WARNING, 1 = INFO, 2 = DEBUG."""
|
||||
|
|
@ -309,4 +326,4 @@ Logger.configure(attach_to_root=True)
|
|||
|
||||
# Get openspace logger for internal logging
|
||||
logger = Logger.get_logger()
|
||||
logger.debug("OpenSpace logging initialized")
|
||||
logger.debug("OpenSpace logging initialized")
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ authors = [
|
|||
]
|
||||
|
||||
dependencies = [
|
||||
"litellm>=1.70.0",
|
||||
"litellm>=1.70.0,<1.82.7", # pinned to avoid PYSEC-2026-2 supply-chain compromise (1.82.7/1.82.8 were malicious)
|
||||
"python-dotenv>=1.0.0",
|
||||
"openai>=1.0.0",
|
||||
"jsonschema>=4.25.0",
|
||||
|
|
@ -26,6 +26,7 @@ dependencies = [
|
|||
"flask>=3.1.0",
|
||||
"pyautogui>=0.9.54",
|
||||
"pydantic>=2.12.0",
|
||||
"aiohttp>=3.10.0",
|
||||
"requests>=2.32.0",
|
||||
]
|
||||
|
||||
|
|
@ -57,8 +58,15 @@ dev = [
|
|||
"mypy>=1.0.0",
|
||||
]
|
||||
|
||||
communication = [
|
||||
"lark-oapi>=1.4.20",
|
||||
]
|
||||
|
||||
all = [
|
||||
"openspace[macos,linux,windows,dev]",
|
||||
"openspace[macos]; sys_platform == 'darwin'",
|
||||
"openspace[linux]; sys_platform == 'linux'",
|
||||
"openspace[windows]; sys_platform == 'win32'",
|
||||
"openspace[communication,dev]",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
|
@ -72,6 +80,7 @@ openspace-mcp = "openspace.mcp_server:run_mcp_server"
|
|||
openspace-download-skill = "openspace.cloud.cli.download_skill:main"
|
||||
openspace-upload-skill = "openspace.cloud.cli.upload_skill:main"
|
||||
openspace-dashboard = "openspace.dashboard_server:main"
|
||||
openspace-gateway = "openspace.communication.gateway:run_main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = {find = {where = ["."], include = ["openspace*"]}}
|
||||
|
|
@ -80,6 +89,7 @@ packages = {find = {where = ["."], include = ["openspace*"]}}
|
|||
openspace = [
|
||||
"config/*.json",
|
||||
"config/*.json.example",
|
||||
"communication/bridges/whatsapp/*",
|
||||
"local_server/config.json",
|
||||
"local_server/README.md",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# OpenSpace core dependencies
|
||||
litellm>=1.70.0
|
||||
litellm>=1.70.0,<1.82.7 # pinned to avoid PYSEC-2026-2 supply-chain compromise (1.82.7/1.82.8 were malicious)
|
||||
python-dotenv>=1.0.0
|
||||
openai>=1.0.0
|
||||
jsonschema>=4.25.0
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue