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:
@@ -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>
|
||||
|
||||
@@ -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`,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
574
web/src/components/FileBrowser.vue
Normal file
574
web/src/components/FileBrowser.vue
Normal 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 & 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>
|
||||
78
web/src/components/LoginPage.vue
Normal file
78
web/src/components/LoginPage.vue
Normal 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>
|
||||
178
web/src/components/WorkersView.vue
Normal file
178
web/src/components/WorkersView.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user