auto-save 2026-04-19 22:34 (+2, ~2)

This commit is contained in:
2026-04-19 22:34:43 +08:00
parent 119afa38ec
commit cca158da1e
4 changed files with 242 additions and 0 deletions

View File

@@ -279,6 +279,13 @@
"message": "auto-save 2026-04-19 22:23 (~1)",
"hash": "76e1682",
"files_changed": 1
},
{
"ts": "2026-04-19T22:29:14+08:00",
"type": "commit",
"message": "auto-save 2026-04-19 22:29 (~1)",
"hash": "119afa3",
"files_changed": 1
}
]
}

162
orchestrator/src/admin.ts Normal file
View File

@@ -0,0 +1,162 @@
import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';
import { env } from './env.ts';
import { getRecentEvents, onEvent } from './events.ts';
import { containerName, incus } from './incus.ts';
export const admin = new Hono();
// 鉴权中间件 — token 走 query(首屏 iframe/跳转友好)或 header
admin.use('*', async (c, next) => {
if (!env.adminToken) return c.text('Admin UI disabled: set ADMIN_TOKEN env', 503);
const token = c.req.query('token') ?? c.req.header('Admin-Token');
if (token !== env.adminToken) return c.text('Unauthorized', 401);
await next();
});
// 全局用户列表
admin.get('/api/users', async (c) => {
const r = await Bun.spawn(
['incus', 'list', '--project', env.incus.project, '--format', 'json'],
{ stdout: 'pipe' },
);
const raw = await new Response(r.stdout).text();
await r.exited;
const instances = JSON.parse(raw || '[]') as any[];
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,
ipv4:
inst.state?.network?.eth0?.addresses?.find((a: any) => a.family === 'inet')?.address ?? '',
memory: inst.state?.memory?.usage ?? 0,
disk: inst.state?.disk?.root?.usage ?? 0,
cpuSeconds: inst.state?.cpu?.usage ?? 0,
lastUsedAt: inst.last_used_at as string,
}));
return c.json({ users });
});
// 单用户 detail
admin.get('/api/user/:userId', async (c) => {
const userId = c.req.param('userId');
const name = containerName(userId);
const s = await incus.state(name);
return c.json({ userId, containerName: name, state: s });
});
// 文件树(递归平铺)
admin.get('/api/files', async (c) => {
const userId = c.req.query('userId');
if (!userId) return c.json({ error: 'userId required' }, 400);
const name = containerName(userId);
const s = await incus.state(name);
if (s === 'MISSING') return c.json({ files: [], error: 'no sandbox' });
if (s === 'STOPPED') return c.json({ files: [], note: 'sandbox stopped — start to view files' });
// 递归 + 安全限制:最多 500 个文件,排除 venv 里的包
const r = await incus.exec(
name,
[
'bash',
'-c',
`find /workspace -type f -not -path '*/.venv/lib/*' -not -path '*/node_modules/*' -printf '%T@\\t%s\\t%p\\n' 2>/dev/null | head -500`,
],
{ user: 1000 },
);
const files = r.stdout
.split('\n')
.filter(Boolean)
.map((line) => {
const [ts, size, path] = line.split('\t');
return {
path,
size: Number(size),
mtime: Math.floor(Number(ts) * 1000),
};
});
return c.json({ files });
});
// 文件内容(小文件 text / 大文件截断)
admin.get('/api/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);
const name = containerName(userId);
// 安全:限制 /workspace 下,最大 2MB
if (!path.startsWith('/workspace')) return c.json({ error: 'path must be under /workspace' }, 403);
try {
const bytes = await incus.pull(name, path);
if (bytes.byteLength > 2 * 1024 * 1024) {
return c.json({ error: 'file too large (>2MB), download only' }, 413);
}
const ext = path.slice(path.lastIndexOf('.')).toLowerCase();
const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'].includes(ext);
if (isImage) {
return new Response(bytes, {
headers: { 'Content-Type': mimeFor(ext) },
});
}
// 文本返回 — 让前端自己渲染
return new Response(bytes, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
} catch (e) {
return c.json({ error: (e as Error).message }, 500);
}
});
// 手动 start/stop
admin.post('/api/user/:userId/start', async (c) => {
try {
await incus.start(containerName(c.req.param('userId')));
return c.json({ ok: true });
} catch (e) {
return c.json({ error: (e as Error).message }, 500);
}
});
admin.post('/api/user/:userId/stop', async (c) => {
try {
await incus.stop(containerName(c.req.param('userId')));
return c.json({ ok: true });
} catch (e) {
return c.json({ error: (e as Error).message }, 500);
}
});
// SSE 实时事件流
admin.get('/api/events', (c) => {
const userId = c.req.query('userId') ?? undefined;
return streamSSE(c, async (stream) => {
// 回放最近事件
for (const e of getRecentEvents(userId)) {
await stream.writeSSE({ data: JSON.stringify(e), event: 'event' });
}
let writing: Promise<unknown> = Promise.resolve();
const off = onEvent((e) => {
if (userId && e.userId !== userId) return;
writing = writing.then(() =>
stream.writeSSE({ data: JSON.stringify(e), event: 'event' }).catch(() => {}),
);
});
try {
// keepalive
while (!stream.aborted) {
await stream.sleep(30_000);
await stream.writeSSE({ data: String(Date.now()), event: 'ping' }).catch(() => {});
}
} finally {
off();
}
});
});
const mimeFor = (ext: string): string =>
({
'.gif': 'image/gif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
})[ext] ?? 'application/octet-stream';

