perf: ask user
This commit is contained in:
40
app/run.go
40
app/run.go
@@ -167,9 +167,18 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, prov
|
|||||||
case agentcore.EventToolExecEnd:
|
case agentcore.EventToolExecEnd:
|
||||||
lastProgressSummary = ""
|
lastProgressSummary = ""
|
||||||
if ev.IsError {
|
if ev.IsError {
|
||||||
|
detail := extractToolErrorText(ev.Result)
|
||||||
|
if detail != "" {
|
||||||
|
log.Printf("[tool:error] %s → %s", ev.Tool, detail)
|
||||||
|
} else {
|
||||||
log.Printf("[tool:error] %s", ev.Tool)
|
log.Printf("[tool:error] %s", ev.Tool)
|
||||||
|
}
|
||||||
if emit != nil {
|
if emit != nil {
|
||||||
emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: ev.Tool + " 执行失败", Level: "error"})
|
summary := ev.Tool + " 执行失败"
|
||||||
|
if detail != "" {
|
||||||
|
summary += ": " + truncateLog(detail, 80)
|
||||||
|
}
|
||||||
|
emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: summary, Level: "error"})
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -618,6 +627,35 @@ func extractLoadingSummary(result json.RawMessage) string {
|
|||||||
return data.Summary
|
return data.Summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractToolErrorText(result json.RawMessage) string {
|
||||||
|
if len(result) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var plain string
|
||||||
|
if err := json.Unmarshal(result, &plain); err == nil {
|
||||||
|
return plain
|
||||||
|
}
|
||||||
|
|
||||||
|
var obj struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(result, &obj); err == nil {
|
||||||
|
switch {
|
||||||
|
case obj.Error != "":
|
||||||
|
return obj.Error
|
||||||
|
case obj.Message != "":
|
||||||
|
return obj.Message
|
||||||
|
case obj.Detail != "":
|
||||||
|
return obj.Detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return truncateLog(string(result), 160)
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -151,3 +151,29 @@ func TestPlanningTierGuidanceForMid(t *testing.T) {
|
|||||||
t.Fatalf("expected architect_mid guidance, got %q", guidance)
|
t.Fatalf("expected architect_mid guidance, got %q", guidance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractToolErrorTextFromJSONString(t *testing.T) {
|
||||||
|
result, err := json.Marshal("save planning tier: permission denied")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := extractToolErrorText(result)
|
||||||
|
if text != "save planning tier: permission denied" {
|
||||||
|
t.Fatalf("unexpected error text: %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractToolErrorTextFromJSONObject(t *testing.T) {
|
||||||
|
result, err := json.Marshal(map[string]any{
|
||||||
|
"message": "parse outline JSON: invalid character",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := extractToolErrorText(result)
|
||||||
|
if text != "parse outline JSON: invalid character" {
|
||||||
|
t.Fatalf("unexpected error text: %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -66,7 +66,7 @@ func buildConfig(style string) app.Config {
|
|||||||
Provider: provider,
|
Provider: provider,
|
||||||
APIKey: apiKey,
|
APIKey: apiKey,
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
ModelName: "stepfun/step-3.5-flash:free",
|
ModelName: "openrouter/hunter-alpha",
|
||||||
Style: style,
|
Style: style,
|
||||||
}
|
}
|
||||||
return cfg
|
return cfg
|
||||||
|
|||||||
@@ -10,11 +10,39 @@
|
|||||||
|
|
||||||
- **subagent**: 调度 architect_short、architect_mid、architect_long、writer 和 editor 子 Agent
|
- **subagent**: 调度 architect_short、architect_mid、architect_long、writer 和 editor 子 Agent
|
||||||
- **novel_context**: 检查当前创作状态
|
- **novel_context**: 检查当前创作状态
|
||||||
|
- **ask_user**: 当需求信息不足,且缺失信息会明显影响规划方向时,向用户补充询问 1-3 个关键问题。返回的是可直接使用的中文摘要,例如:`用户回答:[篇幅] 长篇;[重心] 剧情升级`
|
||||||
|
|
||||||
## 工作流程
|
## 工作流程
|
||||||
|
|
||||||
### 第一阶段:选择合适的规划师并生成基础设定
|
### 第一阶段:选择合适的规划师并生成基础设定
|
||||||
|
|
||||||
|
如果用户需求已经足够明确,就直接判断并开始规划,不要为了形式感额外提问。
|
||||||
|
|
||||||
|
如果用户需求过于稀薄,导致你无法稳定判断作品规模、核心方向或关键卖点,先调用 `ask_user` 做最少必要的澄清,再进入规划。典型场景包括:
|
||||||
|
|
||||||
|
- 只有一个题材词或一句非常短的描述,例如“凡人修仙”“都市悬疑”
|
||||||
|
- 没有说明更偏短篇、中篇还是长篇连载
|
||||||
|
- 没有说明主角路线、核心冲突、基调偏向,而这些信息会显著影响大纲方向
|
||||||
|
|
||||||
|
提问约束:
|
||||||
|
|
||||||
|
- 每次只问 1-3 个最关键问题
|
||||||
|
- 优先问会改变规划方向的问题,不要问细枝末节
|
||||||
|
- 能自己合理推断的,不要问用户
|
||||||
|
- 用户回答后,再选择对应的规划师
|
||||||
|
- `ask_user` 的问题必须是结构化选择题,header 简短清楚,选项之间要有明确区分
|
||||||
|
- 优先询问:篇幅预期、剧情重心、主角路线、必须避免的元素、基调偏好
|
||||||
|
- 不要询问:你已经可以从题材常识中合理补全的基础信息
|
||||||
|
- 不要连续多轮追问;一轮问完后先进入规划
|
||||||
|
- 用户如果给出明确偏好,应把这些偏好视为更高优先级约束
|
||||||
|
|
||||||
|
使用原则:
|
||||||
|
|
||||||
|
- `ask_user` 是补足关键信息的工具,不是把规划责任转交给用户
|
||||||
|
- 你的目标是“最少提问后就能稳定规划”,不是收集尽可能多的设定
|
||||||
|
- 对于“凡人修仙”“都市悬疑”“校园恋爱”这类过短输入,如果你发现不同理解会导向完全不同的大纲,应优先先问再规划
|
||||||
|
- 对于已经明确给出篇幅、主角、冲突、风格的输入,不要再问,直接进入规划
|
||||||
|
|
||||||
在第一次规划前,你必须先判断用户需求更适合哪一种长度级别:
|
在第一次规划前,你必须先判断用户需求更适合哪一种长度级别:
|
||||||
|
|
||||||
- **短篇**:单冲突、单案、单任务、单段关键关系、结局集中
|
- **短篇**:单冲突、单案、单任务、单段关键关系、结局集中
|
||||||
@@ -27,6 +55,8 @@
|
|||||||
- 只有当需求明显更像单卷故事时,才使用 `architect_short`
|
- 只有当需求明显更像单卷故事时,才使用 `architect_short`
|
||||||
- 不确定时,优先 `architect_mid`,但对连载型商业题材宁可偏长,不要误压成短篇
|
- 不确定时,优先 `architect_mid`,但对连载型商业题材宁可偏长,不要误压成短篇
|
||||||
|
|
||||||
|
如果经过 `ask_user` 用户明确表达了篇幅或连载预期,优先遵从用户选择。
|
||||||
|
|
||||||
调用对应规划师完成基础设定:
|
调用对应规划师完成基础设定:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func (t *AskUserTool) SetHandler(h AskUserHandler) {
|
|||||||
func (t *AskUserTool) Name() string { return "ask_user" }
|
func (t *AskUserTool) Name() string { return "ask_user" }
|
||||||
func (t *AskUserTool) Label() string { return "询问用户" }
|
func (t *AskUserTool) Label() string { return "询问用户" }
|
||||||
func (t *AskUserTool) Description() string {
|
func (t *AskUserTool) Description() string {
|
||||||
return "向用户提出结构化问题,用于需要用户确认方向、澄清需求或做出选择时。用户可以从预设选项中选择,也可以自由输入。"
|
return "当需求信息不足、且缺失信息会明显影响规划方向时,向用户提出 1-4 个结构化问题。每个问题必须包含 header、question 和 2-4 个选项;用户可选预设项,也可自由补充。返回结果是可直接阅读的中文摘要,格式类似:用户回答:[篇幅] 长篇;[重心] 剧情升级(补充:不要后宫)。只有在无法稳定判断篇幅、主线重心、关键约束或明确偏好时才使用;不要把能自行合理推断的问题都抛给用户。"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *AskUserTool) Schema() map[string]any {
|
func (t *AskUserTool) Schema() map[string]any {
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ func Run(cfg app.Config, refs tools.References, prompts app.Prompts, styles map[
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
bridge := newAskUserBridge()
|
||||||
|
rt.AskUser().SetHandler(bridge.handler)
|
||||||
restoreLog := redirectLogger(rt.Dir())
|
restoreLog := redirectLogger(rt.Dir())
|
||||||
defer restoreLog()
|
defer restoreLog()
|
||||||
defer rt.Close()
|
defer rt.Close()
|
||||||
|
|
||||||
m := NewModel(rt)
|
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()
|
||||||
return err
|
return err
|
||||||
|
|||||||
278
tui/ask_user.go
Normal file
278
tui/ask_user.go
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/voocel/ainovel-cli/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
type askUserRequest struct {
|
||||||
|
questions []tools.Question
|
||||||
|
resultCh chan askUserResult
|
||||||
|
}
|
||||||
|
|
||||||
|
type askUserResult struct {
|
||||||
|
resp *tools.AskUserResponse
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type askUserBridge struct {
|
||||||
|
requests chan askUserRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAskUserBridge() *askUserBridge {
|
||||||
|
return &askUserBridge{
|
||||||
|
requests: make(chan askUserRequest),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *askUserBridge) handler(ctx context.Context, questions []tools.Question) (*tools.AskUserResponse, error) {
|
||||||
|
req := askUserRequest{
|
||||||
|
questions: questions,
|
||||||
|
resultCh: make(chan askUserResult, 1),
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case b.requests <- req:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case result := <-req.resultCh:
|
||||||
|
return result.resp, result.err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type askUserState struct {
|
||||||
|
request askUserRequest
|
||||||
|
index int
|
||||||
|
cursor int
|
||||||
|
typing bool
|
||||||
|
input string
|
||||||
|
selected map[int]bool
|
||||||
|
answers map[string]string
|
||||||
|
notes map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAskUserState(req askUserRequest) *askUserState {
|
||||||
|
return &askUserState{
|
||||||
|
request: req,
|
||||||
|
selected: make(map[int]bool),
|
||||||
|
answers: make(map[string]string),
|
||||||
|
notes: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *askUserState) currentQuestion() tools.Question {
|
||||||
|
return s.request.questions[s.index]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *askUserState) optionCount() int {
|
||||||
|
return len(s.currentQuestion().Options) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *askUserState) choiceLabel(idx int) string {
|
||||||
|
q := s.currentQuestion()
|
||||||
|
if idx < len(q.Options) {
|
||||||
|
return q.Options[idx].Label
|
||||||
|
}
|
||||||
|
return "自由输入"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *askUserState) choiceDescription(idx int) string {
|
||||||
|
q := s.currentQuestion()
|
||||||
|
if idx < len(q.Options) {
|
||||||
|
return q.Options[idx].Description
|
||||||
|
}
|
||||||
|
return "以上都不合适,自己补充"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *askUserState) moveCursor(delta int) {
|
||||||
|
total := s.optionCount()
|
||||||
|
if total == 0 {
|
||||||
|
s.cursor = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cursor = (s.cursor + delta + total) % total
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *askUserState) toggleSelection() {
|
||||||
|
if s.selected[s.cursor] {
|
||||||
|
delete(s.selected, s.cursor)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.selected[s.cursor] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *askUserState) finishCurrentAnswer() bool {
|
||||||
|
q := s.currentQuestion()
|
||||||
|
if s.typing {
|
||||||
|
text := strings.TrimSpace(s.input)
|
||||||
|
if text == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.answers[q.Question] = text
|
||||||
|
s.notes[q.Question] = text
|
||||||
|
return s.advance()
|
||||||
|
}
|
||||||
|
|
||||||
|
if q.MultiSelect {
|
||||||
|
var values []string
|
||||||
|
var custom string
|
||||||
|
for idx := 0; idx < s.optionCount(); idx++ {
|
||||||
|
if !s.selected[idx] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if idx < len(q.Options) {
|
||||||
|
values = append(values, q.Options[idx].Label)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
custom = strings.TrimSpace(s.input)
|
||||||
|
}
|
||||||
|
if custom != "" {
|
||||||
|
values = append(values, custom)
|
||||||
|
s.notes[q.Question] = custom
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.answers[q.Question] = strings.Join(values, "、")
|
||||||
|
return s.advance()
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.cursor >= len(q.Options) {
|
||||||
|
s.typing = true
|
||||||
|
s.input = ""
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.answers[q.Question] = q.Options[s.cursor].Label
|
||||||
|
return s.advance()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *askUserState) advance() bool {
|
||||||
|
s.index++
|
||||||
|
if s.index >= len(s.request.questions) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
s.cursor = 0
|
||||||
|
s.typing = false
|
||||||
|
s.input = ""
|
||||||
|
s.selected = make(map[int]bool)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *askUserState) submit() {
|
||||||
|
s.request.resultCh <- askUserResult{
|
||||||
|
resp: &tools.AskUserResponse{
|
||||||
|
Answers: s.answers,
|
||||||
|
Notes: s.notes,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *askUserState) cancelCurrentTyping() {
|
||||||
|
s.typing = false
|
||||||
|
s.input = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAskUserModal(width, height int, state *askUserState) string {
|
||||||
|
if state == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
q := state.currentQuestion()
|
||||||
|
boxW := minInt(maxInt(width*60/100, 52), width-4)
|
||||||
|
boxH := minInt(maxInt(height*60/100, 16), height-4)
|
||||||
|
if boxW < 40 {
|
||||||
|
boxW = maxInt(width-2, 20)
|
||||||
|
}
|
||||||
|
if boxH < 10 {
|
||||||
|
boxH = maxInt(height-2, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
title := fmt.Sprintf("需要补充信息 %d/%d", state.index+1, len(state.request.questions))
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render(title))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
if q.Header != "" {
|
||||||
|
b.WriteString(highlightValueStyle.Render(q.Header))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString(cardContentStyle.Render(q.Question))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
for idx := 0; idx < state.optionCount(); idx++ {
|
||||||
|
prefix := " "
|
||||||
|
if state.cursor == idx {
|
||||||
|
prefix = lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render("› ")
|
||||||
|
}
|
||||||
|
label := state.choiceLabel(idx)
|
||||||
|
if q.MultiSelect {
|
||||||
|
marker := "[ ]"
|
||||||
|
if state.selected[idx] {
|
||||||
|
marker = "[x]"
|
||||||
|
}
|
||||||
|
label = marker + " " + label
|
||||||
|
}
|
||||||
|
b.WriteString(prefix + cardContentStyle.Render(label))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(" " + lipgloss.NewStyle().Foreground(colorDim).Render(state.choiceDescription(idx)))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.typing || (q.MultiSelect && state.selected[len(q.Options)]) {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(panelTitleStyle.Render("补充内容"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
content := state.input
|
||||||
|
if content == "" {
|
||||||
|
content = "请输入..."
|
||||||
|
}
|
||||||
|
style := lipgloss.NewStyle().
|
||||||
|
Width(boxW-8).
|
||||||
|
Border(baseBorder).
|
||||||
|
BorderForeground(colorDim).
|
||||||
|
Padding(0, 1)
|
||||||
|
b.WriteString(style.Render(content))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
hint := "↑↓ 选择 · Enter 确认"
|
||||||
|
if q.MultiSelect {
|
||||||
|
hint = "↑↓ 选择 · Space 勾选 · Enter 提交"
|
||||||
|
}
|
||||||
|
if state.typing {
|
||||||
|
hint = "输入补充内容 · Enter 确认 · Esc 返回选项"
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(colorDim).Render(hint))
|
||||||
|
|
||||||
|
box := lipgloss.NewStyle().
|
||||||
|
Width(boxW).
|
||||||
|
Height(boxH).
|
||||||
|
Border(baseBorder).
|
||||||
|
BorderForeground(colorAccent).
|
||||||
|
Padding(1, 2).
|
||||||
|
Background(lipgloss.Color("#1b1712")).
|
||||||
|
Render(b.String())
|
||||||
|
|
||||||
|
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
|
||||||
|
}
|
||||||
|
|
||||||
|
func minInt(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxInt(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ type (
|
|||||||
eventMsg app.UIEvent
|
eventMsg app.UIEvent
|
||||||
snapshotMsg app.UISnapshot
|
snapshotMsg app.UISnapshot
|
||||||
doneMsg struct{}
|
doneMsg struct{}
|
||||||
|
askUserMsg askUserRequest
|
||||||
startResultMsg struct{ err error }
|
startResultMsg struct{ err error }
|
||||||
steerResultMsg struct{}
|
steerResultMsg struct{}
|
||||||
spinnerTickMsg time.Time
|
spinnerTickMsg time.Time
|
||||||
@@ -102,3 +103,13 @@ func listenStreamClear(rt *app.Runtime) tea.Cmd {
|
|||||||
return streamClearMsg{}
|
return streamClearMsg{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func listenAskUser(bridge *askUserBridge) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
req, ok := <-bridge.requests
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return askUserMsg(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
119
tui/model.go
119
tui/model.go
@@ -3,6 +3,7 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textarea"
|
"github.com/charmbracelet/bubbles/textarea"
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
@@ -35,12 +36,15 @@ var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "
|
|||||||
// Model 是 TUI 的顶层状态。
|
// Model 是 TUI 的顶层状态。
|
||||||
type Model struct {
|
type Model struct {
|
||||||
runtime *app.Runtime
|
runtime *app.Runtime
|
||||||
|
askBridge *askUserBridge
|
||||||
|
askState *askUserState
|
||||||
snapshot app.UISnapshot
|
snapshot app.UISnapshot
|
||||||
events []app.UIEvent
|
events []app.UIEvent
|
||||||
viewport viewport.Model // 事件流 viewport
|
viewport viewport.Model // 事件流 viewport
|
||||||
streamVP viewport.Model // 流式输出 viewport
|
streamVP viewport.Model // 流式输出 viewport
|
||||||
detailVP viewport.Model // 右侧详情 viewport
|
detailVP viewport.Model // 右侧详情 viewport
|
||||||
streamBuf *strings.Builder // 流式文本累积缓冲
|
streamBuf *strings.Builder // 流式文本累积缓冲
|
||||||
|
streamRounds []string
|
||||||
textarea textarea.Model
|
textarea textarea.Model
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
@@ -56,7 +60,7 @@ type Model struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewModel 创建 TUI Model。
|
// NewModel 创建 TUI Model。
|
||||||
func NewModel(rt *app.Runtime) Model {
|
func NewModel(rt *app.Runtime, bridge *askUserBridge) Model {
|
||||||
ta := textarea.New()
|
ta := textarea.New()
|
||||||
ta.Placeholder = "输入小说需求,例如:写一部12章都市悬疑小说"
|
ta.Placeholder = "输入小说需求,例如:写一部12章都市悬疑小说"
|
||||||
ta.CharLimit = 500
|
ta.CharLimit = 500
|
||||||
@@ -79,6 +83,7 @@ func NewModel(rt *app.Runtime) Model {
|
|||||||
|
|
||||||
return Model{
|
return Model{
|
||||||
runtime: rt,
|
runtime: rt,
|
||||||
|
askBridge: bridge,
|
||||||
autoScroll: true,
|
autoScroll: true,
|
||||||
streamScroll: true,
|
streamScroll: true,
|
||||||
mode: modeNew,
|
mode: modeNew,
|
||||||
@@ -94,6 +99,7 @@ func (m Model) Init() tea.Cmd {
|
|||||||
return tea.Batch(
|
return tea.Batch(
|
||||||
textarea.Blink,
|
textarea.Blink,
|
||||||
listenEvents(m.runtime),
|
listenEvents(m.runtime),
|
||||||
|
listenAskUser(m.askBridge),
|
||||||
listenDone(m.runtime),
|
listenDone(m.runtime),
|
||||||
listenStream(m.runtime),
|
listenStream(m.runtime),
|
||||||
listenStreamClear(m.runtime),
|
listenStreamClear(m.runtime),
|
||||||
@@ -116,6 +122,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
|
if m.askState != nil {
|
||||||
|
return m.handleAskUserKey(msg)
|
||||||
|
}
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case tea.KeyCtrlC:
|
case tea.KeyCtrlC:
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -127,6 +136,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.viewport.SetContent("")
|
m.viewport.SetContent("")
|
||||||
m.viewport.GotoTop()
|
m.viewport.GotoTop()
|
||||||
m.streamBuf.Reset()
|
m.streamBuf.Reset()
|
||||||
|
m.streamRounds = nil
|
||||||
m.streamVP.SetContent("")
|
m.streamVP.SetContent("")
|
||||||
m.streamVP.GotoTop()
|
m.streamVP.GotoTop()
|
||||||
m.streamRound = 0
|
m.streamRound = 0
|
||||||
@@ -233,6 +243,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.refreshEventViewport()
|
m.refreshEventViewport()
|
||||||
return m, listenEvents(m.runtime)
|
return m, listenEvents(m.runtime)
|
||||||
|
|
||||||
|
case askUserMsg:
|
||||||
|
m.askState = newAskUserState(askUserRequest(msg))
|
||||||
|
m.textarea.Blur()
|
||||||
|
m.events = append(m.events, app.UIEvent{
|
||||||
|
Time: time.Now(), Category: "SYSTEM", Summary: "等待用户补充关键信息", Level: "info",
|
||||||
|
})
|
||||||
|
m.refreshEventViewport()
|
||||||
|
return m, listenAskUser(m.askBridge)
|
||||||
|
|
||||||
case snapshotMsg:
|
case snapshotMsg:
|
||||||
m.snapshot = app.UISnapshot(msg)
|
m.snapshot = app.UISnapshot(msg)
|
||||||
m.refreshDetailViewport()
|
m.refreshDetailViewport()
|
||||||
@@ -270,22 +289,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tickSpinner()
|
return m, tickSpinner()
|
||||||
|
|
||||||
case streamDeltaMsg:
|
case streamDeltaMsg:
|
||||||
m.streamBuf.WriteString(string(msg))
|
if len(m.streamRounds) == 0 {
|
||||||
m.streamVP.SetContent(m.streamBuf.String())
|
m.streamRounds = append(m.streamRounds, "")
|
||||||
|
}
|
||||||
|
m.streamRounds[len(m.streamRounds)-1] += string(msg)
|
||||||
|
m.streamVP.SetContent(renderStreamContent(m.streamRounds, m.streamVP.Width))
|
||||||
if m.streamScroll {
|
if m.streamScroll {
|
||||||
m.streamVP.GotoBottom()
|
m.streamVP.GotoBottom()
|
||||||
}
|
}
|
||||||
return m, listenStream(m.runtime)
|
return m, listenStream(m.runtime)
|
||||||
|
|
||||||
case streamClearMsg:
|
case streamClearMsg:
|
||||||
// 新一轮输出:保留历史内容,用分隔线标记新段落
|
// 新一轮输出:按轮次分块显示,避免长文本和分隔线直接拼接导致错乱。
|
||||||
m.streamRound++
|
if len(m.streamRounds) == 0 {
|
||||||
if m.streamBuf.Len() > 0 {
|
m.streamRounds = append(m.streamRounds, "")
|
||||||
m.streamBuf.WriteString("\n")
|
} else if strings.TrimSpace(m.streamRounds[len(m.streamRounds)-1]) != "" {
|
||||||
m.streamBuf.WriteString(renderStreamSeparator(m.streamRound, m.streamVP.Width))
|
m.streamRounds = append(m.streamRounds, "")
|
||||||
m.streamBuf.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
m.streamVP.SetContent(m.streamBuf.String())
|
m.streamRound = len(m.streamRounds)
|
||||||
|
m.streamVP.SetContent(renderStreamContent(m.streamRounds, m.streamVP.Width))
|
||||||
if m.streamScroll {
|
if m.streamScroll {
|
||||||
m.streamVP.GotoBottom()
|
m.streamVP.GotoBottom()
|
||||||
}
|
}
|
||||||
@@ -486,5 +508,80 @@ func (m Model) View() string {
|
|||||||
body = lipgloss.JoinHorizontal(lipgloss.Top, left, center, right)
|
body = lipgloss.JoinHorizontal(lipgloss.Top, left, center, right)
|
||||||
}
|
}
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, topBar, body, inputBox)
|
view := lipgloss.JoinVertical(lipgloss.Left, topBar, body, inputBox)
|
||||||
|
if m.askState != nil {
|
||||||
|
return renderAskUserModal(m.width, m.height, m.askState)
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) handleAskUserKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if m.askState == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
state := m.askState
|
||||||
|
q := state.currentQuestion()
|
||||||
|
|
||||||
|
if state.typing {
|
||||||
|
switch msg.Type {
|
||||||
|
case tea.KeyEsc:
|
||||||
|
state.cancelCurrentTyping()
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyEnter:
|
||||||
|
if state.finishCurrentAnswer() {
|
||||||
|
state.submit()
|
||||||
|
m.askState = nil
|
||||||
|
if m.mode != modeDone {
|
||||||
|
m.textarea.Focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyBackspace, tea.KeyCtrlH:
|
||||||
|
if state.input != "" {
|
||||||
|
_, size := utf8.DecodeLastRuneInString(state.input)
|
||||||
|
state.input = state.input[:len(state.input)-size]
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
default:
|
||||||
|
if msg.Type == tea.KeyRunes {
|
||||||
|
state.input += string(msg.Runes)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg.Type {
|
||||||
|
case tea.KeyUp:
|
||||||
|
state.moveCursor(-1)
|
||||||
|
case tea.KeyDown:
|
||||||
|
state.moveCursor(1)
|
||||||
|
case tea.KeySpace:
|
||||||
|
if q.MultiSelect {
|
||||||
|
state.toggleSelection()
|
||||||
|
if state.cursor == len(q.Options) && !state.selected[state.cursor] {
|
||||||
|
state.input = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case tea.KeyEnter:
|
||||||
|
if q.MultiSelect {
|
||||||
|
if state.cursor == len(q.Options) {
|
||||||
|
state.toggleSelection()
|
||||||
|
if state.selected[state.cursor] {
|
||||||
|
state.typing = true
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if len(state.selected) == 0 {
|
||||||
|
state.toggleSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if state.finishCurrentAnswer() {
|
||||||
|
state.submit()
|
||||||
|
m.askState = nil
|
||||||
|
if m.mode != modeDone {
|
||||||
|
m.textarea.Focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|||||||
187
tui/panels.go
187
tui/panels.go
@@ -1,6 +1,7 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -228,17 +229,183 @@ func renderStreamPanel(vp viewport.Model, width, height int, focused bool) strin
|
|||||||
return header + "\n" + vpStyle.Render(vp.View())
|
return header + "\n" + vpStyle.Render(vp.View())
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderStreamSeparator 渲染流式面板中的轮次分隔线。
|
// renderStreamContent 将流式输出按轮次渲染为分块内容,避免长段直接拼接导致错乱。
|
||||||
func renderStreamSeparator(round, width int) string {
|
func renderStreamContent(rounds []string, width int) string {
|
||||||
label := fmt.Sprintf(" #%d ", round)
|
if width < 24 {
|
||||||
lineW := (width - lipgloss.Width(label)) / 2
|
width = 24
|
||||||
if lineW < 1 {
|
|
||||||
lineW = 1
|
|
||||||
}
|
}
|
||||||
line := strings.Repeat("─", lineW)
|
|
||||||
dimLine := lipgloss.NewStyle().Foreground(colorDim).Render(line)
|
var blocks []string
|
||||||
dimLabel := lipgloss.NewStyle().Foreground(colorDim).Render(label)
|
displayIndex := 0
|
||||||
return dimLine + dimLabel + dimLine
|
for i, round := range rounds {
|
||||||
|
text := strings.TrimSpace(round)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
displayIndex++
|
||||||
|
blocks = append(blocks, renderStreamBlock(displayIndex, text, width, i == len(rounds)-1))
|
||||||
|
}
|
||||||
|
return strings.Join(blocks, "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderStreamBlock(index int, text string, width int, active bool) string {
|
||||||
|
headerStyle := lipgloss.NewStyle().Foreground(colorDim)
|
||||||
|
bodyStyle := lipgloss.NewStyle().Foreground(colorText)
|
||||||
|
dividerColor := colorDim
|
||||||
|
if active {
|
||||||
|
headerStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
|
||||||
|
dividerColor = colorAccent
|
||||||
|
}
|
||||||
|
|
||||||
|
header := headerStyle.Render(fmt.Sprintf("◆ 第 %d 段", index))
|
||||||
|
divider := lipgloss.NewStyle().Foreground(dividerColor).Render(strings.Repeat("─", max(8, width)))
|
||||||
|
lines := wrapStreamText(text, max(16, width-4))
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(header)
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(divider)
|
||||||
|
b.WriteString("\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString(bodyStyle.Render(line))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapStreamText(text string, width int) []string {
|
||||||
|
if width < 8 {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []string
|
||||||
|
for _, raw := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
out = append(out, "")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if compact, ok := compactJSONLine(raw, width); ok {
|
||||||
|
out = append(out, compact)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefix, rest, nextPrefix := parseWrapPrefix(raw)
|
||||||
|
wrapped := wrapRunes(rest, max(4, width-lipgloss.Width(prefix)))
|
||||||
|
for i, line := range wrapped {
|
||||||
|
if i == 0 {
|
||||||
|
out = append(out, prefix+line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, nextPrefix+line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactJSONLine(line string, width int) (string, bool) {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if !(strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[")) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
var value any
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &value); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
compact, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
text := string(compact)
|
||||||
|
limit := max(24, width-2)
|
||||||
|
if lipgloss.Width(text) > limit {
|
||||||
|
text = truncate(text, limit-1)
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().Foreground(colorDim).Render("JSON: ") +
|
||||||
|
lipgloss.NewStyle().Foreground(lipgloss.Color("#8fb7c9")).Render(text), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWrapPrefix(line string) (prefix, content, nextPrefix string) {
|
||||||
|
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(trimmed, "- "), strings.HasPrefix(trimmed, "* "), strings.HasPrefix(trimmed, "• "):
|
||||||
|
prefix = indent + trimmed[:2]
|
||||||
|
content = strings.TrimSpace(trimmed[2:])
|
||||||
|
nextPrefix = indent + " "
|
||||||
|
return prefix, content, nextPrefix
|
||||||
|
case orderedListPrefix(trimmed) != "":
|
||||||
|
marker := orderedListPrefix(trimmed)
|
||||||
|
prefix = indent + marker
|
||||||
|
content = strings.TrimSpace(strings.TrimPrefix(trimmed, marker))
|
||||||
|
nextPrefix = indent + strings.Repeat(" ", lipgloss.Width(marker))
|
||||||
|
return prefix, content, nextPrefix
|
||||||
|
case strings.HasPrefix(trimmed, "```"):
|
||||||
|
return indent, trimmed, indent
|
||||||
|
default:
|
||||||
|
return indent, trimmed, indent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderedListPrefix(line string) string {
|
||||||
|
end := strings.Index(line, ". ")
|
||||||
|
if end <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range line[:end] {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return line[:end+2]
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapRunes(text string, width int) []string {
|
||||||
|
if text == "" {
|
||||||
|
return []string{""}
|
||||||
|
}
|
||||||
|
if width < 2 {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
var current strings.Builder
|
||||||
|
currentWidth := 0
|
||||||
|
|
||||||
|
for _, r := range text {
|
||||||
|
rw := lipgloss.Width(string(r))
|
||||||
|
if currentWidth > 0 && currentWidth+rw > width {
|
||||||
|
lines = append(lines, strings.TrimRight(current.String(), " "))
|
||||||
|
current.Reset()
|
||||||
|
currentWidth = 0
|
||||||
|
if r == ' ' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.WriteRune(r)
|
||||||
|
currentWidth += rw
|
||||||
|
}
|
||||||
|
if current.Len() > 0 {
|
||||||
|
lines = append(lines, strings.TrimRight(current.String(), " "))
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return []string{""}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderDetailContent 构建右侧详情面板内容。
|
// renderDetailContent 构建右侧详情面板内容。
|
||||||
|
|||||||
Reference in New Issue
Block a user