init commit
This commit is contained in:
+42
@@ -0,0 +1,42 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Build outputs
|
||||
client/build/
|
||||
client/.svelte-kit/
|
||||
target/
|
||||
classes/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Clojure
|
||||
.cpcache/
|
||||
.nrepl-port
|
||||
.lsp/
|
||||
.clj-kondo/
|
||||
|
||||
# PWA
|
||||
client/dev-dist/
|
||||
@@ -0,0 +1,154 @@
|
||||
# Spiceflow
|
||||
|
||||
AI Session Orchestration PWA for monitoring and interacting with Claude Code and OpenCode sessions.
|
||||
|
||||
> "The spice must flow."
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────────────┐ ┌─────────────────┐
|
||||
│ Claude Code │◀───▶│ Spiceflow Server │◀───▶│ PWA Client │
|
||||
│ (CLI) │ │ (Clojure) │ │ (Svelte) │
|
||||
└─────────────────┘ │ │ └─────────────────┘
|
||||
│ ┌─────────────────┐ │
|
||||
┌─────────────────┐ │ │ SQLite + DB │ │
|
||||
│ OpenCode │◀───▶│ │ Abstraction │ │
|
||||
│ (CLI) │ │ │ WebSocket/SSE │ │
|
||||
└─────────────────┘ │ └─────────────────┘ │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Clojure CLI (deps.edn)
|
||||
- Node.js 18+ and pnpm
|
||||
- SQLite
|
||||
|
||||
### Server
|
||||
|
||||
```bash
|
||||
cd server
|
||||
clj -M:run
|
||||
```
|
||||
|
||||
The server will start on http://localhost:3000.
|
||||
|
||||
### Client
|
||||
|
||||
```bash
|
||||
cd client
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The client dev server will start on http://localhost:5173 with proxy to the API.
|
||||
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
# Build client
|
||||
cd client
|
||||
pnpm build
|
||||
|
||||
# Run server (serves static files from client/build)
|
||||
cd ../server
|
||||
clj -M:run
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/health` | Health check |
|
||||
| GET | `/api/sessions` | List all tracked sessions |
|
||||
| POST | `/api/sessions` | Create/import a session |
|
||||
| GET | `/api/sessions/:id` | Get session with messages |
|
||||
| DELETE | `/api/sessions/:id` | Delete a session |
|
||||
| POST | `/api/sessions/:id/send` | Send message to session |
|
||||
| GET | `/api/discover/claude` | Discover Claude Code sessions |
|
||||
| GET | `/api/discover/opencode` | Discover OpenCode sessions |
|
||||
| POST | `/api/import` | Import a discovered session |
|
||||
| WS | `/api/ws` | WebSocket for real-time updates |
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Server tests
|
||||
cd server
|
||||
clj -M:test
|
||||
|
||||
# Client type checking
|
||||
cd client
|
||||
pnpm check
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
spiceflow/
|
||||
├── server/ # Clojure backend
|
||||
│ ├── deps.edn
|
||||
│ ├── src/spiceflow/
|
||||
│ │ ├── core.clj # Entry point
|
||||
│ │ ├── config.clj # Configuration
|
||||
│ │ ├── db/
|
||||
│ │ │ ├── protocol.clj # DataStore protocol
|
||||
│ │ │ ├── sqlite.clj # SQLite implementation
|
||||
│ │ │ └── memory.clj # In-memory impl for tests
|
||||
│ │ ├── adapters/
|
||||
│ │ │ ├── protocol.clj # AgentAdapter protocol
|
||||
│ │ │ ├── claude.clj # Claude Code adapter
|
||||
│ │ │ └── opencode.clj # OpenCode adapter
|
||||
│ │ ├── api/
|
||||
│ │ │ ├── routes.clj # REST endpoints
|
||||
│ │ │ └── websocket.clj # WebSocket handlers
|
||||
│ │ └── session/
|
||||
│ │ └── manager.clj # Session lifecycle
|
||||
│ └── test/spiceflow/
|
||||
│
|
||||
├── client/ # SvelteKit PWA
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # SvelteKit routes
|
||||
│ │ └── lib/
|
||||
│ │ ├── api.ts # API client
|
||||
│ │ ├── stores/ # Svelte stores
|
||||
│ │ └── components/ # UI components
|
||||
│ └── static/ # PWA assets
|
||||
│
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Server
|
||||
|
||||
Configuration via `resources/config.edn` or environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SPICEFLOW_PORT` | 3000 | Server port |
|
||||
| `SPICEFLOW_HOST` | 0.0.0.0 | Server host |
|
||||
| `SPICEFLOW_DB` | spiceflow.db | SQLite database path |
|
||||
| `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions directory |
|
||||
| `OPENCODE_CMD` | opencode | OpenCode command |
|
||||
|
||||
### PWA Icons
|
||||
|
||||
Generate PWA icons from the SVG favicon:
|
||||
|
||||
```bash
|
||||
cd client/static
|
||||
# Use a tool like svg2png or imagemagick to generate:
|
||||
# - pwa-192x192.png
|
||||
# - pwa-512x512.png
|
||||
# - apple-touch-icon.png (180x180)
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
{:paths ["src" "resources"]
|
||||
:deps {org.clojure/clojure {:mvn/version "1.11.1"}
|
||||
|
||||
;; Web server
|
||||
ring/ring-core {:mvn/version "1.10.0"}
|
||||
ring/ring-jetty-adapter {:mvn/version "1.10.0"}
|
||||
ring/ring-json {:mvn/version "0.5.1"}
|
||||
ring-cors/ring-cors {:mvn/version "0.1.13"}
|
||||
|
||||
;; Routing
|
||||
metosin/reitit {:mvn/version "0.7.0-alpha7"}
|
||||
|
||||
;; WebSocket
|
||||
info.sunng/ring-jetty9-adapter {:mvn/version "0.30.0"}
|
||||
|
||||
;; Database
|
||||
com.github.seancorfield/next.jdbc {:mvn/version "1.3.894"}
|
||||
org.xerial/sqlite-jdbc {:mvn/version "3.44.1.0"}
|
||||
|
||||
;; JSON
|
||||
metosin/jsonista {:mvn/version "0.3.8"}
|
||||
|
||||
;; Utilities
|
||||
aero/aero {:mvn/version "1.1.6"}
|
||||
mount/mount {:mvn/version "0.1.18"}
|
||||
org.clojure/tools.logging {:mvn/version "1.2.4"}
|
||||
ch.qos.logback/logback-classic {:mvn/version "1.4.11"}}
|
||||
|
||||
:aliases
|
||||
{:run {:main-opts ["-m" "spiceflow.core"]}
|
||||
:test {:extra-paths ["test"]
|
||||
:extra-deps {lambdaisland/kaocha {:mvn/version "1.87.1366"}}
|
||||
:main-opts ["-m" "kaocha.runner"]}
|
||||
:repl {:main-opts ["-m" "nrepl.cmdline" "-i"]
|
||||
:extra-deps {nrepl/nrepl {:mvn/version "1.1.0"}
|
||||
cider/cider-nrepl {:mvn/version "0.44.0"}}}}}
|
||||
@@ -0,0 +1,10 @@
|
||||
{:server {:port #long #or [#env SPICEFLOW_PORT 3000]
|
||||
:host #or [#env SPICEFLOW_HOST "0.0.0.0"]}
|
||||
|
||||
:database {:type :sqlite
|
||||
:dbname #or [#env SPICEFLOW_DB "spiceflow.db"]}
|
||||
|
||||
:claude {:sessions-dir #or [#env CLAUDE_SESSIONS_DIR
|
||||
#join [#env HOME "/.claude/projects"]]}
|
||||
|
||||
:opencode {:command #or [#env OPENCODE_CMD "opencode"]}}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<logger name="spiceflow" level="INFO"/>
|
||||
<logger name="org.eclipse.jetty" level="WARN"/>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</root>
|
||||
</configuration>
|
||||
@@ -0,0 +1,171 @@
|
||||
(ns spiceflow.adapters.claude
|
||||
"Adapter for Claude Code CLI"
|
||||
(:require [spiceflow.adapters.protocol :as proto]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.string :as str]
|
||||
[jsonista.core :as json]
|
||||
[clojure.tools.logging :as log])
|
||||
(:import [java.io BufferedReader InputStreamReader BufferedWriter OutputStreamWriter]
|
||||
[java.nio.file Files Paths]
|
||||
[java.net URLDecoder]
|
||||
[java.nio.charset StandardCharsets]))
|
||||
|
||||
(def ^:private mapper (json/object-mapper {:decode-key-fn keyword}))
|
||||
|
||||
(defn- decode-path
|
||||
"Decode a URL-encoded path segment"
|
||||
[encoded]
|
||||
(-> encoded
|
||||
(str/replace "%" "%25") ; Handle already-encoded % signs
|
||||
(str/replace "%25" "%")
|
||||
(URLDecoder/decode "UTF-8")))
|
||||
|
||||
(defn- encoded-path->real-path
|
||||
"Convert Claude's encoded path format back to real path.
|
||||
e.g., '-home-user-project' -> '/home/user/project'"
|
||||
[encoded]
|
||||
(-> encoded
|
||||
(str/replace #"^-" "/")
|
||||
(str/replace "-" "/")))
|
||||
|
||||
(defn- discover-project-sessions
|
||||
"Discover sessions from a single project directory"
|
||||
[project-dir]
|
||||
(let [project-path (encoded-path->real-path (.getName project-dir))]
|
||||
(->> (.listFiles project-dir)
|
||||
(filter #(str/ends-with? (.getName %) ".jsonl"))
|
||||
(map (fn [session-file]
|
||||
(let [session-id (str/replace (.getName session-file) ".jsonl" "")]
|
||||
{:external-id session-id
|
||||
:provider :claude
|
||||
:working-dir project-path
|
||||
:title (str "Session " (subs session-id 0 8) "...")
|
||||
:file-path (.getAbsolutePath session-file)}))))))
|
||||
|
||||
(defn- parse-jsonl-message
|
||||
"Parse a JSONL message from Claude Code"
|
||||
[line]
|
||||
(try
|
||||
(let [data (json/read-value line mapper)]
|
||||
(case (:type data)
|
||||
"user" {:role :user
|
||||
:content (get-in data [:message :content])
|
||||
:timestamp (:timestamp data)
|
||||
:uuid (:uuid data)}
|
||||
"assistant" {:role :assistant
|
||||
:content (get-in data [:message :content])
|
||||
:timestamp (:timestamp data)
|
||||
:uuid (:uuid data)
|
||||
:metadata {:model (get-in data [:message :model])
|
||||
:stop-reason (get-in data [:message :stopReason])}}
|
||||
;; Stream events from --output-format stream-json
|
||||
"content_block_start" {:event :content-start
|
||||
:index (:index data)
|
||||
:content-type (get-in data [:content_block :type])}
|
||||
"content_block_delta" {:event :content-delta
|
||||
:index (:index data)
|
||||
:text (get-in data [:delta :text])}
|
||||
"content_block_stop" {:event :content-stop
|
||||
:index (:index data)}
|
||||
"message_start" {:event :message-start
|
||||
:message-id (get-in data [:message :id])}
|
||||
"message_delta" {:event :message-delta
|
||||
:stop-reason (get-in data [:delta :stop_reason])}
|
||||
"message_stop" {:event :message-stop}
|
||||
"result" {:event :result
|
||||
:content (get-in data [:result :assistant :content])
|
||||
:cost (:cost data)
|
||||
:session-id (:session_id data)}
|
||||
;; Unknown type
|
||||
{:raw data}))
|
||||
(catch Exception e
|
||||
(log/debug "Failed to parse JSONL line:" line (.getMessage e))
|
||||
nil)))
|
||||
|
||||
(defn- read-session-messages
|
||||
"Read messages from a session JSONL file"
|
||||
[file-path]
|
||||
(try
|
||||
(with-open [reader (io/reader file-path)]
|
||||
(->> (line-seq reader)
|
||||
(keep parse-jsonl-message)
|
||||
(filter #(contains? #{:user :assistant} (:role %)))
|
||||
vec))
|
||||
(catch Exception e
|
||||
(log/warn "Failed to read session file:" file-path (.getMessage e))
|
||||
[])))
|
||||
|
||||
(defrecord ClaudeAdapter [sessions-dir]
|
||||
proto/AgentAdapter
|
||||
|
||||
(provider-name [_] :claude)
|
||||
|
||||
(discover-sessions [_]
|
||||
(let [base-dir (io/file sessions-dir)]
|
||||
(if (.exists base-dir)
|
||||
(->> (.listFiles base-dir)
|
||||
(filter #(.isDirectory %))
|
||||
(mapcat discover-project-sessions)
|
||||
vec)
|
||||
[])))
|
||||
|
||||
(spawn-session [_ session-id opts]
|
||||
(let [{:keys [working-dir model permission-mode]} opts
|
||||
args (cond-> ["claude"
|
||||
"--resume" session-id
|
||||
"--output-format" "stream-json"
|
||||
"--input-format" "stream-json"
|
||||
"--print"]
|
||||
model (conj "--model" model)
|
||||
permission-mode (conj "--permission-mode" permission-mode))
|
||||
pb (ProcessBuilder. args)]
|
||||
(when working-dir
|
||||
(.directory pb (io/file working-dir)))
|
||||
(.redirectErrorStream pb false)
|
||||
(let [process (.start pb)]
|
||||
{:process process
|
||||
:stdin (BufferedWriter. (OutputStreamWriter. (.getOutputStream process) StandardCharsets/UTF_8))
|
||||
:stdout (BufferedReader. (InputStreamReader. (.getInputStream process) StandardCharsets/UTF_8))
|
||||
:stderr (BufferedReader. (InputStreamReader. (.getErrorStream process) StandardCharsets/UTF_8))})))
|
||||
|
||||
(send-message [_ {:keys [stdin]} message]
|
||||
(try
|
||||
(let [json-msg (json/write-value-as-string {:type "user" :content message})]
|
||||
(.write stdin json-msg)
|
||||
(.newLine stdin)
|
||||
(.flush stdin)
|
||||
true)
|
||||
(catch Exception e
|
||||
(log/error "Failed to send message:" (.getMessage e))
|
||||
false)))
|
||||
|
||||
(read-stream [this {:keys [stdout]} callback]
|
||||
(try
|
||||
(loop []
|
||||
(when-let [line (.readLine stdout)]
|
||||
(when-let [parsed (proto/parse-output this line)]
|
||||
(callback parsed))
|
||||
(recur)))
|
||||
(catch Exception e
|
||||
(log/debug "Stream ended:" (.getMessage e)))))
|
||||
|
||||
(kill-process [_ {:keys [process]}]
|
||||
(when process
|
||||
(.destroyForcibly process)))
|
||||
|
||||
(parse-output [_ line]
|
||||
(when (and line (not (str/blank? line)))
|
||||
(parse-jsonl-message line))))
|
||||
|
||||
(defn create-adapter
|
||||
"Create a Claude Code adapter"
|
||||
([]
|
||||
(create-adapter (str (System/getProperty "user.home") "/.claude/projects")))
|
||||
([sessions-dir]
|
||||
(->ClaudeAdapter sessions-dir)))
|
||||
|
||||
(defn get-session-messages
|
||||
"Read messages from a discovered session"
|
||||
[session]
|
||||
(when-let [file-path (:file-path session)]
|
||||
(read-session-messages file-path)))
|
||||
@@ -0,0 +1,145 @@
|
||||
(ns spiceflow.adapters.opencode
|
||||
"Adapter for OpenCode CLI"
|
||||
(:require [spiceflow.adapters.protocol :as proto]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.java.shell :as shell]
|
||||
[clojure.string :as str]
|
||||
[jsonista.core :as json]
|
||||
[clojure.tools.logging :as log])
|
||||
(:import [java.io BufferedReader InputStreamReader BufferedWriter OutputStreamWriter]
|
||||
[java.nio.charset StandardCharsets]))
|
||||
|
||||
(def ^:private mapper (json/object-mapper {:decode-key-fn keyword}))
|
||||
|
||||
(defn- run-command
|
||||
"Run an opencode command and return parsed output"
|
||||
[command & args]
|
||||
(let [result (apply shell/sh command args)]
|
||||
(when (zero? (:exit result))
|
||||
(:out result))))
|
||||
|
||||
(defn- parse-session-list
|
||||
"Parse output from 'opencode session list'"
|
||||
[output]
|
||||
(try
|
||||
(let [data (json/read-value output mapper)]
|
||||
(->> data
|
||||
(map (fn [session]
|
||||
{:external-id (or (:id session) (:session_id session))
|
||||
:provider :opencode
|
||||
:title (:title session)
|
||||
:working-dir (:working_dir session)
|
||||
:created-at (get-in session [:time :created])
|
||||
:updated-at (get-in session [:time :updated])}))))
|
||||
(catch Exception e
|
||||
(log/debug "Failed to parse session list, trying line-by-line")
|
||||
;; Fallback: parse line by line if it's not JSON
|
||||
(->> (str/split-lines output)
|
||||
(filter #(str/starts-with? % "ses_"))
|
||||
(map (fn [line]
|
||||
(let [[id title] (str/split line #"\s+" 2)]
|
||||
{:external-id id
|
||||
:provider :opencode
|
||||
:title (or title "Untitled")})))))))
|
||||
|
||||
(defn- parse-session-export
|
||||
"Parse output from 'opencode export <session-id>'"
|
||||
[output]
|
||||
(try
|
||||
(let [data (json/read-value output mapper)]
|
||||
{:info (:info data)
|
||||
:messages (->> (:messages data)
|
||||
(map (fn [msg]
|
||||
{:role (keyword (get-in msg [:info :role] "assistant"))
|
||||
:content (->> (:parts msg)
|
||||
(filter #(= (:type %) "text"))
|
||||
(map :text)
|
||||
(str/join "\n"))
|
||||
:metadata {:parts (:parts msg)}})))})
|
||||
(catch Exception e
|
||||
(log/warn "Failed to parse session export:" (.getMessage e))
|
||||
nil)))
|
||||
|
||||
(defn- parse-stream-output
|
||||
"Parse a line of streaming output from OpenCode"
|
||||
[line]
|
||||
(try
|
||||
(when (and line (not (str/blank? line)))
|
||||
(if (str/starts-with? line "{")
|
||||
(let [data (json/read-value line mapper)]
|
||||
(cond
|
||||
(:content data) {:event :content-delta
|
||||
:text (:content data)}
|
||||
(:done data) {:event :message-stop}
|
||||
(:error data) {:event :error
|
||||
:message (:error data)}
|
||||
:else {:raw data}))
|
||||
;; Plain text output
|
||||
{:event :content-delta
|
||||
:text line}))
|
||||
(catch Exception e
|
||||
(log/debug "Failed to parse line:" line)
|
||||
{:event :content-delta :text line})))
|
||||
|
||||
(defrecord OpenCodeAdapter [command]
|
||||
proto/AgentAdapter
|
||||
|
||||
(provider-name [_] :opencode)
|
||||
|
||||
(discover-sessions [_]
|
||||
(if-let [output (run-command command "session" "list" "--json")]
|
||||
(vec (parse-session-list output))
|
||||
[]))
|
||||
|
||||
(spawn-session [_ session-id opts]
|
||||
(let [{:keys [working-dir]} opts
|
||||
args [command "run" "--session" session-id]
|
||||
pb (ProcessBuilder. args)]
|
||||
(when working-dir
|
||||
(.directory pb (io/file working-dir)))
|
||||
(.redirectErrorStream pb false)
|
||||
(let [process (.start pb)]
|
||||
{:process process
|
||||
:stdin (BufferedWriter. (OutputStreamWriter. (.getOutputStream process) StandardCharsets/UTF_8))
|
||||
:stdout (BufferedReader. (InputStreamReader. (.getInputStream process) StandardCharsets/UTF_8))
|
||||
:stderr (BufferedReader. (InputStreamReader. (.getErrorStream process) StandardCharsets/UTF_8))})))
|
||||
|
||||
(send-message [_ {:keys [stdin]} message]
|
||||
(try
|
||||
(.write stdin message)
|
||||
(.newLine stdin)
|
||||
(.flush stdin)
|
||||
true
|
||||
(catch Exception e
|
||||
(log/error "Failed to send message:" (.getMessage e))
|
||||
false)))
|
||||
|
||||
(read-stream [this {:keys [stdout]} callback]
|
||||
(try
|
||||
(loop []
|
||||
(when-let [line (.readLine stdout)]
|
||||
(when-let [parsed (proto/parse-output this line)]
|
||||
(callback parsed))
|
||||
(recur)))
|
||||
(catch Exception e
|
||||
(log/debug "Stream ended:" (.getMessage e)))))
|
||||
|
||||
(kill-process [_ {:keys [process]}]
|
||||
(when process
|
||||
(.destroyForcibly process)))
|
||||
|
||||
(parse-output [_ line]
|
||||
(parse-stream-output line)))
|
||||
|
||||
(defn create-adapter
|
||||
"Create an OpenCode adapter"
|
||||
([]
|
||||
(create-adapter "opencode"))
|
||||
([command]
|
||||
(->OpenCodeAdapter command)))
|
||||
|
||||
(defn export-session
|
||||
"Export a session's full history"
|
||||
[adapter session-id]
|
||||
(when-let [output (run-command (:command adapter) "export" session-id)]
|
||||
(parse-session-export output)))
|
||||
@@ -0,0 +1,38 @@
|
||||
(ns spiceflow.adapters.protocol)
|
||||
|
||||
(defprotocol AgentAdapter
|
||||
"Protocol for interacting with AI coding assistants (Claude Code, OpenCode, etc.)"
|
||||
|
||||
(provider-name [this]
|
||||
"Return the provider name as a keyword (:claude or :opencode)")
|
||||
|
||||
(discover-sessions [this]
|
||||
"Discover existing sessions from the CLI's storage.
|
||||
Returns a sequence of session maps with :external-id, :title, :working-dir, etc.")
|
||||
|
||||
(spawn-session [this session-id opts]
|
||||
"Spawn a new CLI process for the given session.
|
||||
opts may include :working-dir, :model, :permission-mode
|
||||
Returns a process handle map with :process, :stdin, :stdout, :stderr")
|
||||
|
||||
(send-message [this process-handle message]
|
||||
"Send a message to a running CLI process.
|
||||
Returns true if sent successfully.")
|
||||
|
||||
(read-stream [this process-handle callback]
|
||||
"Read streamed output from the CLI process.
|
||||
callback is called with each parsed message/event.
|
||||
Returns when the process completes or stream ends.")
|
||||
|
||||
(kill-process [this process-handle]
|
||||
"Kill a running CLI process.")
|
||||
|
||||
(parse-output [this line]
|
||||
"Parse a line of CLI output into a structured message.
|
||||
Returns nil if the line should be ignored."))
|
||||
|
||||
(defn running?
|
||||
"Check if a process handle represents a running process"
|
||||
[{:keys [process]}]
|
||||
(when process
|
||||
(.isAlive process)))
|
||||
@@ -0,0 +1,141 @@
|
||||
(ns spiceflow.api.routes
|
||||
"REST API routes"
|
||||
(:require [reitit.ring :as ring]
|
||||
[reitit.ring.middleware.parameters :as parameters]
|
||||
[ring.middleware.json :refer [wrap-json-body wrap-json-response]]
|
||||
[ring.middleware.cors :refer [wrap-cors]]
|
||||
[ring.util.response :as response]
|
||||
[spiceflow.db.protocol :as db]
|
||||
[spiceflow.session.manager :as manager]
|
||||
[spiceflow.adapters.claude :as claude]
|
||||
[clojure.tools.logging :as log]))
|
||||
|
||||
(defn- json-response
|
||||
"Create a JSON response"
|
||||
[body]
|
||||
(-> (response/response body)
|
||||
(response/content-type "application/json")))
|
||||
|
||||
(defn- error-response
|
||||
"Create an error response"
|
||||
[status message]
|
||||
(-> (response/response {:error message})
|
||||
(response/status status)
|
||||
(response/content-type "application/json")))
|
||||
|
||||
;; Session handlers
|
||||
(defn list-sessions-handler
|
||||
[store]
|
||||
(fn [_request]
|
||||
(json-response (db/get-sessions store))))
|
||||
|
||||
(defn get-session-handler
|
||||
[store]
|
||||
(fn [request]
|
||||
(let [id (get-in request [:path-params :id])]
|
||||
(if-let [session (db/get-session store id)]
|
||||
(let [messages (db/get-messages store id)]
|
||||
(json-response (assoc session :messages messages)))
|
||||
(error-response 404 "Session not found")))))
|
||||
|
||||
(defn create-session-handler
|
||||
[store]
|
||||
(fn [request]
|
||||
(let [body (:body request)]
|
||||
(if (db/valid-session? body)
|
||||
(let [session (db/save-session store body)]
|
||||
(-> (json-response session)
|
||||
(response/status 201)))
|
||||
(error-response 400 "Invalid session data")))))
|
||||
|
||||
(defn delete-session-handler
|
||||
[store]
|
||||
(fn [request]
|
||||
(let [id (get-in request [:path-params :id])]
|
||||
(if (db/get-session store id)
|
||||
(do
|
||||
(manager/stop-session store id)
|
||||
(db/delete-session store id)
|
||||
(response/status (response/response nil) 204))
|
||||
(error-response 404 "Session not found")))))
|
||||
|
||||
(defn send-message-handler
|
||||
[store broadcast-fn]
|
||||
(fn [request]
|
||||
(let [id (get-in request [:path-params :id])
|
||||
message (get-in request [:body :message])]
|
||||
(if-let [session (db/get-session store id)]
|
||||
(try
|
||||
;; Send message and start streaming in a separate thread
|
||||
(manager/send-message-to-session store id message)
|
||||
(future
|
||||
(try
|
||||
(manager/stream-session-response store id
|
||||
(fn [event]
|
||||
(broadcast-fn id event)))
|
||||
(catch Exception e
|
||||
(log/error "Streaming error:" (.getMessage e))
|
||||
(broadcast-fn id {:event :error :message (.getMessage e)}))))
|
||||
(json-response {:status "sent"})
|
||||
(catch Exception e
|
||||
(error-response 500 (.getMessage e))))
|
||||
(error-response 404 "Session not found")))))
|
||||
|
||||
;; Discovery handlers
|
||||
(defn discover-claude-handler
|
||||
[_store]
|
||||
(fn [_request]
|
||||
(let [adapter (claude/create-adapter)
|
||||
sessions (manager/discover-all-sessions)]
|
||||
(json-response (->> sessions
|
||||
(filter #(= :claude (:provider %)))
|
||||
vec)))))
|
||||
|
||||
(defn discover-opencode-handler
|
||||
[_store]
|
||||
(fn [_request]
|
||||
(let [sessions (manager/discover-all-sessions)]
|
||||
(json-response (->> sessions
|
||||
(filter #(= :opencode (:provider %)))
|
||||
vec)))))
|
||||
|
||||
(defn import-session-handler
|
||||
[store]
|
||||
(fn [request]
|
||||
(let [body (:body request)]
|
||||
(if (:external-id body)
|
||||
(let [session (manager/import-session store body)]
|
||||
(-> (json-response session)
|
||||
(response/status 201)))
|
||||
(error-response 400 "Missing external-id")))))
|
||||
|
||||
;; Health check
|
||||
(defn health-handler
|
||||
[_request]
|
||||
(json-response {:status "ok" :service "spiceflow"}))
|
||||
|
||||
(defn create-routes
|
||||
"Create API routes with the given store and broadcast function"
|
||||
[store broadcast-fn]
|
||||
[["/api"
|
||||
["/health" {:get health-handler}]
|
||||
["/sessions" {:get (list-sessions-handler store)
|
||||
:post (create-session-handler store)}]
|
||||
["/sessions/:id" {:get (get-session-handler store)
|
||||
:delete (delete-session-handler store)}]
|
||||
["/sessions/:id/send" {:post (send-message-handler store broadcast-fn)}]
|
||||
["/discover/claude" {:get (discover-claude-handler store)}]
|
||||
["/discover/opencode" {:get (discover-opencode-handler store)}]
|
||||
["/import" {:post (import-session-handler store)}]]])
|
||||
|
||||
(defn create-app
|
||||
"Create the Ring application"
|
||||
[store broadcast-fn]
|
||||
(-> (ring/ring-handler
|
||||
(ring/router (create-routes store broadcast-fn))
|
||||
(ring/create-default-handler))
|
||||
(wrap-json-body {:keywords? true})
|
||||
wrap-json-response
|
||||
(wrap-cors :access-control-allow-origin [#".*"]
|
||||
:access-control-allow-methods [:get :post :put :delete :options]
|
||||
:access-control-allow-headers [:content-type :authorization])))
|
||||
@@ -0,0 +1,108 @@
|
||||
(ns spiceflow.api.websocket
|
||||
"WebSocket handlers for real-time updates"
|
||||
(:require [jsonista.core :as json]
|
||||
[clojure.tools.logging :as log])
|
||||
(:import [org.eclipse.jetty.websocket.api Session WebSocketListener]
|
||||
[java.util.concurrent ConcurrentHashMap]))
|
||||
|
||||
(def ^:private mapper (json/object-mapper {:encode-key-fn name}))
|
||||
|
||||
;; Connected WebSocket sessions: session-id -> #{ws-sessions}
|
||||
(defonce ^:private connections (ConcurrentHashMap.))
|
||||
|
||||
;; All connected WebSocket sessions for broadcast
|
||||
(defonce ^:private all-connections (ConcurrentHashMap/newKeySet))
|
||||
|
||||
(defn- send-to-ws
|
||||
"Send a message to a WebSocket session"
|
||||
[^Session ws-session message]
|
||||
(try
|
||||
(when (.isOpen ws-session)
|
||||
(let [json-str (json/write-value-as-string message mapper)]
|
||||
(.sendString (.getRemote ws-session) json-str)))
|
||||
(catch Exception e
|
||||
(log/debug "Failed to send to WebSocket:" (.getMessage e)))))
|
||||
|
||||
(defn broadcast-to-session
|
||||
"Broadcast an event to all WebSocket connections subscribed to a session"
|
||||
[session-id event]
|
||||
(when-let [subscribers (.get connections session-id)]
|
||||
(let [message (assoc event :session-id session-id)]
|
||||
(doseq [ws-session subscribers]
|
||||
(send-to-ws ws-session message)))))
|
||||
|
||||
(defn broadcast-all
|
||||
"Broadcast an event to all connected WebSocket sessions"
|
||||
[event]
|
||||
(doseq [ws-session all-connections]
|
||||
(send-to-ws ws-session event)))
|
||||
|
||||
(defn- subscribe-to-session
|
||||
"Subscribe a WebSocket session to updates for a session"
|
||||
[ws-session session-id]
|
||||
(.compute connections session-id
|
||||
(fn [_k existing]
|
||||
(let [subscribers (or existing (ConcurrentHashMap/newKeySet))]
|
||||
(.add subscribers ws-session)
|
||||
subscribers))))
|
||||
|
||||
(defn- unsubscribe-from-session
|
||||
"Unsubscribe a WebSocket session from a session"
|
||||
[ws-session session-id]
|
||||
(when-let [subscribers (.get connections session-id)]
|
||||
(.remove subscribers ws-session)
|
||||
(when (.isEmpty subscribers)
|
||||
(.remove connections session-id))))
|
||||
|
||||
(defn- unsubscribe-from-all
|
||||
"Unsubscribe a WebSocket session from all sessions"
|
||||
[ws-session]
|
||||
(doseq [[session-id _] connections]
|
||||
(unsubscribe-from-session ws-session session-id)))
|
||||
|
||||
(defn- handle-message
|
||||
"Handle an incoming WebSocket message"
|
||||
[ws-session message]
|
||||
(try
|
||||
(let [data (json/read-value message mapper)]
|
||||
(case (:type data)
|
||||
"subscribe" (do
|
||||
(subscribe-to-session ws-session (:session-id data))
|
||||
(send-to-ws ws-session {:type "subscribed"
|
||||
:session-id (:session-id data)}))
|
||||
"unsubscribe" (do
|
||||
(unsubscribe-from-session ws-session (:session-id data))
|
||||
(send-to-ws ws-session {:type "unsubscribed"
|
||||
:session-id (:session-id data)}))
|
||||
"ping" (send-to-ws ws-session {:type "pong"})
|
||||
(log/debug "Unknown WebSocket message type:" (:type data))))
|
||||
(catch Exception e
|
||||
(log/warn "Failed to handle WebSocket message:" (.getMessage e)))))
|
||||
|
||||
(defn create-ws-listener
|
||||
"Create a WebSocket listener"
|
||||
[]
|
||||
(reify WebSocketListener
|
||||
(onWebSocketConnect [_ session]
|
||||
(log/debug "WebSocket connected")
|
||||
(.add all-connections session)
|
||||
(send-to-ws session {:type "connected"}))
|
||||
|
||||
(onWebSocketText [_ session message]
|
||||
(handle-message session message))
|
||||
|
||||
(onWebSocketBinary [_ _session _payload _offset _len]
|
||||
(log/debug "Binary WebSocket message ignored"))
|
||||
|
||||
(onWebSocketClose [_ session status-code reason]
|
||||
(log/debug "WebSocket closed:" status-code reason)
|
||||
(.remove all-connections session)
|
||||
(unsubscribe-from-all session))
|
||||
|
||||
(onWebSocketError [_ _session cause]
|
||||
(log/warn "WebSocket error:" (.getMessage cause)))))
|
||||
|
||||
(defn ws-handler
|
||||
"Ring handler for WebSocket upgrade"
|
||||
[_request]
|
||||
{:ring.websocket/listener (create-ws-listener)})
|
||||
@@ -0,0 +1,28 @@
|
||||
(ns spiceflow.config
|
||||
(:require [aero.core :as aero]
|
||||
[clojure.java.io :as io]
|
||||
[mount.core :refer [defstate]]))
|
||||
|
||||
(defn load-config
|
||||
"Load configuration from config.edn or environment"
|
||||
([]
|
||||
(load-config :default))
|
||||
([profile]
|
||||
(let [config-file (io/resource "config.edn")]
|
||||
(if config-file
|
||||
(aero/read-config config-file {:profile profile})
|
||||
;; Default config if no file exists
|
||||
{:server {:port 3000
|
||||
:host "0.0.0.0"}
|
||||
:database {:type :sqlite
|
||||
:dbname "spiceflow.db"}
|
||||
:claude {:sessions-dir (str (System/getProperty "user.home") "/.claude/projects")}
|
||||
:opencode {:command "opencode"}}))))
|
||||
|
||||
(defstate config
|
||||
:start (load-config (keyword (or (System/getenv "SPICEFLOW_ENV") "default"))))
|
||||
|
||||
(defn get-in-config
|
||||
"Get a nested value from config"
|
||||
[ks]
|
||||
(get-in config ks))
|
||||
@@ -0,0 +1,56 @@
|
||||
(ns spiceflow.core
|
||||
"Main entry point for Spiceflow server"
|
||||
(:require [ring.adapter.jetty9 :as jetty]
|
||||
[spiceflow.config :as config]
|
||||
[spiceflow.db.sqlite :as sqlite]
|
||||
[spiceflow.api.routes :as routes]
|
||||
[spiceflow.api.websocket :as ws]
|
||||
[spiceflow.session.manager :as manager]
|
||||
[mount.core :as mount :refer [defstate]]
|
||||
[clojure.tools.logging :as log])
|
||||
(:gen-class))
|
||||
|
||||
;; Database store
|
||||
(defstate store
|
||||
:start (do
|
||||
(log/info "Initializing database...")
|
||||
(let [db-path (get-in config/config [:database :dbname] "spiceflow.db")]
|
||||
(sqlite/create-store db-path)))
|
||||
:stop nil)
|
||||
|
||||
;; HTTP Server
|
||||
(defstate server
|
||||
:start (let [port (get-in config/config [:server :port] 3000)
|
||||
host (get-in config/config [:server :host] "0.0.0.0")
|
||||
app (routes/create-app store ws/broadcast-to-session)]
|
||||
(log/info "Starting Spiceflow server on" (str host ":" port))
|
||||
(jetty/run-jetty app
|
||||
{:port port
|
||||
:host host
|
||||
:join? false
|
||||
:websockets {"/api/ws" ws/ws-handler}}))
|
||||
:stop (do
|
||||
(log/info "Stopping Spiceflow server...")
|
||||
(manager/cleanup-all store)
|
||||
(.stop server)))
|
||||
|
||||
(defn -main
|
||||
"Main entry point"
|
||||
[& _args]
|
||||
(log/info "Starting Spiceflow - The spice must flow!")
|
||||
(mount/start)
|
||||
(log/info "Spiceflow is ready.")
|
||||
;; Keep the main thread alive
|
||||
@(promise))
|
||||
|
||||
(comment
|
||||
;; Development helpers
|
||||
(mount/start)
|
||||
(mount/stop)
|
||||
|
||||
;; Test database
|
||||
(require '[spiceflow.db.protocol :as db])
|
||||
(db/get-sessions store)
|
||||
|
||||
;; Test discovery
|
||||
(manager/discover-all-sessions))
|
||||
@@ -0,0 +1,79 @@
|
||||
(ns spiceflow.db.memory
|
||||
"In-memory implementation of DataStore for testing"
|
||||
(:require [spiceflow.db.protocol :as proto])
|
||||
(:import [java.util UUID]
|
||||
[java.time Instant]))
|
||||
|
||||
(defn- generate-id []
|
||||
(str (UUID/randomUUID)))
|
||||
|
||||
(defn- now-iso []
|
||||
(.toString (Instant/now)))
|
||||
|
||||
(defrecord MemoryStore [sessions messages]
|
||||
proto/DataStore
|
||||
|
||||
(get-sessions [_]
|
||||
(->> @sessions
|
||||
vals
|
||||
(sort-by :updated-at)
|
||||
reverse
|
||||
vec))
|
||||
|
||||
(get-session [_ id]
|
||||
(get @sessions id))
|
||||
|
||||
(save-session [this session]
|
||||
(let [id (or (:id session) (generate-id))
|
||||
now (now-iso)
|
||||
new-session (assoc session
|
||||
:id id
|
||||
:status (or (:status session) :idle)
|
||||
:created-at now
|
||||
:updated-at now)]
|
||||
(swap! sessions assoc id new-session)
|
||||
new-session))
|
||||
|
||||
(update-session [_ id data]
|
||||
(let [now (now-iso)]
|
||||
(swap! sessions update id merge data {:updated-at now})
|
||||
(get @sessions id)))
|
||||
|
||||
(delete-session [_ id]
|
||||
(swap! messages (fn [msgs]
|
||||
(into {} (remove #(= (:session-id (val %)) id) msgs))))
|
||||
(swap! sessions dissoc id)
|
||||
nil)
|
||||
|
||||
(get-messages [_ session-id]
|
||||
(->> @messages
|
||||
vals
|
||||
(filter #(= (:session-id %) session-id))
|
||||
(sort-by :created-at)
|
||||
vec))
|
||||
|
||||
(save-message [this message]
|
||||
(let [id (or (:id message) (generate-id))
|
||||
now (now-iso)
|
||||
new-message (assoc message
|
||||
:id id
|
||||
:created-at now)]
|
||||
(swap! messages assoc id new-message)
|
||||
;; Update session's updated-at
|
||||
(when-let [session-id (:session-id message)]
|
||||
(swap! sessions update session-id assoc :updated-at now))
|
||||
new-message))
|
||||
|
||||
(get-message [_ id]
|
||||
(get @messages id)))
|
||||
|
||||
(defn create-store
|
||||
"Create an in-memory store for testing"
|
||||
[]
|
||||
(->MemoryStore (atom {}) (atom {})))
|
||||
|
||||
(defn clear-store!
|
||||
"Clear all data from an in-memory store"
|
||||
[store]
|
||||
(reset! (:sessions store) {})
|
||||
(reset! (:messages store) {}))
|
||||
@@ -0,0 +1,38 @@
|
||||
(ns spiceflow.db.protocol)
|
||||
|
||||
(defprotocol DataStore
|
||||
"Protocol for database operations. Implementations can be SQLite, in-memory, etc."
|
||||
|
||||
;; Session operations
|
||||
(get-sessions [this]
|
||||
"Get all sessions")
|
||||
(get-session [this id]
|
||||
"Get a single session by ID")
|
||||
(save-session [this session]
|
||||
"Save a new session. Returns the saved session with ID.")
|
||||
(update-session [this id data]
|
||||
"Update session fields. Returns updated session.")
|
||||
(delete-session [this id]
|
||||
"Delete a session and its messages")
|
||||
|
||||
;; Message operations
|
||||
(get-messages [this session-id]
|
||||
"Get all messages for a session, ordered by created_at")
|
||||
(save-message [this message]
|
||||
"Save a new message. Returns the saved message with ID.")
|
||||
(get-message [this id]
|
||||
"Get a single message by ID"))
|
||||
|
||||
(defn valid-session?
|
||||
"Validate session data has required fields"
|
||||
[{:keys [provider]}]
|
||||
(and provider
|
||||
(contains? #{:claude :opencode "claude" "opencode"} provider)))
|
||||
|
||||
(defn valid-message?
|
||||
"Validate message data has required fields"
|
||||
[{:keys [session-id role content]}]
|
||||
(and session-id
|
||||
role
|
||||
(contains? #{:user :assistant :system "user" "assistant" "system"} role)
|
||||
content))
|
||||
@@ -0,0 +1,194 @@
|
||||
(ns spiceflow.db.sqlite
|
||||
(:require [next.jdbc :as jdbc]
|
||||
[next.jdbc.result-set :as rs]
|
||||
[next.jdbc.sql :as sql]
|
||||
[spiceflow.db.protocol :as proto]
|
||||
[jsonista.core :as json]
|
||||
[clojure.string :as str])
|
||||
(:import [java.util UUID]
|
||||
[java.time Instant]))
|
||||
|
||||
(def ^:private mapper (json/object-mapper {:decode-key-fn keyword}))
|
||||
|
||||
(defn- generate-id []
|
||||
(str (UUID/randomUUID)))
|
||||
|
||||
(defn- now-iso []
|
||||
(.toString (Instant/now)))
|
||||
|
||||
(defn- row->session
|
||||
"Convert a database row to a session map"
|
||||
[row]
|
||||
(when row
|
||||
{:id (:sessions/id row)
|
||||
:provider (keyword (:sessions/provider row))
|
||||
:external-id (:sessions/external_id row)
|
||||
:title (:sessions/title row)
|
||||
:working-dir (:sessions/working_dir row)
|
||||
:status (keyword (or (:sessions/status row) "idle"))
|
||||
:created-at (:sessions/created_at row)
|
||||
:updated-at (:sessions/updated_at row)}))
|
||||
|
||||
(defn- row->message
|
||||
"Convert a database row to a message map"
|
||||
[row]
|
||||
(when row
|
||||
{:id (:messages/id row)
|
||||
:session-id (:messages/session_id row)
|
||||
:role (keyword (:messages/role row))
|
||||
:content (:messages/content row)
|
||||
:metadata (when-let [m (:messages/metadata row)]
|
||||
(json/read-value m mapper))
|
||||
:created-at (:messages/created_at row)}))
|
||||
|
||||
(defn- session->row
|
||||
"Convert a session map to database columns"
|
||||
[{:keys [id provider external-id title working-dir status]}]
|
||||
(cond-> {}
|
||||
id (assoc :id id)
|
||||
provider (assoc :provider (name provider))
|
||||
external-id (assoc :external_id external-id)
|
||||
title (assoc :title title)
|
||||
working-dir (assoc :working_dir working-dir)
|
||||
status (assoc :status (name status))))
|
||||
|
||||
(defn- message->row
|
||||
"Convert a message map to database columns"
|
||||
[{:keys [id session-id role content metadata]}]
|
||||
(cond-> {}
|
||||
id (assoc :id id)
|
||||
session-id (assoc :session_id session-id)
|
||||
role (assoc :role (name role))
|
||||
content (assoc :content content)
|
||||
metadata (assoc :metadata (json/write-value-as-string metadata))))
|
||||
|
||||
(defrecord SQLiteStore [datasource]
|
||||
proto/DataStore
|
||||
|
||||
(get-sessions [_]
|
||||
(let [rows (jdbc/execute! datasource
|
||||
["SELECT * FROM sessions ORDER BY updated_at DESC"]
|
||||
{:builder-fn rs/as-unqualified-kebab-maps})]
|
||||
(mapv (fn [row]
|
||||
{:id (:id row)
|
||||
:provider (keyword (:provider row))
|
||||
:external-id (:external-id row)
|
||||
:title (:title row)
|
||||
:working-dir (:working-dir row)
|
||||
:status (keyword (or (:status row) "idle"))
|
||||
:created-at (:created-at row)
|
||||
:updated-at (:updated-at row)})
|
||||
rows)))
|
||||
|
||||
(get-session [_ id]
|
||||
(let [row (jdbc/execute-one! datasource
|
||||
["SELECT * FROM sessions WHERE id = ?" id]
|
||||
{:builder-fn rs/as-unqualified-kebab-maps})]
|
||||
(when row
|
||||
{:id (:id row)
|
||||
:provider (keyword (:provider row))
|
||||
:external-id (:external-id row)
|
||||
:title (:title row)
|
||||
:working-dir (:working-dir row)
|
||||
:status (keyword (or (:status row) "idle"))
|
||||
:created-at (:created-at row)
|
||||
:updated-at (:updated-at row)})))
|
||||
|
||||
(save-session [this session]
|
||||
(let [id (or (:id session) (generate-id))
|
||||
now (now-iso)
|
||||
row (-> (session->row session)
|
||||
(assoc :id id
|
||||
:created_at now
|
||||
:updated_at now))]
|
||||
(sql/insert! datasource :sessions row)
|
||||
(proto/get-session this id)))
|
||||
|
||||
(update-session [this id data]
|
||||
(let [row (-> (session->row data)
|
||||
(assoc :updated_at (now-iso)))]
|
||||
(sql/update! datasource :sessions row {:id id})
|
||||
(proto/get-session this id)))
|
||||
|
||||
(delete-session [_ id]
|
||||
(jdbc/execute! datasource ["DELETE FROM messages WHERE session_id = ?" id])
|
||||
(jdbc/execute! datasource ["DELETE FROM sessions WHERE id = ?" id])
|
||||
nil)
|
||||
|
||||
(get-messages [_ session-id]
|
||||
(let [rows (jdbc/execute! datasource
|
||||
["SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC"
|
||||
session-id]
|
||||
{:builder-fn rs/as-unqualified-kebab-maps})]
|
||||
(mapv (fn [row]
|
||||
{:id (:id row)
|
||||
:session-id (:session-id row)
|
||||
:role (keyword (:role row))
|
||||
:content (:content row)
|
||||
:metadata (when-let [m (:metadata row)]
|
||||
(json/read-value m mapper))
|
||||
:created-at (:created-at row)})
|
||||
rows)))
|
||||
|
||||
(save-message [this message]
|
||||
(let [id (or (:id message) (generate-id))
|
||||
now (now-iso)
|
||||
row (-> (message->row message)
|
||||
(assoc :id id
|
||||
:created_at now))]
|
||||
(sql/insert! datasource :messages row)
|
||||
;; Update session's updated_at
|
||||
(jdbc/execute! datasource
|
||||
["UPDATE sessions SET updated_at = ? WHERE id = ?"
|
||||
now (:session-id message)])
|
||||
(proto/get-message this id)))
|
||||
|
||||
(get-message [_ id]
|
||||
(let [row (jdbc/execute-one! datasource
|
||||
["SELECT * FROM messages WHERE id = ?" id]
|
||||
{:builder-fn rs/as-unqualified-kebab-maps})]
|
||||
(when row
|
||||
{:id (:id row)
|
||||
:session-id (:session-id row)
|
||||
:role (keyword (:role row))
|
||||
:content (:content row)
|
||||
:metadata (when-let [m (:metadata row)]
|
||||
(json/read-value m mapper))
|
||||
:created-at (:created-at row)}))))
|
||||
|
||||
(def schema
|
||||
"SQLite schema for spiceflow"
|
||||
["CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
external_id TEXT,
|
||||
title TEXT,
|
||||
working_dir TEXT,
|
||||
status TEXT DEFAULT 'idle',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)"
|
||||
"CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT REFERENCES sessions(id),
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
metadata TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)"
|
||||
"CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)"
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_provider ON sessions(provider)"
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_external_id ON sessions(external_id)"])
|
||||
|
||||
(defn init-schema!
|
||||
"Initialize database schema"
|
||||
[datasource]
|
||||
(doseq [stmt schema]
|
||||
(jdbc/execute! datasource [stmt])))
|
||||
|
||||
(defn create-store
|
||||
"Create a SQLite store with the given database path"
|
||||
[db-path]
|
||||
(let [datasource (jdbc/get-datasource {:dbtype "sqlite" :dbname db-path})]
|
||||
(init-schema! datasource)
|
||||
(->SQLiteStore datasource)))
|
||||
@@ -0,0 +1,122 @@
|
||||
(ns spiceflow.session.manager
|
||||
"Session lifecycle management"
|
||||
(:require [spiceflow.db.protocol :as db]
|
||||
[spiceflow.adapters.protocol :as adapter]
|
||||
[spiceflow.adapters.claude :as claude]
|
||||
[spiceflow.adapters.opencode :as opencode]
|
||||
[clojure.tools.logging :as log])
|
||||
(:import [java.util.concurrent ConcurrentHashMap]))
|
||||
|
||||
;; Active process handles for running sessions
|
||||
(defonce ^:private active-processes (ConcurrentHashMap.))
|
||||
|
||||
(defn get-adapter
|
||||
"Get the appropriate adapter for a provider"
|
||||
[provider]
|
||||
(case (keyword provider)
|
||||
:claude (claude/create-adapter)
|
||||
:opencode (opencode/create-adapter)
|
||||
(throw (ex-info "Unknown provider" {:provider provider}))))
|
||||
|
||||
(defn discover-all-sessions
|
||||
"Discover sessions from all configured providers"
|
||||
[]
|
||||
(let [claude-sessions (adapter/discover-sessions (claude/create-adapter))
|
||||
opencode-sessions (adapter/discover-sessions (opencode/create-adapter))]
|
||||
(concat claude-sessions opencode-sessions)))
|
||||
|
||||
(defn import-session
|
||||
"Import a discovered session into the database"
|
||||
[store session]
|
||||
(db/save-session store session))
|
||||
|
||||
(defn get-active-process
|
||||
"Get the active process handle for a session"
|
||||
[session-id]
|
||||
(.get active-processes session-id))
|
||||
|
||||
(defn session-running?
|
||||
"Check if a session has an active process"
|
||||
[session-id]
|
||||
(when-let [handle (get-active-process session-id)]
|
||||
(adapter/running? handle)))
|
||||
|
||||
(defn start-session
|
||||
"Start a CLI process for a session"
|
||||
[store session-id]
|
||||
(let [session (db/get-session store session-id)]
|
||||
(when-not session
|
||||
(throw (ex-info "Session not found" {:session-id session-id})))
|
||||
(when (session-running? session-id)
|
||||
(throw (ex-info "Session already running" {:session-id session-id})))
|
||||
(let [adapter (get-adapter (:provider session))
|
||||
handle (adapter/spawn-session adapter
|
||||
(:external-id session)
|
||||
{:working-dir (:working-dir session)})]
|
||||
(.put active-processes session-id handle)
|
||||
(db/update-session store session-id {:status :running})
|
||||
handle)))
|
||||
|
||||
(defn stop-session
|
||||
"Stop a running CLI process for a session"
|
||||
[store session-id]
|
||||
(when-let [handle (.remove active-processes session-id)]
|
||||
(let [session (db/get-session store session-id)
|
||||
adapter (get-adapter (:provider session))]
|
||||
(adapter/kill-process adapter handle)
|
||||
(db/update-session store session-id {:status :idle}))))
|
||||
|
||||
(defn send-message-to-session
|
||||
"Send a message to a running session"
|
||||
[store session-id message]
|
||||
(let [session (db/get-session store session-id)
|
||||
_ (when-not session
|
||||
(throw (ex-info "Session not found" {:session-id session-id})))
|
||||
handle (get-active-process session-id)
|
||||
;; Start session if not running
|
||||
handle (or handle (start-session store session-id))
|
||||
adapter (get-adapter (:provider session))]
|
||||
;; Save user message
|
||||
(db/save-message store {:session-id session-id
|
||||
:role :user
|
||||
:content message})
|
||||
;; Send to CLI
|
||||
(adapter/send-message adapter handle message)))
|
||||
|
||||
(defn stream-session-response
|
||||
"Stream response from a running session, calling callback for each event"
|
||||
[store session-id callback]
|
||||
(let [session (db/get-session store session-id)
|
||||
_ (when-not session
|
||||
(throw (ex-info "Session not found" {:session-id session-id})))
|
||||
handle (get-active-process session-id)
|
||||
_ (when-not handle
|
||||
(throw (ex-info "Session not running" {:session-id session-id})))
|
||||
adapter (get-adapter (:provider session))
|
||||
content-buffer (StringBuilder.)]
|
||||
;; Read stream and accumulate content
|
||||
(adapter/read-stream adapter handle
|
||||
(fn [event]
|
||||
(callback event)
|
||||
;; Accumulate text content
|
||||
(when-let [text (:text event)]
|
||||
(.append content-buffer text))
|
||||
;; On message stop, save the accumulated message
|
||||
(when (= :message-stop (:event event))
|
||||
(let [content (.toString content-buffer)]
|
||||
(when (seq content)
|
||||
(db/save-message store {:session-id session-id
|
||||
:role :assistant
|
||||
:content content}))))))
|
||||
;; Update session status when stream ends
|
||||
(db/update-session store session-id {:status :idle})
|
||||
(.remove active-processes session-id)))
|
||||
|
||||
(defn cleanup-all
|
||||
"Stop all running sessions"
|
||||
[store]
|
||||
(doseq [session-id (keys active-processes)]
|
||||
(try
|
||||
(stop-session store session-id)
|
||||
(catch Exception e
|
||||
(log/warn "Failed to stop session:" session-id (.getMessage e))))))
|
||||
@@ -0,0 +1,80 @@
|
||||
(ns spiceflow.adapters-test
|
||||
(:require [clojure.test :refer [deftest testing is]]
|
||||
[spiceflow.adapters.protocol :as proto]
|
||||
[spiceflow.adapters.claude :as claude]
|
||||
[spiceflow.adapters.opencode :as opencode]))
|
||||
|
||||
(deftest test-claude-adapter
|
||||
(testing "Provider name"
|
||||
(let [adapter (claude/create-adapter)]
|
||||
(is (= :claude (proto/provider-name adapter)))))
|
||||
|
||||
(testing "Parse JSONL messages"
|
||||
(let [adapter (claude/create-adapter)]
|
||||
;; User message
|
||||
(let [parsed (proto/parse-output adapter
|
||||
"{\"type\":\"user\",\"message\":{\"content\":\"Hello\"},\"timestamp\":\"2024-01-01T00:00:00Z\"}")]
|
||||
(is (= :user (:role parsed)))
|
||||
(is (= "Hello" (:content parsed))))
|
||||
|
||||
;; Assistant message
|
||||
(let [parsed (proto/parse-output adapter
|
||||
"{\"type\":\"assistant\",\"message\":{\"content\":\"Hi!\"},\"timestamp\":\"2024-01-01T00:00:00Z\"}")]
|
||||
(is (= :assistant (:role parsed)))
|
||||
(is (= "Hi!" (:content parsed))))
|
||||
|
||||
;; Content delta (streaming)
|
||||
(let [parsed (proto/parse-output adapter
|
||||
"{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"text\":\"Hello\"}}")]
|
||||
(is (= :content-delta (:event parsed)))
|
||||
(is (= "Hello" (:text parsed))))
|
||||
|
||||
;; Message stop
|
||||
(let [parsed (proto/parse-output adapter
|
||||
"{\"type\":\"message_stop\"}")]
|
||||
(is (= :message-stop (:event parsed))))
|
||||
|
||||
;; Empty line
|
||||
(is (nil? (proto/parse-output adapter "")))
|
||||
(is (nil? (proto/parse-output adapter " "))))))
|
||||
|
||||
(deftest test-opencode-adapter
|
||||
(testing "Provider name"
|
||||
(let [adapter (opencode/create-adapter)]
|
||||
(is (= :opencode (proto/provider-name adapter)))))
|
||||
|
||||
(testing "Parse stream output"
|
||||
(let [adapter (opencode/create-adapter)]
|
||||
;; JSON content
|
||||
(let [parsed (proto/parse-output adapter "{\"content\":\"Hello\"}")]
|
||||
(is (= :content-delta (:event parsed)))
|
||||
(is (= "Hello" (:text parsed))))
|
||||
|
||||
;; Plain text
|
||||
(let [parsed (proto/parse-output adapter "Some output text")]
|
||||
(is (= :content-delta (:event parsed)))
|
||||
(is (= "Some output text" (:text parsed))))
|
||||
|
||||
;; Done message
|
||||
(let [parsed (proto/parse-output adapter "{\"done\":true}")]
|
||||
(is (= :message-stop (:event parsed)))))))
|
||||
|
||||
(deftest test-discover-sessions
|
||||
(testing "Claude adapter discovers sessions (may be empty)"
|
||||
(let [adapter (claude/create-adapter)
|
||||
sessions (proto/discover-sessions adapter)]
|
||||
(is (vector? sessions))
|
||||
(doseq [session sessions]
|
||||
(is (:external-id session))
|
||||
(is (= :claude (:provider session))))))
|
||||
|
||||
(testing "OpenCode adapter discovers sessions (may fail if not installed)"
|
||||
(let [adapter (opencode/create-adapter)]
|
||||
;; This may fail if opencode is not installed, so we just check
|
||||
;; that it doesn't throw an unexpected exception
|
||||
(try
|
||||
(let [sessions (proto/discover-sessions adapter)]
|
||||
(is (or (nil? sessions) (vector? sessions))))
|
||||
(catch Exception _
|
||||
;; OK if opencode is not installed
|
||||
(is true))))))
|
||||
@@ -0,0 +1,86 @@
|
||||
(ns spiceflow.db-test
|
||||
(:require [clojure.test :refer [deftest testing is use-fixtures]]
|
||||
[spiceflow.db.protocol :as db]
|
||||
[spiceflow.db.memory :as memory]
|
||||
[spiceflow.db.sqlite :as sqlite]))
|
||||
|
||||
;; Test both implementations
|
||||
(def ^:dynamic *store* nil)
|
||||
|
||||
(defn memory-fixture [f]
|
||||
(binding [*store* (memory/create-store)]
|
||||
(f)))
|
||||
|
||||
(defn sqlite-fixture [f]
|
||||
(let [db-path (str "/tmp/spiceflow-test-" (System/currentTimeMillis) ".db")]
|
||||
(binding [*store* (sqlite/create-store db-path)]
|
||||
(try
|
||||
(f)
|
||||
(finally
|
||||
(clojure.java.io/delete-file db-path true))))))
|
||||
|
||||
;; Session tests
|
||||
(deftest test-session-crud
|
||||
(testing "Create and retrieve session"
|
||||
(let [session (db/save-session *store* {:provider :claude
|
||||
:external-id "test-123"
|
||||
:title "Test Session"
|
||||
:working-dir "/home/test"})]
|
||||
(is (:id session))
|
||||
(is (= :claude (:provider session)))
|
||||
(is (= "test-123" (:external-id session)))
|
||||
(is (:created-at session))
|
||||
|
||||
;; Get session
|
||||
(let [retrieved (db/get-session *store* (:id session))]
|
||||
(is (= (:id session) (:id retrieved)))
|
||||
(is (= "Test Session" (:title retrieved))))))
|
||||
|
||||
(testing "List sessions"
|
||||
(let [sessions (db/get-sessions *store*)]
|
||||
(is (>= (count sessions) 1))))
|
||||
|
||||
(testing "Update session"
|
||||
(let [session (first (db/get-sessions *store*))
|
||||
updated (db/update-session *store* (:id session) {:title "Updated Title"
|
||||
:status :running})]
|
||||
(is (= "Updated Title" (:title updated)))
|
||||
(is (= :running (:status updated)))))
|
||||
|
||||
(testing "Delete session"
|
||||
(let [session (first (db/get-sessions *store*))]
|
||||
(db/delete-session *store* (:id session))
|
||||
(is (nil? (db/get-session *store* (:id session)))))))
|
||||
|
||||
;; Message tests
|
||||
(deftest test-message-crud
|
||||
(testing "Create and retrieve messages"
|
||||
(let [session (db/save-session *store* {:provider :claude
|
||||
:external-id "msg-test"
|
||||
:title "Message Test"})
|
||||
msg1 (db/save-message *store* {:session-id (:id session)
|
||||
:role :user
|
||||
:content "Hello"})
|
||||
msg2 (db/save-message *store* {:session-id (:id session)
|
||||
:role :assistant
|
||||
:content "Hi there!"
|
||||
:metadata {:model "claude-3"}})]
|
||||
(is (:id msg1))
|
||||
(is (:id msg2))
|
||||
|
||||
;; Get messages
|
||||
(let [messages (db/get-messages *store* (:id session))]
|
||||
(is (= 2 (count messages)))
|
||||
(is (= :user (:role (first messages))))
|
||||
(is (= :assistant (:role (second messages))))
|
||||
(is (= {:model "claude-3"} (:metadata (second messages)))))
|
||||
|
||||
;; Get single message
|
||||
(let [retrieved (db/get-message *store* (:id msg1))]
|
||||
(is (= "Hello" (:content retrieved)))))))
|
||||
|
||||
;; Run with memory store
|
||||
(use-fixtures :each memory-fixture)
|
||||
|
||||
;; Uncomment to run with SQLite
|
||||
;; (use-fixtures :each sqlite-fixture)
|
||||
@@ -0,0 +1,4 @@
|
||||
#kaocha/v1
|
||||
{:tests [{:id :unit
|
||||
:test-paths ["test"]}]
|
||||
:reporter [kaocha.report/dots]}
|
||||
Reference in New Issue
Block a user