diff --git a/docs/ai.md b/docs/ai.md index 9e29011..35df510 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -117,13 +117,15 @@ xsql ai --profile dev --attr source=codex-cli --attr agent=codex --attr env=dev `xsql ai` 默认保持只读。只有所选 profile 配置了 `unsafe_allow_write: true`,且本次启动同时携带 `--unsafe-allow-write` 时,TUI 才进入 READ-WRITE 模式;切换 profile 后会重新校验这两个条件。 ### LLM 集成与 Tool Call 机制 -`xsql` 使用 OpenAI 官方 SDK (`github.com/openai/openai-go`) 与大模型交互,基于标准的 **ReAct Agent Loop 循环推理**,支持 3 大核心 Tools 调度: +`xsql` 使用 OpenAI 官方 SDK (`github.com/openai/openai-go`) 与大模型交互,基于标准的 **ReAct Agent Loop 循环推理**,支持 4 大核心 Tools 调度: 1. **`execute_sql(sql: string, explanation: string)`**: 数据库 SQL 查询工具(执行成功后宿主自动渲染内嵌交互表格)。 2. **`execute_javascript(js_code: string, explanation: string)`**: 基于 `goja` 沙箱的本地 JS 数据聚合计算工具(必须遵循 ES5 语法)。 -3. **`export_data(dataset_id: string, format: string, filepath: string, explanation: string)`**: 会话数据集文件导出工具(触发人机交互二次确认)。 +3. **`export_data(dataset_id: string, format: string, filepath: string, explanation: string)`**: 原始数据集转储工具,仅支持 `csv` 和 `json` 格式(触发人机交互二次确认,路径支持 `~/` 展开)。 +4. **`export_report(content: string, filepath: string, explanation: string)`**: 富文本 Markdown 分析报告导出工具(触发人机交互二次确认,路径支持 `~/` 展开)。 #### ReAct Agent Loop 准则 - **循环驱动**:Agent 会在单次交互中循环执行 Tools,直到不再产生 Tool Call。 +- **自愈重试机制**:当模型返回的 Tool Call 参数格式不符合 JSON Schema 时,Agent 循环自动捕获并将格式错误反馈给模型,触发自动修复与重试(最大 2 次)。 - **最终回答不变性**:交互轮次的最终输出必定是 AI 总结出的自然语言 / Markdown 格式分析报告。 - **工具折叠与容器内嵌**:所有的中间 Tool Call 默认以单行 Pill 收起折叠(内嵌表格与指标数据),界面保持极简清爽。 @@ -131,7 +133,7 @@ xsql ai --profile dev --attr source=codex-cli --attr agent=codex --attr env=dev - 每次查询成功的结果在本地分配标号(`res1`, `res2`, ...)。 - 大模型上下文包含数据集的轻量 Catalog 目录结构(字段名与行数),不会自动加入完整查询结果。 - 本地 JavaScript 的派生结果会以最多 4096 个字符的摘要回传给模型,用于生成最终分析;超出部分会截断并明确标记。 -- AI 可通过 `execute_javascript` 生成纯 Go 沙箱 (`goja`) 执行的代码,在本地对 `res1`, `res2` 等数据集做跨表 Join、占比统计与数据清洗,并通过 `export_data` 安全导出为 CSV/JSON/Markdown。 +- AI 可通过 `execute_javascript` 生成纯 Go 沙箱 (`goja`) 执行的代码,在本地对 `res1`, `res2` 等数据集做跨表 Join、占比统计与数据清洗,并通过 `export_data` 安全导出为 CSV/JSON,或通过 `export_report` 导出完整分析报告。 ### 快捷键操作 diff --git a/docs/config.md b/docs/config.md index 48c953a..e8f9b8a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -38,7 +38,7 @@ ai: api_key: "keyring:ai/api_key" allow_plaintext: false model: gpt-4o - max_tokens: 2048 + max_tokens: 8192 stats: enabled: true @@ -133,7 +133,7 @@ profiles: | `ai.api_key` | string | AI 服务 API Key,推荐使用 `keyring:` 引用 | | `ai.allow_plaintext` | bool | 允许配置文件中的明文 AI API Key(默认 false) | | `ai.model` | string | 模型名称,默认 `gpt-4o` | -| `ai.max_tokens` | int | 单次响应最大 token 数,默认 2048 | +| `ai.max_tokens` | int | 单次响应最大 token 数,默认 8192 | 配置文件中的明文 `ai.api_key` 默认拒绝。设置 `ai.allow_plaintext: true` 或在 `xsql ai` 显式使用 `--allow-plaintext` 时允许;CLI `--api-key` 和 `XSQL_AI_API_KEY` 本身属于显式运行时输入。 diff --git a/docs/rfcs/0013-ai-multi-tool-calls-sequential-execution.md b/docs/rfcs/0013-ai-multi-tool-calls-sequential-execution.md index 2881a37..85ca12e 100644 --- a/docs/rfcs/0013-ai-multi-tool-calls-sequential-execution.md +++ b/docs/rfcs/0013-ai-multi-tool-calls-sequential-execution.md @@ -48,3 +48,18 @@ type AIResponse struct { ### 3. System Prompt 数据库上下文与调用规范 (`internal/ai/prompt.go`) - 注入 `TARGET DATABASE: %s (Dialect: %s)` 显式标识当前连接数据库与方言。 - 规范 Agent 调用行为:在执行 SQL、运行 JS 分析或导出文件时统一使用标准的结构化 Tool Calling 协议。 + +--- + +## 修订记录 (Revision History) + +### 2026-08: 报告导出工具拆分、严格 Schema 校验自动重试与路径展开 +1. **文件导出职责分离**: + - `export_data` 专注于原始数据集转储,仅支持 `csv` 和 `json` 格式,不再支持 `markdown`。 + - 新增 `export_report(content, filepath, explanation)` 工具,专用于将 LLM 总结生成的结构化富文本 Markdown 报告写入本地文件。 +2. **路径自动展开 (Tilde Expansion)**: + - 导出路径支持 `~/` 波浪号展开为用户家目录(`$HOME`),防止误拼接为相对路径。 +3. **严格 Schema 校验与自愈重试**: + - 保持严格 JSON 反序列化校验。 + - 当模型返回非法/畸形 Tool Call 参数时,由 TUI Agent 循环捕获并向上下文注入错误反馈,自动触发重试(最大 2 次),无需人工干预输入“继续”。 + diff --git a/internal/ai/client.go b/internal/ai/client.go index 20490f2..8caf447 100644 --- a/internal/ai/client.go +++ b/internal/ai/client.go @@ -3,6 +3,7 @@ package ai import ( "context" "encoding/json" + "fmt" "net/http" "strings" @@ -104,7 +105,7 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A exportToolDef := openai.ChatCompletionToolParam{ Function: shared.FunctionDefinitionParam{ Name: "export_data", - Description: openai.String("Export a cached session dataset (e.g. res1, res2) to a local file in CSV, JSON, or Markdown format after human confirmation."), + Description: openai.String("Export a cached session dataset (e.g. res1, res2) to a local file in CSV or JSON format after human confirmation."), Parameters: shared.FunctionParameters{ "type": "object", "properties": map[string]interface{}{ @@ -114,8 +115,8 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A }, "format": map[string]interface{}{ "type": "string", - "description": "Export file format: 'csv', 'json', or 'markdown'.", - "enum": []string{"csv", "json", "markdown"}, + "description": "Export file format: 'csv' or 'json'.", + "enum": []string{"csv", "json"}, }, "filepath": map[string]interface{}{ "type": "string", @@ -131,6 +132,31 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A }, } + reportToolDef := openai.ChatCompletionToolParam{ + Function: shared.FunctionDefinitionParam{ + Name: "export_report", + Description: openai.String("Export a comprehensive Markdown analysis report to a local file after human confirmation."), + Parameters: shared.FunctionParameters{ + "type": "object", + "properties": map[string]interface{}{ + "content": map[string]interface{}{ + "type": "string", + "description": "The complete Markdown text content of the analysis report.", + }, + "filepath": map[string]interface{}{ + "type": "string", + "description": "Target file path (e.g. 'summary_report.md', '~/Downloads/report.md').", + }, + "explanation": map[string]interface{}{ + "type": "string", + "description": "Brief explanation of the report being saved.", + }, + }, + "required": []string{"content", "filepath", "explanation"}, + }, + }, + } + model := c.cfg.Model if model == "" { model = "gpt-4o" @@ -139,7 +165,7 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A params := openai.ChatCompletionNewParams{ Model: shared.ChatModel(model), Messages: sdkMessages, - Tools: []openai.ChatCompletionToolParam{sqlToolDef, jsToolDef, exportToolDef}, + Tools: []openai.ChatCompletionToolParam{sqlToolDef, jsToolDef, exportToolDef, reportToolDef}, ParallelToolCalls: openai.Bool(false), } if c.cfg.MaxTokens > 0 { @@ -158,6 +184,11 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A } choice := resp.Choices[0] + if choice.FinishReason == "length" { + return nil, errors.New(errors.CodeInternal, "AI response was truncated because it exceeded output token limit (max_tokens). Please provide a more concise report/summary without embedding excessively large raw datasets.", map[string]any{ + "finish_reason": choice.FinishReason, + }) + } msg := choice.Message if len(msg.ToolCalls) > 0 { @@ -213,6 +244,23 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A Explanation: strings.TrimSpace(raw.Explanation), }) + case "export_report": + var raw struct { + Content string `json:"content"` + FilePath string `json:"filepath"` + Explanation string `json:"explanation"` + } + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &raw); err != nil { + return nil, invalidToolArguments(toolCall.Function.Name, err) + } + actions = append(actions, ToolAction{ + ID: toolCall.ID, + Type: TypeReport, + Content: strings.TrimSpace(raw.Content), + FilePath: strings.TrimSpace(raw.FilePath), + Explanation: strings.TrimSpace(raw.Explanation), + }) + default: return nil, errors.New(errors.CodeInternal, "AI provider returned an unsupported tool call", map[string]any{ "tool": toolCall.Function.Name, @@ -228,6 +276,7 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A DatasetID: first.DatasetID, Format: first.Format, FilePath: first.FilePath, + Content: first.Content, Explanation: first.Explanation, Actions: actions, }, nil @@ -242,7 +291,8 @@ func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*A } func invalidToolArguments(toolName string, err error) *errors.XError { - return errors.New(errors.CodeInternal, "AI provider returned invalid tool arguments", map[string]any{ + msg := fmt.Sprintf("Invalid JSON arguments for tool '%s': %v. Ensure all string properties (especially multiline JS code and Markdown reports) are properly JSON-escaped with valid '\\n' newlines and escaped quotes.", toolName, err) + return errors.New(errors.CodeInternal, msg, map[string]any{ "tool": toolName, "err": err.Error(), }) diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go index 57e5010..d31a1dd 100644 --- a/internal/ai/prompt.go +++ b/internal/ai/prompt.go @@ -20,7 +20,11 @@ DATABASE SCHEMA: ENVIRONMENT & SPECIFICATIONS: - Database Mode: Default to READ-ONLY SELECT queries. - JavaScript Environment: Strict ES5 (ECMAScript 5.1) engine. Active session datasets (e.g. res1, res2) are available in global context. -- Output: Always use the structured tool calling interface when executing SQL, running JS analysis, or exporting files. + * Avoid modern Intl-dependent methods (e.g. do not pass locale string arguments to Number.prototype.toLocaleString). +- Tool Calling Guidelines: + * Always use the structured tool calling interface with strictly valid JSON arguments (all newlines and double quotes in code/text strings must be properly escaped). + * 'export_data': Use ONLY to export a raw cached session dataset (e.g. res1, res2) to 'csv' or 'json'. + * 'export_report': When the user asks to generate, save, or export an analysis report / summary / Markdown document, assemble the comprehensive Markdown content (including titles, insights, conclusions, and key aggregated tables) and call 'export_report'. Focus on concise analytical conclusions and avoid dumping excessively large raw records. ` func FormatDBName(dbType string) string { diff --git a/internal/ai/service.go b/internal/ai/service.go index 77b4c76..e9a61dc 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -18,6 +18,7 @@ const ( TypeSQL ResponseType = "sql" TypeJS ResponseType = "js" TypeExport ResponseType = "export" + TypeReport ResponseType = "report" TypeText ResponseType = "text" ) @@ -29,6 +30,7 @@ type ToolAction struct { DatasetID string `json:"dataset_id,omitempty"` Format string `json:"format,omitempty"` FilePath string `json:"filepath,omitempty"` + Content string `json:"content,omitempty"` Explanation string `json:"explanation,omitempty"` } @@ -39,6 +41,7 @@ type AIResponse struct { DatasetID string `json:"dataset_id,omitempty"` Format string `json:"format,omitempty"` FilePath string `json:"filepath,omitempty"` + Content string `json:"content,omitempty"` Explanation string `json:"explanation"` Actions []ToolAction `json:"actions,omitempty"` } diff --git a/internal/ai/service_test.go b/internal/ai/service_test.go index babf153..9489a6f 100644 --- a/internal/ai/service_test.go +++ b/internal/ai/service_test.go @@ -225,6 +225,64 @@ func TestGenerateResponse_MockHTTP_JSToolCall(t *testing.T) { } } +func TestGenerateResponse_MockHTTP_ExportReportToolCall(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + respBody := `{ + "id": "chatcmpl-126", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_report123", + "type": "function", + "function": { + "name": "export_report", + "arguments": "{\"content\":\"# Analysis Report\\n\\nAll good.\",\"filepath\":\"~/Downloads/report.md\",\"explanation\":\"Exports daily markdown report.\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ] + }` + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(respBody)) + })) + defer mockServer.Close() + + cfg := config.AIConfig{ + Provider: "openai", + BaseURL: mockServer.URL, + APIKey: "test-key", + } + + client := NewClient(cfg, mockServer.Client()) + service := NewService(cfg, client) + + res, xe := service.GenerateResponse(context.Background(), "export report", nil, "mysql", "res1 catalog") + if xe != nil { + t.Fatalf("unexpected error: %v", xe) + } + + if res.Type != TypeReport { + t.Errorf("expected type Report, got %q", res.Type) + } + if res.Content != "# Analysis Report\n\nAll good." { + t.Errorf("unexpected content: %q", res.Content) + } + if res.FilePath != "~/Downloads/report.md" { + t.Errorf("unexpected filepath: %q", res.FilePath) + } +} + func TestGenerateSQL_MockHTTP_TextMessageFallback(t *testing.T) { mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { respBody := `{ @@ -337,3 +395,90 @@ func TestService_ChatCompletion(t *testing.T) { t.Errorf("expected explanation 'Hello from ChatCompletion', got %q", res.Explanation) } } + +func TestChatCompletion_InvalidToolArgumentsAndEdgeCases(t *testing.T) { + testCases := []struct { + name string + respJSON string + wantErr string + }{ + { + name: "invalid execute_sql arguments", + respJSON: `{ + "id": "cmpl-1", "object": "chat.completion", "created": 1, "model": "gpt-4o", + "choices": [{"index": 0, "message": {"role": "assistant", "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "execute_sql", "arguments": "invalid json"}}]}}] + }`, + wantErr: "Invalid JSON arguments for tool 'execute_sql'", + }, + { + name: "invalid execute_javascript arguments", + respJSON: `{ + "id": "cmpl-2", "object": "chat.completion", "created": 1, "model": "gpt-4o", + "choices": [{"index": 0, "message": {"role": "assistant", "tool_calls": [{"id": "c2", "type": "function", "function": {"name": "execute_javascript", "arguments": "{"}}]}}] + }`, + wantErr: "Invalid JSON arguments for tool 'execute_javascript'", + }, + { + name: "invalid export_data arguments", + respJSON: `{ + "id": "cmpl-3", "object": "chat.completion", "created": 1, "model": "gpt-4o", + "choices": [{"index": 0, "message": {"role": "assistant", "tool_calls": [{"id": "c3", "type": "function", "function": {"name": "export_data", "arguments": "not-json"}}]}}] + }`, + wantErr: "Invalid JSON arguments for tool 'export_data'", + }, + { + name: "invalid export_report arguments", + respJSON: `{ + "id": "cmpl-4", "object": "chat.completion", "created": 1, "model": "gpt-4o", + "choices": [{"index": 0, "message": {"role": "assistant", "tool_calls": [{"id": "c4", "type": "function", "function": {"name": "export_report", "arguments": "bad json"}}]}}] + }`, + wantErr: "Invalid JSON arguments for tool 'export_report'", + }, + { + name: "unsupported tool call", + respJSON: `{ + "id": "cmpl-5", "object": "chat.completion", "created": 1, "model": "gpt-4o", + "choices": [{"index": 0, "message": {"role": "assistant", "tool_calls": [{"id": "c5", "type": "function", "function": {"name": "unknown_tool", "arguments": "{}"}}]}}] + }`, + wantErr: "unsupported tool call", + }, + { + name: "empty choices", + respJSON: `{ + "id": "cmpl-6", "object": "chat.completion", "created": 1, "model": "gpt-4o", + "choices": [] + }`, + wantErr: "empty choices", + }, + { + name: "truncated by max_tokens limit", + respJSON: `{ + "id": "cmpl-7", "object": "chat.completion", "created": 1, "model": "gpt-4o", + "choices": [{"index": 0, "finish_reason": "length", "message": {"role": "assistant", "content": "half message"}}] + }`, + wantErr: "exceeded output token limit", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tc.respJSON)) + })) + defer server.Close() + + cfg := config.AIConfig{Provider: "openai", BaseURL: server.URL, APIKey: "k"} + client := NewClient(cfg, server.Client()) + svc := NewService(cfg, client) + + _, xe := svc.ChatCompletion(context.Background(), []ChatMessage{{Role: "user", Content: "hi"}}) + if xe == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(xe.Message, tc.wantErr) { + t.Fatalf("expected error containing %q, got %q", tc.wantErr, xe.Message) + } + }) + } +} diff --git a/internal/config/resolve.go b/internal/config/resolve.go index d1bfa78..e150fdf 100644 --- a/internal/config/resolve.go +++ b/internal/config/resolve.go @@ -120,7 +120,7 @@ func Resolve(opts Options) (Resolved, *errors.XError) { aiConfig.Model = "gpt-4o" } if aiConfig.MaxTokens == 0 { - aiConfig.MaxTokens = 2048 + aiConfig.MaxTokens = 8192 } if opts.EnvAIBaseURL != "" { diff --git a/internal/config/types.go b/internal/config/types.go index 651f93b..08c0e60 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -20,7 +20,7 @@ type AIConfig struct { APIKey string `yaml:"api_key" json:"api_key"` // supports keyring:xxx reference AllowPlaintext bool `yaml:"allow_plaintext" json:"allow_plaintext"` // allow plaintext API key Model string `yaml:"model" json:"model"` // default "gpt-4o" - MaxTokens int `yaml:"max_tokens" json:"max_tokens"` // default 2048 + MaxTokens int `yaml:"max_tokens" json:"max_tokens"` // default 8192 } // SSHProxy defines a reusable SSH proxy configuration. diff --git a/internal/export/exporter.go b/internal/export/exporter.go index cf0d72d..2b698c3 100644 --- a/internal/export/exporter.go +++ b/internal/export/exporter.go @@ -15,31 +15,49 @@ import ( type ExportFormat string const ( - FormatCSV ExportFormat = "csv" - FormatJSON ExportFormat = "json" - FormatMarkdown ExportFormat = "markdown" + FormatCSV ExportFormat = "csv" + FormatJSON ExportFormat = "json" ) -func ExportQueryResult(result *db.QueryResult, format ExportFormat, filePath string) (string, *errors.XError) { - if result == nil { - return "", errors.New(errors.CodeCfgInvalid, "cannot export nil QueryResult", nil) +// ExpandPath expands leading ~ to user's home directory. +func ExpandPath(filePath string) (string, error) { + filePath = strings.TrimSpace(filePath) + if filePath == "" { + return "", nil } - format = ExportFormat(strings.ToLower(strings.TrimSpace(string(format)))) - switch format { - case FormatCSV, FormatJSON, FormatMarkdown: - default: - return "", errors.New(errors.CodeCfgInvalid, "unsupported export format", map[string]any{ - "format": format, - }) + if filePath == "~" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return home, nil } - if filePath == "" { - filePath = fmt.Sprintf("export_%s.%s", format, format) + if strings.HasPrefix(filePath, "~/") || strings.HasPrefix(filePath, "~\\") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, filePath[2:]), nil } - // Ensure directory exists - dir := filepath.Dir(filePath) + return filePath, nil +} + +func resolveAndPrepareTarget(filePath, defaultName string) (string, *errors.XError) { + expandedPath, err := ExpandPath(filePath) + if err != nil { + return "", errors.New(errors.CodeInternal, "failed to expand export file path", map[string]any{ + "path": filePath, + "err": err.Error(), + }) + } + if expandedPath == "" { + expandedPath = defaultName + } + + dir := filepath.Dir(expandedPath) if dir != "" && dir != "." { if err := os.MkdirAll(dir, 0755); err != nil { return "", errors.New(errors.CodeInternal, "failed to create export directory", map[string]any{ @@ -48,11 +66,33 @@ func ExportQueryResult(result *db.QueryResult, format ExportFormat, filePath str }) } } + return expandedPath, nil +} - f, err := os.Create(filePath) +func ExportQueryResult(result *db.QueryResult, format ExportFormat, filePath string) (string, *errors.XError) { + if result == nil { + return "", errors.New(errors.CodeCfgInvalid, "cannot export nil QueryResult", nil) + } + + format = ExportFormat(strings.ToLower(strings.TrimSpace(string(format)))) + switch format { + case FormatCSV, FormatJSON: + default: + return "", errors.New(errors.CodeCfgInvalid, "unsupported export format", map[string]any{ + "format": format, + }) + } + + defaultName := fmt.Sprintf("export_%s.%s", format, format) + expandedPath, xe := resolveAndPrepareTarget(filePath, defaultName) + if xe != nil { + return "", xe + } + + f, err := os.Create(expandedPath) if err != nil { return "", errors.New(errors.CodeInternal, "failed to create export file", map[string]any{ - "path": filePath, + "path": expandedPath, "err": err.Error(), }) } @@ -66,34 +106,6 @@ func ExportQueryResult(result *db.QueryResult, format ExportFormat, filePath str return "", errors.New(errors.CodeInternal, "failed to write JSON export", map[string]any{"err": err.Error()}) } - case FormatMarkdown: - var sb strings.Builder - sb.WriteString("| " + strings.Join(result.Columns, " | ") + " |\n") - var sep []string - for range result.Columns { - sep = append(sep, "---") - } - sb.WriteString("| " + strings.Join(sep, " | ") + " |\n") - - for _, row := range result.Rows { - var vals []string - for _, col := range result.Columns { - val := row[col] - if val == nil { - vals = append(vals, "NULL") - } else { - cellStr := fmt.Sprintf("%v", val) - cellStr = strings.ReplaceAll(cellStr, "\n", " ") - cellStr = strings.ReplaceAll(cellStr, "|", "\\|") - vals = append(vals, cellStr) - } - } - sb.WriteString("| " + strings.Join(vals, " | ") + " |\n") - } - if _, err := f.WriteString(sb.String()); err != nil { - return "", errors.New(errors.CodeInternal, "failed to write Markdown export", map[string]any{"err": err.Error()}) - } - case FormatCSV: w := csv.NewWriter(f) if err := w.Write(result.Columns); err != nil { @@ -119,6 +131,24 @@ func ExportQueryResult(result *db.QueryResult, format ExportFormat, filePath str } } - absPath, _ := filepath.Abs(filePath) + absPath, _ := filepath.Abs(expandedPath) + return absPath, nil +} + +// ExportReport writes the Markdown/text report content to the target file path. +func ExportReport(content string, filePath string) (string, *errors.XError) { + expandedPath, xe := resolveAndPrepareTarget(filePath, "report.md") + if xe != nil { + return "", xe + } + + if err := os.WriteFile(expandedPath, []byte(content), 0644); err != nil { + return "", errors.New(errors.CodeInternal, "failed to write report file", map[string]any{ + "path": expandedPath, + "err": err.Error(), + }) + } + + absPath, _ := filepath.Abs(expandedPath) return absPath, nil } diff --git a/internal/export/exporter_test.go b/internal/export/exporter_test.go index 1089d99..25d8c89 100644 --- a/internal/export/exporter_test.go +++ b/internal/export/exporter_test.go @@ -9,7 +9,7 @@ import ( "github.com/zx06/xsql/internal/db" ) -func TestExportQueryResult_CSV_JSON_MD(t *testing.T) { +func TestExportQueryResult_CSV_JSON(t *testing.T) { tempDir, err := os.MkdirTemp("", "xsql_export_test") if err != nil { t.Fatalf("failed to create temp dir: %v", err) @@ -46,16 +46,10 @@ func TestExportQueryResult_CSV_JSON_MD(t *testing.T) { t.Fatalf("unexpected JSON content: %s", string(content)) } - // 3. Markdown - mdPath := filepath.Join(tempDir, "sub", "test.md") - res.Rows[1]["status"] = "multiline\ntext|pipe" - absPath, xe = ExportQueryResult(res, FormatMarkdown, mdPath) - if xe != nil { - t.Fatalf("Markdown export failed: %v", xe) - } - content, _ = os.ReadFile(absPath) - if !strings.Contains(string(content), "| username |") || !strings.Contains(string(content), "text\\|pipe") { - t.Fatalf("unexpected Markdown content: %s", string(content)) + // 3. Markdown is no longer supported in export_data + mdPath := filepath.Join(tempDir, "test.md") + if _, xe = ExportQueryResult(res, ExportFormat("markdown"), mdPath); xe == nil { + t.Fatal("expected error for markdown format in ExportQueryResult") } // 4. Nil result & empty filePath fallback @@ -79,3 +73,73 @@ func TestExportQueryResult_CSV_JSON_MD(t *testing.T) { t.Fatalf("unsupported format should not create a file, stat err=%v", err) } } + +func TestExpandPath(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("failed to get home dir: %v", err) + } + + pEmpty, err := ExpandPath("") + if err != nil || pEmpty != "" { + t.Fatalf("expected empty, got %q, err %v", pEmpty, err) + } + + pHomeOnly, err := ExpandPath("~") + if err != nil || pHomeOnly != home { + t.Fatalf("expected %s, got %s", home, pHomeOnly) + } + + p, err := ExpandPath("~/Downloads/report.md") + if err != nil { + t.Fatalf("ExpandPath failed: %v", err) + } + expected := filepath.Join(home, "Downloads/report.md") + if p != expected { + t.Fatalf("expected %s, got %s", expected, p) + } + + pWin, err := ExpandPath("~\\Downloads\\report.md") + if err != nil { + t.Fatalf("ExpandPath failed: %v", err) + } + expectedWin := filepath.Join(home, "Downloads\\report.md") + if pWin != expectedWin { + t.Fatalf("expected %s, got %s", expectedWin, pWin) + } + + p2, _ := ExpandPath("/absolute/path.txt") + if p2 != "/absolute/path.txt" { + t.Fatalf("expected /absolute/path.txt, got %s", p2) + } +} + +func TestExportReport(t *testing.T) { + tempDir, err := os.MkdirTemp("", "xsql_report_test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + reportPath := filepath.Join(tempDir, "sub", "summary.md") + content := "# Daily Report\n\n- Total users: 100\n" + absPath, xe := ExportReport(content, reportPath) + if xe != nil { + t.Fatalf("ExportReport failed: %v", xe) + } + + readBack, err := os.ReadFile(absPath) + if err != nil { + t.Fatalf("failed to read report: %v", err) + } + if string(readBack) != content { + t.Fatalf("unexpected content: %s", string(readBack)) + } + + // Test default report name when path is empty + absDef, xe := ExportReport("# Test", "") + if xe != nil { + t.Fatalf("ExportReport with empty path failed: %v", xe) + } + _ = os.Remove(absDef) +} diff --git a/internal/tui/model.go b/internal/tui/model.go index b66f23f..9323033 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -77,6 +77,8 @@ type ToolCallItem struct { } type PendingExport struct { + IsReport bool + Content string DatasetID string Format string FilePath string @@ -102,6 +104,8 @@ type Model struct { pendingExport *PendingExport jsRetryCount int maxJSRetries int + aiRetryCount int + maxAIRetries int lastCtrlCTime time.Time confirmOption int // 0: Confirm/Execute, 1: Adjust Prompt, 2: Cancel/Deny @@ -171,6 +175,8 @@ func NewModel(_ config.Options, resolved config.Resolved, aiService *ai.Service, chatHistory: []ai.ChatMessage{}, jsRetryCount: 0, maxJSRetries: 3, + aiRetryCount: 0, + maxAIRetries: 2, confirmOption: 0, tableStates: []TableState{}, toolCalls: []ToolCallItem{}, @@ -193,6 +199,7 @@ func (m Model) Init() tea.Cmd { return tea.Batch( m.spinner.Tick, m.loadSchemaCmd(), + WatchThemeChangesCmd(m.isDark), ) } @@ -325,6 +332,8 @@ func (m *Model) renderToolCall(idx int) { detailCode = HighlightSQL(tc.Detail) case "execute_javascript": detailCode = HighlightJS(tc.Detail) + case "export_report": + detailCode = RenderMarkdownWithTheme(tc.Detail, m.width-8, m.isDark) } detail := ToolDetailStyle.Render(detailCode) @@ -487,6 +496,7 @@ func (m *Model) executeNextPendingAction() (tea.Model, tea.Cmd) { m.focusToolCall(toolIdx) m.pendingExport = &PendingExport{ + IsReport: false, DatasetID: act.DatasetID, Format: act.Format, FilePath: act.FilePath, @@ -497,6 +507,40 @@ func (m *Model) executeNextPendingAction() (tea.Model, tea.Cmd) { m.viewport.SetContent(strings.Join(m.messages, "\n\n")) m.viewport.GotoBottom() return *m, nil + + case ai.TypeReport: + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "assistant", + Content: fmt.Sprintf("Call tool 'export_report': filepath=%s, explanation=%s", act.FilePath, act.Explanation), + }) + + lineCount := len(strings.Split(act.Content, "\n")) + tc := ToolCallItem{ + ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), + Name: "export_report", + Summary: fmt.Sprintf("Export Markdown report (%d lines) to %s [Pending User Confirmation]", lineCount, act.FilePath), + Detail: act.Content, + Result: "⏳ Pending Human Confirmation", + TableStateIndex: -1, + MsgIndex: len(m.messages), + IsExpanded: false, + } + m.messages = append(m.messages, "") + m.toolCalls = append(m.toolCalls, tc) + toolIdx := len(m.toolCalls) - 1 + m.focusToolCall(toolIdx) + + m.pendingExport = &PendingExport{ + IsReport: true, + Content: act.Content, + FilePath: act.FilePath, + ToolIdx: toolIdx, + } + m.confirmOption = 0 + m.state = StateExportReady + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + return *m, nil } return *m, nil @@ -513,6 +557,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.Width = max(20, msg.Width-4) m.viewport.Height = max(5, msg.Height-15) + case ThemeChangedMsg: + if m.isDark != msg.IsDark { + m.isDark = msg.IsDark + SetThemeDark(m.isDark) + for i := range m.toolCalls { + m.renderToolCall(i) + } + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + } + return m, WatchThemeChangesCmd(m.isDark) + case schemaLoadedMsg: if msg.err != nil { m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("Failed to load schema for profile '%s': %v", m.profileName, msg.err))) @@ -543,13 +598,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case aiResponseMsg: if msg.err != nil { - m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("AI Error: %v", msg.err))) + m.aiRetryCount++ + if m.aiRetryCount <= m.maxAIRetries { + retryWarn := ErrorMsgStyle.Render(fmt.Sprintf("⚠️ AI Response/Tool Call Failed (Attempt %d/%d): %v", m.aiRetryCount, m.maxAIRetries, msg.err.Message)) + m.messages = append(m.messages, retryWarn) + + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "user", + Content: fmt.Sprintf("Your tool call failed with error:\n%s\nPlease carefully fix the tool arguments: ensure strict JSON compliance, escape all newlines as '\\n' and quotes as '\\\"' in string literals, and call the tool again.", msg.err.Message), + }) + + m.pendingActions = nil + m.state = StateThinking + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + m.viewport.GotoBottom() + return m, m.runAgentStepCmd() + } + + m.messages = append(m.messages, ErrorMsgStyle.Render(fmt.Sprintf("AI Error (after %d retries): %v", m.maxAIRetries, msg.err))) + m.aiRetryCount = 0 + m.pendingActions = nil m.state = StateIdle m.viewport.SetContent(strings.Join(m.messages, "\n\n")) m.viewport.GotoBottom() return m, nil } + m.aiRetryCount = 0 m.explanation = msg.response.Explanation actions := msg.response.Actions @@ -561,6 +636,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { actions = []ai.ToolAction{{Type: ai.TypeSQL, SQL: msg.response.SQL, Explanation: msg.response.Explanation}} } else if msg.response.Type == ai.TypeExport && msg.response.DatasetID != "" { actions = []ai.ToolAction{{Type: ai.TypeExport, DatasetID: msg.response.DatasetID, Format: msg.response.Format, FilePath: msg.response.FilePath, Explanation: msg.response.Explanation}} + } else if msg.response.Type == ai.TypeReport && msg.response.Content != "" { + actions = []ai.ToolAction{{Type: ai.TypeReport, Content: msg.response.Content, FilePath: msg.response.FilePath, Explanation: msg.response.Explanation}} } } @@ -690,36 +767,43 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch triggerOpt { case 0: - // Option 1: Confirm & Export - datasetRes, exists := m.sessionStore.Get(m.pendingExport.DatasetID) - if !exists || datasetRes == nil { - m.toolCalls[m.pendingExport.ToolIdx].Result = fmt.Sprintf("❌ Export Failed: Dataset '%s' not found", m.pendingExport.DatasetID) + // Option 1: Confirm & Export / Save + var outPath string + var xe *errors.XError + toolName := "export_data" + actionDesc := fmt.Sprintf("Exported dataset '%s'", m.pendingExport.DatasetID) + + if m.pendingExport.IsReport { + toolName = "export_report" + actionDesc = "Saved Markdown report" + outPath, xe = export.ExportReport(m.pendingExport.Content, m.pendingExport.FilePath) + } else { + datasetRes, exists := m.sessionStore.Get(m.pendingExport.DatasetID) + if !exists || datasetRes == nil { + xe = errors.New(errors.CodeCfgInvalid, fmt.Sprintf("dataset '%s' not found in session catalog", m.pendingExport.DatasetID), nil) + } else { + outPath, xe = export.ExportQueryResult(datasetRes, export.ExportFormat(m.pendingExport.Format), m.pendingExport.FilePath) + } + } + + if xe != nil { + m.toolCalls[m.pendingExport.ToolIdx].Result = fmt.Sprintf("❌ Export Failed: %v", xe.Message) m.renderToolCall(m.pendingExport.ToolIdx) m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "user", - Content: fmt.Sprintf("Tool 'export_data' failed: dataset '%s' not found in session catalog.", m.pendingExport.DatasetID), + Content: fmt.Sprintf("Tool '%s' failed: %v", toolName, xe.Message), }) } else { - outPath, xe := export.ExportQueryResult(datasetRes, export.ExportFormat(m.pendingExport.Format), m.pendingExport.FilePath) - if xe != nil { - m.toolCalls[m.pendingExport.ToolIdx].Result = fmt.Sprintf("❌ Export Failed: %v", xe.Message) - m.renderToolCall(m.pendingExport.ToolIdx) - m.chatHistory = append(m.chatHistory, ai.ChatMessage{ - Role: "user", - Content: fmt.Sprintf("Tool 'export_data' failed to write file: %v", xe.Message), - }) - } else { - m.toolCalls[m.pendingExport.ToolIdx].Result = fmt.Sprintf("✓ Exported dataset '%s' to '%s' (%s)", m.pendingExport.DatasetID, outPath, strings.ToUpper(m.pendingExport.Format)) - m.renderToolCall(m.pendingExport.ToolIdx) + m.toolCalls[m.pendingExport.ToolIdx].Result = fmt.Sprintf("✓ %s to '%s'", actionDesc, outPath) + m.renderToolCall(m.pendingExport.ToolIdx) - statusLine := SuccessBadgeStyle.Render("✓ File Exported Success") + " " + MetricsStyle.Render(fmt.Sprintf("Exported dataset '%s' to '%s'", m.pendingExport.DatasetID, outPath)) - m.messages = append(m.messages, statusLine) + statusLine := SuccessBadgeStyle.Render("✓ Export Success") + " " + MetricsStyle.Render(fmt.Sprintf("%s to '%s'", actionDesc, outPath)) + m.messages = append(m.messages, statusLine) - m.chatHistory = append(m.chatHistory, ai.ChatMessage{ - Role: "user", - Content: fmt.Sprintf("Tool 'export_data' executed successfully. Exported dataset '%s' to local file '%s'.", m.pendingExport.DatasetID, outPath), - }) - } + m.chatHistory = append(m.chatHistory, ai.ChatMessage{ + Role: "user", + Content: fmt.Sprintf("Tool '%s' executed successfully. %s to local file '%s'.", toolName, actionDesc, outPath), + }) } m.pendingExport = nil return m.executeNextPendingAction() @@ -734,12 +818,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case 2: // Option 3: Deny / Cancel Export + toolName := "export_data" + if m.pendingExport.IsReport { + toolName = "export_report" + } m.toolCalls[m.pendingExport.ToolIdx].Result = "🚫 Export Denied by User" m.renderToolCall(m.pendingExport.ToolIdx) m.chatHistory = append(m.chatHistory, ai.ChatMessage{ Role: "user", - Content: "Tool 'export_data' was denied by user.", + Content: fmt.Sprintf("Tool '%s' was denied by user.", toolName), }) m.pendingExport = nil m.pendingActions = nil @@ -933,6 +1021,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.LineDown(6) return m, nil + case tea.KeyCtrlT: + m.isDark = !m.isDark + SetThemeDark(m.isDark) + for i := range m.toolCalls { + m.renderToolCall(i) + } + m.viewport.SetContent(strings.Join(m.messages, "\n\n")) + return m, nil + case tea.KeyUp: m.viewport.LineUp(1) return m, nil @@ -1035,11 +1132,18 @@ func (m Model) View() string { sb.WriteString(m.spinner.View() + " Executing SQL query...\n") case StateExportReady: if m.pendingExport != nil { + title := "✨ File Export Approval Required" + options := []string{"Confirm & Export File", "Adjust Prompt / Change Options", "Deny & Cancel Export"} exportInfo := fmt.Sprintf("Dataset: %s | FilePath: %s | Format: %s", m.pendingExport.DatasetID, m.pendingExport.FilePath, strings.ToUpper(m.pendingExport.Format)) + if m.pendingExport.IsReport { + title = "✨ Markdown Report Save Approval Required" + options = []string{"Confirm & Save Report", "Adjust Prompt / Change Options", "Deny & Cancel Save"} + exportInfo = fmt.Sprintf("Report File: %s | Content: %d lines", m.pendingExport.FilePath, len(strings.Split(m.pendingExport.Content, "\n"))) + } card := renderActionOptionsCard( - "✨ File Export Approval Required", + title, SQLCodeStyle.Render(exportInfo), - []string{"Confirm & Export File", "Adjust Prompt / Change Options", "Deny & Cancel Export"}, + options, m.confirmOption, m.width, ) @@ -1099,12 +1203,17 @@ func (m Model) View() string { if len(m.profileList) > 1 { profileBadge = fmt.Sprintf(" (%s)", m.profileName) } + themeHint := "Dark" + if !m.isDark { + themeHint = "Light" + } keybindings = renderKeybindingBadges([][2]string{ {"Enter", "Send"}, {"Alt+Enter", "Newline"}, {"Ctrl+P", "Profile" + profileBadge}, {"Tab", "Focus Tool" + toolNavHint}, {"Ctrl+O", "Tools (" + toolFoldState + ")"}, + {"Ctrl+T", "Theme (" + themeHint + ")"}, {"←/→", "Cols"}, {"PgUp/PgDn", "Rows"}, {"Ctrl+E", "Expand Table"}, diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 8160fcb..50060d2 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1,6 +1,7 @@ package tui import ( + "os" "path/filepath" "strings" "testing" @@ -434,6 +435,85 @@ func TestTUI_Model_ExportFlow(t *testing.T) { if cmd == nil { t.Fatal("expected runAgentStepCmd after export feedback") } + + // Test TypeReport flow - Deny report export + updated, _ = m.Update(aiResponseMsg{ + response: &ai.AIResponse{ + Type: ai.TypeReport, + Content: "# Sales Report", + FilePath: "report.md", + Explanation: "Export markdown report", + }, + }) + m = updated.(Model) + if m.state != StateExportReady { + t.Fatalf("expected StateExportReady, got %v", m.state) + } + updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}}) + m = updated.(Model) + if m.state != StateThinking { + t.Fatalf("expected StateThinking after denying report save, got %v", m.state) + } + + // Test TypeReport flow - Confirm save + tempDir := t.TempDir() + reportPath := filepath.Join(tempDir, "report.md") + updated, _ = m.Update(aiResponseMsg{ + response: &ai.AIResponse{ + Type: ai.TypeReport, + Content: "# Sales Report\n\n- Q1: 100", + FilePath: reportPath, + Explanation: "Export markdown report", + }, + }) + m = updated.(Model) + if m.state != StateExportReady { + t.Fatalf("expected StateExportReady for TypeReport, got %v", m.state) + } + if m.pendingExport == nil || !m.pendingExport.IsReport { + t.Fatal("expected pendingExport to be report") + } + + // Confirm report save + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + content, err := os.ReadFile(reportPath) + if err != nil || !strings.Contains(string(content), "Sales Report") { + t.Fatalf("expected report file to be written, err: %v", err) + } + + // Test export_data confirm with valid dataset + res := &db.QueryResult{ + Columns: []string{"id", "val"}, + Rows: []map[string]any{{"id": 1, "val": "abc"}}, + } + datasetID := m.sessionStore.Save("SELECT 1", res) + csvPath := filepath.Join(tempDir, "data.csv") + updated, _ = m.Update(aiResponseMsg{ + response: &ai.AIResponse{ + Type: ai.TypeExport, + DatasetID: datasetID, + Format: "csv", + FilePath: csvPath, + }, + }) + m = updated.(Model) + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + if _, err := os.Stat(csvPath); err != nil { + t.Fatalf("expected data csv to be exported, err: %v", err) + } + + // Test tool render for expanded export_report, execute_sql, execute_javascript + m.toolCalls = []ToolCallItem{ + {Name: "export_report", Detail: "# Title", IsExpanded: true, MsgIndex: 0}, + {Name: "execute_javascript", Detail: "var a = 1;", IsExpanded: true, MsgIndex: 0}, + {Name: "execute_sql", Detail: "SELECT 1;", IsExpanded: true, MsgIndex: 0}, + } + m.messages = []string{""} + m.renderToolCall(0) + m.renderToolCall(1) + m.renderToolCall(2) } func TestTUI_Model_FullCoverage(t *testing.T) { @@ -567,13 +647,35 @@ func TestTUI_Model_ViewAndAllStatesCoverage(t *testing.T) { }) m = updated.(Model) - // 4. Test aiResponseMsg with error & TypeText - updated, _ = m.Update(aiResponseMsg{ + // 4. Test aiResponseMsg with error & retry behavior + // Attempt 1: Should trigger retry and set state to StateThinking + updated, cmd := m.Update(aiResponseMsg{ err: errors.New("XSQL_AI_API_ERROR", "api failed", nil), }) m = updated.(Model) + if m.state != StateThinking { + t.Fatalf("expected StateThinking on first AI error retry, got %v", m.state) + } + if cmd == nil { + t.Fatal("expected non-nil cmd to retry step") + } + + // Attempt 2: Should still retry (StateThinking) + updated, _ = m.Update(aiResponseMsg{ + err: errors.New("XSQL_AI_API_ERROR", "api failed again", nil), + }) + m = updated.(Model) + if m.state != StateThinking { + t.Fatalf("expected StateThinking on second AI error retry, got %v", m.state) + } + + // Attempt 3: Exceeds maxAIRetries (2), should transition to StateIdle + updated, _ = m.Update(aiResponseMsg{ + err: errors.New("XSQL_AI_API_ERROR", "api failed third time", nil), + }) + m = updated.(Model) if m.state != StateIdle { - t.Fatalf("expected StateIdle after AI error, got %v", m.state) + t.Fatalf("expected StateIdle after exhausting AI retries, got %v", m.state) } updated, _ = m.Update(aiResponseMsg{ @@ -729,3 +831,107 @@ func TestTUI_Model_ThemeAutoDetection(t *testing.T) { t.Fatal("expected isDark == true when COLORFGBG='15;0'") } } + +func TestTUI_Model_ActionFallback(t *testing.T) { + resolved := config.Resolved{ProfileName: "dev", Profile: config.Profile{DB: "mysql"}} + aiService := ai.NewService(config.AIConfig{}, nil) + + // 1. Single TypeSQL fallback + m := NewModel(config.Options{}, resolved, aiService, "", false) + updated, _ := m.Update(aiResponseMsg{ + response: &ai.AIResponse{ + Type: ai.TypeSQL, + SQL: "SELECT 1;", + Explanation: "sql query", + }, + }) + m = updated.(Model) + if m.state != StateSQLReady { + t.Fatalf("expected StateSQLReady on fallback TypeSQL, got %v", m.state) + } + + // 2. Single TypeJS fallback + m = NewModel(config.Options{}, resolved, aiService, "", false) + updated, _ = m.Update(aiResponseMsg{ + response: &ai.AIResponse{ + Type: ai.TypeJS, + JSCode: "var x = 1;", + Explanation: "js code", + }, + }) + m = updated.(Model) + if m.state != StateThinking { + t.Fatalf("expected StateThinking after JS fallback, got %v", m.state) + } + + // 3. Single TypeExport fallback + m = NewModel(config.Options{}, resolved, aiService, "", false) + updated, _ = m.Update(aiResponseMsg{ + response: &ai.AIResponse{ + Type: ai.TypeExport, + DatasetID: "res1", + Format: "csv", + FilePath: "data.csv", + Explanation: "export", + }, + }) + m = updated.(Model) + if m.state != StateExportReady { + t.Fatalf("expected StateExportReady on fallback TypeExport, got %v", m.state) + } + + // 4. Single TypeReport fallback + m = NewModel(config.Options{}, resolved, aiService, "", false) + updated, _ = m.Update(aiResponseMsg{ + response: &ai.AIResponse{ + Type: ai.TypeReport, + Content: "# Report", + FilePath: "report.md", + Explanation: "report", + }, + }) + m = updated.(Model) + if m.state != StateExportReady { + t.Fatalf("expected StateExportReady on fallback TypeReport, got %v", m.state) + } +} + +func TestTUI_Model_CtrlTToggle(t *testing.T) { + resolved := config.Resolved{ProfileName: "dev", Profile: config.Profile{DB: "mysql"}} + m := NewModel(config.Options{}, resolved, nil, "", false) + initialDark := m.isDark + + // Toggle theme via Ctrl+T + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlT}) + m = updated.(Model) + if m.isDark == initialDark { + t.Fatalf("expected isDark to toggle from %v to %v", initialDark, !initialDark) + } + + // Toggle back + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlT}) + m = updated.(Model) + if m.isDark != initialDark { + t.Fatalf("expected isDark to toggle back to %v", initialDark) + } +} + +func TestTUI_Model_ThemeChangedMsg(t *testing.T) { + resolved := config.Resolved{ProfileName: "dev", Profile: config.Profile{DB: "mysql"}} + m := NewModel(config.Options{}, resolved, nil, "", false) + m.toolCalls = []ToolCallItem{ + {Name: "export_report", Detail: "# Title", IsExpanded: true, MsgIndex: 0}, + } + m.messages = []string{""} + + // Dispatch ThemeChangedMsg with different theme + updated, _ := m.Update(ThemeChangedMsg{IsDark: !m.isDark}) + m = updated.(Model) + if m.isDark == CurrentThemeIsDark && m.isDark == false { + // verified + } + + // Dispatch ThemeChangedMsg with same theme + updated, _ = m.Update(ThemeChangedMsg{IsDark: m.isDark}) + m = updated.(Model) +} diff --git a/internal/tui/render.go b/internal/tui/render.go index d9ad2b5..fa6b5e2 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -1,12 +1,17 @@ package tui import ( + "context" "os" + "os/exec" + "runtime" "strconv" "strings" + "time" "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/lexers" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" "github.com/charmbracelet/glamour/ansi" "github.com/charmbracelet/lipgloss" @@ -39,10 +44,8 @@ func uintPtr(u uint) *uint { // and ensures crisp contrast for tables, headers, lists, code, and text on dark terminal backgrounds. var XSQLDarkMarkdownStyle = ansi.StyleConfig{ Document: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr("#F8FAFC"), // Crisp bright text - }, - Margin: uintPtr(0), + StylePrimitive: ansi.StylePrimitive{}, + Margin: uintPtr(0), }, BlockQuote: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ @@ -104,9 +107,7 @@ var XSQLDarkMarkdownStyle = ansi.StyleConfig{ Bold: boolPtr(false), }, }, - Text: ansi.StylePrimitive{ - Color: stringPtr("#F1F5F9"), - }, + Text: ansi.StylePrimitive{}, Strikethrough: ansi.StylePrimitive{ CrossedOut: boolPtr(true), }, @@ -266,10 +267,8 @@ var XSQLDarkMarkdownStyle = ansi.StyleConfig{ // It uses dark readable text on light backgrounds with vibrant Indigo/Sky accents. var XSQLLightMarkdownStyle = ansi.StyleConfig{ Document: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr("#0F172A"), // Crisp deep slate - }, - Margin: uintPtr(0), + StylePrimitive: ansi.StylePrimitive{}, + Margin: uintPtr(0), }, BlockQuote: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ @@ -331,9 +330,7 @@ var XSQLLightMarkdownStyle = ansi.StyleConfig{ Bold: boolPtr(false), }, }, - Text: ansi.StylePrimitive{ - Color: stringPtr("#0F172A"), - }, + Text: ansi.StylePrimitive{}, Strikethrough: ansi.StylePrimitive{ CrossedOut: boolPtr(true), }, @@ -518,7 +515,23 @@ func DetectDarkBackground() bool { } } - // 3. Default to true (Dark mode) for developer terminals + // 3. macOS Native Appearance Detection (AppleInterfaceStyle) + if runtime.GOOS == "darwin" { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + cmd := exec.CommandContext(ctx, "/usr/bin/defaults", "read", "-g", "AppleInterfaceStyle") + out, err := cmd.Output() + if err != nil { + // On macOS, absence of AppleInterfaceStyle key indicates Light Appearance + return false + } + if strings.Contains(strings.ToLower(string(out)), "dark") { + return true + } + return false + } + + // 4. Default fallback to true (Dark mode) return true } @@ -612,3 +625,23 @@ func HighlightSQL(sqlStr string) string { func HighlightJS(jsStr string) string { return HighlightCode(jsStr, "javascript") } + +// ThemeChangedMsg is dispatched when the terminal/system background theme changes. +type ThemeChangedMsg struct { + IsDark bool +} + +// WatchThemeChangesCmd watches for appearance changes in the background without blocking the UI. +func WatchThemeChangesCmd(currentIsDark bool) tea.Cmd { + return func() tea.Msg { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + for range ticker.C { + newDark := DetectDarkBackground() + if newDark != currentIsDark { + return ThemeChangedMsg{IsDark: newDark} + } + } + return nil + } +} diff --git a/webui/src/lib/components/ConfigModal.svelte b/webui/src/lib/components/ConfigModal.svelte index 53018f6..473e0ff 100644 --- a/webui/src/lib/components/ConfigModal.svelte +++ b/webui/src/lib/components/ConfigModal.svelte @@ -48,7 +48,7 @@ base_url: 'https://api.openai.com/v1', api_key: '', model: 'gpt-4o', - max_tokens: 2048, + max_tokens: 8192, allow_plaintext: true }); @@ -133,7 +133,7 @@ base_url: ai?.base_url || 'https://api.openai.com/v1', api_key: ai?.api_key || '', model: ai?.model || 'gpt-4o', - max_tokens: Number(ai?.max_tokens || 2048), + max_tokens: Number(ai?.max_tokens || 8192), allow_plaintext: ai?.allow_plaintext !== undefined ? Boolean(ai.allow_plaintext) : true }; }