Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +41 to +44

# FastAPI app
app = FastAPI()

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)}")


Expand Down Expand Up @@ -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)

Comment on lines +193 to +195
try:
async with httpx.AsyncClient(timeout=20) as client:
if method == "GET":
Expand All @@ -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)}")


Expand All @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions web/src/core/__checks__/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down Expand Up @@ -592,6 +615,7 @@ function testRegistry() {

export async function checkNodes() {
await testAssemblerLlm();
await testAssemblerRecipeFallback();
await testSplitterExpr();
await testSplitterLlm();
await testMixerConcat();
Expand Down
11 changes: 10 additions & 1 deletion web/src/core/nodes/assembler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
33 changes: 30 additions & 3 deletions web/src/state/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +113 to +118

/**
* Маппинг EngineEvent на store-actions и сайд-эффекты
*/
Expand All @@ -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 });
}
Comment on lines +163 to +166
break;
}

case 'result': {
// Результат от silo или chest → pushResult и запустить rocketLaunch
Expand Down
2 changes: 1 addition & 1 deletion web/src/state/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ export const useStore = create<Store>((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, пока панель закрыта
}));
},
Expand Down
19 changes: 13 additions & 6 deletions web/src/ui/ConfigPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Comment on lines 160 to +165
}
} else {
handleConfigChange(field.key, value);
}
}}
disabled={running}
Expand Down