feat: add tab pane
This commit is contained in:
@@ -36,7 +36,7 @@ func (c *Config) Validate() error {
|
|||||||
// ValidateBase 校验基础配置(TUI 模式下 Prompt 由用户输入,不在此检查)。
|
// ValidateBase 校验基础配置(TUI 模式下 Prompt 由用户输入,不在此检查)。
|
||||||
func (c *Config) ValidateBase() error {
|
func (c *Config) ValidateBase() error {
|
||||||
if c.APIKey == "" {
|
if c.APIKey == "" {
|
||||||
return fmt.Errorf("api key is required (set OPENAI_API_KEY or ANTHROPIC_API_KEY)")
|
return fmt.Errorf("api key is required (set OPENROUTER_API_KEY, Z_OPENAI_API_KEY, ANTHROPIC_API_KEY, or GEMINI_API_KEY)")
|
||||||
}
|
}
|
||||||
switch c.Provider {
|
switch c.Provider {
|
||||||
case "openai", "anthropic", "gemini", "openrouter":
|
case "openai", "anthropic", "gemini", "openrouter":
|
||||||
|
|||||||
51
app/run.go
51
app/run.go
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/voocel/ainovel-cli/domain"
|
"github.com/voocel/ainovel-cli/domain"
|
||||||
"github.com/voocel/ainovel-cli/state"
|
"github.com/voocel/ainovel-cli/state"
|
||||||
"github.com/voocel/ainovel-cli/tools"
|
"github.com/voocel/ainovel-cli/tools"
|
||||||
|
"github.com/voocel/litellm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// emitFn 是可选的 UIEvent 发射回调,用于向 TUI 转发结构化事件。
|
// emitFn 是可选的 UIEvent 发射回调,用于向 TUI 转发结构化事件。
|
||||||
@@ -38,6 +39,7 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
|
|||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
log.Printf("[boot] provider=%s model=%s base_url=%s output=%s", cfg.Provider, cfg.ModelName, cfg.BaseURL, cfg.OutputDir)
|
||||||
|
|
||||||
// 1. 初始化状态
|
// 1. 初始化状态
|
||||||
store := state.NewStore(cfg.OutputDir)
|
store := state.NewStore(cfg.OutputDir)
|
||||||
@@ -61,10 +63,10 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
|
|||||||
askUser.SetHandler(cliAskUserHandler)
|
askUser.SetHandler(cliAskUserHandler)
|
||||||
|
|
||||||
// 4. 确定性控制面:事件监听 + FollowUp 注入
|
// 4. 确定性控制面:事件监听 + FollowUp 注入
|
||||||
registerSubscription(coordinator, store, nil, nil, nil)
|
registerSubscription(coordinator, store, cfg.Provider, nil, nil, nil)
|
||||||
|
|
||||||
// 5. 初始化运行元信息(保留已有 SteerHistory)
|
// 5. 初始化运行元信息(保留已有 SteerHistory)
|
||||||
if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil {
|
if err := store.InitRunMeta(cfg.Style, cfg.Provider, cfg.ModelName); err != nil {
|
||||||
log.Printf("[warn] 初始化运行元信息失败: %v", err)
|
log.Printf("[warn] 初始化运行元信息失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +117,9 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// registerSubscription 注册 coordinator 事件订阅,包含确定性控制和可选的 UIEvent/Delta 转发。
|
// registerSubscription 注册 coordinator 事件订阅,包含确定性控制和可选的 UIEvent/Delta 转发。
|
||||||
func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit emitFn, onDelta deltaFn, onClear clearFn) {
|
func registerSubscription(coordinator *agentcore.Agent, store *state.Store, provider string, emit emitFn, onDelta deltaFn, onClear clearFn) {
|
||||||
|
var lastProgressSummary string
|
||||||
|
|
||||||
coordinator.Subscribe(func(ev agentcore.Event) {
|
coordinator.Subscribe(func(ev agentcore.Event) {
|
||||||
switch ev.Type {
|
switch ev.Type {
|
||||||
case agentcore.EventToolExecStart:
|
case agentcore.EventToolExecStart:
|
||||||
@@ -133,6 +137,13 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
summary := parseProgressSummary(ev)
|
summary := parseProgressSummary(ev)
|
||||||
|
if summary == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if summary == lastProgressSummary {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastProgressSummary = summary
|
||||||
log.Printf("[progress] %s", summary)
|
log.Printf("[progress] %s", summary)
|
||||||
if emit != nil {
|
if emit != nil {
|
||||||
emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: summary, Level: "info"})
|
emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: summary, Level: "info"})
|
||||||
@@ -151,6 +162,7 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit
|
|||||||
}
|
}
|
||||||
|
|
||||||
case agentcore.EventToolExecEnd:
|
case agentcore.EventToolExecEnd:
|
||||||
|
lastProgressSummary = ""
|
||||||
if ev.IsError {
|
if ev.IsError {
|
||||||
log.Printf("[tool:error] %s", ev.Tool)
|
log.Printf("[tool:error] %s", ev.Tool)
|
||||||
if emit != nil {
|
if emit != nil {
|
||||||
@@ -178,9 +190,9 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit
|
|||||||
}
|
}
|
||||||
|
|
||||||
case agentcore.EventError:
|
case agentcore.EventError:
|
||||||
log.Printf("[error] %v", ev.Err)
|
log.Printf("[error][provider=%s] %v", provider, ev.Err)
|
||||||
if emit != nil {
|
if emit != nil {
|
||||||
emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: fmt.Sprintf("%v", ev.Err), Level: "error"})
|
emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: fmt.Sprintf("[%s] %v", provider, ev.Err), Level: "error"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -460,14 +472,19 @@ func parseProgressSummary(ev agentcore.Event) string {
|
|||||||
return "progress"
|
return "progress"
|
||||||
}
|
}
|
||||||
var data struct {
|
var data struct {
|
||||||
Agent string `json:"agent"`
|
Agent string `json:"agent"`
|
||||||
Tool string `json:"tool"`
|
Tool string `json:"tool"`
|
||||||
Turn int `json:"turn"`
|
Turn int `json:"turn"`
|
||||||
Error bool `json:"error"`
|
Error bool `json:"error"`
|
||||||
|
Thinking string `json:"thinking"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(ev.Result, &data); err != nil {
|
if err := json.Unmarshal(ev.Result, &data); err != nil {
|
||||||
return truncateLog(string(ev.Result), 60)
|
return truncateLog(string(ev.Result), 60)
|
||||||
}
|
}
|
||||||
|
// subagent 的 thinking 更新属于高频内部推理,不适合刷到事件流面板。
|
||||||
|
if data.Thinking != "" && data.Tool == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
if data.Tool != "" {
|
if data.Tool != "" {
|
||||||
if data.Error {
|
if data.Error {
|
||||||
return fmt.Sprintf("%s → %s (error)", data.Agent, data.Tool)
|
return fmt.Sprintf("%s → %s (error)", data.Agent, data.Tool)
|
||||||
@@ -540,11 +557,25 @@ func createModel(cfg Config) (agentcore.ChatModel, error) {
|
|||||||
return llm.NewAnthropicModel(cfg.ModelName, cfg.APIKey, baseURL...)
|
return llm.NewAnthropicModel(cfg.ModelName, cfg.APIKey, baseURL...)
|
||||||
case "gemini":
|
case "gemini":
|
||||||
return llm.NewGeminiModel(cfg.ModelName, cfg.APIKey, baseURL...)
|
return llm.NewGeminiModel(cfg.ModelName, cfg.APIKey, baseURL...)
|
||||||
default: // openai, openrouter 及其他 OpenAI 兼容服务
|
case "openrouter":
|
||||||
|
return newOpenRouterModel(cfg.ModelName, cfg.APIKey, baseURL...)
|
||||||
|
default: // openai 及其他 OpenAI 兼容服务
|
||||||
return llm.NewOpenAIModel(cfg.ModelName, cfg.APIKey, baseURL...)
|
return llm.NewOpenAIModel(cfg.ModelName, cfg.APIKey, baseURL...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newOpenRouterModel(model, apiKey string, baseURL ...string) (agentcore.ChatModel, error) {
|
||||||
|
cfg := litellm.ProviderConfig{APIKey: apiKey}
|
||||||
|
if len(baseURL) > 0 {
|
||||||
|
cfg.BaseURL = baseURL[0]
|
||||||
|
}
|
||||||
|
client, err := litellm.NewWithProvider("openrouter", cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("openrouter: %w", err)
|
||||||
|
}
|
||||||
|
return llm.NewLiteLLMAdapter(model, client), nil
|
||||||
|
}
|
||||||
|
|
||||||
// cliAskUserHandler 是 CLI 模式下的交互式选择器,上下键选择,回车确认。
|
// cliAskUserHandler 是 CLI 模式下的交互式选择器,上下键选择,回车确认。
|
||||||
func cliAskUserHandler(_ context.Context, questions []tools.Question) (*tools.AskUserResponse, error) {
|
func cliAskUserHandler(_ context.Context, questions []tools.Question) (*tools.AskUserResponse, error) {
|
||||||
resp := &tools.AskUserResponse{
|
resp := &tools.AskUserResponse{
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/voocel/agentcore"
|
||||||
"github.com/voocel/ainovel-cli/domain"
|
"github.com/voocel/ainovel-cli/domain"
|
||||||
"github.com/voocel/ainovel-cli/state"
|
"github.com/voocel/ainovel-cli/state"
|
||||||
)
|
)
|
||||||
@@ -70,3 +72,53 @@ func TestFinalizeSteerIfIdleKeepsActiveFlow(t *testing.T) {
|
|||||||
t.Fatalf("expected pending steer preserved, got %q", runMeta.PendingSteer)
|
t.Fatalf("expected pending steer preserved, got %q", runMeta.PendingSteer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseProgressSummaryIgnoresThinkingUpdate(t *testing.T) {
|
||||||
|
result, err := json.Marshal(map[string]any{
|
||||||
|
"agent": "architect",
|
||||||
|
"thinking": "好的,我已经获得了模板。",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := parseProgressSummary(agentcore.Event{Result: result})
|
||||||
|
if summary != "" {
|
||||||
|
t.Fatalf("expected thinking update to be ignored, got %q", summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseProgressSummaryKeepsToolProgress(t *testing.T) {
|
||||||
|
result, err := json.Marshal(map[string]any{
|
||||||
|
"agent": "writer",
|
||||||
|
"tool": "plan_chapter",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := parseProgressSummary(agentcore.Event{Result: result})
|
||||||
|
if summary != "writer → plan_chapter" {
|
||||||
|
t.Fatalf("unexpected summary: %q", summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateModelUsesOpenRouterProvider(t *testing.T) {
|
||||||
|
model, err := createModel(Config{
|
||||||
|
Provider: "openrouter",
|
||||||
|
ModelName: "stepfun/step-3.5-flash:free",
|
||||||
|
APIKey: "test-key",
|
||||||
|
BaseURL: "https://openrouter.ai/api/v1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createModel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
providerModel, ok := model.(interface{ ProviderName() string })
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("model does not expose provider name")
|
||||||
|
}
|
||||||
|
if provider := providerModel.ProviderName(); provider != "openrouter" {
|
||||||
|
t.Fatalf("expected provider openrouter, got %q", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type UIEvent struct {
|
|||||||
|
|
||||||
// UISnapshot 是 TUI 渲染所需的聚合状态快照。
|
// UISnapshot 是 TUI 渲染所需的聚合状态快照。
|
||||||
type UISnapshot struct {
|
type UISnapshot struct {
|
||||||
|
Provider string
|
||||||
NovelName string
|
NovelName string
|
||||||
ModelName string
|
ModelName string
|
||||||
Style string
|
Style string
|
||||||
@@ -94,6 +95,7 @@ func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[s
|
|||||||
if err := cfg.ValidateBase(); err != nil {
|
if err := cfg.ValidateBase(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
log.Printf("[boot] provider=%s model=%s base_url=%s output=%s", cfg.Provider, cfg.ModelName, cfg.BaseURL, cfg.OutputDir)
|
||||||
|
|
||||||
store := state.NewStore(cfg.OutputDir)
|
store := state.NewStore(cfg.OutputDir)
|
||||||
if err := store.Init(); err != nil {
|
if err := store.Init(); err != nil {
|
||||||
@@ -119,10 +121,10 @@ func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 注册事件订阅:确定性控制 + UIEvent 转发 + 流式 delta 转发
|
// 注册事件订阅:确定性控制 + UIEvent 转发 + 流式 delta 转发
|
||||||
registerSubscription(coordinator, store, rt.emit, rt.emitDelta, rt.emitClear)
|
registerSubscription(coordinator, store, cfg.Provider, rt.emit, rt.emitDelta, rt.emitClear)
|
||||||
|
|
||||||
// 初始化运行元信息
|
// 初始化运行元信息
|
||||||
if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil {
|
if err := store.InitRunMeta(cfg.Style, cfg.Provider, cfg.ModelName); err != nil {
|
||||||
log.Printf("[warn] 初始化运行元信息失败: %v", err)
|
log.Printf("[warn] 初始化运行元信息失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +251,7 @@ func (rt *Runtime) Steer(text string) {
|
|||||||
func (rt *Runtime) Snapshot() UISnapshot {
|
func (rt *Runtime) Snapshot() UISnapshot {
|
||||||
snap := UISnapshot{
|
snap := UISnapshot{
|
||||||
NovelName: rt.cfg.NovelName,
|
NovelName: rt.cfg.NovelName,
|
||||||
|
Provider: rt.cfg.Provider,
|
||||||
ModelName: rt.cfg.ModelName,
|
ModelName: rt.cfg.ModelName,
|
||||||
Style: rt.cfg.Style,
|
Style: rt.cfg.Style,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ func (p *Progress) NextChapter() int {
|
|||||||
// RunMeta 运行元信息,持久化到 meta/run.json。
|
// RunMeta 运行元信息,持久化到 meta/run.json。
|
||||||
type RunMeta struct {
|
type RunMeta struct {
|
||||||
StartedAt string `json:"started_at"`
|
StartedAt string `json:"started_at"`
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
Style string `json:"style"`
|
Style string `json:"style"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
SteerHistory []SteerEntry `json:"steer_history,omitempty"`
|
SteerHistory []SteerEntry `json:"steer_history,omitempty"`
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ func (s *Store) LoadRunMeta() (*domain.RunMeta, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InitRunMeta 初始化或更新运行元信息,保留已有的 SteerHistory。
|
// InitRunMeta 初始化或更新运行元信息,保留已有的 SteerHistory。
|
||||||
func (s *Store) InitRunMeta(style, model string) error {
|
func (s *Store) InitRunMeta(style, provider, model string) error {
|
||||||
existing, _ := s.LoadRunMeta()
|
existing, _ := s.LoadRunMeta()
|
||||||
meta := domain.RunMeta{
|
meta := domain.RunMeta{
|
||||||
StartedAt: time.Now().Format(time.RFC3339),
|
StartedAt: time.Now().Format(time.RFC3339),
|
||||||
|
Provider: provider,
|
||||||
Style: style,
|
Style: style,
|
||||||
Model: model,
|
Model: model,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ func TestSaveAndLoadRunMeta(t *testing.T) {
|
|||||||
|
|
||||||
meta := domain.RunMeta{
|
meta := domain.RunMeta{
|
||||||
StartedAt: "2026-03-07T10:00:00+08:00",
|
StartedAt: "2026-03-07T10:00:00+08:00",
|
||||||
|
Provider: "openrouter",
|
||||||
Style: "fantasy",
|
Style: "fantasy",
|
||||||
Model: "gpt-4o",
|
Model: "gpt-4o",
|
||||||
}
|
}
|
||||||
@@ -27,6 +28,9 @@ func TestSaveAndLoadRunMeta(t *testing.T) {
|
|||||||
if loaded.Style != "fantasy" {
|
if loaded.Style != "fantasy" {
|
||||||
t.Errorf("style mismatch: %s", loaded.Style)
|
t.Errorf("style mismatch: %s", loaded.Style)
|
||||||
}
|
}
|
||||||
|
if loaded.Provider != "openrouter" {
|
||||||
|
t.Errorf("provider mismatch: %s", loaded.Provider)
|
||||||
|
}
|
||||||
if loaded.Model != "gpt-4o" {
|
if loaded.Model != "gpt-4o" {
|
||||||
t.Errorf("model mismatch: %s", loaded.Model)
|
t.Errorf("model mismatch: %s", loaded.Model)
|
||||||
}
|
}
|
||||||
@@ -80,6 +84,7 @@ func TestAppendSteerEntry_PreservesExistingMeta(t *testing.T) {
|
|||||||
// 先保存 RunMeta
|
// 先保存 RunMeta
|
||||||
_ = store.SaveRunMeta(domain.RunMeta{
|
_ = store.SaveRunMeta(domain.RunMeta{
|
||||||
StartedAt: "2026-03-07T10:00:00+08:00",
|
StartedAt: "2026-03-07T10:00:00+08:00",
|
||||||
|
Provider: "openrouter",
|
||||||
Style: "suspense",
|
Style: "suspense",
|
||||||
Model: "gpt-4o",
|
Model: "gpt-4o",
|
||||||
})
|
})
|
||||||
@@ -91,6 +96,9 @@ func TestAppendSteerEntry_PreservesExistingMeta(t *testing.T) {
|
|||||||
if meta.Style != "suspense" {
|
if meta.Style != "suspense" {
|
||||||
t.Errorf("style should be preserved, got %s", meta.Style)
|
t.Errorf("style should be preserved, got %s", meta.Style)
|
||||||
}
|
}
|
||||||
|
if meta.Provider != "openrouter" {
|
||||||
|
t.Errorf("provider should be preserved, got %s", meta.Provider)
|
||||||
|
}
|
||||||
if meta.Model != "gpt-4o" {
|
if meta.Model != "gpt-4o" {
|
||||||
t.Errorf("model should be preserved, got %s", meta.Model)
|
t.Errorf("model should be preserved, got %s", meta.Model)
|
||||||
}
|
}
|
||||||
@@ -106,6 +114,7 @@ func TestInitRunMeta_PreservesHistory(t *testing.T) {
|
|||||||
// 先建立带历史的 RunMeta
|
// 先建立带历史的 RunMeta
|
||||||
_ = store.SaveRunMeta(domain.RunMeta{
|
_ = store.SaveRunMeta(domain.RunMeta{
|
||||||
StartedAt: "old",
|
StartedAt: "old",
|
||||||
|
Provider: "openai",
|
||||||
Style: "fantasy",
|
Style: "fantasy",
|
||||||
Model: "old-model",
|
Model: "old-model",
|
||||||
SteerHistory: []domain.SteerEntry{{Input: "历史干预", Timestamp: "ts"}},
|
SteerHistory: []domain.SteerEntry{{Input: "历史干预", Timestamp: "ts"}},
|
||||||
@@ -113,12 +122,15 @@ func TestInitRunMeta_PreservesHistory(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// InitRunMeta 应保留 SteerHistory 和 PendingSteer
|
// InitRunMeta 应保留 SteerHistory 和 PendingSteer
|
||||||
_ = store.InitRunMeta("suspense", "new-model")
|
_ = store.InitRunMeta("suspense", "openrouter", "new-model")
|
||||||
|
|
||||||
meta, _ := store.LoadRunMeta()
|
meta, _ := store.LoadRunMeta()
|
||||||
if meta.Style != "suspense" {
|
if meta.Style != "suspense" {
|
||||||
t.Errorf("style should be updated, got %s", meta.Style)
|
t.Errorf("style should be updated, got %s", meta.Style)
|
||||||
}
|
}
|
||||||
|
if meta.Provider != "openrouter" {
|
||||||
|
t.Errorf("provider should be updated, got %s", meta.Provider)
|
||||||
|
}
|
||||||
if meta.Model != "new-model" {
|
if meta.Model != "new-model" {
|
||||||
t.Errorf("model should be updated, got %s", meta.Model)
|
t.Errorf("model should be updated, got %s", meta.Model)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func renderInputBox(inputView string, snap app.UISnapshot, outputDir string, wid
|
|||||||
line1 := prompt + inputView
|
line1 := prompt + inputView
|
||||||
|
|
||||||
// 第二行:左快捷键,右进度
|
// 第二行:左快捷键,右进度
|
||||||
hints := lipgloss.NewStyle().Foreground(colorDim).Render("Tab 切换 · ^L 清屏 · Esc 重置 · Enter 发送")
|
hints := lipgloss.NewStyle().Foreground(colorDim).Render("点击/Tab 切换面板 · ↑↓ 滚动 · End 跳底 · ^L 清屏 · Esc 重置 · Enter 发送")
|
||||||
info := buildRightInfo(snap, outputDir)
|
info := buildRightInfo(snap, outputDir)
|
||||||
|
|
||||||
hintsW := lipgloss.Width(hints)
|
hintsW := lipgloss.Width(hints)
|
||||||
@@ -52,6 +52,9 @@ func renderInputBox(inputView string, snap app.UISnapshot, outputDir string, wid
|
|||||||
func buildRightInfo(snap app.UISnapshot, outputDir string) string {
|
func buildRightInfo(snap app.UISnapshot, outputDir string) string {
|
||||||
var parts []string
|
var parts []string
|
||||||
|
|
||||||
|
if snap.Provider != "" {
|
||||||
|
parts = append(parts, snap.Provider)
|
||||||
|
}
|
||||||
if snap.ModelName != "" {
|
if snap.ModelName != "" {
|
||||||
parts = append(parts, snap.ModelName)
|
parts = append(parts, snap.ModelName)
|
||||||
}
|
}
|
||||||
|
|||||||
128
tui/model.go
128
tui/model.go
@@ -13,6 +13,14 @@ import (
|
|||||||
|
|
||||||
const maxEvents = 500
|
const maxEvents = 500
|
||||||
|
|
||||||
|
type focusPane int
|
||||||
|
|
||||||
|
const (
|
||||||
|
focusEvents focusPane = iota
|
||||||
|
focusStream
|
||||||
|
focusDetail
|
||||||
|
)
|
||||||
|
|
||||||
type appMode int
|
type appMode int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -29,15 +37,18 @@ 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 viewport.Model // 事件流 viewport
|
||||||
streamVP viewport.Model // 流式输出 viewport
|
streamVP viewport.Model // 流式输出 viewport
|
||||||
|
detailVP viewport.Model // 右侧详情 viewport
|
||||||
streamBuf *strings.Builder // 流式文本累积缓冲
|
streamBuf *strings.Builder // 流式文本累积缓冲
|
||||||
textarea textarea.Model
|
textarea textarea.Model
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
autoScroll bool
|
autoScroll bool
|
||||||
streamScroll bool // 流式面板自动跟随
|
streamScroll bool // 流式面板自动跟随
|
||||||
focusStream bool // true=焦点在流式面板, false=事件流
|
focusPane focusPane
|
||||||
|
hoverPane focusPane
|
||||||
|
hoverActive bool
|
||||||
mode appMode
|
mode appMode
|
||||||
err error
|
err error
|
||||||
spinnerIdx int
|
spinnerIdx int
|
||||||
@@ -63,6 +74,9 @@ func NewModel(rt *app.Runtime) Model {
|
|||||||
svp := viewport.New(80, 10)
|
svp := viewport.New(80, 10)
|
||||||
svp.SetContent("")
|
svp.SetContent("")
|
||||||
|
|
||||||
|
dvp := viewport.New(40, 20)
|
||||||
|
dvp.SetContent("")
|
||||||
|
|
||||||
return Model{
|
return Model{
|
||||||
runtime: rt,
|
runtime: rt,
|
||||||
autoScroll: true,
|
autoScroll: true,
|
||||||
@@ -71,6 +85,7 @@ func NewModel(rt *app.Runtime) Model {
|
|||||||
textarea: ta,
|
textarea: ta,
|
||||||
viewport: vp,
|
viewport: vp,
|
||||||
streamVP: svp,
|
streamVP: svp,
|
||||||
|
detailVP: dvp,
|
||||||
streamBuf: &strings.Builder{},
|
streamBuf: &strings.Builder{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,6 +112,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
m.textarea.SetWidth(m.inputWidth())
|
m.textarea.SetWidth(m.inputWidth())
|
||||||
m.updateViewportSize()
|
m.updateViewportSize()
|
||||||
|
m.refreshDetailViewport()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
@@ -116,7 +132,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.streamRound = 0
|
m.streamRound = 0
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyTab:
|
case tea.KeyTab:
|
||||||
m.focusStream = !m.focusStream
|
m.focusPane = (m.focusPane + 1) % 3
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyEnter:
|
case tea.KeyEnter:
|
||||||
text := strings.TrimSpace(m.textarea.Value())
|
text := strings.TrimSpace(m.textarea.Value())
|
||||||
@@ -134,18 +150,23 @@ 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 {
|
if m.focusPane == focusStream {
|
||||||
m.streamScroll = false
|
m.streamScroll = false
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.streamVP, cmd = m.streamVP.Update(msg)
|
m.streamVP, cmd = m.streamVP.Update(msg)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
if m.focusPane == focusDetail {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.detailVP, cmd = m.detailVP.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 {
|
if m.focusPane == focusStream {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.streamVP, cmd = m.streamVP.Update(msg)
|
m.streamVP, cmd = m.streamVP.Update(msg)
|
||||||
if m.streamVP.AtBottom() {
|
if m.streamVP.AtBottom() {
|
||||||
@@ -153,6 +174,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
if m.focusPane == focusDetail {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.detailVP, cmd = m.detailVP.Update(msg)
|
||||||
|
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() {
|
||||||
@@ -160,9 +186,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
case tea.KeyEnd:
|
case tea.KeyEnd:
|
||||||
if m.focusStream {
|
if m.focusPane == focusStream {
|
||||||
m.streamScroll = true
|
m.streamScroll = true
|
||||||
m.streamVP.GotoBottom()
|
m.streamVP.GotoBottom()
|
||||||
|
} else if m.focusPane == focusDetail {
|
||||||
|
m.detailVP.GotoBottom()
|
||||||
} else {
|
} else {
|
||||||
m.autoScroll = true
|
m.autoScroll = true
|
||||||
m.viewport.GotoBottom()
|
m.viewport.GotoBottom()
|
||||||
@@ -171,12 +199,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case tea.MouseMsg:
|
case tea.MouseMsg:
|
||||||
|
if pane, ok := m.paneAtMouse(msg.X, msg.Y); ok {
|
||||||
|
m.hoverPane = pane
|
||||||
|
m.hoverActive = true
|
||||||
|
if msg.Action == tea.MouseActionPress {
|
||||||
|
m.focusPane = pane
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.hoverActive = false
|
||||||
|
}
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
if m.focusStream {
|
if m.focusPane == focusStream {
|
||||||
m.streamVP, cmd = m.streamVP.Update(msg)
|
m.streamVP, cmd = m.streamVP.Update(msg)
|
||||||
if msg.Action == tea.MouseActionPress {
|
if msg.Action == tea.MouseActionPress {
|
||||||
m.streamScroll = m.streamVP.AtBottom()
|
m.streamScroll = m.streamVP.AtBottom()
|
||||||
}
|
}
|
||||||
|
} else if m.focusPane == focusDetail {
|
||||||
|
m.detailVP, cmd = m.detailVP.Update(msg)
|
||||||
} else {
|
} else {
|
||||||
m.viewport, cmd = m.viewport.Update(msg)
|
m.viewport, cmd = m.viewport.Update(msg)
|
||||||
if msg.Action == tea.MouseActionPress {
|
if msg.Action == tea.MouseActionPress {
|
||||||
@@ -196,6 +235,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case snapshotMsg:
|
case snapshotMsg:
|
||||||
m.snapshot = app.UISnapshot(msg)
|
m.snapshot = app.UISnapshot(msg)
|
||||||
|
m.refreshDetailViewport()
|
||||||
return m, tickSnapshot(m.runtime)
|
return m, tickSnapshot(m.runtime)
|
||||||
|
|
||||||
case doneMsg:
|
case doneMsg:
|
||||||
@@ -260,6 +300,50 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) paneAtMouse(x, y int) (focusPane, bool) {
|
||||||
|
if m.width == 0 || m.height == 0 {
|
||||||
|
return focusEvents, false
|
||||||
|
}
|
||||||
|
|
||||||
|
topH := lipgloss.Height(renderTopBar(m.snapshot, m.width, ""))
|
||||||
|
inputH := lipgloss.Height(renderInputBox(m.textarea.View(), m.snapshot, m.runtime.Dir(), m.width))
|
||||||
|
bodyH := m.height - topH - inputH
|
||||||
|
if bodyH < 1 {
|
||||||
|
return focusEvents, false
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStartY := topH
|
||||||
|
bodyEndY := topH + bodyH
|
||||||
|
if y < bodyStartY || y >= bodyEndY {
|
||||||
|
return focusEvents, false
|
||||||
|
}
|
||||||
|
|
||||||
|
leftW := m.width * 25 / 100
|
||||||
|
rightW := m.detailWidth()
|
||||||
|
centerStartX := leftW
|
||||||
|
rightStartX := m.width - rightW
|
||||||
|
|
||||||
|
if x >= rightStartX {
|
||||||
|
return focusDetail, true
|
||||||
|
}
|
||||||
|
if x < centerStartX {
|
||||||
|
return focusEvents, true
|
||||||
|
}
|
||||||
|
|
||||||
|
eventH, _ := m.splitHeights(bodyH)
|
||||||
|
if y-bodyStartY < eventH {
|
||||||
|
return focusEvents, true
|
||||||
|
}
|
||||||
|
return focusStream, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) paneHighlighted(pane focusPane) bool {
|
||||||
|
if m.focusPane == pane {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return m.hoverActive && m.hoverPane == pane
|
||||||
|
}
|
||||||
|
|
||||||
// refreshEventViewport 重新渲染事件流内容并设置 viewport。
|
// refreshEventViewport 重新渲染事件流内容并设置 viewport。
|
||||||
func (m *Model) refreshEventViewport() {
|
func (m *Model) refreshEventViewport() {
|
||||||
centerW := m.eventFlowWidth()
|
centerW := m.eventFlowWidth()
|
||||||
@@ -273,15 +357,26 @@ func (m *Model) refreshEventViewport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) refreshDetailViewport() {
|
||||||
|
rightW := m.detailWidth()
|
||||||
|
if rightW <= 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.detailVP.SetContent(renderDetailContent(m.snapshot, rightW-4))
|
||||||
|
}
|
||||||
|
|
||||||
// updateViewportSize 根据当前窗口尺寸更新 viewport 大小。
|
// updateViewportSize 根据当前窗口尺寸更新 viewport 大小。
|
||||||
func (m *Model) updateViewportSize() {
|
func (m *Model) updateViewportSize() {
|
||||||
centerW := m.eventFlowWidth()
|
centerW := m.eventFlowWidth()
|
||||||
|
rightW := m.detailWidth()
|
||||||
bodyH := m.bodyHeight()
|
bodyH := m.bodyHeight()
|
||||||
eventH, streamH := m.splitHeights(bodyH)
|
eventH, streamH := m.splitHeights(bodyH)
|
||||||
m.viewport.Width = centerW - 2
|
m.viewport.Width = centerW - 2
|
||||||
m.viewport.Height = eventH - 1 // -1 为 event panel header 行
|
m.viewport.Height = eventH - 1 // -1 为 event panel header 行
|
||||||
m.streamVP.Width = centerW - 2
|
m.streamVP.Width = centerW - 2
|
||||||
m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行
|
m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行
|
||||||
|
m.detailVP.Width = rightW - 2
|
||||||
|
m.detailVP.Height = bodyH
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitHeights 计算事件流和流式输出的高度分配。
|
// splitHeights 计算事件流和流式输出的高度分配。
|
||||||
@@ -309,10 +404,17 @@ func (m *Model) eventFlowWidth() int {
|
|||||||
return 80
|
return 80
|
||||||
}
|
}
|
||||||
leftW := m.width * 25 / 100
|
leftW := m.width * 25 / 100
|
||||||
rightW := m.width * 30 / 100
|
rightW := m.detailWidth()
|
||||||
return m.width - leftW - rightW
|
return m.width - leftW - rightW
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) detailWidth() int {
|
||||||
|
if m.width == 0 {
|
||||||
|
return 40
|
||||||
|
}
|
||||||
|
return m.width * 30 / 100
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) bodyHeight() int {
|
func (m *Model) bodyHeight() int {
|
||||||
if m.height == 0 {
|
if m.height == 0 {
|
||||||
return 20
|
return 20
|
||||||
@@ -362,7 +464,7 @@ func (m Model) View() string {
|
|||||||
body = renderWelcome(m.width, bodyH, errMsg)
|
body = renderWelcome(m.width, bodyH, errMsg)
|
||||||
} else {
|
} else {
|
||||||
leftW := m.width * 25 / 100
|
leftW := m.width * 25 / 100
|
||||||
rightW := m.width * 30 / 100
|
rightW := m.detailWidth()
|
||||||
centerW := m.width - leftW - rightW
|
centerW := m.width - leftW - rightW
|
||||||
eventH, streamH := m.splitHeights(bodyH)
|
eventH, streamH := m.splitHeights(bodyH)
|
||||||
|
|
||||||
@@ -375,12 +477,12 @@ func (m Model) View() string {
|
|||||||
m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行
|
m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行
|
||||||
}
|
}
|
||||||
|
|
||||||
eventFlow := renderEventFlowViewport(m.viewport, centerW, eventH, !m.focusStream)
|
eventFlow := renderEventFlowViewport(m.viewport, centerW, eventH, m.paneHighlighted(focusEvents))
|
||||||
streamPanel := renderStreamPanel(m.streamVP, centerW, streamH, m.focusStream)
|
streamPanel := renderStreamPanel(m.streamVP, centerW, streamH, m.paneHighlighted(focusStream))
|
||||||
center := lipgloss.JoinVertical(lipgloss.Left, eventFlow, streamPanel)
|
center := lipgloss.JoinVertical(lipgloss.Left, eventFlow, streamPanel)
|
||||||
|
|
||||||
left := renderStatePanel(m.snapshot, leftW, bodyH)
|
left := renderStatePanel(m.snapshot, leftW, bodyH)
|
||||||
right := renderDetailPanel(m.snapshot, rightW, bodyH)
|
right := renderDetailPanel(m.detailVP, rightW, bodyH, m.paneHighlighted(focusDetail))
|
||||||
body = lipgloss.JoinHorizontal(lipgloss.Top, left, center, right)
|
body = lipgloss.JoinHorizontal(lipgloss.Top, left, center, right)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ func renderTopBar(snap app.UISnapshot, width int, spinnerFrame string) string {
|
|||||||
|
|
||||||
// 第二行左侧:模型 + 风格
|
// 第二行左侧:模型 + 风格
|
||||||
var infoParts []string
|
var infoParts []string
|
||||||
|
if snap.Provider != "" {
|
||||||
|
infoParts = append(infoParts, snap.Provider)
|
||||||
|
}
|
||||||
if snap.ModelName != "" {
|
if snap.ModelName != "" {
|
||||||
infoParts = append(infoParts, snap.ModelName)
|
infoParts = append(infoParts, snap.ModelName)
|
||||||
}
|
}
|
||||||
@@ -238,10 +241,9 @@ func renderStreamSeparator(round, width int) string {
|
|||||||
return dimLine + dimLabel + dimLine
|
return dimLine + dimLabel + dimLine
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderDetailPanel 渲染右侧详情面板。
|
// renderDetailContent 构建右侧详情面板内容。
|
||||||
// 优先展示基础设定(大纲、角色),然后是运行时信息(提交、审阅等)。
|
// 优先展示基础设定(大纲、角色),然后是运行时信息(提交、审阅等)。
|
||||||
func renderDetailPanel(snap app.UISnapshot, width, height int) string {
|
func renderDetailContent(snap app.UISnapshot, contentW int) string {
|
||||||
contentW := width - 4 // 边框 + padding
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
// 大纲
|
// 大纲
|
||||||
@@ -309,14 +311,23 @@ func renderDetailPanel(snap app.UISnapshot, width, height int) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDetailPanel 渲染右侧可滚动详情面板。
|
||||||
|
func renderDetailPanel(vp viewport.Model, width, height int, focused bool) string {
|
||||||
|
borderColor := colorDim
|
||||||
|
if focused {
|
||||||
|
borderColor = colorAccent
|
||||||
|
}
|
||||||
style := lipgloss.NewStyle().
|
style := lipgloss.NewStyle().
|
||||||
Width(width).
|
Width(width).
|
||||||
Height(height).
|
Height(height).
|
||||||
Border(baseBorder, false, false, false, true).
|
Border(baseBorder, false, false, false, true).
|
||||||
BorderForeground(colorDim).
|
BorderForeground(borderColor).
|
||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|
||||||
return style.Render(b.String())
|
return style.Render(vp.View())
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderWelcome 渲染新建态首屏。
|
// renderWelcome 渲染新建态首屏。
|
||||||
|
|||||||
Reference in New Issue
Block a user