Map (WorldMap): - Keep the near-black OpenFreeMap `dark` basemap (verified HTTP 200; the suggested dark-matter / dark-outdoors / liberty-dark variants 404 on that host) so the choropleth nation colors pop - Stable per-nation fills at 0.8 opacity, thin subtle borders, and a brighter thicker amber border for the player nation so "this is me" is always obvious - Hover brightens a region and shows a color-chipped owner + key stats popup; click opens a full dossier popup and focuses the left panel on that nation - Transient, self-cleaning flash (pulsing amber outline) over regions whose ownership changed this turn - Bottom-left legend auto-generated from the region map; per-scenario initial camera (Europe for the WWII scenarios, wider for Cold War / Modern / Aegis) Command-center UI: - New header bar: scenario title, prominent turn + date, the player nation (name + color chip), and a live Idle / "Simulating…" status indicator - 3-column operations-console grid: nation dossier (left) / dominant map (center) / command deck + event feed (right); stacks map-first on mobile - EventFeed: one card per event with a category icon, a colored outcome badge (success/failure/partial/neutral), a nation-colored accent edge, and the turn + date; new events slide in and flash at the top - NationPanel: value-colored animated stat bars (red → green), territory as color-coded region chips, and diplomacy as a list with a colored indicator per relation (ally/rival/war/neutral) - ActionPanel: a "Your move" composer with one prominent Send button and a spinner + "turns can take a few seconds" hint while a turn resolves - sonner error toasts: a 502 / model-down surfaces as "AI temporarily unavailable — try again" Live LLM rewire (server, previously uncommitted): - Default endpoint https://ai.kkhost.pl/v1 (vLLM, no API key); reasoning model qwen3.8-27b; always send a generous max_tokens (4000) so it finishes its chain-of-thought before emitting content; read only `content`, ignore `reasoning`. docker-compose + AGENTS.md updated to match. Tests: the deterministic Playwright e2e gains assertions for the dossier stat bars, the map legend (all nations), and the event-card type icon + outcome badge; it stays fully deterministic under LLM_STUB=1.
107 lines
4.5 KiB
TypeScript
107 lines
4.5 KiB
TypeScript
// Thin OpenAI chat-completions client over native fetch (mirrors mesh-bot/src/llm).
|
|
// The transport is injectable so unit tests and the deterministic e2e stub can
|
|
// replace the network call. Config is read from env, never hardcoded:
|
|
// LLM_API_BASE (default https://ai.kkhost.pl/v1 — a vLLM endpoint, no API key)
|
|
// LLM_MODEL (default qwen3.8-27b — a vLLM *reasoning* model)
|
|
// LLM_TIMEOUT_MS (default 180000 — the 27B reasoning model can be slow)
|
|
//
|
|
// The live model is a REASONING model: it emits its chain-of-thought in the
|
|
// response's `reasoning` field and the final answer in `content`. It therefore
|
|
// needs a generous `max_tokens` budget to finish thinking before writing
|
|
// `content` — a too-small budget makes it run out mid-reasoning and return a
|
|
// null `content`. We always send an explicit, generous `max_tokens` (the
|
|
// OpenAI/vLLM wire key — NOT camelCase `maxTokens`, which the server ignores)
|
|
// and read only `choices[0].message.content`, ignoring `reasoning`. No auth
|
|
// header is added (the endpoint requires no key).
|
|
|
|
export interface ChatMessage {
|
|
role: 'system' | 'user' | 'assistant';
|
|
content: string;
|
|
}
|
|
|
|
/** A network call. The default implementation is native fetch. */
|
|
export type Transport = (url: string, init?: RequestInit) => Promise<Response>;
|
|
|
|
export interface ChatOptions {
|
|
maxTokens?: number;
|
|
temperature?: number;
|
|
}
|
|
|
|
export interface LlmClient {
|
|
chat(messages: ChatMessage[], opts?: ChatOptions): Promise<string>;
|
|
listModels(): Promise<string[]>;
|
|
}
|
|
|
|
export const DEFAULT_BASE = 'https://ai.kkhost.pl/v1';
|
|
export const DEFAULT_MODEL = 'qwen3.8-27b';
|
|
|
|
/**
|
|
* Generous token budget for every chat request. The live qwen3.8-27b is a
|
|
* reasoning model: it spends `reasoning_tokens` on its chain-of-thought before
|
|
* writing the final answer into `content`. A small budget makes it run out
|
|
* mid-reasoning and return a null `content`, so we give it room to finish both.
|
|
* `max_tokens` is always set in the request body, even when a caller omits it.
|
|
*/
|
|
export const DEFAULT_MAX_TOKENS = 4000;
|
|
|
|
const fetchTransport: Transport = (url, init) => fetch(url, init);
|
|
|
|
export interface LlmClientDeps {
|
|
transport?: Transport;
|
|
apiBase?: string;
|
|
model?: string;
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
|
const transport = deps.transport ?? fetchTransport;
|
|
const apiBase = (deps.apiBase ?? process.env.LLM_API_BASE ?? DEFAULT_BASE).replace(/\/+$/, '');
|
|
const model = deps.model ?? process.env.LLM_MODEL ?? DEFAULT_MODEL;
|
|
const timeoutMs = parseInt(
|
|
deps.timeoutMs ? String(deps.timeoutMs) : (process.env.LLM_TIMEOUT_MS ?? '180000'),
|
|
10,
|
|
);
|
|
|
|
async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<string> {
|
|
const payload: Record<string, unknown> = { model, messages };
|
|
if (opts.temperature != null) payload.temperature = opts.temperature;
|
|
// Always send an explicit, generous `max_tokens` (the vLLM/OpenAI wire key —
|
|
// camelCase `maxTokens` is ignored by the server) so the reasoning model has
|
|
// room to finish `reasoning` before emitting `content`.
|
|
payload.max_tokens = opts.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
const res = await transport(`${apiBase}/chat/completions`, {
|
|
method: 'POST',
|
|
// No auth header: the ai.kkhost.pl vLLM endpoint requires no API key.
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
throw new Error(`chat failed: HTTP ${res.status} ${text.slice(0, 200)}`);
|
|
}
|
|
// Reasoning models also return their chain-of-thought in a separate
|
|
// `reasoning` field. We deliberately ignore it and use only the final
|
|
// answer, which lives in `content`.
|
|
const body = (await res.json()) as {
|
|
choices?: { message?: { content?: string; reasoning?: string | null } }[];
|
|
};
|
|
const content = body.choices?.[0]?.message?.content;
|
|
if (typeof content !== 'string') {
|
|
throw new Error('chat failed: no choices[0].message.content in response');
|
|
}
|
|
return content;
|
|
}
|
|
|
|
async function listModels(): Promise<string[]> {
|
|
const res = await transport(`${apiBase}/models`, {
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
if (!res.ok) throw new Error(`listModels failed: HTTP ${res.status}`);
|
|
const body = (await res.json()) as { data?: { id: string }[] };
|
|
return (body.data ?? []).map((m) => m.id);
|
|
}
|
|
|
|
return { chat, listModels };
|
|
}
|