Compare commits
4 Commits
40a3479e2a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c73f024c83 | ||
|
|
8900332910 | ||
|
|
7ed7a6c81b | ||
|
|
351c12fdaa |
159
main.go
159
main.go
@@ -1,9 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/voocel/ainovel-cli/app"
|
"github.com/voocel/ainovel-cli/app"
|
||||||
@@ -21,6 +24,12 @@ var referencesFS embed.FS
|
|||||||
var stylesFS embed.FS
|
var stylesFS embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// 处理 start 子命令
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "start" {
|
||||||
|
handleStartCommand()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
style := envOr("NOVEL_STYLE", "default")
|
style := envOr("NOVEL_STYLE", "default")
|
||||||
refs := loadReferences(style)
|
refs := loadReferences(style)
|
||||||
prompts := loadPrompts()
|
prompts := loadPrompts()
|
||||||
@@ -87,6 +96,7 @@ func printHelp() {
|
|||||||
fmt.Println(`ainovel-cli - AI 小说生成工具
|
fmt.Println(`ainovel-cli - AI 小说生成工具
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
|
ainovel-cli start 交互式开始:选择小说 → 选择风格 → 启动 TUI
|
||||||
ainovel-cli [prompt] CLI 模式:直接生成小说
|
ainovel-cli [prompt] CLI 模式:直接生成小说
|
||||||
ainovel-cli TUI 模式:启动交互界面
|
ainovel-cli TUI 模式:启动交互界面
|
||||||
|
|
||||||
@@ -116,7 +126,15 @@ func printHelp() {
|
|||||||
示例:
|
示例:
|
||||||
ainovel-cli "写一部科幻小说,主角是时间旅行者"
|
ainovel-cli "写一部科幻小说,主角是时间旅行者"
|
||||||
NOVEL_NAME=时空之旅 ainovel-cli "写一部科幻小说"
|
NOVEL_NAME=时空之旅 ainovel-cli "写一部科幻小说"
|
||||||
ainovel-cli # 启动 TUI 交互模式`)
|
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 {
|
func loadReferences(style string) tools.References {
|
||||||
@@ -187,3 +205,142 @@ func envOr(key, fallback string) string {
|
|||||||
}
|
}
|
||||||
return fallback
|
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 索引
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
36
tui/app.go
36
tui/app.go
@@ -1,10 +1,13 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/voocel/ainovel-cli/app"
|
"github.com/voocel/ainovel-cli/app"
|
||||||
@@ -21,11 +24,42 @@ func Run(cfg app.Config, refs tools.References, prompts app.Prompts, styles map[
|
|||||||
rt.AskUser().SetHandler(bridge.handler)
|
rt.AskUser().SetHandler(bridge.handler)
|
||||||
restoreLog := redirectLogger(rt.Dir())
|
restoreLog := redirectLogger(rt.Dir())
|
||||||
defer restoreLog()
|
defer restoreLog()
|
||||||
defer rt.Close()
|
|
||||||
|
|
||||||
m := NewModel(rt, bridge)
|
m := NewModel(rt, bridge)
|
||||||
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||||
_, err = p.Run()
|
_, err = p.Run()
|
||||||
|
|
||||||
|
// bubbletea 退出后,检查任务是否仍在运行
|
||||||
|
snap := rt.Snapshot()
|
||||||
|
if snap.IsRunning {
|
||||||
|
// 任务仍在运行(可能是 tmux detach 导致 TUI 退出),
|
||||||
|
// 不中断任务,回退到无 UI 阻塞等待模式。
|
||||||
|
|
||||||
|
// 脱离 TUI 后解除所有阻塞在 ask_user 的 handler goroutine,
|
||||||
|
// 让 LLM 收到"用户不在线,请自行决策"提示后继续运行,
|
||||||
|
// 而非无限等待一个永远不会到来的用户输入。
|
||||||
|
bridge.Detach()
|
||||||
|
fmt.Println("\n[TUI 已退出,任务仍在后台运行中...]")
|
||||||
|
fmt.Printf("[小说: %s | 阶段: %s | 进度: %d/%d 章]\n",
|
||||||
|
snap.NovelName, snap.Phase, snap.CompletedCount, snap.TotalChapters)
|
||||||
|
fmt.Println("[等待任务完成,按 Ctrl+C 强制中断]")
|
||||||
|
|
||||||
|
// 监听中断信号
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-rt.Done():
|
||||||
|
fmt.Println("\n[任务已完成!]")
|
||||||
|
case <-sigCh:
|
||||||
|
fmt.Println("\n[收到中断信号,正在停止...]")
|
||||||
|
rt.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务未在运行,正常关闭
|
||||||
|
rt.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/voocel/ainovel-cli/tools"
|
"github.com/voocel/ainovel-cli/tools"
|
||||||
@@ -19,23 +20,44 @@ type askUserResult struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// askUserBridge 在 TUI goroutine 与 LLM handler goroutine 之间传递 ask_user 请求。
|
||||||
|
// detachCh 被关闭后,所有阻塞在 handler() 中的调用会立即返回错误,让 LLM 自行决策。
|
||||||
type askUserBridge struct {
|
type askUserBridge struct {
|
||||||
requests chan askUserRequest
|
requests chan askUserRequest
|
||||||
|
detachCh chan struct{}
|
||||||
|
detached uint32 // atomic: 0=active, 1=detached
|
||||||
}
|
}
|
||||||
|
|
||||||
func newAskUserBridge() *askUserBridge {
|
func newAskUserBridge() *askUserBridge {
|
||||||
return &askUserBridge{
|
return &askUserBridge{
|
||||||
requests: make(chan askUserRequest),
|
requests: make(chan askUserRequest),
|
||||||
|
detachCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detach 标记桥接器为已脱离状态,关闭 detachCh,使所有阻塞在 handler() 中的
|
||||||
|
// goroutine 立即收到错误并返回,让 LLM 收到"用户不在线,请自行决策"后继续运行。
|
||||||
|
// 多次调用安全(只有第一次真正关闭 channel)。
|
||||||
|
func (b *askUserBridge) Detach() {
|
||||||
|
if atomic.CompareAndSwapUint32(&b.detached, 0, 1) {
|
||||||
|
close(b.detachCh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *askUserBridge) handler(ctx context.Context, questions []tools.Question) (*tools.AskUserResponse, error) {
|
func (b *askUserBridge) handler(ctx context.Context, questions []tools.Question) (*tools.AskUserResponse, error) {
|
||||||
|
// 若已脱离 TUI,直接返回错误,让 tools/ask_user.go 将错误提示返回给 LLM
|
||||||
|
if atomic.LoadUint32(&b.detached) == 1 {
|
||||||
|
return nil, fmt.Errorf("用户不在线,请自行决策")
|
||||||
|
}
|
||||||
|
|
||||||
req := askUserRequest{
|
req := askUserRequest{
|
||||||
questions: questions,
|
questions: questions,
|
||||||
resultCh: make(chan askUserResult, 1),
|
resultCh: make(chan askUserResult, 1),
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case b.requests <- req:
|
case b.requests <- req:
|
||||||
|
case <-b.detachCh:
|
||||||
|
return nil, fmt.Errorf("用户不在线,请自行决策")
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
@@ -43,6 +65,8 @@ func (b *askUserBridge) handler(ctx context.Context, questions []tools.Question)
|
|||||||
select {
|
select {
|
||||||
case result := <-req.resultCh:
|
case result := <-req.resultCh:
|
||||||
return result.resp, result.err
|
return result.resp, result.err
|
||||||
|
case <-b.detachCh:
|
||||||
|
return nil, fmt.Errorf("用户不在线,请自行决策")
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user