diff --git a/.gitattributes b/.gitattributes index 2caf3224..c8dc0c21 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,11 +1,5 @@ -*.mp4 filter=lfs diff=lfs merge=lfs -text -*.mov filter=lfs diff=lfs merge=lfs -text -*.gif filter=lfs diff=lfs merge=lfs -text -*.png filter=lfs diff=lfs merge=lfs -text -*.jpg filter=lfs diff=lfs merge=lfs -text -*.jpeg filter=lfs diff=lfs merge=lfs -text -*.webp filter=lfs diff=lfs merge=lfs -text -*.webm filter=lfs diff=lfs merge=lfs -text +# All media in assets/ stored as regular blobs (LFS budget exhausted) +assets/** !filter !diff !merge text=auto # Small UI bundled images — keep as regular git blobs so Vite can inline them ui/src/assets/** !filter !diff !merge text=auto diff --git a/assets/awo.gif b/assets/awo.gif index da0e1e69..f8dd499f 100644 Binary files a/assets/awo.gif and b/assets/awo.gif differ diff --git a/assets/awo_en.gif b/assets/awo_en.gif index 19163840..dcace79b 100644 Binary files a/assets/awo_en.gif and b/assets/awo_en.gif differ diff --git a/assets/banner.png b/assets/banner.png index 83e2584d..251040fa 100644 Binary files a/assets/banner.png and b/assets/banner.png differ diff --git a/assets/community/qr-discord.png b/assets/community/qr-discord.png index 387ecb05..a3742883 100644 Binary files a/assets/community/qr-discord.png and b/assets/community/qr-discord.png differ diff --git a/assets/community/qr-feishu.png b/assets/community/qr-feishu.png index 1ecb0303..f22d618c 100644 Binary files a/assets/community/qr-feishu.png and b/assets/community/qr-feishu.png differ diff --git a/assets/community/qr-wechat.png b/assets/community/qr-wechat.png index f791e95a..b770bb7c 100644 Binary files a/assets/community/qr-wechat.png and b/assets/community/qr-wechat.png differ diff --git a/assets/en/iosgame_en.gif b/assets/en/iosgame_en.gif index b4c5bae5..720e0d54 100644 Binary files a/assets/en/iosgame_en.gif and b/assets/en/iosgame_en.gif differ diff --git a/assets/en/iosgame_en.mp4 b/assets/en/iosgame_en.mp4 index 3a2ba356..9cbac49d 100644 Binary files a/assets/en/iosgame_en.mp4 and b/assets/en/iosgame_en.mp4 differ diff --git a/assets/en/modeltraining_en.gif b/assets/en/modeltraining_en.gif index 905050b7..971e9a9b 100644 Binary files a/assets/en/modeltraining_en.gif and b/assets/en/modeltraining_en.gif differ diff --git a/assets/en/modeltraining_en.mp4 b/assets/en/modeltraining_en.mp4 index 343bf557..1127ce24 100644 Binary files a/assets/en/modeltraining_en.mp4 and b/assets/en/modeltraining_en.mp4 differ diff --git a/assets/en/podcast_en.gif b/assets/en/podcast_en.gif index 6180a5a8..708526d9 100644 Binary files a/assets/en/podcast_en.gif and b/assets/en/podcast_en.gif differ diff --git a/assets/en/podcast_en.mp4 b/assets/en/podcast_en.mp4 index dc421a95..1c7b66fe 100644 Binary files a/assets/en/podcast_en.mp4 and b/assets/en/podcast_en.mp4 differ diff --git a/assets/en/ppt_en.gif b/assets/en/ppt_en.gif index ff97aaeb..012816b8 100644 Binary files a/assets/en/ppt_en.gif and b/assets/en/ppt_en.gif differ diff --git a/assets/en/ppt_en.mp4 b/assets/en/ppt_en.mp4 index c13ee2b0..c00b0889 100644 Binary files a/assets/en/ppt_en.mp4 and b/assets/en/ppt_en.mp4 differ diff --git a/assets/memory.gif b/assets/memory.gif index 785ddd6a..be1a9307 100644 Binary files a/assets/memory.gif and b/assets/memory.gif differ diff --git a/assets/memory_en.gif b/assets/memory_en.gif index 8e23e292..bbf918ba 100644 Binary files a/assets/memory_en.gif and b/assets/memory_en.gif differ diff --git a/assets/result/ios_game_result.gif b/assets/result/ios_game_result.gif index 2d1a899c..1da8eb8f 100644 Binary files a/assets/result/ios_game_result.gif and b/assets/result/ios_game_result.gif differ diff --git a/assets/result/modeltraining_result_zh.gif b/assets/result/modeltraining_result_zh.gif index 654e525b..d5270c6f 100644 Binary files a/assets/result/modeltraining_result_zh.gif and b/assets/result/modeltraining_result_zh.gif differ diff --git a/assets/result/modeltraining_result_zh.mov b/assets/result/modeltraining_result_zh.mov index 5d4c5b28..c952f338 100644 Binary files a/assets/result/modeltraining_result_zh.mov and b/assets/result/modeltraining_result_zh.mov differ diff --git a/assets/result/modeltrainingresult_en.gif b/assets/result/modeltrainingresult_en.gif index 1a4f6e4f..bb24d828 100644 Binary files a/assets/result/modeltrainingresult_en.gif and b/assets/result/modeltrainingresult_en.gif differ diff --git a/assets/result/modeltrainingresult_en.mov b/assets/result/modeltrainingresult_en.mov index da08b91a..bde7db00 100644 Binary files a/assets/result/modeltrainingresult_en.mov and b/assets/result/modeltrainingresult_en.mov differ diff --git a/assets/result/podcast_result.gif b/assets/result/podcast_result.gif index a7a1afed..c91bc034 100644 Binary files a/assets/result/podcast_result.gif and b/assets/result/podcast_result.gif differ diff --git a/assets/result/podcast_result.mov b/assets/result/podcast_result.mov index 438020c6..866f2207 100644 Binary files a/assets/result/podcast_result.mov and b/assets/result/podcast_result.mov differ diff --git a/assets/result/podcast_result.mp4 b/assets/result/podcast_result.mp4 index fc61328d..7b494e80 100644 Binary files a/assets/result/podcast_result.mp4 and b/assets/result/podcast_result.mp4 differ diff --git a/assets/result/ppt_result_en.gif b/assets/result/ppt_result_en.gif index c0ca6f8e..34d08b41 100644 Binary files a/assets/result/ppt_result_en.gif and b/assets/result/ppt_result_en.gif differ diff --git a/assets/result/ppt_result_en.mp4 b/assets/result/ppt_result_en.mp4 index f96407a1..c3c758b3 100644 Binary files a/assets/result/ppt_result_en.mp4 and b/assets/result/ppt_result_en.mp4 differ diff --git a/assets/result/ppt_result_zh.gif b/assets/result/ppt_result_zh.gif index 525cdd4a..ace15a6e 100644 Binary files a/assets/result/ppt_result_zh.gif and b/assets/result/ppt_result_zh.gif differ diff --git a/assets/result/ppt_result_zh.mp4 b/assets/result/ppt_result_zh.mp4 index 27768a93..70026c87 100644 Binary files a/assets/result/ppt_result_zh.mp4 and b/assets/result/ppt_result_zh.mp4 differ diff --git a/assets/router.gif b/assets/router.gif index 0c6d50cd..2a33cbbc 100644 Binary files a/assets/router.gif and b/assets/router.gif differ diff --git a/assets/workspace.gif b/assets/workspace.gif index 04ca4ca5..116cd12c 100644 Binary files a/assets/workspace.gif and b/assets/workspace.gif differ diff --git a/assets/workspace_en.gif b/assets/workspace_en.gif index 1587d0ad..fb8e5484 100644 Binary files a/assets/workspace_en.gif and b/assets/workspace_en.gif differ diff --git a/assets/zh/iosgame_zh.gif b/assets/zh/iosgame_zh.gif index dfd2574c..36822d38 100644 Binary files a/assets/zh/iosgame_zh.gif and b/assets/zh/iosgame_zh.gif differ diff --git a/assets/zh/iosgame_zh.mp4 b/assets/zh/iosgame_zh.mp4 index 4dcc1933..6e3e8e97 100644 Binary files a/assets/zh/iosgame_zh.mp4 and b/assets/zh/iosgame_zh.mp4 differ diff --git a/assets/zh/modeltraining_zh.gif b/assets/zh/modeltraining_zh.gif index e84cda61..19d6c3cb 100644 Binary files a/assets/zh/modeltraining_zh.gif and b/assets/zh/modeltraining_zh.gif differ diff --git a/assets/zh/modeltraining_zh.mp4 b/assets/zh/modeltraining_zh.mp4 index d976b9c3..72808acc 100644 Binary files a/assets/zh/modeltraining_zh.mp4 and b/assets/zh/modeltraining_zh.mp4 differ diff --git a/assets/zh/podcast_zh.gif b/assets/zh/podcast_zh.gif index 742599b3..14d30408 100644 Binary files a/assets/zh/podcast_zh.gif and b/assets/zh/podcast_zh.gif differ diff --git a/assets/zh/podcast_zh.mp4 b/assets/zh/podcast_zh.mp4 index f30890d2..b359535a 100644 Binary files a/assets/zh/podcast_zh.mp4 and b/assets/zh/podcast_zh.mp4 differ diff --git a/assets/zh/ppt_zh.gif b/assets/zh/ppt_zh.gif index f46fa2d6..0b44753e 100644 Binary files a/assets/zh/ppt_zh.gif and b/assets/zh/ppt_zh.gif differ diff --git a/assets/zh/ppt_zh.mp4 b/assets/zh/ppt_zh.mp4 index 69be0317..7e466c74 100644 Binary files a/assets/zh/ppt_zh.mp4 and b/assets/zh/ppt_zh.mp4 differ diff --git a/package.json b/package.json index 583d69ae..469078ea 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "install:browser": "npx @playwright/mcp install-browser chrome-for-testing", "prebuild": "node scripts/bootstrap-pilotdeck-config.mjs && cd src/context/memory/edgeclaw-memory-core && npm run build", "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/extension/plugins/builtin','dist/src/extension/plugins/builtin',{recursive:true})\"", + "preserver": "node scripts/bootstrap-pilotdeck-config.mjs", "server": "tsx src/cli/pilotdeck.ts server", + "preserver:built": "node scripts/bootstrap-pilotdeck-config.mjs", "server:built": "node dist/src/cli/pilotdeck.js server", "skills:migrate": "tsx src/cli/pilotdeck.ts skills migrate", "predev": "node scripts/bootstrap-pilotdeck-config.mjs", diff --git a/scripts/dev-launcher.mjs b/scripts/dev-launcher.mjs index 58b260a3..4c1d8372 100644 --- a/scripts/dev-launcher.mjs +++ b/scripts/dev-launcher.mjs @@ -22,11 +22,15 @@ import { spawn } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { createServer } from 'node:net'; -import { homedir } from 'node:os'; +import { homedir, platform } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse as parseYaml } from 'yaml'; +// On Windows npm is actually npm.cmd — spawning plain 'npm' fails with ENOENT. +// The shell: true flag also lets Windows find the batch wrapper. +const npmCommand = platform() === 'win32' ? 'npm.cmd' : 'npm'; + const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '..'); @@ -120,9 +124,9 @@ async function main() { }; const child = spawn( - 'npm', + npmCommand, ['--workspace', 'ui', 'run', 'dev:concurrent'], - { cwd: repoRoot, env, stdio: 'inherit' }, + { cwd: repoRoot, env, stdio: 'inherit', shell: platform() === 'win32' }, ); const forward = (signal) => { diff --git a/src/extension/skills/SkillManager.ts b/src/extension/skills/SkillManager.ts index b4065c93..4b456872 100644 --- a/src/extension/skills/SkillManager.ts +++ b/src/extension/skills/SkillManager.ts @@ -38,6 +38,8 @@ const SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$/; const MAX_TOTAL_BYTES = 50 * 1024 * 1024; const MAX_FILE_BYTES = 10 * 1024 * 1024; const MAX_FILE_COUNT = 500; +const CANONICAL_SKILL_MD = "SKILL.md"; +const SKILL_MD_RE = /^skill\.md$/iu; const RISKY_EXTS = new Set([ ".sh", ".bash", @@ -145,7 +147,10 @@ export class SkillManager { async read(input: SkillAddressInput): Promise { const skillDir = this.resolveSkillDir(input); - const skillFile = join(skillDir, "SKILL.md"); + const skillFile = await findSkillMdFile(skillDir); + if (!skillFile) { + throw new SkillManagerError("not_found", `SKILL.md not found at ${join(skillDir, CANONICAL_SKILL_MD)}.`); + } let content: string; try { content = await fs.readFile(skillFile, "utf8"); @@ -165,7 +170,7 @@ export class SkillManager { } const skillDir = this.resolveSkillDir(input); await fs.mkdir(skillDir, { recursive: true }); - const skillFile = join(skillDir, "SKILL.md"); + const skillFile = await findSkillMdFile(skillDir) ?? join(skillDir, CANONICAL_SKILL_MD); await fs.writeFile(skillFile, input.content, "utf8"); const skill = await readSkillMeta(skillDir, input.scope); return { ok: true, scope: input.scope, slug: input.slug, skill }; @@ -195,7 +200,7 @@ export class SkillManager { description: input.description, body: input.body, }); - const skillFile = join(skillDir, "SKILL.md"); + const skillFile = join(skillDir, CANONICAL_SKILL_MD); await fs.writeFile(skillFile, finalContent, "utf8"); const skill = await readSkillMeta(skillDir, input.scope); return { @@ -257,9 +262,7 @@ export class SkillManager { `Source path is not a directory: ${resolvedSource}`, ); } - try { - await fs.access(join(resolvedSource, "SKILL.md")); - } catch { + if (!(await findSkillMdFile(resolvedSource))) { throw new SkillManagerError( "no_skill_md", `Source folder does not contain a SKILL.md at the root: ${resolvedSource}`, @@ -364,12 +367,10 @@ export class SkillManager { const subDir = join(resolvedRoot, entry.name); let hasSkillMd = false; let meta: SkillSummary | null = null; - try { - await fs.access(join(subDir, "SKILL.md")); + const skillFile = await findSkillMdFile(subDir); + if (skillFile) { hasSkillMd = true; meta = await readSkillMeta(subDir, "user"); - } catch { - /* no SKILL.md */ } let fileCount = 0; @@ -454,6 +455,26 @@ function expandHome(p: string): string { return p; } +function isSkillMdFileName(name: string): boolean { + return SKILL_MD_RE.test(name); +} + +function isRootSkillMdPath(relativePath: string): boolean { + const normalized = relativePath.replace(/\\/g, "/"); + return !normalized.includes("/") && isSkillMdFileName(normalized); +} + +async function findSkillMdFile(skillDir: string): Promise { + let entries: string[]; + try { + entries = await fs.readdir(skillDir); + } catch { + return null; + } + const name = entries.find(isSkillMdFileName); + return name ? join(skillDir, name) : null; +} + /** * Build a fresh SKILL.md from user-supplied fields. We emit a minimal * YAML frontmatter block (just `name` and `description`) plus a markdown @@ -562,7 +583,8 @@ function parseCompatFrontmatter(fmRaw: string): Record { } async function readSkillMeta(skillDir: string, scope: SkillScope): Promise { - const skillFile = join(skillDir, "SKILL.md"); + const skillFile = await findSkillMdFile(skillDir); + if (!skillFile) return null; let content: string; try { content = await fs.readFile(skillFile, "utf8"); @@ -703,10 +725,15 @@ async function validateFromDisk(sourcePath: string): Promise> = [ "openclaw", "hermes", ]; +const SKILL_MD_RE = /^skill\.md$/iu; export async function migrateSkillsToPilotDeck( options: MigrateSkillsToPilotDeckOptions, @@ -241,32 +242,45 @@ function dedupeSources(sources: SkillMigrationSource[]): SkillMigrationSource[] async function discoverSkillDirs(sourceRoot: string): Promise { try { - await access(join(sourceRoot, "SKILL.md")); - return [sourceRoot]; - } catch { - /* Source root is a parent directory, not a single skill. */ + if (await hasSkillMdAtRoot(sourceRoot)) { + return [sourceRoot]; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; } - let entries: import("node:fs").Dirent[]; try { - entries = await readdir(sourceRoot, { withFileTypes: true }); + await access(sourceRoot); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } - const dirs: string[] = []; - for (const entry of entries) { - if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; - const skillDir = join(sourceRoot, entry.name); - try { - await access(join(skillDir, "SKILL.md")); - dirs.push(skillDir); - } catch { - continue; + try { + const statEntries = await readdir(sourceRoot, { withFileTypes: true }); + const dirs: string[] = []; + for (const entry of statEntries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + const skillDir = join(sourceRoot, entry.name); + try { + if (await hasSkillMdAtRoot(skillDir)) { + dirs.push(skillDir); + } + } catch { + continue; + } } + return dirs.sort((a, b) => basename(a).localeCompare(basename(b))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; } - return dirs.sort((a, b) => basename(a).localeCompare(basename(b))); +} + +async function hasSkillMdAtRoot(directory: string): Promise { + const entries = await readdir(directory); + return entries.some((entry) => SKILL_MD_RE.test(entry)); } async function resolveDestinationSlug( diff --git a/src/model/catalog/providers.ts b/src/model/catalog/providers.ts index c1dc3fdd..880ad4df 100644 --- a/src/model/catalog/providers.ts +++ b/src/model/catalog/providers.ts @@ -505,122 +505,6 @@ export const PROVIDER_CATALOG: ProviderCatalog = { }, }, - // ── Volcano Ark (火山方舟) ──────────────────────────────────────────── - - volc_ark: { - displayName: "火山方舟 (Volcano Ark)", - protocol: "openai", - defaultUrl: "https://ark.cn-beijing.volces.com/api/v3", - apiKeyEnvVar: "VOLC_ARK_API_KEY", - models: { - "doubao-1.5-pro-256k": { - displayName: "Doubao 1.5 Pro 256K", - capabilities: { - supportsToolUse: true, - supportsStreaming: true, - supportsParallelToolCalls: true, - supportsThinking: false, - supportsJsonSchema: true, - supportsSystemPrompt: true, - supportsPromptCache: false, - maxContextTokens: 262144, - maxOutputTokens: 16384, - }, - multimodal: { - input: ["text", "image"], - maxImagesPerRequest: 10, - supportedImageMimeTypes: ["image/jpeg", "image/png", "image/webp"], - imageDetail: "auto", - }, - aliases: [], - }, - "doubao-1.5-pro": { - displayName: "Doubao 1.5 Pro", - capabilities: { - supportsToolUse: true, - supportsStreaming: true, - supportsParallelToolCalls: true, - supportsThinking: false, - supportsJsonSchema: true, - supportsSystemPrompt: true, - supportsPromptCache: false, - maxContextTokens: 131072, - maxOutputTokens: 16384, - }, - multimodal: { - input: ["text", "image"], - maxImagesPerRequest: 10, - supportedImageMimeTypes: ["image/jpeg", "image/png", "image/webp"], - imageDetail: "auto", - }, - aliases: [], - }, - "doubao-1.5-lite-128k": { - displayName: "Doubao 1.5 Lite 128K", - capabilities: { - supportsToolUse: true, - supportsStreaming: true, - supportsParallelToolCalls: true, - supportsThinking: false, - supportsJsonSchema: true, - supportsSystemPrompt: true, - supportsPromptCache: false, - maxContextTokens: 131072, - maxOutputTokens: 8192, - }, - multimodal: { - input: ["text"], - maxImagesPerRequest: 0, - supportedImageMimeTypes: [], - imageDetail: "auto", - }, - aliases: [], - }, - "doubao-1.5-lite": { - displayName: "Doubao 1.5 Lite", - capabilities: { - supportsToolUse: true, - supportsStreaming: true, - supportsParallelToolCalls: true, - supportsThinking: false, - supportsJsonSchema: true, - supportsSystemPrompt: true, - supportsPromptCache: false, - maxContextTokens: 32768, - maxOutputTokens: 8192, - }, - multimodal: { - input: ["text"], - maxImagesPerRequest: 0, - supportedImageMimeTypes: [], - imageDetail: "auto", - }, - aliases: [], - }, - "deepseek-r1": { - displayName: "DeepSeek R1 (Volc)", - capabilities: { - supportsToolUse: true, - supportsStreaming: true, - supportsParallelToolCalls: true, - supportsThinking: true, - supportsJsonSchema: true, - supportsSystemPrompt: true, - supportsPromptCache: false, - maxContextTokens: 65536, - maxOutputTokens: 16384, - }, - multimodal: { - input: ["text"], - maxImagesPerRequest: 0, - supportedImageMimeTypes: [], - imageDetail: "auto", - }, - aliases: [], - }, - }, - }, - // ── Proxy providers (no built-in models; use cross-provider lookup) ─── openrouter: { diff --git a/tests/extension/skills/skill-manager.test.ts b/tests/extension/skills/skill-manager.test.ts index f625ebd3..0b0fddd9 100644 --- a/tests/extension/skills/skill-manager.test.ts +++ b/tests/extension/skills/skill-manager.test.ts @@ -1,91 +1,82 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { SkillManager } from "../../../src/extension/skills/SkillManager.js"; +import { SkillManager } from "../../../src/extension/skills/index.js"; -async function withManager(fn: (manager: SkillManager, pilotHome: string) => Promise): Promise { - const pilotHome = await mkdtemp(join(tmpdir(), "pilotdeck-skills-test-")); +const VALID_SKILL_MD = [ + "---", + "name: Caps Skill", + "description: A useful skill with an uppercase markdown filename.", + "---", + "", + "# Caps Skill", + "", +].join("\n"); + +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), "pilotdeck-skill-test-")); try { - return await fn(new SkillManager({ pilotHome }), pilotHome); + return await fn(dir); } finally { - await rm(pilotHome, { recursive: true, force: true }); + await rm(dir, { recursive: true, force: true }); } } -function validateSkillMd(manager: SkillManager, skillMdContent: string) { - return manager.validate({ - skillMdContent, - files: [{ relativePath: "SKILL.md", size: Buffer.byteLength(skillMdContent) }], - }); -} +test("SkillManager accepts SKILL.MD for path-based skill import and reads the imported skill", async () => { + await withTempDir(async (dir) => { + const sourceParent = join(dir, "sources"); + const sourceSkill = join(sourceParent, "caps-skill"); + await mkdir(sourceSkill, { recursive: true }); + await writeFile(join(sourceSkill, "SKILL.MD"), VALID_SKILL_MD, { encoding: "utf8" }); -test("skill validation accepts standard YAML frontmatter", async () => { - await withManager(async (manager) => { - const result = await validateSkillMd( - manager, - [ - "---", - "name: pptx", - "description: Work with PowerPoint decks and .pptx files.", - "---", - "", - "# PPTX", - ].join("\n"), - ); + const manager = new SkillManager({ pilotHome: join(dir, "pilot-home") }); - assert.equal(result.ok, true); - assert.equal(result.frontmatter?.name, "pptx"); - assert.equal(result.frontmatter?.description, "Work with PowerPoint decks and .pptx files."); - assert.equal(result.warnings.some((w) => w.code === "frontmatter_compat_fallback"), false); - }); -}); + const scan = await manager.scan({ parentPath: sourceParent }); + assert.equal(scan.folders.length, 1); + assert.equal(scan.folders[0]?.hasSkillMd, true); + assert.equal(scan.folders[0]?.name, "Caps Skill"); + + const validation = await manager.validate({ sourcePath: sourceSkill }); + assert.equal(validation.ok, true); + + const imported = await manager.import({ + sourcePath: sourceSkill, + scope: "user", + mode: "copy", + }); + assert.equal(imported.ok, true); + assert.equal(imported.skill?.name, "Caps Skill"); + + const listed = await manager.list({}); + assert.deepEqual(listed.user.map((skill) => skill.slug), ["caps-skill"]); + + const read = await manager.read({ scope: "user", slug: "caps-skill" }); + assert.match(read.content, /# Caps Skill/); -test("skill validation accepts OpenClaw-style description block without YAML spacing", async () => { - await withManager(async (manager) => { - const result = await validateSkillMd( - manager, - [ - "---", - "name: pptx", - "description:>", - "当涉及 .pptx 文件时使用此技能。", - "编辑、修改或更新现有演示文稿。", - "---", - "", - "# PPTX", - ].join("\n"), - ); + await manager.write({ + scope: "user", + slug: "caps-skill", + content: VALID_SKILL_MD.replace("# Caps Skill", "# Updated Skill"), + }); - assert.equal(result.ok, true); - assert.equal(result.frontmatter?.name, "pptx"); - assert.equal( - result.frontmatter?.description, - "当涉及 .pptx 文件时使用此技能。\n编辑、修改或更新现有演示文稿。", - ); - assert.equal(result.warnings.some((w) => w.code === "frontmatter_compat_fallback"), true); + const targetUppercase = join(imported.skillPath, "SKILL.MD"); + assert.equal((await stat(targetUppercase)).isFile(), true); + assert.match(await readFile(targetUppercase, "utf8"), /# Updated Skill/); }); }); -test("skill validation still fails when required frontmatter fields are missing", async () => { - await withManager(async (manager) => { - const result = await validateSkillMd( - manager, - [ - "---", - "description: Work with PowerPoint decks and .pptx files.", - "---", - "", - "# PPTX", - ].join("\n"), - ); +test("SkillManager validates uploaded manifest SKILL.MD files case-insensitively", async () => { + await withTempDir(async (dir) => { + const manager = new SkillManager({ pilotHome: join(dir, "pilot-home") }); + const validation = await manager.validate({ + skillMdContent: VALID_SKILL_MD, + files: [{ relativePath: "SKILL.MD", size: Buffer.byteLength(VALID_SKILL_MD) }], + }); - assert.equal(result.ok, false); - assert.equal( - result.hardFails.some((issue) => issue.code === "frontmatter_missing_name"), - true, - ); + assert.equal(validation.ok, true); + assert.equal(validation.frontmatter?.name, "Caps Skill"); }); }); diff --git a/ui/package.json b/ui/package.json index 57be23c5..8ad21525 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,6 +12,7 @@ "dev:gateway": "npm --prefix .. run server", "dev:server": "node --import tsx server/index.js", "dev:client": "vite", + "preserver": "node ../scripts/bootstrap-pilotdeck-config.mjs", "server": "node --import tsx server/index.js", "gateway": "npm --prefix .. run server", "client": "vite", @@ -22,6 +23,7 @@ "lint:fix": "eslint src/ --fix", "test": "vitest run", "test:watch": "vitest", + "prestart": "node ../scripts/bootstrap-pilotdeck-config.mjs", "start": "npm run build && npm run start:built", "start:built": "concurrently --kill-others-on-fail --names gateway,server \"npm:gateway\" \"npm:server\"", "postinstall": "node scripts/fix-node-pty.js" diff --git a/ui/server/load-env.js b/ui/server/load-env.js index 4063ec8c..2c51a872 100644 --- a/ui/server/load-env.js +++ b/ui/server/load-env.js @@ -2,6 +2,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; +import { execSync } from 'child_process'; import { applyConfigToProcessEnv, getPilotDeckConfigPath, @@ -18,6 +19,31 @@ const REPO_ROOT = path.resolve(__dirname, '../..'); // chat execution goes through pilotdeck-bridge.js → src/gateway, which // reads ~/.pilotdeck/pilotdeck.yaml directly. The sanity check has been // retired; ui/server boots even when the config file is missing. +// ─── Auto-bootstrap ────────────────────────────────────────────────────────── +// On first run the ~/.pilotdeck/pilotdeck.yaml config file doesn't exist yet. +// Rather than relying solely on npm `pre` hooks (which are skipped when the +// server is started directly, e.g. `node server/index.js` or `pilotdeck start`), +// we run the bootstrap script inline whenever the config is missing. +// This ensures Windows users always get the folder + placeholder config +// regardless of how they launch the project. + +function ensurePilotDeckConfigExists() { + if (hasPilotDeckConfigFile()) return; + + const bootstrapScript = path.resolve(REPO_ROOT, 'scripts', 'bootstrap-pilotdeck-config.mjs'); + if (!fs.existsSync(bootstrapScript)) return; + + try { + execSync(`node "${bootstrapScript}"`, { + stdio: 'inherit', + cwd: REPO_ROOT, + timeout: 30000, + windowsHide: true, + }); + } catch (err) { + console.warn('[load-env] Config bootstrap warning:', err instanceof Error ? err.message : String(err)); + } +} function applyDerivedRuntimeEnv() { const { config } = readPilotDeckConfigFile(); @@ -45,6 +71,7 @@ export function assertRequiredPilotDeckEnv() { } export function loadRootPilotDeckEnv() { + ensurePilotDeckConfigExists(); applyDerivedRuntimeEnv(); if (!process.env.DATABASE_PATH) { diff --git a/ui/server/routes/commands.js b/ui/server/routes/commands.js index 4daa91a2..13f6e4e5 100644 --- a/ui/server/routes/commands.js +++ b/ui/server/routes/commands.js @@ -18,6 +18,21 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const router = express.Router(); +const SKILL_MD_RE = /^skill\.md$/i; + +function isSkillMdFileName(name) { + return typeof name === 'string' && SKILL_MD_RE.test(name); +} + +async function findSkillMdFile(skillDir) { + try { + const entries = await fs.readdir(skillDir); + const name = entries.find(isSkillMdFileName); + return name ? path.join(skillDir, name) : null; + } catch { + return null; + } +} /** * Slash commands curated to always appear at the top of the menu in this exact @@ -138,7 +153,8 @@ async function scanSkillsDirectory(dir, namespace) { } const skillDir = path.join(dir, entry.name); - const skillFile = path.join(skillDir, 'SKILL.md'); + const skillFile = await findSkillMdFile(skillDir); + if (!skillFile) continue; let content; try { @@ -163,7 +179,7 @@ async function scanSkillsDirectory(dir, namespace) { skills.push({ name: skillName, path: skillFile, - relativePath: path.join(entry.name, 'SKILL.md'), + relativePath: path.join(entry.name, path.basename(skillFile)), description, namespace, metadata: { ...frontmatter, type: 'skill' }, @@ -695,11 +711,11 @@ Custom commands can be created in: let installed = false; let skillMeta = null; - try { - await fs.access(path.join(installPath, 'SKILL.md')); + const installedSkillFile = await findSkillMdFile(installPath); + if (installedSkillFile) { installed = true; try { - const content = await fs.readFile(path.join(installPath, 'SKILL.md'), 'utf8'); + const content = await fs.readFile(installedSkillFile, 'utf8'); const { data: fm } = parseFrontmatter(content); skillMeta = { name: fm.name || slug, @@ -709,8 +725,6 @@ Custom commands can be created in: } catch { /* SKILL.md exists but unreadable/unparseable — keep installed=true */ } - } catch { - /* SKILL.md missing — installed stays false */ } if (runError && runError.code === 'ENOENT') { diff --git a/ui/server/routes/config.js b/ui/server/routes/config.js index 3bec8565..76d35f4d 100644 --- a/ui/server/routes/config.js +++ b/ui/server/routes/config.js @@ -217,7 +217,6 @@ router.post('/test-connection', async (req, res) => { // onboarding values ('openai-chat' | 'anthropic') for compatibility. const normalizedType = String(providerType || '').toLowerCase(); const isAnthropic = normalizedType === 'anthropic'; - const normalizedBaseUrl = String(baseUrl).trim().replace(/\/+$/, ''); const timeout = 10_000; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); @@ -227,7 +226,7 @@ router.post('/test-connection', async (req, res) => { let fetchOptions; if (isAnthropic) { - url = `${normalizedBaseUrl}/v1/messages`; + url = `${baseUrl.replace(/\/+$/, '')}/v1/messages`; fetchOptions = { method: 'POST', headers: { @@ -243,7 +242,9 @@ router.post('/test-connection', async (req, res) => { signal: controller.signal, }; } else { - url = `${normalizedBaseUrl}/chat/completions`; + const base = baseUrl.replace(/\/+$/, ''); + const hasV1 = /\/v1\/?$/i.test(base); + url = hasV1 ? `${base}/chat/completions` : `${base}/v1/chat/completions`; fetchOptions = { method: 'POST', headers: { @@ -261,35 +262,14 @@ router.post('/test-connection', async (req, res) => { const response = await fetch(url, fetchOptions); clearTimeout(timer); - const responseText = await response.text(); if (response.ok) { - let body; - try { - body = JSON.parse(responseText); - } catch { - return res.json({ - ok: false, - error: `Expected a JSON completion response but received non-JSON content from ${url}. For OpenAI-compatible endpoints, the base URL usually ends with /v1.`, - }); - } - - const hasCompletionShape = isAnthropic - ? Array.isArray(body?.content) || body?.type === 'message' - : Array.isArray(body?.choices); - if (!hasCompletionShape) { - return res.json({ - ok: false, - error: `Endpoint returned HTTP ${response.status}, but the response was not a valid ${isAnthropic ? 'Anthropic message' : 'OpenAI chat completion'}. Check the base URL path.`, - }); - } - return res.json({ ok: true, message: `Connected successfully — Model ${model} is available.` }); } let detail = `${response.status} ${response.statusText}`; try { - const body = JSON.parse(responseText); + const body = await response.json(); if (body?.error?.message) detail = body.error.message; else if (body?.error?.type) detail = `${body.error.type}: ${body.error.message || ''}`; } catch { /* ignore parse errors */ } diff --git a/ui/server/routes/skills.js b/ui/server/routes/skills.js index 969962dc..ec88ce48 100644 --- a/ui/server/routes/skills.js +++ b/ui/server/routes/skills.js @@ -55,6 +55,7 @@ const upload = multer({ // --------------------------------------------------------------------------- const SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$/; +const SKILL_MD_RE = /^skill\.md$/i; const PILOT_HOME = resolvePilotHome(process.env); const PROJECT_DIR = '.pilotdeck'; const SKILLS_SUBDIR = 'skills'; @@ -63,39 +64,31 @@ function safeSlug(slug) { return typeof slug === 'string' && SLUG_RE.test(slug) && !slug.includes('..'); } -const GENERAL_CWD_PATHS = [path.resolve(PILOT_HOME)]; - -function isGeneralCwd(projectPath) { - if (!projectPath) return false; - return GENERAL_CWD_PATHS.includes(path.resolve(projectPath)); +function isSkillMdFileName(name) { + return typeof name === 'string' && SKILL_MD_RE.test(name); } -function resolveRequestedScope(scope, projectPath, { defaultToProjectWhenAvailable = false } = {}) { - const generalCwd = isGeneralCwd(projectPath); - const effectiveProjectPath = generalCwd ? null : projectPath || null; - - if (scope === 'project') { - if (generalCwd) { - return { ok: true, scope: 'user', projectPath: null, wantProject: false }; - } - if (!effectiveProjectPath) { - return { - ok: false, - error: "project scope requires a real project (general chat doesn't qualify)", - }; - } - return { ok: true, scope: 'project', projectPath: effectiveProjectPath, wantProject: true }; - } +function isRootSkillMdPath(relativePath) { + if (typeof relativePath !== 'string') return false; + const normalized = relativePath.replace(/\\/g, '/'); + return !normalized.includes('/') && isSkillMdFileName(normalized); +} - if (scope === 'user') { - return { ok: true, scope: 'user', projectPath: null, wantProject: false }; +async function findSkillMdFile(skillDir) { + try { + const entries = await fs.readdir(skillDir); + const name = entries.find(isSkillMdFileName); + return name ? path.join(skillDir, name) : null; + } catch { + return null; } +} - if (defaultToProjectWhenAvailable && effectiveProjectPath) { - return { ok: true, scope: 'project', projectPath: effectiveProjectPath, wantProject: true }; - } +const GENERAL_CWD_PATHS = [path.resolve(PILOT_HOME)]; - return { ok: true, scope: 'user', projectPath: null, wantProject: false }; +function isGeneralCwd(projectPath) { + if (!projectPath) return false; + return GENERAL_CWD_PATHS.includes(path.resolve(projectPath)); } function userSkillsRoot() { @@ -257,12 +250,16 @@ router.post('/write', async (req, res) => { router.post('/create', async (req, res) => { try { const { scope, projectPath, slug, name, description, body, content } = req.body || {}; - const resolved = resolveRequestedScope(scope, projectPath); - if (!resolved.ok) return res.status(400).json({ error: resolved.error }); + const wantProject = scope === 'project'; + if (wantProject && (!projectPath || isGeneralCwd(projectPath))) { + return res.status(400).json({ + error: "project scope requires a real project (general chat doesn't qualify)", + }); + } const result = await callGateway('skillCreate', { - scope: resolved.scope, + scope: wantProject ? 'project' : 'user', slug, - projectKey: resolved.wantProject ? resolved.projectPath : null, + projectKey: wantProject ? projectPath : null, name, description, body, @@ -306,13 +303,17 @@ router.post('/validate', async (req, res) => { router.post('/import', async (req, res) => { try { const { sourcePath, slug, scope, projectPath, mode, force } = req.body || {}; - const resolved = resolveRequestedScope(scope, projectPath); - if (!resolved.ok) return res.status(400).json({ error: resolved.error }); + const wantProject = scope === 'project'; + if (wantProject && (!projectPath || isGeneralCwd(projectPath))) { + return res.status(400).json({ + error: "project scope requires a real project (general chat doesn't qualify)", + }); + } const result = await callGateway('skillImport', { sourcePath, slug, - scope: resolved.scope, - projectKey: resolved.wantProject ? resolved.projectPath : null, + scope: wantProject ? 'project' : 'user', + projectKey: wantProject ? projectPath : null, mode, force, }); @@ -368,7 +369,7 @@ router.post('/import-upload', upload.array('files', 500), async (req, res) => { let skillMdContent = ''; for (const m of manifest) { - if (m.relativePath === 'SKILL.md') { + if (isRootSkillMdPath(m.relativePath)) { skillMdContent = m.buffer.toString('utf8'); break; } @@ -381,9 +382,13 @@ router.post('/import-upload', upload.array('files', 500), async (req, res) => { return res.status(422).json({ error: 'Validation failed', validation }); } - const resolved = resolveRequestedScope(scope, projectPath); - if (!resolved.ok) return res.status(400).json({ error: resolved.error }); - const root = resolved.wantProject ? projectSkillsRoot(resolved.projectPath) : userSkillsRoot(); + const wantProject = scope === 'project'; + if (wantProject && (!projectPath || isGeneralCwd(projectPath))) { + return res + .status(400) + .json({ error: "project scope requires a real project (general chat doesn't qualify)." }); + } + const root = wantProject ? projectSkillsRoot(projectPath) : userSkillsRoot(); const inferredSlug = (typeof requestedSlug === 'string' && requestedSlug.trim()) || (paths[0] && paths[0].split('/')[0]) || @@ -438,9 +443,9 @@ router.post('/import-upload', upload.array('files', 500), async (req, res) => { let skillSummary = null; try { const list = await callGateway('skillsList', { - projectKey: resolved.wantProject ? resolved.projectPath : null, + projectKey: wantProject ? projectPath : null, }); - const bucket = resolved.wantProject ? list.project : list.user; + const bucket = wantProject ? list.project : list.user; skillSummary = bucket.find((s) => s.slug === inferredSlug) ?? null; } catch { /* best-effort; the file is on disk regardless */ @@ -449,7 +454,7 @@ router.post('/import-upload', upload.array('files', 500), async (req, res) => { res.json({ ok: true, mode: 'upload', - scope: resolved.scope, + scope: wantProject ? 'project' : 'user', slug: inferredSlug, skillPath: targetDir, skill: skillSummary, @@ -530,15 +535,18 @@ router.post('/clawhub/install', async (req, res) => { if (!safeSlug(slug)) { return res.status(400).json({ error: `Invalid slug "${slug}".` }); } - const resolved = resolveRequestedScope(scope, projectPath, { - defaultToProjectWhenAvailable: true, - }); - if (!resolved.ok) return res.status(400).json({ error: resolved.error }); + const generalCwd = isGeneralCwd(projectPath); + const effectiveProjectPath = generalCwd ? null : projectPath || null; + const resolvedScope = + scope === 'project' || scope === 'user' ? scope : effectiveProjectPath ? 'project' : 'user'; let workdir; let dir; - if (resolved.wantProject) { - workdir = resolved.projectPath; + if (resolvedScope === 'project') { + if (!effectiveProjectPath) { + return res.status(400).json({ error: 'project scope requires a real project context' }); + } + workdir = effectiveProjectPath; dir = path.join(PROJECT_DIR, SKILLS_SUBDIR); } else { workdir = PILOT_HOME; @@ -572,18 +580,18 @@ router.post('/clawhub/install', async (req, res) => { let installed = false; let skill = null; - try { - await fs.access(path.join(installPath, 'SKILL.md')); + const installedSkillFile = await findSkillMdFile(installPath); + if (installedSkillFile) { installed = true; - // Pull the summary back through the gateway so descriptions reflect - // the same frontmatter parser the agent will use. - const list = await callGateway('skillsList', { - projectKey: resolved.wantProject ? resolved.projectPath : null, - }); - const bucket = resolved.wantProject ? list.project : list.user; - skill = bucket.find((s) => s.slug === slug) ?? null; - } catch { - /* not installed */ + try { + // Pull the summary back through the gateway so descriptions reflect + // the same frontmatter parser the agent will use. + const list = await callGateway('skillsList', { projectKey: effectiveProjectPath }); + const bucket = resolvedScope === 'project' ? list.project : list.user; + skill = bucket.find((s) => s.slug === slug) ?? null; + } catch { + /* installed, but summary lookup failed */ + } } const needsForce = @@ -592,7 +600,7 @@ router.post('/clawhub/install', async (req, res) => { res.json({ ok: installed, slug, - scope: resolved.scope, + scope: resolvedScope, installPath, installed, skill, diff --git a/ui/src/components/main-content-v2/SkillsV2.tsx b/ui/src/components/main-content-v2/SkillsV2.tsx index 5a33e535..ae03ec83 100644 --- a/ui/src/components/main-content-v2/SkillsV2.tsx +++ b/ui/src/components/main-content-v2/SkillsV2.tsx @@ -82,6 +82,10 @@ function isGeneralProject(p: Project | null): boolean { return p.name === 'general' || p.displayName === 'general'; } +function isSkillMdFileName(name: string): boolean { + return /^skill\.md$/i.test(name); +} + async function api(url: string, body: unknown): Promise { const r = await authenticatedFetch(url, { method: 'POST', @@ -1316,7 +1320,7 @@ function ImportFromFolder({ const rootSkillFile = files.find((f) => { const rel = f.webkitRelativePath || f.name; const stripped = rootName && rel.startsWith(rootName + '/') ? rel.slice(rootName.length + 1) : rel; - return stripped === 'SKILL.md'; + return isSkillMdFileName(stripped); }); if (rootSkillFile) { @@ -1351,7 +1355,7 @@ function ImportFromFolder({ const rel = f.webkitRelativePath || f.name; const prefix = rootName ? rootName + '/' + folderName + '/' : folderName + '/'; const stripped = rel.startsWith(prefix) ? rel.slice(prefix.length) : rel; - return stripped === 'SKILL.md'; + return isSkillMdFileName(stripped); }); let name: string | null = null; diff --git a/ui/src/components/onboarding/view/subcomponents/LlmConfigurationStep.tsx b/ui/src/components/onboarding/view/subcomponents/LlmConfigurationStep.tsx index 3d529fec..a1c076ce 100644 --- a/ui/src/components/onboarding/view/subcomponents/LlmConfigurationStep.tsx +++ b/ui/src/components/onboarding/view/subcomponents/LlmConfigurationStep.tsx @@ -332,11 +332,6 @@ export default function LlmConfigurationStep({ onSaved }: LlmConfigurationStepPr autoComplete="off" spellCheck={false} /> - {customProtocol === 'openai' && ( -

- OpenAI-compatible base URLs should include the API version path, for example ending in /v1. -

- )} @@ -431,11 +426,6 @@ export default function LlmConfigurationStep({ onSaved }: LlmConfigurationStepPr autoComplete="off" spellCheck={false} /> - {(selectedProvider?.protocol ?? customProtocol) === 'openai' && ( -

- OpenAI-compatible base URLs should include the API version path, for example ending in /v1. -

- )}
Protocol: {selectedProvider?.protocol ?? customProtocol} · Default URL: {selectedDefaultUrl} diff --git a/ui/src/components/settings/view/tabs/PilotDeckConfigTab.tsx b/ui/src/components/settings/view/tabs/PilotDeckConfigTab.tsx index d13f8937..ff94d2f2 100644 --- a/ui/src/components/settings/view/tabs/PilotDeckConfigTab.tsx +++ b/ui/src/components/settings/view/tabs/PilotDeckConfigTab.tsx @@ -589,9 +589,12 @@ function ModelRefInput({ placeholder?: string; }) { const selected = value ?? ''; + const noOptionsLabel = options.length === 0 + ? (placeholder ?? 'No models configured — add a provider first') + : (placeholder ?? 'Select a configured model'); const hasSelected = !selected || options.some((opt) => opt.value === selected); const selectOptions = [ - { value: '', label: placeholder ?? 'Select a configured model' }, + { value: '', label: noOptionsLabel }, ...options, ...(!hasSelected ? [{ value: selected, label: `Missing: ${selected}` }] : []), ]; @@ -692,7 +695,7 @@ function ProviderCard({ const protocol = provider.protocol ?? catalogEntry?.protocol ?? 'openai'; const effectiveUrl = provider.url || catalogEntry?.defaultUrl || ''; const enabledModels = Object.keys(provider.models ?? {}); - const [newModelId, setNewModelId] = useState(''); + const newModelInputRef = useRef(null); const [providerIdDraft, setProviderIdDraft] = useState(providerId); const [providerIdError, setProviderIdError] = useState(''); const displayName = providerDisplayName( @@ -727,7 +730,7 @@ function ProviderCard({ if (!id) return; if (provider.models && id in provider.models) return; update({ models: { ...(provider.models ?? {}), [id]: {} } }); - setNewModelId(''); + if (newModelInputRef.current) newModelInputRef.current.value = ''; }; const removeModel = (mid: string) => { const next = { ...(provider.models ?? {}) }; @@ -902,13 +905,12 @@ function ProviderCard({ {/* Add custom model */}
setNewModelId(e.target.value)} + ref={newModelInputRef} placeholder={t('pilotDeckConfig.panels.models.customModelPlaceholder')} className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 font-mono text-[11px] text-foreground outline-none focus:ring-1 focus:ring-ring" - onKeyDown={(e) => { if (e.key === 'Enter' && !isImeEnterEvent(e)) addModel(newModelId); }} + onKeyDown={(e) => { if (e.key === 'Enter' && !isImeEnterEvent(e)) addModel(e.currentTarget.value); }} /> - diff --git a/ui/src/shared/catalogProviders.ts b/ui/src/shared/catalogProviders.ts index 4b1893c7..d721b6ed 100644 --- a/ui/src/shared/catalogProviders.ts +++ b/ui/src/shared/catalogProviders.ts @@ -108,19 +108,6 @@ export const CATALOG_PROVIDERS: CatalogProvider[] = [ { id: 'kimi-k1.5', displayName: 'Kimi K1.5', supportsImage: true, maxContextTokens: 131072 }, ], }, - { - id: 'volc_ark', - displayName: '火山方舟 (Volcano Ark)', - protocol: 'openai', - defaultUrl: 'https://ark.cn-beijing.volces.com/api/v3', - models: [ - { id: 'doubao-1.5-pro-256k', displayName: 'Doubao 1.5 Pro 256K', supportsImage: true, maxContextTokens: 262144 }, - { id: 'doubao-1.5-pro', displayName: 'Doubao 1.5 Pro', supportsImage: true, maxContextTokens: 131072 }, - { id: 'doubao-1.5-lite-128k', displayName: 'Doubao 1.5 Lite 128K', maxContextTokens: 131072 }, - { id: 'doubao-1.5-lite', displayName: 'Doubao 1.5 Lite', maxContextTokens: 32768 }, - { id: 'deepseek-r1', displayName: 'DeepSeek R1 (Volc)', maxContextTokens: 65536 }, - ], - }, ]; export function findCatalogProviderById(id: string): CatalogProvider | undefined {