From 74a8c8eaef0af64fafe8c9544770609eef0109c9 Mon Sep 17 00:00:00 2001 From: voocel Date: Tue, 10 Mar 2026 23:55:45 +0800 Subject: [PATCH] feat: add openrouter --- app/config.go | 4 ++-- app/run.go | 26 +++++++++++++++++++++++++- main.go | 12 ++++++++---- tui/model.go | 8 ++++---- tui/panels.go | 23 ++++++++++++++++++++--- 5 files changed, 59 insertions(+), 14 deletions(-) diff --git a/app/config.go b/app/config.go index a91fa7c..0fad394 100644 --- a/app/config.go +++ b/app/config.go @@ -39,9 +39,9 @@ func (c *Config) ValidateBase() error { return fmt.Errorf("api key is required (set OPENAI_API_KEY or ANTHROPIC_API_KEY)") } switch c.Provider { - case "openai", "anthropic", "gemini": + case "openai", "anthropic", "gemini", "openrouter": default: - return fmt.Errorf("unsupported provider %q (use openai/anthropic/gemini)", c.Provider) + return fmt.Errorf("unsupported provider %q (use openai/anthropic/gemini/openrouter)", c.Provider) } return nil } diff --git a/app/run.go b/app/run.go index a9ecdd4..98dc33c 100644 --- a/app/run.go +++ b/app/run.go @@ -5,8 +5,10 @@ import ( "context" "encoding/json" "fmt" + "io" "log" "os" + "path/filepath" "slices" "strings" "time" @@ -43,6 +45,11 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s return fmt.Errorf("init store: %w", err) } + // 1.5 日志写入文件(CLI 模式同时输出到 stderr 和日志文件) + if cleanup := setupFileLogger(store.Dir()); cleanup != nil { + defer cleanup() + } + // 2. 创建模型 model, err := createModel(cfg) if err != nil { @@ -505,6 +512,23 @@ func finalizeSteerIfIdle(store *state.Store) { clearHandledSteer(store) } +// setupFileLogger 设置 CLI 模式日志,同时输出到 stderr 和日志文件。 +func setupFileLogger(outputDir string) func() { + logPath := filepath.Join(outputDir, "meta", "cli.log") + if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { + return nil + } + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return nil + } + log.SetOutput(io.MultiWriter(os.Stderr, f)) + return func() { + log.SetOutput(os.Stderr) + _ = f.Close() + } +} + // createModel 根据 provider 创建对应的 LLM 模型。 func createModel(cfg Config) (agentcore.ChatModel, error) { var baseURL []string @@ -516,7 +540,7 @@ 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: + default: // openai, openrouter 及其他 OpenAI 兼容服务 return llm.NewOpenAIModel(cfg.ModelName, cfg.APIKey, baseURL...) } } diff --git a/main.go b/main.go index eed5146..ad81755 100644 --- a/main.go +++ b/main.go @@ -46,15 +46,19 @@ func main() { } func buildConfig(style string) app.Config { - provider := envOr("LLM_PROVIDER", "openai") + provider := envOr("LLM_PROVIDER", "openrouter") apiKey := os.Getenv("Z_OPENAI_API_KEY") baseURL := os.Getenv("Z_OPENAI_BASE_URL") - if provider == "anthropic" { + switch provider { + case "anthropic": apiKey = envOr("ANTHROPIC_API_KEY", apiKey) baseURL = envOr("ANTHROPIC_BASE_URL", baseURL) - } else if provider == "gemini" { + case "gemini": apiKey = envOr("GEMINI_API_KEY", apiKey) baseURL = envOr("GEMINI_BASE_URL", baseURL) + case "openrouter": + apiKey = envOr("OPENROUTER_API_KEY", apiKey) + baseURL = envOr("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") } cfg := app.Config{ @@ -62,7 +66,7 @@ func buildConfig(style string) app.Config { Provider: provider, APIKey: apiKey, BaseURL: baseURL, - ModelName: envOr("MODEL_NAME", ""), + ModelName: "stepfun/step-3.5-flash:free", Style: style, } return cfg diff --git a/tui/model.go b/tui/model.go index 9014b9f..87d0ecc 100644 --- a/tui/model.go +++ b/tui/model.go @@ -279,7 +279,7 @@ func (m *Model) updateViewportSize() { bodyH := m.bodyHeight() eventH, streamH := m.splitHeights(bodyH) m.viewport.Width = centerW - 2 - m.viewport.Height = eventH + m.viewport.Height = eventH - 1 // -1 为 event panel header 行 m.streamVP.Width = centerW - 2 m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行 } @@ -366,16 +366,16 @@ func (m Model) View() string { centerW := m.width - leftW - rightW eventH, streamH := m.splitHeights(bodyH) - if m.viewport.Width != centerW-2 || m.viewport.Height != eventH { + if m.viewport.Width != centerW-2 || m.viewport.Height != eventH-1 { m.viewport.Width = centerW - 2 - m.viewport.Height = eventH + m.viewport.Height = eventH - 1 // -1 为 event panel header 行 } 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) + eventFlow := renderEventFlowViewport(m.viewport, centerW, eventH, !m.focusStream) streamPanel := renderStreamPanel(m.streamVP, centerW, streamH, m.focusStream) center := lipgloss.JoinVertical(lipgloss.Left, eventFlow, streamPanel) diff --git a/tui/panels.go b/tui/panels.go index 439b265..a548e88 100644 --- a/tui/panels.go +++ b/tui/panels.go @@ -170,13 +170,30 @@ func renderEventContent(events []app.UIEvent, width int) string { } // renderEventFlowViewport 用 viewport 包装渲染事件流面板。 -func renderEventFlowViewport(vp viewport.Model, width, height int) string { +func renderEventFlowViewport(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 + + vpH := height - 1 + if vpH < 1 { + vpH = 1 + } style := lipgloss.NewStyle(). Width(width). - Height(height). + Height(vpH). Padding(0, 1) - return style.Render(vp.View()) + return header + "\n" + style.Render(vp.View()) } // renderStreamPanel 渲染流式输出面板(中间列下半部分)。