View File

@@ -14,6 +14,7 @@ export const env = {
host: process.env.HOST ?? '127.0.0.1',
orchSecret: required('SANDBOX_ORCH_SECRET'),
adminToken: process.env.ADMIN_TOKEN ?? '',
incus: {
project: process.env.INCUS_PROJECT ?? 'lobe-sandbox',

View File

@@ -0,0 +1,72 @@
// Sandbox 活动事件总线 — 供 Manus-style 视图 SSE 订阅
export interface SandboxEvent {
id: string;
ts: number;
userId: string;
toolName: string;
params: unknown;
success: boolean;
durationMs: number;
summary?: string;
}
const listeners = new Set<(e: SandboxEvent) => void>();
const recent: SandboxEvent[] = [];
const MAX_RECENT = 500;
export const emitEvent = (e: Omit<SandboxEvent, 'id'>): SandboxEvent => {
const event: SandboxEvent = { ...e, id: `${e.ts}-${Math.random().toString(36).slice(2, 8)}` };
recent.push(event);
if (recent.length > MAX_RECENT) recent.shift();
for (const fn of listeners) {
try {
fn(event);
} catch {}
}
return event;
};
export const onEvent = (fn: (e: SandboxEvent) => void): (() => void) => {
listeners.add(fn);
return () => listeners.delete(fn);
};
export const getRecentEvents = (userId?: string, limit = 100): SandboxEvent[] => {
const filtered = userId ? recent.filter((e) => e.userId === userId) : recent;
return filtered.slice(-limit);
};
// 把工具结果压缩成一句话摘要(UI 好读)
export const summarizeToolResult = (toolName: string, params: any, result: any): string => {
if (!result.success) return `${result.error?.message ?? 'failed'}`;
const r = result.result as any;
switch (toolName) {
case 'executeCode':
return r?.exitCode === 0
? `${params?.language} ${(r?.stdout ?? '').slice(0, 80)}`
: `exit=${r?.exitCode} ${(r?.stderr ?? '').slice(0, 80)}`;
case 'runCommand':
return `$ ${String(params?.command ?? '').slice(0, 60)}`;
case 'writeLocalFile':
return `${r?.bytes ?? 0}B → ${params?.filePath ?? '?'}`;
case 'readLocalFile':
return `${params?.filePath ?? '?'} (${(r?.content ?? '').length}B)`;
case 'listLocalFiles':
return `ls ${params?.directoryPath ?? '/'}`;
case 'editLocalFile':
return `edit ${params?.filePath ?? '?'}`;
case 'moveLocalFiles':
return `mv ${(params?.sources ?? []).length}${params?.destination ?? '?'}`;
case 'renameLocalFile':
return `mv ${params?.oldPath}${params?.newPath}`;
case 'searchLocalFiles':
return `find ${params?.pattern}`;
case 'grepContent':
return `rg ${params?.pattern}`;
case 'globLocalFiles':
return `glob ${params?.pattern}`;
default:
return toolName;
}
};