From 0256d527f01c94d8ef9f0de5d239984d8c0d6ccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 25 May 2026 23:16:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=80=E8=83=BD=E5=B1=95=E7=A4=BA=E9=80=82?= =?UTF-8?q?=E9=85=8D=E6=8C=81=E4=B9=85=E5=8C=96=20+=20=E5=8F=82=E8=80=83?= =?UTF-8?q?=E6=96=87=E7=8C=AE=E6=94=B6=E9=9B=86=E4=B8=8E=E6=96=B0=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E6=B5=81=E5=BC=8F=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 skill_display_adapters 表,持久化每个技能的 display 配置与 sample payload;/api/skills 列表、详情、设置接口同步读写 - 新增 /api/skills/{name}/display-sample 端点,编辑器优先加载库中样例 - 前端展示适配面板改为从后端加载持久样例,无样例时提示"自动识别" - 参考文献收集修复:仅在 bash/shell/python 等执行类工具上从参数推断技能 名,避免 read_file / web_fetch 误匹配;引用列表纳入最新一条助手消息 - ChatBox 改用「最新 human → 最新 ai」窗口收集引用,并仅保留 citation 模式 - 修复「新对话」点击后已发消息消失:useThreadStream 只在 threadId 切回 空值时重置/中止 SSE,避免 onCreated 分配 id 时把刚开始的流打断; ChatPage 在 landing 状态隐藏 MessageList,移除 KeyedChatPage remount - 技能详情对话框:所有者可查看源码与文件 tab(不再要求管理员) - LocalSandbox:Windows PowerShell 5 路径下把 && 重写为 ;,并以 UTF-8 解码 subprocess 输出,避免中文乱码 - knowledge-search-v2 mock 样例改为中文固定测试数据 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/workspace/chats/chat-box.tsx | 46 +- .../workspace/messages/message-group.tsx | 31 +- .../workspace/skill-display-adapter-panel.tsx | 91 +- .../workspace/skills/skill-detail-dialog.tsx | 48 +- .../src/core/skill-display/display-adapter.ts | 71 +- .../src/core/skill-display/runtime.ts | 264 +- frontend-web/src/core/skills/api.ts | 20 +- frontend-web/src/core/skills/hooks.ts | 4 +- frontend-web/src/core/skills/type.ts | 1 + frontend-web/src/core/threads/hooks.ts | 22 +- frontend-web/src/pages/ChatPage.tsx | 27 +- frontend-web/src/pages/WorkspaceRoutes.tsx | 20 +- .../backend/app/gateway/routers/skills.py | 4120 +++++++++-------- .../deerflow/persistence/models/__init__.py | 3 +- .../deerflow/persistence/skills/model.py | 13 + .../deerflow/sandbox/local/local_sandbox.py | 75 +- 16 files changed, 2722 insertions(+), 2134 deletions(-) diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index ce37986a..23ae1bb8 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -61,10 +61,14 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ () => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), [settings.display.ragModeEnabled, skills], ); - const latestAssistant = useMemo( - () => findLatestAssistantMessage(thread.values.messages ?? []), + const latestHumanIndex = useMemo( + () => findLatestHumanMessageIndex(thread.values.messages ?? []), [thread.values.messages], ); + const latestAssistant = useMemo( + () => findLatestAssistantMessageAfter(thread.values.messages ?? [], latestHumanIndex), + [latestHumanIndex, thread.values.messages], + ); const displaySources = useMemo(() => { if (!settings.display.ragModeEnabled) { return []; @@ -74,10 +78,12 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ return []; } return collectRuntimeDisplaySources(messages, skillDisplayMap, { - afterMessageId: findPreviousHumanMessageId(messages, latestAssistant.id), + afterMessageId: latestHumanIndex >= 0 ? messages[latestHumanIndex]?.id : undefined, beforeMessageId: latestAssistant.id, + includeBeforeMessage: true, + includeModes: ["citation"], }); - }, [settings.display.ragModeEnabled, skillDisplayMap, latestAssistant?.id, thread.values.messages]); + }, [settings.display.ragModeEnabled, skillDisplayMap, latestAssistant?.id, latestHumanIndex, thread.values.messages]); const referenceSources = useMemo( () => displaySources.filter((source) => source.mode === "citation"), [displaySources], @@ -318,8 +324,20 @@ function ReferencePanel({ ); } -function findLatestAssistantMessage(messages: Message[]) { +function findLatestHumanMessageIndex(messages: Message[]) { for (let index = messages.length - 1; index >= 0; index--) { + if (messages[index]?.type === "human") { + return index; + } + } + return -1; +} + +function findLatestAssistantMessageAfter(messages: Message[], afterIndex: number) { + for (let index = messages.length - 1; index >= 0; index--) { + if (afterIndex >= 0 && index <= afterIndex) { + return null; + } const message = messages[index]; if (message?.type === "ai") { return message; @@ -328,22 +346,4 @@ function findLatestAssistantMessage(messages: Message[]) { return null; } -function findPreviousHumanMessageId( - messages: Message[], - beforeMessageId?: string, -): string | undefined { - const beforeIndex = - beforeMessageId === undefined - ? messages.length - : messages.findIndex((message) => message.id === beforeMessageId); - const end = beforeIndex < 0 ? messages.length : beforeIndex; - for (let index = end - 1; index >= 0; index--) { - const message = messages[index]; - if (message?.type === "human") { - return message.id; - } - } - return undefined; -} - export { ChatBox }; diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index f5e5b635..aabf5bc8 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -50,11 +50,14 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useLocalSettings } from "@/core/settings"; import { useSkills } from "@/core/skills/hooks"; import { - buildSkillDisplayMap, - findDisplayConfigForTool, - normalizeDefaultDisplaySources, - type RuntimeDisplaySource, -} from "@/core/skill-display/runtime"; + buildSkillDisplayMap, + findDisplayConfigForPayload, + findDisplayConfigForTool, + isDisplaySamplePayload, + normalizeDefaultDisplaySources, + shouldUseDefaultCitationMode, + type RuntimeDisplaySource, +} from "@/core/skill-display/runtime"; import { normalizeDisplaySources } from "@/core/skill-display/display-adapter"; import { extractTitleFromMarkdown } from "@/core/utils/markdown"; import { env } from "@/env"; @@ -249,7 +252,13 @@ function ToolCall({ const { t } = useI18n(); const artifacts = useArtifactsOptional(); - const displayMatch = findDisplayConfigForTool(name, skillDisplayMap, args); + const displayMatch = + result !== undefined && isDisplaySamplePayload(result) + ? null + : (findDisplayConfigForTool(name, skillDisplayMap, args) ?? + (result !== undefined + ? findDisplayConfigForPayload(name, result, skillDisplayMap) + : null)); const displayItems = displayMatch && result !== undefined ? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({ @@ -263,7 +272,11 @@ function ToolCall({ return ( @@ -272,7 +285,7 @@ function ToolCall({ } const searchItems = normalizeSearchResults(result); - if (ragModeEnabled && searchItems.length > 0) { + if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) { const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({ ...item, index: index + 1, @@ -618,7 +631,7 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) { open && "rotate-180", )} /> - {results.length} 条适配结果 + {results.length} 条真实返回结果 {results.map((item) => ( diff --git a/frontend-web/src/components/workspace/skill-display-adapter-panel.tsx b/frontend-web/src/components/workspace/skill-display-adapter-panel.tsx index ed8d2071..5ae5cbd6 100644 --- a/frontend-web/src/components/workspace/skill-display-adapter-panel.tsx +++ b/frontend-web/src/components/workspace/skill-display-adapter-panel.tsx @@ -1,7 +1,8 @@ "use client"; import { ExternalLinkIcon, Loader2, WandSparklesIcon } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -18,10 +19,12 @@ import { Textarea } from "@/components/ui/textarea"; import { emptyDisplayConfig, inferDisplayConfig, - mockKnowledgeSearchV2Result, normalizeDisplaySources, } from "@/core/skill-display/display-adapter"; -import { getSkillDisplaySample } from "@/core/skills/api"; +import { + getPersistedSkillDisplaySample, + getSkillDisplaySample, +} from "@/core/skills/api"; import { useSetSkillDisplay } from "@/core/skills/hooks"; import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type"; @@ -62,10 +65,16 @@ function updateConfig( return { ...config, [key]: value }; } +function formatSample(sample: Record | null | undefined) { + return sample ? JSON.stringify(sample, null, 2) : ""; +} + export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) { + const queryClient = useQueryClient(); const setDisplay = useSetSkillDisplay(); + const lastSkillNameRef = useRef(skill.name); const [draft, setDraft] = useState(() => skill.display ?? emptyDisplayConfig()); - const [sampleText, setSampleText] = useState(() => JSON.stringify(mockKnowledgeSearchV2Result, null, 2)); + const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample)); const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2)); // Echo server state into the form — on mount, on skill switch, AND after @@ -83,8 +92,52 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) { // per skill, otherwise the user loses the JSON they just pulled in via // 自动识别 every time they hit 保存. useEffect(() => { - setSampleText(JSON.stringify(mockKnowledgeSearchV2Result, null, 2)); - }, [skill.name]); + if (lastSkillNameRef.current !== skill.name) { + lastSkillNameRef.current = skill.name; + setSampleText(formatSample(skill.displaySample)); + return; + } + if (skill.displaySample) { + setSampleText(formatSample(skill.displaySample)); + } + }, [skill.name, skill.displaySample]); + + useEffect(() => { + let cancelled = false; + async function loadPersistedSample() { + try { + const response = await getPersistedSkillDisplaySample(skill.name); + if (cancelled) { + return; + } + if (response.sample) { + setSampleText(formatSample(response.sample)); + queryClient.setQueryData(["skill", skill.name], (current) => + current ? { ...current, displaySample: response.sample } : current, + ); + queryClient.setQueriesData({ queryKey: ["skills"] }, (current) => + Array.isArray(current) + ? current.map((item) => + item.name === skill.name + ? { ...item, displaySample: response.sample } + : item, + ) + : current, + ); + } else if (!skill.displaySample) { + setSampleText(""); + } + } catch (err) { + if (!cancelled) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + } + void loadPersistedSample(); + return () => { + cancelled = true; + }; + }, [queryClient, skill.name, skill.displaySample]); const samplePayload = useMemo(() => { try { @@ -114,6 +167,18 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) { const response = await getSkillDisplaySample(skill.name); const nextSampleText = JSON.stringify(response.sample, null, 2); setSampleText(nextSampleText); + queryClient.setQueryData(["skill", skill.name], (current) => + current ? { ...current, displaySample: response.sample } : current, + ); + queryClient.setQueriesData({ queryKey: ["skills"] }, (current) => + Array.isArray(current) + ? current.map((item) => + item.name === skill.name + ? { ...item, displaySample: response.sample } + : item, + ) + : current, + ); const nextDraft = inferDisplayConfig(response.sample, skill.name); setDraft(nextDraft); setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2)); @@ -126,7 +191,11 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) { async function saveDraft() { try { - await setDisplay.mutateAsync({ skillName: skill.name, display: draft }); + await setDisplay.mutateAsync({ + skillName: skill.name, + display: draft, + sample: samplePayload ?? undefined, + }); toast.success("展示适配已保存"); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); @@ -135,7 +204,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) { async function clearDisplay() { try { - await setDisplay.mutateAsync({ skillName: skill.name, display: null }); + await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null }); setDraft(emptyDisplayConfig()); setTableColumnsText(JSON.stringify([], null, 2)); toast.success("展示适配已清除"); @@ -278,7 +347,11 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
展示预览
{!samplePayload ? ( -
样例 JSON 解析失败
+ sampleText.trim() ? ( +
样例 JSON 解析失败
+ ) : ( +
数据库暂无技能返回样例,请点击自动识别
+ ) ) : previewItems.length === 0 ? (
当前字段映射没有解析出结果
) : draft.mode === "citation" ? ( diff --git a/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx b/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx index f02d68c3..3dbe16ce 100644 --- a/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx +++ b/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx @@ -68,6 +68,18 @@ function canManageSkill(skill: Skill | null | undefined): boolean { ); } +function isSkillOwner(skill: Skill | null | undefined): boolean { + if (!skill) return false; + const auth = getStoredAuth(); + if (!auth) return false; + return ( + skill.category === "custom" && + !skill.builtin && + !!skill.owner_user_id && + skill.owner_user_id === auth.user_id + ); +} + function stripYamlFrontmatter(content: string): string { return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart(); } @@ -96,13 +108,14 @@ export function SkillDetailDialog({ const [openedFilePath, setOpenedFilePath] = useState(null); const canManage = useMemo(() => canManageSkill(skill), [skill]); + const isOwner = useMemo(() => isSkillOwner(skill), [skill]); const { data: contentData, isLoading: isContentLoading, error: contentError, - } = useSkillContent(open && canManage ? skillName : null); + } = useSkillContent(open ? skillName : null); - const filesEnabled = open && canManage && activeTab === "files"; + const filesEnabled = open && isOwner && activeTab === "files"; const { data: filesData, isLoading: isFilesLoading, @@ -121,7 +134,7 @@ export function SkillDetailDialog({ }, [contentData]); const defaultTab: "detail" | "source" = - canManage && skill && !skill.detail ? "source" : "detail"; + skill && !skill.detail ? "source" : "detail"; const selectedTab = activeTab ?? defaultTab; function startEditing() { @@ -215,18 +228,16 @@ export function SkillDetailDialog({ 展示适配 )} - {canManage && ( + {isOwner && ( {tx.filesSectionTitle} )} - {canManage && ( - - - {tx.contentSectionTitle} - - )} + + + {tx.contentSectionTitle} +
@@ -242,7 +253,6 @@ export function SkillDetailDialog({ )} {selectedTab === "source" && - canManage && !isContentLoading && !contentError && ( <> @@ -327,7 +337,7 @@ export function SkillDetailDialog({

{tx.detailEmpty}

- {canManage ? ( + {isOwner ? (

Open the SKILL.md tab to view the skill source.

@@ -348,7 +358,7 @@ export function SkillDetailDialog({ )} - {canManage && ( + {isOwner && ( )} - {canManage && ( - + {isContentLoading ? (
@@ -407,7 +416,6 @@ export function SkillDetailDialog({ /> )} - )} )} diff --git a/frontend-web/src/core/skill-display/display-adapter.ts b/frontend-web/src/core/skill-display/display-adapter.ts index 1add1cf1..ce63e451 100644 --- a/frontend-web/src/core/skill-display/display-adapter.ts +++ b/frontend-web/src/core/skill-display/display-adapter.ts @@ -27,50 +27,65 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({ urlField: null, idField: null, linkStrategy: "auto", - linkTemplate: null, - citationToken: "doubleBracket", - maxItems: 8, - customInstruction: "", - tableColumns: [], + linkTemplate: null, + citationToken: "doubleBracket", + maxItems: 8, + customInstruction: "", + tableColumns: [], }); export const mockKnowledgeSearchV2Result = { success: true, - query: "latest news", - count: 3, + query: "参考文献模式测试 知识库固定测试", + count: 4, + source: "fixed-display-sample", results: [ { index: 1, - recUuid: "rec-001", + recUuid: "ksv2-fixed-001", relevance_score: 0.982, - title: "Knowledge result one", - source_category: "knowledge/news", - source: "internal news library", + title: "参考文献模式测试:参考文献模式固定测试数据一", + source_category: "知识库/测试资料", + source: "knowledge-search-v2/fixed/source-001", publish_time: "2026-05-25", - content_preview: "Mock result used to verify citation numbers, source cards, and recUuid template links.", - content_length: 1200, + content_preview: + "这是 knowledge-search-v2 返回的第一条固定测试资料,用于验证右侧参考文献列表、正文编号以及 recUuid 模板跳转是否正常。", + content_length: 168, }, { index: 2, - recUuid: "rec-002", - relevance_score: 0.951, - title: "Knowledge result two", - source_category: "knowledge/news", - source: "internal news library", + recUuid: "ksv2-fixed-002", + relevance_score: 0.936, + title: "参考文献模式测试:结构化字段映射验证", + source_category: "知识库/字段映射", + source: "knowledge-search-v2/fixed/source-002", publish_time: "2026-05-24", - content_preview: "Mock result used to verify citation ordering and detail preview.", - content_length: 980, + content_preview: + "本条用于验证 title、source_category、source、publish_time、content_preview、relevance_score、recUuid 等字段能被前端正确识别并渲染。", + content_length: 154, }, { index: 3, - recUuid: "rec-003", - relevance_score: 0.921, - title: "Knowledge result three", - source_category: "knowledge/commentary", - source: "internal knowledge library", + recUuid: "ksv2-fixed-003", + relevance_score: 0.887, + title: "参考文献模式测试:多引用排序验证", + source_category: "知识库/排序验证", + source: "knowledge-search-v2/fixed/source-003", publish_time: "2026-05-23", - content_preview: "Mock result used to verify list mode and custom display instruction previews.", - content_length: 760, + content_preview: + "本条用于验证多条参考文献的编号顺序是否与 results 数组顺序一致,并验证点击正文编号时能定位到右侧对应参考文献。", + content_length: 132, + }, + { + index: 4, + recUuid: "", + relevance_score: 0.801, + title: "参考文献模式测试:无 recUuid 跳转隐藏验证", + source_category: "知识库/边界场景", + source: "knowledge-search-v2/fixed/source-004", + publish_time: "2026-05-22", + content_preview: "本条故意把 recUuid 置为空,用于验证右侧弹框仍展示详情,但不展示外部跳转按钮。", + content_length: 98, }, ], }; @@ -196,7 +211,7 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo .slice(0, config.maxItems) .map((item, index) => ({ id: config.idField ? stringify(item[config.idField]) : undefined, - title: config.titleField ? stringify(item[config.titleField], `Result ${index + 1}`) : `Result ${index + 1}`, + title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`, type: config.typeField ? stringify(item[config.typeField], "result") : "result", snippet: config.summaryField ? stringify(item[config.summaryField]) : "", content: config.contentField ? stringify(item[config.contentField]) : undefined, diff --git a/frontend-web/src/core/skill-display/runtime.ts b/frontend-web/src/core/skill-display/runtime.ts index ef18c1ee..e91c2164 100644 --- a/frontend-web/src/core/skill-display/runtime.ts +++ b/frontend-web/src/core/skill-display/runtime.ts @@ -56,7 +56,7 @@ export function findDisplayConfigForTool( // Scan stringy args for the `skills/{public,custom}//` shape so display // configs still resolve. Mirrors the backend `_SKILL_PATH_RE` in // tool_metrics_middleware.py. - if (toolArgs) { + if (toolArgs && canInferSkillFromToolArgs(toolName)) { const skillFromArgs = extractSkillNameFromArgs(toolArgs); if (skillFromArgs) { const direct = displays.get(skillFromArgs); @@ -69,6 +69,51 @@ export function findDisplayConfigForTool( } const SKILL_PATH_RE = /[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)/; +const TOOL_ARG_SKILL_MATCH_EXCLUDES = new Set([ + "read_file", + "readfile", + "ls", + "write_file", + "writefile", + "str_replace", + "strreplace", + "web_fetch", + "webfetch", +]); + +function canInferSkillFromToolArgs(toolName: string): boolean { + const normalized = normalizeName(toolName); + if ( + TOOL_ARG_SKILL_MATCH_EXCLUDES.has(toolName) || + TOOL_ARG_SKILL_MATCH_EXCLUDES.has(normalized) + ) { + return false; + } + return ( + normalized.includes("bash") || + normalized.includes("shell") || + normalized.includes("python") || + normalized.includes("execute") || + normalized.includes("terminal") + ); +} + +function canInspectPayloadForDisplay(toolName: string): boolean { + const normalized = normalizeName(toolName); + if ( + TOOL_ARG_SKILL_MATCH_EXCLUDES.has(toolName) || + TOOL_ARG_SKILL_MATCH_EXCLUDES.has(normalized) + ) { + return false; + } + return ( + canInferSkillFromToolArgs(toolName) || + normalized.includes("search") || + normalized.includes("retriev") || + normalized.includes("knowledge") || + normalized.includes("query") + ); +} function extractSkillNameFromArgs(args: Record): string | undefined { for (const value of Object.values(args)) { @@ -87,6 +132,7 @@ export function collectRuntimeDisplaySources( options: { afterMessageId?: string; beforeMessageId?: string; + includeBeforeMessage?: boolean; includeModes?: SkillDisplayConfig["mode"][]; } = {}, ): RuntimeDisplaySource[] { @@ -101,7 +147,8 @@ export function collectRuntimeDisplaySources( options.beforeMessageId === undefined ? messages.length : Math.max( - messages.findIndex((message) => message.id === options.beforeMessageId), + messages.findIndex((message) => message.id === options.beforeMessageId) + + (options.includeBeforeMessage ? 1 : 0), 0, ); const allowedModes = options.includeModes @@ -117,22 +164,38 @@ export function collectRuntimeDisplaySources( continue; } for (const toolCall of message.tool_calls ?? []) { - const match = findDisplayConfigForTool( + const toolMatch = findDisplayConfigForTool( toolCall.name, displays, toolCall.args as Record | undefined, ); - const effectiveMode = match?.display.mode ?? "citation"; - if (allowedModes && !allowedModes.has(effectiveMode)) { - continue; - } const result = findToolResultPayload(toolCall.id, messages); if (result === undefined) { continue; } + if (isDisplaySamplePayload(result)) { + continue; + } + const match = + toolMatch ?? + findDisplayConfigForPayload( + toolCall.name, + result, + displays, + ); + const effectiveMode = match?.display.mode ?? "citation"; + if (allowedModes && !allowedModes.has(effectiveMode)) { + continue; + } const normalized = match ? normalizeDisplaySources(result, match.display) - : normalizeDefaultDisplaySources(result); + : shouldUseDefaultCitationMode( + toolCall.name, + result, + toolCall.args as Record | undefined, + ) + ? normalizeDefaultDisplaySources(result) + : []; for (const item of normalized) { const skillName = match?.skillName ?? toolCall.name; const displayKey = `${skillName}:${effectiveMode}`; @@ -151,6 +214,113 @@ export function collectRuntimeDisplaySources( return sources; } +export function findDisplayConfigForPayload( + toolName: string, + payload: unknown, + displays: SkillDisplayMap, +): { skillName: string; display: SkillDisplayConfig } | null { + if (!canInspectPayloadForDisplay(toolName)) { + return null; + } + let best: { skillName: string; display: SkillDisplayConfig; score: number } | null = null; + for (const [skillName, display] of displays) { + const score = scoreDisplayPayloadMatch(payload, display); + if (score >= 3 && (!best || score > best.score)) { + best = { skillName, display, score }; + } + } + return best ? { skillName: best.skillName, display: best.display } : null; +} + +export function isDisplaySamplePayload(payload: unknown): boolean { + if (!isRecord(payload)) { + return false; + } + if (payload.__displaySample === true || payload.displaySample === true) { + return true; + } + if (payload.query === "sample query") { + return true; + } + const items = + getArrayByPath(payload, "results") ?? + getArrayByPath(payload, "data") ?? + getArrayByPath(payload, "items"); + const first = items?.find((item): item is Record => isRecord(item)); + if (!first) { + return false; + } + const text = Object.values(first) + .filter((value) => typeof value === "string") + .join("\n") + .toLowerCase(); + return ( + /^sample-/i.test(String(first.id ?? "")) || + text.includes("mock result") || + text.includes("knowledge result one") + ); +} + +function scoreDisplayPayloadMatch(payload: unknown, display: SkillDisplayConfig): number { + const items = getArrayByPath(payload, display.resultPath); + const first = items?.find((item): item is Record => isRecord(item)); + if (!first) { + return 0; + } + const fields = [ + display.titleField, + display.summaryField, + display.contentField, + display.sourceField, + display.typeField, + display.timeField, + display.scoreField, + display.urlField, + display.idField, + ].filter((field): field is string => !!field); + return new Set(fields).size === 0 + ? 0 + : [...new Set(fields)].filter( + (field) => + first[field] !== undefined && + first[field] !== null && + first[field] !== "", + ).length; +} + +function getArrayByPath(payload: unknown, path: string): unknown[] | null { + const value = path.split(".").reduce((acc, key) => { + if (acc && typeof acc === "object") { + return (acc as Record)[key]; + } + return undefined; + }, payload); + return Array.isArray(value) ? value : null; +} + +export function shouldUseDefaultCitationMode( + toolName: string, + payload: unknown, + toolArgs?: Record, +): boolean { + const normalizedName = normalizeName(toolName); + const isSearchLikeTool = + normalizedName.includes("search") || + normalizedName.includes("retriev") || + normalizedName.includes("knowledge") || + normalizedName.includes("query"); + const isSkillInvocation = !!( + toolArgs && + canInferSkillFromToolArgs(toolName) && + extractSkillNameFromArgs(toolArgs) + ); + if (!isSearchLikeTool && !isSkillInvocation) { + return false; + } + + return normalizeDefaultDisplaySources(payload).length > 0; +} + export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceItem[] { const normalized = normalizeSearchResults( typeof payload === "string" || isRecord(payload) @@ -288,23 +458,77 @@ function findToolResultPayload( if (!toolMessage) { return undefined; } + const structured = extractStructuredToolPayload(toolMessage.content); + if (structured !== undefined) { + return structured; + } const content = extractToolText(toolMessage.content); if (!content) { return undefined; } - try { - return JSON.parse(content) as unknown; - } catch { - // bash stdout on the local sandbox can be suffixed with - // "\nStd Error: ..." / "\nExit Code: N" (see local_sandbox.execute_command), - // and skills sometimes print informational lines around their JSON payload. - // Salvage the first balanced JSON object or array we can find before - // falling back to the raw string. - const recovered = extractJSONFromText(content); - if (recovered !== undefined) { - return recovered; + const parsed = parseJSONLikeText(content); + if (parsed !== undefined) { + return parsed; + } + return content; +} + +function extractStructuredToolPayload(content: unknown): unknown { + if (isRecord(content)) { + return unwrapToolPayloadObject(content); + } + if (Array.isArray(content)) { + for (const part of content) { + if (!isRecord(part)) { + continue; + } + if (isRecord(part.json)) { + return unwrapToolPayloadObject(part.json); + } + const unwrapped = unwrapToolPayloadObject(part); + if (unwrapped !== undefined) { + return unwrapped; + } } - return content; + } + return undefined; +} + +function unwrapToolPayloadObject(value: Record): unknown { + if ( + Array.isArray(value.results) || + Array.isArray(value.data) || + Array.isArray(value.items) + ) { + return value; + } + for (const key of ["result", "output", "stdout", "stderr", "content", "text"]) { + const nested = value[key]; + if (typeof nested === "string") { + const parsed = parseJSONLikeText(nested); + if (parsed !== undefined) { + return parsed; + } + } + if (isRecord(nested)) { + const unwrapped = unwrapToolPayloadObject(nested); + if (unwrapped !== undefined) { + return unwrapped; + } + } + } + return undefined; +} + +function parseJSONLikeText(text: string): unknown { + const trimmed = text.trim(); + if (!trimmed) { + return undefined; + } + try { + return JSON.parse(trimmed) as unknown; + } catch { + return extractJSONFromText(trimmed); } } diff --git a/frontend-web/src/core/skills/api.ts b/frontend-web/src/core/skills/api.ts index ce49ad4d..01a64404 100644 --- a/frontend-web/src/core/skills/api.ts +++ b/frontend-web/src/core/skills/api.ts @@ -49,6 +49,7 @@ export async function enableSkill(skillName: string, enabled: boolean) { export async function setSkillDisplay( skillName: string, display: SkillDisplayConfig | null, + sample?: Record | null, ): Promise { const response = await apiFetch( `${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`, @@ -57,7 +58,7 @@ export async function setSkillDisplay( headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ display }), + body: JSON.stringify({ display, sample }), }, ); if (!response.ok) { @@ -71,6 +72,23 @@ export interface SkillDisplaySampleResponse { sample: Record; } +export interface PersistedSkillDisplaySampleResponse { + skill_name: string; + sample: Record | null; +} + +export async function getPersistedSkillDisplaySample( + skillName: string, +): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`, + ); + if (!response.ok) { + throw await parseDetailedError(response, "Failed to get persisted skill display sample"); + } + return response.json() as Promise; +} + export async function getSkillDisplaySample( skillName: string, ): Promise { diff --git a/frontend-web/src/core/skills/hooks.ts b/frontend-web/src/core/skills/hooks.ts index c4c67d02..752bc385 100644 --- a/frontend-web/src/core/skills/hooks.ts +++ b/frontend-web/src/core/skills/hooks.ts @@ -71,10 +71,12 @@ export function useSetSkillDisplay() { mutationFn: ({ skillName, display, + sample, }: { skillName: string; display: Parameters[1]; - }) => setSkillDisplay(skillName, display), + sample?: Parameters[2]; + }) => setSkillDisplay(skillName, display, sample), onSuccess: (_data, { skillName }) => { void queryClient.invalidateQueries({ queryKey: ["skills"] }); void queryClient.invalidateQueries({ queryKey: ["skill", skillName] }); diff --git a/frontend-web/src/core/skills/type.ts b/frontend-web/src/core/skills/type.ts index 3a6ac12b..1ee80050 100644 --- a/frontend-web/src/core/skills/type.ts +++ b/frontend-web/src/core/skills/type.ts @@ -48,6 +48,7 @@ export interface Skill { // Distinct from ``description`` (parsed from SKILL.md); supports Markdown. detail?: string | null; display?: SkillDisplayConfig | null; + displaySample?: Record | null; // Lifecycle fields — populated by curator. The main list filters on these // so stale / archived skills appear in dedicated drawers instead. state?: SkillState; diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index b2625205..94ca230e 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -315,13 +315,23 @@ export function useThreadStream({ const threadStreamRef = useRef(thread); threadStreamRef.current = thread; - // Reset thread-local pending UI state when switching between threads so - // optimistic messages and in-flight guards do not leak across chat views. - // Also abort any in-flight SSE from the previous thread: without this, - // clicking "新对话" mid-stream leaves the old run firing onUpdate events - // against the now-detached thread context, which manifests as the page - // appearing unresponsive to the navigation. + // Reset thread-local pending UI state when the caller actually switches AWAY + // from a thread (threadId becomes nullish). This is the "click 新对话 / open + // a fresh chat" signal — abort the in-flight SSE and clear optimistic state. + // + // We deliberately do NOT reset on every threadId change. During a normal + // "user just sent the first message on /chats/new" flow, useThreadStream + // sees threadId transition from undefined (the landing state) to the + // server-assigned thread id once onCreated fires. Stopping the stream and + // clearing optimistic messages in that case is exactly what made submitted + // questions visually disappear — the live run that just started would be + // aborted by the very effect intended to reset stale state. useEffect(() => { + if (threadId != null) { + // Forward direction: starting or continuing with a real thread. + // No reset needed; the upstream useStream will load history for this id. + return; + } const prevThread = threadStreamRef.current; if (prevThread?.isLoading) { try { diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 9eb6d6db..6a9ef79f 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -231,14 +231,25 @@ export default function ChatPage() {
- + {/* + Landing state covers two cases: (a) a brand-new chat that + hasn't sent anything yet, and (b) the user clicking 「新对话」 + mid-stream. In (b) the underlying useStream's internal + stream.values takes one or two effect ticks to clear, so + rendering MessageList unconditionally would leak the previous + conversation under the welcome card. Skip rendering during + the landing state to guarantee a clean slate. + */} + {!isLandingState && ( + + )}
ReactNode }) { - const key = useLocation().key; - return {render()}; -} - export default function WorkspaceRoutes({ pageSidebarCollapsed = false, pageSidebarHidden = false, @@ -137,7 +123,9 @@ export default function WorkspaceRoutes({ path="chats/:thread_id" element={ - } /> + + + } /> diff --git a/offline-backend-20260512/backend/app/gateway/routers/skills.py b/offline-backend-20260512/backend/app/gateway/routers/skills.py index 1a10cad2..9d8cbcb3 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/skills.py +++ b/offline-backend-20260512/backend/app/gateway/routers/skills.py @@ -1,1965 +1,2155 @@ -import asyncio -import json -import logging -import mimetypes -import re -import tempfile -from datetime import datetime -from pathlib import Path -from typing import Any - -from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, Request, UploadFile -from fastapi.responses import FileResponse -from pydantic import BaseModel, Field - -from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store -from app.gateway.path_utils import resolve_thread_virtual_path -from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async -from deerflow.config.app_config import AppConfig -from deerflow.config.extensions_config import ExtensionsConfig, SkillDisplayConfig, SkillStateConfig, get_extensions_config, reload_extensions_config -from deerflow.persistence.skills import SkillStore -from deerflow.persistence.tags import TagStore -from deerflow.runtime.user_context import get_effective_user_id -from deerflow.skills import Skill -from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted -from deerflow.skills.installer import SkillAlreadyExistsError -from deerflow.skills.security_scanner import scan_skill_content -from deerflow.skills.storage import get_or_new_skill_storage -from deerflow.skills.types import SKILL_MD_FILE, SkillCategory - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api", tags=["skills"]) - - -# --------------------------------------------------------------------------- -# Per-user authz helpers (mirrors routers/agents.py) -# --------------------------------------------------------------------------- - - -def _current_user_id(request: Request) -> str: - user = getattr(request.state, "user", None) - if user is not None: - return str(user.id) - return get_effective_user_id() - - -def _is_admin_user(request: Request) -> bool: - user = getattr(request.state, "user", None) - return getattr(user, "system_role", None) == "admin" - - -def _can_view_skill_source( - category: SkillCategory, - owner_user_id: str | None, - user_id: str, - *, - is_admin: bool, -) -> bool: - """Whether the raw SKILL.md source is visible to a user. - - Admins may view any skill's source; otherwise only the publisher (the - owner of a custom skill) qualifies. Built-in / public skills have no human - owner, so for non-admins they are never source-visible. - """ - if is_admin: - return True - return category == SkillCategory.CUSTOM and owner_user_id is not None and owner_user_id == user_id - - -async def _get_owned_skill_or_raise(store: SkillStore, name: str, user_id: str) -> dict[str, Any]: - owned = await store.get_owned(name, user_id) - if owned is not None: - return owned - visible = await store.get_visible(name, user_id) - if visible is None: - raise HTTPException(status_code=404, detail=f"Skill '{name}' not found") - raise HTTPException(status_code=403, detail="You can only modify skills that you own") - - -async def _get_editable_skill_or_raise( - store: SkillStore, - name: str, - user_id: str, - *, - is_admin: bool, -) -> tuple[dict[str, Any], str]: - """Return (record, edit_mode) where edit_mode is 'owner' or 'admin'. - - Admins can edit/delete any skill (including others' published ones, since - the spec lets them force-takedown). Non-admins are limited to their own. - """ - owned = await store.get_owned(name, user_id) - if owned is not None: - return owned, "owner" - record = await store.get_any(name) - if record is None: - raise HTTPException(status_code=404, detail=f"Skill '{name}' not found") - if is_admin: - return record, "admin" - raise HTTPException(status_code=403, detail="You can only modify skills that you own") - - -class SkillResponse(BaseModel): - """Response model for skill information.""" - - name: str = Field(..., description="Name of the skill") - description: str = Field(..., description="Description of what the skill does") - license: str | None = Field(None, description="License information") - category: SkillCategory = Field(..., description="Category of the skill (public or custom)") - enabled: bool = Field(default=True, description="Whether this skill is enabled") - use_count: int = Field(default=0, description="Number of times the skill has been used") - view_count: int = Field(default=0, description="Number of times the skill has been viewed") - patch_count: int = Field(default=0, description="Number of times the skill has been patched") - last_activity_at: datetime | None = Field(default=None, description="Most recent activity timestamp") - state: str = Field(default="active", description="Lifecycle state: active, stale, or archived") - pinned: bool = Field(default=False, description="Whether this skill is pinned (excluded from curation)") - source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded") - owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)") - published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users") - builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)") - detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin") - display: dict[str, Any] | None = Field(default=None, description="Optional frontend display adapter") - tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill") - - -class SkillsListResponse(BaseModel): - """Response model for listing all skills.""" - - skills: list[SkillResponse] - - -class SkillUpdateRequest(BaseModel): - """Request model for updating a skill.""" - - enabled: bool = Field(..., description="Whether to enable or disable the skill") - - -class SkillDisplayUpdateRequest(BaseModel): - """Request model for updating a skill's frontend display adapter.""" - - display: SkillDisplayConfig | None = Field(default=None, description="Display adapter configuration; null clears it") - - -class SkillDisplaySampleResponse(BaseModel): - """Sample structured output used to infer a display adapter.""" - - skill_name: str - sample: dict[str, Any] - - -class SkillInstallRequest(BaseModel): - """Request model for installing a skill from a .skill file.""" - - thread_id: str = Field(..., description="The thread ID where the .skill file is located") - path: str = Field(..., description="Virtual path to the .skill file (e.g., mnt/user-data/outputs/my-skill.skill)") - target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'") - - -class SkillInstallResponse(BaseModel): - """Response model for skill installation.""" - - success: bool = Field(..., description="Whether the installation was successful") - skill_name: str = Field(..., description="Name of the installed skill") - message: str = Field(..., description="Installation result message") - overwritten: bool = Field(default=False, description="True if an existing skill was replaced") - category: str = Field(default="custom", description="Destination category: 'public' (admin) or 'custom'") - - -class SkillUploadValidateResponse(BaseModel): - """Response model for the pre-install validation endpoint.""" - - ok: bool = Field(..., description="Whether the archive passed cheap validation") - skill_name: str = Field(..., description="Skill name parsed from frontmatter") - description: str = Field(default="", description="Skill description parsed from frontmatter") - license: str | None = Field(default=None, description="Skill license, if declared") - allowed_tools: list[str] = Field(default_factory=list, description="Tools the skill may invoke") - conflict: bool = Field(default=False, description="True if a skill with the same name already exists") - conflict_kind: str | None = Field(default=None, description="'public' or 'custom' when conflict=true") - warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings to surface to the user") - - -class SkillReconcileResponse(BaseModel): - """Response model for reconciling custom skills found on disk.""" - - adopted: list[str] = Field(default_factory=list, description="Skills found on disk that had no DB row; now owned by the caller") - already_registered: list[str] = Field(default_factory=list, description="Skills found on disk that already had a DB row (unchanged)") - - -class CustomSkillContentResponse(SkillResponse): - content: str = Field(..., description="Raw SKILL.md content") - - -class CustomSkillUpdateRequest(BaseModel): - content: str = Field(..., description="Replacement SKILL.md content") - - -class CustomSkillHistoryResponse(BaseModel): - history: list[dict] - - -class SkillRollbackRequest(BaseModel): - history_index: int = Field(default=-1, description="History entry index to restore from, defaulting to the latest change.") - - -def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) -> SkillResponse: - """Convert a Skill object to a SkillResponse. - - ``ownership`` is the skill's record from ``SkillStore`` (or None for public - built-in skills which never have a DB row). When present, contributes the - ``owner_user_id`` / ``published`` / ``builtin`` fields used for per-user - visibility and the frontend's owner badges. - """ - from deerflow.skills.usage import get_entry, latest_activity_at - - entry = get_entry(skill.name) - last_at_str = latest_activity_at(skill.name) - last_at: datetime | None = None - if last_at_str: - try: - last_at = datetime.fromisoformat(last_at_str) - except Exception: - pass - - owner_user_id: str | None = None - published = False - builtin = skill.category != SkillCategory.CUSTOM - detail: str | None = None - if ownership is not None: - owner_user_id = ownership.get("owner_user_id") - published = bool(ownership.get("published", False)) - builtin = bool(ownership.get("builtin", owner_user_id is None)) - detail = ownership.get("detail") - - display = None - try: - skill_config = get_extensions_config().skills.get(skill.name) - if skill_config and skill_config.display: - display = skill_config.display.model_dump(by_alias=True) - except Exception: - logger.debug("Failed to read display adapter for %s", skill.name, exc_info=True) - - return SkillResponse( - name=skill.name, - description=skill.description, - license=skill.license, - category=skill.category, - enabled=skill.enabled, - use_count=entry.get("use_count", 0), - view_count=entry.get("view_count", 0), - patch_count=entry.get("patch_count", 0), - last_activity_at=last_at, - state=entry.get("state", "active"), - pinned=entry.get("pinned", False), - source=entry.get("source", "upload"), - owner_user_id=owner_user_id, - published=published, - builtin=builtin, - detail=detail, - display=display, - ) - - -def _dump_extensions_config(config_obj: ExtensionsConfig) -> dict[str, Any]: - return { - "mcpServers": {name: server.model_dump(by_alias=True) for name, server in config_obj.mcp_servers.items()}, - "skills": {name: skill_config.model_dump(by_alias=True, exclude_none=True) for name, skill_config in config_obj.skills.items()}, - } - - -_SKILL_SAMPLE_SYSTEM_PROMPT = ( - "You infer realistic JSON return shapes for AI agent skills. " - "Given a skill's name, description, and full SKILL.md, output a SINGLE JSON object that " - "represents a plausible successful response from this skill's primary action. " - "Rules:\n" - "- Output JSON ONLY. No prose. No markdown fences. No comments.\n" - "- The top level must be a JSON object (not an array).\n" - "- Include a results/items/list/records array (pick the most natural key) with 2-3 example entries.\n" - "- Use realistic, idiomatic field names (e.g. title, content, source, publish_time, score, url, id) that match the skill's domain.\n" - "- Each entry should have enough fields to drive a citation/list/table UI: a title, a short snippet/content, a source, a time/date, and an id or url when relevant.\n" - "- Numbers stay numbers, dates stay ISO strings, scores between 0 and 1 when applicable.\n" - "- Do not invent fields that contradict the skill description." -) - - -def _extract_json_from_llm_output(raw: str) -> dict[str, Any] | None: - """Best-effort extraction of a JSON object from an LLM response.""" - text = (raw or "").strip() - if not text: - return None - fence = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.DOTALL | re.IGNORECASE) - if fence: - text = fence.group(1).strip() - try: - parsed = json.loads(text) - return parsed if isinstance(parsed, dict) else None - except json.JSONDecodeError: - pass - match = re.search(r"\{.*\}", text, re.DOTALL) - if not match: - return None - try: - parsed = json.loads(match.group(0)) - return parsed if isinstance(parsed, dict) else None - except json.JSONDecodeError: - return None - - -async def _generate_skill_display_sample(skill: Skill, app_config: AppConfig) -> dict[str, Any]: - """Generate a realistic return-shape sample for ``skill`` via the LLM. - - Reads SKILL.md, asks the configured curator model (or main model) to infer - a plausible JSON response. Falls back to :func:`_mock_skill_display_sample` - when the model is unconfigured, the call times out, or the output cannot - be parsed as a JSON object. - """ - try: - skill_content = skill.skill_file.read_text(encoding="utf-8") - except OSError as exc: - logger.debug("Cannot read SKILL.md for %s (%s); falling back to description only", skill.name, exc) - skill_content = "" - # Cap the SKILL.md body so very large workflow docs don't blow the context. - max_chars = 6000 - if len(skill_content) > max_chars: - skill_content = skill_content[:max_chars] + "\n…(truncated)" - - user_prompt = ( - f"Skill name: {skill.name}\n" - f"Short description: {skill.description}\n\n" - f"SKILL.md:\n-----\n{skill_content or '(SKILL.md unavailable; rely on the short description above.)'}\n-----\n\n" - "Return one JSON object that matches what this skill would actually produce on success." - ) - - try: - from deerflow.models import create_chat_model - - # Reuse the curator/security-scan model setting; falls back to the main model when unset. - model_name = app_config.skill_evolution.curator.model_name if app_config.skill_evolution and app_config.skill_evolution.curator else None - model = create_chat_model(name=model_name, thinking_enabled=False, app_config=app_config) - response = await asyncio.wait_for( - model.ainvoke( - [ - {"role": "system", "content": _SKILL_SAMPLE_SYSTEM_PROMPT}, - {"role": "user", "content": user_prompt}, - ], - config={"run_name": "skill_display_sample"}, - ), - timeout=45, - ) - parsed = _extract_json_from_llm_output(str(getattr(response, "content", "") or "")) - if parsed: - return parsed - logger.warning("Skill display sample model returned non-JSON for %s; falling back to static mock", skill.name) - except asyncio.TimeoutError: - logger.warning("Skill display sample LLM call timed out for %s; falling back to static mock", skill.name) - except Exception: - logger.warning("Skill display sample LLM call failed for %s; falling back to static mock", skill.name, exc_info=True) - return _mock_skill_display_sample(skill.name) - - -def _mock_skill_display_sample(skill_name: str) -> dict[str, Any]: - if skill_name == "knowledge-search-v2": - return { - "success": True, - "query": "latest news", - "count": 3, - "results": [ - { - "index": 1, - "recUuid": "rec-001", - "relevance_score": 0.982, - "title": "Knowledge result one", - "source_category": "knowledge/news", - "source": "internal news library", - "publish_time": "2026-05-25", - "content_preview": "Mock result used to verify citation numbers, source cards, and recUuid template links.", - "content_length": 1200, - }, - { - "index": 2, - "recUuid": "rec-002", - "relevance_score": 0.951, - "title": "Knowledge result two", - "source_category": "knowledge/news", - "source": "internal news library", - "publish_time": "2026-05-24", - "content_preview": "Mock result used to verify citation ordering and detail preview.", - "content_length": 980, - }, - { - "index": 3, - "recUuid": "rec-003", - "relevance_score": 0.921, - "title": "Knowledge result three", - "source_category": "knowledge/commentary", - "source": "internal knowledge library", - "publish_time": "2026-05-23", - "content_preview": "Mock result used to verify list mode and custom display instruction previews.", - "content_length": 760, - }, - ], - } - return { - "success": True, - "query": "sample query", - "results": [ - { - "id": "sample-001", - "title": "Sample result", - "summary": "Sample structured output used to infer field mapping.", - "source": "sample", - "updatedAt": "2026-05-25", - } - ], - } - - -async def _build_ownership_index(skill_store: SkillStore, user_id: str) -> dict[str, dict[str, Any]]: - """Index visible-to-user skill records by name. Internal call cache for list endpoints.""" - records = await skill_store.list_visible(user_id) - return {record["name"]: record for record in records} - - -async def _attach_and_filter_tags( - tag_store: TagStore, - responses: list[SkillResponse], - search: str | None, - tag_ids: list[str] | None, -) -> list[SkillResponse]: - """Bulk-attach tags to skill responses, then apply name/tag filters.""" - assignments = await tag_store.list_assignments("skill", [r.name for r in responses]) - for resp in responses: - resp.tags = assignments.get(resp.name, []) - if search: - needle = search.strip().lower() - responses = [r for r in responses if needle in r.name.lower()] - if tag_ids: - wanted = set(tag_ids) - responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)] - return responses - - -@router.get( - "/skills", - response_model=SkillsListResponse, - summary="List All Skills", - description="Retrieve a list of all available skills from both public and custom directories.", -) -async def list_skills( - request: Request, - config: AppConfig = Depends(get_config), - skill_store: SkillStore = Depends(get_skill_store), - tag_store: TagStore = Depends(get_tag_store), - search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name"), - tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"), -) -> SkillsListResponse: - try: - user_id = _current_user_id(request) - all_skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) - ownership = await _build_ownership_index(skill_store, user_id) - responses: list[SkillResponse] = [] - for skill in all_skills: - # Public/built-in skills (no DB row) are visible to everyone. - # Custom skills must have an ownership record in the visible set. - if skill.category != SkillCategory.CUSTOM: - # Built-in skills usually have no DB row, but an admin-authored - # ``detail`` annotation materializes one — surface it when present. - responses.append(_skill_to_response(skill, ownership.get(skill.name))) - continue - record = ownership.get(skill.name) - if record is None: - continue - responses.append(_skill_to_response(skill, record)) - responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids) - return SkillsListResponse(skills=responses) - except Exception as e: - logger.error(f"Failed to load skills: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}") - - -_MAX_SKILL_UPLOAD_BYTES = 512 * 1024 * 1024 -_ZIP_MAGIC = b"PK\x03\x04" - - -async def _persist_uploaded_skill(file: UploadFile) -> Path: - """Stream an uploaded skill archive to a temp file. Caller must unlink it. - - Validates the leading bytes look like a ZIP archive and enforces the same - 512 MB cap that the extractor uses, so we reject obvious garbage early. - """ - if file.filename is None: - raise HTTPException(status_code=400, detail="No file provided") - suffix = Path(file.filename).suffix.lower() - if suffix not in {".zip", ".skill"}: - raise HTTPException(status_code=400, detail="Skill upload must be a .zip or .skill file") - - fd, tmp_name = tempfile.mkstemp(suffix=".skill", prefix="skill-upload-") - tmp_path = Path(tmp_name) - total = 0 - head: bytes = b"" - try: - with open(fd, "wb") as out: - while True: - chunk = await file.read(1024 * 1024) - if not chunk: - break - if not head: - head = chunk[: len(_ZIP_MAGIC)] - total += len(chunk) - if total > _MAX_SKILL_UPLOAD_BYTES: - raise HTTPException(status_code=400, detail="Uploaded skill archive exceeds the 512 MB limit") - out.write(chunk) - except HTTPException: - tmp_path.unlink(missing_ok=True) - raise - except Exception: - tmp_path.unlink(missing_ok=True) - raise - - if not head.startswith(_ZIP_MAGIC): - tmp_path.unlink(missing_ok=True) - raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive") - return tmp_path - - -@router.post( - "/skills/validate", - response_model=SkillUploadValidateResponse, - summary="Validate Skill Archive (No Install)", - description="Inspect an uploaded .zip/.skill archive and report parsed metadata + conflicts. Does NOT install or run the LLM security scan.", -) -async def validate_skill_upload( - file: UploadFile = File(..., description="The .zip or .skill archive to validate"), - config: AppConfig = Depends(get_config), -) -> SkillUploadValidateResponse: - tmp_path = await _persist_uploaded_skill(file) - try: - result = get_or_new_skill_storage(app_config=config).validate_skill_archive(tmp_path) - return SkillUploadValidateResponse(**result) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except FileNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except HTTPException: - raise - except Exception as e: - logger.error("Failed to validate uploaded skill: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to validate skill: {e}") - finally: - tmp_path.unlink(missing_ok=True) - - -@router.post( - "/skills/install-upload", - response_model=SkillInstallResponse, - summary="Install Skill from Upload", - description="Install a skill directly from an uploaded .zip/.skill archive. Set ?overwrite=true to replace an existing skill with the same name.", -) -async def install_skill_upload( - request: Request, - file: UploadFile = File(..., description="The .zip or .skill archive to install"), - overwrite: bool = False, - target_category: SkillCategory = SkillCategory.CUSTOM, - config: AppConfig = Depends(get_config), - skill_store: SkillStore = Depends(get_skill_store), -) -> SkillInstallResponse: - user_id = _current_user_id(request) - is_admin = _is_admin_user(request) - # Only admins may install to the public (built-in) directory. - if target_category == SkillCategory.PUBLIC and not is_admin: - raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory") - tmp_path = await _persist_uploaded_skill(file) - try: - storage = get_or_new_skill_storage(app_config=config) - # Pre-parse the archive to learn the skill name without writing anything, - # so we can reject the upload before touching the filesystem if another - # user already owns this name. Admins bypass the ownership check since - # they install to the shared public directory. - try: - validation = storage.validate_skill_archive(tmp_path) - preview_name = validation.get("skill_name") if isinstance(validation, dict) else None - except Exception: - preview_name = None - if preview_name and not is_admin: - existing = await skill_store.get_any(preview_name) - if existing and existing.get("owner_user_id") not in (None, user_id): - raise HTTPException( - status_code=409, - detail=f"Skill '{preview_name}' already exists and is owned by another user", - ) - result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False, target_category=target_category) - try: - from deerflow.skills.usage import mark_uploaded - mark_uploaded(result["skill_name"]) - except Exception: - pass - # For public installs (admin), register as built-in with no owner. - # For custom installs, register ownership under the uploading user. - if target_category == SkillCategory.PUBLIC: - await skill_store.ensure_legacy( - {"name": result["skill_name"], "owner_user_id": None, "published": True} - ) - else: - # ``ensure_legacy`` is idempotent and only patches a NULL owner; it - # never overwrites an existing owner, so the pre-parse check above - # is what really guards cross-user steals. - await skill_store.ensure_legacy( - {"name": result["skill_name"], "owner_user_id": user_id, "published": False} - ) - await refresh_skills_system_prompt_cache_async() - return SkillInstallResponse(**result) - except FileNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except SkillAlreadyExistsError as e: - raise HTTPException(status_code=409, detail=str(e)) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except HTTPException: - raise - except Exception as e: - logger.error("Failed to install uploaded skill: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to install skill: {e}") - finally: - tmp_path.unlink(missing_ok=True) - - -@router.post( - "/skills/install", - response_model=SkillInstallResponse, - summary="Install Skill", - description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.", -) -async def install_skill( - body: SkillInstallRequest, - request: Request, - config: AppConfig = Depends(get_config), - skill_store: SkillStore = Depends(get_skill_store), -) -> SkillInstallResponse: - try: - user_id = _current_user_id(request) - is_admin = _is_admin_user(request) - target_category = body.target_category - if target_category == SkillCategory.PUBLIC and not is_admin: - raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory") - skill_file_path = resolve_thread_virtual_path(body.thread_id, body.path) - result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path, target_category=target_category) - try: - from deerflow.skills.usage import mark_uploaded - mark_uploaded(result["skill_name"]) - except Exception: - pass - if target_category == SkillCategory.PUBLIC: - await skill_store.ensure_legacy( - {"name": result["skill_name"], "owner_user_id": None, "published": True} - ) - else: - await skill_store.ensure_legacy( - {"name": result["skill_name"], "owner_user_id": user_id, "published": False} - ) - await refresh_skills_system_prompt_cache_async() - return SkillInstallResponse(**result) - except FileNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except SkillAlreadyExistsError as e: - raise HTTPException(status_code=409, detail=str(e)) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to install skill: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}") - - -@router.post( - "/skills/reconcile-custom", - response_model=SkillReconcileResponse, - summary="Adopt Orphan Custom Skills", - description=( - "Scan the on-disk ``skills/custom/`` directory and register any skill that has a " - "valid SKILL.md but no DB ownership row. Adopted skills are recorded as owned by " - "the calling user with ``published=false``. Skips skills that already have a row " - "(regardless of owner). Useful after manually unzipping skills onto the filesystem." - ), -) -async def reconcile_custom_skills( - request: Request, - config: AppConfig = Depends(get_config), - skill_store: SkillStore = Depends(get_skill_store), -) -> SkillReconcileResponse: - try: - user_id = _current_user_id(request) - storage = get_or_new_skill_storage(app_config=config) - custom_skills = [s for s in storage.load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM] - - adopted: list[str] = [] - already: list[str] = [] - for skill in custom_skills: - existing = await skill_store.get_any(skill.name) - if existing is not None: - already.append(skill.name) - continue - await skill_store.ensure_legacy( - {"name": skill.name, "owner_user_id": user_id, "published": False} - ) - try: - from deerflow.skills.usage import mark_uploaded - mark_uploaded(skill.name) - except Exception: - pass - adopted.append(skill.name) - - if adopted: - await refresh_skills_system_prompt_cache_async() - return SkillReconcileResponse(adopted=adopted, already_registered=already) - except HTTPException: - raise - except Exception as e: - logger.error("Failed to reconcile custom skills: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to reconcile custom skills: {e}") - - -@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills") -async def list_custom_skills( - request: Request, - config: AppConfig = Depends(get_config), - skill_store: SkillStore = Depends(get_skill_store), -) -> SkillsListResponse: - try: - user_id = _current_user_id(request) - custom = [s for s in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM] - ownership = await _build_ownership_index(skill_store, user_id) - responses = [ - _skill_to_response(skill, ownership[skill.name]) - for skill in custom - if skill.name in ownership - ] - return SkillsListResponse(skills=responses) - except Exception as e: - logger.error("Failed to list custom skills: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to list custom skills: {str(e)}") - - -@router.get("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Get Custom Skill Content") -async def get_custom_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse: - try: - skill_name = skill_name.replace("\r\n", "").replace("\n", "") - skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) - skill = next((s for s in skills if s.name == skill_name and s.category == SkillCategory.CUSTOM), None) - if skill is None: - raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found") - return CustomSkillContentResponse(**_skill_to_response(skill).model_dump(), content=get_or_new_skill_storage(app_config=config).read_custom_skill(skill_name)) - except HTTPException: - raise - except Exception as e: - logger.error("Failed to get custom skill %s: %s", skill_name, e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to get custom skill: {str(e)}") - - -@router.put("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Edit Custom Skill") -async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse: - try: - skill_name = skill_name.replace("\r\n", "").replace("\n", "") - storage = get_or_new_skill_storage(app_config=config) - storage.ensure_custom_skill_is_editable(skill_name) - storage.validate_skill_markdown_content(skill_name, request.content) - scan = await scan_skill_content(request.content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config) - if scan.decision == "block": - raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}") - prev_content = storage.read_custom_skill(skill_name) - storage.write_custom_skill(skill_name, SKILL_MD_FILE, request.content) - storage.append_history( - skill_name, - { - "action": "human_edit", - "author": "human", - "thread_id": None, - "file_path": SKILL_MD_FILE, - "prev_content": prev_content, - "new_content": request.content, - "scanner": {"decision": scan.decision, "reason": scan.reason}, - }, - ) - try: - from deerflow.skills.usage import bump_patch - bump_patch(skill_name) - except Exception: - pass - await refresh_skills_system_prompt_cache_async() - return await get_custom_skill(skill_name, config) - except HTTPException: - raise - except FileNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error("Failed to update custom skill %s: %s", skill_name, e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to update custom skill: {str(e)}") - - -@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill") -async def delete_custom_skill( - skill_name: str, - request: Request, - config: AppConfig = Depends(get_config), - skill_store: SkillStore = Depends(get_skill_store), - agent_store=Depends(get_agent_store), -) -> dict[str, bool]: - try: - skill_name = skill_name.replace("\r\n", "").replace("\n", "") - user_id = _current_user_id(request) - is_admin = _is_admin_user(request) - record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin) - operation = "admin_takedown" if mode == "admin" and record.get("owner_user_id") not in (None, user_id) else "delete" - - storage = get_or_new_skill_storage(app_config=config) - storage.delete_custom_skill( - skill_name, - history_meta={ - "action": "human_delete", - "author": "human", - "thread_id": None, - "file_path": SKILL_MD_FILE, - "prev_content": None, - "new_content": None, - "scanner": {"decision": "allow", "reason": "Deletion requested."}, - }, - ) - try: - from deerflow.skills.usage import forget - forget(skill_name) - except Exception: - pass - - # Persist ownership record removal so the DB stays consistent. - if mode == "admin": - await skill_store.delete_admin(skill_name) - else: - await skill_store.delete(skill_name, user_id) - - try: - await get_tag_store(request).unassign_all("skill", skill_name) - except Exception: # noqa: BLE001 — tag cleanup must not block deletion - pass - - # Strip references from every agent that pulled this skill in, - # and notify their owners (no-op for self). - await cascade_skill_unpublished_or_deleted( - skill_name, - actor_user_id=user_id, - operation=operation, - skill_owner_user_id=record.get("owner_user_id"), - agent_store=agent_store, - ) - - await refresh_skills_system_prompt_cache_async() - return {"success": True} - except HTTPException: - raise - except FileNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error("Failed to delete custom skill %s: %s", skill_name, e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to delete custom skill: {str(e)}") - - -class SkillPublishRequest(BaseModel): - """Request body for toggling a custom skill's published flag.""" - - published: bool = Field(..., description="Whether to publish (True) or unpublish (False)") - - -class SkillPublishResponse(BaseModel): - name: str - owner_user_id: str | None - published: bool - - -@router.put( - "/skills/custom/{skill_name}/published", - response_model=SkillPublishResponse, - summary="Toggle Custom Skill Published State", -) -async def update_custom_skill_published( - skill_name: str, - body: SkillPublishRequest, - request: Request, - skill_store: SkillStore = Depends(get_skill_store), - agent_store=Depends(get_agent_store), -) -> SkillPublishResponse: - try: - skill_name = skill_name.replace("\r\n", "").replace("\n", "") - user_id = _current_user_id(request) - is_admin = _is_admin_user(request) - record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin) - - was_published = bool(record.get("published", False)) - if mode == "admin": - updated = await skill_store.update_admin(skill_name, {"published": body.published}) - else: - updated = await skill_store.update(skill_name, user_id, {"published": body.published}) - if updated is None: - raise HTTPException(status_code=500, detail="Failed to persist published state") - - # If we are *removing* visibility (was published, now not), strip from - # other users' agents and notify them. admin takedown is signaled by - # the actor not being the owner. - if was_published and not body.published: - skill_owner = record.get("owner_user_id") - operation = "admin_takedown" if mode == "admin" and skill_owner not in (None, user_id) else "unpublish" - await cascade_skill_unpublished_or_deleted( - skill_name, - actor_user_id=user_id, - operation=operation, - skill_owner_user_id=skill_owner, - agent_store=agent_store, - ) - await refresh_skills_system_prompt_cache_async() - - return SkillPublishResponse( - name=updated["name"], - owner_user_id=updated.get("owner_user_id"), - published=bool(updated.get("published", False)), - ) - except HTTPException: - raise - except Exception as e: - logger.error("Failed to update published state for %s: %s", skill_name, e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to update published state: {str(e)}") - - -_ALLOWED_USER_STATES = {"active", "archived"} - - -class SkillStateRequest(BaseModel): - """Request body for switching a custom skill's lifecycle state.""" - - state: str = Field(..., description="New state: 'active' (restore) or 'archived' (soft archive)") - - -class SkillPinRequest(BaseModel): - """Request body for pinning / unpinning a custom skill.""" - - pinned: bool = Field(..., description="Whether to pin (true) so the curator never auto-archives this skill") - - -def _reload_skill_response(skill_name: str, ownership: dict[str, Any], config: AppConfig) -> SkillResponse: - """Reload a single skill from disk and return its API response form.""" - skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) - skill = next((s for s in skills if s.name == skill_name), None) - if skill is None: - raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found on disk") - return _skill_to_response(skill, ownership) - - -@router.put( - "/skills/custom/{skill_name}/state", - response_model=SkillResponse, - summary="Update Custom Skill Lifecycle State", - description="Restore (active) or soft-archive a custom skill. Only the owner or admins may invoke.", -) -async def update_custom_skill_state( - skill_name: str, - body: SkillStateRequest, - request: Request, - config: AppConfig = Depends(get_config), - skill_store: SkillStore = Depends(get_skill_store), -) -> SkillResponse: - try: - skill_name = skill_name.replace("\r\n", "").replace("\n", "") - if body.state not in _ALLOWED_USER_STATES: - raise HTTPException( - status_code=400, - detail=f"state must be one of {sorted(_ALLOWED_USER_STATES)}", - ) - user_id = _current_user_id(request) - is_admin = _is_admin_user(request) - record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin) - - from deerflow.skills.usage import set_state as _usage_set_state - - _usage_set_state(skill_name, body.state) - await refresh_skills_system_prompt_cache_async() - return _reload_skill_response(skill_name, record, config) - except HTTPException: - raise - except Exception as e: - logger.error("Failed to update state for %s: %s", skill_name, e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to update state: {e}") - - -@router.put( - "/skills/custom/{skill_name}/pinned", - response_model=SkillResponse, - summary="Pin or Unpin a Custom Skill", - description="Pin a skill so the curator never auto-archives it. Only the owner or admins may invoke.", -) -async def update_custom_skill_pinned( - skill_name: str, - body: SkillPinRequest, - request: Request, - config: AppConfig = Depends(get_config), - skill_store: SkillStore = Depends(get_skill_store), -) -> SkillResponse: - try: - skill_name = skill_name.replace("\r\n", "").replace("\n", "") - user_id = _current_user_id(request) - is_admin = _is_admin_user(request) - record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin) - - from deerflow.skills.usage import set_pinned as _usage_set_pinned - - _usage_set_pinned(skill_name, body.pinned) - return _reload_skill_response(skill_name, record, config) - except HTTPException: - raise - except Exception as e: - logger.error("Failed to update pinned for %s: %s", skill_name, e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to update pinned: {e}") - - -@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History") -async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse: - try: - skill_name = skill_name.replace("\r\n", "").replace("\n", "") - storage = get_or_new_skill_storage(app_config=config) - if not storage.custom_skill_exists(skill_name) and not storage.get_skill_history_file(skill_name).exists(): - raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found") - return CustomSkillHistoryResponse(history=storage.read_history(skill_name)) - except HTTPException: - raise - except Exception as e: - logger.error("Failed to read history for %s: %s", skill_name, e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to read history: {str(e)}") - - -@router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill") -async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse: - try: - storage = get_or_new_skill_storage(app_config=config) - if not storage.custom_skill_exists(skill_name) and not storage.get_skill_history_file(skill_name).exists(): - raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found") - history = storage.read_history(skill_name) - if not history: - raise HTTPException(status_code=400, detail=f"Custom skill '{skill_name}' has no history") - record = history[request.history_index] - target_content = record.get("prev_content") - if target_content is None: - raise HTTPException(status_code=400, detail="Selected history entry has no previous content to roll back to") - storage.validate_skill_markdown_content(skill_name, target_content) - scan = await scan_skill_content(target_content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config) - skill_file = storage.get_custom_skill_file(skill_name) - current_content = skill_file.read_text(encoding="utf-8") if skill_file.exists() else None - history_entry = { - "action": "rollback", - "author": "human", - "thread_id": None, - "file_path": SKILL_MD_FILE, - "prev_content": current_content, - "new_content": target_content, - "rollback_from_ts": record.get("ts"), - "scanner": {"decision": scan.decision, "reason": scan.reason}, - } - if scan.decision == "block": - storage.append_history(skill_name, history_entry) - raise HTTPException(status_code=400, detail=f"Rollback blocked by security scanner: {scan.reason}") - storage.write_custom_skill(skill_name, SKILL_MD_FILE, target_content) - storage.append_history(skill_name, history_entry) - await refresh_skills_system_prompt_cache_async() - return await get_custom_skill(skill_name, config) - except HTTPException: - raise - except IndexError: - raise HTTPException(status_code=400, detail="history_index is out of range") - except FileNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error("Failed to roll back custom skill %s: %s", skill_name, e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to roll back custom skill: {str(e)}") - - -@router.get( - "/skills/{skill_name}", - response_model=SkillResponse, - summary="Get Skill Details", - description="Retrieve detailed information about a specific skill by its name.", -) -async def get_skill( - skill_name: str, - request: Request, - config: AppConfig = Depends(get_config), - skill_store: SkillStore = Depends(get_skill_store), -) -> SkillResponse: - try: - skill_name = skill_name.replace("\r\n", "").replace("\n", "") - skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) - skill = next((s for s in skills if s.name == skill_name), None) - - if skill is None: - raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") - - # Attach ownership (owner/published/detail) when the caller may see it. - ownership = await skill_store.get_visible(skill_name, _current_user_id(request)) - return _skill_to_response(skill, ownership) - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to get skill {skill_name}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}") - - -class SkillContentResponse(BaseModel): - """SKILL.md 原文内容,适用于公共和自定义技能。""" - - name: str - category: SkillCategory - content: str - - -@router.get( - "/skills/{skill_name}/content", - response_model=SkillContentResponse, - summary="Get Skill Markdown Content", - description="Retrieve the raw SKILL.md content of a skill. Only an admin or the skill's publisher (owner of a custom skill) may read it; other users see only the description and detail.", -) -async def get_skill_content( - skill_name: str, - request: Request, - config: AppConfig = Depends(get_config), - skill_store: SkillStore = Depends(get_skill_store), -) -> SkillContentResponse: - try: - skill_name = skill_name.replace("\r\n", "").replace("\n", "") - skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) - skill = next((s for s in skills if s.name == skill_name), None) - if skill is None: - raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") - # The raw SKILL.md is implementation detail — only an admin or the - # publisher (owner of a custom skill) may view it. Everyone else is - # limited to the description + detail surfaced by the list/detail APIs. - record = await skill_store.get_any(skill_name) - owner_user_id = record.get("owner_user_id") if record else None - if not _can_view_skill_source(skill.category, owner_user_id, _current_user_id(request), is_admin=_is_admin_user(request)): - raise HTTPException(status_code=403, detail="Only the skill publisher or an admin can view the SKILL.md source") - content = skill.skill_file.read_text(encoding="utf-8") - return SkillContentResponse(name=skill.name, category=skill.category, content=content) - except HTTPException: - raise - except Exception as e: - logger.error("Failed to get skill content %s: %s", skill_name, e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to get skill content: {str(e)}") - - -# --------------------------------------------------------------------------- -# Skill files browser (tree + per-file read / write) -# --------------------------------------------------------------------------- - - -_SKILL_FILE_TEXT_EXTS = { - ".md", ".markdown", ".txt", ".rst", - ".py", ".pyi", - ".json", ".jsonl", - ".yaml", ".yml", - ".toml", ".ini", ".cfg", ".env", - ".sh", ".bash", ".zsh", ".ps1", - ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", - ".html", ".htm", ".css", ".scss", ".sass", ".xml", ".svg", - ".csv", ".tsv", - ".sql", -} -_SKILL_FILE_TREE_SKIP_NAMES = { - "__pycache__", "node_modules", ".git", ".DS_Store", ".idea", ".vscode", -} -_SKILL_FILE_MAX_NODES = 2000 -_SKILL_FILE_MAX_DEPTH = 8 -_SKILL_FILE_READ_BYTES = 2 * 1024 * 1024 # 2 MiB cap on JSON content payload -_SKILL_FILE_WRITE_BYTES = 1 * 1024 * 1024 # 1 MiB cap on edits - - -class SkillFileNode(BaseModel): - """One entry in a skill's file tree. - - ``children`` is only set for directories. ``path`` is POSIX-style and is - always relative to the skill's own directory (never absolute). - """ - - name: str = Field(..., description="Base name (no slashes)") - type: str = Field(..., description="'file' or 'dir'") - path: str = Field(..., description="Path relative to the skill directory, POSIX-separated") - size: int | None = Field(default=None, description="File size in bytes (files only)") - mtime: float | None = Field(default=None, description="Modification time as POSIX seconds (files only)") - children: list["SkillFileNode"] | None = Field(default=None, description="Child entries (directories only)") - - -SkillFileNode.model_rebuild() - - -class SkillFilesResponse(BaseModel): - """Tree of files under a skill directory.""" - - name: str - category: SkillCategory - editable: bool = Field(..., description="True when the caller may PUT new content into any file under this tree") - root: SkillFileNode - - -class SkillFileContentResponse(BaseModel): - """JSON payload for reading a single file inside a skill.""" - - name: str - category: SkillCategory - path: str - mime: str - size: int - mtime: float - is_text: bool = Field(..., description="True when ``content`` carries the decoded text; otherwise download via the binary endpoint") - editable: bool = Field(..., description="True when the caller may PUT new content for this file") - content: str | None = Field(default=None, description="Decoded text content when ``is_text`` is True") - - -class SkillFileSaveRequest(BaseModel): - """Request body for writing a text file inside a skill.""" - - path: str = Field(..., description="File path relative to the skill directory, POSIX-separated") - content: str = Field(..., description="Replacement text content") - - -class SkillFileSaveResponse(BaseModel): - name: str - path: str - size: int - mtime: float - - -def _normalize_skill_name_param(skill_name: str) -> str: - """Strip CR/LF the same way other skill routes do before lookup.""" - return skill_name.replace("\r\n", "").replace("\n", "") - - -def _load_skill_or_404(skill_name: str, config: AppConfig) -> Skill: - skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) - skill = next((s for s in skills if s.name == skill_name), None) - if skill is None: - raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") - return skill - - -async def _ensure_files_view_allowed( - skill: Skill, - request: Request, - skill_store: SkillStore, -) -> dict[str, Any] | None: - """Mirror _can_view_skill_source: admins or the publisher (custom owner).""" - record = await skill_store.get_any(skill.name) - owner_user_id = record.get("owner_user_id") if record else None - user_id = _current_user_id(request) - if not _can_view_skill_source(skill.category, owner_user_id, user_id, is_admin=_is_admin_user(request)): - raise HTTPException(status_code=403, detail="Only the skill publisher or an admin can browse skill files") - return record - - -def _caller_can_edit_skill_files( - skill: Skill, - record: dict[str, Any] | None, - request: Request, -) -> bool: - """Editing is limited to custom skills, and to either owner or admin.""" - if skill.category != SkillCategory.CUSTOM: - return False - if _is_admin_user(request): - return True - if record is None: - return False - return record.get("owner_user_id") == _current_user_id(request) - - -def _resolve_skill_relative_path(skill: Skill, relative_path: str) -> Path: - """Validate *relative_path* against the skill directory and return the absolute Path. - - Rejects empty, absolute, parent-traversing, or out-of-tree paths so a - malicious caller can never escape the skill's own directory. - """ - if not relative_path or relative_path.strip() == "": - raise HTTPException(status_code=400, detail="path must not be empty") - candidate = Path(relative_path) - if candidate.is_absolute() or any(part in {"..", ""} for part in candidate.parts): - raise HTTPException(status_code=400, detail="path must be a relative path without '..' segments") - skill_dir = skill.skill_file.parent.resolve() - target = (skill_dir / candidate).resolve() - try: - target.relative_to(skill_dir) - except ValueError: - raise HTTPException(status_code=400, detail="path must stay inside the skill directory") - return target - - -def _file_mime(path: Path) -> str: - """Best-effort MIME guess; defaults to ``application/octet-stream``.""" - guessed, _ = mimetypes.guess_type(path.name) - if guessed: - return guessed - if path.suffix.lower() in _SKILL_FILE_TEXT_EXTS: - return "text/plain" - return "application/octet-stream" - - -def _is_text_file(path: Path) -> bool: - """Treat known text extensions as text; everything else as binary.""" - return path.suffix.lower() in _SKILL_FILE_TEXT_EXTS - - -def _build_skill_file_tree(skill_dir: Path) -> SkillFileNode: - """Walk *skill_dir* and build a SkillFileNode tree, depth/count capped. - - Hidden files (``.foo``) and a small block-list of noise directories - (``__pycache__`` etc.) are skipped so the UI does not drown in housekeeping - artefacts. - """ - counter = {"n": 0} - - def build(current_dir: Path, depth: int) -> SkillFileNode: - rel = current_dir.relative_to(skill_dir).as_posix() - node = SkillFileNode( - name=current_dir.name if rel != "." else skill_dir.name, - type="dir", - path="" if rel == "." else rel, - children=[], - ) - if depth >= _SKILL_FILE_MAX_DEPTH: - return node - try: - entries = sorted(current_dir.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())) - except OSError: - return node - for entry in entries: - if counter["n"] >= _SKILL_FILE_MAX_NODES: - break - if entry.name.startswith(".") or entry.name in _SKILL_FILE_TREE_SKIP_NAMES: - continue - counter["n"] += 1 - if entry.is_dir(): - node.children.append(build(entry, depth + 1)) - continue - try: - stat = entry.stat() - size = stat.st_size - mtime = stat.st_mtime - except OSError: - size = 0 - mtime = 0.0 - node.children.append( - SkillFileNode( - name=entry.name, - type="file", - path=entry.relative_to(skill_dir).as_posix(), - size=size, - mtime=mtime, - ) - ) - return node - - return build(skill_dir, 0) - - -@router.get( - "/skills/{skill_name}/files", - response_model=SkillFilesResponse, - summary="List Skill Files (Tree)", - description=( - "Return the full file tree under a skill directory. Only an admin or the " - "skill's publisher (owner of a custom skill) may browse files; other users " - "get 403." - ), -) -async def list_skill_files( - skill_name: str, - request: Request, - config: AppConfig = Depends(get_config), - skill_store: SkillStore = Depends(get_skill_store), -) -> SkillFilesResponse: - try: - skill_name = _normalize_skill_name_param(skill_name) - skill = _load_skill_or_404(skill_name, config) - record = await _ensure_files_view_allowed(skill, request, skill_store) - skill_dir = skill.skill_file.parent - root = _build_skill_file_tree(skill_dir) - return SkillFilesResponse( - name=skill.name, - category=skill.category, - editable=_caller_can_edit_skill_files(skill, record, request), - root=root, - ) - except HTTPException: - raise - except Exception as e: - logger.error("Failed to list skill files for %s: %s", skill_name, e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to list skill files: {e}") - - -@router.get( - "/skills/{skill_name}/files/content", - response_model=SkillFileContentResponse, - summary="Read Skill File (Text)", - description=( - "Return JSON metadata for a single file under a skill. Text files include " - "their decoded ``content``; binary files omit ``content`` and should be " - "fetched from the ``files/download`` endpoint for inline display or " - "download. Same view authorisation as the file tree endpoint." - ), -) -async def get_skill_file_content( - skill_name: str, - request: Request, - path: str = Query(..., description="File path relative to the skill directory"), - config: AppConfig = Depends(get_config), - skill_store: SkillStore = Depends(get_skill_store), -) -> SkillFileContentResponse: - try: - skill_name = _normalize_skill_name_param(skill_name) - skill = _load_skill_or_404(skill_name, config) - record = await _ensure_files_view_allowed(skill, request, skill_store) - target = _resolve_skill_relative_path(skill, path) - if not target.exists() or not target.is_file(): - raise HTTPException(status_code=404, detail=f"File '{path}' not found in skill '{skill_name}'") - stat = target.stat() - mime = _file_mime(target) - is_text = _is_text_file(target) - content: str | None = None - if is_text: - if stat.st_size > _SKILL_FILE_READ_BYTES: - raise HTTPException(status_code=413, detail=f"File exceeds the {_SKILL_FILE_READ_BYTES // 1024} KiB preview limit") - try: - content = target.read_text(encoding="utf-8") - except UnicodeDecodeError: - # File has a text-y extension but is not UTF-8 — treat as binary. - is_text = False - return SkillFileContentResponse( - name=skill.name, - category=skill.category, - path=target.relative_to(skill.skill_file.parent.resolve()).as_posix(), - mime=mime, - size=stat.st_size, - mtime=stat.st_mtime, - is_text=is_text, - editable=is_text and _caller_can_edit_skill_files(skill, record, request), - content=content, - ) - except HTTPException: - raise - except Exception as e: - logger.error("Failed to read skill file %s/%s: %s", skill_name, path, e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to read skill file: {e}") - - -@router.get( - "/skills/{skill_name}/files/download", - summary="Download Skill File (Raw Bytes)", - description=( - "Stream the raw bytes of a single file under a skill, suitable for " - "embedding in an ```` tag or for direct download. Same view " - "authorisation as the file tree endpoint." - ), -) -async def download_skill_file( - skill_name: str, - request: Request, - path: str = Query(..., description="File path relative to the skill directory"), - config: AppConfig = Depends(get_config), - skill_store: SkillStore = Depends(get_skill_store), -): - skill_name = _normalize_skill_name_param(skill_name) - skill = _load_skill_or_404(skill_name, config) - await _ensure_files_view_allowed(skill, request, skill_store) - target = _resolve_skill_relative_path(skill, path) - if not target.exists() or not target.is_file(): - raise HTTPException(status_code=404, detail=f"File '{path}' not found in skill '{skill_name}'") - return FileResponse( - path=target, - media_type=_file_mime(target), - filename=target.name, - ) - - -@router.put( - "/skills/{skill_name}/files/content", - response_model=SkillFileSaveResponse, - summary="Write Skill File (Text)", - description=( - "Replace the contents of a text file under a custom skill. Only the " - "skill's owner or an admin may write; built-in (public) skills are " - "read-only. Edits to ``SKILL.md`` re-validate its YAML frontmatter and " - "refresh the system-prompt cache." - ), -) -async def save_skill_file( - skill_name: str, - body: SkillFileSaveRequest, - request: Request, - config: AppConfig = Depends(get_config), - skill_store: SkillStore = Depends(get_skill_store), -) -> SkillFileSaveResponse: - try: - skill_name = _normalize_skill_name_param(skill_name) - skill = _load_skill_or_404(skill_name, config) - if skill.category != SkillCategory.CUSTOM: - raise HTTPException(status_code=403, detail="Built-in skills are read-only") - record = await skill_store.get_any(skill.name) - if not _caller_can_edit_skill_files(skill, record, request): - raise HTTPException(status_code=403, detail="Only the skill owner or an admin can edit files in this skill") - - target = _resolve_skill_relative_path(skill, body.path) - if not target.exists(): - raise HTTPException(status_code=404, detail=f"File '{body.path}' not found in skill '{skill_name}'") - if target.is_dir(): - raise HTTPException(status_code=400, detail="path points to a directory") - if not _is_text_file(target): - raise HTTPException(status_code=400, detail=f"File type '{target.suffix or target.name}' is not editable") - - if len(body.content.encode("utf-8")) > _SKILL_FILE_WRITE_BYTES: - raise HTTPException(status_code=413, detail=f"Content exceeds the {_SKILL_FILE_WRITE_BYTES // 1024} KiB edit limit") - - relative_posix = target.relative_to(skill.skill_file.parent.resolve()).as_posix() - storage = get_or_new_skill_storage(app_config=config) - - is_skill_md = relative_posix == SKILL_MD_FILE - prev_content: str | None = None - if target.exists(): - try: - prev_content = target.read_text(encoding="utf-8") - except UnicodeDecodeError: - prev_content = None - - if is_skill_md: - # SKILL.md edits must keep their frontmatter valid (name must match). - storage.validate_skill_markdown_content(skill.name, body.content) - - storage.write_custom_skill(skill.name, relative_posix, body.content) - - try: - storage.append_history( - skill.name, - { - "action": "human_edit_file", - "author": "human", - "thread_id": None, - "file_path": relative_posix, - "prev_content": prev_content, - "new_content": body.content, - }, - ) - except Exception as history_err: # noqa: BLE001 — history is best-effort - logger.warning("Failed to append edit history for %s/%s: %s", skill.name, relative_posix, history_err) - - if is_skill_md: - try: - from deerflow.skills.usage import bump_patch - bump_patch(skill.name) - except Exception: - pass - await refresh_skills_system_prompt_cache_async() - - stat = target.stat() - return SkillFileSaveResponse( - name=skill.name, - path=relative_posix, - size=stat.st_size, - mtime=stat.st_mtime, - ) - except HTTPException: - raise - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except FileNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - logger.error("Failed to save skill file %s/%s: %s", skill_name, body.path, e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to save skill file: {e}") - - -@router.put( - "/skills/{skill_name}", - response_model=SkillResponse, - summary="Update Skill", - description="Update a skill's enabled status by modifying the extensions_config.json file.", -) -async def update_skill(skill_name: str, request: SkillUpdateRequest, config: AppConfig = Depends(get_config)) -> SkillResponse: - try: - skill_name = skill_name.replace("\r\n", "").replace("\n", "") - skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) - skill = next((s for s in skills if s.name == skill_name), None) - - if skill is None: - raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") - - config_path = ExtensionsConfig.resolve_config_path() - if config_path is None: - config_path = Path.cwd().parent / "extensions_config.json" - logger.info(f"No existing extensions config found. Creating new config at: {config_path}") - - extensions_config = get_extensions_config() - current_skill_config = extensions_config.skills.get(skill_name) - extensions_config.skills[skill_name] = SkillStateConfig( - enabled=request.enabled, - display=current_skill_config.display if current_skill_config else None, - ) - - config_data = _dump_extensions_config(extensions_config) - - with open(config_path, "w", encoding="utf-8") as f: - json.dump(config_data, f, indent=2) - - logger.info(f"Skills configuration updated and saved to: {config_path}") - reload_extensions_config() - await refresh_skills_system_prompt_cache_async() - - skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) - updated_skill = next((s for s in skills if s.name == skill_name), None) - - if updated_skill is None: - raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update") - - logger.info(f"Skill '{skill_name}' enabled status updated to {request.enabled}") - return _skill_to_response(updated_skill) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to update skill {skill_name}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}") - - -@router.put( - "/skills/{skill_name}/display", - response_model=SkillResponse, - summary="Update Skill Display Adapter", - description="Update a skill's frontend display adapter in extensions_config.json.", -) -async def update_skill_display( - skill_name: str, - request: SkillDisplayUpdateRequest, - config: AppConfig = Depends(get_config), -) -> SkillResponse: - try: - skill_name = skill_name.replace("\r\n", "").replace("\n", "") - skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) - skill = next((s for s in skills if s.name == skill_name), None) - - if skill is None: - raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") - - config_path = ExtensionsConfig.resolve_config_path() - if config_path is None: - config_path = Path.cwd().parent / "extensions_config.json" - logger.info(f"No existing extensions config found. Creating new config at: {config_path}") - - extensions_config = get_extensions_config() - current_skill_config = extensions_config.skills.get(skill_name) - extensions_config.skills[skill_name] = SkillStateConfig( - enabled=current_skill_config.enabled if current_skill_config else skill.enabled, - display=request.display, - ) - - with open(config_path, "w", encoding="utf-8") as f: - json.dump(_dump_extensions_config(extensions_config), f, indent=2, ensure_ascii=False) - - reload_extensions_config() - await refresh_skills_system_prompt_cache_async() - skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) - updated_skill = next((s for s in skills if s.name == skill_name), None) - if updated_skill is None: - raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after display update") - return _skill_to_response(updated_skill) - except HTTPException: - raise - except Exception as e: - logger.error("Failed to update display adapter for %s: %s", skill_name, e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to update skill display adapter: {str(e)}") - - -@router.post( - "/skills/{skill_name}/display-sample", - response_model=SkillDisplaySampleResponse, - summary="Get Skill Display Sample", - description="Return a structured sample response used by the frontend to infer display mapping.", -) -async def get_skill_display_sample( - skill_name: str, - config: AppConfig = Depends(get_config), -) -> SkillDisplaySampleResponse: - try: - skill_name = skill_name.replace("\r\n", "").replace("\n", "") - skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) - skill = next((s for s in skills if s.name == skill_name), None) - if skill is None: - raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") - sample = await _generate_skill_display_sample(skill, config) - return SkillDisplaySampleResponse(skill_name=skill_name, sample=sample) - except HTTPException: - raise - except Exception as e: - logger.error("Failed to get display sample for %s: %s", skill_name, e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to get display sample: {str(e)}") - - -class SkillDetailRequest(BaseModel): - """Request body for setting a skill's human-authored detail text.""" - - detail: str = Field(default="", description="详情说明文本,支持 Markdown;传空字符串表示清空") - - -class SkillDetailResponse(BaseModel): - """Response model for the skill-detail update endpoint.""" - - name: str = Field(..., description="Skill name") - detail: str | None = Field(default=None, description="Persisted detail text (null when cleared)") - - -@router.put( - "/skills/{skill_name}/detail", - response_model=SkillDetailResponse, - summary="Update Skill Detail", - description="设置技能的详情说明。自定义技能由其所有者或管理员编辑;内置/公开技能仅管理员可编辑。", -) -async def update_skill_detail( - skill_name: str, - body: SkillDetailRequest, - request: Request, - config: AppConfig = Depends(get_config), - skill_store: SkillStore = Depends(get_skill_store), -) -> SkillDetailResponse: - try: - skill_name = skill_name.replace("\r\n", "").replace("\n", "") - user_id = _current_user_id(request) - is_admin = _is_admin_user(request) - - skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) - skill = next((s for s in skills if s.name == skill_name), None) - if skill is None: - raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") - - # Empty / whitespace-only input clears the annotation. - detail_value: str | None = body.detail.strip() or None - - if skill.category == SkillCategory.CUSTOM: - # Owner edits their own skill; admins may edit anyone's. - _record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin) - if mode == "admin": - updated = await skill_store.update_admin(skill_name, {"detail": detail_value}) - else: - updated = await skill_store.update(skill_name, user_id, {"detail": detail_value}) - else: - # Built-in / public skill — only admins may annotate it. These carry - # no DB row by default, so materialize one before writing detail. - if not is_admin: - raise HTTPException(status_code=403, detail="Only admin users can edit built-in skill details") - if await skill_store.get_any(skill_name) is None: - await skill_store.ensure_legacy({"name": skill_name, "owner_user_id": None, "published": True}) - updated = await skill_store.update_admin(skill_name, {"detail": detail_value}) - - if updated is None: - raise HTTPException(status_code=500, detail="Failed to persist skill detail") - return SkillDetailResponse(name=skill_name, detail=updated.get("detail")) - except HTTPException: - raise - except Exception as e: - logger.error("Failed to update detail for %s: %s", skill_name, e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to update skill detail: {str(e)}") - - -class SkillPinRequest(BaseModel): - pinned: bool = Field(..., description="Whether to pin or unpin the skill") - - -@router.post("/skills/custom/{skill_name}/pin", response_model=SkillResponse, summary="Pin or Unpin Skill") -async def pin_skill(skill_name: str, request: SkillPinRequest, config: AppConfig = Depends(get_config)) -> SkillResponse: - """Pin a skill to prevent it from being automatically archived by the curator.""" - try: - skill_name = skill_name.replace("\r\n", "").replace("\n", "") - storage = get_or_new_skill_storage(app_config=config) - if not storage.custom_skill_exists(skill_name): - raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found") - from deerflow.skills.usage import set_pinned - set_pinned(skill_name, request.pinned) - skills = storage.load_skills(enabled_only=False) - skill = next((s for s in skills if s.name == skill_name), None) - if skill is None: - raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") - return _skill_to_response(skill) - except HTTPException: - raise - except Exception as e: - logger.error("Failed to pin skill %s: %s", skill_name, e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to pin skill: {str(e)}") - - -@router.get("/curator/status", summary="Curator Status") -async def curator_status(request: Request) -> dict: - """Return the current curator state and per-lifecycle skill counts for the - authenticated user.""" - try: - from deerflow.skills.curator import get_curator_status - return get_curator_status(_current_user_id(request)) - except Exception as e: - logger.error("Failed to get curator status: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to get curator status: {str(e)}") - - -@router.post("/curator/run", summary="Trigger Curator Run") -async def run_curator(request: Request) -> dict: - """Manually trigger a curator review pass for the authenticated user - (bypasses the interval check, runs in a background thread). - - Pre-checks the master switch / curator switch / run lock so the response - accurately reflects whether a run actually started — instead of always - reporting success. Returns ``{"triggered": bool, "reason": str | None}``. - """ - try: - import threading - - from deerflow.skills.curator import _curator_thread_target, manual_trigger_blocked_reason - - user_id = _current_user_id(request) - reason = manual_trigger_blocked_reason(user_id) - if reason is not None: - return {"triggered": False, "reason": reason} - thread = threading.Thread( - target=_curator_thread_target, - args=(user_id,), - daemon=True, - name=f"skill-curator-manual-{user_id}", - ) - thread.start() - return {"triggered": True, "reason": None} - except Exception as e: - logger.error("Failed to trigger curator: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to trigger curator: {str(e)}") - - -class CuratorSubConfig(BaseModel): - """Nested curator configuration mirroring CuratorConfig.""" - - # 与 CuratorConfig.enabled 保持一致:默认关闭,需显式 opt-in。 - enabled: bool = Field(default=False, description="Whether the curator runs automatically.") - interval_hours: int = Field(default=168, ge=1, description="Hours between curator runs.") - stale_after_days: int = Field(default=30, ge=1, description="Days of inactivity before a skill is marked stale.") - archive_after_days: int = Field(default=90, ge=1, description="Days of inactivity before a stale skill is archived.") - review_timeout_seconds: int = Field(default=600, ge=1, description="Overall timeout (seconds) for the LLM review pass.") - model_name: str | None = Field(default=None, description="Model name for the curator LLM review pass; null = primary model.") - - -class CuratorConfigResponse(BaseModel): - """Response model exposing the effective skill_evolution config.""" - - enabled: bool - curator: CuratorSubConfig - - -class CuratorConfigRequest(BaseModel): - """Payload for updating skill_evolution overrides. - - All fields are required so the frontend always sends a complete config; - backend writes the entire payload to skill_evolution.json atomically. - """ - - enabled: bool = Field(..., description="Whether the agent can create and modify skills under skills/custom.") - curator: CuratorSubConfig - - -class CuratorLogRecord(BaseModel): - ts: str - level: str - message: str - - -class CuratorLogsResponse(BaseModel): - records: list[CuratorLogRecord] - run_count: int - last_run_at: str | None - - -def _build_curator_config_response(evo) -> CuratorConfigResponse: - """Pure helper so both the GET route and the post-PUT refresh share one shape. - - ``evo`` is a ``SkillEvolutionConfig`` instance (already merged with per-user - overrides if any). - """ - return CuratorConfigResponse( - enabled=evo.enabled, - curator=CuratorSubConfig( - enabled=evo.curator.enabled, - interval_hours=evo.curator.interval_hours, - stale_after_days=evo.curator.stale_after_days, - archive_after_days=evo.curator.archive_after_days, - review_timeout_seconds=evo.curator.review_timeout_seconds, - model_name=evo.curator.model_name, - ), - ) - - -@router.get("/curator/config", response_model=CuratorConfigResponse, summary="Get Skill Evolution Config") -async def get_curator_config(request: Request) -> CuratorConfigResponse: - """Return the current curator config for the authenticated user (yaml - defaults + that user's overrides).""" - try: - from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user - - evo = load_skill_evolution_config_for_user(_current_user_id(request)) - return _build_curator_config_response(evo) - except Exception as e: - logger.error("Failed to read curator config: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to read curator config: {str(e)}") - - -@router.put("/curator/config", response_model=CuratorConfigResponse, summary="Update Skill Evolution Config") -async def update_curator_config(body: CuratorConfigRequest, request: Request) -> CuratorConfigResponse: - """Persist per-user overrides to ``users/{user_id}/skill_evolution.json`` - and return the effective merged config. - - The payload is validated against the ``SkillEvolutionConfig`` shape; unknown - fields are rejected. - """ - try: - from deerflow.config.skill_evolution_config import SkillEvolutionConfig - from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user, save_user_overrides - - user_id = _current_user_id(request) - payload = body.model_dump() - # Round-trip through SkillEvolutionConfig to ensure persisted shape stays valid. - SkillEvolutionConfig.model_validate(payload) - save_user_overrides(user_id, payload) - return _build_curator_config_response(load_skill_evolution_config_for_user(user_id)) - except HTTPException: - raise - except Exception as e: - logger.error("Failed to update curator config: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to update curator config: {str(e)}") - - -@router.get("/curator/logs", response_model=CuratorLogsResponse, summary="Recent Curator Logs") -async def get_curator_logs(request: Request) -> CuratorLogsResponse: - """Return in-memory log records from the most recent curator run for the - authenticated user. - - The buffer is process-local and cleared when curator starts a new run or - when the backend restarts. Records are ordered oldest → newest. - """ - try: - from deerflow.skills import curator_log - from deerflow.skills.curator import get_curator_status - - user_id = _current_user_id(request) - records = curator_log.get_recent(user_id) - status = get_curator_status(user_id) - return CuratorLogsResponse( - records=[CuratorLogRecord(**r) for r in records], - run_count=int(status.get("run_count", 0)), - last_run_at=status.get("last_run_at"), - ) - except Exception as e: - logger.error("Failed to read curator logs: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail=f"Failed to read curator logs: {str(e)}") +import asyncio +import json +import logging +import mimetypes +import re +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, Request, UploadFile +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field +from sqlalchemy import select + +from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store +from app.gateway.path_utils import resolve_thread_virtual_path +from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async +from deerflow.config.app_config import AppConfig +from deerflow.config.extensions_config import ExtensionsConfig, SkillDisplayConfig, SkillStateConfig, get_extensions_config, reload_extensions_config +from deerflow.persistence.engine import get_engine, get_session_factory +from deerflow.persistence.skills import SkillStore +from deerflow.persistence.skills.model import SkillDisplayAdapterRow +from deerflow.persistence.tags import TagStore +from deerflow.runtime.user_context import get_effective_user_id +from deerflow.skills import Skill +from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted +from deerflow.skills.installer import SkillAlreadyExistsError +from deerflow.skills.security_scanner import scan_skill_content +from deerflow.skills.storage import get_or_new_skill_storage +from deerflow.skills.types import SKILL_MD_FILE, SkillCategory + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["skills"]) + + +# --------------------------------------------------------------------------- +# Per-user authz helpers (mirrors routers/agents.py) +# --------------------------------------------------------------------------- + + +def _current_user_id(request: Request) -> str: + user = getattr(request.state, "user", None) + if user is not None: + return str(user.id) + return get_effective_user_id() + + +def _is_admin_user(request: Request) -> bool: + user = getattr(request.state, "user", None) + return getattr(user, "system_role", None) == "admin" + + +def _can_view_skill_source( + category: SkillCategory, + owner_user_id: str | None, + user_id: str, + *, + is_admin: bool, +) -> bool: + """Whether the raw SKILL.md source is visible to a user. + + Admins may view any skill's source; otherwise only the publisher (the + owner of a custom skill) qualifies. Built-in / public skills have no human + owner, so for non-admins they are never source-visible. + """ + if is_admin: + return True + return category == SkillCategory.CUSTOM and owner_user_id is not None and owner_user_id == user_id + + +async def _get_owned_skill_or_raise(store: SkillStore, name: str, user_id: str) -> dict[str, Any]: + owned = await store.get_owned(name, user_id) + if owned is not None: + return owned + visible = await store.get_visible(name, user_id) + if visible is None: + raise HTTPException(status_code=404, detail=f"Skill '{name}' not found") + raise HTTPException(status_code=403, detail="You can only modify skills that you own") + + +async def _get_editable_skill_or_raise( + store: SkillStore, + name: str, + user_id: str, + *, + is_admin: bool, +) -> tuple[dict[str, Any], str]: + """Return (record, edit_mode) where edit_mode is 'owner' or 'admin'. + + Admins can edit/delete any skill (including others' published ones, since + the spec lets them force-takedown). Non-admins are limited to their own. + """ + owned = await store.get_owned(name, user_id) + if owned is not None: + return owned, "owner" + record = await store.get_any(name) + if record is None: + raise HTTPException(status_code=404, detail=f"Skill '{name}' not found") + if is_admin: + return record, "admin" + raise HTTPException(status_code=403, detail="You can only modify skills that you own") + + +class SkillResponse(BaseModel): + """Response model for skill information.""" + + name: str = Field(..., description="Name of the skill") + description: str = Field(..., description="Description of what the skill does") + license: str | None = Field(None, description="License information") + category: SkillCategory = Field(..., description="Category of the skill (public or custom)") + enabled: bool = Field(default=True, description="Whether this skill is enabled") + use_count: int = Field(default=0, description="Number of times the skill has been used") + view_count: int = Field(default=0, description="Number of times the skill has been viewed") + patch_count: int = Field(default=0, description="Number of times the skill has been patched") + last_activity_at: datetime | None = Field(default=None, description="Most recent activity timestamp") + state: str = Field(default="active", description="Lifecycle state: active, stale, or archived") + pinned: bool = Field(default=False, description="Whether this skill is pinned (excluded from curation)") + source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded") + owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)") + published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users") + builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)") + detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin") + display: dict[str, Any] | None = Field(default=None, description="Optional frontend display adapter") + display_sample: dict[str, Any] | None = Field(default=None, alias="displaySample", description="Persisted sample output used by the display adapter editor") + tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill") + + +class SkillsListResponse(BaseModel): + """Response model for listing all skills.""" + + skills: list[SkillResponse] + + +class SkillUpdateRequest(BaseModel): + """Request model for updating a skill.""" + + enabled: bool = Field(..., description="Whether to enable or disable the skill") + + +class SkillDisplayUpdateRequest(BaseModel): + """Request model for updating a skill's frontend display adapter.""" + + display: SkillDisplayConfig | None = Field(default=None, description="Display adapter configuration; null clears it") + sample: dict[str, Any] | None = Field(default=None, description="Optional sample payload to persist for future editor sessions") + + +class SkillDisplaySampleResponse(BaseModel): + """Sample structured output used to infer a display adapter.""" + + skill_name: str + sample: dict[str, Any] + + +class PersistedSkillDisplaySampleResponse(BaseModel): + """Persisted sample output loaded from the database only.""" + + skill_name: str + sample: dict[str, Any] | None = None + + +class SkillInstallRequest(BaseModel): + """Request model for installing a skill from a .skill file.""" + + thread_id: str = Field(..., description="The thread ID where the .skill file is located") + path: str = Field(..., description="Virtual path to the .skill file (e.g., mnt/user-data/outputs/my-skill.skill)") + target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'") + + +class SkillInstallResponse(BaseModel): + """Response model for skill installation.""" + + success: bool = Field(..., description="Whether the installation was successful") + skill_name: str = Field(..., description="Name of the installed skill") + message: str = Field(..., description="Installation result message") + overwritten: bool = Field(default=False, description="True if an existing skill was replaced") + category: str = Field(default="custom", description="Destination category: 'public' (admin) or 'custom'") + + +class SkillUploadValidateResponse(BaseModel): + """Response model for the pre-install validation endpoint.""" + + ok: bool = Field(..., description="Whether the archive passed cheap validation") + skill_name: str = Field(..., description="Skill name parsed from frontmatter") + description: str = Field(default="", description="Skill description parsed from frontmatter") + license: str | None = Field(default=None, description="Skill license, if declared") + allowed_tools: list[str] = Field(default_factory=list, description="Tools the skill may invoke") + conflict: bool = Field(default=False, description="True if a skill with the same name already exists") + conflict_kind: str | None = Field(default=None, description="'public' or 'custom' when conflict=true") + warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings to surface to the user") + + +class SkillReconcileResponse(BaseModel): + """Response model for reconciling custom skills found on disk.""" + + adopted: list[str] = Field(default_factory=list, description="Skills found on disk that had no DB row; now owned by the caller") + already_registered: list[str] = Field(default_factory=list, description="Skills found on disk that already had a DB row (unchanged)") + + +class CustomSkillContentResponse(SkillResponse): + content: str = Field(..., description="Raw SKILL.md content") + + +class CustomSkillUpdateRequest(BaseModel): + content: str = Field(..., description="Replacement SKILL.md content") + + +class CustomSkillHistoryResponse(BaseModel): + history: list[dict] + + +class SkillRollbackRequest(BaseModel): + history_index: int = Field(default=-1, description="History entry index to restore from, defaulting to the latest change.") + + +def _skill_to_response( + skill: Skill, + ownership: dict[str, Any] | None = None, + *, + display_override: dict[str, Any] | None = None, + display_sample: dict[str, Any] | None = None, +) -> SkillResponse: + """Convert a Skill object to a SkillResponse. + + ``ownership`` is the skill's record from ``SkillStore`` (or None for public + built-in skills which never have a DB row). When present, contributes the + ``owner_user_id`` / ``published`` / ``builtin`` fields used for per-user + visibility and the frontend's owner badges. + """ + from deerflow.skills.usage import get_entry, latest_activity_at + + entry = get_entry(skill.name) + last_at_str = latest_activity_at(skill.name) + last_at: datetime | None = None + if last_at_str: + try: + last_at = datetime.fromisoformat(last_at_str) + except Exception: + pass + + owner_user_id: str | None = None + published = False + builtin = skill.category != SkillCategory.CUSTOM + detail: str | None = None + if ownership is not None: + owner_user_id = ownership.get("owner_user_id") + published = bool(ownership.get("published", False)) + builtin = bool(ownership.get("builtin", owner_user_id is None)) + detail = ownership.get("detail") + + display = display_override + if display is None: + try: + skill_config = get_extensions_config().skills.get(skill.name) + if skill_config and skill_config.display: + display = skill_config.display.model_dump(by_alias=True) + except Exception: + logger.debug("Failed to read display adapter for %s", skill.name, exc_info=True) + + return SkillResponse( + name=skill.name, + description=skill.description, + license=skill.license, + category=skill.category, + enabled=skill.enabled, + use_count=entry.get("use_count", 0), + view_count=entry.get("view_count", 0), + patch_count=entry.get("patch_count", 0), + last_activity_at=last_at, + state=entry.get("state", "active"), + pinned=entry.get("pinned", False), + source=entry.get("source", "upload"), + owner_user_id=owner_user_id, + published=published, + builtin=builtin, + detail=detail, + display=display, + displaySample=display_sample, + ) + + +def _dump_extensions_config(config_obj: ExtensionsConfig) -> dict[str, Any]: + return { + "mcpServers": {name: server.model_dump(by_alias=True) for name, server in config_obj.mcp_servers.items()}, + "skills": {name: skill_config.model_dump(by_alias=True, exclude_none=True) for name, skill_config in config_obj.skills.items()}, + } + + +async def _ensure_skill_display_table() -> bool: + """Create the display adapter table when DB persistence is enabled.""" + engine = get_engine() + sf = get_session_factory() + if engine is None or sf is None: + return False + async with engine.begin() as conn: + await conn.run_sync(SkillDisplayAdapterRow.__table__.create, checkfirst=True) + return True + + +def _json_text_loads(value: str | None) -> dict[str, Any] | None: + if not value: + return None + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else None + except Exception: + logger.warning("Failed to parse persisted skill display JSON", exc_info=True) + return None + + +def _json_text_dumps(value: dict[str, Any] | None) -> str | None: + if value is None: + return None + return json.dumps(value, ensure_ascii=False) + + +async def _get_skill_display_adapter(skill_name: str) -> dict[str, Any] | None: + if not await _ensure_skill_display_table(): + return None + sf = get_session_factory() + if sf is None: + return None + async with sf() as session: + row = await session.get(SkillDisplayAdapterRow, skill_name) + if row is None: + return None + return { + "display": _json_text_loads(row.display_config), + "sample": _json_text_loads(row.sample_payload), + } + + +async def _list_skill_display_adapters(skill_names: list[str]) -> dict[str, dict[str, Any]]: + if not skill_names or not await _ensure_skill_display_table(): + return {} + sf = get_session_factory() + if sf is None: + return {} + async with sf() as session: + result = await session.execute( + select(SkillDisplayAdapterRow).where(SkillDisplayAdapterRow.skill_name.in_(skill_names)) + ) + return { + row.skill_name: { + "display": _json_text_loads(row.display_config), + "sample": _json_text_loads(row.sample_payload), + } + for row in result.scalars() + } + + +async def _upsert_skill_display_adapter( + skill_name: str, + *, + display: dict[str, Any] | None | object = ..., + sample: dict[str, Any] | None | object = ..., +) -> None: + if not await _ensure_skill_display_table(): + return + sf = get_session_factory() + if sf is None: + return + async with sf() as session: + row = await session.get(SkillDisplayAdapterRow, skill_name) + if row is None: + row = SkillDisplayAdapterRow(skill_name=skill_name) + session.add(row) + if display is not ...: + row.display_config = _json_text_dumps(display) # type: ignore[arg-type] + if sample is not ...: + row.sample_payload = _json_text_dumps(sample) # type: ignore[arg-type] + row.updated_at = datetime.now() + await session.commit() + + +_SKILL_SAMPLE_SYSTEM_PROMPT = ( + "You infer realistic JSON return shapes for AI agent skills. " + "Given a skill's name, description, and full SKILL.md, output a SINGLE JSON object that " + "represents a plausible successful response from this skill's primary action. " + "Rules:\n" + "- Output JSON ONLY. No prose. No markdown fences. No comments.\n" + "- The top level must be a JSON object (not an array).\n" + "- Include a results/items/list/records array (pick the most natural key) with 2-3 example entries.\n" + "- Use realistic, idiomatic field names (e.g. title, content, source, publish_time, score, url, id) that match the skill's domain.\n" + "- Each entry should have enough fields to drive a citation/list/table UI: a title, a short snippet/content, a source, a time/date, and an id or url when relevant.\n" + "- Numbers stay numbers, dates stay ISO strings, scores between 0 and 1 when applicable.\n" + "- Do not invent fields that contradict the skill description." +) + + +def _extract_json_from_llm_output(raw: str) -> dict[str, Any] | None: + """Best-effort extraction of a JSON object from an LLM response.""" + text = (raw or "").strip() + if not text: + return None + fence = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.DOTALL | re.IGNORECASE) + if fence: + text = fence.group(1).strip() + try: + parsed = json.loads(text) + return parsed if isinstance(parsed, dict) else None + except json.JSONDecodeError: + pass + match = re.search(r"\{.*\}", text, re.DOTALL) + if not match: + return None + try: + parsed = json.loads(match.group(0)) + return parsed if isinstance(parsed, dict) else None + except json.JSONDecodeError: + return None + + +async def _generate_skill_display_sample(skill: Skill, app_config: AppConfig) -> dict[str, Any]: + """Generate a realistic return-shape sample for ``skill`` via the LLM. + + Reads SKILL.md, asks the configured curator model (or main model) to infer + a plausible JSON response. Falls back to :func:`_mock_skill_display_sample` + when the model is unconfigured, the call times out, or the output cannot + be parsed as a JSON object. + """ + try: + skill_content = skill.skill_file.read_text(encoding="utf-8") + except OSError as exc: + logger.debug("Cannot read SKILL.md for %s (%s); falling back to description only", skill.name, exc) + skill_content = "" + # Cap the SKILL.md body so very large workflow docs don't blow the context. + max_chars = 6000 + if len(skill_content) > max_chars: + skill_content = skill_content[:max_chars] + "\n…(truncated)" + + user_prompt = ( + f"Skill name: {skill.name}\n" + f"Short description: {skill.description}\n\n" + f"SKILL.md:\n-----\n{skill_content or '(SKILL.md unavailable; rely on the short description above.)'}\n-----\n\n" + "Return one JSON object that matches what this skill would actually produce on success." + ) + + try: + from deerflow.models import create_chat_model + + # Reuse the curator/security-scan model setting; falls back to the main model when unset. + model_name = app_config.skill_evolution.curator.model_name if app_config.skill_evolution and app_config.skill_evolution.curator else None + model = create_chat_model(name=model_name, thinking_enabled=False, app_config=app_config) + response = await asyncio.wait_for( + model.ainvoke( + [ + {"role": "system", "content": _SKILL_SAMPLE_SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + config={"run_name": "skill_display_sample"}, + ), + timeout=45, + ) + parsed = _extract_json_from_llm_output(str(getattr(response, "content", "") or "")) + if parsed: + return parsed + logger.warning("Skill display sample model returned non-JSON for %s; falling back to static mock", skill.name) + except asyncio.TimeoutError: + logger.warning("Skill display sample LLM call timed out for %s; falling back to static mock", skill.name) + except Exception: + logger.warning("Skill display sample LLM call failed for %s; falling back to static mock", skill.name, exc_info=True) + return _mock_skill_display_sample(skill.name) + + +def _mock_skill_display_sample(skill_name: str) -> dict[str, Any]: + if skill_name == "knowledge-search-v2": + return { + "success": True, + "query": "参考文献模式测试 知识库固定测试", + "count": 4, + "source": "fixed-display-sample", + "results": [ + { + "index": 1, + "recUuid": "ksv2-fixed-001", + "relevance_score": 0.982, + "title": "参考文献模式测试:参考文献模式固定测试数据一", + "source_category": "知识库/测试资料", + "source": "knowledge-search-v2/fixed/source-001", + "publish_time": "2026-05-25", + "content_preview": "这是 knowledge-search-v2 返回的第一条固定测试资料,用于验证右侧参考文献列表、正文编号以及 recUuid 模板跳转是否正常。", + "content_length": 168, + }, + { + "index": 2, + "recUuid": "ksv2-fixed-002", + "relevance_score": 0.936, + "title": "参考文献模式测试:结构化字段映射验证", + "source_category": "知识库/字段映射", + "source": "knowledge-search-v2/fixed/source-002", + "publish_time": "2026-05-24", + "content_preview": "本条用于验证 title、source_category、source、publish_time、content_preview、relevance_score、recUuid 等字段能被前端正确识别并渲染。", + "content_length": 154, + }, + { + "index": 3, + "recUuid": "ksv2-fixed-003", + "relevance_score": 0.887, + "title": "参考文献模式测试:多引用排序验证", + "source_category": "知识库/排序验证", + "source": "knowledge-search-v2/fixed/source-003", + "publish_time": "2026-05-23", + "content_preview": "本条用于验证多条参考文献的编号顺序是否与 results 数组顺序一致,并验证点击正文编号时能定位到右侧对应参考文献。", + "content_length": 132, + }, + { + "index": 4, + "recUuid": "", + "relevance_score": 0.801, + "title": "参考文献模式测试:无 recUuid 跳转隐藏验证", + "source_category": "知识库/边界场景", + "source": "knowledge-search-v2/fixed/source-004", + "publish_time": "2026-05-22", + "content_preview": "本条故意把 recUuid 置为空,用于验证右侧弹框仍展示详情,但不展示外部跳转按钮。", + "content_length": 98, + }, + ], + } + return { + "success": True, + "query": "sample query", + "results": [ + { + "id": "sample-001", + "title": "Sample result", + "summary": "Sample structured output used to infer field mapping.", + "source": "sample", + "updatedAt": "2026-05-25", + } + ], + } + + +async def _build_ownership_index(skill_store: SkillStore, user_id: str) -> dict[str, dict[str, Any]]: + """Index visible-to-user skill records by name. Internal call cache for list endpoints.""" + records = await skill_store.list_visible(user_id) + return {record["name"]: record for record in records} + + +async def _attach_and_filter_tags( + tag_store: TagStore, + responses: list[SkillResponse], + search: str | None, + tag_ids: list[str] | None, +) -> list[SkillResponse]: + """Bulk-attach tags to skill responses, then apply name/tag filters.""" + assignments = await tag_store.list_assignments("skill", [r.name for r in responses]) + for resp in responses: + resp.tags = assignments.get(resp.name, []) + if search: + needle = search.strip().lower() + responses = [r for r in responses if needle in r.name.lower()] + if tag_ids: + wanted = set(tag_ids) + responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)] + return responses + + +@router.get( + "/skills", + response_model=SkillsListResponse, + summary="List All Skills", + description="Retrieve a list of all available skills from both public and custom directories.", +) +async def list_skills( + request: Request, + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), + tag_store: TagStore = Depends(get_tag_store), + search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name"), + tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"), +) -> SkillsListResponse: + try: + user_id = _current_user_id(request) + all_skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) + ownership = await _build_ownership_index(skill_store, user_id) + display_adapters = await _list_skill_display_adapters([skill.name for skill in all_skills]) + responses: list[SkillResponse] = [] + for skill in all_skills: + adapter = display_adapters.get(skill.name, {}) + # Public/built-in skills (no DB row) are visible to everyone. + # Custom skills must have an ownership record in the visible set. + if skill.category != SkillCategory.CUSTOM: + # Built-in skills usually have no DB row, but an admin-authored + # ``detail`` annotation materializes one — surface it when present. + responses.append( + _skill_to_response( + skill, + ownership.get(skill.name), + display_override=adapter.get("display"), + display_sample=adapter.get("sample"), + ) + ) + continue + record = ownership.get(skill.name) + if record is None: + continue + responses.append( + _skill_to_response( + skill, + record, + display_override=adapter.get("display"), + display_sample=adapter.get("sample"), + ) + ) + responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids) + return SkillsListResponse(skills=responses) + except Exception as e: + logger.error(f"Failed to load skills: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}") + + +_MAX_SKILL_UPLOAD_BYTES = 512 * 1024 * 1024 +_ZIP_MAGIC = b"PK\x03\x04" + + +async def _persist_uploaded_skill(file: UploadFile) -> Path: + """Stream an uploaded skill archive to a temp file. Caller must unlink it. + + Validates the leading bytes look like a ZIP archive and enforces the same + 512 MB cap that the extractor uses, so we reject obvious garbage early. + """ + if file.filename is None: + raise HTTPException(status_code=400, detail="No file provided") + suffix = Path(file.filename).suffix.lower() + if suffix not in {".zip", ".skill"}: + raise HTTPException(status_code=400, detail="Skill upload must be a .zip or .skill file") + + fd, tmp_name = tempfile.mkstemp(suffix=".skill", prefix="skill-upload-") + tmp_path = Path(tmp_name) + total = 0 + head: bytes = b"" + try: + with open(fd, "wb") as out: + while True: + chunk = await file.read(1024 * 1024) + if not chunk: + break + if not head: + head = chunk[: len(_ZIP_MAGIC)] + total += len(chunk) + if total > _MAX_SKILL_UPLOAD_BYTES: + raise HTTPException(status_code=400, detail="Uploaded skill archive exceeds the 512 MB limit") + out.write(chunk) + except HTTPException: + tmp_path.unlink(missing_ok=True) + raise + except Exception: + tmp_path.unlink(missing_ok=True) + raise + + if not head.startswith(_ZIP_MAGIC): + tmp_path.unlink(missing_ok=True) + raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive") + return tmp_path + + +@router.post( + "/skills/validate", + response_model=SkillUploadValidateResponse, + summary="Validate Skill Archive (No Install)", + description="Inspect an uploaded .zip/.skill archive and report parsed metadata + conflicts. Does NOT install or run the LLM security scan.", +) +async def validate_skill_upload( + file: UploadFile = File(..., description="The .zip or .skill archive to validate"), + config: AppConfig = Depends(get_config), +) -> SkillUploadValidateResponse: + tmp_path = await _persist_uploaded_skill(file) + try: + result = get_or_new_skill_storage(app_config=config).validate_skill_archive(tmp_path) + return SkillUploadValidateResponse(**result) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to validate uploaded skill: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to validate skill: {e}") + finally: + tmp_path.unlink(missing_ok=True) + + +@router.post( + "/skills/install-upload", + response_model=SkillInstallResponse, + summary="Install Skill from Upload", + description="Install a skill directly from an uploaded .zip/.skill archive. Set ?overwrite=true to replace an existing skill with the same name.", +) +async def install_skill_upload( + request: Request, + file: UploadFile = File(..., description="The .zip or .skill archive to install"), + overwrite: bool = False, + target_category: SkillCategory = SkillCategory.CUSTOM, + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), +) -> SkillInstallResponse: + user_id = _current_user_id(request) + is_admin = _is_admin_user(request) + # Only admins may install to the public (built-in) directory. + if target_category == SkillCategory.PUBLIC and not is_admin: + raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory") + tmp_path = await _persist_uploaded_skill(file) + try: + storage = get_or_new_skill_storage(app_config=config) + # Pre-parse the archive to learn the skill name without writing anything, + # so we can reject the upload before touching the filesystem if another + # user already owns this name. Admins bypass the ownership check since + # they install to the shared public directory. + try: + validation = storage.validate_skill_archive(tmp_path) + preview_name = validation.get("skill_name") if isinstance(validation, dict) else None + except Exception: + preview_name = None + if preview_name and not is_admin: + existing = await skill_store.get_any(preview_name) + if existing and existing.get("owner_user_id") not in (None, user_id): + raise HTTPException( + status_code=409, + detail=f"Skill '{preview_name}' already exists and is owned by another user", + ) + result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False, target_category=target_category) + try: + from deerflow.skills.usage import mark_uploaded + mark_uploaded(result["skill_name"]) + except Exception: + pass + # For public installs (admin), register as built-in with no owner. + # For custom installs, register ownership under the uploading user. + if target_category == SkillCategory.PUBLIC: + await skill_store.ensure_legacy( + {"name": result["skill_name"], "owner_user_id": None, "published": True} + ) + else: + # ``ensure_legacy`` is idempotent and only patches a NULL owner; it + # never overwrites an existing owner, so the pre-parse check above + # is what really guards cross-user steals. + await skill_store.ensure_legacy( + {"name": result["skill_name"], "owner_user_id": user_id, "published": False} + ) + await refresh_skills_system_prompt_cache_async() + return SkillInstallResponse(**result) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except SkillAlreadyExistsError as e: + raise HTTPException(status_code=409, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to install uploaded skill: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to install skill: {e}") + finally: + tmp_path.unlink(missing_ok=True) + + +@router.post( + "/skills/install", + response_model=SkillInstallResponse, + summary="Install Skill", + description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.", +) +async def install_skill( + body: SkillInstallRequest, + request: Request, + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), +) -> SkillInstallResponse: + try: + user_id = _current_user_id(request) + is_admin = _is_admin_user(request) + target_category = body.target_category + if target_category == SkillCategory.PUBLIC and not is_admin: + raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory") + skill_file_path = resolve_thread_virtual_path(body.thread_id, body.path) + result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path, target_category=target_category) + try: + from deerflow.skills.usage import mark_uploaded + mark_uploaded(result["skill_name"]) + except Exception: + pass + if target_category == SkillCategory.PUBLIC: + await skill_store.ensure_legacy( + {"name": result["skill_name"], "owner_user_id": None, "published": True} + ) + else: + await skill_store.ensure_legacy( + {"name": result["skill_name"], "owner_user_id": user_id, "published": False} + ) + await refresh_skills_system_prompt_cache_async() + return SkillInstallResponse(**result) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except SkillAlreadyExistsError as e: + raise HTTPException(status_code=409, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to install skill: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}") + + +@router.post( + "/skills/reconcile-custom", + response_model=SkillReconcileResponse, + summary="Adopt Orphan Custom Skills", + description=( + "Scan the on-disk ``skills/custom/`` directory and register any skill that has a " + "valid SKILL.md but no DB ownership row. Adopted skills are recorded as owned by " + "the calling user with ``published=false``. Skips skills that already have a row " + "(regardless of owner). Useful after manually unzipping skills onto the filesystem." + ), +) +async def reconcile_custom_skills( + request: Request, + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), +) -> SkillReconcileResponse: + try: + user_id = _current_user_id(request) + storage = get_or_new_skill_storage(app_config=config) + custom_skills = [s for s in storage.load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM] + + adopted: list[str] = [] + already: list[str] = [] + for skill in custom_skills: + existing = await skill_store.get_any(skill.name) + if existing is not None: + already.append(skill.name) + continue + await skill_store.ensure_legacy( + {"name": skill.name, "owner_user_id": user_id, "published": False} + ) + try: + from deerflow.skills.usage import mark_uploaded + mark_uploaded(skill.name) + except Exception: + pass + adopted.append(skill.name) + + if adopted: + await refresh_skills_system_prompt_cache_async() + return SkillReconcileResponse(adopted=adopted, already_registered=already) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to reconcile custom skills: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to reconcile custom skills: {e}") + + +@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills") +async def list_custom_skills( + request: Request, + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), +) -> SkillsListResponse: + try: + user_id = _current_user_id(request) + custom = [s for s in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM] + ownership = await _build_ownership_index(skill_store, user_id) + display_adapters = await _list_skill_display_adapters([skill.name for skill in custom]) + responses = [ + _skill_to_response( + skill, + ownership[skill.name], + display_override=display_adapters.get(skill.name, {}).get("display"), + display_sample=display_adapters.get(skill.name, {}).get("sample"), + ) + for skill in custom + if skill.name in ownership + ] + return SkillsListResponse(skills=responses) + except Exception as e: + logger.error("Failed to list custom skills: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to list custom skills: {str(e)}") + + +@router.get("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Get Custom Skill Content") +async def get_custom_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse: + try: + skill_name = skill_name.replace("\r\n", "").replace("\n", "") + skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) + skill = next((s for s in skills if s.name == skill_name and s.category == SkillCategory.CUSTOM), None) + if skill is None: + raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found") + return CustomSkillContentResponse(**_skill_to_response(skill).model_dump(), content=get_or_new_skill_storage(app_config=config).read_custom_skill(skill_name)) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to get custom skill %s: %s", skill_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to get custom skill: {str(e)}") + + +@router.put("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Edit Custom Skill") +async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse: + try: + skill_name = skill_name.replace("\r\n", "").replace("\n", "") + storage = get_or_new_skill_storage(app_config=config) + storage.ensure_custom_skill_is_editable(skill_name) + storage.validate_skill_markdown_content(skill_name, request.content) + scan = await scan_skill_content(request.content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config) + if scan.decision == "block": + raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}") + prev_content = storage.read_custom_skill(skill_name) + storage.write_custom_skill(skill_name, SKILL_MD_FILE, request.content) + storage.append_history( + skill_name, + { + "action": "human_edit", + "author": "human", + "thread_id": None, + "file_path": SKILL_MD_FILE, + "prev_content": prev_content, + "new_content": request.content, + "scanner": {"decision": scan.decision, "reason": scan.reason}, + }, + ) + try: + from deerflow.skills.usage import bump_patch + bump_patch(skill_name) + except Exception: + pass + await refresh_skills_system_prompt_cache_async() + return await get_custom_skill(skill_name, config) + except HTTPException: + raise + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error("Failed to update custom skill %s: %s", skill_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to update custom skill: {str(e)}") + + +@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill") +async def delete_custom_skill( + skill_name: str, + request: Request, + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), + agent_store=Depends(get_agent_store), +) -> dict[str, bool]: + try: + skill_name = skill_name.replace("\r\n", "").replace("\n", "") + user_id = _current_user_id(request) + is_admin = _is_admin_user(request) + record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin) + operation = "admin_takedown" if mode == "admin" and record.get("owner_user_id") not in (None, user_id) else "delete" + + storage = get_or_new_skill_storage(app_config=config) + storage.delete_custom_skill( + skill_name, + history_meta={ + "action": "human_delete", + "author": "human", + "thread_id": None, + "file_path": SKILL_MD_FILE, + "prev_content": None, + "new_content": None, + "scanner": {"decision": "allow", "reason": "Deletion requested."}, + }, + ) + try: + from deerflow.skills.usage import forget + forget(skill_name) + except Exception: + pass + + # Persist ownership record removal so the DB stays consistent. + if mode == "admin": + await skill_store.delete_admin(skill_name) + else: + await skill_store.delete(skill_name, user_id) + + try: + await get_tag_store(request).unassign_all("skill", skill_name) + except Exception: # noqa: BLE001 — tag cleanup must not block deletion + pass + + # Strip references from every agent that pulled this skill in, + # and notify their owners (no-op for self). + await cascade_skill_unpublished_or_deleted( + skill_name, + actor_user_id=user_id, + operation=operation, + skill_owner_user_id=record.get("owner_user_id"), + agent_store=agent_store, + ) + + await refresh_skills_system_prompt_cache_async() + return {"success": True} + except HTTPException: + raise + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error("Failed to delete custom skill %s: %s", skill_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to delete custom skill: {str(e)}") + + +class SkillPublishRequest(BaseModel): + """Request body for toggling a custom skill's published flag.""" + + published: bool = Field(..., description="Whether to publish (True) or unpublish (False)") + + +class SkillPublishResponse(BaseModel): + name: str + owner_user_id: str | None + published: bool + + +@router.put( + "/skills/custom/{skill_name}/published", + response_model=SkillPublishResponse, + summary="Toggle Custom Skill Published State", +) +async def update_custom_skill_published( + skill_name: str, + body: SkillPublishRequest, + request: Request, + skill_store: SkillStore = Depends(get_skill_store), + agent_store=Depends(get_agent_store), +) -> SkillPublishResponse: + try: + skill_name = skill_name.replace("\r\n", "").replace("\n", "") + user_id = _current_user_id(request) + is_admin = _is_admin_user(request) + record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin) + + was_published = bool(record.get("published", False)) + if mode == "admin": + updated = await skill_store.update_admin(skill_name, {"published": body.published}) + else: + updated = await skill_store.update(skill_name, user_id, {"published": body.published}) + if updated is None: + raise HTTPException(status_code=500, detail="Failed to persist published state") + + # If we are *removing* visibility (was published, now not), strip from + # other users' agents and notify them. admin takedown is signaled by + # the actor not being the owner. + if was_published and not body.published: + skill_owner = record.get("owner_user_id") + operation = "admin_takedown" if mode == "admin" and skill_owner not in (None, user_id) else "unpublish" + await cascade_skill_unpublished_or_deleted( + skill_name, + actor_user_id=user_id, + operation=operation, + skill_owner_user_id=skill_owner, + agent_store=agent_store, + ) + await refresh_skills_system_prompt_cache_async() + + return SkillPublishResponse( + name=updated["name"], + owner_user_id=updated.get("owner_user_id"), + published=bool(updated.get("published", False)), + ) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to update published state for %s: %s", skill_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to update published state: {str(e)}") + + +_ALLOWED_USER_STATES = {"active", "archived"} + + +class SkillStateRequest(BaseModel): + """Request body for switching a custom skill's lifecycle state.""" + + state: str = Field(..., description="New state: 'active' (restore) or 'archived' (soft archive)") + + +class SkillPinRequest(BaseModel): + """Request body for pinning / unpinning a custom skill.""" + + pinned: bool = Field(..., description="Whether to pin (true) so the curator never auto-archives this skill") + + +def _reload_skill_response(skill_name: str, ownership: dict[str, Any], config: AppConfig) -> SkillResponse: + """Reload a single skill from disk and return its API response form.""" + skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) + skill = next((s for s in skills if s.name == skill_name), None) + if skill is None: + raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found on disk") + return _skill_to_response(skill, ownership) + + +@router.put( + "/skills/custom/{skill_name}/state", + response_model=SkillResponse, + summary="Update Custom Skill Lifecycle State", + description="Restore (active) or soft-archive a custom skill. Only the owner or admins may invoke.", +) +async def update_custom_skill_state( + skill_name: str, + body: SkillStateRequest, + request: Request, + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), +) -> SkillResponse: + try: + skill_name = skill_name.replace("\r\n", "").replace("\n", "") + if body.state not in _ALLOWED_USER_STATES: + raise HTTPException( + status_code=400, + detail=f"state must be one of {sorted(_ALLOWED_USER_STATES)}", + ) + user_id = _current_user_id(request) + is_admin = _is_admin_user(request) + record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin) + + from deerflow.skills.usage import set_state as _usage_set_state + + _usage_set_state(skill_name, body.state) + await refresh_skills_system_prompt_cache_async() + return _reload_skill_response(skill_name, record, config) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to update state for %s: %s", skill_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to update state: {e}") + + +@router.put( + "/skills/custom/{skill_name}/pinned", + response_model=SkillResponse, + summary="Pin or Unpin a Custom Skill", + description="Pin a skill so the curator never auto-archives it. Only the owner or admins may invoke.", +) +async def update_custom_skill_pinned( + skill_name: str, + body: SkillPinRequest, + request: Request, + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), +) -> SkillResponse: + try: + skill_name = skill_name.replace("\r\n", "").replace("\n", "") + user_id = _current_user_id(request) + is_admin = _is_admin_user(request) + record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin) + + from deerflow.skills.usage import set_pinned as _usage_set_pinned + + _usage_set_pinned(skill_name, body.pinned) + return _reload_skill_response(skill_name, record, config) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to update pinned for %s: %s", skill_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to update pinned: {e}") + + +@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History") +async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse: + try: + skill_name = skill_name.replace("\r\n", "").replace("\n", "") + storage = get_or_new_skill_storage(app_config=config) + if not storage.custom_skill_exists(skill_name) and not storage.get_skill_history_file(skill_name).exists(): + raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found") + return CustomSkillHistoryResponse(history=storage.read_history(skill_name)) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to read history for %s: %s", skill_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to read history: {str(e)}") + + +@router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill") +async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse: + try: + storage = get_or_new_skill_storage(app_config=config) + if not storage.custom_skill_exists(skill_name) and not storage.get_skill_history_file(skill_name).exists(): + raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found") + history = storage.read_history(skill_name) + if not history: + raise HTTPException(status_code=400, detail=f"Custom skill '{skill_name}' has no history") + record = history[request.history_index] + target_content = record.get("prev_content") + if target_content is None: + raise HTTPException(status_code=400, detail="Selected history entry has no previous content to roll back to") + storage.validate_skill_markdown_content(skill_name, target_content) + scan = await scan_skill_content(target_content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config) + skill_file = storage.get_custom_skill_file(skill_name) + current_content = skill_file.read_text(encoding="utf-8") if skill_file.exists() else None + history_entry = { + "action": "rollback", + "author": "human", + "thread_id": None, + "file_path": SKILL_MD_FILE, + "prev_content": current_content, + "new_content": target_content, + "rollback_from_ts": record.get("ts"), + "scanner": {"decision": scan.decision, "reason": scan.reason}, + } + if scan.decision == "block": + storage.append_history(skill_name, history_entry) + raise HTTPException(status_code=400, detail=f"Rollback blocked by security scanner: {scan.reason}") + storage.write_custom_skill(skill_name, SKILL_MD_FILE, target_content) + storage.append_history(skill_name, history_entry) + await refresh_skills_system_prompt_cache_async() + return await get_custom_skill(skill_name, config) + except HTTPException: + raise + except IndexError: + raise HTTPException(status_code=400, detail="history_index is out of range") + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error("Failed to roll back custom skill %s: %s", skill_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to roll back custom skill: {str(e)}") + + +@router.get( + "/skills/{skill_name}", + response_model=SkillResponse, + summary="Get Skill Details", + description="Retrieve detailed information about a specific skill by its name.", +) +async def get_skill( + skill_name: str, + request: Request, + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), +) -> SkillResponse: + try: + skill_name = skill_name.replace("\r\n", "").replace("\n", "") + skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) + skill = next((s for s in skills if s.name == skill_name), None) + + if skill is None: + raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") + + # Attach ownership (owner/published/detail) when the caller may see it. + ownership = await skill_store.get_visible(skill_name, _current_user_id(request)) + return _skill_to_response(skill, ownership) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get skill {skill_name}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}") + + +class SkillContentResponse(BaseModel): + """SKILL.md 原文内容,适用于公共和自定义技能。""" + + name: str + category: SkillCategory + content: str + + +@router.get( + "/skills/{skill_name}/content", + response_model=SkillContentResponse, + summary="Get Skill Markdown Content", + description="Retrieve the raw SKILL.md content of a skill. Readable by any user the skill is visible to (built-in, published, or owned).", +) +async def get_skill_content( + skill_name: str, + request: Request, + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), +) -> SkillContentResponse: + try: + skill_name = skill_name.replace("\r\n", "").replace("\n", "") + skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) + skill = next((s for s in skills if s.name == skill_name), None) + if skill is None: + raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") + user_id = _current_user_id(request) + # SKILL.md describes how to use a skill — visible to anyone who can see + # the skill itself (built-in, published, or owned). Admins always pass. + if not _is_admin_user(request): + record = await skill_store.get_any(skill_name) + if skill.category == SkillCategory.CUSTOM: + if record is None: + raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") + if not record.get("published") and record.get("owner_user_id") != user_id: + raise HTTPException(status_code=403, detail="You can only view SKILL.md for skills visible to you") + content = skill.skill_file.read_text(encoding="utf-8") + return SkillContentResponse(name=skill.name, category=skill.category, content=content) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to get skill content %s: %s", skill_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to get skill content: {str(e)}") + + +# --------------------------------------------------------------------------- +# Skill files browser (tree + per-file read / write) +# --------------------------------------------------------------------------- + + +_SKILL_FILE_TEXT_EXTS = { + ".md", ".markdown", ".txt", ".rst", + ".py", ".pyi", + ".json", ".jsonl", + ".yaml", ".yml", + ".toml", ".ini", ".cfg", ".env", + ".sh", ".bash", ".zsh", ".ps1", + ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", + ".html", ".htm", ".css", ".scss", ".sass", ".xml", ".svg", + ".csv", ".tsv", + ".sql", +} +_SKILL_FILE_TREE_SKIP_NAMES = { + "__pycache__", "node_modules", ".git", ".DS_Store", ".idea", ".vscode", +} +_SKILL_FILE_MAX_NODES = 2000 +_SKILL_FILE_MAX_DEPTH = 8 +_SKILL_FILE_READ_BYTES = 2 * 1024 * 1024 # 2 MiB cap on JSON content payload +_SKILL_FILE_WRITE_BYTES = 1 * 1024 * 1024 # 1 MiB cap on edits + + +class SkillFileNode(BaseModel): + """One entry in a skill's file tree. + + ``children`` is only set for directories. ``path`` is POSIX-style and is + always relative to the skill's own directory (never absolute). + """ + + name: str = Field(..., description="Base name (no slashes)") + type: str = Field(..., description="'file' or 'dir'") + path: str = Field(..., description="Path relative to the skill directory, POSIX-separated") + size: int | None = Field(default=None, description="File size in bytes (files only)") + mtime: float | None = Field(default=None, description="Modification time as POSIX seconds (files only)") + children: list["SkillFileNode"] | None = Field(default=None, description="Child entries (directories only)") + + +SkillFileNode.model_rebuild() + + +class SkillFilesResponse(BaseModel): + """Tree of files under a skill directory.""" + + name: str + category: SkillCategory + editable: bool = Field(..., description="True when the caller may PUT new content into any file under this tree") + root: SkillFileNode + + +class SkillFileContentResponse(BaseModel): + """JSON payload for reading a single file inside a skill.""" + + name: str + category: SkillCategory + path: str + mime: str + size: int + mtime: float + is_text: bool = Field(..., description="True when ``content`` carries the decoded text; otherwise download via the binary endpoint") + editable: bool = Field(..., description="True when the caller may PUT new content for this file") + content: str | None = Field(default=None, description="Decoded text content when ``is_text`` is True") + + +class SkillFileSaveRequest(BaseModel): + """Request body for writing a text file inside a skill.""" + + path: str = Field(..., description="File path relative to the skill directory, POSIX-separated") + content: str = Field(..., description="Replacement text content") + + +class SkillFileSaveResponse(BaseModel): + name: str + path: str + size: int + mtime: float + + +def _normalize_skill_name_param(skill_name: str) -> str: + """Strip CR/LF the same way other skill routes do before lookup.""" + return skill_name.replace("\r\n", "").replace("\n", "") + + +def _load_skill_or_404(skill_name: str, config: AppConfig) -> Skill: + skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) + skill = next((s for s in skills if s.name == skill_name), None) + if skill is None: + raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") + return skill + + +async def _ensure_files_view_allowed( + skill: Skill, + request: Request, + skill_store: SkillStore, +) -> dict[str, Any] | None: + """Mirror _can_view_skill_source: admins or the publisher (custom owner).""" + record = await skill_store.get_any(skill.name) + owner_user_id = record.get("owner_user_id") if record else None + user_id = _current_user_id(request) + if not _can_view_skill_source(skill.category, owner_user_id, user_id, is_admin=_is_admin_user(request)): + raise HTTPException(status_code=403, detail="Only the skill publisher or an admin can browse skill files") + return record + + +def _caller_can_edit_skill_files( + skill: Skill, + record: dict[str, Any] | None, + request: Request, +) -> bool: + """Editing is limited to custom skills, and to either owner or admin.""" + if skill.category != SkillCategory.CUSTOM: + return False + if _is_admin_user(request): + return True + if record is None: + return False + return record.get("owner_user_id") == _current_user_id(request) + + +def _resolve_skill_relative_path(skill: Skill, relative_path: str) -> Path: + """Validate *relative_path* against the skill directory and return the absolute Path. + + Rejects empty, absolute, parent-traversing, or out-of-tree paths so a + malicious caller can never escape the skill's own directory. + """ + if not relative_path or relative_path.strip() == "": + raise HTTPException(status_code=400, detail="path must not be empty") + candidate = Path(relative_path) + if candidate.is_absolute() or any(part in {"..", ""} for part in candidate.parts): + raise HTTPException(status_code=400, detail="path must be a relative path without '..' segments") + skill_dir = skill.skill_file.parent.resolve() + target = (skill_dir / candidate).resolve() + try: + target.relative_to(skill_dir) + except ValueError: + raise HTTPException(status_code=400, detail="path must stay inside the skill directory") + return target + + +def _file_mime(path: Path) -> str: + """Best-effort MIME guess; defaults to ``application/octet-stream``.""" + guessed, _ = mimetypes.guess_type(path.name) + if guessed: + return guessed + if path.suffix.lower() in _SKILL_FILE_TEXT_EXTS: + return "text/plain" + return "application/octet-stream" + + +def _is_text_file(path: Path) -> bool: + """Treat known text extensions as text; everything else as binary.""" + return path.suffix.lower() in _SKILL_FILE_TEXT_EXTS + + +def _build_skill_file_tree(skill_dir: Path) -> SkillFileNode: + """Walk *skill_dir* and build a SkillFileNode tree, depth/count capped. + + Hidden files (``.foo``) and a small block-list of noise directories + (``__pycache__`` etc.) are skipped so the UI does not drown in housekeeping + artefacts. + """ + counter = {"n": 0} + + def build(current_dir: Path, depth: int) -> SkillFileNode: + rel = current_dir.relative_to(skill_dir).as_posix() + node = SkillFileNode( + name=current_dir.name if rel != "." else skill_dir.name, + type="dir", + path="" if rel == "." else rel, + children=[], + ) + if depth >= _SKILL_FILE_MAX_DEPTH: + return node + try: + entries = sorted(current_dir.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())) + except OSError: + return node + for entry in entries: + if counter["n"] >= _SKILL_FILE_MAX_NODES: + break + if entry.name.startswith(".") or entry.name in _SKILL_FILE_TREE_SKIP_NAMES: + continue + counter["n"] += 1 + if entry.is_dir(): + node.children.append(build(entry, depth + 1)) + continue + try: + stat = entry.stat() + size = stat.st_size + mtime = stat.st_mtime + except OSError: + size = 0 + mtime = 0.0 + node.children.append( + SkillFileNode( + name=entry.name, + type="file", + path=entry.relative_to(skill_dir).as_posix(), + size=size, + mtime=mtime, + ) + ) + return node + + return build(skill_dir, 0) + + +@router.get( + "/skills/{skill_name}/files", + response_model=SkillFilesResponse, + summary="List Skill Files (Tree)", + description=( + "Return the full file tree under a skill directory. Only an admin or the " + "skill's publisher (owner of a custom skill) may browse files; other users " + "get 403." + ), +) +async def list_skill_files( + skill_name: str, + request: Request, + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), +) -> SkillFilesResponse: + try: + skill_name = _normalize_skill_name_param(skill_name) + skill = _load_skill_or_404(skill_name, config) + record = await _ensure_files_view_allowed(skill, request, skill_store) + skill_dir = skill.skill_file.parent + root = _build_skill_file_tree(skill_dir) + return SkillFilesResponse( + name=skill.name, + category=skill.category, + editable=_caller_can_edit_skill_files(skill, record, request), + root=root, + ) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to list skill files for %s: %s", skill_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to list skill files: {e}") + + +@router.get( + "/skills/{skill_name}/files/content", + response_model=SkillFileContentResponse, + summary="Read Skill File (Text)", + description=( + "Return JSON metadata for a single file under a skill. Text files include " + "their decoded ``content``; binary files omit ``content`` and should be " + "fetched from the ``files/download`` endpoint for inline display or " + "download. Same view authorisation as the file tree endpoint." + ), +) +async def get_skill_file_content( + skill_name: str, + request: Request, + path: str = Query(..., description="File path relative to the skill directory"), + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), +) -> SkillFileContentResponse: + try: + skill_name = _normalize_skill_name_param(skill_name) + skill = _load_skill_or_404(skill_name, config) + record = await _ensure_files_view_allowed(skill, request, skill_store) + target = _resolve_skill_relative_path(skill, path) + if not target.exists() or not target.is_file(): + raise HTTPException(status_code=404, detail=f"File '{path}' not found in skill '{skill_name}'") + stat = target.stat() + mime = _file_mime(target) + is_text = _is_text_file(target) + content: str | None = None + if is_text: + if stat.st_size > _SKILL_FILE_READ_BYTES: + raise HTTPException(status_code=413, detail=f"File exceeds the {_SKILL_FILE_READ_BYTES // 1024} KiB preview limit") + try: + content = target.read_text(encoding="utf-8") + except UnicodeDecodeError: + # File has a text-y extension but is not UTF-8 — treat as binary. + is_text = False + return SkillFileContentResponse( + name=skill.name, + category=skill.category, + path=target.relative_to(skill.skill_file.parent.resolve()).as_posix(), + mime=mime, + size=stat.st_size, + mtime=stat.st_mtime, + is_text=is_text, + editable=is_text and _caller_can_edit_skill_files(skill, record, request), + content=content, + ) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to read skill file %s/%s: %s", skill_name, path, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to read skill file: {e}") + + +@router.get( + "/skills/{skill_name}/files/download", + summary="Download Skill File (Raw Bytes)", + description=( + "Stream the raw bytes of a single file under a skill, suitable for " + "embedding in an ```` tag or for direct download. Same view " + "authorisation as the file tree endpoint." + ), +) +async def download_skill_file( + skill_name: str, + request: Request, + path: str = Query(..., description="File path relative to the skill directory"), + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), +): + skill_name = _normalize_skill_name_param(skill_name) + skill = _load_skill_or_404(skill_name, config) + await _ensure_files_view_allowed(skill, request, skill_store) + target = _resolve_skill_relative_path(skill, path) + if not target.exists() or not target.is_file(): + raise HTTPException(status_code=404, detail=f"File '{path}' not found in skill '{skill_name}'") + return FileResponse( + path=target, + media_type=_file_mime(target), + filename=target.name, + ) + + +@router.put( + "/skills/{skill_name}/files/content", + response_model=SkillFileSaveResponse, + summary="Write Skill File (Text)", + description=( + "Replace the contents of a text file under a custom skill. Only the " + "skill's owner or an admin may write; built-in (public) skills are " + "read-only. Edits to ``SKILL.md`` re-validate its YAML frontmatter and " + "refresh the system-prompt cache." + ), +) +async def save_skill_file( + skill_name: str, + body: SkillFileSaveRequest, + request: Request, + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), +) -> SkillFileSaveResponse: + try: + skill_name = _normalize_skill_name_param(skill_name) + skill = _load_skill_or_404(skill_name, config) + if skill.category != SkillCategory.CUSTOM: + raise HTTPException(status_code=403, detail="Built-in skills are read-only") + record = await skill_store.get_any(skill.name) + if not _caller_can_edit_skill_files(skill, record, request): + raise HTTPException(status_code=403, detail="Only the skill owner or an admin can edit files in this skill") + + target = _resolve_skill_relative_path(skill, body.path) + if not target.exists(): + raise HTTPException(status_code=404, detail=f"File '{body.path}' not found in skill '{skill_name}'") + if target.is_dir(): + raise HTTPException(status_code=400, detail="path points to a directory") + if not _is_text_file(target): + raise HTTPException(status_code=400, detail=f"File type '{target.suffix or target.name}' is not editable") + + if len(body.content.encode("utf-8")) > _SKILL_FILE_WRITE_BYTES: + raise HTTPException(status_code=413, detail=f"Content exceeds the {_SKILL_FILE_WRITE_BYTES // 1024} KiB edit limit") + + relative_posix = target.relative_to(skill.skill_file.parent.resolve()).as_posix() + storage = get_or_new_skill_storage(app_config=config) + + is_skill_md = relative_posix == SKILL_MD_FILE + prev_content: str | None = None + if target.exists(): + try: + prev_content = target.read_text(encoding="utf-8") + except UnicodeDecodeError: + prev_content = None + + if is_skill_md: + # SKILL.md edits must keep their frontmatter valid (name must match). + storage.validate_skill_markdown_content(skill.name, body.content) + + storage.write_custom_skill(skill.name, relative_posix, body.content) + + try: + storage.append_history( + skill.name, + { + "action": "human_edit_file", + "author": "human", + "thread_id": None, + "file_path": relative_posix, + "prev_content": prev_content, + "new_content": body.content, + }, + ) + except Exception as history_err: # noqa: BLE001 — history is best-effort + logger.warning("Failed to append edit history for %s/%s: %s", skill.name, relative_posix, history_err) + + if is_skill_md: + try: + from deerflow.skills.usage import bump_patch + bump_patch(skill.name) + except Exception: + pass + await refresh_skills_system_prompt_cache_async() + + stat = target.stat() + return SkillFileSaveResponse( + name=skill.name, + path=relative_posix, + size=stat.st_size, + mtime=stat.st_mtime, + ) + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error("Failed to save skill file %s/%s: %s", skill_name, body.path, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to save skill file: {e}") + + +@router.put( + "/skills/{skill_name}", + response_model=SkillResponse, + summary="Update Skill", + description="Update a skill's enabled status by modifying the extensions_config.json file.", +) +async def update_skill(skill_name: str, request: SkillUpdateRequest, config: AppConfig = Depends(get_config)) -> SkillResponse: + try: + skill_name = skill_name.replace("\r\n", "").replace("\n", "") + skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) + skill = next((s for s in skills if s.name == skill_name), None) + + if skill is None: + raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") + + config_path = ExtensionsConfig.resolve_config_path() + if config_path is None: + config_path = Path.cwd().parent / "extensions_config.json" + logger.info(f"No existing extensions config found. Creating new config at: {config_path}") + + extensions_config = get_extensions_config() + current_skill_config = extensions_config.skills.get(skill_name) + extensions_config.skills[skill_name] = SkillStateConfig( + enabled=request.enabled, + display=current_skill_config.display if current_skill_config else None, + ) + + config_data = _dump_extensions_config(extensions_config) + + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config_data, f, indent=2) + + logger.info(f"Skills configuration updated and saved to: {config_path}") + reload_extensions_config() + await refresh_skills_system_prompt_cache_async() + + skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) + updated_skill = next((s for s in skills if s.name == skill_name), None) + + if updated_skill is None: + raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update") + + logger.info(f"Skill '{skill_name}' enabled status updated to {request.enabled}") + return _skill_to_response(updated_skill) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to update skill {skill_name}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}") + + +@router.put( + "/skills/{skill_name}/display", + response_model=SkillResponse, + summary="Update Skill Display Adapter", + description="Update a skill's frontend display adapter in extensions_config.json.", +) +async def update_skill_display( + skill_name: str, + request: SkillDisplayUpdateRequest, + config: AppConfig = Depends(get_config), +) -> SkillResponse: + try: + skill_name = skill_name.replace("\r\n", "").replace("\n", "") + skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) + skill = next((s for s in skills if s.name == skill_name), None) + + if skill is None: + raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") + + config_path = ExtensionsConfig.resolve_config_path() + if config_path is None: + config_path = Path.cwd().parent / "extensions_config.json" + logger.info(f"No existing extensions config found. Creating new config at: {config_path}") + + extensions_config = get_extensions_config() + current_skill_config = extensions_config.skills.get(skill_name) + extensions_config.skills[skill_name] = SkillStateConfig( + enabled=current_skill_config.enabled if current_skill_config else skill.enabled, + display=request.display, + ) + + display_payload = request.display.model_dump(by_alias=True) if request.display is not None else None + sample_payload = request.sample if "sample" in request.model_fields_set else ... + await _upsert_skill_display_adapter( + skill_name, + display=display_payload, + sample=sample_payload, + ) + + with open(config_path, "w", encoding="utf-8") as f: + json.dump(_dump_extensions_config(extensions_config), f, indent=2, ensure_ascii=False) + + reload_extensions_config() + await refresh_skills_system_prompt_cache_async() + skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) + updated_skill = next((s for s in skills if s.name == skill_name), None) + if updated_skill is None: + raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after display update") + adapter = await _get_skill_display_adapter(skill_name) or {} + return _skill_to_response( + updated_skill, + display_override=adapter.get("display"), + display_sample=adapter.get("sample"), + ) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to update display adapter for %s: %s", skill_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to update skill display adapter: {str(e)}") + + +@router.get( + "/skills/{skill_name}/display-sample", + response_model=PersistedSkillDisplaySampleResponse, + summary="Get Persisted Skill Display Sample", + description="Return only the sample stored in the database. Does not generate a fallback sample.", +) +async def get_persisted_skill_display_sample( + skill_name: str, + config: AppConfig = Depends(get_config), +) -> PersistedSkillDisplaySampleResponse: + try: + skill_name = skill_name.replace("\r\n", "").replace("\n", "") + skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) + skill = next((s for s in skills if s.name == skill_name), None) + if skill is None: + raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") + adapter = await _get_skill_display_adapter(skill_name) + sample = adapter.get("sample") if adapter else None + return PersistedSkillDisplaySampleResponse( + skill_name=skill_name, + sample=sample if isinstance(sample, dict) else None, + ) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to get persisted display sample for %s: %s", skill_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to get persisted display sample: {str(e)}") + + +@router.post( + "/skills/{skill_name}/display-sample", + response_model=SkillDisplaySampleResponse, + summary="Get Skill Display Sample", + description="Generate or return a structured sample response used by the frontend to infer display mapping.", +) +async def get_skill_display_sample( + skill_name: str, + config: AppConfig = Depends(get_config), +) -> SkillDisplaySampleResponse: + try: + skill_name = skill_name.replace("\r\n", "").replace("\n", "") + skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) + skill = next((s for s in skills if s.name == skill_name), None) + if skill is None: + raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") + adapter = await _get_skill_display_adapter(skill_name) + if adapter and isinstance(adapter.get("sample"), dict): + return SkillDisplaySampleResponse(skill_name=skill_name, sample=adapter["sample"]) + sample = await _generate_skill_display_sample(skill, config) + await _upsert_skill_display_adapter(skill_name, sample=sample) + return SkillDisplaySampleResponse(skill_name=skill_name, sample=sample) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to get display sample for %s: %s", skill_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to get display sample: {str(e)}") + + +class SkillDetailRequest(BaseModel): + """Request body for setting a skill's human-authored detail text.""" + + detail: str = Field(default="", description="详情说明文本,支持 Markdown;传空字符串表示清空") + + +class SkillDetailResponse(BaseModel): + """Response model for the skill-detail update endpoint.""" + + name: str = Field(..., description="Skill name") + detail: str | None = Field(default=None, description="Persisted detail text (null when cleared)") + + +@router.put( + "/skills/{skill_name}/detail", + response_model=SkillDetailResponse, + summary="Update Skill Detail", + description="设置技能的详情说明。自定义技能由其所有者或管理员编辑;内置/公开技能仅管理员可编辑。", +) +async def update_skill_detail( + skill_name: str, + body: SkillDetailRequest, + request: Request, + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), +) -> SkillDetailResponse: + try: + skill_name = skill_name.replace("\r\n", "").replace("\n", "") + user_id = _current_user_id(request) + is_admin = _is_admin_user(request) + + skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) + skill = next((s for s in skills if s.name == skill_name), None) + if skill is None: + raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") + + # Empty / whitespace-only input clears the annotation. + detail_value: str | None = body.detail.strip() or None + + if skill.category == SkillCategory.CUSTOM: + # Owner edits their own skill; admins may edit anyone's. + _record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin) + if mode == "admin": + updated = await skill_store.update_admin(skill_name, {"detail": detail_value}) + else: + updated = await skill_store.update(skill_name, user_id, {"detail": detail_value}) + else: + # Built-in / public skill — only admins may annotate it. These carry + # no DB row by default, so materialize one before writing detail. + if not is_admin: + raise HTTPException(status_code=403, detail="Only admin users can edit built-in skill details") + if await skill_store.get_any(skill_name) is None: + await skill_store.ensure_legacy({"name": skill_name, "owner_user_id": None, "published": True}) + updated = await skill_store.update_admin(skill_name, {"detail": detail_value}) + + if updated is None: + raise HTTPException(status_code=500, detail="Failed to persist skill detail") + return SkillDetailResponse(name=skill_name, detail=updated.get("detail")) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to update detail for %s: %s", skill_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to update skill detail: {str(e)}") + + +class SkillPinRequest(BaseModel): + pinned: bool = Field(..., description="Whether to pin or unpin the skill") + + +@router.post("/skills/custom/{skill_name}/pin", response_model=SkillResponse, summary="Pin or Unpin Skill") +async def pin_skill(skill_name: str, request: SkillPinRequest, config: AppConfig = Depends(get_config)) -> SkillResponse: + """Pin a skill to prevent it from being automatically archived by the curator.""" + try: + skill_name = skill_name.replace("\r\n", "").replace("\n", "") + storage = get_or_new_skill_storage(app_config=config) + if not storage.custom_skill_exists(skill_name): + raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found") + from deerflow.skills.usage import set_pinned + set_pinned(skill_name, request.pinned) + skills = storage.load_skills(enabled_only=False) + skill = next((s for s in skills if s.name == skill_name), None) + if skill is None: + raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") + return _skill_to_response(skill) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to pin skill %s: %s", skill_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to pin skill: {str(e)}") + + +@router.get("/curator/status", summary="Curator Status") +async def curator_status(request: Request) -> dict: + """Return the current curator state and per-lifecycle skill counts for the + authenticated user.""" + try: + from deerflow.skills.curator import get_curator_status + return get_curator_status(_current_user_id(request)) + except Exception as e: + logger.error("Failed to get curator status: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to get curator status: {str(e)}") + + +@router.post("/curator/run", summary="Trigger Curator Run") +async def run_curator(request: Request) -> dict: + """Manually trigger a curator review pass for the authenticated user + (bypasses the interval check, runs in a background thread). + + Pre-checks the master switch / curator switch / run lock so the response + accurately reflects whether a run actually started — instead of always + reporting success. Returns ``{"triggered": bool, "reason": str | None}``. + """ + try: + import threading + + from deerflow.skills.curator import _curator_thread_target, manual_trigger_blocked_reason + + user_id = _current_user_id(request) + reason = manual_trigger_blocked_reason(user_id) + if reason is not None: + return {"triggered": False, "reason": reason} + thread = threading.Thread( + target=_curator_thread_target, + args=(user_id,), + daemon=True, + name=f"skill-curator-manual-{user_id}", + ) + thread.start() + return {"triggered": True, "reason": None} + except Exception as e: + logger.error("Failed to trigger curator: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to trigger curator: {str(e)}") + + +class CuratorSubConfig(BaseModel): + """Nested curator configuration mirroring CuratorConfig.""" + + # 与 CuratorConfig.enabled 保持一致:默认关闭,需显式 opt-in。 + enabled: bool = Field(default=False, description="Whether the curator runs automatically.") + interval_hours: int = Field(default=168, ge=1, description="Hours between curator runs.") + stale_after_days: int = Field(default=30, ge=1, description="Days of inactivity before a skill is marked stale.") + archive_after_days: int = Field(default=90, ge=1, description="Days of inactivity before a stale skill is archived.") + review_timeout_seconds: int = Field(default=600, ge=1, description="Overall timeout (seconds) for the LLM review pass.") + model_name: str | None = Field(default=None, description="Model name for the curator LLM review pass; null = primary model.") + + +class CuratorConfigResponse(BaseModel): + """Response model exposing the effective skill_evolution config.""" + + enabled: bool + curator: CuratorSubConfig + + +class CuratorConfigRequest(BaseModel): + """Payload for updating skill_evolution overrides. + + All fields are required so the frontend always sends a complete config; + backend writes the entire payload to skill_evolution.json atomically. + """ + + enabled: bool = Field(..., description="Whether the agent can create and modify skills under skills/custom.") + curator: CuratorSubConfig + + +class CuratorLogRecord(BaseModel): + ts: str + level: str + message: str + + +class CuratorLogsResponse(BaseModel): + records: list[CuratorLogRecord] + run_count: int + last_run_at: str | None + + +def _build_curator_config_response(evo) -> CuratorConfigResponse: + """Pure helper so both the GET route and the post-PUT refresh share one shape. + + ``evo`` is a ``SkillEvolutionConfig`` instance (already merged with per-user + overrides if any). + """ + return CuratorConfigResponse( + enabled=evo.enabled, + curator=CuratorSubConfig( + enabled=evo.curator.enabled, + interval_hours=evo.curator.interval_hours, + stale_after_days=evo.curator.stale_after_days, + archive_after_days=evo.curator.archive_after_days, + review_timeout_seconds=evo.curator.review_timeout_seconds, + model_name=evo.curator.model_name, + ), + ) + + +@router.get("/curator/config", response_model=CuratorConfigResponse, summary="Get Skill Evolution Config") +async def get_curator_config(request: Request) -> CuratorConfigResponse: + """Return the current curator config for the authenticated user (yaml + defaults + that user's overrides).""" + try: + from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user + + evo = load_skill_evolution_config_for_user(_current_user_id(request)) + return _build_curator_config_response(evo) + except Exception as e: + logger.error("Failed to read curator config: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to read curator config: {str(e)}") + + +@router.put("/curator/config", response_model=CuratorConfigResponse, summary="Update Skill Evolution Config") +async def update_curator_config(body: CuratorConfigRequest, request: Request) -> CuratorConfigResponse: + """Persist per-user overrides to ``users/{user_id}/skill_evolution.json`` + and return the effective merged config. + + The payload is validated against the ``SkillEvolutionConfig`` shape; unknown + fields are rejected. + """ + try: + from deerflow.config.skill_evolution_config import SkillEvolutionConfig + from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user, save_user_overrides + + user_id = _current_user_id(request) + payload = body.model_dump() + # Round-trip through SkillEvolutionConfig to ensure persisted shape stays valid. + SkillEvolutionConfig.model_validate(payload) + save_user_overrides(user_id, payload) + return _build_curator_config_response(load_skill_evolution_config_for_user(user_id)) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to update curator config: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to update curator config: {str(e)}") + + +@router.get("/curator/logs", response_model=CuratorLogsResponse, summary="Recent Curator Logs") +async def get_curator_logs(request: Request) -> CuratorLogsResponse: + """Return in-memory log records from the most recent curator run for the + authenticated user. + + The buffer is process-local and cleared when curator starts a new run or + when the backend restarts. Records are ordered oldest → newest. + """ + try: + from deerflow.skills import curator_log + from deerflow.skills.curator import get_curator_status + + user_id = _current_user_id(request) + records = curator_log.get_recent(user_id) + status = get_curator_status(user_id) + return CuratorLogsResponse( + records=[CuratorLogRecord(**r) for r in records], + run_count=int(status.get("run_count", 0)), + last_run_at=status.get("last_run_at"), + ) + except Exception as e: + logger.error("Failed to read curator logs: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to read curator logs: {str(e)}") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py index 3b19bb8d..2adfd763 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -30,7 +30,7 @@ from deerflow.persistence.scheduled_tasks.model import ( ScheduledTaskSubscriptionRow, SchedulerThreadRow, ) -from deerflow.persistence.skills.model import SkillRow +from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillRow from deerflow.persistence.tags.model import TagAssignmentRow, TagRow from deerflow.persistence.thread_meta.model import ThreadMetaRow from deerflow.persistence.thread_shares.model import ThreadShareRow @@ -51,6 +51,7 @@ __all__ = [ "ScheduledTaskSubscriptionRow", "SchedulerThreadRow", "SkillRow", + "SkillDisplayAdapterRow", "TagAssignmentRow", "TagRow", "ThreadMetaRow", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/model.py index fa6f1d86..ff981c15 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/model.py @@ -31,3 +31,16 @@ class SkillRow(Base): detail: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) + + +class SkillDisplayAdapterRow(Base): + __tablename__ = "skill_display_adapters" + + skill_name: Mapped[str] = mapped_column(String(64), primary_key=True) + # Store JSON as text for maximum MySQL compatibility. Some deployments still + # run MySQL variants where native JSON columns or unquoted names such as + # ``display`` are not accepted consistently. + display_config: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) + sample_payload: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py b/offline-backend-20260512/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py index b4016822..23b25dbe 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py @@ -37,14 +37,26 @@ class LocalSandbox(Sandbox): """Return whether the selected shell is a PowerShell executable.""" return LocalSandbox._shell_name(shell) in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"} - @staticmethod - def _is_cmd_shell(shell: str) -> bool: - """Return whether the selected shell is cmd.exe.""" - return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"} - - @staticmethod - def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None: - """Return the first executable shell path or command found from candidates.""" + @staticmethod + def _is_cmd_shell(shell: str) -> bool: + """Return whether the selected shell is cmd.exe.""" + return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"} + + @staticmethod + def _normalize_powershell_command(command: str) -> str: + """Make bash-style command glue usable in Windows PowerShell 5. + + The public tool is still named ``bash`` for model compatibility, but on + Windows local sandbox it may fall back to PowerShell. PowerShell 5 does + not support ``&&``, while the sandbox layer prepends commands with + ``cd &&``. Rewriting the separator keeps skill scripts + executable in that fallback shell. + """ + return command.replace("&&", ";") + + @staticmethod + def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None: + """Return the first executable shell path or command found from candidates.""" for shell in candidates: if os.path.isabs(shell): if os.path.isfile(shell) and os.access(shell, os.X_OK): @@ -302,30 +314,39 @@ class LocalSandbox(Sandbox): resolved_command = self._resolve_paths_in_command(command) shell = self._get_shell() - if os.name == "nt": - if self._is_powershell(shell): - args = [shell, "-NoProfile", "-Command", resolved_command] + if os.name == "nt": + if self._is_powershell(shell): + args = [ + shell, + "-NoProfile", + "-Command", + self._normalize_powershell_command(resolved_command), + ] elif self._is_cmd_shell(shell): args = [shell, "/c", resolved_command] else: args = [shell, "-c", resolved_command] - result = subprocess.run( - args, - shell=False, - capture_output=True, - text=True, - timeout=600, - ) - else: - args = [shell, "-c", resolved_command] - result = subprocess.run( - args, - shell=False, - capture_output=True, - text=True, - timeout=600, - ) + result = subprocess.run( + args, + shell=False, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=600, + ) + else: + args = [shell, "-c", resolved_command] + result = subprocess.run( + args, + shell=False, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=600, + ) output = result.stdout if result.stderr: output += f"\nStd Error:\n{result.stderr}" if output else result.stderr