Files
OpenQode/bin/ui/models/PartModel.mjs

233 lines
5.4 KiB
JavaScript

/**
* Part Model - Structured message parts
*
* Based on sst/opencode message architecture
* Credit: https://github.com/sst/opencode
*
* Normalizes all content into typed parts for consistent rendering
*/
// Part types enum
export const PART_TYPES = {
TEXT: 'text', // User/assistant prose
REASONING: 'reasoning', // Thinking/intent trace
TOOL_CALL: 'tool_call', // Tool invocation
TOOL_RESULT: 'tool_result', // Tool output
FILE_SNIPPET: 'file_snippet', // Code file content
DIFF: 'diff', // Diff view
TODO: 'todo', // Todo/checklist
SYSTEM: 'system', // System message
ERROR: 'error' // Error message
};
/**
* Create a text part (user/assistant prose)
*/
export function createTextPart(content, role = 'assistant') {
return {
type: PART_TYPES.TEXT,
role,
content,
timestamp: Date.now()
};
}
/**
* Create a reasoning part (thinking/intent trace)
*/
export function createReasoningPart(intent, next, why = null, steps = []) {
return {
type: PART_TYPES.REASONING,
intent,
next,
why,
steps,
timestamp: Date.now()
};
}
/**
* Create a tool call part
*/
export function createToolCallPart(toolName, args = {}, status = 'running') {
return {
type: PART_TYPES.TOOL_CALL,
toolName,
args,
status, // 'running' | 'done' | 'failed'
timestamp: Date.now()
};
}
/**
* Create a tool result part
*/
export function createToolResultPart(toolName, output, summary = null, exitCode = 0) {
return {
type: PART_TYPES.TOOL_RESULT,
toolName,
output,
summary,
exitCode,
isError: exitCode !== 0,
timestamp: Date.now()
};
}
/**
* Create a file snippet part
*/
export function createFileSnippetPart(filename, content, language = null, startLine = 1) {
const lines = content.split('\n').length;
return {
type: PART_TYPES.FILE_SNIPPET,
filename,
content,
language: language || detectLanguage(filename),
startLine,
lineCount: lines,
timestamp: Date.now()
};
}
/**
* Create a diff part
*/
export function createDiffPart(filename, diff, mode = 'unified') {
return {
type: PART_TYPES.DIFF,
filename,
diff,
mode, // 'unified' | 'split'
timestamp: Date.now()
};
}
/**
* Create a todo part
*/
export function createTodoPart(items) {
return {
type: PART_TYPES.TODO,
items: items.map(item => ({
text: item.text,
status: item.status || 'pending', // 'pending' | 'in_progress' | 'done'
checked: item.status === 'done'
})),
timestamp: Date.now()
};
}
/**
* Create a system part
*/
export function createSystemPart(content, subtype = 'info') {
return {
type: PART_TYPES.SYSTEM,
content,
subtype, // 'info' | 'warning' | 'success'
timestamp: Date.now()
};
}
/**
* Create an error part
*/
export function createErrorPart(message, details = null, stack = null) {
return {
type: PART_TYPES.ERROR,
message,
details,
stack,
timestamp: Date.now()
};
}
/**
* Detect language from filename
*/
function detectLanguage(filename) {
if (!filename) return null;
const ext = filename.split('.').pop()?.toLowerCase();
const langMap = {
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
ts: 'typescript', tsx: 'typescript',
py: 'python',
rb: 'ruby',
go: 'go',
rs: 'rust',
java: 'java',
c: 'c', cpp: 'cpp', h: 'c',
cs: 'csharp',
php: 'php',
sh: 'bash', bash: 'bash',
ps1: 'powershell',
json: 'json',
yaml: 'yaml', yml: 'yaml',
md: 'markdown',
html: 'html',
css: 'css',
sql: 'sql'
};
return langMap[ext] || null;
}
/**
* Parse raw message content into parts
* Extracts code blocks, tool calls, etc.
*/
export function parseContentToParts(content, role = 'assistant') {
const parts = [];
// Simple parsing - extract code blocks
const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g;
let lastIndex = 0;
let match;
while ((match = codeBlockRegex.exec(content)) !== null) {
// Text before code block
if (match.index > lastIndex) {
const text = content.slice(lastIndex, match.index).trim();
if (text) {
parts.push(createTextPart(text, role));
}
}
// Code block
const lang = match[1] || null;
const code = match[2];
parts.push(createFileSnippetPart(null, code, lang));
lastIndex = match.index + match[0].length;
}
// Remaining text after last code block
if (lastIndex < content.length) {
const text = content.slice(lastIndex).trim();
if (text) {
parts.push(createTextPart(text, role));
}
}
// If no parts created, treat entire content as text
if (parts.length === 0) {
parts.push(createTextPart(content, role));
}
return parts;
}
export default {
PART_TYPES,
createTextPart,
createReasoningPart,
createToolCallPart,
createToolResultPart,
createFileSnippetPart,
createDiffPart,
createTodoPart,
createSystemPart,
createErrorPart,
parseContentToParts
};