Add skill sync action
This commit is contained in:
@@ -13,6 +13,7 @@ import queue
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import venv
|
import venv
|
||||||
@@ -632,6 +633,69 @@ class AgentState:
|
|||||||
if key.startswith(account_prefix):
|
if key.startswith(account_prefix):
|
||||||
self._agents.pop(key, None)
|
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:
|
def _build_agent(self, account_id: str | None = None) -> LocalCodingAgent:
|
||||||
config = self._config_for(account_id)
|
config = self._config_for(account_id)
|
||||||
paths = self.account_paths(account_id)
|
paths = self.account_paths(account_id)
|
||||||
@@ -797,6 +861,10 @@ class SkillPreferenceUpdate(BaseModel):
|
|||||||
account_id: str | None = None
|
account_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SkillSyncRequest(BaseModel):
|
||||||
|
account_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SessionTitleUpdate(BaseModel):
|
class SessionTitleUpdate(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=80)
|
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))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
return await list_skills(payload.account_id)
|
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')
|
@app.get('/api/models')
|
||||||
async def list_models_get(account_id: str | None = None) -> dict[str, Any]:
|
async def list_models_get(account_id: str | None = None) -> dict[str, Any]:
|
||||||
config = state.config_for(account_id)
|
config = state.config_for(account_id)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,6 +76,17 @@ type SkillSummary = {
|
|||||||
configurable?: boolean;
|
configurable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SkillSyncResponse = {
|
||||||
|
skills?: SkillSummary[];
|
||||||
|
updated?: boolean;
|
||||||
|
before?: string;
|
||||||
|
after?: string;
|
||||||
|
changed_files?: string[];
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type SlashCommandSummary = {
|
type SlashCommandSummary = {
|
||||||
names: string[];
|
names: string[];
|
||||||
primary: string;
|
primary: string;
|
||||||
@@ -1099,6 +1110,7 @@ function SkillInsertDialog() {
|
|||||||
const [skills, setSkills] = useState<SkillSummary[]>([]);
|
const [skills, setSkills] = useState<SkillSummary[]>([]);
|
||||||
const [status, setStatus] = useState("点击后读取当前后端 Skill 列表。");
|
const [status, setStatus] = useState("点击后读取当前后端 Skill 列表。");
|
||||||
const [updatingSkill, setUpdatingSkill] = useState<string | null>(null);
|
const [updatingSkill, setUpdatingSkill] = useState<string | null>(null);
|
||||||
|
const [syncingSkills, setSyncingSkills] = useState(false);
|
||||||
|
|
||||||
async function loadSkills() {
|
async function loadSkills() {
|
||||||
setStatus("正在读取 Skill 列表...");
|
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) {
|
async function setSkillEnabled(skill: SkillSummary, enabled: boolean) {
|
||||||
if (skill.configurable === false) return;
|
if (skill.configurable === false) return;
|
||||||
const previous = skills;
|
const previous = skills;
|
||||||
@@ -1182,6 +1221,24 @@ function SkillInsertDialog() {
|
|||||||
勾选的 Skill 会进入模型可见列表;点击名称可插入显式调用。
|
勾选的 Skill 会进入模型可见列表;点击名称可插入显式调用。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-2"
|
||||||
|
disabled={syncingSkills}
|
||||||
|
onClick={syncSkills}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon
|
||||||
|
className={cn("size-3.5", syncingSkills && "animate-spin")}
|
||||||
|
/>
|
||||||
|
更新 Skill
|
||||||
|
</Button>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
从当前 git 分支拉取最新 skill 文件
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{status ? (
|
{status ? (
|
||||||
<p className="text-muted-foreground text-sm">{status}</p>
|
<p className="text-muted-foreground text-sm">{status}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user