auto-save 2026-04-20 00:31 (~2)
This commit is contained in:
@@ -433,6 +433,13 @@
|
|||||||
"message": "auto-save 2026-04-20 00:20 (~2)",
|
"message": "auto-save 2026-04-20 00:20 (~2)",
|
||||||
"hash": "01b09bc",
|
"hash": "01b09bc",
|
||||||
"files_changed": 2
|
"files_changed": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-04-20T00:26:03+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-04-20 00:25 (~1)",
|
||||||
|
"hash": "7a6935b",
|
||||||
|
"files_changed": 1
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Manus-style 沙箱管理 UI(两张页面:Dashboard + Per-user view)
|
// Manus-style 沙箱管理 UI(两张页面:Dashboard + Per-user terminal view)
|
||||||
// 全部内嵌,靠 Alpine.js + Tailwind CDN,零构建。
|
// 全部内嵌,靠 Alpine.js + Tailwind CDN + xterm.js,零构建。
|
||||||
|
|
||||||
export const DASHBOARD_HTML = String.raw`<!DOCTYPE html>
|
export const DASHBOARD_HTML = String.raw`<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
@@ -21,33 +21,25 @@ export const DASHBOARD_HTML = String.raw`<!DOCTYPE html>
|
|||||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-white">🏜 Lobe Sandbox 管理台</h1>
|
<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>
|
<p class="text-sm text-gray-400 mt-1" x-text="users.length + ' 个用户沙箱 · 运行中 ' + runningCount"></p>
|
||||||
</div>
|
</div>
|
||||||
<button @click="load()" class="text-xs text-gray-400 hover:text-white">⟳ 刷新</button>
|
<button @click="load()" class="text-xs text-gray-400 hover:text-white">⟳</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 列表 -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<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">
|
<template x-for="u in users" :key="u.name">
|
||||||
<a :href="'user/' + u.userId + location.search"
|
<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">
|
class="card rounded-lg p-4 hover:border-emerald-500 block">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<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="font-mono text-xs text-gray-400" x-text="u.userId.slice(0, 30)"></span>
|
||||||
<span class="inline-flex items-center gap-1.5 text-xs text-white px-2 py-0.5 rounded-full"
|
<span class="inline-flex items-center gap-1.5 text-xs text-white px-2 py-0.5 rounded-full"
|
||||||
:class="'state-' + u.state"
|
:class="'state-' + u.state"
|
||||||
x-text="u.state"></span>
|
x-text="u.state"></span>
|
||||||
</div>
|
</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>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
<div x-show="users.length === 0" class="col-span-full text-center py-20 text-gray-500">
|
<div x-show="users.length === 0" class="col-span-full text-center py-20 text-gray-500">还没有沙箱</div>
|
||||||
还没有沙箱容器
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,13 +55,6 @@ function dashboard() {
|
|||||||
const { users } = await r.json();
|
const { users } = await r.json();
|
||||||
this.users = users;
|
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>
|
</script>
|
||||||
@@ -80,365 +65,246 @@ export const USER_VIEW_HTML = String.raw`<!DOCTYPE html>
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Lobe Sandbox · Terminal</title>
|
<title>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>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body { background: #0a0e1a; color: #e5e7eb; font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; }
|
html, body { margin: 0; padding: 0; height: 100%; background: #000; overflow: hidden; }
|
||||||
.card { background: linear-gradient(180deg, #141b2d 0%, #0f1522 100%); border: 1px solid #1f2937; }
|
#term { width: 100%; height: 100%; }
|
||||||
.scrollbar::-webkit-scrollbar { width: 8px; height: 8px; }
|
.xterm { padding: 8px; height: 100%; }
|
||||||
.scrollbar::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
|
|
||||||
.scrollbar::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
pre { font-family: 'SF Mono', Menlo, Consolas, monospace; }
|
|
||||||
#xterm { padding: 8px; height: 100%; background: #0a0e1a; }
|
|
||||||
.xterm-viewport::-webkit-scrollbar { width: 10px; }
|
|
||||||
.xterm-viewport::-webkit-scrollbar-thumb { background: #374151; border-radius: 5px; }
|
|
||||||
/* read-only cursor:隐藏闪烁指示 */
|
|
||||||
.xterm-cursor-layer { display: none !important; }
|
.xterm-cursor-layer { display: none !important; }
|
||||||
|
#ind { position: fixed; top: 6px; right: 10px; font-family: monospace; font-size: 11px; color: #4ade80; z-index: 10; }
|
||||||
|
#ind.off { color: #6b7280; }
|
||||||
|
#filebtn { position: fixed; bottom: 10px; right: 10px; background: rgba(30,40,60,0.6); color: #9ca3af; border: 1px solid #374151; padding: 4px 10px; border-radius: 6px; font-size: 11px; cursor: pointer; z-index: 10; font-family: monospace; }
|
||||||
|
#filebtn:hover { color: #fff; border-color: #4ade80; }
|
||||||
|
#modal { position: fixed; inset: 0; background: rgba(0,0,0,0.85); display: none; align-items: center; justify-content: center; z-index: 20; }
|
||||||
|
#modal.on { display: flex; }
|
||||||
|
#modalbox { background: #0a0e1a; border: 1px solid #374151; border-radius: 8px; width: 85vw; height: 85vh; display: flex; flex-direction: column; font-family: monospace; }
|
||||||
|
#modalhead { padding: 8px 12px; border-bottom: 1px solid #1f2937; font-size: 12px; color: #9ca3af; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
#modalhead .close { cursor: pointer; color: #6b7280; }
|
||||||
|
#modalhead .close:hover { color: #fff; }
|
||||||
|
#modalbody { flex: 1; overflow: auto; padding: 12px; color: #d1d5db; font-size: 12px; }
|
||||||
|
#modalbody pre { margin: 0; white-space: pre-wrap; word-break: break-all; }
|
||||||
|
#modalbody iframe { width: 100%; height: 100%; border: 0; background: #fff; border-radius: 4px; }
|
||||||
|
#modalbody img { max-width: 100%; border-radius: 4px; }
|
||||||
|
#filelist { position: absolute; top: 40px; right: 0; width: 280px; max-height: calc(100vh - 80px); overflow-y: auto; background: rgba(15,21,34,0.95); border: 1px solid #374151; border-radius: 8px; padding: 6px; display: none; z-index: 15; }
|
||||||
|
#filelist.on { display: block; }
|
||||||
|
#filelist .item { padding: 6px 10px; font-size: 12px; color: #d1d5db; cursor: pointer; border-radius: 4px; display: flex; justify-content: space-between; font-family: monospace; }
|
||||||
|
#filelist .item:hover { background: #1f2937; }
|
||||||
|
#filelist .item .size { color: #6b7280; font-size: 10px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="userView()" x-init="init()" class="h-screen flex flex-col overflow-hidden">
|
<body>
|
||||||
|
<div id="term"></div>
|
||||||
<!-- 顶栏 -->
|
<div id="ind" class="off">⬤ offline</div>
|
||||||
<header class="flex items-center justify-between px-6 py-3 border-b border-gray-800 bg-[#0f1522]">
|
<button id="filebtn">📁 files</button>
|
||||||
<div class="flex items-center gap-3">
|
<div id="filelist"></div>
|
||||||
<a :href="'../?token=' + encodeURIComponent(token)" class="text-gray-400 hover:text-white text-sm">← 全局</a>
|
<div id="modal"><div id="modalbox">
|
||||||
<span class="text-gray-600">/</span>
|
<div id="modalhead">
|
||||||
<span class="font-mono text-xs text-gray-300" x-text="userId"></span>
|
<span id="modalpath">file</span>
|
||||||
<span class="inline-flex items-center gap-1.5 text-[11px] text-white px-2 py-0.5 rounded-full"
|
<span class="close" onclick="closeModal()">✕</span>
|
||||||
: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 overflow-hidden" :style="filesOpen ? 'grid-template-columns:1fr 280px' : 'grid-template-columns:1fr 0'">
|
|
||||||
|
|
||||||
<!-- 终端 -->
|
|
||||||
<section class="flex flex-col overflow-hidden bg-[#0a0e1a]" @click="term && term.focus()">
|
|
||||||
<div class="px-4 py-2 border-b border-gray-800 text-xs font-semibold text-gray-400 flex justify-between items-center">
|
|
||||||
<span>📟 sandbox@<span x-text="userId.slice(0,16)"></span>:~$</span>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-[10px] text-red-500/60">● READ-ONLY(只看不能敲,操作交给 AI)</span>
|
|
||||||
<span x-show="sseConnected" class="text-emerald-500 text-[11px]">● live</span>
|
|
||||||
<span x-show="!sseConnected" class="text-gray-600 text-[11px]">○ 离线</span>
|
|
||||||
<button @click="filesOpen=!filesOpen" class="text-[11px] text-gray-500 hover:text-white">
|
|
||||||
<span x-show="!filesOpen">📁 文件 →</span>
|
|
||||||
<span x-show="filesOpen">← 收起文件</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="xterm" class="flex-1 overflow-hidden"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 文件抽屉(可折叠)-->
|
|
||||||
<aside x-show="filesOpen" class="card border-y-0 border-r-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" class="text-xs text-gray-600 p-3">
|
|
||||||
<span x-show="state !== 'RUNNING'">容器停机中</span>
|
|
||||||
<span x-show="state === 'RUNNING'">空工作区</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 文件预览模态 -->
|
|
||||||
<div x-show="selectedPath" @click="selectedPath=''" style="position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:50;display:flex;align-items:center;justify-content:center" x-cloak>
|
|
||||||
<div @click.stop class="card rounded-lg flex flex-col" style="width:85vw;height:85vh">
|
|
||||||
<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 || '')"></span>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-gray-600" x-text="fmtBytes(selectedSize)"></span>
|
|
||||||
<button @click="selectedPath=''" class="text-gray-400 hover:text-white">✕ 关闭</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 overflow-auto scrollbar">
|
|
||||||
<template x-if="selectedType === 'image'">
|
|
||||||
<div class="p-4 text-center"><img :src="selectedUrl" class="max-w-full rounded shadow-lg mx-auto"/></div>
|
|
||||||
</template>
|
|
||||||
<template x-if="selectedType === 'html'">
|
|
||||||
<div class="h-full flex flex-col">
|
|
||||||
<div class="px-3 py-1.5 bg-[#0f1522] border-b border-gray-800 text-xs flex justify-between">
|
|
||||||
<span class="text-gray-500">🎮 HTML 实时预览</span>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button @click="htmlMode='preview'" :class="htmlMode==='preview'?'text-emerald-400':'text-gray-500'">▶ 玩</button>
|
|
||||||
<button @click="htmlMode='source'" :class="htmlMode==='source'?'text-emerald-400':'text-gray-500'"><> 源码</button>
|
|
||||||
<a :href="'data:text/html;charset=utf-8,' + encodeURIComponent(selectedContent)" target="_blank" class="text-gray-500 hover:text-white">↗ 新窗口</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<iframe x-show="htmlMode==='preview'" :srcdoc="selectedContent" sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock allow-popups allow-modals" class="flex-1 bg-white" style="border:0"></iframe>
|
|
||||||
<pre x-show="htmlMode==='source'" class="text-xs text-gray-300 p-4 whitespace-pre-wrap break-all flex-1 overflow-auto scrollbar" x-text="selectedContent"></pre>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div id="modalbody"></div>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function userView() {
|
(function(){
|
||||||
const path = location.pathname;
|
const path = location.pathname;
|
||||||
const m = path.match(/\/admin\/user\/([^\/]+)/);
|
const m = path.match(/\/admin\/user\/([^\/]+)/);
|
||||||
const userId = m ? decodeURIComponent(m[1]) : '';
|
const userId = m ? decodeURIComponent(m[1]) : '';
|
||||||
return {
|
const token = new URLSearchParams(location.search).get('token') || '';
|
||||||
userId,
|
const qs = '?token=' + encodeURIComponent(token);
|
||||||
token: new URLSearchParams(location.search).get('token') ?? '',
|
const api = '../api';
|
||||||
state: 'LOADING',
|
|
||||||
files: [],
|
// xterm
|
||||||
events: [],
|
|
||||||
sseConnected: false,
|
|
||||||
filesOpen: true,
|
|
||||||
selectedPath: '',
|
|
||||||
selectedSize: 0,
|
|
||||||
selectedType: '',
|
|
||||||
selectedContent: '',
|
|
||||||
selectedUrl: '',
|
|
||||||
htmlMode: 'preview',
|
|
||||||
term: null,
|
|
||||||
fitAddon: null,
|
|
||||||
seenEvents: new Set(),
|
|
||||||
get sortedFiles() {
|
|
||||||
return [...this.files].sort((a, b) => b.mtime - a.mtime);
|
|
||||||
},
|
|
||||||
async init() {
|
|
||||||
this.initTerminal();
|
|
||||||
await this.loadInfo();
|
|
||||||
await this.loadFiles();
|
|
||||||
this.connectSSE();
|
|
||||||
window.addEventListener('resize', () => this.fitAddon && this.fitAddon.fit());
|
|
||||||
},
|
|
||||||
initTerminal() {
|
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
cursorBlink: false,
|
cursorBlink: false,
|
||||||
disableStdin: true,
|
disableStdin: true,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
fontFamily: '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||||
theme: {
|
theme: {
|
||||||
background: '#0a0e1a',
|
background: '#000000',
|
||||||
foreground: '#e5e7eb',
|
foreground: '#d0d0d0',
|
||||||
cursor: 'transparent',
|
cursor: 'transparent',
|
||||||
black: '#1f2937',
|
black: '#1a1a1a',
|
||||||
red: '#ef4444',
|
red: '#e06c75',
|
||||||
green: '#10b981',
|
green: '#98c379',
|
||||||
yellow: '#f59e0b',
|
yellow: '#e5c07b',
|
||||||
blue: '#3b82f6',
|
blue: '#61afef',
|
||||||
magenta: '#a855f7',
|
magenta: '#c678dd',
|
||||||
cyan: '#06b6d4',
|
cyan: '#56b6c2',
|
||||||
white: '#f3f4f6',
|
white: '#d0d0d0',
|
||||||
brightBlack: '#6b7280',
|
brightBlack: '#5c6370',
|
||||||
},
|
},
|
||||||
scrollback: 5000,
|
scrollback: 5000,
|
||||||
});
|
});
|
||||||
const fitAddon = new FitAddon.FitAddon();
|
const fit = new FitAddon.FitAddon();
|
||||||
term.loadAddon(fitAddon);
|
term.loadAddon(fit);
|
||||||
term.open(document.getElementById('xterm'));
|
term.open(document.getElementById('term'));
|
||||||
fitAddon.fit();
|
fit.fit();
|
||||||
term.writeln('\x1b[90m# lobe-sandbox terminal — READ-ONLY view, AI 操作实时镜像\x1b[0m');
|
window.addEventListener('resize', () => fit.fit());
|
||||||
term.writeln('\x1b[90m# 你在 ai.milejoy.com 聊,AI 调工具的每一条命令都会显示在这里\x1b[0m');
|
|
||||||
term.writeln('');
|
const HOST = 'sb-' + userId.slice(0,12);
|
||||||
this.term = term;
|
const PROMPT = '\x1b[32msandbox@' + HOST + '\x1b[0m:\x1b[34m~\x1b[0m$ ';
|
||||||
this.fitAddon = fitAddon;
|
term.write(PROMPT);
|
||||||
},
|
|
||||||
renderEventToTerminal(e) {
|
// 文件 modal
|
||||||
if (!this.term) return;
|
const modal = document.getElementById('modal');
|
||||||
const t = this.term;
|
const modalpath = document.getElementById('modalpath');
|
||||||
const ts = '\x1b[90m[' + new Date(e.ts).toTimeString().slice(0,8) + ']\x1b[0m';
|
const modalbody = document.getElementById('modalbody');
|
||||||
const statusIcon = e.success ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
|
window.closeModal = () => modal.classList.remove('on');
|
||||||
const p = e.params || {};
|
|
||||||
switch (e.toolName) {
|
async function openFile(p) {
|
||||||
case 'executeCode': {
|
modalpath.textContent = p;
|
||||||
const lang = p.language || 'python';
|
modalbody.innerHTML = '<div style="color:#6b7280">loading...</div>';
|
||||||
const code = String(p.code || '');
|
modal.classList.add('on');
|
||||||
const tmp = '/tmp/sb.' + (lang === 'python' ? 'py' : lang === 'typescript' ? 'ts' : 'js');
|
const ext = p.slice(p.lastIndexOf('.')).toLowerCase();
|
||||||
t.writeln(ts + ' \x1b[32m$\x1b[0m \x1b[36mcat >\x1b[0m ' + tmp + ' \x1b[90m<< EOF\x1b[0m');
|
const url = api + '/file?userId=' + encodeURIComponent(userId) + '&path=' + encodeURIComponent(p) + '&token=' + encodeURIComponent(token);
|
||||||
code.split('\n').slice(0, 40).forEach(l => t.writeln(' \x1b[37m' + l.replace(/\x1b/g, '') + '\x1b[0m'));
|
if (['.png','.jpg','.jpeg','.gif','.webp','.svg'].includes(ext)) {
|
||||||
if (code.split('\n').length > 40) t.writeln(' \x1b[90m...(' + (code.split('\n').length - 40) + ' 行省略)\x1b[0m');
|
modalbody.innerHTML = '<img src="' + url + '"/>';
|
||||||
t.writeln(' \x1b[90mEOF\x1b[0m');
|
|
||||||
const runner = lang === 'python' ? 'python3' : lang === 'typescript' ? 'bun run' : 'node';
|
|
||||||
t.writeln(ts + ' \x1b[32m$\x1b[0m \x1b[36m' + runner + '\x1b[0m ' + tmp);
|
|
||||||
const r = e.result || {};
|
|
||||||
if (r.stdout) r.stdout.split('\n').slice(0, 50).forEach(l => t.writeln(l));
|
|
||||||
if (r.stderr) r.stderr.split('\n').slice(0, 20).forEach(l => t.writeln('\x1b[31m' + l + '\x1b[0m'));
|
|
||||||
t.writeln(ts + ' ' + statusIcon + ' \x1b[90mexitCode=' + (r.exitCode ?? '?') + ', ' + e.durationMs + 'ms\x1b[0m');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'runCommand': {
|
|
||||||
const cmd = String(p.command || '');
|
|
||||||
t.writeln(ts + ' \x1b[32m$\x1b[0m ' + cmd);
|
|
||||||
const r = e.result || {};
|
|
||||||
if (r.stdout) r.stdout.split('\n').slice(0, 50).forEach(l => t.writeln(l));
|
|
||||||
if (r.stderr) r.stderr.split('\n').slice(0, 20).forEach(l => t.writeln('\x1b[31m' + l + '\x1b[0m'));
|
|
||||||
t.writeln(ts + ' ' + statusIcon + ' \x1b[90mexitCode=' + (r.exitCode ?? '?') + ', ' + e.durationMs + 'ms\x1b[0m');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'writeLocalFile': {
|
|
||||||
const path = p.filePath || '?';
|
|
||||||
const content = String(p.content || '');
|
|
||||||
t.writeln(ts + ' \x1b[32m$\x1b[0m \x1b[36mcat >\x1b[0m ' + path + ' \x1b[90m<< EOF\x1b[0m');
|
|
||||||
content.split('\n').slice(0, 20).forEach(l => t.writeln(' ' + l.replace(/\x1b/g, '')));
|
|
||||||
if (content.split('\n').length > 20) t.writeln(' \x1b[90m...(' + (content.split('\n').length - 20) + ' 行省略)\x1b[0m');
|
|
||||||
t.writeln(' \x1b[90mEOF\x1b[0m');
|
|
||||||
t.writeln(ts + ' ' + statusIcon + ' \x1b[90m' + (content.length) + ' bytes, ' + e.durationMs + 'ms\x1b[0m');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'editLocalFile':
|
|
||||||
t.writeln(ts + ' \x1b[32m$\x1b[0m \x1b[36msed -i\x1b[0m ' + (p.filePath || '?'));
|
|
||||||
t.writeln(ts + ' ' + statusIcon + ' \x1b[90m' + (e.summary || '') + ', ' + e.durationMs + 'ms\x1b[0m');
|
|
||||||
break;
|
|
||||||
case 'readLocalFile':
|
|
||||||
t.writeln(ts + ' \x1b[32m$\x1b[0m \x1b[36mcat\x1b[0m ' + (p.filePath || '?'));
|
|
||||||
t.writeln(ts + ' ' + statusIcon + ' \x1b[90m' + (e.summary || '') + ', ' + e.durationMs + 'ms\x1b[0m');
|
|
||||||
break;
|
|
||||||
case 'listLocalFiles':
|
|
||||||
t.writeln(ts + ' \x1b[32m$\x1b[0m \x1b[36mls -la\x1b[0m ' + (p.directoryPath || '?'));
|
|
||||||
if (e.success && e.result && e.result.output) {
|
|
||||||
e.result.output.split('\n').slice(0, 30).forEach(l => t.writeln(l));
|
|
||||||
}
|
|
||||||
t.writeln(ts + ' ' + statusIcon + ' \x1b[90m' + e.durationMs + 'ms\x1b[0m');
|
|
||||||
break;
|
|
||||||
case 'grepContent':
|
|
||||||
t.writeln(ts + ' \x1b[32m$\x1b[0m \x1b[36mrg -n\x1b[0m ' + (p.pattern || '') + ' ' + (p.directoryPath || ''));
|
|
||||||
t.writeln(ts + ' ' + statusIcon + ' \x1b[90m' + (e.summary || '') + ', ' + e.durationMs + 'ms\x1b[0m');
|
|
||||||
break;
|
|
||||||
case 'globLocalFiles':
|
|
||||||
t.writeln(ts + ' \x1b[32m$\x1b[0m \x1b[36mfind\x1b[0m ' + (p.cwd || '.') + ' -name ' + JSON.stringify(p.pattern || ''));
|
|
||||||
t.writeln(ts + ' ' + statusIcon + ' \x1b[90m' + (e.summary || '') + ', ' + e.durationMs + 'ms\x1b[0m');
|
|
||||||
break;
|
|
||||||
case 'moveLocalFiles':
|
|
||||||
t.writeln(ts + ' \x1b[32m$\x1b[0m \x1b[36mmv\x1b[0m ' + ((p.sources||[]).join(' ')) + ' ' + (p.destination || ''));
|
|
||||||
t.writeln(ts + ' ' + statusIcon + ' \x1b[90m' + e.durationMs + 'ms\x1b[0m');
|
|
||||||
break;
|
|
||||||
case 'renameLocalFile':
|
|
||||||
t.writeln(ts + ' \x1b[32m$\x1b[0m \x1b[36mmv\x1b[0m ' + (p.oldPath || '') + ' ' + (p.newPath || ''));
|
|
||||||
t.writeln(ts + ' ' + statusIcon + ' \x1b[90m' + e.durationMs + 'ms\x1b[0m');
|
|
||||||
break;
|
|
||||||
case 'searchLocalFiles':
|
|
||||||
t.writeln(ts + ' \x1b[32m$\x1b[0m \x1b[36mfd\x1b[0m ' + (p.pattern || '') + ' ' + (p.directoryPath || ''));
|
|
||||||
t.writeln(ts + ' ' + statusIcon + ' \x1b[90m' + (e.summary || '') + ', ' + e.durationMs + 'ms\x1b[0m');
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
t.writeln(ts + ' \x1b[35m# ' + e.toolName + '\x1b[0m \x1b[90m' + (e.summary || '') + '\x1b[0m');
|
|
||||||
}
|
|
||||||
t.writeln('');
|
|
||||||
t.scrollToBottom();
|
|
||||||
},
|
|
||||||
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.seenEvents.has(e.id)) return;
|
|
||||||
this.seenEvents.add(e.id);
|
|
||||||
this.events.push(e);
|
|
||||||
this.renderEventToTerminal(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 isHtml = ['.html', '.htm'].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;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const r = await fetch(url);
|
const r = await fetch(url);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.selectedType = 'error';
|
modalbody.innerHTML = '<div style="color:#ef4444">加载失败: ' + r.status + '</div>';
|
||||||
this.selectedContent = '加载失败: ' + await r.text();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = await r.text();
|
const content = await r.text();
|
||||||
this.selectedContent = content;
|
if (['.html','.htm'].includes(ext)) {
|
||||||
if (isHtml) {
|
modalbody.innerHTML = '';
|
||||||
this.selectedType = 'html';
|
const iframe = document.createElement('iframe');
|
||||||
this.htmlMode = 'preview';
|
iframe.srcdoc = content;
|
||||||
|
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-pointer-lock allow-popups allow-modals');
|
||||||
|
modalbody.appendChild(iframe);
|
||||||
} else {
|
} else {
|
||||||
this.selectedType = 'text';
|
const pre = document.createElement('pre');
|
||||||
}
|
pre.textContent = content;
|
||||||
},
|
modalbody.innerHTML = '';
|
||||||
iconFor(path) {
|
modalbody.appendChild(pre);
|
||||||
const ext = path.slice(path.lastIndexOf('.')).toLowerCase();
|
|
||||||
return ({
|
|
||||||
'.py': '🐍', '.js': '📜', '.ts': '📜',
|
|
||||||
'.json': '{}', '.md': '📝', '.txt': '📄',
|
|
||||||
'.csv': '📊', '.tsv': '📊', '.xlsx': '📊',
|
|
||||||
'.png': '🖼', '.jpg': '🖼', '.jpeg': '🖼', '.gif': '🖼', '.webp': '🖼',
|
|
||||||
'.html': '🎮', '.htm': '🎮', '.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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 文件列表下拉
|
||||||
|
const filebtn = document.getElementById('filebtn');
|
||||||
|
const filelist = document.getElementById('filelist');
|
||||||
|
let files = [];
|
||||||
|
async function loadFiles() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(api + '/files?userId=' + encodeURIComponent(userId) + '&token=' + encodeURIComponent(token));
|
||||||
|
const d = await r.json();
|
||||||
|
files = (d.files || []).sort((a,b) => b.mtime - a.mtime);
|
||||||
|
renderFileList();
|
||||||
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
function renderFileList() {
|
||||||
|
filelist.innerHTML = files.length === 0
|
||||||
|
? '<div class="item" style="color:#6b7280">no files yet</div>'
|
||||||
|
: files.slice(0, 50).map(f => {
|
||||||
|
const short = f.path.replace(/^\/workspace\//, '');
|
||||||
|
const sz = f.size > 1e6 ? (f.size/1e6).toFixed(1)+'M' : f.size > 1e3 ? (f.size/1e3).toFixed(1)+'K' : f.size+'B';
|
||||||
|
return '<div class="item" data-path="' + f.path + '"><span>' + short + '</span><span class="size">' + sz + '</span></div>';
|
||||||
|
}).join('');
|
||||||
|
filelist.querySelectorAll('.item[data-path]').forEach(el => {
|
||||||
|
el.onclick = () => { openFile(el.getAttribute('data-path')); filelist.classList.remove('on'); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
filebtn.onclick = () => {
|
||||||
|
filelist.classList.toggle('on');
|
||||||
|
if (filelist.classList.contains('on')) loadFiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 事件 → 终端输出,模仿真 shell 会话
|
||||||
|
const seen = new Set();
|
||||||
|
let reloadTimer;
|
||||||
|
function esc(s) { return String(s ?? '').replace(/\x1b/g, ''); }
|
||||||
|
function writeBlock(cmd, outStdout, outStderr, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
// 擦除当前 prompt 行末尾光标,把命令打出来
|
||||||
|
term.write(cmd + '\r\n');
|
||||||
|
if (outStdout) {
|
||||||
|
const lines = outStdout.split('\n');
|
||||||
|
lines.forEach((l, i) => {
|
||||||
|
if (i === lines.length - 1 && l === '') return;
|
||||||
|
term.writeln(esc(l));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (outStderr) {
|
||||||
|
outStderr.split('\n').forEach(l => {
|
||||||
|
if (l) term.writeln('\x1b[31m' + esc(l) + '\x1b[0m');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
term.write(PROMPT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEvent(e) {
|
||||||
|
if (seen.has(e.id)) return;
|
||||||
|
seen.add(e.id);
|
||||||
|
const p = e.params || {};
|
||||||
|
const r = e.result || {};
|
||||||
|
switch (e.toolName) {
|
||||||
|
case 'executeCode': {
|
||||||
|
const lang = p.language || 'python';
|
||||||
|
const code = String(p.code || '');
|
||||||
|
const codeInline = code.replace(/\n/g, '\\n');
|
||||||
|
const runner = lang === 'python' ? 'python3 -c' : lang === 'typescript' ? 'bun -e' : 'node -e';
|
||||||
|
writeBlock(runner + " \x1b[90m'" + codeInline.slice(0, 200) + (codeInline.length > 200 ? '...' : '') + "'\x1b[0m", r.stdout, r.stderr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'runCommand':
|
||||||
|
writeBlock(String(p.command || ''), r.stdout, r.stderr);
|
||||||
|
break;
|
||||||
|
case 'writeLocalFile': {
|
||||||
|
const path = p.filePath || '?';
|
||||||
|
const len = (p.content || '').length;
|
||||||
|
writeBlock("\x1b[36mcat > " + path + "\x1b[0m \x1b[90m# " + len + " bytes\x1b[0m", '', '');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'editLocalFile':
|
||||||
|
writeBlock("\x1b[36msed -i ...\x1b[0m " + (p.filePath || ''), '', '');
|
||||||
|
break;
|
||||||
|
case 'readLocalFile':
|
||||||
|
writeBlock("cat " + (p.filePath || ''), r.content ? r.content.slice(0, 1000) : '', '');
|
||||||
|
break;
|
||||||
|
case 'listLocalFiles':
|
||||||
|
writeBlock("ls -la " + (p.directoryPath || ''), r.output || '', '');
|
||||||
|
break;
|
||||||
|
case 'grepContent':
|
||||||
|
writeBlock("rg -n '" + (p.pattern || '') + "' " + (p.directoryPath || ''), (r.matches || '').toString().slice(0, 500), '');
|
||||||
|
break;
|
||||||
|
case 'globLocalFiles':
|
||||||
|
writeBlock("find . -name '" + (p.pattern || '') + "'", (r.matches || []).join('\n'), '');
|
||||||
|
break;
|
||||||
|
case 'moveLocalFiles':
|
||||||
|
writeBlock("mv " + (p.sources || []).join(' ') + ' ' + (p.destination || ''), '', '');
|
||||||
|
break;
|
||||||
|
case 'renameLocalFile':
|
||||||
|
writeBlock("mv " + (p.oldPath || '') + ' ' + (p.newPath || ''), '', '');
|
||||||
|
break;
|
||||||
|
case 'searchLocalFiles':
|
||||||
|
writeBlock("fd '" + (p.pattern || '') + "'", (r.matches || []).join('\n'), '');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
writeBlock("\x1b[35m# " + e.toolName + "\x1b[0m", e.summary || '', '');
|
||||||
|
}
|
||||||
|
clearTimeout(reloadTimer);
|
||||||
|
reloadTimer = setTimeout(loadFiles, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE
|
||||||
|
const ind = document.getElementById('ind');
|
||||||
|
function connect() {
|
||||||
|
const es = new EventSource(api + '/events?userId=' + encodeURIComponent(userId) + '&token=' + encodeURIComponent(token));
|
||||||
|
es.addEventListener('open', () => { ind.className = ''; ind.textContent = '⬤ live'; });
|
||||||
|
es.addEventListener('error', () => { ind.className = 'off'; ind.textContent = '⬤ offline'; });
|
||||||
|
es.addEventListener('event', (ev) => {
|
||||||
|
try { renderEvent(JSON.parse(ev.data)); } catch {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
// 初始加载一次文件列表
|
||||||
|
loadFiles();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user