feat: add ask user question
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// BuildCoordinator 组装 Coordinator Agent 及其 SubAgent。
|
// BuildCoordinator 组装 Coordinator Agent 及其 SubAgent。
|
||||||
|
// 返回 Agent 和 AskUserTool(供调用方注入 handler)。
|
||||||
func BuildCoordinator(
|
func BuildCoordinator(
|
||||||
cfg Config,
|
cfg Config,
|
||||||
store *state.Store,
|
store *state.Store,
|
||||||
@@ -14,9 +15,10 @@ func BuildCoordinator(
|
|||||||
refs tools.References,
|
refs tools.References,
|
||||||
prompts Prompts,
|
prompts Prompts,
|
||||||
styles map[string]string,
|
styles map[string]string,
|
||||||
) *agentcore.Agent {
|
) (*agentcore.Agent, *tools.AskUserTool) {
|
||||||
// 共享工具
|
// 共享工具
|
||||||
contextTool := tools.NewContextTool(store, refs, cfg.Style)
|
contextTool := tools.NewContextTool(store, refs, cfg.Style)
|
||||||
|
askUser := tools.NewAskUserTool()
|
||||||
|
|
||||||
// Architect SubAgent 工具
|
// Architect SubAgent 工具
|
||||||
architectTools := []agentcore.Tool{
|
architectTools := []agentcore.Tool{
|
||||||
@@ -75,10 +77,11 @@ func BuildCoordinator(
|
|||||||
|
|
||||||
subagentTool := agentcore.NewSubAgentTool(architect, writer, editor)
|
subagentTool := agentcore.NewSubAgentTool(architect, writer, editor)
|
||||||
|
|
||||||
return agentcore.NewAgent(
|
agent := agentcore.NewAgent(
|
||||||
agentcore.WithModel(model),
|
agentcore.WithModel(model),
|
||||||
agentcore.WithSystemPrompt(prompts.Coordinator),
|
agentcore.WithSystemPrompt(prompts.Coordinator),
|
||||||
agentcore.WithTools(subagentTool, contextTool),
|
agentcore.WithTools(subagentTool, contextTool, askUser),
|
||||||
agentcore.WithMaxTurns(60),
|
agentcore.WithMaxTurns(60),
|
||||||
)
|
)
|
||||||
|
return agent, askUser
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ type Config struct {
|
|||||||
ModelName string // LLM 模型名
|
ModelName string // LLM 模型名
|
||||||
APIKey string // API Key
|
APIKey string // API Key
|
||||||
BaseURL string // API Base URL(可选)
|
BaseURL string // API Base URL(可选)
|
||||||
MaxChapters int // 最大章节数
|
Style string // 写作风格(default/suspense/fantasy/romance)
|
||||||
Style string // 写作风格(default/suspense/fantasy/romance)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prompts 嵌入的提示词。
|
// Prompts 嵌入的提示词。
|
||||||
@@ -71,7 +70,4 @@ func (c *Config) FillDefaults() {
|
|||||||
if c.Style == "" {
|
if c.Style == "" {
|
||||||
c.Style = "default"
|
c.Style = "default"
|
||||||
}
|
}
|
||||||
if c.MaxChapters <= 0 {
|
|
||||||
c.MaxChapters = 3
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
274
app/run.go
274
app/run.go
@@ -2,6 +2,8 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -9,6 +11,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/voocel/agentcore"
|
"github.com/voocel/agentcore"
|
||||||
"github.com/voocel/agentcore/llm"
|
"github.com/voocel/agentcore/llm"
|
||||||
"github.com/voocel/ainovel-cli/domain"
|
"github.com/voocel/ainovel-cli/domain"
|
||||||
@@ -20,6 +24,12 @@ import (
|
|||||||
// CLI 模式下为 nil,Runtime 模式下指向 events channel。
|
// CLI 模式下为 nil,Runtime 模式下指向 events channel。
|
||||||
type emitFn func(UIEvent)
|
type emitFn func(UIEvent)
|
||||||
|
|
||||||
|
// deltaFn 是可选的流式 token 回调,用于向 TUI 转发 LLM 生成的文字。
|
||||||
|
type deltaFn func(delta string)
|
||||||
|
|
||||||
|
// clearFn 是可选的流式缓冲清空回调,在新一轮 LLM 输出开始时触发。
|
||||||
|
type clearFn func()
|
||||||
|
|
||||||
// Run 启动小说创作流程(CLI 模式,阻塞直到完成)。
|
// Run 启动小说创作流程(CLI 模式,阻塞直到完成)。
|
||||||
func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]string) error {
|
func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]string) error {
|
||||||
cfg.FillDefaults()
|
cfg.FillDefaults()
|
||||||
@@ -40,10 +50,11 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 组装 Coordinator
|
// 3. 组装 Coordinator
|
||||||
coordinator := BuildCoordinator(cfg, store, model, refs, prompts, styles)
|
coordinator, askUser := BuildCoordinator(cfg, store, model, refs, prompts, styles)
|
||||||
|
askUser.SetHandler(cliAskUserHandler)
|
||||||
|
|
||||||
// 4. 确定性控制面:事件监听 + FollowUp 注入
|
// 4. 确定性控制面:事件监听 + FollowUp 注入
|
||||||
registerSubscription(coordinator, store, cfg.MaxChapters, nil)
|
registerSubscription(coordinator, store, nil, nil, nil)
|
||||||
|
|
||||||
// 5. 初始化运行元信息(保留已有 SteerHistory)
|
// 5. 初始化运行元信息(保留已有 SteerHistory)
|
||||||
if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil {
|
if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil {
|
||||||
@@ -65,14 +76,14 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
|
|||||||
// 7. 恢复或启动
|
// 7. 恢复或启动
|
||||||
progress, _ := store.LoadProgress()
|
progress, _ := store.LoadProgress()
|
||||||
runMeta, _ := store.LoadRunMeta()
|
runMeta, _ := store.LoadRunMeta()
|
||||||
recovery := determineRecovery(progress, runMeta, cfg.MaxChapters)
|
recovery := determineRecovery(progress, runMeta)
|
||||||
|
|
||||||
if recovery.IsNew {
|
if recovery.IsNew {
|
||||||
if err := store.InitProgress(cfg.NovelName, cfg.MaxChapters); err != nil {
|
if err := store.InitProgress(cfg.NovelName, 0); err != nil {
|
||||||
return fmt.Errorf("init progress: %w", err)
|
return fmt.Errorf("init progress: %w", err)
|
||||||
}
|
}
|
||||||
log.Printf("新建模式:%s(%d 章)", cfg.NovelName, cfg.MaxChapters)
|
log.Printf("新建模式:%s", cfg.NovelName)
|
||||||
promptText := fmt.Sprintf("请创作一部 %d 章的小说。要求如下:\n\n%s", cfg.MaxChapters, cfg.Prompt)
|
promptText := fmt.Sprintf("请创作一部小说,章节数量由你根据故事需要自行决定。要求如下:\n\n%s", cfg.Prompt)
|
||||||
if err := coordinator.Prompt(promptText); err != nil {
|
if err := coordinator.Prompt(promptText); err != nil {
|
||||||
return fmt.Errorf("prompt: %w", err)
|
return fmt.Errorf("prompt: %w", err)
|
||||||
}
|
}
|
||||||
@@ -96,8 +107,8 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerSubscription 注册 coordinator 事件订阅,包含确定性控制和可选的 UIEvent 转发。
|
// registerSubscription 注册 coordinator 事件订阅,包含确定性控制和可选的 UIEvent/Delta 转发。
|
||||||
func registerSubscription(coordinator *agentcore.Agent, store *state.Store, maxChapters int, emit emitFn) {
|
func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit emitFn, onDelta deltaFn, onClear clearFn) {
|
||||||
coordinator.Subscribe(func(ev agentcore.Event) {
|
coordinator.Subscribe(func(ev agentcore.Event) {
|
||||||
switch ev.Type {
|
switch ev.Type {
|
||||||
case agentcore.EventToolExecStart:
|
case agentcore.EventToolExecStart:
|
||||||
@@ -106,6 +117,32 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, maxC
|
|||||||
emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: ev.Tool + ".start", Level: "info"})
|
emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: ev.Tool + ".start", Level: "info"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case agentcore.EventToolExecUpdate:
|
||||||
|
// 区分流式 delta 和进度摘要
|
||||||
|
if delta, ok := parseStreamDelta(ev); ok {
|
||||||
|
if onDelta != nil {
|
||||||
|
onDelta(delta)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
summary := parseProgressSummary(ev)
|
||||||
|
log.Printf("[progress] %s", summary)
|
||||||
|
if emit != nil {
|
||||||
|
emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: summary, Level: "info"})
|
||||||
|
}
|
||||||
|
|
||||||
|
case agentcore.EventMessageStart:
|
||||||
|
// 新一轮 LLM 输出开始,清空流式缓冲
|
||||||
|
if onClear != nil {
|
||||||
|
onClear()
|
||||||
|
}
|
||||||
|
|
||||||
|
case agentcore.EventMessageUpdate:
|
||||||
|
// Coordinator 自身思考时的流式 token
|
||||||
|
if ev.Delta != "" && onDelta != nil {
|
||||||
|
onDelta(ev.Delta)
|
||||||
|
}
|
||||||
|
|
||||||
case agentcore.EventToolExecEnd:
|
case agentcore.EventToolExecEnd:
|
||||||
if ev.IsError {
|
if ev.IsError {
|
||||||
log.Printf("[tool:error] %s", ev.Tool)
|
log.Printf("[tool:error] %s", ev.Tool)
|
||||||
@@ -120,7 +157,7 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, maxC
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ev.Tool == "subagent" {
|
if ev.Tool == "subagent" {
|
||||||
handleSubAgentDone(coordinator, store, maxChapters, emit)
|
handleSubAgentDone(coordinator, store, emit)
|
||||||
handleEditorDone(coordinator, store, emit)
|
handleEditorDone(coordinator, store, emit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +206,8 @@ type recoveryResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// determineRecovery 根据 Progress 和 RunMeta 判断恢复类型和 Prompt 文本。
|
// determineRecovery 根据 Progress 和 RunMeta 判断恢复类型和 Prompt 文本。
|
||||||
func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta, maxChapters int) recoveryResult {
|
// 章节总数完全来自 Progress.TotalChapters(由大纲自动设定),不再由外部传入。
|
||||||
|
func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recoveryResult {
|
||||||
if progress == nil {
|
if progress == nil {
|
||||||
return recoveryResult{IsNew: true}
|
return recoveryResult{IsNew: true}
|
||||||
}
|
}
|
||||||
@@ -232,7 +270,7 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta, maxCh
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleSubAgentDone 在每次 SubAgent 调用完成后读取文件系统信号,注入确定性任务。
|
// handleSubAgentDone 在每次 SubAgent 调用完成后读取文件系统信号,注入确定性任务。
|
||||||
func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, maxChapters int, emit emitFn) {
|
func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit emitFn) {
|
||||||
result, err := store.LoadLastCommit()
|
result, err := store.LoadLastCommit()
|
||||||
if err != nil || result == nil {
|
if err != nil || result == nil {
|
||||||
return
|
return
|
||||||
@@ -281,20 +319,24 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, maxCha
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确定性判断 1:全书完成
|
// 确定性判断 1:全书完成(TotalChapters 由大纲自动设定)
|
||||||
if result.NextChapter > maxChapters {
|
totalChapters := 0
|
||||||
log.Printf("[host] 所有 %d 章已完成,注入完成指令", maxChapters)
|
if progress != nil {
|
||||||
|
totalChapters = progress.TotalChapters
|
||||||
|
}
|
||||||
|
if totalChapters > 0 && result.NextChapter > totalChapters {
|
||||||
|
log.Printf("[host] 所有 %d 章已完成,注入完成指令", totalChapters)
|
||||||
if err := store.MarkComplete(); err != nil {
|
if err := store.MarkComplete(); err != nil {
|
||||||
log.Printf("[host] 标记完成失败: %v", err)
|
log.Printf("[host] 标记完成失败: %v", err)
|
||||||
}
|
}
|
||||||
clearHandledSteer(store)
|
clearHandledSteer(store)
|
||||||
saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter))
|
saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter))
|
||||||
if emit != nil {
|
if emit != nil {
|
||||||
emit(UIEvent{Time: time.Now(), Category: "SYSTEM", Summary: fmt.Sprintf("全部 %d 章已完成", maxChapters), Level: "success"})
|
emit(UIEvent{Time: time.Now(), Category: "SYSTEM", Summary: fmt.Sprintf("全部 %d 章已完成", totalChapters), Level: "success"})
|
||||||
}
|
}
|
||||||
coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
|
coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
|
||||||
"[系统] 全部 %d 章已写完。请总结全书并结束。不要再调用 writer。",
|
"[系统] 全部 %d 章已写完。请总结全书并结束。不要再调用 writer。",
|
||||||
maxChapters)))
|
totalChapters)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +429,50 @@ func saveCheckpoint(store *state.Store, label string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseStreamDelta 从 EventToolExecUpdate 中提取流式 delta 文本。
|
||||||
|
// 如果事件是 SubAgent 转发的 token delta(含 "delta" 字段),返回文本和 true。
|
||||||
|
func parseStreamDelta(ev agentcore.Event) (string, bool) {
|
||||||
|
if len(ev.Result) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
Delta string `json:"delta"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(ev.Result, &data); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if data.Delta != "" {
|
||||||
|
return data.Delta, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseProgressSummary 从 EventToolExecUpdate 中提取可读摘要。
|
||||||
|
func parseProgressSummary(ev agentcore.Event) string {
|
||||||
|
if len(ev.Result) == 0 {
|
||||||
|
return "progress"
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
Agent string `json:"agent"`
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Turn int `json:"turn"`
|
||||||
|
Error bool `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(ev.Result, &data); err != nil {
|
||||||
|
return truncateLog(string(ev.Result), 60)
|
||||||
|
}
|
||||||
|
if data.Tool != "" {
|
||||||
|
if data.Error {
|
||||||
|
return fmt.Sprintf("%s → %s (error)", data.Agent, data.Tool)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s → %s", data.Agent, data.Tool)
|
||||||
|
}
|
||||||
|
if data.Turn > 0 {
|
||||||
|
return fmt.Sprintf("%s turn %d", data.Agent, data.Turn)
|
||||||
|
}
|
||||||
|
return truncateLog(string(ev.Result), 60)
|
||||||
|
}
|
||||||
|
|
||||||
func truncateLog(s string, maxRunes int) string {
|
func truncateLog(s string, maxRunes int) string {
|
||||||
runes := []rune(s)
|
runes := []rune(s)
|
||||||
if len(runes) <= maxRunes {
|
if len(runes) <= maxRunes {
|
||||||
@@ -434,3 +520,159 @@ func createModel(cfg Config) (agentcore.ChatModel, error) {
|
|||||||
return llm.NewOpenAIModel(cfg.ModelName, cfg.APIKey, baseURL...)
|
return llm.NewOpenAIModel(cfg.ModelName, cfg.APIKey, baseURL...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cliAskUserHandler 是 CLI 模式下的交互式选择器,上下键选择,回车确认。
|
||||||
|
func cliAskUserHandler(_ context.Context, questions []tools.Question) (*tools.AskUserResponse, error) {
|
||||||
|
resp := &tools.AskUserResponse{
|
||||||
|
Answers: make(map[string]string),
|
||||||
|
Notes: make(map[string]string),
|
||||||
|
}
|
||||||
|
for _, q := range questions {
|
||||||
|
m := newSelectModel(q)
|
||||||
|
p := tea.NewProgram(m, tea.WithOutput(os.Stderr))
|
||||||
|
final, err := p.Run()
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
result := final.(selectModel)
|
||||||
|
if result.cancelled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Answers[q.Question] = result.answer
|
||||||
|
if result.isCustom {
|
||||||
|
resp.Notes[q.Question] = result.answer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 交互式选择器(bubbletea mini program)----------
|
||||||
|
|
||||||
|
var (
|
||||||
|
selectCursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212"))
|
||||||
|
selectDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
||||||
|
selectHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("99"))
|
||||||
|
selectInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212"))
|
||||||
|
)
|
||||||
|
|
||||||
|
type selectModel struct {
|
||||||
|
question tools.Question
|
||||||
|
items []string // label 列表,最后一项是"自由输入"
|
||||||
|
descs []string // 描述列表
|
||||||
|
cursor int
|
||||||
|
answer string
|
||||||
|
isCustom bool
|
||||||
|
cancelled bool
|
||||||
|
typing bool // 是否进入自由输入模式
|
||||||
|
input string // 自由输入缓冲
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSelectModel(q tools.Question) selectModel {
|
||||||
|
items := make([]string, 0, len(q.Options)+1)
|
||||||
|
descs := make([]string, 0, len(q.Options)+1)
|
||||||
|
for _, opt := range q.Options {
|
||||||
|
items = append(items, opt.Label)
|
||||||
|
descs = append(descs, opt.Description)
|
||||||
|
}
|
||||||
|
items = append(items, "自由输入")
|
||||||
|
descs = append(descs, "以上都不合适,我自己写")
|
||||||
|
return selectModel{question: q, items: items, descs: descs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m selectModel) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if m.typing {
|
||||||
|
return m.updateTyping(msg)
|
||||||
|
}
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
if m.cursor > 0 {
|
||||||
|
m.cursor--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if m.cursor < len(m.items)-1 {
|
||||||
|
m.cursor++
|
||||||
|
}
|
||||||
|
case "enter":
|
||||||
|
if m.cursor == len(m.items)-1 {
|
||||||
|
m.typing = true
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.answer = m.items[m.cursor]
|
||||||
|
return m, tea.Quit
|
||||||
|
case "q", "esc", "ctrl+c":
|
||||||
|
m.cancelled = true
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m selectModel) updateTyping(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "enter":
|
||||||
|
text := strings.TrimSpace(m.input)
|
||||||
|
if text == "" {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.answer = text
|
||||||
|
m.isCustom = true
|
||||||
|
return m, tea.Quit
|
||||||
|
case "esc":
|
||||||
|
m.typing = false
|
||||||
|
m.input = ""
|
||||||
|
return m, nil
|
||||||
|
case "ctrl+c":
|
||||||
|
m.cancelled = true
|
||||||
|
return m, tea.Quit
|
||||||
|
case "backspace":
|
||||||
|
if len(m.input) > 0 {
|
||||||
|
runes := []rune(m.input)
|
||||||
|
m.input = string(runes[:len(runes)-1])
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if msg.Type == tea.KeyRunes {
|
||||||
|
m.input += string(msg.Runes)
|
||||||
|
} else if msg.Type == tea.KeySpace {
|
||||||
|
m.input += " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m selectModel) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(selectHeaderStyle.Render(fmt.Sprintf("[%s] %s", m.question.Header, m.question.Question)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
for i, item := range m.items {
|
||||||
|
cursor := " "
|
||||||
|
if i == m.cursor {
|
||||||
|
cursor = selectCursorStyle.Render("❯ ")
|
||||||
|
}
|
||||||
|
label := item
|
||||||
|
if i == m.cursor {
|
||||||
|
label = selectCursorStyle.Render(item)
|
||||||
|
}
|
||||||
|
desc := selectDescStyle.Render(" " + m.descs[i])
|
||||||
|
b.WriteString(fmt.Sprintf("%s%s%s\n", cursor, label, desc))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.typing {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(selectInputStyle.Render(" ✎ "))
|
||||||
|
b.WriteString(m.input)
|
||||||
|
b.WriteString(selectCursorStyle.Render("▌"))
|
||||||
|
b.WriteString(selectDescStyle.Render(" (Enter 确认, Esc 返回)"))
|
||||||
|
} else {
|
||||||
|
b.WriteString(selectDescStyle.Render("\n ↑↓ 选择 Enter 确认 Esc 取消"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ type UISnapshot struct {
|
|||||||
RecoveryLabel string // 恢复类型描述,空表示新建
|
RecoveryLabel string // 恢复类型描述,空表示新建
|
||||||
IsRunning bool
|
IsRunning bool
|
||||||
|
|
||||||
|
// 基础设定
|
||||||
|
Premise string // 前提概要
|
||||||
|
Outline []OutlineSnapshot // 大纲(每章标题 + 核心事件)
|
||||||
|
Characters []string // 角色列表(名字 + 身份)
|
||||||
|
|
||||||
// 详情区
|
// 详情区
|
||||||
LastCommitSummary string
|
LastCommitSummary string
|
||||||
LastReviewSummary string
|
LastReviewSummary string
|
||||||
@@ -50,12 +55,22 @@ type UISnapshot struct {
|
|||||||
RecentSummaries []string
|
RecentSummaries []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OutlineSnapshot 是大纲条目的展示摘要。
|
||||||
|
type OutlineSnapshot struct {
|
||||||
|
Chapter int
|
||||||
|
Title string
|
||||||
|
CoreEvent string
|
||||||
|
}
|
||||||
|
|
||||||
// Runtime 封装协调器生命周期,提供 TUI 所需的非阻塞接口。
|
// Runtime 封装协调器生命周期,提供 TUI 所需的非阻塞接口。
|
||||||
type Runtime struct {
|
type Runtime struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
store *state.Store
|
store *state.Store
|
||||||
coordinator *agentcore.Agent
|
coordinator *agentcore.Agent
|
||||||
|
askUser *tools.AskUserTool
|
||||||
events chan UIEvent
|
events chan UIEvent
|
||||||
|
streamCh chan string // 流式 token channel(独立于 events,避免淹没事件日志)
|
||||||
|
clearCh chan struct{} // 流式缓冲清空信号
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
running bool
|
running bool
|
||||||
@@ -68,6 +83,11 @@ func (rt *Runtime) Dir() string {
|
|||||||
return rt.store.Dir()
|
return rt.store.Dir()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AskUser 返回 ask_user 工具实例,供 TUI 注入交互 handler。
|
||||||
|
func (rt *Runtime) AskUser() *tools.AskUserTool {
|
||||||
|
return rt.askUser
|
||||||
|
}
|
||||||
|
|
||||||
// NewRuntime 创建 Runtime:初始化 store/model/coordinator,注册事件订阅,但不启动 Prompt。
|
// NewRuntime 创建 Runtime:初始化 store/model/coordinator,注册事件订阅,但不启动 Prompt。
|
||||||
func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[string]string) (*Runtime, error) {
|
func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[string]string) (*Runtime, error) {
|
||||||
cfg.FillDefaults()
|
cfg.FillDefaults()
|
||||||
@@ -85,18 +105,21 @@ func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[s
|
|||||||
return nil, fmt.Errorf("create model: %w", err)
|
return nil, fmt.Errorf("create model: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
coordinator := BuildCoordinator(cfg, store, model, refs, prompts, styles)
|
coordinator, askUser := BuildCoordinator(cfg, store, model, refs, prompts, styles)
|
||||||
|
|
||||||
rt := &Runtime{
|
rt := &Runtime{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
store: store,
|
store: store,
|
||||||
coordinator: coordinator,
|
coordinator: coordinator,
|
||||||
|
askUser: askUser,
|
||||||
events: make(chan UIEvent, 100),
|
events: make(chan UIEvent, 100),
|
||||||
|
streamCh: make(chan string, 256),
|
||||||
|
clearCh: make(chan struct{}, 4),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注册事件订阅:确定性控制 + UIEvent 转发
|
// 注册事件订阅:确定性控制 + UIEvent 转发 + 流式 delta 转发
|
||||||
registerSubscription(coordinator, store, cfg.MaxChapters, rt.emit)
|
registerSubscription(coordinator, store, rt.emit, rt.emitDelta, rt.emitClear)
|
||||||
|
|
||||||
// 初始化运行元信息
|
// 初始化运行元信息
|
||||||
if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil {
|
if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil {
|
||||||
@@ -106,6 +129,43 @@ func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[s
|
|||||||
return rt, nil
|
return rt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stream 返回只读流式 token 通道。
|
||||||
|
func (rt *Runtime) Stream() <-chan string {
|
||||||
|
return rt.streamCh
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamClear 返回只读流式清空信号通道。
|
||||||
|
func (rt *Runtime) StreamClear() <-chan struct{} {
|
||||||
|
return rt.clearCh
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitClear 发送流式缓冲清空信号,非阻塞。
|
||||||
|
func (rt *Runtime) emitClear() {
|
||||||
|
defer func() { recover() }()
|
||||||
|
select {
|
||||||
|
case rt.clearCh <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitDelta 向流式通道发送 token,非阻塞(满时丢弃旧数据)。
|
||||||
|
func (rt *Runtime) emitDelta(delta string) {
|
||||||
|
defer func() { recover() }()
|
||||||
|
select {
|
||||||
|
case rt.streamCh <- delta:
|
||||||
|
default:
|
||||||
|
// 满了就丢弃最旧的再写入
|
||||||
|
select {
|
||||||
|
case <-rt.streamCh:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case rt.streamCh <- delta:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// emit 向事件通道发送事件,非阻塞(满时丢弃最旧事件)。
|
// emit 向事件通道发送事件,非阻塞(满时丢弃最旧事件)。
|
||||||
func (rt *Runtime) emit(ev UIEvent) {
|
func (rt *Runtime) emit(ev UIEvent) {
|
||||||
defer func() { recover() }() // 防止 channel 关闭后写入 panic
|
defer func() { recover() }() // 防止 channel 关闭后写入 panic
|
||||||
@@ -132,11 +192,11 @@ func (rt *Runtime) Start(prompt string) error {
|
|||||||
}
|
}
|
||||||
rt.mu.Unlock()
|
rt.mu.Unlock()
|
||||||
|
|
||||||
if err := rt.store.InitProgress(rt.cfg.NovelName, rt.cfg.MaxChapters); err != nil {
|
if err := rt.store.InitProgress(rt.cfg.NovelName, 0); err != nil {
|
||||||
return fmt.Errorf("init progress: %w", err)
|
return fmt.Errorf("init progress: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
promptText := fmt.Sprintf("请创作一部 %d 章的小说。要求如下:\n\n%s", rt.cfg.MaxChapters, prompt)
|
promptText := fmt.Sprintf("请创作一部小说,章节数量由你根据故事需要自行决定。要求如下:\n\n%s", prompt)
|
||||||
if err := rt.coordinator.Prompt(promptText); err != nil {
|
if err := rt.coordinator.Prompt(promptText); err != nil {
|
||||||
return fmt.Errorf("prompt: %w", err)
|
return fmt.Errorf("prompt: %w", err)
|
||||||
}
|
}
|
||||||
@@ -161,7 +221,7 @@ func (rt *Runtime) Resume() (string, error) {
|
|||||||
|
|
||||||
progress, _ := rt.store.LoadProgress()
|
progress, _ := rt.store.LoadProgress()
|
||||||
runMeta, _ := rt.store.LoadRunMeta()
|
runMeta, _ := rt.store.LoadRunMeta()
|
||||||
recovery := determineRecovery(progress, runMeta, rt.cfg.MaxChapters)
|
recovery := determineRecovery(progress, runMeta)
|
||||||
|
|
||||||
if recovery.IsNew {
|
if recovery.IsNew {
|
||||||
return "", nil
|
return "", nil
|
||||||
@@ -220,7 +280,7 @@ func (rt *Runtime) Snapshot() UISnapshot {
|
|||||||
snap.StatusLabel = rt.deriveStatusLabel(progress, snap.IsRunning)
|
snap.StatusLabel = rt.deriveStatusLabel(progress, snap.IsRunning)
|
||||||
|
|
||||||
// 恢复标签
|
// 恢复标签
|
||||||
recovery := determineRecovery(progress, runMeta, rt.cfg.MaxChapters)
|
recovery := determineRecovery(progress, runMeta)
|
||||||
if !recovery.IsNew {
|
if !recovery.IsNew {
|
||||||
snap.RecoveryLabel = recovery.Label
|
snap.RecoveryLabel = recovery.Label
|
||||||
}
|
}
|
||||||
@@ -247,6 +307,8 @@ func (rt *Runtime) Close() {
|
|||||||
finalizeSteerIfIdle(rt.store)
|
finalizeSteerIfIdle(rt.store)
|
||||||
rt.closeOnce.Do(func() {
|
rt.closeOnce.Do(func() {
|
||||||
close(rt.events)
|
close(rt.events)
|
||||||
|
close(rt.streamCh)
|
||||||
|
close(rt.clearCh)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +344,27 @@ func (rt *Runtime) deriveStatusLabel(progress *domain.Progress, isRunning bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rt *Runtime) fillDetails(snap *UISnapshot, progress *domain.Progress) {
|
func (rt *Runtime) fillDetails(snap *UISnapshot, progress *domain.Progress) {
|
||||||
|
// 基础设定
|
||||||
|
if premise, _ := rt.store.LoadPremise(); premise != "" {
|
||||||
|
snap.Premise = truncateLog(premise, 80)
|
||||||
|
}
|
||||||
|
if outline, _ := rt.store.LoadOutline(); len(outline) > 0 {
|
||||||
|
for _, e := range outline {
|
||||||
|
snap.Outline = append(snap.Outline, OutlineSnapshot{
|
||||||
|
Chapter: e.Chapter, Title: e.Title, CoreEvent: e.CoreEvent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chars, _ := rt.store.LoadCharacters(); len(chars) > 0 {
|
||||||
|
for _, c := range chars {
|
||||||
|
label := c.Name
|
||||||
|
if c.Role != "" {
|
||||||
|
label += "(" + c.Role + ")"
|
||||||
|
}
|
||||||
|
snap.Characters = append(snap.Characters, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 最近 commit:从 progress 的已完成章节 + 摘要推算(信号文件是一次性的,不可靠)
|
// 最近 commit:从 progress 的已完成章节 + 摘要推算(信号文件是一次性的,不可靠)
|
||||||
if progress != nil && len(progress.CompletedChapters) > 0 {
|
if progress != nil && len(progress.CompletedChapters) > 0 {
|
||||||
lastCh := progress.CompletedChapters[len(progress.CompletedChapters)-1]
|
lastCh := progress.CompletedChapters[len(progress.CompletedChapters)-1]
|
||||||
|
|||||||
3
main.go
3
main.go
@@ -65,9 +65,6 @@ func buildConfig(style string) app.Config {
|
|||||||
ModelName: envOr("MODEL_NAME", ""),
|
ModelName: envOr("MODEL_NAME", ""),
|
||||||
Style: style,
|
Style: style,
|
||||||
}
|
}
|
||||||
if v := os.Getenv("MAX_CHAPTERS"); v != "" {
|
|
||||||
fmt.Sscanf(v, "%d", &cfg.MaxChapters)
|
|
||||||
}
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,19 @@ func (s *Store) InitProgress(novelName string, totalChapters int) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTotalChapters 根据大纲长度设定总章节数。
|
||||||
|
func (s *Store) SetTotalChapters(n int) error {
|
||||||
|
p, err := s.LoadProgress()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if p == nil {
|
||||||
|
p = &domain.Progress{}
|
||||||
|
}
|
||||||
|
p.TotalChapters = n
|
||||||
|
return s.SaveProgress(p)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdatePhase 更新创作阶段。
|
// UpdatePhase 更新创作阶段。
|
||||||
func (s *Store) UpdatePhase(phase domain.Phase) error {
|
func (s *Store) UpdatePhase(phase domain.Phase) error {
|
||||||
p, err := s.LoadProgress()
|
p, err := s.LoadProgress()
|
||||||
|
|||||||
154
tools/ask_user.go
Normal file
154
tools/ask_user.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/voocel/agentcore/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AskUserResponse 用户回答结果。
|
||||||
|
type AskUserResponse struct {
|
||||||
|
Answers map[string]string // question text → 用户选择的答案
|
||||||
|
Notes map[string]string // question text → 自定义输入(选"其他"时)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AskUserHandler 阻塞等待用户回答,由 CLI 或 TUI 注入具体实现。
|
||||||
|
type AskUserHandler func(ctx context.Context, questions []Question) (*AskUserResponse, error)
|
||||||
|
|
||||||
|
// Question 单个问题。
|
||||||
|
type Question struct {
|
||||||
|
Question string `json:"question"`
|
||||||
|
Header string `json:"header"`
|
||||||
|
Options []Option `json:"options"`
|
||||||
|
MultiSelect bool `json:"multiSelect"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option 可选项。
|
||||||
|
type Option struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AskUserTool 让 LLM 向用户提出结构化问题。
|
||||||
|
type AskUserTool struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
handler AskUserHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAskUserTool() *AskUserTool {
|
||||||
|
return &AskUserTool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHandler 注入 UI 回调,CLI 和 TUI 各自实现。
|
||||||
|
func (t *AskUserTool) SetHandler(h AskUserHandler) {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.handler = h
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AskUserTool) Name() string { return "ask_user" }
|
||||||
|
func (t *AskUserTool) Label() string { return "询问用户" }
|
||||||
|
func (t *AskUserTool) Description() string {
|
||||||
|
return "向用户提出结构化问题,用于需要用户确认方向、澄清需求或做出选择时。用户可以从预设选项中选择,也可以自由输入。"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AskUserTool) Schema() map[string]any {
|
||||||
|
option := schema.Object(
|
||||||
|
schema.Property("label", schema.String("选项显示文本(1-5个词)")).Required(),
|
||||||
|
schema.Property("description", schema.String("选项含义说明")).Required(),
|
||||||
|
)
|
||||||
|
question := schema.Object(
|
||||||
|
schema.Property("question", schema.String("完整的问题文本")).Required(),
|
||||||
|
schema.Property("header", schema.String("短标签(最多12字符)")).Required(),
|
||||||
|
schema.Property("options", schema.Array("2-4个可选项", option)).Required(),
|
||||||
|
schema.Property("multiSelect", schema.Bool("是否允许多选")),
|
||||||
|
)
|
||||||
|
return schema.Object(
|
||||||
|
schema.Property("questions", schema.Array("1-4个问题", question)).Required(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type askUserArgs struct {
|
||||||
|
Questions []Question `json:"questions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AskUserTool) Execute(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var a askUserArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid args: %w", err)
|
||||||
|
}
|
||||||
|
if err := validateQuestions(a.Questions); err != nil {
|
||||||
|
return json.Marshal(fmt.Sprintf("参数校验失败: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.RLock()
|
||||||
|
h := t.handler
|
||||||
|
t.mu.RUnlock()
|
||||||
|
|
||||||
|
if h == nil {
|
||||||
|
return json.Marshal("当前环境不支持交互式询问,请根据你的判断自行决策并继续。")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h(ctx, a.Questions)
|
||||||
|
if err != nil {
|
||||||
|
return json.Marshal(fmt.Sprintf("用户交互失败: %s。请根据你的判断自行决策并继续。", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(formatAnswers(a.Questions, resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateQuestions(questions []Question) error {
|
||||||
|
if len(questions) == 0 {
|
||||||
|
return fmt.Errorf("至少需要一个问题")
|
||||||
|
}
|
||||||
|
if len(questions) > 4 {
|
||||||
|
return fmt.Errorf("最多4个问题,当前 %d 个", len(questions))
|
||||||
|
}
|
||||||
|
for i, q := range questions {
|
||||||
|
if q.Question == "" {
|
||||||
|
return fmt.Errorf("问题 %d: 问题文本不能为空", i+1)
|
||||||
|
}
|
||||||
|
if q.Header == "" {
|
||||||
|
return fmt.Errorf("问题 %d: header 不能为空", i+1)
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(q.Header) > 12 {
|
||||||
|
return fmt.Errorf("问题 %d: header %q 超过12字符", i+1, q.Header)
|
||||||
|
}
|
||||||
|
if len(q.Options) < 2 || len(q.Options) > 4 {
|
||||||
|
return fmt.Errorf("问题 %d: 需要2-4个选项,当前 %d 个", i+1, len(q.Options))
|
||||||
|
}
|
||||||
|
for j, opt := range q.Options {
|
||||||
|
if opt.Label == "" {
|
||||||
|
return fmt.Errorf("问题 %d 选项 %d: label 不能为空", i+1, j+1)
|
||||||
|
}
|
||||||
|
if opt.Description == "" {
|
||||||
|
return fmt.Errorf("问题 %d 选项 %d: description 不能为空", i+1, j+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAnswers(questions []Question, resp *AskUserResponse) string {
|
||||||
|
if resp == nil || len(resp.Answers) == 0 {
|
||||||
|
return "用户未提供回答,请根据你的判断自行决策并继续。"
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
for _, q := range questions {
|
||||||
|
answer, ok := resp.Answers[q.Question]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry := fmt.Sprintf("[%s] %s", q.Header, answer)
|
||||||
|
if note, hasNote := resp.Notes[q.Question]; hasNote {
|
||||||
|
entry += "(补充:" + note + ")"
|
||||||
|
}
|
||||||
|
parts = append(parts, entry)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("用户回答:%s", strings.Join(parts, ";"))
|
||||||
|
}
|
||||||
@@ -58,6 +58,8 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
|
|||||||
return nil, fmt.Errorf("save outline: %w", err)
|
return nil, fmt.Errorf("save outline: %w", err)
|
||||||
}
|
}
|
||||||
_ = t.store.UpdatePhase(domain.PhaseOutline)
|
_ = t.store.UpdatePhase(domain.PhaseOutline)
|
||||||
|
// 根据大纲长度自动设定总章节数
|
||||||
|
_ = t.store.SetTotalChapters(len(entries))
|
||||||
return json.Marshal(map[string]any{"saved": true, "type": "outline", "chapters": len(entries)})
|
return json.Marshal(map[string]any{"saved": true, "type": "outline", "chapters": len(entries)})
|
||||||
|
|
||||||
case "characters":
|
case "characters":
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import (
|
|||||||
|
|
||||||
// 消息类型
|
// 消息类型
|
||||||
type (
|
type (
|
||||||
eventMsg app.UIEvent
|
eventMsg app.UIEvent
|
||||||
snapshotMsg app.UISnapshot
|
snapshotMsg app.UISnapshot
|
||||||
doneMsg struct{}
|
doneMsg struct{}
|
||||||
startResultMsg struct{ err error }
|
startResultMsg struct{ err error }
|
||||||
steerResultMsg struct{}
|
steerResultMsg struct{}
|
||||||
spinnerTickMsg time.Time
|
spinnerTickMsg time.Time
|
||||||
|
streamDeltaMsg string // 流式 token 增量
|
||||||
|
streamClearMsg struct{} // 清空流式缓冲(新消息开始)
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Cmd 函数 ---
|
// --- Cmd 函数 ---
|
||||||
@@ -76,7 +78,27 @@ func steerRuntime(rt *app.Runtime, text string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func tickSpinner() tea.Cmd {
|
func tickSpinner() tea.Cmd {
|
||||||
return tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg {
|
return tea.Tick(350*time.Millisecond, func(t time.Time) tea.Msg {
|
||||||
return spinnerTickMsg(t)
|
return spinnerTickMsg(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func listenStream(rt *app.Runtime) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
delta, ok := <-rt.Stream()
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return streamDeltaMsg(delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenStreamClear(rt *app.Runtime) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
_, ok := <-rt.StreamClear()
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return streamClearMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
61
tui/input.go
61
tui/input.go
@@ -1,14 +1,67 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import "github.com/charmbracelet/lipgloss"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/voocel/ainovel-cli/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// renderInputBox 渲染底部栏:左快捷键 | 中输入框 | 右进度+目录。
|
||||||
|
func renderInputBox(inputView string, snap app.UISnapshot, outputDir string, width int) string {
|
||||||
|
// 左侧:快捷键提示
|
||||||
|
keys := lipgloss.NewStyle().Foreground(colorDim).Render("Tab·^L·Esc")
|
||||||
|
|
||||||
|
// 右侧:进度 + 输出目录
|
||||||
|
right := buildRightInfo(snap, outputDir)
|
||||||
|
|
||||||
|
// 中间:输入框,自适应宽度
|
||||||
|
leftW := lipgloss.Width(keys)
|
||||||
|
rightW := lipgloss.Width(right)
|
||||||
|
sep := lipgloss.NewStyle().Foreground(colorDim).Render(" │ ")
|
||||||
|
sepW := lipgloss.Width(sep)
|
||||||
|
inputW := width - leftW - rightW - sepW*2 - 4 // 4 为 padding+border 余量
|
||||||
|
if inputW < 20 {
|
||||||
|
inputW = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
input := lipgloss.NewStyle().Width(inputW).Render(inputView)
|
||||||
|
|
||||||
|
content := keys + sep + input + sep + right
|
||||||
|
|
||||||
// renderInputBox 渲染底部输入框区域。
|
|
||||||
func renderInputBox(inputView string, width int) string {
|
|
||||||
style := lipgloss.NewStyle().
|
style := lipgloss.NewStyle().
|
||||||
Width(width).
|
Width(width).
|
||||||
Border(baseBorder, true, false, false, false).
|
Border(baseBorder, true, false, false, false).
|
||||||
BorderForeground(colorDim).
|
BorderForeground(colorDim).
|
||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|
||||||
return style.Render(inputView)
|
return style.Render(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRightInfo 构建右侧进度和目录信息。
|
||||||
|
func buildRightInfo(snap app.UISnapshot, outputDir string) string {
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
// 章节进度
|
||||||
|
if snap.TotalChapters > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("Ch %d/%d", snap.CompletedCount, snap.TotalChapters))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字数
|
||||||
|
if snap.TotalWordCount > 0 {
|
||||||
|
parts = append(parts, formatNumber(snap.TotalWordCount)+"字")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输出目录(缩短为相对路径的最后一段)
|
||||||
|
if outputDir != "" {
|
||||||
|
dir := filepath.Base(outputDir)
|
||||||
|
parts = append(parts, "./"+dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return lipgloss.NewStyle().Foreground(colorDim).Render("READY")
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().Foreground(colorDim).Render(strings.Join(parts, " · "))
|
||||||
}
|
}
|
||||||
|
|||||||
147
tui/model.go
147
tui/model.go
@@ -21,7 +21,7 @@ const (
|
|||||||
modeDone // 创作完成
|
modeDone // 创作完成
|
||||||
)
|
)
|
||||||
|
|
||||||
// spinner 帧序列
|
// 顶栏 spinner 帧序列
|
||||||
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||||
|
|
||||||
// Model 是 TUI 的顶层状态。
|
// Model 是 TUI 的顶层状态。
|
||||||
@@ -29,11 +29,15 @@ type Model struct {
|
|||||||
runtime *app.Runtime
|
runtime *app.Runtime
|
||||||
snapshot app.UISnapshot
|
snapshot app.UISnapshot
|
||||||
events []app.UIEvent
|
events []app.UIEvent
|
||||||
viewport viewport.Model
|
viewport viewport.Model // 事件流 viewport
|
||||||
|
streamVP viewport.Model // 流式输出 viewport
|
||||||
|
streamBuf strings.Builder // 流式文本累积缓冲
|
||||||
textarea textarea.Model
|
textarea textarea.Model
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
autoScroll bool
|
autoScroll bool
|
||||||
|
streamScroll bool // 流式面板自动跟随
|
||||||
|
focusStream bool // true=焦点在流式面板, false=事件流
|
||||||
mode appMode
|
mode appMode
|
||||||
err error
|
err error
|
||||||
spinnerIdx int
|
spinnerIdx int
|
||||||
@@ -44,7 +48,7 @@ func NewModel(rt *app.Runtime) Model {
|
|||||||
ta := textarea.New()
|
ta := textarea.New()
|
||||||
ta.Placeholder = "输入小说需求,例如:写一部12章都市悬疑小说"
|
ta.Placeholder = "输入小说需求,例如:写一部12章都市悬疑小说"
|
||||||
ta.CharLimit = 500
|
ta.CharLimit = 500
|
||||||
ta.MaxHeight = 3
|
ta.MaxHeight = 1
|
||||||
ta.ShowLineNumbers = false
|
ta.ShowLineNumbers = false
|
||||||
ta.Focus()
|
ta.Focus()
|
||||||
|
|
||||||
@@ -54,12 +58,17 @@ func NewModel(rt *app.Runtime) Model {
|
|||||||
vp := viewport.New(80, 20)
|
vp := viewport.New(80, 20)
|
||||||
vp.SetContent("")
|
vp.SetContent("")
|
||||||
|
|
||||||
|
svp := viewport.New(80, 10)
|
||||||
|
svp.SetContent("")
|
||||||
|
|
||||||
return Model{
|
return Model{
|
||||||
runtime: rt,
|
runtime: rt,
|
||||||
autoScroll: true,
|
autoScroll: true,
|
||||||
mode: modeNew,
|
streamScroll: true,
|
||||||
textarea: ta,
|
mode: modeNew,
|
||||||
viewport: vp,
|
textarea: ta,
|
||||||
|
viewport: vp,
|
||||||
|
streamVP: svp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +77,8 @@ func (m Model) Init() tea.Cmd {
|
|||||||
textarea.Blink,
|
textarea.Blink,
|
||||||
listenEvents(m.runtime),
|
listenEvents(m.runtime),
|
||||||
listenDone(m.runtime),
|
listenDone(m.runtime),
|
||||||
|
listenStream(m.runtime),
|
||||||
|
listenStreamClear(m.runtime),
|
||||||
tickSnapshot(m.runtime),
|
tickSnapshot(m.runtime),
|
||||||
checkResume(m.runtime),
|
checkResume(m.runtime),
|
||||||
tickSpinner(),
|
tickSpinner(),
|
||||||
@@ -81,7 +92,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
m.textarea.SetWidth(m.width - 4)
|
m.textarea.SetWidth(m.inputWidth())
|
||||||
m.updateViewportSize()
|
m.updateViewportSize()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -96,6 +107,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.events = nil
|
m.events = nil
|
||||||
m.viewport.SetContent("")
|
m.viewport.SetContent("")
|
||||||
m.viewport.GotoTop()
|
m.viewport.GotoTop()
|
||||||
|
m.streamBuf.Reset()
|
||||||
|
m.streamVP.SetContent("")
|
||||||
|
m.streamVP.GotoTop()
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyTab:
|
||||||
|
m.focusStream = !m.focusStream
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyEnter:
|
case tea.KeyEnter:
|
||||||
text := strings.TrimSpace(m.textarea.Value())
|
text := strings.TrimSpace(m.textarea.Value())
|
||||||
@@ -113,31 +130,53 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyUp, tea.KeyPgUp:
|
case tea.KeyUp, tea.KeyPgUp:
|
||||||
|
if m.focusStream {
|
||||||
|
m.streamScroll = false
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.streamVP, cmd = m.streamVP.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
m.autoScroll = false
|
m.autoScroll = false
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.viewport, cmd = m.viewport.Update(msg)
|
m.viewport, cmd = m.viewport.Update(msg)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
case tea.KeyDown, tea.KeyPgDown:
|
case tea.KeyDown, tea.KeyPgDown:
|
||||||
|
if m.focusStream {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.streamVP, cmd = m.streamVP.Update(msg)
|
||||||
|
if m.streamVP.AtBottom() {
|
||||||
|
m.streamScroll = true
|
||||||
|
}
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.viewport, cmd = m.viewport.Update(msg)
|
m.viewport, cmd = m.viewport.Update(msg)
|
||||||
// 滚动到底部时恢复自动跟随
|
|
||||||
if m.viewport.AtBottom() {
|
if m.viewport.AtBottom() {
|
||||||
m.autoScroll = true
|
m.autoScroll = true
|
||||||
}
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
case tea.KeyEnd:
|
case tea.KeyEnd:
|
||||||
m.autoScroll = true
|
if m.focusStream {
|
||||||
m.viewport.GotoBottom()
|
m.streamScroll = true
|
||||||
|
m.streamVP.GotoBottom()
|
||||||
|
} else {
|
||||||
|
m.autoScroll = true
|
||||||
|
m.viewport.GotoBottom()
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
case tea.MouseMsg:
|
case tea.MouseMsg:
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.viewport, cmd = m.viewport.Update(msg)
|
if m.focusStream {
|
||||||
if msg.Action == tea.MouseActionPress {
|
m.streamVP, cmd = m.streamVP.Update(msg)
|
||||||
m.autoScroll = false
|
if msg.Action == tea.MouseActionPress {
|
||||||
if m.viewport.AtBottom() {
|
m.streamScroll = m.streamVP.AtBottom()
|
||||||
m.autoScroll = true
|
}
|
||||||
|
} else {
|
||||||
|
m.viewport, cmd = m.viewport.Update(msg)
|
||||||
|
if msg.Action == tea.MouseActionPress {
|
||||||
|
m.autoScroll = m.viewport.AtBottom()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
@@ -181,7 +220,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case spinnerTickMsg:
|
case spinnerTickMsg:
|
||||||
m.spinnerIdx = (m.spinnerIdx + 1) % len(spinnerFrames)
|
m.spinnerIdx = (m.spinnerIdx + 1) % len(spinnerFrames)
|
||||||
|
if m.snapshot.IsRunning {
|
||||||
|
m.refreshEventViewport()
|
||||||
|
}
|
||||||
return m, tickSpinner()
|
return m, tickSpinner()
|
||||||
|
|
||||||
|
case streamDeltaMsg:
|
||||||
|
m.streamBuf.WriteString(string(msg))
|
||||||
|
m.streamVP.SetContent(m.streamBuf.String())
|
||||||
|
if m.streamScroll {
|
||||||
|
m.streamVP.GotoBottom()
|
||||||
|
}
|
||||||
|
return m, listenStream(m.runtime)
|
||||||
|
|
||||||
|
case streamClearMsg:
|
||||||
|
m.streamBuf.Reset()
|
||||||
|
m.streamVP.SetContent("")
|
||||||
|
m.streamVP.GotoTop()
|
||||||
|
m.streamScroll = true
|
||||||
|
return m, listenStreamClear(m.runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新 textarea 组件
|
// 更新 textarea 组件
|
||||||
@@ -196,6 +253,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
func (m *Model) refreshEventViewport() {
|
func (m *Model) refreshEventViewport() {
|
||||||
centerW := m.eventFlowWidth()
|
centerW := m.eventFlowWidth()
|
||||||
content := renderEventContent(m.events, centerW)
|
content := renderEventContent(m.events, centerW)
|
||||||
|
if m.snapshot.IsRunning {
|
||||||
|
content += renderSparkle(m.spinnerIdx)
|
||||||
|
}
|
||||||
m.viewport.SetContent(content)
|
m.viewport.SetContent(content)
|
||||||
if m.autoScroll {
|
if m.autoScroll {
|
||||||
m.viewport.GotoBottom()
|
m.viewport.GotoBottom()
|
||||||
@@ -206,8 +266,39 @@ func (m *Model) refreshEventViewport() {
|
|||||||
func (m *Model) updateViewportSize() {
|
func (m *Model) updateViewportSize() {
|
||||||
centerW := m.eventFlowWidth()
|
centerW := m.eventFlowWidth()
|
||||||
bodyH := m.bodyHeight()
|
bodyH := m.bodyHeight()
|
||||||
|
eventH, streamH := m.splitHeights(bodyH)
|
||||||
m.viewport.Width = centerW - 2
|
m.viewport.Width = centerW - 2
|
||||||
m.viewport.Height = bodyH
|
m.viewport.Height = eventH
|
||||||
|
m.streamVP.Width = centerW - 2
|
||||||
|
m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitHeights 计算事件流和流式输出的高度分配。
|
||||||
|
func (m *Model) splitHeights(bodyH int) (eventH, streamH int) {
|
||||||
|
eventH = bodyH * 40 / 100
|
||||||
|
if eventH < 3 {
|
||||||
|
eventH = 3
|
||||||
|
}
|
||||||
|
streamH = bodyH - eventH - 1 // -1 为分隔线
|
||||||
|
if streamH < 3 {
|
||||||
|
streamH = 3
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) inputWidth() int {
|
||||||
|
if m.width == 0 {
|
||||||
|
return 60
|
||||||
|
}
|
||||||
|
// 与 renderInputBox 中 inputW 计算一致
|
||||||
|
keysW := 10 // "Tab·^L·Esc"
|
||||||
|
rightW := 30 // 进度+目录预估
|
||||||
|
sepW := 3 * 2
|
||||||
|
w := m.width - keysW - rightW - sepW - 4
|
||||||
|
if w < 20 {
|
||||||
|
w = 20
|
||||||
|
}
|
||||||
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) eventFlowWidth() int {
|
func (m *Model) eventFlowWidth() int {
|
||||||
@@ -224,7 +315,7 @@ func (m *Model) bodyHeight() int {
|
|||||||
return 20
|
return 20
|
||||||
}
|
}
|
||||||
topH := 1
|
topH := 1
|
||||||
inputH := 3
|
inputH := 2 // 单行输入 + top border
|
||||||
bodyH := m.height - topH - inputH
|
bodyH := m.height - topH - inputH
|
||||||
if bodyH < 3 {
|
if bodyH < 3 {
|
||||||
bodyH = 3
|
bodyH = 3
|
||||||
@@ -246,11 +337,11 @@ func (m Model) View() string {
|
|||||||
|
|
||||||
spinnerFrame := ""
|
spinnerFrame := ""
|
||||||
if m.snapshot.IsRunning {
|
if m.snapshot.IsRunning {
|
||||||
spinnerFrame = spinnerFrames[m.spinnerIdx]
|
spinnerFrame = spinnerFrames[m.spinnerIdx%len(spinnerFrames)]
|
||||||
}
|
}
|
||||||
|
|
||||||
topBar := renderTopBar(m.snapshot, m.width, spinnerFrame)
|
topBar := renderTopBar(m.snapshot, m.width, spinnerFrame)
|
||||||
inputBox := renderInputBox(m.textarea.View(), m.width)
|
inputBox := renderInputBox(m.textarea.View(), m.snapshot, m.runtime.Dir(), m.width)
|
||||||
|
|
||||||
topH := lipgloss.Height(topBar)
|
topH := lipgloss.Height(topBar)
|
||||||
inputH := lipgloss.Height(inputBox)
|
inputH := lipgloss.Height(inputBox)
|
||||||
@@ -270,14 +361,22 @@ func (m Model) View() string {
|
|||||||
leftW := m.width * 25 / 100
|
leftW := m.width * 25 / 100
|
||||||
rightW := m.width * 30 / 100
|
rightW := m.width * 30 / 100
|
||||||
centerW := m.width - leftW - rightW
|
centerW := m.width - leftW - rightW
|
||||||
|
eventH, streamH := m.splitHeights(bodyH)
|
||||||
|
|
||||||
if m.viewport.Width != centerW-2 || m.viewport.Height != bodyH {
|
if m.viewport.Width != centerW-2 || m.viewport.Height != eventH {
|
||||||
m.viewport.Width = centerW - 2
|
m.viewport.Width = centerW - 2
|
||||||
m.viewport.Height = bodyH
|
m.viewport.Height = eventH
|
||||||
|
}
|
||||||
|
if m.streamVP.Width != centerW-2 || m.streamVP.Height != streamH-1 {
|
||||||
|
m.streamVP.Width = centerW - 2
|
||||||
|
m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eventFlow := renderEventFlowViewport(m.viewport, centerW, eventH)
|
||||||
|
streamPanel := renderStreamPanel(m.streamVP, centerW, streamH, m.focusStream)
|
||||||
|
center := lipgloss.JoinVertical(lipgloss.Left, eventFlow, streamPanel)
|
||||||
|
|
||||||
left := renderStatePanel(m.snapshot, leftW, bodyH)
|
left := renderStatePanel(m.snapshot, leftW, bodyH)
|
||||||
center := renderEventFlowViewport(m.viewport, centerW, bodyH)
|
|
||||||
right := renderDetailPanel(m.snapshot, rightW, bodyH)
|
right := renderDetailPanel(m.snapshot, rightW, bodyH)
|
||||||
body = lipgloss.JoinHorizontal(lipgloss.Top, left, center, right)
|
body = lipgloss.JoinHorizontal(lipgloss.Top, left, center, right)
|
||||||
}
|
}
|
||||||
|
|||||||
155
tui/panels.go
155
tui/panels.go
@@ -9,15 +9,33 @@ import (
|
|||||||
"github.com/voocel/ainovel-cli/app"
|
"github.com/voocel/ainovel-cli/app"
|
||||||
)
|
)
|
||||||
|
|
||||||
// renderTopBar 渲染顶部状态栏。
|
// renderTopBar 渲染顶部状态栏(两行布局)。
|
||||||
|
// 第一行:小说名居中
|
||||||
|
// 第二行:左侧模型/风格信息,右侧状态胶囊
|
||||||
func renderTopBar(snap app.UISnapshot, width int, spinnerFrame string) string {
|
func renderTopBar(snap app.UISnapshot, width int, spinnerFrame string) string {
|
||||||
left := lipgloss.NewStyle().Foreground(colorText).Bold(true).Render(snap.NovelName)
|
// 第一行:小说名居中
|
||||||
if snap.Style != "" && snap.Style != "default" {
|
novelName := snap.NovelName
|
||||||
left += " " + lipgloss.NewStyle().Foreground(colorDim).Render(snap.Style)
|
if novelName == "" {
|
||||||
|
novelName = "Novel Agent"
|
||||||
}
|
}
|
||||||
left += " " + lipgloss.NewStyle().Foreground(colorDim).Render(snap.ModelName)
|
line1 := lipgloss.NewStyle().
|
||||||
|
Width(width - 2).
|
||||||
|
AlignHorizontal(lipgloss.Center).
|
||||||
|
Foreground(colorText).
|
||||||
|
Bold(true).
|
||||||
|
Render("✦ " + novelName + " ✦")
|
||||||
|
|
||||||
// 状态胶囊
|
// 第二行左侧:模型 + 风格
|
||||||
|
var infoParts []string
|
||||||
|
if snap.ModelName != "" {
|
||||||
|
infoParts = append(infoParts, snap.ModelName)
|
||||||
|
}
|
||||||
|
if snap.Style != "" && snap.Style != "default" {
|
||||||
|
infoParts = append(infoParts, snap.Style)
|
||||||
|
}
|
||||||
|
left := lipgloss.NewStyle().Foreground(colorDim).Render(strings.Join(infoParts, " · "))
|
||||||
|
|
||||||
|
// 第二行右侧:状态胶囊
|
||||||
label := snap.StatusLabel
|
label := snap.StatusLabel
|
||||||
if label == "" {
|
if label == "" {
|
||||||
label = "READY"
|
label = "READY"
|
||||||
@@ -28,18 +46,21 @@ func renderTopBar(snap app.UISnapshot, width int, spinnerFrame string) string {
|
|||||||
}
|
}
|
||||||
capsule := statusCapsule.Foreground(lipgloss.Color("#1a1a2e")).Background(color).Render(label)
|
capsule := statusCapsule.Foreground(lipgloss.Color("#1a1a2e")).Background(color).Render(label)
|
||||||
|
|
||||||
// Spinner(运行中显示)
|
|
||||||
if snap.IsRunning && spinnerFrame != "" {
|
if snap.IsRunning && spinnerFrame != "" {
|
||||||
capsule = lipgloss.NewStyle().Foreground(colorAccent).Render(spinnerFrame) + " " + capsule
|
capsule = lipgloss.NewStyle().Foreground(colorAccent).Render(spinnerFrame) + " " + capsule
|
||||||
}
|
}
|
||||||
|
|
||||||
// 左右填充
|
|
||||||
gap := width - lipgloss.Width(left) - lipgloss.Width(capsule) - 2
|
gap := width - lipgloss.Width(left) - lipgloss.Width(capsule) - 2
|
||||||
if gap < 1 {
|
if gap < 1 {
|
||||||
gap = 1
|
gap = 1
|
||||||
}
|
}
|
||||||
|
line2 := left + strings.Repeat(" ", gap) + capsule
|
||||||
|
|
||||||
return topBarStyle.Width(width).Render(left + strings.Repeat(" ", gap) + capsule)
|
content := line1 + "\n" + line2
|
||||||
|
return topBarStyle.Width(width).
|
||||||
|
Border(baseBorder, false, false, true, false).
|
||||||
|
BorderForeground(colorDim).
|
||||||
|
Render(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderStatePanel 渲染左侧状态面板。
|
// renderStatePanel 渲染左侧状态面板。
|
||||||
@@ -89,6 +110,40 @@ func renderStatePanel(snap app.UISnapshot, width, height int) string {
|
|||||||
return style.Render(b.String())
|
return style.Render(b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 星光动画帧
|
||||||
|
var sparklePatterns = []string{
|
||||||
|
" ✦ · ✧ · ",
|
||||||
|
" · ✧ ✦ · ",
|
||||||
|
" · ✦ · ✧ ",
|
||||||
|
" ✧ · ✧ ✦ ·",
|
||||||
|
" ✦ · ✧ · ",
|
||||||
|
" · ✧ ✦ · ",
|
||||||
|
" ✧ · · ✦ ✧ ",
|
||||||
|
" ✦ · ✧ ✦ · ",
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderSparkle 渲染事件流底部的星光加载动画。
|
||||||
|
func renderSparkle(frame int) string {
|
||||||
|
idx := frame % len(sparklePatterns)
|
||||||
|
// 亮星用琥珀色,暗星用灰色
|
||||||
|
line := sparklePatterns[idx]
|
||||||
|
var b strings.Builder
|
||||||
|
for _, ch := range line {
|
||||||
|
switch ch {
|
||||||
|
case '✦':
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(colorAccent).Render("✦"))
|
||||||
|
case '✧':
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#887730")).Render("✧"))
|
||||||
|
case '·':
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(colorDim).Render("·"))
|
||||||
|
default:
|
||||||
|
b.WriteRune(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
label := lipgloss.NewStyle().Foreground(lipgloss.Color("#887730")).Render(" AI 生成中…")
|
||||||
|
return "\n" + b.String() + "\n" + label
|
||||||
|
}
|
||||||
|
|
||||||
// renderEventContent 将事件列表渲染为纯文本(供 viewport 使用)。
|
// renderEventContent 将事件列表渲染为纯文本(供 viewport 使用)。
|
||||||
func renderEventContent(events []app.UIEvent, width int) string {
|
func renderEventContent(events []app.UIEvent, width int) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -124,10 +179,83 @@ func renderEventFlowViewport(vp viewport.Model, width, height int) string {
|
|||||||
return style.Render(vp.View())
|
return style.Render(vp.View())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderStreamPanel 渲染流式输出面板(中间列下半部分)。
|
||||||
|
func renderStreamPanel(vp viewport.Model, width, height int, focused bool) string {
|
||||||
|
// 分隔标题栏
|
||||||
|
titleColor := colorDim
|
||||||
|
if focused {
|
||||||
|
titleColor = colorAccent
|
||||||
|
}
|
||||||
|
title := lipgloss.NewStyle().Foreground(titleColor).Render("✦ 生成内容")
|
||||||
|
lineW := width - lipgloss.Width(title) - 4
|
||||||
|
if lineW < 0 {
|
||||||
|
lineW = 0
|
||||||
|
}
|
||||||
|
separator := lipgloss.NewStyle().Foreground(colorDim).Render(strings.Repeat("─", lineW))
|
||||||
|
header := " " + title + " " + separator
|
||||||
|
|
||||||
|
// viewport 内容(height 包含 header 行,viewport 实际高度需减 1)
|
||||||
|
vpH := height - 1
|
||||||
|
if vpH < 1 {
|
||||||
|
vpH = 1
|
||||||
|
}
|
||||||
|
vpStyle := lipgloss.NewStyle().
|
||||||
|
Width(width).
|
||||||
|
Height(vpH).
|
||||||
|
Padding(0, 1).
|
||||||
|
Foreground(colorText)
|
||||||
|
|
||||||
|
return header + "\n" + vpStyle.Render(vp.View())
|
||||||
|
}
|
||||||
|
|
||||||
// renderDetailPanel 渲染右侧详情面板。
|
// renderDetailPanel 渲染右侧详情面板。
|
||||||
|
// 优先展示基础设定(大纲、角色),然后是运行时信息(提交、审阅等)。
|
||||||
func renderDetailPanel(snap app.UISnapshot, width, height int) string {
|
func renderDetailPanel(snap app.UISnapshot, width, height int) string {
|
||||||
|
contentW := width - 4 // 边框 + padding
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
|
// 大纲
|
||||||
|
if len(snap.Outline) > 0 {
|
||||||
|
b.WriteString(panelTitleStyle.Render("大纲"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
for _, e := range snap.Outline {
|
||||||
|
ch := fmt.Sprintf("%2d", e.Chapter)
|
||||||
|
// 已完成的章节用绿色标记
|
||||||
|
marker := lipgloss.NewStyle().Foreground(colorDim).Render("○")
|
||||||
|
if snap.CompletedCount >= e.Chapter {
|
||||||
|
marker = lipgloss.NewStyle().Foreground(colorSuccess).Render("●")
|
||||||
|
} else if snap.InProgressChapter == e.Chapter {
|
||||||
|
marker = lipgloss.NewStyle().Foreground(colorAccent).Render("◐")
|
||||||
|
}
|
||||||
|
title := truncate(e.Title, contentW-6)
|
||||||
|
line := marker + lipgloss.NewStyle().Foreground(colorDim).Render(ch) + " " +
|
||||||
|
cardContentStyle.Render(title)
|
||||||
|
b.WriteString(line)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 角色
|
||||||
|
if len(snap.Characters) > 0 {
|
||||||
|
b.WriteString(panelTitleStyle.Render("角色"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
for _, c := range snap.Characters {
|
||||||
|
b.WriteString(cardContentStyle.Render("· " + truncate(c, contentW-2)))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前提
|
||||||
|
if snap.Premise != "" {
|
||||||
|
b.WriteString(panelTitleStyle.Render("前提"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(colorDim).Render(truncate(snap.Premise, contentW*3)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行时信息
|
||||||
if snap.LastCommitSummary != "" {
|
if snap.LastCommitSummary != "" {
|
||||||
b.WriteString(cardTitleStyle.Render("─ 最近提交 ─"))
|
b.WriteString(cardTitleStyle.Render("─ 最近提交 ─"))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
@@ -142,18 +270,11 @@ func renderDetailPanel(snap app.UISnapshot, width, height int) string {
|
|||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if snap.LastCheckpointName != "" {
|
|
||||||
b.WriteString(cardTitleStyle.Render("─ 检查点 ─"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(cardContentStyle.Render(snap.LastCheckpointName))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(snap.RecentSummaries) > 0 {
|
if len(snap.RecentSummaries) > 0 {
|
||||||
b.WriteString(cardTitleStyle.Render("─ 摘要 ─"))
|
b.WriteString(cardTitleStyle.Render("─ 摘要 ─"))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
for _, s := range snap.RecentSummaries {
|
for _, s := range snap.RecentSummaries {
|
||||||
b.WriteString(cardContentStyle.Render(s))
|
b.WriteString(cardContentStyle.Render(truncate(s, contentW)))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user