init commit
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "spiceflow-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.1",
|
||||
"@sveltejs/kit": "^2.5.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
||||
"@types/node": "^20.11.0",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
"svelte": "^4.2.9",
|
||||
"svelte-check": "^3.6.3",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.12",
|
||||
"vite-plugin-pwa": "^0.19.2",
|
||||
"workbox-window": "^7.0.0"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply antialiased;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-zinc-800;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-zinc-600 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-zinc-500;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-spice-500 hover:bg-spice-600 text-white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-zinc-700 hover:bg-zinc-600 text-zinc-100;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-zinc-800 rounded-xl p-4 border border-zinc-700;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg
|
||||
text-zinc-100 placeholder:text-zinc-500
|
||||
focus:outline-none focus:ring-2 focus:ring-spice-500 focus:border-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 1rem);
|
||||
}
|
||||
|
||||
.safe-top {
|
||||
padding-top: env(safe-area-inset-top, 0);
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<meta name="theme-color" content="#f97316" />
|
||||
<meta name="description" content="Spiceflow - AI Session Orchestration" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" class="bg-zinc-900 text-zinc-100">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,236 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
provider: 'claude' | 'opencode';
|
||||
'external-id'?: string;
|
||||
externalId?: string;
|
||||
title?: string;
|
||||
'working-dir'?: string;
|
||||
workingDir?: string;
|
||||
status: 'idle' | 'running' | 'completed';
|
||||
'created-at'?: string;
|
||||
createdAt?: string;
|
||||
'updated-at'?: string;
|
||||
updatedAt?: string;
|
||||
messages?: Message[];
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
'session-id': string;
|
||||
sessionId?: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
'created-at'?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface DiscoveredSession {
|
||||
'external-id': string;
|
||||
provider: 'claude' | 'opencode';
|
||||
title?: string;
|
||||
'working-dir'?: string;
|
||||
'file-path'?: string;
|
||||
}
|
||||
|
||||
export interface StreamEvent {
|
||||
event?: string;
|
||||
'session-id'?: string;
|
||||
sessionId?: string;
|
||||
text?: string;
|
||||
content?: string;
|
||||
type?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string = API_BASE) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
private async request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(error.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Sessions
|
||||
async getSessions(): Promise<Session[]> {
|
||||
return this.request<Session[]>('/sessions');
|
||||
}
|
||||
|
||||
async getSession(id: string): Promise<Session> {
|
||||
return this.request<Session>(`/sessions/${id}`);
|
||||
}
|
||||
|
||||
async createSession(session: Partial<Session>): Promise<Session> {
|
||||
return this.request<Session>('/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(session)
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSession(id: string): Promise<void> {
|
||||
await this.request<void>(`/sessions/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async sendMessage(sessionId: string, message: string): Promise<{ status: string }> {
|
||||
return this.request<{ status: string }>(`/sessions/${sessionId}/send`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message })
|
||||
});
|
||||
}
|
||||
|
||||
// Discovery
|
||||
async discoverClaude(): Promise<DiscoveredSession[]> {
|
||||
return this.request<DiscoveredSession[]>('/discover/claude');
|
||||
}
|
||||
|
||||
async discoverOpenCode(): Promise<DiscoveredSession[]> {
|
||||
return this.request<DiscoveredSession[]>('/discover/opencode');
|
||||
}
|
||||
|
||||
async importSession(session: DiscoveredSession): Promise<Session> {
|
||||
return this.request<Session>('/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(session)
|
||||
});
|
||||
}
|
||||
|
||||
// Health
|
||||
async health(): Promise<{ status: string; service: string }> {
|
||||
return this.request<{ status: string; service: string }>('/health');
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
|
||||
// WebSocket connection
|
||||
export class WebSocketClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private reconnectDelay = 1000;
|
||||
private listeners: Map<string, Set<(event: StreamEvent) => void>> = new Map();
|
||||
private globalListeners: Set<(event: StreamEvent) => void> = new Set();
|
||||
|
||||
constructor(url: string = `ws://${window.location.host}/api/ws`) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
this.reconnectAttempts = 0;
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
this.attemptReconnect();
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as StreamEvent;
|
||||
this.handleMessage(data);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WebSocket message:', e);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(event: StreamEvent) {
|
||||
// Notify global listeners
|
||||
this.globalListeners.forEach((listener) => listener(event));
|
||||
|
||||
// Notify session-specific listeners
|
||||
const sessionId = event['session-id'] || event.sessionId;
|
||||
if (sessionId) {
|
||||
const sessionListeners = this.listeners.get(sessionId);
|
||||
sessionListeners?.forEach((listener) => listener(event));
|
||||
}
|
||||
}
|
||||
|
||||
private attemptReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('Max reconnection attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
|
||||
this.connect().catch(() => {});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
subscribe(sessionId: string, callback: (event: StreamEvent) => void) {
|
||||
if (!this.listeners.has(sessionId)) {
|
||||
this.listeners.set(sessionId, new Set());
|
||||
}
|
||||
this.listeners.get(sessionId)!.add(callback);
|
||||
|
||||
// Send subscribe message
|
||||
this.send({ type: 'subscribe', 'session-id': sessionId });
|
||||
|
||||
return () => {
|
||||
this.listeners.get(sessionId)?.delete(callback);
|
||||
this.send({ type: 'unsubscribe', 'session-id': sessionId });
|
||||
};
|
||||
}
|
||||
|
||||
onMessage(callback: (event: StreamEvent) => void) {
|
||||
this.globalListeners.add(callback);
|
||||
return () => this.globalListeners.delete(callback);
|
||||
}
|
||||
|
||||
send(data: Record<string, unknown>) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const wsClient = new WebSocketClient();
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let disabled = false;
|
||||
export let placeholder = 'Type a message...';
|
||||
|
||||
const dispatch = createEventDispatcher<{ send: string }>();
|
||||
|
||||
let message = '';
|
||||
let textarea: HTMLTextAreaElement;
|
||||
|
||||
function handleSubmit() {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
|
||||
dispatch('send', trimmed);
|
||||
message = '';
|
||||
resizeTextarea();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
function resizeTextarea() {
|
||||
if (!textarea) return;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px';
|
||||
}
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit} class="border-t border-zinc-700 bg-zinc-900 safe-bottom">
|
||||
<div class="flex items-end gap-2 p-3">
|
||||
<textarea
|
||||
bind:this={textarea}
|
||||
bind:value={message}
|
||||
on:keydown={handleKeydown}
|
||||
on:input={resizeTextarea}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
rows="1"
|
||||
class="input resize-none min-h-[44px] max-h-[150px] py-3"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || !message.trim()}
|
||||
class="btn btn-primary h-[44px] px-4 flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import type { Message } from '$lib/api';
|
||||
import { onMount, afterUpdate } from 'svelte';
|
||||
|
||||
export let messages: Message[] = [];
|
||||
export let streamingContent: string = '';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let autoScroll = true;
|
||||
|
||||
function scrollToBottom() {
|
||||
if (autoScroll && container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (!container) return;
|
||||
const threshold = 100;
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
autoScroll = distanceFromBottom < threshold;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
const roleStyles = {
|
||||
user: 'bg-spice-500/20 border-spice-500/30',
|
||||
assistant: 'bg-zinc-800 border-zinc-700',
|
||||
system: 'bg-blue-500/20 border-blue-500/30 text-blue-200'
|
||||
};
|
||||
|
||||
const roleLabels = {
|
||||
user: 'You',
|
||||
assistant: 'Assistant',
|
||||
system: 'System'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
on:scroll={handleScroll}
|
||||
class="flex-1 overflow-y-auto px-4 py-4 space-y-4"
|
||||
>
|
||||
{#if messages.length === 0 && !streamingContent}
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="text-center text-zinc-500">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-12 w-12 mx-auto mb-3 opacity-50"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
<p>No messages yet</p>
|
||||
<p class="text-sm mt-1">Send a message to start the conversation</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#each messages as message (message.id)}
|
||||
<div class="rounded-lg border p-3 {roleStyles[message.role]}">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
class="text-xs font-semibold uppercase tracking-wide {message.role === 'user'
|
||||
? 'text-spice-400'
|
||||
: 'text-zinc-400'}"
|
||||
>
|
||||
{roleLabels[message.role]}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm whitespace-pre-wrap break-words font-mono leading-relaxed">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if streamingContent}
|
||||
<div class="rounded-lg border p-3 {roleStyles.assistant}">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
||||
Assistant
|
||||
</span>
|
||||
<span class="flex gap-1">
|
||||
<span class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"></span>
|
||||
<span
|
||||
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
||||
style="animation-delay: 0.1s"
|
||||
></span>
|
||||
<span
|
||||
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
||||
style="animation-delay: 0.2s"
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm whitespace-pre-wrap break-words font-mono leading-relaxed">
|
||||
{streamingContent}<span class="animate-pulse">|</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import type { Session } from '$lib/api';
|
||||
|
||||
export let session: Session;
|
||||
|
||||
$: externalId = session['external-id'] || session.externalId || '';
|
||||
$: workingDir = session['working-dir'] || session.workingDir || '';
|
||||
$: updatedAt = session['updated-at'] || session.updatedAt || session['created-at'] || session.createdAt || '';
|
||||
$: shortId = externalId.slice(0, 8);
|
||||
$: projectName = workingDir.split('/').pop() || workingDir;
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const date = new Date(iso);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days}d ago`;
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
return 'Just now';
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
idle: 'bg-zinc-600',
|
||||
running: 'bg-green-500 animate-pulse',
|
||||
completed: 'bg-blue-500'
|
||||
};
|
||||
|
||||
const providerColors = {
|
||||
claude: 'text-spice-400',
|
||||
opencode: 'text-emerald-400'
|
||||
};
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/session/{session.id}"
|
||||
class="card block hover:border-spice-500/50 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="w-2 h-2 rounded-full {statusColors[session.status]}"></span>
|
||||
<span class="text-xs font-medium uppercase tracking-wide {providerColors[session.provider]}">
|
||||
{session.provider}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="font-medium text-zinc-100 truncate">
|
||||
{session.title || `Session ${shortId}`}
|
||||
</h3>
|
||||
|
||||
{#if projectName}
|
||||
<p class="text-sm text-zinc-400 truncate mt-1">
|
||||
{projectName}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-right flex-shrink-0">
|
||||
<span class="text-xs text-zinc-500">{formatTime(updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,3 @@
|
||||
// Spiceflow client library exports
|
||||
export * from './api';
|
||||
export * from './stores/sessions';
|
||||
@@ -0,0 +1,207 @@
|
||||
import { writable, derived, type Readable } from 'svelte/store';
|
||||
import { api, wsClient, type Session, type Message, type StreamEvent } from '$lib/api';
|
||||
|
||||
interface SessionsState {
|
||||
sessions: Session[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface ActiveSessionState {
|
||||
session: Session | null;
|
||||
messages: Message[];
|
||||
streamingContent: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function createSessionsStore() {
|
||||
const { subscribe, set, update } = writable<SessionsState>({
|
||||
sessions: [],
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
async load() {
|
||||
update((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const sessions = await api.getSessions();
|
||||
update((s) => ({ ...s, sessions, loading: false }));
|
||||
} catch (e) {
|
||||
update((s) => ({ ...s, loading: false, error: (e as Error).message }));
|
||||
}
|
||||
},
|
||||
async delete(id: string) {
|
||||
try {
|
||||
await api.deleteSession(id);
|
||||
update((s) => ({
|
||||
...s,
|
||||
sessions: s.sessions.filter((session) => session.id !== id)
|
||||
}));
|
||||
} catch (e) {
|
||||
update((s) => ({ ...s, error: (e as Error).message }));
|
||||
}
|
||||
},
|
||||
updateSession(id: string, data: Partial<Session>) {
|
||||
update((s) => ({
|
||||
...s,
|
||||
sessions: s.sessions.map((session) =>
|
||||
session.id === id ? { ...session, ...data } : session
|
||||
)
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createActiveSessionStore() {
|
||||
const { subscribe, set, update } = writable<ActiveSessionState>({
|
||||
session: null,
|
||||
messages: [],
|
||||
streamingContent: '',
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
let unsubscribeWs: (() => void) | null = null;
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
async load(id: string) {
|
||||
update((s) => ({ ...s, loading: true, error: null, streamingContent: '' }));
|
||||
|
||||
// Unsubscribe from previous session
|
||||
if (unsubscribeWs) {
|
||||
unsubscribeWs();
|
||||
unsubscribeWs = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await api.getSession(id);
|
||||
update((s) => ({
|
||||
...s,
|
||||
session,
|
||||
messages: session.messages || [],
|
||||
loading: false
|
||||
}));
|
||||
|
||||
// Subscribe to WebSocket updates
|
||||
await wsClient.connect();
|
||||
unsubscribeWs = wsClient.subscribe(id, (event) => {
|
||||
handleStreamEvent(event);
|
||||
});
|
||||
} catch (e) {
|
||||
update((s) => ({ ...s, loading: false, error: (e as Error).message }));
|
||||
}
|
||||
},
|
||||
async sendMessage(message: string) {
|
||||
const state = get();
|
||||
if (!state.session) return;
|
||||
|
||||
// Add user message immediately
|
||||
const userMessage: Message = {
|
||||
id: `temp-${Date.now()}`,
|
||||
'session-id': state.session.id,
|
||||
role: 'user',
|
||||
content: message,
|
||||
'created-at': new Date().toISOString()
|
||||
};
|
||||
|
||||
update((s) => ({
|
||||
...s,
|
||||
messages: [...s.messages, userMessage],
|
||||
streamingContent: ''
|
||||
}));
|
||||
|
||||
try {
|
||||
await api.sendMessage(state.session.id, message);
|
||||
} catch (e) {
|
||||
update((s) => ({ ...s, error: (e as Error).message }));
|
||||
}
|
||||
},
|
||||
appendStreamContent(text: string) {
|
||||
update((s) => ({ ...s, streamingContent: s.streamingContent + text }));
|
||||
},
|
||||
finalizeStream() {
|
||||
update((s) => {
|
||||
if (!s.streamingContent || !s.session) return s;
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: `stream-${Date.now()}`,
|
||||
'session-id': s.session.id,
|
||||
role: 'assistant',
|
||||
content: s.streamingContent,
|
||||
'created-at': new Date().toISOString()
|
||||
};
|
||||
|
||||
return {
|
||||
...s,
|
||||
messages: [...s.messages, assistantMessage],
|
||||
streamingContent: ''
|
||||
};
|
||||
});
|
||||
},
|
||||
clear() {
|
||||
if (unsubscribeWs) {
|
||||
unsubscribeWs();
|
||||
unsubscribeWs = null;
|
||||
}
|
||||
set({
|
||||
session: null,
|
||||
messages: [],
|
||||
streamingContent: '',
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function get(): ActiveSessionState {
|
||||
let state: ActiveSessionState;
|
||||
subscribe((s) => (state = s))();
|
||||
return state!;
|
||||
}
|
||||
|
||||
function handleStreamEvent(event: StreamEvent) {
|
||||
if (event.event === 'content-delta' && event.text) {
|
||||
update((s) => ({ ...s, streamingContent: s.streamingContent + event.text }));
|
||||
} else if (event.event === 'message-stop') {
|
||||
update((s) => {
|
||||
if (!s.streamingContent || !s.session) return s;
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: `stream-${Date.now()}`,
|
||||
'session-id': s.session.id,
|
||||
role: 'assistant',
|
||||
content: s.streamingContent,
|
||||
'created-at': new Date().toISOString()
|
||||
};
|
||||
|
||||
return {
|
||||
...s,
|
||||
messages: [...s.messages, assistantMessage],
|
||||
streamingContent: ''
|
||||
};
|
||||
});
|
||||
} else if (event.event === 'error') {
|
||||
update((s) => ({ ...s, error: event.message || 'Stream error' }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const sessions = createSessionsStore();
|
||||
export const activeSession = createActiveSessionStore();
|
||||
|
||||
// Derived stores
|
||||
export const sortedSessions: Readable<Session[]> = derived(sessions, ($sessions) =>
|
||||
[...$sessions.sessions].sort((a, b) => {
|
||||
const aDate = a['updated-at'] || a.updatedAt || a['created-at'] || a.createdAt || '';
|
||||
const bDate = b['updated-at'] || b.updatedAt || b['created-at'] || b.createdAt || '';
|
||||
return bDate.localeCompare(aDate);
|
||||
})
|
||||
);
|
||||
|
||||
export const runningSessions: Readable<Session[]> = derived(sessions, ($sessions) =>
|
||||
$sessions.sessions.filter((s) => s.status === 'running')
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { sessions } from '$lib/stores/sessions';
|
||||
|
||||
onMount(() => {
|
||||
sessions.load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="h-screen flex flex-col bg-zinc-900 text-zinc-100 safe-top">
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
export const prerender = true;
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,245 @@
|
||||
<script lang="ts">
|
||||
import { sessions, sortedSessions, runningSessions } from '$lib/stores/sessions';
|
||||
import { api, type DiscoveredSession } from '$lib/api';
|
||||
import SessionCard from '$lib/components/SessionCard.svelte';
|
||||
|
||||
let discovering = false;
|
||||
let discoveredSessions: DiscoveredSession[] = [];
|
||||
let showDiscovery = false;
|
||||
|
||||
async function refresh() {
|
||||
await sessions.load();
|
||||
}
|
||||
|
||||
async function discoverSessions() {
|
||||
discovering = true;
|
||||
try {
|
||||
const [claude, opencode] = await Promise.all([
|
||||
api.discoverClaude().catch(() => []),
|
||||
api.discoverOpenCode().catch(() => [])
|
||||
]);
|
||||
discoveredSessions = [...claude, ...opencode];
|
||||
showDiscovery = true;
|
||||
} finally {
|
||||
discovering = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function importSession(session: DiscoveredSession) {
|
||||
await api.importSession(session);
|
||||
discoveredSessions = discoveredSessions.filter(
|
||||
(s) => s['external-id'] !== session['external-id']
|
||||
);
|
||||
await sessions.load();
|
||||
if (discoveredSessions.length === 0) {
|
||||
showDiscovery = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Spiceflow</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-spice-500">Spiceflow</h1>
|
||||
<p class="text-xs text-zinc-500">The spice must flow</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
on:click={refresh}
|
||||
disabled={$sessions.loading}
|
||||
class="btn btn-secondary p-2"
|
||||
title="Refresh"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 {$sessions.loading ? 'animate-spin' : ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
on:click={discoverSessions}
|
||||
disabled={discovering}
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{#if discovering}
|
||||
<span class="animate-spin inline-block mr-2">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
Discover
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $runningSessions.length > 0}
|
||||
<div class="mt-2 px-2 py-1.5 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||||
<span class="text-xs text-green-400">
|
||||
{$runningSessions.length} session{$runningSessions.length === 1 ? '' : 's'} running
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
{#if $sessions.error}
|
||||
<div class="m-4 p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
|
||||
{$sessions.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $sessions.loading && $sortedSessions.length === 0}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center text-zinc-500">
|
||||
<svg class="animate-spin h-8 w-8 mx-auto mb-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
<p>Loading sessions...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if $sortedSessions.length === 0}
|
||||
<div class="flex items-center justify-center h-full p-4">
|
||||
<div class="text-center text-zinc-500 max-w-xs">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-16 w-16 mx-auto mb-4 opacity-50"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
<h2 class="text-lg font-medium text-zinc-300 mb-2">No sessions yet</h2>
|
||||
<p class="text-sm mb-4">
|
||||
Click "Discover" to find existing Claude Code or OpenCode sessions on your machine.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4 space-y-3">
|
||||
{#each $sortedSessions as session (session.id)}
|
||||
<SessionCard {session} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<!-- Discovery Modal -->
|
||||
{#if showDiscovery}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-end sm:items-center justify-center"
|
||||
on:click={() => (showDiscovery = false)}
|
||||
on:keydown={(e) => e.key === 'Escape' && (showDiscovery = false)}
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="bg-zinc-900 w-full sm:max-w-lg sm:rounded-xl rounded-t-xl border border-zinc-700 max-h-[80vh] flex flex-col"
|
||||
on:click|stopPropagation
|
||||
on:keydown|stopPropagation
|
||||
role="document"
|
||||
>
|
||||
<div class="p-4 border-b border-zinc-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Discovered Sessions</h2>
|
||||
<button
|
||||
on:click={() => (showDiscovery = false)}
|
||||
class="p-1 hover:bg-zinc-700 rounded"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
{#if discoveredSessions.length === 0}
|
||||
<p class="text-zinc-500 text-center py-8">No new sessions found.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each discoveredSessions as session}
|
||||
<div class="card flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0 mr-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs font-medium uppercase {session.provider === 'claude'
|
||||
? 'text-spice-400'
|
||||
: 'text-emerald-400'}"
|
||||
>
|
||||
{session.provider}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-zinc-300 truncate">
|
||||
{session.title || session['external-id'].slice(0, 8)}
|
||||
</p>
|
||||
{#if session['working-dir']}
|
||||
<p class="text-xs text-zinc-500 truncate">
|
||||
{session['working-dir']}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
on:click={() => importSession(session)}
|
||||
class="btn btn-primary text-sm py-1.5"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { activeSession } from '$lib/stores/sessions';
|
||||
import MessageList from '$lib/components/MessageList.svelte';
|
||||
import InputBar from '$lib/components/InputBar.svelte';
|
||||
|
||||
$: sessionId = $page.params.id;
|
||||
|
||||
onMount(() => {
|
||||
if (sessionId) {
|
||||
activeSession.load(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
activeSession.clear();
|
||||
});
|
||||
|
||||
function handleSend(event: CustomEvent<string>) {
|
||||
activeSession.sendMessage(event.detail);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
goto('/');
|
||||
}
|
||||
|
||||
$: session = $activeSession.session;
|
||||
$: externalId = session?.['external-id'] || session?.externalId || '';
|
||||
$: workingDir = session?.['working-dir'] || session?.workingDir || '';
|
||||
$: shortId = externalId.slice(0, 8);
|
||||
$: projectName = workingDir.split('/').pop() || '';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
idle: 'bg-zinc-600',
|
||||
running: 'bg-green-500 animate-pulse',
|
||||
completed: 'bg-blue-500'
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{session?.title || `Session ${shortId}`} - Spiceflow</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
on:click={goBack}
|
||||
class="p-1 -ml-1 hover:bg-zinc-700 rounded transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if $activeSession.loading}
|
||||
<div class="flex-1">
|
||||
<div class="h-5 w-32 bg-zinc-700 rounded animate-pulse"></div>
|
||||
<div class="h-4 w-24 bg-zinc-800 rounded animate-pulse mt-1"></div>
|
||||
</div>
|
||||
{:else if session}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full {statusColors[session.status] || statusColors.idle}"></span>
|
||||
<h1 class="font-semibold truncate">
|
||||
{session.title || `Session ${shortId}`}
|
||||
</h1>
|
||||
</div>
|
||||
{#if projectName}
|
||||
<p class="text-xs text-zinc-500 truncate">{projectName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="text-xs font-medium uppercase {session.provider === 'claude'
|
||||
? 'text-spice-400'
|
||||
: 'text-emerald-400'}"
|
||||
>
|
||||
{session.provider}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
{#if $activeSession.error}
|
||||
<div class="flex-1 flex items-center justify-center p-4">
|
||||
<div class="text-center">
|
||||
<div class="text-red-400 mb-4">{$activeSession.error}</div>
|
||||
<button on:click={goBack} class="btn btn-secondary">Go Back</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if $activeSession.loading}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<svg class="animate-spin h-8 w-8 text-spice-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<MessageList messages={$activeSession.messages} streamingContent={$activeSession.streamingContent} />
|
||||
|
||||
<InputBar
|
||||
on:send={handleSend}
|
||||
disabled={session?.status === 'running' && $activeSession.streamingContent !== ''}
|
||||
placeholder={session?.status === 'running' ? 'Waiting for response...' : 'Type a message...'}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
||||
<defs>
|
||||
<linearGradient id="spice" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#f97316"/>
|
||||
<stop offset="100%" style="stop-color:#ea580c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="192" height="192" rx="38" fill="#18181b"/>
|
||||
<path d="M96 38 L144 67 L144 125 L96 154 L48 125 L48 67 Z" fill="url(#spice)"/>
|
||||
<circle cx="96" cy="96" r="23" fill="#18181b"/>
|
||||
<circle cx="96" cy="96" r="12" fill="url(#spice)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 531 B |
Symlink
+1
@@ -0,0 +1 @@
|
||||
favicon.svg
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="spice" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#f97316"/>
|
||||
<stop offset="100%" style="stop-color:#ea580c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100" height="100" rx="20" fill="#18181b"/>
|
||||
<path d="M50 20 L75 35 L75 65 L50 80 L25 65 L25 35 Z" fill="url(#spice)"/>
|
||||
<circle cx="50" cy="50" r="12" fill="#18181b"/>
|
||||
<circle cx="50" cy="50" r="6" fill="url(#spice)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 525 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
||||
<defs>
|
||||
<linearGradient id="spice" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#f97316"/>
|
||||
<stop offset="100%" style="stop-color:#ea580c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="192" height="192" rx="38" fill="#18181b"/>
|
||||
<path d="M96 38 L144 67 L144 125 L96 154 L48 125 L48 67 Z" fill="url(#spice)"/>
|
||||
<circle cx="96" cy="96" r="23" fill="#18181b"/>
|
||||
<circle cx="96" cy="96" r="12" fill="url(#spice)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 531 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
||||
<defs>
|
||||
<linearGradient id="spice" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#f97316"/>
|
||||
<stop offset="100%" style="stop-color:#ea580c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="192" height="192" rx="38" fill="#18181b"/>
|
||||
<path d="M96 38 L144 67 L144 125 L96 154 L48 125 L48 67 Z" fill="url(#spice)"/>
|
||||
<circle cx="96" cy="96" r="23" fill="#18181b"/>
|
||||
<circle cx="96" cy="96" r="12" fill="url(#spice)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 531 B |
@@ -0,0 +1,21 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: 'index.html',
|
||||
precompress: false,
|
||||
strict: true
|
||||
}),
|
||||
paths: {
|
||||
base: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,27 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
spice: {
|
||||
50: '#fff7ed',
|
||||
100: '#ffedd5',
|
||||
200: '#fed7aa',
|
||||
300: '#fdba74',
|
||||
400: '#fb923c',
|
||||
500: '#f97316',
|
||||
600: '#ea580c',
|
||||
700: '#c2410c',
|
||||
800: '#9a3412',
|
||||
900: '#7c2d12',
|
||||
950: '#431407'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace']
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
|
||||
manifest: {
|
||||
name: 'Spiceflow',
|
||||
short_name: 'Spiceflow',
|
||||
description: 'AI Session Orchestration - The spice must flow',
|
||||
theme_color: '#f97316',
|
||||
background_color: '#18181b',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
scope: '/',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable'
|
||||
}
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/.*\/api\/.*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
expiration: {
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 60 * 5 // 5 minutes
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true
|
||||
}
|
||||
})
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user