Просмотр исходного кода

feat: v0.3.2 — dual API format, API key security, optional LLM params

- Support both Chat Completions (default) and Responses API via INKOS_LLM_API_FORMAT
- API key only read from .env, never stored in inkos.json
- temperature/maxTokens/thinkingBudget/apiFormat all optional with defaults
- Add AI detection review to roadmap
- Rewrite README v0.3 section and LinuxDo post for user-facing focus
- Bump version to 0.3.2
majx_mac 8 месяцев назад
Родитель
Сommit
20b5576f29

+ 116 - 33
README.md

@@ -23,6 +23,94 @@
 
 Agent 写小说。写、审、改,全程接管。
 
+## v0.3 更新 (2026-03-13)
+
+创作规则三层分离:基础护栏 → 题材特性 → 用户自定义规则。
+
+### 题材自定义
+
+内置 5 个题材,每个题材带一套完整的创作规则:章节类型、禁忌清单、疲劳词、语言铁律、审计维度。
+
+| 题材 | 自带规则 |
+|------|----------|
+| 玄幻 | 数值系统、战力体系、同质吞噬衰减公式、打脸/升级/收益兑现节奏 |
+| 仙侠 | 修炼/悟道节奏、法宝体系、天道规则 |
+| 都市 | 年代考据、商战/社交驱动、法律术语年代匹配、无数值系统 |
+| 恐怖 | 氛围递进、恐惧层级、克制叙事、无战力审计 |
+| 通用 | 最小化兜底 |
+
+创建书时指定题材,对应规则自动生效:
+
+```bash
+inkos book create --title "吞天魔帝" --genre xuanhuan
+```
+
+题材规则可以查看、复制到项目中修改、或从零创建:
+
+```bash
+inkos genre list                      # 查看所有题材
+inkos genre show xuanhuan             # 查看玄幻的完整规则
+inkos genre copy xuanhuan             # 复制到项目中,随意改
+inkos genre create wuxia --name 武侠   # 从零创建新题材
+```
+
+复制到项目后,增删禁忌、调整疲劳词、修改节奏规则、自定义语言铁律——改完下次写章自动生效。
+
+每个题材有专属语言铁律(带 ✗→✓ 示例),写手和审计员同时执行:
+
+- **玄幻**:✗ "火元从12缕增加到24缕" → ✓ "手臂比先前有力了,握拳时指骨发紧"
+- **都市**:✗ "迅速分析了当前的债务状况" → ✓ "把那叠皱巴巴的白条翻了三遍"
+- **恐怖**:✗ "感到一阵恐惧" → ✓ "后颈的汗毛一根根立起来"
+
+### 单本书规则
+
+每本书有独立的 `book_rules.md`,建筑师 agent 创建书时自动生成,也可以随时手改。写在这里的规则注入每一章的 prompt:
+
+```yaml
+protagonist:
+  name: 林烬
+  personalityLock: ["强势冷静", "能忍能杀", "有脑子不是疯狗"]
+  behavioralConstraints: ["不圣母不留手", "对盟友有温度但不煽情"]
+numericalSystemOverrides:
+  hardCap: 840000000
+  resourceTypes: ["微粒", "血脉浓度", "灵石"]
+prohibitions:
+  - 主角关键时刻心软
+  - 无意义后宫暧昧拖剧情
+  - 配角戏份喧宾夺主
+fatigueWordsOverride: ["瞳孔骤缩", "不可置信"]   # 覆盖题材默认
+```
+
+主角人设锁定、数值上限、自定义禁令、疲劳词覆盖——每本书的规则独立调整,不影响题材模板。
+
+### 19 维度审计
+
+审计细化为 19 个维度,按题材自动启用对应的子集:
+
+OOC检查、时间线、设定冲突、伏笔、节奏、文风、信息越界、词汇疲劳、利益链断裂、配角降智、配角工具人化、爽点虚化、台词失真、流水账、知识库污染、视角一致性、战力崩坏、数值检查、年代考据
+
+玄幻/仙侠全 19 维度,都市 17 维度(含年代考据),恐怖 15 维度。
+
+### 去 AI 味
+
+5 条通用规则 + 每个题材的专属语言规则,控制 AI 标记词密度和叙述习惯:
+
+- AI 标记词限频:仿佛/忽然/竟然/不禁/宛如/猛地,每 3000 字 ≤ 1 次
+- 叙述者不替读者下结论,只写动作
+- 禁止分析报告式语言("核心动机""信息落差"不入正文)
+- 同一意象渲染不超过两轮
+- 方法论术语不入正文
+
+词汇疲劳审计同时检测标记词密度,超标即 warning。
+
+### 其他
+
+- 支持 OpenAI + Anthropic 原生 + 所有 OpenAI 兼容接口
+- 修订者支持 polish / rewrite / rework 三种模式
+- 无数值系统的题材不生成资源账本
+
+---
+
 ## 为什么需要 InkOS?
 
 用 AI 写小说不是简单的"提示词 + 复制粘贴"。长篇小说很快就会崩:角色记忆混乱、物品凭空出现、同样的形容词每段都在重复、伏笔悄无声息地断掉。InkOS 把这些当工程问题来解决。
@@ -67,18 +155,13 @@ Agent 写小说。写、审、改,全程接管。
   <img src="assets/screenshot-state.png" width="800" alt="长期记忆快照">
 </p>
 
-### 内置创作规则体系
+### 创作规则体系
 
-写手 agent 内置了一套从大量小说创作实践中提炼的规则体系,覆盖 6 个维度:
+写手 agent 内置 ~25 条通用创作规则(人物塑造、叙事技法、逻辑自洽、语言约束、去 AI 味),适用于所有题材。
 
