feat: rhythm tracking and structured review
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -37,3 +37,4 @@ release-notes.md
|
|||||||
|
|
||||||
docs/
|
docs/
|
||||||
refer/
|
refer/
|
||||||
|
output
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ type Progress struct {
|
|||||||
InProgressChapter int `json:"in_progress_chapter,omitempty"` // 正在写作的章节(场景级恢复)
|
InProgressChapter int `json:"in_progress_chapter,omitempty"` // 正在写作的章节(场景级恢复)
|
||||||
CompletedScenes []int `json:"completed_scenes,omitempty"` // 当前章节已完成的场景编号
|
CompletedScenes []int `json:"completed_scenes,omitempty"` // 当前章节已完成的场景编号
|
||||||
Flow FlowState `json:"flow,omitempty"` // 当前流程
|
Flow FlowState `json:"flow,omitempty"` // 当前流程
|
||||||
PendingRewrites []int `json:"pending_rewrites,omitempty"` // 待重写章节队列
|
PendingRewrites []int `json:"pending_rewrites,omitempty"` // 待重写章节队列
|
||||||
RewriteReason string `json:"rewrite_reason,omitempty"` // 重写原因
|
RewriteReason string `json:"rewrite_reason,omitempty"` // 重写原因
|
||||||
|
StrandHistory []string `json:"strand_history,omitempty"` // 按章节顺序记录 dominant_strand
|
||||||
|
HookHistory []string `json:"hook_history,omitempty"` // 按章节顺序记录 hook_type
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsResumable 判断是否可以从断点恢复。
|
// IsResumable 判断是否可以从断点恢复。
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Character struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Arc string `json:"arc"`
|
Arc string `json:"arc"`
|
||||||
Traits []string `json:"traits"`
|
Traits []string `json:"traits"`
|
||||||
|
Tier string `json:"tier,omitempty"` // core / important / secondary / decorative(默认 important)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorldRule 世界观规则条目。
|
// WorldRule 世界观规则条目。
|
||||||
|
|||||||
@@ -45,4 +45,6 @@ type CommitResult struct {
|
|||||||
NextChapter int `json:"next_chapter"`
|
NextChapter int `json:"next_chapter"`
|
||||||
ReviewRequired bool `json:"review_required"`
|
ReviewRequired bool `json:"review_required"`
|
||||||
ReviewReason string `json:"review_reason,omitempty"`
|
ReviewReason string `json:"review_reason,omitempty"`
|
||||||
|
HookType string `json:"hook_type,omitempty"` // 钩子类型:crisis/mystery/desire/emotion/choice
|
||||||
|
DominantStrand string `json:"dominant_strand,omitempty"` // 本章主导线:quest/fire/constellation
|
||||||
}
|
}
|
||||||
|
|||||||
4
main.go
4
main.go
@@ -47,8 +47,8 @@ func main() {
|
|||||||
|
|
||||||
func buildConfig(style string) app.Config {
|
func buildConfig(style string) app.Config {
|
||||||
provider := envOr("LLM_PROVIDER", "openai")
|
provider := envOr("LLM_PROVIDER", "openai")
|
||||||
apiKey := os.Getenv("OPENAI_API_KEY")
|
apiKey := os.Getenv("Z_OPENAI_API_KEY")
|
||||||
baseURL := os.Getenv("OPENAI_BASE_URL")
|
baseURL := os.Getenv("Z_OPENAI_BASE_URL")
|
||||||
if provider == "anthropic" {
|
if provider == "anthropic" {
|
||||||
apiKey = envOr("ANTHROPIC_API_KEY", apiKey)
|
apiKey = envOr("ANTHROPIC_API_KEY", apiKey)
|
||||||
baseURL = envOr("ANTHROPIC_BASE_URL", baseURL)
|
baseURL = envOr("ANTHROPIC_BASE_URL", baseURL)
|
||||||
|
|||||||
@@ -10,37 +10,63 @@
|
|||||||
### 1. 获取上下文
|
### 1. 获取上下文
|
||||||
调用 novel_context(chapter=最新章节号),获取全部状态数据。
|
调用 novel_context(chapter=最新章节号),获取全部状态数据。
|
||||||
|
|
||||||
### 2. 审阅重点
|
### 2. 六维结构化审阅
|
||||||
|
|
||||||
逐项检查:
|
逐维度检查,每个维度必须给出结论(通过/存在问题)和具体问题列表:
|
||||||
|
|
||||||
**时间线一致性**
|
#### 维度一:设定一致性
|
||||||
- 事件发生顺序是否合理
|
- 事件发生顺序是否与时间线矛盾
|
||||||
- 时间跨度是否自洽
|
- 时间跨度是否自洽
|
||||||
|
- 世界规则边界是否被违反
|
||||||
|
- 角色属性(能力、外貌、身份)是否前后矛盾
|
||||||
|
|
||||||
**伏笔管理**
|
#### 维度二:人设一致性
|
||||||
- 是否有长期未回收的伏笔(超过 5 章未推进视为遗忘风险)
|
|
||||||
- 新伏笔是否有回收计划
|
|
||||||
|
|
||||||
**人物关系**
|
|
||||||
- 关系变化是否自然
|
|
||||||
- 角色行为是否符合其性格设定和弧线
|
- 角色行为是否符合其性格设定和弧线
|
||||||
|
- 对话风格是否与角色身份匹配
|
||||||
|
- 角色动机是否合理连贯
|
||||||
|
|
||||||
**结构问题**
|
#### 维度三:节奏平衡
|
||||||
- 节奏是否失衡(某几章过快或过慢)
|
- 是否连续多章同一类型(纯打斗、纯对话、纯描写)
|
||||||
- 主线是否推进
|
- 主线是否持续推进,有无原地踏步
|
||||||
- 是否存在重复或矛盾
|
- 情感节奏是否有张有弛
|
||||||
|
- 如果有 strand_history 数据,检查 quest/fire/constellation 三线分布是否失衡
|
||||||
|
|
||||||
|
#### 维度四:叙事连贯
|
||||||
|
- 场景之间过渡是否自然
|
||||||
|
- 因果逻辑是否通顺
|
||||||
|
- 信息传递是否一致(角色A不应知道只有角色B知道的事)
|
||||||
|
|
||||||
|
#### 维度五:伏笔健康
|
||||||
|
- 是否有超过 5 章未推进的伏笔(遗忘风险)
|
||||||
|
- 新伏笔是否有回收方向
|
||||||
|
- 已回收伏笔的解决是否令人满意
|
||||||
|
|
||||||
|
#### 维度六:钩子质量
|
||||||
|
- 章末钩子是否有足够吸引力
|
||||||
|
- 如果有 hook_history 数据,检查是否连续使用同一类型的钩子
|
||||||
|
- 钩子是否与主线推进方向一致
|
||||||
|
|
||||||
### 3. 输出审阅
|
### 3. 输出审阅
|
||||||
调用 save_review,给出:
|
调用 save_review,给出:
|
||||||
- issues:发现的具体问题列表,每个问题包含类型、严重程度、描述和修改建议
|
- issues:发现的具体问题列表,每个问题包含:
|
||||||
|
- type:问题维度(consistency/character/pacing/continuity/foreshadow/hook)
|
||||||
|
- severity:error 或 warning
|
||||||
|
- description:具体问题描述
|
||||||
|
- suggestion:修改建议
|
||||||
- verdict:审阅结论
|
- verdict:审阅结论
|
||||||
- `accept`:质量合格,可以继续写
|
- `accept`:所有维度通过或仅有 warning 级问题,可以继续写
|
||||||
- `polish`:存在细节问题,建议对特定章节做打磨
|
- `polish`:存在细节问题,建议对特定章节做打磨
|
||||||
- `rewrite`:存在结构性问题,建议重写特定章节
|
- `rewrite`:存在 error 级结构性问题,建议重写特定章节
|
||||||
- summary:审阅总结(200字以内)
|
- summary:审阅总结(200字以内),按维度概括
|
||||||
- affected_chapters:需要重写或打磨的章节号列表(verdict 为 polish/rewrite 时必填)
|
- affected_chapters:需要重写或打磨的章节号列表(verdict 为 polish/rewrite 时必填)
|
||||||
|
|
||||||
|
### 判定标准
|
||||||
|
|
||||||
|
- 任一维度出现 error 级问题 → verdict 至少为 polish
|
||||||
|
- 多个维度出现 error 级问题 → verdict 应为 rewrite
|
||||||
|
- 只有 warning 级问题 → verdict 为 accept
|
||||||
|
- 没有发现问题 → verdict 为 accept
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 不要自己修改正文
|
- 不要自己修改正文
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func (s *Store) UpdateForeshadow(chapter int, updates []domain.ForeshadowUpdate)
|
|||||||
for _, u := range updates {
|
for _, u := range updates {
|
||||||
switch u.Action {
|
switch u.Action {
|
||||||
case "plant":
|
case "plant":
|
||||||
|
idx[u.ID] = len(entries)
|
||||||
entries = append(entries, domain.ForeshadowEntry{
|
entries = append(entries, domain.ForeshadowEntry{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
Description: u.Description,
|
Description: u.Description,
|
||||||
@@ -61,6 +62,21 @@ func (s *Store) UpdateForeshadow(chapter int, updates []domain.ForeshadowUpdate)
|
|||||||
return s.SaveForeshadowLedger(entries)
|
return s.SaveForeshadowLedger(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadActiveForeshadow 返回未回收的伏笔条目(status != "resolved")。
|
||||||
|
func (s *Store) LoadActiveForeshadow() ([]domain.ForeshadowEntry, error) {
|
||||||
|
all, err := s.LoadForeshadowLedger()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var active []domain.ForeshadowEntry
|
||||||
|
for _, e := range all {
|
||||||
|
if e.Status != "resolved" {
|
||||||
|
active = append(active, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return active, nil
|
||||||
|
}
|
||||||
|
|
||||||
func renderForeshadow(entries []domain.ForeshadowEntry) string {
|
func renderForeshadow(entries []domain.ForeshadowEntry) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("# 伏笔账本\n\n")
|
b.WriteString("# 伏笔账本\n\n")
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ func (s *Store) UpdatePhase(phase domain.Phase) error {
|
|||||||
|
|
||||||
// MarkChapterComplete 标记章节完成,原子性更新进度。
|
// MarkChapterComplete 标记章节完成,原子性更新进度。
|
||||||
// 支持重写场景:如果章节已完成,先减去旧字数再加新字数。
|
// 支持重写场景:如果章节已完成,先减去旧字数再加新字数。
|
||||||
func (s *Store) MarkChapterComplete(chapter, wordCount int) error {
|
// hookType 和 dominantStrand 用于节奏追踪,可为空。
|
||||||
|
func (s *Store) MarkChapterComplete(chapter, wordCount int, hookType, dominantStrand string) error {
|
||||||
p, err := s.LoadProgress()
|
p, err := s.LoadProgress()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -89,6 +90,29 @@ func (s *Store) MarkChapterComplete(chapter, wordCount int) error {
|
|||||||
p.InProgressChapter = 0
|
p.InProgressChapter = 0
|
||||||
p.CompletedScenes = nil
|
p.CompletedScenes = nil
|
||||||
p.Phase = domain.PhaseWriting
|
p.Phase = domain.PhaseWriting
|
||||||
|
|
||||||
|
// 节奏追踪:按章节顺序填充 history(确保索引对齐)
|
||||||
|
if dominantStrand != "" {
|
||||||
|
for len(p.StrandHistory) < chapter-1 {
|
||||||
|
p.StrandHistory = append(p.StrandHistory, "")
|
||||||
|
}
|
||||||
|
if len(p.StrandHistory) < chapter {
|
||||||
|
p.StrandHistory = append(p.StrandHistory, dominantStrand)
|
||||||
|
} else {
|
||||||
|
p.StrandHistory[chapter-1] = dominantStrand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hookType != "" {
|
||||||
|
for len(p.HookHistory) < chapter-1 {
|
||||||
|
p.HookHistory = append(p.HookHistory, "")
|
||||||
|
}
|
||||||
|
if len(p.HookHistory) < chapter {
|
||||||
|
p.HookHistory = append(p.HookHistory, hookType)
|
||||||
|
} else {
|
||||||
|
p.HookHistory[chapter-1] = hookType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return s.SaveProgress(p)
|
return s.SaveProgress(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,22 @@ func (s *Store) AppendTimelineEvents(newEvents []domain.TimelineEvent) error {
|
|||||||
return s.SaveTimeline(append(existing, newEvents...))
|
return s.SaveTimeline(append(existing, newEvents...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadRecentTimeline 返回最近 window 章内的时间线事件(chapter >= current-window)。
|
||||||
|
func (s *Store) LoadRecentTimeline(current, window int) ([]domain.TimelineEvent, error) {
|
||||||
|
all, err := s.LoadTimeline()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
minCh := max(current-window, 1)
|
||||||
|
var filtered []domain.TimelineEvent
|
||||||
|
for _, e := range all {
|
||||||
|
if e.Chapter >= minCh {
|
||||||
|
filtered = append(filtered, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
func renderTimeline(events []domain.TimelineEvent) string {
|
func renderTimeline(events []domain.TimelineEvent) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("# 时间线\n\n")
|
b.WriteString("# 时间线\n\n")
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ func (t *CommitChapterTool) Schema() map[string]any {
|
|||||||
schema.Property("timeline_events", schema.Array("本章时间线事件", timelineSchema)),
|
schema.Property("timeline_events", schema.Array("本章时间线事件", timelineSchema)),
|
||||||
schema.Property("foreshadow_updates", schema.Array("伏笔操作", foreshadowSchema)),
|
schema.Property("foreshadow_updates", schema.Array("伏笔操作", foreshadowSchema)),
|
||||||
schema.Property("relationship_changes", schema.Array("关系变化", relationshipSchema)),
|
schema.Property("relationship_changes", schema.Array("关系变化", relationshipSchema)),
|
||||||
|
schema.Property("hook_type", schema.Enum("章末钩子类型", "crisis", "mystery", "desire", "emotion", "choice")),
|
||||||
|
schema.Property("dominant_strand", schema.Enum("本章主导叙事线", "quest", "fire", "constellation")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +64,8 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
|
|||||||
TimelineEvents []domain.TimelineEvent `json:"timeline_events"`
|
TimelineEvents []domain.TimelineEvent `json:"timeline_events"`
|
||||||
ForeshadowUpdates []domain.ForeshadowUpdate `json:"foreshadow_updates"`
|
ForeshadowUpdates []domain.ForeshadowUpdate `json:"foreshadow_updates"`
|
||||||
RelationshipChanges []domain.RelationshipEntry `json:"relationship_changes"`
|
RelationshipChanges []domain.RelationshipEntry `json:"relationship_changes"`
|
||||||
|
HookType string `json:"hook_type"`
|
||||||
|
DominantStrand string `json:"dominant_strand"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return nil, fmt.Errorf("invalid args: %w", err)
|
return nil, fmt.Errorf("invalid args: %w", err)
|
||||||
@@ -122,7 +126,7 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. 更新进度
|
// 5. 更新进度
|
||||||
if err := t.store.MarkChapterComplete(a.Chapter, wordCount); err != nil {
|
if err := t.store.MarkChapterComplete(a.Chapter, wordCount, a.HookType, a.DominantStrand); err != nil {
|
||||||
return nil, fmt.Errorf("mark chapter complete: %w", err)
|
return nil, fmt.Errorf("mark chapter complete: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +156,8 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
|
|||||||
NextChapter: a.Chapter + 1,
|
NextChapter: a.Chapter + 1,
|
||||||
ReviewRequired: reviewRequired,
|
ReviewRequired: reviewRequired,
|
||||||
ReviewReason: reviewReason,
|
ReviewReason: reviewReason,
|
||||||
|
HookType: a.HookType,
|
||||||
|
DominantStrand: a.DominantStrand,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. 写入信号文件供宿主程序读取(优先于清理操作,确保信号不丢失)
|
// 9. 写入信号文件供宿主程序读取(优先于清理操作,确保信号不丢失)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/voocel/agentcore/schema"
|
"github.com/voocel/agentcore/schema"
|
||||||
|
"github.com/voocel/ainovel-cli/domain"
|
||||||
"github.com/voocel/ainovel-cli/state"
|
"github.com/voocel/ainovel-cli/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,15 +68,14 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
|
|||||||
if outline, err := t.store.LoadOutline(); err == nil && outline != nil {
|
if outline, err := t.store.LoadOutline(); err == nil && outline != nil {
|
||||||
result["outline"] = outline
|
result["outline"] = outline
|
||||||
}
|
}
|
||||||
if chars, err := t.store.LoadCharacters(); err == nil && chars != nil {
|
|
||||||
result["characters"] = chars
|
|
||||||
}
|
|
||||||
|
|
||||||
if rules, err := t.store.LoadWorldRules(); err == nil && len(rules) > 0 {
|
if rules, err := t.store.LoadWorldRules(); err == nil && len(rules) > 0 {
|
||||||
result["world_rules"] = rules
|
result["world_rules"] = rules
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.Chapter > 0 {
|
if a.Chapter > 0 {
|
||||||
|
// 角色按 Tier 过滤:core/important 始终返回,secondary/decorative 按出场匹配
|
||||||
|
t.loadFilteredCharacters(result, a.Chapter)
|
||||||
|
|
||||||
// Writer/Editor 模式:加载章节相关上下文
|
// Writer/Editor 模式:加载章节相关上下文
|
||||||
if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil {
|
if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil {
|
||||||
result["current_chapter_outline"] = entry
|
result["current_chapter_outline"] = entry
|
||||||
@@ -83,53 +83,104 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
|
|||||||
if summaries, err := t.store.LoadRecentSummaries(a.Chapter, 2); err == nil && len(summaries) > 0 {
|
if summaries, err := t.store.LoadRecentSummaries(a.Chapter, 2); err == nil && len(summaries) > 0 {
|
||||||
result["recent_summaries"] = summaries
|
result["recent_summaries"] = summaries
|
||||||
}
|
}
|
||||||
// V1: 加载状态数据
|
|
||||||
if timeline, err := t.store.LoadTimeline(); err == nil && len(timeline) > 0 {
|
// V3: 状态数据分级加载
|
||||||
|
// timeline:只取最近 5 章的事件(避免后期全量膨胀)
|
||||||
|
if timeline, err := t.store.LoadRecentTimeline(a.Chapter, 5); err == nil && len(timeline) > 0 {
|
||||||
result["timeline"] = timeline
|
result["timeline"] = timeline
|
||||||
}
|
}
|
||||||
if foreshadow, err := t.store.LoadForeshadowLedger(); err == nil && len(foreshadow) > 0 {
|
// foreshadow:只取未回收条目(已回收的对后续写作无意义)
|
||||||
|
if foreshadow, err := t.store.LoadActiveForeshadow(); err == nil && len(foreshadow) > 0 {
|
||||||
result["foreshadow_ledger"] = foreshadow
|
result["foreshadow_ledger"] = foreshadow
|
||||||
}
|
}
|
||||||
|
// relationships:保持全量(pair-key 去重,数据量天然可控)
|
||||||
if relationships, err := t.store.LoadRelationships(); err == nil && len(relationships) > 0 {
|
if relationships, err := t.store.LoadRelationships(); err == nil && len(relationships) > 0 {
|
||||||
result["relationship_state"] = relationships
|
result["relationship_state"] = relationships
|
||||||
}
|
}
|
||||||
// V2: 加载场景级恢复状态
|
|
||||||
|
// V2: 加载场景级恢复状态 + 节奏追踪
|
||||||
if progress, err := t.store.LoadProgress(); err == nil && progress != nil {
|
if progress, err := t.store.LoadProgress(); err == nil && progress != nil {
|
||||||
checkpoint := map[string]any{
|
checkpoint := map[string]any{
|
||||||
"in_progress_chapter": progress.InProgressChapter,
|
"in_progress_chapter": progress.InProgressChapter,
|
||||||
"completed_scenes": progress.CompletedScenes,
|
"completed_scenes": progress.CompletedScenes,
|
||||||
}
|
}
|
||||||
|
if len(progress.StrandHistory) > 0 {
|
||||||
|
checkpoint["strand_history"] = progress.StrandHistory
|
||||||
|
}
|
||||||
|
if len(progress.HookHistory) > 0 {
|
||||||
|
checkpoint["hook_history"] = progress.HookHistory
|
||||||
|
}
|
||||||
result["checkpoint"] = checkpoint
|
result["checkpoint"] = checkpoint
|
||||||
}
|
}
|
||||||
// V2: 加载已有的章节规划(支持场景恢复跳过已完成场景)
|
// V2: 加载已有的章节规划(支持场景恢复跳过已完成场景)
|
||||||
if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil {
|
if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil {
|
||||||
result["chapter_plan"] = plan
|
result["chapter_plan"] = plan
|
||||||
}
|
}
|
||||||
// 写作参考资料
|
|
||||||
result["references"] = t.writerReferences()
|
// V3: 写作参考资料分阶段加载
|
||||||
|
result["references"] = t.writerReferences(a.Chapter)
|
||||||
} else {
|
} else {
|
||||||
// Architect 模式:加载模板
|
// Architect 模式:全量角色 + 模板
|
||||||
|
if chars, err := t.store.LoadCharacters(); err == nil && chars != nil {
|
||||||
|
result["characters"] = chars
|
||||||
|
}
|
||||||
result["references"] = t.architectReferences()
|
result["references"] = t.architectReferences()
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.Marshal(result)
|
return json.Marshal(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ContextTool) writerReferences() map[string]string {
|
// loadFilteredCharacters 按 Tier 和场景出场过滤角色。
|
||||||
|
// core/important 始终返回;secondary/decorative 只在当前章节大纲提及时返回。
|
||||||
|
func (t *ContextTool) loadFilteredCharacters(result map[string]any, chapter int) {
|
||||||
|
chars, err := t.store.LoadCharacters()
|
||||||
|
if err != nil || len(chars) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前章节大纲的场景描述,用于匹配次要角色
|
||||||
|
entry, err := t.store.GetChapterOutline(chapter)
|
||||||
|
if err != nil {
|
||||||
|
result["characters"] = chars
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sceneText := strings.Join(entry.Scenes, " ") + " " + entry.CoreEvent + " " + entry.Title
|
||||||
|
|
||||||
|
var filtered []domain.Character
|
||||||
|
for _, c := range chars {
|
||||||
|
switch c.Tier {
|
||||||
|
case "secondary", "decorative":
|
||||||
|
if strings.Contains(sceneText, c.Name) {
|
||||||
|
filtered = append(filtered, c)
|
||||||
|
}
|
||||||
|
default: // core, important, 或未设置
|
||||||
|
filtered = append(filtered, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result["characters"] = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// writerReferences 返回写作参考资料。章节 1 返回全量,后续章节裁剪掉不再需要的模板。
|
||||||
|
func (t *ContextTool) writerReferences(chapter int) map[string]string {
|
||||||
refs := map[string]string{}
|
refs := map[string]string{}
|
||||||
add := func(k, v string) {
|
add := func(k, v string) {
|
||||||
if v != "" {
|
if v != "" {
|
||||||
refs[k] = v
|
refs[k] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 始终加载的核心参考
|
||||||
add("chapter_guide", t.refs.ChapterGuide)
|
add("chapter_guide", t.refs.ChapterGuide)
|
||||||
add("hook_techniques", t.refs.HookTechniques)
|
add("hook_techniques", t.refs.HookTechniques)
|
||||||
add("quality_checklist", t.refs.QualityChecklist)
|
add("quality_checklist", t.refs.QualityChecklist)
|
||||||
add("chapter_template", t.refs.ChapterTemplate)
|
|
||||||
add("consistency", t.refs.Consistency)
|
add("consistency", t.refs.Consistency)
|
||||||
add("content_expansion", t.refs.ContentExpansion)
|
|
||||||
add("dialogue_writing", t.refs.DialogueWriting)
|
add("dialogue_writing", t.refs.DialogueWriting)
|
||||||
add("style_reference", t.refs.StyleReference)
|
add("style_reference", t.refs.StyleReference)
|
||||||
|
|
||||||
|
// 仅首章加载的补充参考(后续章节不再需要)
|
||||||
|
if chapter <= 1 {
|
||||||
|
add("chapter_template", t.refs.ChapterTemplate)
|
||||||
|
add("content_expansion", t.refs.ContentExpansion)
|
||||||
|
}
|
||||||
return refs
|
return refs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
59
tui/input.go
59
tui/input.go
@@ -9,55 +9,60 @@ import (
|
|||||||
"github.com/voocel/ainovel-cli/app"
|
"github.com/voocel/ainovel-cli/app"
|
||||||
)
|
)
|
||||||
|
|
||||||
// renderInputBox 渲染底部栏:左快捷键 | 中输入框 | 右进度+目录。
|
// renderInputBox 渲染底部栏(两行布局)。
|
||||||
|
// 第一行:❯ + 输入框
|
||||||
|
// 第二行:左快捷键提示,右进度信息
|
||||||
func renderInputBox(inputView string, snap app.UISnapshot, outputDir string, width int) string {
|
func renderInputBox(inputView string, snap app.UISnapshot, outputDir string, width int) string {
|
||||||
// 左侧:快捷键提示
|
innerW := width - 4 // border + padding
|
||||||
keys := lipgloss.NewStyle().Foreground(colorDim).Render("Tab·^L·Esc")
|
|
||||||
|
|
||||||
// 右侧:进度 + 输出目录
|
// 第一行:提示符 + 输入框
|
||||||
right := buildRightInfo(snap, outputDir)
|
prompt := lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render("❯ ")
|
||||||
|
line1 := prompt + inputView
|
||||||
|
|
||||||
// 中间:输入框,自适应宽度
|
// 第二行:左快捷键,右进度
|
||||||
leftW := lipgloss.Width(keys)
|
hints := lipgloss.NewStyle().Foreground(colorDim).Render("Tab 切换 · ^L 清屏 · Esc 重置 · Enter 发送")
|
||||||
rightW := lipgloss.Width(right)
|
info := buildRightInfo(snap, outputDir)
|
||||||
sep := lipgloss.NewStyle().Foreground(colorDim).Render(" │ ")
|
|
||||||
sepW := lipgloss.Width(sep)
|
hintsW := lipgloss.Width(hints)
|
||||||
inputW := width - leftW - rightW - sepW*2 - 4 // 4 为 padding+border 余量
|
infoW := lipgloss.Width(info)
|
||||||
if inputW < 20 {
|
gap := innerW - hintsW - infoW
|
||||||
inputW = 20
|
if gap < 1 {
|
||||||
|
gap = 1
|
||||||
}
|
}
|
||||||
|
line2 := hints + strings.Repeat(" ", gap) + info
|
||||||
|
|
||||||
input := lipgloss.NewStyle().Width(inputW).Render(inputView)
|
// 输入区(上横线 + 输入行)
|
||||||
|
inputStyle := lipgloss.NewStyle().
|
||||||
content := keys + sep + input + sep + right
|
|
||||||
|
|
||||||
style := lipgloss.NewStyle().
|
|
||||||
Width(width).
|
Width(width).
|
||||||
Border(baseBorder, true, false, false, false).
|
Border(baseBorder, true, false, true, false).
|
||||||
BorderForeground(colorDim).
|
BorderForeground(colorDim).
|
||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
inputBlock := inputStyle.Render(line1)
|
||||||
|
|
||||||
return style.Render(content)
|
// 提示行(无边框,紧贴下横线下方)
|
||||||
|
hintStyle := lipgloss.NewStyle().
|
||||||
|
Width(width).
|
||||||
|
Padding(0, 2)
|
||||||
|
hintBlock := hintStyle.Render(line2)
|
||||||
|
|
||||||
|
return inputBlock + "\n" + hintBlock + "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRightInfo 构建右侧进度和目录信息。
|
// buildRightInfo 构建右侧进度和目录信息。
|
||||||
func buildRightInfo(snap app.UISnapshot, outputDir string) string {
|
func buildRightInfo(snap app.UISnapshot, outputDir string) string {
|
||||||
var parts []string
|
var parts []string
|
||||||
|
|
||||||
// 章节进度
|
if snap.ModelName != "" {
|
||||||
|
parts = append(parts, snap.ModelName)
|
||||||
|
}
|
||||||
if snap.TotalChapters > 0 {
|
if snap.TotalChapters > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("Ch %d/%d", snap.CompletedCount, snap.TotalChapters))
|
parts = append(parts, fmt.Sprintf("Ch %d/%d", snap.CompletedCount, snap.TotalChapters))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 字数
|
|
||||||
if snap.TotalWordCount > 0 {
|
if snap.TotalWordCount > 0 {
|
||||||
parts = append(parts, formatNumber(snap.TotalWordCount)+"字")
|
parts = append(parts, formatNumber(snap.TotalWordCount)+"字")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 输出目录(缩短为相对路径的最后一段)
|
|
||||||
if outputDir != "" {
|
if outputDir != "" {
|
||||||
dir := filepath.Base(outputDir)
|
parts = append(parts, "./"+filepath.Base(outputDir))
|
||||||
parts = append(parts, "./"+dir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
|
|||||||
33
tui/model.go
33
tui/model.go
@@ -31,7 +31,7 @@ type Model struct {
|
|||||||
events []app.UIEvent
|
events []app.UIEvent
|
||||||
viewport viewport.Model // 事件流 viewport
|
viewport viewport.Model // 事件流 viewport
|
||||||
streamVP viewport.Model // 流式输出 viewport
|
streamVP viewport.Model // 流式输出 viewport
|
||||||
streamBuf strings.Builder // 流式文本累积缓冲
|
streamBuf *strings.Builder // 流式文本累积缓冲
|
||||||
textarea textarea.Model
|
textarea textarea.Model
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
@@ -41,6 +41,7 @@ type Model struct {
|
|||||||
mode appMode
|
mode appMode
|
||||||
err error
|
err error
|
||||||
spinnerIdx int
|
spinnerIdx int
|
||||||
|
streamRound int // 流式输出轮次计数
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewModel 创建 TUI Model。
|
// NewModel 创建 TUI Model。
|
||||||
@@ -48,6 +49,7 @@ func NewModel(rt *app.Runtime) Model {
|
|||||||
ta := textarea.New()
|
ta := textarea.New()
|
||||||
ta.Placeholder = "输入小说需求,例如:写一部12章都市悬疑小说"
|
ta.Placeholder = "输入小说需求,例如:写一部12章都市悬疑小说"
|
||||||
ta.CharLimit = 500
|
ta.CharLimit = 500
|
||||||
|
ta.SetHeight(1)
|
||||||
ta.MaxHeight = 1
|
ta.MaxHeight = 1
|
||||||
ta.ShowLineNumbers = false
|
ta.ShowLineNumbers = false
|
||||||
ta.Focus()
|
ta.Focus()
|
||||||
@@ -69,6 +71,7 @@ func NewModel(rt *app.Runtime) Model {
|
|||||||
textarea: ta,
|
textarea: ta,
|
||||||
viewport: vp,
|
viewport: vp,
|
||||||
streamVP: svp,
|
streamVP: svp,
|
||||||
|
streamBuf: &strings.Builder{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +113,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.streamBuf.Reset()
|
m.streamBuf.Reset()
|
||||||
m.streamVP.SetContent("")
|
m.streamVP.SetContent("")
|
||||||
m.streamVP.GotoTop()
|
m.streamVP.GotoTop()
|
||||||
|
m.streamRound = 0
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyTab:
|
case tea.KeyTab:
|
||||||
m.focusStream = !m.focusStream
|
m.focusStream = !m.focusStream
|
||||||
@@ -234,10 +238,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, listenStream(m.runtime)
|
return m, listenStream(m.runtime)
|
||||||
|
|
||||||
case streamClearMsg:
|
case streamClearMsg:
|
||||||
m.streamBuf.Reset()
|
// 新一轮输出:保留历史内容,用分隔线标记新段落
|
||||||
m.streamVP.SetContent("")
|
m.streamRound++
|
||||||
m.streamVP.GotoTop()
|
if m.streamBuf.Len() > 0 {
|
||||||
m.streamScroll = true
|
m.streamBuf.WriteString("\n")
|
||||||
|
m.streamBuf.WriteString(renderStreamSeparator(m.streamRound, m.streamVP.Width))
|
||||||
|
m.streamBuf.WriteString("\n")
|
||||||
|
}
|
||||||
|
m.streamVP.SetContent(m.streamBuf.String())
|
||||||
|
if m.streamScroll {
|
||||||
|
m.streamVP.GotoBottom()
|
||||||
|
}
|
||||||
return m, listenStreamClear(m.runtime)
|
return m, listenStreamClear(m.runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,15 +301,7 @@ func (m *Model) inputWidth() int {
|
|||||||
if m.width == 0 {
|
if m.width == 0 {
|
||||||
return 60
|
return 60
|
||||||
}
|
}
|
||||||
// 与 renderInputBox 中 inputW 计算一致
|
return m.width - 6 // border + padding + 提示符 "❯ "
|
||||||
keysW := 10 // "Tab·^L·Esc"
|
|
||||||
rightW := 30 // 进度+目录预估
|
|
||||||
sepW := 3 * 2
|
|
||||||
w := m.width - keysW - rightW - sepW - 4
|
|
||||||
if w < 20 {
|
|
||||||
w = 20
|
|
||||||
}
|
|
||||||
return w
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) eventFlowWidth() int {
|
func (m *Model) eventFlowWidth() int {
|
||||||
@@ -315,7 +318,7 @@ func (m *Model) bodyHeight() int {
|
|||||||
return 20
|
return 20
|
||||||
}
|
}
|
||||||
topH := 1
|
topH := 1
|
||||||
inputH := 2 // 单行输入 + top border
|
inputH := 6 // top border + 输入行 + bottom border + 空行 + 提示行 + \n
|
||||||
bodyH := m.height - topH - inputH
|
bodyH := m.height - topH - inputH
|
||||||
if bodyH < 3 {
|
if bodyH < 3 {
|
||||||
bodyH = 3
|
bodyH = 3
|
||||||
|
|||||||
@@ -208,6 +208,19 @@ func renderStreamPanel(vp viewport.Model, width, height int, focused bool) strin
|
|||||||
return header + "\n" + vpStyle.Render(vp.View())
|
return header + "\n" + vpStyle.Render(vp.View())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderStreamSeparator 渲染流式面板中的轮次分隔线。
|
||||||
|
func renderStreamSeparator(round, width int) string {
|
||||||
|
label := fmt.Sprintf(" #%d ", round)
|
||||||
|
lineW := (width - lipgloss.Width(label)) / 2
|
||||||
|
if lineW < 1 {
|
||||||
|
lineW = 1
|
||||||
|
}
|
||||||
|
line := strings.Repeat("─", lineW)
|
||||||
|
dimLine := lipgloss.NewStyle().Foreground(colorDim).Render(line)
|
||||||
|
dimLabel := lipgloss.NewStyle().Foreground(colorDim).Render(label)
|
||||||
|
return dimLine + dimLabel + dimLine
|
||||||
|
}
|
||||||
|
|
||||||
// renderDetailPanel 渲染右侧详情面板。
|
// renderDetailPanel 渲染右侧详情面板。
|
||||||
// 优先展示基础设定(大纲、角色),然后是运行时信息(提交、审阅等)。
|
// 优先展示基础设定(大纲、角色),然后是运行时信息(提交、审阅等)。
|
||||||
func renderDetailPanel(snap app.UISnapshot, width, height int) string {
|
func renderDetailPanel(snap app.UISnapshot, width, height int) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user