fix(ai): open assistant links externally
This commit is contained in:
parent
754b988ca5
commit
bc5ae91abd
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -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&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("<script>alert(1)</script>");
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
Loading…
Reference in New Issue