mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-12 17:12:06 +08:00
feat(chat): unify session gateway with stable IDs and a single WS protocol
The frontend previously juggled placeholder IDs, provider-native IDs, and session_created handoffs, which caused race conditions and provider-specific branching. This introduces app-allocated session IDs, a chat run registry with event replay, delta sidebar updates, and one kind-based websocket contract so the UI can treat every provider the same while JSONL remains the source of truth.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
|
||||
/**
|
||||
* Static, backend-owned description of what one provider integration supports.
|
||||
*
|
||||
* The frontend renders its composer UI (permission mode picker, image upload,
|
||||
* abort button, ...) purely from this shape, which is what keeps the frontend
|
||||
* free of per-provider conditionals. New provider features should be exposed
|
||||
* here instead of branching on the provider id in React components.
|
||||
*/
|
||||
type ProviderCapabilities = {
|
||||
provider: LLMProvider;
|
||||
/** Permission modes the provider runtime understands, in cycle order. */
|
||||
permissionModes: string[];
|
||||
defaultPermissionMode: string;
|
||||
/** Whether image attachments can be included in a chat.send. */
|
||||
supportsImages: boolean;
|
||||
/** Whether an in-flight run can be cancelled via chat.abort. */
|
||||
supportsAbort: boolean;
|
||||
/** Whether interactive tool permission prompts can reach the UI. */
|
||||
supportsPermissionRequests: boolean;
|
||||
/** Whether the token-usage endpoint has data for this provider. */
|
||||
supportsTokenUsage: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The capability matrix mirrors what each runtime actually implements today:
|
||||
* - permission modes match the option sets accepted by each CLI/SDK.
|
||||
* - only the Claude SDK integration surfaces interactive permission requests.
|
||||
* - Cursor has no token usage endpoint support (its store.db has no usage rows).
|
||||
*/
|
||||
const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||
claude: {
|
||||
provider: 'claude',
|
||||
permissionModes: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: true,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: true,
|
||||
supportsTokenUsage: true,
|
||||
},
|
||||
cursor: {
|
||||
provider: 'cursor',
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: false,
|
||||
},
|
||||
codex: {
|
||||
provider: 'codex',
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
},
|
||||
gemini: {
|
||||
provider: 'gemini',
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
},
|
||||
opencode: {
|
||||
provider: 'opencode',
|
||||
permissionModes: ['default'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Application service exposing the provider capability matrix.
|
||||
*/
|
||||
export const providerCapabilitiesService = {
|
||||
getProviderCapabilities(provider: LLMProvider): ProviderCapabilities {
|
||||
return PROVIDER_CAPABILITIES[provider];
|
||||
},
|
||||
|
||||
listAllProviderCapabilities(): ProviderCapabilities[] {
|
||||
return Object.values(PROVIDER_CAPABILITIES);
|
||||
},
|
||||
};
|
||||
@@ -4,10 +4,11 @@ import { promises as fsPromises } from 'node:fs';
|
||||
|
||||
import chokidar, { type FSWatcher } from 'chokidar';
|
||||
|
||||
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||
import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js';
|
||||
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
import { getProjectsWithSessions } from '@/modules/projects/index.js';
|
||||
import { generateDisplayName } from '@/modules/projects/index.js';
|
||||
|
||||
type WatcherEventType = 'add' | 'change';
|
||||
|
||||
@@ -58,6 +59,11 @@ const watchers: FSWatcher[] = [];
|
||||
type PendingWatcherUpdate = {
|
||||
providers: Set<LLMProvider>;
|
||||
changeTypes: Set<WatcherEventType>;
|
||||
/**
|
||||
* Provider-native session ids reported by the synchronizers. They are
|
||||
* translated back to app-facing session rows at flush time, because the
|
||||
* transcript file names on disk only ever contain provider ids.
|
||||
*/
|
||||
updatedSessionIds: Set<string>;
|
||||
};
|
||||
|
||||
@@ -131,6 +137,50 @@ function queuePendingWatcherUpdate(
|
||||
schedulePendingWatcherFlush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one `session_upserted` delta event for a provider-native session id.
|
||||
*
|
||||
* The event carries everything a sidebar needs to upsert the session in place
|
||||
* (session summary plus owning-project metadata), so clients never need a full
|
||||
* project-list refetch when a transcript file changes on disk. Returns `null`
|
||||
* when the id cannot be resolved to an indexed session row.
|
||||
*/
|
||||
async function buildSessionUpsertedEvent(updatedProviderSessionId: string): Promise<string | null> {
|
||||
const row = sessionsDb.getSessionByProviderSessionId(updatedProviderSessionId)
|
||||
?? sessionsDb.getSessionById(updatedProviderSessionId);
|
||||
if (!row || row.isArchived) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectPath = row.project_path;
|
||||
const project = projectPath ? projectsDb.getProjectPath(projectPath) : null;
|
||||
const displayName = project?.custom_project_name?.trim()
|
||||
? project.custom_project_name
|
||||
: await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath);
|
||||
|
||||
return JSON.stringify({
|
||||
kind: 'session_upserted',
|
||||
sessionId: row.session_id,
|
||||
provider: row.provider,
|
||||
session: {
|
||||
id: row.session_id,
|
||||
summary: row.custom_name || '',
|
||||
messageCount: 0,
|
||||
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
|
||||
},
|
||||
project: project
|
||||
? {
|
||||
projectId: project.project_id,
|
||||
path: project.project_path,
|
||||
fullPath: project.project_path,
|
||||
displayName,
|
||||
isStarred: Boolean(project.isStarred),
|
||||
}
|
||||
: null,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async function flushPendingWatcherUpdate(): Promise<void> {
|
||||
clearPendingWatcherFlushTimer();
|
||||
|
||||
@@ -149,33 +199,29 @@ async function flushPendingWatcherUpdate(): Promise<void> {
|
||||
watcherRefreshInFlight = true;
|
||||
|
||||
try {
|
||||
const updatedProjects = await getProjectsWithSessions({ skipSynchronization: true });
|
||||
const changeTypes = Array.from(queuedUpdate.changeTypes);
|
||||
const watchProviders = Array.from(queuedUpdate.providers);
|
||||
const updatedSessionIds = Array.from(queuedUpdate.updatedSessionIds);
|
||||
|
||||
// Backward-compatible fields stay populated with the first queued values.
|
||||
const updateMessage = JSON.stringify({
|
||||
type: 'projects_updated',
|
||||
projects: updatedProjects,
|
||||
timestamp: new Date().toISOString(),
|
||||
changeType: changeTypes[0] ?? 'change',
|
||||
updatedSessionId: updatedSessionIds[0] ?? undefined,
|
||||
watchProvider: watchProviders[0] ?? undefined,
|
||||
changeTypes,
|
||||
updatedSessionIds,
|
||||
watchProviders,
|
||||
batched: true,
|
||||
});
|
||||
|
||||
connectedClients.forEach(client => {
|
||||
if (client.readyState === WS_OPEN_STATE) {
|
||||
client.send(updateMessage);
|
||||
// Per-session deltas instead of full project snapshots: an upsert of one
|
||||
// session can never clobber unrelated client state, so the frontend needs
|
||||
// no "suppress updates while a run is active" protection logic.
|
||||
const events: string[] = [];
|
||||
for (const updatedSessionId of queuedUpdate.updatedSessionIds) {
|
||||
const event = await buildSessionUpsertedEvent(updatedSessionId);
|
||||
if (event) {
|
||||
events.push(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (events.length > 0) {
|
||||
connectedClients.forEach(client => {
|
||||
if (client.readyState === WS_OPEN_STATE) {
|
||||
for (const event of events) {
|
||||
client.send(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('Session watcher refresh failed while broadcasting projects_updated', { error: message });
|
||||
console.error('Session watcher refresh failed while broadcasting session_upserted', { error: message });
|
||||
} finally {
|
||||
watcherRefreshInFlight = false;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -11,6 +12,12 @@ import type {
|
||||
} from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
type CreateAppSessionResult = {
|
||||
sessionId: string;
|
||||
provider: LLMProvider;
|
||||
projectPath: string;
|
||||
};
|
||||
|
||||
type ArchivedSessionListItem = {
|
||||
sessionId: string;
|
||||
provider: LLMProvider;
|
||||
@@ -89,12 +96,43 @@ export const sessionsService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches persisted history by session id.
|
||||
* Allocates a stable app-facing session id before any provider run happens.
|
||||
*
|
||||
* This is the entry point of the session gateway: the frontend calls this
|
||||
* (via `POST /api/providers/sessions`) when the user starts a brand-new
|
||||
* chat, navigates to the returned id immediately, and the id never changes
|
||||
* for the lifetime of the conversation. The provider-native id is mapped to
|
||||
* this row later, when the provider runtime announces it mid-run.
|
||||
*/
|
||||
createAppSession(provider: LLMProvider, projectPath: string): CreateAppSessionResult {
|
||||
const normalizedProjectPath = projectPath.trim();
|
||||
if (!normalizedProjectPath) {
|
||||
throw new AppError('projectPath is required.', {
|
||||
code: 'PROJECT_PATH_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const sessionId = randomUUID();
|
||||
sessionsDb.createAppSession(sessionId, provider, normalizedProjectPath);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
provider,
|
||||
projectPath: normalizedProjectPath,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches persisted history by app session id.
|
||||
*
|
||||
* Provider and provider-specific lookup hints are resolved from the indexed
|
||||
* session metadata in the database.
|
||||
* session metadata in the database. The provider adapter receives the
|
||||
* provider-native session id (the one written into transcripts on disk),
|
||||
* and every returned message is remapped back to the app session id so
|
||||
* provider ids never reach the frontend.
|
||||
*/
|
||||
fetchHistory(
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
options: Pick<FetchHistoryOptions, 'limit' | 'offset'> = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
@@ -106,12 +144,33 @@ export const sessionsService = {
|
||||
});
|
||||
}
|
||||
|
||||
// App-created sessions that never produced a provider transcript yet
|
||||
// (e.g. first message still streaming) simply have no history.
|
||||
if (!session.provider_session_id) {
|
||||
return {
|
||||
messages: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
offset: options.offset ?? 0,
|
||||
limit: options.limit ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = session.provider as LLMProvider;
|
||||
return providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
|
||||
const result = await providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
|
||||
limit: options.limit ?? null,
|
||||
offset: options.offset ?? 0,
|
||||
projectPath: session.project_path ?? '',
|
||||
providerSessionId: session.provider_session_id,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
messages: result.messages.map((message) => ({
|
||||
...message,
|
||||
sessionId,
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user