feat(llm): group sources in expandable header

This commit is contained in:
Elian Doran
2026-04-03 22:31:58 +03:00
parent ab0338c318
commit eddd77f97f
4 changed files with 91 additions and 72 deletions

View File

@@ -1639,6 +1639,7 @@
"web_search": "Web search",
"note_tools": "Note access",
"sources": "Sources",
"sources_summary": "{{count}} sources from {{sites}} sites",
"extended_thinking": "Extended thinking",
"legacy_models": "Legacy models",
"thinking": "Thinking...",

View File

@@ -70,49 +70,45 @@
51%, 100% { opacity: 0; }
}
/* Citations */
.llm-chat-citations {
margin-top: 0.75rem;
padding-top: 0.75rem;
/* Citations table (inside a tool-call-card) */
.llm-chat-citations-list {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.llm-chat-citations-list td {
padding: 0.25rem 0.75rem;
}
.llm-chat-citations-list tr + tr td {
border-top: 1px solid var(--main-border-color);
}
.llm-chat-citations-label {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--muted-text-color);
margin-bottom: 0.25rem;
.llm-chat-citation-title {
max-width: 0;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.llm-chat-citations-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.llm-chat-citations-list li {
font-size: 0.8rem;
}
.llm-chat-citations-list a {
.llm-chat-citation-title a {
color: var(--link-color, #007bff);
text-decoration: none;
padding: 0.125rem 0.5rem;
background: var(--accented-background-color);
border-radius: 4px;
display: inline-block;
}
.llm-chat-citations-list a:hover {
.llm-chat-citation-title a:hover {
text-decoration: underline;
}
.llm-chat-citation-site {
white-space: nowrap;
color: var(--muted-text-color);
font-size: 0.75rem;
text-align: right;
}
/* Error */
.llm-chat-error {
padding: 0.75rem 1rem;

View File

@@ -3,6 +3,8 @@ import "./ChatMessage.css";
import { Marked } from "marked";
import { useMemo } from "preact/hooks";
import type { LlmCitation } from "@triliumnext/commons";
import { t } from "../../../services/i18n.js";
import utils from "../../../services/utils.js";
import { SanitizedHtml } from "../../react/RawHtml.js";
@@ -35,6 +37,65 @@ type ContentGroup =
| { type: "text"; block: TextBlock; index: number }
| { type: "tool_calls"; blocks: ToolCallBlock[]; index: number };
function getUniqueSiteCount(citations: LlmCitation[]): number {
const hosts = new Set<string>();
for (const c of citations) {
if (c.url) {
try {
hosts.add(new URL(c.url).hostname);
} catch { /* ignore invalid URLs */ }
}
}
return hosts.size;
}
function CitationsSection({ citations }: { citations: LlmCitation[] }) {
const siteCount = getUniqueSiteCount(citations);
const summary = t("llm_chat.sources_summary", { count: citations.length, sites: siteCount });
return (
<div className="llm-chat-tool-call-card">
<details className="llm-chat-tool-call-section">
<summary className="llm-chat-tool-call-section-summary">
<span className="bx bx-link" />
{summary}
<span className="bx bx-chevron-down llm-chat-tool-call-chevron" />
</summary>
<table className="llm-chat-citations-list">
<tbody>
{citations.map((citation, idx) => {
const title = citation.title || citation.citedText?.slice(0, 80) || `Source ${idx + 1}`;
let hostname: string | null = null;
if (citation.url) {
try {
hostname = new URL(citation.url).hostname;
} catch { /* ignore */ }
}
return (
<tr key={idx}>
<td className="llm-chat-citation-title">
{citation.url ? (
<a href={citation.url} target="_blank" rel="noopener noreferrer" title={citation.url}>
{title}
</a>
) : (
<span>{title}</span>
)}
</td>
{hostname && (
<td className="llm-chat-citation-site">{hostname}</td>
)}
</tr>
);
})}
</tbody>
</table>
</details>
</div>
);
}
export default function ChatMessage({ message, isStreaming }: Props) {
const roleLabel = message.role === "user" ? t("llm_chat.role_user") : t("llm_chat.role_assistant");
const isError = message.type === "error";
@@ -95,47 +156,7 @@ export default function ChatMessage({ message, isStreaming }: Props) {
)}
</div>
{message.citations && message.citations.length > 0 && (
<div className="llm-chat-citations">
<div className="llm-chat-citations-label">
<span className="bx bx-link" />
{t("llm_chat.sources")}
</div>
<ul className="llm-chat-citations-list">
{message.citations.map((citation, idx) => {
// Determine display text: title, URL hostname, or cited text
let displayText = citation.title;
if (!displayText && citation.url) {
try {
displayText = new URL(citation.url).hostname;
} catch {
displayText = citation.url;
}
}
if (!displayText) {
displayText = citation.citedText?.slice(0, 50) || `Source ${idx + 1}`;
}
return (
<li key={idx}>
{citation.url ? (
<a
href={citation.url}
target="_blank"
rel="noopener noreferrer"
title={citation.citedText || citation.url}
>
{displayText}
</a>
) : (
<span title={citation.citedText}>
{displayText}
</span>
)}
</li>
);
})}
</ul>
</div>
<CitationsSection citations={message.citations} />
)}
</div>
<div className={`llm-chat-footer llm-chat-footer-${message.role}`}>

View File

@@ -4,6 +4,7 @@
border: 1px solid var(--main-border-color);
border-radius: 8px;
font-size: 0.85rem;
overflow: hidden;
}
/* Tool call section — individual tool call within a card */