mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-16 17:16:19 +00:00
464 lines
13 KiB
TypeScript
464 lines
13 KiB
TypeScript
import fsSync from 'node:fs';
|
|
|
|
import Database from 'better-sqlite3';
|
|
|
|
import type { IProviderSessions } from '@/shared/interfaces.js';
|
|
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
|
import {
|
|
createNormalizedMessage,
|
|
generateMessageId,
|
|
getOpenCodeDatabasePath,
|
|
normalizeProviderTimestamp,
|
|
readObjectRecord,
|
|
readJsonRecord,
|
|
readOptionalString,
|
|
} from '@/shared/utils.js';
|
|
|
|
const PROVIDER = 'opencode';
|
|
|
|
type OpenCodeHistoryRow = {
|
|
message_id: string;
|
|
message_time_created: number | null;
|
|
message_data: string | null;
|
|
part_id: string | null;
|
|
part_time_created: number | null;
|
|
part_data: string | null;
|
|
};
|
|
|
|
type OpenCodeTokenTotals = {
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
cacheReadTokens: number;
|
|
cacheCreationTokens: number;
|
|
reasoningTokens: number;
|
|
};
|
|
|
|
const openOpenCodeDatabase = (): Database.Database | null => {
|
|
const dbPath = getOpenCodeDatabasePath();
|
|
if (!fsSync.existsSync(dbPath)) {
|
|
return null;
|
|
}
|
|
|
|
return new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
};
|
|
|
|
const formatToolContent = (value: unknown): string => {
|
|
if (value === undefined || value === null) {
|
|
return '';
|
|
}
|
|
|
|
if (typeof value === 'string') {
|
|
return value;
|
|
}
|
|
|
|
try {
|
|
return JSON.stringify(value, null, 2);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* OpenCode can persist the first prompt as a JSON string literal inside a text
|
|
* part, for example `"hello"` instead of `hello`. Decode only complete JSON
|
|
* string literals so normal assistant/user prose remains untouched.
|
|
*/
|
|
const unwrapJsonStringLiteral = (value: string): string => {
|
|
const trimmed = value.trim();
|
|
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) {
|
|
return value;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
return typeof parsed === 'string' ? parsed : value;
|
|
} catch {
|
|
return value;
|
|
}
|
|
};
|
|
|
|
const extractText = (value: unknown): string => {
|
|
if (typeof value === 'string') {
|
|
return unwrapJsonStringLiteral(value);
|
|
}
|
|
|
|
const record = readObjectRecord(value);
|
|
const text = readOptionalString(record?.text)
|
|
?? readOptionalString(record?.content)
|
|
?? '';
|
|
return unwrapJsonStringLiteral(text);
|
|
};
|
|
|
|
const hasUserRole = (value: unknown): boolean => {
|
|
const record = readObjectRecord(value);
|
|
return readOptionalString(record?.role) === 'user';
|
|
};
|
|
|
|
const isUserTextEcho = (raw: AnyRecord): boolean => {
|
|
return readOptionalString(raw.role) === 'user'
|
|
|| hasUserRole(raw.message)
|
|
|| hasUserRole(raw.part);
|
|
};
|
|
|
|
const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | undefined => {
|
|
if (!totals) {
|
|
return undefined;
|
|
}
|
|
|
|
const inputTokens = totals.inputTokens;
|
|
const outputTokens = totals.outputTokens;
|
|
const cacheReadTokens = totals.cacheReadTokens;
|
|
const cacheCreationTokens = totals.cacheCreationTokens;
|
|
const reasoningTokens = totals.reasoningTokens;
|
|
const used = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens + reasoningTokens;
|
|
|
|
if (used <= 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
used,
|
|
total: used,
|
|
inputTokens,
|
|
outputTokens,
|
|
cacheReadTokens,
|
|
cacheCreationTokens,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* OpenCode stores per-message token counts on assistant `message.data` objects
|
|
* (see MessageV2.Assistant). Older DBs also had session-level counters; this
|
|
* matches current `opencode.db` layouts that only persist message JSON.
|
|
*/
|
|
const aggregateOpenCodeSessionTokenUsage = (
|
|
db: Database.Database,
|
|
sessionId: string,
|
|
): AnyRecord | undefined => {
|
|
const rows = db.prepare('SELECT data FROM message WHERE session_id = ?').all(sessionId) as { data: string }[];
|
|
|
|
let inputTokens = 0;
|
|
let outputTokens = 0;
|
|
let cacheReadTokens = 0;
|
|
let cacheCreationTokens = 0;
|
|
let reasoningTokens = 0;
|
|
|
|
for (const row of rows) {
|
|
const info = readJsonRecord(row.data);
|
|
if (readOptionalString(info?.role) !== 'assistant') {
|
|
continue;
|
|
}
|
|
|
|
const tokens = readObjectRecord(info?.tokens);
|
|
if (!tokens) {
|
|
continue;
|
|
}
|
|
|
|
inputTokens += Number(tokens.input ?? 0);
|
|
outputTokens += Number(tokens.output ?? 0);
|
|
reasoningTokens += Number(tokens.reasoning ?? 0);
|
|
const cache = readObjectRecord(tokens.cache);
|
|
cacheReadTokens += Number(cache?.read ?? 0);
|
|
cacheCreationTokens += Number(cache?.write ?? 0);
|
|
}
|
|
|
|
return buildTokenUsage({
|
|
inputTokens,
|
|
outputTokens,
|
|
cacheReadTokens,
|
|
cacheCreationTokens,
|
|
reasoningTokens,
|
|
});
|
|
};
|
|
|
|
export class OpenCodeSessionsProvider implements IProviderSessions {
|
|
/**
|
|
* Normalizes live `opencode run --format json` events into frontend messages.
|
|
*/
|
|
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
|
const raw = readObjectRecord(rawMessage);
|
|
if (!raw) {
|
|
return [];
|
|
}
|
|
|
|
const type = readOptionalString(raw.type) ?? readOptionalString(raw.event);
|
|
const eventSessionId = readOptionalString(raw.sessionID) ?? readOptionalString(raw.sessionId) ?? sessionId;
|
|
const timestamp = normalizeProviderTimestamp(raw.time ?? raw.timestamp);
|
|
const baseId = readOptionalString(raw.id)
|
|
?? readOptionalString(raw.messageID)
|
|
?? generateMessageId('opencode');
|
|
|
|
if (type === 'text') {
|
|
// The client already renders an optimistic user bubble, so provider user
|
|
// echoes must not be streamed back as assistant text.
|
|
if (isUserTextEcho(raw)) {
|
|
return [];
|
|
}
|
|
|
|
const content = extractText(raw.text ?? raw.delta ?? raw.message);
|
|
if (!content.trim()) {
|
|
return [];
|
|
}
|
|
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId: eventSessionId,
|
|
timestamp,
|
|
provider: PROVIDER,
|
|
kind: 'stream_delta',
|
|
content,
|
|
})];
|
|
}
|
|
|
|
if (type === 'reasoning') {
|
|
const content = extractText(raw.text ?? raw.delta ?? raw.message);
|
|
if (!content.trim()) {
|
|
return [];
|
|
}
|
|
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId: eventSessionId,
|
|
timestamp,
|
|
provider: PROVIDER,
|
|
kind: 'thinking',
|
|
content,
|
|
})];
|
|
}
|
|
|
|
if (type === 'tool_use') {
|
|
const toolName = readOptionalString(raw.tool) ?? readOptionalString(raw.name) ?? 'Tool';
|
|
const toolId = readOptionalString(raw.callID) ?? readOptionalString(raw.toolCallId) ?? baseId;
|
|
const toolMessage = createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId: eventSessionId,
|
|
timestamp,
|
|
provider: PROVIDER,
|
|
kind: 'tool_use',
|
|
toolName,
|
|
toolInput: raw.input ?? raw.arguments ?? {},
|
|
toolId,
|
|
});
|
|
|
|
if (raw.output !== undefined || raw.error !== undefined) {
|
|
toolMessage.toolResult = {
|
|
content: formatToolContent(raw.output ?? raw.error),
|
|
isError: raw.error !== undefined,
|
|
};
|
|
}
|
|
|
|
return [toolMessage];
|
|
}
|
|
|
|
if (type === 'error') {
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId: eventSessionId,
|
|
timestamp,
|
|
provider: PROVIDER,
|
|
kind: 'error',
|
|
content: readOptionalString(raw.error) ?? readOptionalString(raw.message) ?? 'Unknown OpenCode error',
|
|
})];
|
|
}
|
|
|
|
if (type === 'step_finish') {
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId: eventSessionId,
|
|
timestamp,
|
|
provider: PROVIDER,
|
|
kind: 'stream_end',
|
|
})];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Loads OpenCode history from the shared SQLite session database.
|
|
*/
|
|
async fetchHistory(
|
|
sessionId: string,
|
|
options: FetchHistoryOptions = {},
|
|
): Promise<FetchHistoryResult> {
|
|
const { limit = null, offset = 0 } = options;
|
|
const db = openOpenCodeDatabase();
|
|
if (!db) {
|
|
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
|
}
|
|
|
|
try {
|
|
const rows = db.prepare(`
|
|
SELECT
|
|
m.id AS message_id,
|
|
m.time_created AS message_time_created,
|
|
m.data AS message_data,
|
|
p.id AS part_id,
|
|
p.time_created AS part_time_created,
|
|
p.data AS part_data
|
|
FROM message m
|
|
LEFT JOIN part p
|
|
ON p.session_id = m.session_id
|
|
AND p.message_id = m.id
|
|
WHERE m.session_id = ?
|
|
ORDER BY
|
|
COALESCE(m.time_created, 0),
|
|
m.id,
|
|
COALESCE(p.time_created, 0),
|
|
p.id
|
|
`).all(sessionId) as OpenCodeHistoryRow[];
|
|
|
|
const normalized = this.normalizeHistoryRows(rows, sessionId);
|
|
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, sessionId);
|
|
|
|
const normalizedOffset = Math.max(0, offset);
|
|
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
|
const total = normalized.length;
|
|
const messages = normalizedLimit === null
|
|
? normalized
|
|
: normalized.slice(
|
|
Math.max(0, total - normalizedOffset - normalizedLimit),
|
|
Math.max(0, total - normalizedOffset),
|
|
);
|
|
|
|
return {
|
|
messages,
|
|
total,
|
|
hasMore: normalizedLimit === null
|
|
? false
|
|
: Math.max(0, total - normalizedOffset - normalizedLimit) > 0,
|
|
offset: normalizedOffset,
|
|
limit: normalizedLimit,
|
|
tokenUsage,
|
|
};
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.warn(`[OpenCodeProvider] Failed to load session ${sessionId}:`, message);
|
|
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
private normalizeHistoryRows(rows: OpenCodeHistoryRow[], sessionId: string): NormalizedMessage[] {
|
|
const normalized: NormalizedMessage[] = [];
|
|
const emittedMessageErrors = new Set<string>();
|
|
|
|
for (const row of rows) {
|
|
const timestamp = normalizeProviderTimestamp(row.part_time_created ?? row.message_time_created);
|
|
const baseId = `${row.message_id}_${row.part_id ?? normalized.length}`;
|
|
const messageInfo = readJsonRecord(row.message_data);
|
|
const messageRole = readOptionalString(messageInfo?.role);
|
|
|
|
if (
|
|
messageInfo
|
|
&& messageRole === 'assistant'
|
|
&& messageInfo.error != null
|
|
&& !emittedMessageErrors.has(row.message_id)
|
|
) {
|
|
emittedMessageErrors.add(row.message_id);
|
|
normalized.push(createNormalizedMessage({
|
|
id: `${baseId}_error`,
|
|
sessionId,
|
|
timestamp,
|
|
provider: PROVIDER,
|
|
kind: 'error',
|
|
content: formatToolContent(messageInfo.error),
|
|
}));
|
|
}
|
|
|
|
if (!row.part_id) {
|
|
continue;
|
|
}
|
|
|
|
const partData = readJsonRecord(row.part_data) ?? {};
|
|
const partType = readOptionalString(partData.type);
|
|
if (!partType) {
|
|
continue;
|
|
}
|
|
|
|
if (partType === 'text') {
|
|
const content = extractText(partData);
|
|
if (content.trim()) {
|
|
normalized.push(createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp,
|
|
provider: PROVIDER,
|
|
kind: 'text',
|
|
role: messageRole === 'user' ? 'user' : 'assistant',
|
|
content,
|
|
}));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (partType === 'reasoning') {
|
|
const content = extractText(partData);
|
|
if (content.trim()) {
|
|
normalized.push(createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp,
|
|
provider: PROVIDER,
|
|
kind: 'thinking',
|
|
content,
|
|
}));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (partType === 'tool') {
|
|
const state = readObjectRecord(partData.state) ?? {};
|
|
const status = readOptionalString(state.status);
|
|
const toolMessage = createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp,
|
|
provider: PROVIDER,
|
|
kind: 'tool_use',
|
|
toolName: readOptionalString(partData.tool) ?? 'Tool',
|
|
toolInput: state.input ?? partData.input ?? {},
|
|
toolId: readOptionalString(partData.callID) ?? row.part_id,
|
|
});
|
|
|
|
if (status === 'completed' || status === 'error') {
|
|
toolMessage.toolResult = {
|
|
content: formatToolContent(state.output ?? state.error),
|
|
isError: status === 'error',
|
|
};
|
|
}
|
|
|
|
normalized.push(toolMessage);
|
|
continue;
|
|
}
|
|
|
|
if (partType === 'step-finish') {
|
|
normalized.push(createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp,
|
|
provider: PROVIDER,
|
|
kind: 'stream_end',
|
|
}));
|
|
continue;
|
|
}
|
|
|
|
if (partType === 'patch' || partType === 'agent') {
|
|
normalized.push(createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp,
|
|
provider: PROVIDER,
|
|
kind: 'tool_use',
|
|
toolName: partType === 'patch' ? 'Patch' : 'Agent',
|
|
toolInput: partData,
|
|
toolId: row.part_id,
|
|
}));
|
|
}
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
}
|