auto-save 2026-04-20 00:20 (~2)

This commit is contained in:
2026-04-20 00:20:34 +08:00
parent de7a9eced1
commit 01b09bc7ab
2 changed files with 183 additions and 61 deletions

View File

@@ -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
}
]
}

View File

@@ -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">&lt;&gt; 源码</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'">&lt;&gt; 源码</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);