mirror of
https://github.com/SerTimBerrners-Lee/talkis.git
synced 2026-07-10 01:39:26 +00:00
refactor(local-llm): выбранность модели из настроек (не из рантайма); back-fill llmLocalModelId для старых настроек (порт 8011/18200); token_to_piece_bytes вместо устаревшего API; +юнит-тесты
This commit is contained in:
parent
ef43b484e9
commit
bd82bd12e0
8 changed files with 242 additions and 29 deletions
Binary file not shown.
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
59
src/lib/localLlmSelection.test.ts
Normal file
59
src/lib/localLlmSelection.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
39
src/lib/localLlmSelection.ts
Normal file
39
src/lib/localLlmSelection.ts
Normal 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: "" };
|
||||
}
|
||||
|
|
@ -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
84
src/lib/summarize.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -168,15 +168,15 @@ function localLlmRunner(
|
|||
};
|
||||
}
|
||||
|
||||
// The bundled local text runtime listens on 127.0.0.1:18200–18249 (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
|
||||
// 18200–18249 (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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue