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.
This commit is contained in:
@@ -187,6 +187,12 @@ describe('parseTurnResult', () => {
|
||||
expect(r?.events[0].title).toBe('t');
|
||||
});
|
||||
|
||||
it('parses JSON prefixed by leading whitespace/newlines (reasoning model emits "\\n\\n{...}")', () => {
|
||||
const r = parseTurnResult('\n\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n');
|
||||
expect(r?.events[0].title).toBe('t');
|
||||
expect(r?.date).toBe('1939-09-03');
|
||||
});
|
||||
|
||||
it('extracts the object from surrounding prose', () => {
|
||||
const r = parseTurnResult('Sure! Here is the result: {"date":"1939-09-03","events":[]} hope that helps');
|
||||
expect(r).not.toBeNull();
|
||||
|
||||
+4
-4
@@ -21,7 +21,7 @@ import {
|
||||
type TurnEventSpec,
|
||||
type TurnResult,
|
||||
} from '../shared/protocol';
|
||||
import type { LlmClient } from './llm';
|
||||
import { DEFAULT_MAX_TOKENS, type LlmClient } from './llm';
|
||||
import { ADVISOR_MARKER } from './stub';
|
||||
|
||||
const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
|
||||
@@ -322,7 +322,7 @@ export async function runTurn(opts: {
|
||||
let result = parseTurnResult(await llm.chat([
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
], { temperature: 0.4, maxTokens: 2000 }));
|
||||
], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }));
|
||||
|
||||
if (result === null || !isMeaningful(result)) {
|
||||
result = parseTurnResult(await llm.chat([
|
||||
@@ -334,7 +334,7 @@ export async function runTurn(opts: {
|
||||
content:
|
||||
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.',
|
||||
},
|
||||
], { temperature: 0.4, maxTokens: 2000 }));
|
||||
], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }));
|
||||
}
|
||||
|
||||
if (result === null || !isMeaningful(result)) {
|
||||
@@ -413,7 +413,7 @@ export async function askAdvisor(
|
||||
const raw = await llm.chat([
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
], { temperature: 0.7, maxTokens: 500 });
|
||||
], { temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS });
|
||||
const out = parseAdvisor(raw);
|
||||
return out.length
|
||||
? out
|
||||
|
||||
+18
-2
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createLlmClient } from './llm';
|
||||
import { createLlmClient, DEFAULT_MAX_TOKENS } from './llm';
|
||||
|
||||
function json(body: unknown) {
|
||||
return {
|
||||
@@ -22,12 +22,28 @@ describe('createLlmClient (injectable transport)', () => {
|
||||
expect(out).toBe('hello back');
|
||||
expect(calls[0].url).toBe('http://x/v1/chat/completions'); // trailing slash normalized
|
||||
const body = JSON.parse(calls[0].init!.body as string);
|
||||
// The wire key is `max_tokens` (snake_case), never camelCase `maxTokens`.
|
||||
expect(body).toEqual({
|
||||
model: 'm1',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
maxTokens: 10,
|
||||
temperature: 0.5,
|
||||
max_tokens: 10,
|
||||
});
|
||||
// No auth header: the live vLLM endpoint requires no API key.
|
||||
expect(calls[0].init!.headers).toEqual({ 'content-type': 'application/json' });
|
||||
});
|
||||
|
||||
it('chat uses a generous default max_tokens when the caller omits it', async () => {
|
||||
const calls: { url: string; init?: RequestInit }[] = [];
|
||||
const transport = async (url: string, init?: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
return json({ choices: [{ message: { content: 'ok' } }] });
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm1' });
|
||||
await llm.chat([{ role: 'user', content: 'hi' }]);
|
||||
const body = JSON.parse(calls[0].init!.body as string);
|
||||
expect(body.max_tokens).toBe(DEFAULT_MAX_TOKENS);
|
||||
expect(body.max_tokens).toBe(4000);
|
||||
});
|
||||
|
||||
it('listModels returns data[].id from GET {base}/models', async () => {
|
||||
|
||||
+34
-6
@@ -1,9 +1,18 @@
|
||||
// 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 http://192.168.1.70:1111/v1)
|
||||
// LLM_MODEL (default qwen3.8-27b)
|
||||
// LLM_TIMEOUT_MS (default 180000 — the local 27B model can be slow)
|
||||
// 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';
|
||||
@@ -23,9 +32,18 @@ export interface LlmClient {
|
||||
listModels(): Promise<string[]>;
|
||||
}
|
||||
|
||||
export const DEFAULT_BASE = 'http://192.168.1.70:1111/v1';
|
||||
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 {
|
||||
@@ -45,18 +63,28 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
||||
);
|
||||
|
||||
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({ model, messages, ...opts }),
|
||||
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 } }[];
|
||||
choices?: { message?: { content?: string; reasoning?: string | null } }[];
|
||||
};
|
||||
const content = body.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string') {
|
||||
|
||||
Reference in New Issue
Block a user