feat: add Google OAuth, remote worker system, and file browser

- Google OAuth login with JWT session cookies, per-user project isolation
- Remote worker registration via WebSocket, execute_on_worker/list_workers agent tools
- File browser UI in workflow view, file upload/download API
- Deploy script switched to local build, added tori.euphon.cloud ingress
This commit is contained in:
2026-03-17 01:57:57 +00:00
parent 186d882f35
commit 63f0582f54
26 changed files with 2338 additions and 106 deletions

View File

@@ -1,7 +1,46 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import AppLayout from './components/AppLayout.vue'
import LoginPage from './components/LoginPage.vue'
import { auth, type AuthUser } from './api'
const authed = ref<boolean | null>(null)
const user = ref<AuthUser | null>(null)
onMounted(async () => {
try {
user.value = await auth.me()
authed.value = true
} catch {
authed.value = false
}
})
async function onLogout() {
await auth.logout()
authed.value = false
user.value = null
}
</script>
<template>
<AppLayout />
<div v-if="authed === null" class="loading">
<span class="loading-text">Loading...</span>
</div>
<LoginPage v-else-if="!authed" />
<AppLayout v-else :user="user!" @logout="onLogout" />
</template>
<style scoped>
.loading {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.loading-text {
color: var(--text-secondary);
font-size: 14px;
}
</style>

View File

@@ -5,8 +5,16 @@ const BASE = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/api`
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
...options,
})
if (res.status === 401) {
const basePath = import.meta.env.BASE_URL.replace(/\/$/, '')
if (!window.location.pathname.endsWith('/login')) {
window.location.href = `${basePath}/login`
}
throw new Error('Not authenticated')
}
if (!res.ok) {
const text = await res.text()
throw new Error(`API error ${res.status}: ${text}`)
@@ -112,6 +120,8 @@ export const api = {
body: JSON.stringify({ messages }),
}),
listWorkers: () => request<WorkerInfo[]>('/workers'),
getSettings: () => request<Record<string, string>>('/settings'),
putSetting: (key: string, value: string) =>
@@ -120,3 +130,33 @@ export const api = {
body: JSON.stringify({ value }),
}),
}
export interface WorkerInfo {
name: string
cpu: string
memory: string
gpu: string
os: string
kernel: string
}
export interface AuthUser {
id: string
email: string
name: string
picture: string
}
const AUTH_BASE = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/api/auth`
export const auth = {
me: async (): Promise<AuthUser> => {
const res = await fetch(`${AUTH_BASE}/me`, { credentials: 'same-origin' })
if (!res.ok) throw new Error('Not authenticated')
return res.json()
},
logout: async (): Promise<void> => {
await fetch(`${AUTH_BASE}/logout`, { method: 'POST', credentials: 'same-origin' })
},
loginUrl: `${AUTH_BASE}/login`,
}

View File

@@ -7,9 +7,13 @@ import ReportView from './ReportView.vue'
import CreateForm from './CreateForm.vue'
import KbEditor from './KbEditor.vue'
import ObjBrowser from './ObjBrowser.vue'
import { api } from '../api'
import WorkersView from './WorkersView.vue'
import { api, type AuthUser } from '../api'
import type { Project, KbArticleSummary } from '../types'
const props = defineProps<{ user: AuthUser }>()
const emit = defineEmits<{ logout: [] }>()
const projects = ref<Project[]>([])
const selectedProjectId = ref('')
const reportWorkflowId = ref('')
@@ -17,11 +21,13 @@ const error = ref('')
const creating = ref(false)
const showKb = ref(false)
const showObj = ref(false)
const showWorkers = ref(false)
const kbArticles = ref<KbArticleSummary[]>([])
const selectedArticleId = ref('')
const appTitle = ref('')
const chatOpen = ref(false)
const showSettings = ref(false)
const showUserMenu = ref(false)
const editingTitle = ref(false)
const titleInput = ref('')
@@ -32,6 +38,7 @@ const isReportPage = computed(() => !!reportWorkflowId.value)
const currentPageTitle = computed(() => {
if (showKb.value) return 'Knowledge Base'
if (showObj.value) return 'Object Storage'
if (showWorkers.value) return 'Workers'
if (selectedProjectId.value) {
const p = projects.value.find(p => p.id === selectedProjectId.value)
return p?.name || ''
@@ -52,28 +59,32 @@ function onSaveTitle() {
editingTitle.value = false
}
function parseUrl(): { projectId: string; reportId: string; kb: boolean; obj: boolean } {
function parseUrl(): { projectId: string; reportId: string; kb: boolean; obj: boolean; workers: boolean } {
let path = location.pathname
if (basePath && path.startsWith(basePath)) {
path = path.slice(basePath.length) || '/'
}
const reportMatch = path.match(/^\/report\/([^/]+)/)
if (reportMatch) return { projectId: '', reportId: reportMatch[1] ?? '', kb: false, obj: false }
if (path.startsWith('/kb')) return { projectId: '', reportId: '', kb: true, obj: false }
if (path.startsWith('/obj')) return { projectId: '', reportId: '', kb: false, obj: true }
if (reportMatch) return { projectId: '', reportId: reportMatch[1] ?? '', kb: false, obj: false, workers: false }
if (path.startsWith('/kb')) return { projectId: '', reportId: '', kb: true, obj: false, workers: false }
if (path.startsWith('/obj')) return { projectId: '', reportId: '', kb: false, obj: true, workers: false }
if (path.startsWith('/workers')) return { projectId: '', reportId: '', kb: false, obj: false, workers: true }
const projectMatch = path.match(/^\/projects\/([^/]+)/)
return { projectId: projectMatch?.[1] ?? '', reportId: '', kb: false, obj: false }
return { projectId: projectMatch?.[1] ?? '', reportId: '', kb: false, obj: false, workers: false }
}
function onPopState() {
const { projectId, reportId, kb, obj } = parseUrl()
const { projectId, reportId, kb, obj, workers } = parseUrl()
if (kb) {
onOpenKb()
} else if (obj) {
onOpenObj()
} else if (workers) {
onOpenWorkers()
} else {
showKb.value = false
showObj.value = false
showWorkers.value = false
selectedArticleId.value = ''
selectedProjectId.value = projectId
reportWorkflowId.value = reportId
@@ -87,11 +98,13 @@ onMounted(async () => {
if (appTitle.value) document.title = appTitle.value
projects.value = await api.listProjects()
const { projectId, reportId, kb, obj } = parseUrl()
const { projectId, reportId, kb, obj, workers } = parseUrl()
if (kb) {
onOpenKb()
} else if (obj) {
onOpenObj()
} else if (workers) {
onOpenWorkers()
} else if (reportId) {
reportWorkflowId.value = reportId
} else if (projectId && projects.value.some(p => p.id === projectId)) {
@@ -203,6 +216,18 @@ function onCloseObj() {
}
}
function onOpenWorkers() {
showWorkers.value = true
showKb.value = false
showObj.value = false
selectedProjectId.value = ''
creating.value = false
if (location.pathname !== `${basePath}/workers`) {
history.pushState(null, '', `${basePath}/workers`)
}
}
async function onCreateArticle() {
try {
const article = await api.createArticle('新文章')
@@ -247,6 +272,7 @@ function onArticleSaved(id: string, title: string, updatedAt: string) {
function goHome() {
showKb.value = false
showObj.value = false
showWorkers.value = false
selectedArticleId.value = ''
creating.value = false
if (projects.value[0]) {
@@ -292,11 +318,31 @@ function goHome() {
</div>
<button class="settings-item" @click="showSettings = false; onOpenKb()">Knowledge Base</button>
<button class="settings-item" @click="showSettings = false; onOpenObj()">Object Storage</button>
<button class="settings-item" @click="showSettings = false; onOpenWorkers()">Workers</button>
</div>
</div>
<button class="header-btn" @click="chatOpen = !chatOpen" :title="chatOpen ? 'Close chat' : 'Open chat'">
💬
</button>
<div class="header-settings-wrapper">
<img
v-if="props.user.picture"
:src="props.user.picture"
class="header-avatar"
:title="props.user.email"
@click="showUserMenu = !showUserMenu"
referrerpolicy="no-referrer"
/>
<span v-else class="header-avatar-placeholder" @click="showUserMenu = !showUserMenu" :title="props.user.email">
{{ props.user.email[0]?.toUpperCase() }}
</span>
<div v-if="showUserMenu" class="header-settings-menu">
<div class="settings-item-row">
<span class="settings-label">{{ props.user.name || props.user.email }}</span>
</div>
<button class="settings-item" @click="showUserMenu = false; emit('logout')">Sign out</button>
</div>
</div>
</div>
</header>
<div class="app-body">
@@ -320,8 +366,11 @@ function goHome() {
/>
<main class="main-content">
<div v-if="error" class="error-banner" @click="error = ''">{{ error }}</div>
<WorkersView
v-if="showWorkers"
/>
<ObjBrowser
v-if="showObj"
v-else-if="showObj"
@close="onCloseObj"
/>
<KbEditor
@@ -554,6 +603,32 @@ function goHome() {
cursor: pointer;
}
.header-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
border: 1px solid var(--border);
}
.header-avatar:hover {
opacity: 0.8;
}
.header-avatar-placeholder {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.chat-sidebar {
width: var(--chat-sidebar-width, 360px);
flex-shrink: 0;

View File

@@ -0,0 +1,574 @@
<script setup lang="ts">
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<string[]>([])
const entries = ref<FileEntry[]>([])
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<HTMLInputElement | null>(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)
}
}
</script>
<template>
<div class="fb" @dragover.prevent @drop="onDrop">
<div class="fb-toolbar">
<div class="fb-breadcrumb">
<span
v-for="(b, i) in breadcrumbs"
:key="b.path"
class="fb-crumb"
@click="goTo(b.path)"
>
<span v-if="i > 0" class="fb-sep">/</span>
{{ b.name }}
</span>
</div>
<div class="fb-actions">
<button class="fb-btn" @click="mkdirMode = true" title="New folder">+ Folder</button>
<button class="fb-btn" @click="fileInputRef?.click()" :disabled="uploading" title="Upload files">Upload</button>
<input ref="fileInputRef" type="file" multiple style="display:none" @change="onFileInput" />
<button class="fb-btn fb-btn-icon" @click="load()" title="Refresh"></button>
</div>
</div>
<div v-if="uploading" class="fb-upload-progress">
<div class="fb-progress-bar">
<div class="fb-progress-fill" :style="{ width: uploadProgress + '%' }"></div>
</div>
<span class="fb-progress-text">
{{ uploadProgress }}%
<template v-if="uploadSpeed"> · {{ uploadSpeed }}</template>
<template v-if="uploadEta"> · ETA {{ uploadEta }}</template>
</span>
</div>
<div v-if="error" class="fb-error" @click="error = ''">{{ error }}</div>
<div v-if="mkdirMode" class="fb-mkdir">
<input
v-model="mkdirName"
class="fb-input"
placeholder="Folder name"
@keyup.enter="onMkdir"
@keyup.escape="mkdirMode = false"
@vue:mounted="($event: any) => $event.el.focus()"
/>
<button class="fb-btn" @click="onMkdir">Create</button>
<button class="fb-btn" @click="mkdirMode = false">Cancel</button>
</div>
<div v-if="loading" class="fb-loading">Loading...</div>
<div v-else-if="entries.length === 0" class="fb-empty">
Empty directory drag &amp; drop files here to upload
</div>
<table v-else class="fb-table">
<thead>
<tr>
<th class="fb-th-name">Name</th>
<th class="fb-th-size">Size</th>
<th class="fb-th-actions"></th>
</tr>
</thead>
<tbody>
<tr v-for="e in entries" :key="e.name" class="fb-row">
<td class="fb-cell-name">
<template v-if="renamingItem === e.name">
<input
v-model="renameValue"
class="fb-input fb-input-inline"
@keyup.enter="onRename(e.name)"
@keyup.escape="renamingItem = ''"
@vue:mounted="($event: any) => $event.el.focus()"
/>
</template>
<template v-else>
<span v-if="e.is_dir" class="fb-icon">📁</span>
<span v-else class="fb-icon">📄</span>
<a
v-if="e.is_dir"
class="fb-link"
@click.prevent="enter(e.name)"
href="#"
>{{ e.name }}</a>
<a
v-else
class="fb-link"
:href="downloadUrl(e.name)"
target="_blank"
>{{ e.name }}</a>
</template>
</td>
<td class="fb-cell-size">{{ e.is_dir ? '-' : formatSize(e.size) }}</td>
<td class="fb-cell-actions">
<button class="fb-btn-sm" @click="startRename(e.name)" title="Rename"></button>
<button class="fb-btn-sm" @click="onDelete(e.name, e.is_dir)" title="Delete">🗑</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.fb {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.fb-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
gap: 8px;
flex-shrink: 0;
}
.fb-breadcrumb {
display: flex;
align-items: center;
gap: 2px;
font-size: 13px;
min-width: 0;
overflow: hidden;
}
.fb-crumb {
cursor: pointer;
color: var(--accent);
white-space: nowrap;
}
.fb-crumb:hover {
text-decoration: underline;
}
.fb-sep {
color: var(--text-secondary);
margin: 0 2px;
}
.fb-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.fb-btn {
padding: 4px 10px;
font-size: 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
white-space: nowrap;
}
.fb-btn:hover {
background: var(--border);
}
.fb-btn-icon {
padding: 4px 6px;
font-size: 14px;
}
.fb-upload-progress {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.fb-progress-bar {
flex: 1;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
}
.fb-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.2s ease;
}
.fb-progress-text {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
flex-shrink: 0;
}
.fb-error {
background: var(--error);
color: #fff;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
flex-shrink: 0;
}
.fb-mkdir {
display: flex;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
align-items: center;
flex-shrink: 0;
}
.fb-input {
padding: 4px 8px;
font-size: 13px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-primary);
outline: none;
}
.fb-input:focus {
border-color: var(--accent);
}
.fb-input-inline {
width: 200px;
}
.fb-loading, .fb-empty {
padding: 24px;
text-align: center;
color: var(--text-secondary);
font-size: 13px;
}
.fb-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
flex: 1;
overflow-y: auto;
display: block;
}
.fb-table thead {
display: table;
width: 100%;
table-layout: fixed;
}
.fb-table tbody {
display: block;
overflow-y: auto;
max-height: calc(100vh - 200px);
width: 100%;
}
.fb-table tr {
display: table;
width: 100%;
table-layout: fixed;
}
.fb-table th {
text-align: left;
padding: 6px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
}
.fb-row {
border-bottom: 1px solid var(--border);
}
.fb-row:hover {
background: var(--bg-tertiary);
}
.fb-cell-name {
padding: 6px 12px;
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.fb-cell-size {
padding: 6px 12px;
color: var(--text-secondary);
width: 100px;
}
.fb-cell-actions {
padding: 6px 8px;
width: 80px;
text-align: right;
}
.fb-th-name { width: auto; }
.fb-th-size { width: 100px; }
.fb-th-actions { width: 80px; }
.fb-icon {
flex-shrink: 0;
font-size: 14px;
}
.fb-link {
color: var(--text-primary);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fb-link:hover {
color: var(--accent);
}
.fb-btn-sm {
background: none;
border: none;
cursor: pointer;
font-size: 13px;
padding: 2px 4px;
border-radius: 4px;
opacity: 0.5;
}
.fb-btn-sm:hover {
opacity: 1;
background: var(--bg-tertiary);
}
</style>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { auth } from '../api'
</script>
<template>
<div class="login-page">
<div class="login-card">
<h1 class="login-title">Tori</h1>
<p class="login-subtitle">Sign in to continue</p>
<a :href="auth.loginUrl" class="google-btn">
<svg class="google-icon" viewBox="0 0 24 24" width="18" height="18">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Sign in with Google
</a>
</div>
</div>
</template>
<style scoped>
.login-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
}
.login-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 48px 40px;
text-align: center;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.login-title {
font-size: 28px;
font-weight: 700;
color: var(--accent);
margin: 0 0 8px;
}
.login-subtitle {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 32px;
}
.google-btn {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 24px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
}
.google-btn:hover {
background: var(--bg-tertiary);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.google-icon {
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api, type WorkerInfo } from '../api'
const workers = ref<WorkerInfo[]>([])
const loading = ref(false)
const error = ref('')
async function load() {
loading.value = true
error.value = ''
try {
workers.value = await api.listWorkers()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="workers-view">
<div class="workers-header">
<h2 class="workers-title">Workers</h2>
<button class="workers-refresh" @click="load" :disabled="loading"></button>
</div>
<div v-if="error" class="workers-error" @click="error = ''">{{ error }}</div>
<div v-if="loading" class="workers-loading">Loading...</div>
<div v-else-if="workers.length === 0" class="workers-empty">
No workers registered
</div>
<div v-else class="workers-grid">
<div v-for="w in workers" :key="w.name" class="worker-card">
<div class="worker-name">
<span class="worker-dot"></span>
{{ w.name }}
</div>
<div class="worker-details">
<div class="worker-row">
<span class="worker-label">CPU</span>
<span class="worker-value">{{ w.cpu }}</span>
</div>
<div class="worker-row">
<span class="worker-label">Memory</span>
<span class="worker-value">{{ w.memory }}</span>
</div>
<div class="worker-row">
<span class="worker-label">GPU</span>
<span class="worker-value">{{ w.gpu }}</span>
</div>
<div class="worker-row">
<span class="worker-label">OS</span>
<span class="worker-value">{{ w.os }}</span>
</div>
<div class="worker-row">
<span class="worker-label">Kernel</span>
<span class="worker-value">{{ w.kernel }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.workers-view {
padding: 24px;
max-width: 800px;
margin: 0 auto;
}
.workers-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.workers-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.workers-refresh {
padding: 4px 8px;
font-size: 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
}
.workers-refresh:hover {
background: var(--border);
}
.workers-error {
background: rgba(239, 83, 80, 0.15);
border: 1px solid var(--error);
color: var(--error);
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
margin-bottom: 16px;
}
.workers-loading, .workers-empty {
text-align: center;
color: var(--text-secondary);
padding: 40px;
font-size: 14px;
}
.workers-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.worker-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.worker-name {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
}
.worker-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4caf50;
flex-shrink: 0;
}
.worker-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 24px;
}
.worker-row {
display: flex;
gap: 8px;
font-size: 13px;
}
.worker-label {
color: var(--text-secondary);
min-width: 60px;
flex-shrink: 0;
}
.worker-value {
color: var(--text-primary);
word-break: break-all;
}
</style>

View File

@@ -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<LlmCallLogEntry[]>([])
const quotes = ref<string[]>([])
const currentActivity = ref('')
const error = ref('')
const rightTab = ref<'log' | 'timers'>('log')
const rightTab = ref<'log' | 'timers' | 'files'>('log')
const commentRef = ref<InstanceType<typeof CommentSection> | null>(null)
function addQuote(text: string) {
@@ -184,6 +185,7 @@ async function onSubmitComment(text: string) {
<div class="right-panel">
<div class="tab-bar">
<button class="tab-btn" :class="{ active: rightTab === 'log' }" @click="rightTab = 'log'">日志</button>
<button class="tab-btn" :class="{ active: rightTab === 'files' }" @click="rightTab = 'files'">文件</button>
<button class="tab-btn" :class="{ active: rightTab === 'timers' }" @click="rightTab = 'timers'">定时任务</button>
</div>
<ExecutionSection
@@ -198,6 +200,10 @@ async function onSubmitComment(text: string) {
:currentActivity="currentActivity"
@quote="addQuote"
/>
<FileBrowser
v-show="rightTab === 'files'"
:projectId="projectId"
/>
<TimerSection
v-show="rightTab === 'timers'"
:projectId="projectId"