diff --git a/README.md b/README.md index cf1b41d..9f26230 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ 全自动 AI 长篇小说创作引擎。基于多智能体协作架构,从一句话需求到完整小说,全程无需人工干预。 +

+ ainovel-cli demo +

+ ## 特性 - **多智能体协作** — Coordinator 调度 Architect / Writer / Editor 三个专职智能体,各司其职 @@ -11,7 +15,7 @@ - **七维质量评审** — Editor 从设定一致性、角色行为、节奏、叙事连贯、伏笔、钩子、审美品质七个维度评审,审美维度必须引用原文举证 - **用户实时干预** — 写作过程中可随时注入修改意见,系统自动评估影响范围并重写 - **双模式运行** — CLI 一行命令直接跑,TUI 交互界面实时观察进度 -- **多 LLM 支持** — OpenRouter / Anthropic / Gemini / OpenAI 随意切换 +- **多 LLM 支持** — OpenRouter / Anthropic / Gemini / OpenAI 等等随意切换 ## 架构 @@ -95,10 +99,12 @@ ainovel-cli | 变量 | 说明 | 默认值 | |------|------|--------| -| `LLM_PROVIDER` | LLM 提供商 | `openrouter` | +| `LLM_PROVIDER` | LLM 提供商(`openrouter` / `anthropic` / `gemini` / `openai`) | `openrouter` | | `OPENROUTER_API_KEY` | OpenRouter API Key | — | | `ANTHROPIC_API_KEY` | Anthropic API Key | — | | `GEMINI_API_KEY` | Gemini API Key | — | +| `Z_OPENAI_API_KEY` | OpenAI 兼容接口 API Key(通用) | — | +| `Z_OPENAI_BASE_URL` | OpenAI 兼容接口 Base URL | — | | `NOVEL_STYLE` | 写作风格 | `default` | ### 写作风格 @@ -118,7 +124,7 @@ output/{novel_name}/ │ ├── 01.md │ └── ... ├── summaries/ # 章节摘要(JSON) -├── drafts/ # 场景草稿 +├── drafts/ # 章节草稿 ├── reviews/ # 评审报告 ├── meta/ │ ├── premise.md # 故事前提 @@ -128,8 +134,9 @@ output/{novel_name}/ │ ├── progress.json # 进度状态 │ ├── timeline.json # 时间线 │ ├── foreshadow.json # 伏笔台账 -│ └── snapshots/ # 角色状态快照(长篇) -└── characters.md # 角色档案(可读版) +│ ├── snapshots/ # 角色状态快照(长篇) +│ ├── characters.md # 角色档案(可读版) +│ └── world_rules.md # 世界规则(可读版) ``` ## 设计理念 diff --git a/app/run.go b/app/run.go index b63a818..24537d3 100644 --- a/app/run.go +++ b/app/run.go @@ -122,6 +122,9 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s // registerSubscription 注册 coordinator 事件订阅,包含确定性控制和可选的 UIEvent/Delta 转发。 func registerSubscription(coordinator *agentcore.Agent, store *state.Store, provider string, emit emitFn, onDelta deltaFn, onClear clearFn) { var lastProgressSummary string + agentExt := newFieldExtractor("agent") // Coordinator → subagent 目标 agent 名称 + taskExt := newFieldExtractor("task") // Coordinator → subagent 调度指令 + subFilter := newStreamFilter("content") // SubAgent:文本透传 + JSON 提取 content coordinator.Subscribe(func(ev agentcore.Event) { switch ev.Type { @@ -135,7 +138,9 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, prov // 区分流式 delta 和进度摘要 if delta, ok := parseStreamDelta(ev); ok { if onDelta != nil { - onDelta(delta) + if text := subFilter.Feed(delta); text != "" { + onDelta(text) + } } return } @@ -153,15 +158,23 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, prov } case agentcore.EventMessageStart: - // 新一轮 LLM 输出开始,清空流式缓冲 + // 新一轮 LLM 输出开始,重置提取器 + 清空流式缓冲 + agentExt.Reset() + taskExt.Reset() + subFilter.Reset() if onClear != nil { onClear() } case agentcore.EventMessageUpdate: - // Coordinator 自身思考时的流式 token + // Coordinator 的流式 token:先提取 agent 名称做标题,再提取 task 内容 if ev.Delta != "" && onDelta != nil { - onDelta(ev.Delta) + if name := agentExt.Feed(ev.Delta); name != "" { + onDelta("\n▸ " + agentLabel(name) + "\n") + } + if text := taskExt.Feed(ev.Delta); text != "" { + onDelta(text) + } } case agentcore.EventToolExecEnd: @@ -806,6 +819,20 @@ func extractToolErrorText(result json.RawMessage) string { return truncateLog(string(result), 160) } +// agentLabel 将内部 agent 名称映射为用户友好的标签。 +func agentLabel(name string) string { + switch name { + case "architect_short", "architect_mid", "architect_long": + return "Architect 规划中" + case "writer": + return "Writer 创作中" + case "editor": + return "Editor 审阅中" + default: + return name + } +} + func truncateLog(s string, maxRunes int) string { runes := []rune(s) if len(runes) <= maxRunes { diff --git a/app/stream_extract.go b/app/stream_extract.go new file mode 100644 index 0000000..f9cb55a --- /dev/null +++ b/app/stream_extract.go @@ -0,0 +1,216 @@ +package app + +import "strings" + +// jsonFieldExtractor 从流式 JSON 碎片中提取指定字段的字符串值。 +// +// LLM 流式生成 tool call 时,参数是逐片段到达的(OpenAI/Anthropic) +// 或一次性到达的(Gemini)。本提取器用状态机逐字符扫描, +// 检测到目标 key 后提取其字符串值,处理 JSON 转义。 +type jsonFieldExtractor struct { + key string // 匹配目标,如 `"content"` 或 `"task"` + state extractState + matchPos int + escape bool + buf strings.Builder +} + +type extractState int + +const ( + stateScan extractState = iota // 扫描,寻找目标 key + stateColon // 已匹配 key,等冒号和开头引号 + stateExtract // 提取字符串值中 +) + +func newFieldExtractor(fieldName string) *jsonFieldExtractor { + return &jsonFieldExtractor{key: `"` + fieldName + `"`} +} + +// Feed 处理一段 delta,返回提取到的文本(可能为空)。 +func (e *jsonFieldExtractor) Feed(delta string) string { + e.buf.Reset() + for _, r := range delta { + switch e.state { + case stateScan: + e.feedScan(r) + case stateColon: + e.feedColon(r) + case stateExtract: + e.feedExtract(r) + } + } + return e.buf.String() +} + +func (e *jsonFieldExtractor) feedScan(r rune) { + if e.matchPos < len(e.key) && byte(r) == e.key[e.matchPos] { + e.matchPos++ + if e.matchPos == len(e.key) { + e.state = stateColon + e.matchPos = 0 + } + return + } + e.matchPos = 0 + if byte(r) == e.key[0] { + e.matchPos = 1 + } +} + +func (e *jsonFieldExtractor) feedColon(r rune) { + switch r { + case ':', ' ', '\t': + // 跳过 + case '"': + e.state = stateExtract + e.escape = false + default: + e.state = stateScan + e.matchPos = 0 + if byte(r) == e.key[0] { + e.matchPos = 1 + } + } +} + +func (e *jsonFieldExtractor) feedExtract(r rune) { + if e.escape { + e.escape = false + switch r { + case 'n': + e.buf.WriteByte('\n') + case 't': + e.buf.WriteByte('\t') + case 'r': + e.buf.WriteByte('\r') + case '"', '\\', '/': + e.buf.WriteRune(r) + default: + e.buf.WriteByte('\\') + e.buf.WriteRune(r) + } + return + } + switch r { + case '\\': + e.escape = true + case '"': + e.state = stateScan + e.matchPos = 0 + default: + e.buf.WriteRune(r) + } +} + +// Reset 重置状态(新 LLM 消息轮次时调用)。 +func (e *jsonFieldExtractor) Reset() { + e.state = stateScan + e.matchPos = 0 + e.escape = false +} + +// ThinkingSep 是思考文本与正文之间的分隔标记。 +// streamFilter 在思考文本段前插入此标记,TUI 据此切换渲染样式。 +const ThinkingSep = "\x02" + +// streamFilter 区分 SubAgent 的文本回复和 JSON 工具调用。 +// 文本回复标记为思考内容(前缀 ThinkingSep);JSON 工具调用只提取指定字段。 +// +// 判断依据:遇到 { 进入 JSON 模式(追踪大括号深度), +// 深度归零后回到文本模式。 +type streamFilter struct { + fieldExt *jsonFieldExtractor + mode filterMode + braceDepth int + inString bool // 在 JSON 字符串内(大括号不计数) + escJSON bool // JSON 字符串内的转义 + thinking bool // 当前处于思考文本段 + buf strings.Builder +} + +type filterMode int + +const ( + filterText filterMode = iota // 文本回复,直接透传 + filterJSON // JSON 工具调用,提取目标字段 +) + +func newStreamFilter(fieldName string) *streamFilter { + return &streamFilter{fieldExt: newFieldExtractor(fieldName)} +} + +// Feed 处理一段 delta,返回可展示文本。 +// 文本回复直接输出;JSON 中的目标字段值被提取输出;其余 JSON 结构丢弃。 +func (f *streamFilter) Feed(delta string) string { + f.buf.Reset() + for _, r := range delta { + switch f.mode { + case filterText: + if r == '{' { + f.thinking = false + f.mode = filterJSON + f.braceDepth = 1 + f.inString = false + f.escJSON = false + f.fieldExt.Reset() + f.feedExtractor(r) + } else { + if !f.thinking { + f.thinking = true + f.buf.WriteString(ThinkingSep) + } + f.buf.WriteRune(r) + } + case filterJSON: + f.feedExtractor(r) + f.trackBraces(r) + } + } + return f.buf.String() +} + +// feedExtractor 将单个字符喂给 fieldExt,提取结果写入 buf。 +func (f *streamFilter) feedExtractor(r rune) { + if text := f.fieldExt.Feed(string(r)); text != "" { + f.buf.WriteString(text) + } +} + +// trackBraces 追踪 JSON 大括号深度,深度归零时切回文本模式。 +func (f *streamFilter) trackBraces(r rune) { + if f.escJSON { + f.escJSON = false + return + } + if f.inString { + switch r { + case '\\': + f.escJSON = true + case '"': + f.inString = false + } + return + } + switch r { + case '"': + f.inString = true + case '{': + f.braceDepth++ + case '}': + f.braceDepth-- + if f.braceDepth <= 0 { + f.mode = filterText + } + } +} + +// Reset 重置状态。 +func (f *streamFilter) Reset() { + f.mode = filterText + f.braceDepth = 0 + f.inString = false + f.escJSON = false + f.thinking = false + f.fieldExt.Reset() +} diff --git a/app/stream_extract_test.go b/app/stream_extract_test.go new file mode 100644 index 0000000..79044c1 --- /dev/null +++ b/app/stream_extract_test.go @@ -0,0 +1,194 @@ +package app + +import "testing" + +// --- jsonFieldExtractor tests --- + +func TestFieldExtractor_SingleFeed(t *testing.T) { + e := newFieldExtractor("content") + got := e.Feed(`{"chapter":1,"content":"hello world","mode":"write"}`) + if got != "hello world" { + t.Errorf("got %q, want %q", got, "hello world") + } +} + +func TestFieldExtractor_CrossDelta(t *testing.T) { + e := newFieldExtractor("content") + var result string + for _, d := range []string{`{"chapter":1,"con`, `tent":"`, `第三章`, `\n\n夜幕低垂`, `","mode":"write"}`} { + result += e.Feed(d) + } + if want := "第三章\n\n夜幕低垂"; result != want { + t.Errorf("got %q, want %q", result, want) + } +} + +func TestFieldExtractor_JSONEscape(t *testing.T) { + e := newFieldExtractor("content") + got := e.Feed(`{"content":"line1\nline2\t\"quoted\"\\end"}`) + if want := "line1\nline2\t\"quoted\"\\end"; got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestFieldExtractor_NoTargetField(t *testing.T) { + e := newFieldExtractor("content") + got := e.Feed(`{"chapter":1,"summary":"test","characters":["A"]}`) + if got != "" { + t.Errorf("got %q, want empty", got) + } +} + +func TestFieldExtractor_ColonWithSpace(t *testing.T) { + e := newFieldExtractor("content") + got := e.Feed(`{"content" : "spaced"}`) + if got != "spaced" { + t.Errorf("got %q, want %q", got, "spaced") + } +} + +func TestFieldExtractor_Reset(t *testing.T) { + e := newFieldExtractor("content") + e.Feed(`{"content":"partial`) + e.Reset() + got := e.Feed(`{"content":"fresh"}`) + if got != "fresh" { + t.Errorf("got %q, want %q", got, "fresh") + } +} + +func TestFieldExtractor_TaskField(t *testing.T) { + e := newFieldExtractor("task") + got := e.Feed(`{"agent":"writer","task":"写第1章。核心事件:林尘目睹斗法"}`) + if want := "写第1章。核心事件:林尘目睹斗法"; got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestFieldExtractor_TaskCrossDelta(t *testing.T) { + e := newFieldExtractor("task") + var result string + for _, d := range []string{`{"agent":"writer","ta`, `sk":"写第`, `1章"}`, `extra`} { + result += e.Feed(d) + } + if want := "写第1章"; result != want { + t.Errorf("got %q, want %q", result, want) + } +} + +func TestFieldExtractor_Chinese(t *testing.T) { + e := newFieldExtractor("content") + var result string + for _, d := range []string{`{"content":"`, `林远站在窗前,`, `望着远处的山峦。`, `"}`} { + result += e.Feed(d) + } + if want := "林远站在窗前,望着远处的山峦。"; result != want { + t.Errorf("got %q, want %q", result, want) + } +} + +// --- streamFilter tests --- + +func TestStreamFilter_TextPassthrough(t *testing.T) { + f := newStreamFilter("content") + got := f.Feed("好的,我来加载上下文信息。") + if want := ThinkingSep + "好的,我来加载上下文信息。"; got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestStreamFilter_JSONExtractContent(t *testing.T) { + f := newStreamFilter("content") + got := f.Feed(`{"chapter":1,"content":"第一章 晨曦","mode":"write"}`) + if want := "第一章 晨曦"; got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestStreamFilter_JSONNoContent(t *testing.T) { + f := newStreamFilter("content") + got := f.Feed(`{"chapter":1,"title":"暗流","goal":"揭示线索"}`) + if got != "" { + t.Errorf("got %q, want empty", got) + } +} + +func TestStreamFilter_TextThenJSON(t *testing.T) { + f := newStreamFilter("content") + var result string + result += f.Feed("规划完成,开始写作。") + result += f.Feed(`{"chapter":1,"content":"正文","mode":"write"}`) + if want := ThinkingSep + "规划完成,开始写作。正文"; result != want { + t.Errorf("got %q, want %q", result, want) + } +} + +func TestStreamFilter_JSONThenText(t *testing.T) { + f := newStreamFilter("content") + var result string + result += f.Feed(`{"chapter":1,"summary":"摘要"}`) + result += f.Feed("提交完成。") + if want := ThinkingSep + "提交完成。"; result != want { + t.Errorf("got %q, want %q", result, want) + } +} + +func TestStreamFilter_CrossDeltaMixed(t *testing.T) { + f := newStreamFilter("content") + var result string + deltas := []string{ + "好的,开始", + "写作。", + `{"chapter":1`, + `,"content":"`, + "第一章", + "\n\n正文", + `","mode":"write"}`, + "已写入。", + } + for _, d := range deltas { + result += f.Feed(d) + } + want := ThinkingSep + "好的,开始写作。第一章\n\n正文" + ThinkingSep + "已写入。" + if result != want { + t.Errorf("got %q, want %q", result, want) + } +} + +func TestStreamFilter_NestedBraces(t *testing.T) { + f := newStreamFilter("content") + got := f.Feed(`{"summary":"摘要","foreshadow":[{"type":"plant"}]}`) + if got != "" { + t.Errorf("got %q, want empty (nested JSON should be fully consumed)", got) + } +} + +func TestStreamFilter_BracesInString(t *testing.T) { + f := newStreamFilter("content") + got := f.Feed(`{"content":"文中有{大括号}和\"引号\""}后续文本`) + want := "文中有{大括号}和\"引号\"" + ThinkingSep + "后续文本" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestStreamFilter_Reset(t *testing.T) { + f := newStreamFilter("content") + f.Feed(`{"content":"半截`) + f.Reset() + got := f.Feed("重新开始") + if want := ThinkingSep + "重新开始"; got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestStreamFilter_ThinkingMarkerOnce(t *testing.T) { + // 连续文本只在段首插入一次标记 + f := newStreamFilter("content") + var result string + result += f.Feed("好的") + result += f.Feed(",继续") + if want := ThinkingSep + "好的,继续"; result != want { + t.Errorf("got %q, want %q", result, want) + } +} diff --git a/main.go b/main.go index ad0295e..a85adb8 100644 --- a/main.go +++ b/main.go @@ -66,7 +66,7 @@ func buildConfig(style string) app.Config { Provider: provider, APIKey: apiKey, BaseURL: baseURL, - ModelName: "openrouter/hunter-alpha", + ModelName: "stepfun/step-3.5-flash:free", Style: style, } return cfg diff --git a/scripts/sample.gif b/scripts/sample.gif new file mode 100644 index 0000000..e30e634 Binary files /dev/null and b/scripts/sample.gif differ diff --git a/tui/ask_user.go b/tui/ask_user.go index dd6a27b..675d202 100644 --- a/tui/ask_user.go +++ b/tui/ask_user.go @@ -257,7 +257,7 @@ func renderAskUserModal(width, height int, state *askUserState) string { Border(baseBorder). BorderForeground(colorAccent). Padding(1, 2). - Background(lipgloss.Color("#1b1712")). + Background(lipgloss.Color("#2a2520")). Render(b.String()) return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) diff --git a/tui/panels.go b/tui/panels.go index 9fa0fb8..91f26aa 100644 --- a/tui/panels.go +++ b/tui/panels.go @@ -202,12 +202,8 @@ func renderEventFlowViewport(vp viewport.Model, width, height int, focused bool) // renderStreamPanel 渲染流式输出面板(中间列下半部分)。 func renderStreamPanel(vp viewport.Model, width, height int, focused bool) string { - // 分隔标题栏 - titleColor := colorDim - if focused { - titleColor = colorAccent - } - title := lipgloss.NewStyle().Foreground(titleColor).Render("✦ 生成内容") + // 分隔标题栏(始终醒目) + title := lipgloss.NewStyle().Foreground(colorAccent).Bold(focused).Render("✦ 实时输出") lineW := width - lipgloss.Width(title) - 4 if lineW < 0 { lineW = 0 @@ -229,48 +225,94 @@ func renderStreamPanel(vp viewport.Model, width, height int, focused bool) strin return header + "\n" + vpStyle.Render(vp.View()) } -// renderStreamContent 将流式输出按轮次渲染为分块内容,避免长段直接拼接导致错乱。 +// renderStreamContent 将流式输出按轮次渲染为语义分块。 +// Agent 调度块(以 ▸ 开头)用 accent 标题 + dim 指令;正文块用标准文本色。 func renderStreamContent(rounds []string, width int) string { if width < 24 { width = 24 } var blocks []string - displayIndex := 0 - for i, round := range rounds { + for _, round := range rounds { text := strings.TrimSpace(round) if text == "" { continue } - displayIndex++ - blocks = append(blocks, renderStreamBlock(displayIndex, text, width, i == len(rounds)-1)) + if strings.HasPrefix(text, "▸") { + blocks = append(blocks, renderAgentBlock(text, width)) + } else { + blocks = append(blocks, renderChapterBlock(text, width)) + } } return strings.Join(blocks, "\n\n") } -func renderStreamBlock(index int, text string, width int, active bool) string { - headerStyle := lipgloss.NewStyle().Foreground(colorDim) - bodyStyle := lipgloss.NewStyle().Foreground(colorText) - dividerColor := colorDim - if active { - headerStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) - dividerColor = colorAccent - } +// renderAgentBlock 渲染 Agent 调度块:标题 + 分隔线 + 任务指令。 +func renderAgentBlock(text string, width int) string { + headerLine, body, _ := strings.Cut(text, "\n") - header := headerStyle.Render(fmt.Sprintf("◆ 第 %d 段", index)) - divider := lipgloss.NewStyle().Foreground(dividerColor).Render(strings.Repeat("─", max(8, width))) - lines := wrapStreamText(text, max(16, width-4)) + // 标题行 + 分隔线 + titleW := lipgloss.Width(headerLine) + lineW := max(0, width-titleW-1) + header := lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render(headerLine) + + " " + lipgloss.NewStyle().Foreground(colorDim).Render(strings.Repeat("─", lineW)) var b strings.Builder b.WriteString(header) - b.WriteString("\n") - b.WriteString(divider) - b.WriteString("\n") - for i, line := range lines { - if i > 0 { - b.WriteString("\n") + + // 任务指令:dim 色,缩进 2 格 + body = strings.TrimSpace(body) + if body != "" { + taskStyle := lipgloss.NewStyle().Foreground(colorMuted) + lines := wrapStreamText(body, max(16, width-6)) + b.WriteString("\n") + for i, line := range lines { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(taskStyle.Render(" " + line)) + } + } + return b.String() +} + +// renderChapterBlock 渲染正文块,自动区分思考内容和章节正文。 +// 思考内容(ThinkingSep 标记的段落)用淡色斜体,正文用标准文本色。 +func renderChapterBlock(text string, width int) string { + contentStyle := lipgloss.NewStyle().Foreground(colorText) + thinkStyle := lipgloss.NewStyle().Foreground(colorDim).Italic(true) + wrapW := max(16, width-4) + + // 按 ThinkingSep 分割:奇数段是思考,偶数段是正文 + // 格式:[正文] \x02 [思考] [正文] \x02 [思考] ... + parts := strings.Split(text, app.ThinkingSep) + + var b strings.Builder + for i, part := range parts { + part = strings.TrimRight(part, " ") + if part == "" { + continue + } + isThinking := i > 0 && i%2 != 0 // ThinkingSep 之后的奇数段是思考 + // 如果整段都是思考标记开头(第一个 part 之前无正文),调整判断 + if i == 0 && part == "" { + continue + } + + style := contentStyle + if isThinking { + style = thinkStyle + } + + lines := wrapStreamText(part, wrapW) + for j, line := range lines { + if b.Len() > 0 && j == 0 { + b.WriteString("\n") + } else if j > 0 { + b.WriteString("\n") + } + b.WriteString(style.Render(line)) } - b.WriteString(bodyStyle.Render(line)) } return b.String() } diff --git a/tui/theme.go b/tui/theme.go index d5c6cae..820906d 100644 --- a/tui/theme.go +++ b/tui/theme.go @@ -6,6 +6,7 @@ import "github.com/charmbracelet/lipgloss" var ( colorText = lipgloss.Color("#e0d8c8") colorDim = lipgloss.Color("#666666") + colorMuted = lipgloss.Color("#a09880") // 柔和但可读(介于 dim 和 text 之间) colorAccent = lipgloss.Color("#d4a017") // 琥珀黄 colorSuccess = lipgloss.Color("#2ecc71") // 冷绿 colorError = lipgloss.Color("#e74c3c") // 朱红