auto-save 2026-04-20 00:20 (~2)
This commit is contained in:
@@ -419,6 +419,13 @@
|
||||
"message": "auto-save 2026-04-20 00:09 (~1)",
|
||||
"hash": "865315f",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-04-20T00:15:03+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-04-20 00:14 (~1)",
|
||||
"hash": "de7a9ec",
|
||||
"files_changed": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -80,23 +80,24 @@ export const USER_VIEW_HTML = String.raw`<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Lobe Sandbox · 计算机视图</title>
|
||||
<title>Lobe Sandbox · Terminal</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" />
|
||||
<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>
|
||||
<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; }
|
||||
#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; }
|
||||
</style>
|
||||
</head>
|
||||
<body x-data="userView()" x-init="init()" class="h-screen flex flex-col overflow-hidden">
|
||||
@@ -118,10 +119,28 @@ export const USER_VIEW_HTML = String.raw`<!DOCTYPE html>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 grid grid-cols-[320px_1fr_1fr] overflow-hidden">
|
||||
<!-- 主体:左侧终端(大)+ 右侧文件(可折叠)-->
|
||||
<div class="flex-1 grid overflow-hidden" :style="filesOpen ? 'grid-template-columns:1fr 280px' : 'grid-template-columns:1fr 0'">
|
||||
|
||||
<!-- 文件树 -->
|
||||
<aside class="card border-y-0 border-l-0 overflow-y-auto scrollbar">
|
||||
<!-- 终端 -->
|
||||
<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>
|
||||
@@ -134,33 +153,35 @@ export const USER_VIEW_HTML = String.raw`<!DOCTYPE html>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- 中间:文件预览 -->
|
||||
<section class="card border-y-0 overflow-hidden flex flex-col">
|
||||
<!-- 文件预览模态 -->
|
||||
<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 ? '📄 ' + selectedPath : '📄 选文件查看'"></span>
|
||||
<span x-show="selectedPath" class="text-gray-600" x-text="fmtBytes(selectedSize)"></span>
|
||||
<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 bg-[#0a0e1a]">
|
||||
<template x-if="!selectedPath">
|
||||
<div class="p-10 text-center text-gray-600 text-sm">← 点左边的文件</div>
|
||||
</template>
|
||||
<div class="flex-1 overflow-auto scrollbar">
|
||||
<template x-if="selectedType === 'image'">
|
||||
<div class="p-4"><img :src="selectedUrl" class="max-w-full rounded shadow-lg"/></div>
|
||||
<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 items-center">
|
||||
<span class="text-gray-500">🎮 HTML 实时预览(沙盒 iframe,JS 可跑)</span>
|
||||
<div class="flex gap-2">
|
||||
<button @click="htmlMode='preview'" :class="htmlMode==='preview'?'text-emerald-400':'text-gray-500'" class="hover:text-white">▶ 玩</button>
|
||||
<button @click="htmlMode='source'" :class="htmlMode==='source'?'text-emerald-400':'text-gray-500'" class="hover:text-white"><> 源码</button>
|
||||
<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>
|
||||
@@ -175,33 +196,7 @@ export const USER_VIEW_HTML = String.raw`<!DOCTYPE html>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -214,22 +209,142 @@ function userView() {
|
||||
token: new URLSearchParams(location.search).get('token') ?? '',
|
||||
state: 'LOADING',
|
||||
files: [],
|
||||
loadingFiles: false,
|
||||
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({
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
fontSize: 13,
|
||||
fontFamily: '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
theme: {
|
||||
background: '#0a0e1a',
|
||||
foreground: '#e5e7eb',
|
||||
cursor: 'transparent',
|
||||
black: '#1f2937',
|
||||
red: '#ef4444',
|
||||
green: '#10b981',
|
||||
yellow: '#f59e0b',
|
||||
blue: '#3b82f6',
|
||||
magenta: '#a855f7',
|
||||
cyan: '#06b6d4',
|
||||
white: '#f3f4f6',
|
||||
brightBlack: '#6b7280',
|
||||
},
|
||||
scrollback: 5000,
|
||||
});
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById('xterm'));
|
||||
fitAddon.fit();
|
||||
term.writeln('\x1b[90m# lobe-sandbox terminal — READ-ONLY view, AI 操作实时镜像\x1b[0m');
|
||||
term.writeln('\x1b[90m# 你在 ai.milejoy.com 聊,AI 调工具的每一条命令都会显示在这里\x1b[0m');
|
||||
term.writeln('');
|
||||
this.term = term;
|
||||
this.fitAddon = fitAddon;
|
||||
},
|
||||
renderEventToTerminal(e) {
|
||||
if (!this.term) return;
|
||||
const t = this.term;
|
||||
const ts = '\x1b[90m[' + new Date(e.ts).toTimeString().slice(0,8) + ']\x1b[0m';
|
||||
const statusIcon = e.success ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
|
||||
const p = e.params || {};
|
||||
switch (e.toolName) {
|
||||
case 'executeCode': {
|
||||
const lang = p.language || 'python';
|
||||
const code = String(p.code || '');
|
||||
const tmp = '/tmp/sb.' + (lang === 'python' ? 'py' : lang === 'typescript' ? 'ts' : 'js');
|
||||
t.writeln(ts + ' \x1b[32m$\x1b[0m \x1b[36mcat >\x1b[0m ' + tmp + ' \x1b[90m<< EOF\x1b[0m');
|
||||
code.split('\n').slice(0, 40).forEach(l => t.writeln(' \x1b[37m' + l.replace(/\x1b/g, '') + '\x1b[0m'));
|
||||
if (code.split('\n').length > 40) t.writeln(' \x1b[90m...(' + (code.split('\n').length - 40) + ' 行省略)\x1b[0m');
|
||||
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));
|
||||
@@ -260,12 +375,12 @@ function userView() {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user