Skip to content
Open

Mcp #1912

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c0b1fed
Add MCP tool support and related components
gary149 Oct 2, 2025
0a64483
Add logging for MCP tool invocation and results
gary149 Oct 2, 2025
b67ac9f
Enhance tool mapping and multimodal routing support
gary149 Oct 2, 2025
f849a38
Refine toolRuns condition in runMcpFlow
gary149 Oct 2, 2025
b928475
Update ToolUpdate.svelte
gary149 Oct 3, 2025
d0ffe65
Improve MCP tool handling and OpenAI integration
gary149 Oct 3, 2025
15e2ba8
Improve tool update display and MCP client fallback
gary149 Oct 3, 2025
44abd9d
Update generate.ts
gary149 Oct 3, 2025
6b4ec05
Add inline source citation support to chat messages
gary149 Oct 3, 2025
91324c0
Improve citation mapping and HTML citation handling
gary149 Oct 3, 2025
e8f3782
Improve citation handling and accessibility
gary149 Oct 3, 2025
41ccdc9
test sources update
gary149 Oct 3, 2025
f068529
Improve citation handling and link sanitization
gary149 Oct 4, 2025
326b8c9
files split
gary149 Oct 6, 2025
68619a6
Refactor tool call execution to use async generator
gary149 Oct 6, 2025
342729d
Refactor tool execution to use event-based generator
gary149 Oct 6, 2025
2c5d88d
simplify MarkdownRenderer
gary149 Oct 7, 2025
dc0d47f
Refine title generation prompt and logic
gary149 Oct 7, 2025
cf41a6c
Add citation linkification to MarkdownRenderer
gary149 Oct 7, 2025
b1ef2e0
Support multiple reference links in MarkdownRenderer
gary149 Oct 7, 2025
c2a51a1
Refine citation link formatting in MarkdownRenderer and update psycho…
gary149 Oct 7, 2025
f0210b8
Improve citation handling and URL parsing in chat
gary149 Oct 8, 2025
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
782 changes: 762 additions & 20 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@types/dompurify": "^3.0.5",
"@types/js-yaml": "^4.0.9",
"@types/katex": "^0.16.7",
"@types/linkify-it": "^3.0.5",
"@types/mime-types": "^2.1.4",
"@types/minimist": "^1.2.5",
"@types/node": "^22.1.0",
Expand Down Expand Up @@ -83,6 +84,7 @@
"ip-address": "^9.0.5",
"json5": "^2.2.3",
"katex": "^0.16.21",
"linkify-it": "^5.0.0",
"lint-staged": "^15.2.7",
"marked": "^12.0.1",
"mongodb": "^5.8.0",
Expand All @@ -100,7 +102,8 @@
"tailwindcss": "^3.4.0",
"uuid": "^10.0.0",
"vitest-browser-svelte": "^0.1.0",
"zod": "^3.22.3"
"zod": "^3.22.3",
"@modelcontextprotocol/sdk": "^1.17.5"
},
"overrides": {
"@reflink/reflink": "file:stub/@reflink/reflink"
Expand Down
3 changes: 3 additions & 0 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { initExitHandler } from "$lib/server/exitHandler";
import { refreshConversationStats } from "$lib/jobs/refresh-conversation-stats";
import { adminTokenManager } from "$lib/server/adminToken";
import { isHostLocalhost } from "$lib/server/isURLLocal";
import { loadMcpServersOnStartup, refreshMcpServersIfChanged } from "$lib/server/mcp/registry";

export const init: ServerInit = async () => {
// Wait for config to be fully loaded
Expand All @@ -40,6 +41,7 @@ export const init: ServerInit = async () => {

logger.info("Starting server...");
initExitHandler();
loadMcpServersOnStartup();

checkAndRunMigrations();
refreshConversationStats();
Expand Down Expand Up @@ -93,6 +95,7 @@ export const handleError: HandleServerError = async ({ error, event, status, mes
export const handle: Handle = async ({ event, resolve }) => {
await ready.then(() => {
config.checkForUpdates();
refreshMcpServersIfChanged();
});

logger.debug({
Expand Down
85 changes: 82 additions & 3 deletions src/lib/components/chat/ChatMessage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
MessageUpdateType,
type MessageReasoningUpdate,
MessageReasoningUpdateType,
type MessageToolUpdate,
} from "$lib/types/MessageUpdate";
import MarkdownRenderer from "./MarkdownRenderer.svelte";
import OpenReasoningResults from "./OpenReasoningResults.svelte";
import Alternatives from "./Alternatives.svelte";
import MessageAvatar from "./MessageAvatar.svelte";
import ToolUpdate from "./ToolUpdate.svelte";

interface Props {
message: Message;
Expand Down Expand Up @@ -76,6 +78,22 @@
[]) as MessageReasoningUpdate[]
);

let toolUpdateGroups = $derived.by(() => {
const updates = message.updates?.filter((update) => update.type === MessageUpdateType.Tool) as
| MessageToolUpdate[]
| undefined;
if (!updates || updates.length === 0) return {} as Record<string, MessageToolUpdate[]>;
return updates.reduce(
(acc, update) => {
(acc[update.uuid] ??= []).push(update);
return acc;
},
{} as Record<string, MessageToolUpdate[]>
);
});

let hasToolUpdates = $derived(Object.values(toolUpdateGroups).some((group) => group.length > 0));

// const messageFinalAnswer = $derived(
// message.updates?.find(
// ({ type }) => type === MessageUpdateType.FinalAnswer
Expand All @@ -94,6 +112,23 @@
message.reasoning.trim().length > 0
);
let hasClientThink = $derived(!hasServerReasoning && thinkSegments.length > 1);
let formattedSources = $derived.by(() =>
(message.sources ?? [])
.slice()
.sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
.map((source) => {
let hostname = source.link;
let faviconOrigin = source.link;
try {
const parsed = new URL(source.link);
hostname = parsed.hostname.toLowerCase().replace(/^www\./, "");
faviconOrigin = parsed.origin;
} catch {
// keep raw values if parsing fails
}
return { ...source, hostname, faviconOrigin };
})
);

$effect(() => {
if (isCopied) {
Expand Down Expand Up @@ -154,6 +189,15 @@
loading={loading && message.content.length === 0}
/>
{/if}
{#if hasToolUpdates}
{#each Object.values(toolUpdateGroups) as group}
{#if group.length}
{#key group[0].uuid}
<ToolUpdate tool={group} {loading} />
{/key}
{/if}
{/each}
{/if}

<div bind:this={contentEl}>
{#if isLast && loading && message.content.length === 0}
Expand All @@ -178,21 +222,56 @@
<div
class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 dark:prose-pre:bg-gray-900"
>
<MarkdownRenderer content={part} loading={isLast && loading} />
<MarkdownRenderer
content={part}
sources={message.sources ?? []}
loading={isLast && loading}
/>
</div>
{/if}
{/each}
{:else}
<div
class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 dark:prose-pre:bg-gray-900"
>
<MarkdownRenderer content={message.content} loading={isLast && loading} />
<MarkdownRenderer
content={message.content}
sources={message.sources ?? []}
loading={isLast && loading}
/>
</div>
{/if}
{#if formattedSources.length}
<div class="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1.5 text-xs sm:text-sm">
<div class="text-gray-400">Sources:</div>
{#each formattedSources as source, index}
<a
class="flex items-center gap-1.5 whitespace-nowrap rounded-lg border border-gray-100 bg-white px-1.5 py-1 leading-none hover:border-gray-300 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-gray-700 sm:px-2 sm:py-1.5"
href={source.link}
target="_blank"
rel="noopener noreferrer"
title={source.link}
aria-label={`Source ${source.index ?? index + 1}: ${source.hostname ?? source.link}`}
>
<img
class="h-3.5 w-3.5 rounded"
src={`https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(source.faviconOrigin ?? source.link)}`}
alt=""
/>
<div class="text-gray-600 dark:text-gray-300">{source.hostname}</div>
<span
class="rounded bg-gray-100 px-1 py-0.5 text-[0.65rem] font-medium text-gray-500 dark:bg-gray-800 dark:text-gray-300"
>
{source.index ?? index + 1}
</span>
</a>
{/each}
</div>
{/if}
</div>
</div>

{#if message.routerMetadata || (!loading && message.content)}
{#if message.routerMetadata || (!loading && (message.content || hasToolUpdates))}
<div
class="absolute -bottom-3.5 {message.routerMetadata && messageInfoWidth > messageWidth
? 'left-1 pl-1 lg:pl-7'
Expand Down
132 changes: 121 additions & 11 deletions src/lib/components/chat/MarkdownRenderer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,128 @@
import { onMount } from "svelte";
import { updateDebouncer } from "$lib/utils/updates";

import type { MessageSource } from "$lib/types/MessageUpdate";

interface Props {
content: string;
sources?: { title?: string; link: string }[];
sources?: MessageSource[];
loading?: boolean;
}

let worker: Worker | null = null;

let { content, sources = [], loading = false }: Props = $props();

let tokens: Token[] = $state(processTokensSync(content, sources));
let tokens: Token[] = $state(processTokensSync(content));

const CODE_TAGS = new Set(["code", "pre"]);

function linkifyCitations(html: string, sources: MessageSource[]): string {
if (!Array.isArray(sources) || sources.length === 0 || typeof html !== "string") return html;
const hrefByIndex = new Map<number, string>();
for (const s of sources) {
const idx = Number(s.index);
if (!Number.isFinite(idx) || idx <= 0) continue;
try {
const u = new URL(s.link);
if (u.protocol === "http:" || u.protocol === "https:") {
const safe = u.toString().replace(/"/g, "&quot;");
hrefByIndex.set(idx, safe);
}
} catch {
// ignore invalid URLs
}
}
if (hrefByIndex.size === 0) return html;

const citationPattern = /\s*\[(\d+(?:\s*,\s*\d+)*)\]/g;

const replaceInSegment = (segment: string): string =>
segment.replace(citationPattern, (match: string, group: string) => {
const links = group
.split(/\s*,\s*/)
.map((d: string) => {
const n = Number(d);
const href = hrefByIndex.get(n);
return href
? `<a href="${href}" class="text-blue-500 underline-none no-underline">${n}</a>`
: "";
})
.filter(Boolean)
.join(", ");
return links ? `<sup class="ml-[2px] select-none">${links}</sup>` : match;
});

let result = "";
let cursor = 0;
const stack: string[] = [];

const findTagEnd = (input: string, start: number): number => {
let inQuote: string | null = null;
for (let i = start + 1; i < input.length; i += 1) {
const char = input[i];
if (inQuote) {
if (char === inQuote) {
inQuote = null;
}
continue;
}
if (char === '"' || char === "'") {
inQuote = char;
continue;
}
if (char === ">") {
return i;
}
}
return input.length - 1;
};

const updateStack = (tagContent: string) => {
const trimmed = tagContent.trim();
if (!trimmed) return;
const isClosing = trimmed.startsWith("/");
const cleaned = isClosing ? trimmed.slice(1) : trimmed;
const spaceIndex = cleaned.search(/\s|\/|$/);
const rawName = spaceIndex === -1 ? cleaned : cleaned.slice(0, spaceIndex);
const tagName = rawName.toLowerCase();
const selfClosing = /\/$/.test(trimmed) || ["br", "hr", "img", "input", "meta", "link"].includes(tagName);
if (CODE_TAGS.has(tagName)) {
if (isClosing) {
for (let i = stack.length - 1; i >= 0; i -= 1) {
if (stack[i] === tagName) {
stack.splice(i, 1);
break;
}
}
} else if (!selfClosing) {
stack.push(tagName);
}
}
};

while (cursor < html.length) {
const ltIndex = html.indexOf("<", cursor);
if (ltIndex === -1) {
const trailing = html.slice(cursor);
result += stack.length === 0 ? replaceInSegment(trailing) : trailing;
break;
}

const textSegment = html.slice(cursor, ltIndex);
result += stack.length === 0 ? replaceInSegment(textSegment) : textSegment;

const tagEnd = findTagEnd(html, ltIndex);
const tag = html.slice(ltIndex, tagEnd + 1);
result += tag;
updateStack(html.slice(ltIndex + 1, tagEnd));
cursor = tagEnd + 1;
}

return result;
}

async function processContent(
content: string,
sources: { title?: string; link: string }[]
): Promise<Token[]> {
async function processContent(content: string): Promise<Token[]> {
if (worker) {
return new Promise((resolve) => {
if (!worker) {
Expand All @@ -37,26 +143,30 @@
resolve(event.data.tokens);
};
worker.postMessage(
JSON.parse(JSON.stringify({ content, sources, type: "process" })) as IncomingMessage
JSON.parse(JSON.stringify({ content, type: "process" })) as IncomingMessage
);
});
} else {
return processTokens(content, sources);
return processTokens(content);
}
}

$effect(() => {
if (!browser) {
tokens = processTokensSync(content, sources);
tokens = processTokensSync(content).map((t) =>
t.type === "text" ? { ...t, html: linkifyCitations(t.html as string, sources) } : t
);
} else {
(async () => {
updateDebouncer.startRender();
tokens = await processContent(content, sources).then(
tokens = await processContent(content).then(
async (tokens) =>
await Promise.all(
tokens.map(async (token) => {
if (token.type === "text") {
token.html = DOMPurify.sanitize(await token.html);
const raw = await token.html;
const linked = linkifyCitations(raw, sources);
token.html = DOMPurify.sanitize(linked);
}
return token;
})
Expand Down
Loading
Loading