mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-25 18:48:13 +00:00
637 lines
20 KiB
TypeScript
637 lines
20 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 a mix of truly internal transcript rows and "UI-hidden" local
|
|
* command artifacts into the same JSONL stream.
|
|
*
|
|
* Important distinction:
|
|
* - system reminders / caveats / interruption banners should stay hidden
|
|
* - local command payloads (`<command-name>...`) and stdout wrappers
|
|
* (`<local-command-stdout>...`) should be remapped into normal chat messages
|
|
* instead of being discarded as internal content
|
|
*/
|
|
const INTERNAL_CONTENT_PREFIXES = [
|
|
'<system-reminder>',
|
|
'Caveat:',
|
|
'[Request interrupted',
|
|
] as const;
|
|
|
|
function isInternalContent(content: string): boolean {
|
|
return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix));
|
|
}
|
|
|
|
/**
|
|
* Claude wraps local slash-command metadata in lightweight XML-like tags inside
|
|
* a plain string payload. We intentionally parse only the small tag surface we
|
|
* care about instead of introducing a generic XML parser for untrusted history.
|
|
*/
|
|
function extractTaggedContent(content: string, tagName: string): string | null {
|
|
const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const match = new RegExp(`<${escapedTagName}>([\\s\\S]*?)<\\/${escapedTagName}>`).exec(content);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
type ClaudeLocalCommandPayload = {
|
|
commandName: string;
|
|
commandMessage: string;
|
|
commandArgs: string;
|
|
};
|
|
|
|
/**
|
|
* Converts Claude's hidden local command wrapper into structured metadata.
|
|
*
|
|
* The three tags often coexist in one string payload. Returning `null` lets the
|
|
* normal text path continue untouched for unrelated messages.
|
|
*/
|
|
function parseLocalCommandPayload(content: string): ClaudeLocalCommandPayload | null {
|
|
const commandName = extractTaggedContent(content, 'command-name');
|
|
const commandMessage = extractTaggedContent(content, 'command-message');
|
|
const commandArgs = extractTaggedContent(content, 'command-args');
|
|
|
|
if (commandName === null && commandMessage === null && commandArgs === null) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
commandName: commandName ?? '',
|
|
commandMessage: commandMessage ?? '',
|
|
commandArgs: commandArgs ?? '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Produces the short user-visible command string that should appear in chat.
|
|
*
|
|
* We prefer the slash-prefixed command name because that most closely matches
|
|
* what the user actually typed, and only fall back to the message body when the
|
|
* command name is unavailable in older transcript variants.
|
|
*/
|
|
function buildLocalCommandDisplayText(payload: ClaudeLocalCommandPayload): string {
|
|
const commandName = payload.commandName.trim();
|
|
const commandMessage = payload.commandMessage.trim();
|
|
const commandArgs = payload.commandArgs.trim();
|
|
const baseCommand = commandName || commandMessage;
|
|
|
|
if (!baseCommand) {
|
|
return '';
|
|
}
|
|
|
|
return commandArgs ? `${baseCommand} ${commandArgs}` : baseCommand;
|
|
}
|
|
|
|
/**
|
|
* Claude local-command stdout may contain ANSI styling codes because it was
|
|
* captured from the terminal. The web chat should receive readable plain text.
|
|
*/
|
|
function stripAnsiFormatting(text: string): string {
|
|
return text.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, '');
|
|
}
|
|
|
|
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 && raw.isMeta !== true) {
|
|
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;
|
|
|
|
/**
|
|
* Claude stores compact summaries as synthetic "user" rows so the CLI
|
|
* can resume the next session turn with the summary in-context.
|
|
*
|
|
* For the web UI this is much more useful as assistant-authored summary
|
|
* text; otherwise it is both filtered by the generic internal-prefix
|
|
* check and visually mislabeled as a user message.
|
|
*/
|
|
if (raw.isCompactSummary === true && text.trim()) {
|
|
messages.push(createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'text',
|
|
role: 'assistant',
|
|
content: text,
|
|
isCompactSummary: true,
|
|
}));
|
|
return messages;
|
|
}
|
|
|
|
/**
|
|
* Local slash commands are serialized as tagged text even though they
|
|
* are semantically a user action. Expose the parsed fields to the
|
|
* frontend and emit a plain user-visible command string so the command
|
|
* no longer disappears from history.
|
|
*/
|
|
const localCommandPayload = parseLocalCommandPayload(text);
|
|
if (localCommandPayload) {
|
|
const displayText = buildLocalCommandDisplayText(localCommandPayload);
|
|
if (displayText) {
|
|
messages.push(createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'text',
|
|
role: 'user',
|
|
content: displayText,
|
|
commandName: localCommandPayload.commandName,
|
|
commandMessage: localCommandPayload.commandMessage,
|
|
commandArgs: localCommandPayload.commandArgs,
|
|
isLocalCommand: true,
|
|
}));
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
/**
|
|
* Local command stdout is also written as a "user" row in Claude's
|
|
* transcript, but it is terminal output produced in response to the
|
|
* command. Re-label it as assistant text so the chat transcript matches
|
|
* the actual conversational flow seen by the user.
|
|
*/
|
|
const localCommandStdout = extractTaggedContent(text, 'local-command-stdout');
|
|
if (localCommandStdout !== null) {
|
|
const stdoutText = stripAnsiFormatting(localCommandStdout).trim();
|
|
if (stdoutText) {
|
|
messages.push(createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'text',
|
|
role: 'assistant',
|
|
content: stdoutText,
|
|
isLocalCommandStdout: true,
|
|
}));
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
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 {
|
|
// Load full history first so `total` reflects frontend-normalized messages,
|
|
// not raw JSONL records.
|
|
result = await getSessionMessages(sessionId, null, 0);
|
|
} 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 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;
|
|
}
|
|
}
|
|
|
|
const totalNormalized = normalized.length;
|
|
let total = 0;
|
|
for (const msg of normalized) {
|
|
if (msg.kind !== 'tool_result') {
|
|
total += 1;
|
|
}
|
|
}
|
|
const normalizedOffset = Math.max(0, offset);
|
|
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
|
const messages = normalizedLimit === null
|
|
? normalized
|
|
: normalized.slice(
|
|
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
|
|
Math.max(0, totalNormalized - normalizedOffset),
|
|
);
|
|
const hasMore = normalizedLimit === null
|
|
? false
|
|
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
|
|
|
|
return {
|
|
messages,
|
|
total,
|
|
hasMore,
|
|
offset: normalizedOffset,
|
|
limit: normalizedLimit,
|
|
};
|
|
}
|
|
}
|