mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-09 05:58:27 +00:00
476 lines
14 KiB
TypeScript
476 lines
14 KiB
TypeScript
import fs from 'node:fs';
|
|
import fsp from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import readline from 'node:readline';
|
|
|
|
import type { IProviderSessions } from '@/shared/interfaces.js';
|
|
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
|
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
|
import { sessionsDb } from '@/modules/database/index.js';
|
|
|
|
const PROVIDER = 'claude';
|
|
|
|
type ClaudeToolResult = {
|
|
content: unknown;
|
|
isError: boolean;
|
|
subagentTools?: unknown;
|
|
toolUseResult?: unknown;
|
|
};
|
|
|
|
type ClaudeHistoryResult =
|
|
| AnyRecord[]
|
|
| {
|
|
messages?: AnyRecord[];
|
|
total?: number;
|
|
hasMore?: boolean;
|
|
};
|
|
|
|
type ClaudeHistoryMessagesResult =
|
|
| AnyRecord[]
|
|
| {
|
|
messages: AnyRecord[];
|
|
total: number;
|
|
hasMore: boolean;
|
|
offset?: number;
|
|
limit?: number | null;
|
|
};
|
|
|
|
async function parseAgentTools(filePath: string): Promise<AnyRecord[]> {
|
|
const tools: AnyRecord[] = [];
|
|
|
|
try {
|
|
const fileStream = fs.createReadStream(filePath);
|
|
const rl = readline.createInterface({
|
|
input: fileStream,
|
|
crlfDelay: Infinity,
|
|
});
|
|
|
|
for await (const line of rl) {
|
|
if (!line.trim()) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const entry = JSON.parse(line) as AnyRecord;
|
|
|
|
if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) {
|
|
for (const part of entry.message.content as AnyRecord[]) {
|
|
if (part.type === 'tool_use') {
|
|
tools.push({
|
|
toolId: part.id,
|
|
toolName: part.name,
|
|
toolInput: part.input,
|
|
timestamp: entry.timestamp,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) {
|
|
for (const part of entry.message.content as AnyRecord[]) {
|
|
if (part.type !== 'tool_result') {
|
|
continue;
|
|
}
|
|
|
|
const tool = tools.find((candidate) => candidate.toolId === part.tool_use_id);
|
|
if (!tool) {
|
|
continue;
|
|
}
|
|
|
|
tool.toolResult = {
|
|
content: typeof part.content === 'string'
|
|
? part.content
|
|
: Array.isArray(part.content)
|
|
? part.content
|
|
.map((contentPart: AnyRecord) => contentPart?.text || '')
|
|
.join('\n')
|
|
: JSON.stringify(part.content),
|
|
isError: Boolean(part.is_error),
|
|
};
|
|
}
|
|
}
|
|
} catch {
|
|
// Skip malformed lines that can happen during concurrent writes.
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.warn(`Error parsing agent file ${filePath}:`, message);
|
|
}
|
|
|
|
return tools;
|
|
}
|
|
|
|
async function getSessionMessages(
|
|
sessionId: string,
|
|
limit: number | null,
|
|
offset: number,
|
|
): Promise<ClaudeHistoryMessagesResult> {
|
|
try {
|
|
const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
|
|
|
if (!jsonLPath) {
|
|
return { messages: [], total: 0, hasMore: false };
|
|
}
|
|
|
|
const projectDir = path.dirname(jsonLPath);
|
|
const files = await fsp.readdir(projectDir);
|
|
const agentFiles = files.filter((file) => file.endsWith('.jsonl') && file.startsWith('agent-'));
|
|
|
|
const messages: AnyRecord[] = [];
|
|
const agentToolsCache = new Map<string, AnyRecord[]>();
|
|
|
|
const fileStream = fs.createReadStream(jsonLPath);
|
|
const rl = readline.createInterface({
|
|
input: fileStream,
|
|
crlfDelay: Infinity,
|
|
});
|
|
|
|
for await (const line of rl) {
|
|
if (!line.trim()) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const entry = JSON.parse(line) as AnyRecord;
|
|
if (entry.sessionId === sessionId) {
|
|
messages.push(entry);
|
|
}
|
|
} catch {
|
|
// Skip malformed JSONL lines that can happen during concurrent writes.
|
|
}
|
|
}
|
|
|
|
const agentIds = new Set<string>();
|
|
for (const message of messages) {
|
|
const agentId = message.toolUseResult?.agentId;
|
|
if (agentId) {
|
|
agentIds.add(String(agentId));
|
|
}
|
|
}
|
|
|
|
for (const agentId of agentIds) {
|
|
const agentFileName = `agent-${agentId}.jsonl`;
|
|
if (!agentFiles.includes(agentFileName)) {
|
|
continue;
|
|
}
|
|
|
|
const agentFilePath = path.join(projectDir, agentFileName);
|
|
const tools = await parseAgentTools(agentFilePath);
|
|
agentToolsCache.set(agentId, tools);
|
|
}
|
|
|
|
for (const message of messages) {
|
|
const agentId = message.toolUseResult?.agentId;
|
|
if (!agentId) {
|
|
continue;
|
|
}
|
|
|
|
const agentTools = agentToolsCache.get(String(agentId));
|
|
if (agentTools && agentTools.length > 0) {
|
|
message.subagentTools = agentTools;
|
|
}
|
|
}
|
|
|
|
const sortedMessages = messages.sort(
|
|
(a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(),
|
|
);
|
|
const total = sortedMessages.length;
|
|
|
|
if (limit === null) {
|
|
return sortedMessages;
|
|
}
|
|
|
|
const startIndex = Math.max(0, total - offset - limit);
|
|
const endIndex = total - offset;
|
|
const paginatedMessages = sortedMessages.slice(startIndex, endIndex);
|
|
const hasMore = startIndex > 0;
|
|
|
|
return {
|
|
messages: paginatedMessages,
|
|
total,
|
|
hasMore,
|
|
offset,
|
|
limit,
|
|
};
|
|
} catch (error) {
|
|
console.error(`Error reading messages for session ${sessionId}:`, error);
|
|
return limit === null ? [] : { messages: [], total: 0, hasMore: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Claude writes internal command and system reminder entries into history.
|
|
* Those are useful for the CLI but should not appear in the user-facing chat.
|
|
*/
|
|
const INTERNAL_CONTENT_PREFIXES = [
|
|
'<command-name>',
|
|
'<command-message>',
|
|
'<command-args>',
|
|
'<local-command-stdout>',
|
|
'<system-reminder>',
|
|
'Caveat:',
|
|
'This session is being continued from a previous',
|
|
'[Request interrupted',
|
|
] as const;
|
|
|
|
function isInternalContent(content: string): boolean {
|
|
return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix));
|
|
}
|
|
|
|
export class ClaudeSessionsProvider implements IProviderSessions {
|
|
/**
|
|
* Normalizes one Claude JSONL entry or live SDK stream event into the shared
|
|
* message shape consumed by REST and WebSocket clients.
|
|
*/
|
|
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
|
const raw = readObjectRecord(rawMessage);
|
|
if (!raw) {
|
|
return [];
|
|
}
|
|
|
|
if (raw.type === 'content_block_delta' && raw.delta?.text) {
|
|
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })];
|
|
}
|
|
if (raw.type === 'content_block_stop') {
|
|
return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })];
|
|
}
|
|
|
|
const messages: NormalizedMessage[] = [];
|
|
const ts = raw.timestamp || new Date().toISOString();
|
|
const baseId = raw.uuid || generateMessageId('claude');
|
|
|
|
if (raw.message?.role === 'user' && raw.message?.content) {
|
|
if (Array.isArray(raw.message.content)) {
|
|
for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) {
|
|
const part = raw.message.content[partIndex];
|
|
if (part.type === 'tool_result') {
|
|
messages.push(createNormalizedMessage({
|
|
id: `${baseId}_tr_${part.tool_use_id}`,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'tool_result',
|
|
toolId: part.tool_use_id,
|
|
content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content),
|
|
isError: Boolean(part.is_error),
|
|
subagentTools: raw.subagentTools,
|
|
toolUseResult: raw.toolUseResult,
|
|
}));
|
|
} else if (part.type === 'text') {
|
|
const text = part.text || '';
|
|
if (text && !isInternalContent(text)) {
|
|
messages.push(createNormalizedMessage({
|
|
id: `${baseId}_text_${partIndex}`,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'text',
|
|
role: 'user',
|
|
content: text,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (messages.length === 0) {
|
|
const textParts = raw.message.content
|
|
.filter((part: AnyRecord) => part.type === 'text')
|
|
.map((part: AnyRecord) => part.text)
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
if (textParts && !isInternalContent(textParts)) {
|
|
messages.push(createNormalizedMessage({
|
|
id: `${baseId}_text`,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'text',
|
|
role: 'user',
|
|
content: textParts,
|
|
}));
|
|
}
|
|
}
|
|
} else if (typeof raw.message.content === 'string') {
|
|
const text = raw.message.content;
|
|
if (text && !isInternalContent(text)) {
|
|
messages.push(createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'text',
|
|
role: 'user',
|
|
content: text,
|
|
}));
|
|
}
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
if (raw.type === 'thinking' && raw.message?.content) {
|
|
messages.push(createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'thinking',
|
|
content: raw.message.content,
|
|
}));
|
|
return messages;
|
|
}
|
|
|
|
if (raw.type === 'tool_use' && raw.toolName) {
|
|
messages.push(createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'tool_use',
|
|
toolName: raw.toolName,
|
|
toolInput: raw.toolInput,
|
|
toolId: raw.toolCallId || baseId,
|
|
}));
|
|
return messages;
|
|
}
|
|
|
|
if (raw.type === 'tool_result') {
|
|
messages.push(createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'tool_result',
|
|
toolId: raw.toolCallId || '',
|
|
content: raw.output || '',
|
|
isError: false,
|
|
}));
|
|
return messages;
|
|
}
|
|
|
|
if (raw.message?.role === 'assistant' && raw.message?.content) {
|
|
if (Array.isArray(raw.message.content)) {
|
|
let partIndex = 0;
|
|
for (const part of raw.message.content) {
|
|
if (part.type === 'text' && part.text) {
|
|
messages.push(createNormalizedMessage({
|
|
id: `${baseId}_${partIndex}`,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'text',
|
|
role: 'assistant',
|
|
content: part.text,
|
|
}));
|
|
} else if (part.type === 'tool_use') {
|
|
messages.push(createNormalizedMessage({
|
|
id: `${baseId}_${partIndex}`,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'tool_use',
|
|
toolName: part.name,
|
|
toolInput: part.input,
|
|
toolId: part.id,
|
|
}));
|
|
} else if (part.type === 'thinking' && part.thinking) {
|
|
messages.push(createNormalizedMessage({
|
|
id: `${baseId}_${partIndex}`,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'thinking',
|
|
content: part.thinking,
|
|
}));
|
|
}
|
|
partIndex++;
|
|
}
|
|
} else if (typeof raw.message.content === 'string') {
|
|
messages.push(createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'text',
|
|
role: 'assistant',
|
|
content: raw.message.content,
|
|
}));
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
return messages;
|
|
}
|
|
|
|
/**
|
|
* Loads Claude JSONL history for a project/session and returns normalized
|
|
* messages, preserving the existing pagination behavior from projects.js.
|
|
*/
|
|
async fetchHistory(
|
|
sessionId: string,
|
|
options: FetchHistoryOptions = {},
|
|
): Promise<FetchHistoryResult> {
|
|
const { limit = null, offset = 0 } = options;
|
|
|
|
let result: ClaudeHistoryResult;
|
|
try {
|
|
result = await getSessionMessages(sessionId, limit, offset);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
|
|
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
|
}
|
|
|
|
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
|
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
|
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
|
|
|
const toolResultMap = new Map<string, ClaudeToolResult>();
|
|
for (const raw of rawMessages) {
|
|
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
|
|
for (const part of raw.message.content) {
|
|
if (part.type === 'tool_result' && part.tool_use_id) {
|
|
toolResultMap.set(part.tool_use_id, {
|
|
content: part.content,
|
|
isError: Boolean(part.is_error),
|
|
subagentTools: raw.subagentTools,
|
|
toolUseResult: raw.toolUseResult,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const normalized: NormalizedMessage[] = [];
|
|
for (const raw of rawMessages) {
|
|
normalized.push(...this.normalizeMessage(raw, sessionId));
|
|
}
|
|
|
|
for (const msg of normalized) {
|
|
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
|
const toolResult = toolResultMap.get(msg.toolId);
|
|
if (!toolResult) {
|
|
continue;
|
|
}
|
|
|
|
msg.toolResult = {
|
|
content: typeof toolResult.content === 'string'
|
|
? toolResult.content
|
|
: JSON.stringify(toolResult.content),
|
|
isError: toolResult.isError,
|
|
toolUseResult: toolResult.toolUseResult,
|
|
};
|
|
msg.subagentTools = toolResult.subagentTools;
|
|
}
|
|
}
|
|
|
|
return {
|
|
messages: normalized,
|
|
total,
|
|
hasMore,
|
|
offset,
|
|
limit,
|
|
};
|
|
}
|
|
}
|