From 47671eb566c669910756a55d27a788db11b8aaee Mon Sep 17 00:00:00 2001 From: Anton Kuznetcov Date: Tue, 21 Oct 2025 09:10:22 +0600 Subject: [PATCH] before result functions add --- API_CONTRACT.md | 183 +++++++++ USAGE_GUIDE.md | 27 ++ cmd/explain.go | 107 +++++ cmd/history.go | 157 +++++++ config/config.go | 83 ++++ main.go | 476 ++++------------------ response.go | 48 +++ run_ollama.sh => shell-code/run_ollama.sh | 0 8 files changed, 692 insertions(+), 389 deletions(-) create mode 100644 API_CONTRACT.md create mode 100644 cmd/explain.go create mode 100644 cmd/history.go create mode 100644 config/config.go create mode 100644 response.go rename run_ollama.sh => shell-code/run_ollama.sh (100%) diff --git a/API_CONTRACT.md b/API_CONTRACT.md new file mode 100644 index 0000000..7e146bf --- /dev/null +++ b/API_CONTRACT.md @@ -0,0 +1,183 @@ +# Контракт API для провайдеров (proxy и ollama) + +Этот документ описывает минимально необходимый API, который должен предоставлять сервер-провайдер (режимы: "proxy" и "ollama"), чтобы CLI-приложение работало корректно. + +## Общие требования + +- **Базовый URL** берётся из `config.AppConfig.Host`. Трейлинг-слэш на стороне клиента обрезается. +- **Таймаут** HTTP-запросов задаётся в секундах через конфигурацию (см. `config.AppConfig.Timeout`). +- **Кодирование**: все тела запросов и ответов — `application/json; charset=utf-8`. +- **Стриминг**: на данный момент клиент всегда запрашивает `stream=false`; стриминг не используется. + +--- + +## Режим proxy + +### Аутентификация + +- Все защищённые эндпоинты требуют заголовок: `Authorization: Bearer `. +- Токен берётся из `config.AppConfig.JwtToken`, либо из файла `~/.proxy_jwt_token`. + +### 1) POST `/api/v1/protected/sberchat/chat` + +- **Назначение**: получить единственный текстовый ответ LLM. +- **Заголовки**: + - `Content-Type: application/json` + - `Authorization: Bearer ` (обязательно) +- **Тело запроса** (минимально необходимые поля): + +```json +{ + "messages": [ + { "role": "system", "content": "" }, + { "role": "user", "content": "" } + ], + "model": "", + "temperature": 0.5, + "top_p": 0.5, + "stream": false, + "random_words": ["linux", "command", "gpt"], + "fallback_string": "I'm sorry, I can't help with that. Please try again." +} +``` + +- **Ответ 200 OK**: + +```json +{ + "response": "", + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0 + }, + "error": "", + "model": "", + "timeout_seconds": 0 +} +``` + +- **Ошибки**: любой статус != 200 воспринимается как ошибка. Желательно вернуть JSON вида: + +```json +{ "error": "" } +``` + +- **Пример cURL**: + +```bash +curl -sS -X POST \ + -H "Authorization: Bearer $JWT" \ + -H "Content-Type: application/json" \ + "$HOST/api/v1/protected/sberchat/chat" \ + -d '{ + "messages": [ + {"role":"system","content":"system prompt"}, + {"role":"user","content":"user ask"} + ], + "model":"GigaChat-2-Max", + "temperature":0.5, + "top_p":0.5, + "stream":false, + "random_words":["linux","command","gpt"], + "fallback_string":"I'm sorry, I can't help with that. Please try again." + }' +``` + +### 2) GET `/api/v1/protected/sberchat/health` + +- **Назначение**: health-check API и получение части метаданных по умолчанию. +- **Заголовки**: + - `Authorization: Bearer ` (если сервер требует авторизацию на health) +- **Ответ 200 OK**: + +```json +{ + "status": "ok", + "message": "", + "default_model": "", + "default_timeout_seconds": 120 +} +``` + +- **Ошибки**: любой статус != 200 считается падением health. + +### Модели + +- В текущей реализации клиент не запрашивает список моделей у proxy и использует фиксированный набор. +- Опционально можно реализовать эндпоинт для списка моделей (например, `GET /api/v1/protected/sberchat/models`) и расширить клиента позже. + +--- + +## Режим ollama + +### 1) POST `/api/chat` + +- **Назначение**: синхронная генерация одного ответа (без стрима). +- **Заголовки**: + - `Content-Type: application/json` +- **Тело запроса**: + +```json +{ + "model": "", + "stream": false, + "messages": [ + { "role": "system", "content": "" }, + { "role": "user", "content": "" } + ], + "options": {"temperature": 0.2} +} +``` + +- **Ответ 200 OK** (минимальный, который поддерживает клиент): + +```json +{ + "model": "", + "message": { "role": "assistant", "content": "" }, + "done": true +} +``` + +- Прочие поля ответа (`total_duration`, `eval_count` и т.д.) допускаются, но клиент использует только `message.content`. + +- **Ошибки**: любой статус != 200 считается ошибкой. Желательно возвращать читаемое тело. + +### 2) GET `/api/tags` + +- **Назначение**: используется как health-check и для получения списка моделей. +- **Ответ 200 OK**: + +```json +{ + "models": [ + { "name": "llama3:8b", "modified_at": "2024-01-01T00:00:00Z", "size": 123456789 }, + { "name": "qwen2.5:7b", "modified_at": "2024-01-02T00:00:00Z", "size": 987654321 } + ] +} +``` + +- Любой другой статус трактуется как ошибка health. + +--- + +## Семантика сообщений + +- `messages` — массив объектов `{ "role": "system"|"user"|"assistant", "content": "" }`. +- Клиент всегда отправляет как минимум 2 сообщения: системное и пользовательское. +- Ответ должен содержать один финальный текст в виде `response` (proxy) или `message.content` (ollama). + +## Поведение при таймаутах + +- Сервер должен завершать запрос в пределах `config.AppConfig.Timeout` секунд (значение передаётся клиентом в настройки HTTP-клиента; отдельным полем в запросе оно не отправляется, исключение — `proxy` может возвращать `timeout_seconds` в ответе как справочную информацию). + +## Коды ответов и ошибки + +- 200 — успешный ответ с телом согласно контракту. +- !=200 — ошибка; тело желательно в JSON с полем `error`. + +## Изменения контракта + +- Добавление новых полей в ответах, не используемых клиентом, допустимо при сохранении существующих. +- Переименование или удаление полей `response` (proxy) и `message.content` (ollama) нарушит совместимость. diff --git a/USAGE_GUIDE.md b/USAGE_GUIDE.md index 422bc29..910bf7a 100644 --- a/USAGE_GUIDE.md +++ b/USAGE_GUIDE.md @@ -149,6 +149,33 @@ lcg [глобальные опции] <описание команды> - Основные эндпоинты: `/api/v1/protected/sberchat/chat` и `/api/v1/protected/sberchat/health`. - Команды `update-jwt`/`delete-jwt` помогают управлять токеном локально. +## Рекомендации по выбору провайдера, модели и таймаутов + +### Выбор провайдера + +- **Ollama**: выбирайте для локальной работы (офлайн/частные данные), когда есть доступ к GPU/CPU и готовность поддерживать локальные модели. Минимальные задержки сети, полная приватность. +- **Proxy**: выбирайте для централизованного хостинга моделей, более мощных/обновляемых моделей, простоты развёртывания у команды. Обязательно используйте HTTPS и корректный `JWT`. + +### Выбор модели + +- Для генерации Linux‑команд подходят компактные «code»/«general» модели (по умолчанию `codegeex4`). +- Для подробных объяснений (`v`/`vv`/`vvv`) точность выше у более крупных моделей (например, семейства LLaMA/Qwen/GigaChat), но они медленнее. +- Русскоязычные запросы часто лучше обрабатываются в `GigaChat-*` (режим proxy), английские — в популярных open‑source (Ollama). +- Балансируйте: скорость (малые модели) vs качество (крупные модели). Тестируйте `lcg models` и подбирайте `LCG_MODEL`. + +### Таймауты + +- Стартовые значения: локально с Ollama — **60–120 сек**, удалённый proxy — **120–300 сек**. +- Увеличьте таймаут для больших моделей/длинных запросов. Флаг `--timeout` перекрывает `LCG_TIMEOUT` на время запуска. +- Если часто видите таймауты — проверьте здоровье API (`lcg health`) и сетевую доступность `LCG_HOST`. + +### Практические советы + +- Если данные чувствительные — используйте Ollama локально и `--no-history` при необходимости. +- Для «черновой» команды начните с `Ollama + небольшая модель`; для «объяснений и альтернатив» используйте более крупную модель/Proxy. +- Не вставляйте секреты в запросы. Перед выполнением (`e`) проверяйте команду вручную. +- Для структуры API см. `API_CONTRACT.md` (эндпоинты и форматы запросов/ответов). + ## Системные промпты Встроенные (ID 1–5): diff --git a/cmd/explain.go b/cmd/explain.go new file mode 100644 index 0000000..273e0f5 --- /dev/null +++ b/cmd/explain.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "fmt" + "os" + "path" + "strings" + "time" + + "github.com/atotto/clipboard" + "github.com/direct-dev-ru/linux-command-gpt/config" + "github.com/direct-dev-ru/linux-command-gpt/gpt" +) + +// ExplainDeps инъекция зависимостей для вывода и окружения +type ExplainDeps struct { + DisableHistory bool + PrintColored func(string, string) + ColorPurple string + ColorGreen string + ColorRed string + ColorYellow string + GetCommand func(gpt.Gpt3, string) (string, float64) +} + +// ShowDetailedExplanation делает дополнительный запрос с подробным описанием и альтернативами +func ShowDetailedExplanation(command string, gpt3 gpt.Gpt3, system, originalCmd string, timeout int, level int, deps ExplainDeps) { + 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, config.AppConfig.Host, gpt3.ApiKey, gpt3.Model, detailedSystem, 0.2, timeout) + + deps.PrintColored("\n🧠 Получаю подробное объяснение...\n", deps.ColorPurple) + explanation, elapsed := deps.GetCommand(*detailed, ask) + if explanation == "" { + deps.PrintColored("❌ Не удалось получить подробное объяснение.\n", deps.ColorRed) + return + } + + deps.PrintColored(fmt.Sprintf("✅ Готово за %.2f сек\n", elapsed), deps.ColorGreen) + deps.PrintColored("\nВНИМАНИЕ: ОТВЕТ СФОРМИРОВАН ИИ. ТРЕБУЕТСЯ ПРОВЕРКА И КРИТИЧЕСКИЙ АНАЛИЗ. ВОЗМОЖНЫ ОШИБКИ И ГАЛЛЮЦИНАЦИИ.\n", deps.ColorRed) + deps.PrintColored("\n📖 Подробное объяснение и альтернативы:\n\n", deps.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, config.AppConfig.ResultFolder) + case "r": + fmt.Println("🔄 Перегенерирую подробное объяснение...") + ShowDetailedExplanation(command, gpt3, system, originalCmd, timeout, level, deps) + default: + fmt.Println(" Возврат в основное меню.") + } + + if !deps.DisableHistory && (strings.ToLower(choice) == "c" || strings.ToLower(choice) == "s" || strings.ToLower(choice) == "n") { + SaveToHistory(config.AppConfig.ResultHistory, config.AppConfig.ResultFolder, originalCmd, command, system, explanation) + } +} + +// saveExplanation сохраняет подробное объяснение и альтернативные способы +func saveExplanation(explanation string, model string, originalCmd string, commandResponse string, resultFolder string) { + timestamp := time.Now().Format("2006-01-02_15-04-05") + filename := fmt.Sprintf("gpt_explanation_%s_%s.md", model, timestamp) + filePath := path.Join(resultFolder, 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("Saved to %s\n", filePath) + } +} + +// truncateTitle сокращает строку до 120 символов (по рунам), добавляя " ..." при усечении +func truncateTitle(s string) string { + const maxLen = 120 + if runeCount := len([]rune(s)); runeCount <= maxLen { + return s + } + const head = 116 + r := []rune(s) + if len(r) <= head { + return s + } + return string(r[:head]) + " ..." +} diff --git a/cmd/history.go b/cmd/history.go new file mode 100644 index 0000000..2b3b9ec --- /dev/null +++ b/cmd/history.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +type HistoryEntry struct { + 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"` +} + +func read(historyPath string) ([]HistoryEntry, error) { + data, err := os.ReadFile(historyPath) + if err != nil || len(data) == 0 { + return nil, err + } + var items []HistoryEntry + if err := json.Unmarshal(data, &items); err != nil { + return nil, err + } + return items, nil +} + +func write(historyPath string, entries []HistoryEntry) error { + for i := range entries { + entries[i].Index = i + 1 + } + out, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(historyPath), 0755); err != nil { + return err + } + return os.WriteFile(historyPath, out, 0644) +} + +func ShowHistory(historyPath string, printColored func(string, string), colorYellow string) { + items, err := read(historyPath) + if err != nil || len(items) == 0 { + printColored("📝 История пуста\n", colorYellow) + return + } + printColored("📝 История (из файла):\n", colorYellow) + for _, h := range items { + ts := h.Timestamp.Format("2006-01-02 15:04:05") + fmt.Printf("%d. [%s] %s → %s\n", h.Index, ts, h.Command, h.Response) + } +} + +func ViewHistoryEntry(historyPath string, id int, printColored func(string, string), colorYellow, colorBold, colorGreen string) { + items, err := read(historyPath) + if err != nil || len(items) == 0 { + fmt.Println("История пуста или недоступна") + return + } + var h *HistoryEntry + for i := range items { + if items[i].Index == id { + h = &items[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(historyPath string, id int) error { + items, err := read(historyPath) + if err != nil || len(items) == 0 { + return fmt.Errorf("история пуста или недоступна") + } + pos := -1 + for i := range items { + if items[i].Index == id { + pos = i + break + } + } + if pos == -1 { + return fmt.Errorf("запись не найдена") + } + items = append(items[:pos], items[pos+1:]...) + return write(historyPath, items) +} + +func SaveToHistory(historyPath, resultFolder, cmdText, response, system string, explanationOptional ...string) error { + var explanation string + if len(explanationOptional) > 0 { + explanation = explanationOptional[0] + } + items, _ := read(historyPath) + duplicateIndex := -1 + for i, h := range items { + if strings.EqualFold(strings.TrimSpace(h.Command), strings.TrimSpace(cmdText)) { + duplicateIndex = i + break + } + } + entry := HistoryEntry{ + Index: len(items) + 1, + Command: cmdText, + Response: response, + Explanation: explanation, + System: system, + Timestamp: time.Now(), + } + if duplicateIndex == -1 { + items = append(items, entry) + return write(historyPath, items) + } + fmt.Printf("\nЗапрос уже есть в истории от %s. Перезаписать? (y/N): ", items[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 = items[duplicateIndex].Index + items[duplicateIndex] = entry + return write(historyPath, items) + } + return nil +} + +func CheckAndSuggestFromHistory(historyPath, cmdText string) (bool, *HistoryEntry) { + items, err := read(historyPath) + if err != nil || len(items) == 0 { + return false, nil + } + for _, h := range items { + if strings.EqualFold(strings.TrimSpace(h.Command), strings.TrimSpace(cmdText)) { + 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 +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..50c6155 --- /dev/null +++ b/config/config.go @@ -0,0 +1,83 @@ +package config + +import ( + "os" + "path" + "strings" +) + +type Config struct { + Cwd string + Host string + ProxyUrl string + Completions string + Model string + Prompt string + ApiKeyFile string + ResultFolder string + ProviderType string + JwtToken string + PromptID string + Timeout string + ResultHistory string + NoHistoryEnv string + MainFlags MainFlags +} + +type MainFlags struct { + File string + NoHistory bool + Sys string + PromptID int + Timeout int +} + +func getEnv(key, defaultValue string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return defaultValue +} + +func Load() Config { + cwd, _ := os.Getwd() + + homedir, err := os.UserHomeDir() + if err != nil { + homedir = cwd + } + os.MkdirAll(path.Join(homedir, ".config", "lcg", "gpt_results"), 0755) + resultFolder := getEnv("LCG_RESULT_FOLDER", path.Join(homedir, ".config", "lcg", "gpt_results")) + + return Config{ + Cwd: cwd, + Host: getEnv("LCG_HOST", "http://192.168.87.108:11434/"), + ProxyUrl: getEnv("LCG_PROXY_URL", "/api/v1/protected/sberchat/chat"), + Completions: getEnv("LCG_COMPLETIONS_PATH", "api/chat"), + Model: getEnv("LCG_MODEL", "hf.co/yandex/YandexGPT-5-Lite-8B-instruct-GGUF:Q4_K_M"), + 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."), + ApiKeyFile: getEnv("LCG_API_KEY_FILE", ".openai_api_key"), + ResultFolder: resultFolder, + ProviderType: getEnv("LCG_PROVIDER", "ollama"), + JwtToken: getEnv("LCG_JWT_TOKEN", ""), + PromptID: getEnv("LCG_PROMPT_ID", "1"), + Timeout: getEnv("LCG_TIMEOUT", "300"), + ResultHistory: getEnv("LCG_RESULT_HISTORY", path.Join(resultFolder, "lcg_history.json")), + NoHistoryEnv: getEnv("LCG_NO_HISTORY", ""), + } +} + +func (c Config) IsNoHistoryEnabled() bool { + v := strings.TrimSpace(c.NoHistoryEnv) + if v == "" { + return false + } + vLower := strings.ToLower(v) + return vLower == "1" || vLower == "true" +} + +var AppConfig Config + +func init() { + AppConfig = Load() +} diff --git a/main.go b/main.go index 7d9edb0..c83a11e 100644 --- a/main.go +++ b/main.go @@ -2,18 +2,18 @@ package main import ( _ "embed" - "encoding/json" "fmt" "math" "os" "os/exec" "os/user" - "path" "strconv" "strings" "time" "github.com/atotto/clipboard" + cmdPackage "github.com/direct-dev-ru/linux-command-gpt/cmd" + "github.com/direct-dev-ru/linux-command-gpt/config" "github.com/direct-dev-ru/linux-command-gpt/gpt" "github.com/direct-dev-ru/linux-command-gpt/reader" "github.com/urfave/cli/v2" @@ -22,21 +22,7 @@ import ( //go:embed VERSION.txt 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") // Таймаут в секундах по умолчанию - RESULT_HISTORY = getEnv("LCG_RESULT_HISTORY", path.Join(RESULT_FOLDER, "lcg_history.json")) - NO_HISTORY_ENV = getEnv("LCG_NO_HISTORY", "") -) +// используем глобальный экземпляр конфига из пакета config // disableHistory управляет записью/обновлением истории на уровне процесса (флаг имеет приоритет над env) var disableHistory bool @@ -53,6 +39,8 @@ const ( ) func main() { + _ = colorBlue + app := &cli.App{ Name: "lcg", Usage: "Linux Command GPT - Генерация Linux команд из описаний", @@ -68,7 +56,7 @@ lcg [опции] <описание команды> Description: ` Linux Command GPT - инструмент для генерации Linux команд из описаний на естественном языке. Поддерживает чтение частей промпта из файлов и позволяет сохранять, копировать или перегенерировать результаты. - +может задавать системный промпт или выбирать из предустановленных промптов. Переменные окружения: LCG_HOST Endpoint для LLM API (по умолчанию: http://192.168.87.108:11434/) LCG_MODEL Название модели (по умолчанию: codegeex4) @@ -113,9 +101,24 @@ Linux Command GPT - инструмент для генерации Linux ком Action: func(c *cli.Context) error { file := c.String("file") system := c.String("sys") - disableHistory = c.Bool("no-history") || isNoHistoryEnv() + // обновляем конфиг на основе флагов + if system != "" { + config.AppConfig.Prompt = system + } + if c.IsSet("timeout") { + config.AppConfig.Timeout = fmt.Sprintf("%d", c.Int("timeout")) + } promptID := c.Int("prompt-id") timeout := c.Int("timeout") + // сохраняем конкретные значения флагов + config.AppConfig.MainFlags = config.MainFlags{ + File: file, + NoHistory: c.Bool("no-history"), + Sys: system, + PromptID: promptID, + Timeout: timeout, + } + disableHistory = config.AppConfig.MainFlags.NoHistory || config.AppConfig.IsNoHistoryEnabled() args := c.Args().Slice() if len(args) == 0 { @@ -162,15 +165,15 @@ func getCommands() []*cli.Command { Aliases: []string{"u"}, Usage: "Update the API key", Action: func(c *cli.Context) error { - if PROVIDER_TYPE == "ollama" || PROVIDER_TYPE == "proxy" { + if config.AppConfig.ProviderType == "ollama" || config.AppConfig.ProviderType == "proxy" { fmt.Println("API key is not needed for ollama and proxy providers") return nil } timeout := 120 // default timeout - if t, err := strconv.Atoi(TIMEOUT); err == nil { + if t, err := strconv.Atoi(config.AppConfig.Timeout); err == nil { timeout = t } - gpt3 := initGPT(PROMPT, timeout) + gpt3 := initGPT(config.AppConfig.Prompt, timeout) gpt3.UpdateKey() fmt.Println("API key updated.") return nil @@ -181,15 +184,15 @@ func getCommands() []*cli.Command { Aliases: []string{"d"}, Usage: "Delete the API key", Action: func(c *cli.Context) error { - if PROVIDER_TYPE == "ollama" || PROVIDER_TYPE == "proxy" { + if config.AppConfig.ProviderType == "ollama" || config.AppConfig.ProviderType == "proxy" { fmt.Println("API key is not needed for ollama and proxy providers") return nil } timeout := 120 // default timeout - if t, err := strconv.Atoi(TIMEOUT); err == nil { + if t, err := strconv.Atoi(config.AppConfig.Timeout); err == nil { timeout = t } - gpt3 := initGPT(PROMPT, timeout) + gpt3 := initGPT(config.AppConfig.Prompt, timeout) gpt3.DeleteKey() fmt.Println("API key deleted.") return nil @@ -200,7 +203,7 @@ func getCommands() []*cli.Command { Aliases: []string{"j"}, Usage: "Update the JWT token for proxy API", Action: func(c *cli.Context) error { - if PROVIDER_TYPE != "proxy" { + if config.AppConfig.ProviderType != "proxy" { fmt.Println("JWT token is only needed for proxy provider") return nil } @@ -225,7 +228,7 @@ func getCommands() []*cli.Command { Aliases: []string{"dj"}, Usage: "Delete the JWT token for proxy API", Action: func(c *cli.Context) error { - if PROVIDER_TYPE != "proxy" { + if config.AppConfig.ProviderType != "proxy" { fmt.Println("JWT token is only needed for proxy provider") return nil } @@ -247,17 +250,17 @@ func getCommands() []*cli.Command { Usage: "Show available models", Action: func(c *cli.Context) error { timeout := 120 // default timeout - if t, err := strconv.Atoi(TIMEOUT); err == nil { + if t, err := strconv.Atoi(config.AppConfig.Timeout); err == nil { timeout = t } - gpt3 := initGPT(PROMPT, timeout) + gpt3 := initGPT(config.AppConfig.Prompt, timeout) models, err := gpt3.GetAvailableModels() if err != nil { fmt.Printf("Ошибка получения моделей: %v\n", err) return err } - fmt.Printf("Доступные модели для провайдера %s:\n", PROVIDER_TYPE) + fmt.Printf("Доступные модели для провайдера %s:\n", config.AppConfig.ProviderType) for i, model := range models { fmt.Printf(" %d. %s\n", i+1, model) } @@ -270,10 +273,10 @@ func getCommands() []*cli.Command { Usage: "Check API health", Action: func(c *cli.Context) error { timeout := 120 // default timeout - if t, err := strconv.Atoi(TIMEOUT); err == nil { + if t, err := strconv.Atoi(config.AppConfig.Timeout); err == nil { timeout = t } - gpt3 := initGPT(PROMPT, timeout) + gpt3 := initGPT(config.AppConfig.Prompt, timeout) if err := gpt3.Health(); err != nil { fmt.Printf("Health check failed: %v\n", err) return err @@ -287,14 +290,14 @@ func getCommands() []*cli.Command { Aliases: []string{"co"}, // Изменено с "c" на "co" Usage: "Show current configuration", Action: func(c *cli.Context) error { - fmt.Printf("Provider: %s\n", PROVIDER_TYPE) - fmt.Printf("Host: %s\n", HOST) - fmt.Printf("Model: %s\n", MODEL) - fmt.Printf("Prompt: %s\n", PROMPT) - fmt.Printf("Timeout: %s seconds\n", TIMEOUT) - if PROVIDER_TYPE == "proxy" { + fmt.Printf("Provider: %s\n", config.AppConfig.ProviderType) + fmt.Printf("Host: %s\n", config.AppConfig.Host) + fmt.Printf("Model: %s\n", config.AppConfig.Model) + fmt.Printf("Prompt: %s\n", config.AppConfig.Prompt) + fmt.Printf("Timeout: %s seconds\n", config.AppConfig.Timeout) + if config.AppConfig.ProviderType == "proxy" { fmt.Printf("JWT Token: %s\n", func() string { - if JWT_TOKEN != "" { + if config.AppConfig.JwtToken != "" { return "***set***" } currentUser, _ := user.Current() @@ -318,7 +321,11 @@ func getCommands() []*cli.Command { Aliases: []string{"l"}, Usage: "List history entries", Action: func(c *cli.Context) error { - showHistory() + if disableHistory { + printColored("📝 История отключена (--no-history / LCG_NO_HISTORY)\n", colorYellow) + } else { + cmdPackage.ShowHistory(config.AppConfig.ResultHistory, printColored, colorYellow) + } return nil }, }, @@ -336,7 +343,11 @@ func getCommands() []*cli.Command { fmt.Println("Неверный ID") return nil } - viewHistoryEntry(id) + if disableHistory { + fmt.Println("История отключена") + } else { + cmdPackage.ViewHistoryEntry(config.AppConfig.ResultHistory, id, printColored, colorYellow, colorBold, colorGreen) + } return nil }, }, @@ -354,7 +365,11 @@ func getCommands() []*cli.Command { fmt.Println("Неверный ID") return nil } - deleteHistoryEntry(id) + if disableHistory { + fmt.Println("История отключена") + } else if err := cmdPackage.DeleteHistoryEntry(config.AppConfig.ResultHistory, id); err != nil { + fmt.Println(err) + } return nil }, }, @@ -467,7 +482,7 @@ func getCommands() []*cli.Command { command := strings.Join(c.Args().Slice()[1:], " ") fmt.Printf("\nTesting with command: %s\n", command) timeout := 120 // default timeout - if t, err := strconv.Atoi(TIMEOUT); err == nil { + if t, err := strconv.Atoi(config.AppConfig.Timeout); err == nil { timeout = t } executeMain("", prompt.Content, command, timeout) @@ -489,12 +504,12 @@ func executeMain(file, system, commandInput string, timeout int) { // Если system пустой, используем дефолтный промпт if system == "" { - system = PROMPT + system = config.AppConfig.Prompt } // Обеспечим папку результатов заранее (может понадобиться при действиях) - if _, err := os.Stat(RESULT_FOLDER); os.IsNotExist(err) { - if err := os.MkdirAll(RESULT_FOLDER, 0755); err != nil { + if _, err := os.Stat(config.AppConfig.ResultFolder); os.IsNotExist(err) { + if err := os.MkdirAll(config.AppConfig.ResultFolder, 0755); err != nil { printColored(fmt.Sprintf("❌ Ошибка создания папки результатов: %v\n", err), colorRed) return } @@ -502,7 +517,7 @@ func executeMain(file, system, commandInput string, timeout int) { // Проверка истории: если такой запрос уже встречался — предложить открыть из истории if !disableHistory { - if found, hist := checkAndSuggestFromHistory(commandInput); found && hist != nil { + if found, hist := cmdPackage.CheckAndSuggestFromHistory(config.AppConfig.ResultHistory, commandInput); found && hist != nil { gpt3 := initGPT(system, timeout) printColored("\nВНИМАНИЕ: ОТВЕТ СФОРМИРОВАН ИИ. ТРЕБУЕТСЯ ПРОВЕРКА И КРИТИЧЕСКИЙ АНАЛИЗ. ВОЗМОЖНЫ ОШИБКИ И ГАЛЛЮЦИНАЦИИ.\n", colorRed) printColored("\n📋 Команда (из истории):\n", colorYellow) @@ -542,39 +557,15 @@ func executeMain(file, system, commandInput string, timeout int) { } // 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 -} +// moved to history.go func initGPT(system string, timeout int) gpt.Gpt3 { currentUser, _ := user.Current() // Загружаем JWT токен в зависимости от провайдера var jwtToken string - if PROVIDER_TYPE == "proxy" { - jwtToken = JWT_TOKEN + if config.AppConfig.ProviderType == "proxy" { + jwtToken = config.AppConfig.JwtToken if jwtToken == "" { // Пытаемся загрузить из файла jwtFile := currentUser.HomeDir + "/.proxy_jwt_token" @@ -584,7 +575,7 @@ func initGPT(system string, timeout int) gpt.Gpt3 { } } - return *gpt.NewGpt3(PROVIDER_TYPE, HOST, jwtToken, MODEL, system, 0.01, timeout) + return *gpt.NewGpt3(config.AppConfig.ProviderType, config.AppConfig.Host, jwtToken, config.AppConfig.Model, system, 0.01, timeout) } func getCommand(gpt3 gpt.Gpt3, cmd string) (string, float64) { @@ -626,12 +617,12 @@ func handlePostResponse(response string, gpt3 gpt.Gpt3, system, cmd string, time clipboard.WriteAll(response) fmt.Println("✅ Команда скопирована в буфер обмена") if !disableHistory { - saveToHistory(cmd, response, gpt3.Prompt) + cmdPackage.SaveToHistory(config.AppConfig.ResultHistory, config.AppConfig.ResultFolder, cmd, response, gpt3.Prompt) } case "s": - saveResponse(response, gpt3, cmd) + saveResponse(response, gpt3.Model, gpt3.Prompt, cmd) if !disableHistory { - saveToHistory(cmd, response, gpt3.Prompt) + cmdPackage.SaveToHistory(config.AppConfig.ResultHistory, config.AppConfig.ResultFolder, cmd, response, gpt3.Prompt) } case "r": fmt.Println("🔄 Перегенерирую...") @@ -639,125 +630,37 @@ func handlePostResponse(response string, gpt3 gpt.Gpt3, system, cmd string, time case "e": executeCommand(response) if !disableHistory { - saveToHistory(cmd, response, gpt3.Prompt) + cmdPackage.SaveToHistory(config.AppConfig.ResultHistory, config.AppConfig.ResultFolder, cmd, response, gpt3.Prompt) } case "v", "vv", "vvv": level := len(choice) // 1, 2, 3 - showDetailedExplanation(response, gpt3, system, cmd, timeout, level) + deps := cmdPackage.ExplainDeps{ + DisableHistory: disableHistory, + PrintColored: printColored, + ColorPurple: colorPurple, + ColorGreen: colorGreen, + ColorRed: colorRed, + ColorYellow: colorYellow, + GetCommand: getCommand, + } + cmdPackage.ShowDetailedExplanation(response, gpt3, system, cmd, timeout, level, deps) default: fmt.Println(" До свидания!") if !disableHistory { - saveToHistory(cmd, response, gpt3.Prompt) + cmdPackage.SaveToHistory(config.AppConfig.ResultHistory, config.AppConfig.ResultFolder, cmd, response, gpt3.Prompt) } } } -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) - // Заголовок — сокращенный текст запроса пользователя - 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) - } else { - fmt.Printf("Response saved to %s\n", filePath) - } -} +// moved to response.go // 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) - } -} +// moved to explain.go // 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]) + " ..." -} +// moved to response.go -// 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) - } -} +// moved to explain.go func executeCommand(command string) { fmt.Printf("🚀 Выполняю: %s\n", command) @@ -780,214 +683,9 @@ func executeCommand(command string) { } } -func getEnv(key, defaultValue string) string { - if value, exists := os.LookupEnv(key); exists { - return value - } - return defaultValue -} +// env helpers moved to config package -func isNoHistoryEnv() bool { - v := strings.TrimSpace(NO_HISTORY_ENV) - vLower := strings.ToLower(v) - return vLower == "1" || vLower == "true" -} - -type CommandHistory struct { - 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, system string, explanationOptional ...string) { - if disableHistory { - return - } - var explanation string - if len(explanationOptional) > 0 { - explanation = explanationOptional[0] - } - - 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 - } - - printColored("📝 История команд:\n", colorYellow) - for i, hist := range commandHistory { - fmt.Printf("%d. %s → %s (%s)\n", - i+1, - hist.Command, - hist.Response, - hist.Timestamp.Format("15:04:05")) - } -} - -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) - } -} +// moved to history.go func printColored(text, color string) { fmt.Printf("%s%s%s", color, text, colorReset) diff --git a/response.go b/response.go new file mode 100644 index 0000000..9a5623e --- /dev/null +++ b/response.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "os" + "path" + "time" + + "github.com/direct-dev-ru/linux-command-gpt/config" +) + +func nowTimestamp() string { + return time.Now().Format("2006-01-02_15-04-05") +} + +func pathJoin(base, name string) string { + return path.Join(base, name) +} + +func writeFile(filePath, content string) { + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + fmt.Println("Failed to save response:", err) + } else { + fmt.Printf("Saved to %s\n", filePath) + } +} + +func saveResponse(response string, gpt3Model string, prompt string, cmd string) { + timestamp := nowTimestamp() + filename := fmt.Sprintf("gpt_request_%s_%s.md", gpt3Model, timestamp) + filePath := pathJoin(config.AppConfig.ResultFolder, filename) + title := truncateTitle(cmd) + content := fmt.Sprintf("# %s\n\n## Prompt\n\n%s\n\n## Response\n\n%s\n", title, cmd+". "+prompt, response) + writeFile(filePath, content) +} + +func truncateTitle(s string) string { + const maxLen = 120 + if runeCount := len([]rune(s)); runeCount <= maxLen { + return s + } + const head = 116 + r := []rune(s) + if len(r) <= head { + return s + } + return string(r[:head]) + " ..." +} diff --git a/run_ollama.sh b/shell-code/run_ollama.sh similarity index 100% rename from run_ollama.sh rename to shell-code/run_ollama.sh