harness uniformization
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import { useRef, useEffect, useState } from 'react';
|
import { useRef, useEffect, useState } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router';
|
import { useNavigate, useLocation } from 'react-router';
|
||||||
import { useSessions, useOpenCodeSessions, useSlashCommands, SessionBar } from 'widgets/ChatHistory';
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
|
import { useSlashCommands } from '@/state/useSlashCommands';
|
||||||
|
import { SessionBar } from 'widgets/ChatHistory';
|
||||||
import type { ModelOption } from '@/state/useModels';
|
import type { ModelOption } from '@/state/useModels';
|
||||||
import type { useClaude } from './useClaude';
|
import type { useClaude } from './useClaude';
|
||||||
import { EmbeddableChat, type Attachment } from './EmbeddableChat';
|
import { EmbeddableChat, type Attachment } from './EmbeddableChat';
|
||||||
@@ -24,13 +26,7 @@ export const ChatPanel = ({ chat, provider = 'claude', availableModels = [], onP
|
|||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const initialSentRef = useRef(false);
|
const initialSentRef = useRef(false);
|
||||||
|
|
||||||
const claudeSessions = useSessions();
|
const { sessions, archiveSession, deleteSession } = useChatSessions();
|
||||||
const opencodeSessions = useOpenCodeSessions();
|
|
||||||
const { archiveSession, deleteSession } =
|
|
||||||
provider === 'claude'
|
|
||||||
? claudeSessions
|
|
||||||
: { archiveSession: undefined, deleteSession: opencodeSessions.deleteSession };
|
|
||||||
const sessions = provider === 'claude' ? claudeSessions.sessions : opencodeSessions.sessions;
|
|
||||||
const slashCommands = useSlashCommands({ sessionId });
|
const slashCommands = useSlashCommands({ sessionId });
|
||||||
const sessionTitle = sessionId ? sessions.find((s) => s.id === sessionId)?.title : undefined;
|
const sessionTitle = sessionId ? sessions.find((s) => s.id === sessionId)?.title : undefined;
|
||||||
const listPath = '/chat';
|
const listPath = '/chat';
|
||||||
@@ -89,16 +85,16 @@ export const ChatPanel = ({ chat, provider = 'claude', availableModels = [], onP
|
|||||||
isGenerating={isGenerating}
|
isGenerating={isGenerating}
|
||||||
fullscreen={fullscreen}
|
fullscreen={fullscreen}
|
||||||
onArchive={
|
onArchive={
|
||||||
archiveSession && sessionId
|
provider === 'claude' && sessionId
|
||||||
? async () => {
|
? async () => {
|
||||||
await archiveSession(sessionId);
|
await archiveSession(provider, sessionId);
|
||||||
navigate(listPath);
|
navigate(listPath);
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
await deleteSession(sessionId);
|
await deleteSession(provider, sessionId);
|
||||||
navigate(listPath);
|
navigate(listPath);
|
||||||
}}
|
}}
|
||||||
onToggleFullscreen={() => setFullscreen((f) => !f)}
|
onToggleFullscreen={() => setFullscreen((f) => !f)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||||
import { useSessions } from 'widgets/ChatHistory';
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
import type { ChatMessage, ServerMessage, TaskInfo } from 'widgets/Chat';
|
import type { ChatMessage, ServerMessage, TaskInfo } from 'widgets/Chat';
|
||||||
|
|
||||||
const SAVE_DEBOUNCE_MS = 1000;
|
const SAVE_DEBOUNCE_MS = 1000;
|
||||||
@@ -31,7 +31,7 @@ export const useClaude = (initialSessionId?: string, initialModel?: string | nul
|
|||||||
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
|
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
|
||||||
const saveTimerRef = useRef<number | null>(null);
|
const saveTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const { getMessages, saveMessages } = useSessions();
|
const { getMessages, saveMessages } = useChatSessions();
|
||||||
|
|
||||||
const token = localStorage.getItem('BEARER_TOKEN');
|
const token = localStorage.getItem('BEARER_TOKEN');
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
@@ -137,7 +137,7 @@ export const useClaude = (initialSessionId?: string, initialModel?: string | nul
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!initialSessionId) return;
|
if (!initialSessionId) return;
|
||||||
getMessages(initialSessionId)
|
getMessages('claude', initialSessionId)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (Array.isArray(data) && data.length > 0) setMessages(data);
|
if (Array.isArray(data) && data.length > 0) setMessages(data);
|
||||||
})
|
})
|
||||||
@@ -156,7 +156,7 @@ export const useClaude = (initialSessionId?: string, initialModel?: string | nul
|
|||||||
if (storage) {
|
if (storage) {
|
||||||
storage.save(sid, snapshot).catch(() => {});
|
storage.save(sid, snapshot).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
saveMessages(sid, snapshot).catch(() => {});
|
saveMessages('claude', sid, snapshot).catch(() => {});
|
||||||
}
|
}
|
||||||
saveTimerRef.current = null;
|
saveTimerRef.current = null;
|
||||||
}, SAVE_DEBOUNCE_MS);
|
}, SAVE_DEBOUNCE_MS);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||||
import { useSettings } from '@/state/useSettings';
|
import { useSettings } from '@/state/useSettings';
|
||||||
import { useVisibleOpenCodeModels } from '@/state/useModels';
|
import { useVisibleOpenCodeModels } from '@/state/useModels';
|
||||||
import { useOpenCodeSessions } from 'widgets/ChatHistory';
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
import type { ChatMessage, ServerMessage, TaskInfo } from 'widgets/Chat';
|
import type { ChatMessage, ServerMessage, TaskInfo } from 'widgets/Chat';
|
||||||
|
|
||||||
type UseOpenCodeOptions = {
|
type UseOpenCodeOptions = {
|
||||||
@@ -29,7 +29,7 @@ export const useOpenCode = (initialSessionId?: string, initialModel?: string | n
|
|||||||
setSelectedModel(value);
|
setSelectedModel(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { getMessages } = useOpenCodeSessions();
|
const { getMessages } = useChatSessions();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const openCodeModels = useVisibleOpenCodeModels();
|
const openCodeModels = useVisibleOpenCodeModels();
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ export const useOpenCode = (initialSessionId?: string, initialModel?: string | n
|
|||||||
// Load messages from OpenCode on mount when resuming a session
|
// Load messages from OpenCode on mount when resuming a session
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialSessionId) return;
|
if (!initialSessionId) return;
|
||||||
getMessages(initialSessionId)
|
getMessages('opencode', initialSessionId)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (Array.isArray(data) && data.length > 0) setMessages(data);
|
if (Array.isArray(data) && data.length > 0) setMessages(data);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,28 +1,17 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { Plus, MessageSquare, Trash2 } from 'lucide-react';
|
import { Plus, MessageSquare, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
import { useSessions, useOpenCodeSessions } from 'widgets/ChatHistory';
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
|
|
||||||
type Filter = 'all' | 'claude' | 'opencode';
|
type Filter = 'all' | 'claude' | 'opencode';
|
||||||
|
|
||||||
export const SessionList = () => {
|
export const SessionList = () => {
|
||||||
const [filter, setFilter] = useState<Filter>('all');
|
const [filter, setFilter] = useState<Filter>('all');
|
||||||
const claude = useSessions();
|
const { sessions, deleteSession } = useChatSessions();
|
||||||
const opencode = useOpenCodeSessions();
|
|
||||||
|
|
||||||
const merged = useMemo(
|
const filtered = filter === 'all' ? sessions : sessions.filter((s) => s.provider === filter);
|
||||||
() => [...claude.sessions, ...opencode.sessions].sort((a, b) => b.createdAt - a.createdAt),
|
|
||||||
[claude.sessions, opencode.sessions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filtered = filter === 'all' ? merged : merged.filter((s) => s.provider === filter);
|
|
||||||
|
|
||||||
const handleDelete = (id: string, provider: 'claude' | 'opencode') => {
|
|
||||||
if (provider === 'claude') claude.deleteSession(id);
|
|
||||||
else opencode.deleteSession(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full items-center p-4 md:p-6">
|
<div className="flex flex-col h-full items-center p-4 md:p-6">
|
||||||
@@ -90,7 +79,7 @@ export const SessionList = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(session.id, session.provider)}
|
onClick={() => deleteSession(session.provider, session.id)}
|
||||||
className="shrink-0 p-2 mr-2 text-duck-dark/20 hover:text-red-500 md:opacity-0 md:group-hover:opacity-100 transition-opacity cursor-pointer"
|
className="shrink-0 p-2 mr-2 text-duck-dark/20 hover:text-red-500 md:opacity-0 md:group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|||||||
@@ -1,25 +1,12 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { MessageSquare, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
|
import { MessageSquare, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
import { useSessions } from 'widgets/ChatHistory';
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
import { useOpenCodeSessions } from 'widgets/ChatHistory';
|
|
||||||
import { useUserState } from '@/state/useUserState';
|
import { useUserState } from '@/state/useUserState';
|
||||||
|
|
||||||
export const ChatHistory = () => {
|
export const ChatHistory = () => {
|
||||||
const [collapsed, setCollapsed] = useUserState('widget:chatHistory:collapsed', true);
|
const [collapsed, setCollapsed] = useUserState('widget:chatHistory:collapsed', true);
|
||||||
const claude = useSessions();
|
const { sessions, deleteSession } = useChatSessions();
|
||||||
const opencode = useOpenCodeSessions();
|
|
||||||
|
|
||||||
const sessions = useMemo(
|
|
||||||
() => [...claude.sessions, ...opencode.sessions].sort((a, b) => b.createdAt - a.createdAt),
|
|
||||||
[claude.sessions, opencode.sessions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDelete = (id: string, provider: 'claude' | 'opencode') => {
|
|
||||||
if (provider === 'claude') claude.deleteSession(id);
|
|
||||||
else opencode.deleteSession(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -72,7 +59,7 @@ export const ChatHistory = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(session.id, session.provider)}
|
onClick={() => deleteSession(session.provider, session.id)}
|
||||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
|
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
|||||||
+17
-14
@@ -4,31 +4,34 @@ import { useAuth } from 'hooks/useAuth';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
export const useSessions = () => {
|
export const useChatSessions = () => {
|
||||||
type RawSessionEntry = Omit<SessionEntry, 'provider'>;
|
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
const { data: sessions = [] } = useQuery<RawSessionEntry[], Error, SessionEntry[]>({
|
const { data: sessions = [] } = useQuery<SessionEntry[]>({
|
||||||
queryKey: ['SESSIONS'],
|
queryKey: ['SESSIONS'],
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
queryFn: () => client.get<RawSessionEntry[]>('/sessions'),
|
queryFn: () => client.get<SessionEntry[]>('/sessions'),
|
||||||
select: (data) => data.map((s) => ({ ...s, provider: 'claude' as const })),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const getMessages = (sessionId: string) => client.get<ChatMessage[]>(`/sessions/${sessionId}/messages`);
|
const getMessages = (provider: 'claude' | 'opencode', sessionId: string) =>
|
||||||
|
client.get<ChatMessage[]>(`/sessions/${provider}/${sessionId}/messages`);
|
||||||
|
|
||||||
const saveMessages = (sessionId: string, messages: ChatMessage[]) =>
|
const saveMessages = (provider: 'claude' | 'opencode', sessionId: string, messages: ChatMessage[]) =>
|
||||||
client.put(`/sessions/${sessionId}/messages`, messages);
|
client.put(`/sessions/${provider}/${sessionId}/messages`, messages);
|
||||||
|
|
||||||
const renameSession = async (sessionId: string | null, args: string): Promise<SlashCommandResult> => {
|
const renameSession = async (
|
||||||
|
provider: 'claude' | 'opencode',
|
||||||
|
sessionId: string | null,
|
||||||
|
args: string,
|
||||||
|
): Promise<SlashCommandResult> => {
|
||||||
if (!args) return { handled: true, feedback: 'Usage: /rename <new title>' };
|
if (!args) return { handled: true, feedback: 'Usage: /rename <new title>' };
|
||||||
if (!sessionId) return { handled: true, feedback: 'No active session to rename.' };
|
if (!sessionId) return { handled: true, feedback: 'No active session to rename.' };
|
||||||
|
|
||||||
const title = args.slice(0, 200);
|
const title = args.slice(0, 200);
|
||||||
try {
|
try {
|
||||||
await client.put(`/sessions/${sessionId}`, { title });
|
await client.put(`/sessions/${provider}/${sessionId}`, { title });
|
||||||
queryClient.setQueryData<SessionEntry[]>(
|
queryClient.setQueryData<SessionEntry[]>(
|
||||||
['SESSIONS'],
|
['SESSIONS'],
|
||||||
(prev) => prev?.map((s) => (s.id === sessionId ? { ...s, title } : s)) ?? [],
|
(prev) => prev?.map((s) => (s.id === sessionId ? { ...s, title } : s)) ?? [],
|
||||||
@@ -39,13 +42,13 @@ export const useSessions = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const archiveSession = async (sessionId: string) => {
|
const archiveSession = async (provider: 'claude' | 'opencode', sessionId: string) => {
|
||||||
await client.post(`/sessions/${sessionId}/archive`);
|
await client.post(`/sessions/${provider}/${sessionId}/archive`);
|
||||||
queryClient.setQueryData<SessionEntry[]>(['SESSIONS'], (prev) => prev?.filter((s) => s.id !== sessionId) ?? []);
|
queryClient.setQueryData<SessionEntry[]>(['SESSIONS'], (prev) => prev?.filter((s) => s.id !== sessionId) ?? []);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteSession = async (sessionId: string) => {
|
const deleteSession = async (provider: 'claude' | 'opencode', sessionId: string) => {
|
||||||
await client.delete(`/sessions/${sessionId}`);
|
await client.delete(`/sessions/${provider}/${sessionId}`);
|
||||||
queryClient.setQueryData<SessionEntry[]>(['SESSIONS'], (prev) => prev?.filter((s) => s.id !== sessionId) ?? []);
|
queryClient.setQueryData<SessionEntry[]>(['SESSIONS'], (prev) => prev?.filter((s) => s.id !== sessionId) ?? []);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useSessions } from 'widgets/ChatHistory';
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
import { usePlans } from './usePlans';
|
import { usePlans } from './usePlans';
|
||||||
import { useSettings } from './useSettings';
|
import { useSettings } from './useSettings';
|
||||||
import { useThemeSync } from './useThemeSync';
|
import { useThemeSync } from './useThemeSync';
|
||||||
|
|
||||||
export const useInitialData = () => {
|
export const useInitialData = () => {
|
||||||
const { sessions } = useSessions();
|
const { sessions } = useChatSessions();
|
||||||
const { plans } = usePlans();
|
const { plans } = usePlans();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
useThemeSync();
|
useThemeSync();
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
import { useSessions } from './useSessions';
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
|
|
||||||
export type SlashCommandResult = { handled: true; feedback: string } | { handled: false };
|
export type SlashCommandResult = { handled: true; feedback: string } | { handled: false };
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ type UseSlashCommandsParams = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useSlashCommands = ({ sessionId }: UseSlashCommandsParams) => {
|
export const useSlashCommands = ({ sessionId }: UseSlashCommandsParams) => {
|
||||||
const { renameSession } = useSessions();
|
const { renameSession } = useChatSessions();
|
||||||
|
|
||||||
const execute = async (input: string): Promise<SlashCommandResult> => {
|
const execute = async (input: string): Promise<SlashCommandResult> => {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
@@ -19,7 +19,7 @@ export const useSlashCommands = ({ sessionId }: UseSlashCommandsParams) => {
|
|||||||
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'rename':
|
case 'rename':
|
||||||
return renameSession(sessionId, args);
|
return renameSession('claude', sessionId, args);
|
||||||
default:
|
default:
|
||||||
return { handled: false };
|
return { handled: false };
|
||||||
}
|
}
|
||||||
+1
-2
@@ -2,7 +2,7 @@ import './servers/bootstrap';
|
|||||||
import type { ServerWebSocket } from 'bun';
|
import type { ServerWebSocket } from 'bun';
|
||||||
import { serve } from 'bun';
|
import { serve } from 'bun';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { honoServer, loadPlugins } from './servers/hono';
|
import { honoServer } from './servers/hono';
|
||||||
import { verify } from './servers/jwt';
|
import { verify } from './servers/jwt';
|
||||||
import { officerdb, TokenBlacklist } from 'officerdb';
|
import { officerdb, TokenBlacklist } from 'officerdb';
|
||||||
import { claudeWebsocket } from './servers/api/claude/websocket';
|
import { claudeWebsocket } from './servers/api/claude/websocket';
|
||||||
@@ -11,7 +11,6 @@ import { terminalWebsocket } from './servers/api/terminal/websocket';
|
|||||||
import officerWeb from './apps/officer-web/index.html';
|
import officerWeb from './apps/officer-web/index.html';
|
||||||
|
|
||||||
const { PORT = '5000' } = process.env;
|
const { PORT = '5000' } = process.env;
|
||||||
await loadPlugins();
|
|
||||||
|
|
||||||
type WSData = { userId: number; email: string; provider: 'claude' | 'opencode' | 'terminal' };
|
type WSData = { userId: number; email: string; provider: 'claude' | 'opencode' | 'terminal' };
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { getClaudeDir, getSessionDir, getArchivedSessionDir } from '@@/data-path';
|
|
||||||
import type { HonoVariables } from '@@/create-router';
|
import type { HonoVariables } from '@@/create-router';
|
||||||
|
|
||||||
export const sessionsRouter = new Hono<{ Variables: HonoVariables }>();
|
export const claudeModelsRouter = new Hono<{ Variables: HonoVariables }>();
|
||||||
|
|
||||||
sessionsRouter.get('/claude/models', async (ctx) => {
|
claudeModelsRouter.get('/claude/models', async (ctx) => {
|
||||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||||
if (!apiKey) return ctx.json([]);
|
if (!apiKey) return ctx.json([]);
|
||||||
|
|
||||||
@@ -23,92 +20,3 @@ sessionsRouter.get('/claude/models', async (ctx) => {
|
|||||||
return ctx.json([]);
|
return ctx.json([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
sessionsRouter.get('/sessions', async (ctx) => {
|
|
||||||
const { email } = ctx.get('user');
|
|
||||||
const dir = getClaudeDir(email);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const entries = await readdir(dir);
|
|
||||||
const sessions = await Promise.all(
|
|
||||||
entries
|
|
||||||
.filter((name) => name !== 'archived')
|
|
||||||
.map(async (id) => {
|
|
||||||
try {
|
|
||||||
const metaFile = Bun.file(join(dir, id, 'meta.json'));
|
|
||||||
if (!(await metaFile.exists())) return null;
|
|
||||||
return await metaFile.json();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const valid = sessions.filter(Boolean);
|
|
||||||
valid.sort((a: any, b: any) => b.createdAt - a.createdAt);
|
|
||||||
return ctx.json(valid);
|
|
||||||
} catch {
|
|
||||||
return ctx.json([]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
sessionsRouter.get('/sessions/:id/messages', async (ctx) => {
|
|
||||||
const { email } = ctx.get('user');
|
|
||||||
const id = ctx.req.param('id');
|
|
||||||
const file = Bun.file(join(getSessionDir(email, id), 'messages.json'));
|
|
||||||
|
|
||||||
if (!(await file.exists())) return ctx.json([]);
|
|
||||||
const messages = await file.json();
|
|
||||||
return ctx.json(messages);
|
|
||||||
});
|
|
||||||
|
|
||||||
sessionsRouter.put('/sessions/:id/messages', async (ctx) => {
|
|
||||||
const { email } = ctx.get('user');
|
|
||||||
const id = ctx.req.param('id');
|
|
||||||
const messages = ctx.get('body');
|
|
||||||
const dir = getSessionDir(email, id);
|
|
||||||
|
|
||||||
await Bun.write(join(dir, 'messages.json'), JSON.stringify(messages));
|
|
||||||
return ctx.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
sessionsRouter.put('/sessions/:id', async (ctx) => {
|
|
||||||
const { email } = ctx.get('user');
|
|
||||||
const id = ctx.req.param('id');
|
|
||||||
const body = ctx.get('body') as { title?: string };
|
|
||||||
if (!body?.title || typeof body.title !== 'string') return ctx.json({ error: 'title required' }, 400);
|
|
||||||
|
|
||||||
const dir = getSessionDir(email, id);
|
|
||||||
const metaFile = Bun.file(join(dir, 'meta.json'));
|
|
||||||
|
|
||||||
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
|
|
||||||
|
|
||||||
const meta = await metaFile.json();
|
|
||||||
meta.title = body.title.slice(0, 200);
|
|
||||||
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
|
|
||||||
return ctx.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
sessionsRouter.delete('/sessions/:id', async (ctx) => {
|
|
||||||
const { email } = ctx.get('user');
|
|
||||||
const id = ctx.req.param('id');
|
|
||||||
const dir = getSessionDir(email, id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await rm(dir, { recursive: true });
|
|
||||||
} catch {
|
|
||||||
// dir may not exist
|
|
||||||
}
|
|
||||||
return ctx.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
sessionsRouter.post('/sessions/:id/archive', async (ctx) => {
|
|
||||||
const { email } = ctx.get('user');
|
|
||||||
const id = ctx.req.param('id');
|
|
||||||
const src = getSessionDir(email, id);
|
|
||||||
const dest = getArchivedSessionDir(email, id);
|
|
||||||
|
|
||||||
await mkdir(join(getClaudeDir(email), 'archived'), { recursive: true });
|
|
||||||
await rename(src, dest);
|
|
||||||
return ctx.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import type { HonoVariables } from '@@/create-router';
|
|||||||
const OPENCODE_PORT = process.env.OPENCODE_PORT ?? '10006';
|
const OPENCODE_PORT = process.env.OPENCODE_PORT ?? '10006';
|
||||||
const BASE = `http://localhost:${OPENCODE_PORT}`;
|
const BASE = `http://localhost:${OPENCODE_PORT}`;
|
||||||
|
|
||||||
export const opencodeSessionsRouter = new Hono<{ Variables: HonoVariables }>();
|
export const opencodeModelsRouter = new Hono<{ Variables: HonoVariables }>();
|
||||||
|
|
||||||
// List all active models from OpenCode — visibility filtering is handled client-side
|
opencodeModelsRouter.get('/opencode/models', async (ctx) => {
|
||||||
opencodeSessionsRouter.get('/models', async (ctx) => {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BASE}/provider`);
|
const res = await fetch(`${BASE}/provider`);
|
||||||
if (!res.ok) return ctx.json([]);
|
if (!res.ok) return ctx.json([]);
|
||||||
@@ -34,120 +33,3 @@ opencodeSessionsRouter.get('/models', async (ctx) => {
|
|||||||
return ctx.json([]);
|
return ctx.json([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// List sessions — proxy to OpenCode API (SQLite-backed)
|
|
||||||
opencodeSessionsRouter.get('/sessions', async (ctx) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${BASE}/session`);
|
|
||||||
if (!res.ok) return ctx.json([]);
|
|
||||||
const data = (await res.json()) as any[];
|
|
||||||
const sessions = data.map((s: any) => ({
|
|
||||||
id: s.id,
|
|
||||||
title: s.title ?? 'Untitled',
|
|
||||||
createdAt: s.time?.created ?? 0,
|
|
||||||
}));
|
|
||||||
sessions.sort((a: any, b: any) => b.createdAt - a.createdAt);
|
|
||||||
return ctx.json(sessions);
|
|
||||||
} catch {
|
|
||||||
return ctx.json([]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get session messages — proxy to OpenCode
|
|
||||||
opencodeSessionsRouter.get('/sessions/:id/messages', async (ctx) => {
|
|
||||||
const id = ctx.req.param('id');
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${BASE}/session/${id}/message`);
|
|
||||||
if (!res.ok) return ctx.json([]);
|
|
||||||
|
|
||||||
const data = (await res.json()) as any[];
|
|
||||||
const messages = Array.isArray(data) ? data : Object.values(data);
|
|
||||||
|
|
||||||
const chatMessages: any[] = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
const role = msg.info?.role ?? msg.role;
|
|
||||||
if (role === 'user') {
|
|
||||||
const text = Array.isArray(msg.parts)
|
|
||||||
? msg.parts
|
|
||||||
.filter((p: any) => p.type === 'text')
|
|
||||||
.map((p: any) => p.text ?? p.content ?? '')
|
|
||||||
.join('')
|
|
||||||
: typeof msg.content === 'string'
|
|
||||||
? msg.content
|
|
||||||
: '';
|
|
||||||
if (text) chatMessages.push({ role: 'user', text });
|
|
||||||
} else if (role === 'assistant') {
|
|
||||||
if (Array.isArray(msg.parts)) {
|
|
||||||
for (const part of msg.parts) {
|
|
||||||
if (part.type === 'text' && (part.text || part.content)) {
|
|
||||||
chatMessages.push({ role: 'assistant', text: part.text ?? part.content ?? '' });
|
|
||||||
} else if (part.type === 'tool') {
|
|
||||||
chatMessages.push({
|
|
||||||
role: 'tool',
|
|
||||||
toolName: part.tool ?? 'unknown',
|
|
||||||
toolInput: part.state?.input ?? {},
|
|
||||||
toolUseId: part.callID ?? part.id ?? '',
|
|
||||||
output:
|
|
||||||
part.state?.output != null
|
|
||||||
? typeof part.state.output === 'string'
|
|
||||||
? part.state.output
|
|
||||||
: JSON.stringify(part.state.output)
|
|
||||||
: undefined,
|
|
||||||
isError: part.state?.status === 'error',
|
|
||||||
});
|
|
||||||
} else if (part.type === 'tool-invocation') {
|
|
||||||
const inv = part.toolInvocation ?? part;
|
|
||||||
chatMessages.push({
|
|
||||||
role: 'tool',
|
|
||||||
toolName: inv.toolName ?? 'unknown',
|
|
||||||
toolInput: inv.args ?? {},
|
|
||||||
toolUseId: inv.toolCallId ?? part.id ?? '',
|
|
||||||
output:
|
|
||||||
inv.result != null
|
|
||||||
? typeof inv.result === 'string'
|
|
||||||
? inv.result
|
|
||||||
: JSON.stringify(inv.result)
|
|
||||||
: undefined,
|
|
||||||
isError: !!part.isError,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ctx.json(chatMessages);
|
|
||||||
} catch {
|
|
||||||
return ctx.json([]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rename session — proxy to OpenCode API
|
|
||||||
opencodeSessionsRouter.put('/sessions/:id', async (ctx) => {
|
|
||||||
const id = ctx.req.param('id');
|
|
||||||
const body = ctx.get('body') as { title?: string };
|
|
||||||
if (!body?.title || typeof body.title !== 'string') return ctx.json({ error: 'title required' }, 400);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${BASE}/session/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ title: body.title.slice(0, 200) }),
|
|
||||||
});
|
|
||||||
if (!res.ok) return ctx.json({ error: 'failed to rename' }, 500);
|
|
||||||
return ctx.json({ ok: true });
|
|
||||||
} catch {
|
|
||||||
return ctx.json({ error: 'failed to rename' }, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete session — proxy to OpenCode API
|
|
||||||
opencodeSessionsRouter.delete('/sessions/:id', async (ctx) => {
|
|
||||||
const id = ctx.req.param('id');
|
|
||||||
try {
|
|
||||||
await fetch(`${BASE}/session/${id}`, { method: 'DELETE' });
|
|
||||||
} catch {
|
|
||||||
// best-effort
|
|
||||||
}
|
|
||||||
return ctx.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ServerWebSocket } from 'bun';
|
|||||||
import { mkdir, rename } from 'node:fs/promises';
|
import { mkdir, rename } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { ClientMessage, ServerMessage, ImageData, TaskInfo } from '@@/api/chat-types';
|
import type { ClientMessage, ServerMessage, ImageData, TaskInfo } from '@@/api/chat-types';
|
||||||
import { getTmpAttachmentsDir, getAttachmentsDir, getNativeSkillsDir, getGlobalSkillsDir, getUserSkillsDir } from '@@/data-path';
|
import { getTmpAttachmentsDir, getAttachmentsDir, getOpencodeSessionDir, getNativeSkillsDir, getGlobalSkillsDir, getUserSkillsDir } from '@@/data-path';
|
||||||
import { readSkillDirs, parseFrontmatter } from '@@/api/skills/skills';
|
import { readSkillDirs, parseFrontmatter } from '@@/api/skills/skills';
|
||||||
import { createTaskLog, appendToLog, finalizeLog } from '@@/api/task-logger';
|
import { createTaskLog, appendToLog, finalizeLog } from '@@/api/task-logger';
|
||||||
|
|
||||||
@@ -377,6 +377,14 @@ async function handleChat({ ws, prompt, sessionId, model, attachmentIds, images,
|
|||||||
state.sessionId = sid;
|
state.sessionId = sid;
|
||||||
send(ws, { type: 'session:init', sessionId: sid, model: session.model ?? 'opencode' });
|
send(ws, { type: 'session:init', sessionId: sid, model: session.model ?? 'opencode' });
|
||||||
|
|
||||||
|
// Write meta.json for filesystem-backed session listing
|
||||||
|
const metaDir = getOpencodeSessionDir(ws.data.email, sid);
|
||||||
|
const title = prompt.slice(0, 80) || 'New chat';
|
||||||
|
const meta = { id: sid, title, createdAt: Date.now(), model: session.model ?? 'opencode' };
|
||||||
|
mkdir(metaDir, { recursive: true })
|
||||||
|
.then(() => Bun.write(join(metaDir, 'meta.json'), JSON.stringify(meta)))
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
// Move tmp attachments to session dir
|
// Move tmp attachments to session dir
|
||||||
if (state.pendingAttachmentIds.length > 0) {
|
if (state.pendingAttachmentIds.length > 0) {
|
||||||
const tmpDir = getTmpAttachmentsDir(ws.data.email);
|
const tmpDir = getTmpAttachmentsDir(ws.data.email);
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { getClaudeDir, getSessionDir, getArchivedSessionDir, getOpencodeDir, getOpencodeSessionDir } from '@@/data-path';
|
||||||
|
import type { HonoVariables } from '@@/create-router';
|
||||||
|
|
||||||
|
const OPENCODE_PORT = process.env.OPENCODE_PORT ?? '10006';
|
||||||
|
const OPENCODE_BASE = `http://localhost:${OPENCODE_PORT}`;
|
||||||
|
|
||||||
|
export const sessionsRouter = new Hono<{ Variables: HonoVariables }>();
|
||||||
|
|
||||||
|
// --- List all sessions (merged from both providers) ---
|
||||||
|
|
||||||
|
sessionsRouter.get('/sessions', async (ctx) => {
|
||||||
|
const { email } = ctx.get('user');
|
||||||
|
|
||||||
|
const [claudeSessions, opencodeSessions] = await Promise.all([fetchClaudeSessions(email), fetchOpencodeSessions(email)]);
|
||||||
|
|
||||||
|
const merged = [...claudeSessions, ...opencodeSessions].sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
return ctx.json(merged);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Messages ---
|
||||||
|
|
||||||
|
sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => {
|
||||||
|
const { email } = ctx.get('user');
|
||||||
|
const provider = ctx.req.param('provider');
|
||||||
|
const id = ctx.req.param('id');
|
||||||
|
|
||||||
|
if (provider === 'claude') {
|
||||||
|
const file = Bun.file(join(getSessionDir(email, id), 'messages.json'));
|
||||||
|
if (!(await file.exists())) return ctx.json([]);
|
||||||
|
return ctx.json(await file.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'opencode') {
|
||||||
|
return ctx.json(await fetchOpencodeMessages(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.json({ error: 'invalid provider' }, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionsRouter.put('/sessions/:provider/:id/messages', async (ctx) => {
|
||||||
|
const { email } = ctx.get('user');
|
||||||
|
const provider = ctx.req.param('provider');
|
||||||
|
const id = ctx.req.param('id');
|
||||||
|
|
||||||
|
if (provider === 'opencode') return ctx.json({ error: 'opencode sessions are read-only' }, 400);
|
||||||
|
if (provider !== 'claude') return ctx.json({ error: 'invalid provider' }, 400);
|
||||||
|
|
||||||
|
const messages = ctx.get('body');
|
||||||
|
const dir = getSessionDir(email, id);
|
||||||
|
await Bun.write(join(dir, 'messages.json'), JSON.stringify(messages));
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Rename ---
|
||||||
|
|
||||||
|
sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
|
||||||
|
const { email } = ctx.get('user');
|
||||||
|
const provider = ctx.req.param('provider');
|
||||||
|
const id = ctx.req.param('id');
|
||||||
|
const body = ctx.get('body') as { title?: string };
|
||||||
|
|
||||||
|
if (!body?.title || typeof body.title !== 'string') return ctx.json({ error: 'title required' }, 400);
|
||||||
|
|
||||||
|
if (provider === 'claude') {
|
||||||
|
const dir = getSessionDir(email, id);
|
||||||
|
const metaFile = Bun.file(join(dir, 'meta.json'));
|
||||||
|
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
|
||||||
|
const meta = await metaFile.json();
|
||||||
|
meta.title = body.title.slice(0, 200);
|
||||||
|
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'opencode') {
|
||||||
|
const dir = getOpencodeSessionDir(email, id);
|
||||||
|
const metaFile = Bun.file(join(dir, 'meta.json'));
|
||||||
|
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
|
||||||
|
const meta = await metaFile.json();
|
||||||
|
meta.title = body.title.slice(0, 200);
|
||||||
|
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
|
||||||
|
|
||||||
|
// Best-effort sync to OpenCode API
|
||||||
|
fetch(`${OPENCODE_BASE}/session/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title: meta.title }),
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.json({ error: 'invalid provider' }, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Delete ---
|
||||||
|
|
||||||
|
sessionsRouter.delete('/sessions/:provider/:id', async (ctx) => {
|
||||||
|
const { email } = ctx.get('user');
|
||||||
|
const provider = ctx.req.param('provider');
|
||||||
|
const id = ctx.req.param('id');
|
||||||
|
|
||||||
|
if (provider === 'claude') {
|
||||||
|
const dir = getSessionDir(email, id);
|
||||||
|
try {
|
||||||
|
await rm(dir, { recursive: true });
|
||||||
|
} catch {
|
||||||
|
// dir may not exist
|
||||||
|
}
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'opencode') {
|
||||||
|
const dir = getOpencodeSessionDir(email, id);
|
||||||
|
try {
|
||||||
|
await rm(dir, { recursive: true });
|
||||||
|
} catch {
|
||||||
|
// dir may not exist
|
||||||
|
}
|
||||||
|
// Best-effort sync to OpenCode API
|
||||||
|
fetch(`${OPENCODE_BASE}/session/${id}`, { method: 'DELETE' }).catch(() => {});
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.json({ error: 'invalid provider' }, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Archive (Claude only) ---
|
||||||
|
|
||||||
|
sessionsRouter.post('/sessions/:provider/:id/archive', async (ctx) => {
|
||||||
|
const { email } = ctx.get('user');
|
||||||
|
const provider = ctx.req.param('provider');
|
||||||
|
const id = ctx.req.param('id');
|
||||||
|
|
||||||
|
if (provider === 'opencode') return ctx.json({ error: 'opencode sessions cannot be archived' }, 400);
|
||||||
|
if (provider !== 'claude') return ctx.json({ error: 'invalid provider' }, 400);
|
||||||
|
|
||||||
|
const src = getSessionDir(email, id);
|
||||||
|
const dest = getArchivedSessionDir(email, id);
|
||||||
|
await mkdir(join(getClaudeDir(email), 'archived'), { recursive: true });
|
||||||
|
await rename(src, dest);
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
type SessionMeta = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: number;
|
||||||
|
provider: 'claude' | 'opencode';
|
||||||
|
model?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchClaudeSessions(email: string): Promise<SessionMeta[]> {
|
||||||
|
const dir = getClaudeDir(email);
|
||||||
|
try {
|
||||||
|
const entries = await readdir(dir);
|
||||||
|
const sessions = await Promise.all(
|
||||||
|
entries
|
||||||
|
.filter((name) => name !== 'archived')
|
||||||
|
.map(async (id) => {
|
||||||
|
try {
|
||||||
|
const metaFile = Bun.file(join(dir, id, 'meta.json'));
|
||||||
|
if (!(await metaFile.exists())) return null;
|
||||||
|
const meta = await metaFile.json();
|
||||||
|
return { ...meta, provider: 'claude' as const };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return sessions.filter((s): s is SessionMeta => s !== null);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOpencodeSessions(email: string): Promise<SessionMeta[]> {
|
||||||
|
const dir = getOpencodeDir(email);
|
||||||
|
try {
|
||||||
|
const entries = await readdir(dir);
|
||||||
|
const sessions = await Promise.all(
|
||||||
|
entries.map(async (id) => {
|
||||||
|
try {
|
||||||
|
const metaFile = Bun.file(join(dir, id, 'meta.json'));
|
||||||
|
if (!(await metaFile.exists())) return null;
|
||||||
|
const meta = await metaFile.json();
|
||||||
|
return { ...meta, provider: 'opencode' as const };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return sessions.filter((s): s is SessionMeta => s !== null);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOpencodeMessages(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${OPENCODE_BASE}/session/${id}/message`);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
|
||||||
|
const data = (await res.json()) as any[];
|
||||||
|
const messages = Array.isArray(data) ? data : Object.values(data);
|
||||||
|
|
||||||
|
const chatMessages: any[] = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const role = msg.info?.role ?? msg.role;
|
||||||
|
if (role === 'user') {
|
||||||
|
const text = Array.isArray(msg.parts)
|
||||||
|
? msg.parts
|
||||||
|
.filter((p: any) => p.type === 'text')
|
||||||
|
.map((p: any) => p.text ?? p.content ?? '')
|
||||||
|
.join('')
|
||||||
|
: typeof msg.content === 'string'
|
||||||
|
? msg.content
|
||||||
|
: '';
|
||||||
|
if (text) chatMessages.push({ role: 'user', text });
|
||||||
|
} else if (role === 'assistant') {
|
||||||
|
if (Array.isArray(msg.parts)) {
|
||||||
|
for (const part of msg.parts) {
|
||||||
|
if (part.type === 'text' && (part.text || part.content)) {
|
||||||
|
chatMessages.push({ role: 'assistant', text: part.text ?? part.content ?? '' });
|
||||||
|
} else if (part.type === 'tool') {
|
||||||
|
chatMessages.push({
|
||||||
|
role: 'tool',
|
||||||
|
toolName: part.tool ?? 'unknown',
|
||||||
|
toolInput: part.state?.input ?? {},
|
||||||
|
toolUseId: part.callID ?? part.id ?? '',
|
||||||
|
output:
|
||||||
|
part.state?.output != null
|
||||||
|
? typeof part.state.output === 'string'
|
||||||
|
? part.state.output
|
||||||
|
: JSON.stringify(part.state.output)
|
||||||
|
: undefined,
|
||||||
|
isError: part.state?.status === 'error',
|
||||||
|
});
|
||||||
|
} else if (part.type === 'tool-invocation') {
|
||||||
|
const inv = part.toolInvocation ?? part;
|
||||||
|
chatMessages.push({
|
||||||
|
role: 'tool',
|
||||||
|
toolName: inv.toolName ?? 'unknown',
|
||||||
|
toolInput: inv.args ?? {},
|
||||||
|
toolUseId: inv.toolCallId ?? part.id ?? '',
|
||||||
|
output:
|
||||||
|
inv.result != null
|
||||||
|
? typeof inv.result === 'string'
|
||||||
|
? inv.result
|
||||||
|
: JSON.stringify(inv.result)
|
||||||
|
: undefined,
|
||||||
|
isError: !!part.isError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chatMessages;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,11 @@ export const getClaudeDir = (email: string) => join(DATA_PATH, email, 'chat_sess
|
|||||||
export const getSessionDir = (email: string, sessionId: string) =>
|
export const getSessionDir = (email: string, sessionId: string) =>
|
||||||
join(DATA_PATH, email, 'chat_sessions', 'claude', sessionId);
|
join(DATA_PATH, email, 'chat_sessions', 'claude', sessionId);
|
||||||
|
|
||||||
|
export const getOpencodeDir = (email: string) => join(DATA_PATH, email, 'chat_sessions', 'opencode');
|
||||||
|
|
||||||
|
export const getOpencodeSessionDir = (email: string, sessionId: string) =>
|
||||||
|
join(DATA_PATH, email, 'chat_sessions', 'opencode', sessionId);
|
||||||
|
|
||||||
export const getArchivedSessionDir = (email: string, sessionId: string) =>
|
export const getArchivedSessionDir = (email: string, sessionId: string) =>
|
||||||
join(DATA_PATH, email, 'chat_sessions', 'claude', 'archived', sessionId);
|
join(DATA_PATH, email, 'chat_sessions', 'claude', 'archived', sessionId);
|
||||||
|
|
||||||
|
|||||||
+7
-29
@@ -3,22 +3,21 @@ import { cors } from 'hono/cors';
|
|||||||
import { createRouter } from './create-router';
|
import { createRouter } from './create-router';
|
||||||
import type { HonoVariables } from './create-router';
|
import type { HonoVariables } from './create-router';
|
||||||
import { authRouter } from './api/auth';
|
import { authRouter } from './api/auth';
|
||||||
import { serverSettingsRouter, settingsPath } from './api/server-settings/server-settings';
|
import { serverSettingsRouter } from './api/server-settings/server-settings';
|
||||||
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
|
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
|
||||||
import { updateUserHandler } from './api/users/update-user';
|
import { updateUserHandler } from './api/users/update-user';
|
||||||
import { plansRouter } from './api/plans/plans';
|
import { plansRouter } from './api/plans/plans';
|
||||||
import { skillsRouter } from './api/skills/skills';
|
import { skillsRouter } from './api/skills/skills';
|
||||||
import { tasksRouter } from './api/tasks/tasks';
|
import { tasksRouter } from './api/tasks/tasks';
|
||||||
import { processesRouter } from './api/processes/processes';
|
import { processesRouter } from './api/processes/processes';
|
||||||
import { sessionsRouter } from './api/claude/sessions';
|
import { claudeModelsRouter } from './api/claude/sessions';
|
||||||
import { opencodeSessionsRouter } from './api/opencode/sessions';
|
import { opencodeModelsRouter } from './api/opencode/sessions';
|
||||||
|
import { sessionsRouter } from './api/sessions/sessions';
|
||||||
import { scrapeRouter } from './api/scrape/scrape';
|
import { scrapeRouter } from './api/scrape/scrape';
|
||||||
import { uploadRouter } from './api/upload/upload';
|
import { uploadRouter } from './api/upload/upload';
|
||||||
import { settingsRouter } from './api/settings/settings';
|
import { settingsRouter } from './api/settings/settings';
|
||||||
import { taskLogsRouter } from './api/task-logs/task-logs';
|
import { taskLogsRouter } from './api/task-logs/task-logs';
|
||||||
import { router as fileBrowserRouter } from './api/file-browser/router';
|
import { router as fileBrowserRouter } from './api/file-browser/router';
|
||||||
import { readdirSync, existsSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { CustomError } from './custom-errors';
|
import { CustomError } from './custom-errors';
|
||||||
import { userMiddleware, bodyParser } from './_middlewares';
|
import { userMiddleware, bodyParser } from './_middlewares';
|
||||||
|
|
||||||
@@ -51,36 +50,15 @@ protectedRouter.route('/skills', skillsRouter);
|
|||||||
protectedRouter.route('/tasks', tasksRouter);
|
protectedRouter.route('/tasks', tasksRouter);
|
||||||
protectedRouter.route('/processes', processesRouter);
|
protectedRouter.route('/processes', processesRouter);
|
||||||
protectedRouter.route('/', sessionsRouter);
|
protectedRouter.route('/', sessionsRouter);
|
||||||
protectedRouter.route('/opencode', opencodeSessionsRouter);
|
protectedRouter.route('/', claudeModelsRouter);
|
||||||
|
protectedRouter.route('/', opencodeModelsRouter);
|
||||||
protectedRouter.route('/scrape', scrapeRouter);
|
protectedRouter.route('/scrape', scrapeRouter);
|
||||||
protectedRouter.route('/upload', uploadRouter);
|
protectedRouter.route('/upload', uploadRouter);
|
||||||
protectedRouter.route('/user', settingsRouter);
|
protectedRouter.route('/user', settingsRouter);
|
||||||
protectedRouter.route('/task-logs', taskLogsRouter);
|
protectedRouter.route('/task-logs', taskLogsRouter);
|
||||||
protectedRouter.route('/file-browser', fileBrowserRouter);
|
protectedRouter.route('/file-browser', fileBrowserRouter);
|
||||||
|
|
||||||
// Auto-discover and mount plugin routers, then mount the protected router.
|
honoServer.route('/api', protectedRouter);
|
||||||
// Hono's .route() copies routes at call time, so plugins must be loaded first.
|
|
||||||
export async function loadPlugins() {
|
|
||||||
const pluginsDir = join(import.meta.dir, '../workspaces/plugins');
|
|
||||||
const pluginDirs = readdirSync(pluginsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
||||||
const settings = await Bun.file(settingsPath)
|
|
||||||
.json()
|
|
||||||
.catch(() => ({}));
|
|
||||||
const pluginSettings: Record<string, boolean> = settings.plugins ?? {};
|
|
||||||
|
|
||||||
for (const dir of pluginDirs) {
|
|
||||||
if (pluginSettings[dir.name] === false) continue;
|
|
||||||
|
|
||||||
const serverIndex = join(pluginsDir, dir.name, 'server', 'index.ts');
|
|
||||||
if (!existsSync(serverIndex)) continue;
|
|
||||||
const mod = await import(serverIndex);
|
|
||||||
if (mod.router && mod.apiPath) {
|
|
||||||
protectedRouter.route(mod.apiPath, mod.router);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
honoServer.route('/api', protectedRouter);
|
|
||||||
}
|
|
||||||
|
|
||||||
honoServer.onError((error, ctx) => {
|
honoServer.onError((error, ctx) => {
|
||||||
if (error instanceof CustomError) {
|
if (error instanceof CustomError) {
|
||||||
|
|||||||
@@ -1,4 +1 @@
|
|||||||
export { SessionBar } from './SessionBar';
|
export { SessionBar } from './SessionBar';
|
||||||
export { useSessions } from './useSessions';
|
|
||||||
export { useOpenCodeSessions } from './useOpenCodeSessions';
|
|
||||||
export { useSlashCommands, type SlashCommandResult } from './useSlashCommands';
|
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import type { SessionEntry, ChatMessage } from 'widgets/Chat';
|
|
||||||
import { useAuth } from 'hooks/useAuth';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
export const useOpenCodeSessions = () => {
|
|
||||||
type RawSessionEntry = Omit<SessionEntry, 'provider'>;
|
|
||||||
const client = useClient();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { isAuthenticated } = useAuth();
|
|
||||||
|
|
||||||
const { data: sessions = [] } = useQuery<RawSessionEntry[], Error, SessionEntry[]>({
|
|
||||||
queryKey: ['OC_SESSIONS'],
|
|
||||||
enabled: isAuthenticated,
|
|
||||||
queryFn: () => client.get<RawSessionEntry[]>('/opencode/sessions'),
|
|
||||||
select: (data) => data.map((s) => ({ ...s, provider: 'opencode' as const })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const getMessages = (sessionId: string) => client.get<ChatMessage[]>(`/opencode/sessions/${sessionId}/messages`);
|
|
||||||
|
|
||||||
const renameSession = async (sessionId: string | null, title: string) => {
|
|
||||||
if (!title) return;
|
|
||||||
if (!sessionId) return;
|
|
||||||
|
|
||||||
await client.put(`/opencode/sessions/${sessionId}`, { title: title.slice(0, 200) });
|
|
||||||
queryClient.setQueryData<SessionEntry[]>(
|
|
||||||
['OC_SESSIONS'],
|
|
||||||
(prev) => prev?.map((s) => (s.id === sessionId ? { ...s, title } : s)) ?? [],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteSession = async (sessionId: string) => {
|
|
||||||
await client.delete(`/opencode/sessions/${sessionId}`);
|
|
||||||
queryClient.setQueryData<SessionEntry[]>(['OC_SESSIONS'], (prev) => prev?.filter((s) => s.id !== sessionId) ?? []);
|
|
||||||
};
|
|
||||||
|
|
||||||
return { sessions, getMessages, renameSession, deleteSession };
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user