diff --git a/.agents/skills/xsql/SKILL.md b/.agents/skills/xsql/SKILL.md index edcf7cd9..d9e812c1 100644 --- a/.agents/skills/xsql/SKILL.md +++ b/.agents/skills/xsql/SKILL.md @@ -23,7 +23,7 @@ description: > - `stdout` is data; `stderr` is logs. Never parse stderr as result data. - Validate responses by checking `ok`, `schema_version`, and `error.code` — not by string matching. - On `ok: false`, read `error.details.hint`, `error.details.category`, and `error.details.retryable` before retrying (see `docs/error-contract.md`). Use `hint` as the next action; respect `retryable: false` for SQL/schema/permission fixes instead of blind retries. -- Assume read-only mode. Writes require both `unsafe_allow_write: true` in the selected profile and a runtime write opt-in: `--unsafe-allow-write` on `xsql query` / `xsql ai`, or `--unsafe-allow-write` when starting `xsql mcp server` (or `XSQL_MCP_UNSAFE_ALLOW_WRITE=true`). Only enable both factors when the user has explicitly requested writes. +- Assume read-only mode. Writes require both `unsafe_allow_write: true` in the selected profile and a runtime write opt-in: `--unsafe-allow-write` on `xsql query`, or `--unsafe-allow-write` when starting `xsql mcp server` (or `XSQL_MCP_UNSAFE_ALLOW_WRITE=true`). Only enable both factors when the user has explicitly requested writes. - Never leak secrets, full DSNs, passwords, or private keys in output or summaries. - `--ssh-skip-known-hosts-check` is a risky last resort — call out the security tradeoff if it's needed. diff --git a/AGENTS.md b/AGENTS.md index 4be00926..fe0b444a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ ## 3. 架构与目录约束(不要越界) 推荐结构(实现时请遵循): - `cmd/xsql`:CLI 入口(只做参数解析、调用 app、输出) -- `internal/app`:应用编排(核心入口,供未来 MCP/TUI/Web 复用) +- `internal/app`:应用编排(核心入口,供 MCP/Web 等复用) - `internal/config`:配置加载/合并/校验 + profile - `internal/secret`:keyring/加密/明文兼容 - `internal/db`:driver registry + 执行引擎 @@ -71,7 +71,7 @@ - 目标数据库:MySQL + PostgreSQL。 - 可扩展性:通过 driver registry 扩展新 DB。 - **默认只读**:防止误操作,默认启用双重只读保护(SQL 静态分析 + 数据库事务级只读)。 -- **CLI 写入双重授权**:profile 必须配置 `unsafe_allow_write: true`,且当前 `query` / `ai` 命令必须同时携带 `--unsafe-allow-write`;任一条件缺失都保持只读。 +- **CLI 写入双重授权**:profile 必须配置 `unsafe_allow_write: true`,且当前 `query` 命令必须同时携带 `--unsafe-allow-write`;任一条件缺失都保持只读。 - **AI 调用标记**:Codex 发起的 xsql CLI 调用必须携带 `--attr source=codex-cli`,建议同时追加 `agent`、`env`、`team`、`task` 等 attr;其他 AI 客户端应使用自己的稳定 source 值,且不得覆盖 source。 - 只读拦截必须返回明确错误码(退出码=4)。 diff --git a/README.md b/README.md index 10c3aba2..7bfa014f 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,6 @@ Note: Read-only mode by default. Writes require both profile `unsafe_allow_write | Command | Description | |---------|-------------| -| `xsql ai [PROMPT]` | Start interactive AI assistant mode (TUI) | | `xsql query ` | Execute SQL queries (read-only by default; 1000-row default cap, `--max-rows`) | | `xsql schema dump` | Export database schema (tables, columns, indexes, foreign keys) | | `xsql profile list` | List all profiles | diff --git a/README_zh.md b/README_zh.md index b85e1490..8fad817e 100644 --- a/README_zh.md +++ b/README_zh.md @@ -173,7 +173,6 @@ xsql query "" -p -f json --attr source=codex-cli --attr agent=cod | 命令 | 说明 | |------|------| -| `xsql ai [PROMPT]` | 启动全屏交互式 AI 终端模式(TUI) | | `xsql query ` | 执行 SQL 查询(默认只读;默认最多 1000 行,`--max-rows` 可调) | | `xsql schema dump` | 导出数据库结构(表、列、索引、外键) | | `xsql profile list` | 列出所有 profile | diff --git a/cmd/xsql/ai.go b/cmd/xsql/ai.go deleted file mode 100644 index 54a3a387..00000000 --- a/cmd/xsql/ai.go +++ /dev/null @@ -1,98 +0,0 @@ -package main - -import ( - "fmt" - "os" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/spf13/cobra" - - "github.com/zx06/xsql/internal/ai" - "github.com/zx06/xsql/internal/config" - "github.com/zx06/xsql/internal/errors" - "github.com/zx06/xsql/internal/secret" - "github.com/zx06/xsql/internal/tui" -) - -var newAIProgramFunc = func(model tea.Model) *tea.Program { - return tea.NewProgram(model, tea.WithAltScreen()) -} - -func NewAICommand() *cobra.Command { - var modelStr string - var baseURLStr string - var apiKeyStr string - var allowPlaintext bool - var unsafeAllowWrite bool - - cmd := &cobra.Command{ - Use: "ai [PROMPT]", - Short: "Interactive AI assistant mode (TUI)", - RunE: func(cmd *cobra.Command, args []string) error { - prompt := "" - if len(args) > 0 { - prompt = strings.Join(args, " ") - } - - opts := config.Options{ - ConfigPath: GlobalConfig.ConfigStr, - CLIProfile: GlobalConfig.ProfileStr, - CLIProfileSet: cmd.Flags().Changed("profile"), - CLIAIModel: modelStr, - CLIAIModelSet: cmd.Flags().Changed("model"), - CLIAIBaseURL: baseURLStr, - CLIAIBaseURLSet: cmd.Flags().Changed("base-url"), - CLIAIAPIKey: apiKeyStr, - CLIAIAPIKeySet: cmd.Flags().Changed("api-key"), - } - - resolved := GlobalConfig.Resolved - if opts.CLIAIModelSet && opts.CLIAIModel != "" { - resolved.AI.Model = opts.CLIAIModel - } - if opts.CLIAIBaseURLSet && opts.CLIAIBaseURL != "" { - resolved.AI.BaseURL = opts.CLIAIBaseURL - } - apiKeyFromCLI := opts.CLIAIAPIKeySet && opts.CLIAIAPIKey != "" - if apiKeyFromCLI { - resolved.AI.APIKey = opts.CLIAIAPIKey - } - if resolved.ProfileName == "" || resolved.Profile.DB == "" { - return errors.New(errors.CodeCfgInvalid, "no profile specified and no 'default' profile found in config", nil) - } - - // Runtime CLI/ENV values are explicit plaintext inputs. Plaintext from - // the config file requires an explicit config or CLI opt-in. - apiKey := resolved.AI.APIKey - if apiKey != "" { - allowAPIKeyPlaintext := allowPlaintext || resolved.AI.AllowPlaintext || apiKeyFromCLI || os.Getenv("XSQL_AI_API_KEY") != "" - resolvedKey, xe := secret.Resolve(apiKey, secret.Options{AllowPlaintext: allowAPIKeyPlaintext}) - if xe != nil { - return xe - } - apiKey = resolvedKey - } - resolved.AI.APIKey = apiKey - opts.CLIAIModel = resolved.AI.Model - - aiClient := ai.NewClient(resolved.AI, nil) - aiService := ai.NewService(resolved.AI, aiClient) - - m := tui.NewModel(opts, resolved, aiService, prompt, unsafeAllowWrite) - p := newAIProgramFunc(m) - if _, err := p.Run(); err != nil { - return fmt.Errorf("error running TUI: %w", err) - } - return nil - }, - } - - cmd.Flags().StringVar(&modelStr, "model", "", "AI model name (default: gpt-4o)") - cmd.Flags().StringVar(&baseURLStr, "base-url", "", "AI service base URL") - cmd.Flags().StringVar(&apiKeyStr, "api-key", "", "AI service API key") - cmd.Flags().BoolVar(&allowPlaintext, "allow-plaintext", false, "Allow plaintext AI API key in config") - cmd.Flags().BoolVar(&unsafeAllowWrite, "unsafe-allow-write", false, "Allow writes when the profile also sets unsafe_allow_write: true") - - return cmd -} diff --git a/cmd/xsql/ai_test.go b/cmd/xsql/ai_test.go deleted file mode 100644 index 7bd77421..00000000 --- a/cmd/xsql/ai_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package main - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" -) - -func stubAICommandProgram(t *testing.T) { - t.Helper() - - oldGlobalConfig := GlobalConfig - GlobalConfig = &Config{} - t.Cleanup(func() { GlobalConfig = oldGlobalConfig }) - - oldNewAIProgram := newAIProgramFunc - t.Cleanup(func() { newAIProgramFunc = oldNewAIProgram }) - - newAIProgramFunc = func(model tea.Model) *tea.Program { - p := tea.NewProgram(model, tea.WithInput(strings.NewReader("")), tea.WithOutput(os.Stderr), tea.WithoutRenderer()) - go p.Quit() - return p - } -} - -func TestCmdXSQL_AICommand(t *testing.T) { - stubAICommandProgram(t) - - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "xsql.yaml") - cfgContent := ` -profiles: - dev: - db: mysql - host: 127.0.0.1 - port: 3306 - user: root - database: testdb -` - if err := os.WriteFile(cfgPath, []byte(cfgContent), 0600); err != nil { - t.Fatalf("failed to write temp config: %v", err) - } - - root := NewRootCommand() - root.AddCommand(NewAICommand()) - root.SetArgs([]string{"ai", "--config", cfgPath, "--profile", "dev", "Show top 10 users"}) - - err := root.Execute() - if err != nil { - t.Fatalf("expected xsql ai command execution to succeed, got %v", err) - } -} - -func TestCmdXSQL_AICommandUsesEnvironmentConfig(t *testing.T) { - oldGlobalConfig := GlobalConfig - GlobalConfig = &Config{} - defer func() { GlobalConfig = oldGlobalConfig }() - - oldNewAIProgram := newAIProgramFunc - defer func() { newAIProgramFunc = oldNewAIProgram }() - - var initialView string - newAIProgramFunc = func(model tea.Model) *tea.Program { - initialView = model.View() - p := tea.NewProgram(model, tea.WithInput(strings.NewReader("")), tea.WithOutput(os.Stderr), tea.WithoutRenderer()) - go p.Quit() - return p - } - - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "xsql.yaml") - cfgContent := ` -profiles: - default: - db: mysql - env-profile: - db: pg -ai: - api_key: keyring:must-be-overridden - model: config-model -` - if err := os.WriteFile(cfgPath, []byte(cfgContent), 0600); err != nil { - t.Fatalf("failed to write temp config: %v", err) - } - - t.Setenv("XSQL_PROFILE", "env-profile") - t.Setenv("XSQL_AI_API_KEY", "env-key") - t.Setenv("XSQL_AI_MODEL", "env-model") - - root := NewRootCommand() - root.AddCommand(NewAICommand()) - root.SetArgs([]string{"ai", "--config", cfgPath}) - - if err := root.Execute(); err != nil { - t.Fatalf("expected environment-backed AI command to succeed, got %v", err) - } - if !strings.Contains(initialView, "env-profile") || !strings.Contains(initialView, "pg") { - t.Fatalf("expected environment-selected pg profile in TUI, got %q", initialView) - } -} - -func TestCmdXSQL_AICommandRejectsPlaintextConfigAPIKey(t *testing.T) { - oldGlobalConfig := GlobalConfig - GlobalConfig = &Config{} - defer func() { GlobalConfig = oldGlobalConfig }() - - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "xsql.yaml") - cfgContent := ` -profiles: - default: - db: mysql -ai: - api_key: plaintext-config-key - allow_plaintext: false -` - if err := os.WriteFile(cfgPath, []byte(cfgContent), 0600); err != nil { - t.Fatalf("failed to write temp config: %v", err) - } - - root := NewRootCommand() - root.AddCommand(NewAICommand()) - root.SetArgs([]string{"ai", "--config", cfgPath}) - - err := root.Execute() - if err == nil || !strings.Contains(err.Error(), "plaintext secret not allowed") { - t.Fatalf("expected plaintext API key rejection, got %v", err) - } -} - -func TestCmdXSQL_AICommandAllowsPlaintextAPIKey(t *testing.T) { - tests := []struct { - name string - allowPlaintext bool - extraArgs []string - }{ - {name: "config opt-in", allowPlaintext: true}, - {name: "CLI opt-in", extraArgs: []string{"--allow-plaintext"}}, - {name: "CLI API key", extraArgs: []string{"--api-key", "cli-key"}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - stubAICommandProgram(t) - - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "xsql.yaml") - cfgContent := fmt.Sprintf(` -profiles: - default: - db: mysql -ai: - api_key: plaintext-config-key - allow_plaintext: %t -`, tt.allowPlaintext) - if err := os.WriteFile(cfgPath, []byte(cfgContent), 0600); err != nil { - t.Fatalf("failed to write temp config: %v", err) - } - - root := NewRootCommand() - root.AddCommand(NewAICommand()) - args := []string{"ai", "--config", cfgPath} - root.SetArgs(append(args, tt.extraArgs...)) - - if err := root.Execute(); err != nil { - t.Fatalf("expected plaintext API key to be allowed, got %v", err) - } - }) - } -} diff --git a/cmd/xsql/main.go b/cmd/xsql/main.go index e50ad207..218da233 100644 --- a/cmd/xsql/main.go +++ b/cmd/xsql/main.go @@ -34,7 +34,6 @@ func run() int { root.AddCommand(NewServeCommand(&w)) root.AddCommand(NewWebCommand(&w)) root.AddCommand(NewStatsCommand(&w)) - root.AddCommand(NewAICommand()) // Execute and handle errors if err := root.Execute(); err != nil { diff --git a/cmd/xsql/root.go b/cmd/xsql/root.go index 5b798106..20abf948 100644 --- a/cmd/xsql/root.go +++ b/cmd/xsql/root.go @@ -55,9 +55,6 @@ func NewRootCommand() *cobra.Command { CLIFormatSet: formatSet, EnvProfile: os.Getenv("XSQL_PROFILE"), EnvFormat: os.Getenv("XSQL_FORMAT"), - EnvAIModel: os.Getenv("XSQL_AI_MODEL"), - EnvAIBaseURL: os.Getenv("XSQL_AI_BASE_URL"), - EnvAIAPIKey: os.Getenv("XSQL_AI_API_KEY"), WorkDir: os.Getenv("XSQL_WORKDIR"), HomeDir: os.Getenv("XSQL_HOMEDIR"), }) @@ -95,9 +92,6 @@ func NewRootCommand() *cobra.Command { CLIFormatSet: formatSet, EnvProfile: os.Getenv("XSQL_PROFILE"), EnvFormat: os.Getenv("XSQL_FORMAT"), - EnvAIModel: os.Getenv("XSQL_AI_MODEL"), - EnvAIBaseURL: os.Getenv("XSQL_AI_BASE_URL"), - EnvAIAPIKey: os.Getenv("XSQL_AI_API_KEY"), WorkDir: os.Getenv("XSQL_WORKDIR"), HomeDir: os.Getenv("XSQL_HOMEDIR"), }) diff --git a/docs/ai.md b/docs/ai.md index 064c9265..803921e2 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -112,49 +112,4 @@ xsql web Web UI 复用 xsql 的 profile、SSH、只读策略和结构化错误契约,但其 HTTP API 面向浏览器,不等同于 MCP 协议。 -## AI TUI 交互模式 (xsql ai) -`xsql ai` 为主程序的子命令,提供交互式 AI 终端模式。用户只需在终端以自然语言发问,AI 结合当前数据库 Schema 结构自动构建对应的 SQL 查询,并在 TUI 中提供交互预览与安全执行: - -```bash -# 启动交互式 TUI -xsql ai --profile dev --attr source=codex-cli --attr agent=codex --attr env=dev --attr task=interactive-analysis -``` - -`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 循环推理**,支持 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)`**: 原始数据集转储工具,仅支持 `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 收起折叠(内嵌表格与指标数据),界面保持极简清爽。 - -#### 有界数据回传与 Session 数据集召回 (Session DataStore) -- 每次查询成功的结果在本地分配标号(`res1`, `res2`, ...)。 -- 大模型上下文包含数据集的轻量 Catalog 目录结构(字段名与行数),不会自动加入完整查询结果。 -- 本地 JavaScript 的派生结果会以最多 4096 个字符的摘要回传给模型,用于生成最终分析;超出部分会截断并明确标记。 -- AI 可通过 `execute_javascript` 生成纯 Go 沙箱 (`goja`) 执行的代码,在本地对 `res1`, `res2` 等数据集做跨表 Join、占比统计与数据清洗,并通过 `export_data` 安全导出为 CSV/JSON,或通过 `export_report` 导出完整分析报告。 - -### 快捷键操作 - -#### SQL & 导出确认状态 (Approval Mode) -- `Enter`: 确认并安全执行当前生成预览的 SQL 或同意文件导出 -- `Esc`: 取消当前 SQL 生成建议或拒绝文件导出 - -#### 通用与表格/工具操作 (General & Tool Operations) -- `Enter`: 提交自然语言需求给 AI -- `Ctrl+O`: 折叠/展开当前选中的 Tool Call 详情(内嵌表格与指标) -- `Ctrl+P`: 切换数据库 Profile -- `Tab`: 在会话历史中的多个 Tool Call 组件之间切换焦点 -- `Ctrl+E`: 切换表格单行展开视图(Expand Vertical View) -- `←` / `→`: 横向平滑滚动查看当前焦点表格的隐藏列 -- `PgUp` / `PgDn`: 向上/向下翻页查看当前焦点表格的数据 -- `Shift+Tab`: 一键切换 **自动执行 (AUTO-EXECUTE)** 与 **手动批准 (MANUAL-APPROVE)** 模式 -- `Esc`: 清空输入框 -- `Ctrl+C` 连按两次:退出 AI 模式 +> **说明**:交互式 `xsql ai` TUI 已在后续版本中移除。请使用 `xsql query`、`xsql schema dump`、MCP Server 或 Web UI 完成数据库分析工作流。 diff --git a/docs/architecture.md b/docs/architecture.md index 0f993880..2e6552ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,11 +9,6 @@ /cmd/xsql # CLI 主程序入口 /internal/app # 应用编排(解析参数->执行->返回结构化结果) /internal/config # 配置加载/合并/校验 + profiles -/internal/ai # AI LLM 客户端与 Context Prompt 组装 -/internal/tui # Bubbletea TUI 交互式终端UI实现 -/internal/export # AI 会话数据导出 -/internal/js # goja 数据分析沙箱 -/internal/session # AI 会话数据集存储 /internal/secret # keyring/加密/明文兼容 /internal/db # driver registry + 执行引擎 /internal/db/mysql # MySQL 驱动实现 diff --git a/docs/cli-spec.md b/docs/cli-spec.md index 2d7b2a7f..9fb4bc29 100644 --- a/docs/cli-spec.md +++ b/docs/cli-spec.md @@ -21,30 +21,6 @@ ## 命令 -### `xsql ai [PROMPT]` - -启动交互式 AI 终端模式(TUI)。用户可以直接输入自然语言需求(或在参数中指定初始 prompt),AI 自动结合数据库 Schema 生成 SQL,并提供可视化预览与安全执行。 - -```bash -# 启动交互式 TUI -xsql ai -p dev --attr source=codex-cli --attr agent=codex --attr env=dev --attr task=interactive-analysis - -# 携带初始 Prompt 启动 -xsql ai "查看用户表前 10 条数据" -p dev --attr source=codex-cli --attr agent=codex --attr env=dev --attr task=targeted-query -``` - -**Flags:** -| Flag | 默认值 | 说明 | -|------|--------|------| -| `--profile`, `-p` | - | Profile 名称 | -| `--model` | `gpt-4o` | AI 模型名称(ENV:`XSQL_AI_MODEL`,配置:`ai.model`) | -| `--base-url` | `https://api.openai.com/v1` | AI 服务 Base URL(ENV:`XSQL_AI_BASE_URL`,配置:`ai.base_url`) | -| `--api-key` | - | AI 服务 API Key(ENV:`XSQL_AI_API_KEY`,配置:`ai.api_key`) | -| `--allow-plaintext` | false | 允许配置文件中的明文 AI API Key(也可设置 `ai.allow_plaintext: true`) | -| `--unsafe-allow-write` | false | 本次命令申请写入;仅当 profile 同时设置 `unsafe_allow_write: true` 时生效 | - ---- - ### `xsql query ` 执行 SQL 查询。 @@ -284,8 +260,7 @@ Web UI 使用以下 HTTP API,统一返回 JSON Envelope: | `DELETE` | `/api/v1/config/profiles/{name}` | 删除 profile | | `POST`/`PUT` | `/api/v1/config/ssh-proxies` | 创建或更新 SSH proxy | | `DELETE` | `/api/v1/config/ssh-proxies/{name}` | 删除 SSH proxy | -| `POST`/`PUT` | `/api/v1/config/ai` | 更新 AI 配置 | -| `POST` | `/api/v1/config/test/*` | 测试连接(profile / SSH / AI) | +| `POST` | `/api/v1/config/test/*` | 测试连接(profile / SSH proxy) | | `GET` | `/api/v1/schema/tables` | 列出表(支持 `profile`/`table`/`include_system`) | | `GET` | `/api/v1/schema/tables/{schema}/{table}` | 查看单表结构(支持 `profile`) | | `POST` | `/api/v1/query` | 执行只读 SQL 查询 | @@ -294,7 +269,7 @@ Web 查询强制只读,即使 profile 配置了 `unsafe_allow_write: true`, `POST /api/v1/query` JSON body:`{"profile":"...","sql":"...","max_rows":1000}`(`max_rows` 可选;省略时使用 profile / `XSQL_MAX_ROWS` / 默认 1000;`0` = 不限)。 -**配置 API 脱敏**:`GET /api/v1/config` 与 `GET /api/v1/profiles/{name}` 不会在响应中返回明文密码、DSN、SSH passphrase、AI `api_key` 或 HTTP `auth_token`。明文值以占位符 `***` 表示;`keyring:` 引用会原样返回(不解析 keyring 内容)。通过 Web UI 保存配置时,若客户端未修改某敏感字段(提交空值或 `***`),服务端会保留磁盘上已有值,而不会用占位符覆盖。 +**配置 API 脱敏**:`GET /api/v1/config` 与 `GET /api/v1/profiles/{name}` 不会在响应中返回明文密码、DSN、SSH passphrase 或 HTTP `auth_token`。明文值以占位符 `***` 表示;`keyring:` 引用会原样返回(不解析 keyring 内容)。通过 Web UI 保存配置时,若客户端未修改某敏感字段(提交空值或 `***`),服务端会保留磁盘上已有值,而不会用占位符覆盖。 > **安全说明**:绑定 loopback 时 Web 默认不要求 Bearer token,本机其他进程仍可能调用上述只读配置接口获取脱敏后的配置与 keyring 引用。生产或共享环境请启用 `web.http.auth_token` 或非 loopback 绑定策略(见 `docs/config.md`)。 @@ -657,7 +632,6 @@ xsql mcp server --transport streamable_http --http-addr 127.0.0.1:8787 --http-au | 入口 | profile `unsafe_allow_write` | 运行时第二因子 | |------|------------------------------|----------------| | `xsql query` | 必须 | `--unsafe-allow-write` | -| `xsql ai` (TUI) | 必须 | `--unsafe-allow-write` | | `xsql mcp server` | 必须 | `--unsafe-allow-write`(或 `XSQL_MCP_UNSAFE_ALLOW_WRITE`) | | Web UI / `serve` | 忽略(强制只读) | 不适用 | diff --git a/docs/config.md b/docs/config.md index 543e95da..589a8fe7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -32,14 +32,6 @@ ssh_proxies: ```yaml # xsql.yaml -ai: - provider: openai - base_url: https://api.openai.com/v1 - api_key: "keyring:ai/api_key" - allow_plaintext: false - model: gpt-4o - max_tokens: 8192 - stats: enabled: true log_sql: false @@ -129,19 +121,6 @@ profiles: Profile 还可设置 `max_rows`(query 默认行数上限,CLI/Web 默认 1000)、`query_timeout`、`schema_timeout`。 -## AI 配置项 - -| 字段 | 类型 | 说明 | -|------|------|------| -| `ai.provider` | string | AI provider,当前为 `openai` 兼容接口 | -| `ai.base_url` | string | AI 服务 Base URL,默认 `https://api.openai.com/v1` | -| `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 数,默认 8192 | - -配置文件中的明文 `ai.api_key` 默认拒绝。设置 `ai.allow_plaintext: true` 或在 `xsql ai` 显式使用 `--allow-plaintext` 时允许;CLI `--api-key` 和 `XSQL_AI_API_KEY` 本身属于显式运行时输入。 - ## Web 配置项 | 字段 | 类型 | 说明 | @@ -150,7 +129,7 @@ Profile 还可设置 `max_rows`(query 默认行数上限,CLI/Web 默认 1000 | `web.http.auth_token` | string | Web 鉴权 token(支持 `keyring:` 引用) | | `web.http.allow_plaintext_token` | bool | 允许在配置中使用明文 token | -通过 Web UI 或 `GET /api/v1/config` 读取配置时,明文密码、DSN、passphrase、`api_key` 与 HTTP `auth_token` 会以 `***` 占位符返回,不会泄露实际值;`keyring:` 引用会原样显示。使用 `POST`/`PUT` 保存时,若某敏感字段未修改(提交空字符串或 `***`),服务端保留文件中已有值。 +通过 Web UI 或 `GET /api/v1/config` 读取配置时,明文密码、DSN、passphrase 与 HTTP `auth_token` 会以 `***` 占位符返回,不会泄露实际值;`keyring:` 引用会原样显示。使用 `POST`/`PUT` 保存时,若某敏感字段未修改(提交空字符串或 `***`),服务端保留文件中已有值。 ## SSH Proxy 配置项 @@ -184,7 +163,7 @@ Profile 还可设置 `max_rows`(query 默认行数上限,CLI/Web 默认 1000 | `query_timeout` | int | 查询超时秒数(默认 30 秒) | | `schema_timeout` | int | Schema 导出超时秒数(默认 60 秒) | -> **CLI 写入双重授权**:`xsql query` 和 `xsql ai` 只有在所选 profile 配置 `unsafe_allow_write: true` 且当前命令同时携带 `--unsafe-allow-write` 时才会绕过只读保护。配置或 flag 单独开启都不会允许 CLI 写入。MCP 仍由 profile 配置控制,Web 始终只读。 +> **CLI 写入双重授权**:`xsql query` 只有在所选 profile 配置 `unsafe_allow_write: true` 且当前命令同时携带 `--unsafe-allow-write` 时才会绕过只读保护。配置或 flag 单独开启都不会允许 CLI 写入。MCP 仍由 profile 配置控制,Web 始终只读。 ## Stats 配置项 diff --git a/docs/dev.md b/docs/dev.md index 56e0012e..5439c1df 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -99,25 +99,20 @@ PostgreSQL 测试: ``` cmd/xsql/ # CLI 入口 internal/ - ai/ # AI LLM 客户端与 Prompt 组装 app/ # 应用编排 config/ # 配置加载/合并/校验 + profile db/ # driver registry + query mysql/ # MySQL driver pg/ # PostgreSQL driver errors/ # 错误码/退出码 - export/ # AI 会话数据导出 - js/ # goja 数据分析沙箱 log/ # slog 日志 mcp/ # MCP Server 实现 output/ # json/yaml/table/csv 输出 proxy/ # 端口转发代理 secret/ # keyring + 明文策略 - session/ # AI 会话数据集存储 spec/ # tool spec 导出 ssh/ # SSH client + dial stats/ # 使用统计与 attr 聚合 - tui/ # AI 交互式终端 UI web/ # Web API 与静态资源适配 webui/ # Svelte 前端源码 + 嵌入资源 tests/ diff --git a/docs/env.md b/docs/env.md index 22e09d23..abbfcbea 100644 --- a/docs/env.md +++ b/docs/env.md @@ -14,9 +14,6 @@ - `XSQL_STATS_ENABLED`:是否启用使用统计(`true`/`false`) - `XSQL_STATS_LOG_SQL`:是否记录 SQL 内容(`true`/`false`) - `XSQL_ATTR`:统计属性(格式:`key1=val1,key2=val2`);例如 `source=codex-cli,agent=codex,env=prod,team=data,task=health-check` -- `XSQL_AI_MODEL`:AI 模型名称 -- `XSQL_AI_BASE_URL`:OpenAI 兼容服务 Base URL -- `XSQL_AI_API_KEY`:AI 服务 API Key(明文仅保存在当前进程环境中) - `XSQL_MAX_ROWS`:query 默认最大返回行数(非负整数;`0` = 不限;优先级低于 CLI `--max-rows`) ## 2. 连接参数(计划中,当前未实现) diff --git a/docs/rfcs/0009-ai-tui.md b/docs/rfcs/0009-ai-tui.md index fa65a9dd..9d9dda79 100644 --- a/docs/rfcs/0009-ai-tui.md +++ b/docs/rfcs/0009-ai-tui.md @@ -1,6 +1,8 @@ # RFC 0009: AI TUI Interactive Mode -Status: Implemented +Status: **Removed** — 交互式 `xsql ai` TUI 已从产品中移除;本文档仅作历史记录。 + +Status (historical): Implemented ## 摘要 本 RFC 为 `xsql` 增加交互式 AI 模式(TUI)。用户可以通过自然语言在终端提问,AI 自动结合当前数据库的 Schema 结构生成相应的 SQL 语句,并在 TUI 界面中提供可视化预览与安全执行。 diff --git a/docs/rfcs/0010-openai-sdk-tool-call.md b/docs/rfcs/0010-openai-sdk-tool-call.md index d5007cd6..0f14b204 100644 --- a/docs/rfcs/0010-openai-sdk-tool-call.md +++ b/docs/rfcs/0010-openai-sdk-tool-call.md @@ -1,6 +1,8 @@ # RFC 0010: Migrate OpenAI Integration to Official SDK and Tool Call -Status: Implemented +Status: **Removed**(随 AI TUI 一并移除;历史记录) + +Status (historical): Implemented ## 摘要 本 RFC 提出将 `xsql` 项目中的 AI 客户端重构为使用 OpenAI 官方 Go SDK (`github.com/openai/openai-go`),并将 SQL 生成与解析机制从手动字符串/JSON 解析改为标准 OpenAI Tool Call (`execute_sql`) 模式。 diff --git a/docs/rfcs/0011-tui-visual-redesign.md b/docs/rfcs/0011-tui-visual-redesign.md index d044de26..0d27d12b 100644 --- a/docs/rfcs/0011-tui-visual-redesign.md +++ b/docs/rfcs/0011-tui-visual-redesign.md @@ -1,6 +1,8 @@ # RFC 0011: TUI Visual and Interactive Experience Redesign -Status: Implemented +Status: **Removed**(随 AI TUI 一并移除;历史记录) + +Status (historical): Implemented ## 摘要 本 RFC 对 `xsql ai` 终端 TUI 进行视觉与交互体验的重构。参考 Catppuccin / Tokyo Night 现代调色盘以及 Charm (Bubbletea / Lipgloss) 社区开源工具(如 `mods`、`glow`、`gh-dash`)的设计精髓,全面优化 Pill 胶囊标签、✦ Prompt 输入框、全主题自适应调色盘与按键指引。 diff --git a/docs/rfcs/0012-goja-js-data-analysis.md b/docs/rfcs/0012-goja-js-data-analysis.md index 3c9dfc4f..4d03f2bc 100644 --- a/docs/rfcs/0012-goja-js-data-analysis.md +++ b/docs/rfcs/0012-goja-js-data-analysis.md @@ -1,6 +1,8 @@ # RFC 0012: Integration of goja JS Engine, Session DataStore, and ReAct Tool Agent Loop -Status: Implemented +Status: **Removed**(随 AI TUI 一并移除;历史记录) + +Status (historical): Implemented ## 摘要 本 RFC 在 `xsql ai` 中集成纯 Go 实现的 `goja` JavaScript 虚拟机(100% Zero CGO),构建 **Session DataStore(会话数据集存储与召回)** 机制,并实现无硬编码的 **ReAct Tool Agent Loop 循环推理**。 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 85ca12e6..4d5396a1 100644 --- a/docs/rfcs/0013-ai-multi-tool-calls-sequential-execution.md +++ b/docs/rfcs/0013-ai-multi-tool-calls-sequential-execution.md @@ -1,6 +1,8 @@ # RFC 0013: AI Multi Tool Call Sequential Execution and Schema Dialect Guidance -Status: Implemented +Status: **Removed**(随 AI TUI 一并移除;历史记录) + +Status (historical): Implemented ## 摘要 本 RFC 优化 `xsql ai` 的 ReAct Agent 调用机制:支持模型单轮返回多个 Tool Call 时的**按序依次调度与执行**(Sequential Execution),彻底消除此前由于模型返回多 Tool Call 导致的硬性中断崩溃;同时升级 System Prompt 模板,显式注入当前数据库名与精确的数据库方言元数据查询指引(MySQL vs PostgreSQL),杜绝 Agent 跨方言盲目试探与反复死循环。 diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md index 3908fa3b..d2896c7d 100644 --- a/docs/rfcs/README.md +++ b/docs/rfcs/README.md @@ -14,11 +14,11 @@ | [0006](0006-proxy-port-config-set.md) | Proxy Port Config & Config Set | Implemented | | [0007](0007-web-ui.md) | Web 查询界面 | Implemented | | [0008](0008-usage-stats.md) | Usage Stats & Global Attributes | Implemented | -| [0009](0009-ai-tui.md) | AI TUI Interactive Mode | Implemented | -| [0010](0010-openai-sdk-tool-call.md) | OpenAI SDK & Tool Call | Implemented | -| [0011](0011-tui-visual-redesign.md) | TUI Visual Redesign | Implemented | -| [0012](0012-goja-js-data-analysis.md) | goja Data Analysis & ReAct Loop | Implemented | -| [0013](0013-ai-multi-tool-calls-sequential-execution.md) | AI Multi Tool Call Sequential Execution & Dialect Guidance | Implemented | +| [0009](0009-ai-tui.md) | AI TUI Interactive Mode | Removed | +| [0010](0010-openai-sdk-tool-call.md) | OpenAI SDK & Tool Call (AI TUI) | Removed | +| [0011](0011-tui-visual-redesign.md) | TUI Visual Redesign (AI TUI) | Removed | +| [0012](0012-goja-js-data-analysis.md) | goja Data Analysis & ReAct Loop (AI TUI) | Removed | +| [0013](0013-ai-multi-tool-calls-sequential-execution.md) | AI Multi Tool Call Sequential Execution (AI TUI) | Removed | | [0014](0014-mcp-write-dual-authorization.md) | MCP 写入双重授权 | Implemented | | [0015](0015-mcp-app-layer-schema-tools.md) | MCP 复用 app 层与 Schema Tools | Implemented | | [0016](0016-error-guidance-for-agents.md) | AI Agent 错误指引(Error Guidance) | Accepted | diff --git a/go.mod b/go.mod index dcd7a4ea..6b5be096 100644 --- a/go.mod +++ b/go.mod @@ -3,18 +3,11 @@ module github.com/zx06/xsql go 1.25.0 require ( - github.com/alecthomas/chroma/v2 v2.20.0 - github.com/charmbracelet/bubbles v0.20.0 - github.com/charmbracelet/bubbletea v1.3.4 - github.com/charmbracelet/glamour v1.0.0 - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347 + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/go-sql-driver/mysql v1.10.0 github.com/google/jsonschema-go v0.4.3 github.com/jackc/pgx/v5 v5.9.2 - github.com/mattn/go-runewidth v0.0.17 github.com/modelcontextprotocol/go-sdk v1.6.0 - github.com/openai/openai-go v1.12.0 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.52.0 @@ -25,50 +18,18 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect - github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/aymerick/douceur v0.2.0 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.10.2 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect - github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/dlclark/regexp2/v2 v2.5.2 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect - github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect - github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kr/text v0.2.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/tidwall/gjson v1.14.4 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.1 // indirect - github.com/tidwall/sjson v1.2.5 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - github.com/yuin/goldmark v1.7.13 // indirect - github.com/yuin/goldmark-emoji v1.0.6 // indirect - golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect diff --git a/go.sum b/go.sum index 3ca3c2ac..afe23dfb 100644 --- a/go.sum +++ b/go.sum @@ -2,44 +2,6 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= -github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= -github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= -github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= -github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= -github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= -github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= -github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= -github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= -github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= -github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= -github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= @@ -47,20 +9,8 @@ github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= -github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0= -github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= -github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347 h1:RZr+96+PKQjn444QL1K9MtncwJ/PwfE+3TJLCYJL8es= -github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347/go.mod h1:LiIEzozrcvNXorsG/3+ypGqdTUAqZryhzSsqi0oU/Qg= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= -github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= -github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= -github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -69,12 +19,6 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= -github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= -github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= -github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -90,35 +34,10 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= -github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= -github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY= github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= -github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -138,39 +57,17 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= -github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= -github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= -github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= diff --git a/internal/ai/client.go b/internal/ai/client.go deleted file mode 100644 index 8caf4475..00000000 --- a/internal/ai/client.go +++ /dev/null @@ -1,299 +0,0 @@ -package ai - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strings" - - "github.com/openai/openai-go" - "github.com/openai/openai-go/option" - "github.com/openai/openai-go/shared" - - "github.com/zx06/xsql/internal/config" - "github.com/zx06/xsql/internal/errors" -) - -type ChatMessage struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type Client struct { - cfg config.AIConfig - openaiClient openai.Client -} - -func NewClient(cfg config.AIConfig, httpClient *http.Client) *Client { - opts := []option.RequestOption{} - - if cfg.APIKey != "" { - opts = append(opts, option.WithAPIKey(cfg.APIKey)) - } - if cfg.BaseURL != "" { - opts = append(opts, option.WithBaseURL(strings.TrimRight(cfg.BaseURL, "/"))) - } - if httpClient != nil { - opts = append(opts, option.WithHTTPClient(httpClient)) - } - - cli := openai.NewClient(opts...) - return &Client{ - cfg: cfg, - openaiClient: cli, - } -} - -func (c *Client) ChatCompletion(ctx context.Context, messages []ChatMessage) (*AIResponse, *errors.XError) { - sdkMessages := make([]openai.ChatCompletionMessageParamUnion, 0, len(messages)) - for _, m := range messages { - switch m.Role { - case "system": - sdkMessages = append(sdkMessages, openai.SystemMessage(m.Content)) - case "user": - sdkMessages = append(sdkMessages, openai.UserMessage(m.Content)) - case "assistant": - sdkMessages = append(sdkMessages, openai.AssistantMessage(m.Content)) - default: - sdkMessages = append(sdkMessages, openai.UserMessage(m.Content)) - } - } - - sqlToolDef := openai.ChatCompletionToolParam{ - Function: shared.FunctionDefinitionParam{ - Name: "execute_sql", - Description: openai.String("Execute or present generated SQL query based on database schema and user intent"), - Parameters: shared.FunctionParameters{ - "type": "object", - "properties": map[string]interface{}{ - "sql": map[string]interface{}{ - "type": "string", - "description": "The generated SQL query statement.", - }, - "explanation": map[string]interface{}{ - "type": "string", - "description": "Explanation of what the query does or why SQL generation failed.", - }, - }, - "required": []string{"sql", "explanation"}, - }, - }, - } - - jsToolDef := openai.ChatCompletionToolParam{ - Function: shared.FunctionDefinitionParam{ - Name: "execute_javascript", - Description: openai.String("Execute JavaScript code in local goja VM sandbox to aggregate, transform, join, or format active session datasets (res1, res2, rows)."), - Parameters: shared.FunctionParameters{ - "type": "object", - "properties": map[string]interface{}{ - "js_code": map[string]interface{}{ - "type": "string", - "description": "The JavaScript code snippet to execute on active session datasets.", - }, - "explanation": map[string]interface{}{ - "type": "string", - "description": "Explanation of what the JavaScript processing code does.", - }, - }, - "required": []string{"js_code", "explanation"}, - }, - }, - } - - 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 or JSON format after human confirmation."), - Parameters: shared.FunctionParameters{ - "type": "object", - "properties": map[string]interface{}{ - "dataset_id": map[string]interface{}{ - "type": "string", - "description": "The dataset ID from session catalog to export (e.g. 'res1').", - }, - "format": map[string]interface{}{ - "type": "string", - "description": "Export file format: 'csv' or 'json'.", - "enum": []string{"csv", "json"}, - }, - "filepath": map[string]interface{}{ - "type": "string", - "description": "Target file path (e.g. 'result.csv', 'report.json').", - }, - "explanation": map[string]interface{}{ - "type": "string", - "description": "Explanation of the data being exported.", - }, - }, - "required": []string{"dataset_id", "format", "filepath", "explanation"}, - }, - }, - } - - 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" - } - - params := openai.ChatCompletionNewParams{ - Model: shared.ChatModel(model), - Messages: sdkMessages, - Tools: []openai.ChatCompletionToolParam{sqlToolDef, jsToolDef, exportToolDef, reportToolDef}, - ParallelToolCalls: openai.Bool(false), - } - if c.cfg.MaxTokens > 0 { - params.MaxTokens = openai.Int(int64(c.cfg.MaxTokens)) - } - - resp, err := c.openaiClient.Chat.Completions.New(ctx, params) - if err != nil { - return nil, errors.New(errors.CodeDBExecFailed, "AI provider returned error", map[string]any{ - "err": err.Error(), - }) - } - - if len(resp.Choices) == 0 { - return nil, errors.New(errors.CodeInternal, "AI provider returned empty choices", nil) - } - - 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 { - actions := make([]ToolAction, 0, len(msg.ToolCalls)) - for _, toolCall := range msg.ToolCalls { - switch toolCall.Function.Name { - case "execute_sql": - var raw struct { - SQL string `json:"sql"` - 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: TypeSQL, - SQL: strings.TrimSpace(raw.SQL), - Explanation: strings.TrimSpace(raw.Explanation), - }) - - case "execute_javascript": - var raw struct { - JSCode string `json:"js_code"` - 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: TypeJS, - JSCode: strings.TrimSpace(raw.JSCode), - Explanation: strings.TrimSpace(raw.Explanation), - }) - - case "export_data": - var raw struct { - DatasetID string `json:"dataset_id"` - Format string `json:"format"` - 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: TypeExport, - DatasetID: strings.TrimSpace(raw.DatasetID), - Format: strings.ToLower(strings.TrimSpace(raw.Format)), - FilePath: strings.TrimSpace(raw.FilePath), - 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, - }) - } - } - - first := actions[0] - return &AIResponse{ - Type: first.Type, - SQL: first.SQL, - JSCode: first.JSCode, - DatasetID: first.DatasetID, - Format: first.Format, - FilePath: first.FilePath, - Content: first.Content, - Explanation: first.Explanation, - Actions: actions, - }, nil - } - - content := strings.TrimSpace(msg.Content) - return &AIResponse{ - Type: TypeText, - SQL: "", - Explanation: content, - }, nil -} - -func invalidToolArguments(toolName string, err error) *errors.XError { - 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 deleted file mode 100644 index d31a1ddb..00000000 --- a/internal/ai/prompt.go +++ /dev/null @@ -1,62 +0,0 @@ -package ai - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/zx06/xsql/internal/db" -) - -const SystemPromptTemplate = `You are an AI SQL Generator and Data Analyst for %s database. - -TARGET DATABASE: %s (Dialect: %s) -- Always generate valid %s SQL dialect queries. - -DATABASE SCHEMA: -%s - -%s -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. - * 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 { - switch strings.ToLower(dbType) { - case "mysql": - return "MySQL" - case "pg", "postgres", "postgresql": - return "PostgreSQL" - default: - if dbType != "" { - return dbType - } - return "MySQL/PostgreSQL" - } -} - -func BuildSystemPrompt(dbType string, schemaInfo *db.SchemaInfo, catalog string) string { - formattedDB := FormatDBName(dbType) - currentDB := "(unknown)" - if schemaInfo != nil && schemaInfo.Database != "" { - currentDB = schemaInfo.Database - } - - schemaJSON := "{}" - if schemaInfo != nil { - if bytes, err := json.MarshalIndent(schemaInfo, "", " "); err == nil { - schemaJSON = string(bytes) - } - } - catalogBlock := "" - if catalog != "" { - catalogBlock = fmt.Sprintf("SESSION DATASETS CATALOG:\n%s\n", catalog) - } - return fmt.Sprintf(SystemPromptTemplate, formattedDB, currentDB, formattedDB, formattedDB, schemaJSON, catalogBlock) -} diff --git a/internal/ai/service.go b/internal/ai/service.go deleted file mode 100644 index e9a61dc5..00000000 --- a/internal/ai/service.go +++ /dev/null @@ -1,93 +0,0 @@ -package ai - -import ( - "context" - "strings" - "unicode/utf8" - - "github.com/zx06/xsql/internal/config" - "github.com/zx06/xsql/internal/db" - "github.com/zx06/xsql/internal/errors" -) - -const MaxToolFeedbackRunes = 4096 - -type ResponseType string - -const ( - TypeSQL ResponseType = "sql" - TypeJS ResponseType = "js" - TypeExport ResponseType = "export" - TypeReport ResponseType = "report" - TypeText ResponseType = "text" -) - -type ToolAction struct { - ID string `json:"id,omitempty"` - Type ResponseType `json:"type"` - SQL string `json:"sql,omitempty"` - JSCode string `json:"js_code,omitempty"` - 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"` -} - -type AIResponse struct { - Type ResponseType `json:"type"` - SQL string `json:"sql,omitempty"` - JSCode string `json:"js_code,omitempty"` - 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"` -} - -type SQLResponse = AIResponse - -type Service struct { - client *Client -} - -func NewService(cfg config.AIConfig, client *Client) *Service { - if client == nil { - client = NewClient(cfg, nil) - } - return &Service{ - client: client, - } -} - -func (s *Service) GenerateSQL(ctx context.Context, userPrompt string, schemaInfo *db.SchemaInfo, dbType string) (*AIResponse, *errors.XError) { - return s.GenerateResponse(ctx, userPrompt, schemaInfo, dbType, "") -} - -func (s *Service) GenerateResponse(ctx context.Context, userPrompt string, schemaInfo *db.SchemaInfo, dbType string, catalog string) (*AIResponse, *errors.XError) { - systemPrompt := BuildSystemPrompt(dbType, schemaInfo, catalog) - - messages := []ChatMessage{ - {Role: "system", Content: systemPrompt}, - {Role: "user", Content: userPrompt}, - } - - return s.client.ChatCompletion(ctx, messages) -} - -func (s *Service) ChatCompletion(ctx context.Context, messages []ChatMessage) (*AIResponse, *errors.XError) { - return s.client.ChatCompletion(ctx, messages) -} - -// BoundToolFeedback limits locally computed tool output before it is sent to -// the remote model. The complete output remains available in the local TUI. -func BoundToolFeedback(value string) string { - value = strings.TrimSpace(value) - if utf8.RuneCountInString(value) <= MaxToolFeedbackRunes { - return value - } - - runes := []rune(value) - return string(runes[:MaxToolFeedbackRunes]) + "\n...[truncated: complete result remains local]" -} diff --git a/internal/ai/service_test.go b/internal/ai/service_test.go deleted file mode 100644 index 9489a6f2..00000000 --- a/internal/ai/service_test.go +++ /dev/null @@ -1,484 +0,0 @@ -package ai - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/zx06/xsql/internal/config" - "github.com/zx06/xsql/internal/db" -) - -func TestBuildSystemPrompt(t *testing.T) { - schema := &db.SchemaInfo{ - Database: "testdb", - Tables: []db.Table{ - { - Name: "users", - Columns: []db.Column{ - {Name: "id", Type: "int", PrimaryKey: true}, - {Name: "name", Type: "varchar"}, - }, - }, - }, - } - - prompt := BuildSystemPrompt("mysql", schema, "res1: users") - if prompt == "" { - t.Fatal("expected non-empty prompt") - } - - defaultPrompt := BuildSystemPrompt("", nil, "") - if defaultPrompt == "" { - t.Fatal("expected non-empty default prompt") - } -} - -func TestNewService_NilClient(t *testing.T) { - cfg := config.AIConfig{ - Provider: "openai", - APIKey: "key", - } - svc := NewService(cfg, nil) - if svc == nil || svc.client == nil { - t.Fatal("expected non-nil Service and Client") - } -} - -func TestGenerateSQL_MockHTTP_ToolCall(t *testing.T) { - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/chat/completions" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - if r.Header.Get("Authorization") != "Bearer test-key" { - t.Errorf("unexpected auth header: %s", r.Header.Get("Authorization")) - } - body, _ := io.ReadAll(r.Body) - if !strings.Contains(string(body), `"parallel_tool_calls":false`) { - t.Errorf("expected parallel tool calls to be disabled, got %s", body) - } - - respBody := `{ - "id": "chatcmpl-123", - "object": "chat.completion", - "created": 1677652288, - "model": "gpt-4o", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": null, - "tool_calls": [ - { - "id": "call_abc123", - "type": "function", - "function": { - "name": "execute_sql", - "arguments": "{\"sql\":\"SELECT id, name FROM users;\",\"explanation\":\"Queries all users.\"}" - } - } - ] - }, - "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", - Model: "gpt-4o", - MaxTokens: 100, - } - - client := NewClient(cfg, mockServer.Client()) - service := NewService(cfg, client) - - res, xe := service.GenerateSQL(context.Background(), "show users", nil, "mysql") - if xe != nil { - t.Fatalf("unexpected error: %v", xe) - } - - if res.SQL != "SELECT id, name FROM users;" { - t.Errorf("expected SQL 'SELECT id, name FROM users;', got %q", res.SQL) - } - if res.Explanation != "Queries all users." { - t.Errorf("expected explanation 'Queries all users.', got %q", res.Explanation) - } -} - -func TestChatCompletionParsesMultipleToolCalls(t *testing.T) { - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - respBody := `{ - "id":"chatcmpl-multi", - "object":"chat.completion", - "created":1677652288, - "model":"gpt-4o", - "choices":[{ - "index":0, - "message":{"role":"assistant","content":null,"tool_calls":[ - {"id":"call_1","type":"function","function":{"name":"execute_sql","arguments":"{\"sql\":\"SELECT 1\",\"explanation\":\"one\"}"}}, - {"id":"call_2","type":"function","function":{"name":"execute_javascript","arguments":"{\"js_code\":\"console.log(1);\",\"explanation\":\"two\"}"}} - ]}, - "finish_reason":"tool_calls" - }] - }` - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(respBody)) - })) - defer mockServer.Close() - - cfg := config.AIConfig{BaseURL: mockServer.URL, APIKey: "test-key", Model: "gpt-4o"} - client := NewClient(cfg, mockServer.Client()) - resp, xe := client.ChatCompletion(context.Background(), []ChatMessage{{Role: "user", Content: "run two actions"}}) - if xe != nil { - t.Fatalf("unexpected error for multiple tool calls: %v", xe) - } - if len(resp.Actions) != 2 { - t.Fatalf("expected 2 actions, got %d", len(resp.Actions)) - } - if resp.Actions[0].Type != TypeSQL || resp.Actions[0].SQL != "SELECT 1" { - t.Errorf("unexpected first action: %+v", resp.Actions[0]) - } - if resp.Actions[1].Type != TypeJS || resp.Actions[1].JSCode != "console.log(1);" { - t.Errorf("unexpected second action: %+v", resp.Actions[1]) - } -} - -func TestBoundToolFeedback(t *testing.T) { - short := "compact aggregate" - if got := BoundToolFeedback(short); got != short { - t.Fatalf("expected short feedback unchanged, got %q", got) - } - - long := strings.Repeat("数", MaxToolFeedbackRunes+10) - got := BoundToolFeedback(long) - if !strings.Contains(got, "[truncated:") { - t.Fatalf("expected truncation marker, got suffix %q", got[len(got)-64:]) - } - if count := len([]rune(strings.Split(got, "\n...[truncated:")[0])); count != MaxToolFeedbackRunes { - t.Fatalf("expected %d retained runes, got %d", MaxToolFeedbackRunes, count) - } -} - -func TestGenerateResponse_MockHTTP_JSToolCall(t *testing.T) { - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - respBody := `{ - "id": "chatcmpl-125", - "object": "chat.completion", - "created": 1677652288, - "model": "gpt-4o", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": null, - "tool_calls": [ - { - "id": "call_js123", - "type": "function", - "function": { - "name": "execute_javascript", - "arguments": "{\"js_code\":\"rows.filter(r => r.status === 'ONLINE');\",\"explanation\":\"Filters online servers.\"}" - } - } - ] - }, - "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(), "filter online servers", nil, "mysql", "res1 catalog") - if xe != nil { - t.Fatalf("unexpected error: %v", xe) - } - - if res.Type != TypeJS { - t.Errorf("expected type JS, got %q", res.Type) - } - if res.JSCode != "rows.filter(r => r.status === 'ONLINE');" { - t.Errorf("unexpected JS code: %q", res.JSCode) - } -} - -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 := `{ - "id": "chatcmpl-124", - "object": "chat.completion", - "created": 1677652288, - "model": "gpt-4o", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "I cannot answer this question based on the schema." - }, - "finish_reason": "stop" - } - ] - }` - 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", - Model: "gpt-4o", - } - - client := NewClient(cfg, mockServer.Client()) - service := NewService(cfg, client) - - res, xe := service.GenerateSQL(context.Background(), "unknown table", nil, "mysql") - if xe != nil { - t.Fatalf("unexpected error: %v", xe) - } - - if res.SQL != "" { - t.Errorf("expected empty SQL, got %q", res.SQL) - } - if res.Explanation != "I cannot answer this question based on the schema." { - t.Errorf("expected explanation, got %q", res.Explanation) - } -} - -func TestGenerateSQL_MockHTTP_APIError(t *testing.T) { - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"error": {"message": "internal server error"}}`)) - })) - defer mockServer.Close() - - cfg := config.AIConfig{ - Provider: "openai", - BaseURL: mockServer.URL, - APIKey: "test-key", - } - - client := NewClient(cfg, mockServer.Client()) - service := NewService(cfg, client) - - _, xe := service.GenerateSQL(context.Background(), "test", nil, "mysql") - if xe == nil { - t.Fatal("expected error for HTTP 500") - } -} - -func TestService_ChatCompletion(t *testing.T) { - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - respBody := `{ - "id": "chatcmpl-999", - "object": "chat.completion", - "created": 1677652288, - "model": "gpt-4o", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "Hello from ChatCompletion" - }, - "finish_reason": "stop" - } - ] - }` - 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) - - msgs := []ChatMessage{ - {Role: "user", Content: "Hello"}, - } - - res, xe := service.ChatCompletion(context.Background(), msgs) - if xe != nil { - t.Fatalf("unexpected error: %v", xe) - } - if res.Explanation != "Hello from ChatCompletion" { - 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/app/service.go b/internal/app/service.go index 3dd66bfb..86a9dc9f 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -5,7 +5,6 @@ import ( "sort" "time" - "github.com/zx06/xsql/internal/ai" "github.com/zx06/xsql/internal/config" "github.com/zx06/xsql/internal/db" "github.com/zx06/xsql/internal/errors" @@ -353,33 +352,3 @@ func TestSSHProxyConnection(ctx context.Context, proxy config.SSHProxy, allowPla "user": proxy.User, }, nil } - -// TestAIConnection tests AI configuration and measures latency. -func TestAIConnection(ctx context.Context, aiCfg config.AIConfig) (map[string]any, *errors.XError) { - if aiCfg.APIKey == "" { - return nil, errors.New(errors.CodeCfgInvalid, "api_key is required", nil) - } - if aiCfg.Model == "" { - aiCfg.Model = "gpt-4o" - } - if aiCfg.BaseURL == "" { - aiCfg.BaseURL = "https://api.openai.com/v1" - } - - start := time.Now() - client := ai.NewClient(aiCfg, nil) - resp, xe := client.ChatCompletion(ctx, []ai.ChatMessage{ - {Role: "user", Content: "Respond with only 'OK'"}, - }) - if xe != nil { - return nil, xe - } - latency := time.Since(start).Milliseconds() - - return map[string]any{ - "ok": true, - "latency_ms": latency, - "model": aiCfg.Model, - "response": resp.Explanation, - }, nil -} diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 3761a39d..6ec973f5 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -2,8 +2,6 @@ package app import ( "context" - "net/http" - "net/http/httptest" "os" "path/filepath" "testing" @@ -1086,51 +1084,6 @@ func TestTestSSHProxyConnection_Validation(t *testing.T) { } } -func TestTestAIConnection_Validation(t *testing.T) { - ctx := context.Background() - _, xe := TestAIConnection(ctx, config.AIConfig{APIKey: ""}) - if xe == nil { - t.Fatal("expected error for missing api key") - } - if xe.Code != errors.CodeCfgInvalid { - t.Errorf("expected CodeCfgInvalid, got %s", xe.Code) - } -} - -func TestTestAIConnection_SuccessMock(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "id": "chatcmpl-123", - "object": "chat.completion", - "created": 1677652288, - "model": "gpt-4o", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "OK" - }, - "finish_reason": "stop" - }] - }`)) - })) - defer srv.Close() - - ctx := context.Background() - res, xe := TestAIConnection(ctx, config.AIConfig{ - APIKey: "test-key", - BaseURL: srv.URL, - Model: "gpt-4o", - }) - if xe != nil { - t.Fatalf("unexpected error: %v", xe) - } - if res == nil || res["ok"] != true { - t.Fatalf("expected ok=true, got: %v", res) - } -} - func TestTestProfileConnection_InvalidHost(t *testing.T) { ctx := context.Background() _, xe := TestProfileConnection(ctx, config.Profile{ @@ -1165,20 +1118,3 @@ func TestTestSSHProxyConnection_Branches(t *testing.T) { t.Fatal("expected error connecting to closed port") } } - -func TestTestAIConnection_HttpError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized) - })) - defer srv.Close() - - ctx := context.Background() - _, xe := TestAIConnection(ctx, config.AIConfig{ - APIKey: "bad-key", - BaseURL: srv.URL, - Model: "gpt-4o", - }) - if xe == nil { - t.Fatal("expected error for 401 response") - } -} diff --git a/internal/config/mask.go b/internal/config/mask.go index 4d2baa36..87aadd25 100644 --- a/internal/config/mask.go +++ b/internal/config/mask.go @@ -49,13 +49,6 @@ func SSHProxyForAPI(sp SSHProxy) SSHProxy { return out } -// AIConfigForAPI returns a copy of ai with sensitive fields redacted for HTTP responses. -func AIConfigForAPI(ai AIConfig) AIConfig { - out := ai - out.APIKey = MaskSecretForDisplay(ai.APIKey) - return out -} - // MCPHTTPConfigForAPI redacts auth tokens for HTTP responses. func MCPHTTPConfigForAPI(c MCPHTTPConfig) MCPHTTPConfig { out := c @@ -85,7 +78,6 @@ func FileForAPI(f File) File { out.SSHProxies[name] = SSHProxyForAPI(sp) } } - out.AI = AIConfigForAPI(f.AI) out.MCP = f.MCP out.MCP.HTTP = MCPHTTPConfigForAPI(f.MCP.HTTP) out.Web = f.Web @@ -107,10 +99,3 @@ func MergeSSHProxySecrets(incoming, existing SSHProxy) SSHProxy { out.Passphrase = CoalesceSecret(incoming.Passphrase, existing.Passphrase) return out } - -// MergeAIConfigSecrets copies stored secrets from existing when incoming values are omitted or masked. -func MergeAIConfigSecrets(incoming, existing AIConfig) AIConfig { - out := incoming - out.APIKey = CoalesceSecret(incoming.APIKey, existing.APIKey) - return out -} diff --git a/internal/config/mask_test.go b/internal/config/mask_test.go index bb54935a..5ef45111 100644 --- a/internal/config/mask_test.go +++ b/internal/config/mask_test.go @@ -39,7 +39,6 @@ func TestFileForAPI_RedactsSecrets(t *testing.T) { Passphrase: "ssh-secret", }, }, - AI: AIConfig{APIKey: "sk-live"}, MCP: MCPConfig{ HTTP: MCPHTTPConfig{AuthToken: "mcp-token"}, }, @@ -57,9 +56,6 @@ func TestFileForAPI_RedactsSecrets(t *testing.T) { if masked.SSHProxies["bastion"].Passphrase != MaskedSecret { t.Fatalf("passphrase not masked") } - if masked.AI.APIKey != MaskedSecret { - t.Fatalf("api key not masked") - } if masked.MCP.HTTP.AuthToken != MaskedSecret { t.Fatalf("mcp token not masked") } @@ -73,15 +69,11 @@ func TestFileForAPI_ShowsKeyringReferences(t *testing.T) { Profiles: map[string]Profile{ "dev": {Password: "keyring:prod/db"}, }, - AI: AIConfig{APIKey: "keyring:openai/api_key"}, } masked := FileForAPI(f) if masked.Profiles["dev"].Password != "keyring:prod/db" { t.Fatalf("expected keyring ref for password, got %q", masked.Profiles["dev"].Password) } - if masked.AI.APIKey != "keyring:openai/api_key" { - t.Fatalf("expected keyring ref for api key, got %q", masked.AI.APIKey) - } } func TestMergeProfileSecrets_PreservesOnMask(t *testing.T) { diff --git a/internal/config/resolve.go b/internal/config/resolve.go index e150fdfb..2cb06c03 100644 --- a/internal/config/resolve.go +++ b/internal/config/resolve.go @@ -108,47 +108,11 @@ func Resolve(opts Options) (Resolved, *errors.XError) { format = opts.CLIFormat } - // 5) Merge AI Config: CLI > ENV > Config > Default - aiConfig := cfg.AI - if aiConfig.Provider == "" { - aiConfig.Provider = "openai" - } - if aiConfig.BaseURL == "" { - aiConfig.BaseURL = "https://api.openai.com/v1" - } - if aiConfig.Model == "" { - aiConfig.Model = "gpt-4o" - } - if aiConfig.MaxTokens == 0 { - aiConfig.MaxTokens = 8192 - } - - if opts.EnvAIBaseURL != "" { - aiConfig.BaseURL = opts.EnvAIBaseURL - } - if opts.EnvAIModel != "" { - aiConfig.Model = opts.EnvAIModel - } - if opts.EnvAIAPIKey != "" { - aiConfig.APIKey = opts.EnvAIAPIKey - } - - if opts.CLIAIBaseURLSet && opts.CLIAIBaseURL != "" { - aiConfig.BaseURL = opts.CLIAIBaseURL - } - if opts.CLIAIModelSet && opts.CLIAIModel != "" { - aiConfig.Model = opts.CLIAIModel - } - if opts.CLIAIAPIKeySet && opts.CLIAIAPIKey != "" { - aiConfig.APIKey = opts.CLIAIAPIKey - } - return Resolved{ ConfigPath: cfgPath, ProfileName: profile, Format: format, Profile: selectedProfile, AllProfiles: resolvedProfiles, - AI: aiConfig, }, nil } diff --git a/internal/config/resolve_test.go b/internal/config/resolve_test.go index bfbf59e9..ceee2ba1 100644 --- a/internal/config/resolve_test.go +++ b/internal/config/resolve_test.go @@ -216,74 +216,3 @@ func TestResolve_Port_PreservedWhenSpecified(t *testing.T) { t.Errorf("expected port 13306 to be preserved, got %d", got.Profile.Port) } } - -func TestResolve_AIConfigPrecedence(t *testing.T) { - tmp := t.TempDir() - cfg := []byte(`ai: - base_url: "https://config.api.com" - model: "config-model" - api_key: "config-key" - allow_plaintext: true -`) - path := filepath.Join(tmp, "xsql.yaml") - if err := os.WriteFile(path, cfg, 0o600); err != nil { - t.Fatal(err) - } - - // 1. Config values - got, xe := Resolve(Options{WorkDir: tmp, HomeDir: tmp}) - if xe != nil { - t.Fatal(xe) - } - if got.AI.BaseURL != "https://config.api.com" || got.AI.Model != "config-model" || got.AI.APIKey != "config-key" || !got.AI.AllowPlaintext { - t.Fatalf("unexpected AI config from yaml: %+v", got.AI) - } - - // 2. ENV overrides config - got, xe = Resolve(Options{ - WorkDir: tmp, - HomeDir: tmp, - EnvAIBaseURL: "https://env.api.com", - EnvAIModel: "env-model", - EnvAIAPIKey: "env-key", - }) - if xe != nil { - t.Fatal(xe) - } - if got.AI.BaseURL != "https://env.api.com" || got.AI.Model != "env-model" || got.AI.APIKey != "env-key" { - t.Fatalf("unexpected AI config from env: %+v", got.AI) - } - - // 3. CLI overrides ENV & Config - got, xe = Resolve(Options{ - WorkDir: tmp, - HomeDir: tmp, - EnvAIBaseURL: "https://env.api.com", - EnvAIModel: "env-model", - EnvAIAPIKey: "env-key", - CLIAIBaseURL: "https://cli.api.com", - CLIAIBaseURLSet: true, - CLIAIModel: "cli-model", - CLIAIModelSet: true, - CLIAIAPIKey: "cli-key", - CLIAIAPIKeySet: true, - }) - if xe != nil { - t.Fatal(xe) - } - if got.AI.BaseURL != "https://cli.api.com" || got.AI.Model != "cli-model" || got.AI.APIKey != "cli-key" { - t.Fatalf("unexpected AI config from cli: %+v", got.AI) - } -} - -func TestResolve_AIAllowPlaintextDefaultsFalse(t *testing.T) { - tmp := t.TempDir() - - got, xe := Resolve(Options{WorkDir: tmp, HomeDir: tmp}) - if xe != nil { - t.Fatal(xe) - } - if got.AI.AllowPlaintext { - t.Fatal("expected AI allow_plaintext to default to false") - } -} diff --git a/internal/config/types.go b/internal/config/types.go index 09427e87..89d42f2d 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -10,17 +10,6 @@ type File struct { MCP MCPConfig `yaml:"mcp" json:"mcp"` Web WebConfig `yaml:"web" json:"web"` Stats stats.StatsConfig `yaml:"stats" json:"stats"` - AI AIConfig `yaml:"ai" json:"ai"` -} - -// AIConfig defines the AI LLM service configuration. -type AIConfig struct { - Provider string `yaml:"provider" json:"provider"` // default "openai" - BaseURL string `yaml:"base_url" json:"base_url"` // default "https://api.openai.com/v1" - 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 8192 } // SSHProxy defines a reusable SSH proxy configuration. @@ -102,7 +91,6 @@ type Resolved struct { Format string Profile Profile // full profile for query use AllProfiles map[string]Profile // all configured profiles - AI AIConfig } type Options struct { @@ -115,20 +103,9 @@ type Options struct { CLIFormat string CLIFormatSet bool - // CLI AI - CLIAIModel string - CLIAIModelSet bool - CLIAIBaseURL string - CLIAIBaseURLSet bool - CLIAIAPIKey string - CLIAIAPIKeySet bool - // ENV (injected by caller for testability) - EnvProfile string - EnvFormat string - EnvAIModel string - EnvAIBaseURL string - EnvAIAPIKey string + EnvProfile string + EnvFormat string // HomeDir is used for default path resolution (auto-detected if empty). HomeDir string diff --git a/internal/config/write.go b/internal/config/write.go index 0de394ee..21daf14e 100644 --- a/internal/config/write.go +++ b/internal/config/write.go @@ -277,13 +277,6 @@ func DeleteSSHProxy(configPath, name string) *errors.XError { }) } -// SaveAI creates or updates the AI configuration in the specified config file. -func SaveAI(configPath string, ai AIConfig) *errors.XError { - return modifyConfig(configPath, "ai", "ai", true, func(cfg *File) { - cfg.AI = ai - }) -} - // FindConfigPath returns the path to the config file being used (or default path). func FindConfigPath(opts Options) string { if opts.ConfigPath != "" { diff --git a/internal/config/write_test.go b/internal/config/write_test.go index ba0fc2d8..12b61c48 100644 --- a/internal/config/write_test.go +++ b/internal/config/write_test.go @@ -689,33 +689,4 @@ func TestSaveAndDeleteSSHProxy(t *testing.T) { } }) - t.Run("SaveAI creates and updates AI configuration", func(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "xsql.yaml") - if err := os.WriteFile(path, []byte("profiles: {}\nssh_proxies: {}\n"), 0600); err != nil { - t.Fatal(err) - } - - aiCfg := AIConfig{ - Provider: "openai", - BaseURL: "https://api.openai.com/v1", - APIKey: "sk-test-key", - Model: "gpt-4o", - MaxTokens: 4096, - AllowPlaintext: true, - } - - if err := SaveAI(path, aiCfg); err != nil { - t.Fatalf("failed to save AI config: %v", err) - } - - f, xe := readFile(path) - if xe != nil { - t.Fatalf("failed to read back config: %v", xe) - } - - if f.AI.Provider != "openai" || f.AI.Model != "gpt-4o" || f.AI.APIKey != "sk-test-key" || f.AI.MaxTokens != 4096 || !f.AI.AllowPlaintext { - t.Errorf("AI config mismatch: %+v", f.AI) - } - }) } diff --git a/internal/errors/guidance.go b/internal/errors/guidance.go index e3c93c12..e01d737c 100644 --- a/internal/errors/guidance.go +++ b/internal/errors/guidance.go @@ -64,7 +64,7 @@ func guidanceFor(xe *XError) map[string]any { const ( categoryReadOnlyBlocked = "read_only_blocked" categoryUnrecognizedStmt = "unrecognized_statement" - hintROWriteAuthorization = "Writes require profile `unsafe_allow_write: true` AND `--unsafe-allow-write` on `xsql query` / `xsql ai` or MCP server startup." + hintROWriteAuthorization = "Writes require profile `unsafe_allow_write: true` AND `--unsafe-allow-write` on `xsql query` or MCP server startup." hintROForbiddenKeyword = "Statement contains a write keyword blocked by read-only policy; use SELECT-only SQL, or enable profile `unsafe_allow_write` and `--unsafe-allow-write` if the user explicitly requested writes." hintROForbiddenStart = "Statement starts with a non-read-only command; use SELECT-only SQL, or enable profile `unsafe_allow_write` and `--unsafe-allow-write` if the user explicitly requested writes." hintROUnrecognizedStatement = "Statement not recognized as read-only SQL; check spelling and syntax (must start with an allowed read-only keyword such as SELECT)." diff --git a/internal/errors/hints.go b/internal/errors/hints.go index f986785d..a9e56f81 100644 --- a/internal/errors/hints.go +++ b/internal/errors/hints.go @@ -11,8 +11,8 @@ const ( hintSyntaxPG = "Fix SQL syntax; compare with the profile dialect (pg) and re-run." hintPermissionDenied = "Insufficient database privileges; use a read-only SELECT or ask for a profile with required grants." hintAuthFailed = "Authentication failed; verify profile username/password or keyring secret reference for this profile." - hintReadOnlyWrite = "Write blocked in read-only mode; set profile `unsafe_allow_write: true` AND pass `--unsafe-allow-write` on query/ai/mcp server." - hintReadOnlyWritePG = "Write blocked in read-only transaction; set profile `unsafe_allow_write: true` AND pass `--unsafe-allow-write` on query/ai/mcp server." + hintReadOnlyWrite = "Write blocked in read-only mode; set profile `unsafe_allow_write: true` AND pass `--unsafe-allow-write` on query or MCP server startup." + hintReadOnlyWritePG = "Write blocked in read-only transaction; set profile `unsafe_allow_write: true` AND pass `--unsafe-allow-write` on query or MCP server startup." hintDuplicateKey = "Duplicate key conflict; adjust INSERT/UPDATE values or use ON DUPLICATE handling if writes are explicitly allowed." hintDuplicateKeyPG = "Duplicate key conflict; adjust values or constraints if writes are explicitly allowed." hintMySQLConnection = "Cannot reach MySQL; verify profile host/port, network, and ssh_proxy settings." diff --git a/internal/export/exporter.go b/internal/export/exporter.go deleted file mode 100644 index 2b698c30..00000000 --- a/internal/export/exporter.go +++ /dev/null @@ -1,154 +0,0 @@ -package export - -import ( - "encoding/csv" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/zx06/xsql/internal/db" - "github.com/zx06/xsql/internal/errors" -) - -type ExportFormat string - -const ( - FormatCSV ExportFormat = "csv" - FormatJSON ExportFormat = "json" -) - -// ExpandPath expands leading ~ to user's home directory. -func ExpandPath(filePath string) (string, error) { - filePath = strings.TrimSpace(filePath) - if filePath == "" { - return "", nil - } - - if filePath == "~" { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return home, nil - } - - if strings.HasPrefix(filePath, "~/") || strings.HasPrefix(filePath, "~\\") { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, filePath[2:]), nil - } - - 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{ - "dir": dir, - "err": err.Error(), - }) - } - } - return expandedPath, nil -} - -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": expandedPath, - "err": err.Error(), - }) - } - defer func() { _ = f.Close() }() - - switch format { - case FormatJSON: - enc := json.NewEncoder(f) - enc.SetIndent("", " ") - if err := enc.Encode(result.Rows); err != nil { - return "", errors.New(errors.CodeInternal, "failed to write JSON export", map[string]any{"err": err.Error()}) - } - - case FormatCSV: - w := csv.NewWriter(f) - if err := w.Write(result.Columns); err != nil { - return "", errors.New(errors.CodeInternal, "failed to write CSV header", map[string]any{"err": err.Error()}) - } - for _, row := range result.Rows { - var vals []string - for _, col := range result.Columns { - val := row[col] - if val == nil { - vals = append(vals, "") - } else { - vals = append(vals, fmt.Sprintf("%v", val)) - } - } - if err := w.Write(vals); err != nil { - return "", errors.New(errors.CodeInternal, "failed to write CSV row", map[string]any{"err": err.Error()}) - } - } - w.Flush() - if err := w.Error(); err != nil { - return "", errors.New(errors.CodeInternal, "failed to flush CSV writer", map[string]any{"err": err.Error()}) - } - } - - 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 deleted file mode 100644 index 25d8c892..00000000 --- a/internal/export/exporter_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package export - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/zx06/xsql/internal/db" -) - -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) - } - defer os.RemoveAll(tempDir) - - res := &db.QueryResult{ - Columns: []string{"id", "username", "status"}, - Rows: []map[string]any{ - {"id": 1, "username": "alice", "status": "active"}, - {"id": 2, "username": "bob", "status": nil}, - }, - } - - // 1. CSV - csvPath := filepath.Join(tempDir, "test.csv") - absPath, xe := ExportQueryResult(res, FormatCSV, csvPath) - if xe != nil { - t.Fatalf("CSV export failed: %v", xe) - } - content, _ := os.ReadFile(absPath) - if !strings.Contains(string(content), "username") || !strings.Contains(string(content), "alice") { - t.Fatalf("unexpected CSV content: %s", string(content)) - } - - // 2. JSON - jsonPath := filepath.Join(tempDir, "test.json") - absPath, xe = ExportQueryResult(res, FormatJSON, jsonPath) - if xe != nil { - t.Fatalf("JSON export failed: %v", xe) - } - content, _ = os.ReadFile(absPath) - if !strings.Contains(string(content), `"alice"`) { - t.Fatalf("unexpected JSON 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 - _, xe = ExportQueryResult(nil, FormatCSV, "") - if xe == nil { - t.Fatal("expected error for nil QueryResult") - } - - absDefault, xe := ExportQueryResult(res, FormatCSV, "") - if xe != nil { - t.Fatalf("unexpected error for empty filePath: %v", xe) - } - _ = os.Remove(absDefault) - - // 5. Unsupported formats must fail before creating or truncating a file. - invalidPath := filepath.Join(tempDir, "invalid.xlsx") - if _, xe = ExportQueryResult(res, ExportFormat("xlsx"), invalidPath); xe == nil { - t.Fatal("expected unsupported export format error") - } - if _, err := os.Stat(invalidPath); !os.IsNotExist(err) { - 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/js/engine.go b/internal/js/engine.go deleted file mode 100644 index a39b34fc..00000000 --- a/internal/js/engine.go +++ /dev/null @@ -1,158 +0,0 @@ -package js - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/dop251/goja" - - "github.com/zx06/xsql/internal/errors" - "github.com/zx06/xsql/internal/session" -) - -type ExecutionResult struct { - Value any `json:"value"` - JSONString string `json:"json_string"` - SummaryText string `json:"summary_text"` - Logs []string `json:"logs"` -} - -type JSEngine struct { - DefaultTimeout time.Duration -} - -func NewJSEngine(timeout time.Duration) *JSEngine { - if timeout <= 0 { - timeout = 1 * time.Minute - } - return &JSEngine{ - DefaultTimeout: timeout, - } -} - -func (e *JSEngine) Execute(ctx context.Context, jsCode string, store *session.SessionDataStore) (*ExecutionResult, *errors.XError) { - if ctx == nil { - ctx = context.Background() - } - - execCtx, cancel := context.WithTimeout(ctx, e.DefaultTimeout) - defer cancel() - - vm := goja.New() - vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) - - // Inject console.log / console.error capture - var logs []string - console := vm.NewObject() - _ = console.Set("log", func(call goja.FunctionCall) goja.Value { - var args []string - for _, arg := range call.Arguments { - args = append(args, fmt.Sprintf("%v", arg.Export())) - } - logs = append(logs, strings.Join(args, " ")) - return goja.Undefined() - }) - _ = console.Set("error", func(call goja.FunctionCall) goja.Value { - var args []string - for _, arg := range call.Arguments { - args = append(args, fmt.Sprintf("%v", arg.Export())) - } - logs = append(logs, "[ERROR] "+strings.Join(args, " ")) - return goja.Undefined() - }) - _ = vm.Set("console", console) - - // Inject all active datasets from store - if store != nil { - allData := store.GetAll() - for id, queryRes := range allData { - if queryRes != nil { - _ = vm.Set(id, queryRes.Rows) - } - } - // Inject latest query result as `rows` and `columns` - if latest, ok := store.Latest(); ok && latest != nil { - _ = vm.Set("rows", latest.Rows) - _ = vm.Set("columns", latest.Columns) - } - } - - // Timeout interrupt setup - doneChan := make(chan struct{}) - defer close(doneChan) - - go func() { - select { - case <-execCtx.Done(): - if execCtx.Err() == context.DeadlineExceeded { - vm.Interrupt("execution timeout (limit reached)") - } - case <-doneChan: - } - }() - - // Execute JS script - val, err := vm.RunString(jsCode) - if err != nil { - return nil, errors.New(errors.CodeDBExecFailed, fmt.Sprintf("JavaScript execution error: %v", err), map[string]any{ - "err": err.Error(), - }) - } - - if val == nil || goja.IsUndefined(val) || goja.IsNull(val) { - summary := "(null)" - if len(logs) > 0 { - summary = strings.Join(logs, "\n") - } - return &ExecutionResult{ - Value: nil, - JSONString: "null", - SummaryText: summary, - Logs: logs, - }, nil - } - - exported := val.Export() - var jsonStr string - var summaryText string - - if str, ok := exported.(string); ok { - var parsed any - if json.Unmarshal([]byte(str), &parsed) == nil { - if b, err := json.MarshalIndent(parsed, "", " "); err == nil { - jsonStr = string(b) - } else { - jsonStr = str - } - } else { - jsonStr = str - } - summaryText = jsonStr - } else if exported != nil { - if b, err := json.MarshalIndent(exported, "", " "); err == nil { - jsonStr = string(b) - } else { - jsonStr = fmt.Sprintf("%v", exported) - } - summaryText = jsonStr - } - - if len(logs) > 0 { - consoleLogs := strings.Join(logs, "\n") - if summaryText == "" || summaryText == "(null)" { - summaryText = consoleLogs - } else { - summaryText = consoleLogs + "\n\n" + summaryText - } - } - - return &ExecutionResult{ - Value: exported, - JSONString: jsonStr, - SummaryText: summaryText, - Logs: logs, - }, nil -} diff --git a/internal/js/engine_test.go b/internal/js/engine_test.go deleted file mode 100644 index 8710d3f3..00000000 --- a/internal/js/engine_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package js - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/zx06/xsql/internal/db" - "github.com/zx06/xsql/internal/session" -) - -func TestJSEngine_MultiDatasetRecall(t *testing.T) { - store := session.NewSessionDataStore() - - res1 := &db.QueryResult{ - Columns: []string{"id", "name", "status"}, - Rows: []map[string]any{ - {"id": 1, "name": "srv1", "status": "ONLINE"}, - {"id": 2, "name": "srv2", "status": "OFFLINE"}, - }, - } - store.Save("servers query", res1) - - res2 := &db.QueryResult{ - Columns: []string{"id", "server_id", "severity"}, - Rows: []map[string]any{ - {"id": 101, "server_id": 1, "severity": "HIGH"}, - }, - } - store.Save("alerts query", res2) - - engine := NewJSEngine(5 * time.Second) - - // JS script joining res1 and res2 - jsCode := ` - (function() { - var alertServerIds = new Set(res2.map(function(a) { return a.server_id; })); - var onlineWithAlerts = res1.filter(function(s) { - return s.status === 'ONLINE' && alertServerIds.has(s.id); - }); - return { - count: onlineWithAlerts.length, - servers: onlineWithAlerts - }; - })(); - ` - - result, xe := engine.Execute(context.Background(), jsCode, store) - if xe != nil { - t.Fatalf("unexpected execution error: %v", xe) - } - - if !strings.Contains(result.JSONString, `"count": 1`) || !strings.Contains(result.JSONString, `"srv1"`) { - t.Fatalf("expected joined result with srv1, got:\n%s", result.JSONString) - } -} - -func TestJSEngine_Timeout(t *testing.T) { - engine := NewJSEngine(100 * time.Millisecond) - - jsCode := ` - while(true) {} - ` - - _, xe := engine.Execute(context.Background(), jsCode, nil) - if xe == nil { - t.Fatal("expected timeout error for infinite loop, got nil") - } -} - -func TestJSEngine_SyntaxAndRuntimeError(t *testing.T) { - engine := NewJSEngine(1 * time.Second) - - _, xe := engine.Execute(context.Background(), "invalid js {code", nil) - if xe == nil { - t.Fatal("expected syntax error for invalid JS") - } - - _, xe = engine.Execute(context.Background(), "throw new Error('custom js error');", nil) - if xe == nil { - t.Fatal("expected runtime error for thrown error") - } -} - -func TestJSEngine_PrimitiveResult(t *testing.T) { - engine := NewJSEngine(1 * time.Second) - - res, xe := engine.Execute(context.Background(), "'hello world'", nil) - if xe != nil { - t.Fatalf("unexpected error: %v", xe) - } - if res.SummaryText != "hello world" { - t.Fatalf("expected summary 'hello world', got %q", res.SummaryText) - } - - res, xe = engine.Execute(context.Background(), "[1, 2, 3]", nil) - if xe != nil { - t.Fatalf("unexpected error: %v", xe) - } - if !strings.Contains(res.JSONString, "[1, 2, 3]") && !strings.Contains(res.JSONString, "[\n 1") { - t.Fatalf("expected array json output, got %q", res.JSONString) - } -} - -func TestJSEngine_ConsoleAndNullAndDefaultTimeout(t *testing.T) { - engine := NewJSEngine(0) // Default 1 min - if engine.DefaultTimeout <= 0 { - t.Fatal("expected positive default timeout") - } - - store := session.NewSessionDataStore() - store.Save("test query", &db.QueryResult{ - Columns: []string{"id"}, - Rows: []map[string]any{{"id": 10}}, - }) - - // 1. Console log and error with undefined return - jsCode := ` - console.log("log msg", 123); - console.error("error msg"); - rows.length; - ` - res, xe := engine.Execute(nil, jsCode, store) - if xe != nil { - t.Fatalf("unexpected execution error: %v", xe) - } - if len(res.Logs) != 2 || !strings.Contains(res.SummaryText, "[ERROR] error msg") { - t.Fatalf("expected 2 log entries in summary, got:\n%s", res.SummaryText) - } - - // 2. Null/Undefined return with console logs - resNull, xe := engine.Execute(nil, `console.log("hello"); null;`, nil) - if xe != nil { - t.Fatalf("unexpected execution error: %v", xe) - } - if resNull.JSONString != "null" || !strings.Contains(resNull.SummaryText, "hello") { - t.Fatalf("expected null return with logs, got %q", resNull.SummaryText) - } - - // 3. JSON String return - resJSON, xe := engine.Execute(nil, `JSON.stringify({status: "ok"})`, nil) - if xe != nil { - t.Fatalf("unexpected execution error: %v", xe) - } - if !strings.Contains(resJSON.JSONString, `"status": "ok"`) { - t.Fatalf("expected pretty formatted JSON string, got %q", resJSON.JSONString) - } -} diff --git a/internal/session/store.go b/internal/session/store.go deleted file mode 100644 index fbafeaa5..00000000 --- a/internal/session/store.go +++ /dev/null @@ -1,108 +0,0 @@ -package session - -import ( - "fmt" - "strings" - "sync" - - "github.com/zx06/xsql/internal/db" -) - -type DatasetEntry struct { - ID string `json:"id"` - Description string `json:"description"` - Result *db.QueryResult `json:"result"` -} - -type SessionDataStore struct { - mu sync.RWMutex - datasets map[string]*DatasetEntry - order []string - counter int -} - -func NewSessionDataStore() *SessionDataStore { - return &SessionDataStore{ - datasets: make(map[string]*DatasetEntry), - order: make([]string, 0), - } -} - -func (s *SessionDataStore) Save(description string, result *db.QueryResult) string { - if result == nil { - return "" - } - s.mu.Lock() - defer s.mu.Unlock() - - s.counter++ - id := fmt.Sprintf("res%d", s.counter) - - entry := &DatasetEntry{ - ID: id, - Description: strings.TrimSpace(description), - Result: result, - } - - s.datasets[id] = entry - s.order = append(s.order, id) - return id -} - -func (s *SessionDataStore) Get(id string) (*db.QueryResult, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - - entry, ok := s.datasets[id] - if !ok || entry == nil { - return nil, false - } - return entry.Result, true -} - -func (s *SessionDataStore) Latest() (*db.QueryResult, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - - if len(s.order) == 0 { - return nil, false - } - lastID := s.order[len(s.order)-1] - return s.datasets[lastID].Result, true -} - -func (s *SessionDataStore) GetAll() map[string]*db.QueryResult { - s.mu.RLock() - defer s.mu.RUnlock() - - res := make(map[string]*db.QueryResult, len(s.datasets)) - for k, v := range s.datasets { - res[k] = v.Result - } - return res -} - -func (s *SessionDataStore) GetCatalog() string { - s.mu.RLock() - defer s.mu.RUnlock() - - if len(s.datasets) == 0 { - return "(No active datasets in current session)" - } - - var sb strings.Builder - sb.WriteString("Active Datasets in Session:\n") - for _, id := range s.order { - entry := s.datasets[id] - if entry == nil || entry.Result == nil { - continue - } - cols := strings.Join(entry.Result.Columns, ", ") - desc := entry.Description - if desc == "" { - desc = "Query Result" - } - fmt.Fprintf(&sb, "- `%s`: %s (%d rows, columns: [%s])\n", id, desc, len(entry.Result.Rows), cols) - } - return sb.String() -} diff --git a/internal/session/store_test.go b/internal/session/store_test.go deleted file mode 100644 index eeef1502..00000000 --- a/internal/session/store_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package session - -import ( - "strings" - "testing" - - "github.com/zx06/xsql/internal/db" -) - -func TestSessionDataStore(t *testing.T) { - store := NewSessionDataStore() - - if catalog := store.GetCatalog(); !strings.Contains(catalog, "No active datasets") { - t.Fatalf("expected empty catalog warning, got: %s", catalog) - } - - res1 := &db.QueryResult{ - Columns: []string{"id", "name"}, - Rows: []map[string]any{{"id": 1, "name": "srv1"}}, - } - id1 := store.Save("servers query", res1) - if id1 != "res1" { - t.Fatalf("expected ID 'res1', got %s", id1) - } - - res2 := &db.QueryResult{ - Columns: []string{"id", "severity"}, - Rows: []map[string]any{{"id": 1, "severity": "HIGH"}}, - } - id2 := store.Save("alerts query", res2) - if id2 != "res2" { - t.Fatalf("expected ID 'res2', got %s", id2) - } - - latest, ok := store.Latest() - if !ok || len(latest.Rows) != 1 || latest.Rows[0]["severity"] != "HIGH" { - t.Fatal("failed to retrieve latest dataset") - } - - catalog := store.GetCatalog() - if !strings.Contains(catalog, "`res1`") || !strings.Contains(catalog, "`res2`") { - t.Fatalf("expected catalog to list res1 and res2, got:\n%s", catalog) - } - - got1, ok := store.Get("res1") - if !ok || got1.Rows[0]["name"] != "srv1" { - t.Fatal("failed to get res1 from store") - } - - all := store.GetAll() - if len(all) != 2 || all["res1"] == nil || all["res2"] == nil { - t.Fatalf("expected GetAll to return 2 datasets, got %d", len(all)) - } -} diff --git a/internal/tui/components.go b/internal/tui/components.go deleted file mode 100644 index d12f1f07..00000000 --- a/internal/tui/components.go +++ /dev/null @@ -1,307 +0,0 @@ -package tui - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" - "github.com/mattn/go-runewidth" - - "github.com/zx06/xsql/internal/db" -) - -var ( - TableHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#4F46E5", Dark: "#818CF8"}). - Padding(0, 1) - - TableCellStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#1E293B", Dark: "#F1F5F9"}). - Padding(0, 1) - - TableNilStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#94A3B8", Dark: "#64748B"}). - Italic(true). - Padding(0, 1) - - TableBorderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#CBD5E1", Dark: "#334155"}) - - ActiveTableBorderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#6366F1", Dark: "#818CF8"}) - - FieldKeyStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#4F46E5", Dark: "#818CF8"}) - - FieldValueStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#1E293B", Dark: "#F1F5F9"}) - - RecordDividerStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(PrimaryColor) - - ScrollBadgeStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#BE185D", Dark: "#F472B6"}) -) - -const ( - MaxColumnWidth = 24 // Max character width per cell to keep grid compact - PageRowSize = 12 // Rows per page -) - -// FormatTableResult renders a beautiful terminal box table for SQL query results with column scrolling and row pagination. -func FormatTableResult(result *db.QueryResult, colOffset int, rowOffset int, termWidth int, isActive bool) string { - if result == nil || len(result.Columns) == 0 { - return lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(No columns or empty dataset returned)") - } - - if len(result.Rows) == 0 { - return lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(0 rows returned)") - } - - if termWidth <= 20 { - termWidth = 80 - } - - totalRows := len(result.Rows) - if rowOffset >= totalRows { - rowOffset = (totalRows - 1) / PageRowSize * PageRowSize - } - if rowOffset < 0 { - rowOffset = 0 - } - - // Calculate maximum display width needed for each column using runewidth for CJK support - totalCols := len(result.Columns) - if colOffset >= totalCols { - colOffset = totalCols - 1 - } - if colOffset < 0 { - colOffset = 0 - } - - colWidths := make([]int, totalCols) - for i, col := range result.Columns { - w := runewidth.StringWidth(col) - if w > MaxColumnWidth { - w = MaxColumnWidth - } - // Inspect current page rows for width calculation - endR := rowOffset + PageRowSize - if endR > totalRows { - endR = totalRows - } - for r := rowOffset; r < endR; r++ { - val := result.Rows[r][col] - if val != nil { - cellStr := fmt.Sprintf("%v", val) - cellStr = strings.ReplaceAll(cellStr, "\n", " ") - dispLen := runewidth.StringWidth(cellStr) - if dispLen > w { - w = dispLen - } - } - } - if w > MaxColumnWidth { - w = MaxColumnWidth - } - if w < 6 { - w = 6 - } - // Add padding (2 chars) + border (1 char) - colWidths[i] = w + 3 - } - - // Determine visible column range [startCol, endCol) that fits within termWidth - 8 - availWidth := termWidth - 8 - if availWidth < 30 { - availWidth = 30 - } - - startCol := colOffset - endCol := startCol - accumWidth := 0 - - for i := startCol; i < totalCols; i++ { - if accumWidth+colWidths[i] > availWidth && endCol > startCol { - break - } - accumWidth += colWidths[i] - endCol = i + 1 - } - - visibleCols := result.Columns[startCol:endCol] - - // Truncate and clean visible headers - headers := make([]string, len(visibleCols)) - for i, col := range visibleCols { - colIdx := startCol + i - headerText := sanitizeCell(col, colWidths[colIdx]-3) - if i == 0 && startCol > 0 { - headerText = "◀ " + headerText - } - if i == len(visibleCols)-1 && endCol < totalCols { - headerText = headerText + fmt.Sprintf(" ▶(+%d)", totalCols-endCol) - } - headers[i] = headerText - } - - borderStyle := TableBorderStyle - if isActive { - borderStyle = ActiveTableBorderStyle - } - - t := table.New(). - Border(lipgloss.RoundedBorder()). - BorderStyle(borderStyle). - Headers(headers...) - - // Configure header styling - t.StyleFunc(func(row, col int) lipgloss.Style { - if row == table.HeaderRow { - return TableHeaderStyle - } - return TableCellStyle - }) - - // Add page rows [rowOffset, min(totalRows, rowOffset+PageRowSize)) - endRow := rowOffset + PageRowSize - if endRow > totalRows { - endRow = totalRows - } - - hasTruncatedCell := false - - for i := rowOffset; i < endRow; i++ { - var rowValues []string - for idx, col := range visibleCols { - colIdx := startCol + idx - val := result.Rows[i][col] - if val == nil { - rowValues = append(rowValues, TableNilStyle.Render("NULL")) - } else { - cellStr, wasTruncated := sanitizeCellWithStatus(val, colWidths[colIdx]-3) - if wasTruncated { - hasTruncatedCell = true - } - rowValues = append(rowValues, cellStr) - } - } - t.Row(rowValues...) - } - - var sb strings.Builder - sb.WriteString(t.Render()) - - var footerNotes []string - if isActive { - footerNotes = append(footerNotes, "[FOCUSED]") - } - if totalRows > PageRowSize { - footerNotes = append(footerNotes, fmt.Sprintf("rows %d-%d of %d (Press PgUp/PgDn for Page)", rowOffset+1, endRow, totalRows)) - } - if startCol > 0 || endCol < totalCols { - footerNotes = append(footerNotes, fmt.Sprintf("cols %d-%d of %d (Use ←/→ keys for Cols)", startCol+1, endCol, totalCols)) - } - if hasTruncatedCell { - footerNotes = append(footerNotes, "press Ctrl+E for Full View (Expand/Collapse)") - } - - if len(footerNotes) > 0 { - noteStr := "\n(" + strings.Join(footerNotes, " | ") + ")" - if isActive { - sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#7AA2F7")).Bold(true).Render(noteStr)) - } else { - sb.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render(noteStr)) - } - } - - return sb.String() -} - -// FormatVerticalResult renders SQL query results in full vertical (psql \x expanded) format with NO truncation. -func FormatVerticalResult(result *db.QueryResult) string { - if result == nil || len(result.Columns) == 0 { - return lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(No columns or empty dataset returned)") - } - - if len(result.Rows) == 0 { - return lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(0 rows returned)") - } - - maxKeyLen := 0 - for _, col := range result.Columns { - w := runewidth.StringWidth(col) - if w > maxKeyLen { - maxKeyLen = w - } - } - - maxRows := len(result.Rows) - if maxRows > 500 { - maxRows = 500 - } - - var sb strings.Builder - for i := 0; i < maxRows; i++ { - divider := fmt.Sprintf("━ Record %d of %d ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", i+1, len(result.Rows)) - sb.WriteString(RecordDividerStyle.Render(divider) + "\n") - - for _, col := range result.Columns { - val := result.Rows[i][col] - keyWidth := runewidth.StringWidth(col) - padding := strings.Repeat(" ", max(0, maxKeyLen-keyWidth)) - keyStr := FieldKeyStyle.Render(col + padding) - - if val == nil { - fmt.Fprintf(&sb, " %s : %s\n", keyStr, TableNilStyle.Render("NULL")) - } else { - valStr := fmt.Sprintf("%v", val) - // Full display with indentation for multiline text - if strings.Contains(valStr, "\n") { - indented := strings.ReplaceAll(valStr, "\n", "\n ") - fmt.Fprintf(&sb, " %s :\n %s\n", keyStr, FieldValueStyle.Render(indented)) - } else { - fmt.Fprintf(&sb, " %s : %s\n", keyStr, FieldValueStyle.Render(valStr)) - } - } - } - sb.WriteString("\n") - } - - if len(result.Rows) > 500 { - sb.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render(fmt.Sprintf("... and %d more rows (truncated for performance)\n", len(result.Rows)-500))) - } - - return sb.String() -} - -func sanitizeCell(val any, maxLen int) string { - res, _ := sanitizeCellWithStatus(val, maxLen) - return res -} - -func sanitizeCellWithStatus(val any, maxLen int) (string, bool) { - if val == nil { - return "NULL", false - } - s := fmt.Sprintf("%v", val) - // Replace all line breaks with spaces so the box border line never breaks - s = strings.ReplaceAll(s, "\r\n", " ") - s = strings.ReplaceAll(s, "\n", " ") - s = strings.ReplaceAll(s, "\r", " ") - s = strings.TrimSpace(s) - - dispLen := runewidth.StringWidth(s) - if dispLen > maxLen { - if maxLen <= 3 { - return runewidth.Truncate(s, maxLen, ""), true - } - return runewidth.Truncate(s, maxLen, "..."), true - } - return s, false -} diff --git a/internal/tui/components_test.go b/internal/tui/components_test.go deleted file mode 100644 index b2be21d4..00000000 --- a/internal/tui/components_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package tui - -import ( - "strings" - "testing" - - "github.com/mattn/go-runewidth" - "github.com/zx06/xsql/internal/db" -) - -func TestSanitizeCellWithCJK(t *testing.T) { - // Test Chinese wide character display width calculation & truncation - cjkStr := "机器下架" - if runewidth.StringWidth(cjkStr) != 8 { - t.Fatalf("expected runewidth 8 for '机器下架', got %d", runewidth.StringWidth(cjkStr)) - } - - sanitized, wasTruncated := sanitizeCellWithStatus(cjkStr, 6) - if !wasTruncated { - t.Fatal("expected wasTruncated to be true") - } - if strings.Contains(sanitized, "\n") { - t.Fatal("sanitized string must never contain newlines") - } -} - -func TestFormatTableResult_CJKBorderProtection(t *testing.T) { - res := &db.QueryResult{ - Columns: []string{"id", "status"}, - Rows: []map[string]any{ - {"id": "1", "status": "故障"}, - {"id": "2", "status": "机器下架"}, - {"id": "3", "status": "正常运行"}, - }, - } - - formatted := FormatTableResult(res, 0, 0, 80, true) - lines := strings.Split(formatted, "\n") - - // Verify that rows are strictly single line per data row - for i, line := range lines { - if strings.Contains(line, "故障") { - if !strings.Contains(line, "1") { - t.Errorf("line %d split '故障' into new line: %q", i, line) - } - } - } - - // Nil and empty result fallbacks - if nilOut := FormatTableResult(nil, 0, 0, 80, false); !strings.Contains(nilOut, "empty dataset") { - t.Errorf("expected empty dataset message for nil QueryResult, got %q", nilOut) - } - - if nilVert := FormatVerticalResult(nil); !strings.Contains(nilVert, "empty dataset") { - t.Errorf("expected empty dataset message for nil QueryResult vertical view, got %q", nilVert) - } - - emptyRes := &db.QueryResult{Columns: []string{"id"}, Rows: []map[string]any{}} - if emptyOut := FormatTableResult(emptyRes, 0, 0, 80, false); !strings.Contains(emptyOut, "0 rows returned") { - t.Fatalf("expected '0 rows returned', got %q", emptyOut) - } - - // Test offset and inactive view - offsetFormatted := FormatTableResult(res, 1, 1, 40, false) - if !strings.Contains(offsetFormatted, "status") { - t.Errorf("expected status column in offset view, got:\n%s", offsetFormatted) - } - - // Test sanitizeCell & sanitizeCellWithStatus - if s, _ := sanitizeCellWithStatus(nil, 10); s != "NULL" { - t.Errorf("expected 'NULL' for nil input, got %q", s) - } - - if s := sanitizeCell("short", 10); s != "short" { - t.Errorf("expected 'short', got %q", s) - } -} diff --git a/internal/tui/model.go b/internal/tui/model.go deleted file mode 100644 index 14bfe311..00000000 --- a/internal/tui/model.go +++ /dev/null @@ -1,1235 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "sort" - "strings" - "time" - - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/zx06/xsql/internal/ai" - "github.com/zx06/xsql/internal/app" - "github.com/zx06/xsql/internal/config" - "github.com/zx06/xsql/internal/db" - "github.com/zx06/xsql/internal/errors" - "github.com/zx06/xsql/internal/export" - "github.com/zx06/xsql/internal/js" - "github.com/zx06/xsql/internal/session" -) - -type State int - -const ( - StateLoadingSchema State = iota - StateIdle - StateThinking - StateSQLReady - StateExecuting - StateExportReady -) - -const ( - defaultTUIQueryTimeout = 30 * time.Second - defaultTUISchemaTimeout = 60 * time.Second -) - -// Msg types -type schemaLoadedMsg struct { - schema *db.SchemaInfo - err *errors.XError -} - -type aiResponseMsg struct { - response *ai.AIResponse - err *errors.XError -} - -type queryExecutedMsg struct { - result *db.QueryResult - err *errors.XError - duration time.Duration -} - -type TableState struct { - Result *db.QueryResult - MsgIndex int - ColOffset int - RowOffset int - VerticalView bool -} - -type ToolCallItem struct { - ID string - Name string - Summary string - Detail string - Result string - RawOutput string // Raw execution output/logs (never hidden!) - TableStateIndex int // -1 if no table attached - MsgIndex int - IsExpanded bool -} - -type PendingExport struct { - IsReport bool - Content string - DatasetID string - Format string - FilePath string - ToolIdx int -} - -type Model struct { - aiService *ai.Service - profile config.Profile - profileName string - allProfiles map[string]config.Profile - profileList []string - unsafeAllowWrite bool - cliAllowWrite bool - aiModel string - initialPrompt string - autoExecute bool - - sessionStore *session.SessionDataStore - jsEngine *js.JSEngine - chatHistory []ai.ChatMessage - pendingActions []ai.ToolAction - pendingExport *PendingExport - jsRetryCount int - maxJSRetries int - aiRetryCount int - maxAIRetries int - lastCtrlCTime time.Time - - confirmOption int // 0: Confirm/Execute, 1: Adjust Prompt, 2: Cancel/Deny - - state State - schemaInfo *db.SchemaInfo - currentSQL string - explanation string - messages []string - tableStates []TableState - toolCalls []ToolCallItem - activeTable int - activeToolIdx int - - isDark bool - - textarea textarea.Model - viewport viewport.Model - spinner spinner.Model - - width int - height int -} - -func NewModel(_ config.Options, resolved config.Resolved, aiService *ai.Service, initialPrompt string, unsafeAllowWrite bool) Model { - ta := textarea.New() - ta.Placeholder = "Ask AI to query database or perform data analysis..." - ta.ShowLineNumbers = false - ta.Prompt = "" - ta.Focus() - ta.CharLimit = 4000 - ta.SetWidth(80) - ta.SetHeight(3) - - // Custom crisp styles for textarea - ta.FocusedStyle.CursorLine = lipgloss.NewStyle() - - vp := viewport.New(80, 15) - - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(PrimaryColor) - - var pList []string - if len(resolved.AllProfiles) > 0 { - for name := range resolved.AllProfiles { - pList = append(pList, name) - } - sort.Strings(pList) - } else if resolved.ProfileName != "" { - pList = []string{resolved.ProfileName} - } - - m := Model{ - aiService: aiService, - profile: resolved.Profile, - profileName: resolved.ProfileName, - allProfiles: resolved.AllProfiles, - profileList: pList, - unsafeAllowWrite: unsafeAllowWrite && resolved.Profile.UnsafeAllowWrite, - cliAllowWrite: unsafeAllowWrite, - aiModel: resolved.AI.Model, - initialPrompt: strings.TrimSpace(initialPrompt), - autoExecute: false, - sessionStore: session.NewSessionDataStore(), - jsEngine: js.NewJSEngine(1 * time.Minute), - chatHistory: []ai.ChatMessage{}, - jsRetryCount: 0, - maxJSRetries: 3, - aiRetryCount: 0, - maxAIRetries: 2, - confirmOption: 0, - tableStates: []TableState{}, - toolCalls: []ToolCallItem{}, - activeTable: -1, - activeToolIdx: -1, - isDark: DetectDarkBackground(), - state: StateLoadingSchema, - textarea: ta, - viewport: vp, - spinner: s, - messages: []string{}, - width: 80, - height: 24, - } - SetThemeDark(m.isDark) - return m -} - -func (m Model) Init() tea.Cmd { - return tea.Batch( - m.spinner.Tick, - m.loadSchemaCmd(), - WatchThemeChangesCmd(m.isDark), - ) -} - -func (m Model) loadSchemaCmd() tea.Cmd { - return func() tea.Msg { - timeout := app.SchemaTimeout(m.profile, 0, false, defaultTUISchemaTimeout) - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - info, xe := app.DumpSchema(ctx, app.SchemaDumpRequest{ - Profile: m.profile, - AllowPlaintext: m.profile.AllowPlaintext, - SkipHostKeyCheck: m.profile.SSHConfig != nil && m.profile.SSHConfig.SkipHostKey, - }) - return schemaLoadedMsg{schema: info, err: xe} - } -} - -func (m Model) runAgentStepCmd() tea.Cmd { - return func() tea.Msg { - ctx := context.Background() - catalog := m.sessionStore.GetCatalog() - sysPrompt := ai.BuildSystemPrompt(m.profile.DB, m.schemaInfo, catalog) - - msgs := make([]ai.ChatMessage, 0, len(m.chatHistory)+1) - msgs = append(msgs, ai.ChatMessage{Role: "system", Content: sysPrompt}) - for _, item := range m.chatHistory { - if item.Role != "system" { - msgs = append(msgs, item) - } - } - - resp, xe := m.aiService.ChatCompletion(ctx, msgs) - return aiResponseMsg{response: resp, err: xe} - } -} - -func (m Model) executeSQLCmd(sqlStr string) tea.Cmd { - return func() tea.Msg { - start := time.Now() - timeout := app.QueryTimeout(m.profile, 0, false, defaultTUIQueryTimeout) - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - maxRows, mxe := app.ResolveQueryMaxRows(m.profile, "", 0, false) - if mxe != nil { - return queryExecutedMsg{err: mxe} - } - res, xe := app.Query(ctx, app.QueryRequest{ - Profile: m.profile, - SQL: sqlStr, - AllowPlaintext: m.profile.AllowPlaintext, - SkipHostKeyCheck: m.profile.SSHConfig != nil && m.profile.SSHConfig.SkipHostKey, - UnsafeAllowWrite: m.unsafeAllowWrite, - MaxRows: maxRows, - TruncationHint: app.QueryTruncationHint, - }) - elapsed := time.Since(start) - return queryExecutedMsg{result: res, err: xe, duration: elapsed} - } -} - -func (m *Model) focusToolCall(idx int) { - if len(m.toolCalls) == 0 { - return - } - if idx < 0 { - idx = len(m.toolCalls) - 1 - } else if idx >= len(m.toolCalls) { - idx = 0 - } - - oldIdx := m.activeToolIdx - m.activeToolIdx = idx - - // Sync embedded table focus if tool call has attached table - if tc := m.toolCalls[idx]; tc.TableStateIndex >= 0 { - m.activeTable = tc.TableStateIndex - } - - if oldIdx >= 0 && oldIdx < len(m.toolCalls) { - m.renderToolCall(oldIdx) - } - m.renderToolCall(m.activeToolIdx) -} - -func (m *Model) renderTableState(idx int, isActive bool) { - if idx < 0 || idx >= len(m.tableStates) { - return - } - ts := &m.tableStates[idx] - - // Find associated ToolCallItem if embedded - for i := range m.toolCalls { - if m.toolCalls[i].TableStateIndex == idx { - m.renderToolCall(i) - return - } - } - - if ts.MsgIndex < 0 || ts.MsgIndex >= len(m.messages) { - return - } - formatted := FormatTableResult(ts.Result, ts.ColOffset, ts.RowOffset, m.width, isActive) - if ts.VerticalView { - formatted = FormatVerticalResult(ts.Result) - } - m.messages[ts.MsgIndex] = formatted - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) -} - -func (m *Model) renderToolCall(idx int) { - if idx < 0 || idx >= len(m.toolCalls) { - return - } - tc := &m.toolCalls[idx] - if tc.MsgIndex < 0 || tc.MsgIndex >= len(m.messages) { - return - } - - isActiveTool := (idx == m.activeToolIdx) - activeMarker := "" - if isActiveTool && len(m.toolCalls) > 1 { - activeMarker = fmt.Sprintf(" 🎯[Tool %d/%d]", idx+1, len(m.toolCalls)) - } - - var sb strings.Builder - if !tc.IsExpanded { - badge := ToolCollapsedBadge.Render("▶ 🛠️ Tool: " + tc.Name + activeMarker) - summary := MetricsStyle.Render(fmt.Sprintf("%s (Folded - Press Ctrl+O to unfold)", tc.Summary)) - fmt.Fprintf(&sb, "%s %s", badge, summary) - } else { - badge := ToolExpandedBadge.Render("▼ 🛠️ Tool: " + tc.Name + activeMarker) - - detailCode := tc.Detail - switch tc.Name { - case "execute_sql": - 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) - resText := MetricsStyle.Render(tc.Result) - fmt.Fprintf(&sb, "%s\n%s\n%s", badge, detail, resText) - - // Render Raw Output / Calculation Results if present (Never hide tool output!) - if tc.RawOutput != "" { - outTitle := lipgloss.NewStyle().Bold(true).Foreground(SecondaryColor).Render("📊 Raw Execution Output:") - outBox := ToolDetailStyle.Render(tc.RawOutput) - fmt.Fprintf(&sb, "\n%s\n%s", outTitle, outBox) - } - - // Render embedded Table Result inside container when unfolded - if tc.TableStateIndex >= 0 && tc.TableStateIndex < len(m.tableStates) { - ts := &m.tableStates[tc.TableStateIndex] - tableStr := FormatTableResult(ts.Result, ts.ColOffset, ts.RowOffset, m.width, tc.TableStateIndex == m.activeTable) - if ts.VerticalView { - tableStr = FormatVerticalResult(ts.Result) - } - sb.WriteString("\n\n" + tableStr) - } - } - - m.messages[tc.MsgIndex] = sb.String() - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) -} - -func (m *Model) executeNextPendingAction() (tea.Model, tea.Cmd) { - if len(m.pendingActions) == 0 { - m.state = StateThinking - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return *m, m.runAgentStepCmd() - } - - act := m.pendingActions[0] - m.pendingActions = m.pendingActions[1:] - - switch act.Type { - case ai.TypeJS: - m.chatHistory = append(m.chatHistory, ai.ChatMessage{ - Role: "assistant", - Content: fmt.Sprintf("Call tool 'execute_javascript':\n%s", act.JSCode), - }) - - lineCount := len(strings.Split(act.JSCode, "\n")) - tc := ToolCallItem{ - ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), - Name: "execute_javascript", - Summary: fmt.Sprintf("Executing %d lines of JS data analysis", lineCount), - Detail: act.JSCode, - 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) - - ctx := context.Background() - jsRes, jsErr := m.jsEngine.Execute(ctx, act.JSCode, m.sessionStore) - if jsErr != nil { - m.jsRetryCount++ - m.toolCalls[toolIdx].Result = fmt.Sprintf("❌ Failed: %v", jsErr.Message) - m.renderToolCall(toolIdx) - - if m.jsRetryCount <= m.maxJSRetries { - retryWarn := ErrorMsgStyle.Render(fmt.Sprintf("⚠️ JS Execution Failed (Attempt %d/%d): %v", m.jsRetryCount, m.maxJSRetries, jsErr.Message)) - m.messages = append(m.messages, retryWarn) - - m.chatHistory = append(m.chatHistory, ai.ChatMessage{ - Role: "user", - Content: fmt.Sprintf("Tool 'execute_javascript' failed with error: %s. Please fix the code and call 'execute_javascript' again.", jsErr.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("❌ JS Execution Error (after %d retries): %v", m.maxJSRetries, jsErr.Message))) - m.jsRetryCount = 0 - m.pendingActions = nil - m.state = StateIdle - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return *m, nil - } - - m.jsRetryCount = 0 - m.toolCalls[toolIdx].Result = "✓ JavaScript executed successfully" - m.toolCalls[toolIdx].RawOutput = jsRes.SummaryText - m.renderToolCall(toolIdx) - - feedback := ai.BoundToolFeedback(jsRes.SummaryText) - m.chatHistory = append(m.chatHistory, ai.ChatMessage{ - Role: "user", - Content: fmt.Sprintf("Tool 'execute_javascript' executed successfully. Bounded derived output:\n%s", feedback), - }) - - return m.executeNextPendingAction() - - case ai.TypeSQL: - m.chatHistory = append(m.chatHistory, ai.ChatMessage{ - Role: "assistant", - Content: fmt.Sprintf("Call tool 'execute_sql': %s", act.SQL), - }) - - m.currentSQL = act.SQL - - tc := ToolCallItem{ - ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), - Name: "execute_sql", - Summary: m.currentSQL, - Detail: m.currentSQL, - 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) - - if m.autoExecute { - m.state = StateExecuting - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return *m, m.executeSQLCmd(m.currentSQL) - } - m.confirmOption = 0 - m.state = StateSQLReady - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return *m, nil - - case ai.TypeExport: - m.chatHistory = append(m.chatHistory, ai.ChatMessage{ - Role: "assistant", - Content: fmt.Sprintf("Call tool 'export_data': dataset_id=%s, format=%s, filepath=%s", act.DatasetID, act.Format, act.FilePath), - }) - - tc := ToolCallItem{ - ID: fmt.Sprintf("tc_%d", len(m.toolCalls)+1), - Name: "export_data", - Summary: fmt.Sprintf("Export %s to %s (%s) [Pending User Confirmation]", act.DatasetID, act.FilePath, strings.ToUpper(act.Format)), - Detail: fmt.Sprintf("Dataset: %s | FilePath: %s | Format: %s", act.DatasetID, act.FilePath, act.Format), - 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: false, - DatasetID: act.DatasetID, - Format: act.Format, - 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 - - 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 -} - -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.textarea.SetWidth(max(20, msg.Width-6)) - 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))) - } else { - m.schemaInfo = msg.schema - } - if m.initialPrompt != "" { - prompt := m.initialPrompt - m.initialPrompt = "" - - userHeader := UserTagStyle.Render("👤 YOU") - var userLine string - if strings.Contains(prompt, "\n") { - userLine = fmt.Sprintf("%s\n%s", userHeader, prompt) - } else { - userLine = fmt.Sprintf("%s %s", userHeader, prompt) - } - - m.messages = append(m.messages, userLine) - m.chatHistory = append(m.chatHistory, ai.ChatMessage{Role: "user", Content: prompt}) - m.state = StateThinking - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return m, m.runAgentStepCmd() - } - m.state = StateIdle - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - - case aiResponseMsg: - if msg.err != nil { - 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 - if len(actions) == 0 { - // Backwards compatibility if single response fields are set without Actions slice - if msg.response.Type == ai.TypeJS && msg.response.JSCode != "" { - actions = []ai.ToolAction{{Type: ai.TypeJS, JSCode: msg.response.JSCode, Explanation: msg.response.Explanation}} - } else if msg.response.Type == ai.TypeSQL && msg.response.SQL != "" { - 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}} - } - } - - if len(actions) > 0 { - m.pendingActions = append(m.pendingActions, actions...) - return m.executeNextPendingAction() - } - - // FINAL LLM AGENT OUTPUT (No Tool Call) - exp := msg.response.Explanation - m.chatHistory = append(m.chatHistory, ai.ChatMessage{ - Role: "assistant", - Content: exp, - }) - - if exp != "" { - renderedMD := RenderMarkdownWithTheme(exp, m.width, m.isDark) - aiMsg := AITagStyle.Render("🤖 AI") + "\n" + renderedMD - m.messages = append(m.messages, aiMsg) - } - m.state = StateIdle - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return m, nil - - case queryExecutedMsg: - if msg.err != nil { - errText := fmt.Sprintf("SQL Exec Error [%s]: %s", msg.err.Code, msg.err.Message) - m.messages = append(m.messages, ErrorMsgStyle.Render(errText)) - - m.chatHistory = append(m.chatHistory, ai.ChatMessage{ - Role: "user", - Content: fmt.Sprintf("Tool 'execute_sql' failed with error: %s", errText), - }) - - m.pendingActions = nil - m.state = StateThinking - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return m, m.runAgentStepCmd() - } else if msg.result != nil { - datasetID := m.sessionStore.Save(m.currentSQL, msg.result) - - modelName := m.aiModel - if modelName == "" { - modelName = "gpt-4o" - } - durStr := msg.duration.Round(time.Millisecond).String() - if msg.duration < time.Millisecond { - durStr = fmt.Sprintf("%.2fms", float64(msg.duration.Microseconds())/1000.0) - } - metricsStr := fmt.Sprintf("⏱️ %s | 📊 %d rows | 🤖 %s | 💾 %s", durStr, len(msg.result.Rows), modelName, datasetID) - statusLine := SuccessBadgeStyle.Render("✓ Execution Success") + " " + MetricsStyle.Render(metricsStr) - - ts := TableState{ - Result: msg.result, - MsgIndex: -1, - ColOffset: 0, - RowOffset: 0, - VerticalView: false, - } - m.tableStates = append(m.tableStates, ts) - tableIdx := len(m.tableStates) - 1 - - // Attach TableStateIndex directly inside execute_sql ToolCallItem - if len(m.toolCalls) > 0 { - lastIdx := len(m.toolCalls) - 1 - if m.toolCalls[lastIdx].Name == "execute_sql" { - m.toolCalls[lastIdx].Result = statusLine - m.toolCalls[lastIdx].TableStateIndex = tableIdx - m.focusToolCall(lastIdx) - } - } - - m.chatHistory = append(m.chatHistory, ai.ChatMessage{ - Role: "user", - Content: fmt.Sprintf("Tool 'execute_sql' executed successfully. Returned %d rows (columns: %v). Dataset saved as '%s'.", len(msg.result.Rows), msg.result.Columns, datasetID), - }) - - return m.executeNextPendingAction() - } - m.state = StateIdle - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return m, nil - - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - cmds = append(cmds, cmd) - - case tea.KeyMsg: - // CTRL+C TWICE TO QUIT MECHANISM (Like Claude Code / Aider) - if msg.Type == tea.KeyCtrlC { - if time.Since(m.lastCtrlCTime) < 2*time.Second { - return m, tea.Quit - } - m.lastCtrlCTime = time.Now() - warnMsg := WarningBadgeStyle.Render("⚠️ Press Ctrl+C again to exit xsql AI") - m.messages = append(m.messages, warnMsg) - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return m, nil - } - - // UNIFIED HUMAN-IN-THE-LOOP INTERACTION FOR BOTH StateExportReady AND StateSQLReady! - if m.state == StateExportReady && m.pendingExport != nil { - switch msg.Type { - case tea.KeyUp: - m.confirmOption = (m.confirmOption - 1 + 3) % 3 - return m, nil - case tea.KeyDown: - m.confirmOption = (m.confirmOption + 1) % 3 - return m, nil - } - - triggerOpt := -1 - if msg.Type == tea.KeyEnter { - triggerOpt = m.confirmOption - } else if msg.String() == "1" { - triggerOpt = 0 - } else if msg.String() == "2" { - triggerOpt = 1 - } else if msg.String() == "3" || msg.Type == tea.KeyEsc { - triggerOpt = 2 - } - - switch triggerOpt { - case 0: - // 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 '%s' failed: %v", toolName, xe.Message), - }) - } else { - m.toolCalls[m.pendingExport.ToolIdx].Result = fmt.Sprintf("✓ %s to '%s'", actionDesc, outPath) - m.renderToolCall(m.pendingExport.ToolIdx) - - 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 '%s' executed successfully. %s to local file '%s'.", toolName, actionDesc, outPath), - }) - } - m.pendingExport = nil - return m.executeNextPendingAction() - - case 1: - // Option 2: Adjust Prompt - m.state = StateIdle - m.pendingExport = nil - m.pendingActions = nil - m.textarea.Focus() - return m, nil - - 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: fmt.Sprintf("Tool '%s' was denied by user.", toolName), - }) - m.pendingExport = nil - m.pendingActions = nil - m.state = StateThinking - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return m, m.runAgentStepCmd() - } - return m, nil - } - - if m.state == StateSQLReady { - switch msg.Type { - case tea.KeyUp: - m.confirmOption = (m.confirmOption - 1 + 3) % 3 - return m, nil - case tea.KeyDown: - m.confirmOption = (m.confirmOption + 1) % 3 - return m, nil - } - - triggerOpt := -1 - if msg.Type == tea.KeyEnter { - triggerOpt = m.confirmOption - } else if msg.String() == "1" { - triggerOpt = 0 - } else if msg.String() == "2" { - triggerOpt = 1 - } else if msg.String() == "3" || msg.Type == tea.KeyEsc { - triggerOpt = 2 - } - - switch triggerOpt { - case 0: - // Option 1: Execute SQL - m.state = StateExecuting - m.textarea.Focus() - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return m, m.executeSQLCmd(m.currentSQL) - - case 1: - // Option 2: Adjust Prompt / Re-generate - m.state = StateIdle - m.pendingActions = nil - m.textarea.Focus() - return m, nil - - case 2: - // Option 3: Cancel Execution - m.state = StateIdle - m.pendingActions = nil - m.textarea.Focus() - return m, nil - } - } - - // MULTI-LINE PROMPT INPUT SHORTCUT: Alt+Enter to insert soft newline into textarea - if m.state == StateIdle && ((msg.Alt && msg.Type == tea.KeyEnter) || msg.String() == "alt+enter") { - m.textarea.InsertString("\n") - return m, nil - } - - switch msg.Type { - case tea.KeyEsc: - // ESC CLEARS PROMPT INPUT BOX (No longer quits application) - if m.state == StateIdle { - m.textarea.Reset() - return m, nil - } - - case tea.KeyCtrlP: - // PROFILE SWITCHING FEATURE: Cycle active profile & synchronize with AI agent context! - if len(m.profileList) > 1 { - currIdx := -1 - for i, name := range m.profileList { - if name == m.profileName { - currIdx = i - break - } - } - nextIdx := (currIdx + 1) % len(m.profileList) - nextProfileName := m.profileList[nextIdx] - - newP, ok := m.allProfiles[nextProfileName] - if ok { - if newP.SSHProxy != "" && newP.SSHConfig == nil { - errLine := ErrorMsgStyle.Render(fmt.Sprintf("Cannot switch to profile '%s': ssh_proxy '%s' not found", nextProfileName, newP.SSHProxy)) - m.messages = append(m.messages, errLine) - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return m, nil - } - if newP.Port == 0 { - switch newP.DB { - case "mysql": - newP.Port = 3306 - case "pg": - newP.Port = 5432 - } - } - - m.profileName = nextProfileName - m.profile = newP - m.unsafeAllowWrite = m.cliAllowWrite && newP.UnsafeAllowWrite - m.schemaInfo = nil - m.state = StateLoadingSchema - - switchLine := SuccessBadgeStyle.Render(fmt.Sprintf("✓ Switched active profile to '%s' (%s)", m.profileName, m.profile.DB)) - m.messages = append(m.messages, switchLine) - - m.chatHistory = append(m.chatHistory, ai.ChatMessage{ - Role: "user", - Content: fmt.Sprintf("System Notice: Switched active database profile to '%s' (Database: %s). New database schema metadata is being loaded.", m.profileName, m.profile.DB), - }) - - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return m, m.loadSchemaCmd() - } - } - - case tea.KeyCtrlO: - // Toggle folding/unfolding of currently active/focused ToolCallItem - if len(m.toolCalls) > 0 { - if m.activeToolIdx < 0 || m.activeToolIdx >= len(m.toolCalls) { - m.activeToolIdx = len(m.toolCalls) - 1 - } - m.toolCalls[m.activeToolIdx].IsExpanded = !m.toolCalls[m.activeToolIdx].IsExpanded - m.renderToolCall(m.activeToolIdx) - return m, nil - } - - case tea.KeyTab: - // Tab: Cycle focus between Tool Call Containers - if len(m.toolCalls) > 0 { - m.focusToolCall((m.activeToolIdx + 1) % len(m.toolCalls)) - return m, nil - } - - case tea.KeyShiftTab: - // Shift+Tab: Toggle AUTO-EXECUTE / MANUAL approval mode - m.autoExecute = !m.autoExecute - - case tea.KeyCtrlE: - if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { - ts := &m.tableStates[m.activeTable] - ts.VerticalView = !ts.VerticalView - m.renderTableState(m.activeTable, true) - } - - case tea.KeyLeft: - if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { - ts := &m.tableStates[m.activeTable] - if ts.ColOffset > 0 { - ts.ColOffset-- - m.renderTableState(m.activeTable, true) - } - } - - case tea.KeyRight: - if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { - ts := &m.tableStates[m.activeTable] - if ts.ColOffset < len(ts.Result.Columns)-1 { - ts.ColOffset++ - m.renderTableState(m.activeTable, true) - } - } - - case tea.KeyPgUp: - if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { - ts := &m.tableStates[m.activeTable] - if ts.RowOffset >= PageRowSize { - ts.RowOffset -= PageRowSize - m.renderTableState(m.activeTable, true) - return m, nil - } - } - m.viewport.LineUp(6) - return m, nil - - case tea.KeyPgDown: - if m.activeTable >= 0 && m.activeTable < len(m.tableStates) { - ts := &m.tableStates[m.activeTable] - if ts.RowOffset+PageRowSize < len(ts.Result.Rows) { - ts.RowOffset += PageRowSize - m.renderTableState(m.activeTable, true) - return m, nil - } - } - 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 - - case tea.KeyDown: - m.viewport.LineDown(1) - return m, nil - - case tea.KeyEnter: - prompt := strings.TrimSpace(m.textarea.Value()) - if prompt != "" && m.state == StateIdle { - userHeader := UserTagStyle.Render("👤 YOU") - var userLine string - if strings.Contains(prompt, "\n") { - userLine = fmt.Sprintf("%s\n%s", userHeader, prompt) - } else { - userLine = fmt.Sprintf("%s %s", userHeader, prompt) - } - - m.messages = append(m.messages, userLine) - m.chatHistory = append(m.chatHistory, ai.ChatMessage{Role: "user", Content: prompt}) - m.textarea.Reset() - m.state = StateThinking - m.viewport.SetContent(strings.Join(m.messages, "\n\n")) - m.viewport.GotoBottom() - return m, m.runAgentStepCmd() - } - } - } - - var taCmd tea.Cmd - m.textarea, taCmd = m.textarea.Update(msg) - cmds = append(cmds, taCmd) - - var vpCmd tea.Cmd - m.viewport, vpCmd = m.viewport.Update(msg) - cmds = append(cmds, vpCmd) - - return m, tea.Batch(cmds...) -} - -func renderKeybindingBadges(items [][2]string) string { - var parts []string - for _, item := range items { - keyBadge := KeyBadgeStyle.Render(item[0]) - label := KeyLabelStyle.Render(item[1]) - parts = append(parts, fmt.Sprintf("%s %s", keyBadge, label)) - } - return strings.Join(parts, " ") -} - -func renderActionOptionsCard(title string, detailText string, options []string, activeOpt int, width int) string { - var sb strings.Builder - sb.WriteString(SQLTitleStyle.Render(title) + "\n") - if detailText != "" { - sb.WriteString(detailText + "\n\n") - } - sb.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Render("Action Options (Use ↑/↓ or 1/2/3 to select, Enter to confirm):") + "\n") - for i, opt := range options { - if i == activeOpt { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(PrimaryColor).Render(fmt.Sprintf(" ▶ [%d] %s", i+1, opt)) + "\n") - } else { - sb.WriteString(lipgloss.NewStyle().Foreground(MutedColor).Render(fmt.Sprintf(" [%d] %s", i+1, opt)) + "\n") - } - } - return SQLBoxStyle.Width(width - 4).Render(sb.String()) -} - -func (m Model) View() string { - var sb strings.Builder - - // 1. Full-Width Header Bar - titlePill := HeaderTitleBadge.Render("xsql AI") - profilePill := HeaderProfileBadge.Render(fmt.Sprintf("%s (%s)", m.profileName, m.profile.DB)) - - modePill := BadgeReadOnly.Render("READ-ONLY") - if m.unsafeAllowWrite { - modePill = BadgeReadWrite.Render("READ-WRITE") - } - - execPill := BadgeManualApprove.Render("MANUAL") - if m.autoExecute { - execPill = BadgeAutoExec.Render("AUTO-EXEC") - } - - headerContent := fmt.Sprintf("%s %s %s %s", titlePill, profilePill, modePill, execPill) - header := HeaderBarStyle.Width(m.width).Render(headerContent) - sb.WriteString(header + "\n\n") - - // 2. Main Viewport - sb.WriteString(m.viewport.View() + "\n\n") - - // 3. State Status & Unified SQL / Export Action Selection Card - switch m.state { - case StateLoadingSchema: - sb.WriteString(m.spinner.View() + fmt.Sprintf(" Loading database schema for profile '%s'...\n", m.profileName)) - case StateThinking: - sb.WriteString(m.spinner.View() + " AI is analyzing schema and executing tools...\n") - case StateExecuting: - 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( - title, - SQLCodeStyle.Render(exportInfo), - options, - m.confirmOption, - m.width, - ) - sb.WriteString(card + "\n") - } - case StateSQLReady: - sqlContent := HighlightSQL(m.currentSQL) - if m.currentSQL == "" { - sqlContent = lipgloss.NewStyle().Foreground(MutedColor).Italic(true).Render("(No SQL generated)") - } - card := renderActionOptionsCard( - "✨ SQL Approval Required", - sqlContent, - []string{"Execute SQL Query", "Adjust Prompt / Re-generate", "Cancel Execution"}, - m.confirmOption, - m.width, - ) - sb.WriteString(card + "\n") - } - - // 4. Ultra-Minimal Prompt Input Area & Dynamic Divider Rules - divLen := m.width - 4 - if divLen < 10 { - divLen = 10 - } - separator := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#CBD5E1", Dark: "#334155"}).Render(strings.Repeat("─", divLen)) - - sb.WriteString(separator + "\n") - sb.WriteString(m.textarea.View() + "\n") - sb.WriteString(separator + "\n\n") - - execModeHint := "MANUAL" - if m.autoExecute { - execModeHint = "AUTO" - } - - toolFoldState := "Folded" - if m.activeToolIdx >= 0 && m.activeToolIdx < len(m.toolCalls) && m.toolCalls[m.activeToolIdx].IsExpanded { - toolFoldState = "Unfolded" - } - - toolNavHint := "" - if len(m.toolCalls) > 1 { - toolNavHint = fmt.Sprintf(" [%d/%d]", m.activeToolIdx+1, len(m.toolCalls)) - } - - var keybindings string - if m.state == StateExportReady || m.state == StateSQLReady { - keybindings = renderKeybindingBadges([][2]string{ - {"↑/↓", "Select Option"}, - {"Enter", "Confirm"}, - {"1/2/3", "Quick Select"}, - {"Esc", "Cancel"}, - }) - } else { - profileBadge := "" - 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"}, - {"Shift+Tab", "Mode (" + execModeHint + ")"}, - {"Esc", "Clear"}, - {"Ctrl+C", "Quit (x2)"}, - }) - } - - sb.WriteString(keybindings + "\n") - - return sb.String() -} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go deleted file mode 100644 index 50060d21..00000000 --- a/internal/tui/model_test.go +++ /dev/null @@ -1,937 +0,0 @@ -package tui - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" - - tea "github.com/charmbracelet/bubbletea" - - "github.com/zx06/xsql/internal/ai" - "github.com/zx06/xsql/internal/config" - "github.com/zx06/xsql/internal/db" - "github.com/zx06/xsql/internal/errors" -) - -func TestTUI_Model_StateTransitions(t *testing.T) { - resolved := config.Resolved{ - ProfileName: "dev", - Profile: config.Profile{ - DB: "mysql", - }, - } - aiService := ai.NewService(config.AIConfig{}, nil) - m := NewModel(config.Options{}, resolved, aiService, "", false) - - // Initial State should be StateLoadingSchema - if m.state != StateLoadingSchema { - t.Fatalf("expected initial state StateLoadingSchema, got %v", m.state) - } - - // 1. Send schemaLoadedMsg -> transition to StateIdle - updated, _ := m.Update(schemaLoadedMsg{ - schema: &db.SchemaInfo{Database: "testdb"}, - }) - m = updated.(Model) - if m.state != StateIdle { - t.Fatalf("expected state StateIdle, got %v", m.state) - } - - // 2. Send aiResponseMsg -> transition to StateSQLReady - updated, _ = m.Update(aiResponseMsg{ - response: &ai.AIResponse{ - Type: ai.TypeSQL, - SQL: "SELECT * FROM users;", - Explanation: "Returns all users.", - }, - }) - m = updated.(Model) - if m.state != StateSQLReady { - t.Fatalf("expected state StateSQLReady, got %v", m.state) - } - if m.currentSQL != "SELECT * FROM users;" { - t.Errorf("expected SQL 'SELECT * FROM users;', got %q", m.currentSQL) - } - - // 3. Test View Output Rendering - viewStr := m.View() - if !strings.Contains(viewStr, "xsql AI") { - t.Errorf("expected view to contain header 'xsql AI', got:\n%s", viewStr) - } - if !strings.Contains(viewStr, "READ-ONLY") { - t.Errorf("expected view to contain READ-ONLY badge, got:\n%s", viewStr) - } - if !strings.Contains(viewStr, "SELECT * FROM users;") { - t.Errorf("expected view to contain SQL Preview, got:\n%s", viewStr) - } - - // 4. Test KeyMsg KeyEnter in StateSQLReady -> transition to StateExecuting - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - m = updated.(Model) - if m.state != StateExecuting { - t.Fatalf("expected state StateExecuting after KeyEnter, got %v", m.state) - } - if cmd == nil { - t.Fatal("expected non-nil Cmd for executeSQLCmd") - } - - // 5. Send queryExecutedMsg -> Agent loops back with runAgentStepCmd (StateThinking) - updated, cmd = m.Update(queryExecutedMsg{ - result: &db.QueryResult{ - Columns: []string{"id", "name"}, - Rows: []map[string]any{{"id": 1, "name": "Alice"}}, - }, - }) - m = updated.(Model) - if m.state != StateThinking { - t.Fatalf("expected state StateThinking while Agent processes tool result, got %v", m.state) - } - if cmd == nil { - t.Fatal("expected non-nil Cmd for runAgentStepCmd after query execution") - } - - // 6. Send final aiResponseMsg (TypeText) -> transition to StateIdle - updated, _ = m.Update(aiResponseMsg{ - response: &ai.AIResponse{ - Type: ai.TypeText, - Explanation: "Found 1 user named Alice.", - }, - }) - m = updated.(Model) - if m.state != StateIdle { - t.Fatalf("expected state StateIdle after final text response, got %v", m.state) - } - - // View output should contain query result - viewStr = m.View() - if !strings.Contains(viewStr, "Alice") { - t.Errorf("expected view to contain result 'Alice', got:\n%s", viewStr) - } -} - -func TestTUI_Model_WriteGate(t *testing.T) { - tests := []struct { - name string - profileAllowsWrite bool - cliAllowsWrite bool - wantWrite bool - }{ - {name: "neither enabled"}, - {name: "CLI only", cliAllowsWrite: true}, - {name: "profile only", profileAllowsWrite: true}, - {name: "both enabled", profileAllowsWrite: true, cliAllowsWrite: true, wantWrite: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resolved := config.Resolved{ - ProfileName: "dev", - Profile: config.Profile{ - DB: "mysql", - UnsafeAllowWrite: tt.profileAllowsWrite, - }, - } - m := NewModel(config.Options{}, resolved, ai.NewService(config.AIConfig{}, nil), "", tt.cliAllowsWrite) - - if m.unsafeAllowWrite != tt.wantWrite { - t.Fatalf("unsafeAllowWrite = %v, want %v", m.unsafeAllowWrite, tt.wantWrite) - } - if tt.wantWrite && !strings.Contains(m.View(), "READ-WRITE") { - t.Fatalf("expected READ-WRITE badge, got:\n%s", m.View()) - } - if !tt.wantWrite && !strings.Contains(m.View(), "READ-ONLY") { - t.Fatalf("expected READ-ONLY badge, got:\n%s", m.View()) - } - }) - } -} - -func TestFormatTableResult(t *testing.T) { - res := &db.QueryResult{ - Columns: []string{"id", "username", "extra_json"}, - Rows: []map[string]any{ - {"id": 1, "username": "admin", "extra_json": "{\n \"key\": \"very long value that exceeds column limit\"\n}"}, - {"id": 2, "username": "guest", "extra_json": nil}, - }, - } - - formatted := FormatTableResult(res, 0, 0, 80, true) - if !strings.Contains(formatted, "admin") || !strings.Contains(formatted, "guest") { - t.Errorf("formatted table result missing row data:\n%s", formatted) - } - if !strings.Contains(formatted, "NULL") { - t.Errorf("expected NULL representation for nil value, got:\n%s", formatted) - } - if strings.Contains(formatted, "\n \"key\"") { - t.Errorf("expected newlines inside cells to be sanitized, got:\n%s", formatted) - } - - vertFormatted := FormatVerticalResult(res) - if !strings.Contains(vertFormatted, "Record 1 of 2") { - t.Errorf("expected vertical view header, got:\n%s", vertFormatted) - } - if !strings.Contains(vertFormatted, "very long value that exceeds column limit") { - t.Errorf("expected vertical view to display untruncated multiline text, got:\n%s", vertFormatted) - } -} - -func TestTUI_Model_InitialPromptAutoExecute(t *testing.T) { - resolved := config.Resolved{ - ProfileName: "dev", - Profile: config.Profile{DB: "mysql"}, - } - aiService := ai.NewService(config.AIConfig{}, nil) - m := NewModel(config.Options{}, resolved, aiService, "Show total users", false) - - // When schemaLoadedMsg arrives, initialPrompt should automatically trigger StateThinking - updated, cmd := m.Update(schemaLoadedMsg{ - schema: &db.SchemaInfo{Database: "testdb"}, - }) - m = updated.(Model) - - if m.state != StateThinking { - t.Fatalf("expected state StateThinking when initialPrompt provided, got %v", m.state) - } - if cmd == nil { - t.Fatal("expected non-nil Cmd for generateSQLCmd from initial prompt") - } -} - -func TestTUI_Model_ShiftTabAutoExecuteToggle(t *testing.T) { - resolved := config.Resolved{ - ProfileName: "dev", - Profile: config.Profile{DB: "mysql"}, - } - aiService := ai.NewService(config.AIConfig{}, nil) - m := NewModel(config.Options{}, resolved, aiService, "", false) - - if m.autoExecute { - t.Fatal("expected autoExecute to be false by default") - } - - // Press Shift+Tab -> toggle to autoExecute = true - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) - m = updated.(Model) - if !m.autoExecute { - t.Fatal("expected autoExecute to be true after Shift+Tab") - } - - // Send aiResponseMsg -> should automatically transition to StateExecuting - updated, cmd := m.Update(aiResponseMsg{ - response: &ai.AIResponse{ - Type: ai.TypeSQL, - SQL: "SELECT * FROM users;", - Explanation: "Returns users.", - }, - }) - m = updated.(Model) - if m.state != StateExecuting { - t.Fatalf("expected state StateExecuting when autoExecute is true, got %v", m.state) - } - if cmd == nil { - t.Fatal("expected non-nil executeSQLCmd for auto-execution") - } -} - -func TestTUI_Model_ActionOptionsCardFlow(t *testing.T) { - resolved := config.Resolved{ - ProfileName: "dev", - Profile: config.Profile{DB: "mysql"}, - } - aiService := ai.NewService(config.AIConfig{}, nil) - m := NewModel(config.Options{}, resolved, aiService, "", false) - m.state = StateSQLReady - m.currentSQL = "SELECT * FROM users LIMIT 10;" - - // 1. Press Down -> switches confirmOption to 1 (Adjust Prompt) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) - m = updated.(Model) - if m.confirmOption != 1 { - t.Fatalf("expected confirmOption to be 1 after KeyDown, got %d", m.confirmOption) - } - - // 2. Press Enter -> Option 1 returns to StateIdle for adjusting prompt - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - m = updated.(Model) - if m.state != StateIdle { - t.Fatalf("expected state StateIdle after selecting Adjust Prompt option, got %v", m.state) - } - - // 3. Reset to StateSQLReady and press Enter on Option 0 -> transitions to StateExecuting - m.state = StateSQLReady - m.confirmOption = 0 - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - m = updated.(Model) - if m.state != StateExecuting { - t.Fatalf("expected state StateExecuting after confirming execution, got %v", m.state) - } - if cmd == nil { - t.Fatal("expected non-nil executeSQLCmd for executing SQL") - } -} - -func TestTUI_Model_CtrlCTwiceToQuit(t *testing.T) { - resolved := config.Resolved{ProfileName: "dev", Profile: config.Profile{DB: "mysql"}} - aiService := ai.NewService(config.AIConfig{}, nil) - m := NewModel(config.Options{}, resolved, aiService, "", false) - - // 1st Ctrl+C -> should NOT quit, but set warning line - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) - m = updated.(Model) - if cmd != nil { - t.Fatal("expected 1st Ctrl+C to NOT return quit cmd") - } - - // 2nd Ctrl+C immediately -> returns tea.Quit - _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) - if cmd == nil { - t.Fatal("expected 2nd Ctrl+C to return tea.Quit") - } -} - -func TestTUI_Model_EscClearsTextarea(t *testing.T) { - resolved := config.Resolved{ProfileName: "dev", Profile: config.Profile{DB: "mysql"}} - aiService := ai.NewService(config.AIConfig{}, nil) - m := NewModel(config.Options{}, resolved, aiService, "", false) - m.state = StateIdle - m.textarea.SetValue("draft prompt to clear") - - // Press Esc in StateIdle -> clears prompt - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) - m = updated.(Model) - if m.textarea.Value() != "" { - t.Fatalf("expected textarea to be cleared after Esc, got %q", m.textarea.Value()) - } -} - -func TestTUI_Model_CtrlPProfileSwitching(t *testing.T) { - allProfiles := map[string]config.Profile{ - "dev": {DB: "mysql", UnsafeAllowWrite: true}, - "prod": {DB: "pg"}, - } - resolved := config.Resolved{ - ProfileName: "dev", - Profile: allProfiles["dev"], - AllProfiles: allProfiles, - } - aiService := ai.NewService(config.AIConfig{}, nil) - m := NewModel(config.Options{}, resolved, aiService, "", true) - if !m.unsafeAllowWrite { - t.Fatal("expected write mode when both CLI and initial profile allow writes") - } - - // Press Ctrl+P -> switches profile to 'prod' - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) - m = updated.(Model) - if m.profileName != "prod" { - t.Fatalf("expected profileName to switch to 'prod', got %q", m.profileName) - } - if m.profile.DB != "pg" { - t.Fatalf("expected profile DB to be 'pg', got %q", m.profile.DB) - } - if m.unsafeAllowWrite { - t.Fatal("expected write mode to be disabled after switching to a profile that does not allow writes") - } - if cmd == nil { - t.Fatal("expected loadSchemaCmd after switching profile") - } -} - -func TestTUI_Model_ProfileSwitchRejectsMissingSSHProxy(t *testing.T) { - allProfiles := map[string]config.Profile{ - "dev": {DB: "mysql"}, - "prod": {DB: "pg", SSHProxy: "missing"}, - } - resolved := config.Resolved{ - ProfileName: "dev", - Profile: allProfiles["dev"], - AllProfiles: allProfiles, - } - m := NewModel(config.Options{}, resolved, ai.NewService(config.AIConfig{}, nil), "", false) - - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) - m = updated.(Model) - if cmd != nil { - t.Fatal("expected invalid profile switch to avoid loading schema") - } - if m.profileName != "dev" { - t.Fatalf("expected active profile to remain dev, got %q", m.profileName) - } - if !strings.Contains(strings.Join(m.messages, "\n"), "ssh_proxy 'missing' not found") { - t.Fatalf("expected missing ssh_proxy error, got %v", m.messages) - } -} - -func TestTUI_Model_ToolCallsAndTableRendering(t *testing.T) { - resolved := config.Resolved{ProfileName: "dev", Profile: config.Profile{DB: "mysql"}} - aiService := ai.NewService(config.AIConfig{}, nil) - m := NewModel(config.Options{}, resolved, aiService, "", false) - m.state = StateIdle - m.messages = append(m.messages, "", "") - - tc := ToolCallItem{ - ID: "tc_1", - Name: "execute_sql", - Summary: "SELECT * FROM users", - Detail: "SELECT * FROM users", - Result: "✓ Execution Success", - TableStateIndex: -1, - MsgIndex: 0, - IsExpanded: false, - } - m.toolCalls = append(m.toolCalls, tc) - - // Test focusToolCall & renderToolCall - m.focusToolCall(0) - if m.activeToolIdx != 0 { - t.Fatalf("expected activeToolIdx 0, got %d", m.activeToolIdx) - } - - // Toggle expanded via Ctrl+O - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) - m = updated.(Model) - if !m.toolCalls[0].IsExpanded { - t.Fatal("expected toolCall to be expanded after Ctrl+O") - } - - // Test WindowSizeMsg - updated, _ = m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) - m = updated.(Model) - if m.width != 100 || m.height != 40 { - t.Fatalf("expected width 100, height 40, got %d, %d", m.width, m.height) - } -} - -func TestTUI_Model_ExportFlow(t *testing.T) { - resolved := config.Resolved{ProfileName: "dev", Profile: config.Profile{DB: "mysql"}} - aiService := ai.NewService(config.AIConfig{}, nil) - m := NewModel(config.Options{}, resolved, aiService, "", false) - - // Simulate aiResponseMsg returning TypeExport - updated, _ := m.Update(aiResponseMsg{ - response: &ai.AIResponse{ - Type: ai.TypeExport, - DatasetID: "res1", - Format: "csv", - FilePath: "test.csv", - }, - }) - m = updated.(Model) - if m.state != StateExportReady { - t.Fatalf("expected StateExportReady, got %v", m.state) - } - if m.pendingExport == nil { - t.Fatal("expected pendingExport to be non-nil") - } - - // Select option 3 (Deny / Cancel Export) via Key '3' - 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 export, got %v", m.state) - } - 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) { - resolved := config.Resolved{ - ProfileName: "dev", - Profile: config.Profile{DB: "mysql", AllowPlaintext: true}, - } - aiService := ai.NewService(config.AIConfig{}, nil) - m := NewModel(config.Options{}, resolved, aiService, "", false) - - // 1. Test Init & loadSchemaCmd - initCmd := m.Init() - if initCmd == nil { - t.Fatal("expected non-nil Init Cmd") - } - - loadCmd := m.loadSchemaCmd() - if loadCmd == nil { - t.Fatal("expected non-nil loadSchemaCmd") - } - _ = loadCmd() // execute closure statements - - // 2. Test runAgentStepCmd & executeSQLCmd closures - stepCmd := m.runAgentStepCmd() - if stepCmd != nil { - _ = stepCmd() - } - - execCmd := m.executeSQLCmd("SELECT 1") - if execCmd != nil { - _ = execCmd() - } - - // 3. Test JS response and raw output rendering - updated, _ := m.Update(aiResponseMsg{ - response: &ai.AIResponse{ - Type: ai.TypeJS, - JSCode: "var x = 1;", - Explanation: "Run JS script", - }, - }) - m = updated.(Model) - - // 4. Test queryExecutedMsg with TableResult and TableState - res := &db.QueryResult{ - Columns: []string{"id", "name"}, - Rows: []map[string]any{{"id": 1, "name": "Alice"}}, - } - updated, _ = m.Update(queryExecutedMsg{ - result: res, - duration: 10 * time.Millisecond, - }) - m = updated.(Model) - - // 5. Test Key Navigation & renderTableState - m.tableStates = append(m.tableStates, TableState{ - Result: res, - MsgIndex: 0, - VerticalView: false, - }) - m.messages = []string{"msg0"} - m.renderTableState(0, true) - m.renderTableState(0, false) - m.tableStates[0].VerticalView = true - m.renderTableState(0, true) - - m.Update(tea.KeyMsg{Type: tea.KeyTab}) - m.Update(tea.KeyMsg{Type: tea.KeyRight}) - m.Update(tea.KeyMsg{Type: tea.KeyLeft}) - m.Update(tea.KeyMsg{Type: tea.KeyPgDown}) - m.Update(tea.KeyMsg{Type: tea.KeyPgUp}) - m.Update(tea.KeyMsg{Type: tea.KeyCtrlE}) - - exportPath := filepath.Join(t.TempDir(), "test.csv") - - // 6. Test Export Option 1 (Confirm Export) - m.state = StateExportReady - m.pendingExport = &PendingExport{ - DatasetID: "res1", - Format: "csv", - FilePath: exportPath, - } - m.confirmOption = 0 - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - m = updated.(Model) - - // 7. Test Export Option 2 (Adjust Export Prompt) - m.state = StateExportReady - m.toolCalls = []ToolCallItem{{ID: "tc_1", Name: "export_data"}} - m.pendingExport = &PendingExport{ - DatasetID: "res1", - Format: "csv", - FilePath: exportPath, - ToolIdx: 0, - } - m.confirmOption = 1 - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - m = updated.(Model) - if m.state != StateIdle { - t.Fatalf("expected StateIdle after Adjust Prompt option, got %v", m.state) - } -} - -func TestTUI_Model_ViewAndAllStatesCoverage(t *testing.T) { - resolved := config.Resolved{ - ProfileName: "dev", - Profile: config.Profile{DB: "mysql", UnsafeAllowWrite: true}, - } - aiService := ai.NewService(config.AIConfig{}, nil) - m := NewModel(config.Options{}, resolved, aiService, "", true) // unsafeAllowWrite = true - m.autoExecute = true - - // 1. Test View in StateLoadingSchema - m.state = StateLoadingSchema - if view := m.View(); !strings.Contains(view, "READ-WRITE") || !strings.Contains(view, "AUTO-EXEC") { - t.Fatalf("expected badges in header view, got:\n%s", view) - } - - // 2. Test View in all states - states := []State{ - StateIdle, StateThinking, StateSQLReady, StateExecuting, StateExportReady, - } - for _, st := range states { - m.state = st - _ = m.View() - } - - // 3. Test schemaLoadedMsg with error - updated, _ := m.Update(schemaLoadedMsg{ - err: errors.New("XSQL_CFG_INVALID", "invalid config", nil), - }) - m = updated.(Model) - - // 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 exhausting AI retries, got %v", m.state) - } - - updated, _ = m.Update(aiResponseMsg{ - response: &ai.AIResponse{ - Type: ai.TypeText, - Explanation: "Here is text response", - }, - }) - m = updated.(Model) - if m.state != StateIdle { - t.Fatalf("expected StateIdle after TypeText response, got %v", m.state) - } - - // 5. Test queryExecutedMsg with error - updated, _ = m.Update(queryExecutedMsg{ - err: errors.New("XSQL_SQL_SYNTAX_ERROR", "syntax error", nil), - }) - m = updated.(Model) - - // 6. Test Option keys '1', '2', '3' in StateSQLReady - m.state = StateSQLReady - m.currentSQL = "SELECT 1;" - - m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) - m.state = StateSQLReady - m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}}) - m.state = StateSQLReady - m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}}) - - // 7. Test focusToolCall out of bounds & renderTableState invalid indices - m.focusToolCall(-1) - m.focusToolCall(999) - m.renderTableState(-1, false) - m.renderTableState(999, false) -} - -func TestModel_MultipleToolCalls_SequentialExecution(t *testing.T) { - opts := config.Options{ - CLIAIModel: "test-model", - } - resolved := config.Resolved{ - Profile: config.Profile{ - DB: "mysql", - }, - ProfileName: "default", - } - aiClient := ai.NewClient(config.AIConfig{Provider: "openai", APIKey: "test"}, nil) - aiService := ai.NewService(config.AIConfig{Provider: "openai", APIKey: "test"}, aiClient) - - m := NewModel(opts, resolved, aiService, "", false) - m.autoExecute = true // Auto-execute SQL so actions flow automatically - - // 1. Initial schema loaded - updated, _ := m.Update(schemaLoadedMsg{ - schema: &db.SchemaInfo{Database: "testdb"}, - }) - m = updated.(Model) - - // 2. Receive aiResponseMsg with 2 actions: SQL + JS - multiResp := &ai.AIResponse{ - Actions: []ai.ToolAction{ - { - Type: ai.TypeSQL, - SQL: "SELECT id, val FROM metrics;", - Explanation: "Query metrics", - }, - { - Type: ai.TypeJS, - JSCode: "var sum = res1.rows.reduce(function(acc, r) { return acc + r.val; }, 0); ({ sum: sum });", - Explanation: "Sum metrics", - }, - }, - } - - updated, cmd := m.Update(aiResponseMsg{response: multiResp}) - m = updated.(Model) - if m.state != StateExecuting { - t.Fatalf("expected StateExecuting after 1st SQL action, got %v", m.state) - } - if cmd == nil { - t.Fatal("expected non-nil Cmd for executeSQLCmd") - } - - // 3. Query finishes execution -> should trigger the next pending JS action! - updated, cmd = m.Update(queryExecutedMsg{ - result: &db.QueryResult{ - Columns: []string{"id", "val"}, - Rows: []map[string]any{ - {"id": 1, "val": 10}, - {"id": 2, "val": 20}, - }, - }, - }) - m = updated.(Model) - - // Since JS executes synchronously and there are no more actions, state should transition to StateThinking with runAgentStepCmd - if m.state != StateThinking { - t.Fatalf("expected StateThinking after completing all actions in queue, got %v", m.state) - } - if cmd == nil { - t.Fatal("expected runAgentStepCmd after all actions finish") - } - if len(m.toolCalls) != 2 { - t.Fatalf("expected 2 tool calls rendered, got %d", len(m.toolCalls)) - } - if m.toolCalls[0].Name != "execute_sql" || m.toolCalls[1].Name != "execute_javascript" { - t.Errorf("unexpected tool call names: %s, %s", m.toolCalls[0].Name, m.toolCalls[1].Name) - } - - // 4. Send final AI answer - updated, _ = m.Update(aiResponseMsg{ - response: &ai.AIResponse{ - Type: ai.TypeText, - Explanation: "The total sum is 30.", - }, - }) - m = updated.(Model) - if m.state != StateIdle { - t.Fatalf("expected StateIdle after final text response, got %v", m.state) - } -} - -func TestTUI_Model_ThemeAutoDetection(t *testing.T) { - // Test Default Dark - resolved := config.Resolved{ - ProfileName: "default", - Profile: config.Profile{DB: "mysql"}, - } - t.Setenv("XSQL_THEME", "dark") - mDark := NewModel(config.Options{}, resolved, nil, "", false) - if !mDark.isDark { - t.Fatal("expected isDark == true when XSQL_THEME=dark") - } - - // Test Light Mode auto-detection via env var - t.Setenv("XSQL_THEME", "light") - mLight := NewModel(config.Options{}, resolved, nil, "", false) - if mLight.isDark { - t.Fatal("expected isDark == false when XSQL_THEME=light") - } - - // Test COLORFGBG detection - t.Setenv("XSQL_THEME", "") - t.Setenv("COLORFGBG", "0;15") // Light background (bg=15) - mLight2 := NewModel(config.Options{}, resolved, nil, "", false) - if mLight2.isDark { - t.Fatal("expected isDark == false when COLORFGBG='0;15'") - } - - t.Setenv("COLORFGBG", "15;0") // Dark background (bg=0) - mDark2 := NewModel(config.Options{}, resolved, nil, "", false) - if !mDark2.isDark { - 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 deleted file mode 100644 index fa6b5e2e..00000000 --- a/internal/tui/render.go +++ /dev/null @@ -1,647 +0,0 @@ -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" -) - -var ( - // High-Contrast Adaptive Styles for SQL & JS Syntax Highlighting (Light & Dark mode compatible) - KeywordStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "#6D28D9", Dark: "#C084FC"}) // Rich Purple - StringStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#047857", Dark: "#34D399"}) // Emerald Green - NumberStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#B45309", Dark: "#FBBF24"}) // Amber Gold - NameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "#0369A1", Dark: "#38BDF8"}) // Sky Blue - CommentStyle = lipgloss.NewStyle().Italic(true).Foreground(lipgloss.AdaptiveColor{Light: "#64748B", Dark: "#94A3B8"}) // Slate Grey - DefaultTxtStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "#0F172A", Dark: "#F8FAFC"}) // Deep Slate / Crisp White -) - -func stringPtr(s string) *string { - return &s -} - -func boolPtr(b bool) *bool { - return &b -} - -func uintPtr(u uint) *uint { - return &u -} - -// XSQLDarkMarkdownStyle defines a refined, high-contrast Dark theme for Glamour. -// It eliminates any OSC 11 background probe (which caused garbled terminal echo in input box) -// 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{}, - Margin: uintPtr(0), - }, - BlockQuote: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr("#94A3B8"), - }, - Indent: uintPtr(1), - IndentToken: stringPtr("│ "), - }, - List: ansi.StyleList{ - LevelIndent: 2, - }, - Heading: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - BlockSuffix: "\n", - Bold: boolPtr(true), - }, - }, - H1: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: " ", - Suffix: " ", - Color: stringPtr("#FFFFFF"), - BackgroundColor: stringPtr("#6D28D9"), - Bold: boolPtr(true), - }, - }, - H2: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "## ", - Color: stringPtr("#818CF8"), // Vibrant Indigo - Bold: boolPtr(true), - }, - }, - H3: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "### ", - Color: stringPtr("#38BDF8"), // Sky Blue - Bold: boolPtr(true), - }, - }, - H4: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "#### ", - Color: stringPtr("#34D399"), // Emerald Green - Bold: boolPtr(true), - }, - }, - H5: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "##### ", - Color: stringPtr("#F472B6"), // Pink - Bold: boolPtr(true), - }, - }, - H6: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "###### ", - Color: stringPtr("#94A3B8"), // Slate Grey - Bold: boolPtr(false), - }, - }, - Text: ansi.StylePrimitive{}, - Strikethrough: ansi.StylePrimitive{ - CrossedOut: boolPtr(true), - }, - Emph: ansi.StylePrimitive{ - Italic: boolPtr(true), - Color: stringPtr("#E2E8F0"), - }, - Strong: ansi.StylePrimitive{ - Bold: boolPtr(true), - Color: stringPtr("#FFFFFF"), - }, - HorizontalRule: ansi.StylePrimitive{ - Color: stringPtr("#475569"), - Format: "\n────────\n", - }, - Item: ansi.StylePrimitive{ - BlockPrefix: "• ", - Color: stringPtr("#818CF8"), - }, - Enumeration: ansi.StylePrimitive{ - BlockPrefix: ". ", - Color: stringPtr("#818CF8"), - }, - Task: ansi.StyleTask{ - StylePrimitive: ansi.StylePrimitive{}, - Ticked: "[✓] ", - Unticked: "[ ] ", - }, - Link: ansi.StylePrimitive{ - Color: stringPtr("#38BDF8"), - Underline: boolPtr(true), - }, - LinkText: ansi.StylePrimitive{ - Color: stringPtr("#818CF8"), - Bold: boolPtr(true), - }, - Code: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: " ", - Suffix: " ", - Color: stringPtr("#F472B6"), - BackgroundColor: stringPtr("#1E293B"), - }, - }, - CodeBlock: ansi.StyleCodeBlock{ - StyleBlock: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr("#F8FAFC"), - }, - Margin: uintPtr(0), - }, - Chroma: &ansi.Chroma{ - Text: ansi.StylePrimitive{ - Color: stringPtr("#F8FAFC"), - }, - Error: ansi.StylePrimitive{ - Color: stringPtr("#F8FAFC"), - BackgroundColor: stringPtr("#EF4444"), - }, - Comment: ansi.StylePrimitive{ - Color: stringPtr("#94A3B8"), - }, - CommentPreproc: ansi.StylePrimitive{ - Color: stringPtr("#F472B6"), - }, - Keyword: ansi.StylePrimitive{ - Color: stringPtr("#C084FC"), - Bold: boolPtr(true), - }, - KeywordReserved: ansi.StylePrimitive{ - Color: stringPtr("#F472B6"), - Bold: boolPtr(true), - }, - KeywordNamespace: ansi.StylePrimitive{ - Color: stringPtr("#F43F5E"), - }, - KeywordType: ansi.StylePrimitive{ - Color: stringPtr("#A78BFA"), - }, - Operator: ansi.StylePrimitive{ - Color: stringPtr("#38BDF8"), - }, - Punctuation: ansi.StylePrimitive{ - Color: stringPtr("#CBD5E1"), - }, - Name: ansi.StylePrimitive{ - Color: stringPtr("#F8FAFC"), - }, - NameBuiltin: ansi.StylePrimitive{ - Color: stringPtr("#38BDF8"), - }, - NameTag: ansi.StylePrimitive{ - Color: stringPtr("#F472B6"), - }, - NameAttribute: ansi.StylePrimitive{ - Color: stringPtr("#A78BFA"), - }, - NameClass: ansi.StylePrimitive{ - Color: stringPtr("#FBBF24"), - Bold: boolPtr(true), - }, - NameConstant: ansi.StylePrimitive{ - Color: stringPtr("#F472B6"), - }, - NameDecorator: ansi.StylePrimitive{ - Color: stringPtr("#FBBF24"), - }, - NameFunction: ansi.StylePrimitive{ - Color: stringPtr("#34D399"), - Bold: boolPtr(true), - }, - LiteralNumber: ansi.StylePrimitive{ - Color: stringPtr("#FBBF24"), - }, - LiteralString: ansi.StylePrimitive{ - Color: stringPtr("#34D399"), - }, - LiteralStringEscape: ansi.StylePrimitive{ - Color: stringPtr("#2DD4BF"), - }, - GenericDeleted: ansi.StylePrimitive{ - Color: stringPtr("#F87171"), - }, - GenericEmph: ansi.StylePrimitive{ - Italic: boolPtr(true), - }, - GenericInserted: ansi.StylePrimitive{ - Color: stringPtr("#34D399"), - }, - GenericStrong: ansi.StylePrimitive{ - Bold: boolPtr(true), - }, - GenericSubheading: ansi.StylePrimitive{ - Color: stringPtr("#94A3B8"), - }, - Background: ansi.StylePrimitive{ - BackgroundColor: stringPtr("#0F172A"), - }, - }, - }, - Table: ansi.StyleTable{ - StyleBlock: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr("#F1F5F9"), // Table cell text is crisp white/light slate - }, - }, - CenterSeparator: stringPtr("+"), - ColumnSeparator: stringPtr("|"), - RowSeparator: stringPtr("-"), - }, - DefinitionDescription: ansi.StylePrimitive{ - BlockPrefix: "\n🠶 ", - }, -} - -// XSQLLightMarkdownStyle defines a clean, high-contrast Light theme for Glamour. -// It uses dark readable text on light backgrounds with vibrant Indigo/Sky accents. -var XSQLLightMarkdownStyle = ansi.StyleConfig{ - Document: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - Margin: uintPtr(0), - }, - BlockQuote: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr("#475569"), - }, - Indent: uintPtr(1), - IndentToken: stringPtr("│ "), - }, - List: ansi.StyleList{ - LevelIndent: 2, - }, - Heading: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - BlockSuffix: "\n", - Bold: boolPtr(true), - }, - }, - H1: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: " ", - Suffix: " ", - Color: stringPtr("#FFFFFF"), - BackgroundColor: stringPtr("#4F46E5"), - Bold: boolPtr(true), - }, - }, - H2: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "## ", - Color: stringPtr("#4338CA"), // Deep Indigo - Bold: boolPtr(true), - }, - }, - H3: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "### ", - Color: stringPtr("#0284C7"), // Crisp Sky Blue - Bold: boolPtr(true), - }, - }, - H4: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "#### ", - Color: stringPtr("#059669"), // Emerald Green - Bold: boolPtr(true), - }, - }, - H5: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "##### ", - Color: stringPtr("#BE185D"), // Rose Pink - Bold: boolPtr(true), - }, - }, - H6: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "###### ", - Color: stringPtr("#64748B"), // Slate Grey - Bold: boolPtr(false), - }, - }, - Text: ansi.StylePrimitive{}, - Strikethrough: ansi.StylePrimitive{ - CrossedOut: boolPtr(true), - }, - Emph: ansi.StylePrimitive{ - Italic: boolPtr(true), - Color: stringPtr("#334155"), - }, - Strong: ansi.StylePrimitive{ - Bold: boolPtr(true), - Color: stringPtr("#020617"), - }, - HorizontalRule: ansi.StylePrimitive{ - Color: stringPtr("#CBD5E1"), - Format: "\n────────\n", - }, - Item: ansi.StylePrimitive{ - BlockPrefix: "• ", - Color: stringPtr("#4F46E5"), - }, - Enumeration: ansi.StylePrimitive{ - BlockPrefix: ". ", - Color: stringPtr("#4F46E5"), - }, - Task: ansi.StyleTask{ - StylePrimitive: ansi.StylePrimitive{}, - Ticked: "[✓] ", - Unticked: "[ ] ", - }, - Link: ansi.StylePrimitive{ - Color: stringPtr("#0284C7"), - Underline: boolPtr(true), - }, - LinkText: ansi.StylePrimitive{ - Color: stringPtr("#4338CA"), - Bold: boolPtr(true), - }, - Code: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: " ", - Suffix: " ", - Color: stringPtr("#BE185D"), - BackgroundColor: stringPtr("#F1F5F9"), - }, - }, - CodeBlock: ansi.StyleCodeBlock{ - StyleBlock: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr("#0F172A"), - }, - Margin: uintPtr(0), - }, - Chroma: &ansi.Chroma{ - Text: ansi.StylePrimitive{ - Color: stringPtr("#0F172A"), - }, - Error: ansi.StylePrimitive{ - Color: stringPtr("#FFFFFF"), - BackgroundColor: stringPtr("#EF4444"), - }, - Comment: ansi.StylePrimitive{ - Color: stringPtr("#64748B"), - }, - CommentPreproc: ansi.StylePrimitive{ - Color: stringPtr("#BE185D"), - }, - Keyword: ansi.StylePrimitive{ - Color: stringPtr("#6D28D9"), - Bold: boolPtr(true), - }, - KeywordReserved: ansi.StylePrimitive{ - Color: stringPtr("#BE185D"), - Bold: boolPtr(true), - }, - KeywordNamespace: ansi.StylePrimitive{ - Color: stringPtr("#E11D48"), - }, - KeywordType: ansi.StylePrimitive{ - Color: stringPtr("#6D28D9"), - }, - Operator: ansi.StylePrimitive{ - Color: stringPtr("#0284C7"), - }, - Punctuation: ansi.StylePrimitive{ - Color: stringPtr("#475569"), - }, - Name: ansi.StylePrimitive{ - Color: stringPtr("#0F172A"), - }, - NameBuiltin: ansi.StylePrimitive{ - Color: stringPtr("#0284C7"), - }, - NameTag: ansi.StylePrimitive{ - Color: stringPtr("#BE185D"), - }, - NameAttribute: ansi.StylePrimitive{ - Color: stringPtr("#6D28D9"), - }, - NameClass: ansi.StylePrimitive{ - Color: stringPtr("#B45309"), - Bold: boolPtr(true), - }, - NameConstant: ansi.StylePrimitive{ - Color: stringPtr("#BE185D"), - }, - NameDecorator: ansi.StylePrimitive{ - Color: stringPtr("#B45309"), - }, - NameFunction: ansi.StylePrimitive{ - Color: stringPtr("#059669"), - Bold: boolPtr(true), - }, - LiteralNumber: ansi.StylePrimitive{ - Color: stringPtr("#B45309"), - }, - LiteralString: ansi.StylePrimitive{ - Color: stringPtr("#059669"), - }, - LiteralStringEscape: ansi.StylePrimitive{ - Color: stringPtr("#0D9488"), - }, - GenericDeleted: ansi.StylePrimitive{ - Color: stringPtr("#E11D48"), - }, - GenericEmph: ansi.StylePrimitive{ - Italic: boolPtr(true), - }, - GenericInserted: ansi.StylePrimitive{ - Color: stringPtr("#059669"), - }, - GenericStrong: ansi.StylePrimitive{ - Bold: boolPtr(true), - }, - GenericSubheading: ansi.StylePrimitive{ - Color: stringPtr("#64748B"), - }, - Background: ansi.StylePrimitive{ - BackgroundColor: stringPtr("#F8FAFC"), - }, - }, - }, - Table: ansi.StyleTable{ - StyleBlock: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr("#0F172A"), // Table cell text is crisp deep slate - }, - }, - CenterSeparator: stringPtr("+"), - ColumnSeparator: stringPtr("|"), - RowSeparator: stringPtr("-"), - }, - DefinitionDescription: ansi.StylePrimitive{ - BlockPrefix: "\n🠶 ", - }, -} - -// DetectDarkBackground safely determines whether the terminal has a dark background -// by inspecting COLORFGBG and environment variables, completely avoiding OSC 11 probe sequences -// which cause terminal stdin race conditions and garbled text echo in interactive TUI applications. -func DetectDarkBackground() bool { - // 1. Explicit environment variable override - if val := strings.ToLower(strings.TrimSpace(os.Getenv("XSQL_THEME"))); val != "" { - if val == "light" { - return false - } - if val == "dark" { - return true - } - } - - // 2. Standard COLORFGBG environment variable (e.g. "15;0" -> Dark, "0;15" -> Light) - if colorfgbg := strings.TrimSpace(os.Getenv("COLORFGBG")); colorfgbg != "" { - parts := strings.Split(colorfgbg, ";") - if len(parts) >= 2 { - bgPart := strings.TrimSpace(parts[len(parts)-1]) - if bg, err := strconv.Atoi(bgPart); err == nil { - // Standard terminal colors: 7 (light grey) and 15 (bright white) indicate a light background - if bg == 7 || bg == 15 { - return false - } - return true - } - } - } - - // 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 -} - -// CurrentThemeIsDark tracks the detected or configured theme preference -var CurrentThemeIsDark = DetectDarkBackground() - -// SetThemeDark sets the theme preference across Lipgloss and Glamour without terminal probing -func SetThemeDark(isDark bool) { - CurrentThemeIsDark = isDark - lipgloss.SetHasDarkBackground(isDark) -} - -// RenderMarkdown renders markdown text using Glamour with the active theme style. -func RenderMarkdown(md string, width int) string { - return RenderMarkdownWithTheme(md, width, CurrentThemeIsDark) -} - -// RenderMarkdownWithTheme renders markdown text using Glamour with an explicit theme style. -func RenderMarkdownWithTheme(md string, width int, isDark bool) string { - md = strings.TrimSpace(md) - if md == "" { - return "" - } - if width <= 10 { - width = 80 - } - style := XSQLDarkMarkdownStyle - if !isDark { - style = XSQLLightMarkdownStyle - } - r, err := glamour.NewTermRenderer( - glamour.WithStyles(style), - glamour.WithWordWrap(width-6), - ) - if err != nil { - return md - } - out, err := r.Render(md) - if err != nil { - return md - } - return strings.TrimSpace(out) -} - -// HighlightCode renders adaptive, high-contrast syntax-highlighted code for light & dark terminals. -func HighlightCode(code string, lexerName string) string { - code = strings.TrimSpace(code) - if code == "" { - return "" - } - - lexer := lexers.Get(lexerName) - if lexer == nil { - lexer = lexers.Fallback - } - lexer = chroma.Coalesce(lexer) - - iterator, err := lexer.Tokenise(nil, code) - if err != nil { - return DefaultTxtStyle.Render(code) - } - - var sb strings.Builder - for _, t := range iterator.Tokens() { - val := t.Value - switch t.Type { - case chroma.Keyword, chroma.KeywordReserved, chroma.KeywordType, chroma.KeywordNamespace: - sb.WriteString(KeywordStyle.Render(val)) - case chroma.String, chroma.StringChar, chroma.StringSingle, chroma.StringDouble, chroma.StringBacktick: - sb.WriteString(StringStyle.Render(val)) - case chroma.Number, chroma.NumberInteger, chroma.NumberFloat, chroma.NumberHex, chroma.NumberOct: - sb.WriteString(NumberStyle.Render(val)) - case chroma.Name, chroma.NameAttribute, chroma.NameClass, chroma.NameFunction, chroma.NameTag: - sb.WriteString(NameStyle.Render(val)) - case chroma.Comment, chroma.CommentSingle, chroma.CommentMultiline: - sb.WriteString(CommentStyle.Render(val)) - default: - sb.WriteString(DefaultTxtStyle.Render(val)) - } - } - - return sb.String() -} - -// HighlightSQL applies adaptive high-contrast syntax highlighting to SQL statements. -func HighlightSQL(sqlStr string) string { - return HighlightCode(sqlStr, "sql") -} - -// HighlightJS applies adaptive high-contrast syntax highlighting to JavaScript code blocks. -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/internal/tui/render_test.go b/internal/tui/render_test.go deleted file mode 100644 index 91ae294d..00000000 --- a/internal/tui/render_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package tui - -import ( - "strings" - "testing" - - "github.com/charmbracelet/x/ansi" -) - -func TestRenderMarkdown(t *testing.T) { - out := RenderMarkdown("# Hello World\nThis is a **test**.", 80) - plain := ansi.Strip(out) - if !strings.Contains(plain, "Hello World") { - t.Errorf("expected rendered markdown to contain 'Hello World', got %q (plain: %q)", out, plain) - } - if !strings.Contains(plain, "test") { - t.Errorf("expected rendered markdown to contain 'test', got %q", plain) - } - - tableMD := "| Name | Value |\n| --- | --- |\n| Total | 100 |" - tableOut := RenderMarkdown(tableMD, 80) - plainTable := ansi.Strip(tableOut) - if !strings.Contains(plainTable, "Total") || !strings.Contains(plainTable, "100") { - t.Errorf("expected table markdown to contain Total and 100, got %q", plainTable) - } - - empty := RenderMarkdown("", 80) - if empty != "" { - t.Errorf("expected empty string for empty input, got %q", empty) - } - - narrow := RenderMarkdown("# Header", 5) - if !strings.Contains(ansi.Strip(narrow), "Header") { - t.Errorf("expected narrow width fallback, got %q", narrow) - } - - // Light Theme Rendering Test - lightOut := RenderMarkdownWithTheme("# Light Title\n| A | B |\n| 1 | 2 |", 80, false) - plainLight := ansi.Strip(lightOut) - if !strings.Contains(plainLight, "Light Title") || !strings.Contains(plainLight, "1") { - t.Errorf("expected light theme markdown to render correctly, got %q", plainLight) - } - - // Global SetThemeDark Toggle Test - SetThemeDark(false) - outAfterLight := RenderMarkdown("## Subtitle", 80) - if !strings.Contains(ansi.Strip(outAfterLight), "Subtitle") { - t.Errorf("expected Subtitle in global light mode, got %q", outAfterLight) - } - - SetThemeDark(true) - outAfterDark := RenderMarkdown("## Dark Subtitle", 80) - if !strings.Contains(ansi.Strip(outAfterDark), "Dark Subtitle") { - t.Errorf("expected Dark Subtitle in global dark mode, got %q", outAfterDark) - } -} - -func TestHighlightCode(t *testing.T) { - sqlOut := HighlightSQL("SELECT * FROM users WHERE id = 1;") - if sqlOut == "" || !strings.Contains(sqlOut, "SELECT") { - t.Errorf("expected highlighted SQL, got %q", sqlOut) - } - - jsOut := HighlightJS("var data = res1;\nconsole.log(data);") - if jsOut == "" || !strings.Contains(jsOut, "data") { - t.Errorf("expected highlighted JS, got %q", jsOut) - } - - emptyCode := HighlightCode("", "sql") - if emptyCode != "" { - t.Errorf("expected empty string for empty code, got %q", emptyCode) - } - - fallbackCode := HighlightCode("some raw string", "unknown_lexer") - if fallbackCode == "" { - t.Errorf("expected fallback highlight for unknown lexer") - } -} diff --git a/internal/tui/styles.go b/internal/tui/styles.go deleted file mode 100644 index e638d808..00000000 --- a/internal/tui/styles.go +++ /dev/null @@ -1,151 +0,0 @@ -package tui - -import "github.com/charmbracelet/lipgloss" - -func init() { - // Initialize theme based on safe environment detection (no OSC 11 probe) - SetThemeDark(DetectDarkBackground()) -} - -var ( - // Palette Colors (Adaptive Catppuccin / Tokyo Night Theme) - PrimaryColor = lipgloss.AdaptiveColor{Light: "#6D28D9", Dark: "#A78BFA"} - SecondaryColor = lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"} - AccentColor = lipgloss.AdaptiveColor{Light: "#BE185D", Dark: "#F472B6"} - WarningColor = lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#FBBF24"} - ErrorColor = lipgloss.AdaptiveColor{Light: "#E11D48", Dark: "#F87171"} - InfoColor = lipgloss.AdaptiveColor{Light: "#0284C7", Dark: "#38BDF8"} - MutedColor = lipgloss.AdaptiveColor{Light: "#64748B", Dark: "#94A3B8"} - TextNormal = lipgloss.AdaptiveColor{Light: "#0F172A", Dark: "#F8FAFC"} - HeaderBg = lipgloss.AdaptiveColor{Light: "#E2E8F0", Dark: "#1E293B"} - - // Header Container & Badges - HeaderBarStyle = lipgloss.NewStyle(). - Background(HeaderBg). - Padding(0, 1) - - HeaderTitleBadge = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFFFFF")). - Background(PrimaryColor). - Padding(0, 1) - - HeaderProfileBadge = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFFFFF")). - Background(InfoColor). - Padding(0, 1) - - BadgeReadOnly = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFFFFF")). - Background(SecondaryColor). - Padding(0, 1) - - BadgeReadWrite = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFFFFF")). - Background(WarningColor). - Padding(0, 1) - - BadgeAutoExec = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFFFFF")). - Background(AccentColor). - Padding(0, 1) - - BadgeManualApprove = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#0F172A", Dark: "#E2E8F0"}). - Background(lipgloss.AdaptiveColor{Light: "#CBD5E1", Dark: "#475569"}). - Padding(0, 1) - - // Keybinding Badges - KeyBadgeStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#0F172A", Dark: "#F8FAFC"}). - Background(lipgloss.AdaptiveColor{Light: "#CBD5E1", Dark: "#334155"}). - Padding(0, 1) - - KeyLabelStyle = lipgloss.NewStyle(). - Foreground(MutedColor) - - // SQL Preview Box - SQLBoxStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(PrimaryColor). - Padding(0, 1). - MarginTop(1). - MarginBottom(1) - - SQLTitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(AccentColor) - - SQLCodeStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(InfoColor) - - // User & AI Message Tags - UserTagStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFFFFF")). - Background(SecondaryColor). - Padding(0, 1) - - AITagStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFFFFF")). - Background(PrimaryColor). - Padding(0, 1) - - ExecutingTagStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFFFFF")). - Background(WarningColor). - Padding(0, 1) - - SuccessBadgeStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(SecondaryColor) - - WarningBadgeStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(WarningColor) - - MetricsStyle = lipgloss.NewStyle(). - Foreground(MutedColor). - Italic(true) - - AIResponseStyle = lipgloss.NewStyle(). - Foreground(TextNormal). - PaddingLeft(1) - - ErrorMsgStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(ErrorColor). - PaddingLeft(1) - - PromptPrefixStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(PrimaryColor) - - // Collapsible Tool Call Badges - ToolCollapsedBadge = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#475569", Dark: "#94A3B8"}). - Background(lipgloss.AdaptiveColor{Light: "#E2E8F0", Dark: "#1E293B"}). - Padding(0, 1) - - ToolExpandedBadge = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FFFFFF")). - Background(WarningColor). - Padding(0, 1) - - ToolDetailStyle = lipgloss.NewStyle(). - Border(lipgloss.NormalBorder(), false, false, false, true). - BorderForeground(MutedColor). - PaddingLeft(1). - Foreground(MutedColor) -) diff --git a/internal/web/handler.go b/internal/web/handler.go index cf9bb6ef..bb648556 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -81,10 +81,8 @@ func NewHandler(opts HandlerOptions) http.Handler { mux.Handle(apiPrefix+"/config/profiles/", h.withAuth(http.HandlerFunc(h.handleConfigDeleteProfile))) mux.Handle(apiPrefix+"/config/ssh-proxies", h.withAuth(http.HandlerFunc(h.handleConfigSaveSSHProxy))) mux.Handle(apiPrefix+"/config/ssh-proxies/", h.withAuth(http.HandlerFunc(h.handleConfigDeleteSSHProxy))) - mux.Handle(apiPrefix+"/config/ai", h.withAuth(http.HandlerFunc(h.handleConfigSaveAI))) mux.Handle(apiPrefix+"/config/test/profile", h.withAuth(http.HandlerFunc(h.handleConfigTestProfile))) mux.Handle(apiPrefix+"/config/test/ssh-proxy", h.withAuth(http.HandlerFunc(h.handleConfigTestSSHProxy))) - mux.Handle(apiPrefix+"/config/test/ai", h.withAuth(http.HandlerFunc(h.handleConfigTestAI))) mux.Handle(apiPrefix+"/schema/tables/", h.withAuth(http.HandlerFunc(h.handleSchemaTable))) mux.Handle(apiPrefix+"/schema/tables", h.withAuth(http.HandlerFunc(h.handleSchemaTables))) mux.Handle(apiPrefix+"/query", h.withAuth(http.HandlerFunc(h.handleQuery))) @@ -521,7 +519,6 @@ func (h *handler) handleConfigGet(w http.ResponseWriter, r *http.Request) { "ssh_proxies": safe.SSHProxies, "mcp": safe.MCP, "web": safe.Web, - "ai": safe.AI, }) } @@ -636,30 +633,6 @@ func (h *handler) handleConfigDeleteSSHProxy(w http.ResponseWriter, r *http.Requ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name}) } -type configAIRequest struct { - AI config.AIConfig `json:"ai"` -} - -func (h *handler) handleConfigSaveAI(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost && r.Method != http.MethodPut { - writeMethodNotAllowed(w) - return - } - var req configAIRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, errors.Wrap(errors.CodeCfgInvalid, "invalid JSON payload", nil, err)) - return - } - configPath := h.getResolvedConfigPath() - cfg, _, _ := config.LoadConfig(config.Options{ConfigPath: h.configPath}) - req.AI = config.MergeAIConfigSecrets(req.AI, cfg.AI) - if xe := config.SaveAI(configPath, req.AI); xe != nil { - writeError(w, statusCodeFor(xe.Code), xe) - return - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config_path": configPath}) -} - func (h *handler) handleConfigTestProfile(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeMethodNotAllowed(w) @@ -759,34 +732,3 @@ func (h *handler) handleConfigTestSSHProxy(w http.ResponseWriter, r *http.Reques } writeJSON(w, http.StatusOK, result) } - -func (h *handler) handleConfigTestAI(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeMethodNotAllowed(w) - return - } - var req struct { - AI config.AIConfig `json:"ai"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, errors.Wrap(errors.CodeCfgInvalid, "invalid JSON payload", nil, err)) - return - } - - aiCfg := req.AI - cfg, _, _ := config.LoadConfig(config.Options{ConfigPath: h.configPath}) - aiCfg = config.MergeAIConfigSecrets(aiCfg, cfg.AI) - if aiCfg.BaseURL == "" { - aiCfg.BaseURL = cfg.AI.BaseURL - } - if aiCfg.Model == "" { - aiCfg.Model = cfg.AI.Model - } - - result, xe := app.TestAIConnection(r.Context(), aiCfg) - if xe != nil { - writeError(w, statusCodeFor(xe.Code), xe) - return - } - writeJSON(w, http.StatusOK, result) -} diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index cd01acbe..1bf5df73 100644 --- a/internal/web/handler_test.go +++ b/internal/web/handler_test.go @@ -633,54 +633,11 @@ func TestHandler_ConfigManagementErrors(t *testing.T) { } } -func TestHandler_ConfigAIAndTesting(t *testing.T) { - tempConfig := createConfigFile(t, "profiles:\n dev:\n db: mysql\n host: 127.0.0.1\n port: 3306\nssh_proxies:\n bastion:\n host: 127.0.0.1\n port: 22\nai:\n provider: openai\n base_url: https://api.openai.com/v1\n api_key: sk-test\n model: gpt-4o\n") +func TestHandler_ConfigConnectionTesting(t *testing.T) { + tempConfig := createConfigFile(t, "profiles:\n dev:\n db: mysql\n host: 127.0.0.1\n port: 3306\nssh_proxies:\n bastion:\n host: 127.0.0.1\n port: 22\n") h := NewHandler(HandlerOptions{ConfigPath: tempConfig}) - // 1. GET /api/v1/config returns ai - { - req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 for GET config, got %d", rec.Code) - } - var env envelope - _ = json.NewDecoder(rec.Body).Decode(&env) - dataMap, ok := env.Data.(map[string]any) - if !ok || dataMap["ai"] == nil { - t.Fatalf("expected ai section in config response, got: %v", env.Data) - } - } - - // 2. POST /api/v1/config/ai saves AI - { - body := `{"ai":{"provider":"deepseek","model":"deepseek-chat","base_url":"https://api.deepseek.com","api_key":"sk-deepseek"}}` - req := httptest.NewRequest(http.MethodPost, "/api/v1/config/ai", strings.NewReader(body)) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 for POST /api/v1/config/ai, got %d: %s", rec.Code, rec.Body.String()) - } - - // Invalid method - reqGet := httptest.NewRequest(http.MethodGet, "/api/v1/config/ai", nil) - recGet := httptest.NewRecorder() - h.ServeHTTP(recGet, reqGet) - if recGet.Code != http.StatusMethodNotAllowed { - t.Errorf("expected 405 for GET /api/v1/config/ai, got %d", recGet.Code) - } - - // Invalid json - reqBad := httptest.NewRequest(http.MethodPost, "/api/v1/config/ai", strings.NewReader("{invalid")) - recBad := httptest.NewRecorder() - h.ServeHTTP(recBad, reqBad) - if recBad.Code != http.StatusBadRequest { - t.Errorf("expected 400 for invalid json, got %d", recBad.Code) - } - } - - // 3. POST /api/v1/config/test/profile + // 1. POST /api/v1/config/test/profile { // Invalid method reqGet := httptest.NewRequest(http.MethodGet, "/api/v1/config/test/profile", nil) @@ -724,7 +681,7 @@ func TestHandler_ConfigAIAndTesting(t *testing.T) { } } - // 4. POST /api/v1/config/test/ssh-proxy + // 2. POST /api/v1/config/test/ssh-proxy { // Invalid method reqGet := httptest.NewRequest(http.MethodGet, "/api/v1/config/test/ssh-proxy", nil) @@ -768,42 +725,7 @@ func TestHandler_ConfigAIAndTesting(t *testing.T) { } } - // 5. POST /api/v1/config/test/ai - { - // Invalid method - reqGet := httptest.NewRequest(http.MethodGet, "/api/v1/config/test/ai", nil) - recGet := httptest.NewRecorder() - h.ServeHTTP(recGet, reqGet) - if recGet.Code != http.StatusMethodNotAllowed { - t.Errorf("expected 405 for GET test ai, got %d", recGet.Code) - } - - // Missing API key - reqNoKey := httptest.NewRequest(http.MethodPost, "/api/v1/config/test/ai", strings.NewReader(`{"ai":{"base_url":"https://api.openai.com/v1"}}`)) - recNoKey := httptest.NewRecorder() - // Overwrite temp config with empty key - hEmptyKey := NewHandler(HandlerOptions{ConfigPath: createConfigFile(t, "profiles: {}\nssh_proxies: {}\nai:\n api_key: \"\"\n")}) - hEmptyKey.ServeHTTP(recNoKey, reqNoKey) - if recNoKey.Code != http.StatusBadRequest { - t.Errorf("expected 400 for missing api key, got %d", recNoKey.Code) - } - - // Success path with mock OpenAI server - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"id":"1","object":"chat.completion","created":1,"model":"gpt-4o","choices":[{"index":0,"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}]}`)) - })) - defer srv.Close() - - reqSuccess := httptest.NewRequest(http.MethodPost, "/api/v1/config/test/ai", strings.NewReader(`{"ai":{"api_key":"test-key","base_url":"`+srv.URL+`","model":"gpt-4o"}}`)) - recSuccess := httptest.NewRecorder() - h.ServeHTTP(recSuccess, reqSuccess) - if recSuccess.Code != http.StatusOK { - t.Errorf("expected 200 for successful AI test, got %d: %s", recSuccess.Code, recSuccess.Body.String()) - } - } - - // 6. Test Default Port Resolution for MySQL and PG in handleConfigTestProfile + // 3. Test Default Port Resolution for MySQL and PG in handleConfigTestProfile { // MySQL default port 0 -> 3306 reqMySQL := httptest.NewRequest(http.MethodPost, "/api/v1/config/test/profile", strings.NewReader(`{"profile":{"db":"mysql","host":"127.0.0.1","port":0}}`)) @@ -844,11 +766,6 @@ ssh_proxies: host: bastion.example.com user: admin passphrase: ssh-pass-secret -ai: - api_key: sk-openai-secret - provider: openai - base_url: https://api.openai.com/v1 - model: gpt-4o `) handler := NewHandler(HandlerOptions{ConfigPath: configPath}) @@ -859,7 +776,7 @@ ai: t.Fatalf("GET config status=%d body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() - if strings.Contains(body, plaintextPassword) || strings.Contains(body, "ssh-pass-secret") || strings.Contains(body, "sk-openai-secret") { + if strings.Contains(body, plaintextPassword) || strings.Contains(body, "ssh-pass-secret") { t.Fatalf("config GET leaked plaintext secrets: %s", body) } if !strings.Contains(body, keyringPassword) { @@ -897,4 +814,3 @@ ai: t.Fatalf("profile show leaked password: %s", profileRec.Body.String()) } } - diff --git a/tests/e2e/ai_test.go b/tests/e2e/ai_test.go deleted file mode 100644 index 13f9a424..00000000 --- a/tests/e2e/ai_test.go +++ /dev/null @@ -1,198 +0,0 @@ -//go:build e2e - -package e2e - -import ( - "bytes" - "context" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - tea "github.com/charmbracelet/bubbletea" - - "github.com/zx06/xsql/internal/ai" - "github.com/zx06/xsql/internal/config" - "github.com/zx06/xsql/internal/db" - "github.com/zx06/xsql/internal/tui" -) - -func TestE2E_AI_Service_With_MockOpenAI(t *testing.T) { - // 1. Setup Mock OpenAI Server with Tool Call response - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/chat/completions" { - http.Error(w, "not found", http.StatusNotFound) - return - } - if r.Header.Get("Authorization") != "Bearer test-e2e-key" { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - bodyBytes, _ := io.ReadAll(r.Body) - bodyStr := string(bodyBytes) - - // Assert request contains schema context - if !strings.Contains(bodyStr, "DATABASE SCHEMA") { - t.Errorf("request body missing schema context: %s", bodyStr) - } - - respBody := `{ - "id": "chatcmpl-e2e-123", - "object": "chat.completion", - "created": 1677652288, - "model": "gpt-4o", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": null, - "tool_calls": [ - { - "id": "call_e2e_123", - "type": "function", - "function": { - "name": "execute_sql", - "arguments": "{\"sql\":\"SELECT COUNT(*) FROM users;\",\"explanation\":\"Returns total user count.\"}" - } - } - ] - }, - "finish_reason": "tool_calls" - } - ] - }` - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(respBody)) - })) - defer server.Close() - - // 2. Setup AI Config and Service - aiCfg := config.AIConfig{ - Provider: "openai", - BaseURL: server.URL, - APIKey: "test-e2e-key", - Model: "gpt-4o", - MaxTokens: 2048, - } - - client := ai.NewClient(aiCfg, server.Client()) - service := ai.NewService(aiCfg, client) - - mockSchema := &db.SchemaInfo{ - Database: "e2e_db", - Tables: []db.Table{ - { - Name: "users", - Columns: []db.Column{ - {Name: "id", Type: "bigint", PrimaryKey: true}, - }, - }, - }, - } - - // 3. Test GenerateSQL - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - sqlResp, xe := service.GenerateSQL(ctx, "How many users exist?", mockSchema, "mysql") - if xe != nil { - t.Fatalf("unexpected error generating SQL: %v", xe) - } - - if sqlResp.SQL != "SELECT COUNT(*) FROM users;" { - t.Errorf("expected SQL 'SELECT COUNT(*) FROM users;', got %q", sqlResp.SQL) - } - if sqlResp.Explanation != "Returns total user count." { - t.Errorf("expected explanation 'Returns total user count.', got %q", sqlResp.Explanation) - } -} - -func TestE2E_AI_TUI_Terminal_Pipe(t *testing.T) { - // Setup Mock Server for AI BaseURL - aiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - respBody := `{ - "id": "chatcmpl-tui", - "object": "chat.completion", - "created": 1677652288, - "model": "test-model", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": null, - "tool_calls": [ - { - "id": "call_tui", - "type": "function", - "function": { - "name": "execute_sql", - "arguments": "{\"sql\":\"SELECT COUNT(*) FROM users;\",\"explanation\":\"Returns total user count.\"}" - } - } - ] - }, - "finish_reason": "tool_calls" - } - ] - }` - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(respBody)) - })) - defer aiServer.Close() - - // Setup temporary xsql config file - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "xsql.yaml") - cfgContent := fmt.Sprintf(`profiles: - dev: - db: mysql - host: 127.0.0.1 - port: 3306 - user: root - database: test -ai: - base_url: "%s" - model: "test-model" - api_key: "test-key" -`, aiServer.URL) - if err := os.WriteFile(cfgPath, []byte(cfgContent), 0600); err != nil { - t.Fatal(err) - } - - resolved, xe := config.Resolve(config.Options{ConfigPath: cfgPath, CLIProfile: "dev", CLIProfileSet: true}) - if xe != nil { - t.Fatalf("failed to resolve config: %v", xe) - } - - aiClient := ai.NewClient(resolved.AI, aiServer.Client()) - aiService := ai.NewService(resolved.AI, aiClient) - model := tui.NewModel(config.Options{}, resolved, aiService, "Show total users", false) - - inBuf := bytes.NewBufferString("\n") // Press Enter - outBuf := &bytes.Buffer{} - - p := tea.NewProgram(model, tea.WithInput(inBuf), tea.WithOutput(outBuf)) - - go func() { - time.Sleep(300 * time.Millisecond) - p.Quit() - }() - - if _, err := p.Run(); err != nil { - t.Fatalf("TUI program run failed: %v", err) - } - - outputStr := outBuf.String() - if !strings.Contains(outputStr, "xsql AI") { - t.Errorf("expected TUI terminal output to contain header 'xsql AI', got:\n%s", outputStr) - } -} diff --git a/webui/src/App.svelte b/webui/src/App.svelte index 1925b127..94a59030 100644 --- a/webui/src/App.svelte +++ b/webui/src/App.svelte @@ -221,10 +221,8 @@ onDeleteProfile={(name) => ui.deleteProfileConfig(name)} onSaveSSHProxy={(name, data) => ui.saveSSHProxyConfig(name, data)} onDeleteSSHProxy={(name) => ui.deleteSSHProxyConfig(name)} - onSaveAI={(data) => ui.saveAIConfig(data)} onTestProfile={(name, data) => ui.testProfileConnection(name, data)} onTestSSHProxy={(name, data) => ui.testSSHProxyConnection(name, data)} - onTestAI={(data) => ui.testAIConnection(data)} /> diff --git a/webui/src/lib/components/ConfigModal.svelte b/webui/src/lib/components/ConfigModal.svelte index 473e0ff4..6fc43145 100644 --- a/webui/src/lib/components/ConfigModal.svelte +++ b/webui/src/lib/components/ConfigModal.svelte @@ -9,13 +9,11 @@ onDeleteProfile, onSaveSSHProxy, onDeleteSSHProxy, - onSaveAI, onTestProfile, - onTestSSHProxy, - onTestAI + onTestSSHProxy } = $props(); - let activeTab = $state('profiles'); // 'profiles' | 'ssh_proxies' | 'ai' + let activeTab = $state('profiles'); // 'profiles' | 'ssh_proxies' // Edit forms state let selectedProfileKey = $state(''); @@ -43,15 +41,6 @@ skip_host_key: false }); - let aiForm = $state({ - provider: 'openai', - base_url: 'https://api.openai.com/v1', - api_key: '', - model: 'gpt-4o', - max_tokens: 8192, - allow_plaintext: true - }); - let isSubmitting = $state(false); let formError = $state(''); @@ -125,19 +114,6 @@ } } - function syncAIForm() { - clearTestResult(); - const ai = fullConfig?.ai; - aiForm = { - provider: ai?.provider || 'openai', - 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 || 8192), - allow_plaintext: ai?.allow_plaintext !== undefined ? Boolean(ai.allow_plaintext) : true - }; - } - $effect(() => { if (isOpen && fullConfig) { if (activeTab === 'profiles' && !selectedProfileKey) { @@ -150,8 +126,6 @@ if (keys.length > 0) { selectProxyToEdit(keys[0]); } - } else if (activeTab === 'ai') { - syncAIForm(); } } }); @@ -264,38 +238,6 @@ } } - // --- AI Actions --- - async function handleAISubmit(e) { - e.preventDefault(); - isSubmitting = true; - formError = ''; - try { - await onSaveAI?.({ ...aiForm, max_tokens: Number(aiForm.max_tokens) }); - } catch (err) { - formError = err.message; - } finally { - isSubmitting = false; - } - } - - async function handleTestAI() { - isTesting = true; - testResult = null; - try { - const res = await onTestAI?.({ ...aiForm, max_tokens: Number(aiForm.max_tokens) }); - testResult = { - ok: true, - message: `AI Model "${res.model || aiForm.model}" connected (${res.latency_ms ?? 0}ms)` - }; - } catch (err) { - testResult = { - ok: false, - message: err.message || 'AI service test failed' - }; - } finally { - isTesting = false; - } - } {#if isOpen} @@ -347,15 +289,6 @@ > 🔐 SSH Proxies ({Object.keys(fullConfig?.ssh_proxies || {}).length}) - {#if formError} @@ -664,113 +597,6 @@ - {:else if activeTab === 'ai'} - -
-
-
- Configure LLM service provider for NL-to-SQL generation and AI database optimization in xsql. -
- -
-
- - -
-
- - -
-
- -
- - -
- -
-
- - -
-
- - -
-
- -
- -
- - - {#if testResult} -
- {testResult.ok ? '✓ ' : '✕ '}{testResult.message} -
- {/if} - -
- - - -
-
-
{/if} diff --git a/webui/src/lib/web-ui.svelte.js b/webui/src/lib/web-ui.svelte.js index 1d56437b..2bea207b 100644 --- a/webui/src/lib/web-ui.svelte.js +++ b/webui/src/lib/web-ui.svelte.js @@ -454,22 +454,6 @@ export class WebUIController { } } - async saveAIConfig(aiData) { - this.errorMessage = ''; - try { - await this.api('/api/v1/config/ai', { - method: 'POST', - body: JSON.stringify({ ai: aiData }) - }); - await this.loadConfig(); - this.showToast('AI Settings saved successfully', 'success'); - } catch (error) { - this.errorMessage = `Failed to save AI config: ${error.message}`; - this.showToast(this.errorMessage, 'error'); - throw error; - } - } - async testProfileConnection(name, profileData) { return this.api('/api/v1/config/test/profile', { method: 'POST', @@ -484,13 +468,6 @@ export class WebUIController { }); } - async testAIConnection(aiData) { - return this.api('/api/v1/config/test/ai', { - method: 'POST', - body: JSON.stringify({ ai: aiData }) - }); - } - // --- Copy & Export --- async copyAsMarkdown() { if (!this.columns.length || !this.rows.length) {