diff --git a/server/main.py b/server/main.py index 57fb22d..2c13d84 100644 --- a/server/main.py +++ b/server/main.py @@ -28,6 +28,21 @@ def err(status: int, msg: str) -> JSONResponse: # Контракт docs/07: ошибки провайдера/сети → { "error": ... } return JSONResponse(status_code=status, content={"error": msg}) + +def _short(value, limit: int = 200) -> str: + """Однострочное усечённое превью для логов — длинный текст/JSON не разносит вывод.""" + if value is None: + return "∅" + s = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) + s = " ".join(s.split()) + return s if len(s) <= limit else s[:limit] + "…" + + +def log(tag: str, **fields) -> None: + """Единый формат логов эндпоинтов: [tag] k=v k=v ... (flush — сразу в /tmp/server.log).""" + parts = " ".join(f"{k}={_short(v)}" for k, v in fields.items()) + print(f"[{tag}] {parts}", flush=True) + # FastAPI app app = FastAPI() @@ -70,6 +85,8 @@ async def llm_endpoint(request: Request): if not prompt: raise HTTPException(status_code=400, detail="prompt is required") + log("llm→in", node=node_id, tools=tools, system=system, prompt=prompt) + # Check if we have an API key for real calls if not LLM_API_KEY: # Mock mode @@ -87,6 +104,7 @@ async def llm_endpoint(request: Request): else: text = f"[mock] {prompt[:200]}" + log("llm←out", node=node_id, mock=True, text=text) return {"text": text, "mock": True} # Real API call @@ -127,16 +145,19 @@ async def llm_endpoint(request: Request): error_detail = error_json.get("error", {}).get("message", error_detail) except Exception: pass + log("llm←ERR", node=node_id, model=model, status=response.status_code, error=error_detail) return err(502, f"LLM error: {error_detail}") data = response.json() text = data.get("choices", [{}])[0].get("message", {}).get("content", "") + log("llm←out", node=node_id, model=model, text=text) return {"text": text} except HTTPException: raise except Exception as e: + log("llm←ERR", node=node_id, error=str(e)) return err(502, f"LLM error: {str(e)}") @@ -169,6 +190,9 @@ async def proxy_endpoint(request: Request): raise HTTPException(status_code=400, detail="headers must be an object") req_body = body.get("body") + # url может содержать секрет (напр. bot-token telegram в пути) — усекается _short'ом. + log("proxy→in", method=method, url=url, body=req_body) + try: async with httpx.AsyncClient(timeout=20) as client: if method == "GET": @@ -188,11 +212,13 @@ async def proxy_endpoint(request: Request): except Exception: resp_body = response.text + log("proxy←out", status=response.status_code, body=resp_body) return {"status": response.status_code, "body": resp_body} except HTTPException: raise except Exception as e: + log("proxy←ERR", url=url, error=str(e)) return err(502, f"Proxy error: {str(e)}") @@ -210,6 +236,8 @@ async def webhook_endpoint(node_id: str, request: Request): # Fall back to raw text body = {"raw": (await request.body()).decode("utf-8", errors="replace")} + log("webhook→in", node=node_id, body=body) + # Broadcast to all SSE clients event_data = { "type": "webhook", diff --git a/web/src/core/__checks__/nodes.ts b/web/src/core/__checks__/nodes.ts index 97569af..4be38d3 100644 --- a/web/src/core/__checks__/nodes.ts +++ b/web/src/core/__checks__/nodes.ts @@ -120,6 +120,29 @@ async function testAssemblerLlm() { console.log('✓ AC1: assembler-llm'); } +// AC1b: system отсутствует в config (мир из demo.json/Import) → резолвится из recipe. +// Регрессия бага: у demo-ассемблера config={recipe:'translator'} без system, handler +// раньше слал пустой system и модель просто эхом отдавала текст (не переводила). +async function testAssemblerRecipeFallback() { + mockLlmCalls = []; + const handler = NODE_DEFS.assembler.handler as Handler; + + const ctx: NodeCtx = { + config: { recipe: 'translator' }, // system НЕ задан — как в demo.json + data: 'Привет', + tpl: (s) => s, + llm: mockLlm, + proxyFetch: mockProxyFetch, + }; + + await handler(ctx); + const sentSystem = mockLlmCalls[0]?.system ?? ''; + if (!sentSystem.includes('английский')) { + throw new Error(`AC1b: system должен резолвиться из recipe 'translator', получен: ${JSON.stringify(sentSystem)}`); + } + console.log('✓ AC1b: assembler-recipe-fallback (system из recipe при отсутствии в config)'); +} + // ============================================================================ // AC 2: splitter-expr ветвит по условию, splitter-llm парсит YES/NO // ============================================================================ @@ -592,6 +615,7 @@ function testRegistry() { export async function checkNodes() { await testAssemblerLlm(); + await testAssemblerRecipeFallback(); await testSplitterExpr(); await testSplitterLlm(); await testMixerConcat(); diff --git a/web/src/core/nodes/assembler.ts b/web/src/core/nodes/assembler.ts index d9ff75a..1b759fa 100644 --- a/web/src/core/nodes/assembler.ts +++ b/web/src/core/nodes/assembler.ts @@ -40,7 +40,16 @@ export const assemblerSchema: Field[] = [ * подмешивается в prompt как RAG-контекст. */ export const assemblerHandler: Handler = async (ctx: NodeCtx) => { - const system = (ctx.config['system'] as string) || ''; + // system — источник правды для LLM, но в загруженных мирах (demo.json / Import) его + // может не быть: loadWorld грузит config как есть, не заполняя дефолты схемы (в отличие + // от постановки мышью). Тогда резолвим system из рецепта — recipe остаётся источником, + // а config.system лишь необязательный оверрайд поверх пресета. typeof-проверка (не `||`): + // у рецепта 'custom' system легитимно пустой, и это НЕ повод падать в фолбэк. + let system = ctx.config['system'] as string | undefined; + if (typeof system !== 'string') { + const recipe = RECIPES.find((r) => r.value === ctx.config['recipe']); + system = recipe?.system ?? ''; + } const modules = (ctx.config['modules'] as string[]) || []; let prompt = typeof ctx.data === 'string' ? ctx.data : JSON.stringify(ctx.data); diff --git a/web/src/state/runtime.ts b/web/src/state/runtime.ts index 981c9ce..1145b1c 100644 --- a/web/src/state/runtime.ts +++ b/web/src/state/runtime.ts @@ -106,6 +106,17 @@ function createWebhooksSubscription( }; } +/** + * Превью значения для лога: строка как есть, объект — JSON; всё усекается, + * чтобы длинный текст/документ не разносил панель логов и консоль. + */ +function logPreview(value: unknown, max = 160): string { + if (value === undefined) return '∅'; + const s = typeof value === 'string' ? value : JSON.stringify(value); + const oneLine = s.replace(/\s+/g, ' ').trim(); + return oneLine.length > max ? oneLine.slice(0, max) + '…' : oneLine; +} + /** * Маппинг EngineEvent на store-actions и сайд-эффекты */ @@ -126,19 +137,35 @@ function setupEventHandler() { case 'packet-drop': // error → лом+дым; dead-end/ttl → падение с fade (тост даёт node-status error) dropPacket(event.packetId, event.reason); + if (event.reason !== 'error') { + store.toast(`✕ пакет упал (${event.reason})`); + console.log('[drop]', event.packetId, event.reason); + } break; case 'node-status': store.setStatus(event.nodeId, event.status, event.error); if (event.status === 'error' && event.error) { - // Тост только на node-status error - store.toast(`Node error: ${event.error}`); + const kind = store.entities[event.nodeId]?.kind ?? '?'; + store.toast(`⚠ ${kind} #${event.nodeId}: ${event.error}`); + console.warn('[node error]', kind, event.nodeId, event.error); } break; - case 'node-io': + case 'node-io': { store.setIO(event.nodeId, event.lastIn, event.lastOut); + // Лог вход→выход каждого станка: в панель логов (LogsPanel) и в консоль. + const kind = store.entities[event.nodeId]?.kind ?? '?'; + const label = `${kind} #${event.nodeId}`; + const parts: string[] = []; + if (event.lastIn !== undefined) parts.push(`IN ${logPreview(event.lastIn)}`); + if (event.lastOut !== undefined) parts.push(`OUT ${logPreview(event.lastOut)}`); + if (parts.length > 0) { + store.toast(`${label}: ${parts.join(' → ')}`); + console.log('[io]', label, { in: event.lastIn, out: event.lastOut }); + } break; + } case 'result': { // Результат от silo или chest → pushResult и запустить rocketLaunch diff --git a/web/src/state/store.ts b/web/src/state/store.ts index 83ddf66..cba4e3e 100644 --- a/web/src/state/store.ts +++ b/web/src/state/store.ts @@ -204,7 +204,7 @@ export const useStore = create((set, get) => ({ toast: (text: string) => { set((s) => ({ - toasts: [...s.toasts, { id: crypto.randomUUID().slice(0, 8), text, at: Date.now() }].slice(-50), + toasts: [...s.toasts, { id: crypto.randomUUID().slice(0, 8), text, at: Date.now() }].slice(-200), logsUnread: !s.logsPanelOpen, // «загорается» кнопка в TopBar, пока панель закрыта })); }, diff --git a/web/src/ui/ConfigPanel.tsx b/web/src/ui/ConfigPanel.tsx index 0109781..ff868d3 100644 --- a/web/src/ui/ConfigPanel.tsx +++ b/web/src/ui/ConfigPanel.tsx @@ -3,6 +3,7 @@ import { useStore } from '../state/store'; import { NODE_DEFS } from '../core/nodes'; import { triggerMiner, rechargeAccumulator } from '../state/runtime'; import { MODULE_DEFS } from '../core/nodes/modules'; +import { RECIPES } from '../core/nodes/recipes'; import { JsonView } from './JsonView'; import './ConfigPanel.css'; @@ -151,14 +152,20 @@ export function ConfigPanel() { value={(entity.config[field.key] as string) || ''} onChange={(e) => { const value = e.target.value; - handleConfigChange(field.key, value); - // Если это выбор рецепта, подставить system-промпт + // Выбор рецепта у assembler: handler читает config.system, а не config.recipe, + // поэтому подставляем system-промпт пресета ОДНИМ обновлением (recipe+system + // вместе — два раздельных handleConfigChange затёрли бы друг друга из-за + // устаревшего entity.config в замыкании). Для 'custom' system не трогаем — + // у него пустой пресет, и это стёрло бы то, что пользователь уже написал. if (field.key === 'recipe' && entity.kind === 'assembler') { - const selectedOption = field.options?.find((opt) => opt.value === value); - if (selectedOption?.label) { - // Извлекаем system из опции (это лучше сделать в NODE_DEFS если поддерживается) - // Пока просто обновляем рецепт + const recipe = RECIPES.find((r) => r.value === value); + if (recipe && recipe.value !== 'custom') { + setConfig(entity.id, { ...entity.config, recipe: value, system: recipe.system }); + } else { + handleConfigChange('recipe', value); } + } else { + handleConfigChange(field.key, value); } }} disabled={running}