feat(web): add local Codex Skill management and invocation UI

This commit is contained in:
yu
2026-08-05 02:45:41 +08:00
parent 6cea52302b
commit 08deda5b3f
11 changed files with 695 additions and 88 deletions
+85
View File
@@ -0,0 +1,85 @@
import { create } from "zustand";
import { fetchCodexSkills, type AgentSkillSummary } from "@/services/api/canvas-agent";
import { useAgentStore } from "@/stores/use-agent-store";
let loadSequence = 0;
type AgentSkillStore = {
skills: AgentSkillSummary[];
selectedSkill: AgentSkillSummary | null;
selectionRevision: number;
connectionRevision: number;
autoPrompt: string;
loading: boolean;
loaded: boolean;
errors: string[];
loadSkills: (endpoint: string, token: string, forceReload?: boolean) => Promise<void>;
selectSkill: (skill: AgentSkillSummary | null) => void;
clearSelection: (expectedRevision?: number) => void;
reset: () => void;
};
export const useAgentSkillStore = create<AgentSkillStore>((set, get) => ({
skills: [],
selectedSkill: null,
selectionRevision: 0,
connectionRevision: 0,
autoPrompt: "",
loading: false,
loaded: false,
errors: [],
loadSkills: async (endpoint, token, forceReload = false) => {
const sequence = ++loadSequence;
set({ loading: true });
try {
const response = await fetchCodexSkills(endpoint, token, forceReload);
if (sequence !== loadSequence) return;
const skills = response.data || [];
const current = get();
const selected = current.selectedSkill;
const selectedSkill = selected ? skills.find((item) => item.name === selected.name && item.path === selected.path && item.enabled) || null : null;
const selectionChanged = Boolean(selected && !selectedSkill);
const autoPrompt = selectedSkill
? replaceAutoPrompt(current.autoPrompt, selectedSkill.interface?.defaultPrompt?.trim() || "", false)
: replaceAutoPrompt(current.autoPrompt, "", false);
set({ skills, selectedSkill, autoPrompt, selectionRevision: current.selectionRevision + (selectionChanged ? 1 : 0), loading: false, loaded: true, errors: (response.errors || []).map(skillErrorText) });
} catch (error) {
if (sequence !== loadSequence) return;
set({ loading: false, loaded: true, errors: [error instanceof Error ? error.message : "读取 Skill 失败"] });
}
},
selectSkill: (selectedSkill) => {
const current = get();
const autoPrompt = replaceAutoPrompt(current.autoPrompt, selectedSkill?.interface?.defaultPrompt?.trim() || "", true);
set({ selectedSkill, autoPrompt, selectionRevision: current.selectionRevision + 1 });
},
clearSelection: (expectedRevision) => {
const current = get();
if (expectedRevision !== undefined && expectedRevision !== current.selectionRevision) return;
replaceAutoPrompt(current.autoPrompt, "", false);
set({ selectedSkill: null, autoPrompt: "", selectionRevision: current.selectionRevision + 1 });
},
reset: () => {
loadSequence += 1;
const current = get();
replaceAutoPrompt(current.autoPrompt, "", false);
set({ skills: [], selectedSkill: null, selectionRevision: current.selectionRevision + 1, connectionRevision: current.connectionRevision + 1, autoPrompt: "", loading: false, loaded: false, errors: [] });
},
}));
function replaceAutoPrompt(previous: string, next: string, fillEmpty: boolean) {
const agent = useAgentStore.getState();
const ownsPrompt = Boolean(previous && agent.prompt === previous);
if (!ownsPrompt && !(fillEmpty && !agent.prompt.trim())) return "";
if (agent.prompt !== next) agent.setAgentState({ prompt: next });
return next;
}
function skillErrorText(value: unknown) {
if (typeof value === "string") return value;
if (!value || typeof value !== "object") return "Skill 解析失败";
const item = value as { path?: unknown; error?: unknown; message?: unknown };
const message = typeof item.error === "string" ? item.error : typeof item.message === "string" ? item.message : "Skill 解析失败";
return typeof item.path === "string" ? `${item.path}:${message}` : message;
}
+1 -1
View File
@@ -23,7 +23,7 @@ export type AgentCanvasContext = { snapshot: CanvasAgentSnapshot; applyOps: (ops
export type AgentThreadSummary = { id: string; preview: string; name?: string | null; cwd?: string; status?: string; source?: unknown; createdAt?: number; updatedAt?: number };
export type AgentTokenUsage = { input: number; cached: number; output: number };
export type AgentBootstrapStatus = { key: string; text: string; detail: string; status: "running" | "ready" | "error" };
export type AgentPanelTab = "chat" | "setup" | "history" | "log";
export type AgentPanelTab = "chat" | "setup" | "history" | "skills" | "log";
const CONNECT_TIMEOUT_MS = 6000;
let agentSource: EventSource | null = null;