diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 37bdd9cf..73f1f786 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -49,9 +49,8 @@ import { } from "@/core/artifacts/hooks"; import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; -import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; -import { installSkill } from "@/core/skills/api"; -import { streamdownPlugins } from "@/core/streamdown"; +import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; +import { streamdownPlugins } from "@/core/streamdown"; import { checkCodeFile, getFileName, @@ -79,7 +78,8 @@ import { useThread } from "../messages/context"; import { Tooltip } from "../tooltip"; import { useArtifacts } from "./context"; -import { SpreadsheetPreview } from "./spreadsheet-preview"; +import { SpreadsheetPreview } from "./spreadsheet-preview"; +import { SkillInstallDialog } from "./skill-install-dialog"; const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll"; @@ -232,7 +232,7 @@ export function ArtifactFileDetail({ const [viewMode, setViewMode] = useState<"code" | "preview">("code"); const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true); - const [isInstalling, setIsInstalling] = useState(false); + const [installDialogOpen, setInstallDialogOpen] = useState(false); const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false); const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false); @@ -432,29 +432,7 @@ export function ArtifactFileDetail({ }, 1600); }, [scrollArtifactToBottom]); - const handleInstallSkill = useCallback(async () => { - if (isInstalling) return; - - setIsInstalling(true); - try { - const result = await installSkill({ - thread_id: threadId, - path: filepath, - }); - if (result.success) { - toast.success(result.message); - } else { - toast.error(result.message ?? "Failed to install skill"); - } - } catch (error) { - console.error("Failed to install skill:", error); - toast.error("Failed to install skill"); - } finally { - setIsInstalling(false); - } - }, [threadId, filepath, isInstalling]); - - const handleSaveArtifact = useCallback(async () => { + const handleSaveArtifact = useCallback(async () => { if (!canEditArtifact || updateArtifact.isPending) { return; } @@ -686,18 +664,17 @@ export function ArtifactFileDetail({ )} {!useWriteFileStreaming && filepath.endsWith(".skill") && ( - - - )} + setInstallDialogOpen(true)} + /> + + )} {isCodeFile && ( - ))} - - - ); -} + + + + ); +} function SandboxPreviewImage({ src, diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx index 4e83c9d6..fc418584 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx @@ -1,6 +1,6 @@ -import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react"; -import { useCallback, useState } from "react"; -import { toast } from "sonner"; +import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -10,18 +10,18 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { downloadArtifactFile } from "@/core/artifacts/download"; -import { useI18n } from "@/core/i18n/hooks"; -import { installSkill } from "@/core/skills/api"; -import { - getFileExtensionDisplayName, - getFileIcon, - getFileName, -} from "@/core/utils/files"; +import { downloadArtifactFile } from "@/core/artifacts/download"; +import { useI18n } from "@/core/i18n/hooks"; +import { + getFileExtensionDisplayName, + getFileIcon, + getFileName, +} from "@/core/utils/files"; import { cn } from "@/lib/utils"; -import { useThread } from "../messages/context"; -import { useArtifacts } from "./context"; +import { useThread } from "../messages/context"; +import { useArtifacts } from "./context"; +import { SkillInstallDialog } from "./skill-install-dialog"; export function ArtifactFileList({ className, @@ -32,11 +32,11 @@ export function ArtifactFileList({ files: string[]; threadId: string; }) { - const { t } = useI18n(); - const { isMock, publicShareCode } = useThread(); - const { select: selectArtifact, setOpen } = useArtifacts(); - const [installingFile, setInstallingFile] = useState(null); - const [downloadingFile, setDownloadingFile] = useState(null); + const { t } = useI18n(); + const { isMock, publicShareCode } = useThread(); + const { select: selectArtifact, setOpen } = useArtifacts(); + const [installFile, setInstallFile] = useState(null); + const [downloadingFile, setDownloadingFile] = useState(null); const handleClick = useCallback( (filepath: string) => { @@ -46,33 +46,14 @@ export function ArtifactFileList({ [selectArtifact, setOpen], ); - const handleInstallSkill = useCallback( - async (e: React.MouseEvent, filepath: string) => { - e.stopPropagation(); - e.preventDefault(); - - if (installingFile) return; - - setInstallingFile(filepath); - try { - const result = await installSkill({ - thread_id: threadId, - path: filepath, - }); - if (result.success) { - toast.success(result.message); - } else { - toast.error(result.message || "Failed to install skill"); - } - } catch (error) { - console.error("Failed to install skill:", error); - toast.error("Failed to install skill"); - } finally { - setInstallingFile(null); - } - }, - [threadId, installingFile], - ); + const handleInstallSkill = useCallback( + (e: React.MouseEvent, filepath: string) => { + e.stopPropagation(); + e.preventDefault(); + setInstallFile(filepath); + }, + [], + ); const handleDownload = useCallback( async (e: React.MouseEvent, filepath: string) => { @@ -113,20 +94,15 @@ export function ArtifactFileList({ {getFileExtensionDisplayName(file)} file - {file.endsWith(".skill") && ( - - )} + {file.endsWith(".skill") && ( + + )} + + + + + ); +} diff --git a/frontend-web/src/core/skill-display/inline-citations.test.ts b/frontend-web/src/core/skill-display/inline-citations.test.ts index 1a09a798..db6bf1d2 100644 --- a/frontend-web/src/core/skill-display/inline-citations.test.ts +++ b/frontend-web/src/core/skill-display/inline-citations.test.ts @@ -8,7 +8,7 @@ function makeSources(count: number): RuntimeDisplaySource[] { index: position + 1, skillName: "knowledge-base-search", mode: "citation", - title: `参考文献 ${position + 1}`, + title: `Reference ${position + 1}`, type: "result", snippet: "", raw: {}, @@ -19,7 +19,7 @@ function citation(index: number): string { return `[citation:${index}](#reference-${index})`; } -test("全角单编号 【1】【2】 解析为引用徽章", () => { +test("converts full-width numbered references", () => { const out = applyInlineCitations("结论见【1】【2】。", makeSources(2)); assert.equal(out.includes(citation(1)), true); assert.equal(out.includes(citation(2)), true); @@ -27,21 +27,21 @@ test("全角单编号 【1】【2】 解析为引用徽章", () => { assert.equal(out.includes("【2】"), false); }); -test("全角数字 【1】 也能识别", () => { +test("converts full-width digits", () => { const out = applyInlineCitations("参考【1】和【2】。", makeSources(2)); assert.equal(out.includes(citation(1)), true); assert.equal(out.includes(citation(2)), true); }); -test("全角括号合并编号 【1,2】/【1,2】/【1、2】 拆成多个引用", () => { - for (const marker of ["【1,2】", "【1,2】", "【1、2】"]) { +test("splits merged reference markers", () => { + for (const marker of ["【1,2】", "【1、2】", "【1,2】"]) { const out = applyInlineCitations(`见${marker}。`, makeSources(2)); assert.equal(out.includes(citation(1)), true, marker); assert.equal(out.includes(citation(2)), true, marker); } }); -test("全角区间编号 【1-3】/【2~5】 展开为连续引用", () => { +test("expands reference ranges", () => { const dash = applyInlineCitations("见【1-3】。", makeSources(3)); assert.equal(dash.includes(citation(1)), true); assert.equal(dash.includes(citation(2)), true); @@ -53,21 +53,26 @@ test("全角区间编号 【1-3】/【2~5】 展开为连续引用", () => { assert.equal(tilde.includes(citation(4)), true); }); -test("超出来源范围的全角编号保持原样,不误判正文", () => { - // 只有 1 条来源,【3】 没有对应来源 → 不应转换 +test("does not rewrite out-of-range references", () => { const out = applyInlineCitations("正文【3】不是引用。", makeSources(1)); assert.equal(out.includes("【3】"), true); assert.equal(out.includes("#reference-3"), false); }); -test("非数字的全角括号不被改写", () => { +test("does not rewrite non-numeric bracket text", () => { const out = applyInlineCitations("见【附录】小节。", makeSources(2)); assert.equal(out.includes("【附录】"), true); }); -test("半角写法仍然正常工作", () => { +test("keeps half-width syntax working", () => { const out = applyInlineCitations("见[1][2]和[1,3]。", makeSources(3)); assert.equal(out.includes(citation(1)), true); assert.equal(out.includes(citation(2)), true); assert.equal(out.includes(citation(3)), true); }); + +test("accepts spaced and full-width square brackets", () => { + const out = applyInlineCitations("参考[ 1 ]和[2]。", makeSources(2)); + assert.equal(out.includes(citation(1)), true); + assert.equal(out.includes(citation(2)), true); +}); diff --git a/frontend-web/src/core/skill-display/runtime-parsing.test.ts b/frontend-web/src/core/skill-display/runtime-parsing.test.ts new file mode 100644 index 00000000..35191d4c --- /dev/null +++ b/frontend-web/src/core/skill-display/runtime-parsing.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { Message } from "@langchain/langgraph-sdk"; + +import type { Skill } from "@/core/skills/type"; + +import { buildSkillDisplayMap, collectRuntimeDisplaySources } from "./runtime"; + +function makeSkill(): Skill { + return { + name: "custom-search", + description: "test", + category: "test", + license: "internal", + enabled: true, + display: { + enabled: true, + mode: "citation", + resultPath: "docs", + titleField: "title", + summaryField: "summary", + contentField: "content", + sourceField: "source", + authorField: null, + typeField: null, + timeField: null, + scoreField: null, + urlField: "url", + idField: "id", + linkStrategy: "auto", + linkTemplate: null, + citationToken: "doubleBracket", + maxItems: 20, + customInstruction: "", + tableColumns: [], + resultSets: [], + }, + }; +} + +function makeMessages(toolContent: Message["content"]): Message[] { + return [ + { + id: "ai-1", + type: "ai", + content: "", + tool_calls: [{ id: "call-1", name: "custom-search", args: {} }], + } as unknown as Message, + { + id: "tool-1", + type: "tool", + name: "custom-search", + tool_call_id: "call-1", + content: toolContent, + } as unknown as Message, + ]; +} + +test("collects citations from fenced nested json", () => { + const messages = makeMessages( + "```json\n{\"payload\":{\"docs\":[{\"id\":\"doc-1\",\"title\":\"Alpha\",\"summary\":\"Brief\",\"content\":\"Body\",\"source\":\"KB\",\"url\":\"https://example.com/a\"}]}}\n```", + ); + const sources = collectRuntimeDisplaySources( + messages, + buildSkillDisplayMap([makeSkill()]), + ); + assert.equal(sources.length, 1); + assert.equal(sources[0]?.title, "Alpha"); + assert.equal(sources[0]?.url, "https://example.com/a"); +}); + +test("collects citations from structured tool payload arrays", () => { + const messages = makeMessages([ + { + type: "json", + json: { + wrapper: { + docs: [ + { + id: "doc-2", + title: "Beta", + summary: "Second", + content: "Payload", + source: "Skill", + url: "https://example.com/b", + }, + ], + }, + }, + }, + ] as unknown as Message["content"]); + const sources = collectRuntimeDisplaySources( + messages, + buildSkillDisplayMap([makeSkill()]), + ); + assert.equal(sources.length, 1); + assert.equal(sources[0]?.title, "Beta"); + assert.equal(sources[0]?.source, "Skill"); +}); diff --git a/frontend-web/src/core/skill-display/runtime.ts b/frontend-web/src/core/skill-display/runtime.ts index 877b397b..970c7433 100644 --- a/frontend-web/src/core/skill-display/runtime.ts +++ b/frontend-web/src/core/skill-display/runtime.ts @@ -588,7 +588,7 @@ function replaceBracketCitationTokens( byIndex: Map, orderedSources: RuntimeDisplaySource[], ): string { - const tokenPattern = /\\?\[(\d{1,3})\\?\](?!\()/g; + const tokenPattern = /\\?\[\s*([0-9\uFF10-\uFF19]{1,3})\s*\\?\](?!\()/g; let cursor = 0; let next = ""; for (const match of content.matchAll(tokenPattern)) { @@ -598,13 +598,24 @@ function replaceBracketCitationTokens( continue; } next += content.slice(cursor, start); - next += citationMarkdown(match[0], Number(match[1]), byIndex, orderedSources); + next += citationMarkdown( + match[0], + Number(normalizeAsciiDigits(match[1] ?? "")), + byIndex, + orderedSources, + ); cursor = start + match[0].length; } next += content.slice(cursor); return next; } +function normalizeAsciiDigits(value: string): string { + return value.replace(/[\uFF10-\uFF19]/g, (digit) => + String.fromCharCode(digit.charCodeAt(0) - 0xfee0), + ); +} + function normalizeLooseCitationTokens( content: string, byIndex: Map, @@ -637,13 +648,17 @@ function normalizeLooseCitationTokens( // 避免把正文里的【…】误判成引用。转成半角后交给下面的半角逻辑统一处理。 // 全角单编号:【1】/【1】 - let next = content.replace( + let next = content + .replace(/[/g, "[") + .replace(/]/g, "]") + .replace(/[,、]/g, ",") + .replace( /【\s*([0-90-9]{1,3})\s*】/g, (match, rawIndex: string) => { - const index = Number(toAsciiDigits(rawIndex)); + const index = Number(normalizeAsciiDigits(rawIndex)); return isValidIndex(index) ? `[${index}]` : match; }, - ); + ); // 全角合并编号:【1,2】/【1,2】/【1、2】 next = next.replace( @@ -651,7 +666,7 @@ function normalizeLooseCitationTokens( (match, rawList: string) => { const indexes = rawList .split(/[,,、]/) - .map((part) => Number(toAsciiDigits(part.trim()))); + .map((part) => Number(normalizeAsciiDigits(part.trim()))); if (indexes.some((index) => !isValidIndex(index))) { return match; } @@ -663,8 +678,8 @@ function normalizeLooseCitationTokens( next = next.replace( /【\s*([0-90-9]{1,3})\s*[-–~~]\s*([0-90-9]{1,3})\s*】/g, (match, rawStart: string, rawEnd: string) => { - const start = Number(toAsciiDigits(rawStart)); - const end = Number(toAsciiDigits(rawEnd)); + const start = Number(normalizeAsciiDigits(rawStart)); + const end = Number(normalizeAsciiDigits(rawEnd)); if ( end <= start || end - start > 20 || @@ -978,20 +993,23 @@ function extractStructuredToolPayload(content: unknown): unknown { } function unwrapToolPayloadObject(value: Record): unknown { - if ( - Array.isArray(value.results) || - Array.isArray(value.data) || - Array.isArray(value.items) - ) { - return value; + const directCandidate = findDisplayPayloadCandidate(value, 0); + if (directCandidate !== undefined) { + return directCandidate; } - for (const key of ["result", "output", "stdout", "stderr", "content", "text"]) { - const nested = value[key]; + for (const nested of Object.values(value)) { if (typeof nested === "string") { const parsed = parseJSONLikeText(nested); if (parsed !== undefined) { + if (isRecord(parsed)) { + const structured = findDisplayPayloadCandidate(parsed, 0); + if (structured !== undefined) { + return structured; + } + } return parsed; } + continue; } if (isRecord(nested)) { const unwrapped = unwrapToolPayloadObject(nested); @@ -1008,6 +1026,14 @@ function parseJSONLikeText(text: string): unknown { if (!trimmed) { return undefined; } + const fenced = extractFencedJSON(trimmed); + if (fenced) { + try { + return JSON.parse(fenced) as unknown; + } catch { + // Fall through to best-effort parsing. + } + } try { return JSON.parse(trimmed) as unknown; } catch { @@ -1016,17 +1042,9 @@ function parseJSONLikeText(text: string): unknown { } function extractJSONFromText(text: string): unknown { - const candidates: Array<[number, number]> = []; - const firstObj = text.indexOf("{"); - if (firstObj !== -1) candidates.push([firstObj, text.lastIndexOf("}")]); - const firstArr = text.indexOf("["); - if (firstArr !== -1) candidates.push([firstArr, text.lastIndexOf("]")]); - candidates.sort(([a], [b]) => a - b); - for (const [start, end] of candidates) { - if (start < 0 || end <= start) continue; - const slice = text.slice(start, end + 1); + for (const candidate of iterateBalancedJsonCandidates(text)) { try { - return JSON.parse(slice); + return JSON.parse(candidate); } catch { // try the next candidate } @@ -1034,6 +1052,108 @@ function extractJSONFromText(text: string): unknown { return undefined; } +function *iterateBalancedJsonCandidates(text: string): Generator { + for (let index = 0; index < text.length; index++) { + const char = text[index]; + if (char !== "{" && char !== "[") { + continue; + } + const candidate = extractBalancedJson(text, index); + if (candidate) { + yield candidate; + } + } +} + +function extractBalancedJson(text: string, start: number): string | null { + const opening = text[start]; + if (opening !== "{" && opening !== "[") { + return null; + } + const closing = opening === "{" ? "}" : "]"; + let depth = 0; + let inString = false; + let escaped = false; + for (let index = start; index < text.length; index++) { + const char = text[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === "\"") { + inString = false; + } + continue; + } + if (char === "\"") { + inString = true; + continue; + } + if (char === opening) { + depth += 1; + continue; + } + if (char === closing) { + depth -= 1; + if (depth === 0) { + return text.slice(start, index + 1); + } + } + } + return null; +} + +function extractFencedJSON(text: string): string | null { + const match = /```(?:json)?\s*([\s\S]*?)```/i.exec(text); + return match?.[1]?.trim() ?? null; +} + +function findDisplayPayloadCandidate(value: unknown, depth: number): unknown { + if (depth > 8) { + return undefined; + } + if (Array.isArray(value)) { + const objectItems = value.filter( + (item): item is Record => + isRecord(item), + ); + if (value.length === 0 || objectItems.length === value.length) { + return { results: objectItems }; + } + for (const item of value) { + const nested = findDisplayPayloadCandidate(item, depth + 1); + if (nested !== undefined) { + return nested; + } + } + return undefined; + } + if (!isRecord(value)) { + return undefined; + } + for (const key of ["results", "data", "items", "docs", "records", "rows", "list", "hits", "sources"]) { + const nested = value[key]; + if (!Array.isArray(nested)) { + continue; + } + const objectItems = nested.filter( + (item): item is Record => + isRecord(item), + ); + if (nested.length === 0 || objectItems.length === nested.length) { + return { ...value, [key]: objectItems }; + } + } + for (const nested of Object.values(value)) { + const candidate = findDisplayPayloadCandidate(nested, depth + 1); + if (candidate !== undefined) { + return candidate; + } + } + return undefined; +} + function extractSearchPayloadFromText(text: string): unknown { if (!text.includes('"results"') || !text.includes('"recUuid"')) { return undefined; diff --git a/frontend-web/src/core/skills/api.ts b/frontend-web/src/core/skills/api.ts index e68c2f89..0117455e 100644 --- a/frontend-web/src/core/skills/api.ts +++ b/frontend-web/src/core/skills/api.ts @@ -141,17 +141,39 @@ export async function getSkillDisplaySample( return response.json() as Promise; } -export interface InstallSkillRequest { - thread_id: string; - path: string; -} - -export interface InstallSkillResponse { - success: boolean; - skill_name: string; - message: string; - overwritten?: boolean; -} +export interface InstallSkillRequest { + thread_id: string; + path: string; + skill_name?: string; +} + +export interface InstallSkillResponse { + success: boolean; + skill_name: string; + message: string; + overwritten?: boolean; +} + +export interface PreviewSkillInstallRequest { + thread_id: string; + path: string; +} + +export interface PreviewSkillInstallResponse { + ok: boolean; + skill_name: string; + description: string; + license: string | null; + allowed_tools: string[]; + can_install_as_is: boolean; + owned_by_me: boolean; + owned_by_other: boolean; + builtin_conflict: boolean; + owner_username?: string | null; + owner_user_id?: string | null; + message: string; + warnings: string[]; +} export interface ValidateSkillUploadResponse { ok: boolean; @@ -164,9 +186,9 @@ export interface ValidateSkillUploadResponse { warnings: string[]; } -export async function installSkill( - request: InstallSkillRequest, -): Promise { +export async function installSkill( + request: InstallSkillRequest, +): Promise { const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install`, { method: "POST", headers: { @@ -187,8 +209,24 @@ export async function installSkill( }; } - return response.json(); -} + return response.json(); +} + +export async function previewSkillInstall( + request: PreviewSkillInstallRequest, +): Promise { + const response = await apiFetch(`${getBackendBaseURL()}/api/skills/install-preview`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + if (!response.ok) { + throw await parseDetailedError(response, "Failed to preview skill install"); + } + return response.json() as Promise; +} async function parseDetailedError(response: Response, fallback: string): Promise { const data = await response.json().catch(() => ({})); diff --git a/offline-backend-20260512/backend/app/gateway/routers/skills.py b/offline-backend-20260512/backend/app/gateway/routers/skills.py index 18a3a2c0..47b14483 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/skills.py +++ b/offline-backend-20260512/backend/app/gateway/routers/skills.py @@ -4,9 +4,11 @@ import logging import mimetypes import os import re +import shutil import subprocess import sys import tempfile +import zipfile from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -220,6 +222,7 @@ class SkillInstallRequest(BaseModel): 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)") + skill_name: str | None = Field(default=None, description="Optional custom install name. When set, rewrites the archive SKILL.md frontmatter name before install.") target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'") @@ -233,6 +236,32 @@ class SkillInstallResponse(BaseModel): category: str = Field(default="custom", description="Destination category: 'public' (admin) or 'custom'") +class SkillInstallPreviewRequest(BaseModel): + """Request model for preflighting a .skill artifact install.""" + + thread_id: str = Field(..., description="The thread ID where the .skill file is located") + path: str = Field(..., description="Virtual path to the .skill file") + target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'") + + +class SkillInstallPreviewResponse(BaseModel): + """Response model for skill install preflight.""" + + ok: bool = Field(..., description="Whether the archive could be parsed") + skill_name: str = Field(..., description="Skill name parsed from the archive frontmatter") + description: str = Field(default="", description="Parsed description from the archive frontmatter") + license: str | None = Field(default=None, description="Parsed license from the archive frontmatter") + allowed_tools: list[str] = Field(default_factory=list, description="Allowed tools declared in the archive") + can_install_as_is: bool = Field(default=True, description="Whether the current user may install the archive without renaming") + owned_by_me: bool = Field(default=False, description="True when the existing conflicting skill is already owned by the caller") + owned_by_other: bool = Field(default=False, description="True when another user already owns the conflicting custom skill") + builtin_conflict: bool = Field(default=False, description="True when the parsed name collides with a built-in/public skill") + owner_username: str | None = Field(default=None, description="Friendly owner username for owned_by_other conflicts") + owner_user_id: str | None = Field(default=None, description="Owner user id for custom conflicts") + message: str = Field(default="", description="Human-readable install guidance for the UI") + warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings from validation") + + class SkillUploadValidateResponse(BaseModel): """Response model for the pre-install validation endpoint.""" @@ -1040,6 +1069,162 @@ async def install_skill_upload( tmp_path.unlink(missing_ok=True) +async def _preview_install_target( + *, + request: Request, + skill_store: SkillStore, + skill_name: str, + target_category: SkillCategory, +) -> tuple[bool, bool, bool, bool, str | None, str | None, str]: + """Return installability / ownership info for a parsed skill name.""" + + user_id = _current_user_id(request) + is_admin = _is_admin_user(request) + existing = await skill_store.get_any(skill_name) + owner_user_id = existing.get("owner_user_id") if existing else None + owner_username: str | None = None + if owner_user_id: + try: + attached = await _attach_owner_usernames( + [SkillResponse(name=skill_name, description="", license=None, category=SkillCategory.CUSTOM, owner_user_id=owner_user_id)] + ) + owner_username = attached[0].owner_username if attached else None + except Exception: + owner_username = None + + builtin_conflict = bool(existing and owner_user_id is None) + owned_by_me = bool(existing and owner_user_id == user_id) + owned_by_other = bool(existing and owner_user_id not in (None, user_id)) + + if target_category == SkillCategory.PUBLIC and not is_admin: + return False, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, "Only admin users can install skills to the public directory" + if builtin_conflict: + return False, owned_by_me, owned_by_other, True, owner_user_id, owner_username, f"Skill '{skill_name}' already exists as a built-in/public skill. Please install it under a different name." + if owned_by_other: + who = owner_username or "another user" + return False, owned_by_me, True, builtin_conflict, owner_user_id, owner_username, f"Skill '{skill_name}' has already been installed by {who}. Please choose a different name." + if owned_by_me: + return False, True, False, builtin_conflict, owner_user_id, owner_username, f"You have already installed skill '{skill_name}'. You can keep using it or install this copy under a different name." + return True, False, False, builtin_conflict, owner_user_id, owner_username, f"Skill '{skill_name}' can be installed." + + +async def _build_renamed_skill_archive( + archive_path: str | Path, + *, + new_name: str, + require_skill_suffix: bool, +) -> Path: + """Create a temporary archive whose SKILL.md frontmatter name is rewritten.""" + + from deerflow.skills.installer import resolve_skill_dir_from_archive, safe_extract_skill_archive + + SkillStorage.validate_skill_name(new_name) + tmp_root = Path(tempfile.mkdtemp(prefix="skill-install-rename-")) + try: + path = Path(archive_path) + if require_skill_suffix and path.suffix != ".skill": + raise ValueError("File must have .skill extension") + with zipfile.ZipFile(path, "r") as zf: + safe_extract_skill_archive(zf, tmp_root) + skill_dir = resolve_skill_dir_from_archive(tmp_root) + skill_md = skill_dir / SKILL_MD_FILE + skill_md.write_text( + _rewrite_skill_frontmatter_name(skill_md.read_text(encoding="utf-8"), new_name), + encoding="utf-8", + ) + renamed_archive = tmp_root / f"{new_name}.skill" + with zipfile.ZipFile(renamed_archive, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in sorted(tmp_root.rglob("*")): + if not path.is_file() or path == renamed_archive: + continue + zf.write(path, path.relative_to(tmp_root)) + return renamed_archive + except Exception: + shutil.rmtree(tmp_root, ignore_errors=True) + raise + + +async def _install_archive_with_optional_name( + *, + storage: SkillStorage, + archive_path: str | Path, + requested_name: str | None, + target_category: SkillCategory, + overwrite: bool = False, + require_skill_suffix: bool = True, +) -> SkillInstallResponse: + normalized_requested = SkillStorage.validate_skill_name(requested_name) if requested_name else None + temp_root: Path | None = None + install_source = Path(archive_path) + if normalized_requested: + renamed_archive = await _build_renamed_skill_archive( + archive_path, + new_name=normalized_requested, + require_skill_suffix=require_skill_suffix, + ) + temp_root = renamed_archive.parent + install_source = renamed_archive + try: + result = await storage.ainstall_skill_from_archive( + install_source, + overwrite=overwrite, + require_skill_suffix=require_skill_suffix, + target_category=target_category, + ) + return SkillInstallResponse(**result) + finally: + if temp_root is not None: + shutil.rmtree(temp_root, ignore_errors=True) + + +@router.post( + "/skills/install-preview", + response_model=SkillInstallPreviewResponse, + summary="Preview Skill Install", + description="Parse a .skill artifact from a thread and return ownership/conflict info before install.", +) +async def preview_skill_install( + body: SkillInstallPreviewRequest, + request: Request, + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), +) -> SkillInstallPreviewResponse: + try: + skill_file_path = await aresolve_thread_virtual_path(body.thread_id, body.path) + validation = get_or_new_skill_storage(app_config=config).validate_skill_archive(skill_file_path, require_skill_suffix=False) + skill_name = str(validation.get("skill_name") or "") + can_install_as_is, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, message = await _preview_install_target( + request=request, + skill_store=skill_store, + skill_name=skill_name, + target_category=body.target_category, + ) + return SkillInstallPreviewResponse( + ok=True, + skill_name=skill_name, + description=str(validation.get("description") or ""), + license=validation.get("license"), + allowed_tools=[str(item) for item in (validation.get("allowed_tools") or [])], + can_install_as_is=can_install_as_is, + owned_by_me=owned_by_me, + owned_by_other=owned_by_other, + builtin_conflict=builtin_conflict, + owner_username=owner_username, + owner_user_id=owner_user_id, + message=message, + warnings=[str(item) for item in (validation.get("warnings") or [])], + ) + 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 HTTPException: + raise + except Exception as e: + logger.error("Failed to preview skill install: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to preview skill install: {e}") + + @router.post( "/skills/install", response_model=SkillInstallResponse, @@ -1056,25 +1241,46 @@ async def install_skill( user_id = _current_user_id(request) is_admin = _is_admin_user(request) target_category = body.target_category + requested_name = SkillStorage.validate_skill_name(body.skill_name) if body.skill_name else None 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 = await aresolve_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) + storage = get_or_new_skill_storage(app_config=config) + validation = storage.validate_skill_archive(skill_file_path, require_skill_suffix=False) + preview_name = requested_name or str(validation.get("skill_name") or "") + can_install_as_is, owned_by_me, owned_by_other, builtin_conflict, _owner_user_id, _owner_username, message = await _preview_install_target( + request=request, + skill_store=skill_store, + skill_name=preview_name, + target_category=target_category, + ) + if not can_install_as_is: + if requested_name and requested_name != validation.get("skill_name") and not owned_by_other and not builtin_conflict: + pass + else: + raise HTTPException(status_code=409, detail=message) + result = await _install_archive_with_optional_name( + storage=storage, + archive_path=skill_file_path, + requested_name=requested_name, + target_category=target_category, + require_skill_suffix=False, + ) try: from deerflow.skills.usage import mark_uploaded - mark_uploaded(result["skill_name"]) + 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} + {"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} + {"name": result.skill_name, "owner_user_id": user_id, "published": False} ) await refresh_skills_system_prompt_cache_async() - return SkillInstallResponse(**result) + return result except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except SkillAlreadyExistsError as e: