Skip to content
Merged
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
122 changes: 122 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,128 @@
}

@layer utilities {
.chat-markdown-content {
color: var(--foreground);
font-size: 14px;
line-height: 1.55;
overflow-wrap: break-word;
}

.chat-markdown-content > :first-child {
margin-top: 0;
}

.chat-markdown-content > :last-child {
margin-bottom: 0;
}

.chat-markdown-content h1,
.chat-markdown-content h2,
.chat-markdown-content h3,
.chat-markdown-content h4,
.chat-markdown-content h5,
.chat-markdown-content h6 {
margin-top: 12px;
margin-bottom: 6px;
font-weight: 700;
line-height: 1.3;
}

.chat-markdown-content h1,
.chat-markdown-content h2 {
font-size: 1.05em;
}

.chat-markdown-content h3,
.chat-markdown-content h4,
.chat-markdown-content h5,
.chat-markdown-content h6 {
font-size: 1em;
}

.chat-markdown-content p,
.chat-markdown-content blockquote,
.chat-markdown-content ul,
.chat-markdown-content ol,
.chat-markdown-content table,
.chat-markdown-content pre {
margin-top: 0;
margin-bottom: 10px;
}

.chat-markdown-content a {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
}

.chat-markdown-content strong {
font-weight: 700;
}

.chat-markdown-content ul,
.chat-markdown-content ol {
padding-left: 1.35em;
}

.chat-markdown-content li + li {
margin-top: 0.2em;
}

.chat-markdown-content blockquote {
border-left: 3px solid var(--border);
color: var(--muted-foreground);
padding-left: 0.85em;
}

.chat-markdown-content table {
border-collapse: collapse;
display: block;
font-size: 12px;
max-width: 100%;
overflow-x: auto;
width: max-content;
}

.chat-markdown-content th,
.chat-markdown-content td {
border: 1px solid var(--border);
padding: 5px 8px;
text-align: left;
vertical-align: top;
}

.chat-markdown-content th {
background-color: color-mix(in srgb, var(--muted) 60%, transparent);
font-weight: 700;
}

.chat-markdown-content code {
background-color: color-mix(in srgb, var(--muted) 72%, transparent);
border-radius: 4px;
font-family: var(--font-mono);
font-size: 85%;
padding: 0.15em 0.35em;
}

.chat-markdown-content pre {
background-color: color-mix(in srgb, var(--muted) 80%, transparent);
border: 1px solid var(--border);
border-radius: 6px;
line-height: 1.45;
max-width: 100%;
overflow-x: auto;
padding: 10px;
}

.chat-markdown-content pre code {
background: transparent;
border-radius: 0;
font-size: 100%;
padding: 0;
white-space: pre;
}

.original-markdown-preview {
color: var(--foreground);
font-size: 14px;
Expand Down
103 changes: 103 additions & 0 deletions src/components/chat-message-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,109 @@ describe("ChatMessageList", () => {
).toBeNull();
});

it("renders assistant markdown with GitHub-flavored tables", () => {
render(
React.createElement(ChatMessageList, {
messages: [
{
id: "assistant_1",
role: "assistant",
content:
"### Summary\n\n- **Deadline:** Monday\n\n| Item | Status |\n| --- | --- |\n| Draft | Ready |",
},
],
}),
);

expect(
screen.getByRole("heading", { name: "Summary", level: 3 }),
).toBeTruthy();
expect(screen.getByRole("listitem").textContent).toContain("Deadline:");
expect(screen.getByRole("table")).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Item" })).toBeTruthy();
expect(screen.getByRole("cell", { name: "Ready" })).toBeTruthy();
});

it("keeps user markdown-looking text literal", () => {
render(
React.createElement(ChatMessageList, {
messages: [
{
id: "user_1",
role: "user",
content: "**Do not render this as bold**",
},
],
}),
);

expect(screen.getByText("**Do not render this as bold**")).toBeTruthy();
expect(screen.queryByText("Do not render this as bold")).toBeNull();
});

it("skips assistant inline HTML while rendering markdown text", () => {
render(
React.createElement(ChatMessageList, {
messages: [
{
id: "assistant_1",
role: "assistant",
content: "Visible **text** <img src=\"x\" alt=\"hidden image\" />",
},
],
}),
);

expect(screen.getByText("text")).toBeTruthy();
expect(screen.queryByAltText("hidden image")).toBeNull();
});

it("does not hide image cards when source links dedupe the same section", () => {
render(
React.createElement(ChatMessageList, {
messages: [
{
id: "assistant_1",
role: "assistant",
content: "这里是相关身份证明图片。",
citations: [
{
chunkType: "text",
score: 0.9,
source: {
documentId: "doc_1",
sourceFileName: "商务标文件.pdf",
sectionPath: "二、法定代表人身份证明",
},
},
{
chunkType: "image",
score: 0.9,
assetUrl: "https://blob.example/images/image-6-id-front.jpg",
source: {
documentId: "doc_1",
sourceFileName: "商务标文件.pdf",
sectionPath: "二、法定代表人身份证明",
},
},
],
},
],
}),
);

expect(
screen.getByRole("img", {
name: "商务标文件.pdf · 二、法定代表人身份证明",
}),
).toBeTruthy();
expect(
screen.getAllByRole("button", {
name: "Open source 商务标文件.pdf · 二、法定代表人身份证明",
}),
).toHaveLength(1);
});

it("shows thinking progress after existing messages while sending", () => {
render(
React.createElement(ChatMessageList, {
Expand Down
49 changes: 42 additions & 7 deletions src/components/chat-message-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { type CSSProperties, type ReactElement } from "react";
import { type VirtualItem } from "@tanstack/react-virtual";
import { ImageIcon, MessageCircle } from "lucide-react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";

import { useChatMessageListWorkflow } from "@/components/chat-message-list-workflow";
import { chatPanelModel } from "@/components/chat-panel-model";
Expand All @@ -23,6 +25,12 @@ type DisplayImageCitation = DisplayCitation & {
readonly assetUrl: string;
};

const assistantMarkdownComponents: Components = {
p: ({ children }) => (
<p className="whitespace-pre-wrap break-words">{children}</p>
),
};

export type ChatMessageListProps = {
readonly isDisabled?: boolean;
readonly isSending?: boolean;
Expand Down Expand Up @@ -244,12 +252,15 @@ function MessageBubble({
message,
sourceTitlesByDocumentId,
);
const displayImageCitations = getDisplayImageCitations(displayCitations);
const displayImageCitations = getDisplayImageCitations(
message,
sourceTitlesByDocumentId,
);

return (
<div className="flex min-w-0 flex-col items-start">
<div className="max-w-[92%] overflow-hidden rounded-2xl rounded-tl-sm border border-border/70 bg-card px-3 py-2.5 text-sm leading-relaxed text-foreground shadow-xs sm:max-w-[90%] sm:px-4 sm:py-3">
<p className="whitespace-pre-wrap break-words">{message.content}</p>
<AssistantMessageContent content={message.content} />
{displayImageCitations.length > 0 && (
<div className="mt-3 border-t border-border/70 pt-2.5">
<p className="mb-2 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
Expand Down Expand Up @@ -309,6 +320,24 @@ function MessageBubble({
);
}

function AssistantMessageContent({
content,
}: {
readonly content: string;
}): ReactElement {
return (
<div className="chat-markdown-content min-w-0 max-w-full overflow-x-auto">
<ReactMarkdown
components={assistantMarkdownComponents}
remarkPlugins={[remarkGfm]}
skipHtml
>
{content}
</ReactMarkdown>
</div>
);
}

function getDisplayCitations(
message: ChatMessageView,
sourceTitlesByDocumentId: Readonly<Record<string, string>>,
Expand Down Expand Up @@ -336,18 +365,24 @@ function getDisplayCitations(
}

function getDisplayImageCitations(
citations: readonly DisplayCitation[],
message: ChatMessageView,
sourceTitlesByDocumentId: Readonly<Record<string, string>>,
): readonly DisplayImageCitation[] {
const seenAssetUrls = new Set<string>();
const imageCitations: DisplayImageCitation[] = [];

for (const citation of citations) {
const assetUrl = getTrimmedCitationField(citation.citation.assetUrl);
if (!assetUrl || !isImageCitation(citation.citation, assetUrl)) continue;
for (const [index, citation] of (message.citations ?? []).entries()) {
const assetUrl = getTrimmedCitationField(citation.assetUrl);
if (!assetUrl || !isImageCitation(citation, assetUrl)) continue;
if (seenAssetUrls.has(assetUrl)) continue;

seenAssetUrls.add(assetUrl);
imageCitations.push({ ...citation, assetUrl });
imageCitations.push({
citation,
citationId: chatPanelModel.getCitationId(message.id, index),
label: chatPanelModel.getCitationLabel(citation, sourceTitlesByDocumentId),
assetUrl,
});
}

return imageCitations;
Expand Down
57 changes: 57 additions & 0 deletions src/components/chunks-panel-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,63 @@ describe("chunksPanelState", () => {
])
})

it("deduplicates repeated chunk ids before ordering and building the section tree", () => {
type TestSectionTreeNode = {
readonly chunkCount: number
readonly chunks: readonly ParsedChunkView[]
readonly children: readonly TestSectionTreeNode[]
}
const buildSectionTree = (
chunksPanelState as typeof chunksPanelState & {
readonly buildSectionTree?: (
chunks: readonly ParsedChunkView[],
sourceTitle: string,
) => TestSectionTreeNode
}
).buildSectionTree
const chunks: ParsedChunkView[] = [
{
chunkId: "duplicate_chunk",
type: "text",
content: "First copy.",
sectionPath: "manual.pdf/Overview",
sourceTitle: "manual.pdf",
pageNums: [1],
},
{
chunkId: "other_chunk",
type: "text",
content: "Other chunk.",
sectionPath: "manual.pdf/Overview",
sourceTitle: "manual.pdf",
pageNums: [2],
},
{
chunkId: "duplicate_chunk",
type: "text",
content: "Duplicate copy.",
sectionPath: "manual.pdf/Overview",
sourceTitle: "manual.pdf",
pageNums: [3],
},
]

expect(
chunksPanelState
.getChunksWithFocusedFirst(chunks, null)
.map((chunk) => chunk.content),
).toEqual(["First copy.", "Other chunk."])

const tree = buildSectionTree?.(chunks, "manual.pdf")
const overviewSection = tree?.children[0]

expect(tree?.chunkCount).toBe(2)
expect(overviewSection?.chunks.map((chunk) => chunk.content)).toEqual([
"First copy.",
"Other chunk.",
])
})

it("formats Knowhere section paths and reference labels for display", () => {
expect(
chunksPanelState.formatChunkSectionPath(
Expand Down
Loading
Loading