Files
kamilkrzywda e327e9aea0 Polish map & UI into a command-center war-room; rewire live LLM endpoint
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.
2026-10-10 22:15:05 +00:00

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 };
}