feat: add tab pane
This commit is contained in:
@@ -36,7 +36,7 @@ func (c *Config) Validate() error {
|
||||
// ValidateBase 校验基础配置(TUI 模式下 Prompt 由用户输入,不在此检查)。
|
||||
func (c *Config) ValidateBase() error {
|
||||
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 {
|
||||
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/state"
|
||||
"github.com/voocel/ainovel-cli/tools"
|
||||
"github.com/voocel/litellm"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
log.Printf("[boot] provider=%s model=%s base_url=%s output=%s", cfg.Provider, cfg.ModelName, cfg.BaseURL, cfg.OutputDir)
|
||||
|
||||
// 1. 初始化状态
|
||||
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)
|
||||
|
||||
// 4. 确定性控制面:事件监听 + FollowUp 注入
|
||||
registerSubscription(coordinator, store, nil, nil, nil)
|
||||
registerSubscription(coordinator, store, cfg.Provider, nil, nil, nil)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -115,7 +117,9 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
|
||||
}
|
||||
|
||||
// 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) {
|
||||
switch ev.Type {
|
||||
case agentcore.EventToolExecStart:
|
||||
@@ -133,6 +137,13 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit
|
||||
return
|
||||
}
|
||||
summary := parseProgressSummary(ev)
|
||||
if summary == "" {
|
||||
return
|
||||
}
|
||||
if summary == lastProgressSummary {
|
||||
return
|
||||
}
|
||||
lastProgressSummary = summary
|
||||
log.Printf("[progress] %s", summary)
|
||||
if emit != nil {
|
||||
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:
|
||||
lastProgressSummary = ""
|
||||
if ev.IsError {
|
||||
log.Printf("[tool:error] %s", ev.Tool)
|
||||
if emit != nil {
|
||||
@@ -178,9 +190,9 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit
|
||||
}
|
||||
|
||||
case agentcore.EventError:
|
||||
log.Printf("[error] %v", ev.Err)
|
||||
log.Printf("[error][provider=%s] %v", provider, ev.Err)
|
||||
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"
|
||||
}
|
||||
var data struct {
|
||||
Agent string `json:"agent"`
|
||||
Tool string `json:"tool"`
|
||||
Turn int `json:"turn"`
|
||||
Error bool `json:"error"`
|
||||
Agent string `json:"agent"`
|
||||
Tool string `json:"tool"`
|
||||
Turn int `json:"turn"`
|
||||
Error bool `json:"error"`
|
||||
Thinking string `json:"thinking"`
|
||||
}
|
||||
if err := json.Unmarshal(ev.Result, &data); err != nil {
|
||||
return truncateLog(string(ev.Result), 60)
|
||||
}
|
||||
// subagent 的 thinking 更新属于高频内部推理,不适合刷到事件流面板。
|
||||
if data.Thinking != "" && data.Tool == "" {
|
||||
return ""
|
||||
}
|
||||
if data.Tool != "" {
|
||||
if data.Error {
|
||||
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...)
|
||||
case "gemini":
|
||||
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...)
|
||||
}
|
||||
}
|
||||
|
||||
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 模式下的交互式选择器,上下键选择,回车确认。
|
||||
func cliAskUserHandler(_ context.Context, questions []tools.Question) (*tools.AskUserResponse, error) {
|
||||
resp := &tools.AskUserResponse{
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/voocel/agentcore"
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
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 渲染所需的聚合状态快照。
|
||||
type UISnapshot struct {
|
||||
Provider string
|
||||
NovelName string
|
||||
ModelName 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 {
|
||||
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)
|
||||
if err := store.Init(); err != nil {
|
||||
@@ -119,10 +121,10 @@ func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[s
|
||||
}
|
||||
|
||||
// 注册事件订阅:确定性控制 + 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)
|
||||
}
|
||||
|
||||
@@ -249,6 +251,7 @@ func (rt *Runtime) Steer(text string) {
|
||||
func (rt *Runtime) Snapshot() UISnapshot {
|
||||
snap := UISnapshot{
|
||||
NovelName: rt.cfg.NovelName,
|
||||
Provider: rt.cfg.Provider,
|
||||
ModelName: rt.cfg.ModelName,
|
||||
Style: rt.cfg.Style,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user