Makelore 2.0 initial clean snapshot
This commit is contained in:
844
src/lib/opencode-message.ts
Normal file
844
src/lib/opencode-message.ts
Normal file
@@ -0,0 +1,844 @@
|
||||
import type {
|
||||
AttachedFileMeta,
|
||||
ContentBlock,
|
||||
RawMessage,
|
||||
RuntimeToolEvidence,
|
||||
ToolStatus,
|
||||
} from '@/types/chat';
|
||||
|
||||
const EMPTY_ASSISTANT_RESPONSE_MESSAGE = '模型没有返回任何内容。请重试,或切换到支持图片输入的模型。';
|
||||
|
||||
function toMs(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value < 1e12 ? value * 1000 : value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric)) {
|
||||
return numeric < 1e12 ? numeric * 1000 : numeric;
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
return date.getTime();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getRecord(input: unknown): Record<string, unknown> | null {
|
||||
return input && typeof input === 'object' && !Array.isArray(input)
|
||||
? input as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function getErrorMessage(input: unknown): string | undefined {
|
||||
if (typeof input === 'string') {
|
||||
const trimmed = input.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
const record = getRecord(input);
|
||||
if (!record) return undefined;
|
||||
|
||||
const data = getRecord(record.data);
|
||||
const nestedError = getRecord(record.error);
|
||||
const metadata = getRecord(record.metadata);
|
||||
const candidates = [
|
||||
data?.message,
|
||||
data?.error,
|
||||
nestedError?.message,
|
||||
record.message,
|
||||
record.errorMessage,
|
||||
record.error_message,
|
||||
record.detail,
|
||||
metadata?.message,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const message = getErrorMessage(candidate);
|
||||
if (message) return message;
|
||||
}
|
||||
|
||||
if (typeof record.data === 'string') {
|
||||
const rawData = record.data.trim();
|
||||
if (!rawData) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(rawData) as unknown;
|
||||
return getErrorMessage(parsed) ?? rawData;
|
||||
} catch {
|
||||
return rawData;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeRole(input: unknown): RawMessage['role'] {
|
||||
switch (input) {
|
||||
case 'user':
|
||||
case 'assistant':
|
||||
case 'system':
|
||||
case 'toolresult':
|
||||
return input;
|
||||
default:
|
||||
return 'assistant';
|
||||
}
|
||||
}
|
||||
|
||||
function basenameFromPath(path: string): string {
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
return parts.at(-1) ?? path;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(record: Record<string, unknown>): number | undefined {
|
||||
const time = getRecord(record.time);
|
||||
return toMs(time?.created)
|
||||
?? toMs(record.createdAt)
|
||||
?? toMs(record.updatedAt)
|
||||
?? toMs(record.timestamp);
|
||||
}
|
||||
|
||||
function hasValue(value: unknown): boolean {
|
||||
if (value === true) return true;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return true;
|
||||
if (typeof value === 'string' && value.trim()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasCompletedAssistantSignal(
|
||||
info: Record<string, unknown>,
|
||||
input: Record<string, unknown>,
|
||||
parts: unknown[],
|
||||
): boolean {
|
||||
const time = getRecord(info.time);
|
||||
if (
|
||||
hasValue(time?.completed)
|
||||
|| hasValue(info.completed)
|
||||
|| hasValue(info.completedAt)
|
||||
|| hasValue(input.completed)
|
||||
|| hasValue(input.completedAt)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return parts.some((part) => getRecord(part)?.type === 'step-finish');
|
||||
}
|
||||
|
||||
function normalizeToolStatus(input: unknown): ToolStatus['status'] {
|
||||
switch (input) {
|
||||
case 'completed':
|
||||
return 'completed';
|
||||
case 'error':
|
||||
case 'errored':
|
||||
return 'error';
|
||||
default:
|
||||
return 'running';
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeToolEvidenceText(value: unknown, limit = 600): string | undefined {
|
||||
const redact = (text: string) => text
|
||||
.replace(/(authorization|bearer|token|secret|password|api[_-]?key)(\s*[:=]\s*|\s+)[^\s,;]+/gi, '$1=[REDACTED]')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const visit = (candidate: unknown, depth = 0): string => {
|
||||
if (depth > 3 || candidate === null || candidate === undefined) return '';
|
||||
if (typeof candidate === 'string') return candidate;
|
||||
if (typeof candidate === 'number' || typeof candidate === 'boolean') return String(candidate);
|
||||
if (Array.isArray(candidate)) {
|
||||
return candidate.map((item) => visit(item, depth + 1)).filter(Boolean).join(' ');
|
||||
}
|
||||
const record = getRecord(candidate);
|
||||
if (!record) return '';
|
||||
return Object.entries(record)
|
||||
.filter(([key]) => !/(authorization|bearer|token|secret|password|api[_-]?key|cookie)/i.test(key))
|
||||
.map(([key, item]) => `${key}: ${visit(item, depth + 1)}`)
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
};
|
||||
const text = redact(visit(value));
|
||||
return text ? text.slice(0, limit) : undefined;
|
||||
}
|
||||
|
||||
function getToolExitCode(state: Record<string, unknown> | null): number | undefined {
|
||||
const metadata = getRecord(state?.metadata);
|
||||
const candidates = [
|
||||
state?.exitCode,
|
||||
state?.exit_code,
|
||||
state?.exit,
|
||||
metadata?.exitCode,
|
||||
metadata?.exit_code,
|
||||
metadata?.exit,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const numeric = typeof candidate === 'number'
|
||||
? candidate
|
||||
: typeof candidate === 'string' && candidate.trim()
|
||||
? Number(candidate)
|
||||
: Number.NaN;
|
||||
if (Number.isInteger(numeric)) return numeric;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeToolEvidence(part: Record<string, unknown>): RuntimeToolEvidence | null {
|
||||
if (part.type !== 'tool' || typeof part.tool !== 'string' || !part.tool.trim()) return null;
|
||||
const state = getRecord(part.state);
|
||||
const time = getRecord(state?.time);
|
||||
const startedAt = toMs(time?.start);
|
||||
const endedAt = toMs(time?.end);
|
||||
return {
|
||||
id: typeof part.id === 'string' ? part.id : undefined,
|
||||
toolCallId: typeof part.callID === 'string' ? part.callID : undefined,
|
||||
name: part.tool,
|
||||
status: normalizeToolStatus(state?.status),
|
||||
inputSummary: sanitizeToolEvidenceText(state?.input),
|
||||
outputExcerpt: sanitizeToolEvidenceText(state?.output ?? state?.error),
|
||||
exitCode: getToolExitCode(state),
|
||||
startedAt,
|
||||
endedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeToolEvidence(
|
||||
current: RuntimeToolEvidence[] | undefined,
|
||||
next: RuntimeToolEvidence | null,
|
||||
): RuntimeToolEvidence[] | undefined {
|
||||
if (!next) return current;
|
||||
const combined = [...(current ?? [])];
|
||||
const key = next.toolCallId ?? next.id ?? `${next.name}:${next.startedAt ?? ''}`;
|
||||
const index = combined.findIndex((item) => (
|
||||
(item.toolCallId ?? item.id ?? `${item.name}:${item.startedAt ?? ''}`) === key
|
||||
));
|
||||
if (index >= 0) {
|
||||
combined[index] = { ...combined[index], ...next };
|
||||
} else {
|
||||
combined.push(next);
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
function normalizeLegacyBlocks(content: unknown): ContentBlock[] {
|
||||
if (typeof content === 'string') {
|
||||
return [{ type: 'text', text: content }];
|
||||
}
|
||||
if (!Array.isArray(content)) return [];
|
||||
|
||||
const blocks: ContentBlock[] = [];
|
||||
for (const block of content) {
|
||||
const record = getRecord(block);
|
||||
if (!record) continue;
|
||||
|
||||
const type = record.type;
|
||||
if (type === 'text' && typeof record.text === 'string') {
|
||||
blocks.push({ type: 'text', text: record.text, id: typeof record.id === 'string' ? record.id : undefined });
|
||||
continue;
|
||||
}
|
||||
if (type === 'thinking' && typeof record.thinking === 'string') {
|
||||
blocks.push({ type: 'thinking', thinking: record.thinking, id: typeof record.id === 'string' ? record.id : undefined });
|
||||
continue;
|
||||
}
|
||||
if ((type === 'tool_use' || type === 'toolCall') && typeof record.name === 'string') {
|
||||
blocks.push({
|
||||
type: 'tool_use',
|
||||
id: typeof record.id === 'string' ? record.id : undefined,
|
||||
name: record.name,
|
||||
input: record.input ?? record.arguments ?? {},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if ((type === 'image' || type === 'file') && typeof record.url === 'string' && typeof record.mimeType === 'string') {
|
||||
blocks.push({
|
||||
type: 'image',
|
||||
url: record.url,
|
||||
mimeType: record.mimeType,
|
||||
alt: typeof record.alt === 'string' ? record.alt : undefined,
|
||||
id: typeof record.id === 'string' ? record.id : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function normalizeLegacyMessage(input: Record<string, unknown>): RawMessage {
|
||||
const contentBlocks = normalizeLegacyBlocks(input.content);
|
||||
if (contentBlocks.length > 0) {
|
||||
return {
|
||||
id: typeof input.id === 'string' ? input.id : undefined,
|
||||
role: normalizeRole(input.role),
|
||||
timestamp: normalizeTimestamp(input),
|
||||
content: contentBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof input.content === 'string') {
|
||||
return {
|
||||
id: typeof input.id === 'string' ? input.id : undefined,
|
||||
role: normalizeRole(input.role),
|
||||
timestamp: normalizeTimestamp(input),
|
||||
content: [{ type: 'text', text: input.content }],
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackText = typeof input.text === 'string'
|
||||
? input.text
|
||||
: typeof input.message === 'string'
|
||||
? input.message
|
||||
: JSON.stringify(input);
|
||||
return {
|
||||
id: typeof input.id === 'string' ? input.id : undefined,
|
||||
role: normalizeRole(input.role),
|
||||
timestamp: normalizeTimestamp(input),
|
||||
content: [{ type: 'text', text: fallbackText }],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFilePart(part: Record<string, unknown>): { blocks: ContentBlock[]; attachments: AttachedFileMeta[] } {
|
||||
const url = typeof part.url === 'string' ? part.url : undefined;
|
||||
const mimeType = typeof part.mime === 'string' && part.mime.trim() ? part.mime : 'application/octet-stream';
|
||||
const source = getRecord(part.source);
|
||||
const filePath = source?.type === 'file' && typeof source.path === 'string' ? source.path : undefined;
|
||||
const fileName = typeof part.filename === 'string' && part.filename.trim()
|
||||
? part.filename
|
||||
: filePath
|
||||
? basenameFromPath(filePath)
|
||||
: url
|
||||
? basenameFromPath(url)
|
||||
: 'file';
|
||||
const attachments: AttachedFileMeta[] = [{
|
||||
fileName,
|
||||
mimeType,
|
||||
fileSize: 0,
|
||||
preview: null,
|
||||
filePath,
|
||||
source: 'message-ref',
|
||||
}];
|
||||
|
||||
if (mimeType.trim().toLowerCase().startsWith('image/') && url) {
|
||||
return {
|
||||
blocks: [{
|
||||
type: 'image',
|
||||
id: typeof part.id === 'string' ? part.id : undefined,
|
||||
url,
|
||||
mimeType,
|
||||
alt: fileName,
|
||||
}],
|
||||
attachments,
|
||||
};
|
||||
}
|
||||
|
||||
return { blocks: [], attachments };
|
||||
}
|
||||
|
||||
function normalizePart(part: unknown): { blocks: ContentBlock[]; attachments: AttachedFileMeta[] } {
|
||||
const record = getRecord(part);
|
||||
if (!record || typeof record.type !== 'string') {
|
||||
return { blocks: [], attachments: [] };
|
||||
}
|
||||
|
||||
if (record.type === 'text' && typeof record.text === 'string') {
|
||||
return {
|
||||
blocks: [{
|
||||
type: 'text',
|
||||
id: typeof record.id === 'string' ? record.id : undefined,
|
||||
text: record.text,
|
||||
...(record.synthetic === true ? { synthetic: true as const } : {}),
|
||||
}],
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (record.type === 'reasoning' && typeof record.text === 'string') {
|
||||
return {
|
||||
blocks: [{
|
||||
type: 'thinking',
|
||||
id: typeof record.id === 'string' ? record.id : undefined,
|
||||
thinking: record.text,
|
||||
}],
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (record.type === 'tool' && typeof record.tool === 'string') {
|
||||
const state = getRecord(record.state);
|
||||
return {
|
||||
blocks: [{
|
||||
type: 'tool_use',
|
||||
id: typeof record.callID === 'string' ? record.callID : typeof record.id === 'string' ? record.id : undefined,
|
||||
name: record.tool,
|
||||
input: state?.input ?? {},
|
||||
}],
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (record.type === 'file') {
|
||||
return normalizeFilePart(record);
|
||||
}
|
||||
|
||||
return { blocks: [], attachments: [] };
|
||||
}
|
||||
|
||||
const COMMAND_HISTORY_PART_ID = /^prt[A-Za-z0-9_-]+$/u;
|
||||
const COMMAND_HISTORY_NAME = /^[^\s/][^\s]*$/u;
|
||||
const COMMAND_HISTORY_CONTROL = /\p{Cc}/u;
|
||||
|
||||
interface CommandHistorySelection {
|
||||
invocationBlock?: ContentBlock;
|
||||
parts: unknown[];
|
||||
}
|
||||
|
||||
function isSafeCommandHistoryContextPart(input: unknown): boolean {
|
||||
const record = getRecord(input);
|
||||
return record?.type === 'text' || record?.type === 'file';
|
||||
}
|
||||
|
||||
function selectCommandHistoryParts(parts: unknown[]): CommandHistorySelection {
|
||||
const markers = parts
|
||||
.map(getRecord)
|
||||
.filter((part) => part?.type === 'command-invocation');
|
||||
if (markers.length === 0) return { parts };
|
||||
|
||||
const marker = markers.length === 1 ? markers[0] : null;
|
||||
const command = typeof marker?.command === 'string' ? marker.command : '';
|
||||
const args = typeof marker?.arguments === 'string' ? marker.arguments : '';
|
||||
const ids = Array.isArray(marker?.contextPartIDs) ? marker.contextPartIDs : null;
|
||||
const valid = Boolean(
|
||||
marker
|
||||
&& typeof marker.id === 'string'
|
||||
&& COMMAND_HISTORY_PART_ID.test(marker.id)
|
||||
&& command.length > 0
|
||||
&& command.length <= 256
|
||||
&& COMMAND_HISTORY_NAME.test(command)
|
||||
&& !COMMAND_HISTORY_CONTROL.test(command)
|
||||
&& typeof marker.arguments === 'string'
|
||||
&& args.length <= 8_192
|
||||
&& ids
|
||||
&& ids.length <= 64
|
||||
&& ids.every((id) => typeof id === 'string' && COMMAND_HISTORY_PART_ID.test(id)),
|
||||
);
|
||||
if (!valid) {
|
||||
return {
|
||||
invocationBlock: { type: 'text', text: '[Project command metadata unavailable]' },
|
||||
parts: [],
|
||||
};
|
||||
}
|
||||
|
||||
const allowed = new Set(ids as string[]);
|
||||
return {
|
||||
invocationBlock: {
|
||||
id: marker!.id as string,
|
||||
type: 'text',
|
||||
text: `/${command}${args ? ` ${args}` : ''}`,
|
||||
},
|
||||
parts: parts.filter((part) => {
|
||||
const record = getRecord(part);
|
||||
return typeof record?.id === 'string'
|
||||
&& allowed.has(record.id)
|
||||
&& isSafeCommandHistoryContextPart(record);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const RUNTIME_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
||||
|
||||
function boundedRuntimeIdentifier(value: unknown, maxLength: number): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const candidate = value.trim();
|
||||
return candidate.length > 0
|
||||
&& candidate.length <= maxLength
|
||||
&& RUNTIME_IDENTIFIER_PATTERN.test(candidate)
|
||||
? candidate
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getRuntimeMessageContext(
|
||||
role: RawMessage['role'],
|
||||
info: Record<string, unknown>,
|
||||
): RawMessage['_runtimeContext'] {
|
||||
if (role !== 'user') return undefined;
|
||||
const model = getRecord(info.model);
|
||||
const agent = boundedRuntimeIdentifier(info.agent, 64);
|
||||
const providerID = boundedRuntimeIdentifier(model?.providerID, 128);
|
||||
const modelID = boundedRuntimeIdentifier(model?.modelID, 128);
|
||||
const variant = boundedRuntimeIdentifier(model?.variant, 64);
|
||||
const context = {
|
||||
...(agent ? { agent } : {}),
|
||||
...(providerID && modelID ? { model: `${providerID}/${modelID}` } : {}),
|
||||
...(variant ? { variant } : {}),
|
||||
};
|
||||
return Object.keys(context).length > 0 ? context : undefined;
|
||||
}
|
||||
|
||||
function mergeAttachments(
|
||||
current: AttachedFileMeta[] | undefined,
|
||||
next: AttachedFileMeta[],
|
||||
): AttachedFileMeta[] | undefined {
|
||||
if (next.length === 0) return current;
|
||||
const combined = [...(current ?? [])];
|
||||
const seen = new Set(combined.map((file) => `${file.filePath ?? file.fileName}:${file.mimeType}`));
|
||||
for (const file of next) {
|
||||
const key = `${file.filePath ?? file.fileName}:${file.mimeType}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
combined.push(file);
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
function appendAttachments(
|
||||
current: AttachedFileMeta[] | undefined,
|
||||
next: AttachedFileMeta[],
|
||||
): AttachedFileMeta[] | undefined {
|
||||
if (next.length === 0) return current;
|
||||
return [...(current ?? []), ...next];
|
||||
}
|
||||
|
||||
function isCommandInvocationMarker(input: unknown): boolean {
|
||||
return getRecord(input)?.type === 'command-invocation';
|
||||
}
|
||||
|
||||
function hasDirectCommandInvocationMarker(input: unknown): boolean {
|
||||
if (isCommandInvocationMarker(input)) return true;
|
||||
if (Array.isArray(input)) return input.some(isCommandInvocationMarker);
|
||||
const record = getRecord(input);
|
||||
return record
|
||||
? Object.values(record).some(isCommandInvocationMarker)
|
||||
: false;
|
||||
}
|
||||
|
||||
function hasLegacyCommandInvocationMarker(input: Record<string, unknown>): boolean {
|
||||
if (isCommandInvocationMarker(input)) return true;
|
||||
if (Array.isArray(input.content)) {
|
||||
return input.content.some(isCommandInvocationMarker);
|
||||
}
|
||||
return isCommandInvocationMarker(input.content);
|
||||
}
|
||||
|
||||
function hasOwn(record: Record<string, unknown>, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(record, key);
|
||||
}
|
||||
|
||||
function commandHistoryUnavailableMessage(input: Record<string, unknown>): RawMessage {
|
||||
const info = getRecord(input.info);
|
||||
return {
|
||||
id: typeof info?.id === 'string'
|
||||
? info.id
|
||||
: typeof input.id === 'string'
|
||||
? input.id
|
||||
: undefined,
|
||||
role: normalizeRole(info?.role ?? input.role),
|
||||
timestamp: normalizeTimestamp(info ?? input),
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '[Project command metadata unavailable]',
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function getStreamingDelta(record: Record<string, unknown> | null, part: Record<string, unknown>): string {
|
||||
const rawDelta = record?.delta ?? part.delta;
|
||||
if (typeof rawDelta === 'string') return rawDelta;
|
||||
const delta = getRecord(rawDelta);
|
||||
if (typeof delta?.text === 'string') return delta.text;
|
||||
if (typeof delta?.thinking === 'string') return delta.thinking;
|
||||
return '';
|
||||
}
|
||||
|
||||
function mergeStreamingText(existingText: string, incomingText: string, delta: string): string {
|
||||
if (!delta) return incomingText;
|
||||
if (incomingText && incomingText.startsWith(existingText) && incomingText.length > existingText.length) return incomingText;
|
||||
return `${existingText}${delta}`;
|
||||
}
|
||||
|
||||
function applyStreamingDeltaToBlock(
|
||||
candidate: ContentBlock,
|
||||
existingBlock: ContentBlock | undefined,
|
||||
delta: string,
|
||||
): ContentBlock {
|
||||
if (!delta) return candidate;
|
||||
|
||||
if (candidate.type === 'text') {
|
||||
const existingText = existingBlock?.type === 'text' && typeof existingBlock.text === 'string'
|
||||
? existingBlock.text
|
||||
: '';
|
||||
return {
|
||||
...candidate,
|
||||
text: mergeStreamingText(existingText, candidate.text ?? '', delta),
|
||||
};
|
||||
}
|
||||
|
||||
if (candidate.type === 'thinking') {
|
||||
const existingThinking = existingBlock?.type === 'thinking' && typeof existingBlock.thinking === 'string'
|
||||
? existingBlock.thinking
|
||||
: '';
|
||||
return {
|
||||
...candidate,
|
||||
thinking: mergeStreamingText(existingThinking, candidate.thinking ?? '', delta),
|
||||
};
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function normalizeStructuredMessage(input: Record<string, unknown>): RawMessage | null {
|
||||
const info = getRecord(input.info);
|
||||
const parts = Array.isArray(input.parts) ? input.parts : null;
|
||||
if (!info || !parts) return null;
|
||||
const errorMessage = getErrorMessage(info.error ?? input.error ?? input.errorMessage ?? input.error_message);
|
||||
const role = normalizeRole(info.role);
|
||||
const commandHistory: CommandHistorySelection = role === 'user'
|
||||
? selectCommandHistoryParts(parts)
|
||||
: { parts };
|
||||
|
||||
const blocks: ContentBlock[] = [];
|
||||
let attachedFiles: AttachedFileMeta[] | undefined;
|
||||
let toolEvidence: RuntimeToolEvidence[] | undefined;
|
||||
if (commandHistory.invocationBlock) blocks.push(commandHistory.invocationBlock);
|
||||
|
||||
for (const part of commandHistory.parts) {
|
||||
const record = getRecord(part);
|
||||
const normalized = normalizePart(part);
|
||||
blocks.push(...normalized.blocks);
|
||||
attachedFiles = appendAttachments(attachedFiles, normalized.attachments);
|
||||
toolEvidence = mergeToolEvidence(toolEvidence, record ? normalizeToolEvidence(record) : null);
|
||||
}
|
||||
|
||||
const finalErrorMessage = errorMessage
|
||||
?? (role === 'assistant' && blocks.length === 0 && hasCompletedAssistantSignal(info, input, parts)
|
||||
? EMPTY_ASSISTANT_RESPONSE_MESSAGE
|
||||
: undefined);
|
||||
const runtimeContext = getRuntimeMessageContext(role, info);
|
||||
|
||||
return {
|
||||
id: typeof info.id === 'string' ? info.id : undefined,
|
||||
role,
|
||||
timestamp: normalizeTimestamp(info),
|
||||
content: blocks.length > 0 || !finalErrorMessage
|
||||
? blocks
|
||||
: [{ type: 'text', text: finalErrorMessage }],
|
||||
...(attachedFiles?.length ? { _attachedFiles: attachedFiles } : {}),
|
||||
...(toolEvidence?.length ? { _toolEvidence: toolEvidence } : {}),
|
||||
...(runtimeContext ? { _runtimeContext: runtimeContext } : {}),
|
||||
...(finalErrorMessage ? { isError: true, errorMessage: finalErrorMessage } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOpencodeSessionMessages(input: unknown): RawMessage[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
return input.flatMap((entry) => {
|
||||
const record = getRecord(entry);
|
||||
if (!record) return [];
|
||||
const structuredInfo = getRecord(record.info);
|
||||
const structuredParts = Array.isArray(record.parts) ? record.parts : null;
|
||||
const structuredLike = hasOwn(record, 'info') || hasOwn(record, 'parts');
|
||||
if (structuredLike && (!structuredInfo || !structuredParts)) {
|
||||
return hasDirectCommandInvocationMarker(record.parts)
|
||||
|| hasLegacyCommandInvocationMarker(record)
|
||||
? [commandHistoryUnavailableMessage(record)]
|
||||
: [normalizeLegacyMessage(record)];
|
||||
}
|
||||
if (structuredInfo && structuredParts) {
|
||||
const containsCommandMarker = structuredParts.some(
|
||||
isCommandInvocationMarker,
|
||||
);
|
||||
if (
|
||||
containsCommandMarker
|
||||
&& normalizeRole(structuredInfo.role) !== 'user'
|
||||
) {
|
||||
return [commandHistoryUnavailableMessage(record)];
|
||||
}
|
||||
const structured = normalizeStructuredMessage(record);
|
||||
return structured
|
||||
? [structured]
|
||||
: [commandHistoryUnavailableMessage(record)];
|
||||
}
|
||||
if (hasLegacyCommandInvocationMarker(record)) {
|
||||
return [commandHistoryUnavailableMessage(record)];
|
||||
}
|
||||
return [normalizeLegacyMessage(record)];
|
||||
});
|
||||
}
|
||||
|
||||
export function createOptimisticUserMessage(
|
||||
text: string,
|
||||
timestamp = Date.now(),
|
||||
deliveryStatus?: RawMessage['_deliveryStatus'],
|
||||
attachedFiles?: AttachedFileMeta[],
|
||||
): RawMessage {
|
||||
return {
|
||||
id: `local-user-${timestamp}`,
|
||||
role: 'user',
|
||||
timestamp,
|
||||
content: [{ type: 'text', text }],
|
||||
...(attachedFiles?.length ? { _attachedFiles: attachedFiles } : {}),
|
||||
...(deliveryStatus ? { _deliveryStatus: deliveryStatus } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createStreamingMessageFromEvent(input: unknown): RawMessage | null {
|
||||
const record = getRecord(input);
|
||||
const info = getRecord(record?.info ?? input);
|
||||
if (!info) return null;
|
||||
return {
|
||||
id: typeof info.id === 'string' ? info.id : undefined,
|
||||
role: normalizeRole(info.role),
|
||||
timestamp: normalizeTimestamp(info),
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function getOpencodeEventSessionId(input: unknown): string | null {
|
||||
const record = getRecord(input);
|
||||
if (!record) return null;
|
||||
if (typeof record.sessionID === 'string' && record.sessionID.trim()) return record.sessionID;
|
||||
if (typeof record.sessionId === 'string' && record.sessionId.trim()) return record.sessionId;
|
||||
return getOpencodeEventSessionId(record.part)
|
||||
?? getOpencodeEventSessionId(record.info)
|
||||
?? getOpencodeEventSessionId(record.message)
|
||||
?? null;
|
||||
}
|
||||
|
||||
export function applyStreamingPartToMessage(message: RawMessage | null, input: unknown): RawMessage | null {
|
||||
const record = getRecord(input);
|
||||
const part = getRecord(record?.part ?? input);
|
||||
if (!part) return message;
|
||||
|
||||
const current = message ?? {
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
};
|
||||
const nextContent = Array.isArray(current.content) ? [...current.content] : [];
|
||||
const normalized = normalizePart(part);
|
||||
const toolEvidence = normalizeToolEvidence(part);
|
||||
const rawCandidate = normalized.blocks.at(0);
|
||||
const delta = getStreamingDelta(record, part);
|
||||
const blockId = rawCandidate?.id ?? (typeof part.id === 'string' ? part.id : undefined);
|
||||
const index = blockId
|
||||
? nextContent.findIndex((block) => ('id' in block ? block.id : undefined) === blockId)
|
||||
: -1;
|
||||
const existingBlock = index >= 0 ? nextContent[index] : undefined;
|
||||
const candidate = rawCandidate
|
||||
? applyStreamingDeltaToBlock(rawCandidate, existingBlock, delta)
|
||||
: delta
|
||||
? { type: 'text', id: blockId, text: delta } satisfies ContentBlock
|
||||
: undefined;
|
||||
if (candidate) {
|
||||
const nextBlock = blockId ? { ...candidate, id: blockId } : candidate;
|
||||
if (index >= 0) {
|
||||
nextContent[index] = nextBlock;
|
||||
} else {
|
||||
nextContent.push(nextBlock);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
content: nextContent,
|
||||
_attachedFiles: mergeAttachments(current._attachedFiles, normalized.attachments),
|
||||
_toolEvidence: mergeToolEvidence(current._toolEvidence, toolEvidence),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyStreamingPartDeltaToMessage(message: RawMessage | null, input: unknown): RawMessage | null {
|
||||
const record = getRecord(input);
|
||||
if (!record) return message;
|
||||
|
||||
const delta = typeof record.delta === 'string' ? record.delta : '';
|
||||
if (!delta) return message;
|
||||
|
||||
const part = getRecord(record.part);
|
||||
const messageID = typeof record.messageID === 'string'
|
||||
? record.messageID
|
||||
: typeof record.messageId === 'string'
|
||||
? record.messageId
|
||||
: undefined;
|
||||
const partID = typeof record.partID === 'string'
|
||||
? record.partID
|
||||
: typeof record.partId === 'string'
|
||||
? record.partId
|
||||
: typeof part?.id === 'string'
|
||||
? part.id
|
||||
: undefined;
|
||||
const field = typeof record.field === 'string'
|
||||
? record.field
|
||||
: typeof part?.field === 'string'
|
||||
? part.field
|
||||
: undefined;
|
||||
const isThinkingDelta = part?.type === 'reasoning'
|
||||
|| part?.type === 'thinking'
|
||||
|| field === 'reasoning'
|
||||
|| field === 'thinking';
|
||||
|
||||
if (message?.id && messageID && message.id !== messageID) return message;
|
||||
|
||||
const current = message ?? {
|
||||
id: messageID,
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
} satisfies RawMessage;
|
||||
const nextContent = Array.isArray(current.content) ? [...current.content] : [];
|
||||
const index = partID
|
||||
? nextContent.findIndex((block) => ('id' in block ? block.id : undefined) === partID)
|
||||
: -1;
|
||||
const existingBlock = index >= 0 ? nextContent[index] : undefined;
|
||||
|
||||
if (existingBlock?.type === 'thinking' || isThinkingDelta) {
|
||||
const existingThinking = existingBlock?.type === 'thinking'
|
||||
? existingBlock.thinking ?? ''
|
||||
: existingBlock?.type === 'text'
|
||||
? existingBlock.text ?? ''
|
||||
: '';
|
||||
const nextBlock = {
|
||||
type: 'thinking',
|
||||
id: partID,
|
||||
thinking: `${existingThinking}${delta}`,
|
||||
} satisfies ContentBlock;
|
||||
if (index >= 0) {
|
||||
nextContent[index] = nextBlock;
|
||||
} else {
|
||||
nextContent.push(nextBlock);
|
||||
}
|
||||
} else if (existingBlock?.type === 'text') {
|
||||
nextContent[index] = {
|
||||
...existingBlock,
|
||||
text: `${existingBlock.text ?? ''}${delta}`,
|
||||
};
|
||||
} else {
|
||||
nextContent.push({
|
||||
type: 'text',
|
||||
id: partID,
|
||||
text: delta,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
content: nextContent,
|
||||
};
|
||||
}
|
||||
|
||||
export function getStreamingToolStatus(input: unknown): ToolStatus | null {
|
||||
const record = getRecord(input);
|
||||
const part = getRecord(record?.part ?? input);
|
||||
if (!part || part.type !== 'tool' || typeof part.tool !== 'string') return null;
|
||||
|
||||
const state = getRecord(part.state);
|
||||
const time = getRecord(state?.time);
|
||||
const startedAt = toMs(time?.start);
|
||||
const endedAt = toMs(time?.end);
|
||||
return {
|
||||
id: typeof part.id === 'string' ? part.id : undefined,
|
||||
toolCallId: typeof part.callID === 'string' ? part.callID : undefined,
|
||||
name: part.tool,
|
||||
status: normalizeToolStatus(state?.status),
|
||||
durationMs: startedAt !== undefined && endedAt !== undefined ? Math.max(0, endedAt - startedAt) : undefined,
|
||||
summary: typeof state?.title === 'string' && state.title.trim()
|
||||
? state.title
|
||||
: typeof state?.error === 'string' && state.error.trim()
|
||||
? state.error
|
||||
: undefined,
|
||||
updatedAt: endedAt ?? startedAt ?? Date.now(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user