From b4bd7698a49a2ce79e2f2a9fcc610c9784b395c1 Mon Sep 17 00:00:00 2001 From: wuyang6 Date: Mon, 11 May 2026 09:59:34 +0800 Subject: [PATCH] Add skill sync action --- backend/api/server.py | 80 +++++++++++++++++++ frontend/app/app/api/claw/skills/route.ts | 25 ++++++ .../app/components/assistant-ui/thread.tsx | 57 +++++++++++++ 3 files changed, 162 insertions(+) diff --git a/backend/api/server.py b/backend/api/server.py index ba14413..3349ace 100644 --- a/backend/api/server.py +++ b/backend/api/server.py @@ -13,6 +13,7 @@ import queue import re import shutil import signal +import subprocess import threading import time import venv @@ -632,6 +633,69 @@ class AgentState: if key.startswith(account_prefix): self._agents.pop(key, None) + def _clear_agents_for_account(self, account_id: str | None) -> None: + account_prefix = f'{self._account_key(account_id)}:' + for key in list(self._agents): + if key.startswith(account_prefix): + self._agents.pop(key, None) + + def sync_skills_from_git(self, account_id: str | None) -> dict[str, Any]: + """Pull the current git branch and refresh account-scoped skill state.""" + with self._lock: + config = self._config_for(account_id) + repo = config.cwd + if not (repo / '.git').exists(): + raise ValueError(f'当前工作目录不是 git 仓库:{repo}') + + def run_git(args: list[str], *, timeout: int = 60) -> subprocess.CompletedProcess[str]: + env = { + **os.environ, + 'GIT_TERMINAL_PROMPT': '0', + } + proc = subprocess.run( + ['git', *args], + cwd=repo, + capture_output=True, + text=True, + timeout=timeout, + env=env, + ) + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip() + raise ValueError(f'git {" ".join(args)} 失败:{detail}') + return proc + + dirty = run_git(['status', '--porcelain', '--untracked-files=no']).stdout.strip() + if dirty: + raise ValueError('工作区存在未提交的 tracked 改动,已停止同步 Skill。') + + branch = run_git(['branch', '--show-current']).stdout.strip() or 'main' + before = run_git(['rev-parse', 'HEAD']).stdout.strip() + run_git(['fetch', 'origin'], timeout=120) + pull = run_git(['pull', '--ff-only', 'origin', branch], timeout=120) + after = run_git(['rev-parse', 'HEAD']).stdout.strip() + changed_files: list[str] = [] + if before != after: + changed_files = [ + line.strip() + for line in run_git( + ['diff', '--name-only', f'{before}..{after}'], + timeout=60, + ).stdout.splitlines() + if line.strip() + ] + self._clear_agents_for_account(account_id) + skills = get_bundled_skills(config.cwd) + return { + 'branch': branch, + 'before': before, + 'after': after, + 'updated': before != after, + 'changed_files': changed_files, + 'skill_count': sum(1 for skill in skills if skill.user_invocable), + 'message': pull.stdout.strip() or pull.stderr.strip(), + } + def _build_agent(self, account_id: str | None = None) -> LocalCodingAgent: config = self._config_for(account_id) paths = self.account_paths(account_id) @@ -797,6 +861,10 @@ class SkillPreferenceUpdate(BaseModel): account_id: str | None = None +class SkillSyncRequest(BaseModel): + account_id: str | None = None + + class SessionTitleUpdate(BaseModel): title: str = Field(min_length=1, max_length=80) @@ -879,6 +947,18 @@ def create_app(state: AgentState) -> FastAPI: raise HTTPException(status_code=400, detail=str(exc)) return await list_skills(payload.account_id) + @app.post('/api/skills/sync') + async def sync_skills(payload: SkillSyncRequest) -> dict[str, Any]: + try: + result = await asyncio.to_thread( + state.sync_skills_from_git, + payload.account_id, + ) + except (ValueError, subprocess.TimeoutExpired) as exc: + raise HTTPException(status_code=400, detail=str(exc)) + result['skills'] = await list_skills(payload.account_id) + return result + @app.get('/api/models') async def list_models_get(account_id: str | None = None) -> dict[str, Any]: config = state.config_for(account_id) diff --git a/frontend/app/app/api/claw/skills/route.ts b/frontend/app/app/api/claw/skills/route.ts index a41108f..be25bc2 100644 --- a/frontend/app/app/api/claw/skills/route.ts +++ b/frontend/app/app/api/claw/skills/route.ts @@ -47,3 +47,28 @@ export async function PATCH(request: Request) { }, }); } + +export async function POST() { + const account = await getCurrentAccount(); + if (!account) + return Response.json({ error: "unauthorized" }, { status: 401 }); + + const response = await fetch(`${CLAW_API_URL}/api/skills/sync`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + account_id: account.id, + }), + cache: "no-store", + }); + const payload = await response.text(); + return new Response(payload, { + status: response.status, + headers: { + "content-type": + response.headers.get("content-type") ?? "application/json", + }, + }); +} diff --git a/frontend/app/components/assistant-ui/thread.tsx b/frontend/app/components/assistant-ui/thread.tsx index 14d4b01..dacb72d 100644 --- a/frontend/app/components/assistant-ui/thread.tsx +++ b/frontend/app/components/assistant-ui/thread.tsx @@ -76,6 +76,17 @@ type SkillSummary = { configurable?: boolean; }; +type SkillSyncResponse = { + skills?: SkillSummary[]; + updated?: boolean; + before?: string; + after?: string; + changed_files?: string[]; + message?: string; + error?: string; + detail?: string; +}; + type SlashCommandSummary = { names: string[]; primary: string; @@ -1099,6 +1110,7 @@ function SkillInsertDialog() { const [skills, setSkills] = useState([]); const [status, setStatus] = useState("点击后读取当前后端 Skill 列表。"); const [updatingSkill, setUpdatingSkill] = useState(null); + const [syncingSkills, setSyncingSkills] = useState(false); async function loadSkills() { setStatus("正在读取 Skill 列表..."); @@ -1119,6 +1131,33 @@ function SkillInsertDialog() { } } + async function syncSkills() { + setSyncingSkills(true); + setStatus("正在从 git 同步 Skill..."); + try { + const response = await fetch("/api/claw/skills", { + method: "POST", + cache: "no-store", + }); + const payload = (await response.json()) as SkillSyncResponse; + if (!response.ok || !Array.isArray(payload.skills)) { + throw new Error(payload.detail ?? payload.error ?? "同步 Skill 失败"); + } + setSkills(payload.skills); + const changedCount = payload.changed_files?.length ?? 0; + if (payload.updated) { + const shortHash = payload.after ? payload.after.slice(0, 7) : "最新提交"; + setStatus(`Skill 已更新到 ${shortHash},变更 ${changedCount} 个文件。`); + } else { + setStatus("Skill 已是最新。"); + } + } catch (err) { + setStatus(err instanceof Error ? err.message : "同步 Skill 失败"); + } finally { + setSyncingSkills(false); + } + } + async function setSkillEnabled(skill: SkillSummary, enabled: boolean) { if (skill.configurable === false) return; const previous = skills; @@ -1182,6 +1221,24 @@ function SkillInsertDialog() { 勾选的 Skill 会进入模型可见列表;点击名称可插入显式调用。 +
+ +

+ 从当前 git 分支拉取最新 skill 文件 +

+
{status ? (

{status}

) : null}