package main import ( "bufio" "embed" "fmt" "io/ioutil" "os" "strconv" "strings" "github.com/voocel/ainovel-cli/app" "github.com/voocel/ainovel-cli/tools" "github.com/voocel/ainovel-cli/tui" ) //go:embed prompts/*.md var promptsFS embed.FS //go:embed references var referencesFS embed.FS //go:embed styles/*.md var stylesFS embed.FS func main() { // 处理 start 子命令 if len(os.Args) > 1 && os.Args[1] == "start" { handleStartCommand() return } style := envOr("NOVEL_STYLE", "default") refs := loadReferences(style) prompts := loadPrompts() styles := loadStyles() cfg := buildConfig(style) prompt := parsePrompt() if prompt != "" { // CLI 模式:有命令行参数,直接运行 cfg.Prompt = prompt if err := app.Run(cfg, refs, prompts, styles); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } return } // TUI 模式:无命令行参数,启动交互界面 if err := tui.Run(cfg, refs, prompts, styles); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } } func buildConfig(style string) app.Config { provider := envOr("LLM_PROVIDER", "openrouter") apiKey := os.Getenv("Z_OPENAI_API_KEY") baseURL := os.Getenv("Z_OPENAI_BASE_URL") switch provider { case "anthropic": apiKey = envOr("ANTHROPIC_API_KEY", apiKey) baseURL = envOr("ANTHROPIC_BASE_URL", baseURL) case "gemini": apiKey = envOr("GEMINI_API_KEY", apiKey) baseURL = envOr("GEMINI_BASE_URL", baseURL) case "openrouter": apiKey = envOr("OPENROUTER_API_KEY", apiKey) baseURL = envOr("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") } cfg := app.Config{ NovelName: envOr("NOVEL_NAME", ""), Provider: provider, APIKey: apiKey, BaseURL: baseURL, ModelName: envOr("LLM_MODEL", "stepfun/step-3.5-flash:free"), Style: style, } return cfg } func parsePrompt() string { if len(os.Args) < 2 { return "" } if os.Args[1] == "-help" || os.Args[1] == "--help" || os.Args[1] == "-h" { printHelp() os.Exit(0) } return strings.Join(os.Args[1:], " ") } func printHelp() { fmt.Println(`ainovel-cli - AI 小说生成工具 用法: ainovel-cli start 交互式开始:选择小说 → 选择风格 → 启动 TUI ainovel-cli [prompt] CLI 模式:直接生成小说 ainovel-cli TUI 模式:启动交互界面 环境变量: NOVEL_NAME 小说名称(默认:novel) NOVEL_STYLE 小说风格(默认:default) 可选值: default 通用风格,叙事张弛有度,五感描写,对话自然 fantasy 奇幻冒险,世界观自然展开,魔法体系有代价感 romance 言情,情感递进有节奏,关系张力与内心描写并重 suspense 悬疑推理,多线叙事,信息差悬念,线索管理严谨 LLM_PROVIDER LLM 提供商:openrouter|anthropic|gemini(默认:openrouter) LLM_MODEL 模型名称(默认:stepfun/step-3.5-flash:free) Z_OPENAI_API_KEY API 密钥 Z_OPENAI_BASE_URL API 地址 ANTHROPIC_API_KEY Anthropic API 密钥 ANTHROPIC_BASE_URL Anthropic API 地址 GEMINI_API_KEY Gemini API 密钥 GEMINI_BASE_URL Gemini API 地址 OPENROUTER_API_KEY OpenRouter API 密钥 OPENROUTER_BASE_URL OpenRouter API 地址 示例: ainovel-cli "写一部科幻小说,主角是时间旅行者" NOVEL_NAME=时空之旅 ainovel-cli "写一部科幻小说" ainovel-cli # 启动 TUI 交互模式 tmux 使用(推荐后台运行): tmux new -s novel # 新建名为 novel 的会话 ainovel-cli "写一部科幻小说" # 在会话中运行,关闭终端不中断 Ctrl+b d # 脱离会话(程序继续跑) tmux attach -t novel # 随时回来查看进度 tmux ls # 列出所有会话 tmux kill-session -t novel # 结束会话`) } func loadReferences(style string) tools.References { refs := tools.References{ ChapterGuide: mustRead(referencesFS, "references/chapter-guide.md"), HookTechniques: mustRead(referencesFS, "references/hook-techniques.md"), QualityChecklist: mustRead(referencesFS, "references/quality-checklist.md"), OutlineTemplate: mustRead(referencesFS, "references/outline-template.md"), CharacterTemplate: mustRead(referencesFS, "references/character-template.md"), ChapterTemplate: mustRead(referencesFS, "references/chapter-template.md"), Consistency: mustRead(referencesFS, "references/consistency.md"), ContentExpansion: mustRead(referencesFS, "references/content-expansion.md"), DialogueWriting: mustRead(referencesFS, "references/dialogue-writing.md"), LongformPlanning: mustRead(referencesFS, "references/longform-planning.md"), Differentiation: mustRead(referencesFS, "references/differentiation.md"), } if style != "" && style != "default" { path := "references/" + style + "/style-references.md" if data, err := referencesFS.ReadFile(path); err == nil { refs.StyleReference = string(data) } } return refs } func loadPrompts() app.Prompts { return app.Prompts{ Coordinator: mustRead(promptsFS, "prompts/coordinator.md"), ArchitectShort: mustRead(promptsFS, "prompts/architect-short.md"), ArchitectMid: mustRead(promptsFS, "prompts/architect-mid.md"), ArchitectLong: mustRead(promptsFS, "prompts/architect-long.md"), Writer: mustRead(promptsFS, "prompts/writer.md"), Editor: mustRead(promptsFS, "prompts/editor.md"), } } func mustRead(fs embed.FS, path string) string { data, err := fs.ReadFile(path) if err != nil { panic(fmt.Sprintf("embed read %s: %v", path, err)) } return string(data) } func loadStyles() map[string]string { styles := make(map[string]string) entries, err := stylesFS.ReadDir("styles") if err != nil { return styles } for _, e := range entries { if e.IsDir() { continue } name := strings.TrimSuffix(e.Name(), ".md") data, err := stylesFS.ReadFile("styles/" + e.Name()) if err != nil { continue } styles[name] = string(data) } return styles } func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } // handleStartCommand 处理 start 子命令:交互式选择小说和风格 func handleStartCommand() { // Step 1: 选择小说 novelName := selectNovel() if novelName == "" { fmt.Println("已取消") os.Exit(0) } // Step 2: 选择风格 style := selectStyle() if style == "" { fmt.Println("已取消") os.Exit(0) } // 设置环境变量并启动 TUI os.Setenv("NOVEL_NAME", novelName) os.Setenv("NOVEL_STYLE", style) refs := loadReferences(style) prompts := loadPrompts() styles := loadStyles() cfg := buildConfig(style) cfg.NovelName = novelName if err := tui.Run(cfg, refs, prompts, styles); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } } // selectNovel 显示小说选择菜单 func selectNovel() string { options := []string{"📝 创建新小说"} // 扫描 output 目录下现有小说 outputDir := "output" entries, err := ioutil.ReadDir(outputDir) novels := []string{} if err == nil { for _, e := range entries { if e.IsDir() && e.Name() != "." && e.Name() != ".." { novels = append(novels, e.Name()) options = append(options, "📖 "+e.Name()) } } } if len(options) == 1 { // 只有创建选项,直接输入新名称 return promptNovelName() } // 显示菜单 fmt.Println("\n=== 选择小说或创建新的 ===") for i, opt := range options { fmt.Printf("%d. %s\n", i+1, opt) } choice := promptChoice(len(options)) if choice < 0 { return "" } if choice == 0 { // 创建新小说 return promptNovelName() } // 返回现有小说名称 return novels[choice-1] } // selectStyle 显示风格选择菜单 func selectStyle() string { styles := []struct { name string desc string }{ {"default", "通用风格,叙事张弛有度,五感描写,对话自然"}, {"fantasy", "奇幻冒险,世界观自然展开,魔法体系有代价感"}, {"romance", "言情,情感递进有节奏,关系张力与内心描写并重"}, {"suspense", "悬疑推理,多线叙事,信息差悬念,线索管理严谨"}, } fmt.Println("\n=== 选择小说风格 ===") for i, s := range styles { fmt.Printf("%d. %-10s %s\n", i+1, s.name, s.desc) } choice := promptChoice(len(styles)) if choice < 0 { // EOF 或错误,使用默认风格 fmt.Println("使用默认风格...") return "default" } return styles[choice].name } // promptNovelName 提示输入新小说名称 func promptNovelName() string { reader := bufio.NewReader(os.Stdin) fmt.Print("\n请输入小说名称 (默认: novel): ") name, err := reader.ReadString('\n') if err != nil { return "novel" } name = strings.TrimSpace(name) if name == "" { return "novel" } return name } // promptChoice 提示用户选择菜单项 func promptChoice(maxChoice int) int { reader := bufio.NewReader(os.Stdin) for { fmt.Print("\n请选择 (输入数字): ") input, err := reader.ReadString('\n') if err != nil { // EOF 或其他错误,返回 -1 表示取消 return -1 } input = strings.TrimSpace(input) if input == "" { continue } choice, err := strconv.Atoi(input) if err != nil || choice < 1 || choice > maxChoice { fmt.Printf("❌ 请输入 1 到 %d 之间的数字\n", maxChoice) continue } return choice - 1 // 转换为 0-based 索引 } }