feat(agent): add local Codex Skill management and invocation
This commit is contained in:
@@ -4,13 +4,14 @@ import path from "node:path";
|
||||
import express, { type NextFunction, type Request, type Response } from "express";
|
||||
|
||||
import { runClaudeTurn } from "../agent/claude.js";
|
||||
import { archiveCodexThread, interruptCodexTurn, listCodexModels, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread } from "../agent/codex.js";
|
||||
import type { CodexReasoningEffort } from "../agent/codex-protocol.js";
|
||||
import { archiveCodexThread, CodexSkillLookupError, configureCodexSkill, interruptCodexTurn, listCodexModels, listCodexSkills, listCodexThreads, readCodexThread, resolveCodexApproval, resolveCodexSkill, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread } from "../agent/codex.js";
|
||||
import type { CodexReasoningEffort, CodexSkillSelector } from "../agent/codex-protocol.js";
|
||||
import type { AgentAttachment, AgentPermissionMode } from "../agent/types.js";
|
||||
import { AGENT_PROTOCOL_VERSION, CanvasSession } from "../canvas/session.js";
|
||||
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "../config.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import { checkVersions } from "../version-check.js";
|
||||
import { SkillStore, SkillStoreError } from "../skills/store.js";
|
||||
|
||||
/** 启动仅监听本机的 Canvas Agent HTTP 服务。 */
|
||||
export function startHttpServer() {
|
||||
@@ -20,10 +21,15 @@ export function startHttpServer() {
|
||||
saveConfig(config);
|
||||
|
||||
const session = new CanvasSession();
|
||||
const skillStore = new SkillStore(ensureSiteWorkspace(config).workspacePath);
|
||||
/** 将 Agent 事件广播到所属线程或全部网页。 */
|
||||
const emit = (type: string, payload: unknown) => {
|
||||
const scope = session.codexBusy ? session.codexEventScope : { threadId: "", turnId: "", sourceClientId: "" };
|
||||
const value = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
|
||||
if (type === "skills_changed") {
|
||||
session.emitAll(type, value);
|
||||
return;
|
||||
}
|
||||
const scope = session.codexBusy ? session.codexEventScope : { threadId: "", turnId: "", sourceClientId: "" };
|
||||
const threadId = String(value.threadId || value.thread_id || scope.threadId || ensureSiteWorkspace(config).activeThreadId || "");
|
||||
const turnId = String(value.turnId || value.turn_id || scope.turnId || "");
|
||||
const sourceClientId = String(value.sourceClientId || scope.sourceClientId || "");
|
||||
@@ -129,6 +135,38 @@ export function startHttpServer() {
|
||||
res.json({ ok: true, workspace });
|
||||
});
|
||||
app.get("/agent/codex/models", route(async (_req, res) => res.json({ ok: true, ...(await listCodexModels(emit)) })));
|
||||
app.get("/agent/codex/skills", route(async (req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const result = await listCodexSkills(emit, workspace.workspacePath, String(req.query.forceReload || "") === "1");
|
||||
res.json({ ok: true, data: result.skills.map((skill) => ({ ...skill, managed: skillStore.isManagedPath(skill.path) })), errors: result.errors });
|
||||
}));
|
||||
app.get("/agent/codex/skills/:name", route(async (req, res) => {
|
||||
res.json({ ok: true, data: await skillStore.get(routeParam(req.params.name)) });
|
||||
}));
|
||||
app.post("/agent/codex/skills", route(async (req, res) => {
|
||||
const data = await skillStore.create(req.body);
|
||||
session.emitAll("skills_changed", { forceReload: true });
|
||||
res.status(201).json({ ok: true, data });
|
||||
}));
|
||||
app.post("/agent/codex/skills/:name/enabled", route(async (req, res) => {
|
||||
if (typeof req.body?.enabled !== "boolean") return res.status(400).json({ ok: false, error: "Skill 启用状态无效" });
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const selector = skillSelector(req.body);
|
||||
if (selector.name !== routeParam(req.params.name)) return res.status(400).json({ ok: false, error: "Skill 选择无效" });
|
||||
const data = await configureCodexSkill(emit, workspace.workspacePath, selector, req.body.enabled);
|
||||
session.emitAll("skills_changed", { forceReload: true });
|
||||
res.json({ ok: true, data });
|
||||
}));
|
||||
app.post("/agent/codex/skills/:name/delete", route(async (req, res) => {
|
||||
await skillStore.delete(routeParam(req.params.name), String(req.body?.expectedRevision || ""));
|
||||
session.emitAll("skills_changed", { forceReload: true });
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
app.post("/agent/codex/skills/:name", route(async (req, res) => {
|
||||
const data = await skillStore.update(routeParam(req.params.name), req.body);
|
||||
session.emitAll("skills_changed", { forceReload: true });
|
||||
res.json({ ok: true, data });
|
||||
}));
|
||||
app.get("/agent/codex/threads", route(async (req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
|
||||
@@ -184,6 +222,7 @@ export function startHttpServer() {
|
||||
if (requestedThreadId !== activeThreadId) return res.status(409).json({ ok: false, error: "当前会话已在其他页面切换,请同步后重试" });
|
||||
const model = String(req.body?.model || "") || undefined;
|
||||
const effort = reasoningEffort(req.body?.effort);
|
||||
const skill = req.body?.skill === undefined ? undefined : await resolveCodexSkill(emit, workspace.workspacePath, skillSelector(req.body.skill), true);
|
||||
const messageId = String(req.body?.messageId || Date.now());
|
||||
const messageText = String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`);
|
||||
let threadId = activeThreadId;
|
||||
@@ -224,6 +263,8 @@ export function startHttpServer() {
|
||||
permissionMode: permissionMode(req.body?.permissionMode),
|
||||
model,
|
||||
effort,
|
||||
...(skill ? { skill: { name: skill.name, path: skill.path } } : {}),
|
||||
messageText,
|
||||
appEmit: emit,
|
||||
onStart: () => session.bindClient(clientId),
|
||||
onThread: (actualThreadId) => {
|
||||
@@ -293,6 +334,7 @@ export function startHttpServer() {
|
||||
app.use((_req, res) => res.status(404).json({ ok: false, error: "not found" }));
|
||||
app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
|
||||
logger.error("HTTP request failed", { method: req.method, path: req.path, error });
|
||||
if (error instanceof SkillStoreError || error instanceof CodexSkillLookupError) return void res.status(error.statusCode).json({ ok: false, error: error.message });
|
||||
res.status(500).json({ ok: false, error: error.message });
|
||||
});
|
||||
|
||||
@@ -327,6 +369,15 @@ function reasoningEffort(value: unknown): CodexReasoningEffort | undefined {
|
||||
return value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max" || value === "ultra" ? value : undefined;
|
||||
}
|
||||
|
||||
/** 读取浏览器提交的 Skill 选择器;真实路径随后必须通过原生列表校验。 */
|
||||
function skillSelector(value: unknown): CodexSkillSelector {
|
||||
const selector = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
const name = typeof selector.name === "string" ? selector.name : "";
|
||||
const skillPath = typeof selector.path === "string" ? selector.path : "";
|
||||
if (!name || !skillPath) throw new CodexSkillLookupError("Skill 选择无效", 400);
|
||||
return { name, path: skillPath };
|
||||
}
|
||||
|
||||
/** 使用当前操作系统的文件管理器定位本地文件。 */
|
||||
function revealLocalFile(filePath: string, isDirectory: boolean) {
|
||||
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
||||
|
||||
Reference in New Issue
Block a user