feat: add ask user question

This commit is contained in:
voocel
2026-03-09 19:52:43 +08:00
parent 75bdda1fe3
commit ef55c89e9d
13 changed files with 868 additions and 83 deletions

View File

@@ -43,6 +43,11 @@ type UISnapshot struct {
RecoveryLabel string // 恢复类型描述,空表示新建
IsRunning bool
// 基础设定
Premise string // 前提概要
Outline []OutlineSnapshot // 大纲(每章标题 + 核心事件)
Characters []string // 角色列表(名字 + 身份)
// 详情区
LastCommitSummary string
LastReviewSummary string
@@ -50,12 +55,22 @@ type UISnapshot struct {
RecentSummaries []string
}
// OutlineSnapshot 是大纲条目的展示摘要。
type OutlineSnapshot struct {
Chapter int
Title string
CoreEvent string
}
// Runtime 封装协调器生命周期,提供 TUI 所需的非阻塞接口。
type Runtime struct {
cfg Config
store *state.Store
coordinator *agentcore.Agent
askUser *tools.AskUserTool
events chan UIEvent
streamCh chan string // 流式 token channel独立于 events避免淹没事件日志
clearCh chan struct{} // 流式缓冲清空信号
done chan struct{}
mu sync.Mutex
running bool
@@ -68,6 +83,11 @@ func (rt *Runtime) Dir() string {
return rt.store.Dir()
}
// AskUser 返回 ask_user 工具实例,供 TUI 注入交互 handler。
func (rt *Runtime) AskUser() *tools.AskUserTool {
return rt.askUser
}
// NewRuntime 创建 Runtime初始化 store/model/coordinator注册事件订阅但不启动 Prompt。
func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[string]string) (*Runtime, error) {
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)
}
coordinator := BuildCoordinator(cfg, store, model, refs, prompts, styles)
coordinator, askUser := BuildCoordinator(cfg, store, model, refs, prompts, styles)
rt := &Runtime{
cfg: cfg,
store: store,
coordinator: coordinator,
askUser: askUser,
events: make(chan UIEvent, 100),
streamCh: make(chan string, 256),
clearCh: make(chan struct{}, 4),
done: make(chan struct{}),
}
// 注册事件订阅:确定性控制 + UIEvent 转发
registerSubscription(coordinator, store, cfg.MaxChapters, rt.emit)
// 注册事件订阅:确定性控制 + UIEvent 转发 + 流式 delta 转发
registerSubscription(coordinator, store, rt.emit, rt.emitDelta, rt.emitClear)
// 初始化运行元信息
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
}
// 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 向事件通道发送事件,非阻塞(满时丢弃最旧事件)。
func (rt *Runtime) emit(ev UIEvent) {
defer func() { recover() }() // 防止 channel 关闭后写入 panic
@@ -132,11 +192,11 @@ func (rt *Runtime) Start(prompt string) error {
}
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)
}
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 {
return fmt.Errorf("prompt: %w", err)
}
@@ -161,7 +221,7 @@ func (rt *Runtime) Resume() (string, error) {
progress, _ := rt.store.LoadProgress()
runMeta, _ := rt.store.LoadRunMeta()
recovery := determineRecovery(progress, runMeta, rt.cfg.MaxChapters)
recovery := determineRecovery(progress, runMeta)
if recovery.IsNew {
return "", nil
@@ -220,7 +280,7 @@ func (rt *Runtime) Snapshot() UISnapshot {
snap.StatusLabel = rt.deriveStatusLabel(progress, snap.IsRunning)
// 恢复标签
recovery := determineRecovery(progress, runMeta, rt.cfg.MaxChapters)
recovery := determineRecovery(progress, runMeta)
if !recovery.IsNew {
snap.RecoveryLabel = recovery.Label
}
@@ -247,6 +307,8 @@ func (rt *Runtime) Close() {
finalizeSteerIfIdle(rt.store)
rt.closeOnce.Do(func() {
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) {
// 基础设定
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 的已完成章节 + 摘要推算(信号文件是一次性的,不可靠)
if progress != nil && len(progress.CompletedChapters) > 0 {
lastCh := progress.CompletedChapters[len(progress.CompletedChapters)-1]