Files
ainovel-clients/main.go
2026-03-18 00:20:12 +08:00

190 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"embed"
"fmt"
"os"
"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() {
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 [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 交互模式`)
}
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
}