From d01a04f990099304b68eae9d7865270dc62bb508 Mon Sep 17 00:00:00 2001 From: Smart_Chen Date: Thu, 16 Jul 2026 11:30:01 +0800 Subject: [PATCH] feat: add skill editing and human confirmation (Issue #40) - Backend: add update_skill, confirm_skill, skill_status methods to ContextSeek client - Backend: add PUT /skill and POST /skill/confirm HTTP endpoints - Frontend: add edit mode to SkillsPanel (name, description, body, parameters, tags) - Frontend: add human confirmation action with confidence=1.0 and verified=True - Frontend: add status badges (auto-generated / edited / confirmed) - Frontend: add error messages for save/confirm failures - i18n: add 17 new translation keys (zh/en/ja) - Tests: 18 new tests (4 HTTP + 14 domain) --- dashboard/src/lib/ctxClient.ts | 7 + dashboard/src/lib/i18n.tsx | 48 ++++ dashboard/src/lib/types.ts | 29 ++ dashboard/src/panels/SkillsPanel.tsx | 381 ++++++++++++++++++++++---- dashboard/vite.config.ts | 2 +- src/contextseek/client/contextseek.py | 152 ++++++++++ src/contextseek/http/server.py | 54 ++++ tests/unit_tests/test_http_server.py | 258 +++++++++++++++++ tests/unit_tests/test_mcp_skills.py | 177 ++++++++++++ 9 files changed, 1053 insertions(+), 55 deletions(-) diff --git a/dashboard/src/lib/ctxClient.ts b/dashboard/src/lib/ctxClient.ts index e1c9295..02995ee 100644 --- a/dashboard/src/lib/ctxClient.ts +++ b/dashboard/src/lib/ctxClient.ts @@ -41,12 +41,16 @@ import type { RetrieveRequest, RetrieveResponse, SeedResponse, + SkillConfirmRequest, + SkillConfirmResponse, SkillContextRequest, SkillContextResponse, SkillMdRequest, SkillMdResponse, SkillToolsRequest, SkillToolsResponse, + SkillUpdateRequest, + SkillUpdateResponse, StatusIdResponse, UpstreamRequest, UpstreamResponse, @@ -187,6 +191,9 @@ export const ctx = { skillTools: (req: SkillToolsRequest) => post("/skill_tools", req), skillContext: (req: SkillContextRequest) => post("/skill_context", req), skillMd: (req: SkillMdRequest) => post("/skill_md", req), + updateSkill: (req: SkillUpdateRequest) => put("/skill", req), + confirmSkill: (req: SkillConfirmRequest) => + post("/skill/confirm", req), envVault: { items: () => get("/env-vault/items"), upsert: (req: EnvVaultUpsertRequest) => diff --git a/dashboard/src/lib/i18n.tsx b/dashboard/src/lib/i18n.tsx index 55b9ca5..205e8d0 100644 --- a/dashboard/src/lib/i18n.tsx +++ b/dashboard/src/lib/i18n.tsx @@ -294,6 +294,22 @@ const zh: Dict = { "skills.detail.confidence": "置信度", "skills.detail.source": "来源", "skills.detail.createdAt": "创建时间", + "skills.detail.edit": "编辑", + "skills.detail.save": "保存", + "skills.detail.cancel": "取消", + "skills.detail.confirm": "确认", + "skills.detail.confirmed": "已确认", + "skills.detail.status": "状态", + "skills.detail.name": "名称", + "skills.detail.tags": "标签", + "skills.detail.tagsHint": "逗号分隔", + "skills.detail.parametersHint": "JSON 格式", + "skills.detail.parametersInvalid": "参数不是合法 JSON", + "skills.detail.saveError": "保存失败", + "skills.detail.confirmError": "确认失败", + "skills.status.auto": "自动生成", + "skills.status.edited": "已编辑", + "skills.status.confirmed": "已确认", // env vault "envVault.vault.title": "环境变量保险箱", @@ -654,6 +670,22 @@ const en: Dict = { "skills.detail.confidence": "Confidence", "skills.detail.source": "Source", "skills.detail.createdAt": "Created at", + "skills.detail.edit": "Edit", + "skills.detail.save": "Save", + "skills.detail.cancel": "Cancel", + "skills.detail.confirm": "Confirm", + "skills.detail.confirmed": "Confirmed", + "skills.detail.status": "Status", + "skills.detail.name": "Name", + "skills.detail.tags": "Tags", + "skills.detail.tagsHint": "Comma separated", + "skills.detail.parametersHint": "JSON format", + "skills.detail.parametersInvalid": "Parameters is not valid JSON", + "skills.detail.saveError": "Save failed", + "skills.detail.confirmError": "Confirm failed", + "skills.status.auto": "Auto-generated", + "skills.status.edited": "Edited", + "skills.status.confirmed": "Confirmed", "envVault.vault.title": "Env Vault", "envVault.vault.desc": "Reusable KEY=value records, stored encrypted on disk (fixed app-level key, no passphrase).", @@ -1012,6 +1044,22 @@ const ja: Dict = { "skills.detail.confidence": "信頼度", "skills.detail.source": "ソース", "skills.detail.createdAt": "作成日時", + "skills.detail.edit": "編集", + "skills.detail.save": "保存", + "skills.detail.cancel": "キャンセル", + "skills.detail.confirm": "確認", + "skills.detail.confirmed": "確認済み", + "skills.detail.status": "ステータス", + "skills.detail.name": "名称", + "skills.detail.tags": "タグ", + "skills.detail.tagsHint": "カンマ区切り", + "skills.detail.parametersHint": "JSON形式", + "skills.detail.parametersInvalid": "パラメータが有効なJSONではありません", + "skills.detail.saveError": "保存に失敗しました", + "skills.detail.confirmError": "確認に失敗しました", + "skills.status.auto": "自動生成", + "skills.status.edited": "編集済み", + "skills.status.confirmed": "確認済み", "envVault.vault.title": "環境変数ボールト", "envVault.vault.desc": "再利用可能な KEY=value レコードを、暗号化してローカル保存します(アプリ固定キー、パスフレーズ不要)。", diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index 0aee9da..fa1db89 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -489,6 +489,35 @@ export interface SkillMdResponse { skills: SkillMdItem[]; } +// --- Skill editing & confirmation (Issue #40) --- + +export type SkillStatus = "auto-generated" | "edited" | "confirmed"; + +export interface SkillUpdateRequest { + scope: string; + item_id: string; + name?: string; + description?: string; + body?: string; + parameters?: Record; + tags?: string[]; +} + +export interface SkillConfirmRequest { + scope: string; + item_id: string; +} + +export interface SkillUpdateResponse { + item: ContextItem; + status: SkillStatus; +} + +export interface SkillConfirmResponse { + item: ContextItem; + status: "confirmed"; +} + // --- Env Vault --- export interface EnvVaultItem { diff --git a/dashboard/src/panels/SkillsPanel.tsx b/dashboard/src/panels/SkillsPanel.tsx index d3dd1d5..5885e6b 100644 --- a/dashboard/src/panels/SkillsPanel.tsx +++ b/dashboard/src/panels/SkillsPanel.tsx @@ -7,13 +7,17 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, + DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; import { useScope } from "@/context/ScopeContext"; import { ctx } from "@/lib/ctxClient"; import { useI18n } from "@/lib/i18n"; -import type { ContextItem } from "@/lib/types"; +import type { ContextItem, SkillStatus } from "@/lib/types"; function triggerDownload(filename: string, content: string, mimeType = "application/json") { const blob = new Blob([content], { type: mimeType }); @@ -87,38 +91,108 @@ function ContentBlock({ ); } +// ─── Status helpers ────────────────────────────────────────────────────────── + +function getSkillStatus(item: ContextItem): SkillStatus { + if (item.content && typeof item.content === "object") { + const status = (item.content as Record).status; + if (status === "auto-generated" || status === "edited" || status === "confirmed") { + return status; + } + } + if (item.provenance.verified) return "confirmed"; + if (item.updated_at) return "edited"; + return "auto-generated"; +} + +function StatusBadge({ status }: { status: SkillStatus }) { + const { t } = useI18n(); + if (status === "confirmed") { + return ( + + {t("skills.status.confirmed")} + + ); + } + if (status === "edited") { + return ( + + {t("skills.status.edited")} + + ); + } + return ( + + {t("skills.status.auto")} + + ); +} + +function StatusDot({ status }: { status: SkillStatus }) { + const color = + status === "confirmed" ? "bg-emerald-500" + : status === "edited" ? "bg-amber-500" + : "bg-slate-400"; + return ; +} + +// ─── Skill detail dialog (view + edit + confirm) ───────────────────────────── + +interface EditForm { + name: string; + description: string; + body: string; + parameters: string; // JSON text + tags: string; // comma-separated +} + function SkillDetailDialog({ item, onClose, + onItemUpdated, }: { item: ContextItem | null; onClose: () => void; + onItemUpdated: (updated: ContextItem) => void; }) { const { t } = useI18n(); + const { scope } = useScope(); + const [editMode, setEditMode] = useState(false); + const [saving, setSaving] = useState(false); + const [confirming, setConfirming] = useState(false); + const [error, setError] = useState(null); + const [form, setForm] = useState({ name: "", description: "", body: "", parameters: "", tags: "" }); + + // Reset state when item changes + useEffect(() => { + setEditMode(false); + setError(null); + }, [item?.id]); + if (!item) return null; - // Handle both string-content and dict-content skills const isStringContent = typeof item.content === "string"; const dictContent = item.content && typeof item.content === "object" ? (item.content as Record) : null; - const name = (dictContent?.name as string) || item.summary || item.id; - const description = dictContent?.description as string | undefined; - // For string-content skills, the body IS the raw content string - const body = isStringContent + const status = getSkillStatus(item); + + // Read-only field extraction + const viewName = getSkillName(item); + const viewDescription = dictContent?.description as string | undefined; + const viewBody = isStringContent ? (item.content as string) : (dictContent?.body as string | undefined); - const parameters = dictContent?.parameters as Record | undefined; - const inputSchema = dictContent?.inputSchema as Record | undefined; - const version = dictContent?.version as string | undefined; + const viewParameters = dictContent?.parameters as Record | undefined; + const viewInputSchema = dictContent?.inputSchema as Record | undefined; + const viewVersion = dictContent?.version as string | undefined; const contentTags = dictContent?.tags as string[] | undefined; - // Show item.tags but exclude generic stage/type tags already visible as Badge const displayTags = [ ...(contentTags ?? []), ...(item.tags ?? []).filter( - (t) => !["prompt_skill", "tool_skill", "mcp_skill", "prompt", "tool", "mcp"].includes(t), + (tg) => !["prompt_skill", "tool_skill", "mcp_skill", "prompt", "tool", "mcp"].includes(tg), ), ].filter((v, i, a) => a.indexOf(v) === i); @@ -126,6 +200,70 @@ function SkillDetailDialog({ const confidenceColor = confidence >= 80 ? "text-green-500" : confidence >= 60 ? "text-yellow-500" : "text-red-500"; + function enterEditMode() { + setForm({ + name: viewName, + description: viewDescription ?? "", + body: viewBody ?? "", + parameters: viewParameters ? JSON.stringify(viewParameters, null, 2) : "{}", + tags: displayTags.join(", "), + }); + setError(null); + setEditMode(true); + } + + async function handleSave() { + if (!item) return; + setError(null); + + // Validate parameters JSON + let parsedParameters: Record | undefined; + try { + parsedParameters = JSON.parse(form.parameters); + } catch { + setError(t("skills.detail.parametersInvalid")); + return; + } + + const tagsArray = form.tags + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + setSaving(true); + try { + const res = await ctx.updateSkill({ + scope, + item_id: item.id, + name: form.name, + description: form.description, + body: form.body, + parameters: parsedParameters, + tags: tagsArray, + }); + onItemUpdated(res.item); + setEditMode(false); + } catch (e) { + setError(t("skills.detail.saveError")); + } finally { + setSaving(false); + } + } + + async function handleConfirm() { + if (!item) return; + setError(null); + setConfirming(true); + try { + const res = await ctx.confirmSkill({ scope, item_id: item.id }); + onItemUpdated(res.item); + } catch (e) { + setError(t("skills.detail.confirmError")); + } finally { + setConfirming(false); + } + } + return ( { if (!open) onClose(); }}> @@ -133,62 +271,138 @@ function SkillDetailDialog({
- {name} + {editMode ? form.name : viewName} - {description && !isStringContent && ( -

{description}

+ {!editMode && viewDescription && !isStringContent && ( +

{viewDescription}

+ )} + {!editMode && viewVersion && ( +

v{viewVersion}

)} - {version && ( -

v{version}

+ {!editMode && ( +
+ +
)}
{/* Scrollable body */}
- {body && ( - - )} + {editMode ? ( + <> + {/* Edit mode fields */} +
+ + setForm({ ...form, name: e.target.value })} + /> +
- {parameters && ( - - )} +
+ +