mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-17 09:30:05 +00:00
feat: add opencode support
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const extractText = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const record = readObjectRecord(value);
|
||||
return readOptionalString(record?.text)
|
||||
?? readOptionalString(record?.content)
|
||||
?? '';
|
||||
};
|
||||
|
||||
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') {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user