mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-28 23:15:33 +08:00
feat: setup delete session by id
This commit is contained in:
@@ -4,8 +4,9 @@ import { promises as fs } from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import TOML from '@iarna/toml';
|
import TOML from '@iarna/toml';
|
||||||
import { getCodexSessions, deleteCodexSession } from '../../../projects.js';
|
import { getCodexSessions } from '../../../projects.js';
|
||||||
import { sessionsDb } from '@/shared/database/repositories/sessions.db.js';
|
import { sessionsDb } from '@/shared/database/repositories/sessions.db.js';
|
||||||
|
import { deleteSession as deleteSessionFromProviders } from '@/modules/sessions/sessions.service.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -71,8 +72,7 @@ router.get('/sessions', async (req, res) => {
|
|||||||
router.delete('/sessions/:sessionId', async (req, res) => {
|
router.delete('/sessions/:sessionId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { sessionId } = req.params;
|
const { sessionId } = req.params;
|
||||||
await deleteCodexSession(sessionId);
|
await deleteSessionFromProviders(sessionId);
|
||||||
sessionsDb.deleteSession(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,6 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import sessionManager from '../../../sessionManager.js';
|
import sessionManager from '../../../sessionManager.js';
|
||||||
import { sessionsDb } from '@/shared/database/repositories/sessions.db.js';
|
import { deleteSession as deleteSessionFromProviders } from '@/modules/sessions/sessions.service.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ router.delete('/sessions/:sessionId', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await sessionManager.deleteSession(sessionId);
|
await sessionManager.deleteSession(sessionId);
|
||||||
sessionsDb.deleteSession(sessionId);
|
await deleteSessionFromProviders(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);
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import {
|
|||||||
getProjects,
|
getProjects,
|
||||||
getSessions,
|
getSessions,
|
||||||
renameProject,
|
renameProject,
|
||||||
deleteSession,
|
|
||||||
deleteProject,
|
deleteProject,
|
||||||
searchConversations
|
searchConversations
|
||||||
} from '../../../projects.js';
|
} from '../../../projects.js';
|
||||||
import { sessionsDb } from '@/shared/database/repositories/sessions.db.js';
|
import { sessionsDb } from '@/shared/database/repositories/sessions.db.js';
|
||||||
import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js';
|
import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js';
|
||||||
|
import { deleteSession as deleteSessionFromProviders } from '@/modules/sessions/sessions.service.js';
|
||||||
import { authenticateToken } from '../auth/auth.middleware.js';
|
import { authenticateToken } from '../auth/auth.middleware.js';
|
||||||
import { getWorkspaceNameFromPath, WORKSPACES_ROOT, validateWorkspacePath } from './projects.utils.js';
|
import { getWorkspaceNameFromPath, WORKSPACES_ROOT, validateWorkspacePath } from './projects.utils.js';
|
||||||
|
|
||||||
@@ -69,8 +69,7 @@ router.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToke
|
|||||||
try {
|
try {
|
||||||
const { projectName, sessionId } = req.params;
|
const { projectName, sessionId } = req.params;
|
||||||
console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
|
console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
|
||||||
await deleteSession(projectName, sessionId);
|
await deleteSessionFromProviders(sessionId);
|
||||||
sessionsDb.deleteSession(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) {
|
||||||
|
|||||||
@@ -49,3 +49,67 @@ export async function processClaudeSessions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function encodeClaudeProjectPath(projectPath: string): string {
|
||||||
|
return projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFileIfExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fsp.unlink(filePath);
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'ENOENT') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDirectoryEntriesSafe(directoryPath: string): Promise<import('node:fs').Dirent[]> {
|
||||||
|
try {
|
||||||
|
return await fsp.readdir(directoryPath, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findFilesByName(rootPath: string, fileName: string): Promise<string[]> {
|
||||||
|
const matches: string[] = [];
|
||||||
|
const stack = [rootPath];
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const currentPath = stack.pop() as string;
|
||||||
|
const entries = await listDirectoryEntriesSafe(currentPath);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(currentPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(fullPath);
|
||||||
|
} else if (entry.isFile() && entry.name === fileName) {
|
||||||
|
matches.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteClaudeSession(sessionId: string, workspacePath?: string): Promise<boolean> {
|
||||||
|
const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
|
||||||
|
const fileName = `${sessionId}.jsonl`;
|
||||||
|
let deleted = false;
|
||||||
|
|
||||||
|
if (workspacePath) {
|
||||||
|
const encodedPath = encodeClaudeProjectPath(workspacePath);
|
||||||
|
const candidateFilePath = path.join(claudeProjectsDir, encodedPath, fileName);
|
||||||
|
deleted = (await removeFileIfExists(candidateFilePath)) || deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await findFilesByName(claudeProjectsDir, fileName);
|
||||||
|
for (const filePath of matches) {
|
||||||
|
deleted = (await removeFileIfExists(filePath)) || deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
|
||||||
import fsp from 'node:fs/promises';
|
|
||||||
import { sessionsDb } from '@/shared/database/repositories/sessions.db.js';
|
|
||||||
import { buildLookupMap, extractFirstValidJsonlData, findFilesRecursivelyCreatedAfterLastScan } from '@/modules/providers/shared/session-parser.utils.js';
|
|
||||||
import { SessionData } from '@/shared/types/session.js';
|
|
||||||
|
|
||||||
export async function processCodexSessionFile(file: string, nameMap?: Map<string, string>): Promise<SessionData | null> {
|
|
||||||
if (!nameMap) {
|
|
||||||
const base = path.join(os.homedir(), '.codex');
|
|
||||||
nameMap = await buildLookupMap(path.join(base, 'session_index.jsonl'), 'id', 'thread_name');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Codex nests the required data inside a `payload` object
|
|
||||||
return extractFirstValidJsonlData(file, (data) => ({
|
|
||||||
workspacePath: data?.payload?.cwd,
|
|
||||||
sessionId: data?.payload?.id,
|
|
||||||
sessionName: nameMap!.get(data?.payload?.id) || 'Untitled Codex Session'
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function processCodexSessions() {
|
|
||||||
const base = path.join(os.homedir(), '.codex');
|
|
||||||
// Use the thread_name attribute as requested
|
|
||||||
const nameMap = await buildLookupMap(path.join(base, 'session_index.jsonl'), 'id', 'thread_name');
|
|
||||||
|
|
||||||
const files = await findFilesRecursivelyCreatedAfterLastScan(path.join(base, 'sessions'), '.jsonl');
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const result = await processCodexSessionFile(file, nameMap);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
let createdAt: string | undefined;
|
|
||||||
let updatedAt: string | undefined;
|
|
||||||
try {
|
|
||||||
const stat = await fsp.stat(file);
|
|
||||||
createdAt = stat.birthtime.toISOString();
|
|
||||||
updatedAt = stat.mtime.toISOString();
|
|
||||||
} catch {
|
|
||||||
// Ignore stat failures and let DB defaults handle created_at/updated_at.
|
|
||||||
}
|
|
||||||
sessionsDb.createSession(
|
|
||||||
result.sessionId,
|
|
||||||
'codex',
|
|
||||||
result.workspacePath,
|
|
||||||
result.sessionName,
|
|
||||||
createdAt,
|
|
||||||
updatedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
151
server/src/modules/providers/codex/codex.session-processor.ts
Normal file
151
server/src/modules/providers/codex/codex.session-processor.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import { sessionsDb } from '@/shared/database/repositories/sessions.db.js';
|
||||||
|
import { buildLookupMap, extractFirstValidJsonlData, findFilesRecursivelyCreatedAfterLastScan } from '@/modules/providers/shared/session-parser.utils.js';
|
||||||
|
import { SessionData } from '@/shared/types/session.js';
|
||||||
|
|
||||||
|
export async function processCodexSessionFile(file: string, nameMap?: Map<string, string>): Promise<SessionData | null> {
|
||||||
|
if (!nameMap) {
|
||||||
|
const base = path.join(os.homedir(), '.codex');
|
||||||
|
nameMap = await buildLookupMap(path.join(base, 'session_index.jsonl'), 'id', 'thread_name');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codex nests the required data inside a `payload` object
|
||||||
|
return extractFirstValidJsonlData(file, (data) => ({
|
||||||
|
workspacePath: data?.payload?.cwd,
|
||||||
|
sessionId: data?.payload?.id,
|
||||||
|
sessionName: nameMap!.get(data?.payload?.id) || 'Untitled Codex Session'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processCodexSessions() {
|
||||||
|
const base = path.join(os.homedir(), '.codex');
|
||||||
|
// Use the thread_name attribute as requested
|
||||||
|
const nameMap = await buildLookupMap(path.join(base, 'session_index.jsonl'), 'id', 'thread_name');
|
||||||
|
|
||||||
|
const files = await findFilesRecursivelyCreatedAfterLastScan(path.join(base, 'sessions'), '.jsonl');
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const result = await processCodexSessionFile(file, nameMap);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
let createdAt: string | undefined;
|
||||||
|
let updatedAt: string | undefined;
|
||||||
|
try {
|
||||||
|
const stat = await fsp.stat(file);
|
||||||
|
createdAt = stat.birthtime.toISOString();
|
||||||
|
updatedAt = stat.mtime.toISOString();
|
||||||
|
} catch {
|
||||||
|
// Ignore stat failures and let DB defaults handle created_at/updated_at.
|
||||||
|
}
|
||||||
|
sessionsDb.createSession(
|
||||||
|
result.sessionId,
|
||||||
|
'codex',
|
||||||
|
result.workspacePath,
|
||||||
|
result.sessionName,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCodexDatePathParts(createdAt: string): Array<{ year: string; month: string; day: string }> {
|
||||||
|
const parsedDate = new Date(createdAt);
|
||||||
|
if (Number.isNaN(parsedDate.getTime())) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const localDate = {
|
||||||
|
year: String(parsedDate.getFullYear()),
|
||||||
|
month: String(parsedDate.getMonth() + 1),
|
||||||
|
day: String(parsedDate.getDate()),
|
||||||
|
};
|
||||||
|
|
||||||
|
const utcDate = {
|
||||||
|
year: String(parsedDate.getUTCFullYear()),
|
||||||
|
month: String(parsedDate.getUTCMonth() + 1),
|
||||||
|
day: String(parsedDate.getUTCDate()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
localDate.year === utcDate.year &&
|
||||||
|
localDate.month === utcDate.month &&
|
||||||
|
localDate.day === utcDate.day
|
||||||
|
) {
|
||||||
|
return [localDate];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [localDate, utcDate];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFileIfExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fsp.unlink(filePath);
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'ENOENT') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDirectoryEntriesSafe(directoryPath: string): Promise<import('node:fs').Dirent[]> {
|
||||||
|
try {
|
||||||
|
return await fsp.readdir(directoryPath, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findFilesByName(rootPath: string, fileName: string): Promise<string[]> {
|
||||||
|
const matches: string[] = [];
|
||||||
|
const stack = [rootPath];
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const currentPath = stack.pop() as string;
|
||||||
|
const entries = await listDirectoryEntriesSafe(currentPath);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(currentPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(fullPath);
|
||||||
|
} else if (entry.isFile() && entry.name === fileName) {
|
||||||
|
matches.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCodexSession(sessionId: string, createdAt?: string): Promise<boolean> {
|
||||||
|
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||||
|
const fileName = `${sessionId}.jsonl`;
|
||||||
|
let deleted = false;
|
||||||
|
|
||||||
|
if (createdAt) {
|
||||||
|
const datePathParts = buildCodexDatePathParts(createdAt);
|
||||||
|
for (const parts of datePathParts) {
|
||||||
|
const candidateFilePath = path.join(
|
||||||
|
codexSessionsDir,
|
||||||
|
parts.year,
|
||||||
|
parts.month,
|
||||||
|
parts.day,
|
||||||
|
fileName,
|
||||||
|
);
|
||||||
|
deleted = (await removeFileIfExists(candidateFilePath)) || deleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
const matches = await findFilesByName(codexSessionsDir, fileName);
|
||||||
|
for (const filePath of matches) {
|
||||||
|
deleted = (await removeFileIfExists(filePath)) || deleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
@@ -12,6 +12,84 @@ function md5(input: string): string {
|
|||||||
return crypto.createHash('md5').update(input).digest('hex');
|
return crypto.createHash('md5').update(input).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeFileIfExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fsp.unlink(filePath);
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'ENOENT') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDirectoryIfExists(directoryPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fsp.rm(directoryPath, { recursive: true, force: false });
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'ENOENT') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDirectoryEntriesSafe(directoryPath: string): Promise<import('node:fs').Dirent[]> {
|
||||||
|
try {
|
||||||
|
return await fsp.readdir(directoryPath, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findDirectoriesByName(rootPath: string, directoryName: string): Promise<string[]> {
|
||||||
|
const matches: string[] = [];
|
||||||
|
const stack = [rootPath];
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const currentPath = stack.pop() as string;
|
||||||
|
const entries = await listDirectoryEntriesSafe(currentPath);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path.join(currentPath, entry.name);
|
||||||
|
if (entry.name === directoryName) {
|
||||||
|
matches.push(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findFilesByName(rootPath: string, fileName: string): Promise<string[]> {
|
||||||
|
const matches: string[] = [];
|
||||||
|
const stack = [rootPath];
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const currentPath = stack.pop() as string;
|
||||||
|
const entries = await listDirectoryEntriesSafe(currentPath);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(currentPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(fullPath);
|
||||||
|
} else if (entry.isFile() && entry.name === fileName) {
|
||||||
|
matches.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
export async function extractWorkspacePathFromWorkerLog(filePath: string): Promise<string | null> {
|
export async function extractWorkspacePathFromWorkerLog(filePath: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||||
@@ -104,3 +182,26 @@ export async function processCursorSessions() {
|
|||||||
// Base cursor directory or projects directory likely doesn't exist
|
// Base cursor directory or projects directory likely doesn't exist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteCursorSession(sessionId: string, workspacePath?: string): Promise<boolean> {
|
||||||
|
const cursorChatsDir = path.join(os.homedir(), '.cursor', 'chats');
|
||||||
|
let deleted = false;
|
||||||
|
|
||||||
|
if (workspacePath) {
|
||||||
|
const cwdId = md5(workspacePath);
|
||||||
|
const candidateDir = path.join(cursorChatsDir, cwdId, sessionId);
|
||||||
|
deleted = (await removeDirectoryIfExists(candidateDir)) || deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionDirs = await findDirectoriesByName(cursorChatsDir, sessionId);
|
||||||
|
for (const directoryPath of sessionDirs) {
|
||||||
|
deleted = (await removeDirectoryIfExists(directoryPath)) || deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonlFiles = await findFilesByName(cursorChatsDir, `${sessionId}.jsonl`);
|
||||||
|
for (const filePath of jsonlFiles) {
|
||||||
|
deleted = (await removeFileIfExists(filePath)) || deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
|
||||||
import fsp from 'node:fs/promises';
|
|
||||||
import { sessionsDb } from '@/shared/database/repositories/sessions.db.js';
|
|
||||||
import { findFilesRecursivelyCreatedAfterLastScan } from '@/modules/providers/shared/session-parser.utils.js';
|
|
||||||
import { SessionData } from '@/shared/types/session.js';
|
|
||||||
|
|
||||||
export async function processGeminiSessionFile(file: string): Promise<SessionData | null> {
|
|
||||||
try {
|
|
||||||
// Gemini uses standard JSON (not JSONL), so we read the whole file at once
|
|
||||||
|
|
||||||
const fileContent = await fsp.readFile(file, 'utf8');
|
|
||||||
const data = JSON.parse(fileContent);
|
|
||||||
if (data?.id && data?.projectPath) {
|
|
||||||
return {
|
|
||||||
sessionId: data.id,
|
|
||||||
workspacePath: data.projectPath,
|
|
||||||
sessionName: data.messages?.[0]?.content || 'New Gemini Chat'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore parsing error for gemini
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function processGeminiSessions() {
|
|
||||||
const geminiPath = path.join(os.homedir(), '.gemini', 'sessions');
|
|
||||||
const files = await findFilesRecursivelyCreatedAfterLastScan(geminiPath, '.json');
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const result = await processGeminiSessionFile(file);
|
|
||||||
if (result) {
|
|
||||||
let createdAt: string | undefined;
|
|
||||||
let updatedAt: string | undefined;
|
|
||||||
try {
|
|
||||||
const stat = await fsp.stat(file);
|
|
||||||
createdAt = stat.birthtime.toISOString();
|
|
||||||
updatedAt = stat.mtime.toISOString();
|
|
||||||
} catch {
|
|
||||||
// Ignore stat failures and let DB defaults handle created_at/updated_at.
|
|
||||||
}
|
|
||||||
sessionsDb.createSession(
|
|
||||||
result.sessionId,
|
|
||||||
'gemini',
|
|
||||||
result.workspacePath,
|
|
||||||
result.sessionName,
|
|
||||||
createdAt,
|
|
||||||
updatedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
118
server/src/modules/providers/gemini/gemini.session-processor.ts
Normal file
118
server/src/modules/providers/gemini/gemini.session-processor.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import { sessionsDb } from '@/shared/database/repositories/sessions.db.js';
|
||||||
|
import { findFilesRecursivelyCreatedAfterLastScan } from '@/modules/providers/shared/session-parser.utils.js';
|
||||||
|
import { SessionData } from '@/shared/types/session.js';
|
||||||
|
|
||||||
|
export async function processGeminiSessionFile(file: string): Promise<SessionData | null> {
|
||||||
|
try {
|
||||||
|
// Gemini uses standard JSON (not JSONL), so we read the whole file at once
|
||||||
|
|
||||||
|
const fileContent = await fsp.readFile(file, 'utf8');
|
||||||
|
const data = JSON.parse(fileContent);
|
||||||
|
if (data?.id && data?.projectPath) {
|
||||||
|
return {
|
||||||
|
sessionId: data.id,
|
||||||
|
workspacePath: data.projectPath,
|
||||||
|
sessionName: data.messages?.[0]?.content || 'New Gemini Chat'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore parsing error for gemini
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processGeminiSessions() {
|
||||||
|
const geminiPath = path.join(os.homedir(), '.gemini', 'sessions');
|
||||||
|
const files = await findFilesRecursivelyCreatedAfterLastScan(geminiPath, '.json');
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const result = await processGeminiSessionFile(file);
|
||||||
|
if (result) {
|
||||||
|
let createdAt: string | undefined;
|
||||||
|
let updatedAt: string | undefined;
|
||||||
|
try {
|
||||||
|
const stat = await fsp.stat(file);
|
||||||
|
createdAt = stat.birthtime.toISOString();
|
||||||
|
updatedAt = stat.mtime.toISOString();
|
||||||
|
} catch {
|
||||||
|
// Ignore stat failures and let DB defaults handle created_at/updated_at.
|
||||||
|
}
|
||||||
|
sessionsDb.createSession(
|
||||||
|
result.sessionId,
|
||||||
|
'gemini',
|
||||||
|
result.workspacePath,
|
||||||
|
result.sessionName,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFileIfExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fsp.unlink(filePath);
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'ENOENT') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDirectoryEntriesSafe(directoryPath: string): Promise<import('node:fs').Dirent[]> {
|
||||||
|
try {
|
||||||
|
return await fsp.readdir(directoryPath, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGeminiSession(sessionId: string): Promise<boolean> {
|
||||||
|
const geminiHome = path.join(os.homedir(), '.gemini');
|
||||||
|
const geminiSessionsDir = path.join(geminiHome, 'sessions');
|
||||||
|
const geminiTmpDir = path.join(geminiHome, 'tmp');
|
||||||
|
let deleted = false;
|
||||||
|
|
||||||
|
deleted = (await removeFileIfExists(path.join(geminiSessionsDir, `${sessionId}.json`))) || deleted;
|
||||||
|
deleted = (await removeFileIfExists(path.join(geminiSessionsDir, `${sessionId}.jsonl`))) || deleted;
|
||||||
|
|
||||||
|
const projectDirs = await listDirectoryEntriesSafe(geminiTmpDir);
|
||||||
|
for (const projectDir of projectDirs) {
|
||||||
|
if (!projectDir.isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatsDir = path.join(geminiTmpDir, projectDir.name, 'chats');
|
||||||
|
const chatFiles = await listDirectoryEntriesSafe(chatsDir);
|
||||||
|
|
||||||
|
for (const chatFile of chatFiles) {
|
||||||
|
if (!chatFile.isFile() || !chatFile.name.endsWith('.json')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatFilePath = path.join(chatsDir, chatFile.name);
|
||||||
|
if (chatFile.name === `${sessionId}.json`) {
|
||||||
|
deleted = (await removeFileIfExists(chatFilePath)) || deleted;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fsp.readFile(chatFilePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
const parsedId = parsed?.sessionId || parsed?.id;
|
||||||
|
if (parsedId === sessionId) {
|
||||||
|
deleted = (await removeFileIfExists(chatFilePath)) || deleted;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore unreadable/malformed session files.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
@@ -24,10 +24,10 @@ router.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res
|
|||||||
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)) {
|
if (provider && !VALID_PROVIDERS.includes(provider)) {
|
||||||
return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` });
|
return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` });
|
||||||
}
|
}
|
||||||
sessionsDb.createSessionName(safeSessionId, provider, summary.trim());
|
sessionsDb.updateSessionCustomName(safeSessionId, 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);
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
import { scanStateDb } from '@/shared/database/repositories/scan-state.db.js';
|
import { scanStateDb } from '@/shared/database/repositories/scan-state.db.js';
|
||||||
import { processClaudeSessions } from '@/modules/providers/claude/claude.session-parser.js';
|
import { sessionsDb } from '@/shared/database/repositories/sessions.db.js';
|
||||||
import { processCodexSessions } from '@/modules/providers/codex/codex.session-parser.js';
|
import { processClaudeSessions, deleteClaudeSession } from '@/modules/providers/claude/claude.session-processor.js';
|
||||||
import { processGeminiSessions } from '@/modules/providers/gemini/gemini.session-parser.js';
|
import { processCodexSessions, deleteCodexSession } from '@/modules/providers/codex/codex.session-processor.js';
|
||||||
import { processCursorSessions } from '@/modules/providers/cursor/cursor.session-parser.js';
|
import { processGeminiSessions, deleteGeminiSession } from '@/modules/providers/gemini/gemini.session-processor.js';
|
||||||
|
import { processCursorSessions, deleteCursorSession } from '@/modules/providers/cursor/cursor.session-processor.js';
|
||||||
|
|
||||||
|
const SESSION_ID_PATTERN = /^[a-zA-Z0-9._-]{1,120}$/;
|
||||||
|
|
||||||
|
function sanitizeSessionId(sessionId: string): string {
|
||||||
|
const value = String(sessionId || '').trim();
|
||||||
|
if (!SESSION_ID_PATTERN.test(value)) {
|
||||||
|
throw new Error('Invalid session ID format');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export async function processSessions() {
|
export async function processSessions() {
|
||||||
|
|
||||||
// 1. Start the timer with a unique label
|
// 1. Start the timer with a unique label
|
||||||
console.time("🚀 Workspace sync total time");
|
console.time('Workspace sync total time');
|
||||||
|
|
||||||
console.log("Starting workspace sync...");
|
console.log('Starting workspace sync...');
|
||||||
try {
|
try {
|
||||||
// Wrapping in Promise.all allows these to process concurrently, speeding up the boot time
|
// Wrapping in Promise.all allows these to process concurrently, speeding up the boot time
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
@@ -21,12 +32,33 @@ export async function processSessions() {
|
|||||||
|
|
||||||
scanStateDb.updateLastScannedAt();
|
scanStateDb.updateLastScannedAt();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("An error occurred during sync:", error);
|
console.error('An error occurred during sync:', error);
|
||||||
} finally {
|
} finally {
|
||||||
console.log("----------------------------------");
|
console.log('----------------------------------');
|
||||||
// 2. Stop the timer using the exact same label
|
// 2. Stop the timer using the exact same label
|
||||||
// This will print: 🚀 Workspace sync total time: 123.456ms
|
// This will print: Workspace sync total time: 123.456ms
|
||||||
console.timeEnd("🚀 Workspace sync total time");
|
console.timeEnd('Workspace sync total time');
|
||||||
console.log("Workspace synchronization complete.");
|
console.log('Workspace synchronization complete.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(sessionId: string): Promise<void> {
|
||||||
|
const safeSessionId = sanitizeSessionId(sessionId);
|
||||||
|
const existingSession = sessionsDb.getSessionById(safeSessionId);
|
||||||
|
const workspacePath = existingSession?.workspace_path;
|
||||||
|
const createdAt = existingSession?.created_at;
|
||||||
|
|
||||||
|
const deletionResults = await Promise.allSettled([
|
||||||
|
deleteClaudeSession(safeSessionId, workspacePath),
|
||||||
|
deleteCodexSession(safeSessionId, createdAt),
|
||||||
|
deleteGeminiSession(safeSessionId),
|
||||||
|
deleteCursorSession(safeSessionId, workspacePath),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rejectedResult = deletionResults.find((result) => result.status === 'rejected') as PromiseRejectedResult | undefined;
|
||||||
|
if (rejectedResult) {
|
||||||
|
throw rejectedResult.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionsDb.deleteSession(safeSessionId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import os from "os";
|
|||||||
import { promises as fsPromises } from "fs";
|
import { promises as fsPromises } from "fs";
|
||||||
import { logger } from "@/shared/utils/logger.js";
|
import { logger } from "@/shared/utils/logger.js";
|
||||||
import { processSessions } from "@/modules/sessions/sessions.service.js";
|
import { processSessions } from "@/modules/sessions/sessions.service.js";
|
||||||
import { processClaudeSessionFile } from "@/modules/providers/claude/claude.session-parser.js";
|
import { processClaudeSessionFile } from "@/modules/providers/claude/claude.session-processor.js";
|
||||||
import { processCodexSessionFile } from "@/modules/providers/codex/codex.session-parser.js";
|
import { processCodexSessionFile } from "@/modules/providers/codex/codex.session-processor.js";
|
||||||
import { processGeminiSessionFile } from "@/modules/providers/gemini/gemini.session-parser.js";
|
import { processGeminiSessionFile } from "@/modules/providers/gemini/gemini.session-processor.js";
|
||||||
import { processCursorSessionFile } from "@/modules/providers/cursor/cursor.session-parser.js";
|
import { processCursorSessionFile } from "@/modules/providers/cursor/cursor.session-processor.js";
|
||||||
import { sessionsDb } from "@/shared/database/repositories/sessions.db.js";
|
import { sessionsDb } from "@/shared/database/repositories/sessions.db.js";
|
||||||
import { LLMProvider } from "@/shared/types/app.js";
|
import { LLMProvider } from "@/shared/types/app.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js';
|
import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js';
|
||||||
import { getConnection } from '@/shared/database/connection.js';
|
import { getConnection } from '@/shared/database/connection.js';
|
||||||
import type { SessionWithSummary } from '@/shared/database/types.js';
|
import path from 'node:path';
|
||||||
|
import type { SessionsRow, SessionWithSummary } from '@/shared/database/types.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Queries
|
// Queries
|
||||||
@@ -11,6 +12,11 @@ type SessionNameLookupRow = {
|
|||||||
custom_name: string;
|
custom_name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SessionMetadataLookupRow = Pick<
|
||||||
|
SessionsRow,
|
||||||
|
'session_id' | 'provider' | 'workspace_path' | 'created_at' | 'updated_at'
|
||||||
|
>;
|
||||||
|
|
||||||
function normalizeTimestamp(value?: string): string | null {
|
function normalizeTimestamp(value?: string): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
||||||
@@ -22,6 +28,34 @@ function normalizeTimestamp(value?: string): string | null {
|
|||||||
return parsed.toISOString();
|
return parsed.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCodexWorkspacePath(workspacePath: string): string {
|
||||||
|
const trimmedPath = workspacePath.trim();
|
||||||
|
if (!trimmedPath) {
|
||||||
|
return workspacePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return path.normalize(trimmedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
let strippedPath = trimmedPath;
|
||||||
|
if (strippedPath.startsWith('\\\\?\\UNC\\')) {
|
||||||
|
strippedPath = `\\\\${strippedPath.slice('\\\\?\\UNC\\'.length)}`;
|
||||||
|
} else if (strippedPath.startsWith('\\\\?\\')) {
|
||||||
|
strippedPath = strippedPath.slice('\\\\?\\'.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.win32.normalize(strippedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWorkspacePathForProvider(provider: string, workspacePath: string): string {
|
||||||
|
if (provider !== 'codex') {
|
||||||
|
return workspacePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeCodexWorkspacePath(workspacePath);
|
||||||
|
}
|
||||||
|
|
||||||
export const sessionsDb = {
|
export const sessionsDb = {
|
||||||
|
|
||||||
createSession(
|
createSession(
|
||||||
@@ -35,17 +69,30 @@ export const sessionsDb = {
|
|||||||
const db = getConnection();
|
const db = getConnection();
|
||||||
const createdAtValue = normalizeTimestamp(createdAt);
|
const createdAtValue = normalizeTimestamp(createdAt);
|
||||||
const updatedAtValue = normalizeTimestamp(updatedAt);
|
const updatedAtValue = normalizeTimestamp(updatedAt);
|
||||||
|
const normalizedWorkspacePath = normalizeWorkspacePathForProvider(provider, workspacePath);
|
||||||
|
|
||||||
// First, ensure the workspace path is recorded in the workspace_original_paths table
|
// First, ensure the workspace path is recorded in the workspace_original_paths table
|
||||||
// since it's a foreign key in the sessions table.
|
// since it's a foreign key in the sessions table.
|
||||||
workspaceOriginalPathsDb.createWorkspacePath(workspacePath);
|
workspaceOriginalPathsDb.createWorkspacePath(normalizedWorkspacePath);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO sessions (session_id, provider, custom_name, workspace_path, created_at, updated_at)
|
`INSERT INTO sessions (session_id, provider, custom_name, workspace_path, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
|
VALUES (?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
|
||||||
ON CONFLICT(session_id) DO UPDATE SET updated_at = excluded.updated_at
|
ON CONFLICT(session_id) DO UPDATE SET
|
||||||
|
updated_at = excluded.updated_at,
|
||||||
|
workspace_path = excluded.workspace_path
|
||||||
WHERE sessions.provider = excluded.provider`
|
WHERE sessions.provider = excluded.provider`
|
||||||
).run(session_id, provider, customName, workspacePath, createdAtValue, updatedAtValue);
|
).run(session_id, provider, customName, normalizedWorkspacePath, createdAtValue, updatedAtValue);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Updates a custom session name by session id, regardless of provider. */
|
||||||
|
updateSessionCustomName(sessionId: string, customName: string): void {
|
||||||
|
const db = getConnection();
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE sessions
|
||||||
|
SET custom_name = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE session_id = ?`
|
||||||
|
).run(customName, sessionId);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Updates a custom session name for an existing session row. */
|
/** Updates a custom session name for an existing session row. */
|
||||||
@@ -58,6 +105,19 @@ export const sessionsDb = {
|
|||||||
).run(customName, sessionId, provider);
|
).run(customName, sessionId, provider);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getSessionById(sessionId: string): SessionMetadataLookupRow | null {
|
||||||
|
const db = getConnection();
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT session_id, provider, workspace_path, created_at, updated_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE session_id = ?`
|
||||||
|
)
|
||||||
|
.get(sessionId) as SessionMetadataLookupRow | undefined;
|
||||||
|
|
||||||
|
return row ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
getSessionName(sessionId: string, provider: string): string | null {
|
getSessionName(sessionId: string, provider: string): string | null {
|
||||||
const db = getConnection();
|
const db = getConnection();
|
||||||
const row = db
|
const row = db
|
||||||
|
|||||||
Reference in New Issue
Block a user