mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-30 17:22:02 +08:00
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:
@@ -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("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 文件");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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=?";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 文件)`;
|
||||
}
|
||||
|
||||
@@ -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=",
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user