feat(cli): environment language fallback + localize doctor/fanfic/config

resolveCliLanguage 增加环境回退(书籍语言优先,未设时看
INKOS_LOCALE/LC_ALL/LC_MESSAGES/LANG,默认 zh,与 TUI 对齐);
doctor 诊断/提示、fanfic 错误、config list-models 输出双语(原为
硬编码中文);genre 脚手架模板、init/short 示例、bootstrap 注释、
TUI 斜杠提示双语;doctor 错误关键词匹配补英文词
This commit is contained in:
Ma
2026-07-07 17:23:17 +08:00
parent 587bcdaaa1
commit a3d73c2cbb
12 changed files with 422 additions and 61 deletions
@@ -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")),
);
});
});
@@ -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("deepseek3 个模型");
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 文件");
});
});
@@ -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 <light|normal|deep>"], 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]));
});
});
+4 -2
View File
@@ -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=?";
+19 -7
View File
@@ -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({
+11 -5
View File
@@ -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<string> {
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")),
+69 -21
View File
@@ -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 <language>", "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}`);
+8 -5
View File
@@ -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 <your-api-url> --api-key <your-key> --model <your-model>");
log(" # Option 2: Edit .env for this project only");
log("");
log(exampleCreate);
for (const line of exampleCreateLines) log(line);
}
log(" inkos write next <book-id>");
} catch (e) {
+1 -1
View File
@@ -25,7 +25,7 @@ export const shortCommand = new Command("short")
shortCommand
.command("run")
.description("Run a short fiction chain from a direction")
.requiredOption("--direction <text>", "Story direction, e.g. 女频短篇 婚姻背叛 证据反杀")
.requiredOption("--direction <text>", "Story direction, e.g. \"女频短篇 婚姻背叛 证据反杀\" or \"female-lead short: marriage betrayal, evidence payback\"")
.option("--reference <path>", "Optional reference notes/text")
.option("--story-id <id>", "Output story id under shorts/")
.option("--out-dir <path>", "Output directory", "shorts")
+109 -2
View File
@@ -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 文件)`;
}
+1 -1
View File
@@ -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=",
+25 -17
View File
@@ -1,20 +1,28 @@
export const SLASH_COMMANDS = [
"/new 输入你的想法",
"/write",
"/books",
"/rewrite <n>",
"/focus <text>",
"/truth <file> <content>",
"/rename <from> => <to>",
"/replace <n> <from> => <to>",
"/export [txt|md|epub]",
"/help",
"/status",
"/clear",
"/depth <light|normal|deep>",
"/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 <n>", en: "/rewrite <n>" },
{ zh: "/focus <text>", en: "/focus <text>" },
{ zh: "/truth <file> <content>", en: "/truth <file> <content>" },
{ zh: "/rename <from> => <to>", en: "/rename <from> => <to>" },
{ zh: "/replace <n> <from> => <to>", en: "/replace <n> <from> => <to>" },
{ zh: "/export [txt|md|epub]", en: "/export [txt|md|epub]" },
{ zh: "/help", en: "/help" },
{ zh: "/status", en: "/status" },
{ zh: "/clear", en: "/clear" },
{ zh: "/depth <light|normal|deep>", en: "/depth <light|normal|deep>" },
{ 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";