diff --git a/.gitignore b/.gitignore index 815e9d3..f353b47 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ bin-linux-arm64/* binaries-for-upload/* gpt_results shell-code/jwt.admin.token +run.sh +lcg_history.json diff --git a/LICENSE b/LICENSE index 00f658c..ff518b7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2023 asrul10 +Copyright (c) 2025 direct-dev.ru Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index a403a24..2ee0461 100644 --- a/README.md +++ b/README.md @@ -1,102 +1,85 @@ # Linux Command GPT (lcg) -Get Linux commands in natural language with the power of ChatGPT. +This repo is forked from + +Generate Linux commands from natural language. Supports Ollama and Proxy backends, system prompts, different explanation levels (v/vv/vvv), and JSON history. ## Installation -Build from source +Build from source: ```bash -> git clone --depth 1 https://github.com/asrul10/linux-command-gpt.git ~/.linux-command-gpt -> cd ~/.linux-command-gpt -> go build -o lcg -# Add to your environment $PATH -> ln -s ~/.linux-command-gpt/lcg ~/.local/bin +git clone --depth 1 https://github.com/Direct-Dev-Ru/go-lcg.git ~/.linux-command-gpt +cd ~/.linux-command-gpt +go build -o lcg + +# Add to your PATH +ln -s ~/.linux-command-gpt/lcg ~/.local/bin ``` -Or you can [download lcg executable file](https://github.com/asrul10/linux-command-gpt/releases) - -## Example Usage +## Quick start ```bash -> lcg I want to extract linux-command-gpt.tar.gz file -Completed in 0.92 seconds - -tar -xvzf linux-command-gpt.tar.gz - -Do you want to (c)opy, (r)egenerate, or take (N)o action on the command? (c/r/N): -``` - -```bash -> LCG_PROMPT='Provide full response' LCG_MODEL=codellama:13b lcg 'i need bash script -to execute some command by ssh on some array of hosts' -Completed in 181.16 seconds - -Here is a sample Bash script that demonstrates how to execute commands over SSH on an array of hosts: -```bash -#!/bin/bash - -hosts=(host1 host2 host3) - -for host in "${hosts[@]}"; do - ssh $host "echo 'Hello, world!' > /tmp/hello.txt" -done -``` - -This script defines an array `hosts` that contains the names of the hosts to connect to. The loop iterates over each element in the array and uses the `ssh` command to execute a simple command on the remote host. In this case, the command is `echo 'Hello, world!' > /tmp/hello.txt`, which writes the string "Hello, world!" to a file called `/tmp/hello.txt`. - -You can modify the script to run any command you like by replacing the `echo` command with your desired command. For example, if you want to run a Python script on each host, you could use the following command: - -```bash -ssh $host "python /path/to/script.py" -``` - -This will execute the Python script located at `/path/to/script.py` on the remote host. - -You can also modify the script to run multiple commands in a single SSH session by using the `&&` operator to chain the commands together. For example: - -```bash -ssh $host "echo 'Hello, world!' > /tmp/hello.txt && python /path/to/script.py" -``` - -This will execute both the `echo` command and the Python script in a single SSH session. - -I hope this helps! Let me know if you have any questions or need further assistance. - -Do you want to (c)opy, (r)egenerate, or take (N)o action on the command? (c/r/N): - -``` text - -To use the "copy to clipboard" feature, you need to install either the `xclip` or `xsel` package. - -### Options -```bash -> lcg [options] - ---help -h output usage information ---version -v output the version number ---file -f read command from file ---update-key -u update the API key ---delete-key -d delete the API key - -# ollama example -export LCG_PROVIDER=ollama -export LCG_HOST=http://192.168.87.108:11434/ -export LCG_MODEL=codegeex4 - lcg "I want to extract linux-command-gpt.tar.gz file" - -export LCG_PROVIDER=proxy -export LCG_HOST=http://localhost:8080 -export LCG_MODEL=GigaChat-2 -export LCG_JWT_TOKEN=your_jwt_token_here - -lcg "I want to extract linux-command-gpt.tar.gz file" - -lcg health - -lcg config - -lcg update-jwt - ``` + +After generation you will see a CAPS warning that the answer is from AI and must be verified, the command, and the action menu: + +```text +ACTIONS: (c)opy, (s)ave, (r)egenerate, (e)xecute, (v|vv|vvv)explain, (n)othing +``` + +Explanations: + +- `v` — short; `vv` — medium; `vvv` — detailed with alternatives. + +Clipboard support requires `xclip` or `xsel`. + +## Environment + +- `LCG_PROVIDER` (ollama|proxy), `LCG_HOST`, `LCG_MODEL`, `LCG_PROMPT` +- `LCG_TIMEOUT` (default 120), `LCG_RESULT_FOLDER` (default ./gpt_results) +- `LCG_RESULT_HISTORY` (default $(LCG_RESULT_FOLDER)/lcg_history.json) +- `LCG_JWT_TOKEN` (for proxy) + +## Flags + +- `--file, -f` read part of prompt from file +- `--sys, -s` system prompt content or ID +- `--prompt-id, --pid` choose built-in prompt (1–5) +- `--timeout, -t` request timeout (sec) +- `--version, -v` print version; `--help, -h` help + +## Commands + +- `models`, `health`, `config` +- `prompts list|add|delete` +- `test-prompt ` +- `update-jwt`, `delete-jwt` (proxy) +- `update-key`, `delete-key` (not needed for ollama/proxy) +- `history list` — list history from JSON +- `history view ` — view by index +- `history delete ` — delete by index (re-numbering) + +## Saving results + +Files are saved to `LCG_RESULT_FOLDER`. + +- Command result: `gpt_request__YYYY-MM-DD_HH-MM-SS.md` + - `# ` — H1 with original request (trimmed to 120 chars: first 116 + `...`) + - `## Prompt` + - `## Response` + +- Detailed explanation: `gpt_explanation_<MODEL>_YYYY-MM-DD_HH-MM-SS.md` + - `# <title>` + - `## Prompt` + - `## Command` + - `## Explanation and Alternatives (model: <MODEL>)` + +## History + +- Stored as JSON array in `LCG_RESULT_HISTORY`. +- On new request, if the same command exists, you will be prompted to view or overwrite. +- Showing from history does not call the API; the standard action menu is shown. + +For full guide in Russian, see `USAGE_GUIDE.md`. diff --git a/USAGE_GUIDE.md b/USAGE_GUIDE.md index f75b9fb..422bc29 100644 --- a/USAGE_GUIDE.md +++ b/USAGE_GUIDE.md @@ -51,10 +51,12 @@ lcg --file /path/to/context.txt "хочу вывести список дирек 🤖 Запрос: <ваше описание> ✅ Выполнено за X.XX сек +ВНИМАНИЕ: ОТВЕТ СФОРМИРОВАН ИИ. ТРЕБУЕТСЯ ПРОВЕРКА И КРИТИЧЕСКИЙ АНАЛИЗ. ВОЗМОЖНЫ ОШИБКИ И ГАЛЛЮЦИНАЦИИ. + 📋 Команда: <сгенерированная команда> -Действия: (c)копировать, (s)сохранить, (r)перегенерировать, (e)выполнить, (n)ничего: +Действия: (c)копировать, (s)сохранить, (r)перегенерировать, (e)выполнить, (v|vv|vvv)подробно, (n)ничего: ``` ## Переменные окружения @@ -73,6 +75,8 @@ lcg --file /path/to/context.txt "хочу вывести список дирек | `LCG_JWT_TOKEN` | пусто | JWT токен для `proxy` провайдера (альтернатива — файл `~/.proxy_jwt_token`). | | `LCG_PROMPT_ID` | `1` | ID системного промпта по умолчанию. | | `LCG_TIMEOUT` | `120` | Таймаут запроса в секундах. | +| `LCG_RESULT_HISTORY` | `$(LCG_RESULT_FOLDER)/lcg_history.json` | Путь к JSON‑истории запросов. | +| `LCG_NO_HISTORY` | пусто | Если `1`/`true` — полностью отключает запись/обновление истории. | Примеры настройки: @@ -113,13 +117,24 @@ lcg [глобальные опции] <описание команды> - `lcg models` (`-m`): показать доступные модели у текущего провайдера. - `lcg health` (`-he`): проверить доступность API провайдера. - `lcg config` (`-co`): показать текущую конфигурацию и состояние JWT. -- `lcg history` (`-hist`): показать историю запросов за текущий запуск (до 100 записей, не сохраняется между запусками). +- `lcg history list` (`-l`): показать историю из JSON‑файла (`LCG_RESULT_HISTORY`). +- `lcg history view <id>` (`-v`): показать запись истории по `index`. +- `lcg history delete <id>` (`-d`): удалить запись истории по `index` (с перенумерацией). +- Флаг `--no-history` (`-nh`) отключает запись истории для текущего запуска и имеет приоритет над `LCG_NO_HISTORY`. - `lcg prompts ...` (`-p`): управление системными промптами: - `lcg prompts list` (`-l`) — список всех промптов. - `lcg prompts add` (`-a`) — добавить пользовательский промпт (по шагам в интерактиве). - `lcg prompts delete <id>` (`-d`) — удалить пользовательский промпт по ID (>5). - `lcg test-prompt <prompt-id> <описание>` (`-tp`): показать детали выбранного системного промпта и протестировать его на заданном описании. +### Подробные объяснения (v/vv/vvv) + +- `v` — кратко: что делает команда и ключевые опции, без альтернатив. +- `vv` — средне: назначение, основные ключи, 1–2 примера, кратко об альтернативах. +- `vvv` — максимально подробно: полный разбор ключей, сценариев, примеры, разбор альтернатив и сравнений. + +После вывода подробного объяснения доступно вторичное меню: `Действия: (c)копировать, (s)сохранить, (r)перегенерировать, (n)ничего:` + ## Провайдеры ### Ollama (`LCG_PROVIDER=ollama`) @@ -156,10 +171,18 @@ lcg [глобальные опции] <описание команды> gpt_request_<MODEL>_YYYY-MM-DD_HH-MM-SS.md ``` -Структура файла: +Структура файла (команда): -- `## Prompt:` — ваш запрос (включая системный промпт). -- `## Response:` — полученный ответ. +- `# <заголовок>` — H1, это ваш запрос, при длине >120 символов обрезается до 116 + `...`. +- `## Prompt` — запрос (включая системный промпт). +- `## Response` — сгенерированная команда. + +Структура файла (подробное объяснение): + +- `# <заголовок>` — H1, ваш исходный запрос (с тем же правилом обрезки). +- `## Prompt` — исходный запрос. +- `## Command` — первая сгенерированная команда. +- `## Explanation and Alternatives (model: <MODEL>)` — подробное объяснение и альтернативы. ## Выполнение сгенерированной команды @@ -219,7 +242,7 @@ lcg models `lcg history` выводит историю текущего процесса (не сохраняется между запусками, максимум 100 записей): ```bash -lcg history +lcg history list ``` ## Типичные проблемы @@ -230,6 +253,30 @@ lcg history - Нет допуска к папке результатов: настройте `LCG_RESULT_FOLDER` или права доступа. - Для `ollama`/`proxy` API‑ключ не нужен; команды `update-key`/`delete-key` просто уведомят об этом. +## JSON‑история запросов + +- Путь задаётся `LCG_RESULT_HISTORY` (по умолчанию: `$(LCG_RESULT_FOLDER)/lcg_history.json`). +- Формат — массив объектов: + +```json +[ + { + "index": 1, + "command": "хочу извлечь linux-command-gpt.tar.gz", + "response": "tar -xvzf linux-command-gpt.tar.gz", + "explanation": "... если запрашивалось v/vv/vvv ...", + "system_prompt": "Reply with linux command and nothing else ...", + "timestamp": "2025-10-19T13:05:39.000000000Z" + } +] +``` + +- Перед новым запросом, если такой уже встречался, будет предложено вывести сохранённый результат из истории с указанием даты. +- Сохранение в файл истории выполняется автоматически после завершения работы (любое действие, кроме `v|vv|vvv`). +- При совпадении запроса в истории спрашивается о перезаписи записи. +- Подкоманды истории работают по полю `index` внутри JSON (а не по позиции массива): используйте `lcg history view <index>` и `lcg history delete <index>`. +- При показе из истории запрос к API не выполняется: выводится CAPS‑предупреждение и далее доступно обычное меню действий над командой/объяснением. + ## Лицензия и исходники См. README и репозиторий проекта. Предложения и баг‑репорты приветствуются в Issues. diff --git a/main.go b/main.go index 3e984f8..7d9edb0 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( _ "embed" + "encoding/json" "fmt" "math" "os" @@ -22,19 +23,24 @@ import ( var Version string var ( - cwd, _ = os.Getwd() - HOST = getEnv("LCG_HOST", "http://192.168.87.108:11434/") - COMPLETIONS = getEnv("LCG_COMPLETIONS_PATH", "api/chat") - MODEL = getEnv("LCG_MODEL", "codegeex4") - PROMPT = getEnv("LCG_PROMPT", "Reply with linux command and nothing else. Output with plain response - no need formatting. No need explanation. No need code blocks. No need ` symbols.") - API_KEY_FILE = getEnv("LCG_API_KEY_FILE", ".openai_api_key") - RESULT_FOLDER = getEnv("LCG_RESULT_FOLDER", path.Join(cwd, "gpt_results")) - PROVIDER_TYPE = getEnv("LCG_PROVIDER", "ollama") // "ollama", "proxy" - JWT_TOKEN = getEnv("LCG_JWT_TOKEN", "") - PROMPT_ID = getEnv("LCG_PROMPT_ID", "1") // ID промпта по умолчанию - TIMEOUT = getEnv("LCG_TIMEOUT", "120") // Таймаут в секундах по умолчанию + cwd, _ = os.Getwd() + HOST = getEnv("LCG_HOST", "http://192.168.87.108:11434/") + COMPLETIONS = getEnv("LCG_COMPLETIONS_PATH", "api/chat") + MODEL = getEnv("LCG_MODEL", "codegeex4") + PROMPT = getEnv("LCG_PROMPT", "Reply with linux command and nothing else. Output with plain response - no need formatting. No need explanation. No need code blocks. No need ` symbols.") + API_KEY_FILE = getEnv("LCG_API_KEY_FILE", ".openai_api_key") + RESULT_FOLDER = getEnv("LCG_RESULT_FOLDER", path.Join(cwd, "gpt_results")) + PROVIDER_TYPE = getEnv("LCG_PROVIDER", "ollama") // "ollama", "proxy" + JWT_TOKEN = getEnv("LCG_JWT_TOKEN", "") + PROMPT_ID = getEnv("LCG_PROMPT_ID", "1") // ID промпта по умолчанию + TIMEOUT = getEnv("LCG_TIMEOUT", "120") // Таймаут в секундах по умолчанию + RESULT_HISTORY = getEnv("LCG_RESULT_HISTORY", path.Join(RESULT_FOLDER, "lcg_history.json")) + NO_HISTORY_ENV = getEnv("LCG_NO_HISTORY", "") ) +// disableHistory управляет записью/обновлением истории на уровне процесса (флаг имеет приоритет над env) +var disableHistory bool + const ( colorRed = "\033[31m" colorGreen = "\033[32m" @@ -76,6 +82,12 @@ Linux Command GPT - инструмент для генерации Linux ком Aliases: []string{"f"}, Usage: "Read part of the command from a file", }, + &cli.BoolFlag{ + Name: "no-history", + Aliases: []string{"nh"}, + Usage: "Disable writing/updating command history (overrides LCG_NO_HISTORY)", + Value: false, + }, &cli.StringFlag{ Name: "sys", Aliases: []string{"s"}, @@ -101,6 +113,7 @@ Linux Command GPT - инструмент для генерации Linux ком Action: func(c *cli.Context) error { file := c.String("file") system := c.String("sys") + disableHistory = c.Bool("no-history") || isNoHistoryEnv() promptID := c.Int("prompt-id") timeout := c.Int("timeout") args := c.Args().Slice() @@ -299,9 +312,52 @@ func getCommands() []*cli.Command { Name: "history", Aliases: []string{"hist"}, Usage: "Show command history", - Action: func(c *cli.Context) error { - showHistory() - return nil + Subcommands: []*cli.Command{ + { + Name: "list", + Aliases: []string{"l"}, + Usage: "List history entries", + Action: func(c *cli.Context) error { + showHistory() + return nil + }, + }, + { + Name: "view", + Aliases: []string{"v"}, + Usage: "View history entry by ID", + Action: func(c *cli.Context) error { + if c.NArg() == 0 { + fmt.Println("Укажите ID записи истории") + return nil + } + var id int + if _, err := fmt.Sscanf(c.Args().First(), "%d", &id); err != nil || id <= 0 { + fmt.Println("Неверный ID") + return nil + } + viewHistoryEntry(id) + return nil + }, + }, + { + Name: "delete", + Aliases: []string{"d"}, + Usage: "Delete history entry by ID", + Action: func(c *cli.Context) error { + if c.NArg() == 0 { + fmt.Println("Укажите ID записи истории") + return nil + } + var id int + if _, err := fmt.Sscanf(c.Args().First(), "%d", &id); err != nil || id <= 0 { + fmt.Println("Неверный ID") + return nil + } + deleteHistoryEntry(id) + return nil + }, + }, }, }, { @@ -436,6 +492,7 @@ func executeMain(file, system, commandInput string, timeout int) { system = PROMPT } + // Обеспечим папку результатов заранее (может понадобиться при действиях) if _, err := os.Stat(RESULT_FOLDER); os.IsNotExist(err) { if err := os.MkdirAll(RESULT_FOLDER, 0755); err != nil { printColored(fmt.Sprintf("❌ Ошибка создания папки результатов: %v\n", err), colorRed) @@ -443,6 +500,25 @@ func executeMain(file, system, commandInput string, timeout int) { } } + // Проверка истории: если такой запрос уже встречался — предложить открыть из истории + if !disableHistory { + if found, hist := checkAndSuggestFromHistory(commandInput); found && hist != nil { + gpt3 := initGPT(system, timeout) + printColored("\nВНИМАНИЕ: ОТВЕТ СФОРМИРОВАН ИИ. ТРЕБУЕТСЯ ПРОВЕРКА И КРИТИЧЕСКИЙ АНАЛИЗ. ВОЗМОЖНЫ ОШИБКИ И ГАЛЛЮЦИНАЦИИ.\n", colorRed) + printColored("\n📋 Команда (из истории):\n", colorYellow) + printColored(fmt.Sprintf(" %s\n\n", hist.Response), colorBold+colorGreen) + if strings.TrimSpace(hist.Explanation) != "" { + printColored("\n📖 Подробное объяснение (из истории):\n\n", colorYellow) + fmt.Println(hist.Explanation) + } + // Показали из истории — не выполняем запрос к API, сразу меню действий + handlePostResponse(hist.Response, gpt3, system, commandInput, timeout) + return + } + } + + // Папка уже создана выше + gpt3 := initGPT(system, timeout) printColored("🤖 Запрос: ", colorCyan) @@ -455,11 +531,41 @@ func executeMain(file, system, commandInput string, timeout int) { } printColored(fmt.Sprintf("✅ Выполнено за %.2f сек\n", elapsed), colorGreen) + // Обязательное предупреждение перед первым ответом + printColored("\nВНИМАНИЕ: ОТВЕТ СФОРМИРОВАН ИИ. ТРЕБУЕТСЯ ПРОВЕРКА И КРИТИЧЕСКИЙ АНАЛИЗ. ВОЗМОЖНЫ ОШИБКИ И ГАЛЛЮЦИНАЦИИ.\n", colorRed) printColored("\n📋 Команда:\n", colorYellow) printColored(fmt.Sprintf(" %s\n\n", response), colorBold+colorGreen) - saveToHistory(commandInput, response) - handlePostResponse(response, gpt3, system, commandInput) + // Сохраняем в историю (после завершения работы – т.е. позже, в зависимости от выбора действия) + // Здесь не сохраняем, чтобы учесть правило: сохранять после действия, отличного от v/vv/vvv + handlePostResponse(response, gpt3, system, commandInput, timeout) +} + +// checkAndSuggestFromHistory проверяет файл истории и при совпадении запроса предлагает показать сохраненный результат +func checkAndSuggestFromHistory(cmd string) (bool, *CommandHistory) { + if disableHistory { + return false, nil + } + data, err := os.ReadFile(RESULT_HISTORY) + if err != nil || len(data) == 0 { + return false, nil + } + var fileHistory []CommandHistory + if err := json.Unmarshal(data, &fileHistory); err != nil { + return false, nil + } + for _, h := range fileHistory { + if strings.TrimSpace(strings.ToLower(h.Command)) == strings.TrimSpace(strings.ToLower(cmd)) { + fmt.Printf("\nВ истории найден похожий запрос от %s. Показать сохраненный результат? (y/N): ", h.Timestamp.Format("2006-01-02 15:04:05")) + var ans string + fmt.Scanln(&ans) + if strings.ToLower(ans) == "y" || strings.ToLower(ans) == "yes" { + return true, &h + } + break + } + } + return false, nil } func initGPT(system string, timeout int) gpt.Gpt3 { @@ -510,8 +616,8 @@ func getCommand(gpt3 gpt.Gpt3, cmd string) (string, float64) { return response, elapsed } -func handlePostResponse(response string, gpt3 gpt.Gpt3, system, cmd string) { - fmt.Printf("Действия: (c)копировать, (s)сохранить, (r)перегенерировать, (e)выполнить, (n)ничего: ") +func handlePostResponse(response string, gpt3 gpt.Gpt3, system, cmd string, timeout int) { + fmt.Printf("Действия: (c)копировать, (s)сохранить, (r)перегенерировать, (e)выполнить, (v|vv|vvv)подробно, (n)ничего: ") var choice string fmt.Scanln(&choice) @@ -519,15 +625,30 @@ func handlePostResponse(response string, gpt3 gpt.Gpt3, system, cmd string) { case "c": clipboard.WriteAll(response) fmt.Println("✅ Команда скопирована в буфер обмена") + if !disableHistory { + saveToHistory(cmd, response, gpt3.Prompt) + } case "s": saveResponse(response, gpt3, cmd) + if !disableHistory { + saveToHistory(cmd, response, gpt3.Prompt) + } case "r": fmt.Println("🔄 Перегенерирую...") - executeMain("", system, cmd, 120) // Use default timeout for regeneration + executeMain("", system, cmd, timeout) case "e": executeCommand(response) + if !disableHistory { + saveToHistory(cmd, response, gpt3.Prompt) + } + case "v", "vv", "vvv": + level := len(choice) // 1, 2, 3 + showDetailedExplanation(response, gpt3, system, cmd, timeout, level) default: fmt.Println(" До свидания!") + if !disableHistory { + saveToHistory(cmd, response, gpt3.Prompt) + } } } @@ -535,7 +656,9 @@ func saveResponse(response string, gpt3 gpt.Gpt3, cmd string) { timestamp := time.Now().Format("2006-01-02_15-04-05") filename := fmt.Sprintf("gpt_request_%s_%s.md", gpt3.Model, timestamp) filePath := path.Join(RESULT_FOLDER, filename) - content := fmt.Sprintf("## Prompt:\n\n%s\n\n## Response:\n\n%s\n", cmd+". "+gpt3.Prompt, response) + // Заголовок — сокращенный текст запроса пользователя + title := truncateTitle(cmd) + content := fmt.Sprintf("# %s\n\n## Prompt\n\n%s\n\n## Response\n\n%s\n", title, cmd+". "+gpt3.Prompt, response) if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { fmt.Println("Failed to save response:", err) @@ -544,6 +667,98 @@ func saveResponse(response string, gpt3 gpt.Gpt3, cmd string) { } } +// saveExplanation сохраняет подробное объяснение и альтернативные способы +func saveExplanation(explanation string, model string, originalCmd string, commandResponse string) { + timestamp := time.Now().Format("2006-01-02_15-04-05") + filename := fmt.Sprintf("gpt_explanation_%s_%s.md", model, timestamp) + filePath := path.Join(RESULT_FOLDER, filename) + title := truncateTitle(originalCmd) + content := fmt.Sprintf( + "# %s\n\n## Prompt\n\n%s\n\n## Command\n\n%s\n\n## Explanation and Alternatives (model: %s)\n\n%s\n", + title, + originalCmd, + commandResponse, + model, + explanation, + ) + + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + fmt.Println("Failed to save explanation:", err) + } else { + fmt.Printf("Explanation saved to %s\n", filePath) + } +} + +// truncateTitle сокращает строку до 120 символов (по рунам), добавляя " ..." при усечении +func truncateTitle(s string) string { + const maxLen = 120 + if runeCount := len([]rune(s)); runeCount <= maxLen { + return s + } + // взять первые 116 рунических символов и добавить " ..." + const head = 116 + r := []rune(s) + if len(r) <= head { + return s + } + return string(r[:head]) + " ..." +} + +// showDetailedExplanation делает дополнительный запрос с подробным описанием и альтернативами +func showDetailedExplanation(command string, gpt3 gpt.Gpt3, system, originalCmd string, timeout int, level int) { + // Формируем системный промпт для подробного ответа (на русском) + var detailedSystem string + switch level { + case 1: // v — кратко + detailedSystem = "Ты опытный Linux-инженер. Объясни КРАТКО, по делу: что делает команда и самые важные ключи. Без сравнений и альтернатив. Минимум текста. Пиши на русском." + case 2: // vv — средне + detailedSystem = "Ты опытный Linux-инженер. Дай сбалансированное объяснение: назначение команды, разбор основных ключей, 1-2 примера. Кратко упомяни 1-2 альтернативы без глубокого сравнения. Пиши на русском." + default: // vvv — максимально подробно + detailedSystem = "Ты опытный Linux-инженер. Дай подробное объяснение команды с полным разбором ключей, подкоманд, сценариев применения, примеров. Затем предложи альтернативные способы решения задачи другой командой/инструментами (со сравнениями и когда что лучше применять). Пиши на русском." + } + + // Текст запроса к модели + ask := fmt.Sprintf("Объясни подробно команду и предложи альтернативы. Исходная команда: %s. Исходное задание пользователя: %s", command, originalCmd) + + // Создаем временный экземпляр с иным системным промптом + detailed := gpt.NewGpt3(gpt3.ProviderType, HOST, gpt3.ApiKey, gpt3.Model, detailedSystem, 0.2, timeout) + + printColored("\n🧠 Получаю подробное объяснение...\n", colorPurple) + explanation, elapsed := getCommand(*detailed, ask) + if explanation == "" { + printColored("❌ Не удалось получить подробное объяснение.\n", colorRed) + return + } + + printColored(fmt.Sprintf("✅ Готово за %.2f сек\n", elapsed), colorGreen) + // Обязательное предупреждение перед выводом подробного объяснения + printColored("\nВНИМАНИЕ: ОТВЕТ СФОРМИРОВАН ИИ. ТРЕБУЕТСЯ ПРОВЕРКА И КРИТИЧЕСКИЙ АНАЛИЗ. ВОЗМОЖНЫ ОШИБКИ И ГАЛЛЮЦИНАЦИИ.\n", colorRed) + printColored("\n📖 Подробное объяснение и альтернативы:\n\n", colorYellow) + fmt.Println(explanation) + + // Вторичное меню действий + fmt.Printf("\nДействия: (c)копировать, (s)сохранить, (r)перегенерировать, (n)ничего: ") + var choice string + fmt.Scanln(&choice) + switch strings.ToLower(choice) { + case "c": + clipboard.WriteAll(explanation) + fmt.Println("✅ Объяснение скопировано в буфер обмена") + case "s": + saveExplanation(explanation, gpt3.Model, originalCmd, command) + case "r": + fmt.Println("🔄 Перегенерирую подробное объяснение...") + showDetailedExplanation(command, gpt3, system, originalCmd, timeout, level) + default: + fmt.Println(" Возврат в основное меню.") + } + + // После работы с объяснением — сохраняем запись в файл истории, но только если было действие не r + if !disableHistory && (strings.ToLower(choice) == "c" || strings.ToLower(choice) == "s" || strings.ToLower(choice) == "n") { + saveToHistory(originalCmd, command, system, explanation) + } +} + func executeCommand(command string) { fmt.Printf("🚀 Выполняю: %s\n", command) fmt.Print("Продолжить? (y/N): ") @@ -572,28 +787,118 @@ func getEnv(key, defaultValue string) string { return defaultValue } +func isNoHistoryEnv() bool { + v := strings.TrimSpace(NO_HISTORY_ENV) + vLower := strings.ToLower(v) + return vLower == "1" || vLower == "true" +} + type CommandHistory struct { - Command string - Response string - Timestamp time.Time + Index int `json:"index"` + Command string `json:"command"` + Response string `json:"response"` + Explanation string `json:"explanation,omitempty"` + System string `json:"system_prompt"` + Timestamp time.Time `json:"timestamp"` } var commandHistory []CommandHistory -func saveToHistory(cmd, response string) { - commandHistory = append(commandHistory, CommandHistory{ - Command: cmd, - Response: response, - Timestamp: time.Now(), - }) +func saveToHistory(cmd, response, system string, explanationOptional ...string) { + if disableHistory { + return + } + var explanation string + if len(explanationOptional) > 0 { + explanation = explanationOptional[0] + } - // Ограничиваем историю 100 командами + entry := CommandHistory{ + Index: len(commandHistory) + 1, + Command: cmd, + Response: response, + Explanation: explanation, + System: system, + Timestamp: time.Now(), + } + + commandHistory = append(commandHistory, entry) + + // Ограничиваем историю 100 командами в оперативной памяти if len(commandHistory) > 100 { commandHistory = commandHistory[1:] + // Перепривязать индексы после усечения + for i := range commandHistory { + commandHistory[i].Index = i + 1 + } + } + + // Обеспечим существование папки + if _, err := os.Stat(RESULT_FOLDER); os.IsNotExist(err) { + _ = os.MkdirAll(RESULT_FOLDER, 0755) + } + + // Загрузим существующий файл истории + var fileHistory []CommandHistory + if data, err := os.ReadFile(RESULT_HISTORY); err == nil && len(data) > 0 { + _ = json.Unmarshal(data, &fileHistory) + } + + // Поиск дубликата по полю Command + duplicateIndex := -1 + for i, h := range fileHistory { + if strings.TrimSpace(strings.ToLower(h.Command)) == strings.TrimSpace(strings.ToLower(cmd)) { + duplicateIndex = i + break + } + } + + if duplicateIndex == -1 { + // Добавляем молча, если такого запроса не было + fileHistory = append(fileHistory, entry) + } else { + // Спросим о перезаписи + fmt.Printf("\nЗапрос уже есть в истории от %s. Перезаписать? (y/N): ", fileHistory[duplicateIndex].Timestamp.Format("2006-01-02 15:04:05")) + var ans string + fmt.Scanln(&ans) + if strings.ToLower(ans) == "y" || strings.ToLower(ans) == "yes" { + entry.Index = fileHistory[duplicateIndex].Index + fileHistory[duplicateIndex] = entry + } else { + // Оставляем как есть, ничего не делаем + } + } + + // Пересчитать индексы в файле + for i := range fileHistory { + fileHistory[i].Index = i + 1 + } + + if out, err := json.MarshalIndent(fileHistory, "", " "); err == nil { + _ = os.WriteFile(RESULT_HISTORY, out, 0644) } } func showHistory() { + // Пытаемся прочитать историю из файла + if disableHistory { + printColored("📝 История отключена (--no-history / LCG_NO_HISTORY)\n", colorYellow) + return + } + data, err := os.ReadFile(RESULT_HISTORY) + if err == nil && len(data) > 0 { + var fileHistory []CommandHistory + if err := json.Unmarshal(data, &fileHistory); err == nil && len(fileHistory) > 0 { + printColored("📝 История (из файла):\n", colorYellow) + for _, hist := range fileHistory { + ts := hist.Timestamp.Format("2006-01-02 15:04:05") + fmt.Printf("%d. [%s] %s → %s\n", hist.Index, ts, hist.Command, hist.Response) + } + return + } + } + + // Фоллбек к памяти процесса if len(commandHistory) == 0 { printColored("📝 История пуста\n", colorYellow) return @@ -609,6 +914,81 @@ func showHistory() { } } +func readFileHistory() ([]CommandHistory, error) { + if disableHistory { + return nil, fmt.Errorf("history disabled") + } + data, err := os.ReadFile(RESULT_HISTORY) + if err != nil || len(data) == 0 { + return nil, err + } + var fileHistory []CommandHistory + if err := json.Unmarshal(data, &fileHistory); err != nil { + return nil, err + } + return fileHistory, nil +} + +func viewHistoryEntry(id int) { + fileHistory, err := readFileHistory() + if err != nil || len(fileHistory) == 0 { + fmt.Println("История пуста или недоступна") + return + } + var h *CommandHistory + for i := range fileHistory { + if fileHistory[i].Index == id { + h = &fileHistory[i] + break + } + } + if h == nil { + fmt.Println("Запись не найдена") + return + } + printColored("\n📋 Команда:\n", colorYellow) + printColored(fmt.Sprintf(" %s\n\n", h.Response), colorBold+colorGreen) + if strings.TrimSpace(h.Explanation) != "" { + printColored("\n📖 Подробное объяснение:\n\n", colorYellow) + fmt.Println(h.Explanation) + } +} + +func deleteHistoryEntry(id int) { + fileHistory, err := readFileHistory() + if err != nil || len(fileHistory) == 0 { + fmt.Println("История пуста или недоступна") + return + } + // Найти индекс элемента с совпадающим полем Index + pos := -1 + for i := range fileHistory { + if fileHistory[i].Index == id { + pos = i + break + } + } + if pos == -1 { + fmt.Println("Запись не найдена") + return + } + // Удаляем элемент + fileHistory = append(fileHistory[:pos], fileHistory[pos+1:]...) + // Перенумеровываем индексы + for i := range fileHistory { + fileHistory[i].Index = i + 1 + } + if out, err := json.MarshalIndent(fileHistory, "", " "); err == nil { + if err := os.WriteFile(RESULT_HISTORY, out, 0644); err != nil { + fmt.Println("Ошибка записи истории:", err) + } else { + fmt.Println("Запись удалена") + } + } else { + fmt.Println("Ошибка сериализации истории:", err) + } +} + func printColored(text, color string) { fmt.Printf("%s%s%s", color, text, colorReset) } @@ -619,8 +999,9 @@ func showTips() { fmt.Println(" • Используйте --sys для изменения системного промпта") fmt.Println(" • Используйте --prompt-id для выбора предустановленного промпта") fmt.Println(" • Используйте --timeout для установки таймаута запроса") + fmt.Println(" • Укажите --no-history чтобы не записывать историю (аналог LCG_NO_HISTORY)") fmt.Println(" • Команда 'prompts list' покажет все доступные промпты") - fmt.Println(" • Команда 'history' покажет историю запросов") + fmt.Println(" • Команда 'history list' покажет историю запросов") fmt.Println(" • Команда 'config' покажет текущие настройки") fmt.Println(" • Команда 'health' проверит доступность API") } diff --git a/run_ollama.sh b/run_ollama.sh new file mode 100644 index 0000000..ae91939 --- /dev/null +++ b/run_ollama.sh @@ -0,0 +1,6 @@ +#! /usr/bin/bash + +LCG_PROVIDER=ollama LCG_HOST=http://192.168.87.108:11434/ \ +LCG_MODEL=hf.co/yandex/YandexGPT-5-Lite-8B-instruct-GGUF:Q4_K_M \ +go run . $1 $2 $3 $4 $5 $6 $7 $8 $9 +