diff --git a/README.md b/README.md
index cf1b41d..9f26230 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,10 @@
全自动 AI 长篇小说创作引擎。基于多智能体协作架构,从一句话需求到完整小说,全程无需人工干预。
+
+
+
+
## 特性
- **多智能体协作** — 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") // 朱红