feat: implement drives, backend and frontend

This commit is contained in:
Shivering Ocelot committed 2026-09-22 10:58:08 -05:00
1 parent 6ad0a461e4
commit 3283b77ab9
12 files changed
+781 -93

No files matched your search

+50
View File
@@ -137,6 +137,23 @@ export async function newToken () {
// --- Communication ---
export async function webFetch (path, options = {}) {
const tk = loginToken || await loginTokenDeferred.promise;
const response = await fetch(`${backendUrl}${path}`, {
...options,
headers: {
Authorization: `Bearer ${tk}`,
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Response Error: ${response.status}\n${errorText}`);
}
// TODO: Check content-type before parsing
return response.json();
}
export function sendAction(action, body = {}, callback = null) {
if (socket?.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("WebSocket is not open. Cannot send action."));
@@ -358,6 +375,39 @@ export function invalidateCache({ chats=false, agents=false } = {}) {
}
// --- Drive Storage (REST) ---
// Drive endpoints are plain HTTP routes instead of WebSocket actions,
// so they authenticate with the bearer token in the Authorization header.
export function getDrives () {
return webFetch('/files');
}
export function getDriveFiles (driveName) {
return webFetch(`/files/${driveName}`);
}
export function uploadDriveFile (driveName, file) {
const body = new FormData();
body.append('file', file, file.name);
return webFetch(`/files/${driveName}`, {
method: 'POST',
body,
});
}
// Anchor downloads can't set headers, so the token travels as a query parameter
export function driveFileUrl (driveName, filename) {
return `${backendUrl}/files/${driveName}/${filename}?token=${loginToken}`;
}
export function deleteDriveFile (driveName, filename) {
const body = new FormData();
return webFetch(`/files/${driveName}/${filename}`, {
method: 'DELETE',
});
}
// --- Connection ---
+1
View File
@@ -117,6 +117,7 @@
<RouterLink to="/user" class="nav-item">User</RouterLink>
<RouterLink to="/models" class="nav-item">Manage Models</RouterLink>
<RouterLink to="/remotes" class="nav-item">Remotes</RouterLink>
<RouterLink to="/drives" class="nav-item">Drives</RouterLink>
<RouterLink to="/runs" class="nav-item">Agent Runs</RouterLink>
</nav>
</aside>
+5
View File
@@ -61,6 +61,11 @@ const router = createRouter({
path: `/remotes`,
component: () => import('../views/Remotes.vue')
},
{
name: 'drives',
path: `/drives`,
component: () => import('../views/Drives.vue')
},
objectView('chat', () => import('../views/Chat.vue')),
objectView('agent', () => import('../views/Agent.vue')),
staticView('user', () => import('../views/User.vue')),
+250
View File
@@ -0,0 +1,250 @@
<script setup>
import { ref, onMounted } from 'vue';
import TitleBar from '../components/TitleBar.vue';
import * as backend from '../backend';
import * as ui from '../ui';
// Folder structure is implicit in the stored file names ("docs/api/readme.md"):
// the tree is a pure function of the file list, rebuilt on every load. Which
// folders are open lives on the drive (drive.open), never inside tree nodes,
// which is why a rebuild never loses it.
// drive: { id, name, expanded, loading, error, root, count, open: Set<dirPath> }
// dir node: { type: 'dir', name, path, children: Map<name, node> }
// file node: { type: 'file', name, path, id, size, mime, created }
const drives = ref([]);
const loading = ref(false);
const fileInput = ref(null);
let uploadTarget = null; // { drive, dirPath } while the file picker is open
onMounted(async () => {
loading.value = true;
try {
drives.value = (await backend.getDrives()).map(d => ({
id: d.id,
name: d.name || `drive-${d.id}`,
expanded: false, loading: false, error: null,
root: null, count: 0, open: new Set(),
}));
} catch (error) {
ui.alert('Failed to load drives: ' + (error.message || error));
} finally {
loading.value = false;
}
});
const dirNode = (name, path) => ({ type: 'dir', name, path, children: new Map() });
function buildTree (files) {
const root = dirNode('', '');
for (const file of files) {
const parts = String(file.name).split('/').filter(Boolean);
if (!parts.length) continue;
let node = root;
for (let i = 0; i < parts.length - 1; i++) { // create the parent folders as needed
const path = parts.slice(0, i + 1).join('/');
let dir = node.children.get(parts[i]);
if (!dir || dir.type !== 'dir') node.children.set(parts[i], dir = dirNode(parts[i], path));
node = dir;
}
const name = parts.at(-1);
if (node.children.get(name)?.type !== 'dir') { // a folder wins on a name clash
node.children.set(name, { type: 'file', name, path: parts.join('/'), id: file.id, size: file.size, mime: file.mime, created: file.created });
}
}
return root;
}
// Folders first, then natural name order ("a2" before "a10").
const byTypeThenName = (a, b) =>
a.type === b.type
? a.name.localeCompare(b.name, undefined, { numeric: true })
: a.type === 'dir' ? -1 : 1;
// Flattens the currently open part of a drive's tree into display rows.
function rows (drive) {
const out = [];
const walk = (node, depth) => {
for (const child of [...node.children.values()].sort(byTypeThenName)) {
out.push({ node: child, depth });
if (child.type === 'dir' && drive.open.has(child.path)) walk(child, depth + 1);
}
};
walk(drive.root, 0);
return out;
}
async function loadFiles (drive) {
drive.loading = true;
drive.error = null;
try {
const files = await backend.getDriveFiles(drive.name);
drive.root = buildTree(files);
drive.count = files.length;
} catch (error) {
drive.error = error.message || String(error);
} finally {
drive.loading = false;
}
}
async function toggleDrive (drive) {
drive.expanded = !drive.expanded;
if (drive.expanded && !drive.root && !drive.loading) await loadFiles(drive); // lazy first load
}
function onRowClick (drive, node) {
if (node.type === 'dir') { // toggle folder
drive.open.has(node.path) ? drive.open.delete(node.path) : drive.open.add(node.path);
} else {
window.open(backend.driveFileUrl(drive.name, node.path), '_blank'); // served with its mime type
}
}
async function remove (drive, node) {
if (!await ui.confirm(`Delete "${node.path}" from drive "${drive.name}"?`)) return;
try {
await backend.deleteDriveFile(drive.name, node.path);
await loadFiles(drive); // open folders survive: only the tree is replaced
} catch (error) {
ui.alert('Delete failed: ' + (error.message || error));
}
}
function pickUpload (drive, dirPath = '') {
uploadTarget = { drive, dirPath };
fileInput.value.value = ''; // allow picking the same file again
fileInput.value.click();
}
// prefixes('docs/api') -> ['docs', 'docs/api']
const prefixes = path => path.split('/').filter(Boolean).map((part, i, all) => all.slice(0, i + 1).join('/'));
async function onFilesPicked (event) {
const files = [...event.target.files];
const { drive, dirPath } = uploadTarget ?? {};
uploadTarget = null;
if (!drive || !files.length) return;
drive.expanded = true;
for (const file of files) {
const path = dirPath ? `${dirPath}/${file.name}` : file.name;
try {
await backend.uploadDriveFile(drive.name, file, path);
} catch (error) {
ui.alert(`Upload failed for "${path}": ` + (error.message || error));
}
}
prefixes(dirPath).forEach(path => drive.open.add(path)); // reveal the destination folder
await loadFiles(drive);
}
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
function formatSize (bytes) {
if (bytes == null) return '';
let value = bytes, unit = 0;
while (value >= 1024 && unit < SIZE_UNITS.length - 1) { value /= 1024; unit += 1; }
return value < 10 && unit > 0 ? `${value.toFixed(1)} ${SIZE_UNITS[unit]}` : `${Math.round(value)} ${SIZE_UNITS[unit]}`;
}
function formatDate (iso) {
if (!iso) return '';
const date = new Date(iso);
return isNaN(date) ? iso : date.toLocaleString();
}
</script>
<template>
<main>
<TitleBar><h1>Drives</h1></TitleBar>
<section class="drives">
<p v-if="loading" class="status">Loading drives…</p>
<p v-else-if="!drives.length" class="status">No drives available.</p>
<article v-for="drive in drives" :key="drive.id" class="drive">
<header class="row head" @click="toggleDrive(drive)">
<span class="caret">{{ drive.expanded ? '▾' : '▸' }}</span>
<span class="icon">🗄️</span>
<b>{{ drive.name }}</b>
<span v-if="drive.loading" class="muted">loading…</span>
<span v-else-if="drive.count" class="muted">{{ drive.count }} files</span>
<span class="spacer"></span>
<button class="action" @click.stop="pickUpload(drive)">Upload</button>
<button v-if="drive.root" class="action" @click.stop="loadFiles(drive)">Refresh</button>
</header>
<p v-if="drive.expanded && drive.error" class="error">{{ drive.error }}</p>
<template v-if="drive.expanded && drive.root">
<p v-if="!drive.count" class="status empty">This drive is empty. Use Upload to add files.</p>
<div v-for="row in rows(drive)" :key="row.node.path" class="row"
:style="{ paddingLeft: row.depth * 20 + 12 + 'px' }" @click="onRowClick(drive, row.node)">
<span class="caret">{{ row.node.type === 'dir' ? (drive.open.has(row.node.path) ? '▾' : '▸') : '' }}</span>
<span class="icon">{{ row.node.type === 'dir' ? '📁' : '📄' }}</span>
<span class="name">{{ row.node.name }}</span>
<span v-if="row.node.type === 'file'" class="muted meta">
{{ formatSize(row.node.size) }} · {{ row.node.mime }} · {{ formatDate(row.node.created) }}
</span>
<span class="spacer"></span>
<button v-if="row.node.type === 'dir'" class="action" @click.stop="pickUpload(drive, row.node.path)">Upload</button>
<template v-else>
<a class="action" :href="backend.driveFileUrl(drive.name, row.node.path)" :download="row.node.name" @click.stop>Download</a>
<button class="action danger" @click.stop="remove(drive, row.node)">Delete</button>
</template>
</div>
</template>
</article>
</section>
<input ref="fileInput" type="file" multiple hidden @change="onFilesPicked" />
</main>
</template>
<style scoped>
.drives {
box-sizing: border-box;
width: 100%;
max-width: 60rem;
margin: 0 auto;
padding: 0 1rem 2rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.drive { border: 1px solid rgba(128, 128, 128, 0.35); border-radius: 0.4rem; }
.status, .muted { color: gray; }
.muted { font-size: 0.85em; }
.error { color: #e05d5d; margin: 0.25rem 0.75rem; }
.empty { padding: 0 0.75rem; }
.row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.15rem 0.75rem;
cursor: pointer;
user-select: none;
}
.row:hover { background: rgba(128, 128, 128, 0.1); }
.head { padding: 0.5rem 0.75rem; }
.caret { flex-shrink: 0; }
.name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.spacer { flex: 1; }
.meta { white-space: nowrap; }
.action {
font-size: 0.85em;
padding: 0.1rem 0.55rem;
background: transparent;
border: 1px solid rgba(128, 128, 128, 0.5);
border-radius: 0.25rem;
color: inherit;
text-decoration: none;
cursor: pointer;
}
.action:hover { background: rgba(128, 128, 128, 0.2); }
.action.danger { color: #e05d5d; border-color: rgba(224, 93, 93, 0.6); }
</style>