auto-save 2026-04-19 22:34 (+2, ~2)
This commit is contained in:
@@ -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
162
orchestrator/src/admin.ts
Normal 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';
|
||||
@@ -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',
|
||||
|
||||
72
orchestrator/src/events.ts
Normal file
72
orchestrator/src/events.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user