-- **人物塑造铁律** — 角色行为由"过往经历 + 当前利益 + 性格底色"共同驱动;配角必须有独立动机
-- **叙事技法** — Show don't tell、五感代入法、每章结尾必须设置钩子、信息分层植入
-- **逻辑自洽** — 三连反问自检、信息越界检查、关系改变必须事件驱动
-- **语言约束** — 句式多样化、高疲劳词限制、情绪用细节传达
-- **禁忌清单** — 禁止机械降神、反派降智、主角圣母、特定句式和标点
-- **数值验算铁律** — 每次数值变动必须从账本取值验算,同质资源有衰减公式
+在此基础上,每个题材有专属规则(禁忌、语言铁律、节奏、审计维度),每本书有独立的 `book_rules.md`(主角人设、数值上限、自定义禁令)和 `story_bible.md`(世界观设定),由建筑师 agent 创建书籍时自动生成。
 
-每本书还有自己的 `style_guide.md`(文风指南)和 `story_bible.md`(世界观设定),由建筑师 agent 在创建书籍时生成。
+详见 [v0.3 更新](#v03-更新-2026-03-13)。
 
 ## 三种使用模式
 
@@ -122,10 +205,25 @@ npm i -g @actalk/inkos
 ### 配置
 
 ```bash
-inkos init              # 初始化项目,生成 .env 模板
-# 编辑 .env,填入你的 API Key(支持 OpenAI / Anthropic / 所有 OpenAI 兼容接口)
+inkos init              # 初始化项目,生成 inkos.json + .env 模板
+```
+
+编辑 `.env`:
+
+```bash
+# 必填
+INKOS_LLM_PROVIDER=openai                        # openai / anthropic
+INKOS_LLM_BASE_URL=https://api.openai.com/v1     # API 地址(支持中转站)
+INKOS_LLM_API_KEY=sk-xxx                          # API Key
+INKOS_LLM_MODEL=gpt-5.4                            # 模型名
+
+# 可选
+# INKOS_LLM_TEMPERATURE=0.7                       # 温度
+# INKOS_LLM_MAX_TOKENS=8192                        # 最大输出 token
+# INKOS_LLM_THINKING_BUDGET=0                      # Anthropic 扩展思考预算
 ```
 
+
 ### 使用
 
 ```bash
@@ -146,8 +244,9 @@ inkos up                       # 守护进程模式
 | 命令 | 说明 |
 |------|------|
 | `inkos init` | 初始化项目 |
-| `inkos book create` | 创建新书(生成世界观 + 卷纲 + 文风指南) |
+| `inkos book create` | 创建新书(生成世界观 + 卷纲 + 本书规则) |
 | `inkos book list` | 列出所有书籍 |
+| `inkos genre list/show/copy/create` | 查看、复制、创建题材 |
 | `inkos write next <id>` | 完整管线写下一章 |
 | `inkos write rewrite <id> <n>` | 重写第 N 章(恢复状态快照) |
 | `inkos draft <id>` | 只写草稿(不审不改) |
@@ -245,32 +344,16 @@ TypeScript 单仓库,pnpm workspaces 管理。
 - [x] 自然语言 Agent 模式(tool-use 编排)
 - [x] 可插拔雷达(RadarSource 接口)
 - [x] 外部 Agent 集成(OpenClaw 等)
+- [x] 题材自定义 + 单本书规则(genre CLI + book_rules.md)
+- [x] 19 维度连续性审计
+- [x] 去 AI 味铁律
+- [x] 多 LLM provider(OpenAI + Anthropic + 兼容接口)
+- [ ] AI 检测复核(AIGC 检测 + 自动改写降率)
 - [ ] `packages/studio` Web UI 审阅编辑界面
 - [ ] 多模型路由(不同 agent 用不同模型)
 - [ ] 自定义 agent 插件系统
 - [ ] 平台格式导出(起点、番茄等)
 
-## 更新日志
-
-### v0.3.0 (2026-03-13)
-
-**三层规则架构** — 之前的创作规则全部硬编码在 writer agent 里,只适配玄幻/爽文。现在拆成三层:通用规则(~25 条) → 题材规范(`genres/*.md`) → 单本书规则(`book_rules.md`)。写都市文不会再出现"同质吞噬衰减公式",写恐怖不会要求"三章内必须打脸"。
-
-**5 个内置题材 profile** — 玄幻、仙侠、都市、恐怖、通用。每个 profile 定义章节类型、疲劳词、数值/战力/年代开关、节奏规则、爽点类型、审计维度、题材禁忌和语言铁律。支持自定义和覆盖:
-
-```bash
-inkos genre list                      # 查看所有题材
-inkos genre show xuanhuan             # 查看详情
-inkos genre create wuxia --name 武侠   # 创建自定义题材
-inkos genre copy xuanhuan             # 复制内置到项目中定制
-```
-
-**19 维度连续性审计** — 审计从笼统的"检查一致性"升级到 19 个明确维度,按题材自动启用:玄幻全 19 维度(含数值/战力),都市 17 维度(加年代考据),恐怖 15 维度。新增视角一致性、利益链断裂、知识库污染等维度。
-
-**去 AI 味铁律** — 5 条硬性规则:禁止叙述者替读者下结论、禁止分析报告式语言("核心动机""信息边界"等术语禁入正文)、AI 标记词(仿佛/忽然/竟然)限频每 3000 字 1 次、意象渲染限两轮、方法论术语隔离。每个题材还有专属语言铁律(带 ✗→✓ 示例)。
-
-**实测** — 3 个题材各跑 3 章验证:玄幻(数值追踪正常)、都市(无数值审计、年代考据启用)、恐怖(氛围递进、克制叙事)。审计结果确认题材错位消失、词汇疲劳检测 AI 标记词、文风评价从"AI味重"变成"场景落点具体"。
-
 ## 参与贡献
 
 欢迎贡献代码。提 issue 或 PR。

+ 73 - 74
linuxdo-v0.3.0.md

@@ -1,111 +1,110 @@
-# InkOS v0.3.0 更新:多题材 + 多模型 + 去 AI 味
+# InkOS v0.3 更新:题材自定义 + 单本书规则 + 19 维度审计 + 去 AI 味
 
-之前发过 InkOS 的帖子,一个开源的自动化小说写作 CLI Agent,5 个 Agent 接力完成 写→审→改 全流程。当时只跑了玄幻题材。
+之前发过 InkOS,一个开源的自动化小说写作 CLI Agent,5 个 Agent 接力完成写→审→改全流程。v0.2 跑了一本玄幻,31 章 45 万字。这次 v0.3 核心是让你能自定义创作规则。
 
-这次是一个比较大的更新,解决了三个核心问题:
+## 题材自定义
 
-1. 之前所有创作规则硬编码为玄幻,换题材写出来就是垃圾
-2. LLM 只能用 OpenAI 兼容接口,用 Anthropic 需要中转
-3. AI 味太重,叙述者替读者下结论、"核心动机""信息边界"这种分析报告语言跑到正文里
+内置 5 个题材,每个题材带一套完整的创作规则:章节类型、禁忌清单、疲劳词、语言铁律、审计维度。
 
----
-
-## 1. 三层规则架构
-
-之前 writer agent 的 prompt 里 148 行规则全是"杀伐果断""同质吞噬衰减公式"。用这套规则写都市或者恐怖,出来的东西没法看。
+| 题材 | 自带规则 |
+|------|----------|
+| 玄幻 | 数值系统、战力体系、同质吞噬衰减公式、打脸/升级/收益兑现节奏 |
+| 仙侠 | 修炼/悟道节奏、法宝体系、天道规则 |
+| 都市 | 年代考据、商战/社交驱动、法律术语年代匹配、无数值系统 |
+| 恐怖 | 氛围递进、恐惧层级、克制叙事、无战力审计 |
+| 通用 | 最小化兜底 |
 
-现在拆成三层:
+创建书时指定题材就行:
 
+```bash
+inkos book create --title "吞天魔帝" --genre xuanhuan
 ```
-通用规则(~25 条,代码内置,适用所有题材)
-  ↓ 合并
-题材规范(genres/*.md,按题材定制)
-  ↓ 合并
-单本书规则(books/{id}/story/book_rules.md,逐本定制)
-  ↓ 注入 prompt
-LLM
-```
-
-**通用层**保留了人物塑造、叙事技法、逻辑自洽、语言约束这些跟题材无关的规则,大概 25 条。
 
-**题材层**内置了 5 个 profile:
-
-| 题材 | 特点 |
-|------|------|
-| 玄幻 | 数值系统、战力体系、同质吞噬衰减公式、全 19 维度审计 |
-| 仙侠 | 修炼/悟道节奏、法宝体系、天道规则、数值+战力审计 |
-| 都市 | 无数值/战力、启用年代考据、商战/社交/信息差驱动 |
-| 恐怖 | 无数值/战力、氛围递进、恐惧层级、克制叙事 |
-| 通用 | 最小化兜底 |
-
-每个 profile 用 YAML + markdown 定义,可以自己改:
+题材规则可以查看、复制到项目中改、或从零创建:
 
 ```bash
 inkos genre list                      # 查看所有题材
-inkos genre show xuanhuan             # 查看玄幻 profile
-inkos genre create wuxia --name 武侠   # 创建自定义题材
-inkos genre copy xuanhuan             # 复制内置 profile 到项目定制
+inkos genre show xuanhuan             # 查看玄幻的完整规则
+inkos genre copy xuanhuan             # 复制到项目中,随意改
+inkos genre create wuxia --name 武侠   # 从零创建新题材
 ```
 
-**书籍层**可以锁主角人设、覆盖疲劳词、追加审计维度等,建筑师 agent 自动生成,也可以手改。
+复制到项目后,增删禁忌、调整疲劳词、修改节奏规则、自定义语言铁律——改完下次写章自动生效。
+
+每个题材有专属语言铁律(带 ✗→✓ 示例),写手和审计员同时执行:
 
-## 2. 19 维度审计
+- **玄幻**:✗ "火元从12缕增加到24缕" → ✓ "手臂比先前有力了,握拳时指骨发紧"
+- **都市**:✗ "迅速分析了当前的债务状况" → ✓ "把那叠皱巴巴的白条翻了三遍"
+- **恐怖**:✗ "感到一阵恐惧" → ✓ "后颈的汗毛一根根立起来"
 
-之前审计员就是笼统地"检查一致性"。现在明确了 19 个维度,按题材条件启用:
+## 单本书规则
 
-- 始终启用(15 个):OOC、时间线、设定冲突、伏笔、节奏、文风、信息越界、词汇疲劳、利益链断裂、配角降智、配角工具人化、爽点虚化、台词失真、流水账、知识库污染、视角一致性
-- 条件启用:战力崩坏(powerScaling=true)、数值检查(numericalSystem=true)、年代考据(eraResearch=true)
+每本书有独立的 `book_rules.md`,建筑师 agent 创建书时自动生成,也可以随时手改。写在这里的规则注入每一章的 prompt:
+
+```yaml
+protagonist:
+  name: 林烬
+  personalityLock: ["强势冷静", "能忍能杀", "有脑子不是疯狗"]
+  behavioralConstraints: ["不圣母不留手", "对盟友有温度但不煽情"]
+numericalSystemOverrides:
+  hardCap: 840000000
+  resourceTypes: ["微粒", "血脉浓度", "灵石"]
+prohibitions:
+  - 主角关键时刻心软
+  - 无意义后宫暧昧拖剧情
+  - 配角戏份喧宾夺主
+fatigueWordsOverride: ["瞳孔骤缩", "不可置信"]
+```
 
-玄幻全开 19 个,都市 17 个(加年代考据,去数值/战力),恐怖 15 个。不会出现恐怖小说被审计"战力崩坏"这种事了。
+主角人设锁定、数值上限、自定义禁令、疲劳词覆盖——每本书的规则独立调整,不影响题材模板。
 
-## 3. 去 AI 味
+## 19 维度审计
 
-用 3 个题材各跑了几章测试,发现 AI 味的核心来源:
+审计细化为 19 个维度,按题材自动启用对应的子集:
 
-1. 叙述者替读者下结论(应该只写动作让读者自己判断)
-2. 分析报告式语言("核心动机""信息边界""信息落差"跑进正文)
-3. AI 标记词密度高(仿佛/忽然/竟然/不禁/宛如/猛地)
-4. 同一意象原地打转("火在体内流动"连写三遍)
+OOC检查、时间线、设定冲突、伏笔、节奏、文风、信息越界、词汇疲劳、利益链断裂、配角降智、配角工具人化、爽点虚化、台词失真、流水账、知识库污染、视角一致性、战力崩坏、数值检查、年代考据
 
-对策:通用层加了 5 条铁律,每个题材还有专属语言铁律带 ✗→✓ 示例。比如:
+玄幻/仙侠全 19 维度,都市 17 维度(含年代考据),恐怖 15 维度。不需要的维度不会干扰审计结果。
 
-- 玄幻:✗ "他的火元从12缕增加到24缕" → ✓ "手臂比先前有力了,握拳时指骨发紧"
-- 都市:✗ "他迅速分析了当前的债务状况" → ✓ "他把那叠皱巴巴的白条翻了三遍"
-- 恐怖:✗ "他感到一阵恐惧" → ✓ "他后颈的汗毛一根根立起来"
+## 去 AI 味
 
-AI 标记词密度限制每 3000 字不超过 1 次,审计会 warning。
+5 条通用规则 + 每个题材的专属语言铁律:
 
-## 4. 多 LLM 提供商
+- AI 标记词限频:仿佛/忽然/竟然/不禁/宛如/猛地,每 3000 字 ≤ 1 次
+- 叙述者不替读者下结论,只写动作
+- 禁止分析报告式语言("核心动机""信息落差"不入正文)
+- 同一意象渲染不超过两轮
+- 方法论术语不入正文
 
-之前运行时只有 OpenAI SDK,但 `inkos init` 默认生成 Anthropic 配置 —— 用 OpenAI SDK 打 Anthropic 端点,直接 `Premature close`。(感谢 issue #1 报告)
+词汇疲劳审计同时检测标记词密度,超标即 warning。
 
-现在加了 Anthropic SDK 原生支持。两种用法:
+## 配置
+
+所有 LLM 配置都在 `.env` 里(不在 inkos.json,避免 key 泄漏):
 
 ```bash
-# OpenAI / 任何兼容接口
-INKOS_LLM_PROVIDER=openai
-INKOS_LLM_BASE_URL=https://api.openai.com/v1
-INKOS_LLM_MODEL=gpt-4o
-
-# Anthropic 直连
-INKOS_LLM_PROVIDER=anthropic
-INKOS_LLM_BASE_URL=https://api.anthropic.com
-INKOS_LLM_MODEL=claude-sonnet-4-5-20250514
+# 必填
+INKOS_LLM_PROVIDER=openai                        # openai / anthropic
+INKOS_LLM_BASE_URL=https://api.openai.com/v1     # API 地址(支持中转站)
+INKOS_LLM_API_KEY=sk-xxx                          # API Key
+INKOS_LLM_MODEL=gpt-4o                            # 模型名
+
+# 可选
+# INKOS_LLM_TEMPERATURE=0.7
+# INKOS_LLM_MAX_TOKENS=8192
+# INKOS_LLM_THINKING_BUDGET=0                      # Anthropic 扩展思考预算
 ```
 
-`provider.ts` 内部封装了统一的 `chatCompletion()` 和 `chatWithTools()`,所有 agent 和 agent loop(tool-use)都走同一个抽象层,streaming + tool calling 两种 SDK 都支持。
+OpenAI 兼容中转站只需改 `BASE_URL`。Anthropic 直连把 `PROVIDER` 改成 `anthropic`,`BASE_URL` 改成 `https://api.anthropic.com`。
 
-## 实测
+## 其他
 
-3 个题材各 3 章:
+- 修订者支持 polish / rewrite / rework 三种模式
+- 无数值系统的题材(都市/恐怖)不生成资源账本
 
-| 题材 | 书名 | 验证点 |
-|------|------|--------|
-| 玄幻 | 烈焰吞天 | 数值追踪正常、战力验算、资源账本 |
-| 都市 | 重生2003 | 无数值系统、年代考据、法律术语匹配 2003 年 |
-| 恐怖 | 末班地铁 | 无战力审计、氛围递进、克制叙事 |
+## 实测
 
-去 AI 味改进前后对比:词汇疲劳审计从"AI标记词密度偏高"变成"基本未超标",文风检查从"叙述者越位"变成"场景落点具体"。
+3 个题材各 3 章:玄幻(数值追踪、战力验算、资源账本全程工作)、都市(年代考据启用、法律术语匹配 2003 年语感、无数值系统)、恐怖(氛围递进、克制叙事、恐惧层级、无战力审计)。
 
 ## 安装
 

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "inkos",
-  "version": "0.3.1",
+  "version": "0.3.2",
   "private": true,
   "description": "Multi-agent novel production system",
   "license": "MIT",

+ 1 - 1
packages/cli/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@actalk/inkos",
-  "version": "0.3.1",
+  "version": "0.3.2",
   "description": "InkOS CLI — multi-agent novel production system",
   "type": "module",
   "bin": {

+ 7 - 8
packages/cli/src/commands/doctor.ts

@@ -34,18 +34,17 @@ export const doctorCommand = new Command("doctor")
       checks.push({ name: ".env", ok: false, detail: "Not found" });
     }
 
-    // 4. Check LLM API key
-    try {
-      const raw = await readFile(join(root, "inkos.json"), "utf-8");
-      const config = JSON.parse(raw);
-      const hasKey = config.llm?.apiKey && config.llm.apiKey.length > 10;
+    // 4. Check LLM API key (from .env only)
+    {
+      const { config: loadDotenv } = await import("dotenv");
+      loadDotenv({ path: join(root, ".env") });
+      const apiKey = process.env.INKOS_LLM_API_KEY;
+      const hasKey = !!apiKey && apiKey.length > 10 && apiKey !== "your-api-key-here";
       checks.push({
         name: "LLM API Key",
         ok: hasKey,
-        detail: hasKey ? "Configured" : "Missing or too short",
+        detail: hasKey ? "Configured (from .env)" : "Missing in .env — set INKOS_LLM_API_KEY",
       });
-    } catch {
-      checks.push({ name: "LLM API Key", ok: false, detail: "Cannot read config" });
     }
 
     // 5. Check books directory

+ 6 - 1
packages/cli/src/commands/init.ts

@@ -20,7 +20,6 @@ export const initCommand = new Command("init")
         llm: {
           provider: process.env.INKOS_LLM_PROVIDER ?? "openai",
           baseUrl: process.env.INKOS_LLM_BASE_URL ?? "https://api.openai.com/v1",
-          apiKey: process.env.INKOS_LLM_API_KEY ?? "",
           model: process.env.INKOS_LLM_MODEL ?? "gpt-4o",
         },
         notify: [],
@@ -50,6 +49,12 @@ export const initCommand = new Command("init")
           "INKOS_LLM_API_KEY=your-api-key-here",
           "INKOS_LLM_MODEL=gpt-4o",
           "",
+          "# Optional parameters (defaults shown):",
+          "# INKOS_LLM_TEMPERATURE=0.7",
+          "# INKOS_LLM_MAX_TOKENS=8192",
+          "# INKOS_LLM_THINKING_BUDGET=0          # Anthropic extended thinking budget",
+          "# INKOS_LLM_API_FORMAT=chat             # chat (default) or responses (OpenAI Responses API)",
+          "",
           "# Anthropic example:",
           "# INKOS_LLM_PROVIDER=anthropic",
           "# INKOS_LLM_BASE_URL=https://api.anthropic.com",

+ 13 - 1
packages/cli/src/utils.ts

@@ -42,8 +42,20 @@ export async function loadConfig(): Promise<ProjectConfig> {
     const env = process.env;
     if (env.INKOS_LLM_PROVIDER) config.llm.provider = env.INKOS_LLM_PROVIDER;
     if (env.INKOS_LLM_BASE_URL) config.llm.baseUrl = env.INKOS_LLM_BASE_URL;
-    if (env.INKOS_LLM_API_KEY) config.llm.apiKey = env.INKOS_LLM_API_KEY;
     if (env.INKOS_LLM_MODEL) config.llm.model = env.INKOS_LLM_MODEL;
+    if (env.INKOS_LLM_TEMPERATURE) config.llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE);
+    if (env.INKOS_LLM_MAX_TOKENS) config.llm.maxTokens = parseInt(env.INKOS_LLM_MAX_TOKENS, 10);
+    if (env.INKOS_LLM_THINKING_BUDGET) config.llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
+    if (env.INKOS_LLM_API_FORMAT) config.llm.apiFormat = env.INKOS_LLM_API_FORMAT;
+
+    // API key ONLY from env — never stored in inkos.json
+    const apiKey = env.INKOS_LLM_API_KEY;
+    if (!apiKey) {
+      throw new Error(
+        "INKOS_LLM_API_KEY not set. Add it to your .env file.",
+      );
+    }
+    config.llm.apiKey = apiKey;
 
     return ProjectConfigSchema.parse(config);
   } catch (e) {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@actalk/inkos-core",
-  "version": "0.3.1",
+  "version": "0.3.2",
   "description": "InkOS core: models, agents, pipeline, LLM integration",
   "type": "module",
   "main": "dist/index.js",

+ 7 - 9
packages/core/src/__tests__/models.test.ts

@@ -362,15 +362,13 @@ describe("LLMConfigSchema", () => {
     ).toThrow();
   });
 
-  it("rejects empty apiKey", () => {
-    expect(() =>
-      LLMConfigSchema.parse({
-        provider: "anthropic",
-        baseUrl: "https://api.example.com",
-        apiKey: "",
-        model: "m",
-      }),
-    ).toThrow();
+  it("defaults apiKey to empty string when omitted", () => {
+    const result = LLMConfigSchema.parse({
+      provider: "anthropic",
+      baseUrl: "https://api.example.com",
+      model: "m",
+    });
+    expect(result.apiKey).toBe("");
   });
 
   it("rejects empty model", () => {

+ 16 - 1
packages/core/src/__tests__/writer-parser.test.ts

@@ -1,6 +1,20 @@
 import { describe, it, expect } from "vitest";
 import { WriterAgent } from "../agents/writer.js";
 import type { WriteChapterOutput } from "../agents/writer.js";
+import type { GenreProfile } from "../models/genre-profile.js";
+
+const defaultGenreProfile: GenreProfile = {
+  name: "测试",
+  id: "test",
+  chapterTypes: [],
+  fatigueWords: [],
+  numericalSystem: true,
+  powerScaling: false,
+  eraResearch: false,
+  pacingRule: "",
+  satisfactionTypes: [],
+  auditDimensions: [],
+};
 
 /**
  * WriterAgent.parseOutput is private, so we access it via prototype to test
@@ -9,10 +23,11 @@ import type { WriteChapterOutput } from "../agents/writer.js";
 function callParseOutput(
   chapterNumber: number,
   content: string,
+  genreProfile: GenreProfile = defaultGenreProfile,
 ): WriteChapterOutput {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   const proto = WriterAgent.prototype as any;
-  return proto.parseOutput.call(null, chapterNumber, content);
+  return proto.parseOutput.call(null, chapterNumber, content, genreProfile);
 }
 
 // ---------------------------------------------------------------------------

+ 255 - 51
packages/core/src/llm/provider.ts

@@ -20,8 +20,14 @@ export interface LLMMessage {
 
 export interface LLMClient {
   readonly provider: "openai" | "anthropic";
+  readonly apiFormat: "chat" | "responses";
   readonly _openai?: OpenAI;
   readonly _anthropic?: Anthropic;
+  readonly defaults: {
+    readonly temperature: number;
+    readonly maxTokens: number;
+    readonly thinkingBudget: number;
+  };
 }
 
 // === Tool-calling Types ===
@@ -52,18 +58,30 @@ export interface ChatWithToolsResult {
 // === Factory ===
 
 export function createLLMClient(config: LLMConfig): LLMClient {
+  const defaults = {
+    temperature: config.temperature ?? 0.7,
+    maxTokens: config.maxTokens ?? 8192,
+    thinkingBudget: config.thinkingBudget ?? 0,
+  };
+
+  const apiFormat = config.apiFormat ?? "chat";
+
   if (config.provider === "anthropic") {
     // Anthropic SDK appends /v1/ internally — strip if user included it
     const baseURL = config.baseUrl.replace(/\/v1\/?$/, "");
     return {
       provider: "anthropic",
+      apiFormat,
       _anthropic: new Anthropic({ apiKey: config.apiKey, baseURL }),
+      defaults,
     };
   }
   // openai or custom — both use OpenAI SDK
   return {
     provider: "openai",
+    apiFormat,
     _openai: new OpenAI({ apiKey: config.apiKey, baseURL: config.baseUrl }),
+    defaults,
   };
 }
 
@@ -78,10 +96,17 @@ export async function chatCompletion(
     readonly maxTokens?: number;
   },
 ): Promise<LLMResponse> {
+  const resolved = {
+    temperature: options?.temperature ?? client.defaults.temperature,
+    maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
+  };
   if (client.provider === "anthropic") {
-    return chatCompletionAnthropic(client._anthropic!, model, messages, options);
+    return chatCompletionAnthropic(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget);
   }
-  return chatCompletionOpenAI(client._openai!, model, messages, options);
+  if (client.apiFormat === "responses") {
+    return chatCompletionOpenAIResponses(client._openai!, model, messages, resolved);
+  }
+  return chatCompletionOpenAIChat(client._openai!, model, messages, resolved);
 }
 
 // === Tool-calling Chat (used by agent loop) ===
@@ -96,67 +121,87 @@ export async function chatWithTools(
     readonly maxTokens?: number;
   },
 ): Promise<ChatWithToolsResult> {
+  const resolved = {
+    temperature: options?.temperature ?? client.defaults.temperature,
+    maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
+  };
   if (client.provider === "anthropic") {
-    return chatWithToolsAnthropic(client._anthropic!, model, messages, tools, options);
+    return chatWithToolsAnthropic(client._anthropic!, model, messages, tools, resolved, client.defaults.thinkingBudget);
   }
-  return chatWithToolsOpenAI(client._openai!, model, messages, tools, options);
+  if (client.apiFormat === "responses") {
+    return chatWithToolsOpenAIResponses(client._openai!, model, messages, tools, resolved);
+  }
+  return chatWithToolsOpenAIChat(client._openai!, model, messages, tools, resolved);
 }
 
-// === OpenAI Implementation ===
+// === OpenAI Chat Completions API Implementation (default) ===
 
-async function chatCompletionOpenAI(
+async function chatCompletionOpenAIChat(
   client: OpenAI,
   model: string,
   messages: ReadonlyArray<LLMMessage>,
-  options?: { readonly temperature?: number; readonly maxTokens?: number },
+  options: { readonly temperature: number; readonly maxTokens: number },
 ): Promise<LLMResponse> {
   const stream = await client.chat.completions.create({
     model,
-    messages: messages.map((m) => ({ role: m.role, content: m.content })),
-    temperature: options?.temperature ?? 0.7,
-    max_tokens: options?.maxTokens ?? 8192,
+    messages: messages.map((m) => ({
+      role: m.role,
+      content: m.content,
+    })),
+    temperature: options.temperature,
+    max_tokens: options.maxTokens,
     stream: true,
   });
 
   const chunks: string[] = [];
-  let promptTokens = 0;
-  let completionTokens = 0;
-  let totalTokens = 0;
+  let inputTokens = 0;
+  let outputTokens = 0;
 
   for await (const chunk of stream) {
     const delta = chunk.choices[0]?.delta?.content;
     if (delta) chunks.push(delta);
     if (chunk.usage) {
-      promptTokens = chunk.usage.prompt_tokens ?? 0;
-      completionTokens = chunk.usage.completion_tokens ?? 0;
-      totalTokens = chunk.usage.total_tokens ?? 0;
+      inputTokens = chunk.usage.prompt_tokens ?? 0;
+      outputTokens = chunk.usage.completion_tokens ?? 0;
     }
   }
 
   const content = chunks.join("");
   if (!content) throw new Error("LLM returned empty response");
 
-  return { content, usage: { promptTokens, completionTokens, totalTokens } };
+  return {
+    content,
+    usage: {
+      promptTokens: inputTokens,
+      completionTokens: outputTokens,
+      totalTokens: inputTokens + outputTokens,
+    },
+  };
 }
 
-async function chatWithToolsOpenAI(
+async function chatWithToolsOpenAIChat(
   client: OpenAI,
   model: string,
   messages: ReadonlyArray<AgentMessage>,
   tools: ReadonlyArray<ToolDefinition>,
-  options?: { readonly temperature?: number; readonly maxTokens?: number },
+  options: { readonly temperature: number; readonly maxTokens: number },
 ): Promise<ChatWithToolsResult> {
-  const openaiMessages = messages.map(agentMessageToOpenAI);
-  const openaiTools = tools.map((t) => ({
+  const openaiMessages = agentMessagesToOpenAIChat(messages);
+  const openaiTools: OpenAI.Chat.Completions.ChatCompletionTool[] = tools.map((t) => ({
     type: "function" as const,
-    function: { name: t.name, description: t.description, parameters: t.parameters },
+    function: {
+      name: t.name,
+      description: t.description,
+      parameters: t.parameters,
+    },
   }));
 
   const stream = await client.chat.completions.create({
     model,
     messages: openaiMessages,
     tools: openaiTools,
-    tool_choice: "auto",
+    temperature: options.temperature,
+    max_tokens: options.maxTokens,
     stream: true,
   });
 
@@ -165,13 +210,12 @@ async function chatWithToolsOpenAI(
 
   for await (const chunk of stream) {
     const delta = chunk.choices[0]?.delta;
-    if (!delta) continue;
-    if (delta.content) content += delta.content;
-    if (delta.tool_calls) {
+    if (delta?.content) content += delta.content;
+    if (delta?.tool_calls) {
       for (const tc of delta.tool_calls) {
         const existing = toolCallMap.get(tc.index);
         if (existing) {
-          if (tc.function?.arguments) existing.arguments += tc.function.arguments;
+          existing.arguments += tc.function?.arguments ?? "";
         } else {
           toolCallMap.set(tc.index, {
             id: tc.id ?? "",
@@ -183,30 +227,183 @@ async function chatWithToolsOpenAI(
     }
   }
 
-  const toolCalls = [...toolCallMap.entries()]
-    .sort(([a], [b]) => a - b)
-    .map(([, tc]) => ({ id: tc.id, name: tc.name, arguments: tc.arguments }));
+  const toolCalls: ToolCall[] = [...toolCallMap.values()];
+  return { content, toolCalls };
+}
+
+function agentMessagesToOpenAIChat(
+  messages: ReadonlyArray<AgentMessage>,
+): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
+  const result: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
+
+  for (const msg of messages) {
+    if (msg.role === "system") {
+      result.push({ role: "system", content: msg.content });
+      continue;
+    }
+    if (msg.role === "user") {
+      result.push({ role: "user", content: msg.content });
+      continue;
+    }
+    if (msg.role === "assistant") {
+      const assistantMsg: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam = {
+        role: "assistant",
+        content: msg.content ?? null,
+      };
+      if (msg.toolCalls && msg.toolCalls.length > 0) {
+        assistantMsg.tool_calls = msg.toolCalls.map((tc) => ({
+          id: tc.id,
+          type: "function" as const,
+          function: { name: tc.name, arguments: tc.arguments },
+        }));
+      }
+      result.push(assistantMsg);
+      continue;
+    }
+    if (msg.role === "tool") {
+      result.push({
+        role: "tool",
+        tool_call_id: msg.toolCallId,
+        content: msg.content,
+      });
+    }
+  }
+
+  return result;
+}
+
+// === OpenAI Responses API Implementation (optional) ===
+
+async function chatCompletionOpenAIResponses(
+  client: OpenAI,
+  model: string,
+  messages: ReadonlyArray<LLMMessage>,
+  options: { readonly temperature: number; readonly maxTokens: number },
+): Promise<LLMResponse> {
+  const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
+    role: m.role as "system" | "user" | "assistant",
+    content: m.content,
+  }));
+
+  const stream = await client.responses.create({
+    model,
+    input,
+    temperature: options.temperature,
+    max_output_tokens: options.maxTokens,
+    stream: true,
+  });
+
+  const chunks: string[] = [];
+  let inputTokens = 0;
+  let outputTokens = 0;
+
+  for await (const event of stream) {
+    if (event.type === "response.output_text.delta") {
+      chunks.push(event.delta);
+    }
+    if (event.type === "response.completed") {
+      inputTokens = event.response.usage?.input_tokens ?? 0;
+      outputTokens = event.response.usage?.output_tokens ?? 0;
+    }
+  }
+
+  const content = chunks.join("");
+  if (!content) throw new Error("LLM returned empty response");
+
+  return {
+    content,
+    usage: {
+      promptTokens: inputTokens,
+      completionTokens: outputTokens,
+      totalTokens: inputTokens + outputTokens,
+    },
+  };
+}
+
+async function chatWithToolsOpenAIResponses(
+  client: OpenAI,
+  model: string,
+  messages: ReadonlyArray<AgentMessage>,
+  tools: ReadonlyArray<ToolDefinition>,
+  options: { readonly temperature: number; readonly maxTokens: number },
+): Promise<ChatWithToolsResult> {
+  const input = agentMessagesToResponsesInput(messages);
+  const responsesTools: OpenAI.Responses.Tool[] = tools.map((t) => ({
+    type: "function" as const,
+    name: t.name,
+    description: t.description,
+    parameters: t.parameters as OpenAI.Responses.FunctionTool["parameters"],
+    strict: false,
+  }));
+
+  const stream = await client.responses.create({
+    model,
+    input,
+    tools: responsesTools,
+    temperature: options.temperature,
+    max_output_tokens: options.maxTokens,
+    stream: true,
+  });
+
+  let content = "";
+  const toolCalls: ToolCall[] = [];
+
+  for await (const event of stream) {
+    if (event.type === "response.output_text.delta") {
+      content += event.delta;
+    }
+    if (event.type === "response.output_item.done" && event.item.type === "function_call") {
+      toolCalls.push({
+        id: event.item.call_id,
+        name: event.item.name,
+        arguments: event.item.arguments,
+      });
+    }
+  }
 
   return { content, toolCalls };
 }
 
-function agentMessageToOpenAI(msg: AgentMessage): OpenAI.Chat.Completions.ChatCompletionMessageParam {
-  if (msg.role === "system") return { role: "system", content: msg.content };
-  if (msg.role === "user") return { role: "user", content: msg.content };
-  if (msg.role === "tool") return { role: "tool", content: msg.content, tool_call_id: msg.toolCallId };
-  // assistant
-  if (msg.toolCalls && msg.toolCalls.length > 0) {
-    return {
-      role: "assistant",
-      content: msg.content,
-      tool_calls: msg.toolCalls.map((tc) => ({
-        id: tc.id,
-        type: "function" as const,
-        function: { name: tc.name, arguments: tc.arguments },
-      })),
-    };
+function agentMessagesToResponsesInput(
+  messages: ReadonlyArray<AgentMessage>,
+): OpenAI.Responses.ResponseInputItem[] {
+  const result: OpenAI.Responses.ResponseInputItem[] = [];
+
+  for (const msg of messages) {
+    if (msg.role === "system") {
+      result.push({ role: "system", content: msg.content });
+      continue;
+    }
+    if (msg.role === "user") {
+      result.push({ role: "user", content: msg.content });
+      continue;
+    }
+    if (msg.role === "assistant") {
+      if (msg.content) {
+        result.push({ role: "assistant", content: msg.content });
+      }
+      if (msg.toolCalls) {
+        for (const tc of msg.toolCalls) {
+          result.push({
+            type: "function_call" as const,
+            call_id: tc.id,
+            name: tc.name,
+            arguments: tc.arguments,
+          });
+        }
+      }
+      continue;
+    }
+    if (msg.role === "tool") {
+      result.push({
+        type: "function_call_output" as const,
+        call_id: msg.toolCallId,
+        output: msg.content,
+      });
+    }
   }
-  return { role: "assistant", content: msg.content };
+
+  return result;
 }
 
 // === Anthropic Implementation ===
@@ -215,7 +412,8 @@ async function chatCompletionAnthropic(
   client: Anthropic,
   model: string,
   messages: ReadonlyArray<LLMMessage>,
-  options?: { readonly temperature?: number; readonly maxTokens?: number },
+  options: { readonly temperature: number; readonly maxTokens: number },
+  thinkingBudget: number = 0,
 ): Promise<LLMResponse> {
   const systemText = messages
     .filter((m) => m.role === "system")
@@ -230,8 +428,10 @@ async function chatCompletionAnthropic(
       role: m.role as "user" | "assistant",
       content: m.content,
     })),
-    temperature: options?.temperature ?? 0.7,
-    max_tokens: options?.maxTokens ?? 8192,
+    ...(thinkingBudget > 0
+      ? { thinking: { type: "enabled" as const, budget_tokens: thinkingBudget } }
+      : { temperature: options.temperature }),
+    max_tokens: options.maxTokens,
     stream: true,
   });
 
@@ -269,7 +469,8 @@ async function chatWithToolsAnthropic(
   model: string,
   messages: ReadonlyArray<AgentMessage>,
   tools: ReadonlyArray<ToolDefinition>,
-  options?: { readonly temperature?: number; readonly maxTokens?: number },
+  options: { readonly temperature: number; readonly maxTokens: number },
+  thinkingBudget: number = 0,
 ): Promise<ChatWithToolsResult> {
   const systemText = messages
     .filter((m) => m.role === "system")
@@ -289,7 +490,10 @@ async function chatWithToolsAnthropic(
     ...(systemText ? { system: systemText } : {}),
     messages: anthropicMessages,
     tools: anthropicTools,
-    max_tokens: options?.maxTokens ?? 8192,
+    ...(thinkingBudget > 0
+      ? { thinking: { type: "enabled" as const, budget_tokens: thinkingBudget } }
+      : { temperature: options.temperature }),
+    max_tokens: options.maxTokens,
     stream: true,
   });
 

+ 5 - 1
packages/core/src/models/project.ts

@@ -3,8 +3,12 @@ import { z } from "zod";
 export const LLMConfigSchema = z.object({
   provider: z.enum(["anthropic", "openai", "custom"]),
   baseUrl: z.string().url(),
-  apiKey: z.string().min(1),
+  apiKey: z.string().default(""),
   model: z.string().min(1),
+  temperature: z.number().min(0).max(2).default(0.7),
+  maxTokens: z.number().int().min(1).default(8192),
+  thinkingBudget: z.number().int().min(0).default(0),
+  apiFormat: z.enum(["chat", "responses"]).default("chat"),
 });
 
 export type LLMConfig = z.infer<typeof LLMConfigSchema>;