mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-09 14:08:19 +00:00
234 lines
7.0 KiB
TypeScript
234 lines
7.0 KiB
TypeScript
import fsp from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
|
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
|
import type {
|
|
FetchHistoryOptions,
|
|
FetchHistoryResult,
|
|
LLMProvider,
|
|
NormalizedMessage,
|
|
} from '@/shared/types.js';
|
|
import { AppError } from '@/shared/utils.js';
|
|
|
|
type ArchivedSessionListItem = {
|
|
sessionId: string;
|
|
provider: LLMProvider;
|
|
projectId: string | null;
|
|
projectPath: string | null;
|
|
projectDisplayName: string;
|
|
sessionTitle: string;
|
|
createdAt: string | null;
|
|
updatedAt: string | null;
|
|
lastActivity: string | null;
|
|
isProjectArchived: boolean;
|
|
};
|
|
|
|
/**
|
|
* Removes one file if it exists.
|
|
*/
|
|
async function removeFileIfExists(filePath: string): Promise<boolean> {
|
|
try {
|
|
await fsp.unlink(filePath);
|
|
return true;
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code === 'ENOENT') {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Archive rows need a stable project label even when the owning project is not
|
|
* part of the active sidebar payload. This lightweight resolver keeps the
|
|
* archive API self-contained while still matching the project's stored display
|
|
* name when one exists.
|
|
*/
|
|
function resolveProjectDisplayName(
|
|
projectPath: string | null,
|
|
customProjectName: string | null | undefined,
|
|
): string {
|
|
const trimmedCustomName = typeof customProjectName === 'string' ? customProjectName.trim() : '';
|
|
if (trimmedCustomName.length > 0) {
|
|
return trimmedCustomName;
|
|
}
|
|
|
|
if (!projectPath) {
|
|
return 'Unknown Project';
|
|
}
|
|
|
|
return path.basename(projectPath) || projectPath;
|
|
}
|
|
|
|
/**
|
|
* Application service for provider-backed session message operations.
|
|
*
|
|
* Callers pass a provider id and this service resolves the concrete provider
|
|
* class, keeping normalization/history call sites decoupled from implementation
|
|
* file layout.
|
|
*/
|
|
export const sessionsService = {
|
|
/**
|
|
* Lists provider ids that can load session history and normalize live messages.
|
|
*/
|
|
listProviderIds(): LLMProvider[] {
|
|
return providerRegistry.listProviders().map((provider) => provider.id);
|
|
},
|
|
|
|
/**
|
|
* Normalizes one provider-native event into frontend session message events.
|
|
*/
|
|
normalizeMessage(
|
|
providerName: string,
|
|
raw: unknown,
|
|
sessionId: string | null,
|
|
): NormalizedMessage[] {
|
|
return providerRegistry.resolveProvider(providerName).sessions.normalizeMessage(raw, sessionId);
|
|
},
|
|
|
|
/**
|
|
* Fetches persisted history by session id.
|
|
*
|
|
* Provider and provider-specific lookup hints are resolved from the indexed
|
|
* session metadata in the database.
|
|
*/
|
|
fetchHistory(
|
|
sessionId: string,
|
|
options: Pick<FetchHistoryOptions, 'limit' | 'offset'> = {},
|
|
): Promise<FetchHistoryResult> {
|
|
const session = sessionsDb.getSessionById(sessionId);
|
|
if (!session) {
|
|
throw new AppError(`Session "${sessionId}" was not found.`, {
|
|
code: 'SESSION_NOT_FOUND',
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
const provider = session.provider as LLMProvider;
|
|
return providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
|
|
limit: options.limit ?? null,
|
|
offset: options.offset ?? 0,
|
|
projectPath: session.project_path ?? '',
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Returns archived sessions with enough project metadata for the sidebar to
|
|
* group, filter, open, and restore them without a per-row follow-up query.
|
|
*/
|
|
listArchivedSessions(): ArchivedSessionListItem[] {
|
|
const archivedSessions = sessionsDb.getArchivedSessions();
|
|
const projectCache = new Map<string, ReturnType<typeof projectsDb.getProjectPath>>();
|
|
|
|
return archivedSessions.map((session) => {
|
|
const projectPath = session.project_path?.trim() ? session.project_path : null;
|
|
let project = null;
|
|
|
|
if (projectPath) {
|
|
if (!projectCache.has(projectPath)) {
|
|
projectCache.set(projectPath, projectsDb.getProjectPath(projectPath));
|
|
}
|
|
project = projectCache.get(projectPath) ?? null;
|
|
}
|
|
|
|
return {
|
|
sessionId: session.session_id,
|
|
provider: session.provider as LLMProvider,
|
|
projectId: project?.project_id ?? null,
|
|
projectPath,
|
|
projectDisplayName: resolveProjectDisplayName(projectPath, project?.custom_project_name),
|
|
sessionTitle: session.custom_name?.trim() || session.session_id,
|
|
createdAt: session.created_at ?? null,
|
|
updatedAt: session.updated_at ?? null,
|
|
lastActivity: session.updated_at ?? session.created_at ?? null,
|
|
isProjectArchived: Boolean(project?.isArchived),
|
|
};
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Archives or permanently deletes one persisted session row by id.
|
|
*
|
|
* Soft-delete mirrors the project behavior by toggling `isArchived` so the
|
|
* row disappears from active lists but remains restorable. Force-delete
|
|
* optionally removes the transcript file before deleting the database row.
|
|
*/
|
|
async deleteOrArchiveSessionById(
|
|
sessionId: string,
|
|
options: {
|
|
force?: boolean;
|
|
deletedFromDisk?: boolean;
|
|
} = {},
|
|
): Promise<{ sessionId: string; action: 'archived' | 'deleted'; deletedFromDisk: boolean }> {
|
|
const session = sessionsDb.getSessionById(sessionId);
|
|
if (!session) {
|
|
throw new AppError(`Session "${sessionId}" was not found.`, {
|
|
code: 'SESSION_NOT_FOUND',
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
if (!options.force) {
|
|
sessionsDb.updateSessionIsArchived(sessionId, true);
|
|
return {
|
|
sessionId,
|
|
action: 'archived',
|
|
deletedFromDisk: false,
|
|
};
|
|
}
|
|
|
|
let removedFromDisk = false;
|
|
if (options.deletedFromDisk && session.jsonl_path) {
|
|
removedFromDisk = await removeFileIfExists(session.jsonl_path);
|
|
}
|
|
|
|
const deleted = sessionsDb.deleteSessionById(sessionId);
|
|
if (!deleted) {
|
|
throw new AppError(`Session "${sessionId}" was not found.`, {
|
|
code: 'SESSION_NOT_FOUND',
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
return {
|
|
sessionId,
|
|
action: 'deleted',
|
|
deletedFromDisk: removedFromDisk,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Restores one archived session back into the active sidebar lists.
|
|
*/
|
|
restoreSessionById(sessionId: string): { sessionId: string; isArchived: false } {
|
|
const session = sessionsDb.getSessionById(sessionId);
|
|
if (!session) {
|
|
throw new AppError(`Session "${sessionId}" was not found.`, {
|
|
code: 'SESSION_NOT_FOUND',
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
sessionsDb.updateSessionIsArchived(sessionId, false);
|
|
return { sessionId, isArchived: false };
|
|
},
|
|
|
|
/**
|
|
* Renames one session by id without requiring the caller to pass provider.
|
|
*/
|
|
renameSessionById(sessionId: string, summary: string): { sessionId: string; summary: string } {
|
|
const session = sessionsDb.getSessionById(sessionId);
|
|
if (!session) {
|
|
throw new AppError(`Session "${sessionId}" was not found.`, {
|
|
code: 'SESSION_NOT_FOUND',
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
sessionsDb.updateSessionCustomName(sessionId, summary);
|
|
return { sessionId, summary };
|
|
},
|
|
};
|