chat history in projects
This commit is contained in:
+24
-18
@@ -4,6 +4,7 @@ import * as storage from './storage';
|
||||
import { readApiKeys, readLocalProviders } from '../server-settings/pi-mono';
|
||||
import { listPiModels } from './list-models';
|
||||
import { getHomeDir } from '../../data-path';
|
||||
import { resolveBaseCwd } from './websocket';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
@@ -38,7 +39,8 @@ piRestRouter.get('/pi/models', async (ctx: Context) => {
|
||||
|
||||
/**
|
||||
* POST /api/pi/sessions
|
||||
* List all sessions for the current user
|
||||
* List all sessions for the current user.
|
||||
* Optionally filter by cwd/cwdRoot to show only project-scoped sessions.
|
||||
*/
|
||||
piRestRouter.post('/pi/sessions', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
@@ -46,11 +48,15 @@ piRestRouter.post('/pi/sessions', async (ctx: Context) => {
|
||||
return ctx.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
// TODO: Implement proper user home directory resolution
|
||||
const body = await ctx.req.json().catch(() => ({}));
|
||||
const userHome = getHomeDir(user.email);
|
||||
const filterCwd = body.cwd ? resolveBaseCwd(user.email, body.cwdRoot, body.cwd) : null;
|
||||
|
||||
try {
|
||||
const sessions = await storage.listUserSessions(userHome);
|
||||
let sessions = await storage.listUserSessions(userHome);
|
||||
if (filterCwd) {
|
||||
sessions = sessions.filter((s) => s.cwd === filterCwd);
|
||||
}
|
||||
return ctx.json({ sessions });
|
||||
} catch (err) {
|
||||
logger.error('Failed to list sessions', { email: ctx.get('email'), error: String(err) });
|
||||
@@ -84,7 +90,7 @@ piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
// Not in root, search in groups
|
||||
const groups = await storage.listGroups(userHome);
|
||||
let found = false;
|
||||
|
||||
|
||||
for (const group of groups) {
|
||||
try {
|
||||
({ meta, messages } = await storage.loadSession(userHome, sessionId, group.slug));
|
||||
@@ -94,7 +100,7 @@ piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!found) {
|
||||
throw new Error('Session not found');
|
||||
}
|
||||
@@ -137,7 +143,7 @@ piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
try {
|
||||
// Find the session (root or in group)
|
||||
let groupSlug: string | null = null;
|
||||
|
||||
|
||||
try {
|
||||
const { meta } = await storage.loadSession(userHome, sessionId);
|
||||
groupSlug = meta.groupSlug || null;
|
||||
@@ -158,8 +164,8 @@ piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
const updatedMeta = await storage.updateSessionMeta(userHome, sessionId, {
|
||||
title: body.title,
|
||||
}, groupSlug);
|
||||
|
||||
return ctx.json({
|
||||
|
||||
return ctx.json({
|
||||
success: true,
|
||||
session: updatedMeta,
|
||||
});
|
||||
@@ -189,7 +195,7 @@ piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
try {
|
||||
// Find the session (root or in group)
|
||||
let groupSlug: string | null = null;
|
||||
|
||||
|
||||
try {
|
||||
const { meta } = await storage.loadSession(userHome, sessionId);
|
||||
groupSlug = meta.groupSlug || null;
|
||||
@@ -208,7 +214,7 @@ piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
}
|
||||
|
||||
await storage.deleteSession(userHome, sessionId, groupSlug);
|
||||
|
||||
|
||||
// Update group session count if in a group
|
||||
if (groupSlug) {
|
||||
try {
|
||||
@@ -220,7 +226,7 @@ piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => {
|
||||
// Group might not exist anymore
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return ctx.json({ success: true });
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete session', { sessionId, email: ctx.get('email'), error: String(err) });
|
||||
@@ -265,11 +271,11 @@ piRestRouter.post('/pi/groups', async (ctx: Context) => {
|
||||
}
|
||||
|
||||
const body = await ctx.req.json();
|
||||
|
||||
|
||||
if (!body.name || typeof body.name !== 'string') {
|
||||
return ctx.json({ error: 'Name is required and must be a string' }, 400);
|
||||
}
|
||||
|
||||
|
||||
if (!body.slug || typeof body.slug !== 'string') {
|
||||
return ctx.json({ error: 'Slug is required and must be a string' }, 400);
|
||||
}
|
||||
@@ -319,7 +325,7 @@ piRestRouter.post('/pi/groups', async (ctx: Context) => {
|
||||
await storage.saveGroup(userHome, groupMeta);
|
||||
}
|
||||
|
||||
return ctx.json({
|
||||
return ctx.json({
|
||||
success: true,
|
||||
group: groupMeta,
|
||||
});
|
||||
@@ -366,8 +372,8 @@ piRestRouter.patch('/pi/groups/:groupSlug', async (ctx: Context) => {
|
||||
}
|
||||
|
||||
const body = await ctx.req.json();
|
||||
|
||||
const updates: any = {};
|
||||
|
||||
const updates: Record<string, string> = {};
|
||||
if (body.name && typeof body.name === 'string') {
|
||||
updates.name = body.name;
|
||||
}
|
||||
@@ -383,7 +389,7 @@ piRestRouter.patch('/pi/groups/:groupSlug', async (ctx: Context) => {
|
||||
|
||||
try {
|
||||
const updatedGroup = await storage.updateGroupMeta(userHome, groupSlug, updates);
|
||||
return ctx.json({
|
||||
return ctx.json({
|
||||
success: true,
|
||||
group: updatedGroup,
|
||||
});
|
||||
@@ -482,7 +488,7 @@ piRestRouter.post('/pi/sessions/:sessionId/move', async (ctx: Context) => {
|
||||
|
||||
const updatedMeta = await storage.moveSession(userHome, sessionId, fromGroupSlug, toGroupSlug);
|
||||
|
||||
return ctx.json({
|
||||
return ctx.json({
|
||||
success: true,
|
||||
session: updatedMeta,
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ export type ClientMessage =
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
cwd?: string;
|
||||
cwdRoot?: string;
|
||||
sandboxed?: boolean;
|
||||
groupSlug?: string;
|
||||
attachmentIds?: string[];
|
||||
@@ -53,6 +54,8 @@ export type ClientMessage =
|
||||
| {
|
||||
type: "resume";
|
||||
sessionId: string;
|
||||
cwd?: string;
|
||||
cwdRoot?: string;
|
||||
}
|
||||
| {
|
||||
type: "stop";
|
||||
|
||||
@@ -50,6 +50,11 @@ const resolveCwd = (home: string, cwd?: string) => {
|
||||
return home;
|
||||
};
|
||||
|
||||
export const resolveBaseCwd = (email: string, cwdRoot?: string, cwd?: string) => {
|
||||
const root = resolveRoot(email, cwdRoot);
|
||||
return resolveCwd(root, cwd);
|
||||
};
|
||||
|
||||
const wsToSessionMap = new WeakMap<any, string>();
|
||||
|
||||
function sendToClient(ws: ServerWebSocket<WSData> | null, msg: ServerMessage): void {
|
||||
@@ -93,7 +98,7 @@ export function close(ws: ServerWebSocket<WSData>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function createEventHandler(sessionId: string, model: string, cwd: string) {
|
||||
function createEventHandler(sessionId: string, model: string, cwd: string, storageDir: string) {
|
||||
return async (event: PiEvent): Promise<void> => {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (!session) return;
|
||||
@@ -212,7 +217,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string) {
|
||||
|
||||
// Save session to disk
|
||||
try {
|
||||
await storage.saveSession(cwd, sessionId, session.meta, session.messages);
|
||||
await storage.saveSession(storageDir, 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) });
|
||||
@@ -280,7 +285,7 @@ async function handleChat(
|
||||
sendToClient(ws, { type: 'session:init', sessionId, model, cwd });
|
||||
if (!session.piProcess) {
|
||||
try {
|
||||
const onEvent = createEventHandler(sessionId, model, cwd);
|
||||
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined);
|
||||
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed });
|
||||
} catch (err) {
|
||||
@@ -313,17 +318,17 @@ async function handleChat(
|
||||
|
||||
async function handleResume(
|
||||
ws: ServerWebSocket<WSData>,
|
||||
msg: { sessionId: string }
|
||||
msg: { sessionId: string; cwd?: string; cwdRoot?: string }
|
||||
): Promise<void> {
|
||||
const { email } = ws.data;
|
||||
const { sessionId } = msg;
|
||||
|
||||
try {
|
||||
let session = sessionManager.getSession(sessionId);
|
||||
|
||||
|
||||
if (!session) {
|
||||
const homeDir = getHomeDir(email);
|
||||
|
||||
|
||||
try {
|
||||
const { meta, messages } = await storage.loadSession(homeDir, sessionId);
|
||||
|
||||
@@ -349,7 +354,7 @@ async function handleResume(
|
||||
try {
|
||||
const homeDir = getHomeDir(email);
|
||||
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
|
||||
const onEvent = createEventHandler(sessionId, session.model, session.cwd);
|
||||
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
|
||||
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, onEvent, sandbox);
|
||||
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed });
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MessageSquare, History, Plus } from 'lucide-react';
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
|
||||
import { useWorkspace } from '../../components/Workspace/WorkspaceContext';
|
||||
import { useChatSessions } from './useChatSessions';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { getProviderDisplayName } from 'state/useModels';
|
||||
|
||||
type ChatSessionSelection = {
|
||||
sessionId: string | null;
|
||||
model?: string | null;
|
||||
};
|
||||
|
||||
function formatModel(model: string): string {
|
||||
if (!model.includes('/')) return model;
|
||||
const [provider, modelId] = model.split('/') as [string, string];
|
||||
if (provider.startsWith('officer-local-')) {
|
||||
return `${getProviderDisplayName(provider)} - ${modelId}`;
|
||||
}
|
||||
return model.replace('/', ' - ');
|
||||
}
|
||||
|
||||
export const ChatHeader = () => {
|
||||
const { cwd, root } = useWorkspace();
|
||||
const scoped = cwd !== '~';
|
||||
const { sessions } = useChatSessions(scoped ? { cwd, cwdRoot: root } : {});
|
||||
const [selection, setSelection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null);
|
||||
const [activeSessionId] = usePanelChannel<string | null>('chat:active-session', null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const autoResumedRef = useRef(false);
|
||||
|
||||
// Auto-resume the latest session on mount
|
||||
useEffect(() => {
|
||||
if (autoResumedRef.current || selection) return;
|
||||
if (sessions.length > 0) {
|
||||
const latest = sessions[0]!;
|
||||
setSelection({ sessionId: latest.id, model: latest.model ?? null });
|
||||
autoResumedRef.current = true;
|
||||
}
|
||||
}, [sessions, selection]);
|
||||
|
||||
const selectSession = (sessionId: string | null, model?: string | null) => {
|
||||
setSelection({ sessionId, model });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const activeInList = activeSessionId ? sessions.some((s) => s.id === activeSessionId) : false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium truncate flex-1">Chat</span>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
|
||||
title="Session history"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72 p-0 max-h-80 flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||
<span className="text-xs font-medium">Sessions</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectSession(null)}
|
||||
className="flex items-center gap-1 text-xs text-duck-teal hover:text-duck-teal/80 cursor-pointer"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Show active session at top if not yet in fetched list */}
|
||||
{activeSessionId && !activeInList && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectSession(activeSessionId)}
|
||||
className="w-full text-left px-3 py-2 bg-duck-teal/10 border-b border-border/50 cursor-pointer"
|
||||
>
|
||||
<div className="text-xs font-medium truncate text-duck-teal">Current session</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono">{activeSessionId.slice(0, 8)}</div>
|
||||
</button>
|
||||
)}
|
||||
{sessions.length === 0 && !activeSessionId ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-muted-foreground">No sessions yet</div>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => selectSession(session.id, session.model)}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors cursor-pointer border-b border-border/50 last:border-0 ${isActive ? 'bg-duck-teal/10' : ''}`}
|
||||
>
|
||||
<div className={`text-xs font-medium truncate ${isActive ? 'text-duck-teal' : ''}`}>
|
||||
{session.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<span>
|
||||
{new Date(session.updatedAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
{session.model && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40">|</span>
|
||||
<span className="truncate text-duck-teal/60">{formatModel(session.model)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +1,44 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useWorkspace } from '../../components/Workspace';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { usePiChat } from '../../hooks/usePiChat';
|
||||
import { EmbeddableChat } from './EmbeddableChat';
|
||||
|
||||
type ChatSessionSelection = {
|
||||
sessionId: string | null;
|
||||
model?: string | null;
|
||||
};
|
||||
|
||||
export const ChatPanelWrapper = () => {
|
||||
const { cwd, root } = useWorkspace();
|
||||
const scoped = cwd !== '~';
|
||||
const hostRoot = root === '~' || root === 'officer.dev';
|
||||
const sandboxed = !hostRoot;
|
||||
|
||||
const [selection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null);
|
||||
const [, setActiveSession] = usePanelChannel<string | null>('chat:active-session', null);
|
||||
|
||||
const cwdParam = scoped ? { root, path: cwd } : undefined;
|
||||
|
||||
const sessionId = selection?.sessionId ?? undefined;
|
||||
const model = selection?.model ?? undefined;
|
||||
|
||||
const chat = usePiChat(sessionId, model, { replaceUrl: false });
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSession(chat.sessionId);
|
||||
}, [chat.sessionId]);
|
||||
|
||||
return (
|
||||
<EmbeddableChat
|
||||
key={sessionId ?? 'new'}
|
||||
className="h-full"
|
||||
cwd={scoped ? { root, path: cwd } : undefined}
|
||||
chat={chat}
|
||||
sessionId={sessionId}
|
||||
initialModel={model}
|
||||
cwd={cwdParam}
|
||||
sandboxed={sandboxed}
|
||||
replaceUrl={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { ChatPanelWrapper } from './ChatPanelWrapper';
|
||||
import { ChatHeader } from './ChatHeader';
|
||||
|
||||
export { MessageList } from './components/MessageList';
|
||||
export { MessageBubble, StreamingBubble } from './components/MessageBubble';
|
||||
@@ -29,5 +30,6 @@ export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
name: 'Chat',
|
||||
icon: MessageSquare,
|
||||
component: ChatPanelWrapper,
|
||||
header: ChatHeader,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -5,23 +5,30 @@ import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
type UseChatSessionsParams = {
|
||||
cwd?: string;
|
||||
cwdRoot?: string;
|
||||
};
|
||||
|
||||
export function useChatSessions({ cwd }: UseChatSessionsParams = {}) {
|
||||
export function useChatSessions({ cwd, cwdRoot }: UseChatSessionsParams = {}) {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: sessions = [], isLoading } = useQuery<SessionEntry[]>({
|
||||
queryKey: ['PI_SESSIONS', cwd],
|
||||
queryKey: ['PI_SESSIONS', cwd, cwdRoot],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: async () => {
|
||||
const result = await client.post<{ sessions: SessionEntry[] }>('/pi/sessions', cwd ? { cwd } : {});
|
||||
const body: Record<string, string> = {};
|
||||
if (cwd) body.cwd = cwd;
|
||||
if (cwdRoot) body.cwdRoot = cwdRoot;
|
||||
const result = await client.post<{ sessions: SessionEntry[] }>('/pi/sessions', body);
|
||||
return result.sessions;
|
||||
},
|
||||
});
|
||||
|
||||
function searchSessions(query: string) {
|
||||
return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?q=${encodeURIComponent(query)}`);
|
||||
const params = new URLSearchParams({ q: query });
|
||||
if (cwd) params.set('cwd', cwd);
|
||||
if (cwdRoot) params.set('cwdRoot', cwdRoot);
|
||||
return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?${params}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -41,6 +41,11 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
|
||||
}
|
||||
}, [initialSessionId, initialModel, settings]);
|
||||
|
||||
// Sync selectedModel when initialModel changes (e.g. resuming a session)
|
||||
useEffect(() => {
|
||||
if (initialModel) setSelectedModel(initialModel);
|
||||
}, [initialModel]);
|
||||
|
||||
const streamingRef = useRef('');
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
|
||||
|
||||
Reference in New Issue
Block a user