Backend (internal/llm): backend-agnostic LLMClient interface + factory
with vLLM (OpenAI-compat) and Ollama (native) clients, each Complete +
Stream. prompts.go holds the checkpoint and Ask Petal templates;
checkpoint.go salvages JSON from model output (brace-matched), enforces a
per-doc 30s RateLimiter, and truncates the doc to a latency cap.
internal/suggestions: POST /api/docs/:id/check runs a checkpoint and
replaces the doc's pending suggestions in one tx (accepted/rejected kept
as history); GET /api/docs/:id/suggestions lists pending;
POST /api/suggestions/:id/{accept,dismiss} resolves one. Throttled checks
return the current set rather than erroring.
Frontend: useCheckpoint (4s debounce, loads existing on open, stale-guard
tokens); SuggestionHighlight renders ProseMirror decorations re-anchored
by the `original` string on every doc change (not stored marks), with
precise textblock-offset→PM-position mapping; SuggestionCard shows the
type tag + diff + explanation and applies the replacement in-editor on
accept; breathing rose checkpoint dot in the StatusBar; fade-float +
breathe animations.
Tests: llm parse/rate-limit/truncate; suggestions full flow + rate-limit
over httptest with a stub client. Smoke-tested end-to-end against a fake
vLLM endpoint (anchoring verified) and the LLM-unreachable 502 path.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
111 lines
3.7 KiB
Go
111 lines
3.7 KiB
Go
// Package llm wraps the local inference endpoint behind a backend-agnostic
|
|
// interface. Two concrete backends — vLLM (OpenAI-compatible) and Ollama
|
|
// (native) — implement LLMClient; the rest of the app (the grammar checkpoint,
|
|
// Ask Petal) calls the interface only and never knows which one is active.
|
|
package llm
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/config"
|
|
)
|
|
|
|
// Message is one chat turn sent to the model.
|
|
type Message struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// CompletionRequest is the backend-neutral request shape. Each backend maps
|
|
// these fields onto its own wire format (see the parameter cheatsheet in the
|
|
// spec). Zero-valued sampling fields fall back to backend defaults.
|
|
type CompletionRequest struct {
|
|
Messages []Message
|
|
MaxTokens int
|
|
Temperature float64
|
|
RepetitionPenalty float64
|
|
TopP float64
|
|
Stop []string
|
|
Stream bool
|
|
}
|
|
|
|
// LLMClient is the single surface handler code depends on.
|
|
type LLMClient interface {
|
|
// Complete returns the full response in one shot (used for the checkpoint
|
|
// JSON, which we want whole before parsing).
|
|
Complete(ctx context.Context, req CompletionRequest) (string, error)
|
|
// Stream returns a channel of text chunks (used for Ask Petal SSE). The
|
|
// channel closes when generation ends; on a mid-stream error it closes
|
|
// early and the error is reported via the returned error of a future call.
|
|
Stream(ctx context.Context, req CompletionRequest) (<-chan string, error)
|
|
}
|
|
|
|
// Truncation caps. These guard checkpoint latency (prefill time scales with
|
|
// input length), not the model window — Qwen 3.5 ships 256K, far larger.
|
|
const (
|
|
// maxDocChars ~ 10,000 tokens at ~4 chars/token. Grammar checkpoint only;
|
|
// the voice pass sends the whole document uncut.
|
|
maxDocChars = 40000
|
|
// maxHistoryMsgs caps the rolling Ask Petal history (5 turns); oldest pairs
|
|
// drop first.
|
|
maxHistoryMsgs = 10
|
|
)
|
|
|
|
// TruncateDoc keeps the recent end of a document — the user is actively writing
|
|
// there — when it exceeds the grammar-checkpoint latency cap. Most documents
|
|
// fit uncut. The voice pass deliberately does NOT call this.
|
|
func TruncateDoc(contentText string) string {
|
|
if len(contentText) > maxDocChars {
|
|
return contentText[len(contentText)-maxDocChars:]
|
|
}
|
|
return contentText
|
|
}
|
|
|
|
// TrimHistory keeps the most recent maxHistoryMsgs messages, dropping the
|
|
// oldest first so a long Ask Petal chat stays within budget.
|
|
func TrimHistory(msgs []Message) []Message {
|
|
if len(msgs) > maxHistoryMsgs {
|
|
return msgs[len(msgs)-maxHistoryMsgs:]
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
// NewLLMClient selects a backend from config. LLM_CHAT_MODEL falls back to
|
|
// LLM_MODEL here, in the factory, so handlers never deal with the fallback.
|
|
func NewLLMClient(cfg *config.Config) LLMClient {
|
|
chatModel := cfg.LLMChatModel
|
|
if chatModel == "" {
|
|
chatModel = cfg.LLMModel
|
|
}
|
|
base := backend{
|
|
endpoint: cfg.LLMEndpoint,
|
|
checkpointModel: cfg.LLMModel,
|
|
chatModel: chatModel,
|
|
timeout: cfg.LLMTimeout,
|
|
}
|
|
switch cfg.LLMBackend {
|
|
case "ollama":
|
|
return &OllamaClient{base}
|
|
default: // "vllm"
|
|
return &VLLMClient{base}
|
|
}
|
|
}
|
|
|
|
// backend holds the fields shared by both concrete clients.
|
|
type backend struct {
|
|
endpoint string
|
|
checkpointModel string // LLM_MODEL — small/fast checkpoint model
|
|
chatModel string // LLM_CHAT_MODEL (or LLM_MODEL) — Ask Petal model
|
|
timeout time.Duration
|
|
}
|
|
|
|
// model picks the right model id for a request. Streaming requests are Ask
|
|
// Petal (chat); one-shot Complete calls are the grammar checkpoint.
|
|
func (b backend) model(req CompletionRequest) string {
|
|
if req.Stream {
|
|
return b.chatModel
|
|
}
|
|
return b.checkpointModel
|
|
}
|