fix(ai): open assistant links externally

This commit is contained in:
t8y2 2026-06-27 16:40:04 +08:00
parent 754b988ca5
commit bc5ae91abd
3 changed files with 213 additions and 21 deletions

View File

@ -24,7 +24,7 @@ import { buildAiAgentPlan } from "@/lib/aiAgentPlan";
import { buildAiAgentStepItems, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/aiAgentStepPresentation";
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/aiCodeHighlighter";
import { createAiMessageRenderer } from "@/lib/aiMessageRender";
import { Marked } from "marked";
import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/aiMarkdown";
import { aiCancelStream, aiListModels, saveAiConversation, loadAiConversations, deleteAiConversation, listSchemas, listTables, type AiConversation, type AiModelInfo } from "@/lib/api";
import type { AiMessage } from "@/lib/api";
import type { ConnectionConfig, QueryTab, TableInfo } from "@/types/database";
@ -1021,32 +1021,27 @@ function triggerAction(action: AiAction, instruction?: string) {
defineExpose({ triggerAction });
const markedInstance = new Marked({
breaks: true,
gfm: true,
renderer: {
code({ text }: { text: string }) {
return `<code class="rounded bg-muted px-1.5 py-0.5 text-[11px] font-mono">${text}</code>`;
},
},
});
function formatInlineText(text: string): string {
try {
return markedInstance.parse(text) as string;
} catch {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
}
const messageRenderer = computed(() => {
const appearance = aiCodeAppearance.value;
const highlightCode = shikiCodeHighlighter.value;
return createAiMessageRenderer({
markdown: formatInlineText,
markdown: formatAiInlineMarkdown,
highlightCode: highlightCode ? (content, lang) => highlightCode(content, lang, appearance) : undefined,
});
});
function onMarkdownClick(event: MouseEvent) {
handleAiMarkdownLinkClick(event, openExternalUrl);
}
async function openExternalUrl(url: string) {
try {
const { open } = await import("@tauri-apps/plugin-shell");
await open(url);
} catch {
window.open(url, "_blank", "noopener,noreferrer");
}
}
</script>
<template>
@ -1148,7 +1143,7 @@ const messageRenderer = computed(() => {
</div>
</div>
<template v-for="(seg, j) in messageRenderer.render(msg.content)" :key="j">
<div v-if="seg.type === 'text'" class="ai-markdown whitespace-normal">
<div v-if="seg.type === 'text'" class="ai-markdown whitespace-normal" @click.capture="onMarkdownClick">
<div v-html="seg.html" />
</div>
<div v-else class="my-2 overflow-hidden rounded-md border border-zinc-200 bg-zinc-50 dark:border-zinc-700/50 dark:bg-zinc-900">

View File

@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { aiMarkdownLinkUrlFromClick, formatAiInlineMarkdown, handleAiMarkdownLinkClick, normalizeAiMarkdownLink } from "@/lib/aiMarkdown";
describe("formatAiInlineMarkdown", () => {
it("renders http links for external browser handling", () => {
const html = formatAiInlineMarkdown("See [docs](https://example.com/a?x=1&y=2).");
expect(html).toContain('href="https://example.com/a?x=1&amp;y=2"');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
});
it("does not render unsafe link schemes", () => {
const html = formatAiInlineMarkdown("[run](javascript:alert(1))");
expect(html).toContain("run");
expect(html).not.toContain("javascript:");
expect(html).not.toContain("<a ");
});
it("escapes raw html from assistant text", () => {
const html = formatAiInlineMarkdown("<script>alert(1)</script>");
expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
expect(html).not.toContain("<script>");
});
});
describe("normalizeAiMarkdownLink", () => {
it("accepts absolute http and https urls", () => {
expect(normalizeAiMarkdownLink("https://example.com/docs")).toBe("https://example.com/docs");
expect(normalizeAiMarkdownLink("http://example.com/docs")).toBe("http://example.com/docs");
});
it("rejects relative and non-browser-safe urls", () => {
expect(normalizeAiMarkdownLink("/docs")).toBeNull();
expect(normalizeAiMarkdownLink("mailto:test@example.com")).toBeNull();
expect(normalizeAiMarkdownLink("javascript:alert(1)")).toBeNull();
});
});
describe("ai markdown link clicks", () => {
it("finds anchors from a clicked child node", () => {
const anchor = anchorWithHref("https://example.com/docs");
const target = { closest: (selector: string) => (selector === "a[href]" ? anchor : null) };
const currentTarget = { contains: (node: unknown) => node === anchor };
expect(aiMarkdownLinkUrlFromClick(target, currentTarget)).toBe("https://example.com/docs");
});
it("prevents default navigation and opens external links", () => {
const anchor = anchorWithHref("https://example.com/docs");
const target = { closest: () => anchor };
const currentTarget = { contains: () => true };
let prevented = false;
let stopped = false;
let opened = "";
const handled = handleAiMarkdownLinkClick(
{
target,
currentTarget,
preventDefault: () => {
prevented = true;
},
stopPropagation: () => {
stopped = true;
},
},
(url) => {
opened = url;
},
);
expect(handled).toBe(true);
expect(prevented).toBe(true);
expect(stopped).toBe(true);
expect(opened).toBe("https://example.com/docs");
});
it("ignores unsafe and out-of-scope links", () => {
const unsafeAnchor = anchorWithHref("javascript:alert(1)");
expect(aiMarkdownLinkUrlFromClick({ closest: () => unsafeAnchor }, { contains: () => true })).toBeNull();
const outsideAnchor = anchorWithHref("https://example.com/docs");
expect(aiMarkdownLinkUrlFromClick({ closest: () => outsideAnchor }, { contains: () => false })).toBeNull();
});
});
function anchorWithHref(href: string) {
return {
getAttribute: (name: string) => (name === "href" ? href : null),
};
}

View File

@ -0,0 +1,103 @@
import { Marked, type Tokens } from "marked";
const EXTERNAL_LINK_PROTOCOLS = new Set(["http:", "https:"]);
export interface AiMarkdownLinkClickEvent {
target: unknown;
currentTarget: unknown;
preventDefault: () => void;
stopPropagation: () => void;
}
export type AiMarkdownLinkOpener = (url: string) => void | Promise<void>;
const markedInstance = new Marked({
breaks: true,
gfm: true,
renderer: {
codespan({ text }: Tokens.Codespan) {
return `<code class="rounded bg-muted px-1.5 py-0.5 text-[11px] font-mono">${escapeHtml(text)}</code>`;
},
html({ text }: Tokens.HTML | Tokens.Tag) {
return escapeHtml(text);
},
link({ href, title, tokens }: Tokens.Link) {
const label = this.parser.parseInline(tokens);
const safeHref = normalizeAiMarkdownLink(href);
if (!safeHref) return label;
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return `<a href="${escapeHtml(safeHref)}"${titleAttr} target="_blank" rel="noopener noreferrer">${label}</a>`;
},
},
});
export function formatAiInlineMarkdown(text: string): string {
try {
return markedInstance.parse(text) as string;
} catch {
return escapeHtml(text);
}
}
export function normalizeAiMarkdownLink(href: string): string | null {
try {
const url = new URL(href);
return EXTERNAL_LINK_PROTOCOLS.has(url.protocol) ? url.toString() : null;
} catch {
return null;
}
}
export function aiMarkdownLinkUrlFromClick(target: unknown, currentTarget: unknown): string | null {
const anchor = closestAnchor(target);
if (!anchor) return null;
if (hasContains(currentTarget) && !currentTarget.contains(anchor)) return null;
const href = anchor.getAttribute("href");
return href ? normalizeAiMarkdownLink(href) : null;
}
export function handleAiMarkdownLinkClick(event: AiMarkdownLinkClickEvent, openUrl: AiMarkdownLinkOpener): boolean {
const url = aiMarkdownLinkUrlFromClick(event.target, event.currentTarget);
if (!url) return false;
event.preventDefault();
event.stopPropagation();
void openUrl(url);
return true;
}
function escapeHtml(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
interface AnchorLike {
getAttribute: (name: string) => string | null;
}
function closestAnchor(target: unknown): AnchorLike | null {
const closestSource = hasClosest(target) ? target : parentWithClosest(target);
if (!closestSource) return null;
const anchor = closestSource.closest("a[href]");
return hasGetAttribute(anchor) ? anchor : null;
}
function parentWithClosest(target: unknown): { closest: (selector: string) => unknown } | null {
if (!target || typeof target !== "object" || !("parentElement" in target)) return null;
const parentElement = target.parentElement;
return hasClosest(parentElement) ? parentElement : null;
}
function hasClosest(value: unknown): value is { closest: (selector: string) => unknown } {
return !!value && typeof value === "object" && "closest" in value && typeof value.closest === "function";
}
function hasGetAttribute(value: unknown): value is AnchorLike {
return !!value && typeof value === "object" && "getAttribute" in value && typeof value.getAttribute === "function";
}
function hasContains(value: unknown): value is { contains: (node: unknown) => boolean } {
return !!value && typeof value === "object" && "contains" in value && typeof value.contains === "function";
}