refactor: S06.1 reorganize frontend routes into subdirectories (media/tools/admin)
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { get, post } from "../../lib/api.js";
|
||||
|
||||
let dates = $state([]);
|
||||
let selectedDate = $state("");
|
||||
let summary = $state(null);
|
||||
let messages = $state([]);
|
||||
let showMessages = $state(false);
|
||||
let loading = $state(false);
|
||||
let triggering = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
dates = await get("/api/chat-summary/dates");
|
||||
if (dates.length) selectDate(dates[0]);
|
||||
} catch (e) {
|
||||
error = e.body?.detail || "Failed to load dates";
|
||||
}
|
||||
});
|
||||
|
||||
async function selectDate(d) {
|
||||
selectedDate = d;
|
||||
showMessages = false;
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
summary = await get(`/api/chat-summary/summary/${d}`);
|
||||
} catch (e) {
|
||||
summary = null;
|
||||
if (e.status !== 404) error = e.body?.detail || "Failed to load summary";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages() {
|
||||
if (showMessages) { showMessages = false; return; }
|
||||
try {
|
||||
messages = await get(`/api/chat-summary/messages/${selectedDate}`);
|
||||
showMessages = true;
|
||||
} catch (e) {
|
||||
error = "Failed to load messages";
|
||||
}
|
||||
}
|
||||
|
||||
async function trigger() {
|
||||
triggering = true;
|
||||
try {
|
||||
await post("/api/chat-summary/trigger");
|
||||
} catch (e) {
|
||||
error = "Trigger failed";
|
||||
} finally {
|
||||
triggering = false;
|
||||
}
|
||||
}
|
||||
|
||||
function markdownToHtml(md) {
|
||||
if (!md) return "";
|
||||
return md
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-5 mb-2">$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/^- (.+)$/gm, '<li class="ml-4">$1</li>')
|
||||
.replace(/\n{2,}/g, '<br><br>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-surface-800 dark:text-surface-100">Chat Digest</h2>
|
||||
<button onclick={trigger} disabled={triggering}
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50">
|
||||
{triggering ? "Generating..." : "Generate Now"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm dark:bg-red-900/20 dark:text-red-400">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mb-6 flex-wrap">
|
||||
{#each dates as d}
|
||||
<button onclick={() => selectDate(d)}
|
||||
class="px-3 py-1.5 text-sm rounded-lg transition-colors {selectedDate === d ? 'bg-primary-600 text-white' : 'bg-surface-100 text-surface-600 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
|
||||
{d}
|
||||
</button>
|
||||
{/each}
|
||||
{#if !dates.length && !error}
|
||||
<p class="text-sm text-surface-400">No summaries yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div></div>
|
||||
{:else if summary}
|
||||
<div class="prose dark:prose-invert max-w-none bg-white dark:bg-surface-800 rounded-xl p-6 shadow-sm border border-surface-200 dark:border-surface-700">
|
||||
{@html markdownToHtml(summary.content)}
|
||||
</div>
|
||||
|
||||
<button onclick={loadMessages}
|
||||
class="mt-4 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400">
|
||||
{showMessages ? "Hide raw messages" : "Show raw messages"}
|
||||
</button>
|
||||
|
||||
{#if showMessages}
|
||||
<div class="mt-3 bg-white dark:bg-surface-800 rounded-xl p-4 shadow-sm border border-surface-200 dark:border-surface-700 max-h-96 overflow-y-auto">
|
||||
{#each messages as m}
|
||||
<div class="py-1.5 border-b border-surface-100 dark:border-surface-700 last:border-0 text-sm">
|
||||
<span class="text-surface-400 text-xs">{m.time?.slice(11,16)}</span>
|
||||
<span class="font-medium text-primary-600 dark:text-primary-400 ml-1">[{m.group}] {m.sender}:</span>
|
||||
<span class="text-surface-700 dark:text-surface-300 ml-1">{m.text}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,306 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { get, post } from "../../lib/api.js";
|
||||
|
||||
let dates = $state([]);
|
||||
let selectedDate = $state("");
|
||||
let summary = $state(null);
|
||||
let conversations = $state([]);
|
||||
let selectedConversation = $state(null);
|
||||
let messages = $state([]);
|
||||
let searchQuery = $state("");
|
||||
let searchResults = $state([]);
|
||||
let stats = $state(null);
|
||||
let loading = $state(false);
|
||||
let triggering = $state(false);
|
||||
let error = $state("");
|
||||
let view = $state("summary"); // summary, list, detail, search, stats
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
dates = await get("/conversations/dates");
|
||||
if (dates.length) selectDate(dates[0]);
|
||||
loadStats();
|
||||
} catch (e) {
|
||||
error = e.body?.detail || "Failed to load dates";
|
||||
}
|
||||
});
|
||||
|
||||
async function selectDate(d) {
|
||||
selectedDate = d;
|
||||
view = "summary";
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
summary = await get(`/conversations/summary/${d}`);
|
||||
conversations = await get(`/conversations/list?date=${d}`);
|
||||
} catch (e) {
|
||||
summary = null;
|
||||
if (e.status !== 404) error = e.body?.detail || "Failed to load summary";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectConversation(sessionId) {
|
||||
view = "detail";
|
||||
loading = true;
|
||||
try {
|
||||
selectedConversation = await get(`/conversations/${sessionId}`);
|
||||
messages = await get(`/conversations/${sessionId}/messages`);
|
||||
} catch (e) {
|
||||
error = "Failed to load conversation";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!searchQuery || searchQuery.length < 2) return;
|
||||
view = "search";
|
||||
loading = true;
|
||||
try {
|
||||
searchResults = await get(`/conversations/search?q=${encodeURIComponent(searchQuery)}`);
|
||||
} catch (e) {
|
||||
error = "Search failed";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
stats = await get("/conversations/stats");
|
||||
} catch (e) {
|
||||
console.error("Failed to load stats", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function trigger() {
|
||||
triggering = true;
|
||||
try {
|
||||
await post("/conversations/trigger");
|
||||
setTimeout(() => selectDate(selectedDate), 5000);
|
||||
} catch (e) {
|
||||
error = "Trigger failed";
|
||||
} finally {
|
||||
triggering = false;
|
||||
}
|
||||
}
|
||||
|
||||
function markdownToHtml(md) {
|
||||
if (!md) return "";
|
||||
return md
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-5 mb-2">$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/^- (.+)$/gm, '<li class="ml-4">$1</li>')
|
||||
.replace(/\n{2,}/g, '<br><br>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function formatTokens(n) {
|
||||
return n ? n.toLocaleString() : "0";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-surface-800 dark:text-surface-100">Code Sessions</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick={() => view = "stats"} class="px-3 py-1.5 text-sm font-medium rounded-lg bg-surface-100 hover:bg-surface-200 dark:bg-surface-700 dark:hover:bg-surface-600 transition-colors">
|
||||
Stats
|
||||
</button>
|
||||
<button onclick={trigger} disabled={triggering}
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50">
|
||||
{triggering ? "Generating..." : "Generate Summary"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm dark:bg-red-900/20 dark:text-red-400">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => e.key === "Enter" && search()}
|
||||
placeholder="Search conversations..."
|
||||
class="w-full px-4 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date Selector -->
|
||||
{#if view !== "search" && view !== "stats"}
|
||||
<div class="flex gap-2 mb-6 flex-wrap">
|
||||
{#each dates as d}
|
||||
<button onclick={() => selectDate(d)}
|
||||
class="px-3 py-1.5 text-sm rounded-lg transition-colors {selectedDate === d ? 'bg-primary-600 text-white' : 'bg-surface-100 text-surface-600 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
|
||||
{d}
|
||||
</button>
|
||||
{/each}
|
||||
{#if !dates.length && !error}
|
||||
<p class="text-sm text-surface-400">No conversations yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div></div>
|
||||
{:else if view === "summary" && summary}
|
||||
<!-- Summary View -->
|
||||
<div class="mb-4 grid grid-cols-4 gap-3">
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-xs text-surface-400">Conversations</div>
|
||||
<div class="text-xl font-bold text-surface-900 dark:text-white">{summary.conversation_count}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-xs text-surface-400">Messages</div>
|
||||
<div class="text-xl font-bold text-surface-900 dark:text-white">{summary.total_messages}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-xs text-surface-400">Tokens</div>
|
||||
<div class="text-xl font-bold text-surface-900 dark:text-white">{formatTokens(summary.total_tokens)}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-xs text-surface-400">Projects</div>
|
||||
<div class="text-sm font-medium text-surface-900 dark:text-white truncate">{summary.projects?.split(',')[0] || 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prose dark:prose-invert max-w-none bg-white dark:bg-surface-800 rounded-xl p-6 shadow-sm border border-surface-200 dark:border-surface-700 mb-4">
|
||||
{@html markdownToHtml(summary.content)}
|
||||
</div>
|
||||
|
||||
<!-- Conversation List -->
|
||||
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
|
||||
<div class="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
|
||||
Conversations ({conversations.length})
|
||||
</div>
|
||||
{#each conversations as conv}
|
||||
<button
|
||||
onclick={() => selectConversation(conv.session_id)}
|
||||
class="w-full px-4 py-3 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors text-left">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-surface-700 dark:text-surface-200 truncate">{conv.slug || 'Untitled'}</div>
|
||||
<div class="text-xs text-surface-400 truncate">{conv.project_path?.split('/').pop() || 'unknown'}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-surface-400">
|
||||
<span>{conv.message_count} msgs</span>
|
||||
<span>{formatTokens(conv.total_tokens)} tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else if view === "detail" && selectedConversation}
|
||||
<!-- Conversation Detail -->
|
||||
<button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back to summary</button>
|
||||
|
||||
<div class="bg-white dark:bg-surface-800 rounded-xl p-4 border border-surface-200 dark:border-surface-700 mb-4">
|
||||
<h3 class="text-lg font-bold text-surface-900 dark:text-white mb-2">{selectedConversation.slug || 'Untitled'}</h3>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><span class="text-surface-400">Project:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.project_path?.split('/').pop()}</span></div>
|
||||
<div><span class="text-surface-400">Branch:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.git_branch || 'N/A'}</span></div>
|
||||
<div><span class="text-surface-400">Messages:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.message_count}</span></div>
|
||||
<div><span class="text-surface-400">Tokens:</span> <span class="text-surface-700 dark:text-surface-300">{formatTokens(selectedConversation.total_input_tokens + selectedConversation.total_output_tokens)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="space-y-3">
|
||||
{#each messages as msg}
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-semibold uppercase {msg.role === 'user' ? 'text-blue-600' : 'text-green-600'}">{msg.role}</span>
|
||||
<span class="text-xs text-surface-400">{msg.timestamp?.slice(11, 19)}</span>
|
||||
</div>
|
||||
<div class="text-sm text-surface-700 dark:text-surface-300 whitespace-pre-wrap">{msg.content?.slice(0, 500)}{msg.content?.length > 500 ? '...' : ''}</div>
|
||||
{#if msg.tool_calls?.length}
|
||||
<div class="mt-2 pt-2 border-t border-surface-100 dark:border-surface-700">
|
||||
<div class="text-xs font-medium text-surface-400 mb-1">Tools: {msg.tool_calls.map(t => t.name).join(', ')}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if msg.input_tokens || msg.output_tokens}
|
||||
<div class="mt-2 text-xs text-surface-400">
|
||||
{msg.input_tokens ? `${msg.input_tokens} in` : ''} {msg.output_tokens ? `${msg.output_tokens} out` : ''}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else if view === "search"}
|
||||
<!-- Search Results -->
|
||||
<button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back</button>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
|
||||
<div class="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
|
||||
Search Results ({searchResults.length})
|
||||
</div>
|
||||
{#each searchResults as conv}
|
||||
<button
|
||||
onclick={() => selectConversation(conv.session_id)}
|
||||
class="w-full px-4 py-3 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors text-left">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-surface-700 dark:text-surface-200 truncate">{conv.slug || 'Untitled'}</div>
|
||||
<div class="text-xs text-surface-400">{conv.started_at?.slice(0, 10)} • {conv.project_path?.split('/').pop()}</div>
|
||||
</div>
|
||||
<div class="text-xs text-surface-400">{conv.message_count} msgs</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{#if searchResults.length === 0}
|
||||
<div class="px-4 py-8 text-center text-sm text-surface-400">No results found</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if view === "stats" && stats}
|
||||
<!-- Statistics -->
|
||||
<button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back</button>
|
||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-400">Total Conversations</div>
|
||||
<div class="text-2xl font-bold text-surface-900 dark:text-white">{stats.total_conversations}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-400">Total Messages</div>
|
||||
<div class="text-2xl font-bold text-surface-900 dark:text-white">{stats.total_messages}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-400">Total Tokens</div>
|
||||
<div class="text-2xl font-bold text-surface-900 dark:text-white">{formatTokens(stats.total_tokens)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top Projects</h3>
|
||||
{#each stats.top_projects as proj}
|
||||
<div class="flex justify-between text-sm py-1">
|
||||
<span class="text-surface-600 dark:text-surface-300 truncate">{proj.path?.split('/').pop()}</span>
|
||||
<span class="text-surface-400">{proj.count}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top Tools</h3>
|
||||
{#each stats.top_tools as tool}
|
||||
<div class="flex justify-between text-sm py-1">
|
||||
<span class="text-surface-600 dark:text-surface-300">{tool.name}</span>
|
||||
<span class="text-surface-400">{tool.count}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { get, del, upload, download } from "../../lib/api.js";
|
||||
|
||||
let entries = $state([]);
|
||||
let currentPath = $state("");
|
||||
let loading = $state(true);
|
||||
let uploading = $state(false);
|
||||
let error = $state("");
|
||||
let fileInput;
|
||||
|
||||
async function browse(path) {
|
||||
loading = true;
|
||||
currentPath = path;
|
||||
const data = await get(`/files/browse?path=${encodeURIComponent(path)}`);
|
||||
entries = data?.entries || [];
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
const parts = currentPath.split("/").filter(Boolean);
|
||||
parts.pop();
|
||||
browse(parts.join("/"));
|
||||
}
|
||||
|
||||
async function remove(name) {
|
||||
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return;
|
||||
const path = currentPath ? `${currentPath}/${name}` : name;
|
||||
await del(`/files/delete?path=${encodeURIComponent(path)}`);
|
||||
browse(currentPath);
|
||||
}
|
||||
|
||||
async function handleDownload(name) {
|
||||
const path = currentPath ? `${currentPath}/${name}` : name;
|
||||
error = "";
|
||||
try {
|
||||
await download(path, name);
|
||||
} catch (e) {
|
||||
error = `Failed to download "${name}": ${e.message}`;
|
||||
console.error("Download failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
const file = fileInput?.files?.[0];
|
||||
if (!file) return;
|
||||
uploading = true;
|
||||
await upload(currentPath, file);
|
||||
fileInput.value = "";
|
||||
uploading = false;
|
||||
browse(currentPath);
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return "—";
|
||||
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + u[i];
|
||||
}
|
||||
|
||||
function fileIcon(name) {
|
||||
const ext = name.split(".").pop().toLowerCase();
|
||||
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) return "image";
|
||||
if (["mp4", "mkv", "avi", "mov"].includes(ext)) return "video";
|
||||
if (["mp3", "flac", "wav", "m4a", "ogg"].includes(ext)) return "audio";
|
||||
if (["zip", "tar", "gz", "7z", "rar"].includes(ext)) return "archive";
|
||||
if (["py", "js", "ts", "go", "rs", "sh", "yml", "yaml", "json", "md"].includes(ext)) return "code";
|
||||
return "file";
|
||||
}
|
||||
|
||||
const crumbs = $derived(currentPath.split("/").filter(Boolean));
|
||||
|
||||
onMount(() => browse(""));
|
||||
</script>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Files</h1>
|
||||
<p class="text-sm text-surface-400 mt-1">/volume1{currentPath ? "/" + currentPath : ""}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="file" bind:this={fileInput} onchange={handleUpload} class="hidden" />
|
||||
<button
|
||||
onclick={() => fileInput.click()}
|
||||
class="text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 px-3 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? "Uploading..." : "Upload"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
{#if error}
|
||||
<div class="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800 rounded-lg px-4 py-3 flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-rose-500 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-rose-700 dark:text-rose-300">{error}</p>
|
||||
</div>
|
||||
<button onclick={() => error = ""} class="text-rose-400 hover:text-rose-600 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
<div class="flex items-center gap-1 text-sm flex-wrap">
|
||||
<button class="text-primary-600 hover:text-primary-700 font-medium transition-colors" onclick={() => browse("")}>volume1</button>
|
||||
{#each crumbs as part, i}
|
||||
<svg class="w-3.5 h-3.5 text-surface-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
<button
|
||||
class="text-primary-600 hover:text-primary-700 font-medium transition-colors"
|
||||
onclick={() => browse(crumbs.slice(0, i + 1).join("/"))}
|
||||
>{part}</button>
|
||||
{/each}
|
||||
{#if currentPath}
|
||||
<button
|
||||
class="ml-auto text-xs font-medium text-surface-500 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 px-2.5 py-1 rounded-lg transition-colors flex items-center gap-1"
|
||||
onclick={goUp}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" /></svg>
|
||||
Up
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- File List -->
|
||||
{#if loading}
|
||||
<div class="space-y-1">
|
||||
{#each Array(8) as _}
|
||||
<div class="h-11 rounded-lg bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="grid grid-cols-[1fr_100px_100px] px-4 py-2 text-[11px] font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
|
||||
<span>Name</span>
|
||||
<span class="text-right">Size</span>
|
||||
<span class="text-right">Actions</span>
|
||||
</div>
|
||||
<!-- Entries -->
|
||||
{#each entries as e}
|
||||
<div class="grid grid-cols-[1fr_100px_100px] items-center px-4 py-2.5 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors group">
|
||||
{#if e.is_dir}
|
||||
<button class="flex items-center gap-2.5 text-sm font-medium text-surface-700 dark:text-surface-200 hover:text-primary-600 transition-colors text-left truncate" onclick={() => browse(currentPath ? `${currentPath}/${e.name}` : e.name)}>
|
||||
<svg class="w-4 h-4 text-amber-400 shrink-0" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" /></svg>
|
||||
{e.name}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="flex items-center gap-2.5 text-sm text-surface-600 dark:text-surface-300 truncate">
|
||||
<svg class="w-4 h-4 text-surface-300 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
|
||||
{e.name}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-xs text-surface-400 text-right">{e.is_dir ? "—" : formatSize(e.size)}</span>
|
||||
<div class="flex items-center justify-end gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
|
||||
{#if !e.is_dir}
|
||||
<button onclick={() => handleDownload(e.name)} class="text-[11px] text-primary-500 hover:text-primary-700 font-medium transition-colors">
|
||||
DL
|
||||
</button>
|
||||
{/if}
|
||||
<button class="text-[11px] text-rose-400 hover:text-rose-600 font-medium transition-colors" onclick={() => remove(e.name)}>Del</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if entries.length === 0}
|
||||
<div class="px-4 py-8 text-center text-sm text-surface-400">Empty directory</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { get } from "../../lib/api.js";
|
||||
|
||||
let repos = $state([]);
|
||||
let commits = $state([]);
|
||||
let selectedRepo = $state("");
|
||||
let loading = $state(true);
|
||||
let loadingCommits = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
const data = await get("/gitea/repos");
|
||||
repos = Array.isArray(data) ? data : data?.data || [];
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function showCommits(owner, repo) {
|
||||
selectedRepo = `${owner}/${repo}`;
|
||||
loadingCommits = true;
|
||||
const data = await get(`/gitea/repos/${owner}/${repo}/commits`);
|
||||
commits = Array.isArray(data) ? data : [];
|
||||
loadingCommits = false;
|
||||
}
|
||||
|
||||
function timeAgo(d) {
|
||||
const s = Math.floor((Date.now() - new Date(d)) / 1000);
|
||||
if (s < 60) return "just now";
|
||||
if (s < 3600) return Math.floor(s / 60) + "m ago";
|
||||
if (s < 86400) return Math.floor(s / 3600) + "h ago";
|
||||
return Math.floor(s / 86400) + "d ago";
|
||||
}
|
||||
|
||||
const langColors = {
|
||||
Python: "#3572A5", JavaScript: "#f1e05a", Shell: "#89e051", Go: "#00ADD8",
|
||||
TypeScript: "#3178c6", Markdown: "#083fa1", CSS: "#563d7c", HTML: "#e34c26",
|
||||
};
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Repositories</h1>
|
||||
<p class="text-sm text-surface-400 mt-1">{repos.length} repositories on Gitea</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{#each Array(4) as _}
|
||||
<div class="h-[100px] rounded-xl bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{#each repos as r}
|
||||
<button
|
||||
class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 text-left shadow-sm hover:shadow-md hover:border-primary-200 dark:hover:border-primary-700 transition-all duration-200 group {selectedRepo === `${r.owner?.login}/${r.name}` ? 'ring-2 ring-primary-500 border-primary-300' : ''}"
|
||||
onclick={() => showCommits(r.owner?.login, r.name)}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-surface-800 dark:text-surface-100 group-hover:text-primary-600 transition-colors truncate">{r.name}</p>
|
||||
<p class="text-xs text-surface-400 mt-1 line-clamp-1">{r.description || "No description"}</p>
|
||||
</div>
|
||||
{#if r.language}
|
||||
<span class="flex items-center gap-1 text-[11px] text-surface-500 shrink-0 ml-2">
|
||||
<span class="w-2 h-2 rounded-full" style="background: {langColors[r.language] || '#888'}"></span>
|
||||
{r.language}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-[11px] text-surface-400 mt-2.5">Updated {timeAgo(r.updated_at)}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Commits Panel -->
|
||||
{#if selectedRepo}
|
||||
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-surface-800 dark:text-surface-100">Recent Commits</h3>
|
||||
<p class="text-xs text-surface-400 mt-0.5">{selectedRepo}</p>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close commits"
|
||||
class="text-xs text-surface-400 hover:text-surface-600 transition-colors"
|
||||
onclick={() => { selectedRepo = ""; commits = []; }}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
{#if loadingCommits}
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="h-12 rounded-lg bg-surface-100 animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-0">
|
||||
{#each commits.slice(0, 15) as c, i}
|
||||
<div class="flex gap-3 py-2.5 {i < commits.length - 1 ? 'border-b border-surface-100 dark:border-surface-700' : ''}">
|
||||
<div class="flex flex-col items-center pt-1">
|
||||
<div class="w-2 h-2 rounded-full bg-primary-400 shrink-0"></div>
|
||||
{#if i < commits.length - 1}
|
||||
<div class="w-px flex-1 bg-surface-200 mt-1"></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm text-surface-700 dark:text-surface-200 truncate">{c.commit?.message?.split("\n")[0]}</p>
|
||||
<p class="text-[11px] text-surface-400 mt-0.5">
|
||||
<span class="font-medium">{c.commit?.author?.name}</span> · {timeAgo(c.commit?.author?.date)}
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-[10px] font-mono text-surface-400 bg-surface-50 dark:bg-surface-700 px-1.5 py-0.5 rounded h-fit shrink-0">{c.sha?.slice(0, 7)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { getLiteLLMHealth } from "../../lib/api.js";
|
||||
|
||||
let loading = $state(true);
|
||||
let health = $state(null);
|
||||
let requestError = $state("");
|
||||
|
||||
async function loadHealth() {
|
||||
loading = true;
|
||||
requestError = "";
|
||||
try {
|
||||
health = await getLiteLLMHealth();
|
||||
} catch (e) {
|
||||
requestError = e?.body?.detail || e?.message || "Failed to load LiteLLM health";
|
||||
health = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadgeClass(status) {
|
||||
if (status === "up") {
|
||||
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300";
|
||||
}
|
||||
if (status === "auth_required") {
|
||||
return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300";
|
||||
}
|
||||
return "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300";
|
||||
}
|
||||
|
||||
function valueOrDash(value) {
|
||||
return value === null || value === undefined || value === "" ? "—" : value;
|
||||
}
|
||||
|
||||
onMount(loadHealth);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">LiteLLM</h1>
|
||||
<p class="text-sm text-surface-400 mt-1">Gateway health and connectivity status</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-[11px] font-medium">
|
||||
<span class="px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">up</span>
|
||||
<span class="px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">auth_required</span>
|
||||
<span class="px-2 py-0.5 rounded-full bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300">down</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick={loadHealth} disabled={loading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50 dark:bg-primary-900/30 dark:text-primary-300">
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if requestError}
|
||||
<div class="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-700 rounded-xl p-4 shadow-sm">
|
||||
<p class="text-sm font-medium text-rose-700 dark:text-rose-300">Failed to fetch LiteLLM health</p>
|
||||
<p class="text-xs text-rose-600 dark:text-rose-400 mt-1">{requestError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
|
||||
{#if loading && !health}
|
||||
<div class="space-y-3">
|
||||
<div class="h-6 w-36 rounded bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{#each Array(6) as _}
|
||||
<div class="h-16 rounded-lg bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if health}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Health Status</h3>
|
||||
<span class="px-2.5 py-1 text-xs font-semibold rounded-full {statusBadgeClass(health.status)}">{valueOrDash(health.status)}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
|
||||
<p class="text-xs font-medium text-surface-400 uppercase tracking-wider">OK</p>
|
||||
<p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{String(Boolean(health.ok))}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
|
||||
<p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Latency</p>
|
||||
<p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{health.latency_ms === null || health.latency_ms === undefined ? "—" : `${health.latency_ms} ms`}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
|
||||
<p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Endpoint</p>
|
||||
<p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{valueOrDash(health.endpoint)}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
|
||||
<p class="text-xs font-medium text-surface-400 uppercase tracking-wider">HTTP Status</p>
|
||||
<p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{valueOrDash(health.http_status)}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3 sm:col-span-2 lg:col-span-2">
|
||||
<p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Error</p>
|
||||
<p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1 break-all">{valueOrDash(health.error)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-surface-400">No health data available.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import KanbanBoard from "../../components/opc/KanbanBoard.svelte";
|
||||
import TaskModal from "../../components/opc/TaskModal.svelte";
|
||||
import AgentPanel from "../../components/opc/AgentPanel.svelte";
|
||||
import { getTasks, getAgents } from "../../lib/opc-api.js";
|
||||
import * as opcWs from "../../lib/opc-ws.js";
|
||||
|
||||
let tasks = $state([]);
|
||||
let agents = $state([]);
|
||||
let loading = $state(true);
|
||||
let showTaskModal = $state(false);
|
||||
let editingTask = $state(null);
|
||||
let showAgentPanel = $state(
|
||||
typeof localStorage !== 'undefined'
|
||||
? localStorage.getItem('opc_showAgentPanel') !== 'false'
|
||||
: true
|
||||
);
|
||||
let unsubscribe = null;
|
||||
|
||||
// Persist agent panel visibility
|
||||
$effect(() => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('opc_showAgentPanel', showAgentPanel);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
try {
|
||||
const [tasksRes, agentsRes] = await Promise.all([
|
||||
getTasks(),
|
||||
getAgents()
|
||||
]);
|
||||
tasks = tasksRes.items || [];
|
||||
agents = agentsRes.items || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load data:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleWebSocketMessage(message) {
|
||||
if (message.type === "task_update") {
|
||||
const { task_id, action, data } = message;
|
||||
|
||||
if (action === "created") {
|
||||
tasks = [...tasks, data];
|
||||
} else if (action === "updated" || action === "moved") {
|
||||
tasks = tasks.map(t => t.id === task_id ? data : t);
|
||||
} else if (action === "deleted") {
|
||||
tasks = tasks.filter(t => t.id !== task_id);
|
||||
}
|
||||
} else if (message.type === "agent_execution") {
|
||||
// Could show agent status in UI
|
||||
console.log("Agent execution update:", message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateTask() {
|
||||
editingTask = null;
|
||||
showTaskModal = true;
|
||||
}
|
||||
|
||||
function handleEditTask(task) {
|
||||
editingTask = task;
|
||||
showTaskModal = true;
|
||||
}
|
||||
|
||||
function handleDeleteTask(taskId) {
|
||||
tasks = tasks.filter(t => t.id !== taskId);
|
||||
}
|
||||
|
||||
function handleAssignTask(taskId, assignedTo, assignedType) {
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
task.assigned_to = assignedTo;
|
||||
task.assigned_type = assignedType;
|
||||
tasks = [...tasks];
|
||||
}
|
||||
}
|
||||
|
||||
function handleTaskMoved(taskId, newStatus) {
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
task.status = newStatus;
|
||||
tasks = [...tasks];
|
||||
}
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
showTaskModal = false;
|
||||
editingTask = null;
|
||||
}
|
||||
|
||||
function handleModalSave() {
|
||||
loadData();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadData();
|
||||
|
||||
// Connect WebSocket
|
||||
opcWs.connect();
|
||||
unsubscribe = opcWs.subscribe(handleWebSocketMessage);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
opcWs.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6 h-full flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100">
|
||||
OPC Management
|
||||
</h1>
|
||||
<p class="text-surface-600 dark:text-surface-400 mt-1">
|
||||
Manage your tasks with AI agents
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => showAgentPanel = !showAgentPanel}
|
||||
class="px-4 py-2 bg-surface-200 dark:bg-surface-700 text-surface-900 dark:text-surface-100 rounded-lg hover:bg-surface-300 dark:hover:bg-surface-600 flex items-center gap-2"
|
||||
>
|
||||
<span>🤖</span>
|
||||
<span>{showAgentPanel ? "Hide" : "Show"} Agents</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={handleCreateTask}
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
|
||||
>
|
||||
<span>➕</span>
|
||||
<span>New Task</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-600 dark:text-surface-400">Total Tasks</div>
|
||||
<div class="text-2xl font-bold text-surface-900 dark:text-surface-100 mt-1">
|
||||
{tasks.length}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-600 dark:text-surface-400">Parking Lot</div>
|
||||
<div class="text-2xl font-bold text-slate-600 dark:text-slate-400 mt-1">
|
||||
{tasks.filter(t => t.status === "parking_lot").length}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-600 dark:text-surface-400">In Progress</div>
|
||||
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400 mt-1">
|
||||
{tasks.filter(t => t.status === "in_progress").length}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-600 dark:text-surface-400">Done</div>
|
||||
<div class="text-2xl font-bold text-emerald-600 dark:text-emerald-400 mt-1">
|
||||
{tasks.filter(t => t.status === "done").length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban Board -->
|
||||
{#if loading}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="text-surface-600 dark:text-surface-400">Loading...</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<KanbanBoard
|
||||
{tasks}
|
||||
{agents}
|
||||
onEdit={handleEditTask}
|
||||
onDelete={handleDeleteTask}
|
||||
onAssign={handleAssignTask}
|
||||
onTaskMoved={handleTaskMoved}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Agent Panel -->
|
||||
{#if showAgentPanel}
|
||||
<div class="mt-6">
|
||||
<AgentPanel />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Task Modal -->
|
||||
{#if showTaskModal}
|
||||
<TaskModal
|
||||
task={editingTask}
|
||||
{agents}
|
||||
onClose={handleModalClose}
|
||||
onSave={handleModalSave}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,803 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { VoiceSession } from "../../lib/voice.js";
|
||||
import { tryRefreshSession } from "../../lib/api.js";
|
||||
|
||||
const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:";
|
||||
const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
|
||||
|
||||
const hosts = [
|
||||
{ id: "nas", label: "NAS" },
|
||||
{ id: "vps", label: "VPS" },
|
||||
{ id: "caddy-vps", label: "Caddy VPS" },
|
||||
{ id: "mac", label: "Mac" },
|
||||
{ id: "claude-dev", label: "Claude Dev", persistent: true },
|
||||
];
|
||||
|
||||
const DARK_THEME = {
|
||||
background: "#0f172a", foreground: "#e2e8f0", cursor: "#6366f1",
|
||||
cursorAccent: "#0f172a", selectionBackground: "#334155",
|
||||
black: "#0f172a", red: "#f43f5e", green: "#10b981",
|
||||
yellow: "#f59e0b", blue: "#3b82f6", magenta: "#a855f7",
|
||||
cyan: "#06b6d4", white: "#e2e8f0",
|
||||
};
|
||||
|
||||
const LIGHT_THEME = {
|
||||
background: "#f8fafc", foreground: "#0f172a", cursor: "#4f46e5",
|
||||
cursorAccent: "#f8fafc", selectionBackground: "#cbd5e1",
|
||||
black: "#0f172a", red: "#e11d48", green: "#059669",
|
||||
yellow: "#d97706", blue: "#2563eb", magenta: "#9333ea",
|
||||
cyan: "#0891b2", white: "#334155",
|
||||
};
|
||||
|
||||
let tabs = $state([]);
|
||||
let activeTab = $state(null);
|
||||
let showMenu = $state(false);
|
||||
let voiceTick = $state(0);
|
||||
let isDark = $state(false);
|
||||
let isFullscreen = $state(false);
|
||||
let showActionButtons = $state(false);
|
||||
let tabCounter = 0;
|
||||
const tabData = new Map();
|
||||
|
||||
function currentTheme() {
|
||||
return isDark ? DARK_THEME : LIGHT_THEME;
|
||||
}
|
||||
|
||||
function hostById(hostId) {
|
||||
return hosts.find((h) => h.id === hostId);
|
||||
}
|
||||
|
||||
function openFreshSession(tab) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("page", "terminal");
|
||||
url.searchParams.set("host", tab.hostId);
|
||||
window.open(`${url.pathname}${url.search}${url.hash}`, "_blank", "noopener");
|
||||
}
|
||||
|
||||
function persistenceLabel(status, reconnecting) {
|
||||
if (status === "created") return "created new persistent session";
|
||||
if (reconnecting) return "reattached to persistent session";
|
||||
return "attached to persistent session";
|
||||
}
|
||||
|
||||
function applyThemeToTerminals() {
|
||||
const theme = currentTheme();
|
||||
tabData.forEach((d) => {
|
||||
d.term?.options && (d.term.options.theme = theme);
|
||||
});
|
||||
}
|
||||
|
||||
function syncTheme() {
|
||||
isDark = document.documentElement.classList.contains("dark");
|
||||
applyThemeToTerminals();
|
||||
}
|
||||
|
||||
const themeObserver = new MutationObserver((mutations) => {
|
||||
if (mutations.some((m) => m.attributeName === "class")) syncTheme();
|
||||
});
|
||||
onMount(() => {
|
||||
syncTheme();
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
const requestedHost = new URLSearchParams(window.location.search).get("host");
|
||||
if (requestedHost && !tabs.length && hostById(requestedHost)) addTab(requestedHost);
|
||||
});
|
||||
|
||||
function cleanName(name) {
|
||||
return (name || "image")
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "image";
|
||||
}
|
||||
|
||||
function extensionFor(type) {
|
||||
if (type === "image/png") return "png";
|
||||
if (type === "image/jpeg") return "jpg";
|
||||
if (type === "image/webp") return "webp";
|
||||
if (type === "image/gif") return "gif";
|
||||
return "bin";
|
||||
}
|
||||
|
||||
async function fileToBase64(file) {
|
||||
const buf = await file.arrayBuffer();
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buf);
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function sendDroppedImage(term, ws, file) {
|
||||
if (!file?.type?.startsWith("image/")) return;
|
||||
if (ws.readyState !== 1) return;
|
||||
|
||||
const ext = extensionFor(file.type);
|
||||
const baseName = cleanName(file.name).replace(/\.[a-zA-Z0-9]+$/, "");
|
||||
const stamp = Date.now();
|
||||
const imagePath = `/tmp/claude-images/${baseName || "image"}-${stamp}.${ext}`;
|
||||
const b64 = await fileToBase64(file);
|
||||
|
||||
const cmd = [
|
||||
"python3 - <<'PY'",
|
||||
"import base64, pathlib",
|
||||
`p = pathlib.Path(${JSON.stringify(imagePath)})`,
|
||||
"p.parent.mkdir(parents=True, exist_ok=True)",
|
||||
`p.write_bytes(base64.b64decode(${JSON.stringify(b64)}))`,
|
||||
`print(f'saved image -> {p}')`,
|
||||
"PY",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
ws.send(new TextEncoder().encode(cmd));
|
||||
term.write(`\r\n\x1b[90m[Uploaded image to ${imagePath}]\x1b[0m\r\n`);
|
||||
}
|
||||
|
||||
function addTab(hostId) {
|
||||
const id = ++tabCounter;
|
||||
const host = hostById(hostId);
|
||||
tabs = [...tabs, {
|
||||
id,
|
||||
hostId,
|
||||
label: host.label,
|
||||
connected: false,
|
||||
error: "",
|
||||
statusBanner: host.persistent ? "Connecting to persistent session..." : "",
|
||||
persistent: !!host.persistent,
|
||||
}];
|
||||
activeTab = id;
|
||||
showMenu = false;
|
||||
}
|
||||
|
||||
function closeTab(id) {
|
||||
const d = tabData.get(id);
|
||||
if (d) {
|
||||
d.closedManually = true;
|
||||
if (d.reconnectTimer) clearTimeout(d.reconnectTimer);
|
||||
d.voice?.destroy();
|
||||
d.ro?.disconnect();
|
||||
if (d.heartbeat) clearInterval(d.heartbeat);
|
||||
d.term?.element?.removeEventListener("dragover", d.handleDragOver);
|
||||
d.term?.element?.removeEventListener("drop", d.handleDrop);
|
||||
d.term?.textarea?.removeEventListener("paste", d.handlePaste);
|
||||
d.ws?.close();
|
||||
d.term?.dispose();
|
||||
tabData.delete(id);
|
||||
}
|
||||
tabs = tabs.filter((t) => t.id !== id);
|
||||
if (activeTab === id) activeTab = tabs.length ? tabs[tabs.length - 1].id : null;
|
||||
}
|
||||
|
||||
function updateTab(tabId, props) {
|
||||
tabs = tabs.map((t) => (t.id === tabId ? { ...t, ...props } : t));
|
||||
}
|
||||
|
||||
function initTerminal(el, tab) {
|
||||
const tabId = tab.id;
|
||||
(async () => {
|
||||
const { Terminal } = await import("@xterm/xterm");
|
||||
const { FitAddon } = await import("@xterm/addon-fit");
|
||||
await import("@xterm/xterm/css/xterm.css");
|
||||
const term = new Terminal({
|
||||
cursorBlink: true, fontSize: 13,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', 'Menlo', monospace",
|
||||
lineHeight: 1.4,
|
||||
theme: currentTheme(),
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(el);
|
||||
fit.fit();
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
let heartbeat = null;
|
||||
let pongTimeout = null;
|
||||
let reconnectTimer = null;
|
||||
let closedManually = false;
|
||||
let reconnecting = false;
|
||||
let authRecoveryInFlight = false;
|
||||
let reconnectAttempts = 0;
|
||||
let consecutiveAuthFailures = 0;
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
const MAX_AUTH_FAILURES = 3;
|
||||
const PING_INTERVAL = 30000; // 30s between pings
|
||||
const PONG_TIMEOUT = 10000; // 10s to receive pong
|
||||
|
||||
function clearHeartbeat() {
|
||||
if (heartbeat) {
|
||||
clearInterval(heartbeat);
|
||||
heartbeat = null;
|
||||
}
|
||||
if (pongTimeout) {
|
||||
clearTimeout(pongTimeout);
|
||||
pongTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthExpired(reason = "Session expired — please log in again") {
|
||||
clearHeartbeat();
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
reconnecting = false;
|
||||
updateTab(tabId, {
|
||||
connected: false,
|
||||
error: reason,
|
||||
statusBanner: "",
|
||||
});
|
||||
term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`);
|
||||
const d = tabData.get(tabId);
|
||||
if (d) {
|
||||
d.ws = null;
|
||||
d.heartbeat = heartbeat;
|
||||
d.reconnectTimer = reconnectTimer;
|
||||
d.closedManually = closedManually;
|
||||
d.reconnecting = reconnecting;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(reason) {
|
||||
clearHeartbeat();
|
||||
if (closedManually || reconnectTimer) return;
|
||||
reconnecting = true;
|
||||
reconnectAttempts++;
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), MAX_RECONNECT_DELAY);
|
||||
updateTab(tabId, {
|
||||
connected: false,
|
||||
error: `${reason} — reconnecting in ${Math.round(delay / 1000)}s (attempt ${reconnectAttempts})...`,
|
||||
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
|
||||
});
|
||||
term.write(`\r\n\x1b[90m[${reason} — reconnecting in ${Math.round(delay / 1000)}s...]\x1b[0m`);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (!tabData.has(tabId) || closedManually) return;
|
||||
connect(true);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function sendSize(ws) {
|
||||
if (ws.readyState === 1) {
|
||||
const buf = new Uint8Array(5);
|
||||
buf[0] = 0x01;
|
||||
new DataView(buf.buffer).setUint16(1, term.cols);
|
||||
new DataView(buf.buffer).setUint16(3, term.rows);
|
||||
ws.send(buf);
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(forceRefresh = false) {
|
||||
let d = tabData.get(tabId);
|
||||
|
||||
if (forceRefresh) {
|
||||
const refreshed = await tryRefreshSession();
|
||||
if (!refreshed) {
|
||||
handleAuthExpired();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const ws = new WebSocket(
|
||||
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}`
|
||||
);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempts = 0;
|
||||
consecutiveAuthFailures = 0; // Reset auth failure counter on successful connection
|
||||
updateTab(tabId, {
|
||||
connected: true,
|
||||
error: "",
|
||||
statusBanner: tab.persistent ? "Connected. Waiting for persistent session status..." : "",
|
||||
});
|
||||
sendSize(ws);
|
||||
clearHeartbeat();
|
||||
heartbeat = setInterval(() => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send("__ping__");
|
||||
// Set timeout to detect missing pong
|
||||
pongTimeout = setTimeout(() => {
|
||||
// Check if still the same WebSocket instance and still open
|
||||
const currentTab = tabData.get(tabId);
|
||||
if (currentTab?.ws === ws && ws.readyState === 1) {
|
||||
ws.close(1000, "keepalive ping timeout");
|
||||
}
|
||||
}, PONG_TIMEOUT);
|
||||
}
|
||||
}, PING_INTERVAL);
|
||||
const d = tabData.get(tabId);
|
||||
if (d) {
|
||||
d.ws = ws;
|
||||
d.heartbeat = heartbeat;
|
||||
d.reconnectTimer = reconnectTimer;
|
||||
d.closedManually = closedManually;
|
||||
d.reconnecting = reconnecting;
|
||||
d.voice?.attach(term, ws);
|
||||
}
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
// Check for text pong response
|
||||
if (typeof e.data === 'string' && e.data === '__pong__') {
|
||||
// Clear the pong timeout - connection is alive
|
||||
if (pongTimeout) {
|
||||
clearTimeout(pongTimeout);
|
||||
pongTimeout = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = new Uint8Array(e.data);
|
||||
const isPersistenceMessage = tab.persistent
|
||||
&& data.length >= PERSISTENCE_STATUS_PREFIX_BYTES.length
|
||||
&& PERSISTENCE_STATUS_PREFIX_BYTES.every((byte, index) => data[index] === byte);
|
||||
if (isPersistenceMessage) {
|
||||
const status = new TextDecoder().decode(data.slice(PERSISTENCE_STATUS_PREFIX_BYTES.length)).trim();
|
||||
updateTab(tabId, { statusBanner: persistenceLabel(status, reconnecting) });
|
||||
reconnecting = false;
|
||||
const d = tabData.get(tabId);
|
||||
if (d) d.reconnecting = reconnecting;
|
||||
return;
|
||||
}
|
||||
term.write(data);
|
||||
};
|
||||
ws.onclose = async (e) => {
|
||||
const reason = e.reason || `Connection closed (code ${e.code})`;
|
||||
const tabState = tabData.get(tabId);
|
||||
if (tabState) tabState.ws = null;
|
||||
|
||||
// Handle auth failures (code 1008)
|
||||
if (e.code === 1008) {
|
||||
consecutiveAuthFailures++;
|
||||
|
||||
// Stop after MAX_AUTH_FAILURES consecutive auth failures
|
||||
if (consecutiveAuthFailures >= MAX_AUTH_FAILURES) {
|
||||
handleAuthExpired(`Session expired after ${MAX_AUTH_FAILURES} attempts — please refresh page`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to refresh session
|
||||
if (!closedManually && !authRecoveryInFlight) {
|
||||
authRecoveryInFlight = true;
|
||||
try {
|
||||
const refreshed = await tryRefreshSession();
|
||||
if (refreshed && !closedManually) {
|
||||
reconnecting = true;
|
||||
updateTab(tabId, {
|
||||
connected: false,
|
||||
error: `Refreshing session (attempt ${consecutiveAuthFailures}/${MAX_AUTH_FAILURES})...`,
|
||||
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
|
||||
});
|
||||
term.write(`\r\n\x1b[90m[Refreshing session...]\x1b[0m`);
|
||||
void connect(false);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Session refresh failed:", err);
|
||||
} finally {
|
||||
authRecoveryInFlight = false;
|
||||
}
|
||||
}
|
||||
handleAuthExpired("Session expired — please log in again");
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-auth failures: continue with normal reconnection
|
||||
if (!closedManually) scheduleReconnect(reason);
|
||||
else {
|
||||
clearHeartbeat();
|
||||
updateTab(tabId, { connected: false, error: reason });
|
||||
term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
const tabState = tabData.get(tabId);
|
||||
if (tabState) tabState.ws = null;
|
||||
if (!closedManually) scheduleReconnect("Connection failed");
|
||||
else updateTab(tabId, { connected: false, error: "Connection failed" });
|
||||
};
|
||||
|
||||
d = tabData.get(tabId);
|
||||
if (d) {
|
||||
d.ws = ws;
|
||||
d.heartbeat = heartbeat;
|
||||
d.reconnectTimer = reconnectTimer;
|
||||
d.closedManually = closedManually;
|
||||
d.reconnecting = reconnecting;
|
||||
}
|
||||
return ws;
|
||||
}
|
||||
|
||||
let ws = null;
|
||||
void connect().then((connectedWs) => {
|
||||
ws = connectedWs;
|
||||
if (connectedWs) voice.attach(term, connectedWs);
|
||||
});
|
||||
term.onData((data) => {
|
||||
const currentWs = tabData.get(tabId)?.ws;
|
||||
if (currentWs?.readyState === 1) currentWs.send(new TextEncoder().encode(data));
|
||||
});
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = async (e) => {
|
||||
e.preventDefault();
|
||||
if (activeTab !== tabId) return;
|
||||
const currentWs = tabData.get(tabId)?.ws;
|
||||
const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type?.startsWith("image/"));
|
||||
for (const file of files) {
|
||||
await sendDroppedImage(term, currentWs, file);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async (e) => {
|
||||
if (activeTab !== tabId) return;
|
||||
const currentWs = tabData.get(tabId)?.ws;
|
||||
const items = [...(e.clipboardData?.items || [])]
|
||||
.filter((it) => it.kind === "file" && it.type?.startsWith("image/"));
|
||||
for (const item of items) {
|
||||
const file = item.getAsFile();
|
||||
if (file) await sendDroppedImage(term, currentWs, file);
|
||||
}
|
||||
};
|
||||
|
||||
term.element?.addEventListener("dragover", handleDragOver);
|
||||
term.element?.addEventListener("drop", handleDrop);
|
||||
term.textarea?.addEventListener("paste", handlePaste);
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
fit.fit();
|
||||
const currentWs = tabData.get(tabId)?.ws;
|
||||
if (currentWs) sendSize(currentWs);
|
||||
});
|
||||
ro.observe(el);
|
||||
const voice = new VoiceSession(() => { voiceTick++; });
|
||||
voice.attach(term, ws);
|
||||
tabData.set(tabId, { ws, term, ro, el, voice, heartbeat, reconnectTimer, closedManually, reconnecting, handleDragOver, handleDrop, handlePaste });
|
||||
})();
|
||||
}
|
||||
|
||||
function activeVoice() { return voiceTick >= 0 && tabData.get(activeTab)?.voice; }
|
||||
|
||||
function toggleFullscreen() {
|
||||
const elem = document.documentElement;
|
||||
if (!document.fullscreenElement) {
|
||||
elem.requestFullscreen().then(() => {
|
||||
isFullscreen = true;
|
||||
showActionButtons = true;
|
||||
}).catch(err => {
|
||||
console.error('Failed to enter fullscreen:', err);
|
||||
});
|
||||
} else {
|
||||
document.exitFullscreen().then(() => {
|
||||
isFullscreen = false;
|
||||
showActionButtons = false;
|
||||
}).catch(err => {
|
||||
console.error('Failed to exit fullscreen:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sendKey(key) {
|
||||
const currentWs = tabData.get(activeTab)?.ws;
|
||||
if (currentWs?.readyState === 1) {
|
||||
currentWs.send(new TextEncoder().encode(key));
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
syncTheme();
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
const requestedHost = new URLSearchParams(window.location.search).get("host");
|
||||
if (requestedHost && !tabs.length && hostById(requestedHost)) addTab(requestedHost);
|
||||
|
||||
// Listen for fullscreen changes
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
isFullscreen = !!document.fullscreenElement;
|
||||
showActionButtons = !!document.fullscreenElement;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
themeObserver.disconnect();
|
||||
tabData.forEach((d) => {
|
||||
d.closedManually = true;
|
||||
if (d.reconnectTimer) clearTimeout(d.reconnectTimer);
|
||||
d.voice?.destroy();
|
||||
d.ro?.disconnect();
|
||||
if (d.heartbeat) clearInterval(d.heartbeat);
|
||||
d.term?.element?.removeEventListener("dragover", d.handleDragOver);
|
||||
d.term?.element?.removeEventListener("drop", d.handleDrop);
|
||||
d.term?.textarea?.removeEventListener("paste", d.handlePaste);
|
||||
d.ws?.close();
|
||||
d.term?.dispose();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<div class={activeVoice()?.voiceMode ? 'flex flex-col h-[calc(100vh-4rem)] overflow-hidden gap-1' : 'space-y-2'}>
|
||||
{#if !activeVoice()?.voiceMode}
|
||||
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100 tracking-tight">Terminal</h1>
|
||||
{/if}
|
||||
|
||||
<!-- iPhone Landscape Action Buttons (only in fullscreen) -->
|
||||
{#if showActionButtons && activeTab}
|
||||
<!-- Top Left Pocket -->
|
||||
<div class="actions-top-left">
|
||||
<button class="action-btn" onclick={() => sendKey('\x1b')} title="ESC">ESC</button>
|
||||
<button class="action-btn" onclick={() => sendKey('\t')} title="TAB">TAB</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Left Pocket -->
|
||||
<div class="actions-bottom-left">
|
||||
<button class="action-btn" onclick={() => sendKey('\x01')} title="CTRL+A">^A</button>
|
||||
<button class="action-btn" onclick={() => sendKey('\x03')} title="CTRL+C">^C</button>
|
||||
</div>
|
||||
|
||||
<!-- Top Right Pocket -->
|
||||
<div class="actions-top-right">
|
||||
<button class="action-btn" onclick={() => sendKey('\x0c')} title="CTRL+L">^L</button>
|
||||
<button class="action-btn" onclick={() => sendKey('\x1a')} title="CTRL+Z">^Z</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Right Pocket -->
|
||||
<div class="actions-bottom-right">
|
||||
<button class="action-btn" onclick={() => sendKey('\x04')} title="CTRL+D">^D</button>
|
||||
<button class="action-btn" onclick={() => sendKey('\x12')} title="CTRL+R">^R</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-1 border-b border-surface-300 dark:border-surface-700 pb-1 relative">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-lg transition-colors {activeTab === tab.id ? 'bg-surface-200 dark:bg-surface-800 text-surface-900 dark:text-surface-100' : 'text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800/50'}"
|
||||
onclick={() => activeTab = tab.id}
|
||||
>
|
||||
{#if tab.connected}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
||||
{:else if tab.error}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-500"></span>
|
||||
{:else}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></span>
|
||||
{/if}
|
||||
{tab.label}
|
||||
{#if tab.persistent}
|
||||
<span class="rounded bg-indigo-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">tmux</span>
|
||||
{/if}
|
||||
<span
|
||||
class="ml-1 text-surface-500 hover:text-sky-500 cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="Open fresh session in new browser tab"
|
||||
aria-label={`Open fresh ${tab.label} session in new browser tab`}
|
||||
onclick={(e) => { e.stopPropagation(); openFreshSession(tab); }}
|
||||
onkeydown={(e) => e.key === 'Enter' && openFreshSession(tab)}
|
||||
>↗</span>
|
||||
<span
|
||||
class="ml-1 text-surface-500 hover:text-rose-400 cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => { e.stopPropagation(); closeTab(tab.id); }}
|
||||
onkeydown={(e) => e.key === 'Enter' && closeTab(tab.id)}
|
||||
>×</span>
|
||||
</button>
|
||||
{/each}
|
||||
<div class="relative">
|
||||
<button
|
||||
class="px-2 py-1 text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:bg-surface-200 dark:hover:bg-surface-800 rounded text-lg leading-none"
|
||||
onclick={() => showMenu = !showMenu}
|
||||
>+</button>
|
||||
{#if showMenu}
|
||||
<div class="absolute top-full left-0 mt-1 bg-surface-100 dark:bg-surface-800 border border-surface-300 dark:border-surface-600 rounded-lg shadow-xl z-10 py-1 min-w-[140px]">
|
||||
{#each hosts as h}
|
||||
<button
|
||||
class="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm text-surface-800 dark:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
|
||||
onclick={() => addTab(h.id)}
|
||||
>
|
||||
<span>{h.label}</span>
|
||||
{#if h.persistent}
|
||||
<span class="rounded bg-indigo-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">persistent</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if activeTab && tabs.find(t => t.id === activeTab)?.connected}
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
class="px-2 py-1 rounded text-sm transition-colors text-surface-600 dark:text-surface-500 hover:text-surface-800 dark:hover:text-surface-300"
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
onclick={toggleFullscreen}
|
||||
>
|
||||
{#if isFullscreen}
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"/></svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
{#if activeVoice()}
|
||||
{@const v = activeVoice()}
|
||||
<button
|
||||
class="px-2 py-1 rounded text-sm transition-colors {v.voiceMode ? 'text-rose-400' : v.sttAvailable ? 'text-surface-600 dark:text-surface-500 hover:text-surface-800 dark:hover:text-surface-300' : 'text-surface-300 dark:text-surface-700 cursor-not-allowed'}"
|
||||
title={!v.sttAvailable ? 'STT not supported' : v.voiceMode ? 'Voice mode on' : 'Enter voice mode'}
|
||||
disabled={!v.sttAvailable}
|
||||
onclick={() => { if (!v.voiceMode) v.enterVoiceMode(); else v.exitVoiceMode(); voiceTick++; }}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-surface-600 dark:text-surface-500">
|
||||
Tip: drag & drop or paste images into the active terminal tab to upload them to <code class="text-surface-700 dark:text-surface-400">/tmp/claude-images</code>.
|
||||
</p>
|
||||
|
||||
{#if activeTab && tabs.find(t => t.id === activeTab)?.statusBanner}
|
||||
<div class="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-800 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200">
|
||||
{tabs.find(t => t.id === activeTab)?.statusBanner}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !tabs.length}
|
||||
<div class="flex items-center justify-center h-[calc(100vh-12rem)] text-surface-600 dark:text-surface-500 text-sm">
|
||||
Click + to open a terminal
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each tabs as tab (tab.id)}
|
||||
<div class="{activeTab === tab.id ? '' : 'hidden'} {activeVoice()?.voiceMode ? 'flex-1 min-h-0' : ''}">
|
||||
<div
|
||||
use:initTerminal={tab}
|
||||
class="h-full rounded-xl overflow-hidden shadow-lg border border-surface-300 dark:border-surface-700 bg-surface-50 dark:bg-[#0f172a] transition-all duration-300"
|
||||
style="{activeVoice()?.voiceMode ? '' : 'height: calc(100vh - 10rem);'}"
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if activeVoice()?.voiceMode}
|
||||
{@const v = activeVoice()}
|
||||
<div class="mt-1 shrink-0 rounded-xl bg-surface-100/95 dark:bg-surface-800/95 border border-surface-300 dark:border-surface-700 shadow-xl overflow-hidden animate-slide-up">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-3 py-1.5 border-b border-surface-300/50 dark:border-surface-700/50">
|
||||
<span class="text-xs font-medium text-surface-700 dark:text-surface-300">Voice Mode</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
class="bg-surface-200 dark:bg-surface-700 text-surface-800 dark:text-surface-300 text-xs rounded px-1 py-0.5 outline-none max-w-[120px]"
|
||||
onchange={(e) => { v.setVoice(+e.target.value); voiceTick++; }}
|
||||
>
|
||||
{#each v.voices as voice, i}
|
||||
<option value={i} selected={i === v.voiceIdx}>{voice.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
|
||||
onclick={() => { v.cycleLang(); voiceTick++; }}
|
||||
>{v.langLabel}</button>
|
||||
<button
|
||||
class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
|
||||
onclick={() => { v.cycleTalkMode(); voiceTick++; }}
|
||||
>{v.modeLabel}</button>
|
||||
<button
|
||||
class="px-1.5 py-0.5 rounded text-xs text-surface-500 hover:text-rose-400"
|
||||
onclick={() => { v.exitVoiceMode(); voiceTick++; }}
|
||||
>×</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Transcript + Mic -->
|
||||
<div class="flex items-center gap-3 px-3 py-2">
|
||||
{#if v.talkMode === "hold"}
|
||||
<button
|
||||
aria-label="Hold to talk"
|
||||
class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600'}"
|
||||
onpointerdown={() => { v.startHold(); voiceTick++; }}
|
||||
onpointerup={() => { v.releaseHold(); voiceTick++; }}
|
||||
onpointerleave={() => { if (v.listening) { v.releaseHold(); voiceTick++; } }}
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
||||
</button>
|
||||
{:else if v.talkMode === "tap"}
|
||||
<button
|
||||
aria-label="Tap to talk"
|
||||
class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600'}"
|
||||
onclick={() => { v.tapMic(); voiceTick++; }}
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center {v.listening ? 'bg-emerald-500/20 text-emerald-400 animate-pulse' : 'bg-surface-200 dark:bg-surface-700 text-surface-500'}">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-1 min-w-0">
|
||||
{#if v.transcript}
|
||||
<p class="text-sm text-surface-600 dark:text-surface-400 italic truncate">"{v.transcript}"</p>
|
||||
{:else}
|
||||
<p class="text-xs text-surface-500 dark:text-surface-600">{v.speaking ? 'Speaking...' : v.listening ? 'Listening...' : 'Ready'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
.animate-slide-up { animation: slide-up 0.25s ease-out; }
|
||||
|
||||
/* iPhone 17 Pro Landscape Action Button Zones */
|
||||
.actions-top-left,
|
||||
.actions-bottom-left,
|
||||
.actions-top-right,
|
||||
.actions-bottom-right {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.actions-top-left {
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
width: 52px;
|
||||
height: 125px;
|
||||
}
|
||||
|
||||
.actions-bottom-left {
|
||||
bottom: 26px;
|
||||
left: 5px;
|
||||
width: 52px;
|
||||
height: 110px;
|
||||
}
|
||||
|
||||
.actions-top-right {
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 52px;
|
||||
height: 125px;
|
||||
}
|
||||
|
||||
.actions-bottom-right {
|
||||
bottom: 26px;
|
||||
right: 5px;
|
||||
width: 52px;
|
||||
height: 110px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
background: rgba(99, 102, 241, 0.9);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
background: rgba(79, 70, 229, 1);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Terminal container respects safe areas */
|
||||
.terminal-safe-container {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user