diff --git a/dashboard/src/lib/ctxClient.ts b/dashboard/src/lib/ctxClient.ts index e1c9295..72b7e15 100644 --- a/dashboard/src/lib/ctxClient.ts +++ b/dashboard/src/lib/ctxClient.ts @@ -41,12 +41,18 @@ import type { RetrieveRequest, RetrieveResponse, SeedResponse, + SkillConfirmRequest, + SkillConfirmResponse, SkillContextRequest, SkillContextResponse, + SkillExportRequest, + SkillExportResponse, SkillMdRequest, SkillMdResponse, SkillToolsRequest, SkillToolsResponse, + SkillUpdateRequest, + SkillUpdateResponse, StatusIdResponse, UpstreamRequest, UpstreamResponse, @@ -187,6 +193,11 @@ 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), + exportSkill: (req: SkillExportRequest) => + post("/skill/export", 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..d6bcb57 100644 --- a/dashboard/src/lib/i18n.tsx +++ b/dashboard/src/lib/i18n.tsx @@ -282,6 +282,35 @@ const zh: Dict = { "skills.export": "导出格式", "skills.export.download": "下载", "skills.export.noSkills": "暂无技能", + "skills.export.toDir": "导出到目录", + "skills.export.title": "导出 Skill", + "skills.export.desc": "将已确认的 skill 导出为 SKILL.md,供其他 agent 使用", + "skills.export.targetDir": "目标目录", + "skills.export.pickDir": "选择目录", + "skills.export.dirPlaceholder": "导出目录的绝对路径", + "skills.export.manualHint": "非桌面环境请手动输入绝对路径", + "skills.export.quickTargets": "常用目标", + "skills.export.target.user": "用户默认技能目录", + "skills.export.target.project": "当前项目技能目录", + "skills.export.target.codex": "Codex 技能目录", + "skills.export.target.cursor": "Cursor 技能目录", + "skills.export.target.recent": "最近使用", + "skills.export.conflictStrategy": "冲突策略", + "skills.export.conflict.overwrite": "覆盖", + "skills.export.conflict.skip": "跳过", + "skills.export.conflict.rename": "重命名", + "skills.export.conflict.hint": "目标已存在同名 SKILL.md 时的处理方式", + "skills.export.spec": "格式", + "skills.export.spec.hermes": "Hermes", + "skills.export.spec.agent": "Agent (Anthropic)", + "skills.export.submit": "导出", + "skills.export.exporting": "导出中…", + "skills.export.success": "已导出到 {path}(写入 {written} 个 skill)", + "skills.export.skipped": "跳过 {n} 个未确认 skill", + "skills.export.error": "导出失败", + "skills.export.needConfirm": "未确认的 skill 不可导出,请先在详情中确认", + "skills.export.noSelection": "请至少选择一个已确认的 skill", + "skills.export.noConfirmed": "暂无已确认的 skill 可导出", "skills.systemPrompt": "注入 Agent 的 Prompt 片段", "skills.systemPrompt.hint": "将 prompt 类型的 skill 格式化为 块,复制后粘贴到你的 LLM 系统提示中,LLM 即可感知并遵循这些技能规范", "skills.systemPrompt.copy": "复制", @@ -294,6 +323,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": "环境变量保险箱", @@ -642,6 +687,35 @@ const en: Dict = { "skills.export": "Export formats", "skills.export.download": "Download", "skills.export.noSkills": "No skills yet", + "skills.export.toDir": "Export to directory", + "skills.export.title": "Export Skill", + "skills.export.desc": "Export confirmed skills as SKILL.md for use by other agents", + "skills.export.targetDir": "Target directory", + "skills.export.pickDir": "Pick directory", + "skills.export.dirPlaceholder": "Absolute path of export directory", + "skills.export.manualHint": "Non-desktop: paste an absolute path manually", + "skills.export.quickTargets": "Quick targets", + "skills.export.target.user": "User default skills dir", + "skills.export.target.project": "Current project skills dir", + "skills.export.target.codex": "Codex skills dir", + "skills.export.target.cursor": "Cursor skills dir", + "skills.export.target.recent": "Recent used", + "skills.export.conflictStrategy": "Conflict strategy", + "skills.export.conflict.overwrite": "Overwrite", + "skills.export.conflict.skip": "Skip", + "skills.export.conflict.rename": "Rename", + "skills.export.conflict.hint": "How to handle name conflicts when target SKILL.md exists", + "skills.export.spec": "Format", + "skills.export.spec.hermes": "Hermes", + "skills.export.spec.agent": "Agent (Anthropic)", + "skills.export.submit": "Export", + "skills.export.exporting": "Exporting…", + "skills.export.success": "Exported to {path} ({written} skill(s) written)", + "skills.export.skipped": "Skipped {n} unconfirmed skill(s)", + "skills.export.error": "Export failed", + "skills.export.needConfirm": "Unconfirmed skills cannot be exported. Please confirm them first.", + "skills.export.noSelection": "Please select at least one confirmed skill", + "skills.export.noConfirmed": "No confirmed skills available to export", "skills.systemPrompt": "Agent Prompt Injection", "skills.systemPrompt.hint": "Formats your prompt-type skills as an block. Paste into your LLM system prompt so the model knows about and follows these skill specifications.", "skills.systemPrompt.copy": "Copy", @@ -654,6 +728,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).", @@ -1000,6 +1090,35 @@ const ja: Dict = { "skills.export": "エクスポート形式", "skills.export.download": "ダウンロード", "skills.export.noSkills": "スキルなし", + "skills.export.toDir": "ディレクトリにエクスポート", + "skills.export.title": "Skill をエクスポート", + "skills.export.desc": "確認済みスキルを SKILL.md としてエクスポートし、他の agent で利用可能にします", + "skills.export.targetDir": "出力先ディレクトリ", + "skills.export.pickDir": "ディレクトリを選択", + "skills.export.dirPlaceholder": "エクスポート先の絶対パス", + "skills.export.manualHint": "デスクトップ以外:絶対パスを手動で入力してください", + "skills.export.quickTargets": "クイックターゲット", + "skills.export.target.user": "ユーザー既定スキルディレクトリ", + "skills.export.target.project": "現在のプロジェクトスキルディレクトリ", + "skills.export.target.codex": "Codex スキルディレクトリ", + "skills.export.target.cursor": "Cursor スキルディレクトリ", + "skills.export.target.recent": "最近使用したディレクトリ", + "skills.export.conflictStrategy": "競合戦略", + "skills.export.conflict.overwrite": "上書き", + "skills.export.conflict.skip": "スキップ", + "skills.export.conflict.rename": "リネーム", + "skills.export.conflict.hint": "同名 SKILL.md が既に存在する場合の処理方法", + "skills.export.spec": "形式", + "skills.export.spec.hermes": "Hermes", + "skills.export.spec.agent": "Agent (Anthropic)", + "skills.export.submit": "エクスポート", + "skills.export.exporting": "エクスポート中…", + "skills.export.success": "{path} にエクスポートしました({written} 個のスキルを書き込み)", + "skills.export.skipped": "{n} 個の未確認スキルをスキップしました", + "skills.export.error": "エクスポートに失敗しました", + "skills.export.needConfirm": "未確認のスキルはエクスポートできません。先に確認してください。", + "skills.export.noSelection": "少なくとも1つの確認済みスキルを選択してください", + "skills.export.noConfirmed": "エクスポート可能な確認済みスキルがありません", "skills.systemPrompt": "Agent プロンプト注入", "skills.systemPrompt.hint": "prompt タイプのスキルを ブロックとしてフォーマットします。LLM のシステムプロンプトに貼り付けると、モデルがこれらのスキル仕様を認識・遵守できるようになります。", "skills.systemPrompt.copy": "コピー", @@ -1012,6 +1131,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..6da0d13 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -489,6 +489,56 @@ 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"; +} + +export type SkillConflictStrategy = "overwrite" | "skip" | "rename"; + +export interface SkillExportRequest { + scope: string; + out_dir: string; + item_ids?: string[] | null; + conflict_strategy?: SkillConflictStrategy; + require_confirmed?: boolean; + spec?: string; +} + +export interface SkillExportResponse { + written: number; + unchanged: number; + skipped_low_confidence: number; + skipped_unpublishable: number; + skipped_unconfirmed: number; + skipped_conflict: number; + out_dir: string; +} + // --- Env Vault --- export interface EnvVaultItem { diff --git a/dashboard/src/panels/SkillsPanel.tsx b/dashboard/src/panels/SkillsPanel.tsx index d3dd1d5..3c8d046 100644 --- a/dashboard/src/panels/SkillsPanel.tsx +++ b/dashboard/src/panels/SkillsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import { Badge } from "@/components/ui/badge"; @@ -7,13 +7,31 @@ 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +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 { isNativeDialogAvailable, openDirectory } from "@/lib/tauriDialog"; +import { errorMessage } from "@/lib/utils"; +import type { + ContextItem, + SkillConflictStrategy, + SkillExportResponse, + SkillStatus, +} from "@/lib/types"; function triggerDownload(filename: string, content: string, mimeType = "application/json") { const blob = new Blob([content], { type: mimeType }); @@ -87,38 +105,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 +214,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 +285,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 && ( - - )} +
+ +