mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-28 23:15:33 +08:00
refactor: move rename and delete sessions to modules
This commit is contained in:
@@ -306,7 +306,7 @@ app.delete('/api/projects/:projectId/sessions/:sessionId', authenticateToken, as
|
|||||||
const { projectId, sessionId } = req.params;
|
const { projectId, sessionId } = req.params;
|
||||||
console.log(`[API] Deleting session: ${sessionId} from project: ${projectId}`);
|
console.log(`[API] Deleting session: ${sessionId} from project: ${projectId}`);
|
||||||
await deleteSessionById(projectId, sessionId);
|
await deleteSessionById(projectId, sessionId);
|
||||||
sessionsDb.deleteName(sessionId, 'claude');
|
sessionsDb.deleteSessionById(sessionId);
|
||||||
console.log(`[API] Session ${sessionId} deleted successfully`);
|
console.log(`[API] Session ${sessionId} deleted successfully`);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -323,17 +323,14 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
|
|||||||
if (!safeSessionId || safeSessionId !== String(sessionId)) {
|
if (!safeSessionId || safeSessionId !== String(sessionId)) {
|
||||||
return res.status(400).json({ error: 'Invalid sessionId' });
|
return res.status(400).json({ error: 'Invalid sessionId' });
|
||||||
}
|
}
|
||||||
const { summary, provider } = req.body;
|
const { summary } = req.body;
|
||||||
if (!summary || typeof summary !== 'string' || summary.trim() === '') {
|
if (!summary || typeof summary !== 'string' || summary.trim() === '') {
|
||||||
return res.status(400).json({ error: 'Summary is required' });
|
return res.status(400).json({ error: 'Summary is required' });
|
||||||
}
|
}
|
||||||
if (summary.trim().length > 500) {
|
if (summary.trim().length > 500) {
|
||||||
return res.status(400).json({ error: 'Summary must not exceed 500 characters' });
|
return res.status(400).json({ error: 'Summary must not exceed 500 characters' });
|
||||||
}
|
}
|
||||||
if (!provider || !VALID_PROVIDERS.includes(provider)) {
|
sessionsDb.updateSessionCustomName(safeSessionId, summary.trim());
|
||||||
return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` });
|
|
||||||
}
|
|
||||||
sessionsDb.setName(safeSessionId, provider, summary.trim());
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
|
console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ export { notificationPreferencesDb } from '@/modules/database/repositories/notif
|
|||||||
export { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
export { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
||||||
export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js';
|
export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js';
|
||||||
export { scanStateDb } from '@/modules/database/repositories/scan-state.db.js';
|
export { scanStateDb } from '@/modules/database/repositories/scan-state.db.js';
|
||||||
export { sessionsDb, applyCustomSessionNames } from '@/modules/database/repositories/sessions.db.js';
|
export { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
|
||||||
export { userDb } from '@/modules/database/repositories/users.js';
|
export { userDb } from '@/modules/database/repositories/users.js';
|
||||||
export { vapidKeysDb } from '@/modules/database/repositories/vapid-keys.js';
|
export { vapidKeysDb } from '@/modules/database/repositories/vapid-keys.js';
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ import path from 'node:path';
|
|||||||
import { getConnection } from '@/modules/database/connection.js';
|
import { getConnection } from '@/modules/database/connection.js';
|
||||||
import { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
import { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
||||||
|
|
||||||
type SessionNameLookupRow = {
|
|
||||||
session_id: string;
|
|
||||||
custom_name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SessionRow = {
|
type SessionRow = {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -23,11 +18,6 @@ type SessionMetadataLookupRow = Pick<
|
|||||||
'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'created_at' | 'updated_at'
|
'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'created_at' | 'updated_at'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type LegacySessionSummary = {
|
|
||||||
id: string;
|
|
||||||
summary?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeTimestamp(value?: string): string | null {
|
function normalizeTimestamp(value?: string): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
||||||
@@ -179,111 +169,8 @@ export const sessionsDb = {
|
|||||||
return row?.custom_name ?? null;
|
return row?.custom_name ?? null;
|
||||||
},
|
},
|
||||||
|
|
||||||
getSessionNames(sessionIds: string[], provider: string): Map<string, string> {
|
deleteSessionById(sessionId: string): boolean {
|
||||||
if (sessionIds.length === 0) return new Map();
|
|
||||||
|
|
||||||
const db = getConnection();
|
const db = getConnection();
|
||||||
const placeholders = sessionIds.map(() => '?').join(',');
|
return db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId).changes > 0;
|
||||||
const rows = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT session_id, custom_name
|
|
||||||
FROM sessions
|
|
||||||
WHERE session_id IN (${placeholders})
|
|
||||||
AND provider = ?
|
|
||||||
AND custom_name IS NOT NULL`
|
|
||||||
)
|
|
||||||
.all(...sessionIds, provider) as SessionNameLookupRow[];
|
|
||||||
|
|
||||||
return new Map(rows.map((row) => [row.session_id, row.custom_name]));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy-compatibility method kept for parity with `server/database/db.js`.
|
|
||||||
*
|
|
||||||
* Renaming a session is a metadata-only change — it's not actual activity,
|
|
||||||
* so existing rows intentionally keep their `updated_at` untouched. This
|
|
||||||
* prevents the sidebar's "last activity" timestamp from jumping around when
|
|
||||||
* a user simply edits a session's label.
|
|
||||||
*
|
|
||||||
* When the row doesn't exist yet we still have to seed `created_at`/
|
|
||||||
* `updated_at`; we write ISO-8601 UTC (with the `Z` suffix) rather than
|
|
||||||
* rely on SQLite's `CURRENT_TIMESTAMP`, which stores a naive
|
|
||||||
* `"YYYY-MM-DD HH:MM:SS"` value that JavaScript's `new Date(...)` parses as
|
|
||||||
* local time and displays with the wrong offset.
|
|
||||||
*
|
|
||||||
* TODO: Remove after all legacy imports are migrated to the new repository API.
|
|
||||||
*/
|
|
||||||
setName(sessionId: string, provider: string, customName: string): void {
|
|
||||||
const db = getConnection();
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO sessions (session_id, provider, custom_name, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(session_id, provider) DO UPDATE SET
|
|
||||||
custom_name = excluded.custom_name`
|
|
||||||
).run(sessionId, provider, customName, nowIso, nowIso);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy-compatibility method kept for parity with `server/database/db.js`.
|
|
||||||
* TODO: Remove after all legacy imports are migrated to the new repository API.
|
|
||||||
*/
|
|
||||||
getName(sessionId: string, provider: string): string | null {
|
|
||||||
return sessionsDb.getSessionName(sessionId, provider);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy-compatibility method kept for parity with `server/database/db.js`.
|
|
||||||
* TODO: Remove after all legacy imports are migrated to the new repository API.
|
|
||||||
*/
|
|
||||||
getNames(sessionIds: string[], provider: string): Map<string, string> {
|
|
||||||
return sessionsDb.getSessionNames(sessionIds, provider);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy-compatibility method kept for parity with `server/database/db.js`.
|
|
||||||
* TODO: Remove after all legacy imports are migrated to the new repository API.
|
|
||||||
*/
|
|
||||||
deleteName(sessionId: string, provider: string): boolean {
|
|
||||||
const db = getConnection();
|
|
||||||
return (
|
|
||||||
db
|
|
||||||
.prepare(
|
|
||||||
`DELETE FROM sessions
|
|
||||||
WHERE session_id = ? AND provider = ?`
|
|
||||||
)
|
|
||||||
.run(sessionId, provider).changes > 0
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteSession(sessionId: string): void {
|
|
||||||
const db = getConnection();
|
|
||||||
db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy-compatibility helper kept for parity with `server/database/db.js`.
|
|
||||||
* TODO: Remove after all legacy imports are migrated to the new repository API.
|
|
||||||
*/
|
|
||||||
export function applyCustomSessionNames(
|
|
||||||
sessions: LegacySessionSummary[] | null | undefined,
|
|
||||||
provider: string
|
|
||||||
): void {
|
|
||||||
if (!sessions?.length) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sessionIds = sessions.map((session) => session.id);
|
|
||||||
const customNames = sessionsDb.getNames(sessionIds, provider);
|
|
||||||
|
|
||||||
for (const session of sessions) {
|
|
||||||
const customName = customNames.get(session.id);
|
|
||||||
if (customName) {
|
|
||||||
session.summary = customName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
console.warn(`[DB] Failed to apply custom session names for ${provider}:`, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import express, { type Request, type Response } from 'express';
|
|||||||
|
|
||||||
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
||||||
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
||||||
|
import { sessionsService } from '@/modules/providers/services/sessions.service.js';
|
||||||
import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||||
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
|
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
|
||||||
|
|
||||||
@@ -25,6 +26,20 @@ const readPathParam = (value: unknown, name: string): string => {
|
|||||||
const normalizeProviderParam = (value: unknown): string =>
|
const normalizeProviderParam = (value: unknown): string =>
|
||||||
readPathParam(value, 'provider').trim().toLowerCase();
|
readPathParam(value, 'provider').trim().toLowerCase();
|
||||||
|
|
||||||
|
const SESSION_ID_PATTERN = /^[a-zA-Z0-9._-]{1,120}$/;
|
||||||
|
|
||||||
|
const parseSessionId = (value: unknown): string => {
|
||||||
|
const sessionId = readPathParam(value, 'sessionId').trim();
|
||||||
|
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
||||||
|
throw new AppError('Invalid sessionId.', {
|
||||||
|
code: 'INVALID_SESSION_ID',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
};
|
||||||
|
|
||||||
const readOptionalQueryString = (value: unknown): string | undefined => {
|
const readOptionalQueryString = (value: unknown): string | undefined => {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -143,6 +158,33 @@ const parseProvider = (value: unknown): LLMProvider => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseSessionRenameSummary = (payload: unknown): string => {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
throw new AppError('Request body must be an object.', {
|
||||||
|
code: 'INVALID_REQUEST_BODY',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = payload as Record<string, unknown>;
|
||||||
|
const summary = typeof body.summary === 'string' ? body.summary.trim() : '';
|
||||||
|
if (!summary) {
|
||||||
|
throw new AppError('Summary is required.', {
|
||||||
|
code: 'INVALID_SESSION_SUMMARY',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.length > 500) {
|
||||||
|
throw new AppError('Summary must not exceed 500 characters.', {
|
||||||
|
code: 'INVALID_SESSION_SUMMARY',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
};
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:provider/auth/status',
|
'/:provider/auth/status',
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
@@ -214,4 +256,23 @@ router.post(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/sessions/:sessionId',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const sessionId = parseSessionId(req.params.sessionId);
|
||||||
|
const result = await sessionsService.deleteSessionById(sessionId);
|
||||||
|
res.json(createApiSuccessResponse(result));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/sessions/:sessionId',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const sessionId = parseSessionId(req.params.sessionId);
|
||||||
|
const summary = parseSessionRenameSummary(req.body);
|
||||||
|
const result = sessionsService.renameSessionById(sessionId, summary);
|
||||||
|
res.json(createApiSuccessResponse(result));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,129 +1,16 @@
|
|||||||
import path from 'node:path';
|
import { scanStateDb } from '@/modules/database/index.js';
|
||||||
import fsp, { readFile } from 'node:fs/promises';
|
|
||||||
|
|
||||||
import { scanStateDb, sessionsDb, projectsDb } from '@/modules/database/index.js';
|
|
||||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||||
import { sessionsService } from '@/modules/providers/services/sessions.service.js';
|
import type { LLMProvider } from '@/shared/types.js';
|
||||||
import type { LLMProvider, NormalizedMessage } from '@/shared/types.js';
|
|
||||||
import { AppError } from '@/shared/utils.js';
|
|
||||||
|
|
||||||
type SessionSynchronizeResult = {
|
type SessionSynchronizeResult = {
|
||||||
processedByProvider: Record<LLMProvider, number>;
|
processedByProvider: Record<LLMProvider, number>;
|
||||||
failures: string[];
|
failures: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type SessionHistoryPayload = {
|
|
||||||
sessionId: string;
|
|
||||||
provider: string;
|
|
||||||
projectPath: string | null;
|
|
||||||
filePath: string;
|
|
||||||
fileType: 'jsonl' | 'json';
|
|
||||||
entries: unknown[];
|
|
||||||
messages: NormalizedMessage[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const SESSION_ID_PATTERN = /^[a-zA-Z0-9._-]{1,120}$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restricts session ids before they are used in DB and filesystem operations.
|
|
||||||
*/
|
|
||||||
function sanitizeSessionId(sessionId: string): string {
|
|
||||||
const value = String(sessionId).trim();
|
|
||||||
if (!SESSION_ID_PATTERN.test(value)) {
|
|
||||||
throw new AppError('Invalid session id format.', {
|
|
||||||
code: 'INVALID_SESSION_ID',
|
|
||||||
statusCode: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses newline-delimited JSON and preserves malformed lines as raw entries.
|
|
||||||
*/
|
|
||||||
function parseJsonl(content: string): unknown[] {
|
|
||||||
const entries: unknown[] = [];
|
|
||||||
const lines = content.split(/\r?\n/);
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
entries.push(JSON.parse(trimmed));
|
|
||||||
} catch {
|
|
||||||
entries.push({ raw: trimmed, parseError: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses JSON and normalizes object payloads into a single-element array.
|
|
||||||
*/
|
|
||||||
function parseJson(content: string): unknown[] {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(content) as unknown;
|
|
||||||
return Array.isArray(parsed) ? parsed : [parsed];
|
|
||||||
} catch {
|
|
||||||
return [{ raw: content, parseError: true }];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orchestrates provider-specific session indexers and indexed-session lifecycle operations.
|
* Orchestrates provider-specific session indexers and indexed-session lifecycle operations.
|
||||||
*/
|
*/
|
||||||
export const sessionSynchronizerService = {
|
export const sessionSynchronizerService = {
|
||||||
/**
|
|
||||||
* Lists indexed sessions from DB, optionally scoped to one provider.
|
|
||||||
*/
|
|
||||||
listIndexedSessions(provider?: string) {
|
|
||||||
const allSessions = sessionsDb.getAllSessions();
|
|
||||||
if (!provider) {
|
|
||||||
return allSessions;
|
|
||||||
}
|
|
||||||
|
|
||||||
return allSessions.filter((session) => session.provider === provider);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads one indexed session row and enriches it with the associated project id.
|
|
||||||
*/
|
|
||||||
getIndexedSession(sessionId: string) {
|
|
||||||
const session = sessionsDb.getSessionById(sessionId);
|
|
||||||
if (!session) {
|
|
||||||
throw new AppError(`Session "${sessionId}" was not found.`, {
|
|
||||||
code: 'SESSION_NOT_FOUND',
|
|
||||||
statusCode: 404,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = session.project_path ? projectsDb.getProjectPath(session.project_path) : null;
|
|
||||||
return {
|
|
||||||
...session,
|
|
||||||
project_id: project?.project_id ?? null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs all provider synchronizers and updates scan_state.last_scanned_at.
|
* Runs all provider synchronizers and updates scan_state.last_scanned_at.
|
||||||
*/
|
*/
|
||||||
@@ -177,84 +64,4 @@ export const sessionSynchronizerService = {
|
|||||||
sessionId,
|
sessionId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates one indexed session custom name after validating existence.
|
|
||||||
*/
|
|
||||||
updateSessionCustomName(sessionId: string, sessionCustomName: string): void {
|
|
||||||
const sessionMetadata = sessionsDb.getSessionById(sessionId);
|
|
||||||
if (!sessionMetadata) {
|
|
||||||
throw new AppError('Session not found.', {
|
|
||||||
code: 'SESSION_NOT_FOUND',
|
|
||||||
statusCode: 404,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionsDb.updateSessionCustomName(sessionId, sessionCustomName);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a session artifact path from disk (if present) and deletes DB metadata.
|
|
||||||
*/
|
|
||||||
async deleteSessionArtifacts(rawSessionId: string): Promise<{
|
|
||||||
sessionId: string;
|
|
||||||
deletedFromDisk: boolean;
|
|
||||||
deletedFromDatabase: boolean;
|
|
||||||
}> {
|
|
||||||
const sessionId = sanitizeSessionId(rawSessionId);
|
|
||||||
const existingSession = sessionsDb.getSessionById(sessionId);
|
|
||||||
const sessionFilePath = existingSession?.jsonl_path ?? null;
|
|
||||||
const deletedFromDisk = sessionFilePath ? await removeFileIfExists(sessionFilePath) : false;
|
|
||||||
|
|
||||||
if (existingSession) {
|
|
||||||
sessionsDb.deleteSession(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
deletedFromDisk,
|
|
||||||
deletedFromDatabase: Boolean(existingSession),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads indexed session history directly from session json path and normalizes entries.
|
|
||||||
*/
|
|
||||||
async getSessionHistory(sessionId: string): Promise<SessionHistoryPayload> {
|
|
||||||
const session = sessionsDb.getSessionById(sessionId);
|
|
||||||
if (!session) {
|
|
||||||
throw new AppError(`Session "${sessionId}" was not found.`, {
|
|
||||||
code: 'SESSION_NOT_FOUND',
|
|
||||||
statusCode: 404,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session.jsonl_path) {
|
|
||||||
throw new AppError(`Session "${sessionId}" does not have a history file path.`, {
|
|
||||||
code: 'SESSION_HISTORY_NOT_AVAILABLE',
|
|
||||||
statusCode: 404,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = session.jsonl_path;
|
|
||||||
const fileContent = await readFile(filePath, 'utf8');
|
|
||||||
const extension = path.extname(filePath).toLowerCase();
|
|
||||||
const isGeminiJson = session.provider === 'gemini' || extension === '.json';
|
|
||||||
const entries = isGeminiJson ? parseJson(fileContent) : parseJsonl(fileContent);
|
|
||||||
|
|
||||||
const messages: NormalizedMessage[] = [];
|
|
||||||
for (const entry of entries) {
|
|
||||||
messages.push(...sessionsService.normalizeMessage(session.provider, entry, session.session_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: session.session_id,
|
|
||||||
provider: session.provider,
|
|
||||||
projectPath: session.project_path,
|
|
||||||
filePath,
|
|
||||||
fileType: isGeminiJson ? 'json' : 'jsonl',
|
|
||||||
entries,
|
|
||||||
messages,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
|
||||||
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||||
import type {
|
import type {
|
||||||
FetchHistoryOptions,
|
FetchHistoryOptions,
|
||||||
@@ -5,6 +8,24 @@ import type {
|
|||||||
LLMProvider,
|
LLMProvider,
|
||||||
NormalizedMessage,
|
NormalizedMessage,
|
||||||
} from '@/shared/types.js';
|
} from '@/shared/types.js';
|
||||||
|
import { AppError } from '@/shared/utils.js';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application service for provider-backed session message operations.
|
* Application service for provider-backed session message operations.
|
||||||
@@ -42,4 +63,35 @@ export const sessionsService = {
|
|||||||
): Promise<FetchHistoryResult> {
|
): Promise<FetchHistoryResult> {
|
||||||
return providerRegistry.resolveProvider(providerName).sessions.fetchHistory(sessionId, options);
|
return providerRegistry.resolveProvider(providerName).sessions.fetchHistory(sessionId, options);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes one persisted session row by id.
|
||||||
|
*/
|
||||||
|
deleteSessionById(sessionId: string): { sessionId: string } {
|
||||||
|
const deleted = sessionsDb.deleteSessionById(sessionId);
|
||||||
|
if (!deleted) {
|
||||||
|
throw new AppError(`Session "${sessionId}" was not found.`, {
|
||||||
|
code: 'SESSION_NOT_FOUND',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sessionId };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
|
||||||
import { deleteCodexSession } from '../projects.js';
|
import { deleteCodexSession } from '../projects.js';
|
||||||
import { sessionsDb } from '../modules/database/index.js';
|
import { sessionsDb } from '../modules/database/index.js';
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ router.delete('/sessions/:sessionId', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { sessionId } = req.params;
|
const { sessionId } = req.params;
|
||||||
await deleteCodexSession(sessionId);
|
await deleteCodexSession(sessionId);
|
||||||
sessionsDb.deleteName(sessionId, 'codex');
|
sessionsDb.deleteSessionById(sessionId);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error deleting Codex session ${req.params.sessionId}:`, error);
|
console.error(`Error deleting Codex session ${req.params.sessionId}:`, error);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
|
||||||
import sessionManager from '../sessionManager.js';
|
import sessionManager from '../sessionManager.js';
|
||||||
import { sessionsDb } from '../modules/database/index.js';
|
import { sessionsDb } from '../modules/database/index.js';
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ router.delete('/sessions/:sessionId', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await sessionManager.deleteSession(sessionId);
|
await sessionManager.deleteSession(sessionId);
|
||||||
sessionsDb.deleteName(sessionId, 'gemini');
|
sessionsDb.deleteSessionById(sessionId);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error);
|
console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import webPush from 'web-push';
|
import webPush from 'web-push';
|
||||||
|
|
||||||
import { notificationPreferencesDb, pushSubscriptionsDb, sessionsDb } from '../modules/database/index.js';
|
import { notificationPreferencesDb, pushSubscriptionsDb, sessionsDb } from '../modules/database/index.js';
|
||||||
|
|
||||||
const KIND_TO_PREF_KEY = {
|
const KIND_TO_PREF_KEY = {
|
||||||
@@ -107,7 +108,7 @@ function resolveSessionName(event) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeSessionName(sessionsDb.getName(event.sessionId, event.provider));
|
return normalizeSessionName(sessionsDb.getSessionName(event.sessionId, event.provider));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPushBody(event) {
|
function buildPushBody(event) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type React from 'react';
|
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
import { api } from '../../../utils/api';
|
import { api } from '../../../utils/api';
|
||||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||||
import type {
|
import type {
|
||||||
@@ -340,21 +340,6 @@ export function useSidebarController({
|
|||||||
};
|
};
|
||||||
}, [searchFilter, searchMode]);
|
}, [searchFilter, searchMode]);
|
||||||
|
|
||||||
const handleTouchClick = useCallback(
|
|
||||||
(callback: () => void) =>
|
|
||||||
(event: React.TouchEvent<HTMLElement>) => {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (target.closest('.overflow-y-auto') || target.closest('[data-scroll-container]')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
callback();
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// All sidebar state keys (expanded, starred, loading, etc.) use the DB
|
// All sidebar state keys (expanded, starred, loading, etc.) use the DB
|
||||||
// `projectId` as their identifier after the migration.
|
// `projectId` as their identifier after the migration.
|
||||||
const toggleProject = useCallback((projectId: string) => {
|
const toggleProject = useCallback((projectId: string) => {
|
||||||
@@ -522,8 +507,8 @@ export function useSidebarController({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const showDeleteSessionConfirmation = useCallback(
|
const showDeleteSessionConfirmation = useCallback(
|
||||||
// `projectId` (not the legacy folder-encoded name) is what the DELETE
|
// Kept with project/provider arguments for component wiring compatibility;
|
||||||
// /api/projects/:projectId/sessions/:sessionId endpoint expects.
|
// deletion now uses only `sessionId` via /api/providers/sessions/:sessionId.
|
||||||
(
|
(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -540,19 +525,11 @@ export function useSidebarController({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { projectId, sessionId, provider } = sessionDeleteConfirmation;
|
const { sessionId } = sessionDeleteConfirmation;
|
||||||
setSessionDeleteConfirmation(null);
|
setSessionDeleteConfirmation(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response;
|
const response = await api.deleteSession(sessionId);
|
||||||
if (provider === 'codex') {
|
|
||||||
response = await api.deleteCodexSession(sessionId);
|
|
||||||
} else if (provider === 'gemini') {
|
|
||||||
response = await api.deleteGeminiSession(sessionId);
|
|
||||||
} else {
|
|
||||||
// Claude sessions are owned by the DB project row; pass projectId.
|
|
||||||
response = await api.deleteSession(projectId, sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
onSessionDelete?.(sessionId);
|
onSessionDelete?.(sessionId);
|
||||||
@@ -634,9 +611,9 @@ export function useSidebarController({
|
|||||||
}, [onRefresh]);
|
}, [onRefresh]);
|
||||||
|
|
||||||
const updateSessionSummary = useCallback(
|
const updateSessionSummary = useCallback(
|
||||||
// `_projectId` is unused by the rename endpoint but preserved in the
|
// `_projectId` and `_provider` are preserved for compatibility with
|
||||||
// callback signature so existing wiring from sidebar components works.
|
// existing sidebar callback signatures; backend rename only needs sessionId.
|
||||||
async (_projectId: string, sessionId: string, summary: string, provider: LLMProvider) => {
|
async (_projectId: string, sessionId: string, summary: string, _provider: LLMProvider) => {
|
||||||
const trimmed = summary.trim();
|
const trimmed = summary.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setEditingSession(null);
|
setEditingSession(null);
|
||||||
@@ -644,7 +621,7 @@ export function useSidebarController({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await api.renameSession(sessionId, trimmed, provider);
|
const response = await api.renameSession(sessionId, trimmed);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export type DeleteProjectConfirmation = {
|
|||||||
sessionCount: number;
|
sessionCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete confirmation payload; `projectId` is the DB primary key used by the
|
// Delete confirmation payload used by sidebar UX. `projectId`/`provider` are
|
||||||
// DELETE /api/projects/:projectId/sessions/:sessionId endpoint.
|
// kept for wiring compatibility, while API deletion now keys only by sessionId.
|
||||||
export type SessionDeleteConfirmation = {
|
export type SessionDeleteConfirmation = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ i18n
|
|||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
|
|
||||||
// Enable debug mode in development (logs missing keys to console)
|
// Enable debug mode in development (logs missing keys to console)
|
||||||
debug: import.meta.env.DEV,
|
debug: false,
|
||||||
|
|
||||||
// Namespaces - load only what's needed
|
// Namespaces - load only what's needed
|
||||||
ns: ['common', 'settings', 'auth', 'sidebar', 'chat', 'codeEditor', 'tasks'],
|
ns: ['common', 'settings', 'auth', 'sidebar', 'chat', 'codeEditor', 'tasks'],
|
||||||
|
|||||||
@@ -76,22 +76,14 @@ export const api = {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ displayName }),
|
body: JSON.stringify({ displayName }),
|
||||||
}),
|
}),
|
||||||
deleteSession: (projectId, sessionId) =>
|
deleteSession: (sessionId) =>
|
||||||
authenticatedFetch(`/api/projects/${projectId}/sessions/${sessionId}`, {
|
authenticatedFetch(`/api/providers/sessions/${sessionId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}),
|
}),
|
||||||
renameSession: (sessionId, summary, provider) =>
|
renameSession: (sessionId, summary) =>
|
||||||
authenticatedFetch(`/api/sessions/${sessionId}/rename`, {
|
authenticatedFetch(`/api/providers/sessions/${sessionId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ summary, provider }),
|
body: JSON.stringify({ summary }),
|
||||||
}),
|
|
||||||
deleteCodexSession: (sessionId) =>
|
|
||||||
authenticatedFetch(`/api/codex/sessions/${sessionId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
}),
|
|
||||||
deleteGeminiSession: (sessionId) =>
|
|
||||||
authenticatedFetch(`/api/gemini/sessions/${sessionId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
}),
|
}),
|
||||||
// `hardDelete` => server `?force=true` (remove DB row + Claude *.jsonl + sessions rows for path).
|
// `hardDelete` => server `?force=true` (remove DB row + Claude *.jsonl + sessions rows for path).
|
||||||
deleteProject: (projectId, hardDelete = false) => {
|
deleteProject: (projectId, hardDelete = false) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user