Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 145 additions & 8 deletions omlx/admin/templates/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ <h1 class="text-2xl font-bold">{{ t('chat.welcome_heading') }}</h1>
<!-- Messages -->
<div x-show="messages.length > 0" class="max-w-4xl mx-auto px-4 py-6 space-y-4">
<template x-for="(msg, index) in messages" :key="index">
<div class="message-fade-in">
<div class="message-fade-in" x-show="msg._ui !== false">
<!-- User Message -->
<div x-show="msg.role === 'user'" class="flex justify-end">
<div class="user-message">
Expand Down Expand Up @@ -562,6 +562,17 @@ <h1 class="text-2xl font-bold">{{ t('chat.welcome_heading') }}</h1>
</div>
<div class="message-body markdown-content" x-html="renderMarkdown(msg.content)"></div>
</div>
<!-- Tool call indicator (shown while model is fetching tool results) -->
<div x-show="msg.role === 'tool_call'" class="assistant-message" style="border-left: 2px solid var(--border-normal); opacity: 0.8;"
:style="msg._error ? 'border-left-color: var(--text-danger);' : ''">
<div class="message-header">
<div class="flex items-center gap-2 text-sm" :style="msg._error ? 'color: var(--text-danger);' : 'color: var(--text-tertiary);'">
<i data-lucide="wrench" class="w-3.5 h-3.5"></i>
<svg x-show="msg._error" class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
<span x-text="msg.content"></span>
</div>
</div>
</div>
</div>
</template>

Expand Down Expand Up @@ -746,6 +757,10 @@ <h1 class="text-2xl font-bold">{{ t('chat.welcome_heading') }}</h1>
// VLM Detection State
modelTypeMap: {}, // { modelId: "llm"|"vlm"|"embedding"|"reranker" }

// MCP Tool Call Limits
MAX_TOOL_DEPTH: 10, // Max recursive streamResponse calls for tool loops
TOOL_TIMEOUT_MS: 30000, // Per-tool execution timeout (ms)

async init() {
// Auto-inject API key from server (admin session already authenticated)
const serverApiKey = {{ api_key | tojson }};
Expand Down Expand Up @@ -1114,17 +1129,33 @@ <h1 class="text-2xl font-bold">{{ t('chat.welcome_heading') }}</h1>
await this.streamResponse();
},

async streamResponse() {
async streamResponse(depth = 0) {
if (depth > this.MAX_TOOL_DEPTH) {
this.messages.push({
role: 'assistant',
content: `Error: Maximum tool call depth (${this.MAX_TOOL_DEPTH}) exceeded. The model may be stuck in a loop.`,
model: this.currentModel
});
this.saveCurrentChat();
this.isStreaming = false;
this.streamingContent = '';
this.abortController = null;
return;
}
this.isStreaming = true;
this.streamingContent = '';
this.abortController = new AbortController();
this.autoScrollEnabled = true; // Enable auto-scroll at start of streaming

// Build messages for API - strip metadata, keep content as-is (already OpenAI format)
const messagesForApi = this.messages.map(msg => ({
role: msg.role,
content: msg.content
}));
// Build messages for API - pass through tool_calls/tool_call_id for MCP loop
const messagesForApi = this.messages
.filter(msg => ['user', 'assistant', 'tool', 'system'].includes(msg.role))
.map(msg => {
const m = { role: msg.role, content: msg.content ?? null };
if (msg.tool_calls) m.tool_calls = msg.tool_calls;
if (msg.tool_call_id) m.tool_call_id = msg.tool_call_id;
return m;
});

try {
const response = await fetch('/v1/chat/completions', {
Expand All @@ -1149,6 +1180,8 @@ <h1 class="text-2xl font-bold">{{ t('chat.welcome_heading') }}</h1>
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const toolCallsMap = {}; // accumulate streaming tool_call chunks by index
let finishReason = null;

while (true) {
const { done, value } = await reader.read();
Expand All @@ -1164,7 +1197,21 @@ <h1 class="text-2xl font-bold">{{ t('chat.welcome_heading') }}</h1>
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
const delta = data.choices?.[0]?.delta;
const choice = data.choices?.[0];
const delta = choice?.delta;
if (choice?.finish_reason) finishReason = choice.finish_reason;

// Accumulate tool_call argument chunks
if (delta?.tool_calls) {
for (const tc of delta.tool_calls) {
const i = tc.index ?? 0;
if (!toolCallsMap[i]) toolCallsMap[i] = { id: '', type: 'function', function: { name: '', arguments: '' } };
if (tc.id) toolCallsMap[i].id = tc.id;
if (tc.function?.name) toolCallsMap[i].function.name += tc.function.name;
if (tc.function?.arguments) toolCallsMap[i].function.arguments += tc.function.arguments;
}
}

if (delta?.reasoning_content) {
// Wrap reasoning in <think> tags for UI rendering
if (!this.thinkingState.isInThinking) {
Expand Down Expand Up @@ -1199,6 +1246,96 @@ <h1 class="text-2xl font-bold">{{ t('chat.welcome_heading') }}</h1>
this.thinkingState.isInThinking = false;
}

// --- MCP tool call loop ---
const toolCalls = Object.values(toolCallsMap);
if (finishReason === 'tool_calls' && toolCalls.length > 0) {
// Store the assistant tool_calls message (hidden from UI)
this.messages.push({
role: 'assistant',
content: this.streamingContent || null,
tool_calls: toolCalls,
_ui: false,
});

// Execute all tools in parallel, show indicators while waiting
const indicatorIndices = toolCalls.map(() => {
const idx = this.messages.length;
this.messages.push({ role: 'tool_call', content: '', _ui: true, _error: false });
return idx;
});

const results = await Promise.all(toolCalls.map(async (tc, i) => {
const toolName = tc.function.name;
let args = {};
try { args = JSON.parse(tc.function.arguments || '{}'); } catch(e) {}
this.messages[indicatorIndices[i]].content = `${toolName}…`;

try {
const timeoutSignal = AbortSignal.timeout?.(this.TOOL_TIMEOUT_MS);
const combinedSignal = this.abortController?.signal && timeoutSignal
? AbortSignal.any([this.abortController.signal, timeoutSignal])
: (timeoutSignal || this.abortController?.signal);
const execResp = await fetch('/v1/mcp/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.getApiKey()}` },
body: JSON.stringify({ tool_name: toolName, arguments: args }),
signal: combinedSignal,
});
if (!execResp.ok) {
return { content: `Error: HTTP ${execResp.status}`, error: true, toolName };
}
const execData = await execResp.json();
return { content: typeof execData.content === 'string' ? execData.content : JSON.stringify(execData.content ?? execData), error: false };
} catch(e) {
const isTimeout = e.name === 'TimeoutError';
return { content: isTimeout ? `Error: Tool timed out after ${this.TOOL_TIMEOUT_MS/1000}s` : `Error: ${e.message}`, error: true, toolName };
}
}));

// Update indicators for errors, then remove successful ones
const hasErrors = results.some(r => r.error);
results.forEach((result, i) => {
if (result.error) {
this.messages[indicatorIndices[i]].content = `${result.toolName} failed`;
this.messages[indicatorIndices[i]]._error = true;
}
});

// Remove only successful indicators immediately
const successIndices = indicatorIndices.filter((_, i) => !results[i].error);
successIndices.slice().reverse().forEach(idx => this.messages.splice(idx, 1));

// Push hidden tool result messages in order
toolCalls.forEach((tc, i) => {
this.messages.push({ role: 'tool', tool_call_id: tc.id, content: results[i].content, _ui: false });
});

// Let user see error indicators for 2 seconds, then remove them
if (hasErrors) {
await new Promise(r => setTimeout(r, 2000));
// Remove remaining error indicators by role+flag match (indices shifted)
for (let j = this.messages.length - 1; j >= 0; j--) {
if (this.messages[j].role === 'tool_call' && this.messages[j]._error) {
this.messages.splice(j, 1);
}
}
}

this.saveCurrentChat();
this.streamingContent = '';

// Abort guard: respect user clicking Stop during tool execution
if (this.abortController?.signal.aborted) {
this.isStreaming = false;
return;
}

// Loop back — model will now synthesize a final answer
await this.streamResponse(depth + 1);
return;
}
// --- end MCP tool call loop ---

// Add completed message
if (this.streamingContent) {
this.messages.push({
Expand Down
Loading