fix: Pi harness - dynamic models, correct RPC protocol, proper event handling
Backend: - /api/pi/models now calls 'pi --list-models' with stored API keys - pi-bridge.ts: callback-based event handling (matches pi-monorepo) - pi-bridge.ts: correct RPC format (type: 'prompt' not jsonrpc) - pi-bridge.ts: pass API keys to Pi process env - websocket.ts: event handler runs in background, no blocking - rest.ts: fix user home path (getHomeDir instead of hardcoded) Frontend: - Fix /api/ double prefix in useChatSessions, useChatGroups, useModels - Add PROVIDER_DISPLAY mapping in SystemSettings.tsx - Provider tabs show friendly names (e.g., 'OpenCode Zen') UI (from previous session): - Grouped session list with collapsible folders - CreateGroupDialog, GroupContextMenu, SessionContextMenu components
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useChatGroups } from '@/state/useChatGroups';
|
||||
|
||||
type CreateGroupDialogProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function toSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
export function CreateGroupDialog({ onClose }: CreateGroupDialogProps) {
|
||||
const { createGroup } = useChatGroups();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('Name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const slug = toSlug(name);
|
||||
if (!slug) {
|
||||
setError('Name must contain at least one alphanumeric character');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await createGroup(name.trim(), slug, description.trim() || undefined);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create group');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: React.MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-duck-dark/10 dark:border-foreground/10">
|
||||
<h2 className="text-lg font-semibold text-duck-dark dark:text-foreground">Create Group</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md hover:bg-duck-dark/10 dark:hover:bg-foreground/10 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="group-name" className="block text-sm font-medium text-duck-dark/70 dark:text-foreground/70 mb-1.5">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="group-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Work Projects"
|
||||
className="w-full px-3 py-2 rounded-md border border-duck-dark/20 dark:border-foreground/20 bg-background text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 dark:placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-duck-teal/50"
|
||||
autoFocus
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{name && (
|
||||
<p className="mt-1 text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Slug: <span className="font-mono">{toSlug(name) || '(invalid)'}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="group-description" className="block text-sm font-medium text-duck-dark/70 dark:text-foreground/70 mb-1.5">
|
||||
Description <span className="text-duck-dark/40 dark:text-foreground/40">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="group-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Add a description for this group..."
|
||||
className="w-full px-3 py-2 rounded-md border border-duck-dark/20 dark:border-foreground/20 bg-background text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 dark:placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-duck-teal/50 resize-none"
|
||||
rows={3}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-md bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-sm font-medium text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 rounded-md transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !name.trim()}
|
||||
className="px-4 py-2 text-sm font-medium bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Group'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react';
|
||||
import { MoreVertical, Edit2, Trash2 } from 'lucide-react';
|
||||
import { useChatGroups } from '@/state/useChatGroups';
|
||||
import type { GroupEntry } from 'apps/Chat';
|
||||
|
||||
type GroupContextMenuProps = {
|
||||
group: GroupEntry;
|
||||
};
|
||||
|
||||
export function GroupContextMenu({ group }: GroupContextMenuProps) {
|
||||
const { updateGroup, deleteGroup } = useChatGroups();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState(group.name);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
async function handleRename() {
|
||||
if (editName.trim() && editName !== group.name) {
|
||||
await updateGroup(group.slug, { name: editName.trim() });
|
||||
}
|
||||
setIsEditing(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
await deleteGroup(group.slug);
|
||||
setShowDeleteConfirm(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function handleButtonClick(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
setIsOpen(false);
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 ml-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRename();
|
||||
if (e.key === 'Escape') {
|
||||
setEditName(group.name);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 text-sm border border-duck-dark/20 dark:border-foreground/20 rounded bg-background focus:outline-none focus:ring-2 focus:ring-duck-teal/50"
|
||||
autoFocus
|
||||
onBlur={handleRename}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative ml-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={handleButtonClick}
|
||||
className="p-1 rounded-md hover:bg-duck-dark/10 dark:hover:bg-foreground/10 transition-colors"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={handleBackdropClick} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 min-w-[160px] bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-md shadow-lg overflow-hidden">
|
||||
{!showDeleteConfirm ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-red-500/10 text-red-600 dark:text-red-400 transition-colors text-left"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-3 space-y-2">
|
||||
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
|
||||
Delete "{group.name}"? Sessions will be ungrouped.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="flex-1 px-3 py-1.5 text-xs rounded-md bg-duck-dark/10 dark:bg-foreground/10 hover:bg-duck-dark/20 dark:hover:bg-foreground/20 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex-1 px-3 py-1.5 text-xs rounded-md bg-red-500 hover:bg-red-600 text-white transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { Plus, MessageSquare, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Plus, MessageSquare, Folder, ChevronRight, FolderPlus } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import { useChatGroups } from '@/state/useChatGroups';
|
||||
import type { SelectedSession } from './ChatDetailPanel';
|
||||
import { CreateGroupDialog } from './CreateGroupDialog';
|
||||
import { GroupContextMenu } from './GroupContextMenu';
|
||||
import { SessionContextMenu } from './SessionContextMenu';
|
||||
|
||||
export const SessionList = () => {
|
||||
const { sessions, deleteSession } = useChatSessions();
|
||||
const { groups } = useChatGroups();
|
||||
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
const [showCreateGroup, setShowCreateGroup] = useState(false);
|
||||
|
||||
const scrolledRef = useRef(false);
|
||||
const selectedRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
@@ -22,6 +30,20 @@ export const SessionList = () => {
|
||||
scrolledRef.current = false;
|
||||
}, [selected?.id]);
|
||||
|
||||
const toggleGroup = (slug: string) => {
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(slug) ? next.delete(slug) : next.add(slug);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const ungrouped = sessions.filter(s => !s.groupSlug);
|
||||
const grouped = groups.map(g => ({
|
||||
...g,
|
||||
sessions: sessions.filter(s => s.groupSlug === g.slug)
|
||||
}));
|
||||
|
||||
const handleSelect = (session: (typeof sessions)[number]) => {
|
||||
setSelected({ id: session.id, model: session.model ?? null });
|
||||
window.history.replaceState(null, '', `/chat/${session.id}`);
|
||||
@@ -35,32 +57,7 @@ export const SessionList = () => {
|
||||
await deleteSession(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
|
||||
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelected({ id: 'new' });
|
||||
window.history.replaceState(null, '', '/chat/new');
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-1.5">
|
||||
{sessions.length === 0 && (
|
||||
<div className="text-center py-16 text-duck-dark/30 dark:text-foreground/30 text-sm">
|
||||
No sessions yet. Start a new chat!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.map((session) => {
|
||||
const renderSession = (session: (typeof sessions)[number]) => {
|
||||
const isSelected = selected?.id === session.id;
|
||||
return (
|
||||
<div
|
||||
@@ -99,16 +96,78 @@ export const SessionList = () => {
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(session.id)}
|
||||
className="shrink-0 p-2 mr-2 text-duck-dark/20 dark:text-foreground/20 hover:text-red-500 md:opacity-0 md:group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="shrink-0 mr-2">
|
||||
<SessionContextMenu session={session} onDelete={handleDelete} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
|
||||
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreateGroup(true)}
|
||||
className="flex items-center gap-1.5 rounded-md bg-duck-dark/10 hover:bg-duck-dark/20 dark:bg-foreground/10 dark:hover:bg-foreground/20 text-duck-dark dark:text-foreground cursor-pointer h-7 px-3 text-xs font-medium"
|
||||
>
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
New Group
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelected({ id: 'new' });
|
||||
window.history.replaceState(null, '', '/chat/new');
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3">
|
||||
{sessions.length === 0 && (
|
||||
<div className="text-center py-16 text-duck-dark/30 dark:text-foreground/30 text-sm">
|
||||
No sessions yet. Start a new chat!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ungrouped sessions */}
|
||||
{ungrouped.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{ungrouped.map(renderSession)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grouped sessions */}
|
||||
{grouped.map((group) => (
|
||||
<div key={group.slug} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 px-3 py-2 cursor-pointer rounded-md hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors">
|
||||
<div onClick={() => toggleGroup(group.slug)} className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 transition-transform ${!collapsed.has(group.slug) ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<Folder className="h-4 w-4 text-duck-teal" />
|
||||
<span className="font-medium text-sm text-duck-dark/70 dark:text-foreground/70">{group.name}</span>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">({group.sessionCount})</span>
|
||||
</div>
|
||||
<GroupContextMenu group={group} />
|
||||
</div>
|
||||
{!collapsed.has(group.slug) && (
|
||||
<div className="ml-6 space-y-1.5">
|
||||
{group.sessions.map(renderSession)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showCreateGroup && <CreateGroupDialog onClose={() => setShowCreateGroup(false)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
import { MoreVertical, FolderInput, Edit2, Trash2 } from 'lucide-react';
|
||||
import { useChatGroups } from '@/state/useChatGroups';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import type { SessionEntry } from 'apps/Chat';
|
||||
|
||||
type SessionContextMenuProps = {
|
||||
session: SessionEntry;
|
||||
onDelete: (id: string) => void;
|
||||
};
|
||||
|
||||
export function SessionContextMenu({ session, onDelete }: SessionContextMenuProps) {
|
||||
const { groups, moveSession } = useChatGroups();
|
||||
const { renameSession } = useChatSessions();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showMoveMenu, setShowMoveMenu] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState(session.title);
|
||||
|
||||
async function handleMove(groupSlug: string | null) {
|
||||
await moveSession(session.id, groupSlug);
|
||||
setIsOpen(false);
|
||||
setShowMoveMenu(false);
|
||||
}
|
||||
|
||||
async function handleRename() {
|
||||
if (editTitle.trim() && editTitle !== session.title) {
|
||||
await renameSession(session.id, editTitle.trim());
|
||||
}
|
||||
setIsRenaming(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function handleButtonClick(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
setIsOpen(false);
|
||||
setShowMoveMenu(false);
|
||||
}
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 ml-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRename();
|
||||
if (e.key === 'Escape') {
|
||||
setEditTitle(session.title);
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 text-sm border border-duck-dark/20 dark:border-foreground/20 rounded bg-background focus:outline-none focus:ring-2 focus:ring-duck-teal/50"
|
||||
autoFocus
|
||||
onBlur={handleRename}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={handleButtonClick}
|
||||
className="p-1 rounded-md hover:bg-duck-dark/10 dark:hover:bg-foreground/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={handleBackdropClick} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 min-w-[160px] bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-md shadow-lg overflow-hidden">
|
||||
{!showMoveMenu ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowMoveMenu(true)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
|
||||
>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
Move to group
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRenaming(true);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(session.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-red-500/10 text-red-600 dark:text-red-400 transition-colors text-left"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-3 py-2 text-xs font-medium text-duck-dark/50 dark:text-foreground/50 border-b border-duck-dark/10 dark:border-foreground/10">
|
||||
Move to:
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleMove(null)}
|
||||
className="w-full px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
|
||||
>
|
||||
Ungrouped
|
||||
</button>
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
key={group.slug}
|
||||
onClick={() => handleMove(group.slug)}
|
||||
className="w-full px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
|
||||
>
|
||||
{group.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t border-duck-dark/10 dark:border-foreground/10">
|
||||
<button
|
||||
onClick={() => setShowMoveMenu(false)}
|
||||
className="w-full px-3 py-2 text-xs text-duck-dark/50 dark:text-foreground/50 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,27 @@ import type { UserSettings } from '@/state/types/user-settings';
|
||||
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
|
||||
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel';
|
||||
|
||||
const PROVIDER_DISPLAY: Record<string, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
opencode: 'OpenCode Zen',
|
||||
google: 'Google',
|
||||
groq: 'Groq',
|
||||
mistral: 'Mistral',
|
||||
xai: 'xAI',
|
||||
openrouter: 'OpenRouter',
|
||||
huggingface: 'Hugging Face',
|
||||
'github-copilot': 'GitHub Copilot',
|
||||
minimax: 'MiniMax',
|
||||
bedrock: 'Amazon Bedrock',
|
||||
'google-vertex': 'Google Vertex AI',
|
||||
'azure-openai': 'Azure OpenAI',
|
||||
};
|
||||
|
||||
function displayProviderName(provider: string): string {
|
||||
return PROVIDER_DISPLAY[provider] ?? provider;
|
||||
}
|
||||
|
||||
const groups: SettingsSectionGroup[] = [
|
||||
{
|
||||
label: 'AI',
|
||||
@@ -381,7 +402,7 @@ function ModelVisibilitySection() {
|
||||
: 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark dark:hover:text-foreground hover:bg-duck-dark/5 dark:hover:bg-foreground/5'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
{displayProviderName(p)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -11,11 +11,11 @@ export function useChatGroups() {
|
||||
const { data: groups = [] } = useQuery<GroupEntry[]>({
|
||||
queryKey: ['PI_GROUPS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<{ groups: GroupEntry[] }>('/api/pi/groups').then((r) => r.groups),
|
||||
queryFn: () => client.get<{ groups: GroupEntry[] }>('/pi/groups').then((r) => r.groups),
|
||||
});
|
||||
|
||||
async function createGroup(name: string, slug: string, description?: string, sessionIds?: string[]) {
|
||||
const result = await client.post<{ group: GroupEntry }>('/api/pi/groups', {
|
||||
const result = await client.post<{ group: GroupEntry }>('/pi/groups', {
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
@@ -27,18 +27,18 @@ export function useChatGroups() {
|
||||
}
|
||||
|
||||
async function updateGroup(slug: string, updates: { name?: string; description?: string }) {
|
||||
await client.patch(`/api/pi/groups/${slug}`, updates);
|
||||
await client.patch(`/pi/groups/${slug}`, updates);
|
||||
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
|
||||
}
|
||||
|
||||
async function deleteGroup(slug: string) {
|
||||
await client.delete(`/api/pi/groups/${slug}`);
|
||||
await client.delete(`/pi/groups/${slug}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
|
||||
}
|
||||
|
||||
async function moveSession(sessionId: string, groupSlug: string | null) {
|
||||
await client.post(`/api/pi/sessions/${sessionId}/move`, { groupSlug });
|
||||
await client.post(`/pi/sessions/${sessionId}/move`, { groupSlug });
|
||||
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
|
||||
}
|
||||
|
||||
@@ -28,24 +28,24 @@ export function useChatSessions() {
|
||||
const { data: sessions = [] } = useQuery<SessionEntry[]>({
|
||||
queryKey: ['PI_SESSIONS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.post<{ sessions: SessionEntry[] }>('/api/pi/sessions').then((r) => r.sessions),
|
||||
queryFn: () => client.post<{ sessions: SessionEntry[] }>('/pi/sessions').then((r) => r.sessions),
|
||||
});
|
||||
|
||||
function getSession(sessionId: string) {
|
||||
return client.get<{ session: SessionWithMessages }>(`/api/pi/sessions/${sessionId}`);
|
||||
return client.get<{ session: SessionWithMessages }>(`/pi/sessions/${sessionId}`);
|
||||
}
|
||||
|
||||
function saveMessages(sessionId: string, messages: ChatMessage[]) {
|
||||
return client.put(`/api/pi/sessions/${sessionId}/messages`, messages);
|
||||
return client.put(`/pi/sessions/${sessionId}/messages`, messages);
|
||||
}
|
||||
|
||||
async function renameSession(sessionId: string, title: string) {
|
||||
await client.patch(`/api/pi/sessions/${sessionId}`, { title });
|
||||
await client.patch(`/pi/sessions/${sessionId}`, { title });
|
||||
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId: string) {
|
||||
await client.delete(`/api/pi/sessions/${sessionId}`);
|
||||
await client.delete(`/pi/sessions/${sessionId}`);
|
||||
queryClient.setQueryData<SessionEntry[]>(
|
||||
['PI_SESSIONS'],
|
||||
(prev) => prev?.filter((s) => s.id !== sessionId) ?? [],
|
||||
@@ -53,7 +53,7 @@ export function useChatSessions() {
|
||||
}
|
||||
|
||||
function searchSessions(query: string) {
|
||||
return client.get<{ results: SessionEntry[] }>(`/api/pi/sessions/search?q=${encodeURIComponent(query)}`);
|
||||
return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -18,7 +18,7 @@ export function usePiModels() {
|
||||
queryKey: ['PI_MODELS'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: async () => {
|
||||
const data = await client.get<{ models: ModelOption[] }>('/api/pi/models');
|
||||
const data = await client.get<{ models: ModelOption[] }>('/pi/models');
|
||||
return data.models;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
|
||||
+188
-118
@@ -1,35 +1,180 @@
|
||||
import type { Subprocess } from "bun";
|
||||
import type { PiEvent, MessageCost } from "./types";
|
||||
import { readApiKeys } from "../server-settings/pi-mono";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export type PiEventHandler = (event: PiEvent) => void;
|
||||
|
||||
export async function spawnPi(
|
||||
cwd: string,
|
||||
model: string,
|
||||
env?: Record<string, string>
|
||||
onEvent: PiEventHandler
|
||||
): Promise<Subprocess> {
|
||||
const piProcess = Bun.spawn(
|
||||
[
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--no-extensions",
|
||||
"--no-skills",
|
||||
"--model",
|
||||
model,
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "inherit",
|
||||
}
|
||||
);
|
||||
const storedKeys = await readApiKeys();
|
||||
|
||||
return piProcess;
|
||||
const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
|
||||
if (model) args.push('--model', model);
|
||||
|
||||
const proc = Bun.spawn(args, {
|
||||
cwd,
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env, ...storedKeys },
|
||||
});
|
||||
|
||||
// Read stdout JSON event stream (runs in background)
|
||||
const reader = proc.stdout.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let streamBuffer = '';
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const event = JSON.parse(line) as Record<string, unknown>;
|
||||
const piEvent = parsePiEvent(event, streamBuffer);
|
||||
if (piEvent) {
|
||||
if (piEvent.type === 'delta') {
|
||||
streamBuffer += piEvent.text;
|
||||
} else if (piEvent.type === 'text' || piEvent.type === 'tool:start') {
|
||||
streamBuffer = '';
|
||||
}
|
||||
onEvent(piEvent);
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Process ended
|
||||
}
|
||||
})();
|
||||
|
||||
// Stderr → debug log
|
||||
const stderrReader = proc.stderr.getReader();
|
||||
const stderrDecoder = new TextDecoder();
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await stderrReader.read();
|
||||
if (done) break;
|
||||
const text = stderrDecoder.decode(value, { stream: true });
|
||||
if (text.trim()) logger.info('Pi stderr', { text: text.trim() });
|
||||
}
|
||||
} catch {
|
||||
// Process ended
|
||||
}
|
||||
})();
|
||||
|
||||
// Handle process exit
|
||||
proc.exited.then((code) => {
|
||||
logger.info('Pi process exited', { code });
|
||||
});
|
||||
|
||||
return proc;
|
||||
}
|
||||
|
||||
function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: string): PiEvent | null {
|
||||
const type = event.type as string;
|
||||
|
||||
// Handle response (success/failure for commands)
|
||||
if (type === 'response') {
|
||||
if (event.command === 'prompt' && !event.success) {
|
||||
const errorMsg = (event.error as string) ?? 'Prompt failed';
|
||||
return { type: 'error', message: errorMsg };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'agent_start':
|
||||
// No event to emit, just resets state
|
||||
return null;
|
||||
|
||||
case 'message_update': {
|
||||
const ame = event.assistantMessageEvent as Record<string, unknown> | undefined;
|
||||
if (ame?.type === 'text_delta') {
|
||||
const delta = ame.delta as string;
|
||||
return { type: 'delta', text: delta };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'message_end': {
|
||||
if (currentStreamBuffer) {
|
||||
return { type: 'text', text: currentStreamBuffer };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'tool_execution_start': {
|
||||
const toolCallId = (event.toolCallId as string) ?? '';
|
||||
const toolName = (event.toolName as string) ?? 'unknown';
|
||||
const args = (event.args as Record<string, unknown>) ?? {};
|
||||
|
||||
return {
|
||||
type: 'tool:start',
|
||||
toolCallId,
|
||||
toolName,
|
||||
toolInput: args,
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool_execution_end': {
|
||||
const toolCallId = (event.toolCallId as string) ?? '';
|
||||
const result = event.result;
|
||||
const isError = (event.isError as boolean) ?? false;
|
||||
const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : '';
|
||||
|
||||
return {
|
||||
type: 'tool:result',
|
||||
toolCallId,
|
||||
output,
|
||||
isError,
|
||||
};
|
||||
}
|
||||
|
||||
case 'agent_end': {
|
||||
// Pi doesn't provide cost info in agent_end, use zeros
|
||||
const cost: MessageCost = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalUSD: 0,
|
||||
};
|
||||
return { type: 'result', cost };
|
||||
}
|
||||
|
||||
case 'extension_ui_request': {
|
||||
// Will be handled separately
|
||||
return null;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeRpcCommand(proc: Subprocess, command: Record<string, unknown>): void {
|
||||
const stdin = proc.stdin;
|
||||
if (!stdin || typeof stdin === 'number') return;
|
||||
try {
|
||||
const writer = stdin as { write(data: string): void; flush(): void };
|
||||
writer.write(JSON.stringify(command) + '\n');
|
||||
writer.flush();
|
||||
} catch (err) {
|
||||
logger.error('writeRpcCommand error', { error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
export function sendPrompt(
|
||||
@@ -37,113 +182,38 @@ export function sendPrompt(
|
||||
prompt: string,
|
||||
requestId: string
|
||||
): void {
|
||||
const request = {
|
||||
jsonrpc: "2.0",
|
||||
method: "chat",
|
||||
params: {
|
||||
prompt,
|
||||
},
|
||||
writeRpcCommand(process, {
|
||||
type: 'prompt',
|
||||
id: requestId,
|
||||
};
|
||||
|
||||
const writer = (process.stdin as any).getWriter();
|
||||
writer.write(
|
||||
new TextEncoder().encode(JSON.stringify(request) + "\n")
|
||||
);
|
||||
writer.releaseLock();
|
||||
message: prompt,
|
||||
});
|
||||
}
|
||||
|
||||
export function abort(
|
||||
process: Subprocess,
|
||||
requestId: string
|
||||
): void {
|
||||
const request = {
|
||||
jsonrpc: "2.0",
|
||||
method: "abort",
|
||||
params: {},
|
||||
writeRpcCommand(process, {
|
||||
type: 'abort',
|
||||
id: requestId,
|
||||
};
|
||||
|
||||
const writer = (process.stdin as any).getWriter();
|
||||
writer.write(
|
||||
new TextEncoder().encode(JSON.stringify(request) + "\n")
|
||||
);
|
||||
writer.releaseLock();
|
||||
});
|
||||
}
|
||||
|
||||
export async function* readEvents(
|
||||
process: Subprocess
|
||||
): AsyncGenerator<PiEvent> {
|
||||
if (!process.stdout) {
|
||||
throw new Error("Pi process stdout not available");
|
||||
}
|
||||
|
||||
const reader = (process.stdout as any).getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
|
||||
if (event.method === "text") {
|
||||
yield { type: "text", text: event.params.text };
|
||||
} else if (event.method === "delta") {
|
||||
yield { type: "delta", text: event.params.text };
|
||||
} else if (event.method === "tool:start") {
|
||||
yield {
|
||||
type: "tool:start",
|
||||
toolCallId: event.params.toolCallId,
|
||||
toolName: event.params.toolName,
|
||||
toolInput: event.params.toolInput,
|
||||
};
|
||||
} else if (event.method === "tool:result") {
|
||||
yield {
|
||||
type: "tool:result",
|
||||
toolCallId: event.params.toolCallId,
|
||||
output: event.params.output,
|
||||
isError: event.params.isError || false,
|
||||
};
|
||||
} else if (event.method === "result") {
|
||||
const cost: MessageCost = {
|
||||
inputTokens: event.params.cost?.inputTokens || 0,
|
||||
outputTokens: event.params.cost?.outputTokens || 0,
|
||||
totalUSD: event.params.cost?.totalUSD || 0,
|
||||
};
|
||||
yield { type: "result", cost };
|
||||
} else if (event.method === "error") {
|
||||
yield {
|
||||
type: "error",
|
||||
message: event.params.message || "Unknown error",
|
||||
};
|
||||
} else if (event.method === "stopped") {
|
||||
yield { type: "stopped" };
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Failed to parse Pi event", { line, error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
export function cancelExtensionUi(
|
||||
process: Subprocess,
|
||||
id: unknown
|
||||
): void {
|
||||
writeRpcCommand(process, {
|
||||
type: 'extension_ui_response',
|
||||
id,
|
||||
cancelled: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function killPi(process: Subprocess): void {
|
||||
try {
|
||||
process.kill();
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
}
|
||||
|
||||
+68
-36
@@ -1,6 +1,8 @@
|
||||
import type { Context } from 'hono';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as storage from './storage';
|
||||
import { readApiKeys } from '../server-settings/pi-mono';
|
||||
import { getHomeDir } from '../../data-path';
|
||||
import type { ModelInfo } from './types';
|
||||
import { logger } from './logger';
|
||||
|
||||
@@ -12,36 +14,66 @@ export const piRestRouter = createRouter();
|
||||
|
||||
/**
|
||||
* GET /api/pi/models
|
||||
* List available models
|
||||
* List available models by running `pi --list-models` with stored API keys
|
||||
*/
|
||||
piRestRouter.get('/pi/models', async (ctx: Context) => {
|
||||
// TODO: Implement dynamic model discovery
|
||||
// For now, return hardcoded models
|
||||
const models: ModelInfo[] = [
|
||||
{
|
||||
id: 'gpt-4o',
|
||||
name: 'GPT-4o',
|
||||
provider: 'openai',
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4-5',
|
||||
name: 'Claude Opus 4.5',
|
||||
provider: 'anthropic',
|
||||
contextWindow: 200000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
{
|
||||
id: 'big-pickle',
|
||||
name: 'Big Pickle',
|
||||
provider: 'opencode-zen',
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
];
|
||||
try {
|
||||
const storedKeys = await readApiKeys();
|
||||
|
||||
const proc = Bun.spawn(['pi', '--list-models'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env, ...storedKeys },
|
||||
});
|
||||
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
if (proc.exitCode !== 0) {
|
||||
logger.error('pi --list-models failed', { exitCode: proc.exitCode });
|
||||
return ctx.json({ models: [] });
|
||||
}
|
||||
|
||||
// Parse the whitespace-separated table output:
|
||||
// provider model context max-out thinking images
|
||||
// anthropic claude-sonnet-4-6 200K 128K yes yes
|
||||
const lines = output.trim().split('\n').filter(Boolean);
|
||||
const models: ModelInfo[] = [];
|
||||
|
||||
// Skip header line (first line)
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cols = lines[i]!.trim().split(/\s+/);
|
||||
if (cols.length < 2) continue;
|
||||
|
||||
const [provider, model, context, maxOut] = cols;
|
||||
|
||||
// Parse context window (e.g., "200K" -> 200000)
|
||||
const parseSize = (s?: string): number => {
|
||||
if (!s) return 128000;
|
||||
const match = s.match(/^(\d+)([KMG])?$/i);
|
||||
if (!match) return 128000;
|
||||
const num = parseInt(match[1]!, 10);
|
||||
const unit = (match[2] ?? '').toUpperCase();
|
||||
if (unit === 'K') return num * 1000;
|
||||
if (unit === 'M') return num * 1000000;
|
||||
if (unit === 'G') return num * 1000000000;
|
||||
return num;
|
||||
};
|
||||
|
||||
models.push({
|
||||
id: `${provider}/${model}`,
|
||||
name: model!,
|
||||
provider: provider!,
|
||||
contextWindow: parseSize(context),
|
||||
maxTokens: parseSize(maxOut),
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.json({ models });
|
||||
} catch (err) {
|
||||
logger.error('Failed to list models', { error: String(err) });
|
||||
return ctx.json({ models: [] });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -55,7 +87,7 @@ piRestRouter.post('/pi/sessions', async (ctx: Context) => {
|
||||
}
|
||||
|
||||
// TODO: Implement proper user home directory resolution
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
const sessions = await storage.listUserSessions(userHome);
|
||||
@@ -81,7 +113,7 @@ piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'Session ID required' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
// Try loading from root first
|
||||
@@ -140,7 +172,7 @@ piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'Title is required and must be a string' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
// Find the session (root or in group)
|
||||
@@ -192,7 +224,7 @@ piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'Session ID required' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
// Find the session (root or in group)
|
||||
@@ -251,7 +283,7 @@ piRestRouter.get('/pi/sessions/search', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'Query parameter required' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
const results = await storage.searchSessions(userHome, query);
|
||||
@@ -282,7 +314,7 @@ piRestRouter.post('/pi/groups', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'Slug is required and must be a string' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
// Check if group already exists
|
||||
@@ -347,7 +379,7 @@ piRestRouter.get('/pi/groups', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
const groups = await storage.listGroups(userHome);
|
||||
@@ -387,7 +419,7 @@ piRestRouter.patch('/pi/groups/:groupSlug', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'No valid updates provided' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
const updatedGroup = await storage.updateGroupMeta(userHome, groupSlug, updates);
|
||||
@@ -416,7 +448,7 @@ piRestRouter.delete('/pi/groups/:groupSlug', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'Group slug required' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
await storage.deleteGroup(userHome, groupSlug);
|
||||
@@ -449,7 +481,7 @@ piRestRouter.post('/pi/sessions/:sessionId/move', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'groupSlug must be a string or null' }, 400);
|
||||
}
|
||||
|
||||
const userHome = `/home/${user.email.split('@')[0]}`;
|
||||
const userHome = getHomeDir(user.email);
|
||||
|
||||
try {
|
||||
// Find the session in root or any group
|
||||
|
||||
+166
-228
@@ -1,16 +1,12 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { ClientMessage, ServerMessage, Message } from './types';
|
||||
import type { ClientMessage, ServerMessage, Message, PiEvent } from './types';
|
||||
import { sessionManager } from './session-manager';
|
||||
import * as storage from './storage';
|
||||
import * as piBridge from './pi-bridge';
|
||||
import { getHomeDir } from '../../../servers/data-path';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* WebSocket Handler — Pi chat session lifecycle
|
||||
*/
|
||||
|
||||
type WSData = {
|
||||
userId: number;
|
||||
email: string;
|
||||
@@ -20,46 +16,18 @@ type WSData = {
|
||||
|
||||
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
// Track which session is attached to which WebSocket
|
||||
const wsToSessionMap = new WeakMap<any, string>();
|
||||
|
||||
/**
|
||||
* Build system prompt with conversation history
|
||||
*/
|
||||
function buildSystemPrompt(messages: Message[], homeDir: string, skills: string): string {
|
||||
const history = messages
|
||||
.map((msg) => {
|
||||
if (msg.role === 'user') return `user: ${msg.text}`;
|
||||
if (msg.role === 'assistant') return `assistant: ${msg.text}`;
|
||||
if (msg.role === 'tool') return `tool(${msg.toolName}): ${msg.output}`;
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
return `
|
||||
<system>
|
||||
User home directory: ${homeDir}
|
||||
${skills}
|
||||
|
||||
Below is the conversation history from this session:
|
||||
<conversation_history>
|
||||
${history}
|
||||
</conversation_history>
|
||||
</system>
|
||||
`;
|
||||
function sendToClient(ws: ServerWebSocket<WSData> | null, msg: ServerMessage): void {
|
||||
if (ws?.readyState === 1) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket open handler
|
||||
*/
|
||||
export async function open(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
logger.info('WebSocket connection opened', { email: ws.data.email });
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket message handler
|
||||
*/
|
||||
export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void {
|
||||
const data = typeof raw === 'string' ? raw : raw.toString();
|
||||
|
||||
@@ -76,18 +44,11 @@ export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error handling WebSocket message', { email: ws.data.email, error: String(err) });
|
||||
const errorMsg: ServerMessage = {
|
||||
type: 'error',
|
||||
message: 'Failed to process message',
|
||||
};
|
||||
ws.send(JSON.stringify(errorMsg));
|
||||
sendToClient(ws, { type: 'error', message: 'Failed to process message' });
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket close handler
|
||||
*/
|
||||
export function close(ws: ServerWebSocket<WSData>): void {
|
||||
logger.info('WebSocket connection closed', { email: ws.data.email });
|
||||
|
||||
@@ -95,49 +56,176 @@ export function close(ws: ServerWebSocket<WSData>): void {
|
||||
if (sessionId) {
|
||||
sessionManager.detachWs(sessionId);
|
||||
sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
|
||||
logger.info('Started idle timeout for session', { sessionId, timeoutMs: IDLE_TIMEOUT_MS });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle chat message
|
||||
*/
|
||||
function createEventHandler(sessionId: string, model: string, cwd: string) {
|
||||
return async (event: PiEvent): Promise<void> => {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
const ws = session.ws as ServerWebSocket<WSData> | null;
|
||||
|
||||
switch (event.type) {
|
||||
case 'delta': {
|
||||
sendToClient(ws, { type: 'assistant:delta', text: event.text });
|
||||
session.streamBuffer += event.text;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
// Flush streaming buffer as complete text
|
||||
const text = event.text || session.streamBuffer;
|
||||
if (text) {
|
||||
sendToClient(ws, { type: 'assistant:text', text });
|
||||
|
||||
const assistantMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'assistant',
|
||||
text,
|
||||
model,
|
||||
};
|
||||
session.messages.push(assistantMsg);
|
||||
session.meta.messageCount += 1;
|
||||
session.streamBuffer = '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool:start': {
|
||||
// Flush any pending streaming text first
|
||||
if (session.streamBuffer) {
|
||||
sendToClient(ws, { type: 'assistant:text', text: session.streamBuffer });
|
||||
|
||||
const assistantMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'assistant',
|
||||
text: session.streamBuffer,
|
||||
model,
|
||||
};
|
||||
session.messages.push(assistantMsg);
|
||||
session.meta.messageCount += 1;
|
||||
session.streamBuffer = '';
|
||||
}
|
||||
|
||||
sendToClient(ws, {
|
||||
type: 'tool:start',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
toolInput: event.toolInput,
|
||||
});
|
||||
|
||||
const toolMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'tool',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
toolInput: event.toolInput,
|
||||
};
|
||||
session.messages.push(toolMsg);
|
||||
session.meta.messageCount += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool:result': {
|
||||
sendToClient(ws, {
|
||||
type: 'tool:result',
|
||||
toolCallId: event.toolCallId,
|
||||
output: event.output,
|
||||
isError: event.isError,
|
||||
});
|
||||
|
||||
// Update existing tool message with output
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
const m = session.messages[i]!;
|
||||
if (m.role === 'tool' && m.toolCallId === event.toolCallId) {
|
||||
m.output = event.output;
|
||||
m.isError = event.isError;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
// Flush any remaining streaming buffer
|
||||
if (session.streamBuffer) {
|
||||
sendToClient(ws, { type: 'assistant:text', text: session.streamBuffer });
|
||||
|
||||
const assistantMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'assistant',
|
||||
text: session.streamBuffer,
|
||||
model,
|
||||
cost: event.cost,
|
||||
};
|
||||
session.messages.push(assistantMsg);
|
||||
session.meta.messageCount += 1;
|
||||
session.streamBuffer = '';
|
||||
}
|
||||
|
||||
sendToClient(ws, { type: 'result', sessionId, cost: event.cost });
|
||||
|
||||
session.isGenerating = false;
|
||||
session.meta.cost.inputTokens += event.cost.inputTokens;
|
||||
session.meta.cost.outputTokens += event.cost.outputTokens;
|
||||
session.meta.cost.totalUSD += event.cost.totalUSD;
|
||||
session.meta.updatedAt = Date.now();
|
||||
|
||||
// Save session to disk
|
||||
try {
|
||||
await storage.saveSession(cwd, sessionId, session.meta, session.messages);
|
||||
logger.info('Session saved to disk', { sessionId, messageCount: session.messages.length });
|
||||
} catch (err) {
|
||||
logger.error('Failed to save session', { sessionId, error: String(err) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
sendToClient(ws, { type: 'error', message: event.message });
|
||||
session.isGenerating = false;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stopped': {
|
||||
sendToClient(ws, { type: 'stopped' });
|
||||
session.isGenerating = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function handleChat(
|
||||
ws: ServerWebSocket<WSData>,
|
||||
msg: { prompt: string; sessionId?: string; model?: string; cwd?: string; groupSlug?: string; attachmentIds?: string[] }
|
||||
): Promise<void> {
|
||||
const { email } = ws.data;
|
||||
const sessionId = msg.sessionId || randomUUID();
|
||||
const model = msg.model || 'gpt-4o'; // Default model
|
||||
const model = msg.model || 'opencode/big-pickle';
|
||||
const cwd = msg.cwd || getHomeDir(email);
|
||||
const groupSlug = msg.groupSlug || null;
|
||||
|
||||
// Get or create session
|
||||
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug);
|
||||
sessionManager.attachWs(sessionId, ws);
|
||||
wsToSessionMap.set(ws as any, sessionId);
|
||||
|
||||
// Send session init
|
||||
const initMsg: ServerMessage = {
|
||||
type: 'session:init',
|
||||
sessionId,
|
||||
model,
|
||||
cwd,
|
||||
};
|
||||
ws.send(JSON.stringify(initMsg));
|
||||
sendToClient(ws, { type: 'session:init', sessionId, model, cwd });
|
||||
|
||||
// Spawn Pi process if not already running
|
||||
if (!session.piProcess) {
|
||||
try {
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model);
|
||||
const onEvent = createEventHandler(sessionId, model, cwd);
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model, onEvent);
|
||||
logger.info('Spawned Pi process for session', { sessionId, model, cwd });
|
||||
} catch (err) {
|
||||
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
|
||||
const errorMsg: ServerMessage = {
|
||||
type: 'error',
|
||||
message: 'Failed to start Pi process',
|
||||
};
|
||||
ws.send(JSON.stringify(errorMsg));
|
||||
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -153,7 +241,6 @@ async function handleChat(
|
||||
session.meta.messageCount += 1;
|
||||
session.meta.updatedAt = Date.now();
|
||||
|
||||
// Initialize title from first message
|
||||
if (!session.meta.title) {
|
||||
session.meta.title = msg.prompt.slice(0, 100);
|
||||
}
|
||||
@@ -162,117 +249,8 @@ async function handleChat(
|
||||
const requestId = randomUUID();
|
||||
session.isGenerating = true;
|
||||
piBridge.sendPrompt(session.piProcess, msg.prompt, requestId);
|
||||
|
||||
// Track assistant message and cost
|
||||
let assistantText = '';
|
||||
let assistantMsgId = randomUUID();
|
||||
|
||||
// Stream responses from Pi
|
||||
try {
|
||||
for await (const event of piBridge.readEvents(session.piProcess)) {
|
||||
if (event.type === 'text') {
|
||||
const textMsg: ServerMessage = {
|
||||
type: 'assistant:text',
|
||||
text: event.text,
|
||||
};
|
||||
ws.send(JSON.stringify(textMsg));
|
||||
assistantText = event.text;
|
||||
} else if (event.type === 'delta') {
|
||||
const deltaMsg: ServerMessage = {
|
||||
type: 'assistant:delta',
|
||||
text: event.text,
|
||||
};
|
||||
ws.send(JSON.stringify(deltaMsg));
|
||||
session.streamBuffer += event.text;
|
||||
} else if (event.type === 'tool:start') {
|
||||
const toolStartMsg: ServerMessage = {
|
||||
type: 'tool:start',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
toolInput: event.toolInput,
|
||||
};
|
||||
ws.send(JSON.stringify(toolStartMsg));
|
||||
} else if (event.type === 'tool:result') {
|
||||
const toolResultMsg: ServerMessage = {
|
||||
type: 'tool:result',
|
||||
toolCallId: event.toolCallId,
|
||||
output: event.output,
|
||||
isError: event.isError,
|
||||
};
|
||||
ws.send(JSON.stringify(toolResultMsg));
|
||||
|
||||
// Add tool message to history
|
||||
const toolMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
role: 'tool',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolCallId.split(':')[0] || 'unknown',
|
||||
output: event.output,
|
||||
isError: event.isError,
|
||||
};
|
||||
session.messages.push(toolMsg);
|
||||
session.meta.messageCount += 1;
|
||||
} else if (event.type === 'result') {
|
||||
const resultMsg: ServerMessage = {
|
||||
type: 'result',
|
||||
sessionId,
|
||||
cost: event.cost,
|
||||
};
|
||||
ws.send(JSON.stringify(resultMsg));
|
||||
session.isGenerating = false;
|
||||
|
||||
// Add assistant message with cost
|
||||
const assistantMsg: Message = {
|
||||
id: assistantMsgId,
|
||||
timestamp: Date.now(),
|
||||
role: 'assistant',
|
||||
text: assistantText || session.streamBuffer,
|
||||
model,
|
||||
cost: event.cost,
|
||||
};
|
||||
session.messages.push(assistantMsg);
|
||||
session.meta.messageCount += 1;
|
||||
session.meta.cost.inputTokens += event.cost.inputTokens;
|
||||
session.meta.cost.outputTokens += event.cost.outputTokens;
|
||||
session.meta.cost.totalUSD += event.cost.totalUSD;
|
||||
session.meta.updatedAt = Date.now();
|
||||
|
||||
// Save session to disk
|
||||
try {
|
||||
await storage.saveSession(cwd, sessionId, session.meta, session.messages);
|
||||
logger.info('Session saved to disk', { sessionId, messageCount: session.messages.length });
|
||||
} catch (err) {
|
||||
logger.error('Failed to save session', { sessionId, error: String(err) });
|
||||
}
|
||||
|
||||
// Clear streaming buffer
|
||||
session.streamBuffer = '';
|
||||
} else if (event.type === 'error') {
|
||||
const errorMsg: ServerMessage = {
|
||||
type: 'error',
|
||||
message: event.message,
|
||||
};
|
||||
ws.send(JSON.stringify(errorMsg));
|
||||
session.isGenerating = false;
|
||||
} else if (event.type === 'stopped') {
|
||||
session.isGenerating = false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error streaming from Pi', { sessionId, error: String(err) });
|
||||
const errorMsg: ServerMessage = {
|
||||
type: 'error',
|
||||
message: 'Stream error',
|
||||
};
|
||||
ws.send(JSON.stringify(errorMsg));
|
||||
session.isGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle resume message
|
||||
*/
|
||||
async function handleResume(
|
||||
ws: ServerWebSocket<WSData>,
|
||||
msg: { sessionId: string }
|
||||
@@ -281,105 +259,68 @@ async function handleResume(
|
||||
const { sessionId } = msg;
|
||||
|
||||
try {
|
||||
// First, check if session exists in memory
|
||||
let session = sessionManager.getSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
// Load from disk
|
||||
// We need to try finding it - iterate through potential cwds
|
||||
const homeDir = getHomeDir(email);
|
||||
|
||||
try {
|
||||
const { meta, messages } = await storage.loadSession(homeDir, sessionId);
|
||||
|
||||
// Recreate session in memory
|
||||
session = sessionManager.getOrCreate(
|
||||
sessionId,
|
||||
email,
|
||||
meta.cwd,
|
||||
meta.model
|
||||
);
|
||||
session = sessionManager.getOrCreate(sessionId, email, meta.cwd, meta.model);
|
||||
session.messages = messages;
|
||||
session.meta = meta;
|
||||
|
||||
logger.info('Loaded session from disk', { sessionId, messageCount: messages.length });
|
||||
} catch (err) {
|
||||
logger.error('Failed to load session from disk', { sessionId, error: String(err) });
|
||||
const errorMsg: ServerMessage = {
|
||||
type: 'error',
|
||||
message: 'Session not found',
|
||||
errorCode: 'SESSION_NOT_FOUND',
|
||||
};
|
||||
ws.send(JSON.stringify(errorMsg));
|
||||
sendToClient(ws, { type: 'error', message: 'Session not found', errorCode: 'SESSION_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Attach WebSocket to session
|
||||
sessionManager.attachWs(sessionId, ws);
|
||||
wsToSessionMap.set(ws as any, sessionId);
|
||||
|
||||
// Send session init
|
||||
const initMsg: ServerMessage = {
|
||||
type: 'session:init',
|
||||
sessionId,
|
||||
model: session.model,
|
||||
cwd: session.cwd,
|
||||
};
|
||||
ws.send(JSON.stringify(initMsg));
|
||||
sendToClient(ws, { type: 'session:init', sessionId, model: session.model, cwd: session.cwd });
|
||||
|
||||
// Spawn fresh Pi process
|
||||
// Spawn fresh Pi process if needed
|
||||
if (!session.piProcess) {
|
||||
try {
|
||||
session.piProcess = await piBridge.spawnPi(session.cwd, session.model);
|
||||
const onEvent = createEventHandler(sessionId, session.model, session.cwd);
|
||||
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, onEvent);
|
||||
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model });
|
||||
} catch (err) {
|
||||
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
|
||||
const errorMsg: ServerMessage = {
|
||||
type: 'error',
|
||||
message: 'Failed to start Pi process',
|
||||
};
|
||||
ws.send(JSON.stringify(errorMsg));
|
||||
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Send full sync with history
|
||||
const syncMsg: ServerMessage = {
|
||||
sendToClient(ws, {
|
||||
type: 'sync:messages',
|
||||
sessionId,
|
||||
messages: session.messages,
|
||||
isGenerating: session.isGenerating,
|
||||
streamingText: session.streamBuffer,
|
||||
};
|
||||
ws.send(JSON.stringify(syncMsg));
|
||||
});
|
||||
|
||||
logger.info('Session resumed successfully', { sessionId, messageCount: session.messages.length });
|
||||
} catch (err) {
|
||||
logger.error('Unexpected error in handleResume', { sessionId, error: String(err) });
|
||||
const errorMsg: ServerMessage = {
|
||||
type: 'error',
|
||||
message: 'Failed to resume session',
|
||||
};
|
||||
ws.send(JSON.stringify(errorMsg));
|
||||
sendToClient(ws, { type: 'error', message: 'Failed to resume session' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle stop message
|
||||
*/
|
||||
async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
const sessionId = wsToSessionMap.get(ws);
|
||||
|
||||
if (sessionId) {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
|
||||
if (session && session.piProcess) {
|
||||
if (session?.piProcess) {
|
||||
try {
|
||||
// Send abort to Pi process
|
||||
const requestId = randomUUID();
|
||||
piBridge.abort(session.piProcess, requestId);
|
||||
|
||||
piBridge.abort(session.piProcess, randomUUID());
|
||||
logger.info('Sent abort to Pi process', { sessionId });
|
||||
session.isGenerating = false;
|
||||
} catch (err) {
|
||||
@@ -388,10 +329,7 @@ async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const stoppedMsg: ServerMessage = {
|
||||
type: 'stopped',
|
||||
};
|
||||
ws.send(JSON.stringify(stoppedMsg));
|
||||
sendToClient(ws, { type: 'stopped' });
|
||||
}
|
||||
|
||||
export const piWebsocket = {
|
||||
|
||||
Reference in New Issue
Block a user