refactor(local-llm): выбранность модели из настроек (не из рантайма); back-fill llmLocalModelId для старых настроек (порт 8011/18200); token_to_piece_bytes вместо устаревшего API; +юнит-тесты

This commit is contained in:
David Perov 2026-06-26 19:08:02 +03:00
parent ef43b484e9
commit bd82bd12e0
8 changed files with 242 additions and 29 deletions

View file

@ -14,8 +14,9 @@ use llama_cpp_2::context::params::LlamaContextParams;
use llama_cpp_2::llama_backend::LlamaBackend;
use llama_cpp_2::llama_batch::LlamaBatch;
use llama_cpp_2::model::params::LlamaModelParams;
use llama_cpp_2::model::{AddBos, LlamaModel, Special};
use llama_cpp_2::model::{AddBos, LlamaModel};
use llama_cpp_2::sampling::LlamaSampler;
use llama_cpp_2::TokenToStringError;
const SERVER_NAME: &str = "talkis-llm";
const ENGINE_NAME: &str = "llama.cpp";
@ -350,7 +351,18 @@ fn generate(
// Accumulate raw token bytes and emit only COMPLETE UTF-8 sequences. A
// single token can carry a partial multibyte char (Cyrillic spans token
// boundaries), so decoding tokens one-by-one yielded garbled glyphs.
if let Ok(bytes) = model.token_to_bytes(token, Special::Tokenize) {
let piece_bytes = match model.token_to_piece_bytes(token, 8, true, None) {
Err(TokenToStringError::InsufficientBufferSpace(size)) => {
let needed = size
.checked_neg()
.and_then(|value| usize::try_from(value).ok())
.unwrap_or(32);
model.token_to_piece_bytes(token, needed, true, None)
}
result => result,
};
if let Ok(bytes) = piece_bytes {
pending.extend_from_slice(&bytes);
loop {
match std::str::from_utf8(&pending) {

View file

@ -5,6 +5,11 @@ import { AlertCircle, Check, Download, HardDrive, Loader2, MemoryStick, Trash2,
import { AppSettings } from "../lib/store";
import { useI18n } from "../lib/i18n";
import {
isLocalLlmEndpoint,
localLlmDeleteSettingsPatch,
selectedLocalLlmModelId,
} from "../lib/localLlmSelection";
import qwenAvatar from "../assets/adapters/qwen.png";
interface LocalLlmModel {
@ -17,12 +22,6 @@ interface LocalLlmModel {
downloaded: boolean;
}
interface LocalLlmStatus {
running: boolean;
model_id: string | null;
base_url: string | null;
}
interface DownloadProgress {
model_id: string;
status: string;
@ -56,7 +55,6 @@ export function LocalLlmModels({
}) {
const { t } = useI18n();
const [models, setModels] = useState<LocalLlmModel[]>([]);
const [status, setStatus] = useState<LocalLlmStatus | null>(null);
const [progress, setProgress] = useState<Record<string, DownloadProgress>>({});
const [busy, setBusy] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
@ -65,12 +63,8 @@ export function LocalLlmModels({
const refresh = async (): Promise<void> => {
try {
const [list, runtime] = await Promise.all([
invoke<LocalLlmModel[]>("list_local_llm_models"),
invoke<LocalLlmStatus>("get_local_llm_status"),
]);
const list = await invoke<LocalLlmModel[]>("list_local_llm_models");
setModels(list);
setStatus(runtime);
} catch {
/* keep last known state */
}
@ -152,6 +146,10 @@ export function LocalLlmModels({
setError(null);
try {
await invoke("delete_local_llm_model", { modelId: model.id });
const cleanupPatch = localLlmDeleteSettingsPatch(settings, model.id);
if (cleanupPatch) {
update(cleanupPatch);
}
setProgress((prev) => {
const next = { ...prev };
delete next[model.id];
@ -182,10 +180,8 @@ export function LocalLlmModels({
}
};
const activeModelId = status?.running ? status.model_id : null;
const usingLocalEndpoint = Boolean(
settings.llmEndpoint?.trim().includes("127.0.0.1"),
);
const usingLocalEndpoint = isLocalLlmEndpoint(settings.llmEndpoint);
const selectedModelId = selectedLocalLlmModelId(settings);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
@ -241,7 +237,7 @@ export function LocalLlmModels({
prog?.status === "downloading" || prog?.status === "starting";
const isBusy = busy === model.id;
const isDeleting = deleting === model.id;
const isActive = activeModelId === model.id && usingLocalEndpoint;
const isSelected = selectedModelId === model.id && usingLocalEndpoint;
// Collapsed by default like the recognition cards; force-open while a
// download runs so its progress bar stays visible.
const open = expandedId === model.id || isDownloading;
@ -250,12 +246,12 @@ export function LocalLlmModels({
? t("localLlm.status.deleting")
: isDownloading
? t("localLlm.status.downloading")
: isActive
: isSelected
? t("localLlm.status.selected")
: model.downloaded
? t("localLlm.status.ready")
: t("localLlm.status.notDownloaded");
const statusColor = isActive
const statusColor = isSelected
? "var(--success-bright)"
: model.downloaded
? "var(--success-bright)"
@ -464,7 +460,7 @@ export function LocalLlmModels({
)
) : (
<>
{!isActive && (
{!isSelected && (
<button
onClick={() => void useForSummary(model)}
disabled={isBusy}

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from "bun:test";
import {
isLocalLlmEndpoint,
localLlmDeleteSettingsPatch,
selectedLocalLlmModelId,
} from "./localLlmSelection";
describe("local LLM selection helpers", () => {
it("recognizes localhost and 127.0.0.1 endpoints", () => {
expect(isLocalLlmEndpoint("http://127.0.0.1:8011/v1")).toBe(true);
expect(isLocalLlmEndpoint("http://localhost:11434/v1")).toBe(true);
expect(isLocalLlmEndpoint("https://api.openai.com/v1")).toBe(false);
});
it("prefers the bundled runtime marker over the legacy model field", () => {
expect(
selectedLocalLlmModelId({
llmEndpoint: "http://127.0.0.1:8011/v1",
llmModel: "legacy-model",
llmLocalModelId: "qwen2.5-3b-instruct-q4",
}),
).toBe("qwen2.5-3b-instruct-q4");
});
it("falls back to llmModel for pre-marker local selections", () => {
expect(
selectedLocalLlmModelId({
llmEndpoint: "http://127.0.0.1:8011/v1",
llmModel: "qwen2.5-3b-instruct-q4",
llmLocalModelId: "",
}),
).toBe("qwen2.5-3b-instruct-q4");
});
it("clears persisted text-model fields only when deleting the selected model", () => {
expect(
localLlmDeleteSettingsPatch(
{
llmEndpoint: "http://127.0.0.1:8011/v1",
llmModel: "qwen2.5-3b-instruct-q4",
llmLocalModelId: "qwen2.5-3b-instruct-q4",
},
"qwen2.5-3b-instruct-q4",
),
).toEqual({ llmEndpoint: "", llmModel: "none", llmLocalModelId: "" });
expect(
localLlmDeleteSettingsPatch(
{
llmEndpoint: "http://127.0.0.1:8011/v1",
llmModel: "qwen2.5-7b-instruct-q4",
llmLocalModelId: "qwen2.5-7b-instruct-q4",
},
"qwen2.5-3b-instruct-q4",
),
).toBeNull();
});
});

View file

@ -0,0 +1,39 @@
import type { AppSettings } from "./store";
export interface LocalLlmSelectionSettings {
llmEndpoint?: string;
llmModel?: string;
llmLocalModelId?: string;
}
export function isLocalLlmEndpoint(endpoint?: string): boolean {
return /127\.0\.0\.1|localhost/i.test(endpoint || "");
}
export function selectedLocalLlmModelId(
settings: LocalLlmSelectionSettings,
): string {
if (!isLocalLlmEndpoint(settings.llmEndpoint)) {
return "";
}
return settings.llmLocalModelId?.trim() || settings.llmModel?.trim() || "";
}
export function isSelectedLocalLlmModel(
settings: LocalLlmSelectionSettings,
modelId: string,
): boolean {
return selectedLocalLlmModelId(settings) === modelId;
}
export function localLlmDeleteSettingsPatch(
settings: LocalLlmSelectionSettings,
modelId: string,
): Partial<AppSettings> | null {
if (!isSelectedLocalLlmModel(settings, modelId)) {
return null;
}
return { llmEndpoint: "", llmModel: "none", llmLocalModelId: "" };
}

View file

@ -246,6 +246,11 @@ const MAIN_KEY_ALIASES: Record<string, string> = {
const FUNCTION_KEY_PATTERN = /^F(?:[1-9]|1[0-2])$/;
const DEFAULT_MAC_HOTKEY = "Command+Shift+Space";
const DEFAULT_DESKTOP_HOTKEY = "Control+Alt+Space";
const BUNDLED_LOCAL_LLM_PORTS = new Set([8011]);
const BUNDLED_LOCAL_LLM_MODEL_IDS = new Set([
"qwen2.5-3b-instruct-q4",
"qwen2.5-7b-instruct-q4",
]);
export function isMacPlatform(): boolean {
if (typeof navigator === "undefined") return false;
@ -597,6 +602,20 @@ function parseProvider(value: unknown): ApiProvider | undefined {
return undefined;
}
function isBundledLocalLlmEndpoint(value: unknown): boolean {
if (typeof value !== "string") return false;
const match = value.match(/:(\d{4,5})(?:\/|$)/);
if (!match) return false;
const port = Number(match[1]);
return BUNDLED_LOCAL_LLM_PORTS.has(port) || (port >= 18200 && port <= 18249);
}
function parseBundledLocalLlmModelId(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const modelId = value.trim();
return BUNDLED_LOCAL_LLM_MODEL_IDS.has(modelId) ? modelId : undefined;
}
function parseRealtimeInterpreterSettings(
value: unknown,
): RealtimeInterpreterSettings | undefined {
@ -630,7 +649,7 @@ function parseRealtimeInterpreterSettings(
};
}
function normalizeSavedSettings(saved: unknown): Partial<AppSettings> {
export function normalizeSavedSettings(saved: unknown): Partial<AppSettings> {
if (!saved || typeof saved !== "object") {
return {};
}
@ -772,7 +791,11 @@ function normalizeSavedSettings(saved: unknown): Partial<AppSettings> {
llmEndpoint:
typeof raw.llmEndpoint === "string" ? raw.llmEndpoint : undefined,
llmLocalModelId:
typeof raw.llmLocalModelId === "string" ? raw.llmLocalModelId : undefined,
typeof raw.llmLocalModelId === "string"
? raw.llmLocalModelId
: isBundledLocalLlmEndpoint(raw.llmEndpoint)
? parseBundledLocalLlmModelId(raw.llmModel)
: undefined,
useOwnKey: typeof raw.useOwnKey === "boolean" ? raw.useOwnKey : undefined,
deviceToken:
typeof raw.deviceToken === "string" ? raw.deviceToken : undefined,

84
src/lib/summarize.test.ts Normal file
View file

@ -0,0 +1,84 @@
import { describe, expect, it } from "bun:test";
import { normalizeSavedSettings, type AppSettings } from "./store";
import { isSummaryAvailable, resolveSummaryBackend } from "./summarize";
function settings(overrides: Partial<AppSettings>): AppSettings {
return {
apiKey: "",
llmApiKey: "",
llmEndpoint: "",
llmModel: "gpt-4o-mini",
llmLocalModelId: "",
useOwnKey: true,
deviceToken: "",
...overrides,
} as AppSettings;
}
describe("resolveSummaryBackend local LLM behavior", () => {
it("treats bundled runtime port 8011 without a selected model as unavailable", () => {
const backend = resolveSummaryBackend(
settings({ llmEndpoint: "http://127.0.0.1:8011/v1" }),
);
expect(backend).toBeNull();
expect(
isSummaryAvailable(settings({ llmEndpoint: "http://127.0.0.1:8011/v1" })),
).toBe(false);
});
it("treats bundled fallback ports without a selected model as unavailable", () => {
expect(
resolveSummaryBackend(
settings({ llmEndpoint: "http://127.0.0.1:18210/v1" }),
),
).toBeNull();
});
it("resolves bundled local runtime when llmLocalModelId is set", () => {
const backend = resolveSummaryBackend(
settings({
llmEndpoint: "http://127.0.0.1:8011/v1",
llmModel: "qwen2.5-3b-instruct-q4",
llmLocalModelId: "qwen2.5-3b-instruct-q4",
}),
);
expect(backend?.kind).toBe("local");
});
it("does not block a user's own localhost server outside bundled ports", () => {
const backend = resolveSummaryBackend(
settings({
llmEndpoint: "http://localhost:11434/v1",
llmModel: "qwen2.5:7b",
}),
);
expect(backend?.kind).toBe("local");
});
});
describe("stored settings migration for bundled local LLM", () => {
it("migrates old 8011 bundled runtime settings into llmLocalModelId", () => {
const normalized = normalizeSavedSettings({
llmEndpoint: "http://127.0.0.1:8011/v1",
llmModel: "qwen2.5-3b-instruct-q4",
});
expect(normalized.llmLocalModelId).toBe("qwen2.5-3b-instruct-q4");
});
it("migrates fallback bundled runtime ports into llmLocalModelId", () => {
const normalized = normalizeSavedSettings({
llmEndpoint: "http://127.0.0.1:18208/v1",
llmModel: "qwen2.5-7b-instruct-q4",
});
expect(normalized.llmLocalModelId).toBe("qwen2.5-7b-instruct-q4");
});
it("does not mark a user-managed localhost endpoint as bundled runtime", () => {
const normalized = normalizeSavedSettings({
llmEndpoint: "http://localhost:11434/v1",
llmModel: "qwen2.5:7b",
});
expect(normalized.llmLocalModelId).toBeUndefined();
});
});

View file

@ -168,15 +168,15 @@ function localLlmRunner(
};
}
// The bundled local text runtime listens on 127.0.0.1:1820018249 (see
// src-tauri/src/llm_runtime.rs). It can only auto-start when a model is selected
// (llmLocalModelId); an endpoint in that range with NO selected model is a dead
// port, so we must not treat it as a usable backend.
// The bundled local text runtime prefers 127.0.0.1:8011, then falls back to
// 1820018249 (see src-tauri/src/llm_runtime.rs). It can only auto-start when a
// model is selected (llmLocalModelId); an endpoint in that range with NO selected
// model is a dead port, so we must not treat it as a usable backend.
function isBundledLocalRuntime(endpoint: string): boolean {
const match = endpoint.match(/:(\d{4,5})(?:\/|$)/);
if (!match) return false;
const port = Number(match[1]);
return port >= 18200 && port <= 18249;
return port === 8011 || (port >= 18200 && port <= 18249);
}
/**