gskaro-v1/internal/server/handlers_llm.go

268 lines
7.1 KiB
Go

package server
import (
"bytes"
"fmt"
"gskaro-v1/internal/llm"
"net/http"
"time"
)
type llmLogEntry struct {
Model string
Prompt string
Answer string
Timestamp time.Time
PromptTokens int
AnswerTokens int
}
var llmHistory []llmLogEntry
// -----------------------------
// LLM Console
// -----------------------------
func llmConsoleHandler(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("HX-Request") != "true" {
fmt.Fprintf(w, `
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>LLM Console</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body hx-boost="true">
<div id="top-bar" style="display:flex; gap:16px; align-items:center; margin-bottom:16px;">
<div>
<button hx-get="/api/stats" hx-target="#content">Статистика</button>
<button hx-get="/api/tasks" hx-target="#content">Задачи</button>
<button hx-get="/api/notes" hx-target="#content">AI_NOTES.md</button>
<button hx-get="/api/llm/console" hx-target="#content">LLM Console</button>
<button hx-get="/api/llm/models" hx-target="#content">LLM Models</button>
<button hx-get="/api/llm/history" hx-target="#content">LLM History</button>
</div>
<div id="model-badge"
hx-get="/api/llm/status"
hx-trigger="load, every 5s">
Текущая модель: <strong>—</strong>
</div>
</div>
<div id="content">
`)
renderConsole(w)
fmt.Fprintf(w, `
</div>
</body>
</html>
`)
return
}
renderConsole(w)
}
func renderConsole(w http.ResponseWriter) {
fmt.Fprintf(w, `
<h2>LLM Console</h2>
<p>Текущая модель: <strong>%s</strong></p>
<form hx-post="/api/llm/query" hx-target="#llm-output" hx-swap="innerHTML">
<textarea name="prompt" rows="4" style="width:100%%"></textarea>
<br>
<button type="submit">Отправить</button>
</form>
<div id="llm-output" style="margin-top:20px; border:1px solid #ddd; padding:10px;">
Ожидание ответа...
</div>
`, llm.ActiveModel)
}
// -----------------------------
// Список моделей
// -----------------------------
func llmModelsHandler(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host")
if host == "" {
fmt.Fprintf(w, `
<h2>Подключение к Ollama</h2>
<form hx-get="/api/llm/models" hx-target="#content">
<input type="text" name="host" placeholder="http://localhost:11434" style="width:300px;">
<button type="submit">Получить модели</button>
</form>
`)
return
}
models, err := llm.GetModelsFromHost(host)
if err != nil {
fmt.Fprintf(w, `<p style="color:red;">Ошибка подключения: %s</p>`, err)
return
}
fmt.Fprintf(w, `<h2>Модели на %s</h2>`, host)
fmt.Fprintf(w, `<form hx-post="/api/llm/select" hx-target="#content">`)
fmt.Fprintf(w, `<input type="hidden" name="host" value="%s">`, host)
fmt.Fprintf(w, `<select name="model">`)
for _, m := range models {
fmt.Fprintf(w, `<option value="%s">%s</option>`, m, m)
}
fmt.Fprintf(w, `</select>`)
fmt.Fprintf(w, `<button type="submit">Выбрать</button>`)
fmt.Fprintf(w, `</form>`)
}
// -----------------------------
// Выбор модели
// -----------------------------
func llmSelectHandler(w http.ResponseWriter, r *http.Request) {
host := r.FormValue("host")
model := r.FormValue("model")
if host != "" {
llm.ActiveHost = host
}
if model != "" {
llm.ActiveModel = model
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/api/llm/console")
return
}
http.Redirect(w, r, "/api/llm/console", http.StatusSeeOther)
}
// -----------------------------
// Запрос к модели
// -----------------------------
func llmQueryHandler(w http.ResponseWriter, r *http.Request) {
prompt := r.FormValue("prompt")
if prompt == "" {
fmt.Fprintf(w, "<p style='color:red;'>Пустой запрос.</p>")
return
}
client := &llm.OllamaClient{
Host: llm.ActiveHost,
Model: llm.ActiveModel,
}
var answerBuf bytes.Buffer
var promptTokens, answerTokens int
err := client.Stream(prompt, func(chunk string, meta *llm.OllamaGenerateResponse) {
answerBuf.WriteString(chunk)
if meta != nil {
promptTokens = meta.PromptEvalCount
answerTokens = meta.EvalCount
}
})
if err != nil {
fmt.Fprintf(w, "<p style='color:red;'>Ошибка: %s</p>", err)
return
}
resp := answerBuf.String()
llmHistory = append(llmHistory, llmLogEntry{
Model: llm.ActiveModel,
Prompt: prompt,
Answer: resp,
Timestamp: time.Now(),
PromptTokens: promptTokens,
AnswerTokens: answerTokens,
})
fmt.Fprintf(w, "<pre>%s</pre>", resp)
}
// -----------------------------
// Статус
// -----------------------------
func llmStatusHandler(w http.ResponseWriter, r *http.Request) {
resp, err := http.Get(llm.ActiveHost + "/api/tags")
if err != nil {
fmt.Fprintf(w, `
<span style="padding:4px 8px; border-radius:4px; background:#fee; color:#900;">
Ollama: offline
</span>
&nbsp;
<span>Модель: <strong>%s</strong></span>
`, llm.ActiveModel)
return
}
resp.Body.Close()
fmt.Fprintf(w, `
<span style="padding:4px 8px; border-radius:4px; background:#e6ffed; color:#137333;">
Ollama: online
</span>
&nbsp;
<span>Модель: <strong>%s</strong></span>
`, llm.ActiveModel)
}
// -----------------------------
// История
// -----------------------------
func llmHistoryHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "<h2>LLM History</h2>")
if len(llmHistory) == 0 {
fmt.Fprintf(w, "<p>Пока пусто.</p>")
return
}
fmt.Fprintf(w, `<ul style="list-style:none; padding:0;">`)
for i := len(llmHistory) - 1; i >= 0; i-- {
e := llmHistory[i]
fmt.Fprintf(w, `
<li style="margin-bottom:16px; padding:12px; border:1px solid #ddd; border-radius:6px;">
<div style="color:#666; font-size:13px;">
🕒 %s
</div>
<div><strong>Модель:</strong> %s</div>
<div style="margin-top:8px;"><strong>👤 Запрос:</strong></div>
<pre style="white-space:pre-wrap; background:#fafafa; padding:8px; border-radius:4px;">%s</pre>
<div style="margin-top:8px;"><strong>🤖 Ответ модели:</strong></div>
<pre style="white-space:pre-wrap; background:#f0f7ff; padding:8px; border-radius:4px;">%s</pre>
<div style="margin-top:8px; color:#555;">
🔢 Токены: prompt=%d, answer=%d, total=%d
</div>
</li>
`,
e.Timestamp.Format("2006-01-02 15:04:05"),
e.Model,
e.Prompt,
e.Answer,
e.PromptTokens,
e.AnswerTokens,
e.PromptTokens+e.AnswerTokens,
)
}
fmt.Fprintf(w, `</ul>`)
}