{{ error }}
+ +import { ref, watch, computed } from 'vue' + +const props = defineProps<{ projectId: string }>() + +const BASE = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/api` + +interface FileEntry { + name: string + is_dir: boolean + size: number +} + +const cwd = ref([]) +const entries = ref([]) +const loading = ref(false) +const error = ref('') +const renamingItem = ref('') +const renameValue = ref('') +const mkdirMode = ref(false) +const mkdirName = ref('') +const uploading = ref(false) +const uploadProgress = ref(0) // 0-100 +const uploadSpeed = ref('') // e.g. "2.3 MB/s" +const uploadEta = ref('') // e.g. "12s" +const fileInputRef = ref(null) + +const cwdPath = computed(() => cwd.value.join('/')) + +const breadcrumbs = computed(() => { + const parts = [{ name: 'workspace', path: '' }] + let acc = '' + for (const p of cwd.value) { + acc = acc ? `${acc}/${p}` : p + parts.push({ name: p, path: acc }) + } + return parts +}) + +async function load() { + loading.value = true + error.value = '' + try { + const path = cwdPath.value + const url = path + ? `${BASE}/projects/${props.projectId}/files/${path}` + : `${BASE}/projects/${props.projectId}/files` + const res = await fetch(url, { credentials: 'same-origin' }) + if (!res.ok) throw new Error(`${res.status}`) + entries.value = await res.json() + } catch (e: any) { + error.value = e.message + entries.value = [] + } finally { + loading.value = false + } +} + +watch(() => props.projectId, () => { cwd.value = []; load() }, { immediate: true }) + +function enter(name: string) { + cwd.value = [...cwd.value, name] + load() +} + +function goTo(path: string) { + cwd.value = path ? path.split('/') : [] + load() +} + +function formatSize(bytes: number) { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +function downloadUrl(name: string) { + const path = cwdPath.value ? `${cwdPath.value}/${name}` : name + return `${BASE}/projects/${props.projectId}/files/${path}` +} + +function formatSpeed(bytesPerSec: number): string { + if (bytesPerSec < 1024) return `${bytesPerSec.toFixed(0)} B/s` + if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s` + return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s` +} + +function formatEta(secs: number): string { + if (secs < 60) return `${Math.ceil(secs)}s` + if (secs < 3600) return `${Math.floor(secs / 60)}m ${Math.ceil(secs % 60)}s` + return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m` +} + +function uploadFiles(fileList: FileList) { + uploading.value = true + uploadProgress.value = 0 + uploadSpeed.value = '' + uploadEta.value = '' + error.value = '' + + const form = new FormData() + for (const f of fileList) { + form.append('files', f, f.name) + } + const path = cwdPath.value + const url = path + ? `${BASE}/projects/${props.projectId}/files/${path}` + : `${BASE}/projects/${props.projectId}/files` + + const xhr = new XMLHttpRequest() + const startTime = Date.now() + + xhr.upload.addEventListener('progress', (ev) => { + if (ev.lengthComputable && ev.total > 0) { + uploadProgress.value = Math.round((ev.loaded / ev.total) * 100) + const elapsed = (Date.now() - startTime) / 1000 + if (elapsed > 0.3) { + const bps = ev.loaded / elapsed + uploadSpeed.value = formatSpeed(bps) + const remaining = ev.total - ev.loaded + uploadEta.value = bps > 0 ? formatEta(remaining / bps) : '' + } + } + }) + + xhr.addEventListener('load', () => { + uploading.value = false + if (xhr.status >= 200 && xhr.status < 300) { + load() + } else { + error.value = xhr.responseText || `Upload failed (${xhr.status})` + } + }) + + xhr.addEventListener('error', () => { + uploading.value = false + error.value = 'Upload failed (network error)' + }) + + xhr.open('POST', url) + xhr.withCredentials = true + xhr.send(form) +} + +function onFileInput(ev: Event) { + const input = ev.target as HTMLInputElement + if (input.files && input.files.length > 0) { + uploadFiles(input.files) + input.value = '' + } +} + +async function onRename(oldName: string) { + if (!renameValue.value.trim() || renameValue.value === oldName) { + renamingItem.value = '' + return + } + error.value = '' + try { + const path = cwdPath.value ? `${cwdPath.value}/${oldName}` : oldName + const res = await fetch(`${BASE}/projects/${props.projectId}/files/${path}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ new_name: renameValue.value.trim() }), + credentials: 'same-origin', + }) + if (!res.ok) throw new Error(await res.text()) + renamingItem.value = '' + load() + } catch (e: any) { + error.value = e.message + } +} + +async function onDelete(name: string, isDir: boolean) { + const label = isDir ? 'folder' : 'file' + if (!confirm(`Delete ${label} "${name}"?`)) return + error.value = '' + try { + const path = cwdPath.value ? `${cwdPath.value}/${name}` : name + const res = await fetch(`${BASE}/projects/${props.projectId}/files/${path}`, { + method: 'DELETE', + credentials: 'same-origin', + }) + if (!res.ok) throw new Error(await res.text()) + load() + } catch (e: any) { + error.value = e.message + } +} + +async function onMkdir() { + const name = mkdirName.value.trim() + if (!name) { mkdirMode.value = false; return } + error.value = '' + try { + const path = cwdPath.value + const url = path + ? `${BASE}/projects/${props.projectId}/files/${path}` + : `${BASE}/projects/${props.projectId}/files` + const res = await fetch(url, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + credentials: 'same-origin', + }) + if (!res.ok) throw new Error(await res.text()) + mkdirMode.value = false + mkdirName.value = '' + load() + } catch (e: any) { + error.value = e.message + } +} + +function startRename(name: string) { + renamingItem.value = name + renameValue.value = name +} + +function onDrop(ev: DragEvent) { + ev.preventDefault() + if (ev.dataTransfer?.files && ev.dataTransfer.files.length > 0) { + uploadFiles(ev.dataTransfer.files) + } +} + + + + + diff --git a/web/src/components/LoginPage.vue b/web/src/components/LoginPage.vue new file mode 100644 index 0000000..d2a7c01 --- /dev/null +++ b/web/src/components/LoginPage.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/web/src/components/WorkersView.vue b/web/src/components/WorkersView.vue new file mode 100644 index 0000000..24d86fb --- /dev/null +++ b/web/src/components/WorkersView.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/web/src/components/WorkflowView.vue b/web/src/components/WorkflowView.vue index c877d34..d862ba8 100644 --- a/web/src/components/WorkflowView.vue +++ b/web/src/components/WorkflowView.vue @@ -5,6 +5,7 @@ import PlanSection from './PlanSection.vue' import ExecutionSection from './ExecutionSection.vue' import CommentSection from './CommentSection.vue' import TimerSection from './TimerSection.vue' +import FileBrowser from './FileBrowser.vue' import { api } from '../api' import { connectWs } from '../ws' import type { Workflow, ExecutionLogEntry, PlanStepInfo, Comment, LlmCallLogEntry } from '../types' @@ -26,7 +27,7 @@ const llmCalls = ref([]) const quotes = ref([]) const currentActivity = ref('') const error = ref('') -const rightTab = ref<'log' | 'timers'>('log') +const rightTab = ref<'log' | 'timers' | 'files'>('log') const commentRef = ref | null>(null) function addQuote(text: string) { @@ -184,6 +185,7 @@ async function onSubmitComment(text: string) {
+
+ str: + """Get CPU model name.""" + try: + with open("/proc/cpuinfo") as f: + for line in f: + if line.startswith("model name"): + return line.split(":", 1)[1].strip() + except Exception: + pass + return platform.processor() or platform.machine() + + +def get_memory_info() -> str: + """Get total memory.""" + try: + with open("/proc/meminfo") as f: + for line in f: + if line.startswith("MemTotal"): + kb = int(line.split()[1]) + gb = kb / (1024 * 1024) + return f"{gb:.1f} GB" + except Exception: + pass + return "unknown" + + +def get_gpu_info() -> str: + """Get GPU info via nvidia-smi if available.""" + nvidia_smi = shutil.which("nvidia-smi") + if nvidia_smi: + try: + out = subprocess.check_output( + [nvidia_smi, "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"], + timeout=5, text=True + ).strip() + gpus = [] + for line in out.splitlines(): + parts = [p.strip() for p in line.split(",")] + if len(parts) >= 2: + gpus.append(f"{parts[0]} ({parts[1]} MiB)") + else: + gpus.append(parts[0]) + return "; ".join(gpus) + except Exception: + pass + return "none" + + +def get_worker_info(name: str) -> dict: + return { + "name": name, + "cpu": get_cpu_info(), + "memory": get_memory_info(), + "gpu": get_gpu_info(), + "os": f"{platform.system()} {platform.release()}", + "kernel": platform.release(), + } + + +async def execute_script(script: str, timeout: int = 300) -> dict: + """Execute a bash script and return result. + + If the script starts with a Python shebang or `# /// script` (uv inline metadata), + it's written as .py and run via `uv run --script`. Otherwise it's run as bash. + """ + is_python = script.lstrip().startswith(("#!/usr/bin/env python", "# /// script", "#!/usr/bin/python", "import ", "from ")) + suffix = ".py" if is_python else ".sh" + + with tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) as f: + f.write(script) + f.flush() + script_path = f.name + + try: + if is_python: + cmd = ["uv", "run", "--script", script_path] + else: + cmd = ["bash", script_path] + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return { + "job_id": "", + "exit_code": -1, + "stdout": "", + "stderr": f"Script timed out after {timeout}s", + } + + return { + "job_id": "", + "exit_code": proc.returncode, + "stdout": stdout.decode(errors="replace"), + "stderr": stderr.decode(errors="replace"), + } + finally: + os.unlink(script_path) + + +async def run_worker(server_url: str, name: str): + info = get_worker_info(name) + print(f"Worker info: {json.dumps(info, indent=2)}") + + while True: + try: + print(f"Connecting to {server_url} ...") + async with websockets.connect(server_url) as ws: + # Register + reg_msg = json.dumps({"type": "register", "info": info}) + await ws.send(reg_msg) + + ack = json.loads(await ws.recv()) + if ack.get("type") == "registered": + print(f"Registered as '{ack.get('name')}'") + else: + print(f"Unexpected ack: {ack}") + await asyncio.sleep(5) + continue + + # Main loop: receive jobs, execute, send results + async for message in ws: + msg = json.loads(message) + if msg.get("type") == "execute": + job_id = msg["job_id"] + script = msg["script"] + print(f"Executing job {job_id}: {script[:80]}...") + + result = await execute_script(script) + result["job_id"] = job_id + result["type"] = "result" + + await ws.send(json.dumps(result)) + print(f"Job {job_id} done (exit={result['exit_code']})") + + except (websockets.exceptions.ConnectionClosed, ConnectionRefusedError, OSError) as e: + print(f"Connection lost ({e}), reconnecting in 5s...") + await asyncio.sleep(5) + except Exception as e: + print(f"Error: {e}") + await asyncio.sleep(5) + + +def main(): + parser = argparse.ArgumentParser(description="Tori Worker") + parser.add_argument("--server", default="wss://tori.euphon.cloud/ws/tori/workers", + help="WebSocket server URL") + parser.add_argument("--name", default=platform.node(), + help="Worker name (default: hostname)") + args = parser.parse_args() + + print(f"Starting tori-worker '{args.name}' -> {args.server}") + asyncio.run(run_worker(args.server, args.name)) + + +if __name__ == "__main__": + main() diff --git a/worker/tori-worker.service b/worker/tori-worker.service new file mode 100644 index 0000000..de97b58 --- /dev/null +++ b/worker/tori-worker.service @@ -0,0 +1,14 @@ +[Unit] +Description=Tori Worker +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/uv run --script %h/tori-worker/tori-worker.py --server wss://tori.euphon.cloud/ws/tori/workers +Restart=always +RestartSec=5 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=default.target