2253 lines
72 KiB
JavaScript
2253 lines
72 KiB
JavaScript
let currentConversationId = null;
|
|
let isStreaming = false;
|
|
let abortController = null;
|
|
let currentGroupFilter = null;
|
|
let groups = [];
|
|
let expandedGroups = new Set();
|
|
let groupLocations = {};
|
|
let currentSettingsGroupId = null;
|
|
let selectedGroupId = null;
|
|
let agents = [];
|
|
let selectedAgent = "none";
|
|
let llmSettings = null;
|
|
let activeStreamUi = null;
|
|
let cancelRequested = false;
|
|
const conversationRenderState = {
|
|
pendingToolBubble: null,
|
|
};
|
|
const messageInputMaxLines = 12;
|
|
|
|
function generateId() {
|
|
return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatElapsed(ms) {
|
|
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
const minutes = Math.floor(totalSeconds / 60);
|
|
const seconds = totalSeconds % 60;
|
|
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
|
}
|
|
|
|
function formatStepLabel(step, total) {
|
|
if (!Number.isFinite(step) || step < 1) {
|
|
return '';
|
|
}
|
|
if (Number.isFinite(total) && total > 0) {
|
|
return `Step ${step} of ${total}`;
|
|
}
|
|
return `Step ${step}`;
|
|
}
|
|
|
|
function formatTokenCount(tokens) {
|
|
const value = Number(tokens);
|
|
if (!Number.isFinite(value) || value <= 0) {
|
|
return '';
|
|
}
|
|
return `${value.toLocaleString('en-US')} ${value === 1 ? 'token' : 'tokens'}`;
|
|
}
|
|
|
|
function setSettingsError(message) {
|
|
const errorEl = document.getElementById('settingsFormError');
|
|
if (!errorEl) return;
|
|
if (message) {
|
|
errorEl.textContent = message;
|
|
errorEl.classList.remove('hidden');
|
|
} else {
|
|
errorEl.textContent = '';
|
|
errorEl.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
function normalizeRuntimeSettings(settings) {
|
|
if (!settings) return settings;
|
|
return {
|
|
...settings,
|
|
reasoning_effort: settings.reasoning_effort ?? '',
|
|
auto_open_reasoning: settings.auto_open_reasoning !== false,
|
|
};
|
|
}
|
|
|
|
function reasoningAutoOpenEnabled() {
|
|
return !llmSettings || llmSettings.auto_open_reasoning !== false;
|
|
}
|
|
|
|
function populateSettingsForm(settings) {
|
|
if (!settings) return;
|
|
const normalized = normalizeRuntimeSettings(settings);
|
|
const fieldMap = {
|
|
settingsProvider: normalized.provider ?? '',
|
|
settingsBaseURL: normalized.base_url ?? '',
|
|
settingsAPIKey: normalized.api_key ?? '',
|
|
settingsModel: normalized.model ?? '',
|
|
settingsTemperature: normalized.temperature ?? '',
|
|
settingsMaxTokens: normalized.max_tokens ?? '',
|
|
settingsTopP: normalized.top_p ?? '',
|
|
settingsFrequencyPenalty: normalized.frequency_penalty ?? '',
|
|
settingsPresencePenalty: normalized.presence_penalty ?? '',
|
|
settingsServerHost: normalized.server_host ?? '',
|
|
settingsServerAddr: normalized.server_addr ?? '',
|
|
settingsContextTokens: normalized.context_tokens ?? '',
|
|
settingsTriggerRatio: normalized.context_compaction_trigger_ratio ?? '',
|
|
settingsTargetRatio: normalized.context_compaction_target_ratio ?? '',
|
|
};
|
|
for (const [id, value] of Object.entries(fieldMap)) {
|
|
const input = document.getElementById(id);
|
|
if (input) {
|
|
input.value = value;
|
|
}
|
|
}
|
|
const reasoningEffortInput = document.getElementById('settingsReasoningEffort');
|
|
if (reasoningEffortInput) {
|
|
reasoningEffortInput.value = normalized.reasoning_effort ?? '';
|
|
}
|
|
const autoOpenReasoningInput = document.getElementById('settingsAutoOpenReasoning');
|
|
if (autoOpenReasoningInput) {
|
|
autoOpenReasoningInput.checked = normalized.auto_open_reasoning !== false;
|
|
}
|
|
}
|
|
|
|
async function loadLLMSettings() {
|
|
const res = await fetch('/api/settings');
|
|
if (!res.ok) {
|
|
throw new Error('Failed to load runtime settings');
|
|
}
|
|
llmSettings = normalizeRuntimeSettings(await res.json());
|
|
return llmSettings;
|
|
}
|
|
|
|
async function openLLMSettingsModal() {
|
|
try {
|
|
const settings = await loadLLMSettings();
|
|
populateSettingsForm(settings);
|
|
setSettingsError('');
|
|
const modal = document.getElementById('settingsModal');
|
|
modal.classList.remove('hidden');
|
|
modal.classList.add('flex');
|
|
const input = document.getElementById('settingsProvider');
|
|
setTimeout(() => input.focus(), 50);
|
|
} catch (err) {
|
|
console.error('Failed to load runtime settings:', err);
|
|
setSettingsError('Unable to load settings right now.');
|
|
}
|
|
}
|
|
|
|
function closeLLMSettingsModal() {
|
|
const modal = document.getElementById('settingsModal');
|
|
modal.classList.add('hidden');
|
|
modal.classList.remove('flex');
|
|
setSettingsError('');
|
|
}
|
|
|
|
async function saveLLMSettings() {
|
|
const providerInput = document.getElementById('settingsProvider');
|
|
const baseURLInput = document.getElementById('settingsBaseURL');
|
|
const apiKeyInput = document.getElementById('settingsAPIKey');
|
|
const modelInput = document.getElementById('settingsModel');
|
|
const temperatureInput = document.getElementById('settingsTemperature');
|
|
const maxTokensInput = document.getElementById('settingsMaxTokens');
|
|
const topPInput = document.getElementById('settingsTopP');
|
|
const frequencyPenaltyInput = document.getElementById('settingsFrequencyPenalty');
|
|
const presencePenaltyInput = document.getElementById('settingsPresencePenalty');
|
|
const reasoningEffortInput = document.getElementById('settingsReasoningEffort');
|
|
const autoOpenReasoningInput = document.getElementById('settingsAutoOpenReasoning');
|
|
const serverHostInput = document.getElementById('settingsServerHost');
|
|
const serverAddrInput = document.getElementById('settingsServerAddr');
|
|
const tokensInput = document.getElementById('settingsContextTokens');
|
|
const triggerInput = document.getElementById('settingsTriggerRatio');
|
|
const targetInput = document.getElementById('settingsTargetRatio');
|
|
|
|
const provider = providerInput.value.trim();
|
|
const baseURL = baseURLInput.value.trim();
|
|
const apiKey = apiKeyInput.value;
|
|
const model = modelInput.value.trim();
|
|
const temperature = Number(temperatureInput.value);
|
|
const maxTokens = Number(maxTokensInput.value);
|
|
const topP = Number(topPInput.value);
|
|
const frequencyPenalty = Number(frequencyPenaltyInput.value);
|
|
const presencePenalty = Number(presencePenaltyInput.value);
|
|
const reasoningEffort = reasoningEffortInput.value;
|
|
const autoOpenReasoning = autoOpenReasoningInput.checked;
|
|
const serverHost = serverHostInput.value.trim();
|
|
const serverAddr = serverAddrInput.value.trim();
|
|
const contextTokens = Number(tokensInput.value);
|
|
const triggerRatio = Number(triggerInput.value);
|
|
const targetRatio = Number(targetInput.value);
|
|
|
|
if (!provider) {
|
|
setSettingsError('Provider is required.');
|
|
return;
|
|
}
|
|
if (!baseURL) {
|
|
setSettingsError('Base URL is required.');
|
|
return;
|
|
}
|
|
if (!model) {
|
|
setSettingsError('Model is required.');
|
|
return;
|
|
}
|
|
if (!Number.isFinite(temperature) || temperature < 0) {
|
|
setSettingsError('Temperature must be 0 or higher.');
|
|
return;
|
|
}
|
|
if (!Number.isFinite(maxTokens) || maxTokens <= 0) {
|
|
setSettingsError('Max tokens must be a positive number.');
|
|
return;
|
|
}
|
|
if (!Number.isFinite(topP) || topP <= 0 || topP > 1) {
|
|
setSettingsError('Top P must be greater than 0 and less than or equal to 1.');
|
|
return;
|
|
}
|
|
if (!Number.isFinite(frequencyPenalty)) {
|
|
setSettingsError('Frequency penalty must be a number.');
|
|
return;
|
|
}
|
|
if (!Number.isFinite(presencePenalty)) {
|
|
setSettingsError('Presence penalty must be a number.');
|
|
return;
|
|
}
|
|
if (!['', 'low', 'medium', 'high'].includes(reasoningEffort)) {
|
|
setSettingsError('Reasoning effort must be Default, Low, Medium, or High.');
|
|
return;
|
|
}
|
|
if (!serverHost) {
|
|
setSettingsError('Server host is required.');
|
|
return;
|
|
}
|
|
if (!serverAddr) {
|
|
setSettingsError('Server address is required.');
|
|
return;
|
|
}
|
|
if (!Number.isFinite(contextTokens) || contextTokens <= 0) {
|
|
setSettingsError('Prompt budget must be a positive number.');
|
|
return;
|
|
}
|
|
if (!Number.isFinite(triggerRatio) || triggerRatio <= 0 || triggerRatio >= 1) {
|
|
setSettingsError('Compaction trigger must be greater than 0 and less than 1.');
|
|
return;
|
|
}
|
|
if (!Number.isFinite(targetRatio) || targetRatio <= 0 || targetRatio >= triggerRatio) {
|
|
setSettingsError('Compaction target must be greater than 0 and less than the trigger.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/settings', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
provider,
|
|
base_url: baseURL,
|
|
api_key: apiKey,
|
|
model,
|
|
temperature,
|
|
max_tokens: maxTokens,
|
|
top_p: topP,
|
|
frequency_penalty: frequencyPenalty,
|
|
presence_penalty: presencePenalty,
|
|
reasoning_effort: reasoningEffort,
|
|
auto_open_reasoning: autoOpenReasoning,
|
|
server_host: serverHost,
|
|
server_addr: serverAddr,
|
|
context_tokens: contextTokens,
|
|
context_compaction_trigger_ratio: triggerRatio,
|
|
context_compaction_target_ratio: targetRatio,
|
|
}),
|
|
});
|
|
const data = await res.json().catch(() => null);
|
|
if (!res.ok) {
|
|
throw new Error((data && data.error) || 'Failed to save settings');
|
|
}
|
|
llmSettings = normalizeRuntimeSettings(data);
|
|
closeLLMSettingsModal();
|
|
} catch (err) {
|
|
console.error('Failed to save runtime settings:', err);
|
|
setSettingsError(err.message || 'Failed to save settings');
|
|
}
|
|
}
|
|
|
|
function updateStreamingControls() {
|
|
const sendBtn = document.getElementById('sendBtn');
|
|
const cancelBtn = document.getElementById('cancelBtn');
|
|
if (sendBtn) {
|
|
sendBtn.disabled = isStreaming;
|
|
sendBtn.textContent = isStreaming ? 'Sending...' : 'Send';
|
|
}
|
|
if (cancelBtn) {
|
|
cancelBtn.classList.toggle('hidden', !isStreaming);
|
|
cancelBtn.disabled = !isStreaming;
|
|
}
|
|
}
|
|
|
|
// Marked preserves raw HTML, so sanitize the rendered fragment before it hits innerHTML.
|
|
const safeMarkdownTags = new Set([
|
|
'a',
|
|
'abbr',
|
|
'b',
|
|
'blockquote',
|
|
'br',
|
|
'code',
|
|
'del',
|
|
'em',
|
|
'h1',
|
|
'h2',
|
|
'h3',
|
|
'h4',
|
|
'h5',
|
|
'h6',
|
|
'hr',
|
|
'img',
|
|
'input',
|
|
'li',
|
|
'ol',
|
|
'p',
|
|
'pre',
|
|
'strong',
|
|
'sub',
|
|
'sup',
|
|
'table',
|
|
'tbody',
|
|
'td',
|
|
'th',
|
|
'thead',
|
|
'tr',
|
|
'ul',
|
|
]);
|
|
|
|
function isSafeMarkdownUrl(value) {
|
|
const trimmed = String(value || '').trim();
|
|
if (!trimmed) {
|
|
return false;
|
|
}
|
|
try {
|
|
const url = new URL(trimmed, window.location.href);
|
|
return ['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol);
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function sanitizeRenderedMarkdown(html) {
|
|
if (!html || typeof document === 'undefined') {
|
|
return html;
|
|
}
|
|
|
|
const template = document.createElement('template');
|
|
template.innerHTML = html;
|
|
|
|
function sanitizeElement(element) {
|
|
for (const child of Array.from(element.children)) {
|
|
sanitizeElement(child);
|
|
}
|
|
|
|
const tagName = element.tagName.toLowerCase();
|
|
if (!safeMarkdownTags.has(tagName)) {
|
|
const parent = element.parentNode;
|
|
if (!parent) {
|
|
return;
|
|
}
|
|
while (element.firstChild) {
|
|
parent.insertBefore(element.firstChild, element);
|
|
}
|
|
parent.removeChild(element);
|
|
return;
|
|
}
|
|
|
|
for (const attr of Array.from(element.attributes)) {
|
|
const attrName = attr.name.toLowerCase();
|
|
let keepAttr = false;
|
|
|
|
switch (tagName) {
|
|
case 'a':
|
|
keepAttr = ['href', 'title', 'target', 'rel'].includes(attrName);
|
|
if (keepAttr && attrName === 'href' && !isSafeMarkdownUrl(attr.value)) {
|
|
keepAttr = false;
|
|
}
|
|
break;
|
|
case 'img':
|
|
keepAttr = ['src', 'alt', 'title', 'width', 'height', 'loading'].includes(attrName);
|
|
if (keepAttr && attrName === 'src' && !isSafeMarkdownUrl(attr.value)) {
|
|
keepAttr = false;
|
|
}
|
|
break;
|
|
case 'code':
|
|
case 'pre':
|
|
keepAttr = attrName === 'class';
|
|
break;
|
|
case 'input':
|
|
keepAttr = ['type', 'checked', 'disabled'].includes(attrName);
|
|
if (keepAttr && attrName === 'type' && String(attr.value).toLowerCase() !== 'checkbox') {
|
|
keepAttr = false;
|
|
}
|
|
break;
|
|
case 'th':
|
|
case 'td':
|
|
keepAttr = ['colspan', 'rowspan'].includes(attrName);
|
|
break;
|
|
default:
|
|
keepAttr = false;
|
|
break;
|
|
}
|
|
|
|
if (attrName.startsWith('on') || !keepAttr) {
|
|
element.removeAttribute(attr.name);
|
|
}
|
|
}
|
|
|
|
if (tagName === 'a' && element.hasAttribute('href')) {
|
|
element.setAttribute('rel', 'noreferrer noopener');
|
|
}
|
|
}
|
|
|
|
for (const child of Array.from(template.content.children)) {
|
|
sanitizeElement(child);
|
|
}
|
|
|
|
return template.innerHTML;
|
|
}
|
|
|
|
function renderMarkdown(text) {
|
|
marked.setOptions({
|
|
breaks: true,
|
|
gfm: true,
|
|
});
|
|
return sanitizeRenderedMarkdown(marked.parse(text));
|
|
}
|
|
|
|
function completeIncompleteBlocks(text) {
|
|
// Count unclosed code blocks
|
|
let codeBlockOpen = (text.match(/```/g) || []).length;
|
|
if (codeBlockOpen % 2 !== 0) {
|
|
text += '\n```';
|
|
}
|
|
|
|
// Complete unclosed list items
|
|
let lines = text.split('\n');
|
|
let lastLine = lines[lines.length - 1];
|
|
if (lastLine && lastLine.trim() && !lastLine.trim().endsWith(':') && !lastLine.trim().endsWith(',') && !lastLine.trim().endsWith('.')) {
|
|
// Check if we're in the middle of a code block or already completed
|
|
if ((text.match(/```/g) || []).length % 2 === 0) {
|
|
// Not in a code block, add a newline to help markdown parser
|
|
text += '\n\n';
|
|
}
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
function resizeMessageInput(input) {
|
|
if (!input) return;
|
|
const style = window.getComputedStyle(input);
|
|
const lineHeight = parseFloat(style.lineHeight) || 24;
|
|
const paddingTop = parseFloat(style.paddingTop) || 0;
|
|
const paddingBottom = parseFloat(style.paddingBottom) || 0;
|
|
const borderTop = parseFloat(style.borderTopWidth) || 0;
|
|
const borderBottom = parseFloat(style.borderBottomWidth) || 0;
|
|
const maxHeight = (lineHeight * messageInputMaxLines) + paddingTop + paddingBottom + borderTop + borderBottom;
|
|
|
|
input.style.height = 'auto';
|
|
const nextHeight = Math.min(input.scrollHeight + borderTop + borderBottom, maxHeight);
|
|
input.style.height = `${nextHeight}px`;
|
|
input.style.overflowY = input.scrollHeight + borderTop + borderBottom > maxHeight ? 'auto' : 'hidden';
|
|
}
|
|
|
|
function resetConversationRenderState() {
|
|
conversationRenderState.pendingToolBubble = null;
|
|
}
|
|
|
|
function splitNormalizedLines(text) {
|
|
const normalized = String(text || '').replace(/\r\n/g, '\n');
|
|
if (normalized === '') {
|
|
return [];
|
|
}
|
|
const lines = normalized.split('\n');
|
|
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
lines.pop();
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
function stripReadFileLinePrefix(line) {
|
|
const match = String(line || '').match(/^\s*\d+:\s?(.*)$/);
|
|
return match ? match[1] : String(line || '');
|
|
}
|
|
|
|
function normalizePatchExpectedLines(expectedText) {
|
|
const lines = splitNormalizedLines(expectedText);
|
|
if (lines.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
if (
|
|
lines.length >= 2 &&
|
|
lines[0].startsWith('File: ') &&
|
|
lines[1].startsWith('Lines ') &&
|
|
lines[1].includes(' of ')
|
|
) {
|
|
return lines.slice(2).map(stripReadFileLinePrefix);
|
|
}
|
|
|
|
const normalized = [];
|
|
for (const line of lines) {
|
|
if (/^\s*\d+:\s?/.test(line)) {
|
|
normalized.push(stripReadFileLinePrefix(line));
|
|
continue;
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function parseJsonObject(text) {
|
|
const trimmed = String(text || '').trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(trimmed);
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function prettyPrintValue(value) {
|
|
if (typeof value === 'string') {
|
|
return value;
|
|
}
|
|
if (value === null || value === undefined) {
|
|
return '';
|
|
}
|
|
try {
|
|
return JSON.stringify(value, null, 2);
|
|
} catch (err) {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
function humanizeToolName(name) {
|
|
const trimmed = String(name || '').trim();
|
|
if (!trimmed) {
|
|
return 'Tool';
|
|
}
|
|
return trimmed
|
|
.replace(/_/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.replace(/\b\w/g, char => char.toUpperCase());
|
|
}
|
|
|
|
function formatToolRangeLabel(startLine, endLine) {
|
|
const start = Number(startLine);
|
|
if (!Number.isFinite(start) || start <= 0) {
|
|
return '';
|
|
}
|
|
if (endLine === -1) {
|
|
return `${start}-EOF`;
|
|
}
|
|
const end = Number(endLine);
|
|
if (!Number.isFinite(end)) {
|
|
return `${start}-EOF`;
|
|
}
|
|
return `${start}-${end}`;
|
|
}
|
|
|
|
function renderToolSummaryRows(rows) {
|
|
const visibleRows = rows.filter(row => row && row[0] && row[1] !== undefined && row[1] !== null && String(row[1]).trim() !== '');
|
|
if (visibleRows.length === 0) {
|
|
return '';
|
|
}
|
|
|
|
return `
|
|
<div class="tool-meta-list mt-2 space-y-2">
|
|
${visibleRows.map(([label, value]) => `
|
|
<div class="tool-meta-row">
|
|
<div class="tool-meta-key">${escapeHtml(String(label))}</div>
|
|
<div class="tool-meta-value">${escapeHtml(String(value))}</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderToolTextBlock(text, extraClass = '') {
|
|
const rawText = String(text ?? '');
|
|
const content = rawText.trim() ? rawText : 'No output';
|
|
return `<pre class="tool-text-block ${extraClass}">${escapeHtml(content)}</pre>`;
|
|
}
|
|
|
|
function renderToolPlaceholder(text) {
|
|
return `<div class="tool-placeholder">${escapeHtml(String(text || 'Waiting for result...'))}</div>`;
|
|
}
|
|
|
|
function renderPatchDiffHtml(args) {
|
|
const path = String(args?.path || '').trim() || 'patch';
|
|
const oldLines = normalizePatchExpectedLines(args?.expected_text || '');
|
|
const newLines = splitNormalizedLines(args?.content || '');
|
|
const startLine = Number(args?.start_line);
|
|
const endLine = Number(args?.end_line);
|
|
const rangeLabel = formatToolRangeLabel(startLine, endLine);
|
|
const diffWindowLabel = rangeLabel ? `range ${rangeLabel}` : 'requested range';
|
|
const header = [
|
|
`--- a/${path}`,
|
|
`+++ b/${path}`,
|
|
`@@ ${diffWindowLabel} · -${oldLines.length} +${newLines.length} @@`,
|
|
];
|
|
|
|
if (oldLines.length === 0 && newLines.length === 0) {
|
|
return `
|
|
<div class="tool-diff">
|
|
<div class="tool-diff-header">${escapeHtml(header.join('\n'))}</div>
|
|
<div class="tool-diff-body">
|
|
<div class="tool-diff-note">No diff content available.</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const diffLines = [];
|
|
for (const line of oldLines) {
|
|
diffLines.push(`
|
|
<div class="tool-diff-line tool-diff-remove">
|
|
<span class="tool-diff-prefix">-</span>
|
|
<span class="tool-diff-text">${escapeHtml(line)}</span>
|
|
</div>
|
|
`);
|
|
}
|
|
for (const line of newLines) {
|
|
diffLines.push(`
|
|
<div class="tool-diff-line tool-diff-add">
|
|
<span class="tool-diff-prefix">+</span>
|
|
<span class="tool-diff-text">${escapeHtml(line)}</span>
|
|
</div>
|
|
`);
|
|
}
|
|
|
|
return `
|
|
<div class="tool-diff">
|
|
<div class="tool-diff-header">${escapeHtml(header.join('\n'))}</div>
|
|
<div class="tool-diff-body">
|
|
${diffLines.join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderToolExecutionSection(toolName, args, rawContent) {
|
|
const normalizedName = String(toolName || '').toLowerCase();
|
|
const rows = [];
|
|
let extraHtml = '';
|
|
|
|
switch (normalizedName) {
|
|
case 'read_file':
|
|
case 'write_file':
|
|
case 'patch_file':
|
|
if (args && args.path) {
|
|
rows.push(['Path', args.path]);
|
|
}
|
|
if (args && Object.prototype.hasOwnProperty.call(args, 'start_line')) {
|
|
rows.push(['Range', formatToolRangeLabel(args.start_line, args.end_line) || '1-EOF']);
|
|
}
|
|
if (normalizedName === 'patch_file') {
|
|
rows.push(['Mode', 'Guarded snippet replacement']);
|
|
extraHtml = renderPatchDiffHtml(args || {});
|
|
}
|
|
break;
|
|
case 'run_command':
|
|
if (args && args.command) {
|
|
rows.push(['Command', args.command]);
|
|
}
|
|
if (args && Array.isArray(args.args) && args.args.length > 0) {
|
|
rows.push(['Args', args.args.map(arg => JSON.stringify(arg)).join(' ')]);
|
|
}
|
|
break;
|
|
case 'grep':
|
|
if (args && args.search_term) {
|
|
rows.push(['Search', args.search_term]);
|
|
}
|
|
if (args && args.path) {
|
|
rows.push(['Path', args.path]);
|
|
}
|
|
break;
|
|
case 'search_files':
|
|
if (args && args.path_pattern) {
|
|
rows.push(['Pattern', args.path_pattern]);
|
|
}
|
|
break;
|
|
case 'list_directory':
|
|
case 'create_directory':
|
|
case 'delete_file':
|
|
if (args && args.path) {
|
|
rows.push(['Path', args.path]);
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
const summaryHtml = renderToolSummaryRows(rows);
|
|
if (normalizedName === 'patch_file') {
|
|
const patchHtml = `${summaryHtml}${extraHtml}`.trim();
|
|
if (patchHtml) {
|
|
if (summaryHtml && extraHtml) {
|
|
return `${summaryHtml}<div class="mt-3">${extraHtml}</div>`;
|
|
}
|
|
return patchHtml;
|
|
}
|
|
}
|
|
if (summaryHtml) {
|
|
return summaryHtml;
|
|
}
|
|
|
|
const fallbackText = rawContent && rawContent.trim() ? rawContent : prettyPrintValue(args || {});
|
|
return renderToolTextBlock(fallbackText);
|
|
}
|
|
|
|
function parseReadFileResult(contentText) {
|
|
const lines = splitNormalizedLines(contentText);
|
|
if (lines.length < 2 || !lines[0].startsWith('File: ')) {
|
|
return null;
|
|
}
|
|
|
|
const path = lines[0].slice('File: '.length).trim();
|
|
const secondLine = lines[1] || '';
|
|
if (!secondLine) {
|
|
return { path, note: '', contentLines: [] };
|
|
}
|
|
if (secondLine === 'File is empty.') {
|
|
return { path, note: secondLine, contentLines: [] };
|
|
}
|
|
if (secondLine.startsWith('Requested lines ')) {
|
|
return { path, note: secondLine, contentLines: [] };
|
|
}
|
|
|
|
const match = secondLine.match(/^Lines (\d+)-(\d+|EOF) of (\d+):$/);
|
|
if (!match) {
|
|
return {
|
|
path,
|
|
note: '',
|
|
contentLines: lines.slice(1),
|
|
};
|
|
}
|
|
|
|
return {
|
|
path,
|
|
rangeLabel: `Lines ${match[1]}-${match[2]} of ${match[3]}`,
|
|
contentLines: lines.slice(2),
|
|
};
|
|
}
|
|
|
|
function renderReadFileResultHtml(parsed) {
|
|
const hasContent = Array.isArray(parsed.contentLines) && parsed.contentLines.length > 0;
|
|
const previewText = hasContent ? parsed.contentLines.join('\n') : '';
|
|
const note = parsed.note || '';
|
|
return `
|
|
<div class="tool-file-result space-y-2">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<div class="truncate text-sm font-semibold text-slate-900">${escapeHtml(parsed.path || 'File')}</div>
|
|
${parsed.rangeLabel ? `<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">${escapeHtml(parsed.rangeLabel)}</div>` : ''}
|
|
</div>
|
|
${hasContent ? `
|
|
<button type="button" class="tool-file-toggle rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-600 transition hover:border-slate-300 hover:text-slate-900" aria-expanded="false">
|
|
Show full file
|
|
</button>
|
|
` : ''}
|
|
</div>
|
|
${
|
|
hasContent
|
|
? `
|
|
<div class="tool-file-preview is-collapsed" data-tool-file-preview>
|
|
<pre>${escapeHtml(previewText)}</pre>
|
|
</div>
|
|
`
|
|
: `
|
|
<div class="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600">
|
|
${escapeHtml(note || 'No content')}
|
|
</div>
|
|
`
|
|
}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderToolResultSection(toolName, contentText, args, options = {}) {
|
|
if (options.placeholder) {
|
|
return renderToolPlaceholder('Waiting for result...');
|
|
}
|
|
|
|
const normalizedName = String(toolName || '').toLowerCase();
|
|
const rawText = String(contentText || '');
|
|
if (normalizedName === 'read_file') {
|
|
const parsed = parseReadFileResult(rawText);
|
|
if (parsed) {
|
|
return renderReadFileResultHtml(parsed);
|
|
}
|
|
}
|
|
|
|
const isError = rawText.trim().toLowerCase().startsWith('error:');
|
|
return renderToolTextBlock(rawText || 'No output', isError ? 'tool-text-block-error' : '');
|
|
}
|
|
|
|
function wireReadFilePreviewToggle(root) {
|
|
const previewEl = root.querySelector('[data-tool-file-preview]');
|
|
const toggleBtn = root.querySelector('.tool-file-toggle');
|
|
if (!previewEl || !toggleBtn) {
|
|
return;
|
|
}
|
|
|
|
toggleBtn.addEventListener('click', () => {
|
|
const expanded = previewEl.classList.toggle('is-expanded');
|
|
previewEl.classList.toggle('is-collapsed', !expanded);
|
|
toggleBtn.textContent = expanded ? 'Collapse to 7 lines' : 'Show full file';
|
|
toggleBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
});
|
|
}
|
|
|
|
function createToolBubbleState(message) {
|
|
const toolName = String(message.name || '').trim();
|
|
const agent = String(message.agent || '').trim();
|
|
const totalTokens = Number(message.total_tokens ?? message.totalTokens ?? 0) || 0;
|
|
const tokenBadge = totalTokens > 0
|
|
? `<span class="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold tracking-normal text-slate-600">${escapeHtml(formatTokenCount(totalTokens))}</span>`
|
|
: '';
|
|
const args = parseJsonObject(message.content) || {};
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'mb-4';
|
|
bubble.innerHTML = `
|
|
<div class="flex items-start gap-3">
|
|
${assistantAvatarHtml('bg-slate-700')}
|
|
<div class="tool-bubble flex-1 max-w-[85%] rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-slate-800 shadow-sm">
|
|
<div class="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">
|
|
<span>${escapeHtml(humanizeToolName(toolName))}</span>
|
|
${agent ? `<span class="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold tracking-normal text-slate-600">${escapeHtml(agent)}</span>` : ''}
|
|
${tokenBadge}
|
|
</div>
|
|
<div class="mt-3 tool-execution"></div>
|
|
<div class="mt-3 tool-result"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const executionBody = bubble.querySelector('.tool-execution');
|
|
const resultBody = bubble.querySelector('.tool-result');
|
|
const state = {
|
|
bubble,
|
|
executionBody,
|
|
resultBody,
|
|
toolName,
|
|
agent,
|
|
args,
|
|
resolved: false,
|
|
};
|
|
executionBody.innerHTML = renderToolExecutionSection(toolName, args, message.content || '');
|
|
resultBody.innerHTML = renderToolResultSection(toolName, '', args, { placeholder: true });
|
|
return state;
|
|
}
|
|
|
|
function createStandaloneToolResultBubble(message) {
|
|
const toolName = String(message.name || '').trim();
|
|
const agent = String(message.agent || '').trim();
|
|
const totalTokens = Number(message.total_tokens ?? message.totalTokens ?? 0) || 0;
|
|
const tokenBadge = totalTokens > 0
|
|
? `<span class="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold tracking-normal text-slate-600">${escapeHtml(formatTokenCount(totalTokens))}</span>`
|
|
: '';
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'mb-4';
|
|
bubble.innerHTML = `
|
|
<div class="flex items-start gap-3">
|
|
${assistantAvatarHtml('bg-slate-700')}
|
|
<div class="tool-bubble flex-1 max-w-[85%] rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-slate-800 shadow-sm">
|
|
<div class="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">
|
|
<span>${escapeHtml(humanizeToolName(toolName))}</span>
|
|
${agent ? `<span class="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold tracking-normal text-slate-600">${escapeHtml(agent)}</span>` : ''}
|
|
${tokenBadge}
|
|
</div>
|
|
<div class="mt-3 tool-execution"></div>
|
|
<div class="mt-3 tool-result"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const executionBody = bubble.querySelector('.tool-execution');
|
|
const resultBody = bubble.querySelector('.tool-result');
|
|
executionBody.innerHTML = renderToolPlaceholder('Execution details unavailable.');
|
|
resultBody.innerHTML = renderToolResultSection(toolName, message.content || '', {}, {});
|
|
wireReadFilePreviewToggle(resultBody);
|
|
return bubble;
|
|
}
|
|
|
|
function updatePendingToolBubbleResult(state, message) {
|
|
if (!state || !state.resultBody) {
|
|
return;
|
|
}
|
|
state.resultBody.innerHTML = renderToolResultSection(state.toolName, message.content || '', state.args);
|
|
state.resolved = true;
|
|
wireReadFilePreviewToggle(state.resultBody);
|
|
}
|
|
|
|
function finalizePendingToolBubble() {
|
|
const pending = conversationRenderState.pendingToolBubble;
|
|
if (!pending) {
|
|
return;
|
|
}
|
|
if (!pending.resolved && pending.resultBody) {
|
|
pending.resultBody.innerHTML = renderToolPlaceholder('No result recorded.');
|
|
}
|
|
conversationRenderState.pendingToolBubble = null;
|
|
}
|
|
|
|
function buildThinkingPanelHtml(content, options = {}) {
|
|
const label = options.label || 'Reasoning';
|
|
const tokenBadge = options.tokenBadge || '';
|
|
const openAttr = (options.open ?? reasoningAutoOpenEnabled()) ? ' open' : '';
|
|
return `
|
|
<details class="thinking-panel" ${openAttr}>
|
|
<summary class="flex cursor-pointer list-none items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white/80 px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">
|
|
<span class="flex min-w-0 items-center gap-2">
|
|
<svg class="thinking-chevron h-3.5 w-3.5 shrink-0 text-slate-400 transition-transform" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="m9 18 6-6-6-6" />
|
|
</svg>
|
|
<span class="thinking-summary-label truncate">${escapeHtml(label)}</span>
|
|
</span>
|
|
${tokenBadge}
|
|
</summary>
|
|
<div class="thinking-body markdown-body mt-3 text-sm">${renderMarkdown(completeIncompleteBlocks(content || ''))}</div>
|
|
</details>
|
|
`;
|
|
}
|
|
|
|
async function loadGroups() {
|
|
try {
|
|
const res = await fetch('/api/groups');
|
|
groups = await res.json();
|
|
} catch (err) {
|
|
console.error('Failed to load groups:', err);
|
|
}
|
|
}
|
|
|
|
async function loadConversations() {
|
|
await loadGroups();
|
|
try {
|
|
const res = await fetch('/api/conversations');
|
|
const data = await res.json();
|
|
const listEl = document.getElementById('conversationList');
|
|
listEl.innerHTML = '';
|
|
|
|
// Render groups
|
|
for (const group of groups) {
|
|
const apiGroup = data.groups.find(g => g.id === group.id);
|
|
const groupConvs = (apiGroup?.conversations || []).filter(Boolean);
|
|
|
|
|
|
const groupContainer = document.createElement('div');
|
|
const isExpanded = expandedGroups.has(group.id);
|
|
|
|
// Group header
|
|
const groupHeader = document.createElement('div');
|
|
groupHeader.className = 'flex items-center gap-2 px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-50 rounded-lg transition-colors';
|
|
groupHeader.innerHTML = `
|
|
<span class="transition-transform ${isExpanded ? 'rotate-90' : ''}">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
|
|
</span>
|
|
<span class="flex-1 truncate">${escapeHtml(group.name)}</span>
|
|
<span class="text-gray-400 text-xs">${groupConvs.length}</span>
|
|
${Number(group.total_tokens || 0) > 0 ? `<span class="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold tracking-normal text-slate-600 whitespace-nowrap">${escapeHtml(formatTokenCount(group.total_tokens))}</span>` : ''}
|
|
<button class="group-btn-new opacity-40 hover:opacity-100 hover:text-blue-600 transition-all p-0.5" title="New conversation">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
</button>
|
|
<button class="group-btn-settings opacity-40 hover:opacity-100 hover:text-gray-700 transition-all p-0.5" title="Settings">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
</button>
|
|
<button class="group-btn-delete opacity-40 hover:opacity-100 hover:text-red-500 transition-all p-0.5" title="Delete workspace">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-2 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
|
|
</button>
|
|
`;
|
|
|
|
const deleteBtn = groupHeader.querySelector('.group-btn-delete');
|
|
deleteBtn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
if (confirm(`Delete workspace "${group.name}"?`)) {
|
|
await deleteGroup(group.id);
|
|
}
|
|
});
|
|
|
|
const newConvBtn = groupHeader.querySelector('.group-btn-new');
|
|
newConvBtn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
await createConversationForGroup(group.id);
|
|
});
|
|
|
|
const settingsBtn = groupHeader.querySelector('.group-btn-settings');
|
|
settingsBtn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
await openGroupSettings(group.id);
|
|
});
|
|
|
|
groupHeader.addEventListener('click', () => {
|
|
selectedGroupId = group.id;
|
|
if (expandedGroups.has(group.id)) {
|
|
expandedGroups.delete(group.id);
|
|
} else {
|
|
expandedGroups.add(group.id);
|
|
}
|
|
loadConversations();
|
|
});
|
|
|
|
groupContainer.appendChild(groupHeader);
|
|
|
|
// Group conversations (only if expanded)
|
|
if (isExpanded) {
|
|
const groupConvList = document.createElement('div');
|
|
groupConvList.className = 'ml-4 space-y-1';
|
|
|
|
for (const conv of groupConvs) {
|
|
groupConvList.appendChild(createConversationItem(conv, group.id));
|
|
}
|
|
groupContainer.appendChild(groupConvList);
|
|
}
|
|
|
|
listEl.appendChild(groupContainer);
|
|
}
|
|
|
|
// Render ungrouped conversations
|
|
if (data.ungrouped.length > 0) {
|
|
const ungroupedHeader = document.createElement('div');
|
|
ungroupedHeader.className = 'flex items-center gap-2 px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider';
|
|
ungroupedHeader.innerHTML = `
|
|
<span class="w-3.5"></span>
|
|
<span class="flex-1 truncate">Ungrouped</span>
|
|
<span class="text-gray-400 text-xs">${data.ungrouped.length}</span>
|
|
`;
|
|
listEl.appendChild(ungroupedHeader);
|
|
|
|
const ungroupedList = document.createElement('div');
|
|
ungroupedList.className = 'space-y-1';
|
|
|
|
for (const conv of data.ungrouped) {
|
|
ungroupedList.appendChild(createConversationItem(conv, null));
|
|
}
|
|
listEl.appendChild(ungroupedList);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load conversations:', err);
|
|
}
|
|
}
|
|
|
|
function createConversationItem(conv, groupId) {
|
|
const item = document.createElement('div');
|
|
item.className = 'conversation-item px-3 py-2 rounded-lg cursor-pointer text-sm flex items-center gap-2 group transition-colors';
|
|
if (conv.id === currentConversationId) {
|
|
item.classList.add('bg-blue-100', 'text-blue-700');
|
|
} else {
|
|
item.classList.add('hover:bg-gray-100', 'text-gray-700');
|
|
}
|
|
item.dataset.id = conv.id;
|
|
|
|
const icon = document.createElement('span');
|
|
icon.className = 'text-gray-400 flex-shrink-0';
|
|
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
|
|
|
const title = document.createElement('span');
|
|
title.className = 'flex-1 truncate';
|
|
title.textContent = conv.title;
|
|
|
|
const tokenCount = Number(conv.total_tokens || 0);
|
|
const tokenBadge = document.createElement('span');
|
|
tokenBadge.className = 'rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold tracking-normal text-slate-600 whitespace-nowrap';
|
|
tokenBadge.textContent = formatTokenCount(tokenCount);
|
|
if (!tokenBadge.textContent) {
|
|
tokenBadge.remove();
|
|
}
|
|
|
|
const deleteBtn = document.createElement('button');
|
|
deleteBtn.className = 'opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-all flex-shrink-0 p-0.5';
|
|
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>';
|
|
deleteBtn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
if (confirm('Delete this conversation?')) {
|
|
await deleteConversation(conv.id);
|
|
}
|
|
});
|
|
|
|
item.appendChild(icon);
|
|
item.appendChild(title);
|
|
if (tokenBadge.textContent) {
|
|
item.appendChild(tokenBadge);
|
|
}
|
|
item.appendChild(deleteBtn);
|
|
|
|
item.addEventListener('click', () => {
|
|
if (groupId) selectedGroupId = groupId;
|
|
loadConversation(conv.id, groupId);
|
|
});
|
|
|
|
return item;
|
|
}
|
|
|
|
async function loadConversation(convId, groupId) {
|
|
currentConversationId = convId;
|
|
isStreaming = false;
|
|
if (abortController) {
|
|
abortController.abort();
|
|
abortController = null;
|
|
}
|
|
|
|
const resultEl = document.getElementById('result');
|
|
const resultText = document.getElementById('resultText');
|
|
const emptyState = document.getElementById('emptyState');
|
|
resultEl.classList.remove('hidden');
|
|
emptyState.classList.add('hidden');
|
|
resultText.innerHTML = '';
|
|
resetConversationRenderState();
|
|
|
|
try {
|
|
const res = await fetch(`/api/conversations/${convId}/messages`);
|
|
const messages = await res.json();
|
|
|
|
messages.forEach(msg => {
|
|
appendConversationMessage(msg);
|
|
});
|
|
finalizePendingToolBubble();
|
|
|
|
const emptyState = document.getElementById('emptyState');
|
|
if (messages.length === 0) {
|
|
emptyState.classList.remove('hidden');
|
|
} else {
|
|
emptyState.classList.add('hidden');
|
|
}
|
|
|
|
document.getElementById('messageInput').focus();
|
|
} catch (err) {
|
|
console.error('Failed to load conversation:', err);
|
|
}
|
|
|
|
await loadConversations();
|
|
|
|
if (groupId) {
|
|
await showGroupLocationInHeader(groupId);
|
|
} else {
|
|
const locEl = document.getElementById('headerLocation');
|
|
locEl.classList.add('hidden');
|
|
locEl.classList.remove('flex');
|
|
}
|
|
}
|
|
|
|
async function createConversation() {
|
|
try {
|
|
const body = { title: 'New Conversation' };
|
|
const groupId = selectedGroupId || currentGroupFilter || undefined;
|
|
if (groupId) {
|
|
body.group_id = groupId;
|
|
}
|
|
const res = await fetch('/api/conversations', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const conv = await res.json();
|
|
await loadConversation(conv.id, groupId);
|
|
} catch (err) {
|
|
console.error('Failed to create conversation:', err);
|
|
}
|
|
}
|
|
|
|
async function deleteConversation(convId) {
|
|
try {
|
|
await fetch(`/api/conversations/${convId}`, { method: 'DELETE' });
|
|
if (currentConversationId === convId) {
|
|
currentConversationId = null;
|
|
const resultEl = document.getElementById('result');
|
|
const resultText = document.getElementById('resultText');
|
|
resultEl.classList.add('hidden');
|
|
resultText.innerHTML = '';
|
|
resetConversationRenderState();
|
|
document.getElementById('messageInput').value = '';
|
|
}
|
|
await loadConversations();
|
|
} catch (err) {
|
|
console.error('Failed to delete conversation:', err);
|
|
}
|
|
}
|
|
|
|
async function createGroup() {
|
|
const modal = document.getElementById('groupModal');
|
|
modal.classList.remove('hidden');
|
|
modal.classList.add('flex');
|
|
const input = document.getElementById('groupNameInput');
|
|
input.value = '';
|
|
setTimeout(() => input.focus(), 50);
|
|
}
|
|
|
|
function closeGroupModal() {
|
|
const modal = document.getElementById('groupModal');
|
|
modal.classList.add('hidden');
|
|
modal.classList.remove('flex');
|
|
}
|
|
|
|
async function createGroupFromModal() {
|
|
const input = document.getElementById('groupNameInput');
|
|
const name = input.value.trim();
|
|
if (!name) return;
|
|
try {
|
|
const res = await fetch('/api/groups', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
const group = await res.json();
|
|
expandedGroups.add(group.id);
|
|
currentGroupFilter = group.id;
|
|
closeGroupModal();
|
|
await loadConversations();
|
|
await loadGroupLocation();
|
|
} catch (err) {
|
|
console.error('Failed to create group:', err);
|
|
}
|
|
}
|
|
|
|
async function createConversationForGroup(groupId) {
|
|
try {
|
|
const body = { title: 'New Conversation', group_id: groupId };
|
|
const res = await fetch('/api/conversations', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const conv = await res.json();
|
|
await loadConversation(conv.id, groupId);
|
|
} catch (err) {
|
|
console.error('Failed to create conversation:', err);
|
|
}
|
|
}
|
|
|
|
async function deleteGroup(groupId) {
|
|
groups = groups.filter(g => g.id !== groupId);
|
|
expandedGroups.delete(groupId);
|
|
if (currentGroupFilter === groupId) {
|
|
currentGroupFilter = null;
|
|
}
|
|
await loadConversations();
|
|
}
|
|
|
|
function assistantAvatarHtml(toneClass) {
|
|
return `
|
|
<div class="flex-shrink-0 w-8 h-8 ${toneClass} rounded-lg flex items-center justify-center shadow-sm">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 0 0-7 7c0 5.25 7 13 7 13s7-7.75 7-13a7 7 0 0 0-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function labelForMessage(kind, name, agent = '') {
|
|
if (agent) return agent;
|
|
if (name) return name;
|
|
switch ((kind || '').toLowerCase()) {
|
|
case 'planner':
|
|
return 'Planner';
|
|
case 'programmer':
|
|
return 'Programmer';
|
|
case 'qa':
|
|
return 'QA';
|
|
case 'manager':
|
|
return 'Manager';
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function toneForMessageKind(kind) {
|
|
switch ((kind || '').toLowerCase()) {
|
|
case 'planner':
|
|
return 'border-indigo-200 bg-indigo-50 text-indigo-950';
|
|
case 'programmer':
|
|
return 'border-blue-200 bg-blue-50 text-blue-950';
|
|
case 'qa':
|
|
return 'border-emerald-200 bg-emerald-50 text-emerald-950';
|
|
case 'manager':
|
|
return 'border-slate-200 bg-slate-50 text-slate-900';
|
|
default:
|
|
return 'border-gray-200 bg-gray-50 text-gray-800';
|
|
}
|
|
}
|
|
|
|
function scrollConversationToBottom() {
|
|
const chatArea = document.getElementById('chatScrollArea');
|
|
if (chatArea) {
|
|
chatArea.scrollTop = chatArea.scrollHeight;
|
|
return;
|
|
}
|
|
const resultText = document.getElementById('resultText');
|
|
if (resultText) {
|
|
resultText.scrollTop = resultText.scrollHeight;
|
|
}
|
|
}
|
|
|
|
function appendConversationMessage(message) {
|
|
const resultText = document.getElementById('resultText');
|
|
if (!resultText) return null;
|
|
|
|
const role = (message.role || 'assistant').toLowerCase();
|
|
const kind = (message.kind || 'message').toLowerCase();
|
|
const name = message.name || '';
|
|
const agent = message.agent || '';
|
|
const totalTokens = Number(message.total_tokens ?? message.totalTokens ?? 0) || 0;
|
|
const content = message.content || '';
|
|
const contentText = content.trim();
|
|
let msgDiv = document.createElement('div');
|
|
msgDiv.className = 'mb-4';
|
|
const tokenBadge = totalTokens > 0
|
|
? `<span class="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold tracking-normal text-slate-600">${escapeHtml(formatTokenCount(totalTokens))}</span>`
|
|
: '';
|
|
let shouldAppend = true;
|
|
|
|
if (role === 'user' && kind === 'message') {
|
|
msgDiv.innerHTML = `
|
|
<div class="flex justify-end">
|
|
<div class="max-w-[85%] rounded-2xl bg-blue-600 px-4 py-3 text-white shadow-sm whitespace-pre-wrap break-words">
|
|
${escapeHtml(content)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
} else if (kind === 'thinking') {
|
|
const label = labelForMessage(kind, name, agent) || 'Reasoning';
|
|
msgDiv.innerHTML = `
|
|
<div class="flex items-start gap-3">
|
|
${assistantAvatarHtml('bg-slate-700')}
|
|
<div class="flex-1 max-w-[85%] rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-slate-800 shadow-sm">
|
|
${buildThinkingPanelHtml(contentText || '', {
|
|
label,
|
|
tokenBadge,
|
|
})}
|
|
</div>
|
|
</div>
|
|
`;
|
|
} else if (kind === 'tool_call') {
|
|
if (conversationRenderState.pendingToolBubble && !conversationRenderState.pendingToolBubble.resolved) {
|
|
finalizePendingToolBubble();
|
|
}
|
|
const bubbleState = createToolBubbleState(message);
|
|
conversationRenderState.pendingToolBubble = bubbleState;
|
|
msgDiv = bubbleState.bubble;
|
|
} else if (kind === 'tool_result') {
|
|
const pending = conversationRenderState.pendingToolBubble;
|
|
const sameTool = pending &&
|
|
!pending.resolved &&
|
|
String(pending.toolName || '').trim() === String(name || '').trim() &&
|
|
String(pending.agent || '').trim() === String(agent || '').trim();
|
|
if (sameTool) {
|
|
updatePendingToolBubbleResult(pending, message);
|
|
msgDiv = pending.bubble;
|
|
shouldAppend = false;
|
|
conversationRenderState.pendingToolBubble = null;
|
|
} else {
|
|
if (pending && !pending.resolved) {
|
|
finalizePendingToolBubble();
|
|
}
|
|
conversationRenderState.pendingToolBubble = null;
|
|
msgDiv = createStandaloneToolResultBubble(message);
|
|
}
|
|
} else if (kind === 'error') {
|
|
msgDiv.innerHTML = `
|
|
<div class="flex items-start gap-3">
|
|
${assistantAvatarHtml('bg-red-600')}
|
|
<div class="flex-1 max-w-[85%] rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-red-900 shadow-sm">
|
|
<div class="mb-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-red-600">Error</div>
|
|
<div class="whitespace-pre-wrap font-mono text-xs leading-6">${escapeHtml(contentText || 'Unknown error')}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
} else if (kind === 'cancelled') {
|
|
msgDiv.innerHTML = `
|
|
<div class="flex items-start gap-3">
|
|
${assistantAvatarHtml('bg-slate-500')}
|
|
<div class="flex-1 max-w-[85%] rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-slate-700 shadow-sm">
|
|
<div class="mb-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Cancelled</div>
|
|
<div class="whitespace-pre-wrap font-mono text-xs leading-6">${escapeHtml(contentText || 'Request stopped')}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
const label = labelForMessage(kind, name, agent);
|
|
const tone = label === 'Manager' ? 'border-slate-200 bg-slate-50 text-slate-900' : toneForMessageKind(kind);
|
|
const avatarTone =
|
|
label === 'Manager' || kind === 'manager' ? 'bg-slate-700' :
|
|
kind === 'planner' ? 'bg-indigo-600' :
|
|
kind === 'programmer' ? 'bg-blue-600' :
|
|
kind === 'qa' ? 'bg-emerald-600' :
|
|
'bg-gray-800';
|
|
msgDiv.innerHTML = `
|
|
<div class="flex items-start gap-3">
|
|
${assistantAvatarHtml(avatarTone)}
|
|
<div class="flex-1 max-w-[85%] rounded-2xl border px-4 py-3 shadow-sm overflow-x-auto ${tone}">
|
|
${(label || tokenBadge) ? `<div class="mb-2 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">${label ? `<span>${escapeHtml(label)}</span>` : ''}${tokenBadge}</div>` : ''}
|
|
<div class="markdown-body">${renderMarkdown(completeIncompleteBlocks(contentText || ''))}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (shouldAppend) {
|
|
resultText.appendChild(msgDiv);
|
|
}
|
|
if (activeStreamUi && typeof activeStreamUi.moveToEnd === 'function') {
|
|
activeStreamUi.moveToEnd();
|
|
}
|
|
scrollConversationToBottom();
|
|
return msgDiv;
|
|
}
|
|
|
|
function statusLabelForKind(kind) {
|
|
switch ((kind || '').toLowerCase()) {
|
|
case 'planner':
|
|
return 'Planner';
|
|
case 'programmer':
|
|
return 'Programmer';
|
|
case 'qa':
|
|
return 'QA';
|
|
case 'manager':
|
|
return 'Manager';
|
|
case 'tool_call':
|
|
case 'tool_result':
|
|
case 'tool':
|
|
return 'Tool';
|
|
case 'streaming':
|
|
return 'Streaming';
|
|
case 'done':
|
|
return 'Done';
|
|
case 'cancelled':
|
|
return 'Cancelled';
|
|
case 'error':
|
|
return 'Error';
|
|
case 'thinking':
|
|
return 'Thinking';
|
|
default:
|
|
return 'Working';
|
|
}
|
|
}
|
|
|
|
function createStreamingBubble(initialStatus) {
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'mb-4';
|
|
bubble.innerHTML = `
|
|
<div class="flex items-start gap-3">
|
|
${assistantAvatarHtml('bg-gray-800')}
|
|
<div class="flex-1 max-w-[85%] rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-gray-800 shadow-sm overflow-x-auto">
|
|
<div class="stream-status">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="min-w-0">
|
|
<div class="mb-1 flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">
|
|
<span class="stream-status-label">Working</span>
|
|
<span class="stream-status-step hidden rounded-full bg-slate-900 px-3 py-1 text-xs font-semibold uppercase tracking-normal text-white shadow-sm"></span>
|
|
</div>
|
|
<div class="stream-status-text text-sm leading-6 text-slate-600"></div>
|
|
</div>
|
|
<div class="flex flex-col items-end gap-2 text-right">
|
|
<span class="stream-status-elapsed rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium tracking-normal text-slate-500">0:00 elapsed</span>
|
|
<span class="stream-status-tokens hidden rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-semibold tracking-normal text-indigo-700"></span>
|
|
<span class="stream-status-indicator loading-dots text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Working<span class="dot">.</span><span class="dot">.</span><span class="dot">.</span></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="stream-thinking hidden mt-3 rounded-2xl border border-slate-200 bg-white/80 px-4 py-3 shadow-sm"></div>
|
|
<div class="stream-content hidden markdown-body mt-3"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const statusLabelEl = bubble.querySelector('.stream-status-label');
|
|
const statusStepEl = bubble.querySelector('.stream-status-step');
|
|
const statusTextEl = bubble.querySelector('.stream-status-text');
|
|
const statusElapsedEl = bubble.querySelector('.stream-status-elapsed');
|
|
const statusTokensEl = bubble.querySelector('.stream-status-tokens');
|
|
const statusIndicatorEl = bubble.querySelector('.stream-status-indicator');
|
|
const thinkingContainerEl = bubble.querySelector('.stream-thinking');
|
|
const contentEl = bubble.querySelector('.stream-content');
|
|
const startTime = Date.now();
|
|
let timerId = null;
|
|
let autoHideTimerId = null;
|
|
let reasoningText = '';
|
|
let thinkingPanelEl = null;
|
|
let thinkingBodyEl = null;
|
|
let thinkingTokensEl = null;
|
|
let reasoningCollapsedByUser = false;
|
|
if (initialStatus) {
|
|
statusTextEl.textContent = initialStatus;
|
|
}
|
|
let contentMode = false;
|
|
let finalized = false;
|
|
let lastStep = 0;
|
|
let lastTotal = 0;
|
|
|
|
function updateElapsed() {
|
|
if (statusElapsedEl) {
|
|
statusElapsedEl.textContent = `${formatElapsed(Date.now() - startTime)} elapsed`;
|
|
}
|
|
}
|
|
|
|
function updateTokens(totalTokens) {
|
|
if (!statusTokensEl) return;
|
|
const label = formatTokenCount(totalTokens);
|
|
statusTokensEl.textContent = label;
|
|
statusTokensEl.classList.toggle('hidden', !label);
|
|
if (thinkingTokensEl) {
|
|
thinkingTokensEl.textContent = label;
|
|
thinkingTokensEl.classList.toggle('hidden', !label);
|
|
}
|
|
}
|
|
|
|
function updateProgress(step, total) {
|
|
if (Number.isFinite(step) && step > 0) {
|
|
lastStep = step;
|
|
}
|
|
if (Number.isFinite(total) && total > 0) {
|
|
lastTotal = total;
|
|
}
|
|
if (statusStepEl) {
|
|
const label = formatStepLabel(lastStep, lastTotal);
|
|
statusStepEl.textContent = label;
|
|
statusStepEl.classList.toggle('hidden', !label);
|
|
}
|
|
}
|
|
|
|
function clearAutoHideTimer() {
|
|
if (autoHideTimerId) {
|
|
window.clearTimeout(autoHideTimerId);
|
|
autoHideTimerId = null;
|
|
}
|
|
}
|
|
|
|
function stopTimer() {
|
|
if (timerId) {
|
|
window.clearInterval(timerId);
|
|
timerId = null;
|
|
}
|
|
}
|
|
|
|
function ensureThinkingPanel() {
|
|
if (!thinkingContainerEl || thinkingPanelEl) {
|
|
return;
|
|
}
|
|
thinkingContainerEl.classList.remove('hidden');
|
|
thinkingContainerEl.innerHTML = `
|
|
<details class="thinking-panel"${reasoningAutoOpenEnabled() ? ' open' : ''}>
|
|
<summary class="flex cursor-pointer list-none items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white/80 px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">
|
|
<span class="flex min-w-0 items-center gap-2">
|
|
<svg class="thinking-chevron h-3.5 w-3.5 shrink-0 text-slate-400 transition-transform" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="m9 18 6-6-6-6" />
|
|
</svg>
|
|
<span class="thinking-summary-label truncate">Reasoning</span>
|
|
</span>
|
|
<span class="thinking-summary-tokens hidden rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold tracking-normal text-slate-600"></span>
|
|
</summary>
|
|
<div class="thinking-body markdown-body mt-3 text-sm"></div>
|
|
</details>
|
|
`;
|
|
thinkingPanelEl = thinkingContainerEl.querySelector('.thinking-panel');
|
|
thinkingBodyEl = thinkingContainerEl.querySelector('.thinking-body');
|
|
thinkingTokensEl = thinkingContainerEl.querySelector('.thinking-summary-tokens');
|
|
if (thinkingPanelEl) {
|
|
thinkingPanelEl.addEventListener('toggle', () => {
|
|
if (thinkingPanelEl.open) {
|
|
reasoningCollapsedByUser = false;
|
|
} else if (reasoningText) {
|
|
reasoningCollapsedByUser = true;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function updateThinkingBody() {
|
|
if (!thinkingBodyEl) return;
|
|
thinkingBodyEl.innerHTML = renderMarkdown(completeIncompleteBlocks(reasoningText));
|
|
}
|
|
|
|
function appendReasoningChunk(chunk) {
|
|
if (!chunk) return;
|
|
reasoningText += chunk;
|
|
ensureThinkingPanel();
|
|
updateThinkingBody();
|
|
if (thinkingPanelEl && reasoningAutoOpenEnabled() && !reasoningCollapsedByUser) {
|
|
thinkingPanelEl.open = true;
|
|
}
|
|
api.moveToEnd();
|
|
}
|
|
|
|
timerId = window.setInterval(updateElapsed, 1000);
|
|
updateElapsed();
|
|
|
|
const api = {
|
|
bubble,
|
|
contentEl,
|
|
hasStructuredMessages: false,
|
|
moveToEnd() {
|
|
if (bubble.parentNode) {
|
|
bubble.parentNode.appendChild(bubble);
|
|
}
|
|
},
|
|
setStatus(text, kind, step, total) {
|
|
if (finalized) return;
|
|
statusLabelEl.textContent = statusLabelForKind(kind);
|
|
statusTextEl.textContent = text || '';
|
|
if (statusIndicatorEl) {
|
|
const activeKind = (kind || '').toLowerCase();
|
|
statusIndicatorEl.classList.toggle('hidden', activeKind === 'done' || activeKind === 'cancelled' || activeKind === 'error');
|
|
}
|
|
updateProgress(step, total);
|
|
updateElapsed();
|
|
api.moveToEnd();
|
|
},
|
|
setTokenUsage(totalTokens) {
|
|
updateTokens(totalTokens);
|
|
api.moveToEnd();
|
|
},
|
|
appendReasoning(chunk) {
|
|
appendReasoningChunk(chunk);
|
|
scrollConversationToBottom();
|
|
},
|
|
showContent() {
|
|
if (contentMode) return;
|
|
contentMode = true;
|
|
contentEl.classList.remove('hidden');
|
|
api.moveToEnd();
|
|
},
|
|
finish(options = {}) {
|
|
if (finalized) return;
|
|
finalized = true;
|
|
const kind = options.kind || 'done';
|
|
statusLabelEl.textContent = statusLabelForKind(kind);
|
|
statusTextEl.textContent = options.text || 'Completed';
|
|
if (statusIndicatorEl) {
|
|
statusIndicatorEl.classList.add('hidden');
|
|
}
|
|
updateElapsed();
|
|
stopTimer();
|
|
clearAutoHideTimer();
|
|
api.moveToEnd();
|
|
if (options.autoHideMs && options.autoHideMs > 0) {
|
|
autoHideTimerId = window.setTimeout(() => api.destroy(), options.autoHideMs);
|
|
}
|
|
},
|
|
cancel(text, autoHideMs) {
|
|
api.finish({
|
|
kind: 'cancelled',
|
|
text: text || 'Stopped by you',
|
|
autoHideMs: autoHideMs || 0,
|
|
});
|
|
},
|
|
fail(text, autoHideMs) {
|
|
api.finish({
|
|
kind: 'error',
|
|
text: text || 'Request failed',
|
|
autoHideMs: autoHideMs || 0,
|
|
});
|
|
},
|
|
destroy() {
|
|
clearAutoHideTimer();
|
|
stopTimer();
|
|
if (bubble.parentNode) {
|
|
bubble.remove();
|
|
}
|
|
if (activeStreamUi === api) {
|
|
activeStreamUi = null;
|
|
}
|
|
},
|
|
};
|
|
|
|
return api;
|
|
}
|
|
|
|
async function sendMessage() {
|
|
const input = document.getElementById('messageInput');
|
|
const rawMessage = input.value;
|
|
const message = rawMessage.trim();
|
|
if (!message || isStreaming) return;
|
|
|
|
isStreaming = true;
|
|
cancelRequested = false;
|
|
updateStreamingControls();
|
|
|
|
const resultEl = document.getElementById('result');
|
|
const resultText = document.getElementById('resultText');
|
|
const emptyState = document.getElementById('emptyState');
|
|
resultEl.classList.remove('hidden');
|
|
emptyState.classList.add('hidden');
|
|
|
|
// Append user message
|
|
appendConversationMessage({ role: 'user', kind: 'message', content: rawMessage });
|
|
input.value = '';
|
|
resizeMessageInput(input);
|
|
|
|
// Create a transient bubble that can show live status, then morph into streamed content.
|
|
const streamUi = createStreamingBubble('Waiting for the agent...');
|
|
activeStreamUi = streamUi;
|
|
resultText.appendChild(streamUi.bubble);
|
|
scrollConversationToBottom();
|
|
let structuredStreamSeen = false;
|
|
|
|
abortController = new AbortController();
|
|
const requestSignal = abortController.signal;
|
|
|
|
// Get conversation ID or create new one
|
|
let convId = currentConversationId;
|
|
if (!convId) {
|
|
const titleText = message.replace(/\s+/g, ' ').trim();
|
|
const body = { title: titleText.substring(0, 50) };
|
|
let groupId = selectedGroupId || currentGroupFilter;
|
|
if (groupId) {
|
|
body.group_id = groupId;
|
|
}
|
|
const res = await fetch('/api/conversations', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
signal: requestSignal,
|
|
});
|
|
const conv = await res.json();
|
|
convId = conv.id;
|
|
currentConversationId = convId;
|
|
await loadConversations();
|
|
}
|
|
|
|
const groupId = selectedGroupId || currentGroupFilter || undefined;
|
|
|
|
try {
|
|
const fetchRes = await fetch('/api/echo', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
message: message,
|
|
conversation_id: convId,
|
|
group_id: groupId,
|
|
agent_types: selectedAgent === 'none' ? [] : [selectedAgent],
|
|
}),
|
|
signal: requestSignal,
|
|
});
|
|
|
|
if (!fetchRes.body) {
|
|
throw new Error('No response body');
|
|
}
|
|
|
|
const reader = fetchRes.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
let fullContent = '';
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop();
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
const data = line.slice(6);
|
|
if (data === '[DONE]') continue;
|
|
let parsed = null;
|
|
try {
|
|
parsed = JSON.parse(data);
|
|
} catch (jsonErr) {
|
|
parsed = null;
|
|
}
|
|
|
|
if (parsed && typeof parsed === 'object' && parsed.gocoder_event === 'conversation_message' && typeof parsed.kind === 'string') {
|
|
structuredStreamSeen = true;
|
|
if (activeStreamUi) {
|
|
activeStreamUi.hasStructuredMessages = true;
|
|
}
|
|
appendConversationMessage(parsed);
|
|
if (activeStreamUi && parsed.kind === 'message') {
|
|
activeStreamUi.finish({
|
|
kind: 'done',
|
|
text: 'Completed',
|
|
autoHideMs: 1200,
|
|
});
|
|
} else if (activeStreamUi && parsed.kind === 'error') {
|
|
activeStreamUi.fail(parsed.content || 'Request failed');
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (parsed && typeof parsed === 'object' && parsed.gocoder_event === 'status') {
|
|
if (activeStreamUi && activeStreamUi.setStatus) {
|
|
activeStreamUi.setStatus(parsed.content || 'Working...', parsed.kind || 'thinking', parsed.step, parsed.total_steps);
|
|
scrollConversationToBottom();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (parsed && typeof parsed === 'object' && parsed.gocoder_event === 'stream_usage') {
|
|
if (activeStreamUi && activeStreamUi.setTokenUsage) {
|
|
activeStreamUi.setTokenUsage(parsed.total_tokens || 0);
|
|
scrollConversationToBottom();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (parsed && typeof parsed === 'object' && parsed.gocoder_event === 'thinking') {
|
|
if (activeStreamUi && activeStreamUi.appendReasoning) {
|
|
activeStreamUi.appendReasoning(parsed.content || '');
|
|
scrollConversationToBottom();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (activeStreamUi && activeStreamUi.showContent) {
|
|
activeStreamUi.showContent();
|
|
}
|
|
fullContent += data.replace(/\\n/g, '\n');
|
|
if (activeStreamUi && activeStreamUi.contentEl) {
|
|
activeStreamUi.contentEl.innerHTML = renderMarkdown(completeIncompleteBlocks(fullContent));
|
|
scrollConversationToBottom();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (activeStreamUi) {
|
|
activeStreamUi.finish({
|
|
kind: 'done',
|
|
text: structuredStreamSeen ? 'Completed' : 'Completed',
|
|
autoHideMs: structuredStreamSeen ? 1200 : 0,
|
|
});
|
|
}
|
|
|
|
} catch (err) {
|
|
if (err.name !== 'AbortError') {
|
|
console.error('Stream error:', err);
|
|
if (activeStreamUi) {
|
|
activeStreamUi.fail(`Error: ${err.message}`);
|
|
}
|
|
appendConversationMessage({ role: 'assistant', kind: 'error', content: 'Error: ' + err.message });
|
|
} else if (cancelRequested && activeStreamUi) {
|
|
activeStreamUi.cancel('Stopped by you', activeStreamUi.hasStructuredMessages ? 1200 : 0);
|
|
} else if (activeStreamUi) {
|
|
activeStreamUi.fail('Request aborted', activeStreamUi.hasStructuredMessages ? 1200 : 0);
|
|
}
|
|
} finally {
|
|
isStreaming = false;
|
|
abortController = null;
|
|
cancelRequested = false;
|
|
activeStreamUi = null;
|
|
updateStreamingControls();
|
|
input.focus();
|
|
loadConversations();
|
|
}
|
|
}
|
|
|
|
async function showGroupLocationInHeader(groupId) {
|
|
const group = groups.find(g => g.id === groupId);
|
|
if (!group) {
|
|
const locEl = document.getElementById('headerLocation');
|
|
locEl.classList.add('hidden');
|
|
locEl.classList.remove('flex');
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetch(`/api/groups/${groupId}?action=get_location`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.location) {
|
|
groupLocations[groupId] = data.location;
|
|
} else {
|
|
delete groupLocations[groupId];
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load group location:', err);
|
|
}
|
|
const locEl = document.getElementById('headerLocation');
|
|
const locTextEl = document.getElementById('headerLocationText');
|
|
if (groupLocations[groupId]) {
|
|
locEl.classList.remove('hidden');
|
|
locEl.classList.add('flex');
|
|
locTextEl.textContent = groupLocations[groupId];
|
|
} else {
|
|
locEl.classList.add('hidden');
|
|
locEl.classList.remove('flex');
|
|
}
|
|
renderAgentButtons();
|
|
}
|
|
|
|
async function loadGroupLocation(groupId) {
|
|
if (groups.length === 0) return;
|
|
const targetGroupId = currentSettingsGroupId || groupId || selectedGroupId;
|
|
const group = targetGroupId ? groups.find(g => g.id === targetGroupId) || groups[0] : groups[0];
|
|
try {
|
|
const res = await fetch(`/api/groups/${group.id}?action=get_location`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.location) {
|
|
groupLocations[group.id] = data.location;
|
|
showLocation(data.location, true, group.id);
|
|
} else {
|
|
delete groupLocations[group.id];
|
|
showLocation(null, false, group.id);
|
|
}
|
|
renderAgentButtons();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load group location:', err);
|
|
}
|
|
}
|
|
|
|
function showLocation(location, hasLocation, groupId) {
|
|
// Always update header location
|
|
const headerLocationEl = document.getElementById('headerLocation');
|
|
const headerLocationTextEl = document.getElementById('headerLocationText');
|
|
const headerPickBtn = document.getElementById('pickLocationBtn');
|
|
const headerChangeBtn = document.getElementById('changeLocationBtn');
|
|
|
|
if (hasLocation && location) {
|
|
headerLocationEl.classList.remove('hidden');
|
|
headerLocationEl.classList.add('flex');
|
|
headerLocationTextEl.textContent = location;
|
|
headerPickBtn.classList.add('hidden');
|
|
headerPickBtn.classList.remove('flex');
|
|
headerChangeBtn.classList.remove('hidden');
|
|
headerChangeBtn.classList.add('flex');
|
|
} else {
|
|
headerLocationEl.classList.add('hidden');
|
|
headerLocationEl.classList.remove('flex');
|
|
headerPickBtn.classList.remove('hidden');
|
|
headerPickBtn.classList.add('flex');
|
|
}
|
|
|
|
if (groupId) {
|
|
// Per-group location (settings modal)
|
|
const locationEl = document.getElementById('groupSettingsLocation');
|
|
const locationTextEl = document.getElementById('groupSettingsLocationText');
|
|
const pickBtn = document.getElementById('groupSettingsPickBtn');
|
|
|
|
if (hasLocation && location) {
|
|
locationEl.classList.remove('hidden');
|
|
locationEl.classList.add('flex');
|
|
locationTextEl.textContent = location;
|
|
pickBtn.classList.add('hidden');
|
|
pickBtn.classList.remove('flex');
|
|
} else {
|
|
locationEl.classList.add('hidden');
|
|
locationEl.classList.remove('flex');
|
|
pickBtn.classList.remove('hidden');
|
|
pickBtn.classList.add('flex');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function pickFolderLocation(groupId) {
|
|
if (typeof window.electronAPI !== 'undefined' && window.electronAPI.pickFolder) {
|
|
try {
|
|
const result = await window.electronAPI.pickFolder();
|
|
if (result && result.path) {
|
|
await saveGroupLocation(result.path, groupId);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to pick folder:', err);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Try File System Access API (Chromium browsers only)
|
|
if (typeof window.showDirectoryPicker === 'function') {
|
|
try {
|
|
const dirHandle = await window.showDirectoryPicker();
|
|
const path = dirHandle.name;
|
|
if (path) {
|
|
await saveGroupLocation(path, groupId);
|
|
}
|
|
return;
|
|
} catch (err) {
|
|
if (err.name !== 'AbortError') {
|
|
console.error('File System Access API failed:', err);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: prompt user to enter folder path
|
|
const path = prompt('Enter the full path to your workspace folder:');
|
|
if (path && path.trim()) {
|
|
await saveGroupLocation(path.trim(), groupId);
|
|
}
|
|
}
|
|
|
|
async function saveGroupLocation(location, groupId) {
|
|
const group = groupId ? groups.find(g => g.id === groupId) || groups[0] : (selectedGroupId ? groups.find(g => g.id === selectedGroupId) || groups[0] : groups[0]);
|
|
try {
|
|
const res = await fetch(`/api/groups/${group.id}?action=set_location`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ location }),
|
|
});
|
|
if (res.ok) {
|
|
groupLocations[group.id] = location;
|
|
if (groupId) {
|
|
selectedGroupId = groupId;
|
|
await loadGroupLocation(groupId);
|
|
} else {
|
|
await loadGroupLocation();
|
|
}
|
|
await loadConversations();
|
|
renderAgentButtons();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to save group location:', err);
|
|
}
|
|
}
|
|
|
|
async function changeGroupLocation(groupId) {
|
|
await pickFolderLocation(groupId);
|
|
}
|
|
|
|
async function loadAgents() {
|
|
try {
|
|
const res = await fetch('/api/agents');
|
|
agents = await res.json();
|
|
renderAgentButtons();
|
|
} catch (err) {
|
|
console.error('Failed to load agents:', err);
|
|
}
|
|
}
|
|
|
|
function renderAgentButtons() {
|
|
const container = document.getElementById('agentSelector');
|
|
if (!container || agents.length === 0) return;
|
|
|
|
container.innerHTML = '';
|
|
|
|
const noneBtn = document.createElement('button');
|
|
noneBtn.type = 'button';
|
|
noneBtn.dataset.agent = 'none';
|
|
noneBtn.className = `agent-btn px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 text-gray-600 hover:bg-gray-100 transition-colors ${selectedAgent === 'none' ? 'bg-blue-50 border-blue-300 text-blue-700' : ''}`;
|
|
noneBtn.textContent = 'None';
|
|
noneBtn.addEventListener('click', () => {
|
|
selectedAgent = 'none';
|
|
renderAgentButtons();
|
|
});
|
|
container.appendChild(noneBtn);
|
|
|
|
for (const agent of agents) {
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.dataset.agent = agent.type;
|
|
|
|
const requiresWorkspace = agent.requires_workspace;
|
|
const hasWorkspace = selectedGroupId && groupLocations[selectedGroupId];
|
|
const hasWorkspaceNeeded = requiresWorkspace && !hasWorkspace;
|
|
|
|
const baseClass = `agent-btn px-3 py-1 text-xs font-medium rounded-lg border text-gray-600 hover:bg-gray-100 transition-colors ${selectedAgent === agent.type ? 'bg-blue-50 border-blue-300 text-blue-700' : ''}`;
|
|
|
|
if (hasWorkspaceNeeded) {
|
|
btn.className = `${baseClass} opacity-50 cursor-not-allowed`;
|
|
btn.title = `To use ${agent.display_name}, you must set a workspace folder location in workspace settings`;
|
|
btn.disabled = true;
|
|
} else {
|
|
btn.className = baseClass;
|
|
btn.title = agent.description;
|
|
btn.addEventListener('click', () => {
|
|
selectedAgent = agent.type;
|
|
renderAgentButtons();
|
|
});
|
|
}
|
|
|
|
btn.innerHTML = `
|
|
<span class="inline-flex items-center gap-1.5">
|
|
<span class="w-4 h-4 inline-flex items-center justify-center">${agent.icon}</span>
|
|
${agent.display_name}
|
|
</span>
|
|
`;
|
|
container.appendChild(btn);
|
|
}
|
|
}
|
|
|
|
async function openGroupSettings(groupId) {
|
|
currentSettingsGroupId = groupId;
|
|
const group = groups.find(g => g.id === groupId);
|
|
if (!group) return;
|
|
|
|
const modal = document.getElementById('groupSettingsModal');
|
|
const nameInput = document.getElementById('groupSettingsNameInput');
|
|
nameInput.value = group.name;
|
|
|
|
modal.classList.remove('hidden');
|
|
modal.classList.add('flex');
|
|
setTimeout(() => nameInput.focus(), 50);
|
|
|
|
await loadGroupLocation(groupId);
|
|
}
|
|
|
|
function closeGroupSettingsModal() {
|
|
const modal = document.getElementById('groupSettingsModal');
|
|
modal.classList.add('hidden');
|
|
modal.classList.remove('flex');
|
|
currentSettingsGroupId = null;
|
|
}
|
|
|
|
async function saveGroupSettings() {
|
|
if (!currentSettingsGroupId) return;
|
|
const group = groups.find(g => g.id === currentSettingsGroupId);
|
|
if (!group) return;
|
|
|
|
const nameInput = document.getElementById('groupSettingsNameInput');
|
|
const name = nameInput.value.trim();
|
|
if (!name) return;
|
|
|
|
try {
|
|
await fetch(`/api/groups/${group.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
group.name = name;
|
|
closeGroupSettingsModal();
|
|
await loadConversations();
|
|
} catch (err) {
|
|
console.error('Failed to save group settings:', err);
|
|
}
|
|
}
|
|
|
|
// Initialize
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
|
|
document.getElementById('messageForm').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
});
|
|
document.getElementById('cancelBtn').addEventListener('click', () => {
|
|
if (!isStreaming || !abortController) return;
|
|
cancelRequested = true;
|
|
if (activeStreamUi) {
|
|
activeStreamUi.setStatus('Stopping...', 'cancelled');
|
|
}
|
|
abortController.abort();
|
|
updateStreamingControls();
|
|
});
|
|
|
|
document.getElementById('newConversationBtn').addEventListener('click', createConversation);
|
|
document.getElementById('newGroupBtn').addEventListener('click', createGroup);
|
|
|
|
document.getElementById('messageInput').addEventListener('keydown', (e) => {
|
|
if (
|
|
e.key === 'Enter' &&
|
|
!e.shiftKey &&
|
|
!e.altKey &&
|
|
!e.ctrlKey &&
|
|
!e.metaKey &&
|
|
!e.isComposing
|
|
) {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
}
|
|
});
|
|
const messageInput = document.getElementById('messageInput');
|
|
if (messageInput) {
|
|
resizeMessageInput(messageInput);
|
|
messageInput.addEventListener('input', () => resizeMessageInput(messageInput));
|
|
window.addEventListener('resize', () => resizeMessageInput(messageInput));
|
|
}
|
|
|
|
// Mobile menu
|
|
const mobileMenuBtns = document.querySelectorAll('#mobileMenuBtn');
|
|
const sidebar = document.getElementById('sidebar');
|
|
const overlay = document.getElementById('sidebarOverlay');
|
|
mobileMenuBtns.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
sidebar.classList.toggle('open');
|
|
overlay.classList.toggle('open');
|
|
});
|
|
});
|
|
overlay.addEventListener('click', () => {
|
|
sidebar.classList.remove('open');
|
|
overlay.classList.remove('open');
|
|
});
|
|
|
|
// Mobile menu desktop button
|
|
const mobileMenuBtnDesktop = document.getElementById('mobileMenuBtnDesktop');
|
|
if (mobileMenuBtnDesktop) {
|
|
mobileMenuBtnDesktop.addEventListener('click', () => {
|
|
sidebar.classList.toggle('open');
|
|
overlay.classList.toggle('open');
|
|
});
|
|
}
|
|
|
|
// Group modal
|
|
const groupModal = document.getElementById('groupModal');
|
|
document.getElementById('closeGroupModal').addEventListener('click', closeGroupModal);
|
|
document.getElementById('cancelGroupBtn').addEventListener('click', closeGroupModal);
|
|
document.getElementById('groupForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
await createGroupFromModal();
|
|
});
|
|
groupModal.addEventListener('click', (e) => {
|
|
if (e.target === groupModal) {
|
|
closeGroupModal();
|
|
}
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
closeGroupModal();
|
|
}
|
|
});
|
|
|
|
// Group settings modal
|
|
const groupSettingsModal = document.getElementById('groupSettingsModal');
|
|
document.getElementById('closeGroupSettingsModal').addEventListener('click', closeGroupSettingsModal);
|
|
document.getElementById('cancelGroupSettingsBtn').addEventListener('click', closeGroupSettingsModal);
|
|
document.getElementById('groupSettingsForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
await saveGroupSettings();
|
|
});
|
|
groupSettingsModal.addEventListener('click', (e) => {
|
|
if (e.target === groupSettingsModal) {
|
|
closeGroupSettingsModal();
|
|
}
|
|
});
|
|
document.getElementById('groupSettingsPickBtn').addEventListener('click', () => {
|
|
pickFolderLocation(currentSettingsGroupId);
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
closeGroupSettingsModal();
|
|
}
|
|
});
|
|
|
|
// Runtime settings modal
|
|
const settingsModal = document.getElementById('settingsModal');
|
|
document.getElementById('openSettingsBtn').addEventListener('click', openLLMSettingsModal);
|
|
document.getElementById('closeSettingsModal').addEventListener('click', closeLLMSettingsModal);
|
|
document.getElementById('cancelSettingsBtn').addEventListener('click', closeLLMSettingsModal);
|
|
document.getElementById('settingsForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
await saveLLMSettings();
|
|
});
|
|
settingsModal.addEventListener('click', (e) => {
|
|
if (e.target === settingsModal) {
|
|
closeLLMSettingsModal();
|
|
}
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
closeLLMSettingsModal();
|
|
}
|
|
});
|
|
|
|
// Load runtime settings before rendering history so reasoning panels use the right default.
|
|
await loadLLMSettings().catch((err) => {
|
|
console.error('Failed to preload runtime settings:', err);
|
|
});
|
|
|
|
// Load and render agents after the initial conversation list is ready.
|
|
await loadConversations();
|
|
await loadGroupLocation();
|
|
loadAgents();
|
|
|
|
// Folder picker button in header
|
|
document.getElementById('pickLocationBtn').addEventListener('click', pickFolderLocation);
|
|
|
|
// Change location button (shown when location is locked)
|
|
document.getElementById('changeLocationBtn').addEventListener('click', changeGroupLocation);
|
|
|
|
updateStreamingControls();
|
|
});
|