refactor: Agent驱动重构,整章写入替代场景拼接
This commit is contained in:
@@ -6,12 +6,11 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/voocel/agentcore/schema"
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
"github.com/voocel/ainovel-cli/state"
|
||||
)
|
||||
|
||||
// CheckConsistencyTool 对照状态文件检查章节一致性。
|
||||
// 返回上下文数据和已知约束供 LLM 判断,不做 AI 推理。
|
||||
// CheckConsistencyTool 返回章节内容和全部状态数据,供 Agent 自行对照判断。
|
||||
// 纯 IO 工具:只负责加载数据,不注入指令。
|
||||
type CheckConsistencyTool struct {
|
||||
store *state.Store
|
||||
}
|
||||
@@ -22,7 +21,7 @@ func NewCheckConsistencyTool(store *state.Store) *CheckConsistencyTool {
|
||||
|
||||
func (t *CheckConsistencyTool) Name() string { return "check_consistency" }
|
||||
func (t *CheckConsistencyTool) Description() string {
|
||||
return "检查章节一致性。返回章节内容、全部状态数据和具体检查清单,你需要逐项对照并以 JSON 格式返回冲突项"
|
||||
return "加载章节内容和全部状态数据(时间线、伏笔、关系、世界规则、角色状态),供你自行对照检查一致性"
|
||||
}
|
||||
func (t *CheckConsistencyTool) Label() string { return "一致性检查" }
|
||||
|
||||
@@ -45,7 +44,7 @@ func (t *CheckConsistencyTool) Execute(_ context.Context, args json.RawMessage)
|
||||
|
||||
result := map[string]any{"chapter": a.Chapter}
|
||||
|
||||
// 加载章节内容(polished 优先)
|
||||
// 章节内容
|
||||
content, wordCount, err := t.store.LoadChapterContent(a.Chapter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load chapter content: %w", err)
|
||||
@@ -56,22 +55,18 @@ func (t *CheckConsistencyTool) Execute(_ context.Context, args json.RawMessage)
|
||||
result["content"] = content
|
||||
result["word_count"] = wordCount
|
||||
|
||||
// 加载全部状态数据供 LLM 对照
|
||||
// 状态数据(全部加载,Agent 自行决定怎么用)
|
||||
if timeline, _ := t.store.LoadTimeline(); len(timeline) > 0 {
|
||||
result["timeline"] = timeline
|
||||
}
|
||||
if foreshadow, _ := t.store.LoadForeshadowLedger(); len(foreshadow) > 0 {
|
||||
result["foreshadow_ledger"] = foreshadow
|
||||
if active := filterActive(foreshadow); len(active) > 0 {
|
||||
result["unresolved_foreshadow"] = active
|
||||
}
|
||||
}
|
||||
if relationships, _ := t.store.LoadRelationships(); len(relationships) > 0 {
|
||||
result["relationships"] = relationships
|
||||
}
|
||||
if chars, _ := t.store.LoadCharacters(); len(chars) > 0 {
|
||||
result["characters"] = chars
|
||||
// 构建别名映射表,供 LLM 识别角色的不同称呼
|
||||
aliasMap := make(map[string]string)
|
||||
for _, c := range chars {
|
||||
for _, alias := range c.Aliases {
|
||||
@@ -82,65 +77,15 @@ func (t *CheckConsistencyTool) Execute(_ context.Context, args json.RawMessage)
|
||||
result["alias_map"] = aliasMap
|
||||
}
|
||||
}
|
||||
// 加载最近状态变化,供对照当前章节的状态描述
|
||||
if changes, _ := t.store.LoadRecentStateChanges(a.Chapter, 5); len(changes) > 0 {
|
||||
result["recent_state_changes"] = changes
|
||||
}
|
||||
|
||||
if rules, _ := t.store.LoadWorldRules(); len(rules) > 0 {
|
||||
result["world_rules"] = rules
|
||||
// 提取边界清单,方便 LLM 逐条对照
|
||||
var boundaries []string
|
||||
for _, r := range rules {
|
||||
if r.Boundary != "" {
|
||||
boundaries = append(boundaries, fmt.Sprintf("[%s] %s", r.Category, r.Boundary))
|
||||
}
|
||||
}
|
||||
if len(boundaries) > 0 {
|
||||
result["world_rules_boundaries"] = boundaries
|
||||
}
|
||||
}
|
||||
|
||||
// 加载前两章摘要
|
||||
if summaries, _ := t.store.LoadRecentSummaries(a.Chapter, 2); len(summaries) > 0 {
|
||||
result["recent_summaries"] = summaries
|
||||
}
|
||||
|
||||
result["instruction"] = `请逐项对照以上状态数据检查本章内容,返回 JSON 数组格式的冲突项:
|
||||
[
|
||||
{
|
||||
"type": "timeline|foreshadow|relationship|character|world_rules|state",
|
||||
"severity": "critical|error|warning",
|
||||
"description": "具体冲突描述",
|
||||
"suggestion": "建议修正范围和方式"
|
||||
}
|
||||
]
|
||||
|
||||
severity 分级:
|
||||
- critical:严重逻辑硬伤,必须修复(如角色已死但再次出场、违反世界规则核心边界)
|
||||
- error:明显矛盾,应当修复(如时间线冲突、角色行为与人设严重不符)
|
||||
- warning:轻微瑕疵,可后续处理(如细节不够精确、可改进但不影响阅读)
|
||||
|
||||
检查清单:
|
||||
1. 时间线:本章事件时间是否与已有 timeline 矛盾
|
||||
2. 伏笔:unresolved_foreshadow 中是否有本章应推进但遗漏的
|
||||
3. 人物关系:角色互动是否与 relationships 当前状态矛盾
|
||||
4. 角色一致性:行为是否符合 characters 中的性格和弧线
|
||||
5. 世界规则:逐条检查 world_rules_boundaries 中的边界约束,本章内容是否违反任何一条
|
||||
6. 别名一致性:如果有 alias_map,检查同一角色的不同称呼是否指向正确的人
|
||||
7. 状态连续性:如果有 recent_state_changes,检查本章对角色状态的描述是否与最近的状态变化记录一致
|
||||
|
||||
如果没有发现冲突,返回空数组 []。不要返回其他格式。`
|
||||
|
||||
return json.Marshal(result)
|
||||
}
|
||||
|
||||
func filterActive(entries []domain.ForeshadowEntry) []domain.ForeshadowEntry {
|
||||
var active []domain.ForeshadowEntry
|
||||
for _, e := range entries {
|
||||
if e.Status != "resolved" {
|
||||
active = append(active, e)
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
)
|
||||
|
||||
// CommitChapterTool 提交章节:加载正文 → 保存终稿 → 生成摘要 → 更新状态 → 更新进度。
|
||||
// 这是唯一允许写入 chapters/、summaries/、更新状态文件和进度的工具。
|
||||
type CommitChapterTool struct {
|
||||
store *state.Store
|
||||
}
|
||||
@@ -23,7 +22,7 @@ func NewCommitChapterTool(store *state.Store) *CommitChapterTool {
|
||||
|
||||
func (t *CommitChapterTool) Name() string { return "commit_chapter" }
|
||||
func (t *CommitChapterTool) Description() string {
|
||||
return "提交章节。优先使用打磨版正文,同时更新时间线、伏笔、关系状态。返回结构化信号"
|
||||
return "提交章节终稿。加载草稿正文,保存为终稿,同时更新时间线、伏笔、关系、角色状态。返回结构化信号"
|
||||
}
|
||||
func (t *CommitChapterTool) Label() string { return "提交章节" }
|
||||
|
||||
@@ -34,7 +33,7 @@ func (t *CommitChapterTool) Schema() map[string]any {
|
||||
schema.Property("characters", schema.Array("涉及角色", schema.String(""))),
|
||||
)
|
||||
foreshadowSchema := schema.Object(
|
||||
schema.Property("id", schema.String("伏笔 ID(新埋设时自定义,推进/回收时使用已有 ID)")).Required(),
|
||||
schema.Property("id", schema.String("伏笔 ID")).Required(),
|
||||
schema.Property("action", schema.Enum("操作", "plant", "advance", "resolve")).Required(),
|
||||
schema.Property("description", schema.String("伏笔描述(仅 plant 时必需)")),
|
||||
)
|
||||
@@ -45,11 +44,15 @@ func (t *CommitChapterTool) Schema() map[string]any {
|
||||
)
|
||||
stateChangeSchema := schema.Object(
|
||||
schema.Property("entity", schema.String("角色名或实体名")).Required(),
|
||||
schema.Property("field", schema.String("变化属性:realm/location/status/power/relation 等")).Required(),
|
||||
schema.Property("old_value", schema.String("变化前的值(首次出现可空)")),
|
||||
schema.Property("field", schema.String("变化属性")).Required(),
|
||||
schema.Property("old_value", schema.String("变化前的值")),
|
||||
schema.Property("new_value", schema.String("变化后的值")).Required(),
|
||||
schema.Property("reason", schema.String("变化原因")),
|
||||
)
|
||||
feedbackSchema := schema.Object(
|
||||
schema.Property("deviation", schema.String("偏离大纲的描述")).Required(),
|
||||
schema.Property("suggestion", schema.String("对后续大纲的调整建议")).Required(),
|
||||
)
|
||||
return schema.Object(
|
||||
schema.Property("chapter", schema.Int("章节号")).Required(),
|
||||
schema.Property("summary", schema.String("本章内容摘要(200字以内)")).Required(),
|
||||
@@ -58,9 +61,10 @@ func (t *CommitChapterTool) Schema() map[string]any {
|
||||
schema.Property("timeline_events", schema.Array("本章时间线事件", timelineSchema)),
|
||||
schema.Property("foreshadow_updates", schema.Array("伏笔操作", foreshadowSchema)),
|
||||
schema.Property("relationship_changes", schema.Array("关系变化", relationshipSchema)),
|
||||
schema.Property("state_changes", schema.Array("角色/实体状态变化(修为提升、位置转移、状态变化等)", stateChangeSchema)),
|
||||
schema.Property("state_changes", schema.Array("角色/实体状态变化", stateChangeSchema)),
|
||||
schema.Property("hook_type", schema.Enum("章末钩子类型", "crisis", "mystery", "desire", "emotion", "choice")),
|
||||
schema.Property("dominant_strand", schema.Enum("本章主导叙事线", "quest", "fire", "constellation")),
|
||||
schema.Property("feedback", feedbackSchema),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,6 +80,7 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
|
||||
StateChanges []domain.StateChange `json:"state_changes"`
|
||||
HookType string `json:"hook_type"`
|
||||
DominantStrand string `json:"dominant_strand"`
|
||||
Feedback *domain.OutlineFeedback `json:"feedback"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("invalid args: %w", err)
|
||||
@@ -87,7 +92,7 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 1. 加载章节正文(polished 优先,否则 merge scenes)
|
||||
// 1. 加载章节正文
|
||||
content, wordCount, err := t.store.LoadChapterContent(a.Chapter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load chapter content: %w", err)
|
||||
@@ -157,7 +162,8 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
|
||||
if progress != nil {
|
||||
completedCount = len(progress.CompletedChapters)
|
||||
}
|
||||
// 6b. 长篇模式:弧级边界检测(替代固定间隔评审)+ 更新卷弧位置
|
||||
|
||||
// 6b. 长篇模式:弧级边界检测
|
||||
var arcEnd, volumeEnd bool
|
||||
var vol, arc int
|
||||
if progress != nil && progress.Layered {
|
||||
@@ -169,7 +175,6 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
|
||||
volumeEnd = boundary.IsVolumeEnd
|
||||
vol = boundary.Volume
|
||||
arc = boundary.Arc
|
||||
// 每次提交时更新卷弧位置,确保 novel_context 的 position 始终正确
|
||||
_ = t.store.UpdateVolumeArc(vol, arc)
|
||||
}
|
||||
}
|
||||
@@ -182,35 +187,29 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
|
||||
reviewRequired, reviewReason = domain.ShouldReview(completedCount)
|
||||
}
|
||||
|
||||
// 7. 计算场景数
|
||||
sceneCount := 0
|
||||
if scenes, err := t.store.LoadSceneDrafts(a.Chapter); err == nil {
|
||||
sceneCount = len(scenes)
|
||||
}
|
||||
|
||||
// 8. 构造结构化信号
|
||||
// 7. 构造结构化信号
|
||||
result := domain.CommitResult{
|
||||
Chapter: a.Chapter,
|
||||
Committed: true,
|
||||
WordCount: wordCount,
|
||||
SceneCount: sceneCount,
|
||||
NextChapter: a.Chapter + 1,
|
||||
ReviewRequired: reviewRequired,
|
||||
ReviewReason: reviewReason,
|
||||
HookType: a.HookType,
|
||||
DominantStrand: a.DominantStrand,
|
||||
Feedback: a.Feedback,
|
||||
ArcEnd: arcEnd,
|
||||
VolumeEnd: volumeEnd,
|
||||
Volume: vol,
|
||||
Arc: arc,
|
||||
}
|
||||
|
||||
// 9. 写入信号文件供宿主程序读取(优先于清理操作,确保信号不丢失)
|
||||
// 8. 写入信号文件
|
||||
if err := t.store.SaveLastCommit(result); err != nil {
|
||||
return nil, fmt.Errorf("save commit signal: %w", err)
|
||||
}
|
||||
|
||||
// 10. 清除场景级进度(章节已提交)
|
||||
// 9. 清除进度中间状态
|
||||
if err := t.store.ClearInProgress(); err != nil {
|
||||
return nil, fmt.Errorf("clear in-progress: %w", err)
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ func TestCommitChapterRejectsNonPendingRewrite(t *testing.T) {
|
||||
if err := store.SetFlow(domain.FlowRewriting); err != nil {
|
||||
t.Fatalf("SetFlow: %v", err)
|
||||
}
|
||||
if err := store.SavePolished(3, "这是错误章节的正文。"); err != nil {
|
||||
t.Fatalf("SavePolished: %v", err)
|
||||
if err := store.SaveDraft(3, "这是错误章节的正文。"); err != nil {
|
||||
t.Fatalf("SaveDraft: %v", err)
|
||||
}
|
||||
|
||||
tool := NewCommitChapterTool(store)
|
||||
@@ -76,8 +76,8 @@ func TestCommitChapterAllowsPendingRewrite(t *testing.T) {
|
||||
if err := store.SetFlow(domain.FlowRewriting); err != nil {
|
||||
t.Fatalf("SetFlow: %v", err)
|
||||
}
|
||||
if err := store.SavePolished(2, "这是正确待重写章节的正文。"); err != nil {
|
||||
t.Fatalf("SavePolished: %v", err)
|
||||
if err := store.SaveDraft(2, "这是正确待重写章节的正文。"); err != nil {
|
||||
t.Fatalf("SaveDraft: %v", err)
|
||||
}
|
||||
|
||||
tool := NewCommitChapterTool(store)
|
||||
|
||||
80
tools/draft_chapter.go
Normal file
80
tools/draft_chapter.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/voocel/agentcore/schema"
|
||||
"github.com/voocel/ainovel-cli/state"
|
||||
)
|
||||
|
||||
// DraftChapterTool 写入整章草稿,替代旧的 write_scene + polish_chapter 流水线。
|
||||
// Agent 自主决定一次写完还是分批续写。
|
||||
type DraftChapterTool struct {
|
||||
store *state.Store
|
||||
}
|
||||
|
||||
func NewDraftChapterTool(store *state.Store) *DraftChapterTool {
|
||||
return &DraftChapterTool{store: store}
|
||||
}
|
||||
|
||||
func (t *DraftChapterTool) Name() string { return "draft_chapter" }
|
||||
func (t *DraftChapterTool) Description() string {
|
||||
return "写入章节正文。mode=write 覆盖写入整章,mode=append 追加到现有草稿(续写/修改)"
|
||||
}
|
||||
func (t *DraftChapterTool) Label() string { return "写入章节" }
|
||||
|
||||
func (t *DraftChapterTool) Schema() map[string]any {
|
||||
return schema.Object(
|
||||
schema.Property("chapter", schema.Int("章节号")).Required(),
|
||||
schema.Property("content", schema.String("章节正文")).Required(),
|
||||
schema.Property("mode", schema.Enum("写入模式", "write", "append")),
|
||||
)
|
||||
}
|
||||
|
||||
func (t *DraftChapterTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a struct {
|
||||
Chapter int `json:"chapter"`
|
||||
Content string `json:"content"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("invalid args: %w", err)
|
||||
}
|
||||
if a.Chapter <= 0 {
|
||||
return nil, fmt.Errorf("chapter must be > 0")
|
||||
}
|
||||
if a.Content == "" {
|
||||
return nil, fmt.Errorf("content must not be empty")
|
||||
}
|
||||
|
||||
switch a.Mode {
|
||||
case "append":
|
||||
if err := t.store.AppendDraft(a.Chapter, a.Content); err != nil {
|
||||
return nil, fmt.Errorf("append draft: %w", err)
|
||||
}
|
||||
// 读取合并后的完整内容计算字数
|
||||
full, err := t.store.LoadDraft(a.Chapter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load draft after append: %w", err)
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"written": true,
|
||||
"chapter": a.Chapter,
|
||||
"mode": "append",
|
||||
"word_count": utf8.RuneCountInString(full),
|
||||
})
|
||||
default: // write
|
||||
if err := t.store.SaveDraft(a.Chapter, a.Content); err != nil {
|
||||
return nil, fmt.Errorf("save draft: %w", err)
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"written": true,
|
||||
"chapter": a.Chapter,
|
||||
"mode": "write",
|
||||
"word_count": utf8.RuneCountInString(a.Content),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -211,11 +211,10 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
|
||||
result["position"] = pos
|
||||
}
|
||||
|
||||
// 加载场景级恢复状态 + 节奏追踪
|
||||
// 加载进度状态和节奏追踪
|
||||
if progress != nil {
|
||||
checkpoint := map[string]any{
|
||||
"in_progress_chapter": progress.InProgressChapter,
|
||||
"completed_scenes": progress.CompletedScenes,
|
||||
}
|
||||
if len(progress.StrandHistory) > 0 {
|
||||
checkpoint["strand_history"] = progress.StrandHistory
|
||||
@@ -225,13 +224,43 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
|
||||
}
|
||||
result["checkpoint"] = checkpoint
|
||||
}
|
||||
// 加载已有的章节规划(支持场景恢复跳过已完成场景)
|
||||
// 加载已有的章节构思
|
||||
if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil {
|
||||
result["chapter_plan"] = plan
|
||||
} else {
|
||||
warn("chapter_plan", err)
|
||||
}
|
||||
|
||||
// 风格锚点:从前文提取代表性段落
|
||||
if anchors := t.store.ExtractStyleAnchors(3); len(anchors) > 0 {
|
||||
result["style_anchors"] = anchors
|
||||
}
|
||||
|
||||
// 角色声纹:提取出场角色的对话原文片段
|
||||
if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil && entry != nil {
|
||||
var voiceSamples []map[string]any
|
||||
chars, _ := t.store.LoadCharacters()
|
||||
for _, c := range chars {
|
||||
// 只为 core/important 角色提取声纹
|
||||
if c.Tier == "secondary" || c.Tier == "decorative" {
|
||||
continue
|
||||
}
|
||||
samples := t.store.ExtractDialogue(c.Name, c.Aliases, 3)
|
||||
if len(samples) > 0 {
|
||||
voiceSamples = append(voiceSamples, map[string]any{
|
||||
"character": c.Name,
|
||||
"samples": samples,
|
||||
})
|
||||
}
|
||||
if len(voiceSamples) >= 5 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(voiceSamples) > 0 {
|
||||
result["voice_samples"] = voiceSamples
|
||||
}
|
||||
}
|
||||
|
||||
// 写作参考资料分阶段加载
|
||||
result["references"] = t.writerReferences(a.Chapter)
|
||||
} else {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/voocel/ainovel-cli/state"
|
||||
)
|
||||
|
||||
// PlanChapterTool 生成章节规划。
|
||||
// PlanChapterTool 保存章节构思,Agent 自主决定规划粒度。
|
||||
type PlanChapterTool struct {
|
||||
store *state.Store
|
||||
}
|
||||
@@ -21,25 +21,19 @@ func NewPlanChapterTool(store *state.Store) *PlanChapterTool {
|
||||
|
||||
func (t *PlanChapterTool) Name() string { return "plan_chapter" }
|
||||
func (t *PlanChapterTool) Description() string {
|
||||
return "创建章节写作规划,包括目标、冲突、场景拆分和钩子设计。必须在 write_scene 之前调用"
|
||||
return "保存章节写作构思。Agent 自主决定规划粒度,不强制场景拆分"
|
||||
}
|
||||
func (t *PlanChapterTool) Label() string { return "规划章节" }
|
||||
|
||||
func (t *PlanChapterTool) Schema() map[string]any {
|
||||
sceneSchema := schema.Object(
|
||||
schema.Property("index", schema.Int("场景编号,从 1 开始")).Required(),
|
||||
schema.Property("summary", schema.String("场景概要")).Required(),
|
||||
schema.Property("pov", schema.String("视角人物")),
|
||||
schema.Property("location", schema.String("场景地点")),
|
||||
)
|
||||
return schema.Object(
|
||||
schema.Property("chapter", schema.Int("章节号")).Required(),
|
||||
schema.Property("title", schema.String("章节标题")).Required(),
|
||||
schema.Property("goal", schema.String("本章目标")).Required(),
|
||||
schema.Property("conflict", schema.String("核心冲突")).Required(),
|
||||
schema.Property("scenes", schema.Array("场景列表", sceneSchema)).Required(),
|
||||
schema.Property("hook", schema.String("章末钩子")).Required(),
|
||||
schema.Property("emotion_arc", schema.String("情绪曲线")),
|
||||
schema.Property("notes", schema.String("自由备忘(任何你觉得写作时需要记住的东西)")),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,17 +45,13 @@ func (t *PlanChapterTool) Execute(_ context.Context, args json.RawMessage) (json
|
||||
if plan.Chapter <= 0 {
|
||||
return nil, fmt.Errorf("chapter must be > 0")
|
||||
}
|
||||
if len(plan.Scenes) == 0 {
|
||||
return nil, fmt.Errorf("scenes must not be empty")
|
||||
}
|
||||
|
||||
if err := t.store.SaveChapterPlan(plan); err != nil {
|
||||
return nil, fmt.Errorf("save chapter plan: %w", err)
|
||||
}
|
||||
|
||||
return json.Marshal(map[string]any{
|
||||
"planned": true,
|
||||
"chapter": plan.Chapter,
|
||||
"scene_count": len(plan.Scenes),
|
||||
"planned": true,
|
||||
"chapter": plan.Chapter,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/voocel/agentcore/schema"
|
||||
"github.com/voocel/ainovel-cli/state"
|
||||
)
|
||||
|
||||
// PolishChapterTool 保存打磨后的章节正文,替换原始场景拼接。
|
||||
type PolishChapterTool struct {
|
||||
store *state.Store
|
||||
}
|
||||
|
||||
func NewPolishChapterTool(store *state.Store) *PolishChapterTool {
|
||||
return &PolishChapterTool{store: store}
|
||||
}
|
||||
|
||||
func (t *PolishChapterTool) Name() string { return "polish_chapter" }
|
||||
func (t *PolishChapterTool) Description() string {
|
||||
return "保存打磨后的章节正文。在 write_scene 全部完成后、commit_chapter 之前调用。提交时会优先使用打磨版本"
|
||||
}
|
||||
func (t *PolishChapterTool) Label() string { return "打磨章节" }
|
||||
|
||||
func (t *PolishChapterTool) Schema() map[string]any {
|
||||
return schema.Object(
|
||||
schema.Property("chapter", schema.Int("章节号")).Required(),
|
||||
schema.Property("content", schema.String("打磨后的完整章节正文")).Required(),
|
||||
)
|
||||
}
|
||||
|
||||
func (t *PolishChapterTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a struct {
|
||||
Chapter int `json:"chapter"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("invalid args: %w", err)
|
||||
}
|
||||
if a.Chapter <= 0 {
|
||||
return nil, fmt.Errorf("chapter must be > 0")
|
||||
}
|
||||
if a.Content == "" {
|
||||
return nil, fmt.Errorf("content must not be empty")
|
||||
}
|
||||
|
||||
if err := t.store.SavePolished(a.Chapter, a.Content); err != nil {
|
||||
return nil, fmt.Errorf("save polished: %w", err)
|
||||
}
|
||||
|
||||
return json.Marshal(map[string]any{
|
||||
"polished": true,
|
||||
"chapter": a.Chapter,
|
||||
"word_count": utf8.RuneCountInString(a.Content),
|
||||
})
|
||||
}
|
||||
116
tools/read_chapter.go
Normal file
116
tools/read_chapter.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/voocel/agentcore/schema"
|
||||
"github.com/voocel/ainovel-cli/state"
|
||||
)
|
||||
|
||||
// ReadChapterTool 读取章节原文,让 Agent 能回读自己和前文的文字。
|
||||
type ReadChapterTool struct {
|
||||
store *state.Store
|
||||
}
|
||||
|
||||
func NewReadChapterTool(store *state.Store) *ReadChapterTool {
|
||||
return &ReadChapterTool{store: store}
|
||||
}
|
||||
|
||||
func (t *ReadChapterTool) Name() string { return "read_chapter" }
|
||||
func (t *ReadChapterTool) Description() string { return "读取章节原文。可读终稿、草稿,或提取角色对话片段" }
|
||||
func (t *ReadChapterTool) Label() string { return "读取章节" }
|
||||
|
||||
func (t *ReadChapterTool) Schema() map[string]any {
|
||||
return schema.Object(
|
||||
schema.Property("chapter", schema.Int("章节号(读单章时必填)")),
|
||||
schema.Property("from", schema.Int("起始章节号(读范围时使用)")),
|
||||
schema.Property("to", schema.Int("结束章节号(读范围时使用)")),
|
||||
schema.Property("source", schema.Enum("来源", "final", "draft")).Required(),
|
||||
schema.Property("character", schema.String("角色名(提取对话片段时使用)")),
|
||||
schema.Property("max_runes", schema.Int("每章最大字符数(范围读取时截取,默认 2000)")),
|
||||
)
|
||||
}
|
||||
|
||||
func (t *ReadChapterTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a struct {
|
||||
Chapter int `json:"chapter"`
|
||||
From int `json:"from"`
|
||||
To int `json:"to"`
|
||||
Source string `json:"source"`
|
||||
Character string `json:"character"`
|
||||
MaxRunes int `json:"max_runes"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("invalid args: %w", err)
|
||||
}
|
||||
|
||||
// 模式 1:提取角色对话
|
||||
if a.Character != "" {
|
||||
chars, _ := t.store.LoadCharacters()
|
||||
var aliases []string
|
||||
for _, c := range chars {
|
||||
if c.Name == a.Character {
|
||||
aliases = c.Aliases
|
||||
break
|
||||
}
|
||||
}
|
||||
samples := t.store.ExtractDialogue(a.Character, aliases, 8)
|
||||
return json.Marshal(map[string]any{
|
||||
"character": a.Character,
|
||||
"samples": samples,
|
||||
})
|
||||
}
|
||||
|
||||
// 模式 2:范围读取
|
||||
if a.From > 0 && a.To > 0 {
|
||||
maxRunes := a.MaxRunes
|
||||
if maxRunes <= 0 {
|
||||
maxRunes = 2000
|
||||
}
|
||||
texts, err := t.store.LoadChapterRange(a.From, a.To, maxRunes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load chapter range: %w", err)
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"chapters": texts,
|
||||
"from": a.From,
|
||||
"to": a.To,
|
||||
})
|
||||
}
|
||||
|
||||
// 模式 3:单章读取
|
||||
if a.Chapter <= 0 {
|
||||
return nil, fmt.Errorf("chapter is required")
|
||||
}
|
||||
|
||||
var content string
|
||||
var err error
|
||||
switch a.Source {
|
||||
case "draft":
|
||||
content, err = t.store.LoadDraft(a.Chapter)
|
||||
default: // final
|
||||
content, err = t.store.LoadChapterText(a.Chapter)
|
||||
if (err == nil && content == "") {
|
||||
// 回退到草稿
|
||||
content, err = t.store.LoadDraft(a.Chapter)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read chapter %d: %w", a.Chapter, err)
|
||||
}
|
||||
if content == "" {
|
||||
return json.Marshal(map[string]any{
|
||||
"chapter": a.Chapter,
|
||||
"content": "",
|
||||
"note": "章节不存在",
|
||||
})
|
||||
}
|
||||
|
||||
return json.Marshal(map[string]any{
|
||||
"chapter": a.Chapter,
|
||||
"content": content,
|
||||
"word_count": len([]rune(content)),
|
||||
})
|
||||
}
|
||||
215
tools/read_draft_test.go
Normal file
215
tools/read_draft_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
"github.com/voocel/ainovel-cli/state"
|
||||
)
|
||||
|
||||
func TestReadChapterFinal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := state.NewStore(dir)
|
||||
if err := store.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
if err := store.SaveFinalChapter(1, "第一章的终稿正文。"); err != nil {
|
||||
t.Fatalf("SaveFinalChapter: %v", err)
|
||||
}
|
||||
|
||||
tool := NewReadChapterTool(store)
|
||||
args, _ := json.Marshal(map[string]any{"chapter": 1, "source": "final"})
|
||||
result, err := tool.Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Chapter int `json:"chapter"`
|
||||
Content string `json:"content"`
|
||||
WordCount int `json:"word_count"`
|
||||
}
|
||||
if err := json.Unmarshal(result, &payload); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
if payload.Content == "" {
|
||||
t.Fatal("expected non-empty content")
|
||||
}
|
||||
if payload.WordCount == 0 {
|
||||
t.Fatal("expected non-zero word count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadChapterDraft(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := state.NewStore(dir)
|
||||
if err := store.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
if err := store.SaveDraft(3, "第三章的草稿内容。"); err != nil {
|
||||
t.Fatalf("SaveDraft: %v", err)
|
||||
}
|
||||
|
||||
tool := NewReadChapterTool(store)
|
||||
args, _ := json.Marshal(map[string]any{"chapter": 3, "source": "draft"})
|
||||
result, err := tool.Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(result, &payload); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
if payload.Content == "" {
|
||||
t.Fatal("expected draft content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadChapterDialogue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := state.NewStore(dir)
|
||||
if err := store.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
if err := store.SaveCharacters([]domain.Character{
|
||||
{Name: "张三", Aliases: []string{"老张"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveCharacters: %v", err)
|
||||
}
|
||||
if err := store.SaveFinalChapter(1, "张三站起身来。\u201c我不同意这个方案,\u201d张三冷冷地说。"); err != nil {
|
||||
t.Fatalf("SaveFinalChapter: %v", err)
|
||||
}
|
||||
|
||||
tool := NewReadChapterTool(store)
|
||||
args, _ := json.Marshal(map[string]any{"source": "final", "character": "张三"})
|
||||
result, err := tool.Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Character string `json:"character"`
|
||||
Samples []string `json:"samples"`
|
||||
}
|
||||
if err := json.Unmarshal(result, &payload); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
if payload.Character != "张三" {
|
||||
t.Fatalf("expected character 张三, got %s", payload.Character)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadChapterRange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := state.NewStore(dir)
|
||||
if err := store.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
for i := 1; i <= 3; i++ {
|
||||
if err := store.SaveFinalChapter(i, "这是一段正文内容。"); err != nil {
|
||||
t.Fatalf("SaveFinalChapter(%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
tool := NewReadChapterTool(store)
|
||||
args, _ := json.Marshal(map[string]any{"from": 1, "to": 3, "source": "final"})
|
||||
result, err := tool.Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Chapters map[string]string `json:"chapters"`
|
||||
}
|
||||
if err := json.Unmarshal(result, &payload); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
if len(payload.Chapters) != 3 {
|
||||
t.Fatalf("expected 3 chapters, got %d", len(payload.Chapters))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftChapterWrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := state.NewStore(dir)
|
||||
if err := store.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
tool := NewDraftChapterTool(store)
|
||||
args, _ := json.Marshal(map[string]any{
|
||||
"chapter": 1,
|
||||
"content": "这是整章的正文内容,一次写完。",
|
||||
"mode": "write",
|
||||
})
|
||||
result, err := tool.Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Written bool `json:"written"`
|
||||
WordCount int `json:"word_count"`
|
||||
}
|
||||
if err := json.Unmarshal(result, &payload); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
if !payload.Written {
|
||||
t.Fatal("expected written=true")
|
||||
}
|
||||
if payload.WordCount == 0 {
|
||||
t.Fatal("expected non-zero word count")
|
||||
}
|
||||
|
||||
// 验证能读回来
|
||||
content, err := store.LoadDraft(1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDraft: %v", err)
|
||||
}
|
||||
if content == "" {
|
||||
t.Fatal("expected non-empty draft")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftChapterAppend(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := state.NewStore(dir)
|
||||
if err := store.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
if err := store.SaveDraft(2, "前半部分。"); err != nil {
|
||||
t.Fatalf("SaveDraft: %v", err)
|
||||
}
|
||||
|
||||
tool := NewDraftChapterTool(store)
|
||||
args, _ := json.Marshal(map[string]any{
|
||||
"chapter": 2,
|
||||
"content": "后半部分。",
|
||||
"mode": "append",
|
||||
})
|
||||
result, err := tool.Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Mode string `json:"mode"`
|
||||
WordCount int `json:"word_count"`
|
||||
}
|
||||
if err := json.Unmarshal(result, &payload); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
if payload.Mode != "append" {
|
||||
t.Fatalf("expected mode=append, got %s", payload.Mode)
|
||||
}
|
||||
|
||||
content, _ := store.LoadDraft(2)
|
||||
if content == "" || content == "前半部分。" {
|
||||
t.Fatal("expected appended content")
|
||||
}
|
||||
}
|
||||
@@ -21,27 +21,33 @@ func NewSaveFoundationTool(store *state.Store) *SaveFoundationTool {
|
||||
|
||||
func (t *SaveFoundationTool) Name() string { return "save_foundation" }
|
||||
func (t *SaveFoundationTool) Description() string {
|
||||
return "保存小说基础设定。type=premise 时 content 为 Markdown;type=outline 时 content 为 JSON 数组;type=characters 时 content 为 JSON 数组;type=world_rules 时 content 为 JSON 数组。scale 可选,用于记录 short/mid/long 规划级别"
|
||||
return "保存小说基础设定。参数固定为 {type, content, scale?}。type 可选 premise / outline / layered_outline / characters / world_rules。premise 时 content 必须是 Markdown 字符串;outline、layered_outline、characters、world_rules 时 content 优先直接传 JSON 数组或对象,不要再手动包一层转义字符串;工具也兼容传入 JSON 字符串。scale 可选,仅允许 short / mid / long。"
|
||||
}
|
||||
func (t *SaveFoundationTool) Label() string { return "保存设定" }
|
||||
|
||||
func (t *SaveFoundationTool) Schema() map[string]any {
|
||||
return schema.Object(
|
||||
schema.Property("type", schema.Enum("设定类型", "premise", "outline", "layered_outline", "characters", "world_rules")).Required(),
|
||||
schema.Property("content", schema.String("内容。premise 为 Markdown 文本,outline/layered_outline/characters/world_rules 为 JSON 字符串")).Required(),
|
||||
schema.Property("content", map[string]any{
|
||||
"description": "内容。premise 传 Markdown 字符串;outline/layered_outline/characters/world_rules 直接传 JSON 数组或对象即可,也兼容传 JSON 字符串。不要把数组再次手动转义成难读的字符串。",
|
||||
}).Required(),
|
||||
schema.Property("scale", schema.Enum("规划级别", "short", "mid", "long")),
|
||||
)
|
||||
}
|
||||
|
||||
func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Scale string `json:"scale"`
|
||||
Type string `json:"type"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
Scale string `json:"scale"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("invalid args: %w", err)
|
||||
}
|
||||
content, err := normalizeFoundationContent(a.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Scale != "" {
|
||||
switch domain.PlanningTier(a.Scale) {
|
||||
case domain.PlanningTierShort, domain.PlanningTierMid, domain.PlanningTierLong:
|
||||
@@ -55,7 +61,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
|
||||
|
||||
switch a.Type {
|
||||
case "premise":
|
||||
if err := t.store.SavePremise(a.Content); err != nil {
|
||||
if err := t.store.SavePremise(content); err != nil {
|
||||
return nil, fmt.Errorf("save premise: %w", err)
|
||||
}
|
||||
_ = t.store.UpdatePhase(domain.PhasePremise)
|
||||
@@ -63,7 +69,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
|
||||
|
||||
case "outline":
|
||||
var entries []domain.OutlineEntry
|
||||
if err := json.Unmarshal([]byte(a.Content), &entries); err != nil {
|
||||
if err := json.Unmarshal([]byte(content), &entries); err != nil {
|
||||
return nil, fmt.Errorf("parse outline JSON: %w", err)
|
||||
}
|
||||
if err := t.store.SaveOutline(entries); err != nil {
|
||||
@@ -81,7 +87,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
|
||||
|
||||
case "layered_outline":
|
||||
var volumes []domain.VolumeOutline
|
||||
if err := json.Unmarshal([]byte(a.Content), &volumes); err != nil {
|
||||
if err := json.Unmarshal([]byte(content), &volumes); err != nil {
|
||||
return nil, fmt.Errorf("parse layered_outline JSON: %w", err)
|
||||
}
|
||||
if err := t.store.SaveLayeredOutline(volumes); err != nil {
|
||||
@@ -107,7 +113,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
|
||||
|
||||
case "characters":
|
||||
var chars []domain.Character
|
||||
if err := json.Unmarshal([]byte(a.Content), &chars); err != nil {
|
||||
if err := json.Unmarshal([]byte(content), &chars); err != nil {
|
||||
return nil, fmt.Errorf("parse characters JSON: %w", err)
|
||||
}
|
||||
if err := t.store.SaveCharacters(chars); err != nil {
|
||||
@@ -117,7 +123,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
|
||||
|
||||
case "world_rules":
|
||||
var rules []domain.WorldRule
|
||||
if err := json.Unmarshal([]byte(a.Content), &rules); err != nil {
|
||||
if err := json.Unmarshal([]byte(content), &rules); err != nil {
|
||||
return nil, fmt.Errorf("parse world_rules JSON: %w", err)
|
||||
}
|
||||
if err := t.store.SaveWorldRules(rules); err != nil {
|
||||
@@ -129,3 +135,19 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
|
||||
return nil, fmt.Errorf("unknown type %q, expected premise/outline/layered_outline/characters/world_rules", a.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeFoundationContent(raw json.RawMessage) (string, error) {
|
||||
if len(raw) == 0 {
|
||||
return "", fmt.Errorf("content is required")
|
||||
}
|
||||
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
if !json.Valid(raw) {
|
||||
return "", fmt.Errorf("invalid content: expected Markdown string or valid JSON value")
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
@@ -111,3 +111,41 @@ func TestSaveFoundationOutlineClearsLayeredStateWhenDowngrading(t *testing.T) {
|
||||
t.Fatalf("expected planning tier %q, got %q", domain.PlanningTierMid, meta.PlanningTier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveFoundationAcceptsDirectJSONArrayContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := state.NewStore(dir)
|
||||
if err := store.Init(); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
tool := NewSaveFoundationTool(store)
|
||||
args, err := json.Marshal(map[string]any{
|
||||
"type": "outline",
|
||||
"content": []map[string]any{
|
||||
{
|
||||
"chapter": 1,
|
||||
"title": "第一章",
|
||||
"core_event": "主角登场",
|
||||
"hook": "继续",
|
||||
"scenes": []string{"场景一", "场景二"},
|
||||
},
|
||||
},
|
||||
"scale": "short",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tool.Execute(context.Background(), args); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
outline, err := store.LoadOutline()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOutline: %v", err)
|
||||
}
|
||||
if len(outline) != 1 || outline[0].Title != "第一章" {
|
||||
t.Fatalf("unexpected outline: %+v", outline)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/voocel/agentcore/schema"
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
"github.com/voocel/ainovel-cli/state"
|
||||
)
|
||||
|
||||
// WriteSceneTool 写入单个场景草稿。
|
||||
type WriteSceneTool struct {
|
||||
store *state.Store
|
||||
}
|
||||
|
||||
func NewWriteSceneTool(store *state.Store) *WriteSceneTool {
|
||||
return &WriteSceneTool{store: store}
|
||||
}
|
||||
|
||||
func (t *WriteSceneTool) Name() string { return "write_scene" }
|
||||
func (t *WriteSceneTool) Description() string {
|
||||
return "写入单个场景草稿。严格按场景级写作,每次只写一个场景。必须先调用 plan_chapter"
|
||||
}
|
||||
func (t *WriteSceneTool) Label() string { return "写入场景" }
|
||||
|
||||
func (t *WriteSceneTool) Schema() map[string]any {
|
||||
return schema.Object(
|
||||
schema.Property("chapter", schema.Int("章节号")).Required(),
|
||||
schema.Property("scene", schema.Int("场景编号,从 1 开始")).Required(),
|
||||
schema.Property("content", schema.String("场景正文")).Required(),
|
||||
)
|
||||
}
|
||||
|
||||
func (t *WriteSceneTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a struct {
|
||||
Chapter int `json:"chapter"`
|
||||
Scene int `json:"scene"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("invalid args: %w", err)
|
||||
}
|
||||
if a.Chapter <= 0 || a.Scene <= 0 {
|
||||
return nil, fmt.Errorf("chapter and scene must be > 0")
|
||||
}
|
||||
if a.Content == "" {
|
||||
return nil, fmt.Errorf("content must not be empty")
|
||||
}
|
||||
|
||||
wordCount := utf8.RuneCountInString(a.Content)
|
||||
draft := domain.SceneDraft{
|
||||
Chapter: a.Chapter,
|
||||
Scene: a.Scene,
|
||||
Content: a.Content,
|
||||
WordCount: wordCount,
|
||||
}
|
||||
|
||||
if err := t.store.SaveSceneDraft(draft); err != nil {
|
||||
return nil, fmt.Errorf("save scene draft: %w", err)
|
||||
}
|
||||
|
||||
// 场景级 checkpoint
|
||||
if err := t.store.MarkSceneComplete(a.Chapter, a.Scene); err != nil {
|
||||
return nil, fmt.Errorf("mark scene complete: %w", err)
|
||||
}
|
||||
|
||||
return json.Marshal(map[string]any{
|
||||
"written": true,
|
||||
"chapter": a.Chapter,
|
||||
"scene": a.Scene,
|
||||
"word_count": wordCount,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user