feat(admin): Manus-style 管理台 — 文件树 + 活动时间线 + SSE 实时推送
新增: - src/events.ts: 工具调用事件总线(EventEmitter + 500 条 ring buffer) - src/admin.ts: /admin/api/* 路由(users/files/file/events/start/stop) - src/admin-ui.ts: 两张 HTML 页面 - Dashboard: 所有用户卡片网格,状态彩色徽章 - 单用户视图: 三栏(文件树|预览|时间线),SSE 实时 - src/index.ts: /admin/ 静态页 + 工具调用 emitEvent 埋点 部署: - nginx: sandbox.milejoy.com → 127.0.0.1:8700(含 SSE 长连接 off buffer) - certbot --expand 把 sandbox.milejoy.com 加到现有证书 访问: https://sandbox.milejoy.com/admin/?token=<ADMIN_TOKEN> ADMIN_TOKEN 在 /etc/lobe-sandbox/orchestrator.env Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
307
orchestrator/src/admin-ui.ts
Normal file
307
orchestrator/src/admin-ui.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
// Manus-style 沙箱管理 UI(两张页面:Dashboard + Per-user view)
|
||||
// 全部内嵌,靠 Alpine.js + Tailwind CDN,零构建。
|
||||
|
||||
export const DASHBOARD_HTML = String.raw`<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Lobe Sandbox · 管理台</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<style>
|
||||
body { background: #0a0e1a; color: #e5e7eb; font-family: ui-sans-serif, system-ui, sans-serif; }
|
||||
.card { background: linear-gradient(180deg, #141b2d 0%, #0f1522 100%); border: 1px solid #1f2937; }
|
||||
.state-RUNNING { background: #10b981; }
|
||||
.state-STOPPED { background: #6b7280; }
|
||||
.state-MISSING { background: #ef4444; }
|
||||
</style>
|
||||
</head>
|
||||
<body x-data="dashboard()" x-init="load()">
|
||||
|
||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">🏜 Lobe Sandbox 管理台</h1>
|
||||
<p class="text-sm text-gray-400 mt-1" x-text="'共 ' + users.length + ' 个用户沙箱 · 运行中 ' + runningCount + ' 个'"></p>
|
||||
</div>
|
||||
<button @click="load()" class="text-xs text-gray-400 hover:text-white">⟳ 刷新</button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<template x-for="u in users" :key="u.name">
|
||||
<a :href="'user/' + u.userId + location.search"
|
||||
class="card rounded-lg p-4 hover:border-emerald-500 hover:-translate-y-0.5 transition-all block">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-mono text-xs text-gray-400" x-text="u.userId.slice(0, 24) + (u.userId.length > 24 ? '…' : '')"></span>
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-white px-2 py-0.5 rounded-full"
|
||||
:class="'state-' + u.state"
|
||||
x-text="u.state"></span>
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-500 grid grid-cols-3 gap-2 mt-3 pt-3 border-t border-gray-800">
|
||||
<div><div class="text-gray-400" x-text="fmtBytes(u.memory)"></div><div>内存</div></div>
|
||||
<div><div class="text-gray-400" x-text="fmtBytes(u.disk)"></div><div>磁盘</div></div>
|
||||
<div><div class="text-gray-400" x-text="u.ipv4 || '—'"></div><div>IP</div></div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
<div x-show="users.length === 0" class="col-span-full text-center py-20 text-gray-500">
|
||||
还没有沙箱容器
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function dashboard() {
|
||||
return {
|
||||
users: [],
|
||||
get runningCount() { return this.users.filter(u => u.state === 'RUNNING').length; },
|
||||
get token() { return new URLSearchParams(location.search).get('token') ?? ''; },
|
||||
async load() {
|
||||
const r = await fetch('api/users?token=' + encodeURIComponent(this.token));
|
||||
if (!r.ok) { document.body.innerHTML = '<div class="p-10 text-red-400">Unauthorized — add ?token=... to URL</div>'; return; }
|
||||
const { users } = await r.json();
|
||||
this.users = users;
|
||||
},
|
||||
fmtBytes(n) {
|
||||
if (!n) return '—';
|
||||
if (n > 1e9) return (n / 1e9).toFixed(1) + 'G';
|
||||
if (n > 1e6) return (n / 1e6).toFixed(1) + 'M';
|
||||
if (n > 1e3) return (n / 1e3).toFixed(1) + 'K';
|
||||
return n + 'B';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export const USER_VIEW_HTML = String.raw`<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Lobe Sandbox · 计算机视图</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<style>
|
||||
body { background: #0a0e1a; color: #e5e7eb; font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; }
|
||||
.card { background: linear-gradient(180deg, #141b2d 0%, #0f1522 100%); border: 1px solid #1f2937; }
|
||||
.tool-executeCode { background: #2563eb; }
|
||||
.tool-runCommand { background: #7c3aed; }
|
||||
.tool-writeLocalFile, .tool-editLocalFile { background: #059669; }
|
||||
.tool-readLocalFile, .tool-listLocalFiles { background: #0891b2; }
|
||||
.tool-moveLocalFiles, .tool-renameLocalFile { background: #d97706; }
|
||||
.tool-searchLocalFiles, .tool-grepContent, .tool-globLocalFiles { background: #6366f1; }
|
||||
.tool-fail { background: #dc2626 !important; }
|
||||
.scrollbar::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
.scrollbar::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
|
||||
.scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
pre { font-family: 'SF Mono', Menlo, Consolas, monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body x-data="userView()" x-init="init()" class="h-screen flex flex-col overflow-hidden">
|
||||
|
||||
<!-- 顶栏 -->
|
||||
<header class="flex items-center justify-between px-6 py-3 border-b border-gray-800 bg-[#0f1522]">
|
||||
<div class="flex items-center gap-3">
|
||||
<a :href="'../?token=' + encodeURIComponent(token)" class="text-gray-400 hover:text-white text-sm">← 全局</a>
|
||||
<span class="text-gray-600">/</span>
|
||||
<span class="font-mono text-xs text-gray-300" x-text="userId"></span>
|
||||
<span class="inline-flex items-center gap-1.5 text-[11px] text-white px-2 py-0.5 rounded-full"
|
||||
:class="{'bg-emerald-600': state==='RUNNING','bg-gray-600': state==='STOPPED','bg-red-600':state==='MISSING'}"
|
||||
x-text="state"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button x-show="state==='STOPPED'" @click="action('start')" class="text-xs px-3 py-1 bg-emerald-600 hover:bg-emerald-500 rounded">▶ 启动</button>
|
||||
<button x-show="state==='RUNNING'" @click="action('stop')" class="text-xs px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded">⏹ 停止</button>
|
||||
<button @click="refreshAll()" class="text-xs text-gray-400 hover:text-white">⟳</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 grid grid-cols-[320px_1fr_1fr] overflow-hidden">
|
||||
|
||||
<!-- 文件树 -->
|
||||
<aside class="card border-y-0 border-l-0 overflow-y-auto scrollbar">
|
||||
<div class="px-4 py-2 border-b border-gray-800 sticky top-0 bg-[#0f1522] text-xs font-semibold text-gray-400">
|
||||
📁 /workspace (<span x-text="files.length"></span>)
|
||||
</div>
|
||||
<div class="p-2 text-sm">
|
||||
<template x-for="f in sortedFiles" :key="f.path">
|
||||
<div @click="openFile(f)"
|
||||
class="px-2 py-1.5 rounded hover:bg-gray-800 cursor-pointer truncate flex items-center justify-between group"
|
||||
:class="{'bg-gray-800': selectedPath === f.path}">
|
||||
<span class="truncate" x-text="iconFor(f.path) + ' ' + shortPath(f.path)"></span>
|
||||
<span class="text-[10px] text-gray-600 group-hover:text-gray-400 tabular-nums" x-text="fmtBytes(f.size)"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="files.length === 0 && !loadingFiles" class="text-xs text-gray-600 p-3">
|
||||
<span x-show="state !== 'RUNNING'">容器停机中,启动后可见文件</span>
|
||||
<span x-show="state === 'RUNNING'">空工作区</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 中间:文件预览 -->
|
||||
<section class="card border-y-0 overflow-hidden flex flex-col">
|
||||
<div class="px-4 py-2 border-b border-gray-800 text-xs font-semibold text-gray-400 flex justify-between items-center">
|
||||
<span x-text="selectedPath ? '📄 ' + selectedPath : '📄 选文件查看'"></span>
|
||||
<span x-show="selectedPath" class="text-gray-600" x-text="fmtBytes(selectedSize)"></span>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto scrollbar bg-[#0a0e1a]">
|
||||
<template x-if="!selectedPath">
|
||||
<div class="p-10 text-center text-gray-600 text-sm">← 点左边的文件</div>
|
||||
</template>
|
||||
<template x-if="selectedType === 'image'">
|
||||
<div class="p-4"><img :src="selectedUrl" class="max-w-full rounded shadow-lg"/></div>
|
||||
</template>
|
||||
<template x-if="selectedType === 'text'">
|
||||
<pre class="text-xs text-gray-300 p-4 whitespace-pre-wrap break-all" x-text="selectedContent"></pre>
|
||||
</template>
|
||||
<template x-if="selectedType === 'error'">
|
||||
<div class="p-4 text-red-400 text-sm" x-text="selectedContent"></div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 右侧:活动时间线 -->
|
||||
<section class="card border-y-0 border-r-0 overflow-hidden flex flex-col">
|
||||
<div class="px-4 py-2 border-b border-gray-800 text-xs font-semibold text-gray-400 flex justify-between">
|
||||
<span>▶ 活动时间线 (<span x-text="events.length"></span>)</span>
|
||||
<span x-show="sseConnected" class="text-emerald-500">● live</span>
|
||||
<span x-show="!sseConnected" class="text-gray-600">○ 离线</span>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto scrollbar p-3 space-y-2" id="timeline">
|
||||
<template x-for="e in events.slice().reverse()" :key="e.id">
|
||||
<div class="rounded-md overflow-hidden bg-[#0a0e1a] border border-gray-800">
|
||||
<div class="flex items-center gap-2 px-3 py-2 text-[11px]">
|
||||
<span class="inline-block w-2 h-2 rounded-full" :class="'tool-' + e.toolName + (!e.success ? ' tool-fail' : '')"></span>
|
||||
<span class="font-mono text-gray-400" x-text="fmtTime(e.ts)"></span>
|
||||
<span class="font-semibold text-white" x-text="e.toolName"></span>
|
||||
<span class="text-gray-600 ml-auto" x-text="e.durationMs + 'ms'"></span>
|
||||
</div>
|
||||
<div class="px-3 pb-2 text-xs text-gray-400 truncate font-mono" x-text="e.summary || ''"></div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="events.length === 0" class="text-xs text-gray-600 text-center py-10">
|
||||
等待活动 — 让 AI 写个文件试试
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function userView() {
|
||||
const path = location.pathname;
|
||||
const m = path.match(/\/admin\/user\/([^\/]+)/);
|
||||
const userId = m ? decodeURIComponent(m[1]) : '';
|
||||
return {
|
||||
userId,
|
||||
token: new URLSearchParams(location.search).get('token') ?? '',
|
||||
state: 'LOADING',
|
||||
files: [],
|
||||
loadingFiles: false,
|
||||
events: [],
|
||||
sseConnected: false,
|
||||
selectedPath: '',
|
||||
selectedSize: 0,
|
||||
selectedType: '',
|
||||
selectedContent: '',
|
||||
selectedUrl: '',
|
||||
get sortedFiles() {
|
||||
return [...this.files].sort((a, b) => b.mtime - a.mtime);
|
||||
},
|
||||
async init() {
|
||||
await this.loadInfo();
|
||||
await this.loadFiles();
|
||||
this.connectSSE();
|
||||
},
|
||||
async loadInfo() {
|
||||
const r = await fetch('../api/user/' + encodeURIComponent(this.userId) + '?token=' + encodeURIComponent(this.token));
|
||||
if (!r.ok) { this.state = 'ERROR'; return; }
|
||||
const d = await r.json();
|
||||
this.state = d.state;
|
||||
},
|
||||
async loadFiles() {
|
||||
this.loadingFiles = true;
|
||||
try {
|
||||
const r = await fetch('../api/files?userId=' + encodeURIComponent(this.userId) + '&token=' + encodeURIComponent(this.token));
|
||||
const d = await r.json();
|
||||
this.files = d.files || [];
|
||||
} finally {
|
||||
this.loadingFiles = false;
|
||||
}
|
||||
},
|
||||
refreshAll() {
|
||||
this.loadInfo();
|
||||
this.loadFiles();
|
||||
},
|
||||
async action(verb) {
|
||||
await fetch('../api/user/' + encodeURIComponent(this.userId) + '/' + verb + '?token=' + encodeURIComponent(this.token), { method: 'POST' });
|
||||
setTimeout(() => this.refreshAll(), 1000);
|
||||
},
|
||||
connectSSE() {
|
||||
const url = '../api/events?userId=' + encodeURIComponent(this.userId) + '&token=' + encodeURIComponent(this.token);
|
||||
const es = new EventSource(url);
|
||||
es.addEventListener('event', (ev) => {
|
||||
const e = JSON.parse(ev.data);
|
||||
if (!this.events.find((x) => x.id === e.id)) {
|
||||
this.events.push(e);
|
||||
// 文件列表可能已变,防抖刷新
|
||||
clearTimeout(this._reloadTimer);
|
||||
this._reloadTimer = setTimeout(() => this.loadFiles(), 800);
|
||||
}
|
||||
});
|
||||
es.addEventListener('open', () => this.sseConnected = true);
|
||||
es.addEventListener('error', () => this.sseConnected = false);
|
||||
},
|
||||
async openFile(f) {
|
||||
this.selectedPath = f.path;
|
||||
this.selectedSize = f.size;
|
||||
const ext = f.path.slice(f.path.lastIndexOf('.')).toLowerCase();
|
||||
const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'].includes(ext);
|
||||
const url = '../api/file?userId=' + encodeURIComponent(this.userId) + '&path=' + encodeURIComponent(f.path) + '&token=' + encodeURIComponent(this.token);
|
||||
if (isImage) {
|
||||
this.selectedType = 'image';
|
||||
this.selectedUrl = url;
|
||||
} else {
|
||||
const r = await fetch(url);
|
||||
if (r.ok) {
|
||||
this.selectedType = 'text';
|
||||
this.selectedContent = await r.text();
|
||||
} else {
|
||||
this.selectedType = 'error';
|
||||
this.selectedContent = '加载失败: ' + await r.text();
|
||||
}
|
||||
}
|
||||
},
|
||||
iconFor(path) {
|
||||
const ext = path.slice(path.lastIndexOf('.')).toLowerCase();
|
||||
return ({
|
||||
'.py': '🐍', '.js': '📜', '.ts': '📜',
|
||||
'.json': '{}', '.md': '📝', '.txt': '📄',
|
||||
'.csv': '📊', '.tsv': '📊', '.xlsx': '📊',
|
||||
'.png': '🖼', '.jpg': '🖼', '.jpeg': '🖼', '.gif': '🖼', '.webp': '🖼',
|
||||
'.html': '🌐', '.css': '🎨',
|
||||
'.pdf': '📕', '.zip': '📦',
|
||||
})[ext] ?? '📄';
|
||||
},
|
||||
shortPath(p) {
|
||||
return p.replace(/^\/workspace\//, '');
|
||||
},
|
||||
fmtBytes(n) {
|
||||
if (!n) return '0B';
|
||||
if (n > 1e9) return (n / 1e9).toFixed(1) + 'G';
|
||||
if (n > 1e6) return (n / 1e6).toFixed(1) + 'M';
|
||||
if (n > 1e3) return (n / 1e3).toFixed(1) + 'K';
|
||||
return n + 'B';
|
||||
},
|
||||
fmtTime(ts) {
|
||||
const d = new Date(ts);
|
||||
return d.toTimeString().slice(0, 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -15,7 +15,7 @@ admin.use('*', async (c, next) => {
|
||||
});
|
||||
|
||||
// 全局用户列表
|
||||
admin.get('/api/users', async (c) => {
|
||||
admin.get('/users', async (c) => {
|
||||
const r = await Bun.spawn(
|
||||
['incus', 'list', '--project', env.incus.project, '--format', 'json'],
|
||||
{ stdout: 'pipe' },
|
||||
@@ -26,7 +26,7 @@ admin.get('/api/users', async (c) => {
|
||||
const users = instances.map((inst) => ({
|
||||
name: inst.name as string,
|
||||
userId: inst.name.replace(new RegExp(`^${env.incus.prefix}`), ''),
|
||||
state: inst.state?.status as string,
|
||||
state: String(inst.state?.status ?? 'UNKNOWN').toUpperCase(),
|
||||
ipv4:
|
||||
inst.state?.network?.eth0?.addresses?.find((a: any) => a.family === 'inet')?.address ?? '',
|
||||
memory: inst.state?.memory?.usage ?? 0,
|
||||
@@ -38,7 +38,7 @@ admin.get('/api/users', async (c) => {
|
||||
});
|
||||
|
||||
// 单用户 detail
|
||||
admin.get('/api/user/:userId', async (c) => {
|
||||
admin.get('/user/:userId', async (c) => {
|
||||
const userId = c.req.param('userId');
|
||||
const name = containerName(userId);
|
||||
const s = await incus.state(name);
|
||||
@@ -46,7 +46,7 @@ admin.get('/api/user/:userId', async (c) => {
|
||||
});
|
||||
|
||||
// 文件树(递归平铺)
|
||||
admin.get('/api/files', async (c) => {
|
||||
admin.get('/files', async (c) => {
|
||||
const userId = c.req.query('userId');
|
||||
if (!userId) return c.json({ error: 'userId required' }, 400);
|
||||
const name = containerName(userId);
|
||||
@@ -78,7 +78,7 @@ admin.get('/api/files', async (c) => {
|
||||
});
|
||||
|
||||
// 文件内容(小文件 text / 大文件截断)
|
||||
admin.get('/api/file', async (c) => {
|
||||
admin.get('/file', async (c) => {
|
||||
const userId = c.req.query('userId');
|
||||
const path = c.req.query('path');
|
||||
if (!userId || !path) return c.json({ error: 'userId + path required' }, 400);
|
||||
@@ -107,7 +107,7 @@ admin.get('/api/file', async (c) => {
|
||||
});
|
||||
|
||||
// 手动 start/stop
|
||||
admin.post('/api/user/:userId/start', async (c) => {
|
||||
admin.post('/user/:userId/start', async (c) => {
|
||||
try {
|
||||
await incus.start(containerName(c.req.param('userId')));
|
||||
return c.json({ ok: true });
|
||||
@@ -115,7 +115,7 @@ admin.post('/api/user/:userId/start', async (c) => {
|
||||
return c.json({ error: (e as Error).message }, 500);
|
||||
}
|
||||
});
|
||||
admin.post('/api/user/:userId/stop', async (c) => {
|
||||
admin.post('/user/:userId/stop', async (c) => {
|
||||
try {
|
||||
await incus.stop(containerName(c.req.param('userId')));
|
||||
return c.json({ ok: true });
|
||||
@@ -125,7 +125,7 @@ admin.post('/api/user/:userId/stop', async (c) => {
|
||||
});
|
||||
|
||||
// SSE 实时事件流
|
||||
admin.get('/api/events', (c) => {
|
||||
admin.get('/events', (c) => {
|
||||
const userId = c.req.query('userId') ?? undefined;
|
||||
return streamSSE(c, async (stream) => {
|
||||
// 回放最近事件
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { admin } from './admin.ts';
|
||||
import { DASHBOARD_HTML, USER_VIEW_HTML } from './admin-ui.ts';
|
||||
import { authMiddleware } from './auth.ts';
|
||||
import { env } from './env.ts';
|
||||
import { emitEvent, summarizeToolResult } from './events.ts';
|
||||
import { exportFile } from './export.ts';
|
||||
import { containerName, incus } from './incus.ts';
|
||||
import { startIdleReaper } from './reaper.ts';
|
||||
@@ -12,6 +15,14 @@ const app = new Hono();
|
||||
|
||||
app.get('/health', (c) => c.json({ ok: true, ts: Date.now() }));
|
||||
|
||||
// Admin UI 页面(HTML,本身的 API 走下面 /admin/api/*)
|
||||
app.get('/admin/', (c) => c.html(DASHBOARD_HTML));
|
||||
app.get('/admin', (c) => c.redirect('/admin/?token=' + (c.req.query('token') ?? '')));
|
||||
app.get('/admin/user/:userId', (c) => c.html(USER_VIEW_HTML));
|
||||
|
||||
// Admin API(挂在 /admin/api,auth 在 admin router 里处理)
|
||||
app.route('/admin/api', admin);
|
||||
|
||||
// 下面所有路由都要过 auth
|
||||
app.use('/api/*', authMiddleware);
|
||||
|
||||
@@ -59,14 +70,34 @@ const ToolBody = z.object({
|
||||
app.post('/api/v1/tools/:toolName', async (c) => {
|
||||
const toolName = c.req.param('toolName');
|
||||
const handler = handlers[toolName];
|
||||
if (!handler) return c.json({ success: false, error: { message: `unknown tool: ${toolName}` } }, 404);
|
||||
if (!handler)
|
||||
return c.json({ success: false, error: { message: `unknown tool: ${toolName}` } }, 404);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const parsed = ToolBody.safeParse(body);
|
||||
if (!parsed.success) return c.json({ success: false, error: { message: 'invalid body' } }, 400);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await handler(parsed.data.params, parsed.data.userId);
|
||||
emitEvent({
|
||||
durationMs: Date.now() - start,
|
||||
params: parsed.data.params,
|
||||
success: result.success,
|
||||
summary: summarizeToolResult(toolName, parsed.data.params, result),
|
||||
toolName,
|
||||
ts: start,
|
||||
userId: parsed.data.userId,
|
||||
});
|
||||
return c.json(result);
|
||||
} catch (e) {
|
||||
emitEvent({
|
||||
durationMs: Date.now() - start,
|
||||
params: parsed.data.params,
|
||||
success: false,
|
||||
summary: `❌ ${(e as Error).message}`,
|
||||
toolName,
|
||||
ts: start,
|
||||
userId: parsed.data.userId,
|
||||
});
|
||||
return c.json({ success: false, error: { message: (e as Error).message } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user