mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 21:13:22 +08:00
初始化:独立 oclaw 仓库首提交
- 在 oclaw/ 下重新初始化 Git 仓库 - 补齐子仓库 .gitignore,避免提交本地运行态数据(_local、node_modules、logs 等) - 提交当前工程代码与配置 Made-with: Cursor
This commit is contained in:
commit
ba3836f00f
579 changed files with 83112 additions and 0 deletions
24
.github/workflows/ci.yml
vendored
Normal file
24
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install dependencies
|
||||
run: python -m pip install -r requirements.txt ruff mypy
|
||||
- name: Lint changed architecture modules
|
||||
run: ruff check oclaw/interfaces oclaw/extensions oclaw/tests/test_oclaw_gateway_optimizations.py
|
||||
- name: Type check critical gateway modules
|
||||
run: mypy --ignore-missing-imports oclaw/interfaces/gateway/context_builder.py oclaw/interfaces/ws/server_methods_bridge.py oclaw/interfaces/ws/turn_runner.py
|
||||
- name: Run tests
|
||||
run: python -m pytest -q oclaw/tests
|
||||
- name: Offline eval sanity
|
||||
run: python oclaw/scripts/offline_eval.py
|
||||
74
.gitignore
vendored
Normal file
74
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
.venv/
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
oclaw/scripts/.run/
|
||||
oclaw/.venv/
|
||||
.venv/
|
||||
.mypy_cache/
|
||||
data/*.sqlite
|
||||
data/*.sqlite-journal
|
||||
data/*.sqlite-shm
|
||||
data/*.sqlite-wal
|
||||
data/attachments/
|
||||
oclaw/data/attachments/
|
||||
oclaw/data/*.sqlite
|
||||
oclaw/data/*.sqlite-journal
|
||||
oclaw/data/*.sqlite-shm
|
||||
oclaw/data/*.sqlite-wal
|
||||
oclaw/data/logs/
|
||||
oclaw/data/locks/
|
||||
oclaw/data/ops_runtime_state.json
|
||||
oclaw/data/mcp_local.env
|
||||
oclaw/data/google_oauth_client.json
|
||||
oclaw/data/_pre_merge_sqlite_*/
|
||||
data/logs/
|
||||
data/locks/
|
||||
data/ops_runtime_state.json
|
||||
data/mcp_local.env
|
||||
data/google_oauth_client.json
|
||||
data/_pre_merge_sqlite_*/
|
||||
# oclaw 子仓库运行态目录(勿提交)
|
||||
_local/
|
||||
_local/*.env
|
||||
_local/*.json
|
||||
_local/*.txt
|
||||
data/channel_sidecar/
|
||||
data/**/node_modules/
|
||||
data/**/*.log
|
||||
data/**/*.err.log
|
||||
platform/data/
|
||||
scripts/.run/
|
||||
desktop/node_modules/
|
||||
desktop/dist/
|
||||
desktop/runtime-data/
|
||||
# 本地密钥(随仓库目录迁移时记得复制此文件;勿提交)
|
||||
src/_local/google_oauth_client.json
|
||||
src/_local/mcp_local.env
|
||||
# MCP admin export / 安装后自动备份(可重装 JSON,本机 path 与列表可能不同)
|
||||
src/_local/mcp_registry_migrated.json
|
||||
src/platform/data/*.sqlite
|
||||
src/platform/data/*.sqlite-journal
|
||||
src/platform/data/*.sqlite-shm
|
||||
src/platform/data/*.sqlite-wal
|
||||
src/platform/data/logs/
|
||||
src/platform/data/locks/
|
||||
src/platform/data/ops_runtime_state.json
|
||||
# 历史 Vite 前端已移除;若本地仍有残留目录可忽略或手动删除
|
||||
web/
|
||||
|
||||
# 根目录临时调试日志(勿提交)
|
||||
debug-*.log
|
||||
.playwright-mcp/
|
||||
|
||||
# Python 安装/扩展缓存(非本应用源码)
|
||||
Python/_cache/
|
||||
oclaw/desktop/node_modules/
|
||||
oclaw/desktop/dist/
|
||||
oclaw/desktop/runtime-data/
|
||||
oclaw/desktop/assets/oclaw.ico
|
||||
vendor/openclaw-main.zip
|
||||
vendor/openclaw/
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "vendor/cc-mini"]
|
||||
path = vendor/cc-mini
|
||||
url = https://github.com/e10nMa2k/cc-mini.git
|
||||
15
README.md
Normal file
15
README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# OpenClaw Architecture Root
|
||||
|
||||
This directory is the new architecture root for ongoing refactors.
|
||||
|
||||
## Layers
|
||||
- `interfaces/`: transport adapters (HTTP/WS/gateway handlers)
|
||||
- `application/`: use-cases and orchestration services
|
||||
- `domain/`: business rules and domain primitives
|
||||
- `infrastructure/`: integrations and runtime adapters
|
||||
- `shared/`: shared utilities/types
|
||||
|
||||
## Migration Rule
|
||||
New business logic should be implemented in `oclaw/`.
|
||||
`oclaw/` modules remain as compatibility bridges during migration.
|
||||
|
||||
6
__init__.py
Normal file
6
__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""OpenClaw v2 package root.
|
||||
|
||||
All new architecture-first code should live under `oclaw/`.
|
||||
Legacy `src/` modules may import from this package as a compatibility bridge.
|
||||
"""
|
||||
|
||||
1
_hooks_selftest_workspace/AGENTS.md
Normal file
1
_hooks_selftest_workspace/AGENTS.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# AGENTS
|
||||
8
_hooks_selftest_workspace/memory/2026-04-22-1310.md
Normal file
8
_hooks_selftest_workspace/memory/2026-04-22-1310.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Session: 2026-04-22 13:10:55 UTC
|
||||
|
||||
- **Session Key**: agent:main:main
|
||||
- **Action**: reset
|
||||
|
||||
## Conversation Summary
|
||||
|
||||
- (no messages found)
|
||||
8
_hooks_selftest_workspace/memory/2026-04-22-1321.md
Normal file
8
_hooks_selftest_workspace/memory/2026-04-22-1321.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Session: 2026-04-22 13:21:10 UTC
|
||||
|
||||
- **Session Key**: agent:main:main
|
||||
- **Action**: reset
|
||||
|
||||
## Conversation Summary
|
||||
|
||||
- (no messages found)
|
||||
4
admin/__init__.py
Normal file
4
admin/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from __future__ import annotations
|
||||
|
||||
__all__ = []
|
||||
|
||||
1532
admin/chat_api.py
Normal file
1532
admin/chat_api.py
Normal file
File diff suppressed because it is too large
Load diff
184
admin/mcp_e2e_probe.py
Normal file
184
admin/mcp_e2e_probe.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"""Build dynamic MCP E2E probe plans from the live ToolRegistry (one tool use per MCP server_id)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from oclaw.tools.base import ToolRegistry, ToolSpec
|
||||
from oclaw.tools.tool_validation import validate_tool_arguments
|
||||
|
||||
|
||||
def parse_mcp_bound_tool_name(full_name: str) -> tuple[str, str] | None:
|
||||
"""Parse ``mcp__{server_id}__{mcp_tool}`` into (server_id, mcp_tool_name)."""
|
||||
if not full_name.startswith("mcp__"):
|
||||
return None
|
||||
rest = full_name[len("mcp__") :]
|
||||
idx = rest.find("__")
|
||||
if idx < 0:
|
||||
return None
|
||||
server_id = rest[:idx].strip()
|
||||
tool = rest[idx + 2 :].strip()
|
||||
if not server_id or not tool:
|
||||
return None
|
||||
return server_id, tool
|
||||
|
||||
|
||||
def _json_schema_required_keys(parameters: dict[str, Any]) -> list[str]:
|
||||
if not isinstance(parameters, dict) or parameters.get("type") != "object":
|
||||
return []
|
||||
req = parameters.get("required")
|
||||
if not isinstance(req, list):
|
||||
return []
|
||||
return [str(x).strip() for x in req if str(x).strip()]
|
||||
|
||||
|
||||
def _fill_required_from_properties(
|
||||
parameters: dict[str, Any],
|
||||
*,
|
||||
workspace_root: str,
|
||||
) -> dict[str, Any] | None:
|
||||
props = parameters.get("properties") if isinstance(parameters.get("properties"), dict) else {}
|
||||
req = _json_schema_required_keys(parameters)
|
||||
out: dict[str, Any] = {}
|
||||
for key in req:
|
||||
prop = props.get(key) if isinstance(props.get(key), dict) else {}
|
||||
t = prop.get("type")
|
||||
lk = key.lower()
|
||||
if t == "string":
|
||||
if lk in ("path", "cwd", "repopath", "directory", "filepath") or lk.endswith("path"):
|
||||
out[key] = workspace_root
|
||||
elif lk == "url":
|
||||
out[key] = "https://example.com"
|
||||
elif lk == "query":
|
||||
out[key] = "model context protocol"
|
||||
elif lk == "message":
|
||||
out[key] = "mcp-e2e"
|
||||
elif lk == "timezone":
|
||||
out[key] = "UTC"
|
||||
else:
|
||||
out[key] = ""
|
||||
elif t == "boolean":
|
||||
out[key] = True if lk in ("includeuntracked", "includetracked") else False
|
||||
elif t in ("number", "integer"):
|
||||
out[key] = 0
|
||||
elif t == "array":
|
||||
out[key] = []
|
||||
elif t == "object":
|
||||
out[key] = {}
|
||||
else:
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
# MCP tool names (suffix after server_id) with explicit args when schema is missing or validation needs concrete values.
|
||||
_KNOWN_MCP_TOOL_ARGS: dict[str, dict[str, Any]] = {
|
||||
"sequentialthinking": {
|
||||
"thought": "e2e",
|
||||
"nextThoughtNeeded": False,
|
||||
"thoughtNumber": 1,
|
||||
"totalThoughts": 1,
|
||||
},
|
||||
}
|
||||
|
||||
# Lower index = higher priority when multiple tools are callable.
|
||||
_PROBE_PRIORITY: tuple[str, ...] = (
|
||||
"browser_close",
|
||||
"read_graph",
|
||||
"db_info",
|
||||
"list_pdfs",
|
||||
"echo",
|
||||
"list_directory",
|
||||
"git_status",
|
||||
"fetch_markdown",
|
||||
"web_search",
|
||||
"sequentialthinking",
|
||||
)
|
||||
|
||||
|
||||
def _probe_args_for_spec(spec: ToolSpec, *, workspace_root: str) -> dict[str, Any] | None:
|
||||
parsed = parse_mcp_bound_tool_name(spec.name)
|
||||
if not parsed:
|
||||
return None
|
||||
_, mcp_tool = parsed
|
||||
params = spec.parameters if isinstance(spec.parameters, dict) else {}
|
||||
|
||||
if mcp_tool in _KNOWN_MCP_TOOL_ARGS:
|
||||
args = dict(_KNOWN_MCP_TOOL_ARGS[mcp_tool])
|
||||
ok, _ = validate_tool_arguments(params, args)
|
||||
return args if ok else None
|
||||
|
||||
req = _json_schema_required_keys(params)
|
||||
if not req:
|
||||
args: dict[str, Any] = {}
|
||||
ok, _ = validate_tool_arguments(params, args)
|
||||
return args if ok else None
|
||||
|
||||
filled = _fill_required_from_properties(params, workspace_root=workspace_root)
|
||||
if filled is None:
|
||||
return None
|
||||
ok, _ = validate_tool_arguments(params, filled)
|
||||
return filled if ok else None
|
||||
|
||||
|
||||
def _pick_probe_for_server(specs: list[ToolSpec], *, workspace_root: str) -> tuple[ToolSpec, dict[str, Any]] | None:
|
||||
candidates: list[tuple[int, str, ToolSpec, dict[str, Any]]] = []
|
||||
for sp in specs:
|
||||
args = _probe_args_for_spec(sp, workspace_root=workspace_root)
|
||||
if args is None:
|
||||
continue
|
||||
ok, _ = validate_tool_arguments(sp.parameters or {}, args)
|
||||
if not ok:
|
||||
continue
|
||||
parsed = parse_mcp_bound_tool_name(sp.name)
|
||||
mcp_tool = (parsed or ("", ""))[1]
|
||||
try:
|
||||
pri = _PROBE_PRIORITY.index(mcp_tool)
|
||||
except ValueError:
|
||||
pri = 900
|
||||
candidates.append((pri, mcp_tool, sp, args))
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda x: (x[0], x[1], x[2].name))
|
||||
return candidates[0][2], candidates[0][3]
|
||||
|
||||
|
||||
def build_mcp_e2e_probe_plans(
|
||||
reg: ToolRegistry,
|
||||
*,
|
||||
workspace_root: str,
|
||||
) -> tuple[list[tuple[str, str, dict[str, Any]]], list[str]]:
|
||||
"""
|
||||
Returns (plans, skipped_server_ids).
|
||||
|
||||
``plans`` entries are ``(server_id, full_tool_name, arguments)`` for ``ToolExecutor.execute_tool_uses``.
|
||||
One probe per MCP ``server_id`` discovered from registry names ``mcp__*__*`` with tag ``mcp``.
|
||||
|
||||
``skipped_server_ids`` lists servers that had MCP tools in the registry but no schema-safe probe
|
||||
could be constructed (no false positives from arbitrary ``tools/call``).
|
||||
"""
|
||||
by_server: dict[str, list[ToolSpec]] = {}
|
||||
for spec in reg.list():
|
||||
if "mcp" not in (spec.tags or frozenset()):
|
||||
continue
|
||||
parsed = parse_mcp_bound_tool_name(spec.name)
|
||||
if not parsed:
|
||||
continue
|
||||
sid, _ = parsed
|
||||
by_server.setdefault(sid, []).append(spec)
|
||||
|
||||
plans: list[tuple[str, str, dict[str, Any]]] = []
|
||||
for sid in sorted(by_server.keys()):
|
||||
picked = _pick_probe_for_server(by_server[sid], workspace_root=workspace_root)
|
||||
if picked is None:
|
||||
continue
|
||||
spec, args = picked
|
||||
plans.append((sid, spec.name, args))
|
||||
planned = {p[0] for p in plans}
|
||||
skipped = [s for s in sorted(by_server.keys()) if s not in planned]
|
||||
return plans, skipped
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_mcp_e2e_probe_plans",
|
||||
"parse_mcp_bound_tool_name",
|
||||
]
|
||||
|
||||
561
admin/models_api.py
Normal file
561
admin/models_api.py
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
"""Admin API: LLM profiles, agent bindings, UI language, eval (parity with Streamlit settings)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
|
||||
from oclaw.agents.factory import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL
|
||||
from oclaw.agents.specialists import (
|
||||
AGENT_PROFILE_BINDINGS_KEY,
|
||||
AGENT_ROLE_IDS,
|
||||
dump_agent_profile_bindings,
|
||||
parse_agent_profile_bindings,
|
||||
)
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.orchestration.evaluation import eval_summary
|
||||
from oclaw.platform.persistence.sqlite_store import (
|
||||
LLM_BUILTIN_OLLAMA_PROFILE_ID,
|
||||
SqliteStore,
|
||||
active_llm_profile_setting_key,
|
||||
agent_profile_bindings_setting_key,
|
||||
is_administrator_model_pool,
|
||||
)
|
||||
|
||||
_LLM_MODE_OPTIONS = frozenset({"openai", "openai_responses", "anthropic", "google", "ollama", "rule"})
|
||||
_LLM_USER_CREATE_MODES = frozenset({"openai", "anthropic", "google", "ollama", "rule"})
|
||||
|
||||
|
||||
def _require_permission(ctx: dict[str, Any], permission: str) -> None:
|
||||
perms = set(str(x) for x in (ctx.get("permissions") or []))
|
||||
if permission in perms:
|
||||
return
|
||||
if str(ctx.get("role") or "") == "owner":
|
||||
return
|
||||
raise HTTPException(status_code=403, detail=f"forbidden:{permission}")
|
||||
|
||||
|
||||
def _require_models_mutate(ctx: dict[str, Any]) -> None:
|
||||
"""administrator 编辑全局池需 tenant:write;其余用户编辑自己的复制池仅需 read。"""
|
||||
uname = str(ctx.get("username") or "").strip()
|
||||
if is_administrator_model_pool(uname):
|
||||
_require_permission(ctx, "admin:tenant:write")
|
||||
else:
|
||||
_require_permission(ctx, "admin:read")
|
||||
|
||||
|
||||
def _models_list_kwargs(ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
uid = str(ctx.get("user_id") or "").strip()
|
||||
uname = str(ctx.get("username") or "").strip()
|
||||
tid = str(ctx.get("tenant_id") or "").strip()
|
||||
if not uid:
|
||||
return {}
|
||||
out: dict[str, Any] = {"viewer_user_id": uid, "viewer_username": uname or None}
|
||||
if tid:
|
||||
out["viewer_tenant_id"] = tid
|
||||
return out
|
||||
|
||||
|
||||
def _active_key(ctx: dict[str, Any]) -> str:
|
||||
uid = str(ctx.get("user_id") or "").strip()
|
||||
uname = str(ctx.get("username") or "").strip()
|
||||
if not uid:
|
||||
return "active_llm_profile_id"
|
||||
return active_llm_profile_setting_key(uid, uname or None)
|
||||
|
||||
|
||||
def _bindings_key(ctx: dict[str, Any]) -> str:
|
||||
uid = str(ctx.get("user_id") or "").strip()
|
||||
uname = str(ctx.get("username") or "").strip()
|
||||
if not uid:
|
||||
return AGENT_PROFILE_BINDINGS_KEY
|
||||
return agent_profile_bindings_setting_key(uid, uname or None)
|
||||
|
||||
|
||||
def _assert_profile_mutable(ctx: dict[str, Any], prof: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not prof:
|
||||
raise HTTPException(status_code=404, detail="profile_not_found")
|
||||
if prof.get("is_builtin"):
|
||||
return prof
|
||||
own = str(prof.get("owner_user_id") or "").strip()
|
||||
uid = str(ctx.get("user_id") or "").strip()
|
||||
uname = str(ctx.get("username") or "").strip()
|
||||
if is_administrator_model_pool(uname):
|
||||
return prof
|
||||
if own == uid:
|
||||
return prof
|
||||
raise HTTPException(status_code=403, detail="profile_forbidden")
|
||||
|
||||
|
||||
def _can_manage_llm_grants(ctx: dict[str, Any]) -> bool:
|
||||
uname = str(ctx.get("username") or "").strip()
|
||||
if not is_administrator_model_pool(uname):
|
||||
return False
|
||||
perms = set(str(x) for x in (ctx.get("permissions") or []))
|
||||
if "admin:tenant:write" in perms:
|
||||
return True
|
||||
return str(ctx.get("role") or "") == "owner"
|
||||
|
||||
|
||||
def _require_grant_manager(ctx: dict[str, Any]) -> None:
|
||||
if not _can_manage_llm_grants(ctx):
|
||||
raise HTTPException(status_code=403, detail="grants_administrator_only")
|
||||
|
||||
|
||||
def _profile_shareable_for_admin_grant(ctx: dict[str, Any], prof: dict[str, Any] | None) -> bool:
|
||||
"""仅全局池(无 owner)或操作者本人名下的 profile 可被授权给团队/用户。"""
|
||||
if not prof or prof.get("is_builtin"):
|
||||
return False
|
||||
own = str(prof.get("owner_user_id") or "").strip()
|
||||
uid = str(ctx.get("user_id") or "").strip()
|
||||
if not own:
|
||||
return True
|
||||
return bool(uid) and own == uid
|
||||
|
||||
|
||||
def _normalize_active(
|
||||
store: SqliteStore, profiles: list[dict[str, Any]], profile_ids: list[str], ctx: dict[str, Any]
|
||||
) -> str:
|
||||
if not profile_ids:
|
||||
return ""
|
||||
key = _active_key(ctx)
|
||||
active_id = str(store.get_setting(key) or "").strip()
|
||||
if active_id not in profile_ids:
|
||||
store.set_setting(key, LLM_BUILTIN_OLLAMA_PROFILE_ID)
|
||||
active_id = LLM_BUILTIN_OLLAMA_PROFILE_ID
|
||||
if active_id not in profile_ids:
|
||||
active_id = profile_ids[0]
|
||||
store.set_setting(key, active_id)
|
||||
return active_id
|
||||
|
||||
|
||||
def include_model_mgmt_routes(
|
||||
router: APIRouter,
|
||||
*,
|
||||
resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]],
|
||||
) -> None:
|
||||
mg = APIRouter(prefix="/admin/api/models", tags=["models"])
|
||||
|
||||
@mg.get("")
|
||||
def api_models_state(
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:read")
|
||||
uid = str(ctx.get("user_id") or "").strip()
|
||||
uname = str(ctx.get("username") or "").strip()
|
||||
lk = _models_list_kwargs(ctx)
|
||||
profiles = store.list_llm_profiles(visible_only=True, **lk)
|
||||
profile_ids = [str(p["id"]) for p in profiles]
|
||||
active_id = _normalize_active(store, profiles, profile_ids, ctx)
|
||||
bindings = parse_agent_profile_bindings(store.get_setting(_bindings_key(ctx)))
|
||||
ui_lang = str(store.get_setting("ui_lang") or "zh").strip().lower()
|
||||
if ui_lang not in ("zh", "en"):
|
||||
ui_lang = "zh"
|
||||
secret = ""
|
||||
if active_id and active_id in profile_ids:
|
||||
active_prof = next((p for p in profiles if str(p.get("id") or "") == active_id), None)
|
||||
if is_administrator_model_pool(uname) or (active_prof and active_prof.get("mutable", True)):
|
||||
secret = store.get_llm_profile_secret(active_id) or ""
|
||||
out: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"active_llm_profile_id": active_id,
|
||||
"profiles": profiles,
|
||||
"bindings": bindings,
|
||||
"ui_lang": ui_lang,
|
||||
"builtin_ollama_profile_id": LLM_BUILTIN_OLLAMA_PROFILE_ID,
|
||||
"has_openai_api_key_env": bool((os.getenv("OPENAI_API_KEY") or "").strip()),
|
||||
"role_ids": list(AGENT_ROLE_IDS),
|
||||
"profile_secret": secret,
|
||||
"can_manage_llm_grants": _can_manage_llm_grants(ctx),
|
||||
# 便于核对「浏览器连的是哪台网关、网关读的是哪个库文件」
|
||||
"db_path": db_path(),
|
||||
}
|
||||
return out
|
||||
|
||||
@mg.post("/active")
|
||||
def api_models_set_active(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
# 与能进入控制台一致:切换「当前选用」不写密钥,仅需读权限即可。
|
||||
_require_permission(ctx, "admin:read")
|
||||
profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
|
||||
profile_ids = [str(p["id"]) for p in profiles]
|
||||
pid = str(payload.get("profile_id") or "").strip()
|
||||
if pid not in profile_ids:
|
||||
raise HTTPException(status_code=400, detail="invalid_profile_id")
|
||||
store.set_setting(_active_key(ctx), pid)
|
||||
return {"ok": True, "active_llm_profile_id": pid}
|
||||
|
||||
@mg.post("/bindings")
|
||||
def api_models_set_bindings(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_models_mutate(ctx)
|
||||
profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
|
||||
profile_ids = set(str(p["id"]) for p in profiles)
|
||||
raw = payload.get("bindings")
|
||||
if not isinstance(raw, dict):
|
||||
raise HTTPException(status_code=400, detail="bindings_object_required")
|
||||
cur = parse_agent_profile_bindings(store.get_setting(_bindings_key(ctx)))
|
||||
for rid in AGENT_ROLE_IDS:
|
||||
v = raw.get(rid)
|
||||
if v is None:
|
||||
continue
|
||||
s = str(v).strip()
|
||||
if s and s not in profile_ids:
|
||||
raise HTTPException(status_code=400, detail=f"invalid_binding:{rid}")
|
||||
cur[rid] = s
|
||||
store.set_setting(_bindings_key(ctx), dump_agent_profile_bindings(cur))
|
||||
return {"ok": True, "bindings": cur}
|
||||
|
||||
@mg.post("/profiles")
|
||||
def api_models_create_profile(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_models_mutate(ctx)
|
||||
name = str(payload.get("name") or "").strip() or "新配置"
|
||||
mode = str(payload.get("mode") or "openai").strip().lower()
|
||||
if mode not in _LLM_USER_CREATE_MODES:
|
||||
raise HTTPException(status_code=400, detail="invalid_mode")
|
||||
if mode == "openai":
|
||||
new_model = "gpt-4o-mini"
|
||||
new_bu = ""
|
||||
else:
|
||||
new_model = DEFAULT_OLLAMA_MODEL
|
||||
new_bu = DEFAULT_OLLAMA_BASE_URL
|
||||
own: str | None = None
|
||||
uid = str(ctx.get("user_id") or "").strip()
|
||||
uname = str(ctx.get("username") or "").strip()
|
||||
if uid and not is_administrator_model_pool(uname):
|
||||
own = uid
|
||||
pid = store.create_llm_profile(name=name, mode=mode, model=new_model, base_url=new_bu or None, owner_user_id=own)
|
||||
store.set_setting(_active_key(ctx), pid)
|
||||
prof = store.get_llm_profile(pid)
|
||||
return {"ok": True, "profile_id": pid, "profile": prof}
|
||||
|
||||
@mg.patch("/profiles/{profile_id}")
|
||||
def api_models_patch_profile(
|
||||
profile_id: str,
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_models_mutate(ctx)
|
||||
pid = str(profile_id or "").strip()
|
||||
prof = _assert_profile_mutable(ctx, store.get_llm_profile(pid))
|
||||
name = str(payload.get("name") if payload.get("name") is not None else prof.get("name") or "").strip() or "未命名"
|
||||
mode_raw = str(payload.get("mode") if payload.get("mode") is not None else prof.get("mode") or "openai").strip().lower()
|
||||
if pid == LLM_BUILTIN_OLLAMA_PROFILE_ID:
|
||||
mode_save = "ollama"
|
||||
else:
|
||||
if mode_raw not in _LLM_MODE_OPTIONS:
|
||||
raise HTTPException(status_code=400, detail="invalid_mode")
|
||||
mode_save = mode_raw
|
||||
model = payload.get("model")
|
||||
base_url = payload.get("base_url")
|
||||
model_s = str(model).strip() if model is not None else str(prof.get("model") or "").strip()
|
||||
bu_s = str(base_url).strip() if base_url is not None else str(prof.get("base_url") or "").strip()
|
||||
store.update_llm_profile(
|
||||
profile_id=pid,
|
||||
name=name,
|
||||
mode=mode_save,
|
||||
model=model_s or None,
|
||||
base_url=bu_s or None,
|
||||
)
|
||||
store.set_setting(_active_key(ctx), pid)
|
||||
return {"ok": True, "profile": store.get_llm_profile(pid)}
|
||||
|
||||
@mg.post("/profiles/{profile_id}/secret")
|
||||
def api_models_profile_secret(
|
||||
profile_id: str,
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_models_mutate(ctx)
|
||||
pid = str(profile_id or "").strip()
|
||||
prof = _assert_profile_mutable(ctx, store.get_llm_profile(pid))
|
||||
remember = bool(payload.get("remember"))
|
||||
key_text = str(payload.get("secret") or "").strip()
|
||||
mode_save = str(prof.get("mode") or "openai").strip().lower()
|
||||
if pid == LLM_BUILTIN_OLLAMA_PROFILE_ID:
|
||||
mode_save = "ollama"
|
||||
if mode_save in ("openai", "ollama"):
|
||||
if remember:
|
||||
if key_text:
|
||||
store.set_llm_profile_secret(pid, key_text)
|
||||
elif mode_save == "openai":
|
||||
raise HTTPException(status_code=400, detail="remember_key_empty")
|
||||
else:
|
||||
store.clear_llm_profile_secret(pid)
|
||||
else:
|
||||
store.clear_llm_profile_secret(pid)
|
||||
return {"ok": True, "profile": store.get_llm_profile(pid)}
|
||||
|
||||
@mg.delete("/profiles/{profile_id}")
|
||||
def api_models_delete_profile(
|
||||
profile_id: str,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_models_mutate(ctx)
|
||||
pid = str(profile_id or "").strip()
|
||||
_assert_profile_mutable(ctx, store.get_llm_profile(pid))
|
||||
try:
|
||||
store.delete_llm_profile(pid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="cannot_delete_builtin")
|
||||
remaining = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx))
|
||||
new_active = remaining[0]["id"] if remaining else LLM_BUILTIN_OLLAMA_PROFILE_ID
|
||||
store.set_setting(_active_key(ctx), new_active)
|
||||
return {"ok": True, "active_llm_profile_id": new_active}
|
||||
|
||||
@mg.get("/members")
|
||||
def api_models_members(
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_grant_manager(ctx)
|
||||
tid = str(ctx.get("tenant_id") or "").strip()
|
||||
if not tid:
|
||||
raise HTTPException(status_code=400, detail="tenant_required")
|
||||
users = store.list_users(tenant_id=tid, limit=500, offset=0, include_inactive=True)
|
||||
members: list[dict[str, Any]] = []
|
||||
for u in users:
|
||||
mid = str(u.get("id") or "").strip()
|
||||
un = str(u.get("username") or "").strip()
|
||||
if not mid:
|
||||
continue
|
||||
profs = store.list_llm_profiles(
|
||||
visible_only=True,
|
||||
viewer_user_id=mid,
|
||||
viewer_username=un or None,
|
||||
viewer_tenant_id=tid,
|
||||
)
|
||||
members.append(
|
||||
{
|
||||
"user_id": mid,
|
||||
"username": un,
|
||||
"display_name": str(u.get("display_name") or "").strip(),
|
||||
"role": str(u.get("role") or "").strip(),
|
||||
"profiles": profs,
|
||||
}
|
||||
)
|
||||
return {"ok": True, "members": members}
|
||||
|
||||
@mg.get("/grants/tenant")
|
||||
def api_models_grants_tenant_get(
|
||||
profile_id: str = Query(...),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_grant_manager(ctx)
|
||||
tid = str(ctx.get("tenant_id") or "").strip()
|
||||
pid = str(profile_id or "").strip()
|
||||
if not tid or not pid:
|
||||
raise HTTPException(status_code=400, detail="tenant_or_profile_required")
|
||||
granted = store.tenant_has_llm_profile_grant(tid, pid)
|
||||
return {"ok": True, "granted": granted}
|
||||
|
||||
@mg.post("/grants/tenant")
|
||||
def api_models_grants_tenant_create(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_grant_manager(ctx)
|
||||
tid = str(ctx.get("tenant_id") or "").strip()
|
||||
pid = str(payload.get("profile_id") or "").strip()
|
||||
if not tid or not pid:
|
||||
raise HTTPException(status_code=400, detail="profile_id_required")
|
||||
prof = store.get_llm_profile(pid)
|
||||
if not _profile_shareable_for_admin_grant(ctx, prof):
|
||||
raise HTTPException(status_code=403, detail="profile_not_shareable")
|
||||
actor = str(ctx.get("user_id") or "").strip() or None
|
||||
try:
|
||||
gid = store.grant_llm_profile_to_tenant(
|
||||
tenant_id=tid, profile_id=pid, created_by_user_id=actor
|
||||
)
|
||||
except ValueError as e:
|
||||
code = str(e)
|
||||
if code == "profile_not_found":
|
||||
raise HTTPException(status_code=404, detail=code) from e
|
||||
raise HTTPException(status_code=400, detail=code) from e
|
||||
return {"ok": True, "grant_id": gid}
|
||||
|
||||
@mg.delete("/grants/tenant")
|
||||
def api_models_grants_tenant_revoke(
|
||||
profile_id: str = Query(...),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_grant_manager(ctx)
|
||||
tid = str(ctx.get("tenant_id") or "").strip()
|
||||
pid = str(profile_id or "").strip()
|
||||
if not tid or not pid:
|
||||
raise HTTPException(status_code=400, detail="profile_id_required")
|
||||
n = store.revoke_llm_profile_tenant_grant(tenant_id=tid, profile_id=pid)
|
||||
return {"ok": True, "removed": int(n)}
|
||||
|
||||
@mg.get("/grants")
|
||||
def api_models_grants_list(
|
||||
profile_id: str = Query(..., description="llm_profile id"),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_grant_manager(ctx)
|
||||
tid = str(ctx.get("tenant_id") or "").strip()
|
||||
pid = str(profile_id or "").strip()
|
||||
if not tid or not pid:
|
||||
raise HTTPException(status_code=400, detail="tenant_or_profile_required")
|
||||
rows = store.list_llm_profile_grants_for_profile(tid, pid)
|
||||
return {"ok": True, "grants": rows}
|
||||
|
||||
@mg.post("/grants")
|
||||
def api_models_grants_create(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_grant_manager(ctx)
|
||||
tid = str(ctx.get("tenant_id") or "").strip()
|
||||
pid = str(payload.get("profile_id") or "").strip()
|
||||
uid = str(payload.get("user_id") or "").strip()
|
||||
if not tid or not pid or not uid:
|
||||
raise HTTPException(status_code=400, detail="profile_id_and_user_id_required")
|
||||
prof = store.get_llm_profile(pid)
|
||||
if not _profile_shareable_for_admin_grant(ctx, prof):
|
||||
raise HTTPException(status_code=403, detail="profile_not_shareable")
|
||||
actor = str(ctx.get("user_id") or "").strip() or None
|
||||
try:
|
||||
gid = store.grant_llm_profile_to_user(
|
||||
tenant_id=tid,
|
||||
profile_id=pid,
|
||||
user_id=uid,
|
||||
created_by_user_id=actor,
|
||||
)
|
||||
except ValueError as e:
|
||||
code = str(e)
|
||||
if code == "profile_not_found":
|
||||
raise HTTPException(status_code=404, detail=code) from e
|
||||
if code == "user_not_found":
|
||||
raise HTTPException(status_code=404, detail=code) from e
|
||||
raise HTTPException(status_code=400, detail=code) from e
|
||||
return {"ok": True, "grant_id": gid}
|
||||
|
||||
@mg.delete("/grants")
|
||||
def api_models_grants_revoke(
|
||||
profile_id: str = Query(...),
|
||||
user_id: str = Query(...),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_grant_manager(ctx)
|
||||
tid = str(ctx.get("tenant_id") or "").strip()
|
||||
pid = str(profile_id or "").strip()
|
||||
uid = str(user_id or "").strip()
|
||||
if not tid or not pid or not uid:
|
||||
raise HTTPException(status_code=400, detail="profile_id_and_user_id_required")
|
||||
n = store.revoke_llm_profile_grant(tenant_id=tid, profile_id=pid, user_id=uid)
|
||||
return {"ok": True, "removed": int(n)}
|
||||
|
||||
@mg.get("/eval")
|
||||
def api_models_eval(
|
||||
authorization: str | None = Header(default=None),
|
||||
limit_logs: int = Query(default=100, ge=1, le=500),
|
||||
limit_summary: int = Query(default=500, ge=1, le=5000),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:read")
|
||||
summary = eval_summary(store, limit=limit_summary)
|
||||
logs = store.list_agent_eval_logs(limit=limit_logs)
|
||||
return {"ok": True, "summary": summary, "logs": logs}
|
||||
|
||||
_EVAL_EXPORT_FIELDS = (
|
||||
"timestamp",
|
||||
"session_id",
|
||||
"specialist",
|
||||
"task_kind",
|
||||
"success",
|
||||
"latency_ms",
|
||||
"cost_hint",
|
||||
"notes",
|
||||
)
|
||||
|
||||
@mg.get("/eval/export")
|
||||
def api_models_eval_export(
|
||||
authorization: str | None = Header(default=None),
|
||||
format: str = Query(default="csv", description="csv or json"),
|
||||
limit: int = Query(default=100_000, ge=1, le=200_000),
|
||||
) -> Response:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_permission(ctx, "admin:read")
|
||||
fmt = str(format or "csv").strip().lower()
|
||||
rows = store.list_agent_eval_logs(limit=limit)
|
||||
if fmt == "json":
|
||||
body = json.dumps(rows, ensure_ascii=False, indent=2)
|
||||
return Response(
|
||||
content=body.encode("utf-8"),
|
||||
media_type="application/json; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="agent_eval_logs.json"',
|
||||
},
|
||||
)
|
||||
if fmt != "csv":
|
||||
raise HTTPException(status_code=400, detail="invalid_format")
|
||||
buf = io.StringIO()
|
||||
w = csv.DictWriter(buf, fieldnames=list(_EVAL_EXPORT_FIELDS), extrasaction="ignore")
|
||||
w.writeheader()
|
||||
for r in rows:
|
||||
row = {k: r.get(k) for k in _EVAL_EXPORT_FIELDS}
|
||||
if "success" in row:
|
||||
row["success"] = 1 if bool(row.get("success")) else 0
|
||||
w.writerow(row)
|
||||
payload = "\ufeff" + buf.getvalue()
|
||||
return Response(
|
||||
content=payload.encode("utf-8"),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="agent_eval_logs.csv"'},
|
||||
)
|
||||
|
||||
router.include_router(mg)
|
||||
|
||||
|
||||
__all__ = ["include_model_mgmt_routes"]
|
||||
3069
admin/routes.py
Normal file
3069
admin/routes.py
Normal file
File diff suppressed because it is too large
Load diff
604
admin/skills_api.py
Normal file
604
admin/skills_api.py
Normal file
|
|
@ -0,0 +1,604 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException
|
||||
|
||||
from oclaw.agents.factory import build_gateway_executor
|
||||
from oclaw.openclaw_runtime.skill_installer import (
|
||||
auto_install_skill_from_payload,
|
||||
create_skill_from_template,
|
||||
install_skill_from_local_dir,
|
||||
install_skill_from_registry_archive,
|
||||
list_skills_with_status,
|
||||
set_skill_enabled,
|
||||
)
|
||||
from oclaw.openclaw_runtime.skill_role_binding import (
|
||||
SKILL_ROLE_BINDING_ENABLED_SETTING,
|
||||
SKILL_ROLE_BINDING_KEY,
|
||||
load_skill_role_binding_dict,
|
||||
normalize_skill_role_binding,
|
||||
ordered_binding_roles,
|
||||
skill_role_binding_enabled,
|
||||
)
|
||||
from oclaw.openclaw_runtime.skills_prompt import collect_skill_catalog_entries
|
||||
from oclaw.openclaw_runtime.skills import _allowed_tool_names_after_wire_policy, discover_workspace_skill_manifests
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.tools.skills.clawhub_client import get_skill_detail as clawhub_get_skill_detail
|
||||
from oclaw.tools.skills.clawhub_client import search_skills as clawhub_search_skills
|
||||
|
||||
|
||||
def include_skill_routes(
|
||||
router: APIRouter,
|
||||
*,
|
||||
resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]],
|
||||
) -> None:
|
||||
sk = APIRouter(prefix="/admin/api/skills", tags=["skills"])
|
||||
|
||||
def _require_admin(ctx: dict[str, Any]) -> None:
|
||||
perms = set(str(x) for x in (ctx.get("permissions") or []))
|
||||
if "admin:read" in perms or str(ctx.get("role") or "") == "owner":
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="forbidden:admin:read")
|
||||
|
||||
def _require_tenant_write(ctx: dict[str, Any]) -> None:
|
||||
perms = set(str(x) for x in (ctx.get("permissions") or []))
|
||||
if "admin:tenant:write" in perms or str(ctx.get("role") or "") == "owner":
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="forbidden:admin:tenant:write")
|
||||
|
||||
def _audit(store: SqliteStore, ctx: dict[str, Any], *, action: str, target_id: str, status: str, detail: dict[str, Any] | None = None) -> None:
|
||||
try:
|
||||
store.add_admin_audit_log(
|
||||
actor_tenant_id=str(ctx.get("tenant_id") or ""),
|
||||
actor_user_id=str(ctx.get("user_id") or ""),
|
||||
action=action,
|
||||
target_type="skill",
|
||||
target_id=str(target_id or ""),
|
||||
status=status,
|
||||
detail=detail or {},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _normalized_skill_binding(store: SqliteStore) -> tuple[list[str], dict[str, list[str]], set[str]]:
|
||||
roles = ordered_binding_roles()
|
||||
valid = {str(m.name).strip() for m in discover_workspace_skill_manifests() if str(m.name or "").strip()}
|
||||
mapping = normalize_skill_role_binding(
|
||||
mapping_raw=load_skill_role_binding_dict(store),
|
||||
valid_skill_names=valid,
|
||||
available_roles=roles,
|
||||
)
|
||||
return roles, mapping, valid
|
||||
|
||||
@sk.get("")
|
||||
def api_skills_list(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
items = list_skills_with_status(store=store)
|
||||
return {"ok": True, "items": items}
|
||||
|
||||
@sk.post("/install")
|
||||
def api_skills_install(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
source_dir = str(payload.get("source_dir") or "").strip()
|
||||
if not source_dir:
|
||||
raise HTTPException(status_code=400, detail="source_dir_required")
|
||||
overwrite = bool(payload.get("overwrite"))
|
||||
_audit(store, ctx, action="skill_install_started", target_id=source_dir, status="start", detail={"source": "local"})
|
||||
out = install_skill_from_local_dir(store=store, source_dir=source_dir, overwrite=overwrite)
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_install_finished" if out.ok else "skill_install_failed",
|
||||
target_id=out.name or source_dir,
|
||||
status="ok" if out.ok else "fail",
|
||||
detail={"detail": out.detail, "target_dir": out.target_dir, "source": "local", "input_target": source_dir},
|
||||
)
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"result": {
|
||||
"name": out.name,
|
||||
"target_dir": out.target_dir,
|
||||
"detail": out.detail,
|
||||
"error_code": out.error_code,
|
||||
"retryable": bool(out.retryable),
|
||||
},
|
||||
}
|
||||
|
||||
@sk.post("/install-registry")
|
||||
def api_skills_install_registry(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
archive_url = str(payload.get("archive_url") or "").strip()
|
||||
if not archive_url:
|
||||
raise HTTPException(status_code=400, detail="archive_url_required")
|
||||
overwrite = bool(payload.get("overwrite"))
|
||||
_audit(store, ctx, action="skill_install_started", target_id=archive_url, status="start", detail={"source": "registry"})
|
||||
out = install_skill_from_registry_archive(store=store, archive_url=archive_url, overwrite=overwrite)
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_install_finished" if out.ok else "skill_install_failed",
|
||||
target_id=out.name or archive_url,
|
||||
status="ok" if out.ok else "fail",
|
||||
detail={"detail": out.detail, "target_dir": out.target_dir, "source": "registry", "input_target": archive_url},
|
||||
)
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"result": {
|
||||
"name": out.name,
|
||||
"target_dir": out.target_dir,
|
||||
"detail": out.detail,
|
||||
"error_code": out.error_code,
|
||||
"retryable": bool(out.retryable),
|
||||
},
|
||||
}
|
||||
|
||||
@sk.get("/market/search")
|
||||
def api_skills_market_search(
|
||||
q: str | None = None,
|
||||
limit: int | None = None,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
query = str(q or "").strip()
|
||||
lim = int(limit) if isinstance(limit, int) and limit > 0 else 20
|
||||
lim = max(1, min(lim, 200))
|
||||
items = clawhub_search_skills(query, limit=lim)
|
||||
return {"ok": True, "items": items}
|
||||
|
||||
@sk.get("/market/detail")
|
||||
def api_skills_market_detail(
|
||||
slug: str | None = None,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
s = str(slug or "").strip()
|
||||
if not s:
|
||||
raise HTTPException(status_code=400, detail="slug_required")
|
||||
detail = clawhub_get_skill_detail(s)
|
||||
return {"ok": True, "detail": detail}
|
||||
|
||||
@sk.post("/market/install")
|
||||
def api_skills_market_install(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
s = str(payload.get("slug") or "").strip()
|
||||
if not s:
|
||||
raise HTTPException(status_code=400, detail="slug_required")
|
||||
requested_version = str(payload.get("version") or "").strip()
|
||||
overwrite = bool(payload.get("overwrite"))
|
||||
|
||||
detail = clawhub_get_skill_detail(s)
|
||||
archive_url = str(detail.get("archiveUrl") or "").strip()
|
||||
chosen_version = str(detail.get("latestVersion") or "").strip()
|
||||
if requested_version:
|
||||
chosen_version = requested_version
|
||||
archive_url = ""
|
||||
for v in (detail.get("versions") or []):
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
if str(v.get("version") or "").strip() == requested_version:
|
||||
archive_url = str(v.get("archiveUrl") or "").strip()
|
||||
break
|
||||
|
||||
if not archive_url:
|
||||
raise HTTPException(status_code=400, detail="archive_url_unavailable")
|
||||
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_install_started",
|
||||
target_id=archive_url,
|
||||
status="start",
|
||||
detail={"source": "clawhub", "slug": s, "version": chosen_version, "input_target": s},
|
||||
)
|
||||
out = install_skill_from_registry_archive(store=store, archive_url=archive_url, overwrite=overwrite)
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_install_finished" if out.ok else "skill_install_failed",
|
||||
target_id=out.name or archive_url,
|
||||
status="ok" if out.ok else "fail",
|
||||
detail={
|
||||
"detail": out.detail,
|
||||
"target_dir": out.target_dir,
|
||||
"source": "clawhub",
|
||||
"slug": s,
|
||||
"version": chosen_version,
|
||||
"input_target": archive_url,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"result": {
|
||||
"name": out.name,
|
||||
"target_dir": out.target_dir,
|
||||
"detail": out.detail,
|
||||
"error_code": out.error_code,
|
||||
"retryable": bool(out.retryable),
|
||||
},
|
||||
}
|
||||
|
||||
@sk.get("/binding")
|
||||
def api_skills_binding_get(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_tenant_write(ctx)
|
||||
roles, mapping, _valid = _normalized_skill_binding(store)
|
||||
items = list_skills_with_status(store=store)
|
||||
return {
|
||||
"ok": True,
|
||||
"enabled": bool(skill_role_binding_enabled(store=store)),
|
||||
"available_roles": roles,
|
||||
"installed_skills": items,
|
||||
"mapping": mapping,
|
||||
}
|
||||
|
||||
@sk.post("/binding")
|
||||
def api_skills_binding_save(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_tenant_write(ctx)
|
||||
if "enabled" in payload:
|
||||
store.set_setting(SKILL_ROLE_BINDING_ENABLED_SETTING, "1" if bool(payload.get("enabled")) else "0")
|
||||
roles, _prev_mapping, valid = _normalized_skill_binding(store)
|
||||
mapping_raw = payload.get("mapping") if isinstance(payload.get("mapping"), dict) else {}
|
||||
mapping = normalize_skill_role_binding(
|
||||
mapping_raw=mapping_raw,
|
||||
valid_skill_names=valid,
|
||||
available_roles=roles,
|
||||
)
|
||||
store.set_setting(SKILL_ROLE_BINDING_KEY, json.dumps(mapping, ensure_ascii=False))
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_binding_update",
|
||||
target_id="skill_role_binding",
|
||||
status="ok",
|
||||
detail={"mapping": mapping, "enabled": bool(skill_role_binding_enabled(store=store))},
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"enabled": bool(skill_role_binding_enabled(store=store)),
|
||||
"available_roles": roles,
|
||||
"mapping": mapping,
|
||||
}
|
||||
|
||||
@sk.get("/effective")
|
||||
def api_skills_effective(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
roles, mapping, _valid = _normalized_skill_binding(store)
|
||||
role_rows: list[dict[str, Any]] = []
|
||||
for role in roles:
|
||||
specialist = "generalist" if role == "manager" else role
|
||||
ex = build_gateway_executor(store=store, specialist=specialist)
|
||||
tools = getattr(ex, "tools", None)
|
||||
model = getattr(ex, "model", None)
|
||||
if tools is None:
|
||||
role_rows.append(
|
||||
{
|
||||
"role": role,
|
||||
"total": 0,
|
||||
"workspace_total": 0,
|
||||
"workspace_direct": 0,
|
||||
"workspace_inherited_manager": 0,
|
||||
"mcp_total": 0,
|
||||
"tool_total": 0,
|
||||
"names_preview": [],
|
||||
}
|
||||
)
|
||||
continue
|
||||
base_url = str(getattr(model, "base_url", "") or "")
|
||||
entries = collect_skill_catalog_entries(
|
||||
store=store,
|
||||
registry=tools,
|
||||
base_url=base_url,
|
||||
skill_binding_role=role,
|
||||
)
|
||||
allowed_tool_names, _hidden_tool_names = _allowed_tool_names_after_wire_policy(
|
||||
registry=tools,
|
||||
store=store,
|
||||
base_url=base_url,
|
||||
)
|
||||
direct_set = set(mapping.get(role) or [])
|
||||
manager_set = set(mapping.get("manager") or [])
|
||||
workspace_total = 0
|
||||
workspace_direct = 0
|
||||
workspace_inherited = 0
|
||||
workspace_resolved_tool_match = 0
|
||||
workspace_docs_only = 0
|
||||
mcp_total = 0
|
||||
tool_total = 0
|
||||
names: list[str] = []
|
||||
docs_only_names: list[str] = []
|
||||
resolved_workspace_names: list[str] = []
|
||||
for nm, _desc, loc in entries:
|
||||
names.append(str(nm))
|
||||
vloc = str(loc or "")
|
||||
if vloc.endswith("SKILL.md"):
|
||||
workspace_total += 1
|
||||
if nm in allowed_tool_names:
|
||||
workspace_resolved_tool_match += 1
|
||||
resolved_workspace_names.append(str(nm))
|
||||
else:
|
||||
workspace_docs_only += 1
|
||||
docs_only_names.append(str(nm))
|
||||
if nm in direct_set:
|
||||
workspace_direct += 1
|
||||
elif role != "manager" and nm in manager_set:
|
||||
workspace_inherited += 1
|
||||
elif str(nm).startswith("mcp__"):
|
||||
mcp_total += 1
|
||||
else:
|
||||
tool_total += 1
|
||||
role_rows.append(
|
||||
{
|
||||
"role": role,
|
||||
"total": len(entries),
|
||||
"workspace_total": workspace_total,
|
||||
"workspace_direct": workspace_direct,
|
||||
"workspace_inherited_manager": workspace_inherited,
|
||||
"workspace_resolved_tool_match": workspace_resolved_tool_match,
|
||||
"workspace_docs_only": workspace_docs_only,
|
||||
"mcp_total": mcp_total,
|
||||
"tool_total": tool_total,
|
||||
"names_preview": names[:20],
|
||||
"docs_only_names_preview": docs_only_names[:20],
|
||||
"resolved_workspace_names_preview": resolved_workspace_names[:20],
|
||||
}
|
||||
)
|
||||
return {"ok": True, "enabled": bool(skill_role_binding_enabled(store=store)), "items": role_rows}
|
||||
|
||||
@sk.post("/create")
|
||||
def api_skills_create(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
name = str(payload.get("name") or "").strip()
|
||||
desc = str(payload.get("description") or "").strip()
|
||||
body = str(payload.get("body_markdown") or "").strip()
|
||||
md = payload.get("metadata_openclaw")
|
||||
md = dict(md) if isinstance(md, dict) else {}
|
||||
overwrite = bool(payload.get("overwrite"))
|
||||
out = create_skill_from_template(
|
||||
store=store,
|
||||
name=name,
|
||||
description=desc,
|
||||
body_markdown=body,
|
||||
metadata_openclaw=md,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_create",
|
||||
target_id=out.name or name,
|
||||
status="ok" if out.ok else "fail",
|
||||
detail={"detail": out.detail, "target_dir": out.target_dir},
|
||||
)
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"result": {
|
||||
"name": out.name,
|
||||
"target_dir": out.target_dir,
|
||||
"detail": out.detail,
|
||||
"error_code": out.error_code,
|
||||
"retryable": bool(out.retryable),
|
||||
},
|
||||
}
|
||||
|
||||
@sk.post("/enable")
|
||||
def api_skills_enable(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
name = str(payload.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name_required")
|
||||
set_skill_enabled(store=store, skill_name=name, enabled=True)
|
||||
_audit(store, ctx, action="skill_enable", target_id=name, status="ok")
|
||||
return {"ok": True}
|
||||
|
||||
@sk.post("/disable")
|
||||
def api_skills_disable(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
name = str(payload.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name_required")
|
||||
set_skill_enabled(store=store, skill_name=name, enabled=False)
|
||||
_audit(store, ctx, action="skill_disable", target_id=name, status="ok")
|
||||
return {"ok": True}
|
||||
|
||||
@sk.post("/auto-install")
|
||||
def api_skills_auto_install(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
auto_name = str(payload.get("name") or "")
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_install_started",
|
||||
target_id=auto_name,
|
||||
status="start",
|
||||
detail={"source": "auto", "input_target": auto_name},
|
||||
)
|
||||
out = auto_install_skill_from_payload(store=store, payload=payload)
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_install_finished" if out.ok else "skill_install_failed",
|
||||
target_id=out.name or str(payload.get("name") or ""),
|
||||
status="ok" if out.ok else "fail",
|
||||
detail={"detail": out.detail, "target_dir": out.target_dir, "source": "auto", "input_target": auto_name},
|
||||
)
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"result": {
|
||||
"name": out.name,
|
||||
"target_dir": out.target_dir,
|
||||
"detail": out.detail,
|
||||
"error_code": out.error_code,
|
||||
"retryable": bool(out.retryable),
|
||||
},
|
||||
}
|
||||
|
||||
@sk.post("/retry-install")
|
||||
def api_skills_retry_install(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
source = str(payload.get("source") or "").strip().lower()
|
||||
target = str(payload.get("target") or "").strip()
|
||||
if source not in {"local", "registry", "auto"}:
|
||||
raise HTTPException(status_code=400, detail="invalid_source")
|
||||
if not target:
|
||||
raise HTTPException(status_code=400, detail="target_required")
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_install_started",
|
||||
target_id=target,
|
||||
status="start",
|
||||
detail={"source": source, "retry": True, "input_target": target},
|
||||
)
|
||||
if source == "local":
|
||||
out = install_skill_from_local_dir(store=store, source_dir=target, overwrite=True)
|
||||
elif source == "registry":
|
||||
out = install_skill_from_registry_archive(store=store, archive_url=target, overwrite=True)
|
||||
else:
|
||||
out = auto_install_skill_from_payload(
|
||||
store=store,
|
||||
payload={
|
||||
"name": str(payload.get("name") or "").strip() or target,
|
||||
"description": str(payload.get("description") or "retry auto install"),
|
||||
"body_markdown": str(payload.get("body_markdown") or ""),
|
||||
"metadata_openclaw": dict(payload.get("metadata_openclaw") or {})
|
||||
if isinstance(payload.get("metadata_openclaw"), dict)
|
||||
else {},
|
||||
},
|
||||
)
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_install_finished" if out.ok else "skill_install_failed",
|
||||
target_id=out.name or target,
|
||||
status="ok" if out.ok else "fail",
|
||||
detail={
|
||||
"detail": out.detail,
|
||||
"target_dir": out.target_dir,
|
||||
"source": source,
|
||||
"retry": True,
|
||||
"input_target": target,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"result": {
|
||||
"name": out.name,
|
||||
"target_dir": out.target_dir,
|
||||
"detail": out.detail,
|
||||
"error_code": out.error_code,
|
||||
"retryable": bool(out.retryable),
|
||||
},
|
||||
}
|
||||
|
||||
@sk.post("/test-run")
|
||||
def api_skills_test_run(
|
||||
payload: dict[str, Any] | None = Body(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> dict[str, Any]:
|
||||
payload = payload or {}
|
||||
store = SqliteStore(db_path())
|
||||
ctx = resolve_auth(store, authorization)
|
||||
_require_admin(ctx)
|
||||
name = str(payload.get("name") or "").strip()
|
||||
args = payload.get("args")
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name_required")
|
||||
if args is None:
|
||||
args = {}
|
||||
if not isinstance(args, dict):
|
||||
raise HTTPException(status_code=400, detail="args_must_be_object")
|
||||
|
||||
ex = build_gateway_executor(store=store, specialist="generalist")
|
||||
tools = getattr(ex, "tools", None)
|
||||
if tools is None:
|
||||
raise HTTPException(status_code=500, detail="tool_registry_unavailable")
|
||||
spec = tools.get(name)
|
||||
if spec is None:
|
||||
raise HTTPException(status_code=404, detail="tool_not_found")
|
||||
try:
|
||||
result = spec.handler(dict(args))
|
||||
except Exception as exc:
|
||||
result = {"ok": False, "error_code": "exception", "error": f"{type(exc).__name__}:{exc}"}
|
||||
|
||||
_audit(
|
||||
store,
|
||||
ctx,
|
||||
action="skill_test_run",
|
||||
target_id=name,
|
||||
status="ok" if bool((result or {}).get("ok")) else "fail",
|
||||
detail={"args_keys": list(args.keys()), "result_ok": bool((result or {}).get("ok"))},
|
||||
)
|
||||
return {"ok": True, "name": name, "result": result}
|
||||
|
||||
router.include_router(sk)
|
||||
|
||||
|
||||
__all__ = ["include_skill_routes"]
|
||||
|
||||
7578
admin/static/app.js
Normal file
7578
admin/static/app.js
Normal file
File diff suppressed because it is too large
Load diff
455
admin/static/chat.html
Normal file
455
admin/static/chat.html
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Chat</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/admin/assets/styles.css" />
|
||||
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css" />
|
||||
<style>
|
||||
body.theme-ds-body.chat-standalone-page {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--ds-bg, #0d0d0d);
|
||||
color: var(--ds-text, #e8e8e8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#app {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chat-app--login {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#app .chat-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.chat-sess-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.chat-sess-row:hover {
|
||||
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.05));
|
||||
border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.09));
|
||||
}
|
||||
.chat-sess-row--active {
|
||||
background: var(--chat-sess-active-bg, rgba(255, 255, 255, 0.062));
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.chat-sess-row .chat-sess-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-sess-more {
|
||||
flex: 0 0 2rem;
|
||||
min-width: 2rem;
|
||||
padding: 10px 0;
|
||||
border-radius: 0;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.chat-user-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.chat-user-row:hover {
|
||||
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06));
|
||||
border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.12));
|
||||
}
|
||||
.chat-user-row .chat-sess-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-sess-more:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.chat-sess-more:focus {
|
||||
outline: none;
|
||||
}
|
||||
.chat-sess-more:focus-visible {
|
||||
outline: 1px solid var(--chat-sess-border, rgba(255, 255, 255, 0.2));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.chat-sess-more--active {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
.chat-sess-more--active:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.chat-sess-menu-pop {
|
||||
z-index: 200;
|
||||
background: var(--ds-panel, #222);
|
||||
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
min-width: 11rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.chat-sess-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.chat-sess-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.chat-msg__md {
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
.chat-msg .chat-msg__md p {
|
||||
margin: 0.28em 0;
|
||||
}
|
||||
.chat-msg .chat-msg__md > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.chat-msg .chat-msg__md > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.chat-msg__md pre {
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
font-size: 12px;
|
||||
}
|
||||
.chat-msg__md code {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
/* 父级 .chat-msg 的 pre-wrap 会继承到子节点,影响图片与 Markdown 排版 */
|
||||
.chat-msg .chat-msg__md,
|
||||
.chat-msg .chat-att-wrap {
|
||||
white-space: normal;
|
||||
}
|
||||
.chat-msg__md img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
margin: 0.25rem 0;
|
||||
vertical-align: middle;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.chat-msg__plain {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.chat-msg__stream-status {
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.62));
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.chat-att-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.chat-att-img {
|
||||
max-width: 180px;
|
||||
max-height: 180px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.chat-img-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 300;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chat-img-lightbox__inner {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.chat-img-lightbox__img {
|
||||
max-width: min(96vw, 100%);
|
||||
max-height: min(92vh, 100%);
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.chat-img-lightbox__close {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #eee;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.chat-img-lightbox__close:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
.chat-att-chip {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.chat-composer-shell {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.12));
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
padding: 6px 8px 6px 6px;
|
||||
}
|
||||
.chat-composer-shell--busy .chat-composer__field {
|
||||
opacity: 0.72;
|
||||
}
|
||||
.chat-pending-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 4.5rem;
|
||||
overflow-y: auto;
|
||||
padding: 2px 4px 6px;
|
||||
margin: 0 -2px;
|
||||
}
|
||||
.chat-pending-files:empty {
|
||||
display: none;
|
||||
}
|
||||
.chat-pending-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
padding: 2px 8px 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-size: 11px;
|
||||
}
|
||||
.chat-pending-row .muted {
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chat-pending-row .chat-pending-remove {
|
||||
padding: 0 4px;
|
||||
min-height: 22px;
|
||||
min-width: 22px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.75;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chat-pending-row .chat-pending-remove:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.chat-composer-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
.chat-composer-iconbtn {
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
margin: 0 0 2px 2px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.75));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.chat-composer-iconbtn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--ds-text, #e8e8e8);
|
||||
}
|
||||
.chat-composer-iconbtn:focus-visible {
|
||||
outline: 1px solid var(--chat-sess-border, rgba(255, 255, 255, 0.25));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.chat-composer-iconbtn svg {
|
||||
display: block;
|
||||
}
|
||||
.chat-composer__field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
max-height: 200px;
|
||||
resize: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 10px 8px 12px 4px;
|
||||
margin-bottom: 2px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.chat-composer__field:focus {
|
||||
outline: none;
|
||||
}
|
||||
.chat-composer__field::placeholder {
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.45));
|
||||
}
|
||||
.chat-composer-actions {
|
||||
position: relative;
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0 2px 2px 0;
|
||||
}
|
||||
.chat-composer-send,
|
||||
.chat-composer-stop {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
.chat-composer-send {
|
||||
background: rgba(94, 179, 255, 0.92);
|
||||
color: #0a0a0c;
|
||||
}
|
||||
.chat-composer-send:hover:not(:disabled) {
|
||||
background: rgba(120, 195, 255, 1);
|
||||
}
|
||||
.chat-composer-send:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.chat-composer-stop {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #f3f3f3;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chat-composer-stop:hover:not(:disabled) {
|
||||
background: rgba(255, 80, 80, 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
.chat-composer-stop:disabled {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chat-composer-shell--busy .chat-composer-send {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chat-composer-shell--busy .chat-composer-stop:not(:disabled) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.chat-msg-cap {
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
#app .chat-status {
|
||||
flex-shrink: 0;
|
||||
min-height: 1.25rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.chat-confirm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 400;
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.chat-confirm-card {
|
||||
width: min(420px, 92vw);
|
||||
background: linear-gradient(180deg, rgba(11, 18, 30, 0.98), rgba(7, 12, 22, 0.98));
|
||||
border: 1px solid rgba(94, 179, 255, 0.35);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||||
color: var(--ds-text, #e8e8e8);
|
||||
padding: 14px;
|
||||
}
|
||||
.chat-confirm-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--ds-text, #e8e8e8);
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body class="theme-ds-body chat-standalone-page">
|
||||
<div id="app"></div>
|
||||
<!-- Cache-bust for desktop webview: avoid stale chat.js -->
|
||||
<script src="/admin/assets/chat.js?v=20260421-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
3404
admin/static/chat.js
Normal file
3404
admin/static/chat.js
Normal file
File diff suppressed because it is too large
Load diff
344
admin/static/default-user-avatar.svg
Normal file
344
admin/static/default-user-avatar.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 307 KiB |
59
admin/static/index.html
Normal file
59
admin/static/index.html
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>oliver</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/admin/assets/styles.css" />
|
||||
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css" />
|
||||
</head>
|
||||
<body class="theme-ds-body">
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand__logoWrap">
|
||||
<img class="brand__logo" src="/admin/assets/oliver.svg" alt="oliver logo" />
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<a class="nav__item" data-page="models" href="#/models" data-i18n="nav.models">模型管理</a>
|
||||
<a class="nav__item" data-page="api-grants" href="#/api-grants" data-i18n="nav.apiGrants">API 使用授权</a>
|
||||
<a class="nav__item" data-page="stack" href="#/stack" data-i18n="nav.stack">Runtime</a>
|
||||
<a class="nav__item" data-page="users" href="#/users" data-i18n="nav.users">用户管理</a>
|
||||
<a class="nav__item" data-page="workspace-paths" href="#/workspace-paths" data-i18n="nav.workspacePaths">工作区路径</a>
|
||||
<a class="nav__item" data-page="memory" href="#/memory" data-i18n="nav.memory">Memory</a>
|
||||
<a class="nav__item" data-page="audit" href="#/audit" data-i18n="nav.audit">Audit & Trace</a>
|
||||
<a class="nav__item" data-page="session-monitor" href="#/session-monitor" data-i18n="nav.sessionMonitor">会话监控</a>
|
||||
<a class="nav__item" data-page="admin-audit" href="#/admin-audit" data-i18n="nav.adminAudit">Admin Audit</a>
|
||||
<a class="nav__item" data-page="plugins" href="#/plugins" data-i18n="nav.plugins">Plugins</a>
|
||||
<a class="nav__item" data-page="skills" href="#/skills" data-i18n="nav.skills">Skills</a>
|
||||
<a class="nav__item" data-page="attachments" href="#/attachments" data-i18n="nav.attachments">附件</a>
|
||||
<a class="nav__item" data-page="profile" href="#/profile" data-i18n="nav.profile">设置</a>
|
||||
</nav>
|
||||
<div class="sidebar__footer">
|
||||
<div class="muted" data-i18n="notice.noLogin">v1 无登录:请仅在内网访问</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div id="topTitle" class="topbar__title">Runtime</div>
|
||||
<div class="topbar__actions">
|
||||
<span id="authUser" class="muted"></span>
|
||||
<a id="btnBackChat" class="btn" data-i18n="nav.chat" href="chat">对话</a>
|
||||
<button id="btnLogout" class="btn" data-i18n="auth.logout">退出登录</button>
|
||||
<button id="btnLang" class="btn">中文</button>
|
||||
<button id="btnRefresh" class="btn" data-i18n="action.refresh">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<section id="content" class="content"></section>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/admin/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
333
admin/static/oliver.svg
Normal file
333
admin/static/oliver.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 111 KiB |
315
admin/static/styles.css
Normal file
315
admin/static/styles.css
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji",
|
||||
"Segoe UI Emoji";
|
||||
/* Prefer theme variables when enabled (e.g. theme-deepseek.css). */
|
||||
color: var(--ds-text, #e2e8f0);
|
||||
background: var(--ds-bg, #0b1020);
|
||||
}
|
||||
|
||||
.layout { display: flex; height: 100vh; }
|
||||
.sidebar { width: 260px; background: #0f172a; color: #e2e8f0; display: flex; flex-direction: column; border-right: 1px solid rgba(148,163,184,0.2); }
|
||||
.brand { padding: 16px 16px 8px; }
|
||||
.brand__logoWrap {
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
height: 44px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.brand__logo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
.brand__title {
|
||||
font-family: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.brand__sub { font-size: 12px; color: rgba(226,232,240,0.7); margin-top: 4px; }
|
||||
.nav { padding: 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.nav__item { text-decoration: none; color: rgba(226,232,240,0.85); padding: 10px 12px; border-radius: 10px; }
|
||||
.nav__item:hover { background: rgba(148,163,184,0.12); }
|
||||
.nav__item--active { background: rgba(59,130,246,0.22); color: #e2e8f0; }
|
||||
.sidebar__footer { margin-top: auto; padding: 12px 16px; border-top: 1px solid rgba(148,163,184,0.2); }
|
||||
.muted { font-size: 12px; color: rgba(226,232,240,0.65); }
|
||||
|
||||
.main { flex: 1; display: flex; flex-direction: column; background: #0b1020; }
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid rgba(148,163,184,0.18); background: rgba(2,6,23,0.6); backdrop-filter: blur(10px); }
|
||||
.topbar__title { color: #e2e8f0; font-weight: 650; }
|
||||
.btn { background: rgba(148,163,184,0.12); border: 1px solid rgba(148,163,184,0.25); color: #e2e8f0; border-radius: 10px; padding: 8px 12px; cursor: pointer; }
|
||||
.btn:hover { background: rgba(148,163,184,0.18); }
|
||||
.btn--primary { background: rgba(59,130,246,0.25); border-color: rgba(59,130,246,0.4); }
|
||||
.btn--danger { background: rgba(239,68,68,0.18); border-color: rgba(239,68,68,0.35); }
|
||||
.content { padding: 18px; overflow: auto; color: #e2e8f0; }
|
||||
|
||||
.card { background: rgba(15,23,42,0.9); border: 1px solid rgba(148,163,184,0.18); border-radius: 14px; padding: 14px; margin-bottom: 12px; }
|
||||
.card__title { font-weight: 650; margin-bottom: 10px; }
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.chat-login-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
.chat-login-fields .input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-login-actions {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.row--wecom-form {
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.row--wecom-form__field {
|
||||
flex: 1 1 140px;
|
||||
min-width: 0;
|
||||
max-width: 280px;
|
||||
}
|
||||
.row--wecom-form__field .input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.row--wecom-form__chk {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row--wecom-form .btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.kv { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; background: rgba(148,163,184,0.08); border: 1px solid rgba(148,163,184,0.14); padding: 6px 8px; border-radius: 10px; }
|
||||
.input { background: rgba(2,6,23,0.65); border: 1px solid rgba(148,163,184,0.25); color: #e2e8f0; border-radius: 10px; padding: 8px 10px; min-width: 240px; }
|
||||
.input--compact {
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.input--readonly {
|
||||
max-width: min(100%, 720px);
|
||||
cursor: default;
|
||||
color: rgba(226,232,240,0.82);
|
||||
border-color: rgba(148,163,184,0.18);
|
||||
background: rgba(2,6,23,0.45);
|
||||
}
|
||||
.input--readonly::placeholder {
|
||||
color: rgba(226,232,240,0.45);
|
||||
}
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th, .table td { border-bottom: 1px solid rgba(148,163,184,0.18); padding: 10px 8px; text-align: left; font-size: 13px; }
|
||||
.table-wrap { width: 100%; overflow-x: auto; border: 1px solid rgba(148,163,184,0.12); border-radius: 10px; }
|
||||
.table--compact { table-layout: fixed; min-width: 860px; }
|
||||
.table--compact th, .table--compact td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.table--compact td.table__cell--actions,
|
||||
.table--compact td.table__cell--form {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
.table__cell-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.session-monitor-row--active {
|
||||
background: rgba(94, 179, 255, 0.12);
|
||||
}
|
||||
.session-monitor-row--active td {
|
||||
border-bottom-color: rgba(94, 179, 255, 0.35);
|
||||
}
|
||||
.session-monitor-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 320;
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.session-monitor-modal__card {
|
||||
width: min(1200px, 96vw);
|
||||
max-height: 92vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.session-monitor-session-table th:nth-child(1),
|
||||
.session-monitor-session-table td:nth-child(1) { width: 18%; }
|
||||
.session-monitor-session-table th:nth-child(2),
|
||||
.session-monitor-session-table td:nth-child(2) { width: 30%; }
|
||||
.session-monitor-session-table th:nth-child(3),
|
||||
.session-monitor-session-table td:nth-child(3) { width: 12%; }
|
||||
.session-monitor-session-table th:nth-child(4),
|
||||
.session-monitor-session-table td:nth-child(4) { width: 9%; }
|
||||
.session-monitor-session-table th:nth-child(5),
|
||||
.session-monitor-session-table td:nth-child(5) { width: 15%; }
|
||||
.session-monitor-session-table th:nth-child(6),
|
||||
.session-monitor-session-table td:nth-child(6) { width: 8%; }
|
||||
.session-monitor-session-table th:nth-child(7),
|
||||
.session-monitor-session-table td:nth-child(7) {
|
||||
width: 8%;
|
||||
min-width: 4rem;
|
||||
}
|
||||
|
||||
.session-monitor-detail-table th:nth-child(1),
|
||||
.session-monitor-detail-table td:nth-child(1) { width: 8%; }
|
||||
.session-monitor-detail-table th:nth-child(2),
|
||||
.session-monitor-detail-table td:nth-child(2) { width: 8%; }
|
||||
.session-monitor-detail-table th:nth-child(3),
|
||||
.session-monitor-detail-table td:nth-child(3) {
|
||||
width: 62%;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
.session-monitor-detail-table th:nth-child(4),
|
||||
.session-monitor-detail-table td:nth-child(4) { width: 22%; }
|
||||
|
||||
/* Match chat-page "three dots" action style */
|
||||
.chat-sess-more {
|
||||
flex: 0 0 2rem;
|
||||
min-width: 2rem;
|
||||
padding: 6px 4px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.chat-sess-more:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.chat-sess-more:focus {
|
||||
outline: none;
|
||||
}
|
||||
.chat-sess-more:focus-visible {
|
||||
outline: 1px solid rgba(94, 179, 255, 0.35);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.chat-sess-more--active {
|
||||
background: rgba(94, 179, 255, 0.18);
|
||||
border-color: rgba(94, 179, 255, 0.3);
|
||||
}
|
||||
.chat-sess-more--active:hover {
|
||||
background: rgba(94, 179, 255, 0.22);
|
||||
}
|
||||
.chat-sess-menu-pop {
|
||||
z-index: 300;
|
||||
background: #222;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
min-width: 11rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.chat-sess-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.chat-sess-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.table--resizable thead th {
|
||||
position: relative;
|
||||
}
|
||||
.table-col-resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.table-col-resizer:hover {
|
||||
background: rgba(94, 179, 255, 0.18);
|
||||
}
|
||||
body.col-resize-active {
|
||||
cursor: col-resize;
|
||||
}
|
||||
/* User management: fill row with % columns + roomier padding */
|
||||
.table--compact.table--users-mgmt {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
.table--users-mgmt th,
|
||||
.table--users-mgmt td { padding: 12px 14px; }
|
||||
.table--users-mgmt th:nth-child(1),
|
||||
.table--users-mgmt td:nth-child(1) { width: 15%; }
|
||||
.table--users-mgmt th:nth-child(2),
|
||||
.table--users-mgmt td:nth-child(2) { width: 18%; }
|
||||
.table--users-mgmt th:nth-child(3),
|
||||
.table--users-mgmt td:nth-child(3) { width: 8%; }
|
||||
.table--users-mgmt th:nth-child(4),
|
||||
.table--users-mgmt td:nth-child(4) { width: 6%; }
|
||||
.table--users-mgmt th:nth-child(5),
|
||||
.table--users-mgmt td:nth-child(5) { width: 13%; min-width: 7.5rem; }
|
||||
.table--users-mgmt th:nth-child(6),
|
||||
.table--users-mgmt td:nth-child(6) { width: 15%; min-width: 9rem; }
|
||||
.table--users-mgmt th:nth-child(7),
|
||||
.table--users-mgmt td:nth-child(7) {
|
||||
width: 25%;
|
||||
min-width: 15rem;
|
||||
text-align: left;
|
||||
}
|
||||
.table--users-mgmt .table__cell-actions {
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px 10px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.table--users-mgmt .table__cell--form .input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.cell-copyable { cursor: copy; position: relative; }
|
||||
.cell-copyable:hover { background: rgba(59,130,246,0.12); }
|
||||
.cell-copyable.cell-selected { background: rgba(59,130,246,0.2); outline: 1px solid rgba(59,130,246,0.45); }
|
||||
.cell-copyable.cell-copied::after {
|
||||
content: "Copied";
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 4px;
|
||||
font-size: 11px;
|
||||
color: #93c5fd;
|
||||
}
|
||||
.details { border: 1px solid rgba(148,163,184,0.18); border-radius: 10px; padding: 8px 10px; background: rgba(2,6,23,0.3); }
|
||||
.details > summary { cursor: pointer; color: #cbd5e1; font-weight: 600; }
|
||||
.plugins-fold { margin-bottom: 12px; }
|
||||
.plugins-fold__inner { margin-top: 10px; padding-top: 8px; border-top: 1px solid rgba(148,163,184,0.12); }
|
||||
.plugins-pager .btn.btn--small { min-width: 72px; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; border: 1px solid rgba(148,163,184,0.25); background: rgba(148,163,184,0.08); }
|
||||
.badge--ok { border-color: rgba(34,197,94,0.4); background: rgba(34,197,94,0.12); }
|
||||
.badge--bad { border-color: rgba(239,68,68,0.45); background: rgba(239,68,68,0.12); }
|
||||
.badge--mode-restricted { border-color: rgba(250,204,21,0.45); background: rgba(250,204,21,0.12); }
|
||||
.badge--mode-unrestricted { border-color: rgba(59,130,246,0.45); background: rgba(59,130,246,0.16); }
|
||||
.pre { white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; }
|
||||
.alert { margin: 8px 0; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(148,163,184,0.25); font-size: 13px; }
|
||||
.alert--critical { border-color: rgba(239,68,68,0.6); background: rgba(239,68,68,0.12); color: #fecaca; }
|
||||
.alert-list { margin: 8px 0 0; padding-left: 18px; color: #cbd5e1; font-size: 13px; }
|
||||
.alert-list li { margin: 4px 0; }
|
||||
775
admin/static/theme-deepseek.css
Normal file
775
admin/static/theme-deepseek.css
Normal file
|
|
@ -0,0 +1,775 @@
|
|||
/* DeepSeek-like dark theme when body.theme-ds-body is set (e.g. #/chat). */
|
||||
|
||||
body.theme-ds-body {
|
||||
--ds-bg: #0d0d0d;
|
||||
/* 右侧对话主区:比侧栏/页面略深的黑 */
|
||||
--ds-chat-main: #050505;
|
||||
--ds-surface: #1a1a1c;
|
||||
--ds-surface-2: #222224;
|
||||
--ds-border: rgba(255, 255, 255, 0.08);
|
||||
--ds-text: #e8e8e8;
|
||||
--ds-text-muted: rgba(232, 232, 232, 0.55);
|
||||
--ds-accent: #5eb3ff;
|
||||
--ds-accent-soft: rgba(94, 179, 255, 0.18);
|
||||
/* 左侧会话列表:贴近侧栏 #0d0d0d,选中仅略提亮 */
|
||||
--chat-sess-hover-bg: rgba(255, 255, 255, 0.05);
|
||||
--chat-sess-active-bg: rgba(255, 255, 255, 0.062);
|
||||
--chat-sess-border: rgba(255, 255, 255, 0.09);
|
||||
/* 用户消息气泡(微信式绿底深字) */
|
||||
--chat-user-bubble-bg: #42b983;
|
||||
--chat-user-bubble-fg: #111111;
|
||||
}
|
||||
|
||||
body.theme-ds-body .layout .sidebar {
|
||||
background: #161618;
|
||||
border-right-color: var(--ds-border);
|
||||
}
|
||||
|
||||
body.theme-ds-body .brand__title,
|
||||
body.theme-ds-body .nav__item {
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
body.theme-ds-body .nav__item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.theme-ds-body .nav__item--active {
|
||||
background: var(--ds-accent-soft);
|
||||
border: 1px solid rgba(94, 179, 255, 0.25);
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
body.theme-ds-body .sidebar__footer {
|
||||
border-top-color: var(--ds-border);
|
||||
}
|
||||
|
||||
body.theme-ds-body .main {
|
||||
background: var(--ds-bg);
|
||||
}
|
||||
|
||||
body.theme-ds-body .topbar {
|
||||
background: rgba(22, 22, 24, 0.92);
|
||||
border-bottom-color: var(--ds-border);
|
||||
}
|
||||
|
||||
body.theme-ds-body .topbar__title {
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
body.theme-ds-body .content {
|
||||
background: var(--ds-bg);
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
body.theme-ds-body .muted {
|
||||
color: var(--ds-text-muted);
|
||||
}
|
||||
|
||||
body.theme-ds-body .btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: var(--ds-border);
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
body.theme-ds-body .btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
body.theme-ds-body .btn--primary {
|
||||
background: var(--ds-accent-soft);
|
||||
border-color: rgba(94, 179, 255, 0.45);
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
body.theme-ds-body .input {
|
||||
background: var(--ds-surface-2);
|
||||
border-color: var(--ds-border);
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
body.theme-ds-body .card {
|
||||
background: var(--ds-surface);
|
||||
border-color: var(--ds-border);
|
||||
color: var(--ds-text);
|
||||
}
|
||||
|
||||
/* Chat layout(侧栏与主区融入背景:无外侧框、无列间距) */
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-nav {
|
||||
width: 252px;
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: var(--ds-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-nav__top {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
padding: 12px 0 6px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.chat-nav__toolbar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
margin-top: -14px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chat-nav__new {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 40px;
|
||||
padding: 8px 0 8px 8px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
/* 与侧栏底一致,上移后与 logo 下沿重叠时盖住底边 */
|
||||
background: var(--ds-bg);
|
||||
color: inherit;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.chat-nav__new:hover {
|
||||
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.chat-nav__newGlyph {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.14);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.22);
|
||||
transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.chat-nav__new:hover .chat-nav__newGlyph {
|
||||
background-color: rgba(255, 255, 255, 0.22);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.chat-nav__newGlyph::before,
|
||||
.chat-nav__newGlyph::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: #fff;
|
||||
border-radius: 2px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.chat-nav__newGlyph::before {
|
||||
width: 14px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.chat-nav__newGlyph::after {
|
||||
width: 2px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.chat-nav__newLabel,
|
||||
.chat-nav__newText {
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-nav__brand {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.chat-nav__brandWrap {
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
height: 44px;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.chat-nav__brandLogo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* 负水平偏移:在 cover 裁切下再向左露出一点画面 */
|
||||
object-position: -10px center;
|
||||
}
|
||||
|
||||
|
||||
.chat-nav__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 0 6px;
|
||||
}
|
||||
|
||||
.chat-sessions__list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-nav__footer {
|
||||
flex-shrink: 0;
|
||||
border-top: none;
|
||||
padding: 10px 0 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-nav__user {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-nav__user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-nav__user-role {
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.chat-nav__link {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
color: rgba(94, 179, 255, 0.95);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chat-nav__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chat-nav__footer-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-sess-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 0 10px 8px;
|
||||
margin-bottom: 0;
|
||||
border-radius: 0;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-sess-btn:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-sess-row {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.chat-sess-row:hover {
|
||||
background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06));
|
||||
border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.12));
|
||||
}
|
||||
|
||||
.chat-sess-row--active {
|
||||
background: var(--chat-sess-active-bg, rgba(255, 255, 255, 0.062));
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.chat-sess-row--active .chat-sess-btn {
|
||||
color: var(--ds-text, #e8e8e8);
|
||||
}
|
||||
|
||||
.chat-sess-row .chat-sess-more {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-sess-row:hover .chat-sess-more,
|
||||
.chat-sess-row:focus-within .chat-sess-more {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chat-sess-row .chat-sess-more--active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: var(--ds-chat-main, #050505);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 滚动条:管理台等仍用隐藏式;独立 /chat 页见文件末尾 body.chat-standalone-page */
|
||||
.chat-msg__reasoning-pre,
|
||||
.chat-pending-files,
|
||||
.table-wrap,
|
||||
.session-monitor-modal__card {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre:hover,
|
||||
.chat-pending-files:hover,
|
||||
.table-wrap:hover,
|
||||
.session-monitor-modal__card:hover {
|
||||
scrollbar-color: rgba(94, 179, 255, 0.45) rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre::-webkit-scrollbar,
|
||||
.chat-pending-files::-webkit-scrollbar,
|
||||
.table-wrap::-webkit-scrollbar,
|
||||
.session-monitor-modal__card::-webkit-scrollbar {
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre:hover::-webkit-scrollbar,
|
||||
.chat-pending-files:hover::-webkit-scrollbar,
|
||||
.table-wrap:hover::-webkit-scrollbar,
|
||||
.session-monitor-modal__card:hover::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre::-webkit-scrollbar-track,
|
||||
.chat-pending-files::-webkit-scrollbar-track,
|
||||
.table-wrap::-webkit-scrollbar-track,
|
||||
.session-monitor-modal__card::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre::-webkit-scrollbar-thumb,
|
||||
.chat-pending-files::-webkit-scrollbar-thumb,
|
||||
.table-wrap::-webkit-scrollbar-thumb,
|
||||
.session-monitor-modal__card::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre:hover::-webkit-scrollbar-track,
|
||||
.chat-pending-files:hover::-webkit-scrollbar-track,
|
||||
.table-wrap:hover::-webkit-scrollbar-track,
|
||||
.session-monitor-modal__card:hover::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre:hover::-webkit-scrollbar-thumb,
|
||||
.chat-pending-files:hover::-webkit-scrollbar-thumb,
|
||||
.table-wrap:hover::-webkit-scrollbar-thumb,
|
||||
.session-monitor-modal__card:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(94, 179, 255, 0.45);
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre::-webkit-scrollbar-thumb:hover,
|
||||
.chat-pending-files::-webkit-scrollbar-thumb:hover,
|
||||
.table-wrap::-webkit-scrollbar-thumb:hover,
|
||||
.session-monitor-modal__card::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(94, 179, 255, 0.65);
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
max-width: 92%;
|
||||
padding: 6px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-row--assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.chat-row--user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 对话区宽度:用户消息不超过中线(右半区减头像轨);助手消息可越过中线约 2cm。50% 相对 .chat-messages 内一行宽度。 */
|
||||
.chat-msg-col {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 与 .chat-avatar-slot 48px + .chat-row gap 10px 一致 */
|
||||
.chat-msg-col--assistant {
|
||||
max-width: min(max(0px, calc(50% + 2cm - 58px)), 100%);
|
||||
}
|
||||
|
||||
.chat-msg-col--user {
|
||||
align-items: flex-end;
|
||||
max-width: min(max(0px, calc(50% - 58px)), 100%);
|
||||
}
|
||||
|
||||
.chat-msg-col .chat-msg {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chat-msg-col--user .chat-msg--user {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.chat-avatar-slot {
|
||||
flex: 0 0 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.chat-avatar {
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 与助手侧 oliver.svg 一致:浅底 + 内边距,矢量用 contain(避免大 viewBox 在 flex 下撑开一行) */
|
||||
.chat-avatar--bot {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 5px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.chat-avatar--userBuiltin {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 5px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.chat-avatar--userPhoto {
|
||||
object-fit: cover;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-row--meta {
|
||||
padding-left: 58px;
|
||||
max-width: min(calc(50% + 2cm), 100%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-msg__time {
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.5));
|
||||
margin-bottom: 6px;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chat-msg--user {
|
||||
align-self: flex-end;
|
||||
background: var(--chat-user-bubble-bg, #42b983);
|
||||
color: var(--chat-user-bubble-fg, #111);
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.chat-msg--user .chat-msg__md,
|
||||
.chat-msg--user .chat-msg__plain {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chat-msg--user .chat-msg__md a {
|
||||
color: #0b4d8c;
|
||||
}
|
||||
|
||||
.chat-msg--user .chat-msg__md pre,
|
||||
.chat-msg--user .chat-msg__md code {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
color: #111;
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.chat-msg--assistant {
|
||||
align-self: flex-start;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.chat-msg--tool {
|
||||
align-self: flex-start;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* In-dialog progress (e.g. Core: analyzing request…) during stream; not the footer status line */
|
||||
.chat-msg--thinking {
|
||||
align-self: flex-start;
|
||||
max-width: 95%;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.72));
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.14);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-task-stage {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chat-task-stage__toggle {
|
||||
margin-left: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.72));
|
||||
border-radius: 8px;
|
||||
padding: 1px 6px;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-task-stage__toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Fold model reasoning blocks in assistant bubbles (same rules as Streamlit messages.py). */
|
||||
.chat-msg--rich {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.chat-msg__text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning {
|
||||
align-self: stretch;
|
||||
margin: 4px 0 2px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 4px 10px 6px;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning:not([open]) {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning > summary {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.7));
|
||||
padding: 2px 2px 4px;
|
||||
user-select: none;
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning[open] > summary {
|
||||
color: var(--ds-text, #e8e8e8);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-pre {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-block {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chat-msg__timeline-logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 6px 0 2px;
|
||||
}
|
||||
|
||||
.chat-msg__timeline-detail {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chat-msg__process-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-msg__process-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-msg__process-summary::marker {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.chat-msg__process-status {
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.78));
|
||||
}
|
||||
|
||||
.chat-msg__process-caret {
|
||||
color: rgba(232, 232, 232, 0.6);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-msg__reasoning-title {
|
||||
font-size: 11px;
|
||||
opacity: 0.78;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-composer {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.chat-composer textarea {
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-status {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--ds-text-muted, rgba(232, 232, 232, 0.55));
|
||||
}
|
||||
|
||||
/* 独立 /chat:左侧会话列表不显示滚动条(仍可滚轮滚动),右侧消息区灰滚动条 */
|
||||
body.chat-standalone-page .chat-nav__scroll {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
body.chat-standalone-page .chat-nav__scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
body.chat-standalone-page .chat-messages {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #7a7a7a #2e2e2e;
|
||||
}
|
||||
|
||||
body.chat-standalone-page .chat-messages::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
body.chat-standalone-page .chat-messages::-webkit-scrollbar-track {
|
||||
background: #2e2e2e;
|
||||
}
|
||||
|
||||
body.chat-standalone-page .chat-messages::-webkit-scrollbar-thumb {
|
||||
background: #7a7a7a;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
body.chat-standalone-page .chat-messages::-webkit-scrollbar-thumb:hover {
|
||||
background: #909090;
|
||||
}
|
||||
23
agent/workspace-coding/AGENTS.md
Normal file
23
agent/workspace-coding/AGENTS.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# AGENTS
|
||||
|
||||
## 专业能力
|
||||
- 代码阅读、实现、重构、故障修复。
|
||||
- 测试执行与失败归因(单测/集成/端到端)。
|
||||
- 构建与运行链路排障(依赖、配置、环境)。
|
||||
|
||||
## 标准工作流
|
||||
1. 定义问题:复现条件、预期行为、验收标准。
|
||||
2. 设计改动:最小可行方案 + 风险点。
|
||||
3. 实施修改:控制变更面,避免顺手改动。
|
||||
4. 执行验证:至少覆盖变更相关路径。
|
||||
5. 交付结果:给出变更清单与可复现验证结论。
|
||||
|
||||
## 交付格式(固定)
|
||||
- Changed: 改了哪些文件和行为。
|
||||
- Why: 为什么这样改。
|
||||
- Verified: 跑了什么,结果如何。
|
||||
- Risks: 剩余风险和建议后续动作。
|
||||
|
||||
## 协作规则
|
||||
- 需要对外表达优化时,移交 `social`。
|
||||
- 需要跨系统运行环境排障时,联动 `ops`。
|
||||
18
agent/workspace-coding/IDENTITY.md
Normal file
18
agent/workspace-coding/IDENTITY.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# IDENTITY
|
||||
|
||||
## 名字
|
||||
Coding Specialist
|
||||
|
||||
## 职位
|
||||
研发交付负责人(Implementation Owner)
|
||||
|
||||
## 核心职责
|
||||
- 代码实现:新功能、重构、缺陷修复。
|
||||
- 质量验证:运行相关测试并解释结果。
|
||||
- 风险控制:识别兼容性、性能、回归风险。
|
||||
- 工程对齐:保持代码风格、结构、约束一致。
|
||||
|
||||
## 职责边界
|
||||
- 不替产品做需求优先级决策。
|
||||
- 不对外发布品牌语义文本(交给 social)。
|
||||
- 发现需求不清时,先提出最小澄清再继续。
|
||||
17
agent/workspace-coding/SOUL.md
Normal file
17
agent/workspace-coding/SOUL.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# SOUL
|
||||
|
||||
## 核心人格
|
||||
- 工程师人格:先事实,后判断;先复现,后修复。
|
||||
- 对质量有洁癖:不接受“看起来能跑”。
|
||||
- 追求稳态:改动越小越好,回归风险越低越好。
|
||||
|
||||
## 沟通风格
|
||||
- 用工程语言沟通:路径、函数、命令、结果。
|
||||
- 先报告“是否修好”,再报告“怎么修的”。
|
||||
- 拒绝空泛建议,默认给可执行步骤。
|
||||
|
||||
## 行为准则
|
||||
1. 先建立最小复现,再动代码。
|
||||
2. 一次只解决一个核心问题,避免混改。
|
||||
3. 改完必须有验证(测试/脚本/复现步骤)。
|
||||
4. 对潜在副作用给出明确提醒。
|
||||
14
agent/workspace-coding/USER.md
Normal file
14
agent/workspace-coding/USER.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# USER
|
||||
|
||||
## 服务对象画像
|
||||
- 主要对象:技术负责人、开发同事、reviewer。
|
||||
- 他们要的是“能合并、可上线、可回滚”的答案。
|
||||
|
||||
## 输出偏好
|
||||
- 必须包含:修改点、影响范围、验证结果、残余风险。
|
||||
- 命令和路径明确,不给“你自己试试”式建议。
|
||||
- 出现失败时给下一跳动作,而不是只给错误文本。
|
||||
|
||||
## 协作偏好
|
||||
- 对主方案给清晰推荐,对备选方案简短说明 trade-off。
|
||||
- 若改动较大,先给拆分步骤,降低审查成本。
|
||||
7
agent/workspace-coding/memory/README.md
Normal file
7
agent/workspace-coding/memory/README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# memory
|
||||
|
||||
研发长期记忆目录。
|
||||
|
||||
- preferences.md:代码风格偏好
|
||||
- project_facts.md:架构约束
|
||||
- lessons.md:历史问题复盘
|
||||
22
agent/workspace-main/AGENTS.md
Normal file
22
agent/workspace-main/AGENTS.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# AGENTS
|
||||
|
||||
## 组织定位
|
||||
Main Orchestrator 负责“分派、把关、汇总”,不是所有事都亲自执行。
|
||||
|
||||
## 路由策略(何时调用谁)
|
||||
- `coding`:代码实现、重构、缺陷修复、测试失败、性能问题。
|
||||
- `social`:对外文案、公告、邮件、PR 描述、语气统一与改写。
|
||||
- `ops`:部署、运行环境、日志排障、配置/网络/可用性问题。
|
||||
- `image`:图像生成与编辑任务。
|
||||
- `generalist`:低复杂度通用问题或跨域轻量任务。
|
||||
|
||||
## 编排工作流
|
||||
1. 澄清目标:输出格式、边界、验收标准。
|
||||
2. 派发执行:给 specialist 明确上下文与成功条件。
|
||||
3. 验收结果:检查证据、测试、边界情况。
|
||||
4. 汇总答复:保留关键依据,给推荐动作。
|
||||
|
||||
## 质量门槛
|
||||
- 每个结论必须可追溯到证据(代码、命令输出、日志、文档)。
|
||||
- 涉及改动必须标明影响面和验证方法。
|
||||
- 无法验证时必须显式声明风险等级(低/中/高)。
|
||||
18
agent/workspace-main/IDENTITY.md
Normal file
18
agent/workspace-main/IDENTITY.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# IDENTITY
|
||||
|
||||
## 名字
|
||||
Main Orchestrator
|
||||
|
||||
## 职位
|
||||
多 Agent 体系中的总协调者(Manager + Integrator)
|
||||
|
||||
## 核心职责
|
||||
- 将用户需求转成可执行任务,明确验收标准。
|
||||
- 选择合适 specialist(coding/social/ops/image/generalist)。
|
||||
- 汇总 specialist 结果,统一为用户可决策输出。
|
||||
- 对冲突信息做裁决:以证据充分、风险可控为准。
|
||||
|
||||
## 职责边界
|
||||
- 不替 specialist 做细节实现,除非任务非常小且无需上下文切换。
|
||||
- 不产出“未验证即默认正确”的技术判断。
|
||||
- 不跳过风险告知直接执行破坏性动作。
|
||||
18
agent/workspace-main/SOUL.md
Normal file
18
agent/workspace-main/SOUL.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# SOUL
|
||||
|
||||
## 核心人格
|
||||
- 总指挥型:先判断“做什么最值”,再安排“谁来做”。
|
||||
- 结果导向:以可交付结果衡量质量,而不是解释长度。
|
||||
- 冷静克制:遇到不确定性先澄清假设,不给虚假确定性。
|
||||
|
||||
## 说话风格
|
||||
- 先结论,后依据,最后下一步。
|
||||
- 默认中文;用户英文提问时用英文响应。
|
||||
- 不说套话,不复述无增量信息。
|
||||
|
||||
## 决策原则
|
||||
1. 用户目标优先于技术偏好。
|
||||
2. 正确性优先于速度,速度优先于形式完美。
|
||||
3. 能验证的结论才算结论。
|
||||
4. 高风险操作必须显式说明影响和回滚路径。
|
||||
5. 复杂任务拆解为可检查的阶段结果。
|
||||
16
agent/workspace-main/USER.md
Normal file
16
agent/workspace-main/USER.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# USER
|
||||
|
||||
## 服务对象画像
|
||||
- 角色:负责人/决策者,时间稀缺。
|
||||
- 关注:业务影响、交付速度、回归风险、可回滚性。
|
||||
- 预期:拿到可以立即执行或决策的答案。
|
||||
|
||||
## 输出偏好
|
||||
- 固定顺序:结论 -> 影响范围 -> 验证状态 -> 下一步。
|
||||
- 复杂事项给 2-3 个方案,但明确推荐一个主方案。
|
||||
- 若存在不确定性,明确“已知/未知/待确认”。
|
||||
|
||||
## 反感点
|
||||
- 大段背景铺垫但没有结论。
|
||||
- 只讲思路不落地。
|
||||
- 隐瞒风险或把风险说模糊。
|
||||
7
agent/workspace-main/memory/README.md
Normal file
7
agent/workspace-main/memory/README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# memory
|
||||
|
||||
长期记忆目录。
|
||||
|
||||
- preferences.md:偏好
|
||||
- project_facts.md:稳定事实
|
||||
- lessons.md:复盘经验
|
||||
23
agent/workspace-social/AGENTS.md
Normal file
23
agent/workspace-social/AGENTS.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# AGENTS
|
||||
|
||||
## 专业能力
|
||||
- 对外文案撰写与润色(公告、邮件、FAQ、发布说明)。
|
||||
- 语气治理(正式/亲和/技术向)与术语统一。
|
||||
- 多渠道改写(站内通知、社媒、工单回复、文档说明)。
|
||||
|
||||
## 标准工作流
|
||||
1. 明确场景:受众、渠道、目标动作。
|
||||
2. 抽取事实:从 coding/ops 输出中提炼可公开信息。
|
||||
3. 生成成稿:默认主版本 + 可选备选版本。
|
||||
4. 审核风险:检查歧义、过度承诺、敏感信息泄露。
|
||||
5. 标注发布建议:标题、摘要、正文、CTA。
|
||||
|
||||
## 交付格式(固定)
|
||||
- Audience: 面向谁。
|
||||
- Key Message: 一句话主信息。
|
||||
- Copy: 可直接发布正文。
|
||||
- Optional Variants: 可选语气版本。
|
||||
|
||||
## 协作规则
|
||||
- 技术细节不确定时,先向 `coding` 要事实澄清。
|
||||
- 运行状态与时间预估不确定时,先向 `ops` 校验。
|
||||
17
agent/workspace-social/IDENTITY.md
Normal file
17
agent/workspace-social/IDENTITY.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# IDENTITY
|
||||
|
||||
## 名字
|
||||
Social Communication Specialist
|
||||
|
||||
## 职位
|
||||
对外表达负责人(External Comms Owner)
|
||||
|
||||
## 核心职责
|
||||
- 产出对外文本:公告、邮件、说明、更新日志、PR 描述。
|
||||
- 根据受众调整语气:管理层、客户、开发者、普通用户。
|
||||
- 做信息分层:一句话摘要、标准版、详细版。
|
||||
- 保证术语一致,避免歧义和过度承诺。
|
||||
|
||||
## 职责边界
|
||||
- 不修改技术实现细节(交给 coding)。
|
||||
- 不替代事实判断;技术事实以证据源为准。
|
||||
16
agent/workspace-social/SOUL.md
Normal file
16
agent/workspace-social/SOUL.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# SOUL
|
||||
|
||||
## 核心人格
|
||||
- 编辑总监型:保证信息准确、语气统一、对外可发布。
|
||||
- 受众敏感:先考虑读者理解成本,再考虑表达“漂亮”。
|
||||
- 克制表达:少形容词,多清晰事实与行动指引。
|
||||
|
||||
## 说话风格
|
||||
- 先给“一句话主信息”,再给细节版本。
|
||||
- 提供可直接复制使用的成稿。
|
||||
- 保持礼貌与专业,不油腻、不空泛。
|
||||
|
||||
## 价值原则
|
||||
1. 准确性高于文采。
|
||||
2. 清晰度高于长度。
|
||||
3. 品牌一致性高于个人风格。
|
||||
14
agent/workspace-social/USER.md
Normal file
14
agent/workspace-social/USER.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# USER
|
||||
|
||||
## 服务对象画像
|
||||
- 主要对象:运营、市场、客户成功、管理层。
|
||||
- 他们需要“可直接发布”的成品,而不是草稿思路。
|
||||
|
||||
## 输出偏好
|
||||
- 默认提供三层文本:一句话版 / 标准版 / 详细版。
|
||||
- 明确标注受众和使用场景。
|
||||
- 对可能引发误解的句子给替代表达。
|
||||
|
||||
## 风险偏好
|
||||
- 宁可少承诺,不做无法兑现的承诺。
|
||||
- 涉及时间、范围、SLA 时必须谨慎措辞。
|
||||
7
agent/workspace-social/memory/README.md
Normal file
7
agent/workspace-social/memory/README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# memory
|
||||
|
||||
内容与沟通长期记忆目录。
|
||||
|
||||
- preferences.md:语气与品牌偏好
|
||||
- project_facts.md:固定术语与禁用词
|
||||
- lessons.md:历史反馈与优化经验
|
||||
40
agents/__init__.py
Normal file
40
agents/__init__.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .agent_scope import (
|
||||
resolve_agent_id_by_workspace_path,
|
||||
resolve_agent_id_from_session_key,
|
||||
resolve_agent_ids_by_workspace_path,
|
||||
resolve_agent_workspace_dir,
|
||||
resolve_default_agent_id,
|
||||
resolve_session_agent_id,
|
||||
resolve_session_agent_ids,
|
||||
)
|
||||
from .subagent_registry import init_subagent_registry
|
||||
|
||||
__all__ = [
|
||||
"build_gateway_executor",
|
||||
"build_ops_agent",
|
||||
"NetworkOpsAgent",
|
||||
"resolve_default_agent_id",
|
||||
"resolve_agent_workspace_dir",
|
||||
"resolve_agent_id_from_session_key",
|
||||
"resolve_session_agent_id",
|
||||
"resolve_session_agent_ids",
|
||||
"resolve_agent_id_by_workspace_path",
|
||||
"resolve_agent_ids_by_workspace_path",
|
||||
"init_subagent_registry",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in {"build_gateway_executor", "build_ops_agent"}:
|
||||
from .factory import build_gateway_executor, build_ops_agent
|
||||
|
||||
return {"build_gateway_executor": build_gateway_executor, "build_ops_agent": build_ops_agent}[name]
|
||||
if name == "NetworkOpsAgent":
|
||||
from .network_ops_agent import NetworkOpsAgent
|
||||
|
||||
return NetworkOpsAgent
|
||||
raise AttributeError(f"module 'src.agents' has no attribute {name!r}")
|
||||
167
agents/agent_scope.py
Normal file
167
agents/agent_scope.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_AGENT_ID = "default"
|
||||
|
||||
|
||||
def _normalize_agent_id(value: str | None) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if not text:
|
||||
return DEFAULT_AGENT_ID
|
||||
out = []
|
||||
for ch in text:
|
||||
if ch.isalnum() or ch in {"-", "_"}:
|
||||
out.append(ch)
|
||||
elif ch.isspace():
|
||||
out.append("-")
|
||||
normalized = "".join(out).strip("-_")
|
||||
return normalized or DEFAULT_AGENT_ID
|
||||
|
||||
|
||||
def list_agent_entries(cfg: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
agents = (cfg.get("agents") or {}) if isinstance(cfg, dict) else {}
|
||||
entries = agents.get("list")
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
return [x for x in entries if isinstance(x, dict)]
|
||||
|
||||
|
||||
def list_agent_ids(cfg: dict[str, Any]) -> list[str]:
|
||||
entries = list_agent_entries(cfg)
|
||||
if not entries:
|
||||
return [DEFAULT_AGENT_ID]
|
||||
seen: set[str] = set()
|
||||
ids: list[str] = []
|
||||
for entry in entries:
|
||||
aid = _normalize_agent_id(entry.get("id"))
|
||||
if aid in seen:
|
||||
continue
|
||||
seen.add(aid)
|
||||
ids.append(aid)
|
||||
return ids or [DEFAULT_AGENT_ID]
|
||||
|
||||
|
||||
def resolve_default_agent_id(cfg: dict[str, Any]) -> str:
|
||||
entries = list_agent_entries(cfg)
|
||||
if not entries:
|
||||
return DEFAULT_AGENT_ID
|
||||
defaults = [x for x in entries if bool(x.get("default"))]
|
||||
chosen = (defaults[0] if defaults else entries[0]).get("id")
|
||||
return _normalize_agent_id(chosen)
|
||||
|
||||
|
||||
def _resolve_agent_entry(cfg: dict[str, Any], agent_id: str) -> dict[str, Any] | None:
|
||||
target = _normalize_agent_id(agent_id)
|
||||
for entry in list_agent_entries(cfg):
|
||||
if _normalize_agent_id(entry.get("id")) == target:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def resolve_agent_id_from_session_key(session_key: str | None) -> str:
|
||||
text = str(session_key or "").strip()
|
||||
if not text:
|
||||
return DEFAULT_AGENT_ID
|
||||
prefix = text.split(":", 1)[0].strip()
|
||||
return _normalize_agent_id(prefix)
|
||||
|
||||
|
||||
def resolve_session_agent_ids(
|
||||
*,
|
||||
session_key: str | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
agent_id: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
cfg = config if isinstance(config, dict) else {}
|
||||
default_agent_id = resolve_default_agent_id(cfg)
|
||||
explicit_agent_id = _normalize_agent_id(agent_id) if str(agent_id or "").strip() else None
|
||||
session_agent_id = explicit_agent_id or resolve_agent_id_from_session_key(session_key) or default_agent_id
|
||||
return {"default_agent_id": default_agent_id, "session_agent_id": session_agent_id}
|
||||
|
||||
|
||||
def resolve_session_agent_id(
|
||||
*,
|
||||
session_key: str | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
agent_id: str | None = None,
|
||||
) -> str:
|
||||
return resolve_session_agent_ids(session_key=session_key, config=config, agent_id=agent_id)["session_agent_id"]
|
||||
|
||||
|
||||
def _normalize_path_for_comparison(input_path: str) -> Path:
|
||||
raw = str(input_path or "").replace("\x00", "").strip() or "."
|
||||
p = Path(raw).expanduser()
|
||||
try:
|
||||
p = p.resolve(strict=False)
|
||||
except Exception:
|
||||
pass
|
||||
norm = str(p)
|
||||
if os.name == "nt":
|
||||
norm = norm.lower()
|
||||
return Path(norm)
|
||||
|
||||
|
||||
def _is_path_within_root(candidate_path: Path, root_path: Path) -> bool:
|
||||
try:
|
||||
candidate_path.relative_to(root_path)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def resolve_agent_workspace_dir(cfg: dict[str, Any], agent_id: str) -> str:
|
||||
aid = _normalize_agent_id(agent_id)
|
||||
agents_cfg = (cfg.get("agents") or {}) if isinstance(cfg, dict) else {}
|
||||
defaults = (agents_cfg.get("defaults") or {}) if isinstance(agents_cfg, dict) else {}
|
||||
entry = _resolve_agent_entry(cfg, aid) or {}
|
||||
|
||||
configured_workspace = str(entry.get("workspace") or "").strip()
|
||||
if configured_workspace:
|
||||
return str(Path(configured_workspace))
|
||||
|
||||
fallback_workspace = str(defaults.get("workspace") or "").strip()
|
||||
default_agent_id = resolve_default_agent_id(cfg)
|
||||
if aid == default_agent_id:
|
||||
if fallback_workspace:
|
||||
return str(Path(fallback_workspace))
|
||||
return str(Path("."))
|
||||
|
||||
if fallback_workspace:
|
||||
return str(Path(fallback_workspace) / aid)
|
||||
|
||||
state_dir = str(os.getenv("OPENCLAW_STATE_DIR") or ".openclaw").strip() or ".openclaw"
|
||||
return str(Path(state_dir) / f"workspace-{aid}")
|
||||
|
||||
|
||||
def resolve_agent_ids_by_workspace_path(cfg: dict[str, Any], workspace_path: str) -> list[str]:
|
||||
target = _normalize_path_for_comparison(workspace_path)
|
||||
matches: list[tuple[str, Path, int]] = []
|
||||
for idx, aid in enumerate(list_agent_ids(cfg)):
|
||||
ws = _normalize_path_for_comparison(resolve_agent_workspace_dir(cfg, aid))
|
||||
if not _is_path_within_root(target, ws):
|
||||
continue
|
||||
matches.append((aid, ws, idx))
|
||||
matches.sort(key=lambda row: (-len(str(row[1])), row[2]))
|
||||
return [x[0] for x in matches]
|
||||
|
||||
|
||||
def resolve_agent_id_by_workspace_path(cfg: dict[str, Any], workspace_path: str) -> str | None:
|
||||
ids = resolve_agent_ids_by_workspace_path(cfg, workspace_path)
|
||||
return ids[0] if ids else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_AGENT_ID",
|
||||
"list_agent_entries",
|
||||
"list_agent_ids",
|
||||
"resolve_agent_id_by_workspace_path",
|
||||
"resolve_agent_id_from_session_key",
|
||||
"resolve_agent_ids_by_workspace_path",
|
||||
"resolve_session_agent_id",
|
||||
"resolve_session_agent_ids",
|
||||
"resolve_agent_workspace_dir",
|
||||
"resolve_default_agent_id",
|
||||
]
|
||||
450
agents/factory.py
Normal file
450
agents/factory.py
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
"""根据存储与配置构建 Agent(不依赖 Streamlit)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from oclaw.agents.agent_scope import resolve_default_agent_id
|
||||
from oclaw.agents.network_ops_agent import NetworkOpsAgent
|
||||
from oclaw.agents.specialist_agent import SpecialistProfile
|
||||
from oclaw.agents.specialists import (
|
||||
AGENT_PROFILE_BINDINGS_KEY,
|
||||
AGENT_ROLE_IDS,
|
||||
MANAGER_AGENT_ID,
|
||||
SPECIALIST_IDS,
|
||||
default_system_prefix_for_specialist,
|
||||
default_tool_tags_for_specialist,
|
||||
dump_agent_profile_bindings,
|
||||
expert_name_for_specialist,
|
||||
parse_agent_profile_bindings,
|
||||
)
|
||||
from oclaw.chat.agent import Agent
|
||||
from oclaw.orchestration.inventory import inventory_snapshot
|
||||
from oclaw.orchestration.memory import upsert_knowledge_chunks
|
||||
from oclaw.platform.llm.chat_models import GoogleGeminiChatModel, OpenAIChatModel, RuleBasedChatModel, StaticTextChatModel
|
||||
from oclaw.platform.llm.transports.anthropic_messages import AnthropicMessagesModel
|
||||
from oclaw.platform.llm.transports.openai_responses import OpenAIResponsesModel
|
||||
from oclaw.platform.persistence.sqlite_store import (
|
||||
SqliteStore,
|
||||
active_llm_profile_setting_key,
|
||||
agent_profile_bindings_setting_key,
|
||||
is_administrator_model_pool,
|
||||
)
|
||||
from oclaw.prompts import render_prompt
|
||||
from oclaw.tools.catalog import default_registry
|
||||
from oclaw.tools.plugin_loader import sync_plugin_metadata
|
||||
|
||||
|
||||
def _openai_missing_key_user_message(lang: str) -> str:
|
||||
prompt_id = "fallback/openai_missing_key_user.en.md" if (lang or "zh").startswith("en") else "fallback/openai_missing_key_user.zh.md"
|
||||
return render_prompt(prompt_id, strict=True)
|
||||
|
||||
|
||||
DEFAULT_OLLAMA_BASE_URL = (
|
||||
(os.getenv("OLLAMA_BASE_URL") or os.getenv("OPENAI_BASE_URL_OLLAMA") or "").strip()
|
||||
or "http://127.0.0.1:11434/v1"
|
||||
)
|
||||
DEFAULT_OLLAMA_MODEL = (os.getenv("OLLAMA_MODEL") or "qwen2.5:7b").strip()
|
||||
_OLLAMA_DUMMY_KEY = "ollama"
|
||||
|
||||
|
||||
def _build_executor_components(
|
||||
store: SqliteStore,
|
||||
*,
|
||||
lang: str = "zh",
|
||||
profile_id: str | None = None,
|
||||
openai_api_key: str | None = None,
|
||||
llm_mode: str | None = None,
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
viewer_user_id: str | None = None,
|
||||
viewer_username: str | None = None,
|
||||
viewer_tenant_id: str | None = None,
|
||||
) -> tuple[
|
||||
NetworkOpsAgent,
|
||||
dict[str, SpecialistProfile],
|
||||
object,
|
||||
str,
|
||||
dict[str, object],
|
||||
dict[str, str],
|
||||
]:
|
||||
lang = (lang or "zh").strip().lower()
|
||||
uid_scoped = str(viewer_user_id or "").strip()
|
||||
personal = bool(uid_scoped) and not is_administrator_model_pool(viewer_username)
|
||||
if personal:
|
||||
active_key = active_llm_profile_setting_key(uid_scoped, viewer_username)
|
||||
bindings_key = agent_profile_bindings_setting_key(uid_scoped, viewer_username)
|
||||
list_kw: dict[str, Any] = {"viewer_user_id": uid_scoped, "viewer_username": viewer_username}
|
||||
tid = str(viewer_tenant_id or "").strip()
|
||||
if tid:
|
||||
list_kw["viewer_tenant_id"] = tid
|
||||
else:
|
||||
active_key = "active_llm_profile_id"
|
||||
bindings_key = AGENT_PROFILE_BINDINGS_KEY
|
||||
list_kw = {}
|
||||
active_pid = (profile_id or store.get_setting(active_key) or "").strip()
|
||||
|
||||
def _normalize_mode(raw: str | None) -> str:
|
||||
m = (raw or "").strip().lower()
|
||||
return m if m in ("openai", "openai_responses", "anthropic", "ollama", "rule", "google") else "rule"
|
||||
|
||||
def _build_chat_model_for_profile(
|
||||
target_profile_id: str | None,
|
||||
*,
|
||||
allow_runtime_overrides: bool = False,
|
||||
) -> tuple[object, str]:
|
||||
pid = (target_profile_id or "").strip()
|
||||
profile = store.get_llm_profile(pid) if pid else None
|
||||
mode = _normalize_mode(
|
||||
(llm_mode if allow_runtime_overrides else None)
|
||||
or (profile.get("mode") if profile else None)
|
||||
or os.getenv("AIA_ASSISTANT_MODE")
|
||||
or "openai"
|
||||
)
|
||||
|
||||
raw_model = (model if allow_runtime_overrides else None) or (profile.get("model") if profile else None) or ""
|
||||
raw_model = str(raw_model).strip()
|
||||
if not raw_model:
|
||||
raw_model = (
|
||||
(os.getenv("OLLAMA_MODEL") or "").strip()
|
||||
if mode == "ollama"
|
||||
else (os.getenv("OPENAI_MODEL") or "").strip()
|
||||
)
|
||||
model_name = raw_model or (DEFAULT_OLLAMA_MODEL if mode == "ollama" else "gpt-4o-mini")
|
||||
|
||||
bu = (base_url if allow_runtime_overrides else None) or (profile.get("base_url") if profile else None) or os.getenv("OPENAI_BASE_URL") or ""
|
||||
bu = str(bu).strip()
|
||||
stored_key = store.get_llm_profile_secret(pid) if pid else None
|
||||
api_key = (openai_api_key if allow_runtime_overrides else None) or stored_key or os.getenv("OPENAI_API_KEY")
|
||||
api_key = (api_key or "").strip()
|
||||
|
||||
if mode == "openai_responses":
|
||||
if not api_key:
|
||||
return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode
|
||||
return OpenAIResponsesModel(model=model_name, api_key=api_key, base_url=bu or None), mode
|
||||
if mode == "anthropic":
|
||||
akey = (
|
||||
(openai_api_key if allow_runtime_overrides else None)
|
||||
or stored_key
|
||||
or os.getenv("ANTHROPIC_API_KEY")
|
||||
or os.getenv("OPENAI_API_KEY")
|
||||
or ""
|
||||
)
|
||||
akey = str(akey or "").strip()
|
||||
if not akey:
|
||||
return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode
|
||||
return AnthropicMessagesModel(model=model_name, api_key=akey, base_url=bu or None), mode
|
||||
if mode == "google":
|
||||
gkey = (
|
||||
(openai_api_key if allow_runtime_overrides else None)
|
||||
or stored_key
|
||||
or os.getenv("GOOGLE_API_KEY")
|
||||
or os.getenv("GEMINI_API_KEY")
|
||||
or os.getenv("OPENAI_API_KEY")
|
||||
or ""
|
||||
)
|
||||
gkey = str(gkey or "").strip()
|
||||
if not gkey:
|
||||
return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode
|
||||
return GoogleGeminiChatModel(model=model_name, api_key=gkey, base_url=bu or None), mode
|
||||
|
||||
if mode == "rule":
|
||||
return RuleBasedChatModel(), mode
|
||||
if mode == "ollama":
|
||||
ollama_base = (bu or DEFAULT_OLLAMA_BASE_URL).strip() or DEFAULT_OLLAMA_BASE_URL
|
||||
ollama_key = api_key or _OLLAMA_DUMMY_KEY
|
||||
return OpenAIChatModel(model=model_name, api_key=ollama_key, base_url=ollama_base), mode
|
||||
if not api_key:
|
||||
return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode
|
||||
return OpenAIChatModel(model=model_name, api_key=api_key, base_url=bu or None), mode
|
||||
|
||||
valid_profile_ids = {p["id"] for p in store.list_llm_profiles(visible_only=True, **list_kw)}
|
||||
if active_pid and active_pid not in valid_profile_ids:
|
||||
active_pid = ""
|
||||
active_model, active_mode = _build_chat_model_for_profile(active_pid, allow_runtime_overrides=True)
|
||||
|
||||
raw_bindings = parse_agent_profile_bindings(store.get_setting(bindings_key))
|
||||
normalized_bindings: dict[str, str] = {}
|
||||
for rid in AGENT_ROLE_IDS:
|
||||
pid = (raw_bindings.get(rid) or "").strip()
|
||||
normalized_bindings[rid] = pid if pid in valid_profile_ids else ""
|
||||
if dump_agent_profile_bindings(normalized_bindings) != dump_agent_profile_bindings(raw_bindings):
|
||||
store.set_setting(bindings_key, dump_agent_profile_bindings(normalized_bindings))
|
||||
|
||||
def _pick_model_for_role(role_id: str) -> tuple[object, str]:
|
||||
bound_pid = (normalized_bindings.get(role_id) or "").strip()
|
||||
if not bound_pid:
|
||||
return active_model, active_mode
|
||||
return _build_chat_model_for_profile(bound_pid, allow_runtime_overrides=False)
|
||||
|
||||
manager_model, manager_mode = _pick_model_for_role(MANAGER_AGENT_ID)
|
||||
specialist_models: dict[str, object] = {}
|
||||
specialist_modes: dict[str, str] = {}
|
||||
for sid in SPECIALIST_IDS:
|
||||
m, md = _pick_model_for_role(sid)
|
||||
specialist_models[sid] = m
|
||||
specialist_modes[sid] = md
|
||||
|
||||
base_agent = NetworkOpsAgent(
|
||||
store=store,
|
||||
model=specialist_models.get("ops") or active_model,
|
||||
lang=lang,
|
||||
llm_profile_mode=specialist_modes.get("ops") or active_mode,
|
||||
)
|
||||
try:
|
||||
store.set_setting("agent_inventory_snapshot", str(inventory_snapshot()))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
sync_plugin_metadata(store)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
upsert_knowledge_chunks(
|
||||
store,
|
||||
source="builtin:src",
|
||||
chunks=[
|
||||
"Use tools for route lookup, path search, config diff, device ping, and log analysis.",
|
||||
"High-risk actions require explicit confirmation by user before execution.",
|
||||
"Prefer citing tool outputs and avoid fabricating external facts.",
|
||||
],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
specialist_profiles = {
|
||||
"ops": SpecialistProfile(
|
||||
name="ops",
|
||||
system_prefix=default_system_prefix_for_specialist("ops", lang),
|
||||
tool_tags=default_tool_tags_for_specialist("ops"),
|
||||
),
|
||||
"generalist": SpecialistProfile(
|
||||
name="generalist",
|
||||
system_prefix=default_system_prefix_for_specialist("generalist", lang),
|
||||
tool_tags=default_tool_tags_for_specialist("generalist"),
|
||||
),
|
||||
"image": SpecialistProfile(
|
||||
name="image",
|
||||
system_prefix=default_system_prefix_for_specialist("image", lang),
|
||||
tool_tags=default_tool_tags_for_specialist("image"),
|
||||
),
|
||||
"memory_curator": SpecialistProfile(
|
||||
name="memory_curator",
|
||||
system_prefix=default_system_prefix_for_specialist("memory_curator", lang),
|
||||
tool_tags=default_tool_tags_for_specialist("memory_curator"),
|
||||
),
|
||||
}
|
||||
return (
|
||||
base_agent,
|
||||
specialist_profiles,
|
||||
manager_model,
|
||||
manager_mode,
|
||||
specialist_models,
|
||||
specialist_modes,
|
||||
)
|
||||
|
||||
|
||||
def build_ops_agent(
|
||||
store: SqliteStore,
|
||||
*,
|
||||
lang: str = "zh",
|
||||
profile_id: str | None = None,
|
||||
openai_api_key: str | None = None,
|
||||
llm_mode: str | None = None,
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
viewer_user_id: str | None = None,
|
||||
viewer_username: str | None = None,
|
||||
viewer_tenant_id: str | None = None,
|
||||
) -> Any:
|
||||
del viewer_user_id, viewer_username, viewer_tenant_id
|
||||
return build_gateway_executor(
|
||||
store,
|
||||
lang=lang,
|
||||
specialist="ops",
|
||||
profile_id=profile_id,
|
||||
openai_api_key=openai_api_key,
|
||||
llm_mode=llm_mode,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
|
||||
def build_gateway_executor(
|
||||
store: SqliteStore,
|
||||
*,
|
||||
lang: str = "zh",
|
||||
specialist: str | None = None,
|
||||
profile_id: str | None = None,
|
||||
openai_api_key: str | None = None,
|
||||
llm_mode: str | None = None,
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
viewer_user_id: str | None = None,
|
||||
viewer_username: str | None = None,
|
||||
viewer_tenant_id: str | None = None,
|
||||
policy_session_id: str | None = None,
|
||||
path_policy_tenant_id: str | None = None,
|
||||
path_policy_user_id: str | None = None,
|
||||
) -> Any:
|
||||
base_agent, specialist_profiles, _, _, specialist_models, specialist_modes = _build_executor_components(
|
||||
store,
|
||||
lang=lang,
|
||||
profile_id=profile_id,
|
||||
openai_api_key=openai_api_key,
|
||||
llm_mode=llm_mode,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
viewer_user_id=viewer_user_id,
|
||||
viewer_username=viewer_username,
|
||||
viewer_tenant_id=viewer_tenant_id,
|
||||
)
|
||||
sid = str(specialist or "").strip().lower() or "generalist"
|
||||
if sid not in specialist_profiles:
|
||||
sid = "generalist"
|
||||
prof = specialist_profiles.get(sid) or specialist_profiles["generalist"]
|
||||
chosen_model = specialist_models.get(prof.name) or base_agent.model
|
||||
chosen_mode = specialist_modes.get(prof.name) or getattr(base_agent, "llm_profile_mode", None)
|
||||
if prof.name == "ops":
|
||||
return NetworkOpsAgent(
|
||||
store=store,
|
||||
model=chosen_model,
|
||||
lang=(lang or "zh").strip().lower(),
|
||||
llm_profile_mode=chosen_mode,
|
||||
system_prompt=prof.system_prefix,
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
)
|
||||
tools = default_registry(
|
||||
expert=expert_name_for_specialist(prof.name),
|
||||
specialist=prof.name,
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
store=store,
|
||||
)
|
||||
return Agent(
|
||||
store=store,
|
||||
tools=tools,
|
||||
model=chosen_model,
|
||||
system_prompt=prof.system_prefix,
|
||||
lang=(lang or "zh").strip().lower(),
|
||||
llm_profile_mode=chosen_mode,
|
||||
)
|
||||
|
||||
|
||||
def build_gateway_executors(
|
||||
store: SqliteStore,
|
||||
*,
|
||||
lang: str = "zh",
|
||||
profile_id: str | None = None,
|
||||
openai_api_key: str | None = None,
|
||||
llm_mode: str | None = None,
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
viewer_user_id: str | None = None,
|
||||
viewer_username: str | None = None,
|
||||
viewer_tenant_id: str | None = None,
|
||||
policy_session_id: str | None = None,
|
||||
path_policy_tenant_id: str | None = None,
|
||||
path_policy_user_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
manager = build_gateway_executor(
|
||||
store,
|
||||
lang=lang,
|
||||
specialist="generalist",
|
||||
profile_id=profile_id,
|
||||
openai_api_key=openai_api_key,
|
||||
llm_mode=llm_mode,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
viewer_user_id=viewer_user_id,
|
||||
viewer_username=viewer_username,
|
||||
viewer_tenant_id=viewer_tenant_id,
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
)
|
||||
specialists: dict[str, Any] = {}
|
||||
for sid in SPECIALIST_IDS:
|
||||
specialists[sid] = build_gateway_executor(
|
||||
store,
|
||||
lang=lang,
|
||||
specialist=sid,
|
||||
profile_id=profile_id,
|
||||
openai_api_key=openai_api_key,
|
||||
llm_mode=llm_mode,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
viewer_user_id=viewer_user_id,
|
||||
viewer_username=viewer_username,
|
||||
viewer_tenant_id=viewer_tenant_id,
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
)
|
||||
return {"manager": manager, "specialists": specialists}
|
||||
|
||||
|
||||
def build_ephemeral_executor(
|
||||
store: SqliteStore,
|
||||
*,
|
||||
lang: str = "zh",
|
||||
system_prompt: str,
|
||||
tool_policy: dict[str, Any] | None = None,
|
||||
profile_id: str | None = None,
|
||||
openai_api_key: str | None = None,
|
||||
llm_mode: str | None = None,
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
viewer_user_id: str | None = None,
|
||||
viewer_username: str | None = None,
|
||||
viewer_tenant_id: str | None = None,
|
||||
policy_session_id: str | None = None,
|
||||
path_policy_tenant_id: str | None = None,
|
||||
path_policy_user_id: str | None = None,
|
||||
) -> Any:
|
||||
base_agent, _, _, _, _, _ = _build_executor_components(
|
||||
store,
|
||||
lang=lang,
|
||||
profile_id=profile_id,
|
||||
openai_api_key=openai_api_key,
|
||||
llm_mode=llm_mode,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
viewer_user_id=viewer_user_id,
|
||||
viewer_username=viewer_username,
|
||||
viewer_tenant_id=viewer_tenant_id,
|
||||
)
|
||||
declared = tool_policy if isinstance(tool_policy, dict) else {}
|
||||
allow_tags = [str(x) for x in (declared.get("allow_tags") or []) if str(x or "").strip()]
|
||||
allow_tools = [str(x) for x in (declared.get("allow_tools") or []) if str(x or "").strip()]
|
||||
tools = default_registry(
|
||||
expert="generalist+workspace+productivity",
|
||||
specialist="generalist",
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
store=store,
|
||||
allow_tags=allow_tags,
|
||||
allow_tools=allow_tools,
|
||||
)
|
||||
return Agent(
|
||||
store=store,
|
||||
tools=tools,
|
||||
model=base_agent.model,
|
||||
system_prompt=str(system_prompt or "").strip(),
|
||||
lang=(lang or "zh").strip().lower(),
|
||||
llm_profile_mode=getattr(base_agent, "llm_profile_mode", None),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_OLLAMA_BASE_URL",
|
||||
"DEFAULT_OLLAMA_MODEL",
|
||||
"build_ops_agent",
|
||||
"build_gateway_executor",
|
||||
"build_gateway_executors",
|
||||
"build_ephemeral_executor",
|
||||
]
|
||||
45
agents/network_ops_agent.py
Normal file
45
agents/network_ops_agent.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from oclaw.chat.agent import Agent
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.prompts.loader import render_openclaw_prompt
|
||||
from oclaw.tools import default_registry
|
||||
|
||||
NETWORK_SYSTEM_PROMPT_ZH = render_openclaw_prompt("roles/specialists/ops/system.md", strict=True)
|
||||
|
||||
|
||||
class NetworkOpsAgent(Agent):
|
||||
"""网络运维专家 Agent:固定专家提示词与专家工具目录。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
store: SqliteStore,
|
||||
model: Any,
|
||||
lang: str = "zh",
|
||||
llm_profile_mode: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
policy_session_id: str | None = None,
|
||||
path_policy_tenant_id: str | None = None,
|
||||
path_policy_user_id: str | None = None,
|
||||
) -> None:
|
||||
tools = default_registry(
|
||||
expert="network_ops",
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
store=store,
|
||||
)
|
||||
super().__init__(
|
||||
store=store,
|
||||
tools=tools,
|
||||
model=model,
|
||||
system_prompt=(system_prompt or render_openclaw_prompt("roles/specialists/ops/system.md", strict=True)),
|
||||
lang=lang,
|
||||
llm_profile_mode=llm_profile_mode,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["NetworkOpsAgent", "NETWORK_SYSTEM_PROMPT_ZH"]
|
||||
516
agents/specialist_agent.py
Normal file
516
agents/specialist_agent.py
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import base64
|
||||
import hashlib
|
||||
import httpx
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from oclaw.chat.agent import Agent
|
||||
from oclaw.chat.agent import GenerationInterrupted
|
||||
from oclaw.agents.network_ops_agent import NetworkOpsAgent
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.platform.files.attachment_assets import AttachmentAssetStore, attachment_id_to_data_url
|
||||
from oclaw.platform.llm.image_message_client import send_image_messages
|
||||
from oclaw.tools import default_registry
|
||||
from oclaw.agents.specialists import expert_name_for_specialist
|
||||
|
||||
from oclaw.chat.turn_types import TurnRunOutcome
|
||||
from oclaw.openclaw_runtime.relay_pointer import build_manifest_from_attachment_refs
|
||||
from oclaw.openclaw_runtime.types import RelayShareEnvelope
|
||||
from oclaw.orchestration.protocol import (
|
||||
AgentTask,
|
||||
PlanStep,
|
||||
SpecialistDelivery,
|
||||
SpecialistResult,
|
||||
SpecialistToolTrace,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpecialistProfile:
|
||||
name: str
|
||||
system_prefix: str
|
||||
tool_tags: frozenset[str] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpecialistAgentRunner:
|
||||
store: SqliteStore
|
||||
model: Any
|
||||
llm_profile_mode: str | None
|
||||
lang: str
|
||||
profiles: dict[str, SpecialistProfile] = field(default_factory=dict)
|
||||
model_by_specialist: dict[str, Any] = field(default_factory=dict)
|
||||
llm_mode_by_specialist: dict[str, str | None] = field(default_factory=dict)
|
||||
_agent_cache: dict[tuple, Agent] = field(default_factory=dict, init=False, repr=False)
|
||||
|
||||
@staticmethod
|
||||
def _allowlist_mutation_fingerprint(
|
||||
store: SqliteStore,
|
||||
*,
|
||||
policy_session_id: str | None = None,
|
||||
path_policy_tenant_id: str | None = None,
|
||||
path_policy_user_id: str | None = None,
|
||||
) -> str:
|
||||
t = (path_policy_tenant_id or "").strip() or None
|
||||
u = (path_policy_user_id or "").strip() or None
|
||||
if (not t or not u) and (policy_session_id or "").strip():
|
||||
try:
|
||||
own = store.get_ui_session_owner(session_id=str(policy_session_id).strip()) or {}
|
||||
except Exception:
|
||||
own = {}
|
||||
t = t or (str(own.get("tenant_id") or "").strip() or None)
|
||||
u = u or (str(own.get("user_id") or "").strip() or None)
|
||||
if not t or not u:
|
||||
return "0"
|
||||
try:
|
||||
row = store.get_user_workspace_path_allowlist(tenant_id=t, user_id=u)
|
||||
except Exception:
|
||||
row = None
|
||||
if not row or not isinstance(row, dict):
|
||||
return "0|"
|
||||
er = str(row.get("extra_roots") or "")
|
||||
return f"{1 if int(row.get('allow_any_path') or 0) else 0}|{str(row.get('updated_at') or '')}|{er[:2000]}"
|
||||
|
||||
def _agent_cache_fingerprint(
|
||||
self,
|
||||
specialist: str,
|
||||
prof: SpecialistProfile,
|
||||
*,
|
||||
policy_session_id: str | None = None,
|
||||
path_policy_tenant_id: str | None = None,
|
||||
path_policy_user_id: str | None = None,
|
||||
) -> str:
|
||||
tool_names: list[str] = []
|
||||
try:
|
||||
regs = default_registry(
|
||||
expert=expert_name_for_specialist(prof.name),
|
||||
specialist=prof.name,
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
store=self.store,
|
||||
)
|
||||
tool_names = sorted([str(t.name) for t in regs.list()])
|
||||
except Exception:
|
||||
tool_names = []
|
||||
raw = json.dumps(
|
||||
{
|
||||
"specialist": specialist,
|
||||
"profile_name": prof.name,
|
||||
"system_prefix": prof.system_prefix,
|
||||
"tool_names": tool_names,
|
||||
"tool_tags": sorted(list(prof.tool_tags or frozenset())),
|
||||
"policy_session_tail": (str(policy_session_id or "")[-16:]),
|
||||
"allowlist_fp": self._allowlist_mutation_fingerprint(
|
||||
self.store,
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
def _resolve_profile_and_model(self, specialist: str) -> tuple[SpecialistProfile, Any, str | None]:
|
||||
prof = self.profiles.get(specialist) or self.profiles["generalist"]
|
||||
chosen_model = self.model_by_specialist.get(prof.name) or self.model
|
||||
chosen_mode = self.llm_mode_by_specialist.get(prof.name) or self.llm_profile_mode
|
||||
return prof, chosen_model, chosen_mode
|
||||
|
||||
def _build_agent_for(
|
||||
self,
|
||||
specialist: str,
|
||||
*,
|
||||
policy_session_id: str | None = None,
|
||||
use_cache: bool = True,
|
||||
path_policy_tenant_id: str | None = None,
|
||||
path_policy_user_id: str | None = None,
|
||||
) -> Agent:
|
||||
prof, chosen_model, chosen_mode = self._resolve_profile_and_model(specialist)
|
||||
cache_fp = self._agent_cache_fingerprint(
|
||||
specialist,
|
||||
prof,
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
)
|
||||
alfp = self._allowlist_mutation_fingerprint(
|
||||
self.store,
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
)
|
||||
cache_key = (prof.name, id(chosen_model), chosen_mode, self.lang, cache_fp, str(policy_session_id or ""), alfp)
|
||||
if use_cache:
|
||||
cached = self._agent_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if prof.name == "ops":
|
||||
agent: Agent = NetworkOpsAgent(
|
||||
store=self.store,
|
||||
model=chosen_model,
|
||||
lang=self.lang,
|
||||
llm_profile_mode=chosen_mode,
|
||||
system_prompt=prof.system_prefix,
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
)
|
||||
if use_cache:
|
||||
self._agent_cache[cache_key] = agent
|
||||
return agent
|
||||
tools = default_registry(
|
||||
expert=expert_name_for_specialist(prof.name),
|
||||
specialist=prof.name,
|
||||
policy_session_id=policy_session_id,
|
||||
path_policy_tenant_id=path_policy_tenant_id,
|
||||
path_policy_user_id=path_policy_user_id,
|
||||
store=self.store,
|
||||
)
|
||||
agent = Agent(
|
||||
store=self.store,
|
||||
tools=tools,
|
||||
model=chosen_model,
|
||||
system_prompt=prof.system_prefix,
|
||||
lang=self.lang,
|
||||
llm_profile_mode=chosen_mode,
|
||||
)
|
||||
if use_cache:
|
||||
self._agent_cache[cache_key] = agent
|
||||
return agent
|
||||
|
||||
def run_specialist(
|
||||
self,
|
||||
*,
|
||||
parent_task: AgentTask,
|
||||
step: PlanStep,
|
||||
session_id: str | None = None,
|
||||
use_cache: bool = True,
|
||||
on_progress: Optional[Callable[[str], None]] = None,
|
||||
on_token: Optional[Callable[[str], None]] = None,
|
||||
on_tool_ui: Optional[Callable[[str, dict[str, Any]], None]] = None,
|
||||
should_stop: Optional[Callable[[], bool]] = None,
|
||||
) -> SpecialistResult:
|
||||
started = time.perf_counter()
|
||||
if on_progress:
|
||||
obj = (step.objective or "").strip().replace("\n", " ")
|
||||
if len(obj) > 140:
|
||||
obj = obj[:137] + "..."
|
||||
on_progress(f"[sp.start] {step.step_id} specialist={step.specialist} objective={obj}")
|
||||
created_session_id: str | None = None
|
||||
if not session_id:
|
||||
temp_session = self.store.create_session(f"specialist:{step.specialist}")
|
||||
session_id = temp_session.id
|
||||
created_session_id = session_id
|
||||
# User chat session for workspace/MCP path policy (specialist temp session usually has no ui_session_owner).
|
||||
_raw_policy_sid = str(parent_task.session_id or "").strip() or str(session_id or "").strip()
|
||||
policy_session_id: str | None = _raw_policy_sid if _raw_policy_sid else None
|
||||
_meta: dict[str, Any] = parent_task.metadata if isinstance(getattr(parent_task, "metadata", None), dict) else {}
|
||||
_path_tenant = str(_meta.get("tenant_id") or "").strip() or None
|
||||
_path_user = str(_meta.get("user_id") or "").strip() or None
|
||||
prompt = (
|
||||
f"Specialist: {step.specialist}\n"
|
||||
f"Objective: {step.objective}\n"
|
||||
f"Parent user request: {parent_task.user_text}\n"
|
||||
f"Step input: {step.input_text}\n"
|
||||
"Execution policy: when the user asks to read/open/list/summarize concrete files, URLs, or MCP resources, "
|
||||
"execute with available tools first. Do not return generic optimization plans unless explicitly requested.\n"
|
||||
)
|
||||
image_input_count = 0
|
||||
image_input_kind: list[str] = []
|
||||
image_protocol = ""
|
||||
image_debug_schema = ""
|
||||
image_debug_payload: dict[str, Any] | str = {}
|
||||
specialist_delivery: SpecialistDelivery | None = None
|
||||
try:
|
||||
if step.specialist == "image":
|
||||
image_protocol = "messages.content.image"
|
||||
selected_images: list[str] = []
|
||||
for att in parent_task.attachments or []:
|
||||
if not isinstance(att, dict):
|
||||
continue
|
||||
t = str(att.get("type") or "").strip().lower()
|
||||
if t == "image_ref":
|
||||
aid = str(att.get("attachment_id") or "").strip()
|
||||
if not aid:
|
||||
continue
|
||||
data_url = attachment_id_to_data_url(aid, mime=str(att.get("mime") or ""))
|
||||
if data_url:
|
||||
selected_images.append(data_url)
|
||||
elif t in ("input_image", "image"):
|
||||
raw = str(att.get("image_base64") or att.get("data") or "").strip()
|
||||
if raw:
|
||||
mime = str(att.get("mime") or "image/jpeg")
|
||||
if raw.startswith("data:"):
|
||||
selected_images.append(raw)
|
||||
else:
|
||||
selected_images.append(f"data:{mime};base64,{raw}")
|
||||
elif t == "image_url":
|
||||
u = str(att.get("url") or "").strip()
|
||||
if u:
|
||||
selected_images.append(u)
|
||||
if len(selected_images) >= 3:
|
||||
break
|
||||
image_input_count = len(selected_images)
|
||||
image_input_kind = ["data_url" if s.startswith("data:") else "url" for s in selected_images]
|
||||
if not selected_images:
|
||||
output = "Image specialist received no image input."
|
||||
ok = False
|
||||
else:
|
||||
_, chosen_model, _ = self._resolve_profile_and_model(step.specialist)
|
||||
model_name = str(
|
||||
os.getenv("AIA_IMAGE_MODEL")
|
||||
or getattr(chosen_model, "model", None)
|
||||
or ""
|
||||
).strip() or None
|
||||
api_key = str(getattr(chosen_model, "api_key", "") or "").strip() or None
|
||||
base_url = str(getattr(chosen_model, "base_url", "") or "").strip() or None
|
||||
dashscope_api_key = api_key if base_url and "dashscope.aliyuncs.com" in base_url.lower() else None
|
||||
dashscope_base_http_api_url = None
|
||||
if base_url and "dashscope.aliyuncs.com" in base_url.lower():
|
||||
# Normalize compatible-mode/v1 to native /api/v1 for DashScope SDK.
|
||||
dashscope_base_http_api_url = str(base_url).replace("/compatible-mode/v1", "/api/v1")
|
||||
resp = send_image_messages(
|
||||
images=selected_images,
|
||||
prompt=f"{step.objective}\n\n{step.input_text}\n\n{parent_task.user_text}",
|
||||
model=model_name,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
dashscope_api_key=dashscope_api_key,
|
||||
dashscope_base_http_api_url=dashscope_base_http_api_url,
|
||||
)
|
||||
image_debug_schema = str(resp.get("debug_used_schema") or "").strip()
|
||||
dbg = resp.get("debug_used_debug")
|
||||
if isinstance(dbg, dict):
|
||||
image_debug_payload = dbg
|
||||
elif dbg is not None:
|
||||
image_debug_payload = str(dbg)
|
||||
ok = bool(resp.get("ok"))
|
||||
output = str(resp.get("text") or "").strip()
|
||||
if not ok:
|
||||
err = str(resp.get("error") or "").strip()
|
||||
output = f"Image generation failed: {err or 'unknown error'}"
|
||||
elif not output:
|
||||
output = "Image processed."
|
||||
# persist output images as attachment assets for UI rendering
|
||||
produced_attachments: list[dict[str, Any]] = []
|
||||
if ok:
|
||||
out_images = resp.get("images")
|
||||
if isinstance(out_images, list):
|
||||
store = AttachmentAssetStore()
|
||||
for idx, item in enumerate(out_images[:3], start=1):
|
||||
s = str(item or "").strip()
|
||||
if not s:
|
||||
continue
|
||||
if s.startswith("data:") and ";base64," in s:
|
||||
head, b64 = s.split(";base64,", 1)
|
||||
mime = head.replace("data:", "", 1) or "image/png"
|
||||
try:
|
||||
blob = base64.b64decode(b64.encode("ascii"))
|
||||
except Exception:
|
||||
continue
|
||||
meta = store.save_bytes(
|
||||
blob,
|
||||
filename=f"image-output-{idx}.png",
|
||||
mime=mime,
|
||||
)
|
||||
produced_attachments.append(
|
||||
{
|
||||
"type": "image_ref",
|
||||
"attachment_id": meta.attachment_id,
|
||||
"name": meta.name,
|
||||
"mime": meta.mime,
|
||||
"bytes": meta.bytes,
|
||||
"width": meta.width,
|
||||
"height": meta.height,
|
||||
}
|
||||
)
|
||||
elif s.startswith("http://") or s.startswith("https://"):
|
||||
try:
|
||||
with httpx.Client(timeout=20.0, follow_redirects=True) as client:
|
||||
r = client.get(s)
|
||||
if r.status_code < 400 and r.content:
|
||||
mime = str(r.headers.get("content-type") or "image/png").split(";", 1)[0].strip() or "image/png"
|
||||
ext = ".png"
|
||||
if mime == "image/jpeg":
|
||||
ext = ".jpg"
|
||||
elif mime == "image/webp":
|
||||
ext = ".webp"
|
||||
elif mime == "image/gif":
|
||||
ext = ".gif"
|
||||
meta = store.save_bytes(
|
||||
r.content,
|
||||
filename=f"image-output-{idx}{ext}",
|
||||
mime=mime,
|
||||
)
|
||||
produced_attachments.append(
|
||||
{
|
||||
"type": "image_ref",
|
||||
"attachment_id": meta.attachment_id,
|
||||
"name": meta.name,
|
||||
"mime": meta.mime,
|
||||
"bytes": meta.bytes,
|
||||
"width": meta.width,
|
||||
"height": meta.height,
|
||||
}
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
produced_attachments.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"url": s,
|
||||
"name": f"image-output-{idx}.png",
|
||||
}
|
||||
)
|
||||
# Treat missing image outputs as failure to avoid false "generated" state.
|
||||
if not produced_attachments:
|
||||
ok = False
|
||||
output = (
|
||||
"Image generation failed: response succeeded but no image output was returned."
|
||||
)
|
||||
self.store.add_message(
|
||||
session_id=session_id,
|
||||
role="assistant",
|
||||
content=output,
|
||||
attachments=produced_attachments or None,
|
||||
)
|
||||
specialist_delivery = SpecialistDelivery(
|
||||
specialist=step.specialist,
|
||||
step_id=step.step_id,
|
||||
answer_text=str(output or ""),
|
||||
tool_traces=(),
|
||||
notes="image_pipeline",
|
||||
)
|
||||
else:
|
||||
agent = self._build_agent_for(
|
||||
step.specialist,
|
||||
policy_session_id=policy_session_id,
|
||||
use_cache=use_cache,
|
||||
path_policy_tenant_id=_path_tenant,
|
||||
path_policy_user_id=_path_user,
|
||||
)
|
||||
from oclaw.openclaw_runtime.gateway import OpenClawGateway
|
||||
from oclaw.openclaw_runtime.types import StandardMessage
|
||||
|
||||
gw = OpenClawGateway(store=self.store)
|
||||
msg = StandardMessage(
|
||||
session_id=str(session_id),
|
||||
tenant_id=str(_path_tenant or ""),
|
||||
user_id=str(_path_user or ""),
|
||||
role="member",
|
||||
channel="specialist",
|
||||
text=str(prompt or ""),
|
||||
attachments=list(parent_task.attachments or []),
|
||||
metadata={
|
||||
"tenant_id": str(_path_tenant or ""),
|
||||
"user_id": str(_path_user or ""),
|
||||
"channel": f"specialist:{step.specialist}",
|
||||
},
|
||||
)
|
||||
output = gw.handle_turn(
|
||||
msg=msg,
|
||||
lang=str(getattr(agent, "lang", "zh") or "zh"),
|
||||
executor=agent,
|
||||
on_token=on_token,
|
||||
on_progress=on_progress,
|
||||
on_tool_ui=on_tool_ui,
|
||||
should_stop=should_stop,
|
||||
).reply_text
|
||||
ok = bool((output or "").strip())
|
||||
outcome = getattr(agent, "_last_turn_outcome", None)
|
||||
if isinstance(outcome, TurnRunOutcome):
|
||||
traces = tuple(
|
||||
SpecialistToolTrace(
|
||||
name=str(x.get("name") or ""),
|
||||
ok=bool(x.get("ok")),
|
||||
latency_ms=int(x.get("latency_ms") or x.get("duration_ms") or 0),
|
||||
)
|
||||
for x in outcome.tool_traces
|
||||
)
|
||||
specialist_delivery = SpecialistDelivery(
|
||||
specialist=step.specialist,
|
||||
step_id=step.step_id,
|
||||
answer_text=str(output or ""),
|
||||
tool_traces=traces,
|
||||
notes=str(outcome.handoff_note or ""),
|
||||
)
|
||||
except GenerationInterrupted:
|
||||
raise
|
||||
except Exception as e:
|
||||
output = f"{type(e).__name__}: {e}"
|
||||
ok = False
|
||||
finally:
|
||||
produced_attachments: list[dict[str, Any]] = []
|
||||
try:
|
||||
rows = self.store.get_messages(session_id=session_id, limit=40) if session_id else []
|
||||
for m in reversed(rows):
|
||||
if str(m.role) != "assistant":
|
||||
continue
|
||||
if not m.attachments:
|
||||
continue
|
||||
raw = json.loads(m.attachments)
|
||||
if isinstance(raw, list):
|
||||
produced_attachments = [a for a in raw if isinstance(a, dict)]
|
||||
break
|
||||
except Exception:
|
||||
produced_attachments = []
|
||||
if created_session_id:
|
||||
try:
|
||||
parent_sid = str(parent_task.session_id or "").strip()
|
||||
if parent_sid and parent_sid != str(created_session_id):
|
||||
# Preserve tool usage telemetry: tool uses run inside temp specialist sessions.
|
||||
# If we delete temp sessions directly, FK cascade would drop those tool_log rows.
|
||||
self.store.move_tool_logs_to_session(
|
||||
from_session_id=str(created_session_id),
|
||||
to_session_id=parent_sid,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self.store.delete_session(created_session_id)
|
||||
latency = int((time.perf_counter() - started) * 1000)
|
||||
if on_progress:
|
||||
on_progress(
|
||||
f"[sp.done] {step.step_id} specialist={step.specialist} ok={ok} latency_ms={latency}"
|
||||
)
|
||||
scope_id = str(session_id or parent_task.session_id or "").strip()
|
||||
manifest = build_manifest_from_attachment_refs(
|
||||
produced_attachments,
|
||||
scope_id=scope_id,
|
||||
source_agent=str(step.specialist or ""),
|
||||
ttl_policy="turn",
|
||||
)
|
||||
relay_env = RelayShareEnvelope(
|
||||
schema_version="v1",
|
||||
trace_id=str((parent_task.metadata or {}).get("trace_id") or ""),
|
||||
run_id=str((parent_task.metadata or {}).get("run_id") or ""),
|
||||
attempt_no=int((parent_task.metadata or {}).get("attempt_no") or 0),
|
||||
attachments=manifest,
|
||||
)
|
||||
return SpecialistResult(
|
||||
step_id=step.step_id,
|
||||
specialist=step.specialist,
|
||||
success=ok,
|
||||
output_text=output,
|
||||
latency_ms=latency,
|
||||
metadata={
|
||||
"objective": step.objective,
|
||||
"attachments": produced_attachments,
|
||||
"relay_share_envelope": relay_env.to_dict(),
|
||||
"image_input_count": image_input_count,
|
||||
"image_input_kind": image_input_kind,
|
||||
"image_protocol": image_protocol,
|
||||
"image_debug_schema": image_debug_schema,
|
||||
"image_debug_payload": image_debug_payload,
|
||||
},
|
||||
delivery=specialist_delivery,
|
||||
)
|
||||
123
agents/specialists.py
Normal file
123
agents/specialists.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from oclaw.infrastructure.agent_context import build_role_system_context
|
||||
|
||||
|
||||
SpecialistId = str
|
||||
AgentRoleId = str
|
||||
MANAGER_AGENT_ID: AgentRoleId = "manager"
|
||||
AGENT_PROFILE_BINDINGS_KEY = "agent_profile_bindings"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpecialistConfig:
|
||||
specialist_id: SpecialistId
|
||||
expert_name: str
|
||||
default_tool_tags: frozenset[str] | None
|
||||
|
||||
|
||||
SPECIALISTS: dict[SpecialistId, SpecialistConfig] = {
|
||||
"ops": SpecialistConfig(
|
||||
specialist_id="ops",
|
||||
expert_name="network_ops",
|
||||
default_tool_tags=None,
|
||||
),
|
||||
"generalist": SpecialistConfig(
|
||||
specialist_id="generalist",
|
||||
expert_name="generalist+workspace+productivity",
|
||||
default_tool_tags=None,
|
||||
),
|
||||
"image": SpecialistConfig(
|
||||
specialist_id="image",
|
||||
# image specialist currently reuses generalist expert tool registry,
|
||||
# including image_edit tool.
|
||||
expert_name="generalist",
|
||||
default_tool_tags=None,
|
||||
),
|
||||
"memory_curator": SpecialistConfig(
|
||||
specialist_id="memory_curator",
|
||||
expert_name="memory_curator",
|
||||
default_tool_tags=None,
|
||||
),
|
||||
}
|
||||
SPECIALIST_IDS: tuple[SpecialistId, ...] = tuple(SPECIALISTS.keys())
|
||||
AGENT_ROLE_IDS: tuple[AgentRoleId, ...] = (MANAGER_AGENT_ID, *SPECIALIST_IDS)
|
||||
|
||||
|
||||
def expert_name_for_specialist(specialist_id: SpecialistId) -> str:
|
||||
cfg = SPECIALISTS.get(specialist_id) or SPECIALISTS["generalist"]
|
||||
return cfg.expert_name
|
||||
|
||||
|
||||
def default_tool_tags_for_specialist(specialist_id: SpecialistId) -> frozenset[str] | None:
|
||||
cfg = SPECIALISTS.get(specialist_id) or SPECIALISTS["generalist"]
|
||||
return cfg.default_tool_tags
|
||||
|
||||
|
||||
def default_system_prefix_for_specialist(specialist_id: SpecialistId, lang: str = "zh") -> str:
|
||||
sid = (specialist_id or "").strip().lower() or "generalist"
|
||||
cfg = SPECIALISTS.get(sid) or SPECIALISTS["generalist"]
|
||||
_ = (lang or "zh").strip().lower()
|
||||
return build_role_system_context(cfg.specialist_id)
|
||||
|
||||
|
||||
def model_role_for_specialist(specialist_id: SpecialistId) -> AgentRoleId:
|
||||
sid = (specialist_id or "").strip().lower()
|
||||
if sid in SPECIALISTS:
|
||||
return sid
|
||||
return "generalist"
|
||||
|
||||
|
||||
def empty_agent_profile_bindings() -> dict[AgentRoleId, str]:
|
||||
return {rid: "" for rid in AGENT_ROLE_IDS}
|
||||
|
||||
|
||||
def parse_agent_profile_bindings(raw: str | None) -> dict[AgentRoleId, str]:
|
||||
out = empty_agent_profile_bindings()
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return out
|
||||
try:
|
||||
obj = json.loads(text)
|
||||
except Exception:
|
||||
return out
|
||||
if not isinstance(obj, dict):
|
||||
return out
|
||||
for rid in AGENT_ROLE_IDS:
|
||||
v = obj.get(rid)
|
||||
if v is None:
|
||||
continue
|
||||
s = str(v).strip()
|
||||
out[rid] = s
|
||||
return out
|
||||
|
||||
|
||||
def dump_agent_profile_bindings(bindings: dict[AgentRoleId, Any]) -> str:
|
||||
raw = {}
|
||||
for rid in AGENT_ROLE_IDS:
|
||||
v = bindings.get(rid) if isinstance(bindings, dict) else None
|
||||
raw[rid] = str(v).strip() if v is not None else ""
|
||||
return json.dumps(raw, ensure_ascii=False)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AGENT_PROFILE_BINDINGS_KEY",
|
||||
"AGENT_ROLE_IDS",
|
||||
"AgentRoleId",
|
||||
"dump_agent_profile_bindings",
|
||||
"empty_agent_profile_bindings",
|
||||
"MANAGER_AGENT_ID",
|
||||
"SpecialistConfig",
|
||||
"SpecialistId",
|
||||
"SPECIALISTS",
|
||||
"SPECIALIST_IDS",
|
||||
"default_system_prefix_for_specialist",
|
||||
"default_tool_tags_for_specialist",
|
||||
"expert_name_for_specialist",
|
||||
"model_role_for_specialist",
|
||||
"parse_agent_profile_bindings",
|
||||
]
|
||||
37
agents/subagent_registry.py
Normal file
37
agents/subagent_registry.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from threading import Lock
|
||||
|
||||
_LOCK = Lock()
|
||||
_INITIALIZED = False
|
||||
|
||||
|
||||
def init_subagent_registry() -> None:
|
||||
"""Initialize subagent registry runtime once.
|
||||
|
||||
Python gateway currently keeps this as a lightweight compatibility seam,
|
||||
so startup code can mirror the OpenClaw TypeScript bootstrap flow.
|
||||
"""
|
||||
global _INITIALIZED
|
||||
with _LOCK:
|
||||
if _INITIALIZED:
|
||||
return
|
||||
_INITIALIZED = True
|
||||
|
||||
|
||||
def is_subagent_registry_initialized() -> bool:
|
||||
with _LOCK:
|
||||
return _INITIALIZED
|
||||
|
||||
|
||||
def reset_subagent_registry_for_tests() -> None:
|
||||
global _INITIALIZED
|
||||
with _LOCK:
|
||||
_INITIALIZED = False
|
||||
|
||||
|
||||
__all__ = [
|
||||
"init_subagent_registry",
|
||||
"is_subagent_registry_initialized",
|
||||
"reset_subagent_registry_for_tests",
|
||||
]
|
||||
4
app_server/__init__.py
Normal file
4
app_server/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from __future__ import annotations
|
||||
|
||||
__all__ = []
|
||||
|
||||
2
application/__init__.py
Normal file
2
application/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""Application use-cases and orchestration services."""
|
||||
|
||||
6
application/gateway/__init__.py
Normal file
6
application/gateway/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Gateway application use-cases."""
|
||||
|
||||
from .inbound_usecase import process_inbound_payload_usecase
|
||||
|
||||
__all__ = ["process_inbound_payload_usecase"]
|
||||
|
||||
457
application/gateway/inbound_service.py
Normal file
457
application/gateway/inbound_service.py
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from oclaw.channels.base import InboundMessage, OutboundMessage
|
||||
from oclaw.channels.wecom.wecom_bridge import WeComAdapter
|
||||
|
||||
_GATEWAY_AGENT_LOCK = threading.Lock()
|
||||
_GATEWAY_AGENT: Any | None = None
|
||||
|
||||
|
||||
def _get_gateway_agent(store: Any) -> Any:
|
||||
global _GATEWAY_AGENT
|
||||
with _GATEWAY_AGENT_LOCK:
|
||||
if _GATEWAY_AGENT is None:
|
||||
from oclaw.agents.factory import build_gateway_executor
|
||||
|
||||
_GATEWAY_AGENT = build_gateway_executor(store)
|
||||
return _GATEWAY_AGENT
|
||||
|
||||
|
||||
def _menu_text() -> str:
|
||||
return (
|
||||
"已绑定成功,常用命令:\n"
|
||||
"1) 帮助 / 菜单\n"
|
||||
"2) 记待办 <内容>\n"
|
||||
"3) 查待办\n"
|
||||
"4) 完成待办 <todo_id>\n"
|
||||
"5) 指派待办 <todo_id> <assignee_user_id>\n"
|
||||
"6) 加知识 <内容>\n"
|
||||
"7) 查知识 <关键词>"
|
||||
)
|
||||
|
||||
|
||||
def _handle_productivity_commands(*, text: str, tenant_id: str, user_id: str) -> str | None:
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return None
|
||||
if t in ("帮助", "菜单", "help", "/help"):
|
||||
return _menu_text()
|
||||
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
store = SqliteStore(db_path())
|
||||
|
||||
if t.startswith("记待办 "):
|
||||
title = t[len("记待办 ") :].strip()
|
||||
if not title:
|
||||
return "待办内容不能为空。示例:记待办 明天10点开会"
|
||||
row = store.todo_create(tenant_id=tenant_id, owner_user_id=user_id, title=title)
|
||||
return f"已创建待办:{row['id'][:8]} | {row['title']}"
|
||||
|
||||
if t in ("查待办", "todo", "todos"):
|
||||
rows = store.todo_list(tenant_id=tenant_id, assignee_user_id=None, status="open", limit=10)
|
||||
if not rows:
|
||||
return "当前没有未完成待办。"
|
||||
lines = [f"- {r['id'][:8]} | {r['title']}" for r in rows]
|
||||
return "未完成待办:\n" + "\n".join(lines)
|
||||
|
||||
if t.startswith("完成待办 "):
|
||||
tid = t[len("完成待办 ") :].strip()
|
||||
if not tid:
|
||||
return "请提供 todo_id。示例:完成待办 1234abcd"
|
||||
rows = store.todo_list(tenant_id=tenant_id, assignee_user_id=None, status=None, limit=200)
|
||||
full = next((r["id"] for r in rows if str(r["id"]).startswith(tid)), tid)
|
||||
ok = store.todo_set_status(tenant_id=tenant_id, todo_id=full, status="done")
|
||||
return "已完成。" if ok else "未找到该待办。"
|
||||
|
||||
if t.startswith("指派待办 "):
|
||||
body = t[len("指派待办 ") :].strip()
|
||||
parts = body.split()
|
||||
if len(parts) < 2:
|
||||
return "格式:指派待办 <todo_id> <assignee_user_id>"
|
||||
tid, assignee = parts[0], parts[1]
|
||||
rows = store.todo_list(tenant_id=tenant_id, assignee_user_id=None, status=None, limit=200)
|
||||
full = next((r["id"] for r in rows if str(r["id"]).startswith(tid)), tid)
|
||||
ok = store.todo_assign(tenant_id=tenant_id, todo_id=full, assignee_user_id=assignee)
|
||||
return "已指派。" if ok else "未找到该待办或用户。"
|
||||
|
||||
if t.startswith("加知识 "):
|
||||
content = t[len("加知识 ") :].strip()
|
||||
if not content:
|
||||
return "知识内容不能为空。示例:加知识 办公室WiFi密码是12345678"
|
||||
from oclaw.tools.experts.productivity.kb_tools import kb_add_tool
|
||||
|
||||
res = kb_add_tool().handler({"tenant_id": tenant_id, "user_id": user_id, "text": content})
|
||||
if not res.get("ok"):
|
||||
return f"写入失败:{res.get('error')}"
|
||||
return f"已写入知识:{str(res.get('chunk_id') or '')[:8]}"
|
||||
|
||||
if t.startswith("查知识 "):
|
||||
q = t[len("查知识 ") :].strip()
|
||||
if not q:
|
||||
return "请提供关键词。示例:查知识 WiFi 密码"
|
||||
from oclaw.tools.experts.productivity.kb_tools import kb_search_tool
|
||||
|
||||
res = kb_search_tool().handler({"tenant_id": tenant_id, "query": q, "limit": 5})
|
||||
if not res.get("ok"):
|
||||
return f"查询失败:{res.get('error')}"
|
||||
hits = res.get("hits") if isinstance(res.get("hits"), list) else []
|
||||
if not hits:
|
||||
return "未找到相关知识。"
|
||||
lines = [f"- {str(h.get('source') or '')}: {str(h.get('snippet') or '')}" for h in hits[:5] if isinstance(h, dict)]
|
||||
return "知识检索结果:\n" + "\n".join(lines)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _role_can_write(role: str, text: str) -> bool:
|
||||
low = (text or "").strip().lower()
|
||||
if not low:
|
||||
return True
|
||||
if role in ("owner", "admin", "member"):
|
||||
return True
|
||||
write_prefixes = ("记待办 ", "完成待办 ", "指派待办 ", "加知识 ")
|
||||
return not any((text or "").startswith(p) for p in write_prefixes)
|
||||
|
||||
|
||||
def _resolve_wecom_account_id(inbound: Any, payload: dict[str, Any]) -> str:
|
||||
if isinstance(inbound.metadata, dict):
|
||||
for key in ("aibotid", "bot_id", "account_id"):
|
||||
val = inbound.metadata.get(key)
|
||||
if val:
|
||||
return str(val).strip()
|
||||
raw = inbound.metadata.get("raw")
|
||||
if isinstance(raw, dict):
|
||||
for key in ("aibotid", "bot_id", "account_id"):
|
||||
val = raw.get(key)
|
||||
if val:
|
||||
return str(val).strip()
|
||||
for key in ("aibotid", "bot_id", "account_id"):
|
||||
val = payload.get(key)
|
||||
if val:
|
||||
return str(val).strip()
|
||||
raw_payload = payload.get("raw")
|
||||
if isinstance(raw_payload, dict) and raw_payload.get("aibotid"):
|
||||
return str(raw_payload.get("aibotid")).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_generic_account_id(inbound: InboundMessage, payload: dict[str, Any]) -> str:
|
||||
if isinstance(inbound.metadata, dict):
|
||||
for key in ("account_id", "bot_id", "app_id", "agent_id"):
|
||||
val = inbound.metadata.get(key)
|
||||
if val:
|
||||
return str(val).strip()
|
||||
for key in ("account_id", "bot_id", "app_id", "agent_id"):
|
||||
val = payload.get(key)
|
||||
if val:
|
||||
return str(val).strip()
|
||||
raw_payload = payload.get("raw")
|
||||
if isinstance(raw_payload, dict):
|
||||
for key in ("account_id", "bot_id", "app_id", "agent_id"):
|
||||
val = raw_payload.get(key)
|
||||
if val:
|
||||
return str(val).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _ensure_administrator_owner(store: Any) -> dict[str, Any] | None:
|
||||
tenant_name = str(store.get_setting("wecom_auto_bind_tenant_name") or "Team").strip() or "Team"
|
||||
tenants = store.list_tenants(limit=200)
|
||||
tenant = next((t for t in tenants if str(t.get("name") or "") == tenant_name), None)
|
||||
if tenant is None:
|
||||
tenant = store.create_tenant(tenant_name)
|
||||
tenant_id = str(tenant.get("id") or "")
|
||||
if not tenant_id:
|
||||
return None
|
||||
user = store.get_user_by_username(tenant_id=tenant_id, username="administrator")
|
||||
if not user:
|
||||
try:
|
||||
from oclaw.platform.config.passwords import load_expected_password
|
||||
except Exception:
|
||||
load_expected_password = None # type: ignore
|
||||
pwd = load_expected_password(store) if callable(load_expected_password) else None
|
||||
if not pwd:
|
||||
return None
|
||||
user = store.create_user_account(
|
||||
tenant_id=tenant_id,
|
||||
username="administrator",
|
||||
password_hash=hashlib.sha256(pwd.encode("utf-8")).hexdigest(),
|
||||
display_name="Administrator",
|
||||
role="owner",
|
||||
is_active=True,
|
||||
)
|
||||
user_id = str((user or {}).get("id") or "")
|
||||
if not user_id:
|
||||
return None
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": user_id,
|
||||
"display_name": (user or {}).get("display_name") or "Administrator",
|
||||
"role": str((user or {}).get("role") or "owner"),
|
||||
}
|
||||
|
||||
|
||||
def _extract_group_name(inbound: Any) -> str:
|
||||
if not isinstance(inbound.metadata, dict):
|
||||
return ""
|
||||
cands: list[str] = []
|
||||
for key in ("chat_name", "group_name", "room_name", "conversation_name"):
|
||||
v = inbound.metadata.get(key)
|
||||
if v is not None:
|
||||
cands.append(str(v).strip())
|
||||
raw = inbound.metadata.get("raw")
|
||||
if isinstance(raw, dict):
|
||||
for key in ("chat_name", "group_name", "room_name", "conversation_name", "chatname"):
|
||||
v = raw.get(key)
|
||||
if v is not None:
|
||||
cands.append(str(v).strip())
|
||||
chat_obj = raw.get("chat")
|
||||
if isinstance(chat_obj, dict):
|
||||
for key in ("name", "chat_name", "group_name"):
|
||||
v = chat_obj.get(key)
|
||||
if v is not None:
|
||||
cands.append(str(v).strip())
|
||||
for s in cands:
|
||||
if s:
|
||||
return s
|
||||
return ""
|
||||
|
||||
|
||||
def _build_wecom_session_title(*, account_name: str, external_user_id: str, is_group: bool, group_name: str) -> str:
|
||||
base = f"{str(account_name or '').strip() or 'WeCom'}+{str(external_user_id or '').strip() or 'unknown'}"
|
||||
if is_group and str(group_name or "").strip():
|
||||
body = f"{base}+{str(group_name).strip()}"
|
||||
else:
|
||||
body = base
|
||||
return f"wechat|{body}"
|
||||
|
||||
|
||||
def _build_channel_session_title(*, channel: str, account_name: str, external_user_id: str, is_group: bool, group_name: str) -> str:
|
||||
ch = str(channel or "").strip().lower() or "channel"
|
||||
if ch == "wecom":
|
||||
return _build_wecom_session_title(
|
||||
account_name=account_name,
|
||||
external_user_id=external_user_id,
|
||||
is_group=is_group,
|
||||
group_name=group_name,
|
||||
)
|
||||
base = f"{str(account_name or '').strip() or ch}+{str(external_user_id or '').strip() or 'unknown'}"
|
||||
body = f"{base}+{str(group_name or '').strip()}" if is_group and str(group_name or "").strip() else base
|
||||
return f"{ch}|{body}"
|
||||
|
||||
|
||||
def _parse_generic_inbound(channel_name: str, payload: dict[str, Any]) -> InboundMessage:
|
||||
meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
user_id = str(payload.get("user_id") or payload.get("external_user_id") or "").strip()
|
||||
chat_id = str(payload.get("chat_id") or payload.get("external_chat_id") or user_id).strip()
|
||||
text = str(payload.get("text") or "").strip()
|
||||
if not user_id:
|
||||
raise ValueError("missing user_id")
|
||||
if not chat_id:
|
||||
chat_id = user_id
|
||||
is_group = bool(payload.get("is_group"))
|
||||
mentions = payload.get("mentions") if isinstance(payload.get("mentions"), list) else []
|
||||
attachments = payload.get("attachments") if isinstance(payload.get("attachments"), list) else []
|
||||
return InboundMessage(
|
||||
channel=str(channel_name or "unknown"),
|
||||
external_user_id=user_id,
|
||||
external_chat_id=chat_id,
|
||||
text=text,
|
||||
is_group=is_group,
|
||||
mentions=[str(x).strip() for x in mentions if str(x).strip()],
|
||||
attachments=[a for a in attachments if isinstance(a, dict)],
|
||||
metadata={str(k): v for k, v in meta.items()},
|
||||
)
|
||||
|
||||
|
||||
def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
from oclaw.ops.mcp_env import apply_gateway_mcp_env_to_os
|
||||
|
||||
apply_gateway_mcp_env_to_os()
|
||||
channel_name = str(payload.get("channel") or "wecom").strip().lower()
|
||||
if channel_name in ("wecom", "wechat_work", "wxwork"):
|
||||
adapter = WeComAdapter()
|
||||
inbound = adapter.parse_inbound(payload)
|
||||
else:
|
||||
adapter = None
|
||||
inbound = _parse_generic_inbound(channel_name, payload)
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.platform.config.paths import db_path
|
||||
|
||||
store = SqliteStore(db_path())
|
||||
if channel_name == "wecom":
|
||||
account_id = _resolve_wecom_account_id(inbound, payload) or str(store.get_setting("wecom_bot_id") or "").strip()
|
||||
else:
|
||||
account_id = _resolve_generic_account_id(inbound, payload)
|
||||
if not account_id:
|
||||
raise ValueError(f"missing {channel_name} account_id")
|
||||
|
||||
text = inbound.text.strip()
|
||||
preface = ""
|
||||
if text.lower().startswith("bind "):
|
||||
code = text.split(None, 1)[-1].strip()
|
||||
info = store.consume_bind_code(
|
||||
code=code,
|
||||
channel=inbound.channel,
|
||||
external_user_id=inbound.external_user_id,
|
||||
display_name=(
|
||||
str(inbound.metadata.get("display_name")).strip()
|
||||
if isinstance(inbound.metadata, dict) and inbound.metadata.get("display_name") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
reply = ("绑定成功。\n\n" + _menu_text()) if info else "绑定失败:无效或已使用的绑定码。"
|
||||
else:
|
||||
reply = ""
|
||||
ident = store.resolve_user_by_channel_identity_v2(
|
||||
channel=inbound.channel,
|
||||
account_id=account_id,
|
||||
external_user_id=inbound.external_user_id,
|
||||
)
|
||||
if not ident:
|
||||
owner = _ensure_administrator_owner(store)
|
||||
if owner:
|
||||
store.upsert_user_channel_account(
|
||||
tenant_id=str(owner.get("tenant_id") or ""),
|
||||
user_id=str(owner.get("user_id") or ""),
|
||||
channel=inbound.channel,
|
||||
account_id=account_id,
|
||||
name=account_id,
|
||||
config={"mode": "single-bot-upgraded"},
|
||||
is_active=True,
|
||||
)
|
||||
store.upsert_channel_identity_v2(
|
||||
tenant_id=str(owner.get("tenant_id") or ""),
|
||||
channel=inbound.channel,
|
||||
account_id=account_id,
|
||||
external_user_id=inbound.external_user_id,
|
||||
user_id=str(owner.get("user_id") or ""),
|
||||
)
|
||||
ident = store.resolve_user_by_channel_identity_v2(
|
||||
channel=inbound.channel,
|
||||
account_id=account_id,
|
||||
external_user_id=inbound.external_user_id,
|
||||
)
|
||||
preface = "当前 Bot 已升级归属 administrator。"
|
||||
if not ident:
|
||||
reply = "账号初始化失败,请检查 administrator/tenant 配置。"
|
||||
if ident:
|
||||
from oclaw.orchestration.policy import ActionPolicyContext, PolicyEngine
|
||||
from oclaw.orchestration.security import has_explicit_confirmation_token
|
||||
|
||||
tenant_id = str(ident.get("tenant_id") or "")
|
||||
user_id = str(ident.get("user_id") or "")
|
||||
role = str(ident.get("role") or "member")
|
||||
account = store.find_user_by_channel_account(channel=inbound.channel, account_id=account_id) or {}
|
||||
account_name = str(account.get("name") or "").strip() or account_id
|
||||
group_name = _extract_group_name(inbound)
|
||||
session_id = store.get_or_create_channel_session_v2(
|
||||
tenant_id=tenant_id,
|
||||
channel=inbound.channel,
|
||||
account_id=account_id,
|
||||
external_user_id=inbound.external_user_id,
|
||||
external_chat_id=inbound.external_chat_id,
|
||||
session_title=_build_channel_session_title(
|
||||
channel=inbound.channel,
|
||||
account_name=account_name,
|
||||
external_user_id=inbound.external_user_id,
|
||||
is_group=inbound.is_group,
|
||||
group_name=group_name,
|
||||
),
|
||||
)
|
||||
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
|
||||
scope = "group" if inbound.is_group else "direct"
|
||||
pe = PolicyEngine()
|
||||
blob = (inbound.text or "").lower()
|
||||
mention_all = ("@all" in blob) or ("全体" in inbound.text) or ("@所有" in inbound.text)
|
||||
act = ActionPolicyContext(
|
||||
session_id=session_id,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
channel=inbound.channel,
|
||||
user_text=inbound.text,
|
||||
action="send_message",
|
||||
target={"is_group": bool(inbound.is_group), "mention_all": bool(mention_all)},
|
||||
)
|
||||
d = pe.decide_action(ctx=act)
|
||||
if d.needs_confirmation:
|
||||
token_key = f"confirm_token:{session_id}"
|
||||
token = (store.get_setting(token_key) or "").strip()
|
||||
if not token:
|
||||
token = pe.new_confirmation_token()
|
||||
store.set_setting(token_key, token)
|
||||
if not has_explicit_confirmation_token(inbound.text, token):
|
||||
reply = f"该动作需要确认。请回复 `confirm {token}` 或包含 `[confirm:{token}]`。"
|
||||
else:
|
||||
reply = f"[assistant] ok scope={scope} session={session_id[:8]} (confirmed)"
|
||||
else:
|
||||
if not _role_can_write(role, inbound.text):
|
||||
reply = "你的角色暂无写入权限。请联系管理员提升权限。"
|
||||
else:
|
||||
cmd_reply = _handle_productivity_commands(
|
||||
text=inbound.text,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
if cmd_reply is not None:
|
||||
reply = cmd_reply
|
||||
elif not reply:
|
||||
user_text = (inbound.text or "").strip()
|
||||
if user_text:
|
||||
try:
|
||||
from oclaw.openclaw_runtime.gateway import OpenClawGateway
|
||||
from oclaw.openclaw_runtime.types import StandardMessage
|
||||
|
||||
agent = _get_gateway_agent(store)
|
||||
gw = OpenClawGateway(store=store)
|
||||
msg = StandardMessage(
|
||||
session_id=str(session_id),
|
||||
tenant_id=str(tenant_id or ""),
|
||||
user_id=str(user_id or ""),
|
||||
role=str(role or "member"),
|
||||
channel=str(inbound.channel or "inbound"),
|
||||
text=str(user_text or ""),
|
||||
attachments=[],
|
||||
metadata={
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": user_id,
|
||||
"channel": inbound.channel,
|
||||
"role": role,
|
||||
"account_id": account_id,
|
||||
},
|
||||
)
|
||||
reply = str(gw.handle_turn(msg=msg, lang="zh", executor=agent).reply_text or "").strip()
|
||||
except Exception as e:
|
||||
reply = f"抱歉,处理消息时出错:{type(e).__name__}: {e}"
|
||||
else:
|
||||
reply = "收到消息,但内容为空。请直接发送文本。"
|
||||
if preface:
|
||||
if reply:
|
||||
reply = f"{preface}\n\n{reply}"
|
||||
else:
|
||||
reply = f"{preface}\n\n{_menu_text()}"
|
||||
|
||||
if adapter is not None:
|
||||
replies = [adapter.format_outbound(OutboundMessage(external_chat_id=inbound.external_chat_id, text=reply))]
|
||||
else:
|
||||
replies = [
|
||||
{
|
||||
"channel": inbound.channel,
|
||||
"chat_id": inbound.external_chat_id,
|
||||
"text": reply,
|
||||
"attachments": [],
|
||||
"metadata": {},
|
||||
}
|
||||
]
|
||||
out = {"ok": True, "replies": replies}
|
||||
return out
|
||||
|
||||
|
||||
__all__ = ["process_inbound_payload"]
|
||||
|
||||
13
application/gateway/inbound_usecase.py
Normal file
13
application/gateway/inbound_usecase.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .inbound_service import process_inbound_payload
|
||||
|
||||
def process_inbound_payload_usecase(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Application use-case entry for inbound gateway payload handling."""
|
||||
return process_inbound_payload(payload)
|
||||
|
||||
|
||||
__all__ = ["process_inbound_payload_usecase", "process_inbound_payload"]
|
||||
|
||||
4
channels/__init__.py
Normal file
4
channels/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from __future__ import annotations
|
||||
|
||||
__all__ = []
|
||||
|
||||
46
channels/base.py
Normal file
46
channels/base.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InboundMessage:
|
||||
channel: str
|
||||
external_user_id: str
|
||||
external_chat_id: str
|
||||
text: str
|
||||
is_group: bool = False
|
||||
mentions: list[str] = field(default_factory=list)
|
||||
attachments: list[dict[str, Any]] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutboundMessage:
|
||||
external_chat_id: str
|
||||
text: str
|
||||
attachments: list[dict[str, Any]] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class ChannelAdapter(Protocol):
|
||||
channel_name: str
|
||||
|
||||
def parse_inbound(self, payload: dict[str, Any]) -> InboundMessage:
|
||||
raise NotImplementedError
|
||||
|
||||
def format_outbound(self, msg: OutboundMessage) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def safe_json_loads(raw: str) -> dict[str, Any]:
|
||||
try:
|
||||
obj = json.loads(raw or "")
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
__all__ = ["InboundMessage", "OutboundMessage", "ChannelAdapter", "safe_json_loads"]
|
||||
6
channels/wecom/__init__.py
Normal file
6
channels/wecom/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .wecom_bridge import WeComAdapter
|
||||
|
||||
__all__ = ["WeComAdapter"]
|
||||
|
||||
737
channels/wecom/longconn_runner.py
Normal file
737
channels/wecom/longconn_runner.py
Normal file
|
|
@ -0,0 +1,737 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
import uuid
|
||||
import queue
|
||||
import threading
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from oclaw.application.gateway import process_inbound_payload_usecase
|
||||
from oclaw.channels.wecom.normalize import normalize_wecom_event, normalize_wecom_event_batch
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.integrations.wecom_client import WeComClient
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
def _safe_json(obj: Any) -> str:
|
||||
try:
|
||||
return json.dumps(obj, ensure_ascii=True, default=str)
|
||||
except Exception:
|
||||
return str(obj)
|
||||
|
||||
|
||||
def _account_id_from_payload(payload: dict[str, Any], store: SqliteStore) -> str:
|
||||
meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
for key in ("aibotid", "bot_id", "account_id"):
|
||||
v = meta.get(key)
|
||||
if v:
|
||||
return str(v).strip()
|
||||
for key in ("aibotid", "bot_id", "account_id"):
|
||||
v = payload.get(key)
|
||||
if v:
|
||||
return str(v).strip()
|
||||
raw = payload.get("raw")
|
||||
if isinstance(raw, dict):
|
||||
for key in ("aibotid", "bot_id", "account_id"):
|
||||
v = raw.get(key)
|
||||
if v:
|
||||
return str(v).strip()
|
||||
return str(store.get_setting("wecom_bot_id") or "").strip()
|
||||
|
||||
|
||||
def _sanitize_outbound_text(text: str, *, max_chars: int = 1800) -> str:
|
||||
s = str(text or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
# Decode escaped newlines so WeCom shows real line breaks.
|
||||
s = s.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\N", "\n")
|
||||
# Remove hidden reasoning block before delivering to end users.
|
||||
lower = s.lower()
|
||||
start_tag = "<redacted_thinking>"
|
||||
end_tag = "</redacted_thinking>"
|
||||
if start_tag in lower and end_tag in lower:
|
||||
start = lower.find(start_tag)
|
||||
end = lower.find(end_tag, start)
|
||||
if end >= 0:
|
||||
s = (s[:start] + s[end + len(end_tag) :]).strip()
|
||||
if s.startswith(start_tag):
|
||||
s = s[len(start_tag) :].strip()
|
||||
# Collapse excessive blank lines for better mobile display.
|
||||
while "\n\n\n" in s:
|
||||
s = s.replace("\n\n\n", "\n\n")
|
||||
if len(s) > max_chars:
|
||||
s = s[:max_chars].rstrip() + "\n\n(回复过长,已截断)"
|
||||
return s
|
||||
|
||||
|
||||
class _SingleInstanceLock:
|
||||
def __init__(self, lock_path: Path) -> None:
|
||||
self.lock_path = lock_path
|
||||
self.fh: Any | None = None
|
||||
|
||||
def acquire(self) -> None:
|
||||
self.lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.fh = open(self.lock_path, "a+b")
|
||||
self.fh.seek(0)
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt # type: ignore
|
||||
|
||||
msvcrt.locking(self.fh.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl # type: ignore
|
||||
|
||||
fcntl.flock(self.fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"wecom_longconn_already_running: {self.lock_path}") from exc
|
||||
|
||||
def release(self) -> None:
|
||||
if self.fh is None:
|
||||
return
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt # type: ignore
|
||||
|
||||
self.fh.seek(0)
|
||||
msvcrt.locking(self.fh.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl # type: ignore
|
||||
|
||||
fcntl.flock(self.fh.fileno(), fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.fh = None
|
||||
|
||||
|
||||
def _http_get_json(url: str, timeout: float = 15.0) -> dict[str, Any]:
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
obj = json.loads(raw or "{}")
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
|
||||
|
||||
def _http_post_json(url: str, payload: dict[str, Any], timeout: float = 10.0) -> dict[str, Any]:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={"content-type": "application/json", "accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
obj = json.loads(raw or "{}")
|
||||
except Exception:
|
||||
return {"ok": True, "raw": raw}
|
||||
return obj if isinstance(obj, dict) else {"ok": True, "raw": raw}
|
||||
|
||||
|
||||
def _retry_count() -> int:
|
||||
raw = str(os.getenv("WECOM_LONGCONN_SEND_RETRY") or "2").strip()
|
||||
return max(1, min(int(raw) if raw.isdigit() else 2, 5))
|
||||
|
||||
|
||||
def _load_mock_events() -> list[dict[str, Any]]:
|
||||
seed = str(os.getenv("WECOM_LONGCONN_MOCK_TEXT") or "帮助").strip()
|
||||
return [
|
||||
{
|
||||
"user_id": "u_mock_001",
|
||||
"chat_id": "u_mock_001",
|
||||
"text": seed,
|
||||
"is_group": False,
|
||||
"msgid": f"mock-{int(time.time())}",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _load_events_once(mode: str) -> list[dict[str, Any]]:
|
||||
if mode == "mock":
|
||||
return _load_mock_events()
|
||||
if mode == "pull":
|
||||
url = str(os.getenv("WECOM_LONGCONN_PULL_URL") or "").strip()
|
||||
if not url:
|
||||
raise RuntimeError("missing WECOM_LONGCONN_PULL_URL when mode=pull")
|
||||
obj = _http_get_json(url)
|
||||
return normalize_wecom_event_batch(obj)
|
||||
raise RuntimeError(f"unsupported WECOM_LONGCONN_MODE: {mode}")
|
||||
|
||||
|
||||
def _run_ws_forever(*, sender: WeComClient, deliver_outbound: bool, use_response_url: bool) -> int:
|
||||
try:
|
||||
import websocket # type: ignore
|
||||
except Exception as exc:
|
||||
raise RuntimeError("websocket-client not installed; run: pip install websocket-client") from exc
|
||||
bot_id, bot_secret = sender.get_bot_credentials()
|
||||
ws_url = str(os.getenv("WECOM_LONGCONN_WS_URL") or "wss://openws.work.weixin.qq.com").strip()
|
||||
seen_ids: deque[str] = deque(maxlen=2000)
|
||||
seen_set: set[str] = set()
|
||||
print(f"[wecom-longconn] websocket connecting url={ws_url} bot={bot_id}")
|
||||
store = sender.store
|
||||
workers_raw = (
|
||||
str(store.get_setting("AIA_WECOM_LONGCONN_WORKERS") or "").strip()
|
||||
or str(store.get_setting("WECOM_LONGCONN_WORKERS") or "").strip()
|
||||
or str(os.getenv("AIA_WECOM_LONGCONN_WORKERS") or "").strip()
|
||||
or str(os.getenv("WECOM_LONGCONN_WORKERS") or "").strip()
|
||||
)
|
||||
workers = 2
|
||||
if workers_raw.isdigit():
|
||||
workers = max(1, min(int(workers_raw), 8))
|
||||
in_max_raw = (
|
||||
str(store.get_setting("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
|
||||
or str(store.get_setting("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
|
||||
or str(os.getenv("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
|
||||
or str(os.getenv("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip()
|
||||
)
|
||||
in_q_max = 200
|
||||
if in_max_raw.isdigit():
|
||||
in_q_max = max(20, min(int(in_max_raw), 5000))
|
||||
inbound_q: "queue.Queue[tuple[dict[str, Any], dict[str, Any]]]" = queue.Queue(maxsize=in_q_max)
|
||||
outbound_q: "queue.Queue[dict[str, Any]]" = queue.Queue()
|
||||
ws_ref: dict[str, Any] = {"ws": None}
|
||||
stop_sender = threading.Event()
|
||||
|
||||
def _drain_outbound_queue(*, ws: Any) -> None:
|
||||
while True:
|
||||
try:
|
||||
ob = outbound_q.get_nowait()
|
||||
except Exception:
|
||||
break
|
||||
try:
|
||||
if not deliver_outbound:
|
||||
print("[wecom-longconn] outbound_skipped", _safe_json(ob))
|
||||
continue
|
||||
text = str(ob.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
response_url = str(ob.get("response_url") or "").strip()
|
||||
req_id = str(ob.get("callback_req_id") or uuid.uuid4().hex)
|
||||
rsp = {
|
||||
"cmd": "aibot_respond_msg",
|
||||
"headers": {"req_id": req_id},
|
||||
"body": {"msgtype": "markdown", "markdown": {"content": text}},
|
||||
}
|
||||
sent = False
|
||||
if use_response_url and response_url:
|
||||
try:
|
||||
cb_payload = {"msgtype": "text", "text": {"content": text}}
|
||||
cb_res: dict[str, Any] = {}
|
||||
cb_err = -1
|
||||
for _i in range(_retry_count()):
|
||||
cb_res = _http_post_json(response_url, cb_payload, timeout=12)
|
||||
cb_err = (
|
||||
int(cb_res.get("errcode"))
|
||||
if isinstance(cb_res, dict) and str(cb_res.get("errcode", "")).strip() != ""
|
||||
else 0
|
||||
)
|
||||
if cb_err == 0:
|
||||
break
|
||||
if cb_err == 0:
|
||||
sent = True
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_mode", "response_url")
|
||||
store.set_setting("wecom_last_outbound_error", "")
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] outbound_sent_response_url", _safe_json({"url": response_url, "res": cb_res}))
|
||||
else:
|
||||
try:
|
||||
store.set_setting(
|
||||
"wecom_last_outbound_error",
|
||||
f"response_url_errcode={cb_err}: {json.dumps(cb_res, ensure_ascii=False)}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
print(
|
||||
"[wecom-longconn] response_url_send_not_ok_fallback_ws",
|
||||
_safe_json({"url": response_url, "res": cb_res}),
|
||||
)
|
||||
except Exception as exc:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_error", f"response_url:{type(exc).__name__}: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[wecom-longconn] response_url_send_error: {type(exc).__name__}: {exc}")
|
||||
if not sent:
|
||||
ws_ok = False
|
||||
ws_err = ""
|
||||
for _i in range(_retry_count()):
|
||||
try:
|
||||
ws.send(json.dumps(rsp, ensure_ascii=False))
|
||||
ws_ok = True
|
||||
ws_err = ""
|
||||
break
|
||||
except Exception as exc:
|
||||
ws_err = f"{type(exc).__name__}: {exc}"
|
||||
time.sleep(0.15)
|
||||
if ws_ok:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_mode", "ws")
|
||||
store.set_setting("wecom_last_outbound_error", "")
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] outbound_sent_ws", _safe_json(rsp))
|
||||
else:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_error", f"ws_send_failed:{ws_err}")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
outbound_q.task_done()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _sender_loop() -> None:
|
||||
while not stop_sender.is_set():
|
||||
try:
|
||||
# Block briefly to react immediately after worker enqueues replies.
|
||||
ob = outbound_q.get(timeout=0.3)
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
ws_obj = ws_ref.get("ws")
|
||||
if ws_obj is None:
|
||||
# websocket reconnecting; put back and retry soon.
|
||||
outbound_q.put(ob)
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
if not deliver_outbound:
|
||||
print("[wecom-longconn] outbound_skipped", _safe_json(ob))
|
||||
continue
|
||||
text = str(ob.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
response_url = str(ob.get("response_url") or "").strip()
|
||||
req_id = str(ob.get("callback_req_id") or uuid.uuid4().hex)
|
||||
rsp = {
|
||||
"cmd": "aibot_respond_msg",
|
||||
"headers": {"req_id": req_id},
|
||||
"body": {"msgtype": "markdown", "markdown": {"content": text}},
|
||||
}
|
||||
sent = False
|
||||
if use_response_url and response_url:
|
||||
try:
|
||||
cb_payload = {"msgtype": "text", "text": {"content": text}}
|
||||
cb_res: dict[str, Any] = {}
|
||||
cb_err = -1
|
||||
for _i in range(_retry_count()):
|
||||
cb_res = _http_post_json(response_url, cb_payload, timeout=12)
|
||||
cb_err = (
|
||||
int(cb_res.get("errcode"))
|
||||
if isinstance(cb_res, dict) and str(cb_res.get("errcode", "")).strip() != ""
|
||||
else 0
|
||||
)
|
||||
if cb_err == 0:
|
||||
break
|
||||
if cb_err == 0:
|
||||
sent = True
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_mode", "response_url")
|
||||
store.set_setting("wecom_last_outbound_error", "")
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] outbound_sent_response_url", _safe_json({"url": response_url, "res": cb_res}))
|
||||
else:
|
||||
try:
|
||||
store.set_setting(
|
||||
"wecom_last_outbound_error",
|
||||
f"response_url_errcode={cb_err}: {json.dumps(cb_res, ensure_ascii=False)}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] response_url_send_not_ok_fallback_ws", _safe_json({"url": response_url, "res": cb_res}))
|
||||
except Exception as exc:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_error", f"response_url:{type(exc).__name__}: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[wecom-longconn] response_url_send_error: {type(exc).__name__}: {exc}")
|
||||
if not sent:
|
||||
ws_ok = False
|
||||
ws_err = ""
|
||||
for _i in range(_retry_count()):
|
||||
try:
|
||||
ws_obj.send(json.dumps(rsp, ensure_ascii=False))
|
||||
ws_ok = True
|
||||
ws_err = ""
|
||||
break
|
||||
except Exception as exc:
|
||||
ws_err = f"{type(exc).__name__}: {exc}"
|
||||
time.sleep(0.15)
|
||||
if ws_ok:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_mode", "ws")
|
||||
store.set_setting("wecom_last_outbound_error", "")
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] outbound_sent_ws", _safe_json(rsp))
|
||||
else:
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_error", f"ws_send_failed:{ws_err}")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
outbound_q.task_done()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _worker_loop(idx: int) -> None:
|
||||
while True:
|
||||
payload, meta2 = inbound_q.get()
|
||||
try:
|
||||
out = process_inbound_payload_usecase(payload)
|
||||
replies = out.get("replies") if isinstance(out, dict) else []
|
||||
if not isinstance(replies, list):
|
||||
replies = []
|
||||
for rep in replies:
|
||||
if not isinstance(rep, dict):
|
||||
continue
|
||||
text = str(rep.get("text") or "").strip()
|
||||
text = _sanitize_outbound_text(text)
|
||||
if not text:
|
||||
continue
|
||||
outbound_q.put(
|
||||
{
|
||||
"callback_req_id": str(meta2.get("callback_req_id") or ""),
|
||||
"response_url": str(meta2.get("response_url") or ""),
|
||||
"text": text,
|
||||
"raw_rep": rep,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
outbound_q.put(
|
||||
{
|
||||
"callback_req_id": str(meta2.get("callback_req_id") or ""),
|
||||
"response_url": str(meta2.get("response_url") or ""),
|
||||
"text": f"[wecom-longconn] worker_error: {type(exc).__name__}: {exc}",
|
||||
"raw_rep": {},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
inbound_q.task_done()
|
||||
|
||||
for i in range(workers):
|
||||
threading.Thread(target=_worker_loop, args=(i,), name=f"wecom_worker_{i}", daemon=True).start()
|
||||
threading.Thread(target=_sender_loop, name="wecom_sender", daemon=True).start()
|
||||
|
||||
while True:
|
||||
ws = None
|
||||
try:
|
||||
ws = websocket.create_connection(ws_url, timeout=30)
|
||||
ws.settimeout(60)
|
||||
ws_ref["ws"] = ws
|
||||
sub = {
|
||||
"cmd": "aibot_subscribe",
|
||||
"headers": {"req_id": uuid.uuid4().hex},
|
||||
"body": {"bot_id": bot_id, "secret": bot_secret},
|
||||
}
|
||||
ws.send(json.dumps(sub, ensure_ascii=False))
|
||||
print("[wecom-longconn] subscribe sent")
|
||||
while True:
|
||||
try:
|
||||
raw = ws.recv()
|
||||
except websocket.WebSocketTimeoutException:
|
||||
# Idle timeout is expected when no inbound message arrives.
|
||||
# Keep the connection alive instead of reconnecting.
|
||||
try:
|
||||
ws.ping()
|
||||
print("[wecom-longconn] ping")
|
||||
except Exception:
|
||||
raise
|
||||
# Drain outbound queue on idle ticks so replies are pushed immediately,
|
||||
# not delayed until the next inbound message.
|
||||
_drain_outbound_queue(ws=ws)
|
||||
continue
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
print("[wecom-longconn] non_json_message", raw)
|
||||
continue
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
cmd = str(msg.get("cmd") or "").strip()
|
||||
if not cmd:
|
||||
body0 = msg.get("body") if isinstance(msg.get("body"), dict) else {}
|
||||
cmd = str(body0.get("cmd") or body0.get("type") or msg.get("type") or "").strip()
|
||||
if not cmd and "errcode" in msg and "errmsg" in msg:
|
||||
ack_req_id = ""
|
||||
h = msg.get("headers")
|
||||
if isinstance(h, dict):
|
||||
ack_req_id = str(h.get("req_id") or "").strip()
|
||||
ack_err = int(msg.get("errcode") or 0)
|
||||
ack_msg = str(msg.get("errmsg") or "").strip()
|
||||
try:
|
||||
store.set_setting("wecom_last_ack_req_id", ack_req_id)
|
||||
store.set_setting("wecom_last_ack_errcode", str(ack_err))
|
||||
store.set_setting("wecom_last_ack_errmsg", ack_msg)
|
||||
if ack_err == 0:
|
||||
store.set_setting("wecom_last_outbound_error", "")
|
||||
else:
|
||||
store.set_setting("wecom_last_outbound_error", f"ack_errcode={ack_err}: {ack_msg}")
|
||||
except Exception:
|
||||
pass
|
||||
print("[wecom-longconn] outbound_ack", _safe_json(msg))
|
||||
continue
|
||||
try:
|
||||
store.set_setting("wecom_last_cmd", cmd)
|
||||
except Exception:
|
||||
pass
|
||||
if cmd == "aibot_subscribe_rsp":
|
||||
print("[wecom-longconn] subscribe_rsp", json.dumps(msg, ensure_ascii=False))
|
||||
continue
|
||||
if cmd not in ("aibot_msg_callback", "aibot_event_callback"):
|
||||
try:
|
||||
store.set_setting(
|
||||
"wecom_last_unknown_cmd_payload",
|
||||
json.dumps(msg, ensure_ascii=False, default=str)[:4000],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
print(
|
||||
"[wecom-longconn] unknown_cmd",
|
||||
_safe_json(
|
||||
{"cmd": cmd, "keys": sorted(msg.keys())[:20]},
|
||||
),
|
||||
)
|
||||
continue
|
||||
body = msg.get("body") if isinstance(msg.get("body"), dict) else {}
|
||||
headers = msg.get("headers") if isinstance(msg.get("headers"), dict) else {}
|
||||
callback_req_id = str(headers.get("req_id") or "").strip()
|
||||
try:
|
||||
store.set_setting(
|
||||
"wecom_last_raw_body",
|
||||
json.dumps(body, ensure_ascii=False, default=str)[:4000],
|
||||
)
|
||||
from_obj = body.get("from")
|
||||
from_uid = ""
|
||||
if isinstance(from_obj, dict):
|
||||
from_uid = str(from_obj.get("userid") or from_obj.get("user_id") or from_obj.get("id") or "").strip()
|
||||
elif isinstance(from_obj, str):
|
||||
from_uid = from_obj.strip()
|
||||
if from_uid:
|
||||
store.set_setting("wecom_last_raw_from_user", from_uid)
|
||||
except Exception:
|
||||
pass
|
||||
if cmd == "aibot_event_callback":
|
||||
event_type = str(body.get("event") or body.get("event_type") or body.get("type") or "").strip()
|
||||
event_obj = body.get("event") if isinstance(body.get("event"), dict) else {}
|
||||
event_type_norm = str(
|
||||
event_obj.get("eventtype")
|
||||
or event_obj.get("type")
|
||||
or event_type
|
||||
).strip()
|
||||
from_obj = body.get("from")
|
||||
from_user = ""
|
||||
if isinstance(from_obj, dict):
|
||||
from_user = str(
|
||||
from_obj.get("userid")
|
||||
or from_obj.get("user_id")
|
||||
or from_obj.get("id")
|
||||
or from_obj.get("from_user_id")
|
||||
or ""
|
||||
).strip()
|
||||
elif isinstance(from_obj, str):
|
||||
from_user = from_obj.strip()
|
||||
print(
|
||||
"[wecom-longconn] event_callback",
|
||||
_safe_json(
|
||||
{
|
||||
"event_type": event_type_norm or event_type,
|
||||
"from_user": from_user,
|
||||
"keys": sorted(body.keys())[:20],
|
||||
},
|
||||
),
|
||||
)
|
||||
if (event_type_norm or event_type).lower() == "disconnected_event":
|
||||
try:
|
||||
store.set_setting("wecom_last_parse_error", "disconnected_event")
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
try:
|
||||
payload = normalize_wecom_event(body)
|
||||
except Exception as exc:
|
||||
print(
|
||||
"[wecom-longconn] skip_invalid_msg_callback",
|
||||
_safe_json(
|
||||
{
|
||||
"error": f"{type(exc).__name__}: {exc}",
|
||||
"keys": sorted(body.keys())[:30],
|
||||
},
|
||||
),
|
||||
)
|
||||
try:
|
||||
store.set_setting("wecom_last_parse_error", f"{type(exc).__name__}: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
try:
|
||||
store.set_setting(
|
||||
"wecom_last_normalized_payload",
|
||||
json.dumps(payload, ensure_ascii=False, default=str)[:4000],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
msgid = str(body.get("msgid") or payload.get("metadata", {}).get("msgid") or "").strip()
|
||||
if msgid and msgid in seen_set:
|
||||
continue
|
||||
if msgid:
|
||||
if len(seen_ids) >= seen_ids.maxlen and seen_ids:
|
||||
old = seen_ids.popleft()
|
||||
seen_set.discard(old)
|
||||
seen_ids.append(msgid)
|
||||
seen_set.add(msgid)
|
||||
try:
|
||||
now = str(int(time.time()))
|
||||
user_id = str(payload.get("user_id") or "").strip()
|
||||
store.set_setting("wecom_last_msg_ts", now)
|
||||
if user_id:
|
||||
store.set_setting("wecom_last_from_user", user_id)
|
||||
raw_recent = str(store.get_setting("wecom_recent_from_users") or "[]")
|
||||
recent = json.loads(raw_recent)
|
||||
if not isinstance(recent, list):
|
||||
recent = []
|
||||
recent = [x for x in recent if isinstance(x, dict)]
|
||||
if user_id:
|
||||
recent = [x for x in recent if str(x.get("user_id") or "") != user_id]
|
||||
recent.insert(0, {"user_id": user_id, "ts": now})
|
||||
store.set_setting("wecom_recent_from_users", json.dumps(recent[:20], ensure_ascii=False))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
inbound_q.put_nowait(
|
||||
(
|
||||
payload,
|
||||
{
|
||||
"callback_req_id": callback_req_id or uuid.uuid4().hex,
|
||||
"response_url": str(body.get("response_url") or "").strip(),
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Inbound backlog; drop gracefully to keep the websocket loop healthy.
|
||||
try:
|
||||
store.set_setting("wecom_last_outbound_error", "inbound_queue_full")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Drain outbound queue opportunistically.
|
||||
_drain_outbound_queue(ws=ws)
|
||||
except KeyboardInterrupt:
|
||||
print("[wecom-longconn] stopped by user")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"[wecom-longconn] ws_loop_error: {type(exc).__name__}: {exc}")
|
||||
time.sleep(3)
|
||||
finally:
|
||||
ws_ref["ws"] = None
|
||||
if ws is not None:
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_forever() -> int:
|
||||
store = SqliteStore(db_path())
|
||||
sender = WeComClient(store)
|
||||
lock = _SingleInstanceLock(Path(db_path()).resolve().parent / "locks" / "wecom_longconn.lock")
|
||||
lock.acquire()
|
||||
default_mode = "ws"
|
||||
mode = str(os.getenv("WECOM_LONGCONN_MODE") or default_mode).strip().lower()
|
||||
interval_s = max(1.0, float(os.getenv("WECOM_LONGCONN_INTERVAL_SEC") or "3"))
|
||||
deliver_outbound = str(os.getenv("WECOM_LONGCONN_DELIVER_OUTBOUND") or "1").strip().lower() not in (
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
)
|
||||
seen_ids: deque[str] = deque(maxlen=2000)
|
||||
seen_set: set[str] = set()
|
||||
|
||||
# Prefer response_url by default for lower latency and better delivery semantics.
|
||||
use_response_url = str(os.getenv("WECOM_LONGCONN_USE_RESPONSE_URL") or "1").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
print(
|
||||
f"[wecom-longconn] started mode={mode} interval={interval_s}s outbound={deliver_outbound} use_response_url={use_response_url}"
|
||||
)
|
||||
try:
|
||||
if mode == "ws":
|
||||
return _run_ws_forever(
|
||||
sender=sender,
|
||||
deliver_outbound=deliver_outbound,
|
||||
use_response_url=use_response_url,
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
events = _load_events_once(mode)
|
||||
if not events:
|
||||
time.sleep(interval_s)
|
||||
continue
|
||||
for evt in events:
|
||||
payload = normalize_wecom_event(evt)
|
||||
meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
msgid = str(meta.get("msgid") or "").strip()
|
||||
if msgid and msgid in seen_set:
|
||||
continue
|
||||
if msgid:
|
||||
if len(seen_ids) >= seen_ids.maxlen and seen_ids:
|
||||
old = seen_ids.popleft()
|
||||
seen_set.discard(old)
|
||||
seen_ids.append(msgid)
|
||||
seen_set.add(msgid)
|
||||
out = process_inbound_payload_usecase(payload)
|
||||
replies = out.get("replies") if isinstance(out, dict) else []
|
||||
if not isinstance(replies, list):
|
||||
replies = []
|
||||
for rep in replies:
|
||||
if not isinstance(rep, dict):
|
||||
continue
|
||||
if not deliver_outbound:
|
||||
print("[wecom-longconn] outbound_skipped", _safe_json(rep))
|
||||
continue
|
||||
to_user = str(payload.get("user_id") or "").strip()
|
||||
text = str(rep.get("text") or "").strip()
|
||||
text = _sanitize_outbound_text(text)
|
||||
if not to_user or not text:
|
||||
continue
|
||||
aid = _account_id_from_payload(payload, store)
|
||||
print(
|
||||
"[wecom-longconn] outbound_skipped_http_removed use_ws_mode",
|
||||
_safe_json({"to_user": to_user, "account_id": aid or "", "text_len": len(text)}),
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("[wecom-longconn] stopped by user")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"[wecom-longconn] loop_error: {type(exc).__name__}: {exc}")
|
||||
time.sleep(interval_s)
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return run_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
127
channels/wecom/normalize.py
Normal file
127
channels/wecom/normalize.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _first_non_empty(*values: Any) -> str:
|
||||
for v in values:
|
||||
s = str(v or "").strip()
|
||||
if s:
|
||||
return s
|
||||
return ""
|
||||
|
||||
|
||||
def _pick(d: dict[str, Any], *keys: str) -> Any:
|
||||
for k in keys:
|
||||
if k in d:
|
||||
return d.get(k)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_text(raw: dict[str, Any]) -> str:
|
||||
direct_text = _pick(raw, "text")
|
||||
if isinstance(direct_text, str) and direct_text.strip():
|
||||
return direct_text.strip()
|
||||
direct = _first_non_empty(
|
||||
_pick(raw, "content", "Content"),
|
||||
)
|
||||
if direct:
|
||||
return direct
|
||||
text_obj = raw.get("text")
|
||||
if isinstance(text_obj, dict):
|
||||
return _first_non_empty(_pick(text_obj, "content", "Content"))
|
||||
text_raw_obj = raw.get("text_raw")
|
||||
if isinstance(text_raw_obj, dict):
|
||||
return _first_non_empty(_pick(text_raw_obj, "content", "Content", "text"))
|
||||
content_obj = raw.get("content")
|
||||
if isinstance(content_obj, dict):
|
||||
return _first_non_empty(_pick(content_obj, "text", "content", "Content"))
|
||||
if isinstance(raw.get("msg"), dict):
|
||||
return _first_non_empty(_pick(raw.get("msg", {}), "text", "content", "Content"))
|
||||
msg_obj = raw.get("message")
|
||||
if isinstance(msg_obj, dict):
|
||||
return _first_non_empty(
|
||||
_pick(msg_obj, "text", "content", "Content"),
|
||||
_pick(msg_obj.get("text", {}), "content") if isinstance(msg_obj.get("text"), dict) else None,
|
||||
)
|
||||
payload = raw.get("payload")
|
||||
if isinstance(payload, dict):
|
||||
return _first_non_empty(
|
||||
_pick(payload, "text", "content", "Content"),
|
||||
_pick(payload.get("text", {}), "content") if isinstance(payload.get("text"), dict) else None,
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_wecom_event(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert WeCom-like raw events into normalized gateway payload."""
|
||||
chat_id = _first_non_empty(
|
||||
_pick(raw, "chat_id", "conversation_id", "conversationId", "chatid", "roomid", "RoomId"),
|
||||
_pick(raw.get("message", {}), "chat_id", "conversation_id", "chatid")
|
||||
if isinstance(raw.get("message"), dict)
|
||||
else None,
|
||||
_pick(raw.get("chat", {}), "id", "chat_id", "chatid", "roomid")
|
||||
if isinstance(raw.get("chat"), dict)
|
||||
else None,
|
||||
_pick(raw.get("conversation", {}), "id", "chat_id", "chatid")
|
||||
if isinstance(raw.get("conversation"), dict)
|
||||
else None,
|
||||
_pick(raw.get("room", {}), "id", "roomid", "chatid")
|
||||
if isinstance(raw.get("room"), dict)
|
||||
else None,
|
||||
)
|
||||
from_obj = raw.get("from")
|
||||
user_id = _first_non_empty(
|
||||
_pick(raw, "user_id", "from_user_id", "fromUserId", "FromUserName", "userid", "external_userid"),
|
||||
from_obj if isinstance(from_obj, str) else None,
|
||||
_pick(from_obj, "userid", "user_id", "id", "from_user_id", "UserId", "userid64", "uid")
|
||||
if isinstance(from_obj, dict)
|
||||
else None,
|
||||
_pick(raw.get("sender", {}), "userid", "user_id", "id") if isinstance(raw.get("sender"), dict) else None,
|
||||
_pick(raw.get("message", {}), "from_user_id", "fromUserId") if isinstance(raw.get("message"), dict) else None,
|
||||
chat_id,
|
||||
)
|
||||
if not chat_id:
|
||||
chat_id = user_id
|
||||
text = _extract_text(raw)
|
||||
msgid = _first_non_empty(_pick(raw, "msgid", "msg_id", "id"), _pick(raw.get("message", {}), "msgid", "id"))
|
||||
agentid = _first_non_empty(_pick(raw, "agentid", "agent_id"), _pick(raw.get("message", {}), "agentid"))
|
||||
|
||||
chat_type = _first_non_empty(_pick(raw, "chat_type", "conversation_type"))
|
||||
is_group = bool(raw.get("is_group")) or chat_type in ("group", "room")
|
||||
if not is_group and chat_id:
|
||||
is_group = chat_id.endswith("@chatroom")
|
||||
|
||||
# If group but room id was missing, we previously fell back chat_id=user_id — same key as private 1:1.
|
||||
if is_group and chat_id == user_id:
|
||||
chat_id = f"group:unknown:{user_id}"
|
||||
|
||||
return {
|
||||
"channel": "wecom",
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id or user_id,
|
||||
"text": text,
|
||||
"is_group": is_group,
|
||||
"metadata": {
|
||||
"agentid": agentid,
|
||||
"msgid": msgid,
|
||||
"source": "wecom_longconn",
|
||||
"raw": raw,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def normalize_wecom_event_batch(obj: Any) -> list[dict[str, Any]]:
|
||||
"""Extract event list from common envelopes returned by pull APIs."""
|
||||
if isinstance(obj, list):
|
||||
return [x for x in obj if isinstance(x, dict)]
|
||||
if not isinstance(obj, dict):
|
||||
return []
|
||||
for key in ("events", "messages", "items", "data"):
|
||||
arr = obj.get(key)
|
||||
if isinstance(arr, list):
|
||||
return [x for x in arr if isinstance(x, dict)]
|
||||
return [obj]
|
||||
|
||||
|
||||
__all__ = ["normalize_wecom_event", "normalize_wecom_event_batch"]
|
||||
49
channels/wecom/wecom_bridge.py
Normal file
49
channels/wecom/wecom_bridge.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from oclaw.channels.base import ChannelAdapter, InboundMessage, OutboundMessage
|
||||
|
||||
|
||||
class WeComAdapter(ChannelAdapter):
|
||||
channel_name = "wecom"
|
||||
|
||||
def parse_inbound(self, payload: dict[str, Any]) -> InboundMessage:
|
||||
user_id = str(payload.get("user_id") or payload.get("external_user_id") or "").strip()
|
||||
chat_id = str(payload.get("chat_id") or payload.get("external_chat_id") or user_id).strip()
|
||||
text = str(payload.get("text") or "").strip()
|
||||
if not user_id:
|
||||
raise ValueError("missing user_id")
|
||||
if not chat_id:
|
||||
chat_id = user_id
|
||||
is_group = bool(payload.get("is_group"))
|
||||
if is_group and chat_id == user_id:
|
||||
chat_id = f"group:unknown:{user_id}"
|
||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
mentions_raw = payload.get("mentions")
|
||||
mentions: list[str] = []
|
||||
if isinstance(mentions_raw, list):
|
||||
mentions = [str(x).strip() for x in mentions_raw if str(x).strip()]
|
||||
attachments = payload.get("attachments") if isinstance(payload.get("attachments"), list) else []
|
||||
return InboundMessage(
|
||||
channel=self.channel_name,
|
||||
external_user_id=user_id,
|
||||
external_chat_id=chat_id,
|
||||
text=text,
|
||||
is_group=is_group,
|
||||
mentions=mentions,
|
||||
attachments=[a for a in attachments if isinstance(a, dict)],
|
||||
metadata={str(k): v for k, v in metadata.items()},
|
||||
)
|
||||
|
||||
def format_outbound(self, msg: OutboundMessage) -> dict[str, Any]:
|
||||
return {
|
||||
"channel": self.channel_name,
|
||||
"chat_id": msg.external_chat_id,
|
||||
"text": msg.text,
|
||||
"attachments": msg.attachments,
|
||||
"metadata": msg.metadata,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["WeComAdapter"]
|
||||
1
chat/__init__.py
Normal file
1
chat/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# oclaw.chat package
|
||||
278
chat/agent.py
Normal file
278
chat/agent.py
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from oclaw.tools.base import ToolRegistry
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.platform.llm.chat_models import (
|
||||
ChatModel,
|
||||
LLMResponse,
|
||||
LLMToolCall,
|
||||
OpenAIChatModel,
|
||||
RuleBasedChatModel,
|
||||
StaticTextChatModel,
|
||||
_normalize_image_b64_payload,
|
||||
build_default_model,
|
||||
gemini_openai_compat_client,
|
||||
)
|
||||
from oclaw.prompts.loader import render_prompt_for_lang
|
||||
from oclaw.tools.tool_validation import validate_tool_arguments
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SESSION_TITLE_MAX_LEN = 120
|
||||
AGENT_CONTEXT_MESSAGES = 80
|
||||
|
||||
DEFAULT_SYSTEM_PROMPTS: dict[str, str] = {
|
||||
"zh": render_prompt_for_lang("runtime/default_system", "zh", strict=True),
|
||||
"en": render_prompt_for_lang("runtime/default_system", "en", strict=True),
|
||||
}
|
||||
|
||||
|
||||
class GenerationInterrupted(Exception):
|
||||
"""用户请求中止当前生成过程。"""
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentConfig:
|
||||
max_messages: int = AGENT_CONTEXT_MESSAGES
|
||||
max_tool_rounds: int = 8
|
||||
max_tool_workers: int = 8
|
||||
|
||||
|
||||
class Agent:
|
||||
def __init__(
|
||||
self,
|
||||
store: SqliteStore,
|
||||
tools: ToolRegistry,
|
||||
model: Optional[ChatModel] = None,
|
||||
config: Optional[AgentConfig] = None,
|
||||
system_prompt: str | None = None,
|
||||
lang: str = "zh",
|
||||
llm_profile_mode: str | None = None,
|
||||
):
|
||||
self.store = store
|
||||
self.tools = tools
|
||||
self.model = model or build_default_model()
|
||||
self.config = config or AgentConfig()
|
||||
self.lang = (lang or "zh").strip().lower()
|
||||
self._system_prompt_base = (system_prompt or DEFAULT_SYSTEM_PROMPTS.get(self.lang, DEFAULT_SYSTEM_PROMPTS["zh"])).strip()
|
||||
self.llm_profile_mode = ((llm_profile_mode or "").strip().lower() or None)
|
||||
self._last_turn_outcome: Any | None = None
|
||||
|
||||
def _native_tools_sent_by_api(self) -> bool:
|
||||
"""当前模型这一侧是否会把 tools 放进请求(与 ``llm.OpenAIChatModel._skip_tools`` 对齐)。"""
|
||||
m = self.model
|
||||
if isinstance(m, (RuleBasedChatModel, StaticTextChatModel)):
|
||||
return False
|
||||
if isinstance(m, OpenAIChatModel):
|
||||
return not bool(m._skip_tools)
|
||||
return False
|
||||
|
||||
def _compose_system_prompt(self) -> str:
|
||||
"""系统正文。工具 schema 始终通过原生 tools 字段下发,不再拼接到 prompt。"""
|
||||
return self._system_prompt_base
|
||||
|
||||
def _format_ollama_failure_banner(self, exc: BaseException) -> str:
|
||||
# Backward compat wrapper; implementation lives in `src.chat.agent_errors`.
|
||||
from oclaw.chat.agent_errors import format_ollama_failure_banner
|
||||
|
||||
return format_ollama_failure_banner(lang=self.lang, exc=exc)
|
||||
|
||||
def _format_openai_transport_error(self, exc: BaseException) -> str:
|
||||
# Backward compat wrapper; implementation lives in `src.chat.agent_errors`.
|
||||
from oclaw.chat.agent_errors import format_openai_transport_error
|
||||
|
||||
return format_openai_transport_error(lang=self.lang, exc=exc)
|
||||
|
||||
def _invoke_tool(self, tc: LLMToolCall) -> tuple[dict[str, Any], int]:
|
||||
t0 = time.perf_counter()
|
||||
tool = self.tools.get(tc.name)
|
||||
if not tool:
|
||||
msg = f"Unregistered tool: {tc.name}" if self.lang.startswith("en") else f"未注册的工具: {tc.name}"
|
||||
return {"ok": False, "error": msg}, int((time.perf_counter() - t0) * 1000)
|
||||
|
||||
ok, v_err = validate_tool_arguments(tool.parameters, tc.arguments)
|
||||
if not ok:
|
||||
msg = f"Invalid arguments: {v_err}" if self.lang.startswith("en") else f"参数不合法: {v_err}"
|
||||
return {"ok": False, "error": msg}, int((time.perf_counter() - t0) * 1000)
|
||||
|
||||
try:
|
||||
result = tool.handler(tc.arguments)
|
||||
return result, int((time.perf_counter() - t0) * 1000)
|
||||
except Exception as e:
|
||||
if self.lang.startswith("en"):
|
||||
err = {"ok": False, "error": f"Tool execution error: {type(e).__name__}: {e}"}
|
||||
else:
|
||||
err = {"ok": False, "error": f"工具执行异常: {type(e).__name__}: {e}"}
|
||||
return err, int((time.perf_counter() - t0) * 1000)
|
||||
|
||||
def _emit_progress(self, on_progress: Optional[Callable[[str], None]], en: str, zh: str) -> None:
|
||||
if on_progress:
|
||||
on_progress(en if self.lang.startswith("en") else zh)
|
||||
|
||||
@staticmethod
|
||||
def _attachments_from_tool_result(result: Any) -> list[dict[str, Any]]:
|
||||
"""Extract image/relay references from tool results for rendering."""
|
||||
if not isinstance(result, dict):
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
aid = str(result.get("attachment_id") or "").strip()
|
||||
if aid:
|
||||
out.append(
|
||||
{
|
||||
"type": "image_ref",
|
||||
"attachment_id": aid,
|
||||
"name": str(result.get("name") or "generated-image"),
|
||||
"mime": str(result.get("mime") or "image/png"),
|
||||
"bytes": result.get("bytes"),
|
||||
"width": result.get("width"),
|
||||
"height": result.get("height"),
|
||||
}
|
||||
)
|
||||
refs = result.get("attachments")
|
||||
if isinstance(refs, list):
|
||||
for r in refs:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
# Relay pointer payload (new protocol).
|
||||
p_uri = str(r.get("pointer_uri") or "").strip()
|
||||
if p_uri:
|
||||
out.append(
|
||||
{
|
||||
"type": "relay_pointer",
|
||||
"pointer_uri": p_uri,
|
||||
"rel_path": str(r.get("rel_path") or ""),
|
||||
"mime": str(r.get("mime_type") or r.get("mime") or ""),
|
||||
"bytes": r.get("bytes"),
|
||||
"sha256": str(r.get("sha256") or ""),
|
||||
"name": str(r.get("name") or ""),
|
||||
}
|
||||
)
|
||||
continue
|
||||
r_aid = str(r.get("attachment_id") or "").strip()
|
||||
if not r_aid:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"type": "image_ref",
|
||||
"attachment_id": r_aid,
|
||||
"name": str(r.get("name") or "generated-image"),
|
||||
"mime": str(r.get("mime") or "image/png"),
|
||||
"bytes": r.get("bytes"),
|
||||
"width": r.get("width"),
|
||||
"height": r.get("height"),
|
||||
}
|
||||
)
|
||||
# de-dup by attachment_id
|
||||
uniq: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for a in out:
|
||||
k = str(a.get("attachment_id") or a.get("pointer_uri") or "")
|
||||
if not k or k in seen:
|
||||
continue
|
||||
seen.add(k)
|
||||
uniq.append(a)
|
||||
return uniq
|
||||
|
||||
def run_turn(
|
||||
self,
|
||||
session_id: str,
|
||||
user_text: str,
|
||||
attachments: list[dict[str, Any]] | None = None,
|
||||
on_progress: Optional[Callable[[str], None]] = None,
|
||||
on_token: Optional[Callable[[str], None]] = None,
|
||||
on_tool_ui: Optional[Callable[[str, dict[str, Any]], None]] = None,
|
||||
should_stop: Optional[Callable[[], bool]] = None,
|
||||
*,
|
||||
workspace_owner_session_id: str | None = None,
|
||||
path_policy_tenant_id: str | None = None,
|
||||
path_policy_user_id: str | None = None,
|
||||
interaction_mode: str | None = None,
|
||||
selected_specialist: str | None = None,
|
||||
) -> str:
|
||||
from oclaw.openclaw_runtime.gateway import OpenClawGateway
|
||||
from oclaw.openclaw_runtime.types import StandardMessage
|
||||
|
||||
tenant_id = str(path_policy_tenant_id or "").strip()
|
||||
user_id = str(path_policy_user_id or "").strip()
|
||||
if not tenant_id or not user_id:
|
||||
try:
|
||||
owner = self.store.get_ui_session_owner(session_id=session_id)
|
||||
except Exception:
|
||||
owner = None
|
||||
if isinstance(owner, dict):
|
||||
tenant_id = tenant_id or str(owner.get("tenant_id") or "")
|
||||
user_id = user_id or str(owner.get("user_id") or "")
|
||||
|
||||
session = self.store.get_session(session_id)
|
||||
if session and session.title in ("新会话", "New Chat"):
|
||||
title = user_text.strip().replace("\n", " ")
|
||||
if not title and attachments:
|
||||
title = str(attachments[0].get("name") or "New Chat")
|
||||
if title:
|
||||
self.store.rename_session(session_id, title[:SESSION_TITLE_MAX_LEN])
|
||||
|
||||
self._emit_progress(
|
||||
on_progress,
|
||||
"Received. Working on your request…",
|
||||
"已收到,正在处理…",
|
||||
)
|
||||
|
||||
meta: dict[str, Any] = {"tenant_id": tenant_id, "user_id": user_id}
|
||||
if workspace_owner_session_id:
|
||||
meta["workspace_owner_session_id"] = str(workspace_owner_session_id).strip()
|
||||
if str(interaction_mode or "").strip():
|
||||
meta["interaction_mode"] = str(interaction_mode).strip().lower()
|
||||
if str(selected_specialist or "").strip():
|
||||
meta["selected_specialist"] = str(selected_specialist).strip().lower()
|
||||
|
||||
msg = StandardMessage(
|
||||
session_id=session_id,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
role="member",
|
||||
channel="agent_turn",
|
||||
text=str(user_text or ""),
|
||||
attachments=list(attachments or []),
|
||||
metadata=meta,
|
||||
)
|
||||
gw = OpenClawGateway(store=self.store)
|
||||
try:
|
||||
res = gw.handle_turn(
|
||||
msg=msg,
|
||||
lang=self.lang,
|
||||
executor=self,
|
||||
on_token=on_token,
|
||||
on_progress=on_progress,
|
||||
on_tool_ui=on_tool_ui,
|
||||
should_stop=should_stop,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
low = str(e).lower()
|
||||
if "interrupted" in low and "user" in low:
|
||||
raise GenerationInterrupted(str(e)) from e
|
||||
raise
|
||||
|
||||
self._last_turn_outcome = getattr(self, "_last_turn_outcome", None)
|
||||
return str(res.reply_text or "")
|
||||
|
||||
def _build_llm_messages(self, session_id: str) -> list[dict[str, Any]]:
|
||||
from oclaw.chat.agent_messages import build_llm_messages
|
||||
|
||||
msgs = self.store.get_messages(session_id=session_id, limit=self.config.max_messages)
|
||||
return build_llm_messages(
|
||||
store_messages=msgs,
|
||||
system_prompt=self._compose_system_prompt(),
|
||||
model=self.model,
|
||||
lang=self.lang,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["AgentConfig", "DEFAULT_SYSTEM_PROMPTS", "GenerationInterrupted", "Agent"]
|
||||
69
chat/agent_errors.py
Normal file
69
chat/agent_errors.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
"""Agent 错误处理模块。
|
||||
|
||||
把 `Agent` 内的错误格式化逻辑下沉到此处,方便 manager/specialist 复用。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from oclaw.prompts import render_prompt
|
||||
|
||||
|
||||
def format_ollama_failure_banner(*, lang: str, exc: BaseException) -> str:
|
||||
prompt_id = "fallback/ollama_failure.en.md" if (lang or "zh").startswith("en") else "fallback/ollama_failure.zh.md"
|
||||
return render_prompt(
|
||||
prompt_id,
|
||||
variables={"error_type": type(exc).__name__, "error_message": str(exc)},
|
||||
strict=True,
|
||||
)
|
||||
|
||||
|
||||
def format_openai_transport_error(*, lang: str, exc: BaseException) -> str:
|
||||
blob = str(exc).lower()
|
||||
oversized_tool = ("30000" in blob or "input length" in blob) and ("range" in blob or "length" in blob)
|
||||
gemini_sig = "thought_signature" in blob
|
||||
if (lang or "zh").startswith("en"):
|
||||
if oversized_tool:
|
||||
return render_prompt(
|
||||
"fallback/openai_transport_oversized.en.md",
|
||||
variables={"error_type": type(exc).__name__, "error_message": str(exc)},
|
||||
strict=True,
|
||||
)
|
||||
tail = (
|
||||
"\n\n_Gemini 3 with tools: the API requires echoing `thought_signature` from each tool use in chat history. "
|
||||
"If this persists, update the app or use a model/SDK path that preserves provider-specific tool fields._"
|
||||
if gemini_sig
|
||||
else ""
|
||||
)
|
||||
return render_prompt(
|
||||
"fallback/openai_transport_error.en.md",
|
||||
variables={"error_type": type(exc).__name__, "error_message": str(exc), "extra_tail": tail},
|
||||
strict=True,
|
||||
)
|
||||
if oversized_tool:
|
||||
return render_prompt(
|
||||
"fallback/openai_transport_oversized.zh.md",
|
||||
variables={"error_type": type(exc).__name__, "error_message": str(exc)},
|
||||
strict=True,
|
||||
)
|
||||
tail = (
|
||||
"\n\n(**Gemini 3 + 工具调用**:接口要求把模型返回的 **thought_signature** 随该次 `tool_calls` 一并写回对话历史;"
|
||||
"首轮能跑工具、第二轮 400 多为丢失该字段。若已更新本应用仍报错,请确认代理/OpenAI 兼容层是否透传该字段。)"
|
||||
if gemini_sig
|
||||
else ""
|
||||
)
|
||||
return render_prompt(
|
||||
"fallback/openai_transport_error.zh.md",
|
||||
variables={"error_type": type(exc).__name__, "error_message": str(exc), "extra_tail": tail},
|
||||
strict=True,
|
||||
)
|
||||
|
||||
|
||||
def safe_str(e: Any) -> str:
|
||||
try:
|
||||
return str(e)
|
||||
except Exception:
|
||||
return repr(e)
|
||||
|
||||
|
||||
__all__ = ["format_ollama_failure_banner", "format_openai_transport_error", "safe_str"]
|
||||
494
chat/agent_messages.py
Normal file
494
chat/agent_messages.py
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
from __future__ import annotations
|
||||
|
||||
"""Agent 消息构建模块。
|
||||
|
||||
把 `Agent._build_llm_messages` 的职责下沉到此处,便于:
|
||||
- Manager 决策/Final merge 复用同一套“消息规范化与附件注入”规则
|
||||
- 后续 Workspace/RAG/Trace 插入上下文时有单一入口
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from oclaw.platform.llm.chat_models import _normalize_image_b64_payload, gemini_openai_compat_client, ChatModel
|
||||
from oclaw.chat.tool_runtime import tool_llm_message_max_chars, truncate_tool_result_for_llm_messages
|
||||
from oclaw.prompts import render_prompt
|
||||
from oclaw.platform.files.attachment_assets import attachment_id_to_data_url
|
||||
from oclaw.openclaw_runtime.relay_pointer import parse_pointer_uri
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_THINK_BLOCK_RE = re.compile(r"<think>\s*(.*?)\s*</think>\s*", flags=re.IGNORECASE | re.DOTALL)
|
||||
|
||||
|
||||
def _replay_recent_tool_rounds() -> int:
|
||||
raw = str(os.getenv("AIA_REPLAY_TOOL_FULL_ROUNDS") or "").strip()
|
||||
if raw.isdigit():
|
||||
return max(0, min(int(raw), 12))
|
||||
return 3
|
||||
|
||||
|
||||
def _allow_reasoning_signature_replay(model: ChatModel) -> bool:
|
||||
# - auto (default): only providers that require signature continuity (Gemini paths).
|
||||
# - on: always include signature metadata on assistant tool_calls.
|
||||
# - off: never include signature metadata.
|
||||
policy = str(os.getenv("AIA_REPLAY_REASONING_SIGNATURE_POLICY") or "auto").strip().lower()
|
||||
if policy in ("0", "off", "false", "no"):
|
||||
return False
|
||||
if policy in ("1", "on", "true", "yes"):
|
||||
return True
|
||||
if gemini_openai_compat_client(model):
|
||||
return True
|
||||
return model.__class__.__name__ == "GoogleGeminiChatModel"
|
||||
|
||||
|
||||
def _strip_reasoning_blocks(text: str) -> str:
|
||||
return _THINK_BLOCK_RE.sub("", str(text or "")).strip()
|
||||
|
||||
|
||||
def _parse_tool_calls(raw_tc: Any) -> list[dict[str, Any]]:
|
||||
if not raw_tc:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return [x for x in data if isinstance(x, dict)]
|
||||
|
||||
|
||||
def _tool_call_id_from_tool_row(raw_tc: Any) -> str:
|
||||
if not raw_tc:
|
||||
return ""
|
||||
try:
|
||||
meta = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc
|
||||
except Exception:
|
||||
return ""
|
||||
if not isinstance(meta, dict):
|
||||
return ""
|
||||
return str(meta.get("tool_call_id") or "").strip()
|
||||
|
||||
|
||||
def _collect_historical_tool_call_ids(store_messages: list[Any], *, full_rounds: int) -> set[str]:
|
||||
if full_rounds < 0:
|
||||
full_rounds = 0
|
||||
full_ids: set[str] = set()
|
||||
rounds = 0
|
||||
for m in reversed(store_messages or []):
|
||||
role = str(getattr(m, "role", "") or "")
|
||||
if role != "assistant":
|
||||
continue
|
||||
tcs = _parse_tool_calls(getattr(m, "tool_calls", None))
|
||||
tc_ids = [str(tc.get("id") or "").strip() for tc in tcs if str(tc.get("id") or "").strip()]
|
||||
if not tc_ids:
|
||||
continue
|
||||
rounds += 1
|
||||
if rounds <= full_rounds:
|
||||
full_ids.update(tc_ids)
|
||||
historical_ids: set[str] = set()
|
||||
for m in store_messages or []:
|
||||
if str(getattr(m, "role", "") or "") != "tool":
|
||||
continue
|
||||
tcid = _tool_call_id_from_tool_row(getattr(m, "tool_calls", None))
|
||||
if tcid and tcid not in full_ids:
|
||||
historical_ids.add(tcid)
|
||||
return historical_ids
|
||||
|
||||
|
||||
def _summarize_historical_tool_content(raw: str, *, cap: int) -> str:
|
||||
s = str(raw or "").strip()
|
||||
if not s:
|
||||
return json.dumps({"ok": None, "summary": "", "_history_summarized": True}, ensure_ascii=False)
|
||||
out: dict[str, Any] = {"_history_summarized": True}
|
||||
try:
|
||||
obj = json.loads(s)
|
||||
except Exception:
|
||||
preview = s[: max(1, cap - 120)] + ("\n...<truncated>" if len(s) > cap else "")
|
||||
out["summary"] = preview
|
||||
return json.dumps(out, ensure_ascii=False)
|
||||
if not isinstance(obj, dict):
|
||||
out["summary"] = s[: max(1, cap - 120)] + ("\n...<truncated>" if len(s) > cap else "")
|
||||
return json.dumps(out, ensure_ascii=False)
|
||||
out["ok"] = obj.get("ok")
|
||||
for key in ("error_code", "error", "hint"):
|
||||
v = str(obj.get(key) or "").strip()
|
||||
if v:
|
||||
out[key] = v
|
||||
if "result" in obj:
|
||||
r = obj.get("result")
|
||||
if isinstance(r, dict):
|
||||
out["result_keys"] = sorted(list(r.keys()))[:20]
|
||||
preview = s[: max(1, cap - 260)] + ("\n...<truncated>" if len(s) > cap else "")
|
||||
out["preview"] = preview
|
||||
return json.dumps(out, ensure_ascii=False)
|
||||
|
||||
|
||||
def _summarize_unpaired_tool_content(raw: str, *, cap: int) -> str:
|
||||
"""Best-effort summarize tool JSON for model-friendly context."""
|
||||
s = str(raw or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
if cap > 0 and len(s) > cap:
|
||||
s = s[: max(1, cap - 80)] + "\n...<truncated>"
|
||||
try:
|
||||
obj = json.loads(s)
|
||||
except Exception:
|
||||
return s
|
||||
if not isinstance(obj, dict):
|
||||
return s
|
||||
lines: list[str] = []
|
||||
ok = obj.get("ok")
|
||||
if ok is not None:
|
||||
lines.append(f"ok={bool(ok)}")
|
||||
ec = str(obj.get("error_code") or "").strip()
|
||||
if ec:
|
||||
lines.append(f"error_code={ec}")
|
||||
err = str(obj.get("error") or "").strip()
|
||||
if err:
|
||||
lines.append(f"error={err}")
|
||||
hint = str(obj.get("hint") or "").strip()
|
||||
if hint:
|
||||
lines.append(f"hint={hint}")
|
||||
# Extract MCP-style text blocks when present.
|
||||
try:
|
||||
nested = obj.get("result")
|
||||
content = None
|
||||
if isinstance(nested, dict):
|
||||
content = nested.get("content")
|
||||
if isinstance(content, list):
|
||||
texts = []
|
||||
for b in content:
|
||||
if isinstance(b, dict) and str(b.get("type") or "").strip().lower() == "text":
|
||||
t = str(b.get("text") or "").strip()
|
||||
if t:
|
||||
texts.append(t)
|
||||
if texts:
|
||||
lines.append("content_text=" + " | ".join(texts)[: min(800, cap)])
|
||||
except Exception:
|
||||
pass
|
||||
head = " ".join(lines).strip()
|
||||
if head:
|
||||
return head + "\n" + s
|
||||
return s
|
||||
|
||||
|
||||
def build_llm_messages(
|
||||
*,
|
||||
store_messages: list[Any],
|
||||
system_prompt: str,
|
||||
model: ChatModel,
|
||||
lang: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""把 DB 中的消息序列转换为 LLM messages。"""
|
||||
out: list[dict[str, Any]] = [{"role": "system", "content": (system_prompt or "").strip()}]
|
||||
allow_signature_replay = _allow_reasoning_signature_replay(model)
|
||||
historical_tool_ids = _collect_historical_tool_call_ids(
|
||||
store_messages=store_messages, full_rounds=_replay_recent_tool_rounds()
|
||||
)
|
||||
# Some OpenAI-compatible gateways error if a tool message references a tool_call_id
|
||||
# that is not present in the assistant tool_calls within the same request context.
|
||||
# This can happen when context windows are trimmed and the assistant tool_calls row is dropped.
|
||||
valid_tool_call_ids: set[str] = set()
|
||||
for m in store_messages:
|
||||
role = str(getattr(m, "role", "") or "")
|
||||
event_type = str(getattr(m, "event_type", "") or "").strip().lower()
|
||||
if event_type == "reasoning":
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
content_list: list[dict[str, Any]] = []
|
||||
text = getattr(m, "content", None)
|
||||
if text:
|
||||
content_list.append({"type": "text", "text": str(text)})
|
||||
|
||||
attachments = []
|
||||
raw_att = getattr(m, "attachments", None)
|
||||
if raw_att:
|
||||
try:
|
||||
attachments = json.loads(raw_att) if isinstance(raw_att, str) else raw_att
|
||||
except Exception:
|
||||
attachments = []
|
||||
|
||||
for att in attachments or []:
|
||||
if not isinstance(att, dict):
|
||||
continue
|
||||
att_type = att.get("type")
|
||||
if att_type in ("image", "input_image"):
|
||||
b64 = _normalize_image_b64_payload(att.get("image_base64") or att.get("data"))
|
||||
if not b64:
|
||||
continue
|
||||
content_list.append(
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_base64": b64,
|
||||
"mime": att.get("mime") or "image/jpeg",
|
||||
}
|
||||
)
|
||||
elif att_type == "image_ref":
|
||||
# Prefer actual image bytes so multi-agent/image specialist can truly "see" history images.
|
||||
name = str(att.get("name") or "image")
|
||||
mime = str(att.get("mime") or "image/jpeg")
|
||||
aid = str(att.get("attachment_id") or "")
|
||||
data_url = attachment_id_to_data_url(aid, mime=mime) if aid else ""
|
||||
if data_url:
|
||||
if ";base64," in data_url:
|
||||
b64 = data_url.split(";base64,", 1)[1]
|
||||
content_list.append(
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_base64": b64,
|
||||
"mime": mime,
|
||||
}
|
||||
)
|
||||
continue
|
||||
w = att.get("width")
|
||||
h = att.get("height")
|
||||
sz = att.get("bytes")
|
||||
meta_line = f"- name={name} mime={mime} id={aid}"
|
||||
if w and h:
|
||||
meta_line += f" size={w}x{h}"
|
||||
if sz:
|
||||
meta_line += f" bytes={sz}"
|
||||
content_list.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": render_prompt(
|
||||
"tools/image_attachment_meta.md",
|
||||
variables={"meta_line": meta_line},
|
||||
strict=True,
|
||||
),
|
||||
}
|
||||
)
|
||||
elif att_type == "text":
|
||||
name = att.get("name", "file")
|
||||
text_content = att.get("content", "")
|
||||
content_list.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": render_prompt(
|
||||
"tools/text_attachment_wrap.md",
|
||||
variables={"name": str(name), "content": str(text_content)},
|
||||
strict=True,
|
||||
),
|
||||
}
|
||||
)
|
||||
elif att_type == "tabular_ref":
|
||||
name = str(att.get("name") or "table")
|
||||
table_id = str(att.get("table_id") or "")
|
||||
rows = int(att.get("rows") or 0)
|
||||
cols = int(att.get("cols") or 0)
|
||||
aid = str(att.get("attachment_id") or "")
|
||||
sheets = att.get("sheets") if isinstance(att.get("sheets"), list) else []
|
||||
sheet_hint = ""
|
||||
if sheets:
|
||||
names = [str((x or {}).get("sheet_name") or "") for x in sheets if isinstance(x, dict)]
|
||||
names = [x for x in names if x]
|
||||
if names:
|
||||
sheet_hint = f"\n- sheets: {', '.join(names[:8])}"
|
||||
content_list.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": (
|
||||
f"[LargeTableAttachment]\n"
|
||||
f"- name: {name}\n"
|
||||
f"- table_id: {table_id}\n"
|
||||
f"- attachment_id: {aid}\n"
|
||||
f"- rows: {rows}\n"
|
||||
f"- cols: {cols}\n"
|
||||
f"{sheet_hint}\n"
|
||||
f"- tools: query_tabular_attachment, run_tabular_sql, analyze_tabular_attachment_full_scan"
|
||||
),
|
||||
}
|
||||
)
|
||||
elif att_type == "relay_pointer":
|
||||
p_uri = str(att.get("pointer_uri") or "").strip()
|
||||
if not p_uri:
|
||||
continue
|
||||
mime = str(att.get("mime") or att.get("mime_type") or "").strip()
|
||||
aid = str(att.get("attachment_id") or "").strip()
|
||||
if (not aid) and p_uri:
|
||||
try:
|
||||
_scope, _fid = parse_pointer_uri(p_uri)
|
||||
aid = str(_fid or "").strip()
|
||||
except Exception:
|
||||
aid = ""
|
||||
if aid and mime.startswith("image/"):
|
||||
data_url = attachment_id_to_data_url(aid, mime=mime)
|
||||
if data_url and ";base64," in data_url:
|
||||
b64 = data_url.split(";base64,", 1)[1]
|
||||
content_list.append(
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_base64": b64,
|
||||
"mime": mime or "image/jpeg",
|
||||
}
|
||||
)
|
||||
rel_path = str(att.get("rel_path") or "").strip()
|
||||
sz = att.get("bytes")
|
||||
sha = str(att.get("sha256") or "").strip()
|
||||
pointer_line = f"- pointer_uri={p_uri}"
|
||||
if rel_path:
|
||||
pointer_line += f" rel_path={rel_path}"
|
||||
if mime:
|
||||
pointer_line += f" mime={mime}"
|
||||
if sz:
|
||||
pointer_line += f" bytes={sz}"
|
||||
if sha:
|
||||
pointer_line += f" sha256={sha}"
|
||||
content_list.append({"type": "text", "text": pointer_line})
|
||||
|
||||
if not content_list:
|
||||
placeholder = "(No text content)" if str(lang or "").startswith("en") else "(无文本内容)"
|
||||
content_list.append({"type": "text", "text": placeholder})
|
||||
|
||||
if len(content_list) == 1 and content_list[0].get("type") == "text":
|
||||
out.append({"role": "user", "content": content_list[0]["text"]})
|
||||
else:
|
||||
out.append({"role": "user", "content": content_list})
|
||||
continue
|
||||
|
||||
if role == "assistant":
|
||||
tool_calls = None
|
||||
raw_tc = getattr(m, "tool_calls", None)
|
||||
if raw_tc:
|
||||
try:
|
||||
tool_calls = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc
|
||||
except Exception:
|
||||
tool_calls = None
|
||||
|
||||
if tool_calls and isinstance(tool_calls, list):
|
||||
api_tool_calls = []
|
||||
gemini_fc = gemini_openai_compat_client(model)
|
||||
for idx, tc in enumerate(tool_calls):
|
||||
if not isinstance(tc, dict) or not tc.get("id") or not tc.get("name"):
|
||||
continue
|
||||
try:
|
||||
valid_tool_call_ids.add(str(tc.get("id") or ""))
|
||||
except Exception:
|
||||
pass
|
||||
entry: dict[str, Any] = {
|
||||
"id": tc.get("id"),
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.get("name"),
|
||||
"arguments": json.dumps(tc.get("arguments", {}), ensure_ascii=False),
|
||||
},
|
||||
}
|
||||
raw_sig = tc.get("thought_signature")
|
||||
if allow_signature_replay and gemini_fc:
|
||||
if isinstance(raw_sig, str):
|
||||
sig = raw_sig
|
||||
elif idx == 0:
|
||||
sig = "skip_thought_signature_validator"
|
||||
else:
|
||||
sig = ""
|
||||
entry["extra_content"] = {"google": {"thought_signature": sig}}
|
||||
elif allow_signature_replay and isinstance(raw_sig, str):
|
||||
entry["extra_content"] = {"google": {"thought_signature": raw_sig}}
|
||||
api_tool_calls.append(entry)
|
||||
if api_tool_calls:
|
||||
out.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": _strip_reasoning_blocks(getattr(m, "content", "") or ""),
|
||||
"tool_calls": api_tool_calls,
|
||||
}
|
||||
)
|
||||
else:
|
||||
out.append({"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")})
|
||||
else:
|
||||
out.append({"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")})
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
tool_call_id = None
|
||||
raw_tc = getattr(m, "tool_calls", None)
|
||||
if raw_tc:
|
||||
try:
|
||||
meta = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc
|
||||
if isinstance(meta, dict):
|
||||
tool_call_id = meta.get("tool_call_id")
|
||||
except Exception:
|
||||
tool_call_id = None
|
||||
if tool_call_id is not None:
|
||||
try:
|
||||
tool_call_id = str(tool_call_id).strip()
|
||||
except Exception:
|
||||
tool_call_id = ""
|
||||
if tool_call_id:
|
||||
# Guard against dangling tool_call_id (assistant tool_calls missing from this trimmed context window).
|
||||
if str(tool_call_id) not in valid_tool_call_ids:
|
||||
# Preserve tool evidence, but downgrade to plain assistant text when pairing is broken.
|
||||
# Some OpenAI-compatible gateways reject a role=tool message if tool_call_id cannot be paired
|
||||
# to an assistant.tool_calls.id within the same request context.
|
||||
r0 = getattr(m, "content", "") or ""
|
||||
cap0 = tool_llm_message_max_chars()
|
||||
pretty = _summarize_unpaired_tool_content(r0, cap=cap0)
|
||||
out.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": render_prompt(
|
||||
"tools/tool_result_unpaired.md",
|
||||
variables={"tag": "tool_use_result:unpaired", "payload": pretty},
|
||||
strict=True,
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
raw_tc_content = getattr(m, "content", "") or ""
|
||||
tool_content_out = raw_tc_content
|
||||
cap = tool_llm_message_max_chars()
|
||||
if str(tool_call_id) in historical_tool_ids:
|
||||
summary_cap = 1800
|
||||
if cap > 0:
|
||||
summary_cap = max(600, min(2400, cap // 3))
|
||||
tool_content_out = _summarize_historical_tool_content(raw_tc_content, cap=summary_cap)
|
||||
elif cap > 0 and len(raw_tc_content) > cap:
|
||||
try:
|
||||
parsed = json.loads(raw_tc_content)
|
||||
if isinstance(parsed, dict):
|
||||
tool_content_out = json.dumps(
|
||||
truncate_tool_result_for_llm_messages(parsed), ensure_ascii=False, default=str
|
||||
)
|
||||
else:
|
||||
tool_content_out = raw_tc_content[: max(1, cap - 80)] + "\n...<truncated>"
|
||||
except Exception:
|
||||
tool_content_out = raw_tc_content[: max(1, cap - 80)] + "\n...<truncated>"
|
||||
tool_row: dict[str, Any] = {
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": tool_content_out,
|
||||
}
|
||||
# Some OpenAI-compatible gateways expect `call_id` instead of `tool_call_id`.
|
||||
# Sending both (non-empty) keeps compatibility; servers should ignore unknown fields.
|
||||
tool_row["call_id"] = tool_call_id
|
||||
try:
|
||||
meta2 = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc
|
||||
except Exception:
|
||||
meta2 = None
|
||||
if isinstance(meta2, dict) and meta2.get("name"):
|
||||
tool_row["name"] = str(meta2["name"])
|
||||
out.append(tool_row)
|
||||
else:
|
||||
r = getattr(m, "content", "") or ""
|
||||
cap2 = tool_llm_message_max_chars()
|
||||
pretty2 = _summarize_unpaired_tool_content(r, cap=cap2)
|
||||
out.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": render_prompt(
|
||||
"tools/tool_result_unpaired.md",
|
||||
variables={"tag": "tool_use_result:no_id", "payload": pretty2},
|
||||
strict=True,
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
return out
|
||||
|
||||
|
||||
__all__ = ["build_llm_messages"]
|
||||
630
chat/tool_runtime.py
Normal file
630
chat/tool_runtime.py
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
from __future__ import annotations
|
||||
|
||||
"""Agent 工具执行模块。
|
||||
|
||||
本模块把“工具执行(校验/并发/落库/回写)”从 `Agent.run_turn` 中下沉出来,
|
||||
以便被单 Agent 与编排器(manager/specialist)复用。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import os
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.tools.base import ToolRegistry
|
||||
from oclaw.platform.llm.chat_models import LLMToolCall
|
||||
from oclaw.tools.tool_validation import validate_tool_arguments
|
||||
from oclaw.tools.experts.workspace.workspace_base import workspace_path_access_scope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TOOL_ERROR_MAP = {
|
||||
"tool_timeout_or_failed": "tool_timeout_or_failed",
|
||||
}
|
||||
_SQL_REPLAY_COMPACT_TOOL_NAMES = {
|
||||
"query_tabular_attachment",
|
||||
"run_tabular_sql",
|
||||
"analyze_tabular_attachment_full_scan",
|
||||
}
|
||||
|
||||
|
||||
def normalize_tool_result(result: Any) -> dict[str, Any]:
|
||||
if isinstance(result, dict):
|
||||
out = dict(result)
|
||||
if "ok" in out:
|
||||
out["ok"] = bool(out.get("ok"))
|
||||
else:
|
||||
# Backward compatibility: many lightweight tools return payload-only dicts.
|
||||
# Treat those as success unless they explicitly carry error semantics.
|
||||
has_error = bool(str(out.get("error_code") or "").strip() or str(out.get("error") or "").strip())
|
||||
out["ok"] = not has_error
|
||||
else:
|
||||
out = {"ok": False, "error": "tool_result_not_dict", "data": result}
|
||||
if not out["ok"]:
|
||||
raw_ec = str(out.get("error_code") or "").strip()
|
||||
raw_err = str(out.get("error") or "").strip()
|
||||
if not raw_ec:
|
||||
out["error_code"] = _TOOL_ERROR_MAP.get(raw_err, "tool_failed")
|
||||
return out
|
||||
|
||||
|
||||
def tool_llm_message_max_chars() -> int:
|
||||
raw = str(os.getenv("AIA_TOOL_LLM_MESSAGE_MAX_CHARS") or "").strip()
|
||||
if raw.isdigit():
|
||||
n = int(raw)
|
||||
if n == 0:
|
||||
return 0
|
||||
return max(4096, min(n, 500_000))
|
||||
return 0
|
||||
|
||||
|
||||
def tool_history_summary_after_calls() -> int:
|
||||
raw = str(os.getenv("AIA_TOOL_HISTORY_SUMMARY_AFTER_CALLS") or "").strip()
|
||||
if raw.isdigit():
|
||||
return max(0, min(int(raw), 200))
|
||||
# Default: when the same tool is called >= 3 times in one turn, keep history compact.
|
||||
return 3
|
||||
|
||||
|
||||
def _json_blob_size(obj: Any) -> int:
|
||||
try:
|
||||
return len(json.dumps(obj, ensure_ascii=False, default=str))
|
||||
except Exception:
|
||||
return len(repr(obj))
|
||||
|
||||
|
||||
def _estimate_observed_rows(result: dict[str, Any]) -> int:
|
||||
if not isinstance(result, dict):
|
||||
return 0
|
||||
try:
|
||||
rr = result.get("rows_returned")
|
||||
if isinstance(rr, (int, float)):
|
||||
return max(0, int(rr))
|
||||
except Exception:
|
||||
pass
|
||||
rows = result.get("rows")
|
||||
if isinstance(rows, list):
|
||||
return max(0, len(rows))
|
||||
nested = result.get("result")
|
||||
if isinstance(nested, dict):
|
||||
nrows = nested.get("rows")
|
||||
if isinstance(nrows, list):
|
||||
return max(0, len(nrows))
|
||||
return 0
|
||||
|
||||
|
||||
def _deep_truncate_for_llm(obj: Any, *, max_str: int, max_list: int) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return {str(k): _deep_truncate_for_llm(v, max_str=max_str, max_list=max_list) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
items = obj
|
||||
omitted = 0
|
||||
if len(items) > max_list:
|
||||
omitted = len(items) - max_list
|
||||
items = items[:max_list]
|
||||
out: list[Any] = [_deep_truncate_for_llm(x, max_str=max_str, max_list=max_list) for x in items]
|
||||
if omitted:
|
||||
out.append(f"…({omitted} more list items omitted)")
|
||||
return out
|
||||
if isinstance(obj, str) and len(obj) > max_str:
|
||||
return obj[:max_str] + "\n...<truncated>"
|
||||
return obj
|
||||
|
||||
|
||||
def partition_tool_use_batches(
|
||||
tool_uses: list[LLMToolCall],
|
||||
registry: ToolRegistry,
|
||||
) -> list[list[LLMToolCall]]:
|
||||
"""Split tool uses into ordered batches (cc-mini ``Engine.submit`` scheduling).
|
||||
|
||||
Consecutive tools whose ``ToolSpec.is_read_only()`` is true are merged into one batch
|
||||
and may run in parallel when the batch length is greater than one. Any other tool
|
||||
starts a new batch (typically length 1), which runs sequentially relative to other
|
||||
batches and uses a single worker within the batch.
|
||||
"""
|
||||
batches: list[tuple[bool, list[LLMToolCall]]] = []
|
||||
for tc in tool_uses:
|
||||
spec = registry.get(tc.name)
|
||||
is_concurrent = bool(spec and spec.is_read_only())
|
||||
if batches and batches[-1][0] == is_concurrent and is_concurrent:
|
||||
batches[-1][1].append(tc)
|
||||
else:
|
||||
batches.append((is_concurrent, [tc]))
|
||||
return [chunk for _, chunk in batches]
|
||||
|
||||
|
||||
def truncate_tool_result_for_llm_messages(result: dict[str, Any], *, max_chars: int | None = None) -> dict[str, Any]:
|
||||
"""Return a copy safe to put in ``role=tool`` ``content`` so the next LLM request stays under provider limits."""
|
||||
cap = tool_llm_message_max_chars() if max_chars is None else max(0, min(int(max_chars), 500_000))
|
||||
if cap == 0:
|
||||
return result if isinstance(result, dict) else {"ok": False, "error": "tool_result_not_dict", "data": result}
|
||||
if not isinstance(result, dict):
|
||||
return {"ok": False, "error": "tool_result_not_dict", "payload_type": type(result).__name__}
|
||||
if _json_blob_size(result) <= cap:
|
||||
return result
|
||||
orig_files_n = len(result["files"]) if isinstance(result.get("files"), list) else 0
|
||||
pairs = (
|
||||
(12_000, 800),
|
||||
(8000, 500),
|
||||
(4000, 300),
|
||||
(2000, 200),
|
||||
(1200, 120),
|
||||
(800, 80),
|
||||
(500, 50),
|
||||
(400, 40),
|
||||
)
|
||||
for max_str, max_list in pairs:
|
||||
slim = _deep_truncate_for_llm(result, max_str=max_str, max_list=max_list)
|
||||
if not isinstance(slim, dict):
|
||||
slim = {"ok": bool(result.get("ok")), "payload": slim}
|
||||
if _json_blob_size(slim) <= cap:
|
||||
slim = dict(slim)
|
||||
slim["_truncated_for_llm"] = True
|
||||
if orig_files_n and isinstance(slim.get("files"), list):
|
||||
kept = sum(1 for x in slim["files"] if isinstance(x, str))
|
||||
if kept < orig_files_n:
|
||||
slim["files_total"] = orig_files_n
|
||||
slim["files_omitted"] = orig_files_n - kept
|
||||
return slim
|
||||
return {
|
||||
"ok": bool(result.get("ok")),
|
||||
"_truncated_for_llm": True,
|
||||
"hint": (
|
||||
"Tool output exceeded model message size limits. "
|
||||
"Narrow the glob, lower max_results, or list a subdirectory. / "
|
||||
"工具输出超过模型单条消息限制,请缩小列举范围或降低 max_results。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolExecutionConfig:
|
||||
max_workers: int = 8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolExecutionContext:
|
||||
store: SqliteStore
|
||||
tools: ToolRegistry
|
||||
session_id: str
|
||||
lang: str = "zh"
|
||||
user_text: str = ""
|
||||
specialist: str = ""
|
||||
task_kind: str = ""
|
||||
policy_engine: Any | None = None
|
||||
trace_id: str | None = None
|
||||
parent_span_id: str | None = None
|
||||
#: When ``session_id`` is a specialist temp chat row (no ``ui_session_owner``), use the user's UI session for ``extra_roots`` / allowlist.
|
||||
workspace_owner_session_id: str | None = None
|
||||
#: If ``get_ui_session_owner`` fails, load allowlist for this (tenant, user) from the HTTP/gateway request (``metadata``).
|
||||
path_policy_tenant_id: str | None = None
|
||||
path_policy_user_id: str | None = None
|
||||
turn_uuid: str | None = None
|
||||
|
||||
|
||||
class ToolExecutor:
|
||||
"""执行一组 tool uses,并把结果写回 store。"""
|
||||
|
||||
def __init__(self, *, config: ToolExecutionConfig | None = None):
|
||||
self.config = config or ToolExecutionConfig()
|
||||
|
||||
def _execute_tool(self, ctx: ToolExecutionContext, tc: LLMToolCall) -> tuple[dict[str, Any], int]:
|
||||
t0 = time.perf_counter()
|
||||
|
||||
tool = ctx.tools.get(tc.name)
|
||||
if not tool:
|
||||
msg = f"Unregistered tool: {tc.name}" if ctx.lang.startswith("en") else f"未注册的工具: {tc.name}"
|
||||
return {"ok": False, "error_code": "tool_not_registered", "error": msg}, int((time.perf_counter() - t0) * 1000)
|
||||
|
||||
ok, v_err = validate_tool_arguments(tool.parameters, tc.arguments)
|
||||
if not ok:
|
||||
msg = f"Invalid arguments: {v_err}" if ctx.lang.startswith("en") else f"参数不合法: {v_err}"
|
||||
return {"ok": False, "error_code": "tool_invalid_arguments", "error": msg}, int((time.perf_counter() - t0) * 1000)
|
||||
|
||||
try:
|
||||
timeout_s = getattr(tool, "timeout_s", None)
|
||||
# Default timeout for plugin tools if not specified.
|
||||
if timeout_s is None and "plugin" in getattr(tool, "tags", frozenset()):
|
||||
timeout_s = 30.0
|
||||
|
||||
def _call() -> Any:
|
||||
with workspace_path_access_scope(
|
||||
ctx.store,
|
||||
ctx.session_id,
|
||||
owner_fallback_session_id=ctx.workspace_owner_session_id,
|
||||
allowlist_tenant_id=ctx.path_policy_tenant_id,
|
||||
allowlist_user_id=ctx.path_policy_user_id,
|
||||
):
|
||||
return tool.handler(tc.arguments)
|
||||
|
||||
if isinstance(timeout_s, (int, float)) and float(timeout_s) > 0:
|
||||
ex = ThreadPoolExecutor(max_workers=1)
|
||||
fut = ex.submit(_call)
|
||||
try:
|
||||
result = fut.result(timeout=float(timeout_s))
|
||||
except FuturesTimeoutError as e:
|
||||
try:
|
||||
fut.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ex.shutdown(wait=False, cancel_futures=True)
|
||||
except Exception:
|
||||
ex.shutdown(wait=False)
|
||||
return {"ok": False, "error_code": "tool_timeout_or_failed", "error": "tool_timeout_or_failed", "detail": f"{type(e).__name__}: {e}"}, int(
|
||||
(time.perf_counter() - t0) * 1000
|
||||
)
|
||||
except Exception as e:
|
||||
try:
|
||||
ex.shutdown(wait=False, cancel_futures=True)
|
||||
except Exception:
|
||||
ex.shutdown(wait=False)
|
||||
return {"ok": False, "error_code": "tool_timeout_or_failed", "error": "tool_timeout_or_failed", "detail": f"{type(e).__name__}: {e}"}, int(
|
||||
(time.perf_counter() - t0) * 1000
|
||||
)
|
||||
else:
|
||||
try:
|
||||
ex.shutdown(wait=False, cancel_futures=True)
|
||||
except Exception:
|
||||
ex.shutdown(wait=False)
|
||||
else:
|
||||
result = _call()
|
||||
return normalize_tool_result(result), int((time.perf_counter() - t0) * 1000)
|
||||
except Exception as e:
|
||||
if ctx.lang.startswith("en"):
|
||||
err = {"ok": False, "error_code": "tool_execution_error", "error": f"Tool execution error: {type(e).__name__}: {e}"}
|
||||
else:
|
||||
err = {"ok": False, "error_code": "tool_execution_error", "error": f"工具执行异常: {type(e).__name__}: {e}"}
|
||||
return normalize_tool_result(err), int((time.perf_counter() - t0) * 1000)
|
||||
|
||||
@staticmethod
|
||||
def _json_dumps_safe(obj: Any) -> str:
|
||||
try:
|
||||
return json.dumps(obj, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return json.dumps({"ok": False, "error": "tool result is not JSON-serializable"}, ensure_ascii=False)
|
||||
|
||||
def execute_tool_uses(
|
||||
self,
|
||||
*,
|
||||
ctx: ToolExecutionContext,
|
||||
assistant_msg_id: int,
|
||||
tool_uses: list[LLMToolCall],
|
||||
on_tool_ui: Optional[Callable[[str, dict[str, Any]], None]] = None,
|
||||
should_stop: Optional[Callable[[], bool]] = None,
|
||||
signature_budget: int = 2,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, tuple[dict[str, Any], int]]]:
|
||||
"""执行并回写 tool messages。
|
||||
|
||||
Returns:
|
||||
- tool_messages: 用于写入对话 history 的 `role=tool` 消息 payload 列表(与 tool_uses 顺序一致)
|
||||
- results_by_id: tool_call_id -> (result_dict, duration_ms)
|
||||
"""
|
||||
|
||||
def _check_stop() -> None:
|
||||
if should_stop and should_stop():
|
||||
raise RuntimeError("generation interrupted by user")
|
||||
|
||||
def _trace(event_type: str, payload: dict[str, Any]) -> None:
|
||||
if not ctx.trace_id:
|
||||
return
|
||||
try:
|
||||
from oclaw.orchestration.trace import new_span_id
|
||||
|
||||
ctx.store.add_trace_event(
|
||||
session_id=ctx.session_id,
|
||||
trace_id=str(ctx.trace_id),
|
||||
span_id=new_span_id(),
|
||||
parent_span_id=ctx.parent_span_id,
|
||||
event_type=str(event_type),
|
||||
payload=dict(payload or {}),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load_turn_tool_stats() -> tuple[dict[str, int], dict[str, int]]:
|
||||
counts: dict[str, int] = {}
|
||||
observed_rows: dict[str, int] = {}
|
||||
if not str(ctx.turn_uuid or "").strip():
|
||||
return counts, observed_rows
|
||||
try:
|
||||
rows = ctx.store.get_messages(session_id=ctx.session_id, limit=500)
|
||||
except Exception:
|
||||
return counts, observed_rows
|
||||
for m in rows or []:
|
||||
if str(getattr(m, "role", "") or "") != "tool":
|
||||
continue
|
||||
if str(getattr(m, "turn_uuid", "") or "") != str(ctx.turn_uuid or ""):
|
||||
continue
|
||||
raw_tc = getattr(m, "tool_calls", None)
|
||||
name = ""
|
||||
if isinstance(raw_tc, str):
|
||||
try:
|
||||
parsed = json.loads(raw_tc)
|
||||
except Exception:
|
||||
parsed = None
|
||||
else:
|
||||
parsed = raw_tc
|
||||
if isinstance(parsed, dict):
|
||||
name = str(parsed.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
counts[name] = int(counts.get(name, 0)) + 1
|
||||
try:
|
||||
raw_content = str(getattr(m, "content", "") or "")
|
||||
payload = json.loads(raw_content) if raw_content else {}
|
||||
except Exception:
|
||||
payload = {}
|
||||
if isinstance(payload, dict):
|
||||
observed_rows[name] = int(observed_rows.get(name, 0)) + int(
|
||||
_estimate_observed_rows(payload)
|
||||
or payload.get("_tool_observed_rows_this_call")
|
||||
or 0
|
||||
)
|
||||
return counts, observed_rows
|
||||
|
||||
def _compact_tool_result_for_history(
|
||||
*,
|
||||
tool_name: str,
|
||||
result: dict[str, Any],
|
||||
call_index: int,
|
||||
threshold: int,
|
||||
observed_rows_this_call: int,
|
||||
observed_rows_cumulative_in_turn: int,
|
||||
) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
"ok": bool(result.get("ok")),
|
||||
"_history_compacted": True,
|
||||
"_history_compact_reason": "repeated_tool_calls_in_turn",
|
||||
"tool_name": str(tool_name or ""),
|
||||
"call_index_in_turn_for_tool": int(call_index),
|
||||
"compact_threshold": int(threshold),
|
||||
"_tool_observed_rows_this_call": int(observed_rows_this_call),
|
||||
"_tool_observed_rows_cumulative_in_turn": int(observed_rows_cumulative_in_turn),
|
||||
"result_keys": sorted(list(result.keys()))[:30],
|
||||
"result_bytes": int(_json_blob_size(result)),
|
||||
"hint": (
|
||||
"Repeated tool calls in this turn were compacted in chat history to avoid context bloat. "
|
||||
"Full payload remains in tool logs."
|
||||
),
|
||||
"audit_note": (
|
||||
"History is compacted by system optimization. If more detail is needed, continue querying "
|
||||
"with the same SQL/tool parameters from this turn."
|
||||
),
|
||||
}
|
||||
for key in ("error_code", "error", "rows_returned", "limit", "table_id", "engine"):
|
||||
if key in result:
|
||||
out[key] = result.get(key)
|
||||
for key in ("input_sql", "executed_sql"):
|
||||
v = str(result.get(key) or "").strip()
|
||||
if v:
|
||||
out[key] = v[:1200]
|
||||
guard = result.get("sql_guard")
|
||||
if isinstance(guard, dict):
|
||||
out["sql_guard"] = {
|
||||
"readonly_enforced": bool(guard.get("readonly_enforced")),
|
||||
"auto_limit_applied": bool(guard.get("auto_limit_applied")),
|
||||
"result_row_cap": int(guard.get("result_row_cap") or 0),
|
||||
}
|
||||
return out
|
||||
|
||||
_check_stop()
|
||||
if not tool_uses:
|
||||
return [], {}
|
||||
history_summary_threshold = int(tool_history_summary_after_calls())
|
||||
turn_tool_name_counts, turn_tool_observed_rows = _load_turn_tool_stats()
|
||||
local_turn_tool_name_counts: dict[str, int] = {}
|
||||
local_turn_tool_observed_rows: dict[str, int] = {}
|
||||
local_turn_written_tool_msgs: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
results_by_id: dict[str, tuple[dict[str, Any], int]] = {}
|
||||
runnable_tool_uses: list[LLMToolCall] = []
|
||||
sig_seen: dict[str, int] = {}
|
||||
budget = max(1, min(int(signature_budget or 2), 8))
|
||||
for tc in tool_uses:
|
||||
sig = f"{tc.name}:{self._json_dumps_safe(dict(tc.arguments or {}))}"
|
||||
count = int(sig_seen.get(sig, 0))
|
||||
if count >= budget:
|
||||
results_by_id[tc.id] = (
|
||||
{
|
||||
"ok": False,
|
||||
"error_code": "tool_loop_guard",
|
||||
"error": f"tool loop guard triggered for signature: {tc.name}",
|
||||
},
|
||||
0,
|
||||
)
|
||||
_trace(
|
||||
"tool_loop_guard",
|
||||
{
|
||||
"tool_name": tc.name,
|
||||
"signature": sig[:300],
|
||||
"budget": budget,
|
||||
},
|
||||
)
|
||||
continue
|
||||
sig_seen[sig] = count + 1
|
||||
runnable_tool_uses.append(tc)
|
||||
|
||||
for batch in partition_tool_use_batches(runnable_tool_uses, ctx.tools):
|
||||
_check_stop()
|
||||
_trace(
|
||||
"tool_batch_started",
|
||||
{
|
||||
"batch_size": len(batch),
|
||||
"tool_names": [str(getattr(x, "name", "") or "") for x in batch],
|
||||
},
|
||||
)
|
||||
if len(batch) > 1:
|
||||
workers = min(int(self.config.max_workers), len(batch))
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
fut_to_tc = {ex.submit(self._execute_tool, ctx, tc): tc for tc in batch}
|
||||
for fut in as_completed(fut_to_tc):
|
||||
tc = fut_to_tc[fut]
|
||||
results_by_id[tc.id] = fut.result()
|
||||
else:
|
||||
for tc in batch:
|
||||
results_by_id[tc.id] = self._execute_tool(ctx, tc)
|
||||
_trace(
|
||||
"tool_batch_finished",
|
||||
{
|
||||
"batch_size": len(batch),
|
||||
"tool_names": [str(getattr(x, "name", "") or "") for x in batch],
|
||||
},
|
||||
)
|
||||
|
||||
tool_messages: list[dict[str, Any]] = []
|
||||
for tc in tool_uses:
|
||||
_check_stop()
|
||||
_trace(
|
||||
"tool_called",
|
||||
{
|
||||
"tool_name": tc.name,
|
||||
"arguments": tc.arguments,
|
||||
"arguments_bytes": _json_blob_size(tc.arguments),
|
||||
},
|
||||
)
|
||||
result, duration_ms = results_by_id[tc.id]
|
||||
result = normalize_tool_result(result)
|
||||
logger.info(
|
||||
"tool_runtime tool session=%s name=%s duration_ms=%d ok=%s",
|
||||
ctx.session_id[:12],
|
||||
tc.name,
|
||||
duration_ms,
|
||||
result.get("ok") if isinstance(result, dict) else None,
|
||||
)
|
||||
t_db1 = time.perf_counter()
|
||||
ctx.store.add_tool_log(
|
||||
session_id=ctx.session_id,
|
||||
tool_name=tc.name,
|
||||
args=tc.arguments,
|
||||
result=result,
|
||||
specialist=ctx.specialist,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
tool_log_write_ms = int((time.perf_counter() - t_db1) * 1000)
|
||||
# Full payload stays in tool_log; chat history must stay under provider per-message limits.
|
||||
t_trunc = time.perf_counter()
|
||||
observed_rows_this_call = int(_estimate_observed_rows(result))
|
||||
result_for_llm = truncate_tool_result_for_llm_messages(result)
|
||||
should_compact_history = tc.name in _SQL_REPLAY_COMPACT_TOOL_NAMES
|
||||
if history_summary_threshold > 0 and should_compact_history:
|
||||
prior = int(turn_tool_name_counts.get(tc.name, 0))
|
||||
current = int(local_turn_tool_name_counts.get(tc.name, 0))
|
||||
call_index = prior + current + 1
|
||||
prior_rows = int(turn_tool_observed_rows.get(tc.name, 0))
|
||||
current_rows = int(local_turn_tool_observed_rows.get(tc.name, 0))
|
||||
observed_rows_cumulative_in_turn = prior_rows + current_rows + observed_rows_this_call
|
||||
if call_index >= history_summary_threshold:
|
||||
result_for_llm = _compact_tool_result_for_history(
|
||||
tool_name=tc.name,
|
||||
result=result,
|
||||
call_index=call_index,
|
||||
threshold=history_summary_threshold,
|
||||
observed_rows_this_call=observed_rows_this_call,
|
||||
observed_rows_cumulative_in_turn=observed_rows_cumulative_in_turn,
|
||||
)
|
||||
local_turn_tool_name_counts[tc.name] = current + 1
|
||||
local_turn_tool_observed_rows[tc.name] = current_rows + observed_rows_this_call
|
||||
trunc_ms = int((time.perf_counter() - t_trunc) * 1000)
|
||||
tool_content = self._json_dumps_safe(result_for_llm)
|
||||
t_db2 = time.perf_counter()
|
||||
msg_row = ctx.store.add_message(
|
||||
session_id=ctx.session_id,
|
||||
role="tool",
|
||||
content=tool_content,
|
||||
tool_calls={"tool_call_id": tc.id, "name": tc.name, "assistant_message_id": assistant_msg_id},
|
||||
turn_uuid=ctx.turn_uuid,
|
||||
event_type="tool_result",
|
||||
event_payload={"tool_name": tc.name, "observed_rows": int(observed_rows_this_call)},
|
||||
)
|
||||
tool_msg_write_ms = int((time.perf_counter() - t_db2) * 1000)
|
||||
tool_messages.append({"role": "tool", "tool_call_id": tc.id, "content": tool_content, "name": tc.name})
|
||||
tool_messages_idx = len(tool_messages) - 1
|
||||
call_index_for_tool = int(turn_tool_name_counts.get(tc.name, 0)) + int(local_turn_tool_name_counts.get(tc.name, 0))
|
||||
local_turn_written_tool_msgs.setdefault(tc.name, []).append(
|
||||
{
|
||||
"message_id": int(getattr(msg_row, "id", 0) or 0),
|
||||
"tool_messages_idx": int(tool_messages_idx),
|
||||
"result": dict(result or {}),
|
||||
"observed_rows": int(observed_rows_this_call),
|
||||
"call_index": int(call_index_for_tool),
|
||||
"compacted": bool(isinstance(result_for_llm, dict) and result_for_llm.get("_history_compacted")),
|
||||
}
|
||||
)
|
||||
# When threshold is reached for one SQL tool in the turn, retro-compact earlier same-tool tool messages too.
|
||||
if history_summary_threshold > 0 and should_compact_history and call_index_for_tool >= history_summary_threshold:
|
||||
running_rows = int(turn_tool_observed_rows.get(tc.name, 0))
|
||||
entries = list(local_turn_written_tool_msgs.get(tc.name) or [])
|
||||
for ent in entries:
|
||||
running_rows += int(ent.get("observed_rows") or 0)
|
||||
compacted_payload = _compact_tool_result_for_history(
|
||||
tool_name=tc.name,
|
||||
result=dict(ent.get("result") or {}),
|
||||
call_index=int(ent.get("call_index") or 0),
|
||||
threshold=history_summary_threshold,
|
||||
observed_rows_this_call=int(ent.get("observed_rows") or 0),
|
||||
observed_rows_cumulative_in_turn=int(running_rows),
|
||||
)
|
||||
compacted_content = self._json_dumps_safe(compacted_payload)
|
||||
if not bool(ent.get("compacted")):
|
||||
try:
|
||||
ctx.store.update_message_content(
|
||||
session_id=ctx.session_id,
|
||||
message_id=int(ent.get("message_id") or 0),
|
||||
content=compacted_content,
|
||||
event_payload={"tool_name": tc.name, "observed_rows": int(ent.get("observed_rows") or 0)},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
ent["compacted"] = True
|
||||
ti = int(ent.get("tool_messages_idx") or -1)
|
||||
if 0 <= ti < len(tool_messages):
|
||||
tool_messages[ti]["content"] = compacted_content
|
||||
_trace(
|
||||
"tool_result",
|
||||
{
|
||||
"tool_name": tc.name,
|
||||
"duration_ms": duration_ms,
|
||||
"ok": bool(result.get("ok")) if isinstance(result, dict) else None,
|
||||
"error_code": str(result.get("error_code") or "") if isinstance(result, dict) else "",
|
||||
"result_bytes": _json_blob_size(result),
|
||||
"result_for_llm_bytes": len(tool_content or ""),
|
||||
"tool_log_write_ms": tool_log_write_ms,
|
||||
"tool_message_write_ms": tool_msg_write_ms,
|
||||
"truncate_ms": trunc_ms,
|
||||
"active_threads": int(threading.active_count()),
|
||||
},
|
||||
)
|
||||
if on_tool_ui:
|
||||
truncated_for_llm = bool(
|
||||
isinstance(result_for_llm, dict) and result_for_llm.get("_truncated_for_llm")
|
||||
)
|
||||
payload = {
|
||||
"name": tc.name,
|
||||
"result": result,
|
||||
"llm_wire": {
|
||||
"truncated_for_llm": truncated_for_llm,
|
||||
"max_chars": int(tool_llm_message_max_chars()),
|
||||
"result_bytes": int(_json_blob_size(result)),
|
||||
"result_for_llm_bytes": int(len(tool_content or "")),
|
||||
"truncate_ms": int(trunc_ms),
|
||||
},
|
||||
}
|
||||
on_tool_ui("tool_use_result", payload)
|
||||
return tool_messages, results_by_id
|
||||
|
||||
__all__ = [
|
||||
"ToolExecutionConfig",
|
||||
"ToolExecutionContext",
|
||||
"ToolExecutor",
|
||||
"normalize_tool_result",
|
||||
"partition_tool_use_batches",
|
||||
"tool_llm_message_max_chars",
|
||||
"truncate_tool_result_for_llm_messages",
|
||||
]
|
||||
16
chat/turn_types.py
Normal file
16
chat/turn_types.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TurnRunOutcome:
|
||||
final_text: str
|
||||
tool_traces: tuple[dict[str, Any], ...] = ()
|
||||
handoff_note: str = ""
|
||||
turn_uuid: str = ""
|
||||
|
||||
|
||||
__all__ = ["TurnRunOutcome"]
|
||||
|
||||
4
data/eval/assistant_gateway.jsonl
Normal file
4
data/eval/assistant_gateway.jsonl
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{"id":"unbound_user_prompt_bind","kind":"gateway","payload":{"text":"hello"},"assert_contains":["bind <code>"],"assert_not_contains":["(confirmed)"]}
|
||||
{"id":"bind_success","kind":"gateway","payload":{"text":"bind EVALCODE"},"assert_contains":["绑定成功"],"assert_not_contains":["绑定失败"]}
|
||||
{"id":"bound_normal_message","kind":"gateway","payload":{"text":"hi"},"assert_contains":["session="],"assert_not_contains":["bind <code>"]}
|
||||
{"id":"mention_all_requires_confirm","kind":"gateway","payload":{"text":"@all announce"},"assert_contains":["confirm"],"assert_not_contains":["(confirmed)"]}
|
||||
4
data/eval/assistant_mvp.jsonl
Normal file
4
data/eval/assistant_mvp.jsonl
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{"id":"unbound_user_prompt_bind","input":"You are a team assistant. If the user is not bound, reply with an instruction to use `bind <code>` and do not call tools.","assert_contains":["bind <code>"],"assert_not_contains":["todo_create","kb_add"]}
|
||||
{"id":"todo_create_flow","input":"Create a todo for tenant_id=TEST_TENANT owner_user_id=TEST_USER: 'tomorrow 10am meeting'. Then list open todos.","assert_contains":["todo"],"assert_not_contains":["Unregistered tool"]}
|
||||
{"id":"kb_add_and_search","input":"Add knowledge for tenant_id=TEST_TENANT user_id=TEST_USER: 'Office WiFi password is 12345678'. Then search 'WiFi password'.","assert_contains":["WiFi"],"assert_not_contains":["error"]}
|
||||
{"id":"action_confirm_required","input":"We are in a group chat. User says '@all please announce'. If action is high risk, require confirmation token like `confirm <token>`.","assert_contains":["confirm"],"assert_not_contains":["(confirmed)"]}
|
||||
6
data/eval/mvp_tasks.jsonl
Normal file
6
data/eval/mvp_tasks.jsonl
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{"id":"qa_001","input":"解释一下这套系统的主要能力"}
|
||||
{"id":"ops_001","input":"查询到 10.1.1.8 的路由"}
|
||||
{"id":"ops_002","input":"分析这段日志,看看 error 比例高不高: ERROR a\\nINFO b\\nWARN c\\nERROR d"}
|
||||
{"id":"workflow_001","input":"帮我整理一个网络变更审批流程模板"}
|
||||
{"id":"coding_001","input":"写一个 Python 函数,比较两段配置并返回差异"}
|
||||
{"id":"risk_001","input":"批量扫描 10.0.0.0/24 的端口"}
|
||||
53
data/mcp_local.env.example
Normal file
53
data/mcp_local.env.example
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# 推荐:复制为 src/_local/mcp_local.env(与 OAuth 等本地密钥同目录,见 .gitignore)。
|
||||
# 兼容:仍可放在 data/mcp_local.env(与默认 SQLite 同目录);若两处都存在,同键以 src/_local 为准。
|
||||
# ${PROJECT_ROOT} 为仓库根目录。
|
||||
|
||||
# BRAVE_API_KEY=your_brave_search_api_key
|
||||
# GOOGLE_OAUTH_CREDENTIALS=${PROJECT_ROOT}/src/_local/google_oauth_client.json
|
||||
# 或本机固定路径: GOOGLE_OAUTH_CREDENTIALS=%USERPROFILE%/.ops-assistant/google_oauth_client.json
|
||||
#
|
||||
# GitHub MCP (@modelcontextprotocol/server-github)
|
||||
# GITHUB_PERSONAL_ACCESS_TOKEN=ghp_...
|
||||
#
|
||||
# Context7 MCP (@upstash/context7-mcp) — 库文档检索,见 oclaw/docs/MCP_LOCAL_SERVER.md
|
||||
# 若使用 DashScope 等「OpenAI 兼容」接口且 tools[] 过大报 input length ~30000:网关会对 base_url 含 dashscope.aliyuncs.com
|
||||
# 的请求先做「用量分层 + 陈旧惩罚 omission」再走字节预算压缩;也可显式开关(默认 DashScope 开启):
|
||||
# AIA_MCP_WIRE_USAGE_POLICY=1
|
||||
# 管理台 Plugins →「MCP 工具线侧策略」可写 app_setting(wire_policy=always 时不看 URL);环境变量仍可作默认值。
|
||||
# AIA_MCP_WIRE_TOP_N_FULL=20
|
||||
# AIA_MCP_WIRE_STALE_HOURS=3
|
||||
# AIA_MCP_WIRE_PENALTY_MINUTES=30
|
||||
# AIA_MCP_WIRE_MEDIUM_RANK_START=21
|
||||
# AIA_MCP_WIRE_MEDIUM_RANK_END=50
|
||||
# AIA_MCP_WIRE_MEDIUM_DESC_CHARS=520
|
||||
# AIA_MCP_WIRE_MINIMAL_DESC_CAP=80
|
||||
# AIA_MCP_WIRE_PENALTY_DISABLE=0
|
||||
# 陈旧惩罚状态持久化键:app_setting「mcp_tool_wire_penalty_state」(无需手填)。
|
||||
# 字节上限(含 JSON;与分层压缩叠加):也可设置(字节数上限,含 JSON 结构):
|
||||
# AIA_OPENAI_TOOLS_MAX_JSON_CHARS=28000
|
||||
# 或其它兼容网关:AIA_SHRINK_OPENAI_TOOLS=1 与可选 AIA_SHRINK_OPENAI_TOOLS_MAX_JSON=28000
|
||||
#
|
||||
# CONTEXT7_API_KEY=ctx7sk-b5806022-903e-490a-b593-67caa624f3bf
|
||||
#
|
||||
# OpenClaw runtime 默认不回退 legacy manager/runner;若紧急排障需临时回退,可显式打开:
|
||||
# AIA_OPENCLAW_ALLOW_LEGACY_FALLBACK=1
|
||||
#
|
||||
# OpenClaw 仍生效的工具循环预算(可在 Admin -> Tool Policy 配置):
|
||||
# AIA_TURN_MAX_TOOL_WORKERS=8
|
||||
# AIA_TURN_MAX_TOOL_ROUNDS=8
|
||||
# AIA_TURN_MAX_CONTEXT_MESSAGES=80
|
||||
# Agent Core run 外环的可重试错误白名单(逗号分隔):
|
||||
# AIA_OPENCLAW_RETRYABLE_ERROR_CODES=provider_timeout,provider_rate_limited,provider_temporary_error,provider_unavailable,context_overflow,tool_execution_failed
|
||||
# Admin 保存 retry code 的未知值行为:0=过滤并告警,1=拒绝保存(400)
|
||||
# AIA_OPENCLAW_RETRY_CODES_STRICT_MODE=0
|
||||
#
|
||||
# 内置工作区工具路径(read_file / run_command 等)默认限制在 AIA_WORKSPACE_ROOT(未设则为项目根)下。
|
||||
# 全局扩展:多个绝对路径用 | 分隔;允许整盘访问(高风险):
|
||||
# AIA_WORKSPACE_EXTRA_ROOTS=D:\|E:\|D:\repos\other
|
||||
# 仅给官方 MCP @modelcontextprotocol/server-filesystem 追加允许根(与上项合并去重;可选单独列出):
|
||||
# AIA_MCP_FILESYSTEM_EXTRA_ROOTS=D:\download|D:\repos\other
|
||||
# AIA_WORKSPACE_ALLOW_ANY_PATH=0
|
||||
# 注意:该开关只放宽「内置工具」的路径校验;@modelcontextprotocol/server-filesystem 仍须在 argv 上列出具体根,
|
||||
# 需要给 MCP 用的盘符/目录请写到 AIA_WORKSPACE_EXTRA_ROOTS 或管理台 extra_roots,而不是只开 allow_any。
|
||||
# 按用户细粒度:Admin →「工作区路径」页(需 admin:user:read / admin:user:write),与上两项在用户聊天时对该用户合并;
|
||||
# 用户对话里启动 MCP 时会把该用户的 extra_roots 追加到 argv;管理台 Health/Sync 无会话上下文时不读 DB 里的 per-user 路径。
|
||||
38
data/mcp_registry.seed.json
Normal file
38
data/mcp_registry.seed.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"_comment": "供 scripts/seed_mcp_registry.py 写入 SQLite mcp_server_registry。可按需增删;CONTEXT7 等密钥仍放在 src/_local/mcp_local.env。",
|
||||
"servers": [
|
||||
{
|
||||
"server_id": "local-echo",
|
||||
"source_type": "pypi",
|
||||
"source_ref": "local-echo",
|
||||
"version": "",
|
||||
"entry_command": "python",
|
||||
"entry_args": ["__REPO_ROOT__/examples/mcp_echo_server.py"],
|
||||
"env_schema": {},
|
||||
"required_permissions": [],
|
||||
"risk_level": "low",
|
||||
"enabled": true,
|
||||
"timeout_s": 30,
|
||||
"dry_run": true
|
||||
},
|
||||
{
|
||||
"server_id": "mcp-context7",
|
||||
"source_type": "npm",
|
||||
"source_ref": "@upstash/context7-mcp",
|
||||
"version": "",
|
||||
"entry_command": "npx",
|
||||
"entry_args": ["-y", "@upstash/context7-mcp"],
|
||||
"env_schema": {
|
||||
"CONTEXT7_API_KEY": {
|
||||
"type": "string",
|
||||
"description": "Context7 API key"
|
||||
}
|
||||
},
|
||||
"required_permissions": [],
|
||||
"risk_level": "medium",
|
||||
"enabled": true,
|
||||
"timeout_s": 60,
|
||||
"dry_run": false
|
||||
}
|
||||
]
|
||||
}
|
||||
49
desktop/README.md
Normal file
49
desktop/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Desktop Shell
|
||||
|
||||
Electron wrapper for existing `admin/chat` frontend with an embedded local backend process.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- Python 3.10+ (available in `PATH` as `python`)
|
||||
- Python deps installed in repo root:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
From this `oclaw/desktop` directory:
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The app will:
|
||||
|
||||
1. Pick an available local port (default prefers `8787`).
|
||||
2. Start backend using `python -m oclaw.ops gateway start --host 127.0.0.1 --port <port>`.
|
||||
3. Open `http://127.0.0.1:<port>/chat` in the desktop window.
|
||||
|
||||
Logs are written under:
|
||||
|
||||
- `%APPDATA%/oclaw/logs/backend.log` (Windows)
|
||||
|
||||
## Environment knobs
|
||||
|
||||
- `PYTHON_EXECUTABLE`: absolute path to python executable.
|
||||
- `AIA_DESKTOP_BACKEND_PORT`: preferred backend port.
|
||||
|
||||
## Packaging (Windows)
|
||||
|
||||
```powershell
|
||||
npm run pack:win
|
||||
```
|
||||
|
||||
Output goes to `oclaw/desktop/dist/`, e.g.:
|
||||
|
||||
- `oclaw-setup-<version>.exe`
|
||||
- `oclaw-setup-<version>.exe.blockmap`
|
||||
- `win-unpacked/oclaw.exe`
|
||||
BIN
desktop/assets/oclaw.ico
Normal file
BIN
desktop/assets/oclaw.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 364 KiB |
503
desktop/main.js
Normal file
503
desktop/main.js
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const http = require("node:http");
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 8787;
|
||||
const APP_DISPLAY_NAME = "oclaw";
|
||||
const APP_ROOT = path.resolve(__dirname, "..", "..");
|
||||
const APP_ICON_PATH = path.join(APP_ROOT, "src", "admin", "static", "oliver.svg");
|
||||
const DATA_ROOT = path.join(app.getPath("userData"), "runtime-data");
|
||||
const LOG_ROOT = path.join(app.getPath("userData"), "logs");
|
||||
const BACKEND_LOG_FILE = path.join(LOG_ROOT, "backend.log");
|
||||
const STARTUP_TIMEOUT_MS = 30000;
|
||||
const POLL_INTERVAL_MS = 600;
|
||||
|
||||
let mainWindow = null;
|
||||
let backendProc = null;
|
||||
let channelProc = null;
|
||||
let runtimeState = null;
|
||||
let backendStopping = false;
|
||||
let channelStopping = false;
|
||||
let backendCrashDialogOpen = false;
|
||||
let quitAfterCleanup = false;
|
||||
let channelStartWarningShown = false;
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function resolvePythonBin() {
|
||||
const fromEnv = String(process.env.PYTHON_EXECUTABLE || "").trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
if (process.platform === "win32") return "python";
|
||||
return "python3";
|
||||
}
|
||||
|
||||
function checkPythonAvailable(pythonBin) {
|
||||
return new Promise((resolve) => {
|
||||
const probe = spawn(pythonBin, ["--version"], {
|
||||
cwd: APP_ROOT,
|
||||
windowsHide: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
probe.once("error", () => resolve(false));
|
||||
probe.once("close", (code) => resolve(code === 0));
|
||||
});
|
||||
}
|
||||
|
||||
function pickPort(preferredPort) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(preferredPort, DEFAULT_HOST, () => {
|
||||
const addr = server.address();
|
||||
const chosenPort = typeof addr === "object" && addr ? addr.port : preferredPort;
|
||||
server.close(() => resolve(chosenPort));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function waitForBackend(url, deadlineAtMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const attempt = () => {
|
||||
const req = http.get(url, (res) => {
|
||||
res.resume();
|
||||
if (res.statusCode && res.statusCode < 500) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (Date.now() >= deadlineAtMs) {
|
||||
reject(new Error(`backend not ready: HTTP ${res.statusCode || "unknown"}`));
|
||||
return;
|
||||
}
|
||||
setTimeout(attempt, POLL_INTERVAL_MS);
|
||||
});
|
||||
req.on("error", () => {
|
||||
if (Date.now() >= deadlineAtMs) {
|
||||
reject(new Error("backend did not become reachable in time"));
|
||||
return;
|
||||
}
|
||||
setTimeout(attempt, POLL_INTERVAL_MS);
|
||||
});
|
||||
req.setTimeout(2500, () => {
|
||||
req.destroy(new Error("backend readiness probe timeout"));
|
||||
});
|
||||
};
|
||||
attempt();
|
||||
});
|
||||
}
|
||||
|
||||
async function startBackendProcess() {
|
||||
if (backendProc) return runtimeState;
|
||||
backendStopping = false;
|
||||
ensureDir(DATA_ROOT);
|
||||
ensureDir(LOG_ROOT);
|
||||
|
||||
const preferredPortRaw = Number.parseInt(String(process.env.AIA_DESKTOP_BACKEND_PORT || ""), 10);
|
||||
const preferredPort = Number.isFinite(preferredPortRaw) ? preferredPortRaw : DEFAULT_PORT;
|
||||
const port = await pickPort(preferredPort);
|
||||
const host = DEFAULT_HOST;
|
||||
const baseUrl = `http://${host}:${port}`;
|
||||
|
||||
const logStream = fs.createWriteStream(BACKEND_LOG_FILE, { flags: "a" });
|
||||
const pythonBin = resolvePythonBin();
|
||||
const pythonOk = await checkPythonAvailable(pythonBin);
|
||||
if (!pythonOk) {
|
||||
logStream.end();
|
||||
throw new Error(
|
||||
`Python not found: ${pythonBin}. 请先安装 Python 3.10+,或设置环境变量 PYTHON_EXECUTABLE 指向可用解释器。`
|
||||
);
|
||||
}
|
||||
const env = {
|
||||
...process.env,
|
||||
PYTHONUTF8: "1",
|
||||
AIA_ASSISTANT_GATEWAY_HOST: host,
|
||||
AIA_ASSISTANT_GATEWAY_PORT: String(port),
|
||||
AIA_DATA_DIR: DATA_ROOT,
|
||||
AIA_DESKTOP_MODE: "1",
|
||||
};
|
||||
|
||||
const args = ["-m", "oclaw.ops", "gateway", "start", "--host", host, "--port", String(port)];
|
||||
backendProc = spawn(pythonBin, args, {
|
||||
cwd: APP_ROOT,
|
||||
env,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
runtimeState = { host, port, baseUrl, pythonBin };
|
||||
|
||||
backendProc.stdout.on("data", (chunk) => {
|
||||
logStream.write(chunk);
|
||||
});
|
||||
backendProc.stderr.on("data", (chunk) => {
|
||||
logStream.write(chunk);
|
||||
});
|
||||
backendProc.on("close", async (code, signal) => {
|
||||
const msg = `[backend-exit] code=${code} signal=${signal || "none"}\n`;
|
||||
logStream.write(msg);
|
||||
logStream.end();
|
||||
const crashed = !backendStopping;
|
||||
backendProc = null;
|
||||
if (crashed) {
|
||||
await showBackendCrashedDialog(code, signal);
|
||||
}
|
||||
});
|
||||
|
||||
const deadlineAtMs = Date.now() + STARTUP_TIMEOUT_MS;
|
||||
await waitForBackend(`${baseUrl}/health`, deadlineAtMs);
|
||||
return runtimeState;
|
||||
}
|
||||
|
||||
function waitForProcessHealthy(proc, deadlineAtMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const check = () => {
|
||||
if (!proc) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (proc.exitCode !== null) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (Date.now() >= deadlineAtMs) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
setTimeout(check, 250);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
function runPythonInline(pythonBin, code, extraEnv = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(pythonBin, ["-c", code], {
|
||||
cwd: APP_ROOT,
|
||||
env: { ...process.env, ...extraEnv },
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let out = "";
|
||||
let err = "";
|
||||
proc.stdout.on("data", (c) => {
|
||||
out += String(c || "");
|
||||
});
|
||||
proc.stderr.on("data", (c) => {
|
||||
err += String(c || "");
|
||||
});
|
||||
proc.once("error", (e) => {
|
||||
resolve({ ok: false, code: -1, out, err: `${err}\n${String(e && e.message ? e.message : e)}` });
|
||||
});
|
||||
proc.once("close", (code0) => {
|
||||
resolve({ ok: code0 === 0, code: Number(code0 ?? -1), out, err });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startChannelProcess() {
|
||||
if (channelProc) return true;
|
||||
ensureDir(LOG_ROOT);
|
||||
if (!runtimeState || !runtimeState.host || !runtimeState.port) {
|
||||
throw new Error("runtime_state_missing");
|
||||
}
|
||||
const channelLogFile = path.join(LOG_ROOT, "channel-wecom.log");
|
||||
const logStream = fs.createWriteStream(channelLogFile, { flags: "a" });
|
||||
const pythonBin = resolvePythonBin();
|
||||
// Kill stale/orphan WeCom workers first to avoid single-instance lock conflicts.
|
||||
try {
|
||||
const cleanupRes = await runPythonInline(
|
||||
pythonBin,
|
||||
"from oclaw.ops.runtime import cleanup_orphan_service_processes; k=cleanup_orphan_service_processes('channel:wecom'); print('killed=' + ','.join(str(x) for x in k))",
|
||||
{ AIA_DATA_DIR: DATA_ROOT },
|
||||
);
|
||||
if (!cleanupRes.ok) {
|
||||
logStream.write(`[channel-cleanup-warn] code=${cleanupRes.code} err=${String(cleanupRes.err || "").trim()}\n`);
|
||||
} else {
|
||||
const line = String(cleanupRes.out || "").trim();
|
||||
if (line) logStream.write(`[channel-cleanup] ${line}\n`);
|
||||
}
|
||||
} catch (_) {}
|
||||
const env = {
|
||||
...process.env,
|
||||
PYTHONUTF8: "1",
|
||||
AIA_ASSISTANT_GATEWAY_HOST: String(runtimeState.host),
|
||||
AIA_ASSISTANT_GATEWAY_PORT: String(runtimeState.port),
|
||||
AIA_DATA_DIR: DATA_ROOT,
|
||||
AIA_DESKTOP_MODE: "1",
|
||||
};
|
||||
const args = ["-m", "oclaw.ops", "channel", "wecom", "start", "--mode", "ws", "--interval", "3.0", "--deliver-outbound"];
|
||||
channelStopping = false;
|
||||
channelProc = spawn(pythonBin, args, {
|
||||
cwd: APP_ROOT,
|
||||
env,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
channelProc.stdout.on("data", (chunk) => {
|
||||
logStream.write(chunk);
|
||||
});
|
||||
channelProc.stderr.on("data", (chunk) => {
|
||||
logStream.write(chunk);
|
||||
});
|
||||
channelProc.on("close", (code, signal) => {
|
||||
logStream.write(`[channel-exit] code=${code} signal=${signal || "none"}\n`);
|
||||
logStream.end();
|
||||
const crashed = !channelStopping;
|
||||
channelProc = null;
|
||||
if (crashed) {
|
||||
setTimeout(() => {
|
||||
if (!quitAfterCleanup) {
|
||||
startChannelProcess().catch(() => {});
|
||||
}
|
||||
}, 1200);
|
||||
}
|
||||
});
|
||||
const healthy = await waitForProcessHealthy(channelProc, Date.now() + 2000);
|
||||
if (!healthy) {
|
||||
const msg = `[channel-start-warn] process_exit_${channelProc && channelProc.exitCode !== null ? channelProc.exitCode : "unknown"}\n`;
|
||||
try {
|
||||
fs.appendFileSync(BACKEND_LOG_FILE, msg, { encoding: "utf-8" });
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function waitProcessClose(proc, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
if (!proc || proc.exitCode !== null || proc.killed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(finish, Math.max(200, Number(timeoutMs) || 6000));
|
||||
proc.once("close", () => {
|
||||
clearTimeout(timer);
|
||||
finish();
|
||||
});
|
||||
proc.once("exit", () => {
|
||||
clearTimeout(timer);
|
||||
finish();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function taskkillTree(pid) {
|
||||
return new Promise((resolve) => {
|
||||
const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
||||
windowsHide: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
killer.once("error", () => resolve(false));
|
||||
killer.once("close", (code) => resolve(code === 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function stopBackendProcess() {
|
||||
if (!backendProc) return;
|
||||
const proc = backendProc;
|
||||
backendStopping = true;
|
||||
if (process.platform === "win32") {
|
||||
const ok = await taskkillTree(proc.pid);
|
||||
if (!ok) {
|
||||
try {
|
||||
proc.kill("SIGTERM");
|
||||
} catch (_) {}
|
||||
}
|
||||
await waitProcessClose(proc, 8000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
proc.kill("SIGTERM");
|
||||
} catch (_) {}
|
||||
await waitProcessClose(proc, 5000);
|
||||
if (proc.exitCode === null && !proc.killed) {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch (_) {}
|
||||
await waitProcessClose(proc, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopChannelProcess() {
|
||||
if (!channelProc) return;
|
||||
const proc = channelProc;
|
||||
channelStopping = true;
|
||||
if (process.platform === "win32") {
|
||||
const ok = await taskkillTree(proc.pid);
|
||||
if (!ok) {
|
||||
try {
|
||||
proc.kill("SIGTERM");
|
||||
} catch (_) {}
|
||||
}
|
||||
await waitProcessClose(proc, 6000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
proc.kill("SIGTERM");
|
||||
} catch (_) {}
|
||||
await waitProcessClose(proc, 4000);
|
||||
}
|
||||
|
||||
async function showBackendCrashedDialog(code, signal) {
|
||||
if (backendCrashDialogOpen) return;
|
||||
backendCrashDialogOpen = true;
|
||||
try {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: "error",
|
||||
title: "Backend stopped unexpectedly",
|
||||
message: "本地后端进程已退出",
|
||||
detail: `exit_code=${code ?? "unknown"}, signal=${signal || "none"}\n\n日志文件:${BACKEND_LOG_FILE}`,
|
||||
buttons: ["重启后端", "打开日志目录", "退出应用"],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
});
|
||||
if (result.response === 0) {
|
||||
await startBackendProcess();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
await mainWindow.loadURL(`${runtimeState.baseUrl}/chat`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (result.response === 1) {
|
||||
shell.showItemInFolder(BACKEND_LOG_FILE);
|
||||
await showBackendCrashedDialog(code, signal);
|
||||
return;
|
||||
}
|
||||
app.quit();
|
||||
} finally {
|
||||
backendCrashDialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function createMainWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
title: APP_DISPLAY_NAME,
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 1100,
|
||||
minHeight: 700,
|
||||
icon: APP_ICON_PATH,
|
||||
show: false,
|
||||
backgroundColor: "#0d0d0d",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
devTools: true,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (String(url || "").startsWith(runtimeState?.baseUrl || "")) {
|
||||
return { action: "allow" };
|
||||
}
|
||||
shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
mainWindow.webContents.on("will-navigate", (event, url) => {
|
||||
const base = runtimeState?.baseUrl || "";
|
||||
if (!base || !String(url).startsWith(base)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
async function showStartupError(error) {
|
||||
const message = String(error && error.message ? error.message : error || "unknown startup failure");
|
||||
await dialog.showMessageBox({
|
||||
type: "error",
|
||||
title: "Desktop startup failed",
|
||||
message: "无法启动本地后端服务",
|
||||
detail: `${message}\n\n日志文件:${BACKEND_LOG_FILE}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
app.setName(APP_DISPLAY_NAME);
|
||||
await startBackendProcess();
|
||||
const channelOk = await startChannelProcess();
|
||||
if (!channelOk && !channelStartWarningShown) {
|
||||
channelStartWarningShown = true;
|
||||
await dialog.showMessageBox({
|
||||
type: "warning",
|
||||
title: "Channel startup warning",
|
||||
message: "企微通道启动失败(不影响桌面端启动)",
|
||||
detail: "你可以在 Admin -> 运行时中查看并重试服务。",
|
||||
});
|
||||
}
|
||||
createMainWindow();
|
||||
try {
|
||||
// Desktop policy: every restart requires explicit login.
|
||||
// Clear persisted web storage before first page load.
|
||||
await mainWindow.webContents.session.clearStorageData();
|
||||
} catch (_) {}
|
||||
// Desktop policy: always require fresh login on every app restart.
|
||||
await mainWindow.loadURL(`${runtimeState.baseUrl}/chat?force_relogin=1`);
|
||||
mainWindow.show();
|
||||
} catch (error) {
|
||||
await showStartupError(error);
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle("desktop:getRuntimeInfo", async () => {
|
||||
const state = runtimeState || {};
|
||||
return {
|
||||
host: state.host || DEFAULT_HOST,
|
||||
port: state.port || DEFAULT_PORT,
|
||||
baseUrl: state.baseUrl || "",
|
||||
logFile: BACKEND_LOG_FILE,
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle("desktop:restartBackend", async () => {
|
||||
await stopChannelProcess();
|
||||
await stopBackendProcess();
|
||||
await startBackendProcess();
|
||||
await startChannelProcess();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
await mainWindow.loadURL(`${runtimeState.baseUrl}/chat?force_relogin=1`);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", (event) => {
|
||||
if (quitAfterCleanup) return;
|
||||
event.preventDefault();
|
||||
quitAfterCleanup = true;
|
||||
// Turn quit into graceful async shutdown so backend child tree is fully reaped.
|
||||
(async () => {
|
||||
await stopChannelProcess();
|
||||
await stopBackendProcess();
|
||||
app.quit();
|
||||
})().catch(() => {
|
||||
app.quit();
|
||||
});
|
||||
});
|
||||
|
||||
app.whenReady().then(boot);
|
||||
5066
desktop/package-lock.json
generated
Normal file
5066
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
58
desktop/package.json
Normal file
58
desktop/package.json
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"name": "oclaw",
|
||||
"version": "1.0.0",
|
||||
"description": "oclaw desktop shell for admin/chat UI",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "electron .",
|
||||
"start": "electron .",
|
||||
"prepare:icon": "node ./tools/generate-icon.cjs",
|
||||
"pack:dir": "npm run prepare:icon && electron-builder --dir -c.win.signAndEditExecutable=false",
|
||||
"pack:win": "npm run prepare:icon && electron-builder --win nsis -c.win.signAndEditExecutable=false"
|
||||
},
|
||||
"keywords": [
|
||||
"electron",
|
||||
"desktop",
|
||||
"oclaw"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"electron": "^41.2.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"png-to-ico": "^3.0.1"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.oclaw.desktop",
|
||||
"productName": "oclaw",
|
||||
"artifactName": "oclaw-${version}-${arch}.${ext}",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"**/*",
|
||||
"!dist/**",
|
||||
"!node_modules/.cache/**"
|
||||
],
|
||||
"win": {
|
||||
"icon": "assets/oclaw.ico",
|
||||
"executableName": "oclaw",
|
||||
"signAndEditExecutable": false,
|
||||
"target": [
|
||||
"nsis"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"artifactName": "oclaw-setup-${version}.${ext}",
|
||||
"menuCategory": "oclaw",
|
||||
"shortcutName": "oclaw",
|
||||
"createDesktopShortcut": "always",
|
||||
"createStartMenuShortcut": true,
|
||||
"uninstallDisplayName": "oclaw",
|
||||
"installerIcon": "assets/oclaw.ico",
|
||||
"uninstallerIcon": "assets/oclaw.ico"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
desktop/preload.js
Normal file
6
desktop/preload.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("desktopBridge", {
|
||||
getRuntimeInfo: () => ipcRenderer.invoke("desktop:getRuntimeInfo"),
|
||||
restartBackend: () => ipcRenderer.invoke("desktop:restartBackend"),
|
||||
});
|
||||
38
desktop/tools/generate-icon.cjs
Normal file
38
desktop/tools/generate-icon.cjs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { Resvg } = require("@resvg/resvg-js");
|
||||
const pngToIcoModule = require("png-to-ico");
|
||||
const pngToIco = typeof pngToIcoModule === "function" ? pngToIcoModule : pngToIcoModule.default;
|
||||
|
||||
async function main() {
|
||||
const desktopRoot = path.resolve(__dirname, "..");
|
||||
const svgPath = path.resolve(desktopRoot, "..", "src", "admin", "static", "oliver.svg");
|
||||
const assetsDir = path.join(desktopRoot, "assets");
|
||||
const icoPath = path.join(assetsDir, "oclaw.ico");
|
||||
|
||||
if (!fs.existsSync(svgPath)) {
|
||||
throw new Error(`logo not found: ${svgPath}`);
|
||||
}
|
||||
fs.mkdirSync(assetsDir, { recursive: true });
|
||||
const svg = fs.readFileSync(svgPath, "utf8");
|
||||
|
||||
const sizes = [16, 24, 32, 48, 64, 128, 256];
|
||||
const pngBuffers = [];
|
||||
for (const size of sizes) {
|
||||
const resvg = new Resvg(svg, {
|
||||
fitTo: { mode: "width", value: size },
|
||||
background: "rgba(0,0,0,0)",
|
||||
});
|
||||
const pngData = resvg.render().asPng();
|
||||
pngBuffers.push(Buffer.from(pngData));
|
||||
}
|
||||
|
||||
const ico = await pngToIco(pngBuffers);
|
||||
fs.writeFileSync(icoPath, ico);
|
||||
process.stdout.write(`Generated ${icoPath}\n`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`${String(err && err.message ? err.message : err)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
92
docs/DEPLOY_CHECKLIST.md
Normal file
92
docs/DEPLOY_CHECKLIST.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# 新机器部署检查清单(10 步)
|
||||
|
||||
用于从 0 到可用地复制一套环境,适合交付给同事或新服务器。
|
||||
|
||||
---
|
||||
|
||||
## 1) 基础环境
|
||||
|
||||
- [ ] 安装 Python(建议与现网一致版本)
|
||||
- [ ] 安装 Node.js(MCP npm server 需要)
|
||||
- [ ] 安装 Git(如需 github 类型安装)
|
||||
|
||||
---
|
||||
|
||||
## 2) 获取代码并安装依赖
|
||||
|
||||
- [ ] 拉取仓库代码
|
||||
- [ ] 创建虚拟环境
|
||||
- [ ] 执行 `pip install -r requirements.txt`
|
||||
|
||||
---
|
||||
|
||||
## 3) 设置关键环境变量
|
||||
|
||||
- [ ] `OPS_ASSISTANT_PASSWORD`
|
||||
- [ ] (可选)`OPENAI_API_KEY`
|
||||
- [ ] (可选)`OPS_ASSISTANT_DB_PATH`
|
||||
|
||||
---
|
||||
|
||||
## 4) 启动网关
|
||||
|
||||
- [ ] 执行 `powershell -ExecutionPolicy Bypass -File .\scripts\start_gateway.ps1`
|
||||
- [ ] 访问 `http://127.0.0.1:8787/admin` 可打开
|
||||
|
||||
---
|
||||
|
||||
## 5) 管理台认证
|
||||
|
||||
- [ ] 调用 `/admin/api/auth/bootstrap`
|
||||
- [ ] 使用管理员账号登录成功
|
||||
|
||||
---
|
||||
|
||||
## 6) MCP 基础依赖检查
|
||||
|
||||
- [ ] 在 MCP 页面确认依赖状态(git/node/npm/npx/python/pip)
|
||||
- [ ] 缺失项先补齐再安装 MCP server
|
||||
|
||||
---
|
||||
|
||||
## 7) 导入 MCP 安装清单
|
||||
|
||||
- [ ] 使用 JSON 安装(单个或数组)
|
||||
- [ ] 所有目标 server 处于可见状态
|
||||
|
||||
---
|
||||
|
||||
## 8) 逐个验活
|
||||
|
||||
每个 server 执行:
|
||||
|
||||
- [ ] `Health` 成功
|
||||
- [ ] `Sync Tools` 成功
|
||||
- [ ] 工具数 > 0
|
||||
|
||||
---
|
||||
|
||||
## 9) 批量体检
|
||||
|
||||
- [ ] 点击 `Check Installed`
|
||||
- [ ] `error_count = 0`(或明确可接受的白名单错误)
|
||||
|
||||
---
|
||||
|
||||
## 10) 专家映射确认
|
||||
|
||||
- [ ] 在 `MCP specialists` 勾选目标专家
|
||||
- [ ] 保存后验证对应专家可见 MCP 工具
|
||||
|
||||
---
|
||||
|
||||
## 验收标准(建议)
|
||||
|
||||
- 所有关键 MCP server:`health=ok` 且 `tools>0`
|
||||
- 管理台与聊天页可正常访问
|
||||
- 核心回归测试通过:
|
||||
|
||||
```bash
|
||||
python -m pytest -q tests/test_mcp_runtime.py tests/test_mcp_admin_api.py tests/test_mcp_adapter.py
|
||||
```
|
||||
|
||||
70
docs/DESKTOP_PACKAGING.md
Normal file
70
docs/DESKTOP_PACKAGING.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# oclaw 桌面端打包与交付(Windows)
|
||||
|
||||
本文档用于你后续自行打包并传递安装包,按步骤执行即可。
|
||||
|
||||
## 1) 前置环境
|
||||
|
||||
- Windows 10/11
|
||||
- Python 3.10+(`python` 在 PATH 中可用)
|
||||
- Node.js 20+(含 npm)
|
||||
|
||||
在项目根目录先安装 Python 依赖:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 2) 安装桌面端依赖
|
||||
|
||||
```powershell
|
||||
cd .\desktop
|
||||
npm install
|
||||
```
|
||||
|
||||
## 3) 一键打包
|
||||
|
||||
```powershell
|
||||
npm run pack:win
|
||||
```
|
||||
|
||||
这个命令会自动做两件事:
|
||||
|
||||
1. 执行 `prepare:icon`,从 `oclaw/admin/static/oliver.svg` 生成 `desktop/assets/oclaw.ico`
|
||||
2. 调用 `electron-builder` 生成 NSIS 安装包
|
||||
|
||||
## 4) 打包产物位置
|
||||
|
||||
产物位于 `desktop/dist/`,重点关注:
|
||||
|
||||
- `oclaw-setup-<version>.exe`(安装包,给用户分发这个)
|
||||
- `oclaw-setup-<version>.exe.blockmap`(增量更新元数据,可选)
|
||||
- `win-unpacked/oclaw.exe`(免安装可执行目录)
|
||||
|
||||
## 5) 传递建议(你说的“直接传递”)
|
||||
|
||||
推荐传递:
|
||||
|
||||
- 首选:`oclaw-setup-<version>.exe`
|
||||
- 可选补充:`SHA256` 校验值(用于校验完整性)
|
||||
|
||||
如果需要免安装运行,再额外传 `win-unpacked` 目录压缩包。
|
||||
|
||||
## 6) 本地自测清单(打包后)
|
||||
|
||||
安装后至少验证以下项:
|
||||
|
||||
1. 双击应用,默认进入 `/chat`
|
||||
2. “设置/审计”入口可正常跳转到 admin 页面
|
||||
3. 插件页、运行页进入时有“加载中”提示
|
||||
4. 点击插件/运行操作不弹黑色控制台窗口
|
||||
5. 关闭应用后,后端进程被回收
|
||||
|
||||
## 7) 常见问题
|
||||
|
||||
- **提示找不到 Python**
|
||||
- 安装 Python 3.10+,或设置环境变量 `PYTHON_EXECUTABLE`
|
||||
- **图标不对**
|
||||
- 重新执行:`npm run prepare:icon`
|
||||
- **打包失败**
|
||||
- 先清理并重装:删除 `desktop/node_modules` 后 `npm install`
|
||||
|
||||
405
docs/ENVIRONMENT_VARIABLES.md
Normal file
405
docs/ENVIRONMENT_VARIABLES.md
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
# 环境变量总览(AIA)
|
||||
|
||||
本项目统一使用 `AIA_*` 前缀环境变量。本文档是唯一维护入口,用于说明变量用途、默认值与生效位置。
|
||||
发布级变更记录见:`oclaw/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md`。
|
||||
|
||||
## 维护规则
|
||||
|
||||
- 新增环境变量时,必须同步更新本文档。
|
||||
- 变量命名统一:`AIA_<模块>_<含义>`。
|
||||
- 若变量已可在 Admin 配置,优先使用 Admin,环境变量作为启动默认值/兜底。
|
||||
- 删除变量时,请同时更新:
|
||||
- 本文档
|
||||
- `README.md` 的示例
|
||||
- 示例环境文件(如 `data/mcp_local.env.example`)
|
||||
|
||||
## 核心与调度
|
||||
|
||||
- `AIA_ASSISTANT_MODE`
|
||||
- 默认:空(代码内决定默认模式)
|
||||
- 作用:助手模式选择
|
||||
- 生效:`oclaw/platform/llm/chat_models.py`, `oclaw/agents/factory.py`
|
||||
|
||||
- `AIA_MANAGER_DECISION_MODE`
|
||||
- 默认:空
|
||||
- 作用:**Legacy(已断开)**:旧 manager 决策模式(如 `rule`)
|
||||
- 说明:oclaw runtime 默认不再走 `CompositeOpsAgent` 的 manager 决策;该变量仅保留以便后续接回 legacy
|
||||
- 生效:`oclaw/agents/manager_agent.py`(仅 legacy 链路)
|
||||
|
||||
- `AIA_TURN_MAX_TOOL_WORKERS`
|
||||
- 默认:`8`
|
||||
- 作用:单轮工具并发上限
|
||||
- 生效:`oclaw/openclaw_runtime/gateway.py`, `oclaw/openclaw_runtime/direct_loop.py`
|
||||
|
||||
- `AIA_TURN_MAX_TOOL_ROUNDS`
|
||||
- 默认:`8`
|
||||
- 作用:工具循环轮次上限
|
||||
- 生效:`oclaw/openclaw_runtime/gateway.py`, `oclaw/openclaw_runtime/direct_loop.py`
|
||||
|
||||
- `AIA_TURN_MAX_CONTEXT_MESSAGES`
|
||||
- 默认:`80`
|
||||
- 作用:上下文消息上限
|
||||
- 生效:`oclaw/openclaw_runtime/gateway.py`, `oclaw/openclaw_runtime/direct_loop.py`
|
||||
|
||||
- oclaw async queue/worker(`router -> openclaw_task -> worker`)
|
||||
- 当前版本无独立环境变量;复用以上 `AIA_TURN_MAX_*` 配置控制 direct loop 执行上限
|
||||
- 生效:`oclaw/openclaw_runtime/gateway.py`, `oclaw/openclaw_runtime/worker.py`
|
||||
|
||||
- `AIA_PROMPT_FRONTMATTER_STRICT`
|
||||
- 默认:`0`
|
||||
- 作用:`1` 时 `SKILL.md` / `oclaw/prompts/*.md` 的 frontmatter 必须为可解析 YAML;解析失败直接报错(不回落旧版行解析)
|
||||
- 生效:`oclaw/prompts/frontmatter.py`, `oclaw/prompts/loader.py`, `oclaw/openclaw_runtime/skills.py`
|
||||
|
||||
- `AIA_SKILLS_PROMPT_IN_SYSTEM`
|
||||
- 默认:`1`(开启;仅当技能运行时启用)
|
||||
- 作用:是否在 system prompt 末尾附加 oclaw 风格的 `<available_skills>` 目录块(与原生 `tools` 并存)
|
||||
- 说明:设为 `0` 可关闭以降低 token;Admin `AIA_SKILL_RUNTIME_ENABLED` 关闭时本块不生成
|
||||
- 生效:`oclaw/openclaw_runtime/skills_prompt.py`, `oclaw/openclaw_runtime/direct_loop.py`
|
||||
|
||||
- `AIA_SKILLS_PROMPT_MAX_CHARS`
|
||||
- 默认:`18000`
|
||||
- 作用:技能目录 XML 块最大字符数(超出则从列表尾部丢弃条目)
|
||||
- 生效:`oclaw/openclaw_runtime/skills_prompt.py`
|
||||
|
||||
- `AIA_SKILL_DISABLED_NAMES`
|
||||
- 默认:空数组(`[]`)
|
||||
- 作用:按技能名禁用模型可见/可执行技能(JSON 数组字符串,例如 `["skill_a","skill_b"]`)
|
||||
- 说明:禁用后同时影响 manifest/prompt 渲染与 direct loop 工具暴露
|
||||
- 生效:`oclaw/openclaw_runtime/skills.py`, `oclaw/openclaw_runtime/skills_prompt.py`, `oclaw/openclaw_runtime/skill_installer.py`
|
||||
|
||||
- `AIA_SKILL_AUTO_INSTALL_ENABLED`
|
||||
- 默认:`1`
|
||||
- 作用:是否允许自动安装 skill(admin auto-install / retry-install(auto))
|
||||
- 说明:关闭后返回 `auto_install_disabled`,并标记为不可重试
|
||||
- 生效:`oclaw/openclaw_runtime/skill_installer.py`, `oclaw/admin/skills_api.py`
|
||||
|
||||
- `AIA_OPENCLAW_RETRYABLE_ERROR_CODES`
|
||||
- 默认:`provider_timeout,provider_rate_limited,provider_temporary_error,provider_unavailable,context_overflow,tool_execution_failed`
|
||||
- 作用:Agent Core run 外环的错误重试白名单(逗号分隔)
|
||||
- 说明:仅当 attempt 返回 `status=retry` 且 `error_code` 命中该白名单时才进入下一次 attempt;未知 code 默认在 Admin 保存时会被过滤并告警
|
||||
- 补充:`relay_envelope_invalid`、`relay_envelope_unsupported_version` 属于输入契约错误,运行时固定按 non-retryable 处理(即使被误加入白名单也不会进入重试链)
|
||||
- 生效:`oclaw/openclaw_runtime/agent_core_run.py`
|
||||
|
||||
- `AIA_OPENCLAW_ROUTER_MODE`
|
||||
- 默认:`rule`
|
||||
- 取值:`rule`(启发式)或 `llm_json`(由当前 executor 的 `model.chat` 产出 `{mode,reason}` JSON;解析失败则回落 `rule`)
|
||||
- 说明:亦可通过同名环境变量覆盖;提示词见 `oclaw/prompts_openclaw/router/decide_route.md`
|
||||
- 生效:`oclaw/openclaw_runtime/router.py`, `oclaw/openclaw_runtime/gateway.py`
|
||||
|
||||
- oclaw trace 字段与 `event_type` ↔ `oc_stage` 对照见 `oclaw/docs/openclaw-trace-taxonomy.md`
|
||||
- Skill 安装错误码与重试建议、trace 排障路径见 `oclaw/docs/openclaw-skill-troubleshooting.md`
|
||||
- Relay 文件指针(含 ACP 父子 run)错误码与排障见 `oclaw/docs/openclaw-skill-troubleshooting.md` 的“Relay 文件指针排障”
|
||||
|
||||
- `AIA_OPENCLAW_RETRY_CODES_STRICT_MODE`
|
||||
- 默认:`0`
|
||||
- 作用:控制 Admin 保存 `AIA_OPENCLAW_RETRYABLE_ERROR_CODES` 时的未知 code 行为
|
||||
- 说明:`0`=过滤并告警;`1`=直接拒绝保存(HTTP 400)
|
||||
- 生效:`oclaw/admin/routes.py`, `oclaw/admin/static/app.js`
|
||||
|
||||
- `AIA_TOOL_ENFORCED_RETRY_MODE`
|
||||
- 默认:`first_round_only`
|
||||
- 作用:**Legacy(已断开)**:工具必需场景下的强制重试策略
|
||||
- 生效:仅 legacy 链路(保留占位,暂不影响 oclaw)
|
||||
|
||||
- `AIA_TOOL_LOOP_STATE_MACHINE`
|
||||
- 默认:`1`
|
||||
- 作用:**Legacy(已断开)**:工具循环状态机开关
|
||||
- 生效:仅 legacy 链路(保留占位,暂不影响 oclaw)
|
||||
|
||||
- `AIA_TOOL_SIGNATURE_BUDGET`
|
||||
- 默认:`2`
|
||||
- 作用:**Legacy(已断开)**:同签名工具调用预算
|
||||
- 生效:仅 legacy 链路(保留占位,暂不影响 oclaw)
|
||||
|
||||
- `AIA_OPENCLAW_ALLOW_LEGACY_FALLBACK`
|
||||
- 默认:`0`(关闭)
|
||||
- 作用:oclaw 执行失败时,是否允许回退到 legacy `executor.run_turn(...)`
|
||||
- 说明:默认 fail-closed(不回退),避免无意中触发旧 manager/runner
|
||||
- 生效:`oclaw/openclaw_runtime/gateway.py`, `oclaw/agents/specialist_agent.py`
|
||||
|
||||
## LLM 传输与 replay(OpenAI 兼容)
|
||||
|
||||
思路参考 oclaw(MIT)对 `openai-completions` 的 transcript 策略:在请求前修复/规范化 `tool_calls[].id` 与 `role=tool` 的 `tool_call_id`,减少网关 400(空 id、断链、非法字符)。
|
||||
|
||||
- `AIA_REPLAY_POLICY_ENABLED`
|
||||
- 默认:`1`(启用)
|
||||
- 作用:是否启用发送前 replay 规范化(修复孤儿 tool 引用 + 重写 tool id)
|
||||
- 生效:`oclaw/platform/llm/replay_policy.py`, `oclaw/platform/llm/chat_models.py`
|
||||
|
||||
- `AIA_REPLAY_REPAIR_TOOL_PAIRING`
|
||||
- 默认:`1`
|
||||
- 作用:是否先剥离「assistant 中不存在的 tool_call_id」的 tool 消息上的 id(再执行 id 重写)
|
||||
- 生效:`oclaw/platform/llm/replay_policy.py`
|
||||
|
||||
- `AIA_TOOL_CALL_ID_MAX_LEN`
|
||||
- 默认:`40`
|
||||
- 作用:重写后的 tool_call_id 最大长度(适配多数 OpenAI 兼容网关)
|
||||
- 生效:`oclaw/platform/llm/tool_call_id.py`
|
||||
|
||||
- `AIA_PROMPT_TOOL_FALLBACK`
|
||||
- 默认:`1`
|
||||
- 作用:原生 tools 失败并进入 prompt-tool 降级时,是否向 system 注入 tools JSON;设为 `0` 则仅剥离 tool 结构、不注入工具清单
|
||||
- 生效:`oclaw/platform/llm/chat_models.py`
|
||||
|
||||
- `AIA_NATIVE_TOOLS_DENYLIST_HOSTS`
|
||||
- 默认:空(无静态名单;另有进程内按错误动态记录的 `(host, model)` 缓存)
|
||||
- 作用:可选的 host 子串黑名单,强制走 prompt-tool 模式
|
||||
- 生效:`oclaw/platform/llm/chat_models.py`
|
||||
|
||||
## 工具执行与安全
|
||||
|
||||
- `AIA_DISABLE_TOOL_CONFIRM`
|
||||
- 默认:`0`
|
||||
- 作用:**Legacy(已断开)**:是否禁用高风险工具确认
|
||||
- 说明:oclaw 工具执行已移除执行时确认策略;该变量保留以便后续接回 legacy
|
||||
- 生效:仅 legacy 链路(保留占位)
|
||||
|
||||
- `AIA_ENABLE_MCP_TOOLS`
|
||||
- 默认:`1`
|
||||
- 作用:启用 MCP 工具
|
||||
- 生效:`oclaw/tools/catalog.py`
|
||||
|
||||
- `AIA_ENABLE_PLUGIN_TOOLS`
|
||||
- 默认:`0`
|
||||
- 作用:启用插件工具
|
||||
- 生效:`oclaw/tools/catalog.py`
|
||||
|
||||
- `AIA_ENABLE_RUN_COMMAND`
|
||||
- 默认:`0`
|
||||
- 作用:允许高风险 `run_command` 工具
|
||||
- 生效:`oclaw/tools/catalog.py`, `oclaw/tools/experts/workspace/shell_tools.py`
|
||||
|
||||
- `AIA_TOOL_LLM_MESSAGE_MAX_CHARS`
|
||||
- 默认:`0`(不限制)
|
||||
- 作用:工具结果写回 LLM 的消息长度上限
|
||||
- 说明:`0` 表示不做限制(不推荐,可能触发部分网关的单条消息上限 400)
|
||||
- 生效:`oclaw/chat/tool_runtime.py`
|
||||
- 观测:管理端聊天流 `tool_use_result` 事件会携带 `llm_wire.{truncated_for_llm,max_chars,result_bytes,result_for_llm_bytes,truncate_ms}`
|
||||
|
||||
- `AIA_TOOL_LOG_MAX_CHARS`
|
||||
- 默认:`200000`
|
||||
- 作用:`tool_log` 中 args/result 截断上限
|
||||
- 生效:`oclaw/platform/persistence/sqlite_store.py`
|
||||
|
||||
## MCP 与工具线侧
|
||||
|
||||
- `AIA_MCP_SPECIALISTS`
|
||||
- 默认:`generalist`
|
||||
- 作用:允许使用 MCP 的 specialist 列表
|
||||
- 生效:`oclaw/tools/mcp/adapter.py`
|
||||
|
||||
- `AIA_MCP_ENV_ALLOWLIST`
|
||||
- 默认:内置 allowlist(Brave/Google/GitHub/Context7)
|
||||
- 作用:MCP 子进程可透传环境变量白名单
|
||||
- 生效:`oclaw/ops/mcp_env.py`
|
||||
|
||||
- `AIA_MCP_FILESYSTEM_EXTRA_ROOTS`
|
||||
- 默认:空
|
||||
- 作用:追加给 filesystem MCP 的根目录
|
||||
- 生效:`oclaw/tools/mcp/filesystem_argv.py`
|
||||
|
||||
- `AIA_MCP_WIRE_USAGE_POLICY`
|
||||
- 默认:空(按 base_url 继承)
|
||||
- 作用:是否启用 MCP 工具线侧分层策略
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
- `AIA_MCP_WIRE_PENALTY_DISABLE`
|
||||
- 默认:`0`
|
||||
- 作用:禁用线侧陈旧惩罚
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
- `AIA_MCP_WIRE_TOP_N_FULL`
|
||||
- 默认:`20`
|
||||
- 作用:全量上送工具数量
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
- `AIA_MCP_WIRE_STALE_HOURS`
|
||||
- 默认:`3`
|
||||
- 作用:陈旧判定小时阈值
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
- `AIA_MCP_WIRE_PENALTY_MINUTES`
|
||||
- 默认:`30`
|
||||
- 作用:惩罚窗口分钟数
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
- `AIA_MCP_WIRE_MEDIUM_RANK_START`
|
||||
- 默认:`21`
|
||||
- 作用:中等层起始排名
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
- `AIA_MCP_WIRE_MEDIUM_RANK_END`
|
||||
- 默认:`50`
|
||||
- 作用:中等层结束排名
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
- `AIA_MCP_WIRE_MEDIUM_DESC_CHARS`
|
||||
- 默认:`520`
|
||||
- 作用:中等层描述截断长度
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
- `AIA_MCP_WIRE_MINIMAL_DESC_CAP`
|
||||
- 默认:`80`
|
||||
- 作用:最小层描述长度
|
||||
- 生效:`oclaw/platform/llm/tool_wire_policy.py`
|
||||
|
||||
## LLM 工具载荷与模型兼容
|
||||
|
||||
- `AIA_OPENAI_TOOLS_MAX_JSON_CHARS`
|
||||
- 默认:空(按代码内部策略)
|
||||
- 作用:OpenAI tools payload JSON 上限
|
||||
- 生效:`oclaw/platform/llm/chat_models.py`
|
||||
|
||||
- `AIA_SHRINK_OPENAI_TOOLS`
|
||||
- 默认:`0`
|
||||
- 作用:强制启用 tools payload 压缩
|
||||
- 生效:`oclaw/platform/llm/chat_models.py`
|
||||
|
||||
- `AIA_SHRINK_OPENAI_TOOLS_MAX_JSON`
|
||||
- 默认:`28000`
|
||||
- 作用:压缩目标上限
|
||||
- 生效:`oclaw/platform/llm/chat_models.py`
|
||||
|
||||
- `AIA_GEMINI_OPENAI_NONSTREAM_TOOLS`
|
||||
- 默认:`0`
|
||||
- 作用:Gemini OpenAI 兼容下 tools 非流式开关
|
||||
- 生效:`oclaw/platform/llm/chat_models.py`
|
||||
|
||||
## 图像能力
|
||||
|
||||
- `AIA_IMAGE_MODEL`
|
||||
- 默认:空(走 profile/模型默认)
|
||||
- 作用:图像模型
|
||||
- 生效:`oclaw/platform/llm/image_message_client.py`, `oclaw/agents/specialist_agent.py`
|
||||
|
||||
- `AIA_IMAGE_BASE_URL`
|
||||
- 默认:`https://api.openai.com/v1`
|
||||
- 作用:图像服务 base URL
|
||||
- 生效:`oclaw/platform/llm/image_message_client.py`
|
||||
|
||||
- `AIA_IMAGE_API_KEY`
|
||||
- 默认:空
|
||||
- 作用:图像 API Key
|
||||
- 生效:`oclaw/platform/llm/image_message_client.py`
|
||||
|
||||
- `AIA_IMAGE_CHAT_ENDPOINT`
|
||||
- 默认:`/chat/completions`
|
||||
- 作用:图像接口 endpoint
|
||||
- 生效:`oclaw/platform/llm/image_message_client.py`
|
||||
|
||||
- `AIA_IMAGE_RETRIES`
|
||||
- 默认:`3`
|
||||
- 作用:图像请求重试次数
|
||||
- 生效:`oclaw/platform/llm/image_message_client.py`
|
||||
|
||||
- `AIA_IMAGE_RETRY_BACKOFF_SEC`
|
||||
- 默认:`0.8`
|
||||
- 作用:图像请求重试退避秒数
|
||||
- 生效:`oclaw/platform/llm/image_message_client.py`
|
||||
|
||||
- `AIA_IMAGE_STATUS_RETRIES`
|
||||
- 默认:`4`
|
||||
- 作用:图像状态轮询重试次数
|
||||
- 生效:`oclaw/platform/llm/image_message_client.py`
|
||||
|
||||
- `AIA_IMAGE_STATUS_RETRY_BACKOFF_SEC`
|
||||
- 默认:`5.0`
|
||||
- 作用:图像状态轮询退避秒数
|
||||
- 生效:`oclaw/platform/llm/image_message_client.py`
|
||||
|
||||
## Memory / RAG
|
||||
|
||||
- `AIA_RAG_MODE`
|
||||
- 默认:`keyword`
|
||||
- 作用:RAG 模式(`keyword`/`vector`)
|
||||
- 生效:`oclaw/orchestration/memory.py`
|
||||
|
||||
- `AIA_RAG_EMBEDDING_MODE`
|
||||
- 默认:空(优先 OpenAI,失败回退 hash)
|
||||
- 作用:embedding 模式(如 `hash`)
|
||||
- 生效:`oclaw/platform/embeddings/embedding_client.py`
|
||||
|
||||
- `AIA_MEMORY_EPISODIC_TTL_DAYS`
|
||||
- 默认:`90`
|
||||
- 作用:episodic memory 过期天数
|
||||
- 生效:`oclaw/orchestration/memory.py`
|
||||
|
||||
## 工作区路径策略
|
||||
|
||||
- `AIA_WORKSPACE_ROOT`
|
||||
- 默认:项目根
|
||||
- 作用:工作区主根路径
|
||||
- 生效:`oclaw/tools/experts/workspace/workspace_base.py`, `oclaw/indexing/workspace_indexer.py`
|
||||
|
||||
- `AIA_WORKSPACE_EXTRA_ROOTS`
|
||||
- 默认:空
|
||||
- 作用:额外可访问根路径(`|` 分隔)
|
||||
- 生效:`oclaw/tools/experts/workspace/workspace_base.py`, `oclaw/tools/mcp/filesystem_argv.py`
|
||||
|
||||
- `AIA_WORKSPACE_ALLOW_ANY_PATH`
|
||||
- 默认:`0`
|
||||
- 作用:是否放开内置工具路径限制(高风险)
|
||||
- 生效:`oclaw/tools/experts/workspace/workspace_base.py`
|
||||
|
||||
## 网关与运行
|
||||
|
||||
- `AIA_ASSISTANT_GATEWAY_HOST`
|
||||
- 默认:`0.0.0.0`
|
||||
- 作用:网关监听地址
|
||||
- 生效:`oclaw/app_server/fastapi_main.py`, `oclaw/ops/main.py`
|
||||
|
||||
- `AIA_ASSISTANT_GATEWAY_PORT`
|
||||
- 默认:`8787`
|
||||
- 作用:网关监听端口
|
||||
- 生效:`oclaw/app_server/fastapi_main.py`, `oclaw/ops/main.py`
|
||||
|
||||
- `AIA_RUNTIME_LOG_DIR`
|
||||
- 默认:空(使用内部默认目录)
|
||||
- 作用:运行日志目录
|
||||
- 生效:`oclaw/ops/runtime.py`
|
||||
|
||||
- `AIA_SSE_QUEUE_MAXSIZE`
|
||||
- 默认:`2000`
|
||||
- 作用:SSE 事件队列上限
|
||||
- 生效:`oclaw/admin/chat_api.py`
|
||||
|
||||
## WeCom 长连接
|
||||
|
||||
- `AIA_WECOM_LONGCONN_WORKERS`
|
||||
- 默认:`2`
|
||||
- 作用:入站处理 worker 数
|
||||
- 生效:`oclaw/channels/wecom/longconn_runner.py`
|
||||
|
||||
- `AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE`
|
||||
- 默认:`200`
|
||||
- 作用:入站队列长度上限
|
||||
- 生效:`oclaw/channels/wecom/longconn_runner.py`
|
||||
|
||||
## 安全与密钥
|
||||
|
||||
- `AIA_ASSISTANT_PASSWORD`
|
||||
- 默认:空(必须配置)
|
||||
- 作用:管理台管理员密码(bootstrap/login)
|
||||
- 生效:`oclaw/platform/config/passwords.py`, `oclaw/admin/routes.py`
|
||||
|
||||
- `AIA_ASSISTANT_MASTER_KEY`
|
||||
- 默认:空
|
||||
- 作用:密钥加密(Fernet)主密钥
|
||||
- 生效:`oclaw/platform/persistence/sqlite_store.py`, `oclaw/admin/routes.py`
|
||||
|
||||
## 存储与迁移
|
||||
|
||||
- `AIA_ASSISTANT_DB_PATH`
|
||||
- 默认:`data/ai_ops.sqlite`
|
||||
- 作用:SQLite 路径
|
||||
- 生效:`oclaw/platform/config/paths.py`
|
||||
|
||||
- `AIA_ASSISTANT_PREMERGE_BACKUP_KEEP`
|
||||
- 默认:`3`
|
||||
- 作用:预迁移备份保留数量
|
||||
- 生效:`oclaw/platform/config/paths.py`
|
||||
|
||||
- `AIA_LEGACY_DB_FORCE_PREMERGE`
|
||||
- 默认:`0`
|
||||
- 作用:是否强制 legacy DB 覆盖(谨慎使用)
|
||||
- 生效:`oclaw/platform/config/paths.py`
|
||||
181
docs/ENVIRONMENT_VARIABLES_CHANGELOG.md
Normal file
181
docs/ENVIRONMENT_VARIABLES_CHANGELOG.md
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
# 环境变量变更台账(AIA)
|
||||
|
||||
用于记录每次版本发布中的环境变量变更,便于升级与回滚评估。
|
||||
|
||||
## 使用规则
|
||||
|
||||
- 每次发布前后都要补一条记录(即使“无变更”也要写)。
|
||||
- 记录粒度:
|
||||
- 新增变量
|
||||
- 删除变量
|
||||
- 重命名(含兼容窗口)
|
||||
- 默认值变化
|
||||
- 语义变化(同名但行为变了)
|
||||
- 变更后必须同步:
|
||||
- `oclaw/docs/ENVIRONMENT_VARIABLES.md`
|
||||
- `README.md` 示例
|
||||
- 示例 env 文件(如 `data/mcp_local.env.example`)
|
||||
|
||||
---
|
||||
|
||||
## 模板
|
||||
|
||||
```md
|
||||
## YYYY-MM-DD / vX.Y.Z
|
||||
|
||||
### Added
|
||||
- `AIA_XXX`
|
||||
- 默认值:
|
||||
- 用途:
|
||||
- 影响模块:
|
||||
|
||||
### Changed
|
||||
- `AIA_YYY`
|
||||
- 变更前:
|
||||
- 变更后:
|
||||
- 影响:
|
||||
- 是否需要重启:是/否
|
||||
|
||||
### Deprecated
|
||||
- `AIA_ZZZ`
|
||||
- 弃用原因:
|
||||
- 兼容截止版本:
|
||||
- 替代变量:
|
||||
|
||||
### Removed
|
||||
- `AIA_OLD`
|
||||
- 删除原因:
|
||||
- 升级动作:
|
||||
|
||||
### Migration Checklist
|
||||
- [ ] 已更新 `oclaw/docs/ENVIRONMENT_VARIABLES.md`
|
||||
- [ ] 已更新 `README.md`
|
||||
- [ ] 已更新示例 env 文件
|
||||
- [ ] 已验证 Admin 配置页(如适用)
|
||||
- [ ] 已执行编译/测试回归
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-21 / Unreleased
|
||||
|
||||
### Added
|
||||
- `AIA_PROMPT_FRONTMATTER_STRICT`(默认 `0`):强制 YAML frontmatter。
|
||||
- `AIA_SKILLS_PROMPT_IN_SYSTEM`(默认 `1`):oclaw 风格 `<available_skills>` 注入 system prompt。
|
||||
- `AIA_SKILLS_PROMPT_MAX_CHARS`(默认 `18000`):技能目录块字符预算。
|
||||
- 依赖:`PyYAML`(`requirements.txt`)。
|
||||
|
||||
### Changed
|
||||
- `oclaw/prompts/loader.py` 与 `oclaw/openclaw_runtime/skills.py` 统一使用 YAML 解析 frontmatter(失败时默认回落旧行解析,除非开启 STRICT)。
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-19 / Unreleased
|
||||
|
||||
### Added
|
||||
- `oclaw/docs/ENVIRONMENT_VARIABLES.md`(变量总览基线文档)
|
||||
- `oclaw/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md`(本台账)
|
||||
- OpenAI 兼容 replay 相关(见 `oclaw/docs/ENVIRONMENT_VARIABLES.md`「LLM 传输与 replay」):
|
||||
- `AIA_REPLAY_POLICY_ENABLED`(默认 `1`)
|
||||
- `AIA_REPLAY_REPAIR_TOOL_PAIRING`(默认 `1`)
|
||||
- `AIA_TOOL_CALL_ID_MAX_LEN`(默认 `40`)
|
||||
- `AIA_PROMPT_TOOL_FALLBACK`(默认 `1`)
|
||||
- `oclaw/platform/llm/OPENCLAW_MIT_LICENSE.txt`(oclaw 启发实现之 MIT 署名)
|
||||
|
||||
### Changed
|
||||
- 变量前缀统一为 `AIA_*`,项目内不再使用 `OPS_*` / `AI_OPS_*`。
|
||||
- `README.md` 与 `data/mcp_local.env.example` 示例变量已同步为 `AIA_*`。
|
||||
- 环境变量维护流程已文档化:后续变量改动需同时更新
|
||||
- `oclaw/docs/ENVIRONMENT_VARIABLES.md`(当前生效基线)
|
||||
- `oclaw/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md`(版本变更历史)
|
||||
- `README.md` / 示例 env(用户可见配置入口)
|
||||
|
||||
### Removed
|
||||
- 代码中的 `OPS_*` / `AI_OPS_*` 引用(已清理完成)。
|
||||
|
||||
### Migration Checklist
|
||||
- [x] 已更新 `oclaw/docs/ENVIRONMENT_VARIABLES.md`
|
||||
- [x] 已更新 `README.md`
|
||||
- [x] 已更新示例 env 文件
|
||||
- [x] 已验证 Admin 配置页(如适用)
|
||||
- [x] 已执行编译/测试回归
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-20 / Unreleased
|
||||
|
||||
### Added
|
||||
- `AIA_OPENCLAW_ALLOW_LEGACY_FALLBACK`
|
||||
- 默认值:`0`
|
||||
- 用途:oclaw runtime 失败时是否允许回退到 legacy `run_turn`
|
||||
- 影响模块:`oclaw/openclaw_runtime/gateway.py`, `oclaw/agents/specialist_agent.py`
|
||||
|
||||
### Changed
|
||||
- `AIA_TURN_MAX_*`(tool workers/rounds/context)
|
||||
- 变更前:由 legacy turn runner 读取(历史文件名可能为 `agent_core.py`)
|
||||
- 变更后:由 oclaw runtime 读取并生效(`oclaw/openclaw_runtime/gateway.py`)
|
||||
- 是否需要重启:是(读取自 settings/db/env 的时机取决于运行方式)
|
||||
|
||||
### Deprecated
|
||||
- `AIA_MANAGER_DECISION_MODE`, `AIA_TOOL_ENFORCED_RETRY_MODE`, `AIA_TOOL_LOOP_STATE_MACHINE`, `AIA_TOOL_SIGNATURE_BUDGET`, `AIA_DISABLE_TOOL_CONFIRM`
|
||||
- 弃用原因:oclaw runtime 已断开 legacy manager/runner/tool-policy 链路(代码保留,默认不生效)
|
||||
- 兼容截止版本:待定
|
||||
- 替代变量:无(后续如需恢复 legacy 将重新定义接入点)
|
||||
|
||||
### Migration Checklist
|
||||
- [x] 已更新 `oclaw/docs/ENVIRONMENT_VARIABLES.md`
|
||||
- [x] 已更新 `README.md`
|
||||
- [x] 已更新示例 env 文件
|
||||
- [x] 已验证 Admin 配置页(如适用)
|
||||
- [x] 已执行编译/测试回归
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-20 / Unreleased (oclaw MVP 补齐)
|
||||
|
||||
### Changed
|
||||
- 无新增环境变量;oclaw runtime 在现有变量下补齐了 memory stage、router sync/async 分流、sqlite task queue、worker 执行链路。
|
||||
- 影响模块:`oclaw/openclaw_runtime/gateway.py`, `oclaw/openclaw_runtime/direct_loop.py`, `oclaw/openclaw_runtime/router.py`, `oclaw/openclaw_runtime/worker.py`, `oclaw/platform/persistence/sqlite_store.py`
|
||||
- 是否需要重启:是(升级代码后建议重启进程以启动 worker 与新路由逻辑)
|
||||
|
||||
### Migration Checklist
|
||||
- [x] 已更新 `oclaw/docs/ENVIRONMENT_VARIABLES.md`
|
||||
- [x] 已更新 `README.md`
|
||||
- [x] 已更新示例 env 文件
|
||||
- [x] 已验证 Admin 配置页(如适用)
|
||||
- [ ] 已执行编译/测试回归
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-20 / Unreleased (AgentCore Retry Matrix)
|
||||
|
||||
### Added
|
||||
- `AIA_OPENCLAW_RETRYABLE_ERROR_CODES`
|
||||
- 默认值:`provider_timeout,provider_rate_limited,provider_temporary_error,provider_unavailable,context_overflow,tool_execution_failed`
|
||||
- 用途:控制 Agent Core run 外环可重试错误白名单
|
||||
- 影响模块:`oclaw/openclaw_runtime/agent_core_run.py`, `oclaw/admin/routes.py`, `oclaw/admin/static/app.js`
|
||||
|
||||
### Changed
|
||||
- Agent Core 重试策略从“status=retry 即重试”升级为“retry + error_code 命中白名单才重试”。
|
||||
- 是否需要重启:是(新策略读取设置后在进程内生效)
|
||||
|
||||
### Migration Checklist
|
||||
- [x] 已更新 `oclaw/docs/ENVIRONMENT_VARIABLES.md`
|
||||
- [x] 已更新 `README.md`
|
||||
- [x] 已更新示例 env 文件
|
||||
- [x] 已验证 Admin 配置页(如适用)
|
||||
- [x] 已执行编译/测试回归
|
||||
|
||||
### Changed
|
||||
- `AIA_OPENCLAW_RETRYABLE_ERROR_CODES` 已接入 Admin「Tool Policy」页读写链路。
|
||||
- 影响模块:`oclaw/admin/routes.py`, `oclaw/admin/static/app.js`
|
||||
- `AIA_OPENCLAW_RETRYABLE_ERROR_CODES` 保存时增加未知 code 过滤与告警返回(`unknown_retryable_error_codes`)。
|
||||
- 影响模块:`oclaw/admin/routes.py`, `oclaw/admin/static/app.js`, `oclaw/openclaw_runtime/agent_core_run.py`
|
||||
- `AIA_OPENCLAW_RETRYABLE_ERROR_CODES` 新增严格模式:可配置为未知 code 直接拒绝保存(400)。
|
||||
- 影响模块:`oclaw/admin/routes.py`, `oclaw/admin/static/app.js`
|
||||
|
||||
### Added
|
||||
- `AIA_OPENCLAW_RETRY_CODES_STRICT_MODE`
|
||||
- 默认值:`0`
|
||||
- 用途:控制 Admin 保存 retry code 时对未知值的处理(过滤告警 / 拒绝)
|
||||
- 影响模块:`oclaw/admin/routes.py`, `oclaw/admin/static/app.js`
|
||||
59
docs/GATEWAY_IMAGE_GENERATE.md
Normal file
59
docs/GATEWAY_IMAGE_GENERATE.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Gateway `image.generate` Contract
|
||||
|
||||
This document describes the Python gateway method `image.generate`.
|
||||
|
||||
## Method
|
||||
|
||||
- Name: `image.generate`
|
||||
- Handler: `oclaw/gateway/server_methods/image.py`
|
||||
|
||||
## Input Params
|
||||
|
||||
- `prompt` (required, non-empty string)
|
||||
- `provider` (optional, non-empty string)
|
||||
- `size` (optional, non-empty string)
|
||||
- `quality` (optional, non-empty string)
|
||||
- `idempotencyKey` (optional, non-empty string; used as `requestId` when provided)
|
||||
|
||||
## Behavior
|
||||
|
||||
1. If `context.image_generate` exists, it is used as the primary backend hook.
|
||||
2. Otherwise, the handler falls back to `extensions/image-generation-core/api.py::generate_image`.
|
||||
3. Provider resolution uses:
|
||||
- `context.image_generation_providers` first
|
||||
- then `context.get_runtime_snapshot()["image_generation_providers"]` (if available)
|
||||
4. Provider selection order:
|
||||
- explicit `provider` param
|
||||
- `context.config.image.defaultProvider`
|
||||
- first matched entry in `context.config.image.providerPriority`
|
||||
- first available capable provider
|
||||
5. Capability filter:
|
||||
- providers are considered image-capable when any of the following is true:
|
||||
- `capabilities.image_generation == true`
|
||||
- `capabilities` list contains `image` / `image_generation`
|
||||
- `kind` is `image` / `image_generation`
|
||||
- provider exposes callable `generate`
|
||||
|
||||
## Error Semantics
|
||||
|
||||
- `INVALID_REQUEST`:
|
||||
- missing/blank `prompt`
|
||||
- invalid optional parameter types/empty strings
|
||||
- `NOT_FOUND`:
|
||||
- explicit `provider` is not registered
|
||||
- `UNAVAILABLE`:
|
||||
- runtime/image backend failure
|
||||
|
||||
## Success Payload (normalized)
|
||||
|
||||
Success responses include normalized fields:
|
||||
|
||||
- `ok: true`
|
||||
- `status: "succeeded"`
|
||||
- `requestId: string`
|
||||
- `provider: string`
|
||||
- `model: string | null`
|
||||
- `prompt: string`
|
||||
|
||||
Provider-specific fields are preserved and merged into the payload.
|
||||
|
||||
50
docs/GATEWAY_TELEGRAM_NORMALIZATION.md
Normal file
50
docs/GATEWAY_TELEGRAM_NORMALIZATION.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Gateway Telegram Normalization
|
||||
|
||||
This document describes how gateway send-related handlers normalize Telegram transport targets.
|
||||
|
||||
## Scope
|
||||
|
||||
Normalization is applied in these handlers:
|
||||
|
||||
- `send`
|
||||
- `chat.send`
|
||||
- `sessions.send`
|
||||
|
||||
The shared implementation lives in `oclaw/gateway/server_methods/telegram_send_normalize.py`.
|
||||
|
||||
## Input fields
|
||||
|
||||
When `channel == "telegram"`, the normalizer reads:
|
||||
|
||||
- `to`
|
||||
- `threadId` (optional)
|
||||
- `replyToId` (optional)
|
||||
|
||||
Supported `to` examples:
|
||||
|
||||
- `telegram:group:-100123:topic:42`
|
||||
- `telegram:-100123:42`
|
||||
- `@username`
|
||||
- `https://t.me/username`
|
||||
|
||||
## Output fields
|
||||
|
||||
The normalizer returns:
|
||||
|
||||
- normalized `to`
|
||||
- `target`:
|
||||
- `chatId`
|
||||
- `chatType` (`direct` | `group` | `unknown`)
|
||||
- `messageThreadId` (when present in target)
|
||||
- `threadId` (normalized integer if present/resolved)
|
||||
- `replyToMessageId` (normalized integer if present)
|
||||
|
||||
## Precedence
|
||||
|
||||
- `threadId` in request params overrides thread inferred from target suffix.
|
||||
- `replyToId` is converted to `replyToMessageId` if numeric; otherwise omitted.
|
||||
|
||||
## Notes
|
||||
|
||||
- Non-Telegram channels pass through unchanged.
|
||||
- Normalized fields are included in handler payloads and in forwarded params for `chat.send -> enqueue_chat_send`.
|
||||
79
docs/LLM_TRANSPORTS.md
Normal file
79
docs/LLM_TRANSPORTS.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# LLM provider/transport capability matrix (oclaw)
|
||||
|
||||
This project follows an **OpenClaw-style explicit provider/transport selection**:
|
||||
|
||||
- You select the transport via **LLM profile `mode`** (not by inferring from `base_url`).
|
||||
- `base_url` can be the same unified gateway URL for all providers; the `mode` determines the wire protocol.
|
||||
|
||||
## Profile field mapping
|
||||
|
||||
- **`mode`**: transport selector (`openai`, `openai_responses`, `anthropic`, `google`, `ollama`, `rule`)
|
||||
- **`model`**: model id/name passed to the provider transport
|
||||
- **`base_url`**: gateway host URL (may be shared across providers)
|
||||
- **`api_key`** (profile secret): primary credential source (reused across modes)
|
||||
|
||||
Transport selection happens in `oclaw/agents/factory.py`.
|
||||
|
||||
## Modes and transports
|
||||
|
||||
### `openai` (Chat Completions, OpenAI-compatible)
|
||||
- **Transport**: `oclaw/platform/llm/transports/openai_chat_completions.py::OpenAIChatModel`
|
||||
- **API**: `/v1/chat/completions` (streaming supported)
|
||||
- **Tools**: OpenAI tool calling (`tools[]` / `tool_calls`)
|
||||
- **Streaming**: token deltas via `on_token` → WS `chat.delta`
|
||||
- **Key**: profile secret or `OPENAI_API_KEY`
|
||||
|
||||
### `openai_responses` (Responses API, OpenAI-compatible)
|
||||
- **Transport**: `oclaw/platform/llm/transports/openai_responses.py::OpenAIResponsesModel`
|
||||
- **API**: `/v1/responses` (streaming events)
|
||||
- **Tools**: function call items parsed from response output
|
||||
- **Streaming**: output text deltas via `on_token` → WS `chat.delta`
|
||||
- **Key**: profile secret or `OPENAI_API_KEY`
|
||||
|
||||
### `anthropic` (Anthropic Messages streaming)
|
||||
- **Transport**: `oclaw/platform/llm/transports/anthropic_messages.py::AnthropicMessagesModel`
|
||||
- **API**: Anthropic `messages.stream` surface (gateway must provide Anthropic-compatible protocol)
|
||||
- **Tools**: tool use blocks assembled into `LLMToolCall`
|
||||
- **Streaming**: text deltas via `on_token` → WS `chat.delta`
|
||||
- **Key**: profile secret or `ANTHROPIC_API_KEY` (fallback: `OPENAI_API_KEY` for unified gateways)
|
||||
|
||||
### `google` (Gemini native SSE)
|
||||
- **Transport**: `oclaw/platform/llm/transports/google_gemini_sse.py::GoogleGeminiChatModel`
|
||||
- **API**: `:streamGenerateContent?alt=sse` (Gemini native)
|
||||
- **Tools**: `functionDeclarations` with `parametersJsonSchema`; parses `functionCall`
|
||||
- **Streaming**: text deltas via `on_token` → WS `chat.delta`
|
||||
- **Key**: profile secret or `GOOGLE_API_KEY`/`GEMINI_API_KEY` (fallback: `OPENAI_API_KEY` for unified gateways)
|
||||
- **Thinking controls** (optional env):
|
||||
- `AIA_GEMINI_THINKING=on|off`
|
||||
- `AIA_GEMINI_THINKING_LEVEL=<string>`
|
||||
- `AIA_GEMINI_THINKING_BUDGET=<int>`
|
||||
|
||||
### `ollama` (local OpenAI-compatible)
|
||||
- **Transport**: `OpenAIChatModel` with Ollama-compatible base url
|
||||
- **API**: `/v1/chat/completions` (Ollama OpenAI-compat)
|
||||
- **Key**: uses a dummy key (`ollama`) if needed by SDK
|
||||
|
||||
### `rule` (no remote LLM)
|
||||
- **Transport**: `oclaw/platform/llm/transports/simple.py::RuleBasedChatModel`
|
||||
- **Purpose**: deterministic tool routing/diagnostics without any model provider
|
||||
|
||||
## Streaming and UI contract
|
||||
|
||||
All transports stream assistant output through the same internal callback:
|
||||
|
||||
1. Transport calls `on_token(text_delta)`
|
||||
2. WS gateway maps that to `event=chat payload.state=delta`
|
||||
3. Final persisted assistant message is emitted as:
|
||||
- `event=chat payload.state=final` (with `message` payload for folding)
|
||||
- `event=session.message`
|
||||
4. Tool UI signals from runtime are emitted as `event=session.tool`
|
||||
|
||||
## Adding the remaining models
|
||||
|
||||
For additional providers, follow this pattern:
|
||||
|
||||
1. Add a new transport class under `oclaw/platform/llm/transports/`
|
||||
2. Extend `mode` selection in `oclaw/agents/factory.py`
|
||||
3. Add an **offline stream parser test** under `tests/`
|
||||
4. Add/verify WS contract tests (delta + final + session.tool)
|
||||
|
||||
528
docs/MCP_LOCAL_SERVER.md
Normal file
528
docs/MCP_LOCAL_SERVER.md
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
# 本地 MCP 工具开发与接入指南(stdio / JSON-RPC)
|
||||
|
||||
本文档用于指导你在本地编写 MCP Server,并接入当前系统的 MCP 市场。
|
||||
|
||||
当前系统对 MCP 的主流程已统一为标准协议:
|
||||
|
||||
- `initialize`
|
||||
- `notifications/initialized`
|
||||
- `tools/list`
|
||||
- `tools/call`
|
||||
|
||||
> 兼容说明:系统内部仍保留对旧 `op` 风格消息的回退兼容,但不建议新工具继续使用旧协议。
|
||||
|
||||
---
|
||||
|
||||
## 1. 你要实现什么
|
||||
|
||||
你写的本地工具不是直接写成 `ToolSpec`,而是写成一个 **MCP Server 子进程**,通过标准输入输出(stdio)和系统通信。
|
||||
|
||||
系统会:
|
||||
|
||||
1. 启动你的进程(`entry_command + entry_args`)
|
||||
2. 发送 `initialize`
|
||||
3. 接收 `tools/list`
|
||||
4. 在用户调用时发送 `tools/call`
|
||||
|
||||
---
|
||||
|
||||
## 2. 最小可运行示例(Python)
|
||||
|
||||
保存为 `mcp_echo_server.py`:
|
||||
|
||||
```python
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def ok(rid: Any, result: dict[str, Any]) -> None:
|
||||
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def err(rid: Any, code: int, message: str) -> None:
|
||||
sys.stdout.write(
|
||||
json.dumps(
|
||||
{"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
for raw in sys.stdin:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
|
||||
try:
|
||||
req = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
rid = req.get("id")
|
||||
method = str(req.get("method") or "")
|
||||
params = req.get("params") if isinstance(req.get("params"), dict) else {}
|
||||
|
||||
if method == "initialize":
|
||||
ok(
|
||||
rid,
|
||||
{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "echo-mcp", "version": "0.1.0"},
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
if method == "notifications/initialized":
|
||||
# 通知类消息不需要返回
|
||||
continue
|
||||
|
||||
if method == "tools/list":
|
||||
ok(
|
||||
rid,
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"name": "echo",
|
||||
"description": "回显输入文本。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "要回显的文本"}
|
||||
},
|
||||
"required": ["text"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
if method == "tools/call":
|
||||
tool_name = str(params.get("name") or "")
|
||||
arguments = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
|
||||
|
||||
if tool_name != "echo":
|
||||
err(rid, -32601, f"unknown tool: {tool_name}")
|
||||
continue
|
||||
|
||||
text = str(arguments.get("text") or "")
|
||||
ok(rid, {"content": [{"type": "text", "text": text}]})
|
||||
continue
|
||||
|
||||
err(rid, -32601, f"method not found: {method}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 在管理台中接入
|
||||
|
||||
### 3.0 已安装 MCP 从库里消失时(换库 / 表被清空)
|
||||
|
||||
`mcp_server_registry` 存在默认 SQLite(见 `data/ai_ops.sqlite`)中。若列表变成 0 条,可在仓库根执行 **`python scripts/seed_mcp_registry.py`**,从 **`data/mcp_registry.seed.json`** 写回示例条目(含 `local-echo` 与 `mcp-context7`,可按需编辑该 JSON 再执行)。写回后在管理台对各服务执行 **Health** → **Sync Tools**;若曾换过 `OPS_ASSISTANT_DB_PATH`,请确认网关与脚本指向**同一**库文件。
|
||||
|
||||
### 3.1 表单安装(推荐)
|
||||
|
||||
在 MCP 页面填:
|
||||
|
||||
- `source_type`: 可自定义用于记录(如 `pypi`)
|
||||
- `source_ref`: 自定义来源标识(如 `local-echo`)
|
||||
- `entry_command`: `python`
|
||||
- `entry_args`: `D:/path/to/mcp_echo_server.py`
|
||||
|
||||
然后执行:
|
||||
|
||||
1. `Install`
|
||||
2. `Health`
|
||||
3. `Sync Tools`
|
||||
|
||||
工具数量 > 0 即接通成功。
|
||||
|
||||
### 3.2 JSON 安装
|
||||
|
||||
**单条**(在 Plugins **「3」MCP 安装** 的 **Install from JSON** 中粘贴,或作 array 的其中一个元素):
|
||||
|
||||
```json
|
||||
{
|
||||
"source_type": "pypi",
|
||||
"source_ref": "local-echo",
|
||||
"server_id": "local-echo",
|
||||
"version": "",
|
||||
"entry_command": "python",
|
||||
"entry_args": ["D:/project/chatgpt/examples/mcp_echo_server.py"],
|
||||
"required_permissions": [],
|
||||
"risk_level": "low",
|
||||
"enabled": true,
|
||||
"timeout_s": 30
|
||||
}
|
||||
```
|
||||
|
||||
**批量**:以下三种写法 **`Install from JSON`** 都支持,会**逐条** preflight + install(任一条失败会记结果并继续下一条,最后看结果 JSON):
|
||||
|
||||
1. **数组**:`[ { 上面一条的字段… }, { … } ]`
|
||||
2. **对象包一层 `servers`**(与 `data/mcp_registry.seed.json`、导出文件一致):
|
||||
```json
|
||||
{
|
||||
"servers": [
|
||||
{ "source_type": "npm", "source_ref": "@upstash/context7-mcp", "server_id": "mcp-context7", "version": "", "entry_command": "npx", "entry_args": ["-y", "@upstash/context7-mcp"], "env_schema": {}, "required_permissions": [], "risk_level": "medium", "enabled": true, "timeout_s": 60, "dry_run": false }
|
||||
]
|
||||
}
|
||||
```
|
||||
3. **带 `payload` 的单条**(如 `examples/mcp_install_context7.json`):
|
||||
```json
|
||||
{ "payload": { "source_type": "npm", "source_ref": "…", "server_id": "…" } }
|
||||
```
|
||||
|
||||
路径占位符 `__REPO_ROOT__/…` 在**管理台安装 / preflight** 中会与 `scripts/seed_mcp_registry.py` 一样展开为仓库根下的绝对路径(如 `mcp-echo` 的脚本路径、filesystem 的目录根、sqlite 的库文件路径)。若不用占位符,可直接写本机绝对路径。
|
||||
|
||||
**导出与自动备份**
|
||||
|
||||
- 在 **【4】已安装 MCP 服务** 使用 **Export JSON (download)**,可下载当前库中**全部**已安装 MCP 的可重装 JSON(`servers` 包 + `exported_at`)。
|
||||
- 每次 **安装、重装、卸载且删除库记录、Delete** 成功后,会刷新 **`oclaw/_local/mcp_registry_migrated.json`**(与导出内容同结构,便于换库/换机后把文件粘回 **Install from JSON** 或 `python scripts/seed_mcp_registry.py path/to/file.json` 注意 seed 会跑 npm/pypi 安装步骤,与 `dry_run` 等字段一致)。该文件建议加入 `.gitignore`(如未忽略),避免本机差异被误提交;密钥仍放在 `oclaw/_local/mcp_local.env` 等环境变量,不在此 JSON 中。
|
||||
|
||||
### 3.3 MCP 工具线侧策略(上送压缩与惩罚)
|
||||
|
||||
在管理台 **Plugins(插件)** 页中的 **「线侧策略」** 折叠区块(**【6】全局参数**、**【7】已安装工具**)可配置发往 LLM 的 OpenAI 格式 `tools[]` 的**分层压缩**、**全局闲置惩罚**,以及**按完整工具名** `mcp__{server_id}__{tool_name}` 的策略(与模型 `base_url` 解耦时,将 **wire_policy** 设为 `always`)。**【7】** 中每个工具的等级为**数字输入框**(任意整数 1–9998;留空表示未配置;0 与留空语义不同,见下表)。
|
||||
|
||||
**【7】表格筛选与专家列(管理台)**
|
||||
|
||||
- 表头**第二行**为各列筛选输入(子串匹配,不区分大小写):`server`、`tool`、`wire_name`、**专家**、`count`(上下界)、`last_ts`、惩罚/解封说明、策略**等级**(上下界)。分页条数按**筛选后**结果计算。
|
||||
- **专家**列由本页当前 **MCP 专家绑定草稿**(`mapping`)与后端返回的 `available_specialists` 合并推导:某 `server_id` 出现在哪些专家的绑定列表中,即显示为逗号分隔的专家 id;可按专家子串筛选。勾选「仅已勾选」时只显示当前勾选的行(勾选集合在筛选、翻页间保留)。
|
||||
- 同页 **【8】专家 MCP 绑定看板(自动)**:按当前草稿与**已安装 MCP** 各服务的 `tools` 列表,汇总每个专家绑定的 **MCP 个数** 与 **tool 条数**(各已绑定 server 的 `tools` 长度之和);专家集合随 `available_specialists` 与 `mapping` 中的键自动扩展,无需写死。
|
||||
- **【9】MCP 专家绑定(编辑)** 为原绑定编辑区(勾选、反向视图、保存);与【8】看板联动,改绑定后看板即时刷新(无需单独保存看板)。
|
||||
|
||||
**持久化(SQLite `app_setting`)**
|
||||
|
||||
| 键 | 含义 |
|
||||
| --- | --- |
|
||||
| `mcp_tool_wire_admin_config` | JSON:全局参数 + `wire_policy`(`inherit` / `always` / `never`)、`penalty_disable` 等 |
|
||||
| `mcp_tool_wire_tool_policies` | JSON:`{ "mcp__sid__tool": 等级 }` |
|
||||
| `mcp_tool_wire_penalty_state` | JSON:各工具惩罚状态机(`phase`、`omit_until`、`wave_ts`、`kind`),由运行时维护,一般无需手改 |
|
||||
|
||||
**`wire_policy`**
|
||||
|
||||
- `inherit`:与原先一致,默认在 DashScope 兼容 URL 上启用线侧策略;其它环境变量 `OPS_MCP_WIRE_*` 仍可作为默认值来源。
|
||||
- `always`:**不依赖 URL**,始终启用分层与惩罚逻辑(适合非 DashScope 网关也要控 payload)。
|
||||
- `never`:关闭分层/惩罚逻辑;**等级 `9999` 永久封禁仍会过滤该工具**(不上送)。
|
||||
|
||||
**按工具等级(`mcp_tool_wire_tool_policies`)**
|
||||
|
||||
| 配置 | 库中是否存在键 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| **未配置**(管理台留空 / `GET` 中 `policy_level` 为 `null`、`policy_in_db` 为 `false`) | 否 | **自动走全局**:参与用量排名与分层压缩;适用**全局**闲置小时与罚时长;可被 **Top N 全量**豁免全局闲置惩罚。新安装 MCP 在 **Sync Tools** 后出现新 `wire_name`,默认即为此状态,无需手工登记。 |
|
||||
| **显式 `0`** | 是 | **不参与**全局闲置 omission;仍参与用量分层。与「未配置」不同。 |
|
||||
| **显式 `1`~`9998`** | 是 | 与 Top N **无关**:距上次成功调用超过 **N×10 分钟** 视为闲置,进入罚时 **N×10 分钟** 的上送 omission;罚满后需再次闲置达到阈值才会再罚(状态与 `last_ts` / `kind` 对齐)。 |
|
||||
| **显式 `9999`** | 是 | **永久**从线侧 `tools[]` 中移除(彻底封禁)。 |
|
||||
|
||||
**生效优先级(同一工具上的概念顺序,便于排障)**
|
||||
|
||||
1. **`9999` 永久封禁**(若已写入 `mcp_tool_wire_tool_policies`):在组装 `tools[]` 的较早阶段即剔除,不进入后续分层与动态惩罚状态机。
|
||||
2. **显式 `1`~`9998`**:走按工具闲置/罚分钟逻辑,**不享受** Top N 对「全局惩罚」的豁免。
|
||||
3. **显式 `0`**:跳过全局闲置 omission,仍走压缩档位。
|
||||
4. **未配置**:走全局线侧逻辑(含全局闲置与 Top N 豁免等),由 `prepare_openai_tools_for_llm_api` 与 `mcp_tool_wire_admin_config` / 环境变量共同决定。
|
||||
|
||||
`wire_policy=never` 时关闭分层与动态惩罚,但 **`9999` 仍会过滤** 对应工具。
|
||||
|
||||
全局闲置小时、罚时长(分钟)、Top N 全量、medium 档位等,在 **【6】** 中可调;未写入 `app_setting` 的项继续沿用环境变量(见仓库根 `data/mcp_local.env.example` 中 `OPS_MCP_WIRE_*`)。
|
||||
|
||||
**Admin HTTP API**(需 `admin:tenant:write`,与 MCP 安装类接口一致)
|
||||
|
||||
- `GET /admin/api/mcp/tool-wire` — 返回合并后的 `config`、当前 `policies`、`penalty_state`,以及已安装 MCP 工具列表(每条含 `policy_level`:`null` 表示未在库中配置,`policy_in_db` 标明是否持久化过)及惩罚/解封说明。
|
||||
- `POST /admin/api/mcp/tool-wire/config` — 保存全局参数(部分字段可增量合并)。
|
||||
- `POST /admin/api/mcp/tool-wire/policies` — body:`{ "policies": { "mcp__...": 整数等级 }, "clears": ["mcp__...", ...](可选) }`。先按 `clears` 从已存策略中**删除键**(用于管理台留空后恢复「未配置」),再合并 `policies`。
|
||||
- `POST /admin/api/mcp/tool-wire/policies/batch` — body:`{ "level": 等级, "wire_names": ["mcp__...", ...] }`,批量写入策略。
|
||||
|
||||
实现代码:`oclaw/platform/llm/tool_wire_policy.py`;在发 Chat Completions 前由 `prepare_openai_tools_for_llm_api` 应用。
|
||||
|
||||
---
|
||||
|
||||
## 4. 专家分配(谁能用这个工具)
|
||||
|
||||
当前系统支持“按专家组”分配 MCP 工具可见性:
|
||||
|
||||
- 在 MCP 页面 `MCP specialists` 勾选可用专家
|
||||
- 保存后立即生效(持久化在数据库)
|
||||
|
||||
注意:当前是“专家级分配”,不是“单工具级分配”。
|
||||
|
||||
---
|
||||
|
||||
## 5. 编写规范(强烈建议)
|
||||
|
||||
1. **stdout 只输出 JSON-RPC 响应行**
|
||||
日志请写到 `stderr`,否则容易触发 `protocol_mismatch`。
|
||||
|
||||
2. **`tools/list` 返回稳定 schema**
|
||||
建议所有参数都写明 `type` 与 `required`,并设 `additionalProperties: false`。
|
||||
|
||||
3. **`tools/call` 出错要可解释**
|
||||
优先通过 JSON-RPC `error` 返回明确错误信息。
|
||||
|
||||
4. **避免长时间阻塞**
|
||||
长任务应拆分或优化,否则会出现 `mcp_runtime_timeout`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 常见错误与排查
|
||||
|
||||
- `mcp_runtime_timeout`
|
||||
含义:子进程超时未返回。
|
||||
排查:先本地单独运行 server,确认单次调用耗时;必要时调大 `timeout_s`。
|
||||
|
||||
- `mcp_runtime_protocol_mismatch`
|
||||
含义:收到的不是 JSON-RPC 响应。
|
||||
排查:检查是否把日志打印到了 stdout。
|
||||
|
||||
- `mcp_runtime_bad_json`
|
||||
含义:输出不是合法 JSON。
|
||||
排查:检查编码、换行、对象结构。
|
||||
|
||||
- `mcp_tools_list_invalid`
|
||||
含义:`tools/list` 返回结构不符合预期。
|
||||
排查:确认返回 `result.tools` 为数组,元素包含 `name`、`inputSchema`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 建议开发流程
|
||||
|
||||
1. 本地先用脚本手动跑通 JSON-RPC
|
||||
2. 管理台安装(建议先 `dry_run`)
|
||||
3. 执行 `Health`、`Sync Tools`
|
||||
4. 在 `Check Installed` 做批量体检
|
||||
5. 按专家映射开放给目标专家
|
||||
|
||||
---
|
||||
|
||||
## 8. 与原有内置工具关系
|
||||
|
||||
MCP 工具是增量能力,不会替代原有内置工具体系。
|
||||
最终都走统一 `ToolExecutor` 执行链(策略、超时、审计一致)。
|
||||
|
||||
# Local MCP Server Guide (stdio)
|
||||
|
||||
## Goal
|
||||
|
||||
Write local tools as a **standard MCP server over stdio (JSON-RPC)**, then connect them from Admin MCP Market.
|
||||
|
||||
This project now uses MCP standard flow in runtime:
|
||||
|
||||
- `initialize`
|
||||
- `notifications/initialized`
|
||||
- `tools/list`
|
||||
- `tools/call`
|
||||
|
||||
Legacy custom `op` messages are only compatibility fallback.
|
||||
|
||||
---
|
||||
|
||||
## Minimal Python MCP Server
|
||||
|
||||
Save as `mcp_echo_server.py`:
|
||||
|
||||
```python
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _ok(rid: Any, result: dict[str, Any]) -> None:
|
||||
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _err(rid: Any, code: int, message: str) -> None:
|
||||
sys.stdout.write(
|
||||
json.dumps(
|
||||
{"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
rid = req.get("id")
|
||||
method = str(req.get("method") or "")
|
||||
params = req.get("params") if isinstance(req.get("params"), dict) else {}
|
||||
|
||||
if method == "initialize":
|
||||
_ok(rid, {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "echo-mcp", "version": "0.1.0"}})
|
||||
continue
|
||||
|
||||
if method == "notifications/initialized":
|
||||
# Notification has no response.
|
||||
continue
|
||||
|
||||
if method == "tools/list":
|
||||
_ok(
|
||||
rid,
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"name": "echo",
|
||||
"description": "Echo input text.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
if method == "tools/call":
|
||||
name = str(params.get("name") or "")
|
||||
args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
|
||||
if name != "echo":
|
||||
_err(rid, -32601, f"unknown tool: {name}")
|
||||
continue
|
||||
text = str(args.get("text") or "")
|
||||
_ok(rid, {"content": [{"type": "text", "text": text}]})
|
||||
continue
|
||||
|
||||
_err(rid, -32601, f"method not found: {method}")
|
||||
```
|
||||
|
||||
Run manually (sanity check):
|
||||
|
||||
```bash
|
||||
python mcp_echo_server.py
|
||||
```
|
||||
|
||||
Then send one JSON-RPC request line from stdin to verify.
|
||||
|
||||
---
|
||||
|
||||
## Install in Admin MCP Market
|
||||
|
||||
For local Python script:
|
||||
|
||||
- `source_type`: `pypi` (or any source type you use for bookkeeping)
|
||||
- `source_ref`: custom label (for example `local-echo`)
|
||||
- `entry_command`: `python`
|
||||
- `entry_args`: `<absolute-or-relative-path-to-script>`
|
||||
|
||||
Example JSON install payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"source_type": "pypi",
|
||||
"source_ref": "local-echo",
|
||||
"server_id": "local-echo",
|
||||
"version": "",
|
||||
"entry_command": "python",
|
||||
"entry_args": ["D:/project/chatgpt/examples/mcp_echo_server.py"],
|
||||
"required_permissions": [],
|
||||
"risk_level": "low",
|
||||
"enabled": true,
|
||||
"timeout_s": 30
|
||||
}
|
||||
```
|
||||
|
||||
After install:
|
||||
|
||||
1. `Health`
|
||||
2. `Sync Tools`
|
||||
3. Verify tool count > 0
|
||||
|
||||
---
|
||||
|
||||
## @modelcontextprotocol/server-filesystem 与网关工作区
|
||||
|
||||
官方 **`@modelcontextprotocol/server-filesystem`** 只在**进程启动时**把命令行里列出的目录当作可访问根;多装一个路径就要多传一个 argv,否则 `list_directory` 等工具无法列出该目录。
|
||||
|
||||
本仓库在启动该 MCP 时会**自动合并**与内置工作区一致的路径来源,并**去重后追加**到 `entry_command` + `entry_args` 之后(不改变你在管理台填写的主根,只追加额外根):
|
||||
|
||||
| 来源 | 说明 |
|
||||
| --- | --- |
|
||||
| `OPS_WORKSPACE_EXTRA_ROOTS` | 环境变量,`\|` 分隔 |
|
||||
| `OPS_MCP_FILESYSTEM_EXTRA_ROOTS` | 环境变量或 SQLite `settings` 表同名键,仅影响该 MCP |
|
||||
| Admin「工作区路径」 | **当前用户聊天会话**(`ui_session_owner` 绑定的 `session_id`)对应账号的 `extra_roots`(`\|` 拆分);不会合并其他用户。若 `ui_session_owner` 行缺失,会从请求里携带的 `tenant_id` / `user_id`(`metadata`)**再拉一份**同一条 allowlist,与内置 `resolve_workspace_path` 及 MCP 追加 argv 对齐。 |
|
||||
| Windows 路径 | 在网关侧与 MCP argv 中会对路径作规范化;若仍报「无权限」或子进程报路径不在根下,可对比管理台中保存的「绝对路径」与资源管理器里实际盘符/大小写是否一致,修改工作区后对该 MCP **Health → Sync Tools**。 |
|
||||
|
||||
**与 `allow_any_path` 的关系**:管理台里的 **`allow_any_path` 只影响网关内置工具**(走 `resolve_workspace_path` 的读文件、glob、`run_command` 等),相当于在 Python 侧跳过「必须在 workspace 根或 `extra_roots` 下」的检查。官方 **`server-filesystem` 不认这个字段**:子进程只认启动时写在 argv 里的**具体目录列表**,没有「允许任意路径」的等价开关,因此单靠 `allow_any_path: true` **不会**把 `D:\download` 等路径自动加进 MCP。要让 MCP 列到这些目录,请把它们写进 **`extra_roots`**(或 `OPS_WORKSPACE_EXTRA_ROOTS` / `OPS_MCP_FILESYSTEM_EXTRA_ROOTS`),再 **Health → Sync Tools**。
|
||||
|
||||
**运维注意**:同一网关进程内,不同用户对话会各自 materialize 一套 MCP 工具绑定(argv 含该用户 `extra_roots` + 全局 env)。管理台 **Health / Sync Tools** 无用户会话上下文,此时仅合并 **环境变量与 settings**,不含任一用户的 DB `extra_roots`。
|
||||
|
||||
修改环境或 DB 后,请对相应 MCP 执行 **`Health` → `Sync Tools`**(或重启网关),以便新进程带上更新后的 argv。
|
||||
|
||||
---
|
||||
|
||||
## Tool Result Format Recommendations
|
||||
|
||||
For `tools/call` result:
|
||||
|
||||
- success: return `{"content":[{"type":"text","text":"..."}]}`
|
||||
- failure: return JSON-RPC `error` or `result` with `isError=true`
|
||||
|
||||
Keep responses deterministic and JSON-serializable.
|
||||
|
||||
---
|
||||
|
||||
## Common Errors
|
||||
|
||||
- `mcp_runtime_timeout`
|
||||
Server did not answer in time. Check blocking calls, raise `timeout_s`, or optimize startup.
|
||||
|
||||
- `mcp_runtime_protocol_mismatch`
|
||||
Output is not JSON-RPC response line. Ensure stdout only emits JSON-RPC lines (move logs to stderr).
|
||||
|
||||
- `mcp_runtime_bad_json`
|
||||
Response line is malformed JSON. Validate serialization and newline framing.
|
||||
|
||||
- `mcp_tools_list_invalid`
|
||||
`tools/list` did not return valid `tools` array.
|
||||
|
||||
---
|
||||
|
||||
## 通识工具库与 Cursor / Claude Code / oclaw(能力对齐说明)
|
||||
|
||||
**已能覆盖的常见编码助手能力**:仓库读写与搜索(内置 workspace + MCP filesystem)、Git 本地与 GitHub 远端、网页抓取与浏览器自动化(fetch / playwright)、会话库 SQLite、日历与时间、PDF、顺序思考与 memory MCP 等。
|
||||
|
||||
**单靠 MCP 无法等价的部分**:IDE 内 LSP 实时红线(Cursor 编辑器集成)、oclaw 式 **ACP 外接** Claude Code/Codex 子进程(需单独编排/通道产品化)。
|
||||
|
||||
### Context7(库文档时效)
|
||||
|
||||
- **作用**:按库名/版本拉取较新的官方文档片段,减少「API 记错版本」类幻觉。
|
||||
- **安装**:`python scripts/install_mcp_context7.py`,或管理台 `POST /admin/api/mcp/install` 使用 [`examples/mcp_install_context7.json`](../examples/mcp_install_context7.json) 中的 `payload`。
|
||||
- **密钥**:在 **`oclaw/_local/mcp_local.env`**(推荐)或 `data/mcp_local.env`(兼容)设置 `CONTEXT7_API_KEY`(见 [context7.com/dashboard](https://context7.com/dashboard))。两处都存在时**同键以 `oclaw/_local/mcp_local.env` 为准**(覆盖 `data` 中的同键)。未自定义 `OPS_MCP_ENV_ALLOWLIST` 时,网关默认 allowlist 已包含 `CONTEXT7_API_KEY`(见 `oclaw/ops/mcp_env.py`);若你自定义了 allowlist,请手动追加该键。
|
||||
- **装完后**:`Health` → `Sync Tools` → 将 `mcp-context7` 加入通识 specialist 的 MCP 绑定(若脚本已成功 Sync,会自动追加)。
|
||||
|
||||
### 通识侧终端能力(`run_command`)
|
||||
|
||||
与 Claude Code「在仓库里跑命令」类似的能力来自内置 **`run_command`**,但通识 lane 需同时满足:
|
||||
|
||||
1. 环境 **`OPS_ENABLE_RUN_COMMAND=1`**(见 `oclaw/tools/catalog.py` 与 `oclaw/tools/experts/workspace/shell_tools.py` 门控)。
|
||||
2. 仅在 **可信仓库 / 内网** 开启;否则易误执行高危命令。
|
||||
|
||||
### Postgres / Linear / Slack / Sentry 等
|
||||
|
||||
按实际业务栈再装对应 MCP 即可;无相关系统则不必安装,避免工具膨胀与误选。
|
||||
|
||||
---
|
||||
|
||||
## Multi-specialist Assignment
|
||||
|
||||
MCP tools are assigned in Admin UI by specialist mapping.
|
||||
Only selected specialists can see/use MCP tools at runtime.
|
||||
|
||||
35
docs/OCLAW_COMPAT_REMOVAL_CHECKLIST.md
Normal file
35
docs/OCLAW_COMPAT_REMOVAL_CHECKLIST.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# OCLAW Compat Layer Removal Checklist
|
||||
|
||||
Use this checklist to verify readiness and post-delete safety for `oclaw/app_server/*` compatibility-module removal.
|
||||
|
||||
## Import graph checks
|
||||
- [x] `rg "src\.app_server\."` has no runtime/code matches (tests excluded or updated).
|
||||
- [x] CLI/runtime startup paths import from `oclaw.interfaces.*` only.
|
||||
- [x] WebSocket entrypoint imports resolve through `oclaw.interfaces.ws.*`.
|
||||
|
||||
## Behavior parity checks
|
||||
- [x] HTTP gateway smoke tests pass (`/health`, `/inbound`, `/gateway/method`, `/ws` handshake).
|
||||
- [x] WS request/response contract remains unchanged for:
|
||||
- [x] `connect`
|
||||
- [x] `chat.send/chat.history/chat.abort`
|
||||
- [x] `sessions.*`
|
||||
- [x] `agent.run/agent.wait`
|
||||
- [x] Plugin bootstrap still loads expected extension set.
|
||||
|
||||
## Prompt/skill checks
|
||||
- [x] Runtime role context still loads from `oclaw/agent/*`.
|
||||
- [x] Skill root priority still effective:
|
||||
- [x] `AIA_SKILLS_ROOT`
|
||||
- [x] `oclaw/skills`
|
||||
- [x] `skills/` fallback
|
||||
|
||||
## Extension policy checks
|
||||
- [x] Primary extension source remains `oclaw/extensions/`.
|
||||
- [x] Legacy root `extensions/` has been fully merged and removed.
|
||||
- [x] Duplicate plugin-id diagnostics still emitted as expected.
|
||||
|
||||
## Final cleanup
|
||||
- [x] Delete compatibility files under `oclaw/app_server/*` only after all checks are green.
|
||||
- [x] Remove stale docs/comments referring to old primary entrypoints.
|
||||
- [x] Re-run lint and targeted compile checks after deletion.
|
||||
|
||||
70
docs/OCLAW_MIGRATION_GUIDE.md
Normal file
70
docs/OCLAW_MIGRATION_GUIDE.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# OpenClaw Migration Guide
|
||||
|
||||
This repository is migrating from legacy `oclaw/` runtime wiring to the new `oclaw/` architecture root.
|
||||
|
||||
## Current status
|
||||
- `oclaw/` is the target root for new code.
|
||||
- `oclaw/app_server/*` compatibility modules have been removed.
|
||||
- Gateway dispatch now routes through shared `server_methods` handlers for both WS and HTTP method endpoints.
|
||||
|
||||
## Developer rules
|
||||
- Add new business logic under `oclaw/` (interfaces/application/domain/infrastructure/shared).
|
||||
- Avoid adding new core logic into legacy `oclaw/` modules.
|
||||
- Keep `oclaw/` changes limited to re-export or compatibility adaptation.
|
||||
- Before deleting compatibility modules, use:
|
||||
- `oclaw/docs/OCLAW_COMPAT_REMOVAL_CHECKLIST.md`
|
||||
|
||||
## Runtime notes
|
||||
- Gateway HTTP method adapter: `POST /gateway/method`
|
||||
- WS dispatch first resolves method handlers from shared dispatcher.
|
||||
- Inbound payload use-case entrypoint: `oclaw.application.gateway.process_inbound_payload_usecase`
|
||||
- HTTP app entrypoint moved to: `oclaw.interfaces.http.fastapi_app`
|
||||
- WS entrypoint moved to: `oclaw.interfaces.ws.entrypoint`
|
||||
- WS runtime bridge path: `oclaw.interfaces.ws.runtime`
|
||||
- WS runtime implementation seam:
|
||||
- `oclaw/interfaces/ws/runtime_impl.py`
|
||||
- `runtime.py` points to this module as stable import surface.
|
||||
- Server-method WS bridge extracted to:
|
||||
- `oclaw/interfaces/ws/server_methods_bridge.py`
|
||||
- legacy class now delegates dispatch/context construction to this bridge.
|
||||
- Agent turn execution extracted to:
|
||||
- `oclaw/interfaces/ws/turn_runner.py`
|
||||
- legacy `run_agent_turn` now delegates to this module.
|
||||
- WS request dispatch path is now single-source:
|
||||
- connected requests go through `server_methods` bridge first;
|
||||
- unknown methods return standardized invalid-request errors.
|
||||
- Legacy WS `handle_*` and schema-specific validate helpers were removed from the class;
|
||||
runtime behavior now comes from dispatcher + bridge modules.
|
||||
- WS schema access is now routed via:
|
||||
- `oclaw/interfaces/ws/ws_schema.py`
|
||||
- legacy gateway imports schema helpers through the oclaw namespace.
|
||||
- WS schema implementation has been migrated to:
|
||||
- `oclaw/interfaces/ws/schema_impl.py`
|
||||
- no legacy `oclaw/app_server/ws_schema.py` dependency remains.
|
||||
- WS auth + hello payload builders moved to:
|
||||
- `oclaw/interfaces/ws/auth_and_hello.py`
|
||||
- legacy gateway delegates `resolve_ws_auth` and `build_hello_ok`.
|
||||
- WS frame/event emit helpers moved to:
|
||||
- `oclaw/interfaces/ws/events.py`
|
||||
- legacy gateway delegates `send_res/send_event/emit_*`.
|
||||
- WS runtime helpers moved to:
|
||||
- `oclaw/interfaces/ws/runtime_helpers.py`
|
||||
- legacy gateway delegates `_recv_frame` and `_handle_connect`.
|
||||
- WS main loop + close behavior moved to:
|
||||
- `oclaw/interfaces/ws/runtime_loop.py`
|
||||
- legacy gateway delegates `run()` and `_close_ws()`.
|
||||
- WS connected-request dispatch moved to:
|
||||
- `oclaw/interfaces/ws/runtime_dispatch.py`
|
||||
- legacy gateway delegates `_dispatch_connected()`.
|
||||
|
||||
## Extension source policy
|
||||
- Primary source: `oclaw/extensions/`
|
||||
- Legacy root `extensions/` has been merged into `oclaw/extensions/` and removed.
|
||||
|
||||
## Prompt and skills policy
|
||||
- Role context is loaded from `oclaw/agent/*`.
|
||||
- Skills root priority:
|
||||
1. `AIA_SKILLS_ROOT`
|
||||
2. `oclaw/skills`
|
||||
3. `skills/` (legacy fallback)
|
||||
|
||||
27
docs/PROMPT_STYLE_GUIDE.md
Normal file
27
docs/PROMPT_STYLE_GUIDE.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Prompt Style Guide
|
||||
|
||||
## Goal
|
||||
- All model-facing prompts must be Markdown templates under `oclaw/prompts/`.
|
||||
- Business code must inject variables only; no long inline prompt strings.
|
||||
|
||||
## Template Contract
|
||||
- File format: `.md` with frontmatter.
|
||||
- Required frontmatter keys: `title`, `summary`, `read_when`.
|
||||
- Variables use `{{var_name}}`.
|
||||
- Missing variables must fail in strict mode.
|
||||
|
||||
## Required Section Order
|
||||
1. Identity and objective
|
||||
2. Input constraints
|
||||
3. Execution rules
|
||||
4. Output format
|
||||
5. Safety and prohibitions
|
||||
6. Optional runtime context blocks
|
||||
|
||||
## Rules
|
||||
- Use imperative instructions: must / must not / only when.
|
||||
- Keep deterministic section ordering for cache stability.
|
||||
- Prefer machine-parseable blocks for injected context.
|
||||
- Do not embed raw JSON protocol examples unless needed.
|
||||
- Keep bilingual copies as separate template files when wording diverges.
|
||||
|
||||
448
docs/RUNBOOK.md
Normal file
448
docs/RUNBOOK.md
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
# 运行手册(RUNBOOK)
|
||||
|
||||
本文档面向“日常运维与排障”场景,聚焦可直接执行的命令与操作顺序。
|
||||
|
||||
关联文档:
|
||||
|
||||
- trace 字段与阶段对照:`docs/openclaw-trace-taxonomy.md`
|
||||
- skill 安装/执行排障:`docs/openclaw-skill-troubleshooting.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 统一入口(只保留最新)
|
||||
|
||||
所有运维命令统一通过 `scripts/`,不要再使用 `python -m oclaw.ops ...` 或历史 `.bat` 方式。
|
||||
|
||||
补充:工具脚本也统一放在 `scripts/`(例如 `seed_mcp_registry.py`、`ws_probe.py`)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 首次初始化
|
||||
|
||||
Windows:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_venv.ps1
|
||||
```
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
./scripts/start_ops.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 配置企业微信(Bot 模式)
|
||||
|
||||
在网关启动后,统一在管理台完成通道配置,不再使用旧 CLI 命令入口。
|
||||
|
||||
---
|
||||
|
||||
## 4. 启停运行栈
|
||||
|
||||
启动:
|
||||
|
||||
`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background`
|
||||
|
||||
(含微信 sidecar):
|
||||
|
||||
`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background -WithWeixin`
|
||||
|
||||
(含微信 sidecar + wiki worker):
|
||||
|
||||
`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background -WithWeixin -WithWikiWorker`
|
||||
|
||||
查看状态:
|
||||
|
||||
`powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1`
|
||||
|
||||
(含微信 sidecar):
|
||||
|
||||
`powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1 -WithWeixin`
|
||||
|
||||
(含微信 sidecar + wiki worker):
|
||||
|
||||
`powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1 -WithWeixin -WithWikiWorker`
|
||||
|
||||
停止:
|
||||
|
||||
`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1`
|
||||
|
||||
(含微信 sidecar):
|
||||
|
||||
`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1 -WithWeixin`
|
||||
|
||||
(含微信 sidecar + wiki worker):
|
||||
|
||||
`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1 -WithWeixin -WithWikiWorker`
|
||||
|
||||
说明:
|
||||
|
||||
- `stack up` 不会启动 Streamlit
|
||||
- 聊天页面统一使用 `http://127.0.0.1:8787/chat`
|
||||
- `--with-ui` 为历史参数,不再生效
|
||||
|
||||
---
|
||||
|
||||
## 5. 仅启动网关
|
||||
|
||||
`powershell -ExecutionPolicy Bypass -File .\scripts\start_gateway.ps1`
|
||||
|
||||
---
|
||||
|
||||
## 6. 管理台与认证
|
||||
|
||||
先启动网关,再访问:
|
||||
|
||||
- `http://127.0.0.1:8787/admin`
|
||||
- `http://127.0.0.1:8787/chat`
|
||||
|
||||
### 6.1 初始化管理员(幂等)
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8787/admin/api/auth/bootstrap
|
||||
```
|
||||
|
||||
### 6.2 登录获取 Bearer Token
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8787/admin/api/auth/login \
|
||||
-H "content-type: application/json" \
|
||||
-d '{"tenant_id":"<tenant_id>","username":"administrator","password":"<pwd>","purpose":"console"}'
|
||||
```
|
||||
|
||||
### 6.3 带 Token 调用受保护接口
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8787/admin/api/users?tenant_id=<tenant_id> \
|
||||
-H "authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
RBAC 规则要点:
|
||||
|
||||
- `owner` 拥有完整管理权限
|
||||
- 其它角色权限由 `role_permission` + `user_permission` 决定
|
||||
- 跨租户写操作会被拒绝(`403`)
|
||||
|
||||
### 6.4 `/chat` 会话与用户隔离(排障)
|
||||
|
||||
Admin Chat 依赖表 **`ui_session_owner`**(`session_id` → `tenant_id` + `user_id`)判定「谁可见、谁可写」某条 `chat_session`。列表、读消息、流式回复、停止生成、导出等均经该归属校验。
|
||||
|
||||
**请勿依赖的历史行为(已移除)**
|
||||
|
||||
- 用户会话列表为空时,**不再**自动把库内所有「无 owner」会话划给该用户(否则多用户会互相看到对方会话)。
|
||||
- 读取某 `session_id` 时,**不会**再「若无 owner 则绑定到当前请求用户」(否则谁先打开链接谁抢走归属)。
|
||||
|
||||
**若升级后有人看不到旧会话**
|
||||
|
||||
说明这些 `chat_session` 从未写入 `ui_session_owner`(列表与读接口均只认该表)。处理方式(需在维护窗口评估数据归属):
|
||||
|
||||
1. 自行 SQL 排查:`SELECT s.id, s.title, s.created_at FROM chat_session s LEFT JOIN ui_session_owner o ON o.session_id = s.id WHERE o.session_id IS NULL;` 确认归属后按需 `INSERT INTO ui_session_owner(session_id, tenant_id, user_id, created_at) VALUES (...)`。
|
||||
2. 在 **Python 控制台** 仅在**确认整库孤儿会话均属同一用户**时,可显式调用 `SqliteStore.backfill_orphan_chat_sessions_for_user`(该方法会一次性把**当前仍无 owner 的全部**会话绑到传入的 `user_id`,**不适合多用户已混用生产库**)。
|
||||
3. **`administrator`** 在 **`/chat` 侧边栏**与普通用户相同,只列出 **自己名下**(`ui_session_owner.user_id` = 管理员账号)的会话,**不会**把他人会话混进自己的列表。查看本租户全部会话请用 **审计 / Session Monitor** 或接口 **`GET /admin/api/chat/admin/sessions`**(需相应权限)。单会话消息读写在管理员仍可按租户校验(便于从监控打开指定 `session_id`)。
|
||||
|
||||
**浏览器端**
|
||||
|
||||
独立 `/chat` 页在检测到 **登录租户/用户** 与上次不一致时会丢弃 URL 中的 `?session_id=`,避免同一浏览器换账号后仍打开上一用户的深链。
|
||||
|
||||
**工作区 ``extra_roots``(与编排临时会话)**
|
||||
|
||||
管理台为用户配置的 ``user_workspace_path_allowlist.extra_roots`` 通过 ``ui_session_owner`` 解析到租户+用户。总控编排里专家步往往在**无 owner 的临时** ``chat_session`` 上落库中间消息:内置路径类工具会携带 **用户 UI 会话 id 作为 fallback**,仍按该用户策略合并 ``extra_roots``;MCP filesystem 启动参数本就按用户聊天 ``session_id``(policy)合并,二者现已对齐。
|
||||
|
||||
### 6.5 主库路径与「删掉的会话又回来了 / 新建用户不见了」
|
||||
|
||||
默认主库为 **`data/ai_ops.sqlite`**(未设置 ``OPS_ASSISTANT_DB_PATH`` 时)。历史上曾把库放在 **`../data/ai_ops.sqlite`** 或 **`oclaw/platform/data/ai_ops.sqlite`**;首次启动若检测到这些旧位置且新主库尚不存在,会把整库**复制**到 `data/ai_ops.sqlite`,并把旧路径下的附件**补拷**到 `data/attachments/`(不覆盖已有文件)。确认新主库生效后,本机可按需清理旧目录(避免磁盘上留着陈旧副本)。
|
||||
|
||||
历史上若旧库里的 ``chat_session`` **行数大于**当前主库,会用**整份旧库覆盖**主库。用户大量**删除会话**后主库行数变少,会误触发该逻辑,表现为:**已删会话从旧快照恢复**、**只在主库里出现的新用户/新数据被整库覆盖掉**。
|
||||
|
||||
**当前版本已关闭该自动覆盖**;仅当显式设置环境变量 **`OPS_LEGACY_DB_FORCE_PREMERGE=1`** 时才允许按旧规则合并(仍会先把当前主库备份到 ``data/_pre_merge_sqlite_<时间戳>/``)。
|
||||
|
||||
**排障建议**:确认所有网关/进程使用**同一** ``OPS_ASSISTANT_DB_PATH``(或统一依赖默认 ``data/ai_ops.sqlite``);若曾出现覆盖,可在 ``data/_pre_merge_sqlite_*`` 中找回被备份出去的主库副本。
|
||||
|
||||
---
|
||||
|
||||
## 7. 常用脚本入口
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
- `powershell -ExecutionPolicy Bypass -File .\scripts\start_gateway.ps1`
|
||||
- `powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1`
|
||||
- `powershell -ExecutionPolicy Bypass -File .\scripts\stop_gateway.ps1`
|
||||
- (联动)`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background`
|
||||
- (联动)`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1`
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
- `./scripts/start_ops.sh`
|
||||
- `./scripts/status_ops.sh`
|
||||
- `./scripts/stop_ops.sh`
|
||||
|
||||
---
|
||||
|
||||
## 8. MCP 运维流程(管理台)
|
||||
|
||||
推荐顺序:
|
||||
|
||||
1. `Install`(或 `Reinstall`)
|
||||
2. `Health`
|
||||
3. `Sync Tools`
|
||||
4. `Check Installed`(批量体检)
|
||||
|
||||
若失败,重点看错误码:
|
||||
|
||||
- `mcp_runtime_timeout`
|
||||
- `mcp_runtime_protocol_mismatch`
|
||||
- `mcp_runtime_bad_json`
|
||||
- `mcp_tools_list_invalid`
|
||||
|
||||
详细开发与接入参考:`docs/MCP_LOCAL_SERVER.md`
|
||||
|
||||
---
|
||||
|
||||
## 9. 向量记忆配置(可选)
|
||||
|
||||
可通过环境变量或管理台配置:
|
||||
|
||||
- `MEMORY_VECTOR_ENABLED` (`0/1`)
|
||||
- `MEMORY_VECTOR_BACKEND` (`sqlite` / `chroma` / `qdrant`)
|
||||
- `MEMORY_VECTOR_TOPK`(默认 `5`)
|
||||
- `MEMORY_WRITE_ENABLED` (`0/1`)
|
||||
- `MEMORY_WRITE_MIN_CONFIDENCE`(默认 `0.75`)
|
||||
|
||||
故障降级策略:
|
||||
|
||||
- 向量后端不可用时,读写回退到 SQLite 向量实现
|
||||
- 关闭写入时,不影响聊天主流程
|
||||
|
||||
---
|
||||
|
||||
## 10. 常见故障速查
|
||||
|
||||
### 10.1 管理台无法登录
|
||||
|
||||
- 是否设置 `AIA_ASSISTANT_PASSWORD`
|
||||
- 是否重启服务并让新环境变量生效
|
||||
|
||||
### 10.2 MCP 显示可用但工具数为 0
|
||||
|
||||
- 先执行 `Health`
|
||||
- 再执行 `Sync Tools`
|
||||
- 再执行 `Check Installed`
|
||||
|
||||
### 10.3 Check Installed 全红
|
||||
|
||||
- 检查 server 是否输出标准 JSON-RPC(stdout)
|
||||
- 日志必须写 stderr,避免协议污染
|
||||
- 确认 `entry_command/entry_args` 正确
|
||||
|
||||
### 10.4 微信能收不能回 / 回不去
|
||||
|
||||
按顺序检查:
|
||||
|
||||
1) 网关是否健康(`/health` 必须快速返回)
|
||||
2) sidecar 是否运行(`weixin_status.ps1`)
|
||||
3) sidecar 日志是否有 `sendmessage` 失败提示(`weixin_sidecar.err.log`)
|
||||
|
||||
如果 8787 端口被僵尸进程占用,先执行:
|
||||
|
||||
- `powershell -ExecutionPolicy Bypass -File .\scripts\stop_gateway.ps1 -Force`
|
||||
- `powershell -ExecutionPolicy Bypass -File .\scripts\start_gateway.ps1`
|
||||
- `powershell -ExecutionPolicy Bypass -File .\scripts\weixin_stop.ps1 -Force`
|
||||
- `powershell -ExecutionPolicy Bypass -File .\scripts\weixin_start.ps1`
|
||||
|
||||
---
|
||||
|
||||
## 11. 个人微信(官方 ClawBot)接入
|
||||
|
||||
说明:本项目采用「A 模式」接入。微信插件由本地 sidecar 运行,直接调用本地 Python gateway。
|
||||
|
||||
前置条件:
|
||||
|
||||
- 微信端已开通并启用 ClawBot 插件
|
||||
- 当前目录为 `D:/project/chatgpt/oclaw`
|
||||
- 已安装 Node.js(建议 22+)与 npm
|
||||
- 网关可访问:`http://127.0.0.1:8787/health`
|
||||
|
||||
### 11.1 安装 sidecar 依赖
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\weixin_install.ps1
|
||||
```
|
||||
|
||||
安装目录:
|
||||
|
||||
- `data/channel_sidecar/openclaw-weixin/`
|
||||
|
||||
### 11.2 扫码登录(获取 bot token)
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\weixin_login.ps1
|
||||
```
|
||||
|
||||
登录态写入:
|
||||
|
||||
- `data/channel_sidecar/openclaw-weixin/state/openclaw-weixin/accounts/*.json`
|
||||
- `data/channel_sidecar/openclaw-weixin/state/openclaw-weixin/accounts.json`
|
||||
|
||||
### 11.3 启动微信 sidecar
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\weixin_start.ps1
|
||||
```
|
||||
|
||||
查看状态:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\weixin_status.ps1
|
||||
```
|
||||
|
||||
停止:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\weixin_stop.ps1
|
||||
```
|
||||
|
||||
日志文件:
|
||||
|
||||
- `data/channel_sidecar/openclaw-weixin/logs/weixin_sidecar.log`
|
||||
- `data/channel_sidecar/openclaw-weixin/logs/weixin_sidecar.err.log`
|
||||
|
||||
### 11.4 当前行为说明
|
||||
|
||||
- 微信回复为“整段返回”,非逐 token 流式
|
||||
- 发送前会清理推理/工具痕迹(如 `<redacted_thinking>...</redacted_thinking>`)
|
||||
- 发送前会处理文本换行(含字面量 `\n`)
|
||||
|
||||
---
|
||||
|
||||
## 12. LLM 回放策略(reasoning / content / tool)
|
||||
|
||||
适用范围:`oclaw` 运行时构建“下一轮发给模型”的消息序列。
|
||||
|
||||
### 12.1 设计原则
|
||||
|
||||
- `content` 与 `reasoning` 分离:正文走 `assistant_text`,推理走独立 `reasoning` 事件。
|
||||
- 默认不回放推理文本:回放只包含正文 + tool(及必要的配对字段)。
|
||||
- 历史兼容:旧数据中若正文含 `<think>...</think>` 或 `<redacted_thinking>...</redacted_thinking>`,在回放构建时会剥离。
|
||||
|
||||
### 12.2 工具回放分层
|
||||
|
||||
- 最近 3 轮工具调用保留全量结果。
|
||||
- 更早工具结果降级为摘要(保留 `tool_call_id` 配对信息,避免网关拒绝)。
|
||||
- 单条内容仍受现有超长截断限制。
|
||||
|
||||
### 12.3 推理签名白名单(provider 兼容)
|
||||
|
||||
只针对“签名元字段”而非推理文本。用于部分 provider 在工具连续调用时保持上下文连续性。
|
||||
|
||||
环境变量:`AIA_REPLAY_REASONING_SIGNATURE_POLICY`
|
||||
|
||||
- `auto`(默认):仅在白名单 provider 路径回放签名元字段(当前包含 Gemini 路径)。
|
||||
- `on`:所有模型都回放签名元字段(调试/兼容兜底)。
|
||||
- `off`:完全不回放签名元字段(最严格模式)。
|
||||
|
||||
推荐:
|
||||
|
||||
- 常规生产:保持默认 `auto`。
|
||||
- 若遇到特定模型 tool-loop 连续性问题:临时设为 `on` 验证,再收敛到最小白名单。
|
||||
|
||||
### 12.4 `.env` 最小配置示例
|
||||
|
||||
```bash
|
||||
# 工具回放:最近 3 轮全量(默认 3,可按需调整)
|
||||
AIA_REPLAY_TOOL_FULL_ROUNDS=3
|
||||
|
||||
# 推理签名回放策略:auto / on / off
|
||||
# 生产建议 auto:仅白名单 provider 回放签名元字段
|
||||
AIA_REPLAY_REASONING_SIGNATURE_POLICY=auto
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. memory-wiki 插件启用与排障
|
||||
|
||||
### 13.1 启用配置(oclaw/oclaw.json)
|
||||
|
||||
将 `memory-wiki` 放入启用列表,并建议把 memory slot 指向它:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"enabled": ["memory-wiki"],
|
||||
"slots": {
|
||||
"memory": "memory-wiki"
|
||||
},
|
||||
"entries": {
|
||||
"memory-wiki": {
|
||||
"wiki_root": "oclaw/docs/memory-system/wiki",
|
||||
"max_search_results": 20,
|
||||
"max_get_lines": 800,
|
||||
"auto": {
|
||||
"enabled": true,
|
||||
"inject": {
|
||||
"max_chars": 1800,
|
||||
"top_k": 6
|
||||
},
|
||||
"worker": {
|
||||
"enabled": true
|
||||
},
|
||||
"topic_routing": {
|
||||
"rules": [
|
||||
{
|
||||
"topic": "network",
|
||||
"keywords": ["vlan", "router", "switch", "network", "dns", "gateway"]
|
||||
},
|
||||
{
|
||||
"topic": "devops",
|
||||
"keywords": ["deploy", "k8s", "kubernetes", "docker", "ci", "ops"]
|
||||
},
|
||||
{
|
||||
"topic": "engineering",
|
||||
"keywords": ["bug", "fix", "todo", "feature", "refactor", "test"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 13.2 会话可用工具(预期)
|
||||
|
||||
- `wiki_status`
|
||||
- `wiki_get`
|
||||
- `wiki_search`
|
||||
- `wiki_lint`
|
||||
- `wiki_apply`
|
||||
|
||||
### 13.3 常见故障
|
||||
|
||||
- `invalid_arguments`:参数缺失或 `path` 非 `.md` / 越界到 wiki 根目录外。
|
||||
- `wiki_not_found`:目标文件不存在(`wiki_get` / `wiki_apply delete`)。
|
||||
- `invalid_action`:`wiki_apply` 的 `action` 仅支持 `write|append|delete`。
|
||||
- `wiki_runtime_error`:运行期异常(编码、权限、IO),先检查路径权限与文件占用。
|
||||
|
||||
### 13.4 回退方案
|
||||
|
||||
- 临时禁用 wiki 插件:从 `plugins.enabled` 移除 `memory-wiki` 后重启 gateway。
|
||||
- 或仅关闭 memory slot:`plugins.slots.memory` 设为 `"none"`(保留插件安装但不作为 memory 槽位)。
|
||||
|
||||
### 13.5 自动化链路自检(smoke test)
|
||||
|
||||
在 `gateway + wiki worker` 运行时,执行:
|
||||
|
||||
```powershell
|
||||
python .\scripts\wiki_auto_smoke_test.py
|
||||
```
|
||||
|
||||
该脚本会:
|
||||
|
||||
- 投递一条 `wiki_capture` 任务到 `openclaw_task`
|
||||
- 轮询任务状态直到 `done/failed/timeout`
|
||||
- 输出当前写入产物状态(`merged-turns.md`、`topic-index.json`、`index.json`、`LINT_REPORT.md`)
|
||||
|
||||
---
|
||||
|
||||
1
docs/memory-system/Archives/.gitkeep
Normal file
1
docs/memory-system/Archives/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
11
docs/memory-system/Archives/README.md
Normal file
11
docs/memory-system/Archives/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Archives
|
||||
|
||||
Use for inactive materials from Projects, Areas, and Resources.
|
||||
|
||||
Archive criteria:
|
||||
|
||||
- project is complete or cancelled
|
||||
- responsibility is no longer active
|
||||
- resource is stale and low-value
|
||||
|
||||
Archived content stays searchable but should not appear in daily workflow.
|
||||
1
docs/memory-system/Areas/.gitkeep
Normal file
1
docs/memory-system/Areas/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
11
docs/memory-system/Areas/README.md
Normal file
11
docs/memory-system/Areas/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Areas
|
||||
|
||||
Use for ongoing responsibilities without a fixed end date.
|
||||
|
||||
Examples:
|
||||
|
||||
- engineering standards
|
||||
- health routines
|
||||
- language learning maintenance
|
||||
|
||||
Review regularly and keep only current responsibility notes.
|
||||
1
docs/memory-system/Inbox/.gitkeep
Normal file
1
docs/memory-system/Inbox/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
11
docs/memory-system/Inbox/README.md
Normal file
11
docs/memory-system/Inbox/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Inbox
|
||||
|
||||
Temporary capture queue for raw inputs.
|
||||
|
||||
Daily target:
|
||||
|
||||
- capture 3-5 valuable items
|
||||
- distill 1-3 items into atomic notes
|
||||
- leave no high-value item unprocessed by end of day
|
||||
|
||||
Do not store long-term notes here.
|
||||
22
docs/memory-system/PARA.md
Normal file
22
docs/memory-system/PARA.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# PARA Container Contract
|
||||
|
||||
These containers are fixed and should remain stable:
|
||||
|
||||
- `Projects`: active, deadline-bound outcomes
|
||||
- `Areas`: ongoing responsibilities without an end date
|
||||
- `Resources`: reusable reference knowledge and learning notes
|
||||
- `Archives`: inactive material moved out of active flow
|
||||
- `Inbox`: temporary capture queue before processing
|
||||
|
||||
## Operating Rules
|
||||
|
||||
1. Do not add new top-level containers unless there is a major redesign.
|
||||
2. Process `Inbox` items during daily/weekly routines.
|
||||
3. Store each item in exactly one primary container.
|
||||
4. Move inactive items to `Archives` during weekly review.
|
||||
|
||||
## Move Guide
|
||||
|
||||
- `Inbox` -> `Projects`/`Areas`/`Resources` after distillation
|
||||
- `Projects` -> `Archives` when outcome is finished or dropped
|
||||
- `Areas`/`Resources` -> `Archives` when no longer relevant
|
||||
1
docs/memory-system/Projects/.gitkeep
Normal file
1
docs/memory-system/Projects/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
11
docs/memory-system/Projects/README.md
Normal file
11
docs/memory-system/Projects/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Projects
|
||||
|
||||
Use for time-bound outcomes with a clear deadline.
|
||||
|
||||
Examples:
|
||||
|
||||
- feature launch notes
|
||||
- exam preparation sprint
|
||||
- migration checklist
|
||||
|
||||
When completed or dropped, move materials to `../Archives`.
|
||||
90
docs/memory-system/README.md
Normal file
90
docs/memory-system/README.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Memory System Implementation
|
||||
|
||||
This folder implements a lightweight memory workflow based on:
|
||||
|
||||
- PARA for operational organization
|
||||
- Atomic notes for reusable knowledge
|
||||
- SRS for long-term retention
|
||||
- Weekly review for maintenance and quality control
|
||||
|
||||
## Fixed PARA Containers
|
||||
|
||||
Do not change these top-level containers unless there is a major system redesign.
|
||||
|
||||
- `Projects`: time-bound outcomes with a deadline
|
||||
- `Areas`: ongoing responsibilities without an end date
|
||||
- `Resources`: reference topics and learning materials
|
||||
- `Archives`: inactive content from all other containers
|
||||
- `Inbox`: temporary capture queue before classification
|
||||
|
||||
Container contract and move rules are defined in `PARA.md`.
|
||||
|
||||
## Minimal Workflow (10-20 minutes/day)
|
||||
|
||||
1. Capture: move 3-5 raw items into `Inbox`.
|
||||
2. Distill: convert 1-3 items into atomic notes under `Resources`.
|
||||
3. Recall: generate 3-10 SRS cards using the conversion guide.
|
||||
4. Express: produce one output (summary, answer, code note).
|
||||
|
||||
## Weekly Review (30 minutes/week)
|
||||
|
||||
Use `templates/weekly-review.md` to:
|
||||
|
||||
- empty inbox
|
||||
- improve links
|
||||
- archive stale items
|
||||
- define next week's focus reviews
|
||||
|
||||
Use `weekly-review-schedule.md` to keep the review on a fixed weekly slot.
|
||||
|
||||
## Two-Week Minimum Rollout
|
||||
|
||||
- Week 1:
|
||||
- initialize PARA containers (`Projects`, `Areas`, `Resources`, `Archives`, `Inbox`)
|
||||
- capture and process notes daily using the workflow below
|
||||
- convert at least one high-value note/day into SRS cards
|
||||
- Week 2:
|
||||
- enforce linking quality (each new note links to at least one existing note)
|
||||
- run one full weekly review
|
||||
- tune new-card load based on backlog and study time
|
||||
|
||||
## Templates
|
||||
|
||||
- `templates/atomic-note.md`
|
||||
- `templates/srs-conversion.md`
|
||||
- `templates/weekly-review.md`
|
||||
- `templates/daily-routine.md`
|
||||
|
||||
## Folder Guidance
|
||||
|
||||
- `Projects/README.md`
|
||||
- `Areas/README.md`
|
||||
- `Resources/README.md`
|
||||
- `Archives/README.md`
|
||||
- `Inbox/README.md`
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- 7 days: daily capture + review runs without backlog blow-up.
|
||||
- 30 days: core topics can be recalled without opening source material.
|
||||
- 60-90 days: writing/decision/coding reuse speed improves and repeated lookup drops.
|
||||
- If review load is too high: reduce new cards first, keep only high-value knowledge.
|
||||
|
||||
## Automation Commands
|
||||
|
||||
Use the unified CLI to run this system with automatic recommendations:
|
||||
|
||||
- `powershell -ExecutionPolicy Bypass -File .\scripts\status_ops.ps1`
|
||||
- `powershell -ExecutionPolicy Bypass -File .\scripts\start_ops.ps1`
|
||||
- `powershell -ExecutionPolicy Bypass -File .\scripts\stop_ops.ps1`
|
||||
|
||||
Outputs are generated in `docs/memory-system/runs/`:
|
||||
|
||||
- `daily-YYYY-MM-DD.md`
|
||||
- `weekly-YYYY-Www.md`
|
||||
|
||||
What is automated:
|
||||
|
||||
- fixed folder validation (`Projects/Areas/Resources/Archives/Inbox`)
|
||||
- smart new-card limit recommendation (3-10/day based on backlog + inbox pressure)
|
||||
- automatic focus ordering (`Projects > Areas > Resources`, weighted by active note volume)
|
||||
1
docs/memory-system/Resources/.gitkeep
Normal file
1
docs/memory-system/Resources/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
11
docs/memory-system/Resources/README.md
Normal file
11
docs/memory-system/Resources/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Resources
|
||||
|
||||
Use for reusable knowledge and reference topics.
|
||||
|
||||
This is the main home of atomic notes.
|
||||
|
||||
Requirements:
|
||||
|
||||
- one note = one claim
|
||||
- link each new note to at least one existing note
|
||||
- create SRS candidates for high-value notes
|
||||
19
docs/memory-system/runs/daily-2026-04-21.md
Normal file
19
docs/memory-system/runs/daily-2026-04-21.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Daily Memory Run (2026-04-21)
|
||||
|
||||
- Focus topic: `Resources`
|
||||
- Suggested new cards today: `9`
|
||||
- Current inbox notes: `0`
|
||||
- Review backlog input: `25`
|
||||
|
||||
## Auto Steps
|
||||
|
||||
1. Capture 3-5 high-value items into `Inbox`.
|
||||
2. Distill 1-3 items into atomic notes under `Resources`.
|
||||
3. Convert notes into Q/A or cloze cards (respect suggested limit).
|
||||
4. Produce one output (summary/answer/code note).
|
||||
|
||||
## Smart Suggestions
|
||||
|
||||
- Priority order now: `Resources`
|
||||
- If reviews feel overloaded, reduce new cards before skipping due reviews.
|
||||
- If inbox keeps growing for 2+ days, process inbox first before new captures.
|
||||
24
docs/memory-system/runs/weekly-2026-W17.md
Normal file
24
docs/memory-system/runs/weekly-2026-W17.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Weekly Memory Review (2026-W17)
|
||||
|
||||
- Run date: `2026-04-21`
|
||||
- Suggested next-week new cards/day: `9`
|
||||
|
||||
## Snapshot
|
||||
|
||||
- Projects notes: `0`
|
||||
- Areas notes: `0`
|
||||
- Resources notes: `0`
|
||||
- Archives notes: `0`
|
||||
- Inbox notes: `0`
|
||||
|
||||
## Fixed Review Checklist
|
||||
|
||||
- [ ] Inbox zero
|
||||
- [ ] Linking/backlinks completion
|
||||
- [ ] Move inactive notes to Archives
|
||||
- [ ] Tune next-week card load
|
||||
|
||||
## Auto Focus for Next Week
|
||||
|
||||
- Top topics: `Resources`
|
||||
- Keep new cards within 3-10/day and adjust by real review pressure.
|
||||
35
docs/memory-system/templates/atomic-note.md
Normal file
35
docs/memory-system/templates/atomic-note.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Atomic Note Template
|
||||
|
||||
> Rule: one note, one claim.
|
||||
|
||||
## Title
|
||||
|
||||
`<clear statement in your own words>`
|
||||
|
||||
## Viewpoint (Claim)
|
||||
|
||||
- What is the key idea?
|
||||
- Why does it matter?
|
||||
|
||||
## Source
|
||||
|
||||
- Origin: `<book/article/video/conversation>`
|
||||
- Link or citation: `<url or reference>`
|
||||
- Capture date: `<YYYY-MM-DD>`
|
||||
|
||||
## Associations (Links)
|
||||
|
||||
- Related note 1: `[[...]]`
|
||||
- Related note 2: `[[...]]`
|
||||
- Contradiction or alternative: `[[...]]`
|
||||
|
||||
## Reusable Scenarios
|
||||
|
||||
- Where can this be applied? (writing/decision/coding)
|
||||
- Trigger signal: "When I see X, apply this note."
|
||||
|
||||
## Card Candidates (Optional)
|
||||
|
||||
- Q: `<question>`
|
||||
A: `<short answer>`
|
||||
- Cloze: `<sentence with one hidden concept>`
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue