From a3d73c2cbbb768c6d2142c32a0b5e8b64d91f276 Mon Sep 17 00:00:00 2001 From: Ma Date: Tue, 7 Jul 2026 17:23:17 +0800 Subject: [PATCH] feat(cli): environment language fallback + localize doctor/fanfic/config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveCliLanguage 增加环境回退(书籍语言优先,未设时看 INKOS_LOCALE/LC_ALL/LC_MESSAGES/LANG,默认 zh,与 TUI 对齐); doctor 诊断/提示、fanfic 错误、config list-models 输出双语(原为 硬编码中文);genre 脚手架模板、init/short 示例、bootstrap 注释、 TUI 斜杠提示双语;doctor 错误关键词匹配补英文词 --- .../cli/src/__tests__/genre-command.test.ts | 55 +++++++++ .../cli/src/__tests__/localization.test.ts | 108 +++++++++++++++++ .../__tests__/tui-slash-autocomplete.test.ts | 12 ++ packages/cli/src/commands/config.ts | 6 +- packages/cli/src/commands/doctor.ts | 26 ++-- packages/cli/src/commands/fanfic.ts | 16 ++- packages/cli/src/commands/genre.ts | 90 ++++++++++---- packages/cli/src/commands/init.ts | 13 +- packages/cli/src/commands/short-fiction.ts | 2 +- packages/cli/src/localization.ts | 111 +++++++++++++++++- packages/cli/src/project-bootstrap.ts | 2 +- packages/cli/src/tui/slash-autocomplete.ts | 42 ++++--- 12 files changed, 422 insertions(+), 61 deletions(-) create mode 100644 packages/cli/src/__tests__/genre-command.test.ts diff --git a/packages/cli/src/__tests__/genre-command.test.ts b/packages/cli/src/__tests__/genre-command.test.ts new file mode 100644 index 00000000..40a3b5bb --- /dev/null +++ b/packages/cli/src/__tests__/genre-command.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { buildGenreTemplate } from "../commands/genre.js"; + +const CHINESE_CHARS = /[一-鿿]/; + +describe("genre template scaffold", () => { + const params = { + id: "scifi", + name: "Sci-Fi", + numerical: true, + power: false, + era: true, + } as const; + + it("defaults to the Chinese template", () => { + const template = buildGenreTemplate(params); + + expect(template).toContain("name: Sci-Fi"); + expect(template).toContain("id: scifi"); + expect(template).toContain('chapterTypes: ["推进章", "布局章", "过渡章", "回收章"]'); + expect(template).toContain("## 题材禁忌"); + expect(template).toContain("## 叙事指导"); + expect(template).toContain("numericalSystem: true"); + expect(template).toContain("powerScaling: false"); + expect(template).toContain("eraResearch: true"); + }); + + it("produces a pure English template for en", () => { + const template = buildGenreTemplate(params, "en"); + + expect(template).toContain("name: Sci-Fi"); + expect(template).toContain("id: scifi"); + expect(template).toContain('chapterTypes: ["progression", "setup", "transition", "payoff"]'); + expect(template).toContain("## Genre Taboos"); + expect(template).toContain("## Narrative Guidance"); + expect(template).toContain("numericalSystem: true"); + expect(template).toContain("powerScaling: false"); + expect(template).toContain("eraResearch: true"); + expect(template).not.toMatch(CHINESE_CHARS); + }); + + it("keeps the same frontmatter keys in both languages", () => { + const extractKeys = (template: string): string[] => { + const frontmatter = template.split("---")[1] ?? ""; + return frontmatter + .split("\n") + .map((line) => line.split(":")[0]?.trim() ?? "") + .filter((key) => key.length > 0); + }; + + expect(extractKeys(buildGenreTemplate(params, "en"))).toEqual( + extractKeys(buildGenreTemplate(params, "zh")), + ); + }); +}); diff --git a/packages/cli/src/__tests__/localization.test.ts b/packages/cli/src/__tests__/localization.test.ts index cf874a15..9677b62c 100644 --- a/packages/cli/src/__tests__/localization.test.ts +++ b/packages/cli/src/__tests__/localization.test.ts @@ -5,16 +5,31 @@ import { formatBookCreateCreating, formatBookCreateCreated, formatBookCreateNextStep, + formatDoctorHintBaseUrl, + formatDoctorHintInvalidApiKey, + formatDoctorHintModelName, + formatDoctorHintOpenAiProbeExhausted, + formatDoctorHintQuota, + formatDoctorHintStreamRequirement, + formatFanficCanonMissingError, + formatFanficInvalidModeError, + formatFanficSourceDirEmptyError, + formatFanficSourceTooShortError, formatImportCanonComplete, formatImportCanonStart, formatImportChaptersComplete, formatImportChaptersDiscovery, formatImportChaptersResume, + formatListModelsEmpty, + formatListModelsHeader, formatWriteNextComplete, formatWriteNextProgress, formatWriteNextResultLines, + resolveCliLanguage, } from "../localization.js"; +const CHINESE_CHARS = /[一-鿿]/; + describe("CLI localization", () => { it("formats book-create summaries in both languages", () => { expect(formatBookCreateCreating("zh", "山河", "xuanhuan", "tomato")) @@ -133,3 +148,96 @@ describe("CLI localization", () => { ]); }); }); + +describe("resolveCliLanguage environment fallback", () => { + it("prefers the explicit language over any environment variable", () => { + expect(resolveCliLanguage("en", { INKOS_LOCALE: "zh_CN" })).toBe("en"); + expect(resolveCliLanguage("zh", { INKOS_LOCALE: "en", LANG: "en_US.UTF-8" })).toBe("zh"); + }); + + it("reads INKOS_LOCALE before the system locale variables", () => { + expect(resolveCliLanguage(undefined, { INKOS_LOCALE: "en", LANG: "zh_CN.UTF-8" })).toBe("en"); + expect(resolveCliLanguage(undefined, { INKOS_LOCALE: "zh-CN", LC_ALL: "en_US.UTF-8" })).toBe("zh"); + }); + + it("falls back to LC_ALL, then LC_MESSAGES, then LANG", () => { + expect(resolveCliLanguage(undefined, { LC_ALL: "en_US.UTF-8" })).toBe("en"); + expect(resolveCliLanguage(undefined, { LC_MESSAGES: "en_GB.UTF-8" })).toBe("en"); + expect(resolveCliLanguage(undefined, { LANG: "en_US.UTF-8" })).toBe("en"); + expect(resolveCliLanguage(undefined, { LANG: "zh_CN.UTF-8" })).toBe("zh"); + }); + + it("lets an unrecognized explicit language fall through to the environment", () => { + expect(resolveCliLanguage("fr", { LANG: "en_US.UTF-8" })).toBe("en"); + }); + + it("defaults to zh when nothing is set or the locale is unrecognized", () => { + expect(resolveCliLanguage(undefined, {})).toBe("zh"); + expect(resolveCliLanguage(undefined, { LANG: "C" })).toBe("zh"); + expect(resolveCliLanguage("fr", {})).toBe("zh"); + }); +}); + +describe("config list-models localization", () => { + it("formats the empty-result error in both languages", () => { + expect(formatListModelsEmpty("zh", "deepseek")) + .toBe("deepseek 没有可用模型(可能需要 --api-key 和 --base-url)"); + expect(formatListModelsEmpty("en", "deepseek")) + .toBe("No models available for deepseek (you may need --api-key and --base-url)"); + }); + + it("formats the model-count header in both languages", () => { + expect(formatListModelsHeader("zh", "deepseek", 3)).toBe("deepseek:3 个模型"); + expect(formatListModelsHeader("en", "deepseek", 3)).toBe("deepseek: 3 model(s)"); + }); +}); + +describe("doctor hint localization", () => { + it("keeps the original Chinese hints for zh", () => { + expect(formatDoctorHintQuota("zh")) + .toBe("检查 API Key 是否正确、模型是否可用,以及账号余额或配额是否足够。"); + expect(formatDoctorHintBaseUrl("zh")).toContain("INKOS_LLM_BASE_URL"); + expect(formatDoctorHintStreamRequirement("zh")).toContain("stream"); + expect(formatDoctorHintModelName("zh")).toContain("INKOS_LLM_MODEL"); + expect(formatDoctorHintInvalidApiKey("zh")).toContain("INKOS_LLM_API_KEY"); + expect(formatDoctorHintOpenAiProbeExhausted("zh")).toContain("chat/responses"); + }); + + it("emits pure English hints for en", () => { + const hints = [ + formatDoctorHintQuota("en"), + formatDoctorHintOpenAiProbeExhausted("en"), + formatDoctorHintBaseUrl("en"), + formatDoctorHintStreamRequirement("en"), + formatDoctorHintModelName("en"), + formatDoctorHintInvalidApiKey("en"), + ]; + for (const hint of hints) { + expect(hint).not.toMatch(CHINESE_CHARS); + } + expect(formatDoctorHintBaseUrl("en")).toContain("INKOS_LLM_BASE_URL"); + expect(formatDoctorHintModelName("en")).toContain("INKOS_LLM_MODEL"); + expect(formatDoctorHintInvalidApiKey("en")).toContain("INKOS_LLM_API_KEY"); + expect(formatDoctorHintStreamRequirement("en")).toContain("stream=true"); + }); +}); + +describe("fanfic error localization", () => { + it("builds bilingual error messages", () => { + const invalidMode = formatFanficInvalidModeError("xx"); + expect(invalidMode).toContain('Invalid fanfic mode: "xx"'); + expect(invalidMode).toContain("无效的同人模式"); + + const tooShort = formatFanficSourceTooShortError(42); + expect(tooShort).toContain("Source material too short (42 chars)"); + expect(tooShort).toContain("仅 42 字符"); + + const missingCanon = formatFanficCanonMissingError(); + expect(missingCanon).toContain("inkos fanfic init"); + expect(missingCanon).toContain("同人正典"); + + const emptyDir = formatFanficSourceDirEmptyError("/tmp/source"); + expect(emptyDir).toContain("No .txt or .md files found in /tmp/source"); + expect(emptyDir).toContain("目录 /tmp/source 中没有 .txt 或 .md 文件"); + }); +}); diff --git a/packages/cli/src/__tests__/tui-slash-autocomplete.test.ts b/packages/cli/src/__tests__/tui-slash-autocomplete.test.ts index 689d3881..389828ed 100644 --- a/packages/cli/src/__tests__/tui-slash-autocomplete.test.ts +++ b/packages/cli/src/__tests__/tui-slash-autocomplete.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { applySlashSuggestion, + buildSlashCommands, getSlashSuggestions, getNextSlashSelection, SLASH_COMMANDS, @@ -30,4 +31,15 @@ describe("tui slash autocomplete", () => { expect(applySlashSuggestion("/st", ["/status"], 0)).toBe("/status"); expect(applySlashSuggestion("/d", ["/depth "], 0)).toBe("/depth "); }); + + it("builds locale-specific command lists with identical stems", () => { + const zh = buildSlashCommands(); + const en = buildSlashCommands("en"); + + expect(zh).toEqual(SLASH_COMMANDS); + expect(zh[0]).toBe("/new 输入你的想法"); + expect(en[0]).toBe("/new describe your idea"); + expect(en).toHaveLength(zh.length); + expect(en.map((c) => c.match(/^\/\S+/)?.[0])).toEqual(zh.map((c) => c.match(/^\/\S+/)?.[0])); + }); }); diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 952d8891..701980e7 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -3,6 +3,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { findProjectRoot, log, logError, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH } from "../utils.js"; import { listModelsForService } from "@actalk/inkos-core"; +import { formatListModelsEmpty, formatListModelsHeader, resolveCliLanguage } from "../localization.js"; export const configCommand = new Command("config") .description("Manage project configuration"); @@ -308,16 +309,17 @@ configCommand .option("--json", "Output as JSON") .action(async (service: string, opts: { apiKey?: string; baseUrl?: string; json?: boolean }) => { const apiKey = opts.apiKey ?? process.env.INKOS_LLM_API_KEY; + const language = resolveCliLanguage(); const models = await listModelsForService(service, apiKey, opts.baseUrl); if (models.length === 0) { - logError(`${service} 没有可用模型(可能需要 --api-key 和 --base-url)`); + logError(formatListModelsEmpty(language, service)); process.exit(1); } if (opts.json) { log(JSON.stringify(models, null, 2)); return; } - log(`${service}:${models.length} 个模型\n`); + log(`${formatListModelsHeader(language, service, models.length)}\n`); for (const m of models) { const maxOut = m.maxOutput ? `out=${m.maxOutput}` : "out=?"; const ctx = m.contextWindow > 0 ? `ctx=${m.contextWindow}` : "ctx=?"; diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index a1d3c329..42c21593 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -8,6 +8,15 @@ import { evaluateSqliteMemorySupport, inspectNodeRuntimePinFiles, } from "../runtime-requirements.js"; +import { + formatDoctorHintBaseUrl, + formatDoctorHintInvalidApiKey, + formatDoctorHintModelName, + formatDoctorHintOpenAiProbeExhausted, + formatDoctorHintQuota, + formatDoctorHintStreamRequirement, + resolveCliLanguage, +} from "../localization.js"; function buildDoctorProbePlans( preferredApiFormat: "chat" | "responses" | undefined, @@ -97,6 +106,9 @@ export const doctorCommand = new Command("doctor") .action(async (opts: { repairNodeRuntime?: boolean }) => { const checks: Array<{ name: string; ok: boolean; detail: string }> = []; const root = findProjectRoot(); + // doctor is not scoped to a book, so the language comes from the environment + // (INKOS_LOCALE -> LC_ALL/LC_MESSAGES/LANG, default zh). + const language = resolveCliLanguage(); if (opts.repairNodeRuntime) { const repair = await ensureNodeRuntimePinFiles(root); @@ -331,11 +343,11 @@ export const doctorCommand = new Command("doctor") detail: connected ? detectedDetail : lastError.split("\n")[0]!, }); - if (!connected && /\b(?:401|403|429)\b|unauthorized|forbidden|quota|额度|余额|配额/i.test(lastError)) { + if (!connected && /\b(?:401|403|429)\b|unauthorized|forbidden|quota|balance|insufficient|exceeded|额度|余额|配额/i.test(lastError)) { checks.push({ name: " Hint", ok: false, - detail: "检查 API Key 是否正确、模型是否可用,以及账号余额或配额是否足够。", + detail: formatDoctorHintQuota(language), }); } @@ -343,7 +355,7 @@ export const doctorCommand = new Command("doctor") checks.push({ name: " Hint", ok: false, - detail: "当前已自动尝试 chat/responses 与流式开关组合;如果仍失败,问题更可能在模型名、baseUrl 路径或服务商兼容性本身。", + detail: formatDoctorHintOpenAiProbeExhausted(language), }); } } @@ -352,14 +364,14 @@ export const doctorCommand = new Command("doctor") const hints: string[] = []; if (errMsg.includes("Connection error") || errMsg.includes("ECONNREFUSED") || errMsg.includes("fetch failed")) { - hints.push("baseUrl 可能不正确,检查 INKOS_LLM_BASE_URL 是否包含完整路径(如 /v1)"); + hints.push(formatDoctorHintBaseUrl(language)); } if (errMsg.includes("400")) { - hints.push("检查提供方文档,确认该接口要求 stream=true、stream=false,还是根本不支持 stream"); - hints.push("检查模型名称是否正确(INKOS_LLM_MODEL)"); + hints.push(formatDoctorHintStreamRequirement(language)); + hints.push(formatDoctorHintModelName(language)); } if (errMsg.includes("401")) { - hints.push("API Key 无效,检查 INKOS_LLM_API_KEY"); + hints.push(formatDoctorHintInvalidApiKey(language)); } checks.push({ diff --git a/packages/cli/src/commands/fanfic.ts b/packages/cli/src/commands/fanfic.ts index 2bcb47e6..873066d9 100644 --- a/packages/cli/src/commands/fanfic.ts +++ b/packages/cli/src/commands/fanfic.ts @@ -3,9 +3,15 @@ import { readFile, readdir, stat } from "node:fs/promises"; import { join, resolve, basename } from "node:path"; import { deriveBookIdFromTitle, normalizePlatformOrOther, PipelineRunner, type BookConfig, type FanficMode } from "@actalk/inkos-core"; import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js"; +import { + formatFanficCanonMissingError, + formatFanficInvalidModeError, + formatFanficSourceDirEmptyError, + formatFanficSourceTooShortError, +} from "../localization.js"; export const fanficCommand = new Command("fanfic") - .description("Fan fiction writing tools (同人创作)"); + .description("Fan fiction writing tools"); fanficCommand .command("init") @@ -26,7 +32,7 @@ fanficCommand const mode = opts.mode as FanficMode; if (!["canon", "au", "ooc", "cp"].includes(mode)) { - throw new Error(`无效的同人模式:"${mode}"。可选:canon, au, ooc, cp`); + throw new Error(formatFanficInvalidModeError(mode)); } // Read source material @@ -35,7 +41,7 @@ fanficCommand const sourceName = basename(sourcePath); if (!sourceText || sourceText.length < 100) { - throw new Error(`源素材文件内容过短(${sourceText.length} 字符)。请提供至少 100 字符的原作素材。`); + throw new Error(formatFanficSourceTooShortError(sourceText.length)); } const bookId = deriveBookIdFromTitle(opts.title) || `book-${Date.now().toString(36)}`; @@ -107,7 +113,7 @@ fanficCommand try { canon = await readFile(join(bookDir, "story/fanfic_canon.md"), "utf-8"); } catch { - throw new Error(`该书没有同人正典文件。用 inkos fanfic init 创建同人书。`); + throw new Error(formatFanficCanonMissingError()); } if (opts.json) { @@ -172,7 +178,7 @@ async function readSourceMaterial(sourcePath: string): Promise { const files = await readdir(sourcePath); const textFiles = files.filter((f) => f.endsWith(".txt") || f.endsWith(".md")); if (textFiles.length === 0) { - throw new Error(`目录 ${sourcePath} 中没有 .txt 或 .md 文件。`); + throw new Error(formatFanficSourceDirEmptyError(sourcePath)); } const contents = await Promise.all( textFiles.sort().map((f) => readFile(join(sourcePath, f), "utf-8")), diff --git a/packages/cli/src/commands/genre.ts b/packages/cli/src/commands/genre.ts index eabfa78d..4a731bb0 100644 --- a/packages/cli/src/commands/genre.ts +++ b/packages/cli/src/commands/genre.ts @@ -3,6 +3,64 @@ import { writeFile, mkdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { listAvailableGenres, readGenreProfile, getBuiltinGenresDir } from "@actalk/inkos-core"; import { findProjectRoot, log, logError } from "../utils.js"; +import { resolveCliLanguage, type CliLanguage } from "../localization.js"; + +export function buildGenreTemplate( + params: { + readonly id: string; + readonly name: string; + readonly numerical: boolean; + readonly power: boolean; + readonly era: boolean; + }, + language: CliLanguage = "zh", +): string { + if (language === "en") { + return `--- +name: ${params.name} +id: ${params.id} +chapterTypes: ["progression", "setup", "transition", "payoff"] +fatigueWords: ["shocked", "unbelievable", "incredible"] +numericalSystem: ${params.numerical} +powerScaling: ${params.power} +eraResearch: ${params.era} +pacingRule: "A clear advance or payoff every 2-3 chapters" +satisfactionTypes: ["goal achieved", "obstacle overcome", "truth revealed"] +auditDimensions: [1,2,3,6,7,8,9,10,13,14,15,16,17,18,19] +--- + +## Genre Taboos + +- (add taboos for this genre) + +## Narrative Guidance + +(describe the narrative focus and style requirements for this genre) +`; + } + + return `--- +name: ${params.name} +id: ${params.id} +chapterTypes: ["推进章", "布局章", "过渡章", "回收章"] +fatigueWords: ["震惊", "不可思议", "难以置信"] +numericalSystem: ${params.numerical} +powerScaling: ${params.power} +eraResearch: ${params.era} +pacingRule: "每2-3章有一个明确的进展或反馈" +satisfactionTypes: ["目标达成", "困难克服", "真相揭示"] +auditDimensions: [1,2,3,6,7,8,9,10,13,14,15,16,17,18,19] +--- + +## 题材禁忌 + +- (根据题材添加禁忌) + +## 叙事指导 + +(根据题材描述叙事重心和风格要求) +`; +} export const genreCommand = new Command("genre") .description("Manage genre profiles"); @@ -74,6 +132,7 @@ genreCommand .option("--numerical", "Enable numerical system", false) .option("--power", "Enable power scaling", false) .option("--era", "Enable era research", false) + .option("--lang ", "Template language: zh or en (defaults to INKOS_LOCALE/LANG, then zh)") .action(async (id: string, opts) => { try { const root = findProjectRoot(); @@ -90,27 +149,16 @@ genreCommand await mkdir(genresDir, { recursive: true }); const name = opts.name || id; - const template = `--- -name: ${name} -id: ${id} -chapterTypes: ["推进章", "布局章", "过渡章", "回收章"] -fatigueWords: ["震惊", "不可思议", "难以置信"] -numericalSystem: ${opts.numerical} -powerScaling: ${opts.power} -eraResearch: ${opts.era} -pacingRule: "每2-3章有一个明确的进展或反馈" -satisfactionTypes: ["目标达成", "困难克服", "真相揭示"] -auditDimensions: [1,2,3,6,7,8,9,10,13,14,15,16,17,18,19] ---- - -## 题材禁忌 - -- (根据题材添加禁忌) - -## 叙事指导 - -(根据题材描述叙事重心和风格要求) -`; + const template = buildGenreTemplate( + { + id, + name, + numerical: opts.numerical, + power: opts.power, + era: opts.era, + }, + resolveCliLanguage(opts.lang), + ); await writeFile(filePath, template, "utf-8"); log(`Created genre profile: ${filePath}`); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index ff11b9d5..95f9c515 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -21,15 +21,18 @@ export const initCommand = new Command("init") log(`Project initialized at ${projectDir}`); log(""); const isEnglish = (opts.lang ?? "zh") === "en"; - const exampleCreate = isEnglish - ? " inkos book create --title 'My Novel' --genre progression --platform royalroad --lang en" - : " inkos book create --title '我的小说' --genre xuanhuan --platform tomato"; + const exampleCreateLines = isEnglish + ? [" inkos book create --title 'My Novel' --genre progression --platform royalroad --lang en"] + : [ + " inkos book create --title '我的小说' --genre xuanhuan --platform tomato", + " # English project? Re-run with: inkos init --lang en", + ]; if (global) { log("Global LLM config detected. Ready to go!"); log(""); log("Next steps:"); if (name) log(` cd ${name}`); - log(exampleCreate); + for (const line of exampleCreateLines) log(line); } else { log("Next steps:"); if (name) log(` cd ${name}`); @@ -37,7 +40,7 @@ export const initCommand = new Command("init") log(" inkos config set-global --provider openai --base-url --api-key --model "); log(" # Option 2: Edit .env for this project only"); log(""); - log(exampleCreate); + for (const line of exampleCreateLines) log(line); } log(" inkos write next "); } catch (e) { diff --git a/packages/cli/src/commands/short-fiction.ts b/packages/cli/src/commands/short-fiction.ts index 2092c897..62618771 100644 --- a/packages/cli/src/commands/short-fiction.ts +++ b/packages/cli/src/commands/short-fiction.ts @@ -25,7 +25,7 @@ export const shortCommand = new Command("short") shortCommand .command("run") .description("Run a short fiction chain from a direction") - .requiredOption("--direction ", "Story direction, e.g. 女频短篇 婚姻背叛 证据反杀") + .requiredOption("--direction ", "Story direction, e.g. \"女频短篇 婚姻背叛 证据反杀\" or \"female-lead short: marriage betrayal, evidence payback\"") .option("--reference ", "Optional reference notes/text") .option("--story-id ", "Output story id under shorts/") .option("--out-dir ", "Output directory", "shorts") diff --git a/packages/cli/src/localization.ts b/packages/cli/src/localization.ts index 230f22d9..c42d5c9b 100644 --- a/packages/cli/src/localization.ts +++ b/packages/cli/src/localization.ts @@ -30,8 +30,37 @@ function localize(language: CliLanguage, messages: { zh: string; en: string }): return language === "en" ? messages.en : messages.zh; } -export function resolveCliLanguage(language?: string): CliLanguage { - return language === "en" ? "en" : "zh"; +function normalizeCliLanguageTag(value: string | undefined): CliLanguage | undefined { + if (!value) { + return undefined; + } + + const normalized = value.trim().toLowerCase(); + if (normalized.startsWith("en")) { + return "en"; + } + if (normalized.startsWith("zh")) { + return "zh"; + } + return undefined; +} + +export function resolveCliLanguage( + language?: string, + env: NodeJS.ProcessEnv = process.env, +): CliLanguage { + const explicit = normalizeCliLanguageTag(language); + if (explicit) { + return explicit; + } + + const requested = normalizeCliLanguageTag(env.INKOS_LOCALE); + if (requested) { + return requested; + } + + const detected = normalizeCliLanguageTag(env.LC_ALL ?? env.LC_MESSAGES ?? env.LANG); + return detected ?? "zh"; } export function formatBookCreateCreating( @@ -336,3 +365,81 @@ export function formatImportCanonComplete(language: CliLanguage): string[] { }), ]; } + +export function formatListModelsEmpty(language: CliLanguage, service: string): string { + return localize(language, { + zh: `${service} 没有可用模型(可能需要 --api-key 和 --base-url)`, + en: `No models available for ${service} (you may need --api-key and --base-url)`, + }); +} + +export function formatListModelsHeader( + language: CliLanguage, + service: string, + count: number, +): string { + return localize(language, { + zh: `${service}:${count} 个模型`, + en: `${service}: ${count} model(s)`, + }); +} + +export function formatDoctorHintQuota(language: CliLanguage): string { + return localize(language, { + zh: "检查 API Key 是否正确、模型是否可用,以及账号余额或配额是否足够。", + en: "Check that the API key is valid, the model is available, and the account has enough balance or quota.", + }); +} + +export function formatDoctorHintOpenAiProbeExhausted(language: CliLanguage): string { + return localize(language, { + zh: "当前已自动尝试 chat/responses 与流式开关组合;如果仍失败,问题更可能在模型名、baseUrl 路径或服务商兼容性本身。", + en: "All chat/responses and stream on/off combinations were already probed; if it still fails, the problem is more likely the model name, the baseUrl path, or provider compatibility itself.", + }); +} + +export function formatDoctorHintBaseUrl(language: CliLanguage): string { + return localize(language, { + zh: "baseUrl 可能不正确,检查 INKOS_LLM_BASE_URL 是否包含完整路径(如 /v1)", + en: "The baseUrl may be wrong. Check that INKOS_LLM_BASE_URL includes the full path (e.g. /v1).", + }); +} + +export function formatDoctorHintStreamRequirement(language: CliLanguage): string { + return localize(language, { + zh: "检查提供方文档,确认该接口要求 stream=true、stream=false,还是根本不支持 stream", + en: "Check the provider docs to confirm whether the endpoint requires stream=true, stream=false, or does not support streaming at all.", + }); +} + +export function formatDoctorHintModelName(language: CliLanguage): string { + return localize(language, { + zh: "检查模型名称是否正确(INKOS_LLM_MODEL)", + en: "Check that the model name is correct (INKOS_LLM_MODEL).", + }); +} + +export function formatDoctorHintInvalidApiKey(language: CliLanguage): string { + return localize(language, { + zh: "API Key 无效,检查 INKOS_LLM_API_KEY", + en: "The API key is invalid. Check INKOS_LLM_API_KEY.", + }); +} + +// Fanfic errors are intentionally bilingual in a single string: they can surface +// through `--json` output or be rethrown before any book language is known. +export function formatFanficInvalidModeError(mode: string): string { + return `Invalid fanfic mode: "${mode}". Valid modes: canon, au, ooc, cp(无效的同人模式:"${mode}",可选 canon、au、ooc、cp)`; +} + +export function formatFanficSourceTooShortError(length: number): string { + return `Source material too short (${length} chars); provide at least 100 chars(源素材内容过短,仅 ${length} 字符,请提供至少 100 字符的原作素材)`; +} + +export function formatFanficCanonMissingError(): string { + return "No fanfic canon found for this book. Create one with `inkos fanfic init`(该书没有同人正典文件,用 inkos fanfic init 创建同人书)"; +} + +export function formatFanficSourceDirEmptyError(sourcePath: string): string { + return `No .txt or .md files found in ${sourcePath}(目录 ${sourcePath} 中没有 .txt 或 .md 文件)`; +} diff --git a/packages/cli/src/project-bootstrap.ts b/packages/cli/src/project-bootstrap.ts index 301dc52e..2d5c7109 100644 --- a/packages/cli/src/project-bootstrap.ts +++ b/packages/cli/src/project-bootstrap.ts @@ -90,7 +90,7 @@ function buildProjectEnvTemplate(globalConfigured: boolean): string { return [ "# Project-level LLM overrides (optional)", "# Global config at ~/.inkos/.env will be used by default.", - "# Switch Studio to '使用 Studio 配置' if you want per-project service settings.", + "# Switch Studio to 'Use Studio config' (使用 Studio 配置) if you want per-project service settings.", "# Uncomment below to override for this project only:", "# INKOS_LLM_PROVIDER=openai", "# INKOS_LLM_BASE_URL=", diff --git a/packages/cli/src/tui/slash-autocomplete.ts b/packages/cli/src/tui/slash-autocomplete.ts index 6d53a609..f6e9f624 100644 --- a/packages/cli/src/tui/slash-autocomplete.ts +++ b/packages/cli/src/tui/slash-autocomplete.ts @@ -1,20 +1,28 @@ -export const SLASH_COMMANDS = [ - "/new 输入你的想法", - "/write", - "/books", - "/rewrite ", - "/focus ", - "/truth ", - "/rename => ", - "/replace => ", - "/export [txt|md|epub]", - "/help", - "/status", - "/clear", - "/depth ", - "/quit", - "/exit", -] as const; +import type { CliLanguage } from "../localization.js"; + +const SLASH_COMMAND_VARIANTS: ReadonlyArray<{ zh: string; en: string }> = [ + { zh: "/new 输入你的想法", en: "/new describe your idea" }, + { zh: "/write", en: "/write" }, + { zh: "/books", en: "/books" }, + { zh: "/rewrite ", en: "/rewrite " }, + { zh: "/focus ", en: "/focus " }, + { zh: "/truth ", en: "/truth " }, + { zh: "/rename => ", en: "/rename => " }, + { zh: "/replace => ", en: "/replace => " }, + { zh: "/export [txt|md|epub]", en: "/export [txt|md|epub]" }, + { zh: "/help", en: "/help" }, + { zh: "/status", en: "/status" }, + { zh: "/clear", en: "/clear" }, + { zh: "/depth ", en: "/depth " }, + { zh: "/quit", en: "/quit" }, + { zh: "/exit", en: "/exit" }, +]; + +export function buildSlashCommands(language: CliLanguage = "zh"): readonly string[] { + return SLASH_COMMAND_VARIANTS.map((variant) => (language === "en" ? variant.en : variant.zh)); +} + +export const SLASH_COMMANDS = buildSlashCommands("zh"); export type SlashNavigationDirection = "up" | "down";