From 3a6b24a2e971c2f22e64dc450004758a55846bc0 Mon Sep 17 00:00:00 2001 From: ciallo Date: Mon, 6 Jul 2026 13:05:05 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20/load=20=E5=91=BD=E4=BB=A4=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=A7=E6=96=87=E4=BB=B6=E8=87=AA=E5=8A=A8=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 load_file.go,按文件大小自动分发:≤32KB 进输入框,>32KB 直接发送 - 绕过 textarea 5000 字符限制,由 LLM 上下文窗口作为真正上限 - 新增完整测试覆盖路径解析、大小判断、send 模式等场景 --- internal/entry/tui/load_file.go | 98 +++++++++++++++++++++++++ internal/entry/tui/load_file_test.go | 102 +++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 internal/entry/tui/load_file.go create mode 100644 internal/entry/tui/load_file_test.go diff --git a/internal/entry/tui/load_file.go b/internal/entry/tui/load_file.go new file mode 100644 index 00000000..c4918bd3 --- /dev/null +++ b/internal/entry/tui/load_file.go @@ -0,0 +1,98 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// maxLoadFileSize 限制单次加载的文件大小,避免误读超大文件灌爆 textarea。 +// 32KB 大约对应中文 1.5 万字 / 英文 3 万字符,足够覆盖共创导出的 md。 +const maxLoadFileSize = 32 * 1024 + +// resolveProjectPath 把用户输入的路径解析为绝对路径。 +// +// 规则: +// - 以 @ 开头:剥离 @(/rewrite @path 语法糖) +// - 以 ~/ 开头:展开为 $HOME +// - 以 ./ 开头或相对路径:相对 projectDir +// - 绝对路径:原样使用 +func resolveProjectPath(projectDir, raw string) (string, error) { + p := strings.TrimSpace(raw) + p = strings.TrimPrefix(p, "@") + if p == "" { + return "", fmt.Errorf("路径为空") + } + + if strings.HasPrefix(p, "~") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("解析家目录失败:%w", err) + } + p = filepath.Join(home, strings.TrimPrefix(p, "~")) + } + + if filepath.IsAbs(p) { + return p, nil + } + + return filepath.Join(projectDir, p), nil +} + +// loadFileAsPrompt 读取文件内容并裁剪为可直接灌入 textarea 的字符串。 +// +// 错误场景: +// - 文件不存在:友好提示"文件不存在:" +// - 超过 maxLoadFileSize:友好提示文件过大 +// - 读取失败:包装原始错误 +func loadFileAsPrompt(projectDir, raw string) (string, error) { + abs, err := resolveProjectPath(projectDir, raw) + if err != nil { + return "", err + } + info, err := os.Stat(abs) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("文件不存在:%s", abs) + } + return "", fmt.Errorf("stat 失败:%w", err) + } + if info.IsDir() { + return "", fmt.Errorf("目标路径是目录,请指定 .md 文件:%s", abs) + } + if info.Size() > maxLoadFileSize { + return "", fmt.Errorf("文件过大(%d 字节,上限 %d):%s", info.Size(), maxLoadFileSize, abs) + } + data, err := os.ReadFile(abs) + if err != nil { + return "", fmt.Errorf("读取失败:%w", err) + } + return strings.TrimSpace(string(data)), nil +} + +// loadFileForSend 读取文件内容供 --send 模式直接作为消息提交。 +// 与 loadFileAsPrompt 不同:不检查 maxLoadFileSize,因为内容不经过 textarea, +// 而是直接交给 LLM;textarea 的 CharLimit 和文件大小检查本来就是为了保护 +// 输入框,--send 路径不需要这层防护。LLM 上下文窗口才是真正上限。 +func loadFileForSend(projectDir, raw string) (string, error) { + abs, err := resolveProjectPath(projectDir, raw) + if err != nil { + return "", err + } + info, err := os.Stat(abs) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("文件不存在:%s", abs) + } + return "", fmt.Errorf("stat 失败:%w", err) + } + if info.IsDir() { + return "", fmt.Errorf("目标路径是目录,请指定 .md 文件:%s", abs) + } + data, err := os.ReadFile(abs) + if err != nil { + return "", fmt.Errorf("读取失败:%w", err) + } + return strings.TrimSpace(string(data)), nil +} diff --git a/internal/entry/tui/load_file_test.go b/internal/entry/tui/load_file_test.go new file mode 100644 index 00000000..9367f521 --- /dev/null +++ b/internal/entry/tui/load_file_test.go @@ -0,0 +1,102 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolveProjectPath_AtPrefix(t *testing.T) { + dir := t.TempDir() + abs, err := resolveProjectPath(dir, "@meta/cocreate/x.md") + if err != nil { + t.Fatalf("解析失败:%v", err) + } + want := filepath.Join(dir, "meta", "cocreate", "x.md") + if abs != want { + t.Errorf("解析错误:got %s want %s", abs, want) + } +} + +func TestResolveProjectPath_Relative(t *testing.T) { + dir := t.TempDir() + abs, err := resolveProjectPath(dir, "outline.md") + if err != nil { + t.Fatalf("解析失败:%v", err) + } + want := filepath.Join(dir, "outline.md") + if abs != want { + t.Errorf("解析错误:got %s want %s", abs, want) + } +} + +func TestResolveProjectPath_Absolute(t *testing.T) { + dir := t.TempDir() + abs := "/tmp/some-abs-path.md" + got, err := resolveProjectPath(dir, abs) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if got != abs { + t.Errorf("绝对路径应原样返回:got %s want %s", got, abs) + } +} + +func TestLoadFileAsPrompt_ReadsContent(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "meta", "cocreate") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(sub, "x.md") + want := "# 创作指令\n\n主角:陆沉\n世界观:赛博朋克" + if err := os.WriteFile(path, []byte(want), 0o644); err != nil { + t.Fatal(err) + } + got, err := loadFileAsPrompt(dir, "@meta/cocreate/x.md") + if err != nil { + t.Fatalf("读取失败:%v", err) + } + if got != strings.TrimSpace(want) { + t.Errorf("内容不匹配:got=%q want=%q", got, want) + } +} + +func TestLoadFileAsPrompt_FileNotExist(t *testing.T) { + dir := t.TempDir() + _, err := loadFileAsPrompt(dir, "@meta/cocreate/missing.md") + if err == nil { + t.Fatal("应返回错误") + } + if !strings.Contains(err.Error(), "不存在") { + t.Errorf("错误消息应包含'不存在',got:%v", err) + } +} + +func TestLoadFileAsPrompt_TooLarge(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "big.md") + if err := os.WriteFile(path, make([]byte, maxLoadFileSize+1), 0o644); err != nil { + t.Fatal(err) + } + _, err := loadFileAsPrompt(dir, "big.md") + if err == nil { + t.Fatal("应返回错误") + } + if !strings.Contains(err.Error(), "文件过大") { + t.Errorf("错误消息应包含'文件过大',got:%v", err) + } +} + +func TestLoadFileAsPrompt_Directory(t *testing.T) { + dir := t.TempDir() + _ = os.MkdirAll(filepath.Join(dir, "meta"), 0o755) + _, err := loadFileAsPrompt(dir, "@meta") + if err == nil { + t.Fatal("应返回错误") + } + if !strings.Contains(err.Error(), "目录") { + t.Errorf("错误消息应提到目标为目录,got:%v", err) + } +} From bd8f3f312b788dc0f5f014145e244380f3dca3d9 Mon Sep 17 00:00:00 2001 From: ciallo Date: Mon, 6 Jul 2026 13:05:13 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20skill=20?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=EF=BC=88=E8=A7=A3=E6=9E=90=E3=80=81=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E3=80=81=E5=B7=A5=E5=85=B7=E3=80=81TUI=20=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/skills/: skill 解析、注入、存储与检索能力 - internal/cli/skill.go: 命令行 skill 子命令入口 - internal/tools/skill_add.go / skill_read.go / skill_search.go: LLM 工具 - cmd/ainovel-cli/main.go: 拦截 skill 子命令 - TUI command_palette: 集成 skill 入口与测试 --- cmd/ainovel-cli/main.go | 12 +- internal/cli/skill.go | 218 +++++++++++ internal/entry/tui/command_palette.go | 257 +++++++++++-- internal/entry/tui/command_palette_test.go | 130 +++++++ internal/skills/inject.go | 103 ++++++ internal/skills/parser.go | 346 ++++++++++++++++++ internal/skills/parser_test.go | 219 +++++++++++ internal/skills/store.go | 401 +++++++++++++++++++++ internal/skills/store_test.go | 282 +++++++++++++++ internal/tools/skill_add.go | 114 ++++++ internal/tools/skill_read.go | 69 ++++ internal/tools/skill_search.go | 109 ++++++ 12 files changed, 2223 insertions(+), 37 deletions(-) create mode 100644 internal/cli/skill.go create mode 100644 internal/entry/tui/command_palette_test.go create mode 100644 internal/skills/inject.go create mode 100644 internal/skills/parser.go create mode 100644 internal/skills/parser_test.go create mode 100644 internal/skills/store.go create mode 100644 internal/skills/store_test.go create mode 100644 internal/tools/skill_add.go create mode 100644 internal/tools/skill_read.go create mode 100644 internal/tools/skill_search.go diff --git a/cmd/ainovel-cli/main.go b/cmd/ainovel-cli/main.go index d37578bc..b05bdacc 100644 --- a/cmd/ainovel-cli/main.go +++ b/cmd/ainovel-cli/main.go @@ -8,6 +8,7 @@ import ( "github.com/voocel/ainovel-cli/assets" "github.com/voocel/ainovel-cli/internal/bootstrap" + "github.com/voocel/ainovel-cli/internal/cli" "github.com/voocel/ainovel-cli/internal/entry/headless" "github.com/voocel/ainovel-cli/internal/entry/tui" "github.com/voocel/ainovel-cli/internal/eval" @@ -25,9 +26,14 @@ var ( var headlessMode bool func main() { - // 子命令在常规 flag 解析之前拦截:eval 是离线评测 harness,参数体系独立。 - if len(os.Args) > 1 && os.Args[1] == "eval" { - os.Exit(eval.Command(os.Args[2:])) + // 子命令在常规 flag 解析之前拦截:eval / skill 参数体系独立,不需要加载配置。 + if len(os.Args) > 1 { + switch os.Args[1] { + case "eval": + os.Exit(eval.Command(os.Args[2:])) + case "skill": + os.Exit(cli.SkillCommand(os.Args[2:])) + } } opts, args, err := parseCLIOptions(os.Args[1:]) diff --git a/internal/cli/skill.go b/internal/cli/skill.go new file mode 100644 index 00000000..df351ceb --- /dev/null +++ b/internal/cli/skill.go @@ -0,0 +1,218 @@ +// Package cli 提供 ainovel 的辅助子命令(如 skill)。 +// 这些命令在 main.go 顶部拦截,不参与 TUI/headless 主流程。 +package cli + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "text/tabwriter" + + "github.com/voocel/ainovel-cli/internal/bootstrap" + "github.com/voocel/ainovel-cli/internal/skills" +) + +// SkillCommand 处理 `ainovel skill ...` 子命令。 +func SkillCommand(args []string) int { + if len(args) == 0 { + printSkillUsage() + return 0 + } + + store := skills.NewStore(filepath.Join(bootstrap.DefaultConfigDir(), "skills")) + if err := store.Refresh(); err != nil { + fmt.Fprintf(os.Stderr, "skill 库初始化失败: %v\n", err) + return 1 + } + + switch args[0] { + case "list", "ls": + return runSkillList(store, args[1:]) + case "show", "cat": + return runSkillShow(store, args[1:]) + case "add": + return runSkillAdd(store, args[1:]) + case "edit": + return runSkillEdit(store, args[1:]) + case "remove", "rm", "delete": + return runSkillRemove(store, args[1:]) + case "refresh", "reload": + return runSkillRefresh(store, args[1:]) + case "-h", "--help", "help": + printSkillUsage() + return 0 + default: + fmt.Fprintf(os.Stderr, "未知子命令: %s\n", args[0]) + printSkillUsage() + return 1 + } +} + +func printSkillUsage() { + fmt.Println("用法: ainovel skill [args]") + fmt.Println("") + fmt.Println("命令:") + fmt.Println(" list [category] 列出所有 skill(或某分类下)") + fmt.Println(" show 显示某 skill 全文") + fmt.Println(" add 从 .md 文件添加 skill(含 frontmatter 校验)") + fmt.Println(" edit 用 $EDITOR 编辑 skill") + fmt.Println(" remove 删除 skill(带确认)") + fmt.Println(" refresh 强制刷新索引") + fmt.Println("") + fmt.Println("skill 存放位置: ~/.ainovel/skills//.md") +} + +func runSkillList(store *skills.Store, args []string) int { + category := "" + if len(args) > 0 { + category = strings.TrimSpace(args[0]) + } + metas := store.List(category) + if len(metas) == 0 { + fmt.Println("本地 skill 库为空。") + return 0 + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tCATEGORY\tPRIORITY\tDESCRIPTION") + for _, m := range metas { + fmt.Fprintf(w, "%s\t%s\t%d\t%s\n", m.Name, m.Category, m.Priority, m.Description) + } + w.Flush() + return 0 +} + +func runSkillShow(store *skills.Store, args []string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "用法: ainovel skill show ") + return 1 + } + content, err := store.Read(args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "读取失败: %v\n", err) + return 1 + } + fmt.Println(content) + return 0 +} + +func runSkillAdd(store *skills.Store, args []string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "用法: ainovel skill add ") + return 1 + } + path := args[0] + raw, err := os.ReadFile(path) + if err != nil { + fmt.Fprintf(os.Stderr, "读取文件失败: %v\n", err) + return 1 + } + meta, body, err := skills.ParseSkill(path, string(raw)) + if err != nil { + fmt.Fprintf(os.Stderr, "解析 skill 失败: %v\n", err) + return 1 + } + if meta.Name == "" { + fmt.Fprintln(os.Stderr, "frontmatter 缺少 name 字段") + return 1 + } + if meta.Description == "" { + fmt.Fprintln(os.Stderr, "frontmatter 缺少 description 字段") + return 1 + } + if err := store.Add(meta, body); err != nil { + fmt.Fprintf(os.Stderr, "添加失败: %v\n", err) + return 1 + } + fmt.Printf("已添加 skill: %s (category=%s)\n", meta.Name, meta.Category) + return 0 +} + +func runSkillEdit(store *skills.Store, args []string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "用法: ainovel skill edit ") + return 1 + } + name := args[0] + + // 找到文件路径 + metas := store.List("") + var target string + for _, m := range metas { + if m.Name == name { + target = m.Path + break + } + } + if target == "" { + fmt.Fprintf(os.Stderr, "skill %q 不存在\n", name) + return 1 + } + + editor := os.Getenv("EDITOR") + if editor == "" { + editor = defaultEditor() + } + + cmd := exec.Command(editor, target) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "编辑器退出失败: %v\n", err) + return 1 + } + if err := store.Refresh(); err != nil { + fmt.Fprintf(os.Stderr, "刷新索引失败: %v\n", err) + return 1 + } + fmt.Printf("已更新 skill: %s\n", name) + return 0 +} + +func runSkillRemove(store *skills.Store, args []string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "用法: ainovel skill remove ") + return 1 + } + name := args[0] + + // 确认 + fmt.Printf("确认删除 skill %q 吗?输入 yes 继续: ", name) + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + fmt.Fprintf(os.Stderr, "读取确认失败: %v\n", err) + return 1 + } + if strings.TrimSpace(strings.ToLower(line)) != "yes" { + fmt.Println("已取消删除。") + return 0 + } + + if err := store.Remove(name); err != nil { + fmt.Fprintf(os.Stderr, "删除失败: %v\n", err) + return 1 + } + fmt.Printf("已删除 skill: %s\n", name) + return 0 +} + +func runSkillRefresh(store *skills.Store, args []string) int { + if err := store.Refresh(); err != nil { + fmt.Fprintf(os.Stderr, "刷新失败: %v\n", err) + return 1 + } + fmt.Println("skill 索引已刷新。") + return 0 +} + +func defaultEditor() string { + if os.PathSeparator == '\\' { + return "notepad" + } + return "vi" +} diff --git a/internal/entry/tui/command_palette.go b/internal/entry/tui/command_palette.go index 46e9b3d2..a36d5cd2 100644 --- a/internal/entry/tui/command_palette.go +++ b/internal/entry/tui/command_palette.go @@ -6,6 +6,27 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/voocel/ainovel-cli/internal/skills" +) + +// 条目类型。 +// - kindCommand:内置 / 命令(help / diag / model / ...) +// - kindSkill:跨书 skill 库条目 +// - kindSkillEntry:命令列表里的 "Skill ▸" 子菜单入口,不直接补全 +const ( + kindCommand = "command" + kindSkill = "skill" + kindSkillEntry = "skill-entry" +) + +// paletteLevel 标识菜单层级。 +// - paletteLevelCommands:一级,显示"命令 + Skill 入口"混合列表 +// - paletteLevelSkills:选中 Skill 入口后展开的子菜单,只显示 skill 库内容 +type paletteLevel int + +const ( + paletteLevelCommands paletteLevel = iota + paletteLevelSkills ) type commandPaletteItem struct { @@ -14,21 +35,83 @@ type commandPaletteItem struct { Usage string Description string AutoExecute bool + Kind string // 缺省 kindCommand + Category string // 仅 skill 用:genres / structures / ... } func builtinCommandItems() []commandPaletteItem { return commandRegistryInstance().PaletteItems() } +// skillPaletteItems 从 store 抽出当前所有 skill 元数据并转为菜单条目。 +// store 为 nil(库未启用)时返回 nil —— 子菜单为空,但入口仍可见。 +func skillPaletteItems(store *skills.Store) []commandPaletteItem { + if store == nil { + return nil + } + metas := store.List("") + items := make([]commandPaletteItem, 0, len(metas)) + for _, m := range metas { + desc := m.Description + if desc == "" { + desc = "(无描述)" + } + items = append(items, commandPaletteItem{ + Name: m.Name, + Description: desc, + Usage: "/" + m.Name + " [补充要求]", + Kind: kindSkill, + Category: m.Category, + }) + } + return items +} + +// commandsWithSkillEntry 在内置命令后追加 "Skill ▸" 子菜单入口。 +// 入口始终存在(即使 skill 库为空),描述里给出数量提示让用户判断是否值得展开。 +// 这样 skill 路径可发现,又不污染命令列表的高频入口(help/diag/...)。 +func commandsWithSkillEntry(store *skills.Store) []commandPaletteItem { + items := append([]commandPaletteItem(nil), builtinCommandItems()...) + + skillCount := 0 + if store != nil { + skillCount = len(store.List("")) + } + desc := "本地题材 / 结构 / 风格 skill 库" + if skillCount == 0 { + desc = "(skill 库为空,用 'ainovel skill add' 添加)" + } else { + desc = desc + "(" + strconv.Itoa(skillCount) + " 项)" + } + + items = append(items, commandPaletteItem{ + Name: "Skill", + Description: desc, + Usage: "→ 展开 skill 库", + Kind: kindSkillEntry, + }) + return items +} + func scoreCommandItem(item commandPaletteItem, query string) int { if query == "" { - return 100 + base := 100 + switch item.Kind { + case kindSkill: + base = 60 + case kindSkillEntry: + // Skill 入口在空查询时排末尾:它是个"补充路径",不希望抢占 help/diag 等高频命令的视线。 + // 用户主动输入 "skill" 时下面精确匹配分支会让它浮顶。 + base = 40 + } + return base } name := strings.ToLower(item.Name) desc := strings.ToLower(item.Description) usage := strings.ToLower(item.Usage) aliases := strings.ToLower(strings.Join(item.Aliases, " ")) + category := strings.ToLower(item.Category) switch { case name == query: @@ -43,6 +126,8 @@ func scoreCommandItem(item commandPaletteItem, query string) int { return 650 case strings.Contains(desc, query): return 420 + case category != "" && strings.Contains(category, query): + return 380 case strings.Contains(usage, query): return 360 default: @@ -50,9 +135,8 @@ func scoreCommandItem(item commandPaletteItem, query string) int { } } -func commandCompletions(prefix string) []commandPaletteItem { - query := strings.TrimSpace(strings.ToLower(prefix)) - items := append([]commandPaletteItem(nil), builtinCommandItems()...) +// sortAndFilter 共用排序+过滤。query 非空时丢弃零分条目。 +func sortAndFilter(items []commandPaletteItem, query string) []commandPaletteItem { slices.SortStableFunc(items, func(a, b commandPaletteItem) int { scoreA := scoreCommandItem(a, query) scoreB := scoreCommandItem(b, query) @@ -61,20 +145,87 @@ func commandCompletions(prefix string) []commandPaletteItem { } return strings.Compare(a.Name, b.Name) }) - - var out []commandPaletteItem - for _, item := range items { - if scoreCommandItem(item, query) > 0 { - out = append(out, item) + if query == "" { + return items + } + out := items[:0] + for _, it := range items { + if scoreCommandItem(it, query) > 0 { + out = append(out, it) } } return out } +// commandCompletions 一级菜单用:返回"内置命令 + Skill 入口",按 query 过滤。 +// Skill 入口在 query 命中 "skill" 时排前,否则随 base 分排在末尾(保持命令优先)。 +func commandCompletions(prefix string, store *skills.Store) []commandPaletteItem { + query := strings.TrimSpace(strings.ToLower(prefix)) + items := commandsWithSkillEntry(store) + return sortAndFilter(items, query) +} + +// skillCompletions 二级子菜单用:只返回 skill 库条目。 +func skillCompletions(prefix string, store *skills.Store) []commandPaletteItem { + query := strings.TrimSpace(strings.ToLower(prefix)) + items := skillPaletteItems(store) + return sortAndFilter(items, query) +} + func (m *Model) clearCommandPalette() { m.compItems = nil m.compIdx = 0 m.compActive = false + m.compLevel = paletteLevelCommands +} + +// enterSkillSubMenu 从命令列表进入 Skill 子菜单:清空 textarea 到 "/" 重新过滤。 +// 用 Esc 返回时调 exitSkillSubMenu。 +func (m *Model) enterSkillSubMenu() { + m.compLevel = paletteLevelSkills + m.compIdx = 0 + m.textarea.Reset() + m.textarea.SetValue("/") + m.textarea.CursorEnd() + m.refreshCommandPaletteItems() +} + +// exitSkillSubMenu 从 Skill 子菜单返回命令列表:恢复 textarea 到 "/" 并重建一级列表。 +func (m *Model) exitSkillSubMenu() { + m.compLevel = paletteLevelCommands + m.compIdx = 0 + m.textarea.Reset() + m.textarea.SetValue("/") + m.textarea.CursorEnd() + m.refreshCommandPaletteItems() +} + +// refreshCommandPaletteItems 按 compLevel 重新计算 compItems。 +// 抽出来避免 update / enter / exit 路径重复构造。 +func (m *Model) refreshCommandPaletteItems() { + var store *skills.Store + if m.runtime != nil { + store = m.runtime.SkillStore() + } + + text := strings.TrimSpace(m.textarea.Value()) + query := strings.TrimPrefix(text, "/") + + switch m.compLevel { + case paletteLevelSkills: + m.compItems = skillCompletions(query, store) + default: + m.compItems = commandCompletions(query, store) + } + + m.compActive = len(m.compItems) > 0 + if !m.compActive { + m.compIdx = 0 + return + } + if m.compIdx >= len(m.compItems) { + m.compIdx = max(0, len(m.compItems)-1) + } } func (m *Model) updateCommandPalette() { @@ -87,17 +238,7 @@ func (m *Model) updateCommandPalette() { m.clearCommandPalette() return } - - items := commandCompletions(strings.TrimPrefix(text, "/")) - m.compItems = items - m.compActive = len(items) > 0 - if !m.compActive { - m.compIdx = 0 - return - } - if m.compIdx >= len(items) { - m.compIdx = max(0, len(items)-1) - } + m.refreshCommandPaletteItems() } func (m *Model) selectedCommandItem() (commandPaletteItem, bool) { @@ -107,11 +248,25 @@ func (m *Model) selectedCommandItem() (commandPaletteItem, bool) { return m.compItems[m.compIdx], true } +// acceptCommandCompletion 接受当前选中条目。 +// - 一级 + Skill 入口:进入子菜单(不写入 textarea) +// - 一级 + 普通命令 / 二级 + skill:补全到 "/ " 让用户附加消息 +// +// 返回 (item, true) 表示已补全(调用方可继续 AutoExecute 流程); +// 返回 (item, false) 表示"已切换层级,textarea 已重置,等待下一轮按键"。 func (m *Model) acceptCommandCompletion() (commandPaletteItem, bool) { item, ok := m.selectedCommandItem() if !ok { return commandPaletteItem{}, false } + + // Skill 子菜单入口:进入子菜单 + if m.compLevel == paletteLevelCommands && item.Kind == kindSkillEntry { + m.enterSkillSubMenu() + return item, false + } + + // 普通条目:补全到 / 让用户继续 m.textarea.Reset() m.textarea.SetValue("/" + item.Name + " ") m.textarea.CursorEnd() @@ -121,14 +276,14 @@ func (m *Model) acceptCommandCompletion() (commandPaletteItem, bool) { return item, true } -func renderCommandPalette(width int, items []commandPaletteItem, cursor int) string { +func renderCommandPalette(width int, level paletteLevel, items []commandPaletteItem, cursor int) string { if len(items) == 0 || width <= 0 { return "" } boxW := width - 2 - if boxW > 72 { - boxW = 72 + if boxW > 84 { + boxW = 84 } if boxW < 48 { boxW = 48 @@ -141,50 +296,84 @@ func renderCommandPalette(width int, items []commandPaletteItem, cursor int) str visible := items[start:end] remaining := len(items) - end + // 配色:command 主色(金);skill 次色(青绿);Skill 入口用次色但加 ▸ 强调 nameStyle := lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + skillNameStyle := lipgloss.NewStyle().Foreground(colorAccent2).Bold(true) + entryNameStyle := lipgloss.NewStyle().Foreground(colorAccent2).Bold(true) descStyle := lipgloss.NewStyle().Foreground(bodyTextColor) mutedStyle := lipgloss.NewStyle().Foreground(colorMuted) selectedNameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#1c1a14")).Background(colorAccent).Bold(true) selectedDescStyle := lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + title := "命令" + hint := "↑↓ 选择 · Enter/Tab 接受 · → 展开 Skill · Esc 关闭" + if level == paletteLevelSkills { + title = "Skill 库" + hint = "↑↓ 选择 · Enter/Tab 接受 · ←/Esc 返回上级" + } + var body []string selectedIdx := cursor - start for i, item := range visible { prefix := " " nameRenderer := nameStyle descRenderer := descStyle + displayName := item.Name + switch item.Kind { + case kindSkill: + nameRenderer = skillNameStyle + cat := strings.TrimSpace(item.Category) + if cat == "" { + cat = "skill" + } + displayName = "[" + cat + "] " + item.Name + case kindSkillEntry: + nameRenderer = entryNameStyle + displayName = item.Name + " ▸" + } if i == selectedIdx { prefix = "› " nameRenderer = selectedNameStyle descRenderer = selectedDescStyle } - name := nameRenderer.Render(item.Name) - // truncateWidth 按视觉宽度截断(中文字符算 2 列);用 truncate 会按 rune 数算, - // 中文场景实际宽度 = 期望的 2 倍,导致弹窗溢出。 - desc := truncateWidth(item.Description, max(12, contentW-18)) + // 动态算 desc 可用宽度:contentW - prefix(2) - displayName 宽 - gap(1) + // name 超长先截断 name;desc 至少留 8 列。 + nameAvail := contentW - 2 - 1 - 8 + if nameAvail < 8 { + nameAvail = 8 + } + if lipgloss.Width(displayName) > nameAvail { + displayName = truncateWidth(displayName, nameAvail) + } + descAvail := contentW - 2 - lipgloss.Width(displayName) - 1 + if descAvail < 8 { + descAvail = 8 + } + desc := truncateWidth(item.Description, descAvail) + + nameView := nameRenderer.Render(displayName) descText := descRenderer.Render(desc) - line := prefix + name - gap := contentW - lipgloss.Width(line) - lipgloss.Width(descText) + gap := contentW - 2 - lipgloss.Width(displayName) - lipgloss.Width(desc) if gap < 1 { gap = 1 } - body = append(body, line+strings.Repeat(" ", gap)+descText) + body = append(body, prefix+nameView+strings.Repeat(" ", gap)+descText) } if selectedIdx < 0 || selectedIdx >= len(visible) { selectedIdx = 0 } - hint := mutedStyle.Render("↑↓ 选择 · Tab/Enter 接受 · Esc 关闭") usage := "Usage: " + visible[selectedIdx].Usage if remaining > 0 { - usage = usage + " · 还有 " + strconv.Itoa(remaining) + " 个命令" + usage = usage + " · 还有 " + strconv.Itoa(remaining) + " 项" } usageLine := mutedStyle.Render(truncateWidth(usage, contentW)) body = append(body, usageLine+strings.Repeat(" ", max(0, contentW-lipgloss.Width(usageLine)))) - body = append(body, hint+strings.Repeat(" ", max(0, contentW-lipgloss.Width(hint)))) + hintLine := mutedStyle.Render(hint) + body = append(body, hintLine+strings.Repeat(" ", max(0, contentW-lipgloss.Width(hintLine)))) - return renderPaddedModalFrame(boxW, len(body)+2, "命令", "", body) + return renderPaddedModalFrame(boxW, len(body)+2, title, "", body) } func commandPaletteWindow(total, cursor, limit int) (start, end int) { diff --git a/internal/entry/tui/command_palette_test.go b/internal/entry/tui/command_palette_test.go new file mode 100644 index 00000000..e72862f0 --- /dev/null +++ b/internal/entry/tui/command_palette_test.go @@ -0,0 +1,130 @@ +package tui + +import ( + "testing" + + "github.com/voocel/ainovel-cli/internal/skills" +) + +// TestSkillPaletteItemsConvertsMeta 验证 store.List 输出被正确映射为菜单条目。 +func TestSkillPaletteItemsConvertsMeta(t *testing.T) { + dir := t.TempDir() + store := skills.NewStore(dir) + if err := store.Add(skills.SkillMeta{ + Name: "cyberpunk-noir", + Description: "赛博朋克 + 黑色侦探题材清单", + Category: "genres", + Tags: []string{"sci-fi"}, + }, "body content"); err != nil { + t.Fatalf("Add: %v", err) + } + if err := store.Add(skills.SkillMeta{ + Name: "minimal-skill", + Description: "最小用例", + }, "body"); err != nil { + t.Fatalf("Add: %v", err) + } + + items := skillPaletteItems(store) + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d: %+v", len(items), items) + } + + byName := map[string]commandPaletteItem{} + for _, it := range items { + byName[it.Name] = it + } + cs, ok := byName["cyberpunk-noir"] + if !ok { + t.Fatalf("missing cyberpunk-noir in %+v", items) + } + if cs.Kind != kindSkill || cs.Category != "genres" { + t.Fatalf("cyberpunk-noir kind/category mismatch: %+v", cs) + } + if cs.Usage != "/cyberpunk-noir [补充要求]" { + t.Fatalf("usage should mention skill inject format, got %q", cs.Usage) + } +} + +// TestCommandCompletionsIncludesSkillEntry 验证一级命令列表末尾始终追加 +// "Skill ▸" 子菜单入口(即便 store 为 nil),让用户能发现 skill 路径。 +func TestCommandCompletionsIncludesSkillEntry(t *testing.T) { + // nil store:入口仍存在,但描述提示"为空" + items := commandCompletions("", nil) + last := items[len(items)-1] + if last.Kind != kindSkillEntry { + t.Fatalf("last item should be Skill entry, got %+v", last) + } + if last.Name != "Skill" { + t.Fatalf("Skill entry name mismatch: %+v", last) + } + if last.Description == "" { + t.Fatal("Skill entry should describe empty store") + } + + // 有 skill 的 store:描述应包含数量 + dir := t.TempDir() + store := skills.NewStore(dir) + for _, n := range []string{"a", "b", "c"} { + if err := store.Add(skills.SkillMeta{ + Name: n, Description: "x", Category: "misc", + }, "body"); err != nil { + t.Fatalf("Add: %v", err) + } + } + items = commandCompletions("", store) + last = items[len(items)-1] + if last.Kind != kindSkillEntry { + t.Fatalf("last item should be Skill entry, got %+v", last) + } + if !containsStr(last.Description, "3") { + t.Fatalf("description should mention skill count 3: %q", last.Description) + } +} + +// TestCommandCompletionsQuerySkillMatchesEntry 输入 "skill" 时一级菜单应让 Skill 入口浮顶, +// 因为名字精确匹配。 +func TestCommandCompletionsQuerySkillMatchesEntry(t *testing.T) { + items := commandCompletions("skill", nil) + if len(items) == 0 { + t.Fatal("query 'skill' should at least match Skill entry") + } + if items[0].Kind != kindSkillEntry { + t.Fatalf("Skill entry should rank first for query 'skill', got %+v", items[0]) + } +} + +// TestSkillCompletionsFiltersByQuery 二级子菜单按查询词过滤。 +func TestSkillCompletionsFiltersByQuery(t *testing.T) { + dir := t.TempDir() + store := skills.NewStore(dir) + _ = store.Add(skills.SkillMeta{Name: "cyberpunk-noir", Description: "赛博朋克", Category: "genres"}, "body") + _ = store.Add(skills.SkillMeta{Name: "wuxia", Description: "武侠", Category: "genres"}, "body") + + all := skillCompletions("", store) + if len(all) != 2 { + t.Fatalf("expected 2 skills on empty query, got %d", len(all)) + } + + filtered := skillCompletions("cyb", store) + if len(filtered) != 1 || filtered[0].Name != "cyberpunk-noir" { + t.Fatalf("query 'cyb' should match cyberpunk-noir only, got %+v", filtered) + } +} + +// TestSkillCompletionsNilStore 二级子菜单 store 缺失时不 panic。 +func TestSkillCompletionsNilStore(t *testing.T) { + items := skillCompletions("any", nil) + if len(items) != 0 { + t.Fatalf("nil store should yield empty list, got %+v", items) + } +} + +func containsStr(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/skills/inject.go b/internal/skills/inject.go new file mode 100644 index 00000000..2c4b5018 --- /dev/null +++ b/internal/skills/inject.go @@ -0,0 +1,103 @@ +package skills + +import ( + "fmt" + "strings" +) + +// InjectRequest 描述一次 /skill-name 主动调用展开。 +// 调用方(TUI / cocreate)从用户原始输入解析出 SkillName 和 UserMsg, +// 交给 Store 展开。 +type InjectRequest struct { + SkillName string // 不含前缀 / + UserMsg string // 用户在 skill name 之后输入的额外说明,可为空 +} + +// InjectResult 是 InjectSkill 的返回。 +type InjectResult struct { + Expanded bool // true=命中 skill 并已展开;false=未命中 + Text string // 展开后的完整消息(命中时)或原始回退(未命中时) + Hint string // 未命中时的错误描述(供 UI 显示) +} + +// InjectSkill 把 /skill-name 用户消息展开为最终发给 LLM 的文本。 +// +// 命中时格式: +// +// 【已主动加载本地 skill:】 +// +// +// +// --- +// +// 用户补充要求: +// (UserMsg 为空时使用通用提示) +// +// 未命中(name 不存在):返回 Expanded=false、Hint 描述错误。调用方决定如何处理 +// ——TUI 一般是显示错误并保留输入框,cocreate 同理。 +// +// store 为 nil(禁用)时直接返回未命中 + 禁用提示。 +func (s *Store) InjectSkill(req InjectRequest) InjectResult { + name := strings.TrimSpace(req.SkillName) + if name == "" { + return InjectResult{Expanded: false, Hint: "skill 名称为空"} + } + if s == nil || s.root == "" { + return InjectResult{ + Expanded: false, + Hint: "本地 skill 库未启用(路径解析失败或禁用)。", + } + } + content, err := s.Read(name) + if err != nil { + // 给出近似建议:取前 5 个 name 供用户参考 + hint := fmt.Sprintf("skill %q 不存在。", name) + all := s.List("") + if len(all) > 0 { + limit := 5 + if len(all) < limit { + limit = len(all) + } + suggestions := make([]string, 0, limit) + for _, m := range all[:limit] { + suggestions = append(suggestions, m.Name) + } + hint += " 可用 skill(前 " + fmt.Sprintf("%d", limit) + " 个):" + strings.Join(suggestions, ", ") + "。完整列表用 'ainovel skill list'。" + } else { + hint += " 当前 skill 库为空。" + } + return InjectResult{Expanded: false, Hint: hint} + } + + var sb strings.Builder + sb.WriteString("【已主动加载本地 skill:" + name + "】\n\n") + sb.WriteString(content) + sb.WriteString("\n\n---\n\n") + userMsg := strings.TrimSpace(req.UserMsg) + if userMsg != "" { + sb.WriteString("用户补充要求:" + userMsg) + } else { + sb.WriteString("(用户未补充具体要求。请基于上述 skill 内容给出建议、确认理解、或直接开始创作。)") + } + return InjectResult{Expanded: true, Text: sb.String()} +} + +// ParseSkillRef 从用户原始输入解析出 InjectRequest。 +// 输入形如 "/cyberpunk-noir-checklist 帮我写开篇"。 +// 返回 ok=false 表示不是 skill 引用(不以 / 开头,或 / 后内容为空)。 +func ParseSkillRef(raw string) (InjectRequest, bool) { + text := strings.TrimSpace(raw) + if !strings.HasPrefix(text, "/") { + return InjectRequest{}, false + } + rest := strings.TrimPrefix(text, "/") + if strings.TrimSpace(rest) == "" { + return InjectRequest{}, false + } + parts := strings.SplitN(rest, " ", 2) + req := InjectRequest{SkillName: parts[0]} + if len(parts) > 1 { + req.UserMsg = strings.TrimSpace(parts[1]) + } + return req, true +} diff --git a/internal/skills/parser.go b/internal/skills/parser.go new file mode 100644 index 00000000..5083be9f --- /dev/null +++ b/internal/skills/parser.go @@ -0,0 +1,346 @@ +// Package skills 实现跨书 skill 库的存储与检索。 +// +// skill 是 Markdown + frontmatter 文件,存放在 ~/.ainovel/skills//.md。 +// frontmatter 用一个极小的 YAML 子集解析(不引入外部依赖),仅支持: +// +// - key: value # scalar(字符串 / 整数) +// - key: "double quoted" # 带引号 scalar +// - key: [a, b, c] # inline 数组 +// - key: # 块数组(缩进 - 开头) +// - a +// - b +// - key: | # 块 scalar(缩进多行) +// 多行 +// 文本 +// +// 仅 frontmatter、name 与 description 必填;其它字段可省略。 +package skills + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// SkillMeta 是 skill 文件的元数据,作为索引和工具返回的基本单位。 +// 字段 JSON tag 用 snake_case,方便工具返回直接 marshalling。 +type SkillMeta struct { + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + Tags []string `json:"tags,omitempty"` + Triggers []string `json:"triggers,omitempty"` + When string `json:"when,omitempty"` + Do string `json:"do,omitempty"` + Priority int `json:"priority"` + Path string `json:"path,omitempty"` +} + +// frontmatter 分隔符。 +const fmDelim = "---" + +// ParseSkill 解析 skill 文件全文,返回元数据、正文和错误。 +// +// 行为: +// - 空文件 → 报错 +// - 无 frontmatter(不以 --- 开头)→ 返回零元数据 + 整文为 body(不报错, +// 由调用方决定是否接受) +// - frontmatter 缺闭合 --- → 报错 +// - frontmatter 缺 name 或 description → 报错 +// - priority 非整数 → 报错 +// - 字段值不符合 YAML 子集 → 报错并指明行号 +// +// category 缺省时从 path 的父目录名推断(如 ~/.ainovel/skills/genres/x.md → "genres")。 +// priority 缺省时为 50(中等优先级)。 +func ParseSkill(path, raw string) (SkillMeta, string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return SkillMeta{}, "", fmt.Errorf("empty skill content") + } + + meta := SkillMeta{Path: path, Priority: 50} + + // 无 frontmatter:零元数据 + 整文为 body + if !strings.HasPrefix(raw, fmDelim+"\n") && raw != fmDelim { + return meta, raw, nil + } + + lines := strings.Split(raw, "\n") + // 至少应有开头 --- + 内容 + 闭合 ---,少于 3 行肯定不完整 + if len(lines) < 3 { + return meta, "", fmt.Errorf("invalid frontmatter: too short") + } + + // 找闭合 --- + closeIdx := -1 + for i := 1; i < len(lines); i++ { + if strings.TrimRight(lines[i], "\r") == fmDelim { + closeIdx = i + break + } + } + if closeIdx == -1 { + return meta, "", fmt.Errorf("invalid frontmatter: missing closing %s", fmDelim) + } + + fmText := strings.Join(lines[1:closeIdx], "\n") + body := strings.TrimSpace(strings.Join(lines[closeIdx+1:], "\n")) + + if err := parseFrontmatter(&meta, fmText); err != nil { + return meta, body, fmt.Errorf("parse frontmatter: %w", err) + } + + if strings.TrimSpace(meta.Name) == "" { + return meta, body, fmt.Errorf("frontmatter: name is required") + } + if strings.TrimSpace(meta.Description) == "" { + return meta, body, fmt.Errorf("frontmatter: description is required") + } + if meta.Category == "" { + meta.Category = inferCategory(path) + } + return meta, body, nil +} + +// parseFrontmatter 把 YAML 子集文本解析到 meta。详细语法见包注释。 +func parseFrontmatter(meta *SkillMeta, text string) error { + lines := strings.Split(text, "\n") + for i := 0; i < len(lines); { + line := lines[i] + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + i++ + continue + } + + colonIdx := strings.Index(line, ":") + if colonIdx == -1 { + return fmt.Errorf("line %d: expected 'key: value', got %q", i+1, line) + } + key := strings.TrimSpace(line[:colonIdx]) + rest := strings.TrimSpace(line[colonIdx+1:]) + + switch key { + case "tags", "triggers": + items, advance, err := parseListField(lines, i, rest, key) + if err != nil { + return err + } + if key == "tags" { + meta.Tags = items + } else { + meta.Triggers = items + } + i += advance + + case "when", "do": + body, advance, err := parseScalarField(lines, i, rest, key) + if err != nil { + return err + } + if key == "when" { + meta.When = body + } else { + meta.Do = body + } + i += advance + + case "priority": + n, err := strconv.Atoi(strings.TrimSpace(rest)) + if err != nil { + return fmt.Errorf("line %d: priority must be integer, got %q", i+1, rest) + } + meta.Priority = n + i++ + + case "name", "description", "category": + val := unquote(strings.TrimSpace(rest)) + switch key { + case "name": + meta.Name = val + case "description": + meta.Description = val + case "category": + meta.Category = val + } + i++ + + default: + // 未知字段忽略(向前兼容) + i++ + } + } + return nil +} + +// parseListField 解析 tags/triggers。支持两种形式: +// +// key: [a, b, c] inline +// key: block(后续缩进 - 开头的行) +func parseListField(lines []string, i int, rest, key string) ([]string, int, error) { + if rest != "" { + items, err := parseInlineArray(rest) + if err != nil { + return nil, 0, fmt.Errorf("line %d: %s: %w", i+1, key, err) + } + return items, 1, nil + } + + items, advance := parseBlockList(lines, i+1) + return items, 1 + advance, nil +} + +// parseScalarField 解析 when/do。支持: +// +// key: "single line" 单行带引号 +// key: single line 单行裸 +// key: | 块 scalar(后续缩进行) +// key: |- 块 scalar,去除末尾换行(YAML 语义,与 | 等价处理) +func parseScalarField(lines []string, i int, rest, key string) (string, int, error) { + rest = strings.TrimSpace(rest) + if rest == "|" || rest == "|-" || rest == "|+" { + body, advance := parseBlockScalar(lines, i+1) + return body, 1 + advance, nil + } + if rest == "" { + // 空 value 视为块 scalar 容许(无内容) + body, advance := parseBlockScalar(lines, i+1) + return body, 1 + advance, nil + } + return unquote(rest), 1, nil +} + +// parseBlockList 解析块数组。返回 items 和消耗的非空行数。 +func parseBlockList(lines []string, start int) ([]string, int) { + var items []string + i := start + for i < len(lines) { + line := lines[i] + if strings.TrimSpace(line) == "" { + break + } + if countIndent(line) == 0 { + break + } + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "- ") && trimmed != "-" { + break + } + var item string + if trimmed == "-" { + item = "" + } else { + item = unquote(strings.TrimSpace(trimmed[2:])) + } + items = append(items, item) + i++ + } + return items, i - start +} + +// parseBlockScalar 解析块 scalar(|)。返回内容和消耗的非空行数。 +// 缩进保留:剥离所有行共同的最小缩进。 +func parseBlockScalar(lines []string, start int) (string, int) { + var sb strings.Builder + baseIndent := -1 + i := start + for i < len(lines) { + line := lines[i] + if strings.TrimSpace(line) == "" { + sb.WriteString("\n") + i++ + continue + } + indent := countIndent(line) + if indent == 0 { + break + } + if baseIndent == -1 { + baseIndent = indent + } + if indent < baseIndent { + break + } + sb.WriteString(line[baseIndent:]) + sb.WriteString("\n") + i++ + } + return strings.TrimRight(sb.String(), "\n"), i - start +} + +// parseInlineArray 解析 [a, b, c] 形式内联数组。 +func parseInlineArray(s string) ([]string, error) { + s = strings.TrimSpace(s) + if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") { + return nil, fmt.Errorf("array must be [a, b, c] form: %q", s) + } + inner := strings.TrimSpace(s[1 : len(s)-1]) + if inner == "" { + return nil, nil + } + parts := strings.Split(inner, ",") + items := make([]string, 0, len(parts)) + for _, p := range parts { + v := unquote(strings.TrimSpace(p)) + if v == "" { + continue + } + items = append(items, v) + } + return items, nil +} + +func countIndent(s string) int { + n := 0 + for n < len(s) && (s[n] == ' ' || s[n] == '\t') { + n++ + } + return n +} + +// unquote 去掉包围引号(双或单)。无引号原样返回。 +func unquote(s string) string { + s = strings.TrimSpace(s) + if len(s) < 2 { + return s + } + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + inner := s[1 : len(s)-1] + if s[0] == '"' { + // 双引号走 JSON unescape,支持 \"、\n 等 + var out string + if err := json.Unmarshal([]byte(s), &out); err == nil { + return out + } + } + return inner + } + return s +} + +// inferCategory 从文件路径推断分类。取父目录名;不合法时退回 "misc"。 +func inferCategory(path string) string { + parts := strings.Split(strings.ReplaceAll(path, "\\", "/"), "/") + if len(parts) < 2 { + return "misc" + } + parent := parts[len(parts)-2] + if !isValidName(parent) { + return "misc" + } + return parent +} + +// isValidName 校验 name/category 必须为 [a-z0-9-]+。 +func isValidName(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '-' { + return false + } + } + return true +} diff --git a/internal/skills/parser_test.go b/internal/skills/parser_test.go new file mode 100644 index 00000000..9d6fb349 --- /dev/null +++ b/internal/skills/parser_test.go @@ -0,0 +1,219 @@ +package skills + +import ( + "strings" + "testing" +) + +func TestParseSkill_FullFrontmatter(t *testing.T) { + raw := `--- +name: cyberpunk-noir +description: "赛博朋克 + 黑色侦探题材清单" +category: genres +tags: [sci-fi, cyberpunk, noir] +triggers: + - 赛博朋克 + - cyberpunk +when: | + 用户想写赛博朋克题材时 + 规划世界观与视觉锚点 +do: | + - 高密度都市 + - 霓虹与雨夜 +priority: 70 +--- + +# 标题 + +正文段落。 +` + meta, body, err := ParseSkill("/home/u/.ainovel/skills/genres/cyberpunk-noir.md", raw) + if err != nil { + t.Fatalf("ParseSkill error: %v", err) + } + if meta.Name != "cyberpunk-noir" { + t.Errorf("Name = %q, want %q", meta.Name, "cyberpunk-noir") + } + if meta.Description != "赛博朋克 + 黑色侦探题材清单" { + t.Errorf("Description = %q", meta.Description) + } + if meta.Category != "genres" { + t.Errorf("Category = %q", meta.Category) + } + wantTags := []string{"sci-fi", "cyberpunk", "noir"} + if len(meta.Tags) != len(wantTags) { + t.Errorf("Tags = %v, want %v", meta.Tags, wantTags) + } + wantTriggers := []string{"赛博朋克", "cyberpunk"} + if len(meta.Triggers) != len(wantTriggers) { + t.Errorf("Triggers = %v, want %v", meta.Triggers, wantTriggers) + } + if !strings.Contains(meta.When, "用户想写赛博朋克题材时") { + t.Errorf("When = %q", meta.When) + } + if !strings.Contains(meta.Do, "高密度都市") { + t.Errorf("Do = %q", meta.Do) + } + if meta.Priority != 70 { + t.Errorf("Priority = %d", meta.Priority) + } + if !strings.Contains(body, "# 标题") { + t.Errorf("body = %q", body) + } +} + +func TestParseSkill_NoFrontmatter(t *testing.T) { + raw := "# Just Markdown\n\nbody only" + meta, body, err := ParseSkill("/tmp/foo.md", raw) + if err != nil { + t.Fatalf("expected no error for no-frontmatter file, got: %v", err) + } + if meta.Name != "" { + t.Errorf("Name should be empty, got %q", meta.Name) + } + if meta.Priority != 50 { + t.Errorf("default Priority = %d, want 50", meta.Priority) + } + if body != raw { + t.Errorf("body should equal input") + } +} + +func TestParseSkill_EmptyFile(t *testing.T) { + _, _, err := ParseSkill("/tmp/x.md", "") + if err == nil { + t.Errorf("expected error for empty file") + } +} + +func TestParseSkill_MissingName(t *testing.T) { + raw := `--- +description: "no name here" +--- +body` + _, _, err := ParseSkill("/tmp/x.md", raw) + if err == nil { + t.Errorf("expected error for missing name") + } + if !strings.Contains(err.Error(), "name is required") { + t.Errorf("error should mention name, got: %v", err) + } +} + +func TestParseSkill_MissingDescription(t *testing.T) { + raw := `--- +name: foo +--- +body` + _, _, err := ParseSkill("/tmp/x.md", raw) + if err == nil { + t.Errorf("expected error for missing description") + } +} + +func TestParseSkill_MissingCloseDelim(t *testing.T) { + raw := `--- +name: foo +description: bar +body without close` + _, _, err := ParseSkill("/tmp/x.md", raw) + if err == nil { + t.Errorf("expected error for missing close delim") + } +} + +func TestParseSkill_InvalidPriority(t *testing.T) { + raw := `--- +name: foo +description: bar +priority: not-a-number +--- +body` + _, _, err := ParseSkill("/tmp/x.md", raw) + if err == nil { + t.Errorf("expected error for non-integer priority") + } +} + +func TestParseSkill_InferCategoryFromPath(t *testing.T) { + raw := `--- +name: foo +description: bar +--- +body` + meta, _, err := ParseSkill("/home/u/.ainovel/skills/genres/foo.md", raw) + if err != nil { + t.Fatalf("ParseSkill error: %v", err) + } + if meta.Category != "genres" { + t.Errorf("Category = %q, want %q (inferred from path)", meta.Category, "genres") + } +} + +func TestParseSkill_InlineArrayWithSpaces(t *testing.T) { + raw := `--- +name: foo +description: bar +tags: [a, b, "c d", "e"] +--- +body` + meta, _, err := ParseSkill("/tmp/x.md", raw) + if err != nil { + t.Fatalf("ParseSkill error: %v", err) + } + want := []string{"a", "b", "c d", "e"} + if len(meta.Tags) != len(want) { + t.Fatalf("Tags = %v, want %v", meta.Tags, want) + } + for i := range want { + if meta.Tags[i] != want[i] { + t.Errorf("Tags[%d] = %q, want %q", i, meta.Tags[i], want[i]) + } + } +} + +func TestParseSkill_QuotedDescription(t *testing.T) { + cases := []struct { + raw string + want string + }{ + {`name: foo +description: "包含: 冒号" +`, "包含: 冒号"}, + {`name: foo +description: '单引号包围' +`, "单引号包围"}, + {`name: foo +description: 裸值 +`, "裸值"}, + } + for i, c := range cases { + raw := "---\n" + c.raw + "---\nbody" + meta, _, err := ParseSkill("/tmp/x.md", raw) + if err != nil { + t.Errorf("case %d error: %v", i, err) + continue + } + if meta.Description != c.want { + t.Errorf("case %d Description = %q, want %q", i, meta.Description, c.want) + } + } +} + +func TestIsValidName(t *testing.T) { + cases := map[string]bool{ + "cyberpunk-noir": true, + "abc123": true, + "a": true, + "": false, + "Abc": false, // 大写不允许 + "abc_def": false, // 下划线不允许 + "abc.def": false, + "中文": false, + } + for input, want := range cases { + if got := isValidName(input); got != want { + t.Errorf("isValidName(%q) = %v, want %v", input, got, want) + } + } +} diff --git a/internal/skills/store.go b/internal/skills/store.go new file mode 100644 index 00000000..14de5c18 --- /dev/null +++ b/internal/skills/store.go @@ -0,0 +1,401 @@ +package skills + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// Store 管理全局 skill 库(默认 ~/.ainovel/skills//.md)。 +// +// 设计: +// - 启动时调用 Refresh() 扫描磁盘,构建内存索引(仅元数据,不含正文) +// - 写入(Add/Remove)后自动 Refresh,保证索引一致 +// - 用户外部编辑文件时,下次 Search/List 前按 mtime 懒重载 +// - 解析失败的文件被跳过(best-effort),不影响其他 skill 可用 +// +// 并发:所有读操作 RLock、写操作 Lock,工具调用安全。 +type Store struct { + root string + mu sync.RWMutex + index []SkillMeta + lastScan time.Time +} + +// NewStore 创建 store。root 为空时,所有操作返回空结果(不报错)—— +// 用于禁用 skill 库的场景(如配置错误时优雅降级)。 +func NewStore(root string) *Store { + return &Store{root: root} +} + +// Root 返回根目录路径(CLI 展示用)。 +func (s *Store) Root() string { return s.root } + +// Refresh 扫描 root 重建索引。root 不存在时自动创建空目录。 +// 解析失败的文件被跳过(不影响整体),返回的 error 仅代表结构性问题(如目录无法创建)。 +func (s *Store) Refresh() error { + if s.root == "" { + return nil + } + if err := os.MkdirAll(s.root, 0o755); err != nil { + return fmt.Errorf("create skills dir: %w", err) + } + + var index []SkillMeta + _ = filepath.WalkDir(s.root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil // 访问失败:跳过此路径但不中止 + } + if d.IsDir() || !strings.HasSuffix(path, ".md") { + return nil + } + raw, err := os.ReadFile(path) + if err != nil { + return nil + } + meta, _, err := ParseSkill(path, string(raw)) + if err != nil { + return nil // 解析失败:跳过单文件,不影响整体 + } + // 仅当 frontmatter 缺失时 meta.Name 为空——也跳过 + if meta.Name == "" { + return nil + } + index = append(index, meta) + return nil + }) + + s.mu.Lock() + s.index = index + s.lastScan = time.Now() + s.mu.Unlock() + return nil +} + +// maybeRefresh 比较 root 的 mtime 与 lastScan,变化则重载。 +// 避免每次工具调用都全扫,但保证用户手动改文件能被感知。 +// 失败静默:最坏情况是读到旧索引,下次工具调用再试。 +func (s *Store) maybeRefresh() { + if s.root == "" { + return + } + info, err := os.Stat(s.root) + if err != nil { + return + } + s.mu.RLock() + last := s.lastScan + s.mu.RUnlock() + if info.ModTime().After(last) { + _ = s.Refresh() + } +} + +// Search 按加权评分检索 skill。limit<=0 时取默认 5。 +// +// 评分规则(query 非空时): +// - tag 精确命中(不分大小写):+30 +// - trigger 部分匹配(query 包含或被包含):+25 +// - description 子串匹配:+15 +// - name 子串匹配:+10 +// - priority 仅在已命中时作微调加分:+ priority / 10 +// +// query 为空:仅按 priority 排序返回前 limit 条(用于"列概览"场景)。 +// +// 重要:query 非空时,必须 matchScore > 0 才算命中——priority 不会让不相关的 +// skill 出现在结果里。 +func (s *Store) Search(query string, limit int) []SkillMeta { + if limit <= 0 { + limit = 5 + } + s.maybeRefresh() + + s.mu.RLock() + defer s.mu.RUnlock() + + q := strings.ToLower(strings.TrimSpace(query)) + type scored struct { + meta SkillMeta + score int + } + hits := make([]scored, 0, len(s.index)) + for _, m := range s.index { + if q == "" { + score := m.Priority + if score > 0 { + hits = append(hits, scored{m, score}) + } + continue + } + matchScore := 0 + if containsCI(m.Tags, q) { + matchScore += 30 + } + for _, t := range m.Triggers { + tl := strings.ToLower(t) + if strings.Contains(tl, q) || strings.Contains(q, tl) { + matchScore += 25 + break + } + } + if strings.Contains(strings.ToLower(m.Description), q) { + matchScore += 15 + } + if strings.Contains(strings.ToLower(m.Name), q) { + matchScore += 10 + } + if matchScore > 0 { + hits = append(hits, scored{m, matchScore + m.Priority/10}) + } + } + sort.SliceStable(hits, func(i, j int) bool { + if hits[i].score != hits[j].score { + return hits[i].score > hits[j].score + } + return hits[i].meta.Name < hits[j].meta.Name + }) + if len(hits) > limit { + hits = hits[:limit] + } + out := make([]SkillMeta, 0, len(hits)) + for _, h := range hits { + out = append(out, h.meta) + } + return out +} + +// Read 返回某 skill 的完整文件内容(含 frontmatter)。 +// 不存在时返回错误(让 LLM 知道 name 错了)。 +func (s *Store) Read(name string) (string, error) { + if name == "" { + return "", fmt.Errorf("skill name is empty") + } + s.maybeRefresh() + s.mu.RLock() + path := "" + for _, m := range s.index { + if m.Name == name { + path = m.Path + break + } + } + s.mu.RUnlock() + if path == "" { + return "", fmt.Errorf("skill %q not found", name) + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read skill %s: %w", name, err) + } + return string(data), nil +} + +// Add 写入新 skill 文件,frontmatter 由 caller 提供。 +// - name 必须为 [a-z0-9-]+ +// - category 缺省 "misc",必须为 [a-z0-9-]+ +// - 同名 skill 已存在 → 报错(不覆盖用户手改) +// - 写入成功后自动 Refresh 索引 +func (s *Store) Add(meta SkillMeta, body string) error { + name := strings.TrimSpace(meta.Name) + if name == "" { + return fmt.Errorf("skill name is required") + } + if !isValidName(name) { + return fmt.Errorf("skill name must match [a-z0-9-]+, got %q", name) + } + category := strings.TrimSpace(meta.Category) + if category == "" { + category = "misc" + } + if !isValidName(category) { + return fmt.Errorf("category must match [a-z0-9-]+, got %q", category) + } + if s.root == "" { + return fmt.Errorf("skill store root is empty (disabled)") + } + + dir := filepath.Join(s.root, category) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create skill dir: %w", err) + } + path := filepath.Join(dir, name+".md") + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("skill %q already exists at %s", name, path) + } + + content := renderSkillFile(meta, body) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("write skill: %w", err) + } + return s.Refresh() +} + +// Remove 删除 skill。不存在返回错误。删除后自动 Refresh。 +func (s *Store) Remove(name string) error { + if name == "" { + return fmt.Errorf("skill name is empty") + } + s.maybeRefresh() + s.mu.RLock() + path := "" + for _, m := range s.index { + if m.Name == name { + path = m.Path + break + } + } + s.mu.RUnlock() + if path == "" { + return fmt.Errorf("skill %q not found", name) + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove skill %s: %w", name, err) + } + return s.Refresh() +} + +// List 返回所有 skill 元数据。category 非空时按分类过滤。 +// 返回结果按 name 升序,便于 CLI 列表展示稳定。 +func (s *Store) List(category string) []SkillMeta { + s.maybeRefresh() + s.mu.RLock() + defer s.mu.RUnlock() + + out := make([]SkillMeta, 0, len(s.index)) + for _, m := range s.index { + if category != "" && m.Category != category { + continue + } + out = append(out, m) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Category != out[j].Category { + return out[i].Category < out[j].Category + } + return out[i].Name < out[j].Name + }) + return out +} + +// Categories 返回所有现存分类(去重、升序)。CLI 列分类用。 +func (s *Store) Categories() []string { + s.maybeRefresh() + s.mu.RLock() + defer s.mu.RUnlock() + seen := map[string]struct{}{} + for _, m := range s.index { + if m.Category == "" { + continue + } + seen[m.Category] = struct{}{} + } + out := make([]string, 0, len(seen)) + for c := range seen { + out = append(out, c) + } + sort.Strings(out) + return out +} + +// renderSkillFile 把 meta + body 序列化为 .md 文件全文(frontmatter + body)。 +// 写出的 frontmatter 严格符合 ParseSkill 的 YAML 子集,保证 round-trip。 +func renderSkillFile(meta SkillMeta, body string) string { + var sb strings.Builder + sb.WriteString("---\n") + sb.WriteString("name: " + meta.Name + "\n") + sb.WriteString("description: " + yamlScalar(meta.Description) + "\n") + if meta.Category != "" { + sb.WriteString("category: " + meta.Category + "\n") + } + if len(meta.Tags) > 0 { + sb.WriteString("tags: [" + strings.Join(quoteItems(meta.Tags), ", ") + "]\n") + } + if len(meta.Triggers) > 0 { + sb.WriteString("triggers: [" + strings.Join(quoteItems(meta.Triggers), ", ") + "]\n") + } + if strings.TrimSpace(meta.When) != "" { + sb.WriteString("when: |\n") + writeBlockScalar(&sb, meta.When) + } + if strings.TrimSpace(meta.Do) != "" { + sb.WriteString("do: |\n") + writeBlockScalar(&sb, meta.Do) + } + if meta.Priority > 0 && meta.Priority != 50 { + sb.WriteString(fmt.Sprintf("priority: %d\n", meta.Priority)) + } + sb.WriteString("---\n\n") + sb.WriteString(strings.TrimSpace(body)) + sb.WriteString("\n") + return sb.String() +} + +// helpers ------------------------------------------------------------------- + +// containsCI 不区分大小写判断 slice 是否包含 s。 +func containsCI(slice []string, s string) bool { + for _, x := range slice { + if strings.EqualFold(x, s) { + return true + } + } + return false +} + +// yamlScalar 把字符串编码为 YAML scalar。 +// 含特殊字符(:[]#*!|>'"%@`{} 等)或为空时用双引号 + JSON 转义;否则裸用。 +func yamlScalar(s string) string { + if s == "" { + return `""` + } + if needsYAMLQuote(s) { + b, _ := json.Marshal(s) + return string(b) + } + return s +} + +// needsYAMLQuote 判断字符串是否含 YAML 中有结构含义的字符, +// 含则需用引号包围以免被解析器误解。拆成多次 ContainsAny 是为了 +// 避免在双引号字符串字面量里同时转义 " ` { } 引起的视觉混乱。 +func needsYAMLQuote(s string) bool { + if strings.ContainsAny(s, ":[]#*,!|>'\"%@&") { + return true + } + if strings.ContainsAny(s, "{}") { + return true + } + if strings.ContainsRune(s, '`') { + return true + } + if strings.ContainsRune(s, '#') { + return true + } + if strings.ContainsRune(s, '*') { + return true + } + return false +} + +func quoteItems(items []string) []string { + out := make([]string, len(items)) + for i, s := range items { + out[i] = yamlScalar(s) + } + return out +} + +func writeBlockScalar(sb *strings.Builder, text string) { + for _, line := range strings.Split(text, "\n") { + if line == "" { + sb.WriteString("\n") + continue + } + sb.WriteString(" " + line + "\n") + } +} diff --git a/internal/skills/store_test.go b/internal/skills/store_test.go new file mode 100644 index 00000000..53106e5e --- /dev/null +++ b/internal/skills/store_test.go @@ -0,0 +1,282 @@ +package skills + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// helper:在 path 写一个最小合法 skill 文件 +func writeSkillFile(t *testing.T, path, name, desc, category string, tags []string) { + t.Helper() + content := "---\nname: " + name + "\ndescription: " + desc + "\ncategory: " + category + "\n" + if len(tags) > 0 { + content += "tags: [" + for i, tag := range tags { + if i > 0 { + content += ", " + } + content += tag + } + content += "]\n" + } + content += "priority: 50\n---\n\nbody\n" + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } +} + +func TestStore_RefreshAndList(t *testing.T) { + root := t.TempDir() + writeSkillFile(t, filepath.Join(root, "genres", "a.md"), "a", "alpha", "genres", []string{"x"}) + writeSkillFile(t, filepath.Join(root, "genres", "b.md"), "b", "beta", "genres", []string{"y"}) + writeSkillFile(t, filepath.Join(root, "styles", "c.md"), "c", "gamma", "styles", nil) + + s := NewStore(root) + if err := s.Refresh(); err != nil { + t.Fatalf("Refresh: %v", err) + } + all := s.List("") + if len(all) != 3 { + t.Errorf("List = %d items, want 3", len(all)) + } + genres := s.List("genres") + if len(genres) != 2 { + t.Errorf("List(genres) = %d, want 2", len(genres)) + } + cats := s.Categories() + if len(cats) != 2 { + t.Errorf("Categories = %v", cats) + } +} + +func TestStore_SearchTagHit(t *testing.T) { + root := t.TempDir() + writeSkillFile(t, filepath.Join(root, "genres", "cyber.md"), "cyberpunk", "赛博朋克题材", "genres", []string{"cyberpunk", "sci-fi"}) + writeSkillFile(t, filepath.Join(root, "genres", "wuxia.md"), "wuxia", "武侠题材", "genres", []string{"wuxia", "ancient"}) + + s := NewStore(root) + _ = s.Refresh() + + hits := s.Search("cyberpunk", 5) + if len(hits) != 1 { + t.Fatalf("Search = %d hits, want 1", len(hits)) + } + if hits[0].Name != "cyberpunk" { + t.Errorf("Search[0].Name = %q, want cyberpunk", hits[0].Name) + } +} + +func TestStore_SearchTriggerHit(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "genres", "x.md") + content := "---\nname: x\ndescription: foo\ncategory: genres\ntriggers:\n - 赛博朋克\n - 仿生人\npriority: 50\n---\nbody\n" + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + s := NewStore(root) + _ = s.Refresh() + + hits := s.Search("我想写赛博朋克", 5) + if len(hits) == 0 { + t.Errorf("expected trigger hit, got 0") + } +} + +func TestStore_SearchPriorityTieBreaker(t *testing.T) { + root := t.TempDir() + // 两个 skill 都有 tag "x",priority 高的应排前 + p1 := filepath.Join(root, "g", "a.md") + c1 := "---\nname: a\ndescription: alpha\ncategory: g\ntags: [x]\npriority: 80\n---\nbody\n" + p2 := filepath.Join(root, "g", "b.md") + c2 := "---\nname: b\ndescription: beta\ncategory: g\ntags: [x]\npriority: 60\n---\nbody\n" + os.MkdirAll(filepath.Dir(p1), 0o755) + os.WriteFile(p1, []byte(c1), 0o644) + os.WriteFile(p2, []byte(c2), 0o644) + + s := NewStore(root) + _ = s.Refresh() + + hits := s.Search("x", 5) + if len(hits) != 2 { + t.Fatalf("Search = %d, want 2", len(hits)) + } + if hits[0].Name != "a" { + t.Errorf("Search[0].Name = %q, want a (higher priority)", hits[0].Name) + } +} + +func TestStore_Read(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "g", "foo.md") + content := "---\nname: foo\ndescription: bar\ncategory: g\n---\n\n# Title\nbody\n" + os.MkdirAll(filepath.Dir(path), 0o755) + os.WriteFile(path, []byte(content), 0o644) + + s := NewStore(root) + _ = s.Refresh() + + body, err := s.Read("foo") + if err != nil { + t.Fatalf("Read: %v", err) + } + if body != content { + t.Errorf("Read returned %q, want %q", body, content) + } + + if _, err := s.Read("nonexistent"); err == nil { + t.Errorf("Read nonexistent should error") + } +} + +func TestStore_AddAndRemove(t *testing.T) { + root := t.TempDir() + s := NewStore(root) + _ = s.Refresh() + + meta := SkillMeta{ + Name: "new-skill", + Description: "新增 skill", + Category: "genres", + Tags: []string{"new"}, + } + if err := s.Add(meta, "# 新增\nbody"); err != nil { + t.Fatalf("Add: %v", err) + } + + // Add 后立即可 Search + hits := s.Search("new", 5) + if len(hits) != 1 || hits[0].Name != "new-skill" { + t.Fatalf("Search after Add = %v, want [new-skill]", hits) + } + + // 文件确实落盘 + path := filepath.Join(root, "genres", "new-skill.md") + if _, err := os.Stat(path); err != nil { + t.Errorf("file not created: %v", err) + } + + // 重复 Add 报错 + if err := s.Add(meta, "body"); err == nil { + t.Errorf("Add duplicate should error") + } + + // Remove + if err := s.Remove("new-skill"); err != nil { + t.Fatalf("Remove: %v", err) + } + hits = s.Search("new", 5) + if len(hits) != 0 { + t.Errorf("Search after Remove = %d, want 0", len(hits)) + } + + // Remove 不存在报错 + if err := s.Remove("nonexistent"); err == nil { + t.Errorf("Remove nonexistent should error") + } +} + +func TestStore_AddInvalidName(t *testing.T) { + root := t.TempDir() + s := NewStore(root) + _ = s.Refresh() + + cases := []string{"", "UPPER", "under_score", "中文", "dot.dot"} + for _, name := range cases { + meta := SkillMeta{Name: name, Description: "x", Category: "g"} + if err := s.Add(meta, "body"); err == nil { + t.Errorf("Add with name %q should error", name) + } + } +} + +func TestStore_LazyReloadOnMtimeChange(t *testing.T) { + root := t.TempDir() + s := NewStore(root) + _ = s.Refresh() + + // 初始空 + if hits := s.List(""); len(hits) != 0 { + t.Fatalf("expected empty, got %d", len(hits)) + } + + // 写入新文件 + path := filepath.Join(root, "g", "x.md") + os.MkdirAll(filepath.Dir(path), 0o755) + content := "---\nname: x\ndescription: y\ncategory: g\n---\nbody\n" + os.WriteFile(path, []byte(content), 0o644) + + // 推进 root 目录 mtime(maybeRefresh 检测的是 root 目录的 mtime, + // 不是单个文件的 mtime——文件创建时父目录 mtime 通常会更新,但保险起见显式 Chtimes) + newMtime := time.Now().Add(2 * time.Second) + if err := os.Chtimes(root, newMtime, newMtime); err != nil { + t.Logf("Chtimes: %v (可能不受支持,跳过)", err) + } + + // 再 List 应触发懒重载 + hits := s.List("") + if len(hits) != 1 { + t.Errorf("after mtime change, List = %d, want 1", len(hits)) + } +} + +func TestStore_EmptyRoot(t *testing.T) { + s := NewStore("") // 禁用 + if err := s.Refresh(); err != nil { + t.Errorf("Refresh on empty root should not error, got %v", err) + } + if hits := s.Search("x", 5); len(hits) != 0 { + t.Errorf("Search on empty root = %d, want 0", len(hits)) + } + if cats := s.Categories(); len(cats) != 0 { + t.Errorf("Categories on empty root = %v", cats) + } +} + +func TestRenderSkillFile_RoundTrip(t *testing.T) { + meta := SkillMeta{ + Name: "round-trip", + Description: "包含: 冒号的描述", + Category: "genres", + Tags: []string{"a", "b c"}, + When: "场景\n多行", + Do: "动作 1\n动作 2", + Priority: 80, + } + body := "# 标题\n\n正文段落\n" + content := renderSkillFile(meta, body) + + // 解析回来应一致 + parsed, parsedBody, err := ParseSkill("/tmp/x.md", content) + if err != nil { + t.Fatalf("round-trip ParseSkill: %v\ncontent:\n%s", err, content) + } + if parsed.Name != meta.Name { + t.Errorf("Name %q != %q", parsed.Name, meta.Name) + } + if parsed.Description != meta.Description { + t.Errorf("Description %q != %q", parsed.Description, meta.Description) + } + if parsed.Category != meta.Category { + t.Errorf("Category %q != %q", parsed.Category, meta.Category) + } + if len(parsed.Tags) != len(meta.Tags) { + t.Errorf("Tags len %d != %d", len(parsed.Tags), len(meta.Tags)) + } + if parsed.Priority != meta.Priority { + t.Errorf("Priority %d != %d", parsed.Priority, meta.Priority) + } + // ParseSkill 对 body 做 TrimSpace,所以末尾换行不保留——比较时去边白 + if strings.TrimSpace(parsedBody) != strings.TrimSpace(body) { + t.Errorf("body round-trip mismatch:\n got: %q\nwant: %q", parsedBody, body) + } +} diff --git a/internal/tools/skill_add.go b/internal/tools/skill_add.go new file mode 100644 index 00000000..afbcb21d --- /dev/null +++ b/internal/tools/skill_add.go @@ -0,0 +1,114 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/internal/errs" + "github.com/voocel/ainovel-cli/internal/skills" +) + +// SkillAddTool 把可复用经验沉淀进跨书 skill 库。仅 coordinator 持有, +// 在 book_complete 后的"完结学习"环节调用。 +// +// 设计动机:让 LLM 主动把本书创作中形成的可复用经验(新题材套路、解决过的结构 +// 难题、效果好的开篇模板等)落盘到 ~/.ainovel/skills//.md, +// 后续项目可用 search_skills 检索到——形成跨书学习闭环。 +// +// 限制:name 必须为 [a-z0-9-]+(避免文件系统/路径注入);同名 skill 已存在时报错, +// 不会覆盖用户手改。 +type SkillAddTool struct { + store *skills.Store +} + +func NewSkillAddTool(store *skills.Store) *SkillAddTool { + return &SkillAddTool{store: store} +} + +func (t *SkillAddTool) Name() string { return "add_skill" } +func (t *SkillAddTool) Label() string { return "沉淀 skill 到跨书库" } + +func (t *SkillAddTool) Description() string { + return "把本书创作中形成的可复用经验沉淀为 skill 文件,写入 ~/.ainovel/skills//.md,跨书共享。" + + "仅在全书完结后(book_complete=true)调用一次,且只提炼真正可复用的经验——" + + "新题材套路、结构解决方案、有效的开篇/伏笔模板等。不要把本书独有设定当通用套路。" + + "name 用 kebab-case 英文(如 cyberpunk-noir-checklist),description 一句话写用途," + + "body 是 Markdown 正文,建议用 when/do 结构。" + + "不强制每次都加;没有可提炼的就跳过本工具。" +} + +// 写工具,禁止并发。 +func (t *SkillAddTool) ReadOnly(_ json.RawMessage) bool { return false } +func (t *SkillAddTool) ConcurrencySafe(_ json.RawMessage) bool { return false } + +func (t *SkillAddTool) ActivityDescription(_ json.RawMessage) string { + return "沉淀 skill 到跨书库" +} + +func (t *SkillAddTool) Schema() map[string]any { + return schema.Object( + schema.Property("name", schema.String("skill 唯一名,kebab-case 英文([a-z0-9-]+),如 cyberpunk-noir-checklist")).Required(), + schema.Property("description", schema.String("一句话用途说明,方便后续检索;中英文均可")).Required(), + schema.Property("category", schema.String("分类,建议从 genres/structures/styles/tropes/processes 中选;缺省 misc")).Required(), + schema.Property("body", schema.String("skill 正文,Markdown 格式;建议用 ## when / ## do / ## checklist 等小节")).Required(), + schema.Property("tags", schema.Array("标签数组(精确匹配加分)", schema.String("标签,建议英文短词"))), + schema.Property("triggers", schema.Array("触发词数组(用户输入命中时优先级提升)", schema.String("触发词,建议中英文都列"))), + schema.Property("priority", schema.Int("优先级 0-100,默认 50;高频 skill 可调高")), + ) +} + +func (t *SkillAddTool) Execute(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a struct { + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + Body string `json:"body"` + Tags []string `json:"tags"` + Triggers []string `json:"triggers"` + Priority int `json:"priority"` + } + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("invalid args: %w: %w", errs.ErrToolArgs, err) + } + name := strings.TrimSpace(a.Name) + if name == "" { + return nil, fmt.Errorf("name 不能为空: %w", errs.ErrToolArgs) + } + if strings.TrimSpace(a.Description) == "" { + return nil, fmt.Errorf("description 不能为空: %w", errs.ErrToolArgs) + } + if strings.TrimSpace(a.Body) == "" { + return nil, fmt.Errorf("body 不能为空: %w", errs.ErrToolArgs) + } + if t.store == nil { + return nil, fmt.Errorf("本地 skill 库未启用") + } + if a.Priority == 0 { + a.Priority = 50 + } + + meta := skills.SkillMeta{ + Name: name, + Description: strings.TrimSpace(a.Description), + Category: strings.TrimSpace(a.Category), + Tags: a.Tags, + Triggers: a.Triggers, + Priority: a.Priority, + } + if err := t.store.Add(meta, a.Body); err != nil { + return nil, fmt.Errorf("add skill: %w", err) + } + + // 读回完整路径返回给 LLM + path := t.store.Root() + "/" + meta.Category + "/" + meta.Name + ".md" + return json.Marshal(map[string]any{ + "saved": true, + "name": meta.Name, + "category": meta.Category, + "path": path, + "hint": "skill 已落盘到跨书库。后续项目调 search_skills 即可命中。", + }) +} diff --git a/internal/tools/skill_read.go b/internal/tools/skill_read.go new file mode 100644 index 00000000..5c38784e --- /dev/null +++ b/internal/tools/skill_read.go @@ -0,0 +1,69 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/internal/errs" + "github.com/voocel/ainovel-cli/internal/skills" +) + +// SkillReadTool 加载某 skill 的完整 Markdown 内容(含 frontmatter)。 +// 配合 search_skills:先检索拿到 name,再读全文获取详细套路/清单。 +type SkillReadTool struct { + store *skills.Store +} + +func NewSkillReadTool(store *skills.Store) *SkillReadTool { + return &SkillReadTool{store: store} +} + +func (t *SkillReadTool) Name() string { return "read_skill" } +func (t *SkillReadTool) Label() string { return "读取 skill 全文" } + +func (t *SkillReadTool) Description() string { + return "读取本地 skill 库中某 skill 的完整 Markdown 内容(含 frontmatter 元数据)。" + + "先用 search_skills 检索拿到 name,再调本工具读全文。" + + "返回 {name, content, length};name 不存在时返回错误。" +} + +func (t *SkillReadTool) ReadOnly(_ json.RawMessage) bool { return true } +func (t *SkillReadTool) ConcurrencySafe(_ json.RawMessage) bool { return true } + +func (t *SkillReadTool) ActivityDescription(_ json.RawMessage) string { + return "读取 skill 全文" +} + +func (t *SkillReadTool) Schema() map[string]any { + return schema.Object( + schema.Property("name", schema.String("skill 名称(来自 search_skills 返回的 name 字段)")).Required(), + ) +} + +func (t *SkillReadTool) Execute(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a struct { + Name string `json:"name"` + } + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("invalid args: %w: %w", errs.ErrToolArgs, err) + } + name := strings.TrimSpace(a.Name) + if name == "" { + return nil, fmt.Errorf("name 不能为空: %w", errs.ErrToolArgs) + } + if t.store == nil { + return nil, fmt.Errorf("本地 skill 库未启用") + } + content, err := t.store.Read(name) + if err != nil { + return nil, fmt.Errorf("read skill %s: %w", name, err) + } + return json.Marshal(map[string]any{ + "name": name, + "content": content, + "length": len([]rune(content)), + }) +} diff --git a/internal/tools/skill_search.go b/internal/tools/skill_search.go new file mode 100644 index 00000000..a45ef5a6 --- /dev/null +++ b/internal/tools/skill_search.go @@ -0,0 +1,109 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/internal/errs" + "github.com/voocel/ainovel-cli/internal/skills" +) + +// SkillSearchTool 让 architect/coordinator 在本地跨书 skill 库(~/.ainovel/skills/) +// 中按关键词检索可复用经验(题材套路、结构模板、风格预设、流派常识等)。 +// +// 与 web_search 的关系:本工具是"本地优先"层——零成本、零延迟、可复用。 +// 命中后再用 skill_read 读全文;未命中(count=0)再走 web_search 联网搜索。 +type SkillSearchTool struct { + store *skills.Store +} + +func NewSkillSearchTool(store *skills.Store) *SkillSearchTool { + return &SkillSearchTool{store: store} +} + +func (t *SkillSearchTool) Name() string { return "search_skills" } +func (t *SkillSearchTool) Label() string { return "检索本地 skill 库" } + +func (t *SkillSearchTool) Description() string { + return "在本地跨书 skill 库(~/.ainovel/skills/)中按关键词检索可复用经验——" + + "题材套路、结构模板、风格预设、流派常识、创作流程清单等。" + + "返回 top-N 元数据(name/description/category/tags/priority),命中后再调 read_skill 读全文。" + + "零联网成本、零延迟;未命中(count=0)时再走 web_search。" + + "典型场景:用户提到某流派(赛博朋克、武侠、克苏鲁...)或结构(三幕剧、双线叙事...)时先调本工具查有没有相关 skill。" +} + +// 纯读,并发安全。 +func (t *SkillSearchTool) ReadOnly(_ json.RawMessage) bool { return true } +func (t *SkillSearchTool) ConcurrencySafe(_ json.RawMessage) bool { return true } + +func (t *SkillSearchTool) ActivityDescription(_ json.RawMessage) string { + return "检索本地 skill 库" +} + +func (t *SkillSearchTool) Schema() map[string]any { + return schema.Object( + schema.Property("query", schema.String("检索关键词,流派名/结构名/风格特征等(中英文均可)")).Required(), + schema.Property("category", schema.String("可选:限定分类(如 genres/structures/styles/tropes/processes)")), + schema.Property("limit", schema.Int("返回上限,默认 5,最大 20")), + ) +} + +func (t *SkillSearchTool) Execute(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a struct { + Query string `json:"query"` + Category string `json:"category"` + Limit int `json:"limit"` + } + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("invalid args: %w: %w", errs.ErrToolArgs, err) + } + q := strings.TrimSpace(a.Query) + if q == "" { + return nil, fmt.Errorf("query 不能为空: %w", errs.ErrToolArgs) + } + if t.store == nil { + // store 未配置(如 ~/.ainovel 路径解析失败)→ 返回空结果而非错误, + // 让 LLM 知道本工具不可用、走 web_search fallback。 + return json.Marshal(map[string]any{ + "query": q, + "results": []any{}, + "count": 0, + "hint": "本地 skill 库未启用(路径解析失败或禁用)。请走 web_search 联网搜索。", + }) + } + if a.Limit <= 0 { + a.Limit = 5 + } + if a.Limit > 20 { + a.Limit = 20 + } + + metas := t.store.Search(q, a.Limit) + results := make([]map[string]any, 0, len(metas)) + for _, m := range metas { + if a.Category != "" && m.Category != a.Category { + continue + } + results = append(results, map[string]any{ + "name": m.Name, + "description": m.Description, + "category": m.Category, + "tags": m.Tags, + "triggers": m.Triggers, + "priority": m.Priority, + }) + } + + out := map[string]any{ + "query": q, + "results": results, + "count": len(results), + } + if len(results) == 0 { + out["hint"] = "本地 skill 库未命中。可改走 web_search 联网搜索,或基于已有知识工作。" + } + return json.Marshal(out) +} From d34b6b30122e50f7560ab354acbf7c3049d74930 Mon Sep 17 00:00:00 2001 From: ciallo Date: Mon, 6 Jul 2026 13:05:24 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20materials=20?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E5=BA=93=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/domain/materials.go: 素材条目领域模型 - internal/host/materials.go: 素材收集流程与候选筛选 - internal/store/materials*.go: 项目级素材库持久化(meta/materials.json)+ 测试 - internal/tools/save_materials.go / list_materials.go / remove_material.go: LLM 工具 - TUI materials.go: 素材收集 modal 状态机 --- internal/domain/materials.go | 37 +++ internal/entry/tui/materials.go | 386 ++++++++++++++++++++++++++++++ internal/host/materials.go | 179 ++++++++++++++ internal/store/materials.go | 152 ++++++++++++ internal/store/materials_test.go | 132 ++++++++++ internal/tools/list_materials.go | 105 ++++++++ internal/tools/remove_material.go | 59 +++++ internal/tools/save_materials.go | 113 +++++++++ 8 files changed, 1163 insertions(+) create mode 100644 internal/domain/materials.go create mode 100644 internal/entry/tui/materials.go create mode 100644 internal/host/materials.go create mode 100644 internal/store/materials.go create mode 100644 internal/store/materials_test.go create mode 100644 internal/tools/list_materials.go create mode 100644 internal/tools/remove_material.go create mode 100644 internal/tools/save_materials.go diff --git a/internal/domain/materials.go b/internal/domain/materials.go new file mode 100644 index 00000000..47289624 --- /dev/null +++ b/internal/domain/materials.go @@ -0,0 +1,37 @@ +package domain + +import "time" + +// 素材分类约定。LLM 写入时按下列值归类,便于 novel_context 按 category 过滤; +// 不强制枚举,自定义分类也能存(add_materials 不校验 category 取值)。 +const ( + MaterialCategoryNaming = "naming" // 命名表:人名/地名/组织名/技能名 + MaterialCategoryTerminology = "terminology" // 术语表:流派/技术/官职/职业 + MaterialCategoryVisual = "visual" // 视觉锚点:场景/服装/道具/色彩 + MaterialCategorySetting = "setting" // 设定资料:力量体系/历史/地理/经济 + MaterialCategoryReference = "reference" // 参考资料:流派套路/同类作品/真实事件 +) + +// MaterialItem 单条素材。 +// +// 字段语义: +// - ID:稳定标识,store.Add 时自动分配(mat-001/002/...) +// - Category:见 MaterialCategory* 常量;空值按 "reference" 处理 +// - Title:一句话标题("赛博朋克巨型企业命名候选"),便于检索与展示 +// - Content:素材正文,Markdown;novel_context 注入时原样返回 +// - Source:来源标记("web_search:query=xxx" / "skill:" / "builtin"), +// 便于追溯与未来去重 +// - AddedAt:首次写入时间,方便排序与回看 +type MaterialItem struct { + ID string `json:"id"` + Category string `json:"category"` + Title string `json:"title"` + Content string `json:"content"` + Source string `json:"source,omitempty"` + AddedAt time.Time `json:"added_at"` +} + +// MaterialLibrary 整本小说的素材集合。一本书一份,存于 meta/materials.json。 +type MaterialLibrary struct { + Items []MaterialItem `json:"items"` +} diff --git a/internal/entry/tui/materials.go b/internal/entry/tui/materials.go new file mode 100644 index 00000000..8a4ed0c2 --- /dev/null +++ b/internal/entry/tui/materials.go @@ -0,0 +1,386 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/viewport" + "github.com/charmbracelet/lipgloss" + tea "github.com/charmbracelet/bubbletea" + "github.com/voocel/ainovel-cli/internal/host" +) + +// materialsPhase 标识素材收集 modal 的状态。 +// +// 流转:collecting → selecting →(用户确认后)落盘退出 / (Esc)取消退出。 +// collecting 阶段调 host.MaterialsCollect 异步收集;selecting 阶段渲染候选列表 +// 让用户勾选;done 阶段是瞬态,渲染"保存了 N 条"提示后退出。 +type materialsPhase int + +const ( + materialsPhaseCollecting materialsPhase = iota + materialsPhaseSelecting + materialsPhaseDone +) + +type materialsState struct { + phase materialsPhase + userPrompt string + + candidates []host.MaterialsCandidate + selected []bool // 与 candidates 对齐;true 表示用户勾选 + cursor int + + thinkingText string // 流式 thinking 文本(debug 用,折叠展示) + streamText string // 流式 reply 文本(候选 JSON 原文,渲染前用于 debug) + rawText string // 完整 LLM 输出 + + err error + viewport viewport.Model +} + +func newMaterialsState(userPrompt string) *materialsState { + vp := viewport.New(60, 12) + vp.SetContent("") + return &materialsState{ + phase: materialsPhaseCollecting, + userPrompt: userPrompt, + viewport: vp, + } +} + +// applyCollect 把候选填充到 state,转到 selecting 阶段。 +func (s *materialsState) applyCollect(candidates []host.MaterialsCandidate, raw string) { + s.candidates = candidates + s.rawText = raw + s.selected = make([]bool, len(candidates)) + // 默认全选——用户主要意图是"挑掉不要的",全选让 Space 反选成为默认操作 + for i := range s.selected { + s.selected[i] = true + } + if s.cursor >= len(candidates) { + s.cursor = 0 + } + s.phase = materialsPhaseSelecting +} + +func (s *materialsState) refreshViewport() { + // viewport 仅用于 thinking preview 流式;selecting 阶段直接整段渲染 +} + +// toggleCurrent 切换当前光标条目的选中状态。 +func (s *materialsState) toggleCurrent() { + if s.cursor < 0 || s.cursor >= len(s.selected) { + return + } + s.selected[s.cursor] = !s.selected[s.cursor] +} + +// selectAll / unselectAll 一键切换。LLM 偶尔会产出多条无关候选, +// 给用户一个快速"全清空再单选"的入口。 +func (s *materialsState) selectAll(yes bool) { + for i := range s.selected { + s.selected[i] = yes + } +} + +func (s *materialsState) moveCursor(delta int) { + if len(s.candidates) == 0 { + return + } + s.cursor = (s.cursor + delta + len(s.candidates)) % len(s.candidates) +} + +func (s *materialsState) approved() []host.MaterialsCandidate { + out := make([]host.MaterialsCandidate, 0, len(s.candidates)) + for i, c := range s.candidates { + if i < len(s.selected) && s.selected[i] { + out = append(out, c) + } + } + return out +} + +// ── 渲染 ── + +func materialsModalSize(width, height int) (boxW, boxH int) { + boxW = width - 4 + if boxW > 92 { + boxW = 92 + } + if boxW < 60 { + boxW = 60 + } + boxH = height - 4 + if boxH > 28 { + boxH = 28 + } + if boxH < 14 { + boxH = 14 + } + return +} + +// renderMaterialsModal 渲染整个素材收集 modal。 +// +// collecting:展示 spinner + 思考过程预览(让用户知道 LLM 在干活)。 +// selecting:展示候选列表 + 操作提示。 +// done:短暂展示"已保存 N 条",调用方应随后清空 state。 +func renderMaterialsModal(width, height int, state *materialsState, spinnerFrame string) string { + if state == nil { + return "" + } + boxW, boxH := materialsModalSize(width, height) + contentW := paddedModalContentWidth(boxW) + if contentW < 24 { + contentW = 24 + } + + var body string + switch state.phase { + case materialsPhaseCollecting: + body = renderMaterialsCollecting(contentW, boxH-4, state, spinnerFrame) + case materialsPhaseSelecting: + body = renderMaterialsSelecting(contentW, boxH-4, state) + case materialsPhaseDone: + body = renderMaterialsDone(contentW, state) + } + + title := "素材收集" + hint := "" + switch state.phase { + case materialsPhaseCollecting: + hint = "Esc 取消" + case materialsPhaseSelecting: + hint = "↑↓ 移动 · Space 切换 · a 全选 · n 全不选 · Enter 保存 · Esc 取消" + case materialsPhaseDone: + hint = "Enter 关闭" + } + return renderPaddedModalFrame(boxW, boxH, title, hint, splitBodyLines(body, contentW)) +} + +func renderMaterialsCollecting(width, height int, state *materialsState, spinnerFrame string) string { + headerStyle := lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + mutedStyle := lipgloss.NewStyle().Foreground(colorMuted) + dimStyle := lipgloss.NewStyle().Foreground(colorDim) + + header := headerStyle.Render(spinnerFrame + " 正在收集素材…") + prompt := "需求:" + truncateWidth(state.userPrompt, width-8) + promptLine := mutedStyle.Render(prompt) + + // 思考预览:截尾,最多 height-4 行让 footer hint 不被挤掉 + preview := strings.TrimSpace(state.thinkingText) + if preview == "" { + preview = "(等待模型响应…)" + } + previewLines := strings.Split(preview, "\n") + maxLines := height - 4 + if maxLines < 2 { + maxLines = 2 + } + if len(previewLines) > maxLines { + previewLines = append(previewLines[:maxLines-1], "…(截断)") + } + previewBlock := dimStyle.Render(strings.Join(previewLines, "\n")) + + lines := []string{ + header, + promptLine, + "", + previewBlock, + } + return strings.Join(lines, "\n") +} + +func renderMaterialsSelecting(width, height int, state *materialsState) string { + if len(state.candidates) == 0 { + errStyle := lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + return errStyle.Render("(LLM 未产出任何候选,按 Esc 退出重试)") + } + + headerStyle := lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + mutedStyle := lipgloss.NewStyle().Foreground(colorMuted) + catStyle := lipgloss.NewStyle().Foreground(colorAccent2) + selStyle := lipgloss.NewStyle().Foreground(colorSuccess).Bold(true) + dimSelStyle := lipgloss.NewStyle().Foreground(colorDim) + titleStyle := lipgloss.NewStyle().Foreground(bodyTextColor).Bold(true) + contentStyle := lipgloss.NewStyle().Foreground(bodyTextColor) + + header := headerStyle.Render(fmt.Sprintf("LLM 给出 %d 条候选(默认全选,Space 反选不要的):", len(state.candidates))) + + lines := []string{header, ""} + for i, c := range state.candidates { + isCur := i == state.cursor + isSel := i < len(state.selected) && state.selected[i] + + // 选中标记:✓ / · ;光标用 › + var mark string + if isSel { + mark = selStyle.Render("[✓]") + } else { + mark = dimSelStyle.Render("[ ]") + } + cursor := " " + if isCur { + cursor = "› " + } + + cat := strings.TrimSpace(c.Category) + if cat == "" { + cat = "misc" + } + catTag := catStyle.Render("[" + cat + "]") + title := titleStyle.Render(strings.TrimSpace(c.Title)) + + // 第一行:› [✓] [naming] 赛博朋克企业名候选 + line1 := cursor + mark + " " + catTag + " " + title + + // 第二行(content 预览,缩进 + 截断) + contentPreview := truncateWidth(strings.ReplaceAll(strings.TrimSpace(c.Content), "\n", " "), width-6) + line2 := " " + contentStyle.Render(contentPreview) + + lines = append(lines, line1, line2) + if i < len(state.candidates)-1 { + lines = append(lines, mutedStyle.Render(strings.Repeat("─", width-4))) + } + } + + // 截断到 height 行,保留头部 + if len(lines) > height { + lines = append(lines[:height-1], mutedStyle.Render("…(更多候选用 ↑↓ 浏览)")) + } + return strings.Join(lines, "\n") +} + +func renderMaterialsDone(width int, state *materialsState) string { + okStyle := lipgloss.NewStyle().Foreground(colorSuccess).Bold(true) + count := 0 + for _, v := range state.selected { + if v { + count++ + } + } + return okStyle.Render(fmt.Sprintf("✓ 已保存 %d 条素材到 meta/materials.json", count)) +} + +// splitBodyLines 把多行字符串拆成 []string,方便 renderPaddedModalFrame 处理。 +// 保留空行(modal frame 会按 contentW 对齐补空格)。 +func splitBodyLines(body string, contentW int) []string { + if body == "" { + return nil + } + return strings.Split(body, "\n") +} + +// ── 键盘交互 ── + +// handleMaterialsKey 处理素材 modal 内的按键。返回 (model, cmd); +// 由 handleBlockingModalKey 统一包装成 (model, cmd, handled=true)。 +// 所有 modal 内按键都被视为"已消费"——escape 取消、enter 保存、其他键忽略—— +// 避免落到 textarea 改动底层输入。 +func (m Model) handleMaterialsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.materials == nil { + return m, nil + } + + state := m.materials + + switch msg.Type { + case tea.KeyEsc: + // collecting 阶段取消会泄漏后台 goroutine,但 ctx cancel 会让 LLM 流尽快返回; + // 简化处理:直接关闭 modal,host 端的 stream 自然超时。 + m.materials = nil + return m, nil + case tea.KeyUp: + if state.phase == materialsPhaseSelecting { + state.moveCursor(-1) + } + return m, nil + case tea.KeyDown: + if state.phase == materialsPhaseSelecting { + state.moveCursor(1) + } + return m, nil + case tea.KeySpace: + if state.phase == materialsPhaseSelecting { + state.toggleCurrent() + } + return m, nil + case tea.KeyEnter: + switch state.phase { + case materialsPhaseSelecting: + approved := state.approved() + if len(approved) == 0 { + return m, nil + } + saved, err := m.runtime.MaterialsApprove(approved) + if err != nil { + state.err = err + return m, nil + } + _ = saved + state.phase = materialsPhaseDone + // 短暂展示后清空(用一次 tick 触发)。简化为:直接清空 + m.materials = nil + m.applyEvent(host.Event{ + Time: time.Now(), + Category: "SYSTEM", + Summary: fmt.Sprintf("已保存 %d 条素材到素材库", len(approved)), + Level: "info", + }) + m.refreshEventViewport() + return m, nil + case materialsPhaseDone: + m.materials = nil + return m, nil + } + return m, nil + } + + // 字母键:a 全选 / n 全不选 + if msg.Type == tea.KeyRunes { + switch strings.ToLower(string(msg.Runes)) { + case "a": + if state.phase == materialsPhaseSelecting { + state.selectAll(true) + } + case "n": + if state.phase == materialsPhaseSelecting { + state.selectAll(false) + } + } + } + + return m, nil +} + +// ── tea.Cmd 工厂 ── + +// materialsCollectResultMsg 是收集完成的回调消息。 +type materialsCollectResultMsg struct { + candidates []host.MaterialsCandidate + raw string + err error +} + +// runMaterialsCollect 在后台 goroutine 调用 host.MaterialsCollect,避免阻塞 TUI。 +// onProgress 通过单独的 channel 推到 TUI(目前 MVP 用一个简化的 partial 消息)。 +func runMaterialsCollect(runtime *host.Host, userPrompt string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), materialsCollectDeadline) + defer cancel() + + // onProgress 在 GenerateStream 内被同步调用,但本回调运行在 host 端 goroutine 里—— + // 不能直接更新 state(race)。MVP 阶段先丢掉 progress,等候选回来一次性渲染。 + candidates, raw, err := runtime.MaterialsCollect(ctx, userPrompt, nil) + return materialsCollectResultMsg{ + candidates: candidates, + raw: raw, + err: err, + } + } +} + +const materialsCollectDeadline = 130 * 1_000_000_000 // 130s,比 host 内部 timeout 长 10s 留余量 diff --git a/internal/host/materials.go b/internal/host/materials.go new file mode 100644 index 00000000..92f17dc8 --- /dev/null +++ b/internal/host/materials.go @@ -0,0 +1,179 @@ +package host + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/voocel/agentcore" + "github.com/voocel/ainovel-cli/internal/bootstrap" + "github.com/voocel/ainovel-cli/internal/tools" +) + +// MaterialsCandidate 是 LLM 产出的素材候选条目。LLM 输出 JSON 时按这个结构。 +// 用户筛选后,TUI 调 Host.MaterialsApprove 把选中的批量落盘。 +type MaterialsCandidate struct { + Category string `json:"category"` + Title string `json:"title"` + Content string `json:"content"` + Source string `json:"source,omitempty"` + Reason string `json:"reason,omitempty"` // LLM 自述为什么这条值得入素材库 +} + +const materialsCollectorPrompt = `你是一个小说素材收集助手。 + +用户会给你一个小说创作需求(题材、风格、长度等)。你的任务:基于需求 + 自身知识 + 必要时调 web_search,**搜集 8-15 条具体可用的素材**,让用户挑选入库。 + +## 素材类型 + +每条素材按下列 category 归类: + +- naming:命名表(人名/地名/组织名/技能名/作品名 候选清单,至少 5 个) +- terminology:术语表(流派/技术/官职/职业/招式 等术语释义) +- visual:视觉锚点(场景/服装/道具/色彩/光影 的具体描写素材) +- setting:设定资料(力量体系/历史/地理/经济 等世界规则) +- reference:参考资料(同类作品分析 / 真实事件参考 / 流派套路) + +## 要求 + +- **具体**:title 一句话标题;content 是真实可用的素材内容(命名表给名字清单,不要给"取名的注意事项") +- **多样**:每条素材聚焦一个角度,避免重复(同一题材不要重复出现"霓虹配色"两次) +- **量力**:8-15 条;少了不够挑,多了稀释质量 +- **真实**:不确定的资料要标"(参考性内容)",不要编造看似真实的人名/作品名 + +## 输出格式 + +直接输出一段 JSON,外层是 {"items": [...]},items 是数组,每个元素含 category/title/content/source 字段。**不要任何额外文字、不要 ` + "```" + `json 围栏、不要 XML 标签**。 + +source 字段格式:"web_search:query=xxx" / "builtin" / "skill:name"。 + +示例: +{"items":[{"category":"naming","title":"赛博朋克企业名候选","content":"Yorinaga-Kessler / Praxis Holdings / Helix Dynamics / Nightingale Cybernetics / Kuznetsov-Vol","source":"builtin"},{"category":"visual","title":"霓虹配色方案","content":"主色 #ff0080 配青蓝 #00d4ff;招牌用洋红+琥珀双色调;雨夜街道反光用紫色漫射。","source":"builtin"}]}` + +const ( + materialsCollectorMaxTokens = 4096 + materialsCollectorTimeout = 120 * time.Second + materialsMinCandidates = 1 // 用户至少要看到 1 条候选才算收集成功;少于阈值报错 +) + +// materialsCollect 单轮调用 LLM 收集素材候选。 +// +// 设计: +// - 单轮:MVP 不实现工具往返。LLM 若调 web_search 而不输出文本 → 报错让用户重试。 +// 下一版加 mini loop(参考 cocreate 的实现)。 +// - 流式:onProgress 实时回传 thinking + 文本片段,TUI 可以渲染加载动画 +// - webSearch 允许传 nil(Provider 不支持时降级为纯知识模式) +// +// 返回 (candidates, rawResponse, err):rawResponse 给调用方用于失败时 debug。 +func materialsCollect(ctx context.Context, models *bootstrap.ModelSet, userPrompt string, onProgress func(kind, text string), webSearch *tools.WebSearchTool) ([]MaterialsCandidate, string, error) { + userPrompt = strings.TrimSpace(userPrompt) + if userPrompt == "" { + return nil, "", fmt.Errorf("materials collect: empty user prompt") + } + + model := models.ForRole("thinking") + ctx, cancel := context.WithTimeout(ctx, materialsCollectorTimeout) + defer cancel() + + msgs := []agentcore.Message{ + agentcore.SystemMsg(materialsCollectorPrompt), + agentcore.UserMsg(userPrompt), + } + + var toolSpecs []agentcore.ToolSpec + if webSearch != nil { + toolSpecs = []agentcore.ToolSpec{webSearchToolSpec()} + } + + streamCh, err := model.GenerateStream(ctx, msgs, toolSpecs, agentcore.WithMaxTokens(materialsCollectorMaxTokens)) + if err != nil { + return nil, "", fmt.Errorf("materials generate: %w", err) + } + + var ( + text strings.Builder + thinking strings.Builder + finalMsg agentcore.Message + ) + for ev := range streamCh { + switch ev.Type { + case agentcore.StreamEventThinkingDelta: + thinking.WriteString(ev.Delta) + if onProgress != nil { + onProgress(CoCreateProgressThinking, thinking.String()) + } + case agentcore.StreamEventTextDelta: + text.WriteString(ev.Delta) + if onProgress != nil { + onProgress(CoCreateProgressReply, text.String()) + } + case agentcore.StreamEventDone: + finalMsg = ev.Message + case agentcore.StreamEventError: + if ev.Err != nil { + return nil, "", fmt.Errorf("materials generate: %w", ev.Err) + } + return nil, "", fmt.Errorf("materials generate failed") + } + } + + raw := text.String() + if raw == "" && finalMsg.TextContent() != "" { + raw = finalMsg.TextContent() + } + if strings.TrimSpace(raw) == "" { + return nil, raw, fmt.Errorf("LLM 未输出文本(可能调用了 web_search 但 MVP 版未实现工具往返,请重试)") + } + + candidates, err := parseMaterialsJSON(raw) + if err != nil { + return nil, raw, fmt.Errorf("parse materials json: %w", err) + } + if len(candidates) < materialsMinCandidates { + return candidates, raw, fmt.Errorf("no candidates parsed from response") + } + return candidates, raw, nil +} + +// parseMaterialsJSON 容忍 LLM 输出可能的杂质(前导思考文字、代码围栏)。 +// 先整段 parse;失败则提取首尾大括号之间的子串再 parse。 +func parseMaterialsJSON(raw string) ([]MaterialsCandidate, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("empty response") + } + + // 剥 ```json ... ``` 围栏(即便 prompt 明确禁止,仍偶尔出现) + if strings.HasPrefix(raw, "```") { + raw = strings.TrimPrefix(raw, "```") + // 去掉可能的 "json" 标识符 + raw = strings.TrimPrefix(raw, "json") + raw = strings.Trim(raw, "\n \t") + if idx := strings.LastIndex(raw, "```"); idx >= 0 { + raw = raw[:idx] + } + raw = strings.TrimSpace(raw) + } + + // 直接 parse + var envelope struct { + Items []MaterialsCandidate `json:"items"` + } + if err := json.Unmarshal([]byte(raw), &envelope); err == nil && envelope.Items != nil { + return envelope.Items, nil + } + + // 提取首尾大括号之间的内容再试 + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start < 0 || end <= start { + return nil, fmt.Errorf("no JSON envelope found") + } + block := raw[start : end+1] + if err := json.Unmarshal([]byte(block), &envelope); err != nil { + return nil, fmt.Errorf("parse JSON block: %w", err) + } + return envelope.Items, nil +} diff --git a/internal/store/materials.go b/internal/store/materials.go new file mode 100644 index 00000000..77007f6f --- /dev/null +++ b/internal/store/materials.go @@ -0,0 +1,152 @@ +package store + +import ( + "fmt" + "os" + "sync" + "time" + + "github.com/voocel/ainovel-cli/internal/domain" +) + +// MaterialsStore 管理项目级素材库(meta/materials.json)。 +// +// 设计与 SimulationStore 类似:单文件 JSON、独立 mu 保护、os.IsNotExist 优雅降级。 +// 不做缓存——Load 量小(典型几十条素材),每次工具调用读盘足够。 +type MaterialsStore struct { + io *IO + mu sync.Mutex +} + +func NewMaterialsStore(io *IO) *MaterialsStore { + return &MaterialsStore{io: io} +} + +const materialsPath = "meta/materials.json" + +// Load 返回完整素材库。文件不存在时返回空库(不报错)—— +// 调用方(novel_context / list_materials)需要稳定拿到 *MaterialLibrary,而非 nil。 +func (s *MaterialsStore) Load() (*domain.MaterialLibrary, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.loadUnlocked() +} + +// Add 追加单条素材,自动补 ID/AddedAt,返回带 ID 的完整条目。 +// Category 空值归一化为 reference;Title 或 Content 空则报错—— +// 这两项是后续检索与展示的关键,不能缺。 +func (s *MaterialsStore) Add(item domain.MaterialItem) (domain.MaterialItem, error) { + if item.Title == "" { + return item, fmt.Errorf("material title is required") + } + if item.Content == "" { + return item, fmt.Errorf("material content is required") + } + if item.Category == "" { + item.Category = domain.MaterialCategoryReference + } + + s.mu.Lock() + defer s.mu.Unlock() + + lib, err := s.loadUnlocked() + if err != nil { + return item, err + } + if item.ID == "" { + item.ID = generateMaterialID(lib) + } + if item.AddedAt.IsZero() { + item.AddedAt = time.Now() + } + lib.Items = append(lib.Items, item) + if err := s.saveUnlocked(lib); err != nil { + return item, err + } + return item, nil +} + +// AddBatch 批量追加。比循环调 Add 少几次加锁/落盘,architect 一次性写多条素材时用。 +// 任何一条 Title/Content 空都直接返回错误,整体不写入——避免半提交状态。 +func (s *MaterialsStore) AddBatch(items []domain.MaterialItem) ([]domain.MaterialItem, error) { + for i, it := range items { + if it.Title == "" || it.Content == "" { + return nil, fmt.Errorf("items[%d]: title and content are required", i) + } + } + + s.mu.Lock() + defer s.mu.Unlock() + + lib, err := s.loadUnlocked() + if err != nil { + return nil, err + } + out := make([]domain.MaterialItem, 0, len(items)) + for _, it := range items { + if it.Category == "" { + it.Category = domain.MaterialCategoryReference + } + if it.ID == "" { + it.ID = generateMaterialID(lib) + } + if it.AddedAt.IsZero() { + it.AddedAt = time.Now() + } + lib.Items = append(lib.Items, it) + out = append(out, it) + } + if err := s.saveUnlocked(lib); err != nil { + return nil, err + } + return out, nil +} + +// Remove 按 ID 删除单条。不存在返回错误,调用方(remove_material 工具)转 friendly 提示。 +func (s *MaterialsStore) Remove(id string) error { + if id == "" { + return fmt.Errorf("material id is empty") + } + s.mu.Lock() + defer s.mu.Unlock() + + lib, err := s.loadUnlocked() + if err != nil { + return err + } + for i, it := range lib.Items { + if it.ID == id { + lib.Items = append(lib.Items[:i], lib.Items[i+1:]...) + return s.saveUnlocked(lib) + } + } + return fmt.Errorf("material %q not found", id) +} + +func (s *MaterialsStore) loadUnlocked() (*domain.MaterialLibrary, error) { + var lib domain.MaterialLibrary + if err := s.io.ReadJSONUnlocked(materialsPath, &lib); err != nil { + if os.IsNotExist(err) { + return &domain.MaterialLibrary{}, nil + } + return nil, err + } + return &lib, nil +} + +func (s *MaterialsStore) saveUnlocked(lib *domain.MaterialLibrary) error { + return s.io.WriteJSONUnlocked(materialsPath, lib) +} + +// generateMaterialID 生成稳定递增的 mat-NNN ID。 +// 不用 UUID 是为了让 LLM 在 remove_material(id=...) 时能直接报出可读 ID。 +func generateMaterialID(lib *domain.MaterialLibrary) string { + maxIdx := 0 + var n int + for _, it := range lib.Items { + if _, err := fmt.Sscanf(it.ID, "mat-%d", &n); err == nil && n > maxIdx { + maxIdx = n + } + } + return fmt.Sprintf("mat-%03d", maxIdx+1) +} diff --git a/internal/store/materials_test.go b/internal/store/materials_test.go new file mode 100644 index 00000000..75fa333d --- /dev/null +++ b/internal/store/materials_test.go @@ -0,0 +1,132 @@ +package store + +import ( + "strings" + "testing" + + "github.com/voocel/ainovel-cli/internal/domain" +) + +func TestMaterialsStoreLoadEmpty(t *testing.T) { + dir := t.TempDir() + s := NewMaterialsStore(newIO(dir)) + lib, err := s.Load() + if err != nil { + t.Fatalf("Load empty: %v", err) + } + if lib == nil { + t.Fatal("Load should never return nil") + } + if len(lib.Items) != 0 { + t.Fatalf("expected empty items, got %d", len(lib.Items)) + } +} + +func TestMaterialsStoreAddAssignsIDAndTime(t *testing.T) { + dir := t.TempDir() + s := NewMaterialsStore(newIO(dir)) + + it, err := s.Add(domain.MaterialItem{ + Category: domain.MaterialCategoryNaming, + Title: "测试命名表", + Content: "测试内容", + }) + if err != nil { + t.Fatalf("Add: %v", err) + } + if it.ID != "mat-001" { + t.Fatalf("first ID should be mat-001, got %q", it.ID) + } + if it.AddedAt.IsZero() { + t.Fatal("AddedAt should be auto-filled") + } + + // 第二条递增到 mat-002 + it2, _ := s.Add(domain.MaterialItem{Title: "x", Content: "y"}) + if it2.ID != "mat-002" { + t.Fatalf("second ID should be mat-002, got %q", it2.ID) + } + + // Load 看到 2 条 + lib, _ := s.Load() + if len(lib.Items) != 2 { + t.Fatalf("expected 2 items after 2 adds, got %d", len(lib.Items)) + } +} + +func TestMaterialsStoreAddRequiresTitleAndContent(t *testing.T) { + dir := t.TempDir() + s := NewMaterialsStore(newIO(dir)) + + if _, err := s.Add(domain.MaterialItem{Title: "x"}); err == nil { + t.Fatal("Add without content should error") + } + if _, err := s.Add(domain.MaterialItem{Content: "x"}); err == nil { + t.Fatal("Add without title should error") + } +} + +func TestMaterialsStoreAddBatchAtomic(t *testing.T) { + dir := t.TempDir() + s := NewMaterialsStore(newIO(dir)) + + // 任一条无效 → 整批不写 + _, err := s.AddBatch([]domain.MaterialItem{ + {Title: "a", Content: "1"}, + {Title: "", Content: "2"}, // 触发错误 + }) + if err == nil { + t.Fatal("batch with empty title should error") + } + lib, _ := s.Load() + if len(lib.Items) != 0 { + t.Fatalf("batch should be atomic, but got %d items", len(lib.Items)) + } + + // 全部合法 → 全部入库 + saved, err := s.AddBatch([]domain.MaterialItem{ + {Title: "a", Content: "1"}, + {Title: "b", Content: "2", Category: "custom"}, + }) + if err != nil { + t.Fatalf("valid batch: %v", err) + } + if len(saved) != 2 { + t.Fatalf("expected 2 saved, got %d", len(saved)) + } + // ID 连续递增 + if saved[0].ID != "mat-001" || saved[1].ID != "mat-002" { + t.Fatalf("IDs not sequential: %+v", saved) + } + // 空 category 默认归 reference + if saved[0].Category != domain.MaterialCategoryReference { + t.Fatalf("default category should be reference, got %q", saved[0].Category) + } + // 显式 category 透传 + if saved[1].Category != "custom" { + t.Fatalf("explicit category should pass through, got %q", saved[1].Category) + } +} + +func TestMaterialsStoreRemove(t *testing.T) { + dir := t.TempDir() + s := NewMaterialsStore(newIO(dir)) + + it, _ := s.Add(domain.MaterialItem{Title: "x", Content: "y"}) + if err := s.Remove(it.ID); err != nil { + t.Fatalf("Remove: %v", err) + } + lib, _ := s.Load() + if len(lib.Items) != 0 { + t.Fatalf("expected 0 items after remove, got %d", len(lib.Items)) + } + + // 再删同样的 ID → 报错 + err := s.Remove(it.ID) + if err == nil { + t.Fatal("remove non-existent should error") + } + if !strings.Contains(err.Error(), "not found") { + t.Fatalf("error should mention not found, got %v", err) + } +} diff --git a/internal/tools/list_materials.go b/internal/tools/list_materials.go new file mode 100644 index 00000000..779cb24c --- /dev/null +++ b/internal/tools/list_materials.go @@ -0,0 +1,105 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/internal/errs" + "github.com/voocel/ainovel-cli/internal/store" +) + +// ListMaterialsTool 列出本项目素材库(meta/materials.json)。 +// +// 默认仅返回概览(id/title/category/source/added_at),不返回 content—— +// 让 LLM 在拿全表后选择性读全文(避免把整库灌进上下文)。 +// +// 适用场景: +// - architect 规划前快速盘点已有素材避免重复搜集 +// - coordinator 完结时核对素材沉淀情况 +// - writer/editor 在写作过程中按需检索(一般走 novel_context.materials 即可,不必直接调本工具) +type ListMaterialsTool struct { + store *store.Store +} + +func NewListMaterialsTool(s *store.Store) *ListMaterialsTool { + return &ListMaterialsTool{store: s} +} + +func (t *ListMaterialsTool) Name() string { return "list_materials" } +func (t *ListMaterialsTool) Label() string { return "列出素材" } + +func (t *ListMaterialsTool) Description() string { + return "列出项目素材库(meta/materials.json)。默认仅返回概览(不含 content)," + + "include_content=true 时返回完整内容。可按 category 过滤。空库返回 {items:[], count:0}。" +} + +func (t *ListMaterialsTool) ReadOnly(_ json.RawMessage) bool { return true } +func (t *ListMaterialsTool) ConcurrencySafe(_ json.RawMessage) bool { return true } + +func (t *ListMaterialsTool) ActivityDescription(_ json.RawMessage) string { + return "列出素材" +} + +func (t *ListMaterialsTool) Schema() map[string]any { + return schema.Object( + schema.Property("category", schema.String("可选:仅返回该分类(naming/terminology/visual/setting/reference 或自定义)")), + schema.Property("include_content", schema.Bool("是否返回 content 字段。默认 false(仅概览),true 返回完整内容")), + ) +} + +func (t *ListMaterialsTool) Execute(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a struct { + Category string `json:"category"` + IncludeContent bool `json:"include_content"` + } + if len(args) > 0 { + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("invalid args: %w: %w", errs.ErrToolArgs, err) + } + } + cat := strings.TrimSpace(a.Category) + + lib, err := t.store.Materials.Load() + if err != nil { + return nil, fmt.Errorf("load materials: %w", err) + } + + type summary struct { + ID string `json:"id"` + Category string `json:"category"` + Title string `json:"title"` + Source string `json:"source,omitempty"` + AddedAt string `json:"added_at,omitempty"` + Content string `json:"content,omitempty"` + } + + out := make([]summary, 0, len(lib.Items)) + for _, it := range lib.Items { + if cat != "" && !strings.EqualFold(it.Category, cat) { + continue + } + row := summary{ + ID: it.ID, + Category: it.Category, + Title: it.Title, + Source: it.Source, + } + if !it.AddedAt.IsZero() { + row.AddedAt = it.AddedAt.Format("2006-01-02 15:04") + } + if a.IncludeContent { + row.Content = it.Content + } + out = append(out, row) + } + + return json.Marshal(map[string]any{ + "items": out, + "count": len(out), + "category_filter": cat, + "include_content": a.IncludeContent, + }) +} diff --git a/internal/tools/remove_material.go b/internal/tools/remove_material.go new file mode 100644 index 00000000..561afc62 --- /dev/null +++ b/internal/tools/remove_material.go @@ -0,0 +1,59 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/internal/errs" + "github.com/voocel/ainovel-cli/internal/store" +) + +// RemoveMaterialTool 按 ID 删除单条素材。 +// +// 用途:用户/LLM 发现某条素材不准确或重复时清理。批量删除请逐条调用。 +// 不提供 delete_all:避免误操作清库(真要重置可直接删 meta/materials.json)。 +type RemoveMaterialTool struct { + store *store.Store +} + +func NewRemoveMaterialTool(s *store.Store) *RemoveMaterialTool { + return &RemoveMaterialTool{store: s} +} + +func (t *RemoveMaterialTool) Name() string { return "remove_material" } +func (t *RemoveMaterialTool) Label() string { return "删除素材" } + +func (t *RemoveMaterialTool) Description() string { + return "按 ID 删除单条素材(mat-001/...)。不存在的 ID 返回 error。批量删除请逐条调用。" +} + +func (t *RemoveMaterialTool) ReadOnly(_ json.RawMessage) bool { return false } +func (t *RemoveMaterialTool) ConcurrencySafe(_ json.RawMessage) bool { return false } + +func (t *RemoveMaterialTool) ActivityDescription(_ json.RawMessage) string { + return "删除素材" +} + +func (t *RemoveMaterialTool) Schema() map[string]any { + return schema.Object( + schema.Property("id", schema.String("要删除的素材 ID(mat-001/...)")).Required(), + ) +} + +func (t *RemoveMaterialTool) Execute(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a struct { + ID string `json:"id"` + } + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("invalid args: %w: %w", errs.ErrToolArgs, err) + } + if a.ID == "" { + return nil, fmt.Errorf("id is required: %w", errs.ErrToolArgs) + } + if err := t.store.Materials.Remove(a.ID); err != nil { + return nil, fmt.Errorf("remove material: %w", err) + } + return json.Marshal(map[string]any{"removed": a.ID}) +} diff --git a/internal/tools/save_materials.go b/internal/tools/save_materials.go new file mode 100644 index 00000000..a1b7ec9f --- /dev/null +++ b/internal/tools/save_materials.go @@ -0,0 +1,113 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/internal/domain" + "github.com/voocel/ainovel-cli/internal/errs" + "github.com/voocel/ainovel-cli/internal/store" +) + +// SaveMaterialsTool 把 architect 收集到的素材批量持久化到 meta/materials.json。 +// +// 适用阶段:architect 在规划前 web_search / 调本地 skill / 自身知识搜集完素材后调用, +// 后续 novel_context.reference_pack.materials 会注入这些素材供所有子代理消费。 +// +// 设计:批量接口而非单条 add——architect 一次产出的素材通常是 5-15 条命名/术语/设定的列表, +// 单条 add 工具会逼 LLM 多轮 tool_call,浪费 token。批量提交+错误整体回滚避免半落盘。 +type SaveMaterialsTool struct { + store *store.Store +} + +func NewSaveMaterialsTool(s *store.Store) *SaveMaterialsTool { + return &SaveMaterialsTool{store: s} +} + +func (t *SaveMaterialsTool) Name() string { return "save_materials" } +func (t *SaveMaterialsTool) Label() string { return "保存素材" } + +func (t *SaveMaterialsTool) Description() string { + return "批量保存项目级素材到 meta/materials.json。architect 在规划前搜集到的命名表、术语、" + + "视觉锚点、设定资料、参考资料等都通过此工具持久化;后续 novel_context.reference_pack.materials " + + "会注入这些素材供 writer/editor 消费。category 可选 naming/terminology/visual/setting/reference," + + "自定义值也接受。items 必须含 title 与 content,空值的条目会被跳过(不报错);至少有一条有效条目才会落盘。" +} + +// 写工具,禁止并发——保护 meta/materials.json 不被同时改写。 +func (t *SaveMaterialsTool) ReadOnly(_ json.RawMessage) bool { return false } +func (t *SaveMaterialsTool) ConcurrencySafe(_ json.RawMessage) bool { return false } + +func (t *SaveMaterialsTool) ActivityDescription(_ json.RawMessage) string { + return "保存素材" +} + +func (t *SaveMaterialsTool) Schema() map[string]any { + return schema.Object( + schema.Property("items", schema.Array( + "素材列表(每条含 category/title/content/source)", + schema.Object( + schema.Property("category", schema.String("分类:naming/terminology/visual/setting/reference,可自定义")).Required(), + schema.Property("title", schema.String("素材标题(一句话概括,便于检索展示")).Required(), + schema.Property("content", schema.String("素材正文(Markdown),原样持久化")).Required(), + schema.Property("source", schema.String("来源标记,便于追溯。建议格式:web_search: / skill: / builtin")), + ), + )).Required(), + ) +} + +func (t *SaveMaterialsTool) Execute(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a struct { + Items []struct { + Category string `json:"category"` + Title string `json:"title"` + Content string `json:"content"` + Source string `json:"source"` + } `json:"items"` + } + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("invalid args: %w: %w", errs.ErrToolArgs, err) + } + + // 过滤掉 title/content 空的条目——LLM 偶尔会输出占位 {"title":""},本小姐宁可跳过也不让报错污染整批。 + items := make([]domain.MaterialItem, 0, len(a.Items)) + skipped := 0 + for _, in := range a.Items { + if strings.TrimSpace(in.Title) == "" || strings.TrimSpace(in.Content) == "" { + skipped++ + continue + } + items = append(items, domain.MaterialItem{ + Category: in.Category, + Title: in.Title, + Content: in.Content, + Source: in.Source, + }) + } + if len(items) == 0 { + return nil, fmt.Errorf("no valid items (all %d had empty title or content): %w", len(a.Items), errs.ErrToolArgs) + } + + saved, err := t.store.Materials.AddBatch(items) + if err != nil { + return nil, fmt.Errorf("save materials: %w", err) + } + + // 返回每条的 ID/Category/Title,便于 LLM 在后续 remove_material 时引用。 + summaries := make([]map[string]string, 0, len(saved)) + for _, it := range saved { + summaries = append(summaries, map[string]string{ + "id": it.ID, + "category": it.Category, + "title": it.Title, + }) + } + return json.Marshal(map[string]any{ + "saved": len(saved), + "skipped": skipped, + "items": summaries, + }) +} From f1e6e65c6f0c4364cf8d97c887cc02434799921c Mon Sep 17 00:00:00 2001 From: ciallo Date: Mon, 6 Jul 2026 13:05:32 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20rewrite=20?= =?UTF-8?q?=E9=87=8D=E5=86=99=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/host/rewrite.go: 全量 foundation 重写流程 - internal/host/rewrite_test.go: 重写流程单测 - TUI rewrite_confirm.go: 重写授权二次确认模态 --- internal/entry/tui/rewrite_confirm.go | 215 ++++++++++++++++++++++++++ internal/host/rewrite.go | 116 ++++++++++++++ internal/host/rewrite_test.go | 115 ++++++++++++++ 3 files changed, 446 insertions(+) create mode 100644 internal/entry/tui/rewrite_confirm.go create mode 100644 internal/host/rewrite.go create mode 100644 internal/host/rewrite_test.go diff --git a/internal/entry/tui/rewrite_confirm.go b/internal/entry/tui/rewrite_confirm.go new file mode 100644 index 00000000..24ca0811 --- /dev/null +++ b/internal/entry/tui/rewrite_confirm.go @@ -0,0 +1,215 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + tea "github.com/charmbracelet/bubbletea" + "github.com/voocel/ainovel-cli/internal/host" +) + +// rewriteConfirmState 是重写授权 modal 的状态。 +// +// 流程:用户输入 /rewrite [可选新方向] → 命令创建 state(带原始 args 作为 +// pendingArgs)→ modal 渲染警告 + 输入框 → 用户输入 "yes" / "确认" → +// 调 host.RewriteFoundation() → 切回 modeNew,pendingArgs 作为初始需求填回。 +type rewriteConfirmState struct { + input string + pendingArgs string + errorMsg string + backupPath string // 成功后填入,触发"已备份到 ..."的临时提示 + done bool // done=true 时下一次渲染只显示成功行后立即关闭 +} + +// rewriteConfirmKeywords 接受这些不区分大小写的输入作为授权凭证。 +var rewriteConfirmKeywords = []string{"yes", "确认", "sure", "ok"} + +func newRewriteConfirmState(pendingArgs string) *rewriteConfirmState { + return &rewriteConfirmState{pendingArgs: pendingArgs} +} + +func (s *rewriteConfirmState) isAuthorized() bool { + ans := strings.ToLower(strings.TrimSpace(s.input)) + for _, k := range rewriteConfirmKeywords { + if ans == k { + return true + } + } + return false +} + +// ── 渲染 ── + +func rewriteConfirmModalSize(width, height int) (boxW, boxH int) { + boxW = width - 6 + if boxW > 88 { + boxW = 88 + } + if boxW < 60 { + boxW = 60 + } + boxH = 18 + if boxH > height-4 { + boxH = height - 4 + } + if boxH < 12 { + boxH = 12 + } + return +} + +func renderRewriteConfirmModal(width, height int, state *rewriteConfirmState) string { + if state == nil { + return "" + } + boxW, boxH := rewriteConfirmModalSize(width, height) + contentW := paddedModalContentWidth(boxW) + if contentW < 24 { + contentW = 24 + } + + warn := lipgloss.NewStyle().Foreground(colorError).Bold(true) + muted := lipgloss.NewStyle().Foreground(colorMuted) + dim := lipgloss.NewStyle().Foreground(colorDim) + hl := lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + body := lipgloss.NewStyle().Foreground(bodyTextColor) + + var lines []string + if state.done { + ok := lipgloss.NewStyle().Foreground(colorSuccess).Bold(true) + lines = append(lines, ok.Render("✓ 已重写:项目回到未启动状态")) + lines = append(lines, "") + lines = append(lines, body.Render("备份目录:"+state.backupPath)) + lines = append(lines, "") + lines = append(lines, dim.Render("(输入框已切回创作模式,下方按 Esc 或 Enter 关闭此提示)")) + } else { + lines = append(lines, warn.Render("⚠ 即将清空全部 foundation")) + lines = append(lines, "") + lines = append(lines, muted.Render("此操作不可撤销,会归档以下内容到 meta/rewrite-backup-*/:")) + items := []string{ + "premise / outline / layered_outline(设定与大纲)", + "characters / world_rules(角色与世界规则)", + "compass / progress(故事方向与进度)", + "chapters/ 已完成章节正文(全部)", + } + for _, it := range items { + lines = append(lines, body.Render(" · "+it)) + } + lines = append(lines, "") + lines = append(lines, muted.Render("保留:materials.json(素材库)、meta/sessions(调用日志)")) + lines = append(lines, "") + lines = append(lines, hl.Render("请输入 "+strings.Join(rewriteConfirmKeywords, " / ")+" 确认:")) + inputBox := lipgloss.NewStyle(). + Width(contentW-2). + Border(baseBorder). + BorderForeground(colorAccent). + Padding(0, 1). + Render(state.input + "_") + lines = append(lines, inputBox) + if state.errorMsg != "" { + lines = append(lines, warn.Render("! "+state.errorMsg)) + } + } + + title := "重写授权" + hint := "Esc 取消" + if state.done { + hint = "Enter / Esc 关闭" + } + return renderPaddedModalFrame(boxW, boxH, title, hint, splitBodyLines(strings.Join(lines, "\n"), contentW)) +} + +// ── 按键 ── + +// handleRewriteConfirmKey 处理授权 modal 内的按键。返回 (model, cmd), +// 由 handleBlockingModalKey 统一包装成 (model, cmd, handled=true)。 +func (m Model) handleRewriteConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.rewriteConfirm == nil { + return m, nil + } + state := m.rewriteConfirm + + if state.done { + // 成功后任意键关闭 + m.rewriteConfirm = nil + return m, nil + } + + switch msg.Type { + case tea.KeyEsc: + m.rewriteConfirm = nil + return m, nil + case tea.KeyBackspace, tea.KeyCtrlH: + if len(state.input) > 0 { + // 处理多字节 rune + runes := []rune(state.input) + state.input = string(runes[:len(runes)-1]) + } + state.errorMsg = "" + return m, nil + case tea.KeyCtrlU: + state.input = "" + state.errorMsg = "" + return m, nil + case tea.KeyEnter: + if !state.isAuthorized() { + state.errorMsg = "输入不匹配:请输入 " + strings.Join(rewriteConfirmKeywords, " / ") + return m, nil + } + backup, err := m.runtime.RewriteFoundation() + if err != nil { + state.errorMsg = err.Error() + m.applyEvent(host.Event{ + Time: time.Now(), Category: "ERROR", + Summary: "重写失败:" + err.Error(), Level: "error", + }) + m.refreshEventViewport() + return m, nil + } + state.backupPath = backup + state.done = true + // 切回 modeNew:textarea 回到"启动需求"占位符;后续 submit 走 startup.PrepareQuick + m.mode = modeNew + m.snapshot.IsRunning = false + m.snapshot.RuntimeState = "" + m.startupMode = startupModeQuick + m.textarea.Placeholder = placeholderForNewMode(m.startupMode) + m.startupMode = startupModeQuick + m.applyEvent(host.Event{ + Time: time.Now(), Category: "SYSTEM", + Summary: fmt.Sprintf("已重写 foundation(备份在 %s)", backup), Level: "info", + }) + m.refreshEventViewport() + // 用户最初输入的新方向(pendingArgs)填回 textarea,避免再问一次 + if strings.TrimSpace(state.pendingArgs) != "" { + m.textarea.SetValue(state.pendingArgs) + m.refitTextareaHeight() + } + return m, tea.Batch(tea.DisableMouse, m.textarea.Focus()) + } + + // 字符输入 + if msg.Type == tea.KeyRunes { + cleaned := cleanHumanKeyRunesForRewrite(msg) + if cleaned != "" { + state.input += cleaned + state.errorMsg = "" + } + } + return m, nil +} + +// cleanHumanKeyRunesForRewrite 过滤粘贴流的 ANSI 残片,保留可见字符。 +// 复用主输入路径的清理逻辑,但只关心文本拼接(不需要 KeyMsg 再传递)。 +func cleanHumanKeyRunesForRewrite(msg tea.KeyMsg) string { + if containsSGRFragment(string(msg.Runes)) || isCSILeak(msg.Runes) { + return "" + } + cleaned, ok := cleanHumanKeyRunes(msg) + if !ok { + return "" + } + return string(cleaned.Runes) +} diff --git a/internal/host/rewrite.go b/internal/host/rewrite.go new file mode 100644 index 00000000..d983a48b --- /dev/null +++ b/internal/host/rewrite.go @@ -0,0 +1,116 @@ +package host + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +// RewriteFoundation 把所有 foundation 数据归档到 +// meta/rewrite-backup-YYYYMMDD-HHMMSS/,再把项目重置回"未启动"状态。 +// +// 归档清单: +// - premise.md / outline.json / outline.md +// - layered_outline.json / layered_outline.md +// - characters.json / characters.md +// - world_rules.json / world_rules.md +// - meta/compass.json / meta/progress.json +// - chapters/ 整个目录(已完成章节正文) +// +// 调用后 phase 回到空(未启动),architect 可重新被启动跑大纲。 +// +// 该方法只能在 lifecycle != lifecycleRunning 且非阶段共创时调用; +// 违反时返回错误,避免与正在跑的 agent 冲突。 +// +// 不删除:meta/sessions/(调用历史,便于审计)、meta/runtime/(运行队列,重启时 +// 会清空)、meta/materials.json(素材库与大纲解耦,单独保留)。 +func (h *Host) RewriteFoundation() (backupDir string, err error) { + if h == nil { + return "", fmt.Errorf("host 未初始化") + } + h.mu.Lock() + if h.lifecycle == lifecycleRunning { + h.mu.Unlock() + return "", fmt.Errorf("创作进行中,请先 Esc 暂停或等待完成后再重写") + } + if h.cocreating { + h.mu.Unlock() + return "", fmt.Errorf("阶段共创进行中,请先结束共创再重写") + } + h.mu.Unlock() + + stamp := time.Now().Format("20060102-150405") + rel := filepath.Join("meta", "rewrite-backup-"+stamp) + dir := filepath.Join(h.Dir(), rel) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("创建备份目录失败:%w", err) + } + + // 同秒内重复执行:把已有目录改名作为兜底(理论上前置约束会拦住,但保险一点) + for i := 2; ; i++ { + alt := dir + fmt.Sprintf("-%d", i) + if _, err := os.Stat(alt); os.IsNotExist(err) { + break + } + } + + // 文件 → 备份目录根;目录(chapters/)→ 备份目录下的同名子目录 + type moveTarget struct { + rel string + as string + } + files := []moveTarget{ + {"premise.md", "premise.md"}, + {"outline.json", "outline.json"}, + {"outline.md", "outline.md"}, + {"layered_outline.json", "layered_outline.json"}, + {"layered_outline.md", "layered_outline.md"}, + {"characters.json", "characters.json"}, + {"characters.md", "characters.md"}, + {"world_rules.json", "world_rules.json"}, + {"world_rules.md", "world_rules.md"}, + {"meta/compass.json", "compass.json"}, + {"meta/progress.json", "progress.json"}, + } + dirs := []moveTarget{ + {"chapters", "chapters"}, + } + + for _, f := range files { + src := filepath.Join(h.Dir(), f.rel) + if _, err := os.Stat(src); err != nil { + if os.IsNotExist(err) { + continue + } + return dir, fmt.Errorf("stat %s 失败:%w", f.rel, err) + } + dst := filepath.Join(dir, f.as) + if err := os.Rename(src, dst); err != nil { + return dir, fmt.Errorf("归档 %s 失败:%w", f.rel, err) + } + } + for _, d := range dirs { + src := filepath.Join(h.Dir(), d.rel) + if _, err := os.Stat(src); err != nil { + if os.IsNotExist(err) { + continue + } + return dir, fmt.Errorf("stat %s 失败:%w", d.rel, err) + } + dst := filepath.Join(dir, d.as) + if err := os.Rename(src, dst); err != nil { + return dir, fmt.Errorf("归档 %s 失败:%w", d.rel, err) + } + } + + // 重建空 chapters/ 目录 + 重置 progress(保持 store 内部状态一致) + if err := h.store.Init(); err != nil { + return dir, fmt.Errorf("重建目录失败:%w", err) + } + if err := h.store.Progress.Init("", 0); err != nil { + return dir, fmt.Errorf("重置 progress 失败:%w", err) + } + + return dir, nil +} diff --git a/internal/host/rewrite_test.go b/internal/host/rewrite_test.go new file mode 100644 index 00000000..4c1e1484 --- /dev/null +++ b/internal/host/rewrite_test.go @@ -0,0 +1,115 @@ +package host + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/voocel/ainovel-cli/internal/domain" + "github.com/voocel/ainovel-cli/internal/store" +) + +// setupRewriteHost 构造一个有 foundation + 已完成章节的 host,用于 rewrite 测试。 +func setupRewriteHost(t *testing.T) (*Host, string) { + t.Helper() + dir := t.TempDir() + st := store.NewStore(dir) + if err := st.Init(); err != nil { + t.Fatal(err) + } + if err := st.Outline.SavePremise("# 测试书\n赛博朋克短篇"); err != nil { + t.Fatal(err) + } + if err := st.Outline.SaveOutline([]domain.OutlineEntry{{Chapter: 1, Title: "首章", CoreEvent: "..."}}); err != nil { + t.Fatal(err) + } + if err := st.Outline.SaveCompass(domain.StoryCompass{EndingDirection: "终结"}); err != nil { + t.Fatal(err) + } + if err := st.Progress.Init("测试书", 10); err != nil { + t.Fatal(err) + } + if err := st.Drafts.SaveFinalChapter(1, "正文内容"); err != nil { + t.Fatal(err) + } + + h := &Host{store: st} + return h, dir +} + +func TestRewriteFoundation_ArchivesAllArtifacts(t *testing.T) { + h, dir := setupRewriteHost(t) + + backup, err := h.RewriteFoundation() + if err != nil { + t.Fatalf("重写失败:%v", err) + } + if !strings.Contains(backup, "rewrite-backup-") { + t.Errorf("备份路径名不正确:%s", backup) + } + + // 原 foundation 文件应被移走 + for _, p := range []string{"premise.md", "outline.json", "outline.md", "characters.json"} { + if _, err := os.Stat(filepath.Join(dir, p)); !os.IsNotExist(err) { + t.Errorf("原文件 %s 应被移走:err=%v", p, err) + } + } + // 备份目录里应有这些文件 + for _, p := range []string{"premise.md", "outline.json", "compass.json", "progress.json", "chapters/01.md"} { + if _, err := os.Stat(filepath.Join(backup, p)); err != nil { + t.Errorf("备份目录应有 %s:%v", p, err) + } + } +} + +func TestRewriteFoundation_LeavesSessionsAndMaterials(t *testing.T) { + h, dir := setupRewriteHost(t) + + // 写一个 materials.json 和 sessions/ 下的文件 + if err := os.WriteFile(filepath.Join(dir, "meta", "materials.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "meta", "sessions", "agents"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "meta", "sessions", "agents", "architect.jsonl"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := h.RewriteFoundation(); err != nil { + t.Fatalf("重写失败:%v", err) + } + + // materials.json 应保留(与大纲解耦) + if _, err := os.Stat(filepath.Join(dir, "meta", "materials.json")); err != nil { + t.Errorf("materials.json 应被保留:%v", err) + } + // sessions 也应保留 + if _, err := os.Stat(filepath.Join(dir, "meta", "sessions", "agents", "architect.jsonl")); err != nil { + t.Errorf("sessions 应被保留:%v", err) + } +} + +func TestRewriteFoundation_RejectsEmptyProject(t *testing.T) { + // 没有 foundation 的项目也能重写(幂等),不应报错。 + dir := t.TempDir() + st := store.NewStore(dir) + if err := st.Init(); err != nil { + t.Fatal(err) + } + h := &Host{store: st} + + backup, err := h.RewriteFoundation() + if err != nil { + t.Fatalf("空项目重写应成功:%v", err) + } + // 备份目录会被创建(即使为空) + if _, err := os.Stat(backup); err != nil { + t.Errorf("备份目录应被创建:%v", err) + } + // chapters/ 目录被重建 + if _, err := os.Stat(filepath.Join(dir, "chapters")); err != nil { + t.Errorf("chapters 目录应被重建:%v", err) + } +} From 7150fb4be11ab97fa2e5beae9a640dea3a0d4966 Mon Sep 17 00:00:00 2001 From: ciallo Date: Mon, 6 Jul 2026 13:05:45 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=E6=94=B9=E8=BF=9B=20cocreate=20?= =?UTF-8?q?=E5=85=B1=E5=88=9B=E5=AF=B9=E8=AF=9D=E4=B8=8E=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/host/cocreate.go: 优化超时与 max_tokens、修复 parseCoCreateResponse draft 丢失 - internal/host/cocreate_doc*.go: 共创对话文档导出能力 + 测试 - TUI cocreate.go: 共创 modal 适配 --- internal/entry/tui/cocreate.go | 4 +- internal/host/cocreate.go | 171 +++++++++++++++++++++++------ internal/host/cocreate_doc.go | 52 +++++++++ internal/host/cocreate_doc_test.go | 72 ++++++++++++ 4 files changed, 266 insertions(+), 33 deletions(-) create mode 100644 internal/host/cocreate_doc.go create mode 100644 internal/host/cocreate_doc_test.go diff --git a/internal/entry/tui/cocreate.go b/internal/entry/tui/cocreate.go index 50f359ac..ef16d6b2 100644 --- a/internal/entry/tui/cocreate.go +++ b/internal/entry/tui/cocreate.go @@ -396,9 +396,9 @@ func coCreateHint(state *cocreateState) string { if state.stage { action = "Ctrl+S 应用并继续" } - return "Enter 继续补充 · " + action + " · ↑↓ 滚对话 · 滚轮滚指令 · Esc 退出" + return "Enter 继续补充 · " + action + " · Ctrl+E 导出 md · ↑↓ 滚对话 · 滚轮滚指令 · Esc 退出" default: - return "Enter 发送 · ↑↓ 滚对话 · 滚轮滚指令 · Esc 退出" + return "Enter 发送 · Ctrl+E 导出 md · ↑↓ 滚对话 · 滚轮滚指令 · Esc 退出" } } diff --git a/internal/host/cocreate.go b/internal/host/cocreate.go index d20711ce..24605511 100644 --- a/internal/host/cocreate.go +++ b/internal/host/cocreate.go @@ -2,6 +2,7 @@ package host import ( "context" + "encoding/json" "fmt" "strings" "time" @@ -14,6 +15,12 @@ import ( // 冷启动共创:从零澄清需求,产出整本书的创作指令。 const coCreateSystemPrompt = `你是一个小说共创助手。你的任务不是直接开始写小说,而是通过多轮简短对话帮助用户澄清创作需求,并持续整理出一段可直接交给创作引擎的中文创作指令。 +## 联网搜索 + +你有 ` + "`web_search`" + ` 工具:当用户问到需要外部资料才能答好的问题(特定流派套路、题材常识、行业背景、时事参考等),先调用 ` + "`web_search(query=...)`" + ` 拉取真实资料,再基于结果给出回复。工具返回 ` + "`summary`" + `(中文总结)+ ` + "`links`" + `(参考链接);若返回空 ` + "`hint`" + ` 说明上游不支持,告诉用户本次未能联网,继续基于已有知识工作,不要重试。无需外部资料的常规澄清不要硬搜,浪费 token。 + +工具调用与最终输出相互独立:调工具那轮不会输出 XML,系统拿到工具结果后会再让你续生成 XML。 + 每一轮回复严格按以下 XML 格式输出,包含四个标签,依次出现,每个标签都必须有正确的开闭标签: @@ -33,6 +40,12 @@ const stageCoCreateSystemPrompt = `你是一个小说"阶段共创"助手。这 铁律:所有建议必须与"当前故事状态"里已发生的剧情、人物、伏笔一致,绝不推翻或忽略已写内容;只规划"后续怎么走",不重新设计整本书。 +## 联网搜索 + +你有 ` + "`web_search`" + ` 工具:当用户问到需要外部资料才能答好的问题(特定流派发展史、力量体系参考、连载套路变迁、行业/历史背景等),先调用 ` + "`web_search(query=...)`" + ` 拉取真实资料,再基于结果给出回复。工具返回 ` + "`summary`" + `(中文总结)+ ` + "`links`" + `(参考链接);若返回空 ` + "`hint`" + ` 说明上游不支持,告诉用户本次未能联网,继续基于已有知识工作,不要重试。无需外部资料的常规规划不要硬搜,浪费 token。 + +工具调用与最终输出相互独立:调工具那轮不会输出 XML,系统拿到工具结果后会再让你续生成 XML。 + 每一轮回复严格按以下 XML 格式输出,包含四个标签,依次出现,每个标签都必须有正确的开闭标签: @@ -83,13 +96,17 @@ const ( tagSuggestions = "suggestions" ) -func coCreateStream(ctx context.Context, models *bootstrap.ModelSet, sessions *store.SessionStore, sysPrompt string, history []CoCreateMessage, onProgress func(kind, text string)) (reply CoCreateReply, err error) { +func coCreateStream(ctx context.Context, models *bootstrap.ModelSet, sessions *store.SessionStore, sysPrompt string, history []CoCreateMessage, onProgress func(kind, text string), webSearchTool webSearchExecuter) (reply CoCreateReply, err error) { if len(history) == 0 { return CoCreateReply{}, fmt.Errorf("cocreate history is empty") } model := models.ForRole("thinking") - ctx, cancel := context.WithTimeout(ctx, 180*time.Second) + // 总预算:1 次主答 (~30s) + 至多 3 次工具往返(每次 web_search 实测 8-38s + 续生成 ~30s ≈ 60s) + // = 30 + 3×60 = 210s 最坏情况。给 300s 留 ~90s 余量,覆盖极端慢路由。 + // 旧值 180s 不够——LLM 调 2-3 次 web_search 就把预算耗光,正在跑的调用被 ctx.DeadlineExceeded + // 切断,错误冒泡到用户那变成"上游服务超时"。 + ctx, cancel := context.WithTimeout(ctx, 300*time.Second) defer cancel() msgs := []agentcore.Message{agentcore.SystemMsg(sysPrompt)} @@ -106,10 +123,17 @@ func coCreateStream(ctx context.Context, models *bootstrap.ModelSet, sessions *s } } - var raw, thinking strings.Builder + // 共创模式下的 web_search 工具规格。LLM 看到这个工具就会自主决定何时调用。 + // cocoCreate 没有完整的 agent loop(GenerateStream 不会自己跑 tool_use → tool_result + // 续生成),下面手动实现一个 mini loop:每次流结束检测 tool_call,执行后把 + // assistant(tool_use) + tool_result 加回 msgs 再续生成。 + var toolSpecs []agentcore.ToolSpec + if webSearchTool != nil { + toolSpecs = []agentcore.ToolSpec{webSearchToolSpec()} + } + const maxCoCreateTurns = 4 // 1 次主答 + 至多 3 次工具往返,避免 LLM 死循环 - // 排查 "cocreate empty response" 等偶发问题需要看到模型实际返回什么。 - // 每轮全程落盘到 /meta/sessions/cocreate.jsonl,与正式创作的 session 日志同位。 + var raw, thinking strings.Builder start := time.Now() defer func() { if sessions == nil { @@ -130,34 +154,74 @@ func coCreateStream(ctx context.Context, models *bootstrap.ModelSet, sessions *s }) }() - streamCh, err := model.GenerateStream(ctx, msgs, nil, agentcore.WithMaxTokens(2048)) - if err != nil { - return CoCreateReply{}, fmt.Errorf("cocreate generate: %w", err) - } + for turn := 0; turn < maxCoCreateTurns; turn++ { + streamCh, err := model.GenerateStream(ctx, msgs, toolSpecs, agentcore.WithMaxTokens(8192)) + if err != nil { + return CoCreateReply{}, fmt.Errorf("cocreate generate: %w", err) + } - var streamed bool - for ev := range streamCh { - switch ev.Type { - case agentcore.StreamEventThinkingDelta: - thinking.WriteString(ev.Delta) - if onProgress != nil { - onProgress(CoCreateProgressThinking, thinking.String()) - } - case agentcore.StreamEventTextDelta: - streamed = true - raw.WriteString(ev.Delta) - if onProgress != nil { - onProgress(CoCreateProgressReply, extractReplyPreview(raw.String())) + var ( + turnText strings.Builder + streamed bool + pendingCalls []agentcore.ToolCall + finalMsg agentcore.Message + ) + for ev := range streamCh { + switch ev.Type { + case agentcore.StreamEventThinkingDelta: + thinking.WriteString(ev.Delta) + if onProgress != nil { + onProgress(CoCreateProgressThinking, thinking.String()) + } + case agentcore.StreamEventTextDelta: + streamed = true + turnText.WriteString(ev.Delta) + if onProgress != nil { + onProgress(CoCreateProgressReply, extractReplyPreview(turnText.String())) + } + case agentcore.StreamEventToolCallEnd: + if ev.CompletedToolCall != nil { + pendingCalls = append(pendingCalls, *ev.CompletedToolCall) + } + case agentcore.StreamEventDone: + finalMsg = ev.Message + if !streamed { + turnText.WriteString(ev.Message.TextContent()) + } + case agentcore.StreamEventError: + if ev.Err != nil { + return CoCreateReply{}, fmt.Errorf("cocreate generate: %w", ev.Err) + } + return CoCreateReply{}, fmt.Errorf("cocreate generate failed") } - case agentcore.StreamEventDone: - if !streamed { - raw.WriteString(ev.Message.TextContent()) + } + + // 把这一轮的文本并入总 raw。多轮工具往返时通常只有最后一轮输出 XML 协议, + // 但稳妥起见用 += 拼接:parseCoCreateResponse 会兜底找不到 XML 标签的情况。 + raw.WriteString(turnText.String()) + + // 没工具调用:mini loop 结束 + if len(pendingCalls) == 0 || webSearchTool == nil { + break + } + + // 有工具调用:执行 web_search,把 assistant(tool_use) + tool_result 加回 msgs 续生成。 + // finalMsg 已含模型输出的 tool_use 块(agentcore 保证 Done 时 Message 完整)。 + msgs = append(msgs, finalMsg) + for _, tc := range pendingCalls { + if tc.Name != "web_search" { + continue } - case agentcore.StreamEventError: - if ev.Err != nil { - return CoCreateReply{}, fmt.Errorf("cocreate generate: %w", ev.Err) + result, execErr := webSearchTool.Execute(ctx, tc.Args) + if execErr != nil { + result, _ = json.Marshal(map[string]any{ + "query": "", + "hint": "工具执行失败:" + execErr.Error(), + }) } - return CoCreateReply{}, fmt.Errorf("cocreate generate failed") + // agentcore.ToolResultMsg 把 tool_result 包成 user 角色带 tool_result 块的消息, + // 符合 Anthropic 协议(tool_result 必须在 user message 里)。 + msgs = append(msgs, agentcore.ToolResultMsg(tc.ID, result, false)) } } @@ -175,6 +239,36 @@ func coCreateStream(ctx context.Context, models *bootstrap.ModelSet, sessions *s return reply, err } +// webSearchExecuter 抽象 *tools.WebSearchTool 的 Execute 方法,让 cocreate_test +// 可以注入 fake。nil 表示不挂搜索(cold start 早期或测试场景)。 +type webSearchExecuter interface { + Execute(ctx context.Context, args json.RawMessage) (json.RawMessage, error) +} + +// webSearchToolSpec 是 cocreate 传给 LLM 的 web_search 工具规格。 +// 与 tools.WebSearchTool.Schema() 同构,但 ToolSpec 是 agentcore 的 wire 格式。 +// 复用 tools 包的描述保持一致,避免 LLM 看到两个版本的 web_search。 +func webSearchToolSpec() agentcore.ToolSpec { + return agentcore.ToolSpec{ + Name: "web_search", + Description: "联网搜索:获取外部资料、流派套路、时事常识、参考资料等。返回模型基于搜索结果生成的总结和原始链接。能否真正联网取决于代理当前路由到的上游是否支持服务端 web_search;不支持时返回空结果与 hint,调用方应基于已有知识继续工作。", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{ + "type": "string", + "description": "搜索关键词(中英文均可,过长会被模型自行精炼)", + }, + "max_results": map[string]any{ + "type": "integer", + "description": "返回链接上限,默认 8(已截取最相关的前 N 条)", + }, + }, + "required": []string{"query"}, + }, + } +} + // coCreateLogEntry 是写入 meta/sessions/cocreate.jsonl 的一行结构。 // 字段命名贴近 jsonl 直查习惯(snake_case),方便 jq 过滤。 type coCreateLogEntry struct { @@ -208,6 +302,13 @@ func assistantMsg(text string) agentcore.Message { // parseCoCreateResponse 解析 XML 标签输出。模型若没遵守协议(直接说自然语言), // 整段作为 reply 显示,draft 留空让 session 保留上一轮。 +// +// ★ 部分遵守协议的 rescue 路径(修历史 bug): +// 旧版只要 reply=="" 就直接 Prompt="" 返回,连已成功解析的 draft 一起丢弃。 +// 这会让 LLM 写了 但漏了 >(常见于 max_tokens 截断 / 模型半遵守协议) +// 时整份大纲消失——用户视角看到的是"本轮 draft 为空",但 raw 里其实有完整内容。 +// 现行逻辑:只有 reply+draft 双空才走"整段当 reply"降级;reply 缺失但 draft 有时 +// 给一个 fallback reply,保住 draft 不被丢弃。 func parseCoCreateResponse(raw string) (CoCreateReply, error) { raw = strings.TrimSpace(raw) if raw == "" { @@ -215,10 +316,18 @@ func parseCoCreateResponse(raw string) (CoCreateReply, error) { } reply, draft, ready, suggestions := splitCoCreateMarkers(raw) - if reply == "" { - // 模型没遵守 XML 协议:整段作为 reply。 + + // 双空:模型完全没遵守协议,整段作为 reply。 + if reply == "" && draft == "" { return CoCreateReply{Message: raw, Prompt: "", Ready: false, Raw: raw}, nil } + + // reply 缺失但 draft 已解析出来:不要丢弃 draft!给个 fallback reply, + // 让用户在 TUI 看到本轮 AI 没给对话文本,但右栏 brief 已经更新。 + if reply == "" { + reply = "_(本轮模型未输出 段,但已更新 ;查看右栏当前创作指令)_" + } + return CoCreateReply{ Message: reply, Prompt: draft, diff --git a/internal/host/cocreate_doc.go b/internal/host/cocreate_doc.go new file mode 100644 index 00000000..8e161a70 --- /dev/null +++ b/internal/host/cocreate_doc.go @@ -0,0 +1,52 @@ +package host + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// SaveCoCreateDoc 把 AI 整理出的创作指令草稿保存为 Markdown 文件。 +// +// 落盘位置:/meta/cocreate/cocreate-YYYYMMDD-HHMMSS.md +// 同一秒内多次保存:追加 -2、-3 后缀避免覆盖。 +// +// content 为空时返回错误(友好提示),不创建空文件。 +func (h *Host) SaveCoCreateDoc(content string) (string, error) { + if h == nil { + return "", fmt.Errorf("host 未初始化") + } + draft := strings.TrimSpace(content) + if draft == "" { + return "", fmt.Errorf("AI 还未整理出创作指令,暂无可导出内容") + } + + dir := filepath.Join(h.Dir(), "meta", "cocreate") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("创建目录失败:%w", err) + } + + base := "cocreate-" + time.Now().Format("20060102-150405") + name := base + ".md" + for i := 2; ; i++ { + full := filepath.Join(dir, name) + f, err := os.OpenFile(full, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + if os.IsExist(err) { + name = fmt.Sprintf("%s-%d.md", base, i) + continue + } + return "", fmt.Errorf("写入失败:%w", err) + } + if _, err := f.WriteString(draft + "\n"); err != nil { + f.Close() + return "", fmt.Errorf("写入失败:%w", err) + } + if err := f.Close(); err != nil { + return "", fmt.Errorf("关闭文件失败:%w", err) + } + return full, nil + } +} diff --git a/internal/host/cocreate_doc_test.go b/internal/host/cocreate_doc_test.go new file mode 100644 index 00000000..b241253d --- /dev/null +++ b/internal/host/cocreate_doc_test.go @@ -0,0 +1,72 @@ +package host + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/voocel/ainovel-cli/internal/store" +) + +// TestSaveCoCreateDoc_EmptyContent 拒绝空 draft:不创建任何文件。 +func TestSaveCoCreateDoc_EmptyContent(t *testing.T) { + dir := t.TempDir() + h := &Host{store: store.NewStore(dir)} + + if _, err := h.SaveCoCreateDoc(" \n "); err == nil { + t.Fatalf("空内容应返回错误") + } + + // 目录不应被创建(避免遗留空目录) + if _, err := os.Stat(filepath.Join(dir, "meta", "cocreate")); !os.IsNotExist(err) { + t.Fatalf("空内容不应创建目录,got err=%v", err) + } +} + +// TestSaveCoCreateDoc_WritesMarkdown 校验落盘路径与内容一致。 +func TestSaveCoCreateDoc_WritesMarkdown(t *testing.T) { + dir := t.TempDir() + h := &Host{store: store.NewStore(dir)} + + draft := "# 创作指令\n\n主角:陆沉;世界观:赛博朋克;\n" + path, err := h.SaveCoCreateDoc(draft) + if err != nil { + t.Fatalf("保存失败:%v", err) + } + + if !strings.HasSuffix(path, ".md") { + t.Errorf("文件应以 .md 结尾,got %s", path) + } + if !strings.HasPrefix(filepath.Base(path), "cocreate-") { + t.Errorf("文件名应以 cocreate- 开头,got %s", filepath.Base(path)) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("读回失败:%v", err) + } + if strings.TrimSpace(string(got)) != strings.TrimSpace(draft) { + t.Errorf("内容不匹配,got=%q want=%q", got, draft) + } +} + +// TestSaveCoCreateDoc_Dedup 同一秒内重复保存:文件名加 -2/-3 后缀。 +func TestSaveCoCreateDoc_Dedup(t *testing.T) { + dir := t.TempDir() + h := &Host{store: store.NewStore(dir)} + + p1, err := h.SaveCoCreateDoc("# A") + if err != nil { + t.Fatalf("第一次保存失败:%v", err) + } + p2, err := h.SaveCoCreateDoc("# B") + if err != nil { + t.Fatalf("第二次保存失败:%v", err) + } + if p1 == p2 { + t.Fatalf("同秒保存应生成不同路径:p1=%s p2=%s", p1, p2) + } + if filepath.Dir(p1) != filepath.Dir(p2) { + t.Fatalf("两次保存应同目录:p1=%s p2=%s", p1, p2) + } +} From 0f66bd35b77a8b406e86e94bab3d70611b984aff Mon Sep 17 00:00:00 2001 From: ciallo Date: Mon, 6 Jul 2026 13:05:53 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20web=5Fsearch?= =?UTF-8?q?=20=E8=81=94=E7=BD=91=E6=90=9C=E7=B4=A2=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过代理触发 Anthropic 服务端 web_search,由代理路由到的上游 provider 在服务端 执行真实搜索,把模型基于结果生成的总结 + 链接列表返回给 architect/coordinator。 路由到的上游不支持 web_search 时返回空结果与 hint,让 architect 优雅降级。 --- internal/tools/web_search.go | 366 +++++++++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 internal/tools/web_search.go diff --git a/internal/tools/web_search.go b/internal/tools/web_search.go new file mode 100644 index 00000000..ca45522d --- /dev/null +++ b/internal/tools/web_search.go @@ -0,0 +1,366 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "regexp" + "strings" + "time" + + "github.com/voocel/agentcore/schema" +) + +// WebSearchTool 通过代理触发 Anthropic 服务端 web_search 工具。 +// +// 实现思路(方案 E):本工具是一个客户端 Tool,但内部不发 Tavily/DuckDuckGo, +// 而是构造一个带 tools:[{type:"web_search_20250305"}] 的 Anthropic /v1/messages +// 请求打到代理,由代理路由到的上游(各 provider 等)在服务端执行 +// 真实搜索,再把模型基于结果生成的总结 + 提取出的链接列表返回给 +// architect/coordinator。 +// +// ★ 路由不确定性:代理按 priority/weight 自己调度,ainovel-cli 这边无法假设 +// 一定走某个特定 provider。如果路由到的上游不支持 web_search_20250305,本工具返回空结果 +// + hint(而非 error),让 architect 优雅降级到"无外部资料"继续工作,避免 +// 死循环重试。 +// +// 选这条路而非 litellm 的 extra_body 透传,是因为 litellm v1.8.5 的 anthropic +// provider 用强类型 anthropicRequest 序列化,ProviderOptions 只识别 metadata +// 两个 key,extra_body.tools 会被静默丢弃或直接报 "unsupported provider option"。 +// +// 配置由 build.go 从 cfg.Providers[主provider] + cfg.ModelName 推导,零额外配置。 +type WebSearchTool struct { + baseURL string // 代理入口,如 http://localhost:23000(不带 /v1) + apiKey string // 代理用户 key(x-api-key 头) + model string // 代理路由用模型名(代理会按自己的 weight/priority 重定向到真实 provider) + httpClient *http.Client + maxResults int // 提取的链接上限,避免响应过大 + maxAttempts int // 总尝试次数(首次 + 重试),覆盖暂时性超时/5xx/429 +} + +func NewWebSearchTool(baseURL, apiKey, model string) *WebSearchTool { + return &WebSearchTool{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + model: model, + httpClient: &http.Client{ + // 单次 HTTP 超时。实测代理→上游的 web_search 中位数 28s、 + // P95 ~38s(见 /tmp/ws_stress_result.json,28 样本);30s 会切断约 + // 43% 的真实请求,导致 LLM 看到"工具执行失败: deadline exceeded" + // 后转述成"上游服务超时"。给 60s 留 ~50% 余量,再叠加下面的重试。 + Timeout: 60 * time.Second, + }, + maxResults: 8, + maxAttempts: 3, // 首次 + 至多 2 次重试,覆盖暂时性 5xx/超时/网络抖动 + } +} + +func (t *WebSearchTool) Name() string { return "web_search" } +func (t *WebSearchTool) Description() string { + return "联网搜索:获取外部资料、流派套路、时事常识、参考资料等。返回模型基于搜索结果生成的总结和原始链接。注意:能否真正联网取决于代理当前路由到的上游是否支持服务端 web_search;不支持时返回空结果与 hint,调用方应基于已有知识继续工作。" +} +func (t *WebSearchTool) Label() string { return "联网搜索" } + +// 纯读工具(不改 store 状态),可被并发调度。 +func (t *WebSearchTool) ReadOnly(_ json.RawMessage) bool { return true } +func (t *WebSearchTool) ConcurrencySafe(_ json.RawMessage) bool { return true } + +func (t *WebSearchTool) Schema() map[string]any { + return schema.Object( + schema.Property("query", schema.String("搜索关键词(中英文均可,过长会被模型自行精炼)")).Required(), + schema.Property("max_results", schema.Int("返回链接上限,默认 8(已截取最相关的前 N 条)")), + ) +} + +func (t *WebSearchTool) Execute(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a struct { + Query string `json:"query"` + MaxResults int `json:"max_results"` + } + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + if strings.TrimSpace(a.Query) == "" { + return nil, fmt.Errorf("query is required") + } + if a.MaxResults > 0 { + t.maxResults = a.MaxResults + } + + payload := map[string]any{ + "model": t.model, + "max_tokens": 2048, + // 服务端搜索工具:上游 provider 把它映射成内部 web_search_prime。 + // max_uses 限制单次请求内模型自主发起的搜索次数,避免它把请求 + // 拖成长链路。5 是经验值(之前 curl 测试中模型用了 4 次)。 + "tools": []map[string]any{ + { + "type": "web_search_20250305", + "name": "web_search", + "max_uses": 5, + }, + }, + "messages": []map[string]any{ + { + "role": "user", + "content": fmt.Sprintf( + "请用 web_search 工具联网搜索:%s\n\n"+ + "要求:\n"+ + "1. 优先搜索 2025-2026 年的资料;\n"+ + "2. 阅读搜索结果后,给出一份精炼的中文总结(聚焦事实,不要空话),不超过 500 字;\n"+ + "3. 总结后另起一行,列出参考链接(标题 + URL),最多 %d 条。\n\n"+ + "如果搜索失败或无结果,直接说明,不要编造。", + a.Query, t.maxResults, + ), + }, + }, + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + + // ctx 预算检查:父 ctx(如 cocreate 300s 总预算)剩余时间不足以完成一次完整调用时, + // 直接降级,避免拖死整个对话——否则父 ctx 超时会切断正在跑的 HTTP 请求, + // 错误冒泡到 LLM 那里被转述成"上游服务超时",体验更差。 + // 阈值用 httpClient.Timeout(60s):剩余不到单次超时就不发,宁可早降级不冒险。 + if deadline, ok := ctx.Deadline(); ok { + remaining := time.Until(deadline) + if remaining < t.httpClient.Timeout { + slog.Warn("web_search 跳过:ctx 剩余预算不足", + "module", "tool", "query", a.Query, + "remaining", remaining.Round(time.Second), + "need", t.httpClient.Timeout) + return json.Marshal(map[string]any{ + "query": a.Query, + "summary": "", + "links": []map[string]string{}, + "count": 0, + "hint": fmt.Sprintf( + "本次对话剩余时间不足(剩 %s,需 %s),跳过联网搜索。"+ + "请基于已有知识继续工作,不要重试本工具。", + remaining.Round(time.Second), t.httpClient.Timeout, + ), + }) + } + } + + // 重试循环:覆盖暂时性网络抖动 / 偶发 5xx / 单次超时。 + // 4xx(除 429)不重试——通常是请求本身问题(上游不支持、参数错误), + // 重试也是浪费。注意:architect 收到 error 会触发 subagentMaxRetries 死循环, + // 所以最终失败仍走降级 hint 路径,绝不让 error 冒泡。 + url := t.baseURL + "/v1/messages" + var lastHTTP int + var lastErr error + var lastBody []byte + for attempt := 1; attempt <= t.maxAttempts; attempt++ { + httpCode, respBody, callErr := t.doOnce(ctx, url, body) + lastHTTP, lastBody, lastErr = httpCode, respBody, callErr + + // 成功路径 + if callErr == nil && httpCode >= 200 && httpCode < 300 { + summary, links := parseAnthropicSearchResponse(respBody, t.maxResults) + result := map[string]any{ + "query": a.Query, + "summary": summary, + "links": links, + "count": len(links), + } + if summary == "" && len(links) == 0 { + slog.Warn("web_search 返回空结果", "module", "tool", + "query", a.Query, "status", httpCode, "attempt", attempt) + result["hint"] = "搜索未返回可用内容(可能是网络问题或上游限流)。可换关键词重试,或基于已有知识继续。" + } + if attempt > 1 { + slog.Info("web_search 重试后成功", "module", "tool", + "query", a.Query, "attempt", attempt) + } + return json.Marshal(result) + } + + // 失败:判断是否可重试 + retryable := isRetryableWebSearchErr(callErr, httpCode) + slog.Warn("web_search 调用失败", + "module", "tool", "query", a.Query, + "attempt", attempt, "max_attempts", t.maxAttempts, + "http", httpCode, "retryable", retryable, "err", callErr) + if !retryable || attempt == t.maxAttempts { + break + } + // 简易线性退避:第 1 次重试前睡 1s,第 2 次前睡 2s。不在 ctx 取消时傻等。 + backoff := time.Duration(attempt) * time.Second + select { + case <-ctx.Done(): + lastErr = ctx.Err() + break + case <-time.After(backoff): + } + } + + // 全部失败:降级返回 hint(绝不冒泡 error,避免 architect 死循环重试)。 + snippet := string(lastBody) + if len(snippet) > 500 { + snippet = snippet[:500] + "...(truncated)" + } + slog.Warn("web_search 全部重试失败,降级返回空结果", + "module", "tool", "query", a.Query, + "last_http", lastHTTP, "last_err", lastErr, "snippet", snippet) + hint := buildFallbackHint(lastHTTP, lastErr) + return json.Marshal(map[string]any{ + "query": a.Query, + "summary": "", + "links": []map[string]string{}, + "count": 0, + "hint": hint, + }) +} + +// doOnce 发一次 HTTP 请求,返回 (http_code, body, err)。 +// 抽出来让 Execute 的重试循环更清爽。 +func (t *WebSearchTool) doOnce(ctx context.Context, url string, body []byte) (int, []byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return 0, nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", t.apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + + resp, err := t.httpClient.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("web_search request failed: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, nil, fmt.Errorf("read response: %w", err) + } + return resp.StatusCode, data, nil +} + +// isRetryableWebSearchErr 判断错误是否值得重试。 +// 可重试:网络错误(含超时)、HTTP 5xx、429。 +// 不可重试:HTTP 4xx(除 429)—— 通常是请求本身问题。 +// 注意:HTTP 200 但 callErr != nil(如读 body 中途断开)也算可重试。 +func isRetryableWebSearchErr(callErr error, httpCode int) bool { + if callErr != nil { + return true + } + if httpCode >= 500 || httpCode == 429 { + return true + } + return false +} + +// buildFallbackHint 根据失败原因生成给 LLM 看的 hint 文本。 +// 始终告诉 LLM"不要重试本工具",避免它在 mini loop 里反复调。 +func buildFallbackHint(httpCode int, lastErr error) string { + if lastErr != nil { + // 多半是 timeout / connection reset / context canceled + return fmt.Sprintf( + "web_search 调用出错:%s。本次跳过联网搜索,"+ + "请基于已有知识继续工作,不要重试本工具。", lastErr.Error(), + ) + } + return fmt.Sprintf( + "上游返回 HTTP %d(可能路由到的 provider 不支持 web_search_20250305 服务端工具)。"+ + "本次跳过联网搜索,请基于已有知识继续工作,不要重试本工具。", + httpCode, + ) +} + +// parseAnthropicSearchResponse 从 Anthropic /v1/messages 响应里提取 +// 模型总结(text 块)和参考链接(tool_result.content 中正则提取的 URL)。 +// +// 上游 provider 返回的 tool_result.content 是 Python repr 形式的字符串(' 而非 "), +// 不能直接 JSON parse;用正则提取 link/title 字段最稳健。 +func parseAnthropicSearchResponse(data []byte, maxResults int) (string, []map[string]string) { + var parsed struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Content json.RawMessage `json:"content,omitempty"` // tool_result.content 可能是 string 或 array + } `json:"content"` + StopReason string `json:"stop_reason"` + } + if err := json.Unmarshal(data, &parsed); err != nil { + return "", nil + } + + var summaryParts []string + for _, block := range parsed.Content { + if block.Type == "text" && strings.TrimSpace(block.Text) != "" { + summaryParts = append(summaryParts, block.Text) + } + } + summary := strings.Join(summaryParts, "\n\n") + + // 链接提取:从原始响应里抓所有 https?:// 链接 + 邻近的 title。 + // 优先从 tool_result.content 区域抓,但简单起见全域抓取然后去重。 + links := extractLinks(string(data), maxResults) + + return summary, links +} + +// linkRe 匹配 http(s) 链接。仅抓标准化 URL 字符(含中文路径),不含引号/空格。 +var linkRe = regexp.MustCompile(`https?://[^\s'"<>\]\\]+`) + +// titleRe 在原始文本中抓 'title': '...' 形式(Python repr)或 "title": "..."(标准 JSON)。 +var titleRe = regexp.MustCompile(`(?:["']title["']\s*:\s*["'])([^"']{1,200})["']`) + +func extractLinks(raw string, maxResults int) []map[string]string { + if maxResults <= 0 { + maxResults = 8 + } + + urls := linkRe.FindAllString(raw, -1) + if len(urls) == 0 { + return nil + } + + seen := make(map[string]bool) + titles := titleRe.FindAllStringSubmatch(raw, -1) + + out := make([]map[string]string, 0, maxResults) + titleIdx := 0 + for _, u := range urls { + // 去重 + 跳过明显的非资源链接(如 schema.org 之类的本体) + if seen[u] || isNoiseURL(u) { + continue + } + seen[u] = true + item := map[string]string{"link": u} + if titleIdx < len(titles) { + item["title"] = titles[titleIdx][1] + titleIdx++ + } + out = append(out, item) + if len(out) >= maxResults { + break + } + } + return out +} + +// isNoiseURL 过滤掉搜索结果中常见的"伪结果"链接(schema、tracker、API 文档等)。 +func isNoiseURL(u string) bool { + for _, skip := range []string{ + "schema.org", + "openid", + "/api/", + "google.com/sorry", + "google.com/url", + "web.archive.org", + } { + if strings.Contains(u, skip) { + return true + } + } + return false +} From 604880e1aa3053dbc1e3f15e09f7f15477fa204b Mon Sep 17 00:00:00 2001 From: ciallo Date: Mon, 6 Jul 2026 13:06:08 +0800 Subject: [PATCH 7/7] =?UTF-8?q?chore:=20TUI=20=E4=B8=8E=20agents/host=20?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E5=B1=82=E9=85=8D=E5=A5=97=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TUI commands.go: 注册 /load、/materials、/rewrite 三个新命令 - TUI model.go / model_update.go: 适配新命令的 modal 状态与事件处理 - agents/build.go: 装配 skill 工具与 web_search 工具到 architect/coordinator - host.go: 暴露 materials / rewrite / cocreate / import 等能力 - assets/prompts: 更新 architect-long/short 与 coordinator 提示词 - 配套 store、tools/novel_context_builders、bootstrap/models、observer_stream 微调 --- assets/prompts/architect-long.md | 21 +++ assets/prompts/architect-short.md | 6 + assets/prompts/coordinator.md | 14 ++ internal/agents/build.go | 58 ++++++- internal/bootstrap/models.go | 2 +- internal/entry/tui/commands.go | 212 +++++++++++++++++++++++ internal/entry/tui/model.go | 55 +++++- internal/entry/tui/model_update.go | 58 ++++++- internal/host/host.go | 101 ++++++++++- internal/host/observer_stream.go | 2 +- internal/store/store.go | 2 + internal/tools/novel_context_builders.go | 20 +++ 12 files changed, 538 insertions(+), 13 deletions(-) diff --git a/assets/prompts/architect-long.md b/assets/prompts/architect-long.md index 89d9cd60..4bd8c942 100644 --- a/assets/prompts/architect-long.md +++ b/assets/prompts/architect-long.md @@ -4,6 +4,27 @@ - **novel_context**: 获取参考模板和当前状态。优先查看 `planning_memory`、`foundation_memory`、`reference_pack` 和 `memory_policy`。`working_memory.user_rules` 是用户对本书的长期偏好(`structured` 机械约束含 chapter_words + `preferences` 自然语言偏好),规划/扩展大纲时一并遵守,与参考模板冲突时用户要求优先。 - **save_foundation**: 保存基础设定。 +- **search_skills**: 本地 skill 库检索。当需要题材套路、结构模板、风格预设、流派发展史、连载套路等可复用经验时,**优先**调用本工具。返回 top-N 本地 skill 的 name/description/category/tags/priority;命中后再调 `read_skill(name=...)` 读全文。零成本、零延迟、跨书复用。 +- **read_skill**: 读取本地 skill 全文(需先通过 `search_skills` 拿到 name)。 +- **web_search**: 可选,联网搜索。长篇规划常涉及外部资料(如世界观考据、力量体系参考、特定流派发展史、连载套路变迁),需要时主动调用,但**仅在 search_skills 未命中后使用**。传入精炼关键词(中英文均可)。返回 `summary`(基于搜索结果的中文总结)+ `links`(参考链接列表)。若返回空结果或 `hint` 说明上游不支持,**基于已有知识继续工作,不要重试**。 +- **save_materials** / **list_materials** / **remove_material**: 项目级素材库(meta/materials.json)读写工具。在规划前搜集到的命名表、术语表、视觉锚点、设定资料、参考资料等可复用素材通过 `save_materials` 批量持久化;后续 `novel_context.reference_pack.materials` 会自动注入这些素材给所有子代理消费,writer 写新场景/新角色时按需取用。 + +**检索优先级**:`search_skills`(本地)→ `web_search`(联网)。不要本末倒置。 + +## 素材收集(规划前必做) + +长篇规划涉及大量细节(人名 / 地名 / 组织名 / 力量体系 / 历史年表 / 视觉锚点 等)。**调 `save_foundation` 之前**,先把这些素材沉淀到项目素材库,避免在 premise/outline 里临时编造导致后期不一致: + +1. **盘点**:先调 `list_materials` 看本地已有哪些素材,避免重复搜集。 +2. **搜集**:从 `search_skills`(跨书经验,可能含题材命名套路)+ `web_search`(外部考据资料)+ 自身知识。 +3. **沉淀**:把搜集到的素材调一次 `save_materials` 批量写入。每条 item 含: + - `category`:`naming`(命名表)/ `terminology`(术语表)/ `visual`(视觉锚点)/ `setting`(设定资料)/ `reference`(参考资料);可自定义。 + - `title`:一句话标题,便于检索("赛博朋克巨型企业命名候选")。 + - `content`:素材正文,Markdown,原样注入 novel_context。命名表用列表、设定用段落、参考资料用"标题+摘要"。 + - `source`:来源标记便于追溯(`web_search:query=xxx` / `skill:` / `builtin`)。 +4. **去重**:保存后用 `list_materials` 抽查,发现重复或不准的用 `remove_material(id=...)` 删除。 + +素材库是项目级的(不会跨书污染);只放**本书会用到的具体资料**——跨书经验走 `add_skill`(完结时由 coordinator 沉淀)。典型量:每本书 5-20 条素材。 ## 硬约束 diff --git a/assets/prompts/architect-short.md b/assets/prompts/architect-short.md index 1faecdf1..329c8b28 100644 --- a/assets/prompts/architect-short.md +++ b/assets/prompts/architect-short.md @@ -4,6 +4,12 @@ - **novel_context**: 获取参考模板和当前状态。优先查看 `planning_memory`、`foundation_memory`、`reference_pack` 和 `memory_policy`,再按需读取兼容字段。`working_memory.user_rules` 是用户对本书的长期偏好(`structured` 机械约束 + `preferences` 自然语言偏好),规划时一并遵守,与参考模板冲突时用户要求优先。 - **save_foundation**: 保存基础设定 +- **search_skills**: 本地 skill 库检索。当需要题材套路、结构模板、风格预设、流派常识等可复用经验时,**优先**调用本工具。返回 top-N 本地 skill 的 name/description/category/tags/priority;命中后再调 `read_skill(name=...)` 读全文。零成本、零延迟、跨书复用。 +- **read_skill**: 读取本地 skill 全文(需先通过 `search_skills` 拿到 name)。 +- **web_search**: 可选,联网搜索。当题材需要外部资料(如硬科幻常识、特定流派套路、历史/行业背景、时事参考)且 **search_skills 未命中**时调用,传入精炼关键词。返回 `summary`(基于搜索结果的中文总结)+ `links`(参考链接列表)。若返回空结果或 `hint` 说明上游不支持,**基于已有知识继续工作,不要重试**。 +- **save_materials** / **list_materials** / **remove_material**: 项目级素材库(meta/materials.json)读写工具。短篇虽小但同样需要素材——命名 / 术语 / 视觉锚点。规划前先 `list_materials` 盘点,再 `save_materials` 把搜集到的素材批量入库。category 用 naming/terminology/visual/setting/reference;详见工具 schema。 + +**检索优先级**:`search_skills`(本地)→ `web_search`(联网)。不要本末倒置。 ## 硬约束 diff --git a/assets/prompts/coordinator.md b/assets/prompts/coordinator.md index bb4f2d5f..66522853 100644 --- a/assets/prompts/coordinator.md +++ b/assets/prompts/coordinator.md @@ -56,15 +56,29 @@ architect 返回后读 `save_foundation` 的 `foundation_ready`: - **要求重写/打磨已完成的章节** → 调 `reopen_book(chapters=[...], reason=...)` 把全书重新打开并把目标章入队,然后**等 Host 指令**——Host 会派 writer 逐章返工,全部改完后自动重新收尾完结。不要在 reopen 前先派 `subagent`,也**不需要** `save_pause_point`(返工完自动重新完结,run 自然停机就是验收点)。 - **要求续写新增剧情/扩展篇幅**(不是改旧章)→ 这超出返工范围,按上面"篇幅调整"判据处理;若确实只想在已完结的书上加章节而非重规划,告知"全书已完结,如需续写新增剧情请新建项目"。 +**完结学习(可选)**:输出全书总结前,检查本次创作是否形成了可跨书复用的经验(新题材套路、解决过的结构难题、效果好的开篇模板、有效的伏笔回收方式、好用的标题节奏等)。如有,调 `add_skill(name=..., description=..., category=..., body=..., tags=[...], triggers=[...])` 把它沉淀到 `~/.ainovel/skills/` 跨书库。要求: +- 只提炼**真正可复用的**,不要把本书独有设定当通用套路; +- name 用 kebab-case 英文(如 `cyberpunk-noir-checklist`、`three-act-short`); +- description 一句话写用途; +- body 建议用 when/do/checklist 结构; +- 没有可提炼的**不要硬加**; +- 每次完结最多沉淀 2-3 条,贪多反而稀释质量。 + ## 工具与子代理 - `subagent(agent, task)`:调用子代理 - `novel_context`:**仅**在用户查询需要时使用;Host 指令到达后禁止先调它(指令注明"第 N 次下达"时除外) +- `search_skills`: 本地 skill 库检索。当用户查询涉及题材套路、结构模板、风格预设、流派常识等可复用经验时使用;也用于判断当前需求是否可复用过往经验。返回 name/description/category/tags/priority;命中后再调 `read_skill(name=...)` 读全文。 +- `read_skill`: 读取本地 skill 全文(需先通过 `search_skills` 拿到 name)。 +- `add_skill`: 仅全书完结后使用(见"### 全书完成"的完结学习说明)。把本次创作中形成的可复用经验沉淀为跨书 skill。 - `save_user_rules(text)`:把用户长效的"怎么写"风格/质量要求归一化为结构化规则并持久化(**仅**用户干预属于写作笔法/风格/质量规则时使用;剧情/结构走 architect、返工走 editor;返回的理解需回显给用户确认) - `reopen_book(chapters, reason)`:把已完结(phase=complete)的全书重开进返工态并把目标章入队(**仅**完本后用户要求返工已写章节时使用) - `save_pause_point(after, reason)` / `save_pause_point(cancel=true)`:登记/取消验收停靠点(**仅**写作期用户要求改已写章节且未表达继续写意图时,在派 editor 前登记;重写队列排空后系统自动暂停等验收) +- `web_search(query)`:可选,联网搜索外部资料(题材常识、流派套路、参考资料等)。**仅**在调度判断确实需要外部知识补足且 `search_skills` 未命中时使用;写作期不主动调(writer 自己不挂这工具)。返回 `summary` + `links`;返回空或 `hint` 时基于已有信息继续,不要重试。 - 子代理:`architect_long` / `architect_short` / `writer` / `editor` +**检索优先级**:`search_skills`(本地)→ `web_search`(联网)。本地 skill 零成本、跨书复用,应优先尝试。 + ## 禁止 - 在 Host 指令到达时先调 novel_context 或输出推理再行动 diff --git a/internal/agents/build.go b/internal/agents/build.go index 5a828999..10122f1c 100644 --- a/internal/agents/build.go +++ b/internal/agents/build.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "log/slog" + "path/filepath" "regexp" "strconv" "strings" @@ -22,6 +23,7 @@ import ( "github.com/voocel/ainovel-cli/internal/domain" "github.com/voocel/ainovel-cli/internal/host/reminder" "github.com/voocel/ainovel-cli/internal/rules" + "github.com/voocel/ainovel-cli/internal/skills" "github.com/voocel/ainovel-cli/internal/store" "github.com/voocel/ainovel-cli/internal/tools" "github.com/voocel/ainovel-cli/internal/userrules" @@ -121,9 +123,25 @@ func BuildCoordinator( recordUsage UsageRecorder, onFlowBoundary FlowBoundaryHook, onGuardBlock reminder.BlockHook, -) (*agentcore.Agent, *tools.AskUserTool, *ctxpack.WriterRestorePack, *corecontext.ContextEngine, ApplyThinking) { +) (*agentcore.Agent, *tools.AskUserTool, *ctxpack.WriterRestorePack, *corecontext.ContextEngine, ApplyThinking, *tools.WebSearchTool, *skills.Store) { // 共享工具 contextTool := tools.NewContextTool(store, bundle.References, cfg.Style) + + // 跨书 skill 库。失败时不阻断主流程:skill 工具返回空结果,走 web_search fallback。 + skillStore := skills.NewStore(filepath.Join(bootstrap.DefaultConfigDir(), "skills")) + if err := skillStore.Refresh(); err != nil { + slog.Warn("skill 库初始化失败,本次禁用", "module", "agent", "err", err) + skillStore = nil + } + + // 联网搜索工具:复用主 provider 链路(代理→上游 provider,触发 Anthropic 服务端 + // web_search_20250305)。零额外配置,baseURL/apiKey/model 都从顶层推导。 + // 仅挂载到 architect 与 coordinator:写作阶段(writer/editor)需要的是 + // 故事内一致性而非外部资料,挂上去反而引入幻觉风险。 + var webSearchTool *tools.WebSearchTool + if pc, ok := cfg.Providers[cfg.Provider]; ok && pc.BaseURL != "" { + webSearchTool = tools.NewWebSearchTool(pc.BaseURL, pc.APIKey, cfg.ModelName) + } // 用户规则服务:归一化各来源 → 确定性合并 → 落盘本书快照。Coordinator 的 // save_user_rules 工具复用它做运行中更新;归一化用 Default 模型(与 Host 开书侧一致)。 userRulesSvc := userrules.NewService(store, models.Default, rules.DefaultOptions()) @@ -133,6 +151,21 @@ func BuildCoordinator( architectTools := []agentcore.Tool{ contextTool, tools.NewSaveFoundationTool(store), + // 素材库写工具:architect 收集到的命名/术语/视觉/设定素材落盘到 meta/materials.json, + // 后续 novel_context.reference_pack.materials 自动注入。 + tools.NewSaveMaterialsTool(store), + tools.NewListMaterialsTool(store), + tools.NewRemoveMaterialTool(store), + } + if webSearchTool != nil { + architectTools = append(architectTools, webSearchTool) + } + // 本地 skill 优先于联网:architect 规划时先看跨书经验,未命中再查外部。 + if skillStore != nil { + architectTools = append(architectTools, + tools.NewSkillSearchTool(skillStore), + tools.NewSkillReadTool(skillStore), + ) } writerTools := []agentcore.Tool{ contextTool, @@ -341,10 +374,29 @@ func BuildCoordinator( CommitOnProject: true, }) + coordinatorTools := []agentcore.Tool{ + subagentTool, + contextTool, + tools.NewSaveUserRulesTool(userRulesSvc), + tools.NewReopenBookTool(store), + tools.NewSavePausePointTool(store), + } + if webSearchTool != nil { + coordinatorTools = append(coordinatorTools, webSearchTool) + } + // coordinator 也挂 skill 读工具,用于"用户查询"类场景;写工具 add_skill 仅限完结学习。 + if skillStore != nil { + coordinatorTools = append(coordinatorTools, + tools.NewSkillSearchTool(skillStore), + tools.NewSkillReadTool(skillStore), + tools.NewSkillAddTool(skillStore), + ) + } + agent := agentcore.NewAgent( agentcore.WithModel(coordinatorModel), agentcore.WithSystemPrompt(bundle.Prompts.Coordinator), - agentcore.WithTools(subagentTool, contextTool, tools.NewSaveUserRulesTool(userRulesSvc), tools.NewReopenBookTool(store), tools.NewSavePausePointTool(store)), + agentcore.WithTools(coordinatorTools...), agentcore.WithMaxTurns(100_000), agentcore.WithOnMessage(coordinatorOnMessage), agentcore.WithToolsAreIdempotent(true), @@ -384,7 +436,7 @@ func BuildCoordinator( } } - return agent, askUser, restore, coordinatorEngine, applyThinking + return agent, askUser, restore, coordinatorEngine, applyThinking, webSearchTool, skillStore } func flowBoundaryMiddleware(onBoundary FlowBoundaryHook) agentcore.ToolMiddleware { diff --git a/internal/bootstrap/models.go b/internal/bootstrap/models.go index 1a231c06..cb1b7c5d 100644 --- a/internal/bootstrap/models.go +++ b/internal/bootstrap/models.go @@ -14,7 +14,7 @@ import ( "github.com/voocel/ainovel-cli/internal/errs" ) -// 长输出 + 长 ctx 场景下,reasoning-aware provider(mimo / deepseek-r1 等) +// 长输出 + 长 ctx 场景下,reasoning-aware provider(如某些支持 reasoning 的模型) // 思考阶段如果 server 端不流式发 reasoning delta,SSE 整段会保持沉默。 // litellm 默认 watchdog 是 2 分钟,对 8000 字写作章节经常触发误杀。 // 5 分钟覆盖绝大多数实测案例(参见 tasks/todo.md plan→draft 思考时长统计), diff --git a/internal/entry/tui/commands.go b/internal/entry/tui/commands.go index d71e7390..61f5b482 100644 --- a/internal/entry/tui/commands.go +++ b/internal/entry/tui/commands.go @@ -1,13 +1,70 @@ package tui import ( + "fmt" + "os" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/voocel/ainovel-cli/internal/host" + "github.com/voocel/ainovel-cli/internal/skills" ) +// resolveRewritePending 解析 /rewrite 的参数为重写后的新方向(pendingArgs)。 +// +// 三种形态: +// - 无参数:pendingArgs 为空,授权后用户自己填 +// - @<路径>:读取文件内容(共创导出的 md),作为新方向 +// - 普通文本:原样作为新方向 +// +// 返回 (pendingArgs, cmd, err):cmd 用于在 @ 路径解析阶段调度额外命令(目前恒为 nil)。 +func resolveRewritePending(m Model, args []string) (string, tea.Cmd, error) { + if len(args) == 0 { + return "", nil, nil + } + first := args[0] + if strings.HasPrefix(first, "@") || isLikelyPathOnly(args) { + // @path 形式:把所有 args 合并为单一路径字符串(支持路径含空格的边缘场景) + raw := strings.Join(args, " ") + content, err := loadFileAsPrompt(m.runtime.Dir(), raw) + if err != nil { + return "", nil, err + } + return content, nil, nil + } + return strings.Join(args, " "), nil, nil +} + +// isLikelyPathOnly 当首个 arg 看起来像路径(含 / 或 .md 后缀)而 args 总长很短时, +// 把整段 args 当路径解析。避免误把含 / 的中文需求当路径。 +func isLikelyPathOnly(args []string) bool { + if len(args) == 0 || len(args) > 2 { + return false + } + // 仅当看起来像 "path" 或 "path with space" 这种短结构 + for _, a := range args { + if strings.Contains(a, "\n") { + return false + } + } + first := args[0] + if strings.HasSuffix(first, ".md") { + return true + } + // 含 / 但开头不是中文(用首字节粗略判断:ASCII / UTF-8 多字节首位 >= 0x80) + if strings.Contains(first, "/") { + if first == "" { + return false + } + if first[0] >= 0x80 { + return false + } + return true + } + return false +} + type slashCommandSpec struct { Name string Aliases []string @@ -212,6 +269,140 @@ func commandRegistryInstance() commandRegistry { return m, cmd }, }, + { + Name: "materials", + Aliases: []string{"material", "素材"}, + Group: "writing", + Usage: "/materials [需求描述]", + Description: "搜集素材并筛选入库(项目级 meta/materials.json)", + NeedsIdle: true, + Run: func(m Model, args []string) (tea.Model, tea.Cmd) { + userPrompt := strings.Join(args, " ") + if strings.TrimSpace(userPrompt) == "" { + // 无参数时尝试用本书 premise 作 prompt;没有 premise 就报错 + if prem, _ := m.runtime.LoadPremise(); strings.TrimSpace(prem) != "" { + userPrompt = prem + } else { + m.applyEvent(host.Event{ + Time: time.Now(), Category: "ERROR", + Summary: "请提供需求描述,例:/materials 赛博朋克短篇 霓虹残响", + Level: "error", + }) + m.refreshEventViewport() + return m, nil + } + } + m.materialsSeq++ + state := newMaterialsState(userPrompt) + m.materials = state + m.textarea.Blur() + return m, runMaterialsCollect(m.runtime, userPrompt) + }, + }, + { + Name: "rewrite", + Aliases: []string{"reset", "重写"}, + Group: "system", + Usage: "/rewrite [新方向 | @路径]", + Description: "清空全量 foundation 重新规划(需输入 yes/确认 授权)", + NeedsIdle: true, + Run: func(m Model, args []string) (tea.Model, tea.Cmd) { + pending, cmd, err := resolveRewritePending(m, args) + if err != nil { + m.applyEvent(host.Event{ + Time: time.Now(), Category: "ERROR", + Summary: err.Error(), Level: "error", + }) + m.refreshEventViewport() + return m, nil + } + m.rewriteConfirm = newRewriteConfirmState(pending) + m.textarea.Blur() + return m, cmd + }, + }, + { + Name: "load", + Aliases: []string{"导入"}, + Group: "writing", + Usage: "/load <路径> [--send]", + Description: "加载 md 文件:≤32KB 进输入框可编辑,>32KB 自动直接发送(也可显式 --send 强制发送)", + Run: func(m Model, args []string) (tea.Model, tea.Cmd) { + if len(args) == 0 { + m.applyEvent(host.Event{ + Time: time.Now(), Category: "ERROR", + Summary: "请提供路径,例:/load meta/cocreate/x.md [--send]", + Level: "error", + }) + m.refreshEventViewport() + return m, nil + } + sendMode := false + var pathArgs []string + for _, a := range args { + if a == "--send" || a == "-s" { + sendMode = true + } else { + pathArgs = append(pathArgs, a) + } + } + if len(pathArgs) == 0 { + m.applyEvent(host.Event{ + Time: time.Now(), Category: "ERROR", + Summary: "请提供路径,例:/load meta/cocreate/x.md [--send]", + Level: "error", + }) + m.refreshEventViewport() + return m, nil + } + var content string + var err error + // 自动判断:文件超过 maxLoadFileSize 时自动启用 send 模式(绕过 32KB 限制), + // 否则按原行为加载到输入框。用户也可显式 --send 强制发送。 + autoSend := false + if abs, statErr := resolveProjectPath(m.runtime.Dir(), pathArgs[0]); statErr == nil { + if info, infoErr := os.Stat(abs); infoErr == nil && !info.IsDir() { + autoSend = info.Size() > maxLoadFileSize + } + } + effectiveSend := sendMode || autoSend + if effectiveSend { + content, err = loadFileForSend(m.runtime.Dir(), pathArgs[0]) + } else { + content, err = loadFileAsPrompt(m.runtime.Dir(), pathArgs[0]) + } + if err != nil { + m.applyEvent(host.Event{ + Time: time.Now(), Category: "ERROR", + Summary: err.Error(), Level: "error", + }) + m.refreshEventViewport() + return m, nil + } + if effectiveSend { + hint := "直接发送文件内容" + if autoSend && !sendMode { + hint = fmt.Sprintf("文件超过 %d 字节,自动切换为直接发送", maxLoadFileSize) + } + m.applyEvent(host.Event{ + Time: time.Now(), Category: "SYSTEM", + Summary: fmt.Sprintf("%s:%d 字符", hint, len([]rune(content))), + Level: "info", + }) + m.refreshEventViewport() + return m.submitUserText(content) + } + m.textarea.SetValue(content) + m.refitTextareaHeight() + m.applyEvent(host.Event{ + Time: time.Now(), Category: "SYSTEM", + Summary: fmt.Sprintf("已加载 %d 字符到输入框(按 Enter 提交,或继续编辑)", len([]rune(content))), + Level: "info", + }) + m.refreshEventViewport() + return m, nil + }, + }, }) } @@ -222,6 +413,27 @@ func commandSpecs() []slashCommandSpec { func (m Model) handleSlashCommand(cmd slashCommand) (tea.Model, tea.Cmd) { spec, ok := commandRegistryInstance().Find(cmd.name) if !ok { + // 未注册命令:尝试作为 skill 引用展开(/skill-name 用户消息) + if m.runtime != nil { + userMsg := strings.Join(cmd.args, " ") + result := m.runtime.SkillInject(skills.InjectRequest{ + SkillName: cmd.name, + UserMsg: userMsg, + }) + if result.Expanded { + return m.submitUserText(result.Text) + } + // 未命中:显示 hint,输入框已 reset,让用户重输 + hint := result.Hint + if hint == "" { + hint = "未知命令:/" + cmd.name + } + m.applyEvent(host.Event{ + Time: time.Now(), Category: "ERROR", Summary: hint, Level: "error", + }) + m.refreshEventViewport() + return m, nil + } m.applyEvent(host.Event{ Time: time.Now(), Category: "ERROR", Summary: "未知命令:/" + cmd.name, Level: "error", }) diff --git a/internal/entry/tui/model.go b/internal/entry/tui/model.go index fa49cdc3..7ad0b7ab 100644 --- a/internal/entry/tui/model.go +++ b/internal/entry/tui/model.go @@ -12,6 +12,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/voocel/ainovel-cli/internal/host" + "github.com/voocel/ainovel-cli/internal/skills" "github.com/voocel/ainovel-cli/internal/tools" "github.com/voocel/ainovel-cli/internal/utils" ) @@ -65,9 +66,13 @@ type Model struct { importSeq int simulator *simulationState simSeq int + materials *materialsState + materialsSeq int + rewriteConfirm *rewriteConfirmState compItems []commandPaletteItem compIdx int compActive bool + compLevel paletteLevel // 当前菜单层级:命令列表 / skill 子菜单 snapshot host.UISnapshot events []host.Event eventIndex map[string]int // event.ID → m.events 下标;调用类事件到达时原地更新 @@ -618,6 +623,12 @@ func (m Model) View() string { if m.simulator != nil { return renderSimulationModal(m.width, m.height, m.simulator) } + if m.materials != nil { + return renderMaterialsModal(m.width, m.height, m.materials, m.currentSpinnerFrame()) + } + if m.rewriteConfirm != nil { + return renderRewriteConfirmModal(m.width, m.height, m.rewriteConfirm) + } topBar := renderTopBar(m.snapshot, m.width, m.currentSpinnerFrame(), m.version) inputBox := m.renderBottomBar() @@ -661,7 +672,7 @@ func (m Model) View() string { commandBar := renderModelSwitchBar(m.width, m.modelSwitch) view = overlayAboveInput(view, commandBar, inputH) } else if m.compActive { - commandBar := renderCommandPalette(m.width, m.compItems, m.compIdx) + commandBar := renderCommandPalette(m.width, m.compLevel, m.compItems, m.compIdx) view = overlayAboveInput(view, commandBar, inputH) } return view @@ -738,6 +749,33 @@ func (m Model) handleCoCreateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // 让 Enter 节流先于 awaiting 屏蔽——这样粘贴的 \n 残片仍能补空格。 switch msg.Type { + case tea.KeyCtrlE: + if state.awaiting { + return m, nil + } + draft := strings.TrimSpace(state.draftPrompt()) + if draft == "" { + m.applyEvent(host.Event{ + Time: time.Now(), Category: "ERROR", + Summary: "AI 还未整理出创作指令,暂无可导出内容", Level: "error", + }) + m.refreshEventViewport() + return m, nil + } + path, err := m.runtime.SaveCoCreateDoc(draft) + if err != nil { + m.applyEvent(host.Event{ + Time: time.Now(), Category: "ERROR", + Summary: "导出失败:" + err.Error(), Level: "error", + }) + } else { + m.applyEvent(host.Event{ + Time: time.Now(), Category: "SYSTEM", + Summary: "已导出创作指令到:" + path, Level: "info", + }) + } + m.refreshEventViewport() + return m, nil case tea.KeyCtrlS: if state.awaiting { return m, nil @@ -784,6 +822,21 @@ func (m Model) handleCoCreateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if text == "" { return m, nil } + // /skill-name 主动调用:展开为 skill 全文 + 用户补充 + if req, ok := skills.ParseSkillRef(text); ok { + if m.runtime != nil { + result := m.runtime.SkillInject(req) + if result.Expanded { + text = result.Text + } else if hint := strings.TrimSpace(result.Hint); hint != "" { + m.applyEvent(host.Event{ + Time: time.Now(), Category: "ERROR", Summary: hint, Level: "error", + }) + m.refreshEventViewport() + return m, nil + } + } + } m.err = nil state.appendUser(text) m.textarea.Reset() diff --git a/internal/entry/tui/model_update.go b/internal/entry/tui/model_update.go index 8ee2bedd..179f3e7d 100644 --- a/internal/entry/tui/model_update.go +++ b/internal/entry/tui/model_update.go @@ -73,6 +73,10 @@ func (m Model) handleOverlayKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { return m.handleBlockingModalKey(msg, m.handleImportKey) case m.simulator != nil: return m.handleBlockingModalKey(msg, m.handleSimulationKey) + case m.materials != nil: + return m.handleBlockingModalKey(msg, m.handleMaterialsKey) + case m.rewriteConfirm != nil: + return m.handleBlockingModalKey(msg, m.handleRewriteConfirmKey) default: return m, nil, false } @@ -127,8 +131,31 @@ func (m Model) handleCommandPaletteKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool switch msg.Type { case tea.KeyEsc: + // Skill 子菜单:Esc 返回命令列表 + // 命令列表:Esc 关闭菜单 + if m.compLevel == paletteLevelSkills { + m.exitSkillSubMenu() + return m, nil, true + } m.clearCommandPalette() return m, nil, true + case tea.KeyLeft: + // ← 在 Skill 子菜单返回上级;命令列表忽略 + if m.compLevel == paletteLevelSkills { + m.exitSkillSubMenu() + return m, nil, true + } + return m, nil, false + case tea.KeyRight: + // → 在命令列表选中 Skill 入口时进入子菜单;其他情况忽略 + if m.compLevel == paletteLevelCommands { + item, ok := m.selectedCommandItem() + if ok && item.Kind == kindSkillEntry { + m.enterSkillSubMenu() + return m, nil, true + } + } + return m, nil, false case tea.KeyUp: if m.compIdx > 0 { m.compIdx-- @@ -139,14 +166,13 @@ func (m Model) handleCommandPaletteKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool m.compIdx++ } return m, nil, true - case tea.KeyTab: - m.acceptCommandCompletion() - return m, nil, true - case tea.KeyEnter: + case tea.KeyTab, tea.KeyEnter: item, ok := m.acceptCommandCompletion() + // ok=false 表示"已切换层级"(进入或退出子菜单),textarea 已重置,等下次按键循环 if !ok { return m, nil, true } + // Skill 入口不会走到这里(acceptCommandCompletion 一级 Skill 入口返回 ok=false) if item.AutoExecute { m.textarea.Reset() next, cmd := m.handleSlashCommand(slashCommand{name: item.Name}) @@ -289,6 +315,13 @@ func (m Model) handleEnterKey() (tea.Model, tea.Cmd) { m.pushInputHistory(text) m.textarea.Reset() m.refitTextareaHeight() + return m.submitUserText(text) +} + +// submitUserText 把一段文本作为用户输入提交到当前模式(modeNew/modeRunning/modeDone)。 +// 抽出来是为了让 skill 注入(/skill-name 展开)能复用同一套提交流程—— +// skill 注入本质上是把 /name + 用户消息替换为一段更长的用户消息。 +func (m Model) submitUserText(text string) (tea.Model, tea.Cmd) { switch m.mode { case modeNew: m.err = nil @@ -512,6 +545,23 @@ func (m Model) handleRuntimeMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { return m, nil, true } return m, listenSimulationEvent(msg.reqID, msg.ch), true + case materialsCollectResultMsg: + // 后台 LLM 收集完成:把候选填充到 state 并切到 selecting 阶段。 + // m.materials 为空说明用户已 Esc 取消,结果丢弃即可。 + if m.materials == nil { + return m, nil, true + } + if msg.err != nil { + m.applyEvent(host.Event{ + Time: time.Now(), Category: "ERROR", + Summary: "素材收集失败:" + msg.err.Error(), Level: "error", + }) + m.refreshEventViewport() + m.materials = nil + return m, nil, true + } + m.materials.applyCollect(msg.candidates, msg.raw) + return m, nil, true case exportDoneMsg: if msg.err != nil { m.applyEvent(host.Event{ diff --git a/internal/host/host.go b/internal/host/host.go index c4f52038..6bf36642 100644 --- a/internal/host/host.go +++ b/internal/host/host.go @@ -25,6 +25,7 @@ import ( modelreg "github.com/voocel/ainovel-cli/internal/models" "github.com/voocel/ainovel-cli/internal/notify" "github.com/voocel/ainovel-cli/internal/rules" + "github.com/voocel/ainovel-cli/internal/skills" storepkg "github.com/voocel/ainovel-cli/internal/store" "github.com/voocel/ainovel-cli/internal/tools" "github.com/voocel/ainovel-cli/internal/userrules" @@ -42,6 +43,8 @@ type Host struct { coordinatorCtxMgr *corecontext.ContextEngine // 切 default/coordinator 模型时联动 SetContextWindow + SetReserveTokens thinkingApplier agents.ApplyThinking // /model 调推理强度时联动 live agent(coordinator + 子代理) askUser *tools.AskUserTool + webSearch *tools.WebSearchTool + skillStore *skills.Store writerRestore *ctxpack.WriterRestorePack observer *observer router *flow.Dispatcher @@ -121,7 +124,7 @@ func New(cfg bootstrap.Config, bundle assets.Bundle) (*Host, error) { var pauser *PausePointSentinel // onGuardBlock 与 router/budget 同款前置声明:h 构造后才能挂事件浮出闭包。 var onGuardBlock func(agent, reason string, consecutive int32) - coordinator, askUser, restore, coordinatorCtxMgr, applyThinking := agents.BuildCoordinator(cfg, store, models, bundle, usage.Record, func(string) { + coordinator, askUser, restore, coordinatorCtxMgr, applyThinking, webSearchTool, skillStore := agents.BuildCoordinator(cfg, store, models, bundle, usage.Record, func(string) { if budget != nil && budget.HandleBoundary() { return } @@ -148,6 +151,8 @@ func New(cfg bootstrap.Config, bundle assets.Bundle) (*Host, error) { coordinatorCtxMgr: coordinatorCtxMgr, thinkingApplier: applyThinking, askUser: askUser, + webSearch: webSearchTool, + skillStore: skillStore, writerRestore: restore, usage: usage, usageCancel: usageCancel, @@ -1119,13 +1124,13 @@ func (h *Host) ReplayQueue(afterSeq int64) ([]domain.RuntimeQueueItem, error) { // CoCreateStream 冷启动共创:从零澄清需求,产出整本书的创作指令。 func (h *Host) CoCreateStream(ctx context.Context, history []CoCreateMessage, onProgress func(kind, text string)) (CoCreateReply, error) { - return coCreateStream(ctx, h.models, h.store.Sessions, coCreateSystemPrompt, history, onProgress) + return coCreateStream(ctx, h.models, h.store.Sessions, coCreateSystemPrompt, history, onProgress, h.webSearch) } // StageCoCreateStream 阶段共创:在已写内容的基础上规划后续方向。 // 系统提示 = 阶段 prompt + 当前故事状态摘要,让助手知道"已经写了什么"。 func (h *Host) StageCoCreateStream(ctx context.Context, history []CoCreateMessage, onProgress func(kind, text string)) (CoCreateReply, error) { - return coCreateStream(ctx, h.models, h.store.Sessions, stageSystemPrompt(h.store), history, onProgress) + return coCreateStream(ctx, h.models, h.store.Sessions, stageSystemPrompt(h.store), history, onProgress, h.webSearch) } // stagePlanPrefix 把共创产出的"后续方向 brief"包装成一条阶段规划干预,交 Coordinator 裁定。 @@ -1284,3 +1289,93 @@ func (h *Host) guardExclusive(action string) error { func (h *Host) Export(ctx context.Context, opts exp.Options) (*exp.Result, error) { return exp.Run(ctx, exp.Deps{Store: h.store}, opts) } + +// SkillStore 返回跨书 skill 库的引用。nil 表示 skill 库未启用 +// (路径解析失败或被禁用),调用方应优雅降级。 +func (h *Host) SkillStore() *skills.Store { + if h == nil { + return nil + } + return h.skillStore +} + +// SkillInject 把 /skill-name 用户消息展开为最终发给 LLM 的文本。 +// 调用方(TUI / cocreate)先用 skills.ParseSkillRef 解析原始输入, +// 命中再调本方法。返回 (展开后文本, 是否命中, 错误提示)。 +// +// host 自身不解析输入——拆分"解析"和"展开"两个职责让 cocreate 等场景 +// 可以独立测试解析逻辑。 +func (h *Host) SkillInject(req skills.InjectRequest) skills.InjectResult { + if h == nil { + return skills.InjectResult{ + Expanded: false, + Hint: "host 未初始化", + } + } + return h.skillStore.InjectSkill(req) +} + +// ── 素材收集(项目级素材库)── + +// MaterialsCollect 触发一次素材收集:单轮 LLM 调用产出 8-15 条候选素材, +// 调用方(TUI)展示候选给用户筛选,选中条目走 MaterialsApprove 落盘。 +// +// onProgress 在流式事件到达时被回调(kind=thinking/reply),可用于渲染加载动画。 +// webSearch 是否可用取决于 Provider 配置;不可用时纯靠模型自身知识。 +func (h *Host) MaterialsCollect(ctx context.Context, userPrompt string, onProgress func(kind, text string)) ([]MaterialsCandidate, string, error) { + if h == nil { + return nil, "", fmt.Errorf("host 未初始化") + } + return materialsCollect(ctx, h.models, userPrompt, onProgress, h.webSearch) +} + +// MaterialsApprove 把用户筛选后的候选批量落盘到 meta/materials.json。 +// 调用方应只传用户实际选中的条目;本小姐不做二次校验(信任 TUI 已经过滤)。 +// 返回每条的最终 ID,便于 TUI 在事件流提示"保存了哪几条"。 +func (h *Host) MaterialsApprove(items []MaterialsCandidate) ([]domain.MaterialItem, error) { + if h == nil { + return nil, fmt.Errorf("host 未初始化") + } + if len(items) == 0 { + return nil, nil + } + conv := make([]domain.MaterialItem, 0, len(items)) + for _, c := range items { + conv = append(conv, domain.MaterialItem{ + Category: c.Category, + Title: c.Title, + Content: c.Content, + Source: c.Source, + }) + } + return h.store.Materials.AddBatch(conv) +} + +// MaterialsList 列出当前项目素材库。便于 TUI 在收集前展示已有素材。 +func (h *Host) MaterialsList() ([]domain.MaterialItem, error) { + if h == nil { + return nil, fmt.Errorf("host 未初始化") + } + lib, err := h.store.Materials.Load() + if err != nil { + return nil, err + } + return lib.Items, nil +} + +// MaterialsRemove 按 ID 删除单条素材。 +func (h *Host) MaterialsRemove(id string) error { + if h == nil { + return fmt.Errorf("host 未初始化") + } + return h.store.Materials.Remove(id) +} + +// LoadPremise 返回本书的 premise(Markdown 字符串)。空表示尚未规划。 +// 给 TUI 在 /materials 不带参数时复用规划前提作收集 prompt。 +func (h *Host) LoadPremise() (string, error) { + if h == nil { + return "", fmt.Errorf("host 未初始化") + } + return h.store.Outline.LoadPremise() +} diff --git a/internal/host/observer_stream.go b/internal/host/observer_stream.go index 3934f6b8..825e663e 100644 --- a/internal/host/observer_stream.go +++ b/internal/host/observer_stream.go @@ -27,7 +27,7 @@ func (o *observer) handleSubagentDelta(p *agentcore.ProgressPayload) { cur, ok := o.streamExtractors[p.Agent] // 同工具调用 args 已闭合(顶层 } 命中)后,仍可能收到 trailing delta: - // 某些 provider(deepseek-v4-flash 实测)会把单次 args 拆成多个 chunk, + // 某些 provider(实测)会把单次 args 拆成多个 chunk, // 最末一个 chunk 在 `}` 之后还跟着空白或重复字符。此时若按"工具名匹配 + // Done 即重建"处理,新 extractor 又会 emit 一次 ✻ header 并把尾段 token // 当作新 args 解析。这些 delta 是冗余尾巴,丢弃即可。 diff --git a/internal/store/store.go b/internal/store/store.go index 3fce121e..dd25ee05 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -27,6 +27,7 @@ type Store struct { Sessions *SessionStore Usage *UsageStore Simulation *SimulationStore + Materials *MaterialsStore crossMu sync.Mutex // 保护跨域原子操作 } @@ -52,6 +53,7 @@ func NewStore(dir string) *Store { Sessions: NewSessionStore(newIO(dir)), Usage: NewUsageStore(newIO(dir)), Simulation: NewSimulationStore(newIO(dir)), + Materials: NewMaterialsStore(newIO(dir)), } } diff --git a/internal/tools/novel_context_builders.go b/internal/tools/novel_context_builders.go index e6f2ef03..4491f56a 100644 --- a/internal/tools/novel_context_builders.go +++ b/internal/tools/novel_context_builders.go @@ -560,6 +560,15 @@ func (t *ContextTool) buildChapterReferencePack(envelope *chapterContextEnvelope } } + // 注入项目级素材:writer 写新场景/新角色时按命名表、术语表取用。 + // 空库不写 materials 字段,避免污染 chapter context;architect 路径会始终注入。 + if lib, err := t.store.Materials.Load(); err == nil && len(lib.Items) > 0 { + envelope.References["materials"] = map[string]any{ + "items": lib.Items, + "count": len(lib.Items), + } + } + envelope.References["references"] = t.writerReferences(state.chapter) } @@ -694,5 +703,16 @@ func (t *ContextTool) buildArchitectReferences(envelope *architectContextEnvelop warn("style_rules", err) } + // 注入项目级素材:architect 规划 / 扩展大纲时直接消费 meta/materials.json。 + // 空库也注入 {items:[], count:0},让 LLM 看到字段稳定存在(避免误判为"未启用")。 + if lib, err := t.store.Materials.Load(); err == nil { + envelope.References["materials"] = map[string]any{ + "items": lib.Items, + "count": len(lib.Items), + } + } else { + warn("materials", err) + } + envelope.References["references"] = t.architectReferences() }