From f9dc6f2607e24bf94c090d5b202d07128c1c9a6d Mon Sep 17 00:00:00 2001 From: ljy Date: Sat, 31 Oct 2026 12:13:19 +0800 Subject: [PATCH 01/26] feat(frontend): add conversation artifacts panel for agent-generated files Collect every agent-generated file across a conversation and expose a persistent "view all artifacts (N)" entry above the composer. The entry opens a right-hand panel listing each artifact with icon, size and generation time, plus preview (reusing FilePreviewDrawer) and download. Aggregation covers both attachment paths: streamed marker parts carrying skillFileAttachments and the history-restore skillFileUploadsRegistry. Keys stay unique across streaming chunks whose attachment ids restart. Unit proof lives in conversationArtifacts.test.tsx (aggregation, panel, preview, download fallbacks, entry button). The change reuses the proven SSE files-chunk, preview-drawer and storage contracts without adding a new cross-component boundary, so no formal D1 case is added; the assessment is recorded in CHAT-ARTIFACTS-PANEL-20261031. --- .../[locale]/newchat/assistant-ui/thread.tsx | 34 ++ .../[locale]/newchat/ui/artifacts-panel.tsx | 261 +++++++++++ .../app/[locale]/newchat/ui/attachment.tsx | 7 +- .../newchat/utils/conversation-artifacts.ts | 117 +++++ frontend/public/locales/en/common.json | 8 + frontend/public/locales/zh/common.json | 8 + .../component/conversationArtifacts.test.tsx | 410 ++++++++++++++++++ .../CHAT-ARTIFACTS-PANEL-20261031.yaml | 47 ++ 8 files changed, 890 insertions(+), 2 deletions(-) create mode 100644 frontend/app/[locale]/newchat/ui/artifacts-panel.tsx create mode 100644 frontend/app/[locale]/newchat/utils/conversation-artifacts.ts create mode 100644 frontend/tests/component/conversationArtifacts.test.tsx create mode 100644 test-e2e/changes/requirements/CHAT-ARTIFACTS-PANEL-20261031.yaml diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 7c517c4658..38cd7241cf 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -63,6 +63,14 @@ import { } from "@assistant-ui/react"; import { Sources } from "../ui/sources"; import { SourcesPanel } from "../ui/sources-panel"; +import { + ConversationArtifactsButton, + ConversationArtifactsPanel, +} from "../ui/artifacts-panel"; +import { + useConversationArtifacts, + type ConversationArtifact, +} from "../utils/conversation-artifacts"; import { SourcesPanelProvider, useSourcesPanel, @@ -335,6 +343,11 @@ export const Thread: FC = ({ }; const messages = useAuiState((s) => s.thread.messages); + // Conversation-wide agent-generated files feed the artifacts entry above the + // composer and the side panel it opens. Computed here so both the button and + // the panel share one aggregation pass over the thread messages. + const conversationArtifacts = useConversationArtifacts(messages); + const [artifactsOpen, setArtifactsOpen] = useState(false); const currentThreadTitle = useAuiState((s) => { const currentThread = s.threads.threadItems.find( (item) => item.id === s.threads.mainThreadId @@ -482,6 +495,9 @@ export const Thread: FC = ({ onCreateShare={createShare} selection={selection} onPanelClose={close} + conversationArtifacts={conversationArtifacts} + artifactsOpen={artifactsOpen} + onArtifactsOpenChange={setArtifactsOpen} /> void; selection: SourcesPanelSelection | null; onPanelClose: () => void; + conversationArtifacts: ConversationArtifact[]; + artifactsOpen: boolean; + onArtifactsOpenChange: (open: boolean) => void; variant: "default" | "embedded"; skillFiles?: readonly SkillFileContent[]; onSkillFileSelect?: (path: string) => void; @@ -650,6 +669,9 @@ const ThreadView: FC = ({ onCreateShare, selection, onPanelClose, + conversationArtifacts, + artifactsOpen, + onArtifactsOpenChange, variant, skillFiles, onSkillFileSelect, @@ -852,6 +874,12 @@ const ThreadView: FC = ({ {!newChatDesign && (!debugLayout || hasMessages) && ( )} + {conversationArtifacts.length > 0 && ( + onArtifactsOpenChange(true)} + /> + )} {!hasMessages && emptyFooterContent && (debugLayout ? ( @@ -909,6 +937,12 @@ const ThreadView: FC = ({ selectionToken={selection ?? undefined} onClose={onPanelClose} /> + + onArtifactsOpenChange(false)} + /> ); }; diff --git a/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx b/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx new file mode 100644 index 0000000000..279f0c9ea3 --- /dev/null +++ b/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx @@ -0,0 +1,261 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useState, type FC } from "react"; +import { + DownloadIcon, + EyeIcon, + FolderOutputIcon, + ImageIcon, + XIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { TooltipIconButton } from "./tooltip-icon-button"; +import { FilePreviewDrawer } from "@/components/common/filePreviewDrawer"; +import { storageService } from "@/services/storageService"; +import { cn } from "@/lib/utils"; +import log from "@/lib/logger"; +import { useTranslation } from "react-i18next"; +import { getFileIcon, triggerUrlDownload } from "./attachment"; +import type { + ConversationArtifact, + ArtifactAttachmentLike, +} from "../utils/conversation-artifacts"; + +export interface ConversationArtifactsPanelProps { + /** Whether the panel is currently mounted. */ + open: boolean; + /** All agent-generated files collected across the conversation. */ + artifacts: ConversationArtifact[]; + onClose: () => void; + className?: string; +} + +/** Formats an artifact creation time without importing a date library. */ +const formatGeneratedAt = (value: Date | undefined): string => { + if (!value) return ""; + return new Intl.DateTimeFormat(undefined, { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(value); +}; + +/** + * Side panel listing every file the agent generated during the conversation. + * Mirrors the SourcesPanel layout (right-hand column, header + scrollable + * list) so the two panels read as one family. Each entry supports preview, + * through the shared {@link FilePreviewDrawer}, and download. + */ +export const ConversationArtifactsPanel: FC< + ConversationArtifactsPanelProps +> = ({ open, artifacts, onClose, className }) => { + const { t } = useTranslation(); + const [previewing, setPreviewing] = useState( + null + ); + + if (!open) return null; + + const handleDownload = async (attachment: ArtifactAttachmentLike) => { + const name = attachment.name || "Generated file"; + try { + if (attachment.download_url) { + triggerUrlDownload(attachment.download_url, name); + } else if (attachment.object_name) { + await storageService.downloadFile(attachment.object_name, name); + } else { + const fallbackUrl = + attachment.preview_url || + attachment.presigned_url || + attachment.url || + readAttachmentDataUrl(attachment); + if (fallbackUrl) triggerUrlDownload(fallbackUrl, name); + } + } catch (error) { + log.error("Failed to download conversation artifact:", error); + } + }; + + return ( + + ); +}; + +/** Local size formatter matching the chat attachment cards. */ +function formatArtifactSize(size: number): string { + if (size < 1024) return `${size} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = size / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + const precision = value >= 10 ? 0 : 1; + return `${value.toFixed(precision)} ${units[unitIndex]}`; +} + +/** Image detection aligned with the chat attachment cards. */ +function isImageAttachment(name: string, contentType?: string): boolean { + const extension = name.split(".").pop()?.toLowerCase() || ""; + return ( + contentType?.startsWith("image/") === true || + ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico"].includes( + extension + ) + ); +} + +/** Reads the raw URL stashed in a file attachment content part. */ +function readAttachmentDataUrl( + attachment: ArtifactAttachmentLike +): string | undefined { + const part = attachment.content?.[0] as { data?: string } | undefined; + return part?.data; +} + +export interface ConversationArtifactsButtonProps { + /** Number of artifacts accumulated in the conversation. */ + count: number; + onClick: () => void; + className?: string; +} + +/** + * Compact entry shown above the composer while the conversation has + * agent-generated files. Opens the {@link ConversationArtifactsPanel}. + */ +export const ConversationArtifactsButton: FC< + ConversationArtifactsButtonProps +> = ({ count, onClick, className }) => { + const { t } = useTranslation(); + if (count <= 0) return null; + return ( +
+ +
+ ); +}; diff --git a/frontend/app/[locale]/newchat/ui/attachment.tsx b/frontend/app/[locale]/newchat/ui/attachment.tsx index 7d2d58bb46..e65087c756 100644 --- a/frontend/app/[locale]/newchat/ui/attachment.tsx +++ b/frontend/app/[locale]/newchat/ui/attachment.tsx @@ -53,7 +53,10 @@ const useFileSrc = (file: File | undefined) => { return src; }; -const getFileIcon = (filename: string | undefined, contentType?: string) => { +export const getFileIcon = ( + filename: string | undefined, + contentType?: string +) => { const ext = filename?.split(".").pop()?.toLowerCase() || ""; const mimeType = contentType?.toLowerCase() || ""; @@ -159,7 +162,7 @@ interface AttachmentViewModel { download_url?: string; } -const triggerUrlDownload = (url: string, filename: string) => { +export const triggerUrlDownload = (url: string, filename: string) => { const link = document.createElement("a"); link.href = url; link.download = filename; diff --git a/frontend/app/[locale]/newchat/utils/conversation-artifacts.ts b/frontend/app/[locale]/newchat/utils/conversation-artifacts.ts new file mode 100644 index 0000000000..d54cac408e --- /dev/null +++ b/frontend/app/[locale]/newchat/utils/conversation-artifacts.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useMemo } from "react"; +import type { CompleteAttachment } from "@assistant-ui/react"; +import { skillFileUploadsRegistry } from "../adapter/remote-chat-model-adapter"; + +/** + * Loose shape of an assistant-ui thread message as consumed by the artifacts + * aggregation. Only the fields actually read are declared, mirroring the + * relaxed typing used by `thread.tsx` for restored content parts. + */ +export interface ArtifactMessageLike { + id: string; + role?: string; + createdAt?: Date | string; + content?: readonly { + type?: string; + /** Marker used by the history adapter for restored artifact messages. */ + isSkillFiles?: boolean; + skillFileAttachments?: CompleteAttachment[]; + }[]; +} + +/** + * One agent-generated file in a conversation, flattened from the per-message + * attachment payloads for the conversation-level artifacts panel. + */ +export interface ConversationArtifact { + /** Stable identity combining the source message and attachment id. */ + key: string; + /** assistant-ui message id the file belongs to. */ + messageId: string; + /** Message creation time, used to show when the file was generated. */ + createdAt?: Date; + attachment: CompleteAttachment; +} + +/** Attachment fields the artifacts panel reads; keeps rendering loosely typed. */ +export type ArtifactAttachmentLike = CompleteAttachment & { + name?: string; + contentType?: string; + size?: number; + object_name?: string; + preview_url?: string; + download_url?: string; + url?: string; + presigned_url?: string; +}; + +/** + * Collects every agent-generated file across a conversation's messages. + * + * Assistant-generated files arrive through two paths that must both be + * covered: + * + * - Streaming: the chat adapter appends a marker text part carrying + * `skillFileAttachments` on the assistant message itself. + * - History restore: the thread history adapter stores the attachments in + * `skillFileUploadsRegistry` keyed by message id and leaves only an + * `isSkillFiles` marker part on the message. + * + * The per-part payload wins when present so live updates are picked up + * immediately; the registry is the fallback for restored messages. + */ +export function collectConversationArtifacts( + messages: readonly ArtifactMessageLike[] +): ConversationArtifact[] { + const artifacts: ConversationArtifact[] = []; + for (const message of messages) { + if (message.role !== "assistant") continue; + + const attachments = readMessageArtifacts(message); + const createdAt = message.createdAt + ? new Date(message.createdAt) + : undefined; + + attachments.forEach((attachment, index) => { + artifacts.push({ + // Attachment ids restart per streaming chunk, so the position within + // the message keeps the key unique when several chunks contribute. + key: `${message.id}:${attachment.id}:${index}`, + messageId: message.id, + createdAt: Number.isNaN(createdAt?.getTime()) ? undefined : createdAt, + attachment, + }); + }); + } + return artifacts; +} + +function readMessageArtifacts( + message: ArtifactMessageLike +): CompleteAttachment[] { + const parts = message.content ?? []; + // Streaming path: the last marker part carries the cumulative attachment + // list for the message, so later parts supersede earlier ones. + for (let index = parts.length - 1; index >= 0; index -= 1) { + const part = parts[index]; + if (part?.type === "text" && part.skillFileAttachments?.length) { + return part.skillFileAttachments; + } + } + // History restore path: attachments live in the module-level registry. + return skillFileUploadsRegistry.get(message.id) ?? []; +} + +/** + * Memoized wrapper for component consumption. `messages` identity changes on + * every streamed token, which keeps the artifact list live during generation. + */ +export function useConversationArtifacts( + messages: readonly ArtifactMessageLike[] +): ConversationArtifact[] { + return useMemo(() => collectConversationArtifacts(messages), [messages]); +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index bb479105ec..66d2fca8f1 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -469,6 +469,14 @@ "chat.attachments.preview": "Preview {{name}}", "chat.attachments.remove": "Remove {{name}}", "chat.attachments.download": "Download", + "chat.artifacts.viewAll": "View all artifacts ({{count}})", + "chat.artifacts.title": "Conversation artifacts", + "chat.artifacts.total": "{{count}} files in total", + "chat.artifacts.empty": "No files generated in this conversation yet", + "chat.artifacts.preview": "Preview", + "chat.artifacts.download": "Download", + "chat.artifacts.close": "Close", + "chat.artifacts.generatedAt": "Generated {{time}}", "chat.tokenUsage.used": "used", "chat.tokenUsage.turn": "this turn", "chat.tokenUsage.details": "Token usage details", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index e076d14195..6edf4009e2 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -467,6 +467,14 @@ "chat.attachments.preview": "预览 {{name}}", "chat.attachments.remove": "移除 {{name}}", "chat.attachments.download": "下载", + "chat.artifacts.viewAll": "查看所有产物 ({{count}})", + "chat.artifacts.title": "对话产物", + "chat.artifacts.total": "共 {{count}} 个文件", + "chat.artifacts.empty": "本次对话尚未生成文件", + "chat.artifacts.preview": "预览", + "chat.artifacts.download": "下载", + "chat.artifacts.close": "关闭", + "chat.artifacts.generatedAt": "生成于 {{time}}", "chat.tokenUsage.used": "已使用", "chat.tokenUsage.turn": "本轮", "chat.tokenUsage.details": "Token 使用详情", diff --git a/frontend/tests/component/conversationArtifacts.test.tsx b/frontend/tests/component/conversationArtifacts.test.tsx new file mode 100644 index 0000000000..6005d4a5e2 --- /dev/null +++ b/frontend/tests/component/conversationArtifacts.test.tsx @@ -0,0 +1,410 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { fireEvent, render, screen, cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { + collectConversationArtifacts, + type ArtifactMessageLike, +} from "@/app/newchat/utils/conversation-artifacts"; +import { + ConversationArtifactsButton, + ConversationArtifactsPanel, +} from "@/app/newchat/ui/artifacts-panel"; +import { skillFileUploadsRegistry } from "@/app/newchat/adapter/remote-chat-model-adapter"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import type { CompleteAttachment } from "@assistant-ui/react"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => { + if (params && "count" in params) return `${key}:${params.count}`; + if (params && "time" in params) return `${key}:${params.time}`; + return key; + }, + }), +})); + +// The aggregation util imports the artifact registry from the streaming +// adapter; mocking the module keeps the heavyweight adapter out of the test. +vi.mock("@/app/newchat/adapter/remote-chat-model-adapter", () => ({ + searchSourcesRegistry: new Map(), + searchImagesRegistry: new Map(), + conversationSourcesRegistry: new Map(), + skillFileUploadsRegistry: new Map(), + stepTokenCounts: [], +})); + +const previewDrawerMock = vi.hoisted(() => ({ + open: vi.fn(), +})); +vi.mock("@/components/common/filePreviewDrawer", () => ({ + FilePreviewDrawer: (props: { + open: boolean; + fileName: string; + objectName: string; + onClose: () => void; + }) => { + previewDrawerMock.open(props); + return props.open ? ( +
{props.fileName}
+ ) : null; + }, +})); + +const storageServiceMock = vi.hoisted(() => ({ + downloadFile: vi.fn(), + getPreviewUrl: vi.fn(() => "blob:preview"), +})); +vi.mock("@/services/storageService", () => ({ + storageService: storageServiceMock, + getLocalFilePreviewUrl: vi.fn(), + extractObjectNameFromUrl: vi.fn(), + fetchImageBlob: vi.fn(), + isLocalStorageObjectUrl: vi.fn(() => false), +})); + +vi.mock("@/lib/logger", () => ({ + default: { error: vi.fn(), warn: vi.fn(), log: vi.fn() }, +})); + +const makeAttachment = ( + id: string, + name: string, + overrides: Record = {} +): CompleteAttachment => + ({ + id, + status: { type: "complete" }, + type: "file", + name, + contentType: + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + object_name: `runs/${id}/${name}`, + preview_url: `blob:preview-${id}`, + download_url: `/api/storage/download/${id}`, + size: 2048, + ...overrides, + }) as unknown as CompleteAttachment; + +beforeEach(() => { + skillFileUploadsRegistry.clear(); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +test("collectConversationArtifacts reads streamed part payloads", () => { + const messages: ArtifactMessageLike[] = [ + { + id: "m1", + role: "user", + content: [{ type: "text" }], + }, + { + id: "m2", + role: "assistant", + createdAt: new Date("2026-10-31T08:10:00Z"), + content: [ + { type: "text" }, + { + type: "text", + skillFileAttachments: [ + makeAttachment("a1", "report.pptx"), + makeAttachment("a2", "data.csv"), + ], + }, + ], + }, + ]; + + const artifacts = collectConversationArtifacts(messages); + + expect(artifacts).toHaveLength(2); + expect(artifacts[0]?.attachment.name).toBe("report.pptx"); + expect(artifacts[1]?.attachment.name).toBe("data.csv"); + expect(artifacts[0]?.messageId).toBe("m2"); + expect(artifacts[0]?.key).toBe("m2:a1:0"); + expect(artifacts[0]?.createdAt).toEqual(new Date("2026-10-31T08:10:00Z")); +}); + +test("collectConversationArtifacts falls back to the history registry", () => { + skillFileUploadsRegistry.set("m-restored", [ + makeAttachment("r1", "restored.md"), + ]); + const messages: ArtifactMessageLike[] = [ + { + id: "m-restored", + role: "assistant", + content: [{ type: "text", isSkillFiles: true }], + }, + ]; + + const artifacts = collectConversationArtifacts(messages); + + expect(artifacts).toHaveLength(1); + expect(artifacts[0]?.attachment.name).toBe("restored.md"); +}); + +test("collectConversationArtifacts prefers the latest cumulative part", () => { + const messages: ArtifactMessageLike[] = [ + { + id: "m3", + role: "assistant", + content: [ + { + type: "text", + skillFileAttachments: [makeAttachment("old", "old.txt")], + }, + { + type: "text", + skillFileAttachments: [ + makeAttachment("old", "old.txt"), + makeAttachment("new", "new.txt"), + ], + }, + ], + }, + ]; + + const artifacts = collectConversationArtifacts(messages); + + expect(artifacts).toHaveLength(2); + expect(artifacts.map((a) => a.attachment.name)).toEqual([ + "old.txt", + "new.txt", + ]); +}); + +test("collectConversationArtifacts ignores invalid createdAt values", () => { + const messages: ArtifactMessageLike[] = [ + { + id: "m4", + role: "assistant", + createdAt: new Date("not-a-date"), + content: [ + { + type: "text", + skillFileAttachments: [makeAttachment("a", "a.txt")], + }, + ], + }, + ]; + + const artifacts = collectConversationArtifacts(messages); + + expect(artifacts).toHaveLength(1); + expect(artifacts[0]?.createdAt).toBeUndefined(); +}); + +test("collectConversationArtifacts keeps keys unique across chunks", () => { + // Two streaming chunks restart the attachment id counter, so both files + // share the id "m9-skill-file-0" inside the cumulative part payload. + const messages: ArtifactMessageLike[] = [ + { + id: "m9", + role: "assistant", + content: [ + { + type: "text", + skillFileAttachments: [ + { ...makeAttachment("m9-skill-file-0", "first.txt") }, + { ...makeAttachment("m9-skill-file-0", "second.txt") }, + ], + }, + ], + }, + ]; + + const artifacts = collectConversationArtifacts(messages); + const keys = artifacts.map((artifact) => artifact.key); + + expect(artifacts).toHaveLength(2); + expect(new Set(keys).size).toBe(2); +}); + +test("collectConversationArtifacts returns empty for empty input", () => { + expect(collectConversationArtifacts([])).toEqual([]); +}); + +test("ConversationArtifactsPanel renders artifacts with actions", () => { + const artifacts = collectConversationArtifacts([ + { + id: "m5", + role: "assistant", + createdAt: new Date("2026-10-31T08:10:00Z"), + content: [ + { + type: "text", + skillFileAttachments: [makeAttachment("a1", "summary.pptx")], + }, + ], + }, + ]); + + render( + + {}} + /> + + ); + + expect(screen.getByText("summary.pptx")).toBeTruthy(); + expect(screen.getByText("chat.artifacts.total:1")).toBeTruthy(); + // Size (2.0 KB) and generated-at metadata render alongside the name. + expect(screen.getByText(/2\.0 KB/)).toBeTruthy(); +}); + +test("ConversationArtifactsPanel preview opens the shared drawer", () => { + const artifacts = collectConversationArtifacts([ + { + id: "m6", + role: "assistant", + content: [ + { + type: "text", + skillFileAttachments: [makeAttachment("a1", "summary.pptx")], + }, + ], + }, + ]); + + render( + + {}} + /> + + ); + + fireEvent.click( + screen.getByRole("button", { name: "chat.artifacts.preview" }) + ); + + expect(previewDrawerMock.open).toHaveBeenCalledWith( + expect.objectContaining({ + open: true, + fileName: "summary.pptx", + objectName: "runs/a1/summary.pptx", + downloadUrl: "/api/storage/download/a1", + }) + ); + expect(screen.getByTestId("file-preview-drawer")).toBeTruthy(); +}); + +test("ConversationArtifactsPanel download uses the download url", () => { + const artifacts = collectConversationArtifacts([ + { + id: "m7", + role: "assistant", + content: [ + { + type: "text", + skillFileAttachments: [makeAttachment("a1", "summary.pptx")], + }, + ], + }, + ]); + + render( + + {}} + /> + + ); + + fireEvent.click( + screen.getByRole("button", { name: "chat.artifacts.download" }) + ); + + // The anchor-based download path does not call the storage service. + expect(storageServiceMock.downloadFile).not.toHaveBeenCalled(); +}); + +test("ConversationArtifactsPanel download falls back to object storage", () => { + const artifacts = collectConversationArtifacts([ + { + id: "m8", + role: "assistant", + content: [ + { + type: "text", + skillFileAttachments: [ + makeAttachment("a1", "summary.pptx", { download_url: undefined }), + ], + }, + ], + }, + ]); + + render( + + {}} + /> + + ); + + fireEvent.click( + screen.getByRole("button", { name: "chat.artifacts.download" }) + ); + + expect(storageServiceMock.downloadFile).toHaveBeenCalledWith( + "runs/a1/summary.pptx", + "summary.pptx" + ); +}); + +test("ConversationArtifactsPanel hides when closed", () => { + const { container } = render( + {}} + /> + ); + + expect(container.firstChild).toBeNull(); +}); + +test("ConversationArtifactsPanel shows the empty state", () => { + render( + + {}} /> + + ); + + expect(screen.getByText("chat.artifacts.empty")).toBeTruthy(); +}); + +test("ConversationArtifactsButton hides without artifacts", () => { + const { container } = render( + {}} /> + ); + + expect(container.firstChild).toBeNull(); +}); + +test("ConversationArtifactsButton opens the panel on click", () => { + const onClick = vi.fn(); + render(); + + const button = screen.getByRole("button", { + name: "chat.artifacts.viewAll:3", + }); + fireEvent.click(button); + + expect(onClick).toHaveBeenCalledTimes(1); +}); diff --git a/test-e2e/changes/requirements/CHAT-ARTIFACTS-PANEL-20261031.yaml b/test-e2e/changes/requirements/CHAT-ARTIFACTS-PANEL-20261031.yaml new file mode 100644 index 0000000000..4c4ba0d410 --- /dev/null +++ b/test-e2e/changes/requirements/CHAT-ARTIFACTS-PANEL-20261031.yaml @@ -0,0 +1,47 @@ +schema_version: "1.0" +change: + change_id: CHAT-ARTIFACTS-PANEL-20261031 + change_type: requirement + requirement_id: CHAT-ARTIFACTS-PANEL-20261031 + title: 对话产物面板聚合多轮生成的文件 + description: + 'Collect every agent-generated file across a conversation and expose + it through a persistent entry above the composer ("view all artifacts") that + opens a side panel listing each artifact with preview (shared FilePreviewDrawer) + and download. Aggregation covers both the streaming path (marker text parts + carrying skillFileAttachments) and the history-restore path (skillFileUploadsRegistry).' + affected_features: + existing: [] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: strengthen + test_selectors: + - frontend/tests/component/conversationArtifacts.test.tsx + behaviors: + - collectConversationArtifacts gathers attachments from assistant messages via + streamed cumulative parts, falls back to the history registry for restored + messages, keeps keys unique across streaming chunks whose attachment ids restart, + skips user messages, and tolerates invalid createdAt values. + - ConversationArtifactsPanel renders each artifact with icon, size and generated-at + metadata, opens the shared FilePreviewDrawer for preview, prefers download_url + and falls back to object storage or raw preview URLs, and hides when closed. + - ConversationArtifactsButton stays hidden without artifacts and opens the panel + on click. + reason: |- + The feature is a frontend aggregation over already-proven contracts: the SSE + files/skill_files chunk parsing, the FilePreviewDrawer rendering, and the + storageService download path all have existing coverage and are reused + unchanged. No new inter-component, service or protocol boundary is introduced, + so per the D1 admission rules ("a feature need not add a D1 if UT and + higher-layer tests already supply the necessary proof") the targeted UT is + sufficient and no formal Case is added. + notes: | + Core scope requested by the user; extension candidates (artifact grouping by turn, + share-page availability, office online preview) are intentionally deferred. From 71de5f7cc08224bb46631cf6a608668530139912 Mon Sep 17 00:00:00 2001 From: ljy Date: Mon, 2 Nov 2026 10:00:23 +0800 Subject: [PATCH 02/26] feat(chat): add "add to chat" action for conversation artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Artifacts listed in the conversation artifacts panel can now be re-attached into the composer (WorkBuddy "添加到对话框" parity): - attachment-adapter: files marked via markFileWithExistingUpload carry their MinIO metadata through add(); send() reuses the stored object instead of uploading the same bytes again, keeping minio_files intact - conversation-artifacts: fetchArtifactFile resolves artifact bytes via the backend stream endpoint for object_name with URL fallbacks - artifacts-panel: optional onAddToComposer prop renders a per-entry "add to chat" action with an in-flight guard; omitted in read-only and shared conversations - thread: wires the handler to the main thread composer with success and failure feedback; artifacts without object_name fall back to the regular upload path - i18n: chat.artifacts.addToChat / addedToChat / addToChatFailed (zh/en) - tests: 8 new UTs (22 total) covering the panel action, the fetch fallback chain, and stored-object reuse; adds the missing copyright header to attachment-adapter.ts Change record updated: CHAT-ARTIFACTS-PANEL-20261031. --- .../newchat/adapter/attachment-adapter.ts | 102 +++++++-- .../[locale]/newchat/assistant-ui/thread.tsx | 54 +++++ .../[locale]/newchat/ui/artifacts-panel.tsx | 45 +++- .../newchat/utils/conversation-artifacts.ts | 58 +++++ frontend/public/locales/en/common.json | 3 + frontend/public/locales/zh/common.json | 3 + .../component/conversationArtifacts.test.tsx | 207 +++++++++++++++++- .../CHAT-ARTIFACTS-PANEL-20261031.yaml | 13 +- 8 files changed, 455 insertions(+), 30 deletions(-) diff --git a/frontend/app/[locale]/newchat/adapter/attachment-adapter.ts b/frontend/app/[locale]/newchat/adapter/attachment-adapter.ts index fa2cd6c371..bf66f4c54f 100644 --- a/frontend/app/[locale]/newchat/adapter/attachment-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/attachment-adapter.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + "use client"; import type { @@ -39,6 +41,65 @@ interface UploadedFileMeta { size: number; } +/** + * Marker for files whose bytes already live in storage (agent-generated + * artifacts added back into the composer). `send()` reuses the stashed + * metadata instead of uploading the same object a second time. + */ +export type ExistingUploadMeta = UploadedFileMeta; + +const EXISTING_UPLOAD_MARKER = "__nexentExistingUpload"; + +type FileWithUploadMarker = File & { + [EXISTING_UPLOAD_MARKER]?: ExistingUploadMeta; +}; + +/** Stashes known storage metadata on a file so `send()` can skip the upload. */ +export const markFileWithExistingUpload = ( + file: File, + meta: ExistingUploadMeta +): File => { + (file as FileWithUploadMarker)[EXISTING_UPLOAD_MARKER] = meta; + return file; +}; + +const readExistingUploadMeta = ( + file: File | undefined +): ExistingUploadMeta | undefined => + (file as FileWithUploadMarker | undefined)?.[EXISTING_UPLOAD_MARKER]; + +const buildCompleteAttachment = ( + attachment: PendingAttachment, + meta: UploadedFileMeta +): CompleteAttachment => { + // Prefer the presigned URL (works for time-limited access); fall back to url. + const serverUrl = meta.presigned_url || meta.url; + const isImage = attachment.type === "image"; + + // Build the content part so the message attachment carries a usable + // reference for both UI rendering and downstream consumption. + const part: ThreadUserMessagePart = isImage + ? { type: "image", image: serverUrl } + : { + type: "file", + filename: attachment.name, + data: serverUrl, + mimeType: attachment.contentType || "application/octet-stream", + }; + + const completeAttachment: CompleteAttachment = { + id: attachment.id, + status: { type: "complete" }, + type: attachment.type as "image" | "document" | "file", + name: attachment.name, + contentType: attachment.contentType, + content: [part], + // Carry upload metadata alongside the typed fields. + ...(meta as object), + }; + return completeAttachment; +}; + const createPendingAttachment = async ({ file, }: { @@ -68,12 +129,24 @@ export const compositeAttachmentAdapter: AttachmentAdapter = { }, async send(attachment: PendingAttachment): Promise { + // Agent-generated artifacts re-attached from the artifacts panel carry + // their storage metadata; reuse the existing object instead of uploading + // the same bytes a second time. + const existingMeta = readExistingUploadMeta(attachment.file); + if (existingMeta) { + log.log( + "[AttachmentAdapter] Reusing stored object for attachment:", + existingMeta.object_name + ); + return buildCompleteAttachment(attachment, existingMeta); + } + const file = attachment.file; log.log( "[AttachmentAdapter] Uploading file to MinIO:", file.name, "type:", - attachment.type, + attachment.type ); try { @@ -88,38 +161,18 @@ export const compositeAttachmentAdapter: AttachmentAdapter = { throw new Error(errorMessage); } - // Prefer the presigned URL (works for time-limited access); fall back to url. - const serverUrl = item.presigned_url || item.url; - const isImage = attachment.type === "image"; - - // Build the content part so the message attachment carries a usable - // reference for both UI rendering and downstream consumption. - const part: ThreadUserMessagePart = isImage - ? { type: "image", image: serverUrl } - : { type: "file", filename: file.name, data: serverUrl, mimeType: file.type || "application/octet-stream" }; - // Stash upload metadata on the attachment for the ChatModelAdapter to // forward as `minio_files`. Cast through unknown because the public // BaseAttachment type does not declare these fields. const meta: UploadedFileMeta = { object_name: item.object_name, - url: serverUrl, + url: item.presigned_url || item.url, presigned_url: item.presigned_url, type: file.type || attachment.type, size: item.file_size ?? file.size, }; - const completeAttachment: CompleteAttachment = { - id: attachment.id, - status: { type: "complete" }, - type: attachment.type as "image" | "document" | "file", - name: attachment.name, - contentType: attachment.contentType, - content: [part], - // Carry upload metadata alongside the typed fields. - ...(meta as object), - }; - return completeAttachment; + return buildCompleteAttachment(attachment, meta); } catch (error) { log.error("[AttachmentAdapter] Upload error:", error); // Surface the error to the assistant-ui runtime so the user sees it. @@ -168,7 +221,8 @@ export const createNewChatAttachmentAdapter = (): AttachmentAdapter => { }, async send(attachment: PendingAttachment): Promise { - const completeAttachment = await compositeAttachmentAdapter.send(attachment); + const completeAttachment = + await compositeAttachmentAdapter.send(attachment); releaseAttachmentSlot(); return completeAttachment; }, diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 38cd7241cf..9682b1d6b4 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -67,8 +67,11 @@ import { ConversationArtifactsButton, ConversationArtifactsPanel, } from "../ui/artifacts-panel"; +import { markFileWithExistingUpload } from "../adapter/attachment-adapter"; import { + fetchArtifactFile, useConversationArtifacts, + type ArtifactAttachmentLike, type ConversationArtifact, } from "../utils/conversation-artifacts"; import { @@ -94,6 +97,7 @@ import { XIcon, } from "lucide-react"; import { message } from "antd"; +import log from "@/lib/logger"; import type { Agent, PublishedAgent } from "@/types/agentConfig"; import { getConversationResourceLimitMessage } from "@/const/errorMessageI18n"; import { getAgentIcon } from "@/lib/chat/agentIconUtils"; @@ -701,6 +705,51 @@ const ThreadView: FC = ({ agent.example_questions ); + // "Add to chat": re-attach an agent-generated artifact into the composer so + // the next outgoing message references the already-stored object instead of + // uploading its bytes a second time. + const handleArtifactToComposer = useCallback( + async (attachment: ArtifactAttachmentLike) => { + const name = attachment.name || "Generated file"; + try { + const file = await fetchArtifactFile(attachment); + const objectName = attachment.object_name ?? ""; + // Artifacts with a known MinIO object carry their storage metadata so + // the attachment adapter skips the redundant upload; the rest fall + // back to the regular upload path. + const enriched = objectName + ? markFileWithExistingUpload(file, { + object_name: objectName, + url: + attachment.preview_url ?? + attachment.presigned_url ?? + attachment.url ?? + "", + presigned_url: attachment.presigned_url, + type: attachment.contentType ?? file.type, + size: attachment.size ?? file.size, + }) + : file; + const runtime = aui.threads().__internal_getAssistantRuntime?.(); + const threadId = runtime?.threads.getState().mainThreadId; + const thread = threadId + ? runtime?.threads.getById(threadId) + : undefined; + if (!thread) { + log.error("[Artifacts] Composer runtime unavailable"); + message.error(t("chat.artifacts.addToChatFailed")); + return; + } + await thread.composer.addAttachment(enriched); + message.success(t("chat.artifacts.addedToChat", { name })); + } catch (error) { + log.error("Failed to add conversation artifact to composer:", error); + message.error(t("chat.artifacts.addToChatFailed")); + } + }, + [aui, t] + ); + return ( = ({ open={artifactsOpen} artifacts={conversationArtifacts} onClose={() => onArtifactsOpenChange(false)} + onAddToComposer={ + showComposer && !isShareMode && !readOnly + ? handleArtifactToComposer + : undefined + } /> ); diff --git a/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx b/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx index 279f0c9ea3..1ef2418e31 100644 --- a/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx +++ b/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx @@ -8,6 +8,7 @@ import { EyeIcon, FolderOutputIcon, ImageIcon, + MessageSquarePlusIcon, XIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -29,6 +30,14 @@ export interface ConversationArtifactsPanelProps { /** All agent-generated files collected across the conversation. */ artifacts: ConversationArtifact[]; onClose: () => void; + /** + * Adds an artifact back into the composer as an attachment ("add to chat"). + * When omitted the per-entry action is hidden, e.g. in read-only or shared + * conversations where there is no composer to add to. + */ + onAddToComposer?: ( + attachment: ArtifactAttachmentLike + ) => Promise | void; className?: string; } @@ -46,19 +55,37 @@ const formatGeneratedAt = (value: Date | undefined): string => { /** * Side panel listing every file the agent generated during the conversation. * Mirrors the SourcesPanel layout (right-hand column, header + scrollable - * list) so the two panels read as one family. Each entry supports preview, - * through the shared {@link FilePreviewDrawer}, and download. + * list) so the two panels read as one family. Each entry supports preview + * through the shared {@link FilePreviewDrawer}, download, and re-attaching + * the file to the composer when {@link ConversationArtifactsPanelProps.onAddToComposer} + * is provided. */ export const ConversationArtifactsPanel: FC< ConversationArtifactsPanelProps -> = ({ open, artifacts, onClose, className }) => { +> = ({ open, artifacts, onClose, onAddToComposer, className }) => { const { t } = useTranslation(); const [previewing, setPreviewing] = useState( null ); + // Key of the entry currently being added to the composer; guards against + // double-activating the fetch-and-attach flow while it is in flight. + const [addingKey, setAddingKey] = useState(null); if (!open) return null; + const handleAddToComposer = async ( + key: string, + attachment: ArtifactAttachmentLike + ) => { + if (!onAddToComposer || addingKey === key) return; + setAddingKey(key); + try { + await onAddToComposer(attachment); + } finally { + setAddingKey(null); + } + }; + const handleDownload = async (attachment: ArtifactAttachmentLike) => { const name = attachment.name || "Generated file"; try { @@ -171,6 +198,18 @@ export const ConversationArtifactsPanel: FC< > + {onAddToComposer && ( + + void handleAddToComposer(artifact.key, attachment) + } + > + + + )} diff --git a/frontend/app/[locale]/newchat/utils/conversation-artifacts.ts b/frontend/app/[locale]/newchat/utils/conversation-artifacts.ts index d54cac408e..d160ba54f2 100644 --- a/frontend/app/[locale]/newchat/utils/conversation-artifacts.ts +++ b/frontend/app/[locale]/newchat/utils/conversation-artifacts.ts @@ -4,6 +4,7 @@ import { useMemo } from "react"; import type { CompleteAttachment } from "@assistant-ui/react"; +import { API_ENDPOINTS } from "@/services/api"; import { skillFileUploadsRegistry } from "../adapter/remote-chat-model-adapter"; /** @@ -115,3 +116,60 @@ export function useConversationArtifacts( ): ConversationArtifact[] { return useMemo(() => collectConversationArtifacts(messages), [messages]); } + +/** Raw URL stashed on a file attachment content part, if any. */ +const readAttachmentPartUrl = ( + attachment: ArtifactAttachmentLike +): string | undefined => { + const part = attachment.content?.[0] as { data?: string } | undefined; + return part?.data; +}; + +/** + * Downloads an artifact's bytes so it can be attached to the composer. + * + * Prefers the backend stream endpoint for the artifact's `object_name` (same + * origin, no CORS concerns) and falls back to the attachment's own URLs when + * no object name is available. + */ +export async function fetchArtifactFile( + attachment: ArtifactAttachmentLike +): Promise { + const name = attachment.name || "Generated file"; + const contentType = attachment.contentType || "application/octet-stream"; + const sources: string[] = []; + if (attachment.object_name) { + sources.push( + API_ENDPOINTS.storage.file(attachment.object_name, "stream", name) + ); + } + for (const url of [ + attachment.download_url, + attachment.preview_url, + attachment.presigned_url, + attachment.url, + readAttachmentPartUrl(attachment), + ]) { + if (url) sources.push(url); + } + if (sources.length === 0) { + throw new Error(`No downloadable source for artifact: ${name}`); + } + + let lastError: unknown; + for (const url of sources) { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Fetch failed with status ${response.status}`); + } + const blob = await response.blob(); + return new File([blob], name, { type: contentType }); + } catch (error) { + lastError = error; + } + } + throw lastError instanceof Error + ? lastError + : new Error(`Failed to fetch artifact: ${name}`); +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 66d2fca8f1..efa3565faa 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -475,6 +475,9 @@ "chat.artifacts.empty": "No files generated in this conversation yet", "chat.artifacts.preview": "Preview", "chat.artifacts.download": "Download", + "chat.artifacts.addToChat": "Add to chat", + "chat.artifacts.addedToChat": "{{name}} added to the chat box", + "chat.artifacts.addToChatFailed": "Failed to add to the chat box. Please try again", "chat.artifacts.close": "Close", "chat.artifacts.generatedAt": "Generated {{time}}", "chat.tokenUsage.used": "used", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 6edf4009e2..db661f0d58 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -473,6 +473,9 @@ "chat.artifacts.empty": "本次对话尚未生成文件", "chat.artifacts.preview": "预览", "chat.artifacts.download": "下载", + "chat.artifacts.addToChat": "添加到对话框", + "chat.artifacts.addedToChat": "已将 {{name}} 添加到对话框", + "chat.artifacts.addToChatFailed": "添加到对话框失败,请重试", "chat.artifacts.close": "关闭", "chat.artifacts.generatedAt": "生成于 {{time}}", "chat.tokenUsage.used": "已使用", diff --git a/frontend/tests/component/conversationArtifacts.test.tsx b/frontend/tests/component/conversationArtifacts.test.tsx index 6005d4a5e2..c48664393c 100644 --- a/frontend/tests/component/conversationArtifacts.test.tsx +++ b/frontend/tests/component/conversationArtifacts.test.tsx @@ -1,16 +1,27 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -import { fireEvent, render, screen, cleanup } from "@testing-library/react"; +import { + fireEvent, + render, + screen, + cleanup, + waitFor, +} from "@testing-library/react"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { collectConversationArtifacts, + fetchArtifactFile, type ArtifactMessageLike, } from "@/app/newchat/utils/conversation-artifacts"; import { ConversationArtifactsButton, ConversationArtifactsPanel, } from "@/app/newchat/ui/artifacts-panel"; +import { + compositeAttachmentAdapter, + markFileWithExistingUpload, +} from "@/app/newchat/adapter/attachment-adapter"; import { skillFileUploadsRegistry } from "@/app/newchat/adapter/remote-chat-model-adapter"; import { TooltipProvider } from "@/components/ui/tooltip"; import type { CompleteAttachment } from "@assistant-ui/react"; @@ -55,6 +66,7 @@ vi.mock("@/components/common/filePreviewDrawer", () => ({ const storageServiceMock = vi.hoisted(() => ({ downloadFile: vi.fn(), getPreviewUrl: vi.fn(() => "blob:preview"), + uploadFiles: vi.fn(), })); vi.mock("@/services/storageService", () => ({ storageService: storageServiceMock, @@ -408,3 +420,196 @@ test("ConversationArtifactsButton opens the panel on click", () => { expect(onClick).toHaveBeenCalledTimes(1); }); + +const renderArtifactsPanel = ( + panelProps: Partial[0]> = {} +) => { + const artifacts = collectConversationArtifacts([ + { + id: "m-add", + role: "assistant", + content: [ + { + type: "text", + skillFileAttachments: [makeAttachment("a1", "summary.pptx")], + }, + ], + }, + ]); + return render( + + {}} + {...panelProps} + /> + + ); +}; + +test("ConversationArtifactsPanel offers add-to-chat when a handler is provided", async () => { + const onAddToComposer = vi.fn(); + renderArtifactsPanel({ onAddToComposer }); + + const addButton = screen.getByRole("button", { + name: "chat.artifacts.addToChat", + }); + fireEvent.click(addButton); + + expect(onAddToComposer).toHaveBeenCalledTimes(1); + expect(onAddToComposer).toHaveBeenCalledWith( + expect.objectContaining({ name: "summary.pptx" }) + ); + // The in-flight guard releases once the handler settles. + await waitFor(() => expect(addButton).not.toBeDisabled()); +}); + +test("ConversationArtifactsPanel hides add-to-chat without a handler", () => { + renderArtifactsPanel(); + + expect( + screen.queryByRole("button", { name: "chat.artifacts.addToChat" }) + ).toBeNull(); +}); + +test("ConversationArtifactsPanel ignores repeat clicks while adding", () => { + const onAddToComposer = vi.fn( + () => new Promise(() => {}) // never settles + ); + renderArtifactsPanel({ onAddToComposer }); + + const addButton = screen.getByRole("button", { + name: "chat.artifacts.addToChat", + }); + fireEvent.click(addButton); + fireEvent.click(addButton); + + expect(onAddToComposer).toHaveBeenCalledTimes(1); +}); + +test("fetchArtifactFile prefers the object storage stream endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + blob: async () => new Blob(["bytes"]), + }); + vi.stubGlobal("fetch", fetchMock); + + try { + const file = await fetchArtifactFile(makeAttachment("a1", "report.pptx")); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain( + "/file/download/runs/a1/report.pptx" + ); + expect(file.name).toBe("report.pptx"); + expect(file.size).toBeGreaterThan(0); + } finally { + vi.unstubAllGlobals(); + } +}); + +test("fetchArtifactFile falls back to the attachment download url", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + blob: async () => new Blob(["bytes"]), + }); + vi.stubGlobal("fetch", fetchMock); + + try { + const attachment = makeAttachment("a2", "notes.md", { + object_name: undefined, + contentType: "text/markdown", + }); + const file = await fetchArtifactFile(attachment); + + expect(fetchMock).toHaveBeenCalledWith("/api/storage/download/a2"); + expect(file.name).toBe("notes.md"); + expect(file.type).toBe("text/markdown"); + } finally { + vi.unstubAllGlobals(); + } +}); + +test("fetchArtifactFile rejects when no source is available", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + try { + const attachment = makeAttachment("a3", "lost.bin", { + object_name: undefined, + preview_url: undefined, + download_url: undefined, + }); + + await expect(fetchArtifactFile(attachment)).rejects.toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } +}); + +const makePendingAttachment = (file: File, name: string) => + ({ + id: `att-${name}`, + status: { type: "running", reason: "composer-send" }, + type: "file", + name, + contentType: "application/vnd.api+json", + file, + }) as unknown as Parameters[0]; + +test("compositeAttachmentAdapter reuses stored objects for marked files", async () => { + const meta = { + object_name: "runs/a1/report.pptx", + url: "blob:preview-a1", + type: "application/vnd.api+json", + size: 2048, + }; + const file = markFileWithExistingUpload( + new File(["bytes"], "report.pptx"), + meta + ); + + const result = await compositeAttachmentAdapter.send( + makePendingAttachment(file, "report.pptx") + ); + + // The already-stored object must not be uploaded a second time. + expect(storageServiceMock.uploadFiles).not.toHaveBeenCalled(); + expect(result.status.type).toBe("complete"); + expect(result.content[0]).toMatchObject({ + type: "file", + data: "blob:preview-a1", + }); + expect(result).toMatchObject({ object_name: meta.object_name }); +}); + +test("compositeAttachmentAdapter still uploads unmarked files", async () => { + storageServiceMock.uploadFiles.mockResolvedValue({ + success_count: 1, + results: [ + { + success: true, + object_name: "attachments/report.pptx", + url: "blob:url", + presigned_url: "blob:presigned", + file_size: 2048, + }, + ], + }); + + const result = await compositeAttachmentAdapter.send( + makePendingAttachment(new File(["bytes"], "report.pptx"), "report.pptx") + ); + + expect(storageServiceMock.uploadFiles).toHaveBeenCalledTimes(1); + expect(result.status.type).toBe("complete"); + expect(result).toMatchObject({ object_name: "attachments/report.pptx" }); + expect(result.content[0]).toMatchObject({ + type: "file", + data: "blob:presigned", + }); +}); diff --git a/test-e2e/changes/requirements/CHAT-ARTIFACTS-PANEL-20261031.yaml b/test-e2e/changes/requirements/CHAT-ARTIFACTS-PANEL-20261031.yaml index 4c4ba0d410..20c1cbf11f 100644 --- a/test-e2e/changes/requirements/CHAT-ARTIFACTS-PANEL-20261031.yaml +++ b/test-e2e/changes/requirements/CHAT-ARTIFACTS-PANEL-20261031.yaml @@ -8,7 +8,8 @@ change: 'Collect every agent-generated file across a conversation and expose it through a persistent entry above the composer ("view all artifacts") that opens a side panel listing each artifact with preview (shared FilePreviewDrawer) - and download. Aggregation covers both the streaming path (marker text parts + and download, plus an "add to chat" action that re-attaches the artifact into + the composer. Aggregation covers both the streaming path (marker text parts carrying skillFileAttachments) and the history-restore path (skillFileUploadsRegistry).' affected_features: existing: [] @@ -31,7 +32,15 @@ change: skips user messages, and tolerates invalid createdAt values. - ConversationArtifactsPanel renders each artifact with icon, size and generated-at metadata, opens the shared FilePreviewDrawer for preview, prefers download_url - and falls back to object storage or raw preview URLs, and hides when closed. + and falls back to object storage or raw preview URLs, hides when closed, and + offers the add-to-chat action only when an onAddToComposer handler is provided + (read-only/share contexts omit the prop), with a per-entry in-flight guard. + - fetchArtifactFile downloads the artifact bytes via the backend stream endpoint + for object_name, falls back to the attachment's own URLs, and rejects when no + source exists. + - compositeAttachmentAdapter.send reuses the stored MinIO object for files marked + with markFileWithExistingUpload (no second upload, minio_files metadata intact) + and keeps uploading unmarked files through storageService. - ConversationArtifactsButton stays hidden without artifacts and opens the panel on click. reason: |- From 942433c0b7c684b26b54a4720c4400c1266f5eff Mon Sep 17 00:00:00 2001 From: ljy Date: Mon, 2 Nov 2026 10:24:59 +0800 Subject: [PATCH 03/26] feat(chat): add question directory with viewport jump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit List every user question asked in the current agent conversation and jump the message viewport to the selected turn ("问题目录"): - question-directory utils: collectQuestionDirectory extracts one entry per user message (first text line truncated for the summary, full text kept for the tooltip, first attachment name for attachment-only turns); scrollThreadToMessage locates the message root via data-message-id (assistant-ui exposes no scroll API), smooth-scrolls it into view, and briefly highlights the target - question-directory ui: QuestionDirectoryButton renders a floating chip on surfaces without a persistent header (main newchat flow) and a ghost header icon button elsewhere (workbench, share mode, read-only page); the shared popover lists numbered entries and closes on jump - thread: directory computed next to the artifacts aggregation and wired into all four surfaces; navigation stays available while a run streams and in read-only/share contexts - i18n: chat.questions.viewAll/title/total/jump (zh/en) - tests: 8 new UTs (30 with the artifacts regression) covering collection rules, scroll/highlight behavior, and popover interaction Change record added: QUESTION-DIRECTORY-JUMP-20261102. --- .../[locale]/newchat/assistant-ui/thread.tsx | 69 +++++- .../newchat/ui/question-directory.tsx | 118 +++++++++++ .../newchat/utils/question-directory.ts | 137 ++++++++++++ frontend/public/locales/en/common.json | 4 + frontend/public/locales/zh/common.json | 4 + .../component/questionDirectory.test.tsx | 199 ++++++++++++++++++ .../QUESTION-DIRECTORY-JUMP-20261102.yaml | 54 +++++ 7 files changed, 580 insertions(+), 5 deletions(-) create mode 100644 frontend/app/[locale]/newchat/ui/question-directory.tsx create mode 100644 frontend/app/[locale]/newchat/utils/question-directory.ts create mode 100644 frontend/tests/component/questionDirectory.test.tsx create mode 100644 test-e2e/changes/requirements/QUESTION-DIRECTORY-JUMP-20261102.yaml diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 9682b1d6b4..2096b6f44a 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -67,6 +67,7 @@ import { ConversationArtifactsButton, ConversationArtifactsPanel, } from "../ui/artifacts-panel"; +import { QuestionDirectoryButton } from "../ui/question-directory"; import { markFileWithExistingUpload } from "../adapter/attachment-adapter"; import { fetchArtifactFile, @@ -74,6 +75,11 @@ import { type ArtifactAttachmentLike, type ConversationArtifact, } from "../utils/conversation-artifacts"; +import { + scrollThreadToMessage, + useQuestionDirectory, + type QuestionDirectoryEntry, +} from "../utils/question-directory"; import { SourcesPanelProvider, useSourcesPanel, @@ -351,6 +357,9 @@ export const Thread: FC = ({ // composer and the side panel it opens. Computed here so both the button and // the panel share one aggregation pass over the thread messages. const conversationArtifacts = useConversationArtifacts(messages); + // Conversation-wide user questions feed the question directory entry at the + // top of the thread. Same single-pass pattern as the artifacts aggregation. + const questionDirectory = useQuestionDirectory(messages); const [artifactsOpen, setArtifactsOpen] = useState(false); const currentThreadTitle = useAuiState((s) => { const currentThread = s.threads.threadItems.find( @@ -502,6 +511,7 @@ export const Thread: FC = ({ conversationArtifacts={conversationArtifacts} artifactsOpen={artifactsOpen} onArtifactsOpenChange={setArtifactsOpen} + questionDirectory={questionDirectory} /> void; + questionDirectory: QuestionDirectoryEntry[]; variant: "default" | "embedded"; skillFiles?: readonly SkillFileContent[]; onSkillFileSelect?: (path: string) => void; @@ -676,6 +687,7 @@ const ThreadView: FC = ({ conversationArtifacts, artifactsOpen, onArtifactsOpenChange, + questionDirectory, variant, skillFiles, onSkillFileSelect, @@ -750,6 +762,19 @@ const ThreadView: FC = ({ [aui, t] ); + // "Question directory": jump the viewport to the message with the clicked + // question. Navigation is safe in read-only and while a run is streaming. + const handleJumpToQuestion = useCallback((messageId: string) => { + if (!scrollThreadToMessage(messageId)) { + log.warn("[QuestionDirectory] Jump target not found:", messageId); + } + }, []); + + const threadHeaderVisible = Boolean( + showConversationTitle && + (isShareMode || (!newChatDesign && workbenchPresentation)) + ); + return ( = ({
{conversationTitle}
+ )} + )} )} + {newChatDesign && !threadHeaderVisible && ( + + )} + ( null ); + const messages = useAuiState((s) => s.thread.messages); + const questionDirectory = useQuestionDirectory(messages); + const handleJumpToQuestion = useCallback((messageId: string) => { + if (!scrollThreadToMessage(messageId)) { + log.warn("[QuestionDirectory] Jump target not found:", messageId); + } + }, []); const open = useCallback( (payload: SourcesPanelSelection) => setSelection(payload), [] @@ -1037,11 +1088,19 @@ export const ReadOnlyConversation: FC<{ )} >
-
-

{title}

-

- {t("chatInterface.shareReadOnly", "分享对话仅可查看")} -

+
+
+

{title}

+

+ {t("chatInterface.shareReadOnly", "分享对话仅可查看")} +

+
+
diff --git a/frontend/app/[locale]/newchat/ui/question-directory.tsx b/frontend/app/[locale]/newchat/ui/question-directory.tsx new file mode 100644 index 0000000000..00cb86caf6 --- /dev/null +++ b/frontend/app/[locale]/newchat/ui/question-directory.tsx @@ -0,0 +1,118 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useState, type FC } from "react"; +import { ListTreeIcon, PaperclipIcon } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; +import type { QuestionDirectoryEntry } from "../utils/question-directory"; + +export interface QuestionDirectoryButtonProps { + /** User question turns collected across the conversation. */ + questions: QuestionDirectoryEntry[]; + /** Scrolls the thread to the clicked question's message. */ + onJump: (messageId: string) => void; + /** + * Floating renders a compact pill chip (overlaid on the thread viewport); + * header renders a ghost icon button for the persistent header bars. + */ + variant?: "floating" | "header"; + className?: string; +} + +/** + * Entry point for the in-conversation question directory: lists every question + * asked so far and jumps the message viewport to the selected turn. Rendered + * as a floating chip when the surface has no persistent header, and as a + * header icon button otherwise; the popover list is shared by both. + */ +export const QuestionDirectoryButton: FC = ({ + questions, + onJump, + variant = "floating", + className, +}) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + if (questions.length === 0) return null; + + const label = t("chat.questions.viewAll", { count: questions.length }); + + return ( + + + {variant === "header" ? ( + + ) : ( + + )} + + +
+ + {t("chat.questions.title")} + + + {t("chat.questions.total", { count: questions.length })} + +
+
    + {questions.map((question) => ( +
  • + +
  • + ))} +
+
+
+ ); +}; diff --git a/frontend/app/[locale]/newchat/utils/question-directory.ts b/frontend/app/[locale]/newchat/utils/question-directory.ts new file mode 100644 index 0000000000..2cb3157db0 --- /dev/null +++ b/frontend/app/[locale]/newchat/utils/question-directory.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useMemo } from "react"; + +/** Truncation length for the question summary shown in the directory list. */ +const QUESTION_SUMMARY_MAX_LENGTH = 60; + +/** + * Loose shape of an assistant-ui thread message as consumed by the question + * directory. Only the fields actually read are declared, mirroring the relaxed + * typing used by `conversation-artifacts.ts` for restored content parts. + */ +export interface QuestionMessageLike { + id: string; + role?: string; + content?: readonly { + type?: string; + text?: string; + }[]; + attachments?: readonly { name?: string }[]; +} + +/** One user question turn, flattened for the conversation directory. */ +export interface QuestionDirectoryEntry { + /** Stable identity (the user message id; also the jump target). */ + key: string; + /** assistant-ui message id used to locate the DOM node for scrolling. */ + messageId: string; + /** 1-based position of the question within the conversation. */ + index: number; + /** Short single-line label for the list. */ + summary: string; + /** Full question text (or attachment name) for the hover tooltip. */ + fullText: string; + /** Whether the turn was derived from text or from an attachment-only message. */ + kind: "text" | "attachment"; +} + +const truncateSummary = (value: string): string => { + const line = value.split("\n")[0]?.trim() ?? ""; + if (!line) return ""; + if (line.length <= QUESTION_SUMMARY_MAX_LENGTH) return line; + return `${line.slice(0, QUESTION_SUMMARY_MAX_LENGTH - 1)}…`; +}; + +/** + * Collects one directory entry per user message, in conversation order. + * + * Text questions use the first non-empty text part (first line, truncated) as + * the summary; messages that only carried attachments fall back to the first + * attachment's name. Messages with neither are skipped. + */ +export function collectQuestionDirectory( + messages: readonly QuestionMessageLike[] +): QuestionDirectoryEntry[] { + const entries: QuestionDirectoryEntry[] = []; + for (const message of messages) { + if (message.role !== "user") continue; + + const fullText = + message.content + ?.find( + (part) => + part?.type === "text" && typeof part.text === "string" && part.text + ) + ?.text?.trim() ?? ""; + + let kind: QuestionDirectoryEntry["kind"]; + let source: string; + if (fullText) { + kind = "text"; + source = fullText; + } else { + const attachmentName = message.attachments?.find((a) => a?.name)?.name; + if (!attachmentName) continue; + kind = "attachment"; + source = attachmentName; + } + + const summary = truncateSummary(source); + if (!summary) continue; + + entries.push({ + key: message.id, + messageId: message.id, + index: entries.length + 1, + summary, + fullText: source, + kind, + }); + } + return entries; +} + +/** + * Memoized wrapper for component consumption. `messages` identity changes on + * every streamed token, which keeps the directory live during generation. + */ +export function useQuestionDirectory( + messages: readonly QuestionMessageLike[] +): QuestionDirectoryEntry[] { + return useMemo(() => collectQuestionDirectory(messages), [messages]); +} + +/** + * Scrolls the thread viewport to the message with the given id. + * + * assistant-ui renders `data-message-id` on every message root and exposes no + * public scroll-to-message API, so the target is located through the DOM and + * scrolled natively. The target flashes briefly so the landing point is + * obvious after the smooth scroll settles. + */ +export function scrollThreadToMessage(messageId: string): boolean { + if (!messageId) return false; + // Message ids are runtime-generated, but escape quote/backslash anyway so + // the attribute selector stays valid for any id shape. + const escaped = messageId.replace(/["\\]/g, "\\$&"); + const element = document.querySelector( + `[data-message-id="${escaped}"]` + ); + if (!element) return false; + + // Breathing room between the landed message and the viewport top edge. + element.style.scrollMarginTop = "16px"; + element.scrollIntoView({ behavior: "smooth", block: "start" }); + + // Brief highlight so the jump target is visible after scrolling. + element.style.transition = "box-shadow 0.4s ease-out"; + element.style.boxShadow = "0 0 0 3px rgba(59, 130, 246, 0.45)"; + window.setTimeout(() => { + element.style.boxShadow = ""; + element.style.transition = ""; + }, 1600); + return true; +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index efa3565faa..2597a7ad5e 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -479,6 +479,10 @@ "chat.artifacts.addedToChat": "{{name}} added to the chat box", "chat.artifacts.addToChatFailed": "Failed to add to the chat box. Please try again", "chat.artifacts.close": "Close", + "chat.questions.viewAll": "Questions ({{count}})", + "chat.questions.title": "Question directory", + "chat.questions.total": "{{count}} questions in total", + "chat.questions.jump": "Jump to question {{index}}", "chat.artifacts.generatedAt": "Generated {{time}}", "chat.tokenUsage.used": "used", "chat.tokenUsage.turn": "this turn", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index db661f0d58..fa06e92553 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -477,6 +477,10 @@ "chat.artifacts.addedToChat": "已将 {{name}} 添加到对话框", "chat.artifacts.addToChatFailed": "添加到对话框失败,请重试", "chat.artifacts.close": "关闭", + "chat.questions.viewAll": "问题目录 ({{count}})", + "chat.questions.title": "问题目录", + "chat.questions.total": "共 {{count}} 个问题", + "chat.questions.jump": "跳转到问题 {{index}}", "chat.artifacts.generatedAt": "生成于 {{time}}", "chat.tokenUsage.used": "已使用", "chat.tokenUsage.turn": "本轮", diff --git a/frontend/tests/component/questionDirectory.test.tsx b/frontend/tests/component/questionDirectory.test.tsx new file mode 100644 index 0000000000..ce55f73ccf --- /dev/null +++ b/frontend/tests/component/questionDirectory.test.tsx @@ -0,0 +1,199 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; + +import { + collectQuestionDirectory, + scrollThreadToMessage, + type QuestionMessageLike, +} from "@/app/newchat/utils/question-directory"; +import { QuestionDirectoryButton } from "@/app/newchat/ui/question-directory"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => { + if (params && "count" in params) return `${key}:${params.count}`; + if (params && "index" in params) return `${key}:${params.index}`; + return key; + }, + }), +})); + +vi.mock("@/lib/logger", () => ({ + default: { error: vi.fn(), warn: vi.fn(), log: vi.fn() }, +})); + +const makeMessage = ( + id: string, + role: string, + overrides: Record = {} +): QuestionMessageLike => + ({ + id, + role, + content: [{ type: "text", text: `question ${id}` }], + ...overrides, + }) as QuestionMessageLike; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + document.body.innerHTML = ""; +}); + +test("collectQuestionDirectory extracts user questions in order", () => { + const messages: QuestionMessageLike[] = [ + makeMessage("u1", "user", { + content: [{ type: "text", text: "第一问" }], + }), + makeMessage("a1", "assistant"), + makeMessage("u2", "user", { + content: [{ type: "text", text: "第二问" }], + }), + ]; + + const questions = collectQuestionDirectory(messages); + + expect(questions).toHaveLength(2); + expect(questions[0]).toMatchObject({ + key: "u1", + messageId: "u1", + index: 1, + summary: "第一问", + kind: "text", + }); + expect(questions[1]).toMatchObject({ + key: "u2", + messageId: "u2", + index: 2, + kind: "text", + }); +}); + +test("collectQuestionDirectory uses the first line and truncates long questions", () => { + const longQuestion = `第一行超长问题${"很".repeat(80)},后面还有第二行`; + const questions = collectQuestionDirectory([ + makeMessage("u1", "user", { + content: [{ type: "text", text: `${longQuestion}\nsecond line` }], + }), + ]); + + expect(questions).toHaveLength(1); + expect(questions[0]?.summary.startsWith("第一行超长问题")).toBe(true); + expect(questions[0]?.summary.length).toBeLessThanOrEqual(60); + expect(questions[0]?.summary.endsWith("…")).toBe(true); + // The full multi-line text is preserved for the tooltip. + expect(questions[0]?.fullText).toContain("second line"); +}); + +test("collectQuestionDirectory falls back to the first attachment name", () => { + const questions = collectQuestionDirectory([ + makeMessage("u1", "user", { + content: [{ type: "file", filename: "data.csv" }], + attachments: [{ name: "data.csv" }, { name: "extra.txt" }], + }), + ]); + + expect(questions).toHaveLength(1); + expect(questions[0]).toMatchObject({ + kind: "attachment", + summary: "data.csv", + fullText: "data.csv", + }); +}); + +test("collectQuestionDirectory skips empty and non-user messages", () => { + const questions = collectQuestionDirectory([ + makeMessage("a1", "assistant"), + makeMessage("u1", "user", { content: [], attachments: [] }), + makeMessage("u2", "user", { + content: [{ type: "text", text: " " }], + }), + ]); + + expect(questions).toEqual([]); +}); + +test("scrollThreadToMessage scrolls to and highlights the target message", () => { + const element = document.createElement("div"); + element.setAttribute("data-message-id", "msg-42"); + const scrollIntoView = vi.fn(); + element.scrollIntoView = scrollIntoView; + document.body.appendChild(element); + + const found = scrollThreadToMessage("msg-42"); + + expect(found).toBe(true); + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: "smooth", + block: "start", + }); + expect(element.style.boxShadow).not.toBe(""); +}); + +test("scrollThreadToMessage reports missing targets", () => { + expect(scrollThreadToMessage("missing")).toBe(false); + expect(scrollThreadToMessage("")).toBe(false); +}); + +const renderDirectoryButton = ( + props: Partial[0]> = {} +) => + render( + + {}} + {...props} + /> + + ); + +test("QuestionDirectoryButton hides without questions", () => { + const { container } = render( + + {}} /> + + ); + + expect(container.firstChild).toBeNull(); +}); + +test("QuestionDirectoryButton lists questions and jumps on click", async () => { + const onJump = vi.fn(); + renderDirectoryButton({ onJump }); + + // The trigger shows the question count. + const trigger = screen.getByRole("button", { + name: "chat.questions.viewAll:2", + }); + fireEvent.click(trigger); + + // The popover lists both questions with jump affordances. + expect(await screen.findByText("第一问是什么")).toBeTruthy(); + expect(screen.getByText("data.csv")).toBeTruthy(); + + fireEvent.click( + screen.getByRole("button", { name: "chat.questions.jump:1" }) + ); + + expect(onJump).toHaveBeenCalledWith("u1"); + // Clicking an entry closes the popover. + await waitFor(() => expect(screen.queryByText("第一问是什么")).toBeNull()); +}); diff --git a/test-e2e/changes/requirements/QUESTION-DIRECTORY-JUMP-20261102.yaml b/test-e2e/changes/requirements/QUESTION-DIRECTORY-JUMP-20261102.yaml new file mode 100644 index 0000000000..fc10bc8667 --- /dev/null +++ b/test-e2e/changes/requirements/QUESTION-DIRECTORY-JUMP-20261102.yaml @@ -0,0 +1,54 @@ +schema_version: "1.0" +change: + change_id: QUESTION-DIRECTORY-JUMP-20261102 + change_type: requirement + requirement_id: QUESTION-DIRECTORY-JUMP-20261102 + title: 会话内问题目录跳转 + description: + 'List every user question asked during a single agent conversation in a + directory popover ("问题目录") and jump the message viewport to the + selected turn. The entry renders as a floating chip when the surface has + no persistent header (main newchat flow) and as a header icon button on + surfaces that have one (workbench presentation, share mode, read-only + conversation page). Jumping locates the message root via its + data-message-id attribute, smooth-scrolls the viewport, and briefly + highlights the target. Navigation stays available while a run streams and + in read-only/share contexts.' + affected_features: + existing: [] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + test_selectors: + - frontend/tests/component/questionDirectory.test.tsx + behaviors: + - collectQuestionDirectory extracts user messages in conversation order with + 1-based indices, uses the first non-empty text part (first line, truncated + to 60 chars with ellipsis, full text preserved for the tooltip), falls + back to the first attachment name for attachment-only turns, and skips + assistant messages plus turns with neither text nor attachments. + - scrollThreadToMessage locates the message root through its + data-message-id attribute, smooth-scrolls it into view, flashes a + highlight, and reports missing or empty targets as false. + - QuestionDirectoryButton hides without questions, opens the directory + popover from the trigger, lists each question with its jump affordance, + invokes onJump with the target message id, and closes the popover after + an entry is clicked. + reason: |- + The feature is a frontend navigation layer over already-proven contracts: + thread message state comes from the existing runtime subscription, the + popover reuses the shared Radix-based popover component, and no new + inter-component, service, or protocol boundary is introduced. Per the D1 + admission rules the targeted UT supplies the necessary component proof, + so no formal Case is added. + notes: | + Core scope confirmed by the user (popover form, header entry point, + attachment-name fallback, read-only support). Deferred: scroll-spy current + position indicator, per-question answer status, keyboard shortcut. From c2143534a6c97307d81e5b347937db743f629200 Mon Sep 17 00:00:00 2001 From: ljy Date: Mon, 2 Nov 2026 10:38:46 +0800 Subject: [PATCH 04/26] feat(chat): add keyword search to the question directory The question directory popover now filters entries as you type: - a search box at the top of the popover list, matching the full question text (not just the truncated summary) case-insensitively so keywords from any line still find the entry; attachment turns match by their file name - unmatched keywords show an explicit empty state - the keyword resets when the popover closes so reopening starts from the full directory again - i18n: chat.questions.search / noMatches (zh/en) - tests: 3 new UTs (11 total in the file) covering keyword filtering, the empty state, and the close-reset behaviour Change record updated: QUESTION-DIRECTORY-JUMP-20261102. --- .../newchat/ui/question-directory.tsx | 51 +++++++++++++-- frontend/public/locales/en/common.json | 2 + frontend/public/locales/zh/common.json | 2 + .../component/questionDirectory.test.tsx | 64 +++++++++++++++++++ .../QUESTION-DIRECTORY-JUMP-20261102.yaml | 21 +++--- 5 files changed, 125 insertions(+), 15 deletions(-) diff --git a/frontend/app/[locale]/newchat/ui/question-directory.tsx b/frontend/app/[locale]/newchat/ui/question-directory.tsx index 00cb86caf6..64f41db569 100644 --- a/frontend/app/[locale]/newchat/ui/question-directory.tsx +++ b/frontend/app/[locale]/newchat/ui/question-directory.tsx @@ -2,8 +2,8 @@ "use client"; -import { useState, type FC } from "react"; -import { ListTreeIcon, PaperclipIcon } from "lucide-react"; +import { useMemo, useState, type FC } from "react"; +import { ListTreeIcon, PaperclipIcon, SearchIcon } from "lucide-react"; import { Popover, PopoverContent, @@ -31,7 +31,8 @@ export interface QuestionDirectoryButtonProps { * Entry point for the in-conversation question directory: lists every question * asked so far and jumps the message viewport to the selected turn. Rendered * as a floating chip when the surface has no persistent header, and as a - * header icon button otherwise; the popover list is shared by both. + * header icon button otherwise; the popover list is shared by both and offers + * a keyword filter over the full question text. */ export const QuestionDirectoryButton: FC = ({ questions, @@ -41,13 +42,32 @@ export const QuestionDirectoryButton: FC = ({ }) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + // Match against the full text (not the truncated summary) so keywords from + // any line of the question still find the entry; case-insensitive. + const filteredQuestions = useMemo(() => { + const keyword = search.trim().toLowerCase(); + if (!keyword) return questions; + return questions.filter((question) => + question.fullText.toLowerCase().includes(keyword) + ); + }, [questions, search]); if (questions.length === 0) return null; const label = t("chat.questions.viewAll", { count: questions.length }); return ( - + { + setOpen(nextOpen); + // Reset the filter between sessions so reopening starts from the + // full directory. + if (!nextOpen) setSearch(""); + }} + > {variant === "header" ? ( ))} + {filteredQuestions.length === 0 && ( +
  • + {t("chat.questions.noMatches")} +
  • + )}
    diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 2597a7ad5e..16550de50a 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -483,6 +483,8 @@ "chat.questions.title": "Question directory", "chat.questions.total": "{{count}} questions in total", "chat.questions.jump": "Jump to question {{index}}", + "chat.questions.search": "Search questions…", + "chat.questions.noMatches": "No matching questions", "chat.artifacts.generatedAt": "Generated {{time}}", "chat.tokenUsage.used": "used", "chat.tokenUsage.turn": "this turn", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index fa06e92553..2e783e6a61 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -481,6 +481,8 @@ "chat.questions.title": "问题目录", "chat.questions.total": "共 {{count}} 个问题", "chat.questions.jump": "跳转到问题 {{index}}", + "chat.questions.search": "搜索问题…", + "chat.questions.noMatches": "没有匹配的问题", "chat.artifacts.generatedAt": "生成于 {{time}}", "chat.tokenUsage.used": "已使用", "chat.tokenUsage.turn": "本轮", diff --git a/frontend/tests/component/questionDirectory.test.tsx b/frontend/tests/component/questionDirectory.test.tsx index ce55f73ccf..5357d4e29e 100644 --- a/frontend/tests/component/questionDirectory.test.tsx +++ b/frontend/tests/component/questionDirectory.test.tsx @@ -197,3 +197,67 @@ test("QuestionDirectoryButton lists questions and jumps on click", async () => { // Clicking an entry closes the popover. await waitFor(() => expect(screen.queryByText("第一问是什么")).toBeNull()); }); + +test("QuestionDirectoryButton filters questions by keyword", async () => { + renderDirectoryButton(); + + fireEvent.click( + screen.getByRole("button", { name: "chat.questions.viewAll:2" }) + ); + const searchBox = await screen.findByRole("textbox", { + name: "chat.questions.search", + }); + + fireEvent.change(searchBox, { target: { value: "第一问" } }); + await waitFor(() => expect(screen.getByText("第一问是什么")).toBeTruthy()); + expect(screen.queryByText("data.csv")).toBeNull(); + + // Filtering is case-insensitive and matches attachment turns. + fireEvent.change(searchBox, { target: { value: "DATA" } }); + await waitFor(() => expect(screen.getByText("data.csv")).toBeTruthy()); + expect(screen.queryByText("第一问是什么")).toBeNull(); +}); + +test("QuestionDirectoryButton shows an empty state for unmatched keywords", async () => { + renderDirectoryButton(); + + fireEvent.click( + screen.getByRole("button", { name: "chat.questions.viewAll:2" }) + ); + const searchBox = await screen.findByRole("textbox", { + name: "chat.questions.search", + }); + + fireEvent.change(searchBox, { target: { value: "不存在的关键词" } }); + + await waitFor(() => + expect(screen.getByTestId("question-directory-no-matches")).toBeTruthy() + ); + expect(screen.queryByText("第一问是什么")).toBeNull(); + expect(screen.queryByText("data.csv")).toBeNull(); +}); + +test("QuestionDirectoryButton resets the keyword when the popover closes", async () => { + renderDirectoryButton(); + + fireEvent.click( + screen.getByRole("button", { name: "chat.questions.viewAll:2" }) + ); + const searchBox = await screen.findByRole("textbox", { + name: "chat.questions.search", + }); + fireEvent.change(searchBox, { target: { value: "data" } }); + await waitFor(() => expect(screen.getByText("data.csv")).toBeTruthy()); + + // Close via the trigger; reopening must show the unfiltered directory again. + fireEvent.click( + screen.getByRole("button", { name: "chat.questions.viewAll:2" }) + ); + await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull()); + fireEvent.click( + screen.getByRole("button", { name: "chat.questions.viewAll:2" }) + ); + + await waitFor(() => expect(screen.getByText("第一问是什么")).toBeTruthy()); + expect(screen.getByText("data.csv")).toBeTruthy(); +}); diff --git a/test-e2e/changes/requirements/QUESTION-DIRECTORY-JUMP-20261102.yaml b/test-e2e/changes/requirements/QUESTION-DIRECTORY-JUMP-20261102.yaml index fc10bc8667..9489ea7174 100644 --- a/test-e2e/changes/requirements/QUESTION-DIRECTORY-JUMP-20261102.yaml +++ b/test-e2e/changes/requirements/QUESTION-DIRECTORY-JUMP-20261102.yaml @@ -7,13 +7,14 @@ change: description: 'List every user question asked during a single agent conversation in a directory popover ("问题目录") and jump the message viewport to the - selected turn. The entry renders as a floating chip when the surface has - no persistent header (main newchat flow) and as a header icon button on - surfaces that have one (workbench presentation, share mode, read-only - conversation page). Jumping locates the message root via its - data-message-id attribute, smooth-scrolls the viewport, and briefly - highlights the target. Navigation stays available while a run streams and - in read-only/share contexts.' + selected turn. The popover offers a keyword filter over the full question + text (case-insensitive, resets when it closes). The entry renders as a + floating chip when the surface has no persistent header (main newchat + flow) and as a header icon button on surfaces that have one (workbench + presentation, share mode, read-only conversation page). Jumping locates + the message root via its data-message-id attribute, smooth-scrolls the + viewport, and briefly highlights the target. Navigation stays available + while a run streams and in read-only/share contexts.' affected_features: existing: [] added: [] @@ -39,8 +40,10 @@ change: highlight, and reports missing or empty targets as false. - QuestionDirectoryButton hides without questions, opens the directory popover from the trigger, lists each question with its jump affordance, - invokes onJump with the target message id, and closes the popover after - an entry is clicked. + invokes onJump with the target message id, closes the popover after an + entry is clicked, filters entries by a case-insensitive keyword over the + full question text (attachment turns included), shows an empty state + for unmatched keywords, and resets the keyword when the popover closes. reason: |- The feature is a frontend navigation layer over already-proven contracts: thread message state comes from the existing runtime subscription, the From 030dd2169764583b90f3ec4192768ba641a625cc Mon Sep 17 00:00:00 2001 From: ljy Date: Mon, 2 Nov 2026 12:10:30 +0800 Subject: [PATCH 05/26] feat(chat): branch a conversation to a new chat from an answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "branch to new chat" action on the assistant message "..." menu. Branching creates a new conversation that copies the configuration (agent, chat mode, knowledge scope, runtime metadata, workbench config) plus every message, unit, search source, and image source up to and including the selected Q&A pair, then switches to the branched thread. The original conversation and its MinIO file references stay untouched; user feedback is not carried over. The turn is addressed by a 1-based index that both sides compute the same way: the frontend counts user messages up to the selected answer, the backend counts its active (deduplicated) user rows in message_index order, so no message-id mapping round trip is needed. Backend: POST /conversation/branch with conversation_id and turn_index; the repository clones rows in one transaction behind the user-quota advisory lock and collapses refresh duplicates / regenerated answers to the newest row per message_index. The branched title reuses the source title with a single "(分支)" suffix within the 100-char column. --- backend/contracts/conversation.py | 6 + .../conversation/conversation_management.py | 46 +- .../management/db/conversation/repository.py | 314 ++++++++++++++ .../services/conversation/service.py | 39 +- .../conversation-branch-context.ts | 10 + .../conversation-branch-menu-item.tsx | 26 ++ .../[locale]/newchat/assistant-ui/thread.tsx | 315 ++++++++------ .../newchat/utils/conversation-branch.ts | 29 ++ frontend/public/locales/en/common.json | 3 + frontend/public/locales/zh/common.json | 3 + frontend/services/api.ts | 1 + frontend/services/conversationService.ts | 21 + .../component/conversationBranch.test.tsx | 137 ++++++ ...NCH-CONVERSATION-TO-NEW-CHAT-20261102.yaml | 73 ++++ .../app/test_conversation_management_app.py | 104 +++++ .../database/test_conversation_branch_db.py | 392 ++++++++++++++++++ .../test_conversation_management_service.py | 46 ++ 17 files changed, 1429 insertions(+), 136 deletions(-) create mode 100644 frontend/app/[locale]/newchat/assistant-ui/conversation-branch-context.ts create mode 100644 frontend/app/[locale]/newchat/assistant-ui/conversation-branch-menu-item.tsx create mode 100644 frontend/app/[locale]/newchat/utils/conversation-branch.ts create mode 100644 frontend/tests/component/conversationBranch.test.tsx create mode 100644 test-e2e/changes/requirements/BRANCH-CONVERSATION-TO-NEW-CHAT-20261102.yaml create mode 100644 test/backend/database/test_conversation_branch_db.py diff --git a/backend/contracts/conversation.py b/backend/contracts/conversation.py index 6749038fd9..f006608e68 100644 --- a/backend/contracts/conversation.py +++ b/backend/contracts/conversation.py @@ -92,6 +92,12 @@ class RenameRequest(BaseModel): only_if_default_title: bool = False +class BranchConversationRequest(BaseModel): + conversation_id: int + # 1-based Q&A turn (inclusive) that marks the branch point + turn_index: int + + class BatchDeleteConversationRequest(BaseModel): conversation_ids: List[int] diff --git a/backend/management/api/conversation/conversation_management.py b/backend/management/api/conversation/conversation_management.py index ec4369bff3..2049abd3ee 100644 --- a/backend/management/api/conversation/conversation_management.py +++ b/backend/management/api/conversation/conversation_management.py @@ -1,5 +1,5 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -from contracts.conversation import BatchDeleteConversationRequest, ConversationRequest, ConversationResponse, GenerateTitleRequest, MessageIdRequest, OpinionRequest, RenameRequest +from contracts.conversation import BatchDeleteConversationRequest, BranchConversationRequest, ConversationRequest, ConversationResponse, GenerateTitleRequest, MessageIdRequest, OpinionRequest, RenameRequest from contracts.knowledge import ConversationKnowledgeScopeUpdateRequest from contracts.agent import WorkbenchConfigUpdateRequest from contracts.errors.exceptions import AppException, ConversationNotFoundError, ValidationError, TokenExpiredError, WorkbenchConfigVersionConflict, WorkbenchError @@ -10,12 +10,13 @@ from fastapi import APIRouter, Header, HTTPException, Path, Query, Request from contracts.constants.environment import ENABLE_AGENT_WORKBENCH -from contracts.conversation import BatchDeleteConversationRequest, ConversationRequest, ConversationResponse, GenerateTitleRequest, MessageIdRequest, OpinionRequest, RenameRequest +from contracts.conversation import BatchDeleteConversationRequest, BranchConversationRequest, ConversationRequest, ConversationResponse, GenerateTitleRequest, MessageIdRequest, OpinionRequest, RenameRequest from contracts.knowledge import ConversationKnowledgeScopeUpdateRequest from contracts.agent import WorkbenchConfigUpdateRequest from contracts.errors.exceptions import AppException, ConversationNotFoundError, ValidationError, TokenExpiredError, WorkbenchConfigVersionConflict, WorkbenchError from management.db.conversation.repository import get_conversation_list_page from management.services.conversation.service import ( + branch_conversation_service, create_new_conversation, delete_conversation_service, delete_conversations_batch_service, @@ -233,6 +234,47 @@ async def rename_conversation_endpoint(request: RenameRequest, authorization: Op raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) +@router.post("/branch", response_model=ConversationResponse) +async def branch_conversation_endpoint( + request: BranchConversationRequest, authorization: Optional[str] = Header(None) +): + """ + Branch a conversation into a new one up to and including the given turn + + Args: + request: BranchConversationRequest object containing: + - conversation_id: Source conversation ID + - turn_index: 1-based Q&A turn (inclusive) marking the branch point + authorization: Authorization header + + Returns: + ConversationResponse object containing: + - conversation_id: New conversation ID + - conversation_title: New conversation title (suffixed with the branch marker) + - branched_message_count: Number of copied messages + """ + try: + user_id, tenant_id = get_current_user_id(authorization) + branched = branch_conversation_service( + request.conversation_id, request.turn_index, user_id + ) + return ConversationResponse(code=0, message="success", data=branched) + except ValueError as exc: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)) from exc + except ConversationNotFoundError as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc)) from exc + except TokenExpiredError as e: + logging.warning("Session expired") + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except AppException: + raise + except HTTPException: + raise + except Exception as e: + logging.exception(f"Failed to branch conversation: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + @router.delete("/{conversation_id}", response_model=ConversationResponse) async def delete_conversation_endpoint(conversation_id: int, authorization: Optional[str] = Header(None)): """ diff --git a/backend/management/db/conversation/repository.py b/backend/management/db/conversation/repository.py index 16ba041472..291ba0491a 100644 --- a/backend/management/db/conversation/repository.py +++ b/backend/management/db/conversation/repository.py @@ -423,6 +423,320 @@ def create_message_units(message_units: List[Dict[str, Any]], message_id: int, c return unit_ids +# Title suffix appended to branched conversations; also stripped before +# re-appending so branching a branch does not stack suffixes. +BRANCH_TITLE_SUFFIX = "(分支)" +CONVERSATION_TITLE_MAX_LENGTH = 100 + + +def _build_branch_title(source_title: Optional[str]) -> str: + """Compose the branched conversation title within the column length.""" + base = (source_title or "").strip() + if base.endswith(BRANCH_TITLE_SUFFIX): + base = base[: -len(BRANCH_TITLE_SUFFIX)] + keep = CONVERSATION_TITLE_MAX_LENGTH - len(BRANCH_TITLE_SUFFIX) + if len(base) > keep: + base = base[:keep] + return f"{base}{BRANCH_TITLE_SUFFIX}" + + +def _select_active_branch_rows(rows: Sequence[ConversationMessage]) -> List[ConversationMessage]: + """Collapse rows sharing a message_index to the newest (active) row. + + Refresh flows may re-insert a user row with the same index and regenerated + answers append new assistant rows with the index of the previous attempt; + in both cases the row with the highest message_id is the active one. + """ + active_by_index: Dict[int, ConversationMessage] = {} + for row in rows: + active_by_index[int(row.message_index)] = row + return [active_by_index[index] for index in sorted(active_by_index)] + + +def _select_branch_turn_rows( + active_rows: Sequence[ConversationMessage], + turn_index: int, +) -> List[ConversationMessage]: + """Return the active rows up to and including the requested turn's answer. + + ``turn_index`` is 1-based over the active user rows in message_index order. + The copied tail keeps rows that follow the boundary user row until the + first assistant row (inclusive), so system/tool rows inside the turn and + the current answer are carried over while later turns stay behind. + """ + boundary_position = None + user_seen = 0 + for position, row in enumerate(active_rows): + if row.message_role == MESSAGE_ROLE["USER"]: + user_seen += 1 + if user_seen == turn_index: + boundary_position = position + break + if boundary_position is None: + raise ValueError( + f"turn_index {turn_index} exceeds the {user_seen} turns in the conversation" + ) + + branch_rows = list(active_rows[: boundary_position + 1]) + for row in active_rows[boundary_position + 1:]: + if row.message_role == MESSAGE_ROLE["USER"]: + break + branch_rows.append(row) + if row.message_role == MESSAGE_ROLE["ASSISTANT"]: + break + return branch_rows + + +def branch_conversation( + conversation_id: int, + turn_index: int, + user_id: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + """ + Branch a conversation into a new one up to and including the given turn. + + Copies conversation-level configuration plus the active messages, units and + search/image sources of every turn up to and including ``turn_index`` + (1-based over active user rows). MinIO file references inside + ``minio_files`` are shared, not copied; the original conversation and its + rows stay untouched. + + Args: + conversation_id: Source conversation ID (integer) + turn_index: 1-based Q&A turn (inclusive) marking the branch point + user_id: User that must own the source conversation + + Returns: + Optional[Dict[str, Any]]: New conversation data, or None when the + source conversation does not exist or is not accessible + """ + conversation_id = int(conversation_id) + turn_index = int(turn_index) + if turn_index < 1: + raise ValueError("turn_index must be a 1-based turn number") + + with get_db_session() as session: + # Take the user-quota advisory lock before any row lock so the lock + # ordering matches create_conversation (advisory -> row). + _enforce_user_conversation_limit(session, user_id) + + conversation = session.scalars( + select(ConversationRecord) + .where( + ConversationRecord.conversation_id == conversation_id, + ConversationRecord.delete_flag == "N", + *([ConversationRecord.created_by == user_id] if user_id else []), + ) + .with_for_update() + ).first() + if conversation is None: + return None + + message_rows = session.scalars( + select(ConversationMessage) + .where( + ConversationMessage.conversation_id == conversation_id, + ConversationMessage.delete_flag == "N", + ) + .order_by(asc(ConversationMessage.message_index), asc(ConversationMessage.message_id)) + .with_for_update() + ).all() + + active_rows = _select_active_branch_rows(message_rows) + branch_rows = _select_branch_turn_rows(active_rows, turn_index) + + conversation_data = { + "conversation_title": _build_branch_title(conversation.conversation_title), + "delete_flag": "N", + "agent_id": conversation.agent_id, + "chat_mode": conversation.chat_mode, + "knowledge_scope": deepcopy(conversation.knowledge_scope), + "runtime_metadata": deepcopy(conversation.runtime_metadata) or {}, + "runtime_metadata_version": 1, + "workbench_config": deepcopy(conversation.workbench_config), + "workbench_config_version": 1, + } + if user_id: + conversation_data = add_creation_tracking(conversation_data, user_id) + + new_conversation = session.execute( + insert(ConversationRecord) + .values(**conversation_data) + .returning( + ConversationRecord.conversation_id, + ConversationRecord.conversation_title, + ConversationRecord.agent_id, + ConversationRecord.chat_mode, + ConversationRecord.knowledge_scope, + ConversationRecord.runtime_metadata, + ConversationRecord.runtime_metadata_version, + _optional_conversation_column("workbench_config", None), + _optional_conversation_column("workbench_config_version", 0), + (func.extract("epoch", ConversationRecord.create_time) * 1000).label("create_time"), + (func.extract("epoch", ConversationRecord.update_time) * 1000).label("update_time"), + ) + ).fetchone() + + new_conversation_id = int(new_conversation.conversation_id) + branch_message_ids = [int(row.message_id) for row in branch_rows] + + units_by_message: Dict[int, List[ConversationMessageUnit]] = {} + searches_by_message: Dict[int, List[ConversationSourceSearch]] = {} + images_by_message: Dict[int, List[ConversationSourceImage]] = {} + if branch_message_ids: + for unit in session.scalars( + select(ConversationMessageUnit) + .where( + ConversationMessageUnit.message_id.in_(branch_message_ids), + ConversationMessageUnit.delete_flag == "N", + ) + .order_by(asc(ConversationMessageUnit.message_id), asc(ConversationMessageUnit.unit_index)) + ).all(): + units_by_message.setdefault(int(unit.message_id), []).append(unit) + + for search in session.scalars( + select(ConversationSourceSearch).where( + ConversationSourceSearch.message_id.in_(branch_message_ids), + ConversationSourceSearch.delete_flag == "N", + ) + ).all(): + searches_by_message.setdefault(int(search.message_id), []).append(search) + + for image in session.scalars( + select(ConversationSourceImage).where( + ConversationSourceImage.message_id.in_(branch_message_ids), + ConversationSourceImage.delete_flag == "N", + ) + ).all(): + images_by_message.setdefault(int(image.message_id), []).append(image) + + copied_unit_ids = { + int(unit.unit_id) + for units in units_by_message.values() + for unit in units + } + + message_id_map: Dict[int, int] = {} + for row in branch_rows: + # opinion_flag is intentionally not copied: user feedback stays + # attached to the original conversation's messages. + message_data = { + "conversation_id": new_conversation_id, + "message_index": row.message_index, + "message_role": row.message_role, + "message_source": row.message_source or MessageSource.CHAT.value, + "message_content": row.message_content, + "minio_files": row.minio_files, + "status": row.status or "completed", + "delete_flag": "N", + } + if user_id: + message_data = add_creation_tracking(message_data, user_id) + message_id_map[int(row.message_id)] = int( + session.execute( + insert(ConversationMessage) + .values(**message_data) + .returning(ConversationMessage.message_id) + ).scalar_one() + ) + + unit_id_map: Dict[int, int] = {} + for old_message_id, units in units_by_message.items(): + for unit in units: + unit_data = { + "message_id": message_id_map[old_message_id], + "conversation_id": new_conversation_id, + "unit_index": unit.unit_index, + "unit_type": unit.unit_type, + "unit_content": unit.unit_content, + "unit_status": unit.unit_status or "completed", + "tool_call_id": unit.tool_call_id, + "invocation_id": unit.invocation_id, + "delete_flag": "N", + } + if user_id: + unit_data = add_creation_tracking(unit_data, user_id) + unit_id_map[int(unit.unit_id)] = int( + session.execute( + insert(ConversationMessageUnit) + .values(**unit_data) + .returning(ConversationMessageUnit.unit_id) + ).scalar_one() + ) + + for old_message_id, searches in searches_by_message.items(): + for search in searches: + search_data = { + "message_id": message_id_map[old_message_id], + "conversation_id": new_conversation_id, + "unit_id": ( + unit_id_map.get(int(search.unit_id)) + if search.unit_id is not None and int(search.unit_id) in copied_unit_ids + else None + ), + "source_type": search.source_type, + "source_title": search.source_title, + "source_location": search.source_location, + "source_content": search.source_content, + "score_overall": search.score_overall, + "score_accuracy": search.score_accuracy, + "score_semantic": search.score_semantic, + "retrieval_highlight_terms": search.retrieval_highlight_terms, + "aidp_metadata": search.aidp_metadata, + "aidp_source_reference": search.aidp_source_reference, + "published_date": search.published_date, + "cite_index": search.cite_index, + "search_type": search.search_type, + "tool_sign": search.tool_sign, + "delete_flag": "N", + } + if user_id: + search_data = add_creation_tracking(search_data, user_id) + session.execute(insert(ConversationSourceSearch).values(**search_data)) + + for old_message_id, images in images_by_message.items(): + for image in images: + image_data = { + "message_id": message_id_map[old_message_id], + "conversation_id": new_conversation_id, + "unit_id": ( + unit_id_map.get(int(image.unit_id)) + if image.unit_id is not None and int(image.unit_id) in copied_unit_ids + else None + ), + "image_url": image.image_url, + "cite_index": image.cite_index, + "search_type": image.search_type, + "delete_flag": "N", + } + if user_id: + image_data = add_creation_tracking(image_data, user_id) + session.execute(insert(ConversationSourceImage).values(**image_data)) + + return { + "conversation_id": new_conversation_id, + "conversation_title": new_conversation.conversation_title, + "agent_id": new_conversation.agent_id, + "chat_mode": new_conversation.chat_mode or "execution", + "knowledge_scope": new_conversation.knowledge_scope, + "runtime_metadata": new_conversation.runtime_metadata or {}, + "runtime_metadata_version": new_conversation.runtime_metadata_version or 0, + "workbench_config": ( + new_conversation.workbench_config + if isinstance(getattr(new_conversation, "workbench_config", None), dict) + else None + ), + "workbench_config_version": ( + new_conversation.workbench_config_version + if isinstance(getattr(new_conversation, "workbench_config_version", None), int) + else 0 + ), + "create_time": int(new_conversation.create_time), + "update_time": int(new_conversation.update_time), + "branched_message_count": len(branch_rows), + } + + def persist_assistant_run_batch( message_id: int, conversation_id: int, diff --git a/backend/management/services/conversation/service.py b/backend/management/services/conversation/service.py index 76a1c1ee3b..9c8365115b 100644 --- a/backend/management/services/conversation/service.py +++ b/backend/management/services/conversation/service.py @@ -21,7 +21,7 @@ from contracts.conversation import MessageRequest, MessageUnit from contracts.errors.exceptions import AppException, ConversationNotFoundError, ValidationError from contracts.constants.constants import CHAT_MODE_VALUES -from management.db.conversation.repository import create_conversation, create_conversation_message, create_message_unit, create_source_image, create_source_search, delete_conversation, delete_conversations_batch, get_conversation, get_conversation_history, get_historical_context, get_latest_assistant_message, get_latest_assistant_message_id, get_latest_user_message_id, get_last_unit_for_message, get_message_id_by_index, get_source_images_by_conversation, get_source_images_by_message, get_source_searches_by_conversation, get_source_searches_by_message, persist_assistant_run_batch as persist_assistant_run_batch_db, rename_conversation, save_history_summary, update_conversation_agent_id, update_conversation_chat_mode, update_conversation_knowledge_scope, replace_conversation_workbench_config, replace_conversation_workbench_and_metadata, update_conversation_message_content, update_conversation_message_status, update_message_minio_files, update_message_opinion, update_message_unit_content, update_message_unit_status +from management.db.conversation.repository import branch_conversation, create_conversation, create_conversation_message, create_message_unit, create_source_image, create_source_search, delete_conversation, delete_conversations_batch, get_conversation, get_conversation_history, get_historical_context, get_latest_assistant_message, get_latest_assistant_message_id, get_latest_user_message_id, get_last_unit_for_message, get_message_id_by_index, get_source_images_by_conversation, get_source_images_by_message, get_source_searches_by_conversation, get_source_searches_by_message, persist_assistant_run_batch as persist_assistant_run_batch_db, rename_conversation, save_history_summary, update_conversation_agent_id, update_conversation_chat_mode, update_conversation_knowledge_scope, replace_conversation_workbench_config, replace_conversation_workbench_and_metadata, update_conversation_message_content, update_conversation_message_status, update_message_minio_files, update_message_opinion, update_message_unit_content, update_message_unit_status from management.db.model.repository import get_model_by_model_id from management.services.model.adapter import get_llm_adapter_from_config from management.services.configuration.tenant.settings import tenant_config_manager @@ -416,6 +416,43 @@ def create_new_conversation( raise Exception(str(e)) +def branch_conversation_service( + conversation_id: int, + turn_index: int, + user_id: str, +) -> Dict[str, Any]: + """ + Branch a conversation into a new one up to and including the given turn + + Args: + conversation_id: Source conversation ID + turn_index: 1-based Q&A turn (inclusive) marking the branch point + user_id: User ID that must own the source conversation + + Returns: + Dict[str, Any]: New conversation data + + Raises: + ConversationNotFoundError: When the source conversation does not exist + """ + if turn_index < 1: + raise ValueError("turn_index must be a 1-based turn number") + try: + conversation_data = branch_conversation( + conversation_id, turn_index, user_id=user_id + ) + if conversation_data is None: + raise ConversationNotFoundError( + "Conversation does not exist or has been deleted" + ) + return conversation_data + except (AppException, ConversationNotFoundError, ValueError): + raise + except Exception as e: + logging.error(f"Failed to branch conversation: {str(e)}") + raise Exception(str(e)) + + def update_conversation_workbench_config_service( conversation_id: int, config: Dict[str, Any], diff --git a/frontend/app/[locale]/newchat/assistant-ui/conversation-branch-context.ts b/frontend/app/[locale]/newchat/assistant-ui/conversation-branch-context.ts new file mode 100644 index 0000000000..b68e9b2ff4 --- /dev/null +++ b/frontend/app/[locale]/newchat/assistant-ui/conversation-branch-context.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { createContext } from "react"; + +export const ConversationBranchContext = createContext<{ + disabled: boolean; + branchFromMessage: (assistantMessageId: string) => void; +} | null>(null); diff --git a/frontend/app/[locale]/newchat/assistant-ui/conversation-branch-menu-item.tsx b/frontend/app/[locale]/newchat/assistant-ui/conversation-branch-menu-item.tsx new file mode 100644 index 0000000000..401ec5506b --- /dev/null +++ b/frontend/app/[locale]/newchat/assistant-ui/conversation-branch-menu-item.tsx @@ -0,0 +1,26 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useContext } from "react"; +import { ActionBarMorePrimitive, useAui } from "@assistant-ui/react"; +import { GitBranchIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { ConversationBranchContext } from "./conversation-branch-context"; + +export function ConversationBranchMenuItem() { + const branch = useContext(ConversationBranchContext); + const aui = useAui(); + const { t } = useTranslation(); + if (!branch) return null; + return ( + branch.branchFromMessage(aui.message.getState().id)} + className="flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm outline-none hover:bg-accent focus:bg-accent data-[disabled]:opacity-50" + > + + {t("chat.thread.branchToNewChat")} + + ); +} diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 2096b6f44a..e35718f777 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -199,6 +199,10 @@ import { useConversationShare } from "./use-conversation-share"; import { ConversationShareContext } from "./conversation-share-context"; import { ConversationShareFooter } from "./conversation-share-footer"; import { ConversationShareMenuItem } from "./conversation-share-menu-item"; +import { ConversationBranchContext } from "./conversation-branch-context"; +import { ConversationBranchMenuItem } from "./conversation-branch-menu-item"; +import { computeBranchTurnIndex } from "../utils/conversation-branch"; +import { conversationService } from "@/services/conversationService"; import type { ConversationKnowledgeScope, KnowledgeCapabilities, @@ -398,6 +402,48 @@ export const Thread: FC = ({ readOnly, }); + const aui = useAui(); + const [isBranching, setIsBranching] = useState(false); + const branchDisabled = + !conversationId || readOnly || isRunning || isLoading || isBranching; + + // Branch the conversation into a new chat that keeps every turn up to and + // including the one the selected assistant message answers, then switch to + // the branched thread. The original conversation stays untouched. + const branchFromMessage = useCallback( + (assistantMessageId: string) => { + if (!conversationId || branchDisabled) return; + const turnIndex = computeBranchTurnIndex(messages, assistantMessageId); + if (turnIndex === null) { + message.error(t("chat.thread.branchConversationFailed")); + return; + } + setIsBranching(true); + conversationService + .branch(conversationId, turnIndex) + .then(async (data) => { + const newConversationId = Number(data?.conversation_id); + if (!Number.isInteger(newConversationId) || newConversationId <= 0) { + throw new Error("Invalid branch response"); + } + await aui.threads.switchToThread(String(newConversationId)); + await aui.threads.reload(); + message.success(t("chat.thread.branchConversationSuccess")); + }) + .catch((error) => { + log.error("[Thread] Failed to branch conversation:", error); + message.error(t("chat.thread.branchConversationFailed")); + }) + .finally(() => setIsBranching(false)); + }, + [aui, branchDisabled, conversationId, messages, t] + ); + + const branchContextValue = useMemo( + () => ({ disabled: branchDisabled, branchFromMessage }), + [branchDisabled, branchFromMessage] + ); + // Sources panel state lives at the Thread level so the right-hand panel and // each `group-source` button share a single source of truth. The selection // carries the snapshot of sources/images for the group that opened it, @@ -436,139 +482,141 @@ export const Thread: FC = ({ - - void enterShareMode()} - onLeaveShareMode={leaveShareMode} - onToggleShareAll={toggleShareAll} - onToggleShareMessage={toggleShareMessage} - onCreateShare={createShare} - selection={selection} - onPanelClose={close} - conversationArtifacts={conversationArtifacts} - artifactsOpen={artifactsOpen} - onArtifactsOpenChange={setArtifactsOpen} - questionDirectory={questionDirectory} - /> - !open && setManualShareUrl(null)} - > - - - - {t("chatInterface.shareLinkReady", "分享链接已生成")} - - - {t( - "chatInterface.shareCreatedCopyFailed", - "分享链接已创建,但当前环境无法自动复制" - )} - - - event.currentTarget.select()} - className="w-full rounded-md border bg-muted/30 px-3 py-2 text-sm" - /> - - - - - - - + + + void enterShareMode()} + onLeaveShareMode={leaveShareMode} + onToggleShareAll={toggleShareAll} + onToggleShareMessage={toggleShareMessage} + onCreateShare={createShare} + selection={selection} + onPanelClose={close} + conversationArtifacts={conversationArtifacts} + artifactsOpen={artifactsOpen} + onArtifactsOpenChange={setArtifactsOpen} + questionDirectory={questionDirectory} + /> + !open && setManualShareUrl(null)} + > + + + + {t("chatInterface.shareLinkReady", "分享链接已生成")} + + + {t( + "chatInterface.shareCreatedCopyFailed", + "分享链接已创建,但当前环境无法自动复制" + )} + + + event.currentTarget.select()} + className="w-full rounded-md border bg-muted/30 px-3 py-2 text-sm" + /> + + + + + + + + ); }; @@ -1856,6 +1904,7 @@ const AssistantActionBar: FC = () => { className="aui-action-bar-more-content bg-popover/95 text-popover-foreground data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:animate-out data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-xl border p-1.5 shadow-lg backdrop-blur-sm" > + diff --git a/frontend/app/[locale]/newchat/utils/conversation-branch.ts b/frontend/app/[locale]/newchat/utils/conversation-branch.ts new file mode 100644 index 0000000000..1e0c65bff2 --- /dev/null +++ b/frontend/app/[locale]/newchat/utils/conversation-branch.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +export type BranchMessageLike = { + id: string; + role: string; +}; + +/** + * Derive the 1-based turn number that an assistant message answers. + * + * A turn is one user message plus its answer(s); the turn number therefore + * equals the count of user messages from the thread start up to and including + * the turn of the selected assistant message. The backend resolves the same + * turn over its active (deduplicated) user rows, so both sides agree even + * after refresh duplicates or regenerated answers. Returns null when the + * message is missing from the list or no user message precedes it. + */ +export function computeBranchTurnIndex( + messages: readonly BranchMessageLike[], + assistantMessageId: string +): number | null { + const position = messages.findIndex((m) => m.id === assistantMessageId); + if (position < 0) return null; + let turnIndex = 0; + for (let index = 0; index <= position; index++) { + if (messages[index].role === "user") turnIndex++; + } + return turnIndex >= 1 ? turnIndex : null; +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 16550de50a..680b452a36 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -398,6 +398,9 @@ "chat.thread.refresh": "Refresh", "chat.thread.more": "More", "chat.thread.exportMarkdown": "Export as Markdown", + "chat.thread.branchToNewChat": "Branch to new chat", + "chat.thread.branchConversationSuccess": "Branched to a new chat", + "chat.thread.branchConversationFailed": "Failed to branch conversation", "chat.thread.edit": "Edit", "chat.thread.newChat": "New Chat", "chat.threadList.loading": "Loading threads", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 2e783e6a61..34bcc805cb 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -396,6 +396,9 @@ "chat.thread.refresh": "刷新", "chat.thread.more": "更多", "chat.thread.exportMarkdown": "导出为 Markdown", + "chat.thread.branchToNewChat": "分支到新聊天", + "chat.thread.branchConversationSuccess": "已分支到新聊天", + "chat.thread.branchConversationFailed": "分支会话失败", "chat.thread.edit": "编辑", "chat.thread.newChat": "新对话", "chat.threadList.loading": "正在加载对话", diff --git a/frontend/services/api.ts b/frontend/services/api.ts index e1867ea674..aba882c416 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -66,6 +66,7 @@ export const API_ENDPOINTS = { create: `${API_BASE_URL}/conversation/create`, save: `${API_BASE_URL}/conversation/save`, rename: `${API_BASE_URL}/conversation/rename`, + branch: `${API_BASE_URL}/conversation/branch`, detail: (id: number) => `${API_BASE_URL}/conversation/${id}`, knowledgeScope: (id: number) => `${API_BASE_URL}/conversation/${id}/knowledge-scope`, diff --git a/frontend/services/conversationService.ts b/frontend/services/conversationService.ts index a6ff9b68fc..f2f51cda74 100644 --- a/frontend/services/conversationService.ts +++ b/frontend/services/conversationService.ts @@ -315,6 +315,27 @@ export const conversationService = { throw new ApiError(data.code, data.message); }, + // Branch a conversation into a new one up to and including the given turn + // (1-based). Returns the new conversation payload. + async branch(conversationId: number, turnIndex: number) { + const response = await fetch(API_ENDPOINTS.conversation.branch, { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ + conversation_id: conversationId, + turn_index: turnIndex, + }), + }); + + const data = await response.json(); + + if (data.code === 0) { + return data.data; + } + + throw new ApiError(data.code, data.message); + }, + // Get conversation details async getDetail( conversationId: number, diff --git a/frontend/tests/component/conversationBranch.test.tsx b/frontend/tests/component/conversationBranch.test.tsx new file mode 100644 index 0000000000..1488b444f0 --- /dev/null +++ b/frontend/tests/component/conversationBranch.test.tsx @@ -0,0 +1,137 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; + +import { + computeBranchTurnIndex, + type BranchMessageLike, +} from "@/app/newchat/utils/conversation-branch"; +import { ConversationBranchMenuItem } from "@/app/newchat/assistant-ui/conversation-branch-menu-item"; +import { ConversationBranchContext } from "@/app/newchat/assistant-ui/conversation-branch-context"; + +const auiMocks = vi.hoisted(() => ({ + getState: vi.fn(() => ({ id: "assistant-2" })), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@assistant-ui/react", () => ({ + useAui: () => ({ message: { getState: auiMocks.getState } }), + ActionBarMorePrimitive: { + Item: ({ + onSelect, + disabled, + children, + }: { + onSelect?: () => void; + disabled?: boolean; + children: React.ReactNode; + }) => ( + + ), + }, +})); + +const makeMessage = (id: string, role: string): BranchMessageLike => ({ + id, + role, +}); + +const renderMenuItem = ( + contextValue: { + disabled: boolean; + branchFromMessage: (assistantMessageId: string) => void; + } | null +) => + render( + contextValue ? ( + + + + ) : ( + + ) + ); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + document.body.innerHTML = ""; +}); + +test("computeBranchTurnIndex counts turns up to the selected answer", () => { + const messages = [ + makeMessage("u1", "user"), + makeMessage("a1", "assistant"), + makeMessage("u2", "user"), + makeMessage("a2", "assistant"), + makeMessage("u3", "user"), + makeMessage("a3", "assistant"), + ]; + + expect(computeBranchTurnIndex(messages, "a1")).toBe(1); + expect(computeBranchTurnIndex(messages, "a2")).toBe(2); + expect(computeBranchTurnIndex(messages, "a3")).toBe(3); +}); + +test("computeBranchTurnIndex ignores non-user rows while keeping turn order", () => { + const messages = [ + makeMessage("s1", "system"), + makeMessage("u1", "user"), + makeMessage("s2", "system"), + makeMessage("a1", "assistant"), + ]; + + expect(computeBranchTurnIndex(messages, "a1")).toBe(1); +}); + +test("computeBranchTurnIndex returns null for unknown ids or missing questions", () => { + const messages = [makeMessage("u1", "user"), makeMessage("a1", "assistant")]; + + expect(computeBranchTurnIndex(messages, "missing")).toBeNull(); + expect(computeBranchTurnIndex([], "a1")).toBeNull(); + expect( + computeBranchTurnIndex([makeMessage("a0", "assistant")], "a0") + ).toBeNull(); +}); + +test("menu item invokes the branch handler with the aui message id", () => { + const branchFromMessage = vi.fn(); + + renderMenuItem({ disabled: false, branchFromMessage }); + fireEvent.click(screen.getByTestId("branch-item")); + + expect(branchFromMessage).toHaveBeenCalledTimes(1); + expect(branchFromMessage).toHaveBeenCalledWith("assistant-2"); +}); + +test("menu item surfaces the localized branch label", () => { + renderMenuItem({ disabled: false, branchFromMessage: vi.fn() }); + + expect(screen.getByText("chat.thread.branchToNewChat")).toBeTruthy(); +}); + +test("menu item is disabled when branching is unavailable", () => { + renderMenuItem({ disabled: true, branchFromMessage: vi.fn() }); + + const item = screen.getByTestId("branch-item") as HTMLButtonElement; + expect(item.disabled).toBe(true); +}); + +test("menu item renders nothing without the branch context", () => { + renderMenuItem(null); + + expect(screen.queryByTestId("branch-item")).toBeNull(); +}); diff --git a/test-e2e/changes/requirements/BRANCH-CONVERSATION-TO-NEW-CHAT-20261102.yaml b/test-e2e/changes/requirements/BRANCH-CONVERSATION-TO-NEW-CHAT-20261102.yaml new file mode 100644 index 0000000000..0a813de0ac --- /dev/null +++ b/test-e2e/changes/requirements/BRANCH-CONVERSATION-TO-NEW-CHAT-20261102.yaml @@ -0,0 +1,73 @@ +schema_version: "1.0" +change: + change_id: BRANCH-CONVERSATION-TO-NEW-CHAT-20261102 + change_type: requirement + requirement_id: BRANCH-CONVERSATION-TO-NEW-CHAT-20261102 + title: 会话分支到新聊天 + description: + 'Add a "branch to new chat" action on the assistant message "..." menu. + Branching creates a new conversation that copies the conversation-level + configuration (agent, chat mode, knowledge scope, runtime metadata, + workbench config) plus every message, message unit, search source, and + image source of all turns up to and including the selected answer (the + question is included). The new conversation title reuses the original + title suffixed with "(分支)" (suffix never stacks, truncated to the + 100-char column). The original conversation and its MinIO file + references stay untouched (branch shares file references instead of + copying objects). After branching, the client switches to the new + conversation thread and refreshes the thread list. User feedback + (like/dislike) is not carried over. The action is unavailable while a + run streams, while loading, in read-only contexts, or before the + backend conversation id exists.' + affected_features: + existing: [] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + test_selectors: + - test/backend/database/test_conversation_branch_db.py + - test/backend/services/test_conversation_management_service.py + - test/backend/app/test_conversation_management_app.py + - frontend/tests/component/conversationBranch.test.tsx + behaviors: + - repository branch_conversation takes the user-quota advisory lock + before row locks, rejects non-positive or out-of-range turn indexes, + collapses same-index rows to the newest (refresh duplicates and + regenerated answers), copies the active rows through the boundary + turn including its first assistant answer, remaps message/unit/search + /image foreign keys to the new rows, keeps opinion feedback and later + turns out, and never stacks or over-lengthens the title suffix. + - branch_conversation_service returns the repository payload, maps a + missing conversation to ConversationNotFoundError, rejects non-positive + turn indexes with ValueError, and wraps unexpected repository failures. + - POST /conversation/branch maps invalid turns to 400, missing + conversations to 404, expired sessions to 401, unexpected errors to + 500, and returns the new conversation payload on success. + - computeBranchTurnIndex derives the 1-based turn number of the selected + answer (counting user messages up to its position) and returns null + for unknown ids or answers without a preceding question. + - ConversationBranchMenuItem renders the localized label, forwards the + aui message id to the branch handler, reflects the disabled state, + and renders nothing without the branch context. + reason: |- + The feature crosses app/service/database layers but each boundary is + already exercised through the existing conversation-management suites; + the targeted UT covers the new unit behavior (turn boundary selection, + dedup collapse, remapping, error mapping, menu wiring) at its owning + layer. No new external protocol or cross-component runtime contract is + introduced, so no formal Case is added. + notes: | + Core scope confirmed by the user (branch point includes the selected + Q&A pair, entry point is the assistant message "..." menu only, the + branched title keeps only a "(分支)" suffix, and the original + conversation is never modified). Turn counting aligns the frontend + (user messages up to the selected answer) with the backend (active + user rows in message_index order), so no message-id mapping request is + needed. diff --git a/test/backend/app/test_conversation_management_app.py b/test/backend/app/test_conversation_management_app.py index 36d91b4be5..a8ed1f2639 100644 --- a/test/backend/app/test_conversation_management_app.py +++ b/test/backend/app/test_conversation_management_app.py @@ -57,6 +57,7 @@ list_agent_groups_endpoint, list_agent_group_conversations_endpoint, list_agent_group_conversation_ids_endpoint, + branch_conversation_endpoint, router, ) @@ -72,6 +73,7 @@ def conversation_mocks(): patch('management.api.conversation.conversation_management.create_new_conversation') as mock_create_new_conv, \ patch('management.api.conversation.conversation_management.get_conversation_list_page') as mock_get_conv_list, \ patch('management.api.conversation.conversation_management.rename_conversation_service') as mock_rename_conv, \ + patch('management.api.conversation.conversation_management.branch_conversation_service') as mock_branch_conv, \ patch('management.api.conversation.conversation_management.logging') as mock_logging, \ patch('management.api.conversation.conversation_management.delete_conversation_service') as mock_delete_conv, \ patch('management.api.conversation.conversation_management.delete_conversations_batch_service') as mock_delete_batch, \ @@ -90,6 +92,7 @@ def conversation_mocks(): 'create_new_convo': mock_create_new_conv, 'get_conversation_list': mock_get_conv_list, 'rename_conversation': mock_rename_conv, + 'branch_conversation': mock_branch_conv, 'logging': mock_logging, 'delete_conversation': mock_delete_conv, 'delete_conversations_batch': mock_delete_batch, @@ -874,3 +877,104 @@ def test_agent_history_routes_reject_invalid_scope_and_expired_identity(conversa ) expired = client.get("/api/conversation/agent_groups") assert expired.status_code == 401 + + +@pytest.mark.asyncio +async def test_branch_conversation_success(conversation_mocks): + """Verify successful conversation branching""" + mock_auth_header = "Bearer test-token" + dummy_response = { + "conversation_id": 42, + "conversation_title": "New Conversation����֧��", + "branched_message_count": 4, + } + + conversation_mocks["get_current_user_id"].return_value = ( + "user_id", "tenant_id") + conversation_mocks["branch_conversation"].return_value = dummy_response + + request_obj = MagicMock() + request_obj.conversation_id = 7 + request_obj.turn_index = 2 + + result = await branch_conversation_endpoint(request_obj, authorization=mock_auth_header) + + assert result.code == 0 + assert result.data == dummy_response + conversation_mocks["branch_conversation"].assert_called_once_with( + 7, 2, "user_id") + + +@pytest.mark.asyncio +async def test_branch_conversation_invalid_turn(conversation_mocks): + """Invalid turn indexes are rejected with a client error""" + conversation_mocks["get_current_user_id"].return_value = ( + "user_id", "tenant_id") + conversation_mocks["branch_conversation"].side_effect = ValueError( + "turn_index must be a 1-based turn number") + + request_obj = MagicMock() + request_obj.conversation_id = 7 + request_obj.turn_index = 0 + + with pytest.raises(HTTPException) as exc_info: + await branch_conversation_endpoint(request_obj, authorization="Bearer test-token") + + assert exc_info.value.status_code == 400 + conversation_mocks["branch_conversation"].assert_called_once() + + +@pytest.mark.asyncio +async def test_branch_conversation_not_found(conversation_mocks): + """Domain not-found errors from the service map to 404""" + from contracts.errors.exceptions import ConversationNotFoundError + + conversation_mocks["get_current_user_id"].return_value = ( + "user_id", "tenant_id") + conversation_mocks["branch_conversation"].side_effect = ConversationNotFoundError( + "Conversation does not exist or has been deleted") + + request_obj = MagicMock() + request_obj.conversation_id = 7 + request_obj.turn_index = 2 + + with pytest.raises(HTTPException) as exc_info: + await branch_conversation_endpoint(request_obj, authorization="Bearer test-token") + + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_branch_conversation_expired_token(conversation_mocks): + """Expired sessions are rejected with 401""" + from contracts.errors.exceptions import TokenExpiredError + + conversation_mocks["get_current_user_id"].side_effect = TokenExpiredError( + "expired") + + request_obj = MagicMock() + request_obj.conversation_id = 7 + request_obj.turn_index = 2 + + with pytest.raises(HTTPException) as exc_info: + await branch_conversation_endpoint(request_obj, authorization="Bearer test-token") + + assert exc_info.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_branch_conversation_failure(conversation_mocks): + """Unexpected service errors are mapped to 500""" + conversation_mocks["get_current_user_id"].return_value = ( + "user_id", "tenant_id") + conversation_mocks["branch_conversation"].side_effect = Exception( + "branch error") + + request_obj = MagicMock() + request_obj.conversation_id = 7 + request_obj.turn_index = 2 + + with pytest.raises(HTTPException) as exc_info: + await branch_conversation_endpoint(request_obj, authorization="Bearer test-token") + + assert exc_info.value.status_code == 500 diff --git a/test/backend/database/test_conversation_branch_db.py b/test/backend/database/test_conversation_branch_db.py new file mode 100644 index 0000000000..8daf370e30 --- /dev/null +++ b/test/backend/database/test_conversation_branch_db.py @@ -0,0 +1,392 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Conversation branching copies configuration and the selected turn range.""" + +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from sqlalchemy.dialects import postgresql + +from management.db.conversation import repository as db + + +NEW_CONVERSATION_ROW = SimpleNamespace( + conversation_id=42, + conversation_title="Trip planning(分支)", + agent_id=5, + chat_mode="execution", + knowledge_scope={"kb": True}, + runtime_metadata={}, + runtime_metadata_version=1, + workbench_config=None, + workbench_config_version=0, + create_time=1700000000000, + update_time=1700000000000, +) + + +def _conversation_row(**overrides): + row = SimpleNamespace( + conversation_id=7, + conversation_title="Trip planning", + title_version=3, + last_activity_time=None, + agent_id=5, + chat_mode="execution", + knowledge_scope={"kb": True}, + runtime_metadata={"model": "gpt"}, + runtime_metadata_version=4, + workbench_config=None, + workbench_pinned_at=None, + workbench_config_version=0, + create_time=None, + update_time=None, + created_by="owner", + updated_by="owner", + delete_flag="N", + ) + for key, value in overrides.items(): + setattr(row, key, value) + return row + + +def _message(message_id, message_index, role, content, **overrides): + row = SimpleNamespace( + message_id=message_id, + conversation_id=7, + message_index=message_index, + message_role=role, + message_source="CHAT", + message_content=content, + minio_files=None, + opinion_flag=None, + status="completed", + ) + for key, value in overrides.items(): + setattr(row, key, value) + return row + + +def _unit(unit_id, message_id, **overrides): + row = SimpleNamespace( + unit_id=unit_id, + message_id=message_id, + conversation_id=7, + unit_index=0, + unit_type="answer", + unit_content="unit body", + unit_status="completed", + tool_call_id=None, + invocation_id=None, + ) + for key, value in overrides.items(): + setattr(row, key, value) + return row + + +def _search(search_id, message_id, unit_id=None, **overrides): + row = SimpleNamespace( + search_id=search_id, + unit_id=unit_id, + message_id=message_id, + conversation_id=7, + source_type="url", + source_title="doc", + source_location="https://example.com", + source_content="text", + score_overall=None, + score_accuracy=None, + score_semantic=None, + retrieval_highlight_terms=None, + aidp_metadata=None, + aidp_source_reference=None, + published_date=None, + cite_index=None, + search_type="web_search", + tool_sign=None, + ) + for key, value in overrides.items(): + setattr(row, key, value) + return row + + +def _image(image_id, message_id, **overrides): + row = SimpleNamespace( + image_id=image_id, + conversation_id=7, + message_id=message_id, + unit_id=None, + image_url="https://example.com/a.png", + cite_index=None, + search_type="web_search", + ) + for key, value in overrides.items(): + setattr(row, key, value) + return row + + +def _scalars_result(first=None, all_rows=None): + result = MagicMock() + result.first.return_value = first + result.all.return_value = all_rows or [] + return result + + +def _three_turn_rows(): + """Two settled turns, a regenerated answer on turn two, and a third turn.""" + return [ + _message(11, 0, "user", "Q1"), + _message(12, 1, "assistant", "A1"), + _message(13, 2, "user", "Q2"), + _message(14, 3, "assistant", "A2-old"), + _message(15, 3, "assistant", "A2-new", opinion_flag="Y"), + _message(16, 4, "user", "Q3"), + _message(17, 5, "assistant", "A3"), + ] + + +def sql(statement): + return str( + statement.compile( + dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True} + ) + ) + + +def params(statement): + return statement.compile().params + + +class _ExecuteRecorder: + """Route executed statements by table and return per-table results.""" + + def __init__(self, new_conversation_row=NEW_CONVERSATION_ROW): + self.statements = [] + self.new_conversation_row = new_conversation_row + self.next_ids = {"message": 1000, "unit": 2000} + self.results = { + "conversation_record_t": self._conversation_result, + "conversation_message_t": self._message_result, + "conversation_message_unit_t": self._unit_result, + } + + def _conversation_result(self, _statement): + result = MagicMock() + result.fetchone.return_value = self.new_conversation_row + return result + + def _message_result(self, _statement): + self.next_ids["message"] += 1 + result = MagicMock() + result.scalar_one.return_value = self.next_ids["message"] + return result + + def _unit_result(self, _statement): + self.next_ids["unit"] += 1 + result = MagicMock() + result.scalar_one.return_value = self.next_ids["unit"] + return result + + def __call__(self, statement, *_args, **_kwargs): + self.statements.append(statement) + text = str(statement.compile(dialect=postgresql.dialect())) + for table, factory in self.results.items(): + if text.startswith(f"INSERT INTO nexent.{table}"): + return factory(statement) + return MagicMock() + + +@pytest.fixture +def session(monkeypatch): + value = MagicMock() + value.scalar.return_value = 1 + + @contextmanager + def sessions(): + yield value + + monkeypatch.setattr(db, "get_db_session", sessions) + return value + + +_UNSET = object() + + +def _wire(session, rows, *, conversation=_UNSET, units=None, searches=None, images=None): + """Queue the scalars results in the order branch_conversation reads them.""" + if conversation is _UNSET: + conversation = _conversation_row() + session.scalars.side_effect = [ + _scalars_result(first=conversation), + _scalars_result(all_rows=rows), + _scalars_result(all_rows=units or []), + _scalars_result(all_rows=searches or []), + _scalars_result(all_rows=images or []), + ] + + +def test_branch_copies_turn_range_and_remaps_children(session): + rows = _three_turn_rows() + units = [ + _unit(21, 15, unit_index=1, tool_call_id="t-1"), + ] + searches = [ + _search(31, 12), + _search(33, 15, unit_id=21), + ] + images = [ + _image(41, 15), + ] + _wire(session, rows, units=units, searches=searches, images=images) + recorder = _ExecuteRecorder() + session.execute.side_effect = recorder + + result = db.branch_conversation(7, 2, user_id="owner") + + assert result["conversation_id"] == 42 + assert result["conversation_title"] == "Trip planning(分支)" + assert result["branched_message_count"] == 4 + + # Child queries only fetch rows of the copied messages. + units_filter = sql(session.scalars.call_args_list[2].args[0]) + assert "message_id IN (11, 12, 13, 15)" in units_filter + searches_filter = sql(session.scalars.call_args_list[3].args[0]) + assert "message_id IN (11, 12, 13, 15)" in searches_filter + images_filter = sql(session.scalars.call_args_list[4].args[0]) + assert "message_id IN (11, 12, 13, 15)" in images_filter + + inserted = {} + for statement in recorder.statements: + text = str(statement.compile(dialect=postgresql.dialect())) + if text.startswith("INSERT INTO nexent."): + table = text.split("nexent.")[1].split(" ")[0] + inserted.setdefault(table, []).append(params(statement)) + + # Only the four active rows of turns one and two are copied. + assert [row["message_content"] for row in inserted["conversation_message_t"]] == [ + "Q1", + "A1", + "Q2", + "A2-new", + ] + # User feedback is intentionally not carried over. + assert all("opinion_flag" not in row for row in inserted["conversation_message_t"]) + assert all(row["delete_flag"] == "N" for row in inserted["conversation_message_t"]) + assert all(row["created_by"] == "owner" for row in inserted["conversation_message_t"]) + + # Conversation configuration is cloned with fresh versions. + conversation_row = inserted["conversation_record_t"][0] + assert conversation_row["conversation_title"] == "Trip planning(分支)" + assert conversation_row["agent_id"] == 5 + assert conversation_row["chat_mode"] == "execution" + assert conversation_row["knowledge_scope"] == {"kb": True} + assert conversation_row["runtime_metadata"] == {"model": "gpt"} + assert conversation_row["runtime_metadata_version"] == 1 + + # Units, searches and images follow only the copied messages, with + # unit_id references remapped to the inserted unit rows. + assert [row["unit_content"] for row in inserted["conversation_message_unit_t"]] == ["unit body"] + assert len(inserted["conversation_source_search_t"]) == 2 + remapped_unit = inserted["conversation_source_search_t"][1] + assert remapped_unit["source_title"] == "doc" + assert remapped_unit["unit_id"] == 2001 + assert len(inserted["conversation_source_image_t"]) == 1 + assert inserted["conversation_source_image_t"][0]["message_id"] == 1004 + + +def test_branch_last_turn_copies_everything(session): + _wire(session, _three_turn_rows()) + recorder = _ExecuteRecorder() + session.execute.side_effect = recorder + + result = db.branch_conversation(7, 3, user_id="owner") + + assert result["branched_message_count"] == 6 + inserted_rows = [ + params(statement) + for statement in recorder.statements + if str(statement.compile(dialect=postgresql.dialect())).startswith( + "INSERT INTO nexent.conversation_message_t" + ) + ] + assert [row["message_content"] for row in inserted_rows] == [ + "Q1", + "A1", + "Q2", + "A2-new", + "Q3", + "A3", + ] + + +def test_branch_takes_user_quota_lock_before_row_locks(session): + _wire(session, _three_turn_rows()) + session.execute.side_effect = _ExecuteRecorder() + + db.branch_conversation(7, 1, user_id="owner") + + quota_lock = sql(session.execute.call_args_list[0].args[0]) + assert "pg_advisory_xact_lock" in quota_lock + conversation_lock = sql(session.scalars.call_args_list[0].args[0]) + assert "conversation_record_t" in conversation_lock and "FOR UPDATE" in conversation_lock + assert "conversation_id = 7" in conversation_lock + assert "created_by = 'owner'" in conversation_lock and "delete_flag = 'N'" in conversation_lock + + +def test_branch_returns_none_without_inserts_for_missing_conversation(session): + _wire(session, [], conversation=None) + recorder = _ExecuteRecorder() + session.execute.side_effect = recorder + + assert db.branch_conversation(7, 1, user_id="owner") is None + assert all( + not str(statement.compile(dialect=postgresql.dialect())).startswith("INSERT INTO nexent.") + for statement in recorder.statements + ) + + +@pytest.mark.parametrize("turn_index", [0, -1]) +def test_branch_rejects_non_positive_turn_index(session, turn_index): + with pytest.raises(ValueError, match="1-based turn number"): + db.branch_conversation(7, turn_index, user_id="owner") + + +def test_branch_rejects_turn_index_beyond_available_turns(session): + _wire(session, _three_turn_rows()) + + with pytest.raises(ValueError, match="exceeds the 3 turns"): + db.branch_conversation(7, 4, user_id="owner") + + +def test_branch_title_never_stacks_suffixes_and_respects_length(session): + # Branching a branch keeps a single suffix. + assert db._build_branch_title("Trip planning(分支)") == "Trip planning(分支)" + # Over-long titles are truncated so the result fits the 100-char column. + long_title = "x" * 120 + assert len(db._build_branch_title(long_title)) == 100 + assert db._build_branch_title(long_title).endswith("(分支)") + assert db._build_branch_title(None) == "(分支)" + + +def test_branch_without_answer_stops_at_user_row(session): + rows = [ + _message(11, 0, "user", "Q1"), + _message(12, 1, "assistant", "A1"), + _message(13, 2, "user", "Q2"), + ] + _wire(session, rows) + recorder = _ExecuteRecorder() + session.execute.side_effect = recorder + + result = db.branch_conversation(7, 2, user_id="owner") + + assert result["branched_message_count"] == 3 + inserted_rows = [ + params(statement) + for statement in recorder.statements + if str(statement.compile(dialect=postgresql.dialect())).startswith( + "INSERT INTO nexent.conversation_message_t" + ) + ] + assert [row["message_content"] for row in inserted_rows] == ["Q1", "A1", "Q2"] diff --git a/test/backend/services/test_conversation_management_service.py b/test/backend/services/test_conversation_management_service.py index 2f4bba899c..55591c7dc4 100644 --- a/test/backend/services/test_conversation_management_service.py +++ b/test/backend/services/test_conversation_management_service.py @@ -218,6 +218,7 @@ def validate(self): pass call_llm_for_title, update_conversation_title, create_new_conversation, + branch_conversation_service, get_conversation_service, rename_conversation_service, delete_conversation_service, @@ -2061,5 +2062,50 @@ def test_message_database_failure_preserves_unexpected_error(): assert error.value is failure +def test_branch_conversation_service_returns_repository_data(): + import pytest + + repository_result = { + "conversation_id": 42, + "conversation_title": "Original(分支)", + "branched_message_count": 4, + } + with patch( + 'management.services.conversation.service.branch_conversation', + return_value=repository_result, + ) as mock_branch: + result = branch_conversation_service(7, 2, "user-1") + + assert result == repository_result + mock_branch.assert_called_once_with(7, 2, user_id="user-1") + + +def test_branch_conversation_service_rejects_non_positive_turn_index(): + import pytest + + with pytest.raises(ValueError, match="1-based turn number"): + branch_conversation_service(7, 0, "user-1") + + +def test_branch_conversation_service_raises_not_found_for_missing_conversation(): + import pytest + from contracts.errors.exceptions import ConversationNotFoundError + + with patch('management.services.conversation.service.branch_conversation', return_value=None): + with pytest.raises(ConversationNotFoundError): + branch_conversation_service(7, 2, "user-1") + + +def test_branch_conversation_service_wraps_repository_failure(): + import pytest + + with patch( + 'management.services.conversation.service.branch_conversation', + side_effect=RuntimeError("database unavailable"), + ): + with pytest.raises(Exception, match="database unavailable"): + branch_conversation_service(7, 2, "user-1") + + if __name__ == '__main__': unittest.main() From e2383762349c494165c08ff664c88a8c0320b147 Mon Sep 17 00:00:00 2001 From: ljy Date: Mon, 2 Nov 2026 18:12:38 +0800 Subject: [PATCH 06/26] feat(chat): quote selected reply text into the next message Selecting text inside one assistant reply shows a floating "Add to chat" bubble above the selection. Confirming it (optionally after typing a comment) stores a pending annotation and renders a count chip above the composer input; hovering the chip opens the annotation list where each entry can be edited or deleted, and the trailing button clears all. Sending folds every pending annotation into the outgoing message text as a numbered quote block ("> "-prefixed quoted reply lines, an optional comment line, and the first-40-char excerpt of the source reply), followed by a "---" separator and the user-typed text, so the model resolves the reference from the full conversation history without any backend or protocol change. Annotation-only sends (empty composer text) are allowed. The fold is idempotent for the send-button/form-submit double invocation; a rejected run keeps the annotations and restores the original draft, and the annotations are consumed only once the run accepts. Switching threads resets them. Quotable selections are restricted to the content area of a single assistant message: reasoning, tool, execution-code, and subagent containers are excluded, and the bubble is disabled in read-only threads, share mode, skill-file chats, and the preview layouts. --- .../newchat/assistant-ui/annotation-chip.tsx | 292 +++++++++++ .../newchat/assistant-ui/composer.tsx | 60 ++- .../message-annotation-bubble.tsx | 235 +++++++++ .../[locale]/newchat/assistant-ui/thread.tsx | 99 ++++ .../newchat/utils/message-annotations.ts | 270 ++++++++++ frontend/public/locales/en/common.json | 18 + frontend/public/locales/zh/common.json | 17 + .../component/messageAnnotation.test.tsx | 472 ++++++++++++++++++ ...-TO-CHAT-MESSAGE-ANNOTATIONS-20261102.yaml | 76 +++ 9 files changed, 1537 insertions(+), 2 deletions(-) create mode 100644 frontend/app/[locale]/newchat/assistant-ui/annotation-chip.tsx create mode 100644 frontend/app/[locale]/newchat/assistant-ui/message-annotation-bubble.tsx create mode 100644 frontend/app/[locale]/newchat/utils/message-annotations.ts create mode 100644 frontend/tests/component/messageAnnotation.test.tsx create mode 100644 test-e2e/changes/requirements/ADD-TO-CHAT-MESSAGE-ANNOTATIONS-20261102.yaml diff --git a/frontend/app/[locale]/newchat/assistant-ui/annotation-chip.tsx b/frontend/app/[locale]/newchat/assistant-ui/annotation-chip.tsx new file mode 100644 index 0000000000..560e2b6daa --- /dev/null +++ b/frontend/app/[locale]/newchat/assistant-ui/annotation-chip.tsx @@ -0,0 +1,292 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useCallback, useEffect, useRef, useState, type FC } from "react"; +import { useTranslation } from "react-i18next"; +import { + CheckIcon, + MessageSquareQuote, + PencilIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { + useAnnotationQuotePreview, + type MessageAnnotation, +} from "../utils/message-annotations"; + +/** Hover grace period before the popover closes. */ +const POPOVER_CLOSE_DELAY_MS = 150; + +/** + * Composer chip summarizing the pending message annotations. + * + * Renders nothing without annotations. Hovering (or focusing) the chip opens + * the annotation list where each entry can be edited or deleted; the trailing + * button clears every annotation at once. + */ +export const ComposerAnnotationChip: FC<{ + /** Pending annotations in creation order. */ + annotations: readonly MessageAnnotation[]; + /** Disables edit/delete/clear actions while a run is active. */ + disabled?: boolean; + /** Saves an edited comment. */ + onEditComment: (id: string, comment: string) => void; + /** Deletes a single annotation. */ + onRemove: (id: string) => void; + /** Deletes every pending annotation. */ + onClear: () => void; +}> = ({ annotations, disabled, onEditComment, onRemove, onClear }) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editValue, setEditValue] = useState(""); + const closeTimerRef = useRef(undefined); + + useEffect(() => () => window.clearTimeout(closeTimerRef.current), []); + + const openNow = useCallback(() => { + window.clearTimeout(closeTimerRef.current); + setOpen(true); + }, []); + + const scheduleClose = useCallback(() => { + // Keep the popover open while a comment is being edited so moving the + // pointer towards the input cannot discard the draft. + if (editingId) return; + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = window.setTimeout( + () => setOpen(false), + POPOVER_CLOSE_DELAY_MS + ); + }, [editingId]); + + const startEdit = useCallback((annotation: MessageAnnotation) => { + setEditingId(annotation.id); + setEditValue(annotation.comment ?? ""); + }, []); + + const commitEdit = useCallback(() => { + if (editingId) onEditComment(editingId, editValue.trim()); + setEditingId(null); + }, [editValue, editingId, onEditComment]); + + const cancelEdit = useCallback(() => setEditingId(null), []); + + if (annotations.length === 0) return null; + + return ( + { + setOpen(next); + if (!next) setEditingId(null); + }} + > + + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openNow(); + } + }} + className="inline-flex h-7 max-w-72 cursor-pointer items-center gap-1.5 rounded-md border border-[#e5e5e5] bg-[#f7f7f7] px-2 text-xs text-[#191919] dark:border-[#404040] dark:bg-[#2a2a2a] dark:text-[#e5e5e5]" + > + + + {t("chat.annotation.chipLabel", { count: annotations.length })} + + + + + +

    + {t("chat.annotation.listTitle")} +

    +
      + {annotations.map((annotation, index) => ( + + ))} +
    +
    +
    + ); +}; + +const AnnotationListItem: FC<{ + index: number; + annotation: MessageAnnotation; + editing: boolean; + editValue: string; + disabled?: boolean; + onEditValueChange: (value: string) => void; + onEditStart: (annotation: MessageAnnotation) => void; + onEditCommit: () => void; + onEditCancel: () => void; + onRemove: (id: string) => void; +}> = ({ + index, + annotation, + editing, + editValue, + disabled, + onEditValueChange, + onEditStart, + onEditCommit, + onEditCancel, + onRemove, +}) => { + const { t } = useTranslation(); + const quotePreview = useAnnotationQuotePreview(annotation); + const editInputRef = useRef(null); + + useEffect(() => { + if (editing) editInputRef.current?.focus(); + }, [editing]); + + if (editing) { + return ( +
  • + + {index}. + + onEditValueChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + onEditCommit(); + } else if (event.key === "Escape") { + event.preventDefault(); + onEditCancel(); + } + }} + className="h-7 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground" + /> + + +
  • + ); + } + + return ( +
  • + + {index}. + +
    +

    + + {t("chat.annotation.selectedTextLabel")} + {t("chat.annotation.colon")} + + {quotePreview} +

    + {annotation.comment ? ( +

    + {t("chat.annotation.commentLabel")} + {t("chat.annotation.colon")} + {annotation.comment} +

    + ) : null} +
    + + + + +
  • + ); +}; diff --git a/frontend/app/[locale]/newchat/assistant-ui/composer.tsx b/frontend/app/[locale]/newchat/assistant-ui/composer.tsx index e9d8e4ddce..c466ad8dd1 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/composer.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/composer.tsx @@ -7,6 +7,7 @@ import { isReasoningEffort } from "@/lib/modelReasoning"; import { useMemo, + useRef, useState, useSyncExternalStore, type FC, @@ -82,6 +83,11 @@ import { skillDirectiveIconMap, } from "../ui/skill-directives"; import { ordinarySendError, protectOrdinarySend } from "../utils/ordinary-send"; +import { ComposerAnnotationChip } from "./annotation-chip"; +import { + formatAnnotationsIntoMessage, + type MessageAnnotationsControl, +} from "../utils/message-annotations"; import { RuntimeMetadataEditor } from "@/components/chat/RuntimeMetadataEditor"; import { getConversationResourceLimitMessage } from "@/const/errorMessageI18n"; @@ -170,6 +176,8 @@ export interface ComposerProps { workbenchResources?: import("@/features/workbench/types").WorkbenchResourceControls; onRemoveWorkbenchSkill?: (skillId: number) => void; onOpenWorkbenchSkillPicker?: () => void; + /** Pending reply annotations attached to the next outgoing message. */ + annotations?: MessageAnnotationsControl; } // Simple tooltip wrapper @@ -320,6 +328,7 @@ export const Composer: FC = ({ workbenchResources, onRemoveWorkbenchSkill, onOpenWorkbenchSkillPicker, + annotations, }) => { const conversationResources = !workbenchResources && @@ -329,14 +338,28 @@ export const Composer: FC = ({ const aui = useAui(); const runtime = aui.threads().__internal_getAssistantRuntime?.(); const [sendError, setSendError] = useState(""); + // Remembers the text produced by folding the pending annotations into the + // draft. The send button click and the form submit can both invoke + // prepareSend for one send; the guard keeps the fold idempotent. + const lastComposedAnnotationTextRef = useRef(null); const prepareSend = () => { if (!runtime || runtime.thread.getState().isRunning) return; const threadId = runtime.threads.getState().mainThreadId; const thread = runtime.threads.getById(threadId); const config = thread.composer.getState().runConfig; setSendError(""); + const pendingAnnotations = annotations?.annotations ?? []; const feedback = protectOrdinarySend(thread, { restoreDraft: true, + // Annotations attach to exactly one outgoing message: consume them + // once the run accepts them. A rejected send keeps them (together + // with the restored draft) so the user can retry. + onStarted: pendingAnnotations.length + ? () => { + lastComposedAnnotationTextRef.current = null; + annotations?.consumeAnnotations(); + } + : undefined, onRejected: (error) => { if (runtime.threads.getState().mainThreadId === threadId) { setSendError( @@ -347,6 +370,28 @@ export const Composer: FC = ({ } }, }); + // Fold the pending annotations into the outgoing text. `protectOrdinarySend` + // snapshots the draft before the fold, so a rejected run still restores the + // user's original text. + if (pendingAnnotations.length) { + const currentText = thread.composer.getState().text; + if (currentText !== lastComposedAnnotationTextRef.current) { + const composed = formatAnnotationsIntoMessage( + currentText, + pendingAnnotations, + { + quoteLabel: t("chat.annotation.quoteLabel"), + sourceLabel: t("chat.annotation.sourceLabel"), + commentLabel: t("chat.annotation.commentLabel"), + colon: t("chat.annotation.colon"), + } + ); + lastComposedAnnotationTextRef.current = composed; + thread.composer.setText(composed); + } + } else { + lastComposedAnnotationTextRef.current = null; + } thread.composer.setRunConfig({ ...config, custom: { ...config.custom, ...feedback }, @@ -638,6 +683,15 @@ export const Composer: FC = ({ }} > {hasAttachments && } + + annotations?.updateAnnotationComment(id, comment) + } + onRemove={(id) => annotations?.removeAnnotation(id)} + onClear={() => annotations?.clearAnnotations()} + /> {conversationResources && (selectedSkillNames.length > 0 || (canSelectKnowledge && @@ -845,6 +899,7 @@ export const Composer: FC = ({ 0} /> @@ -962,7 +1017,8 @@ const WorkbenchPlanningMenu: FC<{ const ComposerSendOrCancel: FC<{ onSend: () => void; disabled?: boolean; -}> = ({ onSend, disabled }) => { + hasAnnotations?: boolean; +}> = ({ onSend, disabled, hasAnnotations = false }) => { const { t } = useTranslation(); const hasText = useAuiState((state) => state.composer.text.trim().length > 0); @@ -999,7 +1055,7 @@ const ComposerSendOrCancel: FC<{ + ) : ( + <> + + setPhase((current) => + current.kind === "composing" + ? { ...current, comment: event.target.value } + : current + ) + } + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + commitComposing(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelComposing(); + } + }} + aria-label={t("chat.annotation.commentPlaceholder")} + placeholder={t("chat.annotation.commentPlaceholder")} + className="h-8 min-w-0 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground" + /> + + + + )} + , + document.body + ); +}; diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index e35718f777..3a82d65907 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -75,6 +75,13 @@ import { type ArtifactAttachmentLike, type ConversationArtifact, } from "../utils/conversation-artifacts"; +import { MessageAnnotationBubble } from "./message-annotation-bubble"; +import { + createMessageAnnotation, + type MessageAnnotation, + type MessageAnnotationDraft, + type MessageAnnotationsControl, +} from "../utils/message-annotations"; import { scrollThreadToMessage, useQuestionDirectory, @@ -444,6 +451,73 @@ export const Thread: FC = ({ [branchDisabled, branchFromMessage] ); + // Reply annotations ("add to chat") attach to the next outgoing message. + // State lives at the Thread level so the selection bubble, the composer + // chip and the send flow share one source of truth. The source excerpt is + // captured when the annotation is created so it keeps referencing the reply + // content the user saw when selecting. + const [messageAnnotations, setMessageAnnotations] = useState< + MessageAnnotation[] + >([]); + // Pending annotations belong to the active thread: switching threads resets + // them during render (the same keyed-state pattern the composer uses for + // the knowledge modal) so they never leak across conversations. + const [annotationThreadId, setAnnotationThreadId] = useState(threadId); + if (annotationThreadId !== threadId) { + setAnnotationThreadId(threadId); + setMessageAnnotations([]); + } + + const addMessageAnnotation = useCallback( + (draft: MessageAnnotationDraft) => { + setMessageAnnotations((current) => [ + ...current, + createMessageAnnotation(draft, messages, crypto.randomUUID()), + ]); + }, + [messages] + ); + const updateMessageAnnotationComment = useCallback( + (id: string, comment: string) => { + setMessageAnnotations((current) => + current.map((annotation) => + annotation.id === id + ? { ...annotation, comment: comment || undefined } + : annotation + ) + ); + }, + [] + ); + const removeMessageAnnotation = useCallback((id: string) => { + setMessageAnnotations((current) => + current.filter((annotation) => annotation.id !== id) + ); + }, []); + const clearMessageAnnotations = useCallback(() => { + setMessageAnnotations([]); + }, []); + + const messageAnnotationsControl = useMemo( + () => ({ + annotations: messageAnnotations, + addAnnotation: addMessageAnnotation, + updateAnnotationComment: updateMessageAnnotationComment, + removeAnnotation: removeMessageAnnotation, + clearAnnotations: clearMessageAnnotations, + // Consumed annotations attach to exactly one outgoing message; the + // composer invokes this once the run has accepted them. + consumeAnnotations: clearMessageAnnotations, + }), + [ + messageAnnotations, + addMessageAnnotation, + updateMessageAnnotationComment, + removeMessageAnnotation, + clearMessageAnnotations, + ] + ); + // Sources panel state lives at the Thread level so the right-hand panel and // each `group-source` button share a single source of truth. The selection // carries the snapshot of sources/images for the group that opened it, @@ -559,6 +633,7 @@ export const Thread: FC = ({ artifactsOpen={artifactsOpen} onArtifactsOpenChange={setArtifactsOpen} questionDirectory={questionDirectory} + messageAnnotations={messageAnnotationsControl} /> void; questionDirectory: QuestionDirectoryEntry[]; + messageAnnotations: MessageAnnotationsControl; variant: "default" | "embedded"; skillFiles?: readonly SkillFileContent[]; onSkillFileSelect?: (path: string) => void; @@ -736,6 +812,7 @@ const ThreadView: FC = ({ artifactsOpen, onArtifactsOpenChange, questionDirectory, + messageAnnotations, variant, skillFiles, onSkillFileSelect, @@ -759,6 +836,17 @@ const ThreadView: FC = ({ const workbenchLanding = Boolean( workbenchPresentation && !hasMessages && !isShareMode ); + // Reply annotations only make sense where the composer is live. They stay + // disabled for read-only threads, share mode, skill-file chats and the + // preview layouts, which either block sending or render no real replies. + const annotationsEnabled = Boolean( + showComposer && + !isShareMode && + !readOnly && + !skillFiles && + !configurationLayout && + !debugLayout + ); const aui = useAui(); const suggestions = deriveWelcomeSuggestions( welcomeSuggestions, @@ -1056,6 +1144,7 @@ const ThreadView: FC = ({ disabled={readOnly} modelSelectionDisabled={modelSelectionDisabled} disabledReason={readOnlyReason} + annotations={annotationsEnabled ? messageAnnotations : undefined} workbenchPresentation={workbenchPresentation} workbenchResources={workbenchResources} onRemoveWorkbenchSkill={onRemoveWorkbenchSkill} @@ -1089,6 +1178,16 @@ const ThreadView: FC = ({ : undefined } /> + + {/* Mounted only while quotable so the bubble state resets whenever the + thread turns read-only, enters share mode, or switches to a preview + layout. */} + {annotationsEnabled ? ( + + ) : null} ); }; diff --git a/frontend/app/[locale]/newchat/utils/message-annotations.ts b/frontend/app/[locale]/newchat/utils/message-annotations.ts new file mode 100644 index 0000000000..564be7d40d --- /dev/null +++ b/frontend/app/[locale]/newchat/utils/message-annotations.ts @@ -0,0 +1,270 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useMemo } from "react"; + +/** DOM hooks used to scope a quotable selection to one assistant message. */ +const ASSISTANT_MESSAGE_SELECTOR = "[data-role='assistant'][data-message-id]"; +const MESSAGE_CONTENT_SELECTOR = "[data-slot='aui_assistant-message-content']"; +// Reasoning, tool calls, execution-code summaries and subagent cards render +// outside the quotable reply text. +const NON_QUOTABLE_SELECTOR = + "[data-slot='aui_chain-of-thought'], .aui-tool-group-root"; + +/** Default excerpt length for the annotation source label. */ +const SOURCE_EXCERPT_MAX_LENGTH = 40; + +/** Default truncation length for quoted text shown inside the chip list. */ +const LIST_QUOTE_MAX_LENGTH = 80; + +/** + * Loose shape of an assistant-ui thread message as consumed by the + * annotation helpers. Only the fields actually read are declared, mirroring + * the relaxed typing used by `question-directory.ts`. + */ +export interface AnnotationMessageLike { + id: string; + role?: string; + content?: readonly { + type?: string; + text?: string; + }[]; +} + +/** A pending selection inside one assistant message, resolved from the DOM. */ +export interface AnnotationSelectionTarget { + /** assistant-ui message id of the message the selection belongs to. */ + messageId: string; + /** Normalized selected text (CRLF collapsed to LF). */ + text: string; + /** Cloned DOM range used to anchor the floating bubble. */ + range: Range; +} + +/** Draft annotation captured from a completed selection. */ +export interface MessageAnnotationDraft { + /** The selected text quoted verbatim from the assistant reply. */ + quotedText: string; + /** Optional user comment attached to the quote. */ + comment?: string; + /** assistant-ui message id of the message the quote came from. */ + sourceMessageId: string; +} + +/** A stored annotation attached to the next outgoing message. */ +export interface MessageAnnotation extends MessageAnnotationDraft { + /** Runtime identity used for list keys and edit/delete actions. */ + id: string; + /** Short excerpt of the source reply, shown in the list and the outgoing text. */ + sourceExcerpt: string; +} + +/** Aggregate handle passed from the Thread down to the composer and bubble. */ +export interface MessageAnnotationsControl { + /** Pending annotations in creation order. */ + annotations: readonly MessageAnnotation[]; + /** Stores a new annotation captured from a selection. */ + addAnnotation: (draft: MessageAnnotationDraft) => void; + /** Saves an edited comment. */ + updateAnnotationComment: (id: string, comment: string) => void; + /** Deletes a single annotation. */ + removeAnnotation: (id: string) => void; + /** Deletes every pending annotation. */ + clearAnnotations: () => void; + /** Consumes the annotations once the outgoing run has accepted them. */ + consumeAnnotations: () => void; +} + +/** Localized labels used when folding annotations into the outgoing text. */ +export interface AnnotationMessageLabels { + /** "Quote" label opening each annotation header. */ + quoteLabel: string; + /** Source template containing the `{{excerpt}}` placeholder. */ + sourceLabel: string; + /** Label prefixing the user comment line. */ + commentLabel: string; + /** Header terminator (full-width in Chinese, ": " in English). */ + colon: string; +} + +/** + * Collapses whitespace and truncates to a single-line excerpt. + * + * @param value Raw text to summarize. + * @param maxLength Maximum character count including the ellipsis. + * @returns The truncated single-line summary. + */ +export function truncateAnnotationText( + value: string, + maxLength: number +): string { + const line = value.replace(/\s+/g, " ").trim(); + if (!line) return ""; + if (line.length <= maxLength) return line; + return `${line.slice(0, maxLength - 1)}…`; +} + +/** + * Resolves the live selection into a quotable target. + * + * A selection is quotable when both endpoints sit inside the content area of + * the same assistant message and outside reasoning, tool, execution-code and + * subagent containers. Selections crossing messages, user messages and + * collapsed selections are rejected. + * + * @param selection The live window selection. + * @returns The target, or null when the selection is not quotable. + */ +export function resolveAnnotationSelection( + selection: Selection | null +): AnnotationSelectionTarget | null { + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + return null; + } + const text = selection.toString(); + if (!text || !text.trim()) return null; + const anchorElement = elementFromNode(selection.anchorNode); + const focusElement = elementFromNode(selection.focusNode); + if (!anchorElement || !focusElement) return null; + + const messageElement = anchorElement.closest(ASSISTANT_MESSAGE_SELECTOR); + if (!messageElement) return null; + // Both endpoints must belong to the same assistant message; this also + // rejects selections that start in chrome outside the message list. + if ( + messageElement !== focusElement.closest(ASSISTANT_MESSAGE_SELECTOR) || + anchorElement.closest(MESSAGE_CONTENT_SELECTOR) !== + focusElement.closest(MESSAGE_CONTENT_SELECTOR) + ) { + return null; + } + if ( + anchorElement.closest(NON_QUOTABLE_SELECTOR) || + focusElement.closest(NON_QUOTABLE_SELECTOR) + ) { + return null; + } + + const messageId = messageElement.getAttribute("data-message-id") ?? ""; + if (!messageId) return null; + + return { + messageId, + text: text.replace(/\r\n/g, "\n"), + range: selection.getRangeAt(0).cloneRange(), + }; +} + +function elementFromNode(node: Node | null): HTMLElement | null { + if (!node) return null; + if (node.nodeType === Node.TEXT_NODE) return node.parentElement; + return node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null; +} + +/** + * Extracts a short excerpt of the assistant message an annotation quotes. + * + * @param messages Thread messages in conversation order. + * @param messageId assistant-ui message id of the source message. + * @param maxLength Maximum excerpt length including the ellipsis. + * @returns The truncated first line of the reply, or "" for unknown sources. + */ +export function getAnnotationSourceExcerpt( + messages: readonly AnnotationMessageLike[], + messageId: string, + maxLength = SOURCE_EXCERPT_MAX_LENGTH +): string { + const message = messages.find((item) => item.id === messageId); + if (!message || message.role !== "assistant") return ""; + const text = (message.content ?? []) + .map((part) => (part.type === "text" ? (part.text ?? "") : "")) + .join("") + .trim(); + return truncateAnnotationText(text, maxLength); +} + +/** + * Builds a stored annotation from a draft. + * + * The comment is trimmed; a whitespace-only comment is dropped. The source + * excerpt is captured at creation time so the annotation keeps referencing + * the reply content the user saw when selecting. + * + * @param draft Draft captured from the selection bubble. + * @param messages Thread messages used to resolve the source excerpt. + * @param id Runtime identity for the stored annotation. + * @returns The fully populated annotation. + */ +export function createMessageAnnotation( + draft: MessageAnnotationDraft, + messages: readonly AnnotationMessageLike[], + id: string +): MessageAnnotation { + return { + id, + ...draft, + comment: draft.comment?.trim() || undefined, + sourceExcerpt: getAnnotationSourceExcerpt(messages, draft.sourceMessageId), + }; +} + +function renderSourceLabel(template: string, excerpt: string): string { + return template.replace(/\{\{excerpt\}\}/g, excerpt); +} + +/** + * Folds pending annotations into the outgoing message text. + * + * Each annotation becomes a numbered quote block; the user's own text follows + * behind a `---` separator so the model receives both the quoted reply + * excerpts (with the user's comments) and the follow-up question in one + * message. With no annotations the text is returned unchanged. + * + * @param text The user-typed composer text (may be empty). + * @param annotations Pending annotations in creation order. + * @param labels Localized labels for the composed blocks. + * @returns The composed outgoing message text. + */ +export function formatAnnotationsIntoMessage( + text: string, + annotations: readonly MessageAnnotation[], + labels: AnnotationMessageLabels +): string { + if (annotations.length === 0) return text; + + const blocks = annotations.map((annotation, index) => { + const source = annotation.sourceExcerpt + ? renderSourceLabel(labels.sourceLabel, annotation.sourceExcerpt) + : ""; + const lines = [ + // trimEnd drops the trailing blank of the English ": " colon. + `${index + 1}. ${labels.quoteLabel}${source}${labels.colon}`.trimEnd(), + ]; + for (const line of annotation.quotedText.split("\n")) { + lines.push(`> ${line}`); + } + if (annotation.comment) { + lines.push(`${labels.commentLabel}${labels.colon}${annotation.comment}`); + } + return lines.join("\n"); + }); + + const quoteBlock = blocks.join("\n\n"); + return text ? `${quoteBlock}\n\n---\n\n${text}` : quoteBlock; +} + +/** + * Memoized helper for the chip list: shortens quoted text for display. + * + * @param annotation The annotation whose quote should be summarized. + * @returns A single-line truncated quote. + */ +export function useAnnotationQuotePreview( + annotation: MessageAnnotation +): string { + return useMemo( + () => truncateAnnotationText(annotation.quotedText, LIST_QUOTE_MAX_LENGTH), + [annotation.quotedText] + ); +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 680b452a36..52b88515e2 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -488,6 +488,24 @@ "chat.questions.jump": "Jump to question {{index}}", "chat.questions.search": "Search questions…", "chat.questions.noMatches": "No matching questions", + "chat.annotation.add": "Add to chat", + "chat.annotation.commentPlaceholder": "Add an optional comment...", + "chat.annotation.editPlaceholder": "Edit comment...", + "chat.annotation.confirm": "Add annotation", + "chat.annotation.cancel": "Cancel annotation", + "chat.annotation.chipLabel_one": "{{count}} annotation", + "chat.annotation.chipLabel_other": "{{count}} annotations", + "chat.annotation.listTitle": "Annotations", + "chat.annotation.selectedTextLabel": "Selected text", + "chat.annotation.commentLabel": "Comment", + "chat.annotation.editComment": "Edit comment", + "chat.annotation.deleteAnnotation": "Delete annotation", + "chat.annotation.clearAnnotations": "Clear all annotations", + "chat.annotation.saveComment": "Save comment", + "chat.annotation.cancelEdit": "Cancel editing", + "chat.annotation.quoteLabel": "Quote", + "chat.annotation.sourceLabel": " (from reply \"{{excerpt}}\")", + "chat.annotation.colon": ": ", "chat.artifacts.generatedAt": "Generated {{time}}", "chat.tokenUsage.used": "used", "chat.tokenUsage.turn": "this turn", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 34bcc805cb..1ec54d0ae5 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -486,6 +486,23 @@ "chat.questions.jump": "跳转到问题 {{index}}", "chat.questions.search": "搜索问题…", "chat.questions.noMatches": "没有匹配的问题", + "chat.annotation.add": "添加到对话", + "chat.annotation.commentPlaceholder": "添加可选批注…", + "chat.annotation.editPlaceholder": "编辑批注…", + "chat.annotation.confirm": "添加批注", + "chat.annotation.cancel": "取消批注", + "chat.annotation.chipLabel": "{{count}} 条批注", + "chat.annotation.listTitle": "批注", + "chat.annotation.selectedTextLabel": "选中内容", + "chat.annotation.commentLabel": "批注", + "chat.annotation.editComment": "编辑批注", + "chat.annotation.deleteAnnotation": "删除批注", + "chat.annotation.clearAnnotations": "清空全部批注", + "chat.annotation.saveComment": "保存批注", + "chat.annotation.cancelEdit": "取消编辑", + "chat.annotation.quoteLabel": "引用", + "chat.annotation.sourceLabel": "(来自回复「{{excerpt}}」)", + "chat.annotation.colon": ":", "chat.artifacts.generatedAt": "生成于 {{time}}", "chat.tokenUsage.used": "已使用", "chat.tokenUsage.turn": "本轮", diff --git a/frontend/tests/component/messageAnnotation.test.tsx b/frontend/tests/component/messageAnnotation.test.tsx new file mode 100644 index 0000000000..2d8c8ad5c6 --- /dev/null +++ b/frontend/tests/component/messageAnnotation.test.tsx @@ -0,0 +1,472 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { + createMessageAnnotation, + formatAnnotationsIntoMessage, + getAnnotationSourceExcerpt, + resolveAnnotationSelection, + truncateAnnotationText, + type AnnotationMessageLabels, + type MessageAnnotation, +} from "@/app/newchat/utils/message-annotations"; +import { ComposerAnnotationChip } from "@/app/newchat/assistant-ui/annotation-chip"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const LABELS: AnnotationMessageLabels = { + quoteLabel: "Quote", + sourceLabel: ' (from reply "{{excerpt}}")', + commentLabel: "Comment", + colon: ": ", +}; + +const makeAnnotation = ( + overrides: Partial = {} +): MessageAnnotation => ({ + id: "annotation-1", + quotedText: "quoted reply text", + comment: undefined, + sourceMessageId: "assistant-1", + sourceExcerpt: "reply intro", + ...overrides, +}); + +afterEach(cleanup); + +describe("formatAnnotationsIntoMessage", () => { + test("returns the text unchanged without annotations", () => { + expect(formatAnnotationsIntoMessage("why stop?", [], LABELS)).toBe( + "why stop?" + ); + expect(formatAnnotationsIntoMessage("", [], LABELS)).toBe(""); + }); + + test("composes a single annotation with comment and source excerpt", () => { + const result = formatAnnotationsIntoMessage( + "why stop?", + [ + makeAnnotation({ + quotedText: "line one\nline two", + comment: "skip this", + sourceExcerpt: "reply intro", + }), + ], + LABELS + ); + + expect(result).toBe( + '1. Quote (from reply "reply intro"):\n> line one\n> line two\nComment: skip this\n\n---\n\nwhy stop?' + ); + }); + + test("composes multiple annotations, skipping empty comments and excerpts", () => { + const result = formatAnnotationsIntoMessage( + "follow-up", + [ + makeAnnotation({ id: "a1", comment: "note" }), + makeAnnotation({ + id: "a2", + quotedText: "second", + comment: undefined, + sourceExcerpt: "", + }), + ], + LABELS + ); + + expect(result).toBe( + '1. Quote (from reply "reply intro"):\n> quoted reply text\nComment: note\n\n2. Quote:\n> second\n\n---\n\nfollow-up' + ); + }); + + test("composes annotation-only sends without the user separator", () => { + const result = formatAnnotationsIntoMessage( + "", + [makeAnnotation({ comment: undefined })], + LABELS + ); + + expect(result).toBe( + '1. Quote (from reply "reply intro"):\n> quoted reply text' + ); + }); +}); + +describe("truncateAnnotationText", () => { + test("keeps short text and collapses whitespace", () => { + expect(truncateAnnotationText(" hello world ", 20)).toBe("hello world"); + }); + + test("truncates long text with an ellipsis at the limit", () => { + expect(truncateAnnotationText("abcdefghij", 4)).toBe("abc…"); + expect(truncateAnnotationText("abcdefghij", 10)).toBe("abcdefghij"); + expect(truncateAnnotationText(" ", 10)).toBe(""); + }); +}); + +describe("getAnnotationSourceExcerpt", () => { + test("joins text parts of the assistant message and skips other parts", () => { + const messages = [ + { + id: "user-1", + role: "user", + content: [{ type: "text", text: "question" }], + }, + { + id: "assistant-1", + role: "assistant", + content: [ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "first " }, + { type: "text", text: "second" }, + { type: "tool-call" }, + ], + }, + ]; + + expect(getAnnotationSourceExcerpt(messages, "assistant-1")).toBe( + "first second" + ); + }); + + test("returns an empty excerpt for user or unknown messages", () => { + const messages = [ + { + id: "user-1", + role: "user", + content: [{ type: "text", text: "question" }], + }, + ]; + + expect(getAnnotationSourceExcerpt(messages, "user-1")).toBe(""); + expect(getAnnotationSourceExcerpt(messages, "missing")).toBe(""); + }); + + test("truncates long replies to the excerpt limit", () => { + const long = "x".repeat(60); + const messages = [ + { + id: "assistant-1", + role: "assistant", + content: [{ type: "text", text: long }], + }, + ]; + + const excerpt = getAnnotationSourceExcerpt(messages, "assistant-1"); + expect(excerpt).toHaveLength(40); + expect(excerpt.endsWith("…")).toBe(true); + }); +}); + +describe("createMessageAnnotation", () => { + test("uses the given id, trims the comment and resolves the excerpt", () => { + const messages = [ + { + id: "assistant-1", + role: "assistant", + content: [{ type: "text", text: "reply intro text" }], + }, + ]; + + const annotation = createMessageAnnotation( + { + quotedText: " quoted ", + comment: " keep this ", + sourceMessageId: "assistant-1", + }, + messages, + "generated-id" + ); + + expect(annotation).toEqual({ + id: "generated-id", + quotedText: " quoted ", + comment: "keep this", + sourceMessageId: "assistant-1", + sourceExcerpt: "reply intro text", + }); + }); + + test("drops whitespace-only comments", () => { + const annotation = createMessageAnnotation( + { quotedText: "q", comment: " ", sourceMessageId: "assistant-1" }, + [], + "generated-id" + ); + + expect(annotation.comment).toBeUndefined(); + }); +}); + +describe("resolveAnnotationSelection", () => { + const buildAssistantMessage = (id: string) => { + const root = document.createElement("div"); + root.setAttribute("data-role", "assistant"); + root.setAttribute("data-message-id", id); + const content = document.createElement("div"); + content.setAttribute("data-slot", "aui_assistant-message-content"); + const paragraph = document.createElement("p"); + paragraph.textContent = "quotable text here"; + content.appendChild(paragraph); + root.appendChild(content); + const chain = document.createElement("div"); + chain.setAttribute("data-slot", "aui_chain-of-thought"); + const chainText = document.createElement("span"); + chainText.textContent = "reasoning content"; + chain.appendChild(chainText); + root.appendChild(chain); + const toolGroup = document.createElement("div"); + toolGroup.className = "aui-tool-group-root"; + const toolText = document.createElement("span"); + toolText.textContent = "tool content"; + toolGroup.appendChild(toolText); + root.appendChild(toolGroup); + document.body.appendChild(root); + return { root, paragraph, chainText, toolText }; + }; + + const makeSelection = ( + anchorNode: Node, + focusNode: Node, + text: string, + isCollapsed = false + ) => + ({ + isCollapsed, + rangeCount: 1, + anchorNode, + focusNode, + toString: () => text, + getRangeAt: () => document.createRange(), + }) as unknown as Selection; + + test("resolves a selection inside one assistant message", () => { + const { paragraph } = buildAssistantMessage("assistant-1"); + const textNode = paragraph.firstChild as Node; + + const target = resolveAnnotationSelection( + makeSelection(textNode, textNode, "quotable text") + ); + + expect(target).not.toBeNull(); + expect(target?.messageId).toBe("assistant-1"); + expect(target?.text).toBe("quotable text"); + expect(target?.range).toBeInstanceOf(Range); + }); + + test("rejects collapsed and empty selections", () => { + const { paragraph } = buildAssistantMessage("assistant-1"); + const textNode = paragraph.firstChild as Node; + + expect( + resolveAnnotationSelection( + makeSelection(textNode, textNode, "quotable", true) + ) + ).toBeNull(); + expect( + resolveAnnotationSelection(makeSelection(textNode, textNode, " ")) + ).toBeNull(); + expect(resolveAnnotationSelection(null)).toBeNull(); + }); + + test("rejects selections inside reasoning and tool containers", () => { + const { paragraph, chainText, toolText } = + buildAssistantMessage("assistant-1"); + const contentNode = paragraph.firstChild as Node; + const chainNode = chainText.firstChild as Node; + const toolNode = toolText.firstChild as Node; + + expect( + resolveAnnotationSelection( + makeSelection(chainNode, chainNode, "reasoning content") + ) + ).toBeNull(); + expect( + resolveAnnotationSelection( + makeSelection(toolNode, toolNode, "tool content") + ) + ).toBeNull(); + // Spans from the reply content into a reasoning block. + expect( + resolveAnnotationSelection( + makeSelection(contentNode, chainNode, "crossing") + ) + ).toBeNull(); + }); + + test("rejects selections outside or across assistant messages", () => { + const { paragraph } = buildAssistantMessage("assistant-1"); + const other = buildAssistantMessage("assistant-2"); + const userMessage = document.createElement("div"); + userMessage.setAttribute("data-role", "user"); + const userText = document.createElement("p"); + userText.textContent = "user text"; + userMessage.appendChild(userText); + document.body.appendChild(userMessage); + + const contentNode = paragraph.firstChild as Node; + const otherNode = other.paragraph.firstChild as Node; + const userNode = userText.firstChild as Node; + + // Endpoints in two different messages. + expect( + resolveAnnotationSelection(makeSelection(contentNode, otherNode, "two")) + ).toBeNull(); + // User message selection. + expect( + resolveAnnotationSelection(makeSelection(userNode, userNode, "user text")) + ).toBeNull(); + }); +}); + +describe("ComposerAnnotationChip", () => { + const baseAnnotations = (): MessageAnnotation[] => [ + makeAnnotation({ + id: "a1", + quotedText: "first quote", + comment: "first note", + }), + makeAnnotation({ + id: "a2", + quotedText: "second quote", + comment: undefined, + }), + ]; + + const renderChip = (annotations: MessageAnnotation[]) => + render( + + ); + + test("renders nothing without annotations", () => { + const { container } = renderChip([]); + expect(container.firstChild).toBeNull(); + }); + + test("opens the annotation list on hover and lists every entry", () => { + renderChip(baseAnnotations()); + + const trigger = screen.getByText("chat.annotation.chipLabel"); + fireEvent.mouseEnter(trigger); + + const list = screen.getByRole("list"); + const items = within(list).getAllByRole("listitem"); + expect(items).toHaveLength(2); + expect(within(items[0]).getByText(/first quote/)).toBeTruthy(); + expect(within(items[0]).getByText(/first note/)).toBeTruthy(); + expect(within(items[1]).getByText(/second quote/)).toBeTruthy(); + }); + + test("removes a single annotation from the list", () => { + const onRemove = vi.fn(); + render( + + ); + + fireEvent.mouseEnter(screen.getByText("chat.annotation.chipLabel")); + fireEvent.click( + screen.getAllByRole("button", { + name: "chat.annotation.deleteAnnotation", + })[0] + ); + + expect(onRemove).toHaveBeenCalledWith("a1"); + }); + + test("clears all annotations from the chip", () => { + const onClear = vi.fn(); + render( + + ); + + fireEvent.click( + screen.getByRole("button", { name: "chat.annotation.clearAnnotations" }) + ); + + expect(onClear).toHaveBeenCalledTimes(1); + }); + + test("edits a comment inline and saves the trimmed value", () => { + const onEditComment = vi.fn(); + render( + + ); + + fireEvent.mouseEnter(screen.getByText("chat.annotation.chipLabel")); + fireEvent.click( + screen.getAllByRole("button", { + name: "chat.annotation.editComment", + })[0] + ); + + const input = screen.getByLabelText("chat.annotation.editPlaceholder"); + fireEvent.change(input, { target: { value: " updated note " } }); + fireEvent.click( + screen.getByRole("button", { name: "chat.annotation.saveComment" }) + ); + + expect(onEditComment).toHaveBeenCalledWith("a1", "updated note"); + }); + + test("cancels editing without saving", () => { + const onEditComment = vi.fn(); + render( + + ); + + fireEvent.mouseEnter(screen.getByText("chat.annotation.chipLabel")); + fireEvent.click( + screen.getAllByRole("button", { + name: "chat.annotation.editComment", + })[0] + ); + fireEvent.click( + screen.getByRole("button", { name: "chat.annotation.cancelEdit" }) + ); + + expect(onEditComment).not.toHaveBeenCalled(); + // The edit entry point is rendered again after cancelling. + expect( + screen.getAllByRole("button", { name: "chat.annotation.editComment" }) + .length + ).toBeGreaterThan(0); + }); +}); diff --git a/test-e2e/changes/requirements/ADD-TO-CHAT-MESSAGE-ANNOTATIONS-20261102.yaml b/test-e2e/changes/requirements/ADD-TO-CHAT-MESSAGE-ANNOTATIONS-20261102.yaml new file mode 100644 index 0000000000..409ebc3d38 --- /dev/null +++ b/test-e2e/changes/requirements/ADD-TO-CHAT-MESSAGE-ANNOTATIONS-20261102.yaml @@ -0,0 +1,76 @@ +schema_version: "1.0" +change: + change_id: ADD-TO-CHAT-MESSAGE-ANNOTATIONS-20261102 + change_type: requirement + requirement_id: ADD-TO-CHAT-MESSAGE-ANNOTATIONS-20261102 + title: 回复选中批注(添加到对话) + description: + 'Selecting text inside one assistant reply shows a floating "Add to chat" + bubble above the selection. Confirming it (optionally after typing a + comment) stores a pending annotation and renders a count chip above the + composer input; hovering the chip opens the annotation list where each + entry can be edited or deleted and the trailing button clears all. + Annotations attach to the next outgoing message only: sending folds each + one into the message text as a numbered quote block (quoted reply text + prefixed with ">", an optional comment line, and the first-40-char excerpt + of the source reply), followed by a "---" separator and the user-typed + text. An annotation-only send (empty composer text) is allowed. The fold + is idempotent for the send-button/form-submit double invocation; a + rejected run keeps the annotations and restores the user draft, and the + annotations are consumed once the run accepts. Switching threads resets + pending annotations. Selections are quotable only inside the content area + of a single assistant message (reasoning, tool, execution-code, and + subagent containers are excluded); the bubble is disabled in read-only + threads, share mode, skill-file chats, and the preview layouts.' + affected_features: + existing: [] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + test_selectors: + - frontend/tests/component/messageAnnotation.test.tsx + behaviors: + - formatAnnotationsIntoMessage returns the text unchanged without + annotations, composes numbered quote blocks with the "from reply" + excerpt and optional comment lines, and joins the user text behind a + "---" separator (annotation-only sends keep the block alone). + - truncateAnnotationText collapses whitespace and truncates to the + limit with an ellipsis. + - getAnnotationSourceExcerpt joins only the text parts of the source + assistant message, returns "" for user or unknown messages, and + truncates long replies to the excerpt limit. + - createMessageAnnotation keeps the quoted text verbatim, trims the + comment (dropping whitespace-only values), and captures the source + excerpt at creation time. + - resolveAnnotationSelection accepts a selection whose endpoints sit in + the content area of the same assistant message, normalizes CRLF, and + rejects collapsed/empty selections, reasoning and tool containers, + cross-message selections, and user-message selections. + - ComposerAnnotationChip renders nothing without annotations, opens + the annotation list on hover, deletes a single entry, clears all + entries, saves an inline-edited trimmed comment, and cancels editing + without saving. + reason: |- + The feature is pure frontend composition at send time: no backend + route, payload, or protocol changes are involved, and the send path + reuses the existing ordinary-send rejection/restore flow. The targeted + component UT covers the new unit behavior (message composition, + selection resolution, chip list interactions) at its owning layer; the + existing workbench Composer suite still passes unchanged. No new + external protocol or cross-component runtime contract is introduced, + so no formal Case is added. + notes: | + Core scope confirmed by the user: the bubble only offers "Add to chat" + (no side chat), an optional comment is enough to send (empty composer + text allowed), each annotation carries the first-40-char excerpt of its + source reply, and the chip expands its list on hover. Quoted text is + sent inside the message content (markdown-style "> " lines) so the model + resolves the reference from the full conversation history without any + message-reference protocol. From 7218efdc962968a0ad86852000b5dd107f11a830 Mon Sep 17 00:00:00 2001 From: ljy Date: Mon, 2 Nov 2026 20:40:01 +0800 Subject: [PATCH 07/26] feat(chat): send reply annotations as structured fields Upgrade the "add to chat" flow from frontend-only text folding to a LibreChat-style structured channel: - AgentRequest/MessageRequest gain an `annotations` field (snake_case wire items quoted_text/comment/source_excerpt, capped at 10 entries and 1500/500/120 chars, truncated rather than rejected) via the new contracts/annotations.py module. - The user message persists annotations as a `message_annotations` unit (mirroring clarification_response) while the stored message_content stays clean; history reads return the structured field. - The backend merges the numbered quote blocks into the model-facing text at request time: the current turn in prepare_agent_run and past turns in get_historical_context. - The composer takes over the append when annotations are pending (no in-flight uploads) so the optimistic user message carries them as metadata; the user bubble renders read-only reference cards, restored from history after reload. In-flight uploads fall back to the runConfig channel; stale payloads are cleared so they cannot leak into clarification sends or edit resends. Ordinary sends are untouched. - The fold function, its localized labels and the double-invocation guard are removed; formatting now happens in the backend. --- backend/contracts/agent.py | 5 + backend/contracts/annotations.py | 137 ++++++++ backend/contracts/conversation.py | 3 + .../management/db/conversation/repository.py | 87 ++++- backend/management/services/agent/run.py | 12 +- .../services/conversation/service.py | 18 ++ .../conversation-thread-list-adapter.tsx | 4 + .../adapter/remote-chat-model-adapter.ts | 26 ++ .../newchat/assistant-ui/composer.tsx | 124 +++++-- .../assistant-ui/message-annotation-cards.tsx | 91 ++++++ .../[locale]/newchat/assistant-ui/thread.tsx | 36 ++- .../components/clarification-message-card.tsx | 11 +- .../newchat/utils/message-annotations.ts | 117 ++++--- frontend/public/locales/en/common.json | 4 +- frontend/public/locales/zh/common.json | 4 +- frontend/services/conversationService.ts | 9 + .../component/messageAnnotation.test.tsx | 185 +++++++---- frontend/types/conversation.ts | 7 + ...-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml | 98 ++++++ test/backend/database/test_conversation_db.py | 6 +- .../services/test_message_annotations.py | 306 ++++++++++++++++++ 21 files changed, 1134 insertions(+), 156 deletions(-) create mode 100644 backend/contracts/annotations.py create mode 100644 frontend/app/[locale]/newchat/assistant-ui/message-annotation-cards.tsx create mode 100644 test-e2e/changes/requirements/ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml create mode 100644 test/backend/services/test_message_annotations.py diff --git a/backend/contracts/agent.py b/backend/contracts/agent.py index 31fd51073a..48f7d98824 100644 --- a/backend/contracts/agent.py +++ b/backend/contracts/agent.py @@ -6,6 +6,7 @@ from typing import Any, Dict, List, Literal, Optional from typing import Optional as Opt +from contracts.annotations import MessageAnnotationItem from contracts.clarification import ClarificationResponse from contracts.constants.constants import WORKBENCH_OFFICIAL_SKILL_NAMES from contracts.conversation import HistoryItem @@ -213,6 +214,10 @@ class AgentRequest(BaseModel): _message_source: str = PrivateAttr(default="CHAT") query: str clarification_response: Optional[ClarificationResponse] = None + # Reply annotations ("add to chat" quotes) attached to this turn's user + # message; persisted as a structured unit and merged into the model-facing + # text at request time. + annotations: Optional[List[MessageAnnotationItem]] = None conversation_id: Optional[int] = None history: Optional[List[HistoryItem]] = None # Complete list of attachment information diff --git a/backend/contracts/annotations.py b/backend/contracts/annotations.py new file mode 100644 index 0000000000..942cc96d58 --- /dev/null +++ b/backend/contracts/annotations.py @@ -0,0 +1,137 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Structured reply annotations attached to an outgoing user message. + +The "add to chat" flow lets a user quote excerpts of an assistant reply +(optionally with a comment) and attach them to the next outgoing message. +Annotations travel as a structured field on ``AgentRequest``, are persisted +on the user message as a ``message_annotations`` unit, and are merged into +the model-facing text at request time so the stored message text stays +clean (mirroring how ``clarification_response`` is handled). +""" + +import json +from typing import Any, List, Optional + +from pydantic import BaseModel + +# Unit type used for the per-message annotation payload persisted on user +# messages through conversation_message_unit_t. +MESSAGE_ANNOTATIONS_UNIT_TYPE = "message_annotations" + +# Defensive caps mirroring the client-side limits; the backend truncates +# rather than rejects so oversized payloads never break a send. +ANNOTATION_QUOTED_TEXT_MAX_CHARS = 1500 +ANNOTATION_COMMENT_MAX_CHARS = 500 +ANNOTATION_SOURCE_EXCERPT_MAX_CHARS = 120 +ANNOTATION_MAX_COUNT = 10 + + +class MessageAnnotationItem(BaseModel): + """One quoted excerpt attached to an outgoing user message. + + Length caps are enforced by ``normalize_message_annotations`` (truncating + rather than rejecting) so oversized client payloads degrade gracefully + instead of failing the whole send. + """ + + quoted_text: str + comment: Optional[str] = None + source_excerpt: Optional[str] = None + + +def normalize_message_annotations( + raw: Any, +) -> Optional[List[MessageAnnotationItem]]: + """Validate raw annotation payloads, applying defensive caps. + + Accepts a list of annotation dicts (or models) as delivered by the + request body or read back from a persisted unit. Entries without usable + quoted text are dropped, over-length fields are truncated, and the list + is capped to ``ANNOTATION_MAX_COUNT``. Returns ``None`` when nothing + usable remains so callers can skip persistence and merging entirely. + """ + if not isinstance(raw, list): + return None + + annotations: List[MessageAnnotationItem] = [] + for item in raw: + try: + annotation = ( + item + if isinstance(item, MessageAnnotationItem) + else MessageAnnotationItem.model_validate(item) + ) + except (ValueError, TypeError): + continue + quoted = annotation.quoted_text.strip() + if not quoted: + continue + comment = (annotation.comment or "").strip() or None + excerpt = (annotation.source_excerpt or "").strip() or None + annotations.append( + MessageAnnotationItem( + quoted_text=quoted[:ANNOTATION_QUOTED_TEXT_MAX_CHARS], + comment=comment[:ANNOTATION_COMMENT_MAX_CHARS] if comment else None, + source_excerpt=( + excerpt[:ANNOTATION_SOURCE_EXCERPT_MAX_CHARS] + if excerpt + else None + ), + ) + ) + if len(annotations) >= ANNOTATION_MAX_COUNT: + break + + return annotations or None + + +def parse_message_annotation_unit(unit_content: Optional[str]) -> Optional[List[MessageAnnotationItem]]: + """Parse a persisted ``message_annotations`` unit payload. + + Returns the normalized annotations, or ``None`` for missing/invalid + payloads so history readers can skip the field entirely. + """ + if not unit_content: + return None + try: + payload = json.loads(unit_content) + except (ValueError, TypeError): + return None + return normalize_message_annotations(payload) + + +def _annotation_block(index: int, annotation: MessageAnnotationItem) -> str: + source = ( + f' (from reply "{annotation.source_excerpt}")' + if annotation.source_excerpt + else "" + ) + lines = [f"{index}. Quote{source}:"] + for line in annotation.quoted_text.split("\n"): + lines.append(f"> {line}" if line else ">") + if annotation.comment: + lines.append(f"Comment: {annotation.comment}") + return "\n".join(lines) + + +def format_annotations_for_llm( + text: str, annotations: Optional[List[MessageAnnotationItem]] +) -> str: + """Merge annotation blocks into the model-facing user text. + + Each annotation becomes a numbered quote block (blockquote excerpt plus + an optional comment line); the blocks are joined with blank lines and + separated from the user's own text by a horizontal rule so the model + receives both the quoted reply excerpts and the follow-up question in + one message. With no annotations the text is returned unchanged. + """ + if not annotations: + return text + + blocks = [ + _annotation_block(index, annotation) + for index, annotation in enumerate(annotations, start=1) + ] + quote_block = "\n\n".join(blocks) + body = text or "" + return f"{quote_block}\n\n---\n\n{body}" if body else quote_block diff --git a/backend/contracts/conversation.py b/backend/contracts/conversation.py index f006608e68..ded9d5cae6 100644 --- a/backend/contracts/conversation.py +++ b/backend/contracts/conversation.py @@ -11,6 +11,7 @@ from pydantic import BaseModel, Field +from contracts.annotations import MessageAnnotationItem from contracts.clarification import ClarificationResponse @@ -72,6 +73,8 @@ class MessageRequest(BaseModel): role: str message: List[MessageUnit] clarification_response: Optional[ClarificationResponse] = None + # Reply annotations ("add to chat" quotes) attached to this user message + annotations: Optional[List[MessageAnnotationItem]] = None # Complete list of attachment information minio_files: Optional[List[Dict[str, Any]]] = None diff --git a/backend/management/db/conversation/repository.py b/backend/management/db/conversation/repository.py index 291ba0491a..278f399eb7 100644 --- a/backend/management/db/conversation/repository.py +++ b/backend/management/db/conversation/repository.py @@ -1,5 +1,11 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +from contracts.annotations import ( + MESSAGE_ANNOTATIONS_UNIT_TYPE, + format_annotations_for_llm, + normalize_message_annotations, + parse_message_annotation_unit, +) from contracts.constants.constants import CHAT_MODE_VALUES, HISTORY_SUMMARY_UNIT_TYPE from contracts.conversation import ConversationHistory, ImageRecord, MessageRecord, SearchRecord from contracts.errors.exceptions import HistorySummaryPersistenceError @@ -290,6 +296,9 @@ def create_conversation_message(message_data: Dict[str, Any], user_id: Optional[ response = _resolve_clarification_response( session, conversation_id, user_id, message_role, message_data['clarification_response'], ) + annotations = None + if message_data.get('annotations') is not None: + annotations = normalize_message_annotations(message_data['annotations']) minio_files = message_data.get('minio_files') # Convert minio_files to JSON string for storage @@ -319,6 +328,22 @@ def create_conversation_message(message_data: Dict[str, Any], user_id: Optional[ if user_id: unit_data = add_creation_tracking(unit_data, user_id) session.execute(insert(ConversationMessageUnit).values(**unit_data)) + if annotations is not None and message_role == MESSAGE_ROLE["USER"]: + # Reply annotations ride as a structured unit so the stored + # message_content stays clean; the unit drives history rendering + # and the model-facing merge for later turns. + annotation_unit = { + 'conversation_id': conversation_id, 'message_id': message_id, + 'unit_index': 0, 'unit_type': MESSAGE_ANNOTATIONS_UNIT_TYPE, + 'unit_content': json.dumps( + [item.model_dump(mode="json") for item in annotations], + ensure_ascii=False, + ), + 'unit_status': 'completed', 'delete_flag': 'N', + } + if user_id: + annotation_unit = add_creation_tracking(annotation_unit, user_id) + session.execute(insert(ConversationMessageUnit).values(**annotation_unit)) if message_role == MESSAGE_ROLE["USER"] or ( message_role == MESSAGE_ROLE["ASSISTANT"] and message_data["content"] and status != "streaming" ): @@ -2871,9 +2896,15 @@ def _stopped_history_text(message, session, conversation_id: int) -> str: return _STOPPED_CONTEXT_NOTE + ("\n" + body if body else "") -def _history_turn(user, assistant_text: str, assistant_id=None) -> Dict[str, Any]: +def _history_turn(user, assistant_text: str, assistant_id=None, + annotations=None) -> Dict[str, Any]: + # Annotations merge into the model-facing user text only; the stored + # message_content stays clean. + user_message = user.message_content or "" + if annotations: + user_message = format_annotations_for_llm(user_message, annotations) return { - "user_message": user.message_content or "", + "user_message": user_message, "assistant_final_answer": assistant_text, "attachments": user.minio_files, "user_message_id": user.message_id, @@ -2983,12 +3014,45 @@ def get_historical_context( .order_by(asc(ConversationMessage.message_index)) ).all() + # Fetch reply-annotation units for the historical user messages so the + # model keeps seeing quoted excerpts on later turns while the stored + # message_content stays clean. + user_message_ids = [ + message.message_id + for message in messages + if message.message_role == "user" + ] + annotations_by_message: Dict[int, Any] = {} + if user_message_ids: + annotation_rows = session.execute( + select( + ConversationMessageUnit.message_id, + ConversationMessageUnit.unit_content, + ).where( + ConversationMessageUnit.conversation_id == conversation_id, + ConversationMessageUnit.message_id.in_(user_message_ids), + ConversationMessageUnit.unit_type == MESSAGE_ANNOTATIONS_UNIT_TYPE, + ConversationMessageUnit.unit_status == "completed", + ConversationMessageUnit.delete_flag == "N", + ) + ).all() + for row in annotation_rows: + items = parse_message_annotation_unit(row.unit_content) + if items is not None: + annotations_by_message[int(row.message_id)] = items + turns: List[Dict[str, Any]] = [] pending_user = None for message in messages: if message.message_role == "user": if pending_user is not None: - turns.append(_history_turn(pending_user, _UNANSWERED_CONTEXT_NOTE)) + turns.append( + _history_turn( + pending_user, + _UNANSWERED_CONTEXT_NOTE, + annotations=annotations_by_message.get(pending_user.message_id), + ) + ) pending_user = message elif pending_user is not None: answer = ( @@ -2996,10 +3060,23 @@ def get_historical_context( if message.status == "stopped" else message.message_content or "" ) - turns.append(_history_turn(pending_user, answer, message.message_id)) + turns.append( + _history_turn( + pending_user, + answer, + message.message_id, + annotations=annotations_by_message.get(pending_user.message_id), + ) + ) pending_user = None if pending_user is not None: - turns.append(_history_turn(pending_user, _UNANSWERED_CONTEXT_NOTE)) + turns.append( + _history_turn( + pending_user, + _UNANSWERED_CONTEXT_NOTE, + annotations=annotations_by_message.get(pending_user.message_id), + ) + ) summary_result = None if summary_record and summary_payload: diff --git a/backend/management/services/agent/run.py b/backend/management/services/agent/run.py index 18fc1c49ad..83507d77a5 100644 --- a/backend/management/services/agent/run.py +++ b/backend/management/services/agent/run.py @@ -25,6 +25,10 @@ from contracts.constants.constants import MESSAGE_ROLE from contracts.errors.exceptions import MemoryPreparationException from contracts.agent import AgentRequest +from contracts.annotations import ( + format_annotations_for_llm, + normalize_message_annotations, +) from management.services.agent.context import ( build_agent_run_context, build_authorized_context_input, @@ -137,7 +141,13 @@ async def prepare_agent_run( create_run_kwargs = { "agent_id": agent_request.agent_id, "minio_files": agent_request.minio_files, - "query": agent_request.query, + # Merge reply annotations into the model-facing task text only; the + # persisted message (save_conversation_user reads agent_request.query) + # keeps the clean user text. + "query": format_annotations_for_llm( + agent_request.query, + normalize_message_annotations(getattr(agent_request, "annotations", None)), + ), "history": agent_request.history, "tenant_id": tenant_id, "user_id": user_id, diff --git a/backend/management/services/conversation/service.py b/backend/management/services/conversation/service.py index 9c8365115b..3f723a69cb 100644 --- a/backend/management/services/conversation/service.py +++ b/backend/management/services/conversation/service.py @@ -3,6 +3,11 @@ import json import logging +from contracts.annotations import ( + MESSAGE_ANNOTATIONS_UNIT_TYPE, + normalize_message_annotations, + parse_message_annotation_unit, +) from contracts.conversation import MessageSource from management.services.agent.context import ( @@ -86,6 +91,8 @@ def save_message(request: MessageRequest, user_id: str, tenant_id: str, } if message_data.get('clarification_response') is not None: message_data_copy['clarification_response'] = message_data['clarification_response'] + if message_data.get('annotations') is not None: + message_data_copy['annotations'] = message_data['annotations'] return create_conversation_message( message_data_copy, user_id, status=status, message_source=message_source, ) @@ -258,6 +265,10 @@ def save_conversation_user(request: AgentRequest, user_id: str, tenant_id: str) # run_agent_stream for the LLM call only. raw_query = strip_current_time_prefix(request.query) + # Normalize annotations (defensive caps) before persistence; None skips + # the structured unit entirely. + annotations = normalize_message_annotations(getattr(request, "annotations", None)) + conversation_req = MessageRequest( conversation_id=request.conversation_id, message_idx=user_role_count * 2, @@ -266,6 +277,7 @@ def save_conversation_user(request: AgentRequest, user_id: str, tenant_id: str) minio_files=request.minio_files, **({'clarification_response': request.clarification_response} if getattr(request, 'clarification_response', None) is not None else {}), + **({'annotations': annotations} if annotations is not None else {}), ) save_message( conversation_req, user_id=user_id, tenant_id=tenant_id, @@ -906,6 +918,12 @@ def get_conversation_history_service(conversation_id: int, user_id: str) -> List message_item['clarification_response'] = response except (ValueError, TypeError): logger.warning("Skipping invalid clarification response message_id=%s", message_id) + elif unit.get('unit_type') == MESSAGE_ANNOTATIONS_UNIT_TYPE: + annotations = parse_message_annotation_unit(unit.get('unit_content')) + if annotations is not None: + message_item['annotations'] = [ + item.model_dump(mode="json") for item in annotations + ] # Add minio_files field (if any) if 'minio_files' in msg and msg['minio_files']: diff --git a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx index f962e3168a..33514b5f56 100644 --- a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx +++ b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx @@ -45,6 +45,7 @@ import { collapseRefreshUserMessages } from "./history-branching"; import log from "@/lib/logger"; import { createAssistantStream } from "assistant-stream"; import type { AttachmentType } from "../utils/attachment-type"; +import { fromMessageAnnotationWireItems } from "../utils/message-annotations"; import { attachExecutionLogsToTool, collapseSubAgentParts, @@ -1414,6 +1415,9 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { : {}), ...(historicalNl2a ? { nl2a: historicalNl2a } : {}), ...(stepTokenCounts.length > 0 ? { stepTokenCounts } : {}), + ...(msg.role === "user" && msg.annotations?.length + ? { annotations: fromMessageAnnotationWireItems(msg.annotations) } + : {}), ...(msg.role === "user" && typeof msg.message_index === "number" ? { historicalMessageIndex: msg.message_index } : {}), diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts index 505e536e7d..e029d00fcd 100644 --- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts @@ -46,6 +46,7 @@ import type { HumanInteractionEvent, } from "@/types/clarification"; import { extractMinioFiles } from "../utils/history-attachments"; +import { fromMessageAnnotationWireItems } from "../utils/message-annotations"; // Backend SSE chunk format interface ImageMetadata { @@ -310,6 +311,9 @@ interface NexentRunConfig { onRunId?: (runId: string) => void; onRunStarted?: () => void; onRunRejected?: (error: unknown, message?: ThreadMessage) => void; + // Structured reply annotations for the outgoing message; set when the + // composer keeps the library-managed send (in-flight attachment uploads). + messageAnnotations?: unknown; resume?: boolean; agentId?: number | string; agentVersionNo?: number; @@ -1538,6 +1542,25 @@ export const remoteChatModelAdapter: ChatModelAdapter = { if (!isResume && !isNl2Agent && clarificationResponse) { requestBody.clarification_response = clarificationResponse; } + // Reply annotations: prefer the structured metadata on the outgoing user + // message (manual append path and retries); fall back to the runConfig + // channel used when in-flight uploads keep the library submission flow + // in charge of the append. + const messageAnnotations = fromMessageAnnotationWireItems( + lastUserIndex >= 0 + ? ( + messages[lastUserIndex].metadata?.custom as + | { annotations?: unknown } + | undefined + )?.annotations + : undefined + ); + const annotations = messageAnnotations.length + ? messageAnnotations + : fromMessageAnnotationWireItems(custom?.messageAnnotations); + if (!isResume && !isNl2Agent && annotations.length) { + requestBody.annotations = annotations; + } const numericServerThreadId = Number(serverThreadId); const hasServerConversationId = Number.isInteger(numericServerThreadId) && numericServerThreadId > 0; @@ -1751,6 +1774,9 @@ export const remoteChatModelAdapter: ChatModelAdapter = { query: String(requestBody.query || ""), clarification_response: requestBody.clarification_response as ClarificationResponse | undefined, + annotations: requestBody.annotations as + | import("@/types/conversation").ApiMessageAnnotation[] + | undefined, history: (requestBody.history || []) as Array<{ role: string; content: string; diff --git a/frontend/app/[locale]/newchat/assistant-ui/composer.tsx b/frontend/app/[locale]/newchat/assistant-ui/composer.tsx index c466ad8dd1..a81c7bd012 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/composer.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/composer.tsx @@ -7,7 +7,6 @@ import { isReasoningEffort } from "@/lib/modelReasoning"; import { useMemo, - useRef, useState, useSyncExternalStore, type FC, @@ -42,6 +41,8 @@ import { ComposerPrimitive, useAui, useAuiState, + type CompleteAttachment, + type ThreadRuntime, } from "@assistant-ui/react"; import { LexicalComposerInput, @@ -85,7 +86,7 @@ import { import { ordinarySendError, protectOrdinarySend } from "../utils/ordinary-send"; import { ComposerAnnotationChip } from "./annotation-chip"; import { - formatAnnotationsIntoMessage, + toMessageAnnotationWireItems, type MessageAnnotationsControl, } from "../utils/message-annotations"; import { RuntimeMetadataEditor } from "@/components/chat/RuntimeMetadataEditor"; @@ -141,6 +142,23 @@ const KnowledgeBookIcon: FC<{ className?: string }> = ({ className }) => ( export type ChatMode = "planning" | "execution"; +/** + * Drops the annotation payload from the composer runConfig. + * + * The fallback send path (in-flight attachment uploads) parks the structured + * annotations on the composer runConfig because the library owns the append + * there. Once the run settles the payload must be cleared again so it cannot + * ride a later programmatic send that bypasses `prepareSend` (edit resends, + * clarification cards). + */ +function clearComposerMessageAnnotations(thread: ThreadRuntime) { + const config = thread.composer.getState().runConfig; + thread.composer.setRunConfig({ + ...config, + custom: { ...config.custom, messageAnnotations: undefined }, + }); +} + export interface ComposerProps { models: readonly ModelOption[]; selectedModelId?: string; @@ -338,15 +356,12 @@ export const Composer: FC = ({ const aui = useAui(); const runtime = aui.threads().__internal_getAssistantRuntime?.(); const [sendError, setSendError] = useState(""); - // Remembers the text produced by folding the pending annotations into the - // draft. The send button click and the form submit can both invoke - // prepareSend for one send; the guard keeps the fold idempotent. - const lastComposedAnnotationTextRef = useRef(null); - const prepareSend = () => { + const prepareSend = (event?: { preventDefault: () => void }) => { if (!runtime || runtime.thread.getState().isRunning) return; const threadId = runtime.threads.getState().mainThreadId; const thread = runtime.threads.getById(threadId); - const config = thread.composer.getState().runConfig; + const composerState = thread.composer.getState(); + const config = composerState.runConfig; setSendError(""); const pendingAnnotations = annotations?.annotations ?? []; const feedback = protectOrdinarySend(thread, { @@ -356,11 +371,12 @@ export const Composer: FC = ({ // with the restored draft) so the user can retry. onStarted: pendingAnnotations.length ? () => { - lastComposedAnnotationTextRef.current = null; annotations?.consumeAnnotations(); + clearComposerMessageAnnotations(thread); } : undefined, onRejected: (error) => { + clearComposerMessageAnnotations(thread); if (runtime.threads.getState().mainThreadId === threadId) { setSendError( ordinarySendError(error, i18n.language, (value) => @@ -370,32 +386,70 @@ export const Composer: FC = ({ } }, }); - // Fold the pending annotations into the outgoing text. `protectOrdinarySend` - // snapshots the draft before the fold, so a rejected run still restores the - // user's original text. - if (pendingAnnotations.length) { - const currentText = thread.composer.getState().text; - if (currentText !== lastComposedAnnotationTextRef.current) { - const composed = formatAnnotationsIntoMessage( - currentText, - pendingAnnotations, - { - quoteLabel: t("chat.annotation.quoteLabel"), - sourceLabel: t("chat.annotation.sourceLabel"), - commentLabel: t("chat.annotation.commentLabel"), - colon: t("chat.annotation.colon"), - } - ); - lastComposedAnnotationTextRef.current = composed; - thread.composer.setText(composed); - } - } else { - lastComposedAnnotationTextRef.current = null; + if (!pendingAnnotations.length) { + // Ordinary send: keep the library-managed composer submit untouched. + // The explicit undefined drops any annotation payload left on the + // composer runConfig by a previous fallback send. + thread.composer.setRunConfig({ + ...config, + custom: { ...config.custom, ...feedback, messageAnnotations: undefined }, + }); + return; } - thread.composer.setRunConfig({ - ...config, - custom: { ...config.custom, ...feedback }, + + // Reply annotations travel as a structured field alongside the clean + // composer text; the backend persists them as a unit and merges them + // into the model-facing text at request time. + const annotationItems = toMessageAnnotationWireItems(pendingAnnotations); + const attachmentsReady = composerState.attachments.every( + (attachment) => attachment.status.type === "complete" + ); + if (!attachmentsReady) { + // In-flight uploads must go through the library submission flow, + // which waits for uploads before appending; ride the annotations via + // the runConfig channel there (the optimistic bubble shows no cards + // until reload). + thread.composer.setRunConfig({ + ...config, + custom: { + ...config.custom, + ...feedback, + messageAnnotations: annotationItems, + }, + }); + return; + } + + // Take over the append so the optimistic message carries the structured + // annotation metadata (rendered as cards on the user bubble). Preventing + // the default keeps the library from appending a second message; the + // composer draft is cleared manually to match the library send behavior. + event?.preventDefault(); + if (composerState.dictation) { + thread.composer.stopDictation(); + } + const completeAttachments = composerState.attachments.filter( + (attachment): attachment is CompleteAttachment => + attachment.status.type === "complete" + ); + thread.append({ + role: "user", + content: composerState.text + ? [{ type: "text", text: composerState.text }] + : [], + attachments: completeAttachments, + metadata: { custom: { annotations: annotationItems } }, + runConfig: { + ...config, + custom: { + ...config.custom, + ...feedback, + messageAnnotations: annotationItems, + }, + }, }); + thread.composer.setText(""); + void thread.composer.clearAttachments(); }; const [skillPickerOpen, setSkillPickerOpen] = useState(false); const [conversationSkills, setConversationSkills] = useState< @@ -669,7 +723,7 @@ export const Composer: FC = ({ className="flex w-full flex-col gap-2.5 outline-none" onSubmit={(event) => { if (isRunning) event.preventDefault(); - else prepareSend(); + else prepareSend(event); }} onKeyDownCapture={(event) => { if (isRunning && event.key === "Enter" && !event.shiftKey) @@ -1015,7 +1069,7 @@ const WorkbenchPlanningMenu: FC<{ // Trigger can use `asChild` against the Button. `AuiIf` toggles between the // two branches declaratively based on `thread.isRunning`. const ComposerSendOrCancel: FC<{ - onSend: () => void; + onSend: (event?: { preventDefault: () => void }) => void; disabled?: boolean; hasAnnotations?: boolean; }> = ({ onSend, disabled, hasAnnotations = false }) => { diff --git a/frontend/app/[locale]/newchat/assistant-ui/message-annotation-cards.tsx b/frontend/app/[locale]/newchat/assistant-ui/message-annotation-cards.tsx new file mode 100644 index 0000000000..e71e230223 --- /dev/null +++ b/frontend/app/[locale]/newchat/assistant-ui/message-annotation-cards.tsx @@ -0,0 +1,91 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { memo, useMemo, type ComponentProps, type FC } from "react"; +import { useTranslation } from "react-i18next"; +import { useAuiState } from "@assistant-ui/react"; +import { MessageSquareQuote } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + fromMessageAnnotationWireItems, + type MessageAnnotationWireItem, +} from "../utils/message-annotations"; + +/** + * Read-only annotation cards rendered on a user message bubble. + * + * One card per attached reply annotation: the quoted excerpt, an optional + * comment line and the source reply excerpt. Data comes from the message's + * structured `metadata.custom.annotations` (set by the optimistic append on + * send and restored from the persisted history unit on reload), so the + * cards survive page refreshes just like the model-facing context does. + */ +export const MessageAnnotationCards: FC< + { + /** Overrides the message metadata (used by unit tests). */ + annotations?: readonly unknown[]; + } & ComponentProps<"div"> +> = memo(function MessageAnnotationCards({ + annotations: annotationsProp, + className, + ...props +}) { + const rawAnnotations = useAuiState((state) => + state.message?.role === "user" + ? ( + state.message.metadata as + { custom?: { annotations?: unknown[] } } | undefined + )?.custom?.annotations + : undefined, + ); + const items = useMemo( + () => fromMessageAnnotationWireItems(annotationsProp ?? rawAnnotations), + [annotationsProp, rawAnnotations], + ); + if (items.length === 0) return null; + + return ( +
    + {items.map((item) => ( + + ))} +
    + ); +}); + +const AnnotationCard: FC<{ item: MessageAnnotationWireItem }> = ({ item }) => { + const { t } = useTranslation(); + return ( +
    + +
    +

    {item.quoted_text}

    + {item.comment ? ( +

    + {t("chat.annotation.commentLabel")} + {t("chat.annotation.colon")} + {item.comment} +

    + ) : null} + {item.source_excerpt ? ( +

    + {t("chat.annotation.cardSourceLabel", { + excerpt: item.source_excerpt, + })} +

    + ) : null} +
    +
    + ); +}; diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 3a82d65907..be775c7539 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -76,8 +76,11 @@ import { type ConversationArtifact, } from "../utils/conversation-artifacts"; import { MessageAnnotationBubble } from "./message-annotation-bubble"; +import { MessageAnnotationCards } from "./message-annotation-cards"; import { createMessageAnnotation, + MESSAGE_ANNOTATION_COMMENT_MAX, + MESSAGE_ANNOTATION_MAX_COUNT, type MessageAnnotation, type MessageAnnotationDraft, type MessageAnnotationsControl, @@ -470,19 +473,37 @@ export const Thread: FC = ({ const addMessageAnnotation = useCallback( (draft: MessageAnnotationDraft) => { - setMessageAnnotations((current) => [ - ...current, - createMessageAnnotation(draft, messages, crypto.randomUUID()), - ]); + setMessageAnnotations((current) => { + // Cap matches the backend limit; the excess annotation would be + // silently dropped by the request-time normalization anyway. + if (current.length >= MESSAGE_ANNOTATION_MAX_COUNT) { + message.warning( + t("chat.annotation.maxCountReached", { + maxCount: MESSAGE_ANNOTATION_MAX_COUNT, + }) + ); + return current; + } + return [ + ...current, + createMessageAnnotation(draft, messages, crypto.randomUUID()), + ]; + }); }, - [messages] + // `message` is a module-level antd import, not a reactive value. + [messages, t] ); const updateMessageAnnotationComment = useCallback( (id: string, comment: string) => { setMessageAnnotations((current) => current.map((annotation) => annotation.id === id - ? { ...annotation, comment: comment || undefined } + ? { + ...annotation, + comment: + comment.trim().slice(0, MESSAGE_ANNOTATION_COMMENT_MAX) || + undefined, + } : annotation ) ); @@ -2037,6 +2058,9 @@ const UserMessage: FC<{
    + {/* Reply annotations ("add to chat") render as cards above the + message text; data rides the structured message metadata. */} + {(quote) => } diff --git a/frontend/app/[locale]/newchat/components/clarification-message-card.tsx b/frontend/app/[locale]/newchat/components/clarification-message-card.tsx index cce2d445e5..27034cfdf8 100644 --- a/frontend/app/[locale]/newchat/components/clarification-message-card.tsx +++ b/frontend/app/[locale]/newchat/components/clarification-message-card.tsx @@ -82,7 +82,16 @@ export function ClarificationMessageCard({ }, }, }, - runConfig: { ...config, custom: { ...config.custom, ...feedback } }, + runConfig: { + ...config, + custom: { + ...config.custom, + ...feedback, + // Clarification answers are their own message: reply annotations + // stay pending on the composer and must not ride this run. + messageAnnotations: undefined, + }, + }, }); }); }; diff --git a/frontend/app/[locale]/newchat/utils/message-annotations.ts b/frontend/app/[locale]/newchat/utils/message-annotations.ts index 564be7d40d..84099b7bd3 100644 --- a/frontend/app/[locale]/newchat/utils/message-annotations.ts +++ b/frontend/app/[locale]/newchat/utils/message-annotations.ts @@ -18,6 +18,11 @@ const SOURCE_EXCERPT_MAX_LENGTH = 40; /** Default truncation length for quoted text shown inside the chip list. */ const LIST_QUOTE_MAX_LENGTH = 80; +/** Defensive caps mirrored by the backend (see backend/contracts/annotations.py). */ +export const MESSAGE_ANNOTATION_QUOTED_TEXT_MAX = 1500; +export const MESSAGE_ANNOTATION_COMMENT_MAX = 500; +export const MESSAGE_ANNOTATION_MAX_COUNT = 10; + /** * Loose shape of an assistant-ui thread message as consumed by the * annotation helpers. Only the fields actually read are declared, mirroring @@ -76,16 +81,14 @@ export interface MessageAnnotationsControl { consumeAnnotations: () => void; } -/** Localized labels used when folding annotations into the outgoing text. */ -export interface AnnotationMessageLabels { - /** "Quote" label opening each annotation header. */ - quoteLabel: string; - /** Source template containing the `{{excerpt}}` placeholder. */ - sourceLabel: string; - /** Label prefixing the user comment line. */ - commentLabel: string; - /** Header terminator (full-width in Chinese, ": " in English). */ - colon: string; +/** + * Wire shape of one persisted reply annotation (snake_case, matching + * `backend/contracts/annotations.py::MessageAnnotationItem`). + */ +export interface MessageAnnotationWireItem { + quoted_text: string; + comment?: string | null; + source_excerpt?: string | null; } /** @@ -204,54 +207,72 @@ export function createMessageAnnotation( return { id, ...draft, - comment: draft.comment?.trim() || undefined, + // Defensive caps keep the structured payload inside the backend limits. + quotedText: draft.quotedText.slice(0, MESSAGE_ANNOTATION_QUOTED_TEXT_MAX), + comment: + draft.comment?.trim().slice(0, MESSAGE_ANNOTATION_COMMENT_MAX) || + undefined, sourceExcerpt: getAnnotationSourceExcerpt(messages, draft.sourceMessageId), }; } -function renderSourceLabel(template: string, excerpt: string): string { - return template.replace(/\{\{excerpt\}\}/g, excerpt); +/** + * Maps pending annotations onto the structured wire payload sent with the + * outgoing request (the backend persists it and merges it into the + * model-facing text at request time). + * + * @param annotations Pending annotations in creation order. + * @returns The snake_case wire items (length-capped by the caller). + */ +export function toMessageAnnotationWireItems( + annotations: readonly MessageAnnotation[] +): MessageAnnotationWireItem[] { + return annotations.slice(0, MESSAGE_ANNOTATION_MAX_COUNT).map((annotation) => ({ + quoted_text: annotation.quotedText.slice( + 0, + MESSAGE_ANNOTATION_QUOTED_TEXT_MAX + ), + comment: annotation.comment?.slice(0, MESSAGE_ANNOTATION_COMMENT_MAX) ?? null, + source_excerpt: annotation.sourceExcerpt || null, + })); } /** - * Folds pending annotations into the outgoing message text. + * Normalizes annotation payloads read back from persisted messages. * - * Each annotation becomes a numbered quote block; the user's own text follows - * behind a `---` separator so the model receives both the quoted reply - * excerpts (with the user's comments) and the follow-up question in one - * message. With no annotations the text is returned unchanged. + * Invalid entries are dropped and the defensive caps are re-applied so the + * user bubble renders sane cards even for legacy or oversized payloads. * - * @param text The user-typed composer text (may be empty). - * @param annotations Pending annotations in creation order. - * @param labels Localized labels for the composed blocks. - * @returns The composed outgoing message text. + * @param raw Value of the message's `annotations` field. + * @returns Validated wire items; empty when nothing usable remains. */ -export function formatAnnotationsIntoMessage( - text: string, - annotations: readonly MessageAnnotation[], - labels: AnnotationMessageLabels -): string { - if (annotations.length === 0) return text; - - const blocks = annotations.map((annotation, index) => { - const source = annotation.sourceExcerpt - ? renderSourceLabel(labels.sourceLabel, annotation.sourceExcerpt) - : ""; - const lines = [ - // trimEnd drops the trailing blank of the English ": " colon. - `${index + 1}. ${labels.quoteLabel}${source}${labels.colon}`.trimEnd(), - ]; - for (const line of annotation.quotedText.split("\n")) { - lines.push(`> ${line}`); - } - if (annotation.comment) { - lines.push(`${labels.commentLabel}${labels.colon}${annotation.comment}`); - } - return lines.join("\n"); - }); - - const quoteBlock = blocks.join("\n\n"); - return text ? `${quoteBlock}\n\n---\n\n${text}` : quoteBlock; +export function fromMessageAnnotationWireItems( + raw: unknown +): MessageAnnotationWireItem[] { + if (!Array.isArray(raw)) return []; + const items: MessageAnnotationWireItem[] = []; + for (const entry of raw) { + if (typeof entry !== "object" || entry === null) continue; + const record = entry as Record; + const quotedText = + typeof record.quoted_text === "string" ? record.quoted_text.trim() : ""; + if (!quotedText) continue; + const comment = + typeof record.comment === "string" ? record.comment.trim() : ""; + const sourceExcerpt = + typeof record.source_excerpt === "string" + ? record.source_excerpt.trim() + : ""; + items.push({ + quoted_text: quotedText.slice(0, MESSAGE_ANNOTATION_QUOTED_TEXT_MAX), + comment: comment ? comment.slice(0, MESSAGE_ANNOTATION_COMMENT_MAX) : null, + source_excerpt: sourceExcerpt + ? sourceExcerpt.slice(0, MESSAGE_ANNOTATION_QUOTED_TEXT_MAX) + : null, + }); + if (items.length >= MESSAGE_ANNOTATION_MAX_COUNT) break; + } + return items; } /** diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 52b88515e2..1ce218d7b2 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -503,9 +503,9 @@ "chat.annotation.clearAnnotations": "Clear all annotations", "chat.annotation.saveComment": "Save comment", "chat.annotation.cancelEdit": "Cancel editing", - "chat.annotation.quoteLabel": "Quote", - "chat.annotation.sourceLabel": " (from reply \"{{excerpt}}\")", "chat.annotation.colon": ": ", + "chat.annotation.cardSourceLabel": "From reply \"{{excerpt}}\"", + "chat.annotation.maxCountReached": "Up to {{maxCount}} annotations can be attached", "chat.artifacts.generatedAt": "Generated {{time}}", "chat.tokenUsage.used": "used", "chat.tokenUsage.turn": "this turn", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 1ec54d0ae5..f151c0c5fa 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -500,9 +500,9 @@ "chat.annotation.clearAnnotations": "清空全部批注", "chat.annotation.saveComment": "保存批注", "chat.annotation.cancelEdit": "取消编辑", - "chat.annotation.quoteLabel": "引用", - "chat.annotation.sourceLabel": "(来自回复「{{excerpt}}」)", "chat.annotation.colon": ":", + "chat.annotation.cardSourceLabel": "来自回复「{{excerpt}}」", + "chat.annotation.maxCountReached": "最多添加 {{maxCount}} 条批注", "chat.artifacts.generatedAt": "生成于 {{time}}", "chat.tokenUsage.used": "已使用", "chat.tokenUsage.turn": "本轮", diff --git a/frontend/services/conversationService.ts b/frontend/services/conversationService.ts index f2f51cda74..412ffcfee9 100644 --- a/frontend/services/conversationService.ts +++ b/frontend/services/conversationService.ts @@ -7,6 +7,7 @@ import { API_ENDPOINTS, ApiError } from "./api"; import { chatConfig } from "@/const/chatConfig"; import type { + ApiMessageAnnotation, ConversationListResponse, ConversationListPage, ConversationListParams, @@ -1177,6 +1178,7 @@ export const conversationService = { params: { query: string; clarification_response?: ClarificationResponse; + annotations?: ApiMessageAnnotation[]; conversation_id?: number; history: Array<{ role: string; content: string }>; files?: File[]; @@ -1245,6 +1247,13 @@ export const conversationService = { ) { requestParams.clarification_response = params.clarification_response; } + if ( + !params.is_resume && + !params.runtime_mode && + params.annotations?.length + ) { + requestParams.annotations = params.annotations; + } if (params.runtime_mode === "nl2skill") { requestParams.draft_snapshot = params.draft_snapshot; requestParams.language = params.language; diff --git a/frontend/tests/component/messageAnnotation.test.tsx b/frontend/tests/component/messageAnnotation.test.tsx index 2d8c8ad5c6..1163f0c95b 100644 --- a/frontend/tests/component/messageAnnotation.test.tsx +++ b/frontend/tests/component/messageAnnotation.test.tsx @@ -11,14 +11,18 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { createMessageAnnotation, - formatAnnotationsIntoMessage, + fromMessageAnnotationWireItems, getAnnotationSourceExcerpt, + MESSAGE_ANNOTATION_COMMENT_MAX, + MESSAGE_ANNOTATION_MAX_COUNT, + MESSAGE_ANNOTATION_QUOTED_TEXT_MAX, resolveAnnotationSelection, + toMessageAnnotationWireItems, truncateAnnotationText, - type AnnotationMessageLabels, type MessageAnnotation, } from "@/app/newchat/utils/message-annotations"; import { ComposerAnnotationChip } from "@/app/newchat/assistant-ui/annotation-chip"; +import { MessageAnnotationCards } from "@/app/newchat/assistant-ui/message-annotation-cards"; vi.mock("react-i18next", () => ({ useTranslation: () => ({ @@ -26,12 +30,10 @@ vi.mock("react-i18next", () => ({ }), })); -const LABELS: AnnotationMessageLabels = { - quoteLabel: "Quote", - sourceLabel: ' (from reply "{{excerpt}}")', - commentLabel: "Comment", - colon: ": ", -}; +vi.mock("@assistant-ui/react", () => ({ + useAuiState: (selector: (state: unknown) => unknown) => + selector({ message: { role: "user" } }), +})); const makeAnnotation = ( overrides: Partial = {} @@ -46,62 +48,78 @@ const makeAnnotation = ( afterEach(cleanup); -describe("formatAnnotationsIntoMessage", () => { - test("returns the text unchanged without annotations", () => { - expect(formatAnnotationsIntoMessage("why stop?", [], LABELS)).toBe( - "why stop?" - ); - expect(formatAnnotationsIntoMessage("", [], LABELS)).toBe(""); - }); +describe("toMessageAnnotationWireItems", () => { + test("maps camelCase annotations onto the snake_case wire shape", () => { + const items = toMessageAnnotationWireItems([ + makeAnnotation({ comment: "note", sourceExcerpt: "reply intro" }), + makeAnnotation({ id: "a2", quotedText: "second", comment: undefined, sourceExcerpt: "" }), + ]); - test("composes a single annotation with comment and source excerpt", () => { - const result = formatAnnotationsIntoMessage( - "why stop?", - [ - makeAnnotation({ - quotedText: "line one\nline two", - comment: "skip this", - sourceExcerpt: "reply intro", - }), - ], - LABELS - ); + expect(items).toEqual([ + { + quoted_text: "quoted reply text", + comment: "note", + source_excerpt: "reply intro", + }, + { quoted_text: "second", comment: null, source_excerpt: null }, + ]); + }); - expect(result).toBe( - '1. Quote (from reply "reply intro"):\n> line one\n> line two\nComment: skip this\n\n---\n\nwhy stop?' + test("caps the list length and truncates oversized fields", () => { + const many = Array.from({ length: MESSAGE_ANNOTATION_MAX_COUNT + 3 }, (_, i) => + makeAnnotation({ id: `a${i}`, quotedText: `${i}` }) ); + const items = toMessageAnnotationWireItems(many); + expect(items).toHaveLength(MESSAGE_ANNOTATION_MAX_COUNT); + + const oversized = toMessageAnnotationWireItems([ + makeAnnotation({ + quotedText: "q".repeat(MESSAGE_ANNOTATION_QUOTED_TEXT_MAX + 10), + comment: "c".repeat(MESSAGE_ANNOTATION_COMMENT_MAX + 10), + }), + ]); + expect(oversized[0].quoted_text).toHaveLength(MESSAGE_ANNOTATION_QUOTED_TEXT_MAX); + expect(oversized[0].comment).toHaveLength(MESSAGE_ANNOTATION_COMMENT_MAX); }); +}); - test("composes multiple annotations, skipping empty comments and excerpts", () => { - const result = formatAnnotationsIntoMessage( - "follow-up", - [ - makeAnnotation({ id: "a1", comment: "note" }), - makeAnnotation({ - id: "a2", - quotedText: "second", - comment: undefined, - sourceExcerpt: "", - }), - ], - LABELS - ); +describe("fromMessageAnnotationWireItems", () => { + test("validates entries and drops unusable ones", () => { + const items = fromMessageAnnotationWireItems([ + { quoted_text: "one", comment: "note", source_excerpt: "intro" }, + { quoted_text: " " }, + { quoted_text: "" }, + { comment: "no quote" }, + "not-an-object", + ]); + + expect(items).toEqual([ + { quoted_text: "one", comment: "note", source_excerpt: "intro" }, + ]); + }); - expect(result).toBe( - '1. Quote (from reply "reply intro"):\n> quoted reply text\nComment: note\n\n2. Quote:\n> second\n\n---\n\nfollow-up' - ); + test("returns an empty list for non-array payloads", () => { + expect(fromMessageAnnotationWireItems(undefined)).toEqual([]); + expect(fromMessageAnnotationWireItems(null)).toEqual([]); + expect(fromMessageAnnotationWireItems({ quoted_text: "x" })).toEqual([]); }); - test("composes annotation-only sends without the user separator", () => { - const result = formatAnnotationsIntoMessage( - "", - [makeAnnotation({ comment: undefined })], - LABELS - ); + test("re-applies the defensive caps for oversized persisted payloads", () => { + const items = fromMessageAnnotationWireItems([ + { + quoted_text: "q".repeat(MESSAGE_ANNOTATION_QUOTED_TEXT_MAX + 5), + comment: null, + source_excerpt: null, + }, + ]); + expect(items[0].quoted_text).toHaveLength(MESSAGE_ANNOTATION_QUOTED_TEXT_MAX); + }); - expect(result).toBe( - '1. Quote (from reply "reply intro"):\n> quoted reply text' - ); + test("round-trips payloads produced by the outgoing mapping", () => { + const outgoing = toMessageAnnotationWireItems([ + makeAnnotation({ comment: "note" }), + ]); + expect(fromMessageAnnotationWireItems(outgoing)).toEqual(outgoing); }); }); @@ -209,6 +227,21 @@ describe("createMessageAnnotation", () => { expect(annotation.comment).toBeUndefined(); }); + + test("applies the defensive caps to oversized quotes and comments", () => { + const annotation = createMessageAnnotation( + { + quotedText: "q".repeat(MESSAGE_ANNOTATION_QUOTED_TEXT_MAX + 5), + comment: "c".repeat(MESSAGE_ANNOTATION_COMMENT_MAX + 5), + sourceMessageId: "assistant-1", + }, + [], + "generated-id" + ); + + expect(annotation.quotedText).toHaveLength(MESSAGE_ANNOTATION_QUOTED_TEXT_MAX); + expect(annotation.comment).toHaveLength(MESSAGE_ANNOTATION_COMMENT_MAX); + }); }); describe("resolveAnnotationSelection", () => { @@ -470,3 +503,45 @@ describe("ComposerAnnotationChip", () => { ).toBeGreaterThan(0); }); }); + +describe("MessageAnnotationCards", () => { + test("renders nothing without annotations", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + test("renders one card per annotation with quote, comment and source", () => { + const { container } = render( + + ); + + const cards = container.querySelectorAll( + "[data-slot='aui_message-annotation-card']" + ); + expect(cards).toHaveLength(2); + expect(screen.getByText("first quote")).toBeTruthy(); + expect(screen.getByText(/first note/)).toBeTruthy(); + expect(screen.getByText(/chat.annotation.cardSourceLabel/)).toBeTruthy(); + expect(screen.getByText("second quote")).toBeTruthy(); + }); + + test("drops invalid entries instead of rendering broken cards", () => { + const { container } = render( + + ); + + expect( + container.querySelectorAll("[data-slot='aui_message-annotation-card']") + ).toHaveLength(1); + expect(screen.getByText("valid")).toBeTruthy(); + }); +}); diff --git a/frontend/types/conversation.ts b/frontend/types/conversation.ts index 5d0a906579..bb1f7627c0 100644 --- a/frontend/types/conversation.ts +++ b/frontend/types/conversation.ts @@ -100,8 +100,15 @@ export interface ApiMessageItem { depth?: number; } +export interface ApiMessageAnnotation { + quoted_text: string; + comment?: string | null; + source_excerpt?: string | null; +} + export interface ApiMessage { clarification_response?: unknown; + annotations?: ApiMessageAnnotation[]; status?: "pending" | "streaming" | "completed" | "failed" | "stopped"; role: "user" | "assistant"; message: ApiMessageItem[] | string; diff --git a/test-e2e/changes/requirements/ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml b/test-e2e/changes/requirements/ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml new file mode 100644 index 0000000000..0334d39a23 --- /dev/null +++ b/test-e2e/changes/requirements/ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml @@ -0,0 +1,98 @@ +schema_version: "1.0" +change: + change_id: ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102 + change_type: requirement + requirement_id: ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102 + title: 回复选中批注结构化传输(LibreChat 式) + description: + 'Upgrades the reply-annotation ("add to chat") channel from frontend-only + text folding to a structured implementation. Pending annotations are now + sent as AgentRequest.annotations (snake_case wire items + quoted_text/comment/source_excerpt, capped at 10 entries / 1500 / 500 / + 120 chars, truncated rather than rejected) alongside the clean composer + text. The backend persists them as a message_annotations unit on the user + message (mirroring clarification_response) and merges the numbered quote + blocks into the model-facing text at request time: the current turn in + prepare_agent_run (create_run query kwarg) and past turns in + get_historical_context, while the stored message_content stays clean. + History reads return the structured annotations field. On the frontend, + a send with annotations and no in-flight uploads takes over the append + (thread.append) so the optimistic user message carries the annotations as + message metadata and renders read-only reference cards (quoted text, + optional comment, source reply excerpt) above the message text; cards are + restored from history after reload. With in-flight uploads the send rides + the library submission flow and carries annotations via the composer + runConfig channel (no optimistic cards until reload). Ordinary sends + without annotations are untouched, rejected sends keep the annotations + and restore the draft, and stale runConfig payloads are cleared so they + cannot leak into clarification sends or edit resends.' + affected_features: + existing: [] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + test_selectors: + - frontend/tests/component/messageAnnotation.test.tsx + - test/backend/services/test_message_annotations.py + - test/backend/database/test_conversation_db.py + behaviors: + - toMessageAnnotationWireItems maps camelCase annotations onto the + snake_case wire shape, caps the list at the max count and truncates + oversized quoted text/comment fields. + - fromMessageAnnotationWireItems validates entries (dropping unusable + ones), returns an empty list for non-array payloads, re-applies the + defensive caps and round-trips the outgoing mapping. + - createMessageAnnotation drops whitespace-only comments and applies + the defensive caps; adding beyond the max count is rejected in the + Thread with a localized warning. + - MessageAnnotationCards renders one read-only card per annotation + (quote, comment, source excerpt) and drops invalid entries. + - normalize_message_annotations validates, trims and drops unusable + entries, rejects non-list payloads, returns None when nothing usable + remains, and truncates oversized fields instead of rejecting them. + - format_annotations_for_llm returns the text unchanged without + annotations, composes numbered quote blocks ("> " lines, optional + comment, source excerpt header), joins multiple blocks, and omits + the "---" separator for annotation-only sends. + - parse_message_annotation_unit round-trips persisted payloads and + rejects malformed/empty ones. + - AgentRequest retains structured annotations; save_conversation_user + passes the normalized payload to the atomic message save. + - get_conversation_history returns the annotations field without + replacing the readable message text (malformed units are skipped). + - create_conversation_message persists annotations as a + message_annotations unit for user rows only (assistant rows skip the + unit); historical turns merge the annotation units into the + model-facing user_message while deleted units are ignored. + - test_conversation_db.py get_historical_context mock sequence updated + for the additional annotation-unit query (regression guard for the + existing summary/boundary assertions). + reason: |- + The upgrade spans the request payload (additive annotations field), + unit persistence, prompt construction and the user bubble, all covered + by targeted UT at their owning layers: frontend component/util tests + for the wire mapping, caps, takeover send and card rendering; backend + tests for contract normalization/formatting, service passthrough, + history projection and repository persistence/merge against isolated + SQLite rows. The model-facing merge is invisible to external protocol + boundaries and the northbound API does not expose the field, so no + formal Case is added. + notes: | + Supersedes the frontend-only folding implementation recorded in + ADD-TO-CHAT-MESSAGE-ANNOTATIONS-20261102: the fold function, its labels + (quoteLabel/sourceLabel) and related tests were removed; formatting now + happens in the backend (fixed English labels) so the stored user text + stays clean. Manual takeover uses the library's own + composeEventHandlers(preventDefault) contract; the fallback path parks + the payload on the composer runConfig and clears it once the run settles + (ordinary sends, clarification cards and edit resends cannot carry stale + annotations). The composeSend double-invocation guard of the fold era is + obsolete because the manual append is idempotent. Northbound API does + not transparently expose annotations; resume runs do not carry them. diff --git a/test/backend/database/test_conversation_db.py b/test/backend/database/test_conversation_db.py index f5bed85b41..9fdc9b32fe 100644 --- a/test/backend/database/test_conversation_db.py +++ b/test/backend/database/test_conversation_db.py @@ -3640,8 +3640,12 @@ def test_get_historical_context_returns_latest_summary_and_only_new_turns( SimpleNamespace(message_id=32, message_index=5, message_role="assistant", message_content="new answer", minio_files=None, status="completed"), ] + # Reply-annotation units for the historical user messages (none here). + annotations_result = MagicMock() + annotations_result.all.return_value = [] session.execute.side_effect = [ - current_result, candidates_result, boundary_result, messages_result] + current_result, candidates_result, boundary_result, messages_result, + annotations_result] monkeypatch.setattr("management.db.conversation.repository.get_db_session", lambda: ctx) result = get_historical_context(1, 25, "user-a", "tenant-a") diff --git a/test/backend/services/test_message_annotations.py b/test/backend/services/test_message_annotations.py new file mode 100644 index 0000000000..40cd2a0a6e --- /dev/null +++ b/test/backend/services/test_message_annotations.py @@ -0,0 +1,306 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Structured reply annotations: contracts, persistence and history merging.""" + +import importlib +import json +from contextlib import contextmanager +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from contracts.annotations import ( + ANNOTATION_COMMENT_MAX_CHARS, + ANNOTATION_MAX_COUNT, + ANNOTATION_QUOTED_TEXT_MAX_CHARS, + ANNOTATION_SOURCE_EXCERPT_MAX_CHARS, + format_annotations_for_llm, + normalize_message_annotations, + parse_message_annotation_unit, +) +from contracts.agent import AgentRequest + +ANNOTATION = { + 'quoted_text': 'quoted reply text', + 'comment': 'note', + 'source_excerpt': 'reply intro', +} + + +def test_normalize_validates_trims_and_drops_unusable_entries(): + annotations = normalize_message_annotations([ + ANNOTATION, + {'quoted_text': ' '}, + {'quoted_text': ''}, + {'comment': 'no quote'}, + 'not-a-dict', + {'quoted_text': 'keep', 'comment': ' ', 'source_excerpt': ' '}, + ]) + + assert annotations is not None + assert [item.quoted_text for item in annotations] == [ + 'quoted reply text', 'keep', + ] + assert annotations[1].comment is None + assert annotations[1].source_excerpt is None + + +@pytest.mark.parametrize('raw', [None, 'text', {}, 42]) +def test_normalize_rejects_non_list_payloads(raw): + assert normalize_message_annotations(raw) is None + + +def test_normalize_returns_none_when_nothing_usable_remains(): + assert normalize_message_annotations([{'quoted_text': ' '}]) is None + + +def test_normalize_applies_defensive_caps(): + oversized = { + 'quoted_text': 'q' * (ANNOTATION_QUOTED_TEXT_MAX_CHARS + 10), + 'comment': 'c' * (ANNOTATION_COMMENT_MAX_CHARS + 10), + 'source_excerpt': 's' * (ANNOTATION_SOURCE_EXCERPT_MAX_CHARS + 10), + } + + truncated = normalize_message_annotations([oversized]) + + assert truncated is not None + assert len(truncated[0].quoted_text) == ANNOTATION_QUOTED_TEXT_MAX_CHARS + assert len(truncated[0].comment) == ANNOTATION_COMMENT_MAX_CHARS + assert len(truncated[0].source_excerpt) == ANNOTATION_SOURCE_EXCERPT_MAX_CHARS + + many = [dict(oversized, quoted_text=f'quote {index}') + for index in range(ANNOTATION_MAX_COUNT + 3)] + capped = normalize_message_annotations(many) + + assert capped is not None + assert len(capped) == ANNOTATION_MAX_COUNT + + +def test_format_without_annotations_returns_text_unchanged(): + assert format_annotations_for_llm('why stop?', None) == 'why stop?' + assert format_annotations_for_llm('why stop?', []) == 'why stop?' + + +def test_format_merges_blocks_comment_and_source_into_user_text(): + annotations = normalize_message_annotations([ + {'quoted_text': 'line one\nline two', 'comment': 'skip this', + 'source_excerpt': 'reply intro'}, + ]) + + merged = format_annotations_for_llm('why stop?', annotations) + + assert merged == ('1. Quote (from reply "reply intro"):\n' + '> line one\n> line two\n' + 'Comment: skip this\n\n---\n\nwhy stop?') + + +def test_format_joins_multiple_blocks_and_skips_empty_fields(): + annotations = normalize_message_annotations([ + {'quoted_text': 'first', 'comment': 'note'}, + {'quoted_text': 'second'}, + ]) + + merged = format_annotations_for_llm('follow-up', annotations) + + assert merged == ('1. Quote:\n> first\nComment: note\n\n' + '2. Quote:\n> second\n\n---\n\nfollow-up') + + +def test_format_annotation_only_send_keeps_blocks_without_separator(): + annotations = normalize_message_annotations([{'quoted_text': 'only quote'}]) + + assert format_annotations_for_llm('', annotations) == '1. Quote:\n> only quote' + + +def test_parse_unit_round_trips_and_rejects_invalid_payloads(): + annotations = normalize_message_annotations([ANNOTATION]) + payload = json.dumps([item.model_dump(mode='json') for item in annotations]) + + parsed = parse_message_annotation_unit(payload) + assert parsed is not None and len(parsed) == 1 + assert parsed[0].quoted_text == 'quoted reply text' + + assert parse_message_annotation_unit(None) is None + assert parse_message_annotation_unit('{malformed') is None + assert parse_message_annotation_unit(json.dumps([{'quoted_text': ''}])) is None + + +def test_agent_request_retains_structured_annotations(): + request = AgentRequest(query='why stop?', conversation_id=1, annotations=[ANNOTATION]) + + assert request.annotations[0].comment == 'note' + dumped = request.model_dump() + assert dumped['annotations'][0]['source_excerpt'] == 'reply intro' + + +def test_service_passes_annotations_to_atomic_message_save(monkeypatch): + from management.services.conversation import service + + captured = [] + monkeypatch.setattr( + service, 'create_conversation_message', + lambda data, *args, **kwargs: captured.append(data) or 20, + ) + request = AgentRequest(query='why stop?', conversation_id=1, history=[], + annotations=[ANNOTATION, {'quoted_text': ' '}]) + service.save_conversation_user(request, 'owner', 'tenant') + + assert captured[0]['content'] == 'why stop?' + # The unusable second entry is dropped by normalization. + assert captured[0]['annotations'] == [ANNOTATION] + + +def test_history_returns_annotations_without_replacing_readable_text(monkeypatch): + from management.services.conversation import service + + annotations = normalize_message_annotations([ANNOTATION]) + payload = json.dumps([item.model_dump(mode='json') for item in annotations]) + record = {'message_id': 20, 'message_index': 2, 'role': 'user', + 'message_content': 'why stop?', 'status': 'completed', + 'units': [ + {'unit_type': 'message_annotations', 'unit_content': payload}, + {'unit_type': 'message_annotations', 'unit_content': '{bad'}, + ], + 'minio_files': []} + history = {'conversation_id': 1, 'create_time': 0, 'message_records': [record], + 'search_records': [], 'image_records': []} + monkeypatch.setattr(service, 'get_conversation_history', lambda *args: history) + + item = service.get_conversation_history_service(1, 'owner')[0]['message'][0] + + assert item['message'] == 'why stop?' + assert item['annotations'] == [ANNOTATION] + + +@pytest.fixture +def message_db(monkeypatch): + monkeypatch.setattr( + 'nexent.storage.storage_client_factory.create_storage_client_from_config', + lambda *_: MagicMock(), + ) + module = importlib.import_module('management.db.conversation.repository') + engine = create_engine('sqlite://', poolclass=StaticPool) + with engine.begin() as connection: + connection.exec_driver_sql("ATTACH DATABASE ':memory:' AS nexent") + connection.exec_driver_sql( + 'CREATE TABLE nexent.conversation_record_t ' + '(conversation_id INTEGER PRIMARY KEY, created_by TEXT, delete_flag TEXT)') + connection.exec_driver_sql( + 'CREATE TABLE nexent.conversation_message_t ' + '(message_id INTEGER PRIMARY KEY, conversation_id INTEGER, message_index INTEGER, ' + 'message_role TEXT, message_source TEXT, message_content TEXT, minio_files TEXT, ' + 'opinion_flag TEXT, status TEXT, delete_flag TEXT, created_by TEXT, updated_by TEXT, ' + 'create_time DATETIME, update_time DATETIME)') + connection.exec_driver_sql( + 'CREATE TABLE nexent.conversation_message_unit_t ' + '(unit_id INTEGER PRIMARY KEY, conversation_id INTEGER, message_id INTEGER, ' + 'unit_index INTEGER, unit_type TEXT, unit_content TEXT, unit_status TEXT, ' + 'delete_flag TEXT, created_by TEXT, updated_by TEXT, create_time DATETIME, ' + 'update_time DATETIME)') + connection.exec_driver_sql("INSERT INTO nexent.conversation_record_t VALUES (1, 'owner', 'N')") + + monkeypatch.setattr(module, 'get_db_session', + _session_factory(engine)) + monkeypatch.setattr(module, '_get_user_tenant', lambda _: {'tenant_id': 'tenant'}) + monkeypatch.setattr(module, '_enforce_conversation_turn_limit', lambda **kwargs: None) + monkeypatch.setattr(module, '_touch_conversation_activity', lambda *args: None) + + def insert_message(message_id, index, role, content, status='completed', deleted='N'): + with engine.begin() as connection: + connection.execute(text( + 'INSERT INTO nexent.conversation_message_t ' + '(message_id, conversation_id, message_index, message_role, ' + 'message_content, status, delete_flag) ' + 'VALUES (:id, 1, :idx, :role, :body, :status, :deleted)'), + {'id': message_id, 'idx': index, 'role': role, 'body': content, + 'status': status, 'deleted': deleted}) + + def insert_unit(unit_id, message_id, unit_type, content, status='completed', deleted='N'): + with engine.begin() as connection: + connection.execute(text( + 'INSERT INTO nexent.conversation_message_unit_t ' + '(unit_id, message_id, conversation_id, unit_index, unit_type, ' + 'unit_content, unit_status, delete_flag) ' + 'VALUES (:id, :mid, 1, :idx, :kind, :body, :status, :deleted)'), + {'id': unit_id, 'mid': message_id, 'idx': unit_id, 'kind': unit_type, + 'body': content, 'status': status, 'deleted': deleted}) + + yield module, insert_message, insert_unit, engine + engine.dispose() + + +def _session_factory(engine): + @contextmanager + def session(): + with Session(engine) as current, current.begin(): + yield current + + return session + + +def test_create_message_persists_annotations_as_structured_unit(message_db): + module, _, _, engine = message_db + + message_id = module.create_conversation_message({ + 'conversation_id': 1, 'message_idx': 0, 'role': 'user', + 'content': 'why stop?', 'annotations': [ANNOTATION], + }, 'owner') + + with engine.begin() as connection: + rows = connection.execute(text( + 'SELECT unit_type, unit_content FROM nexent.conversation_message_unit_t ' + 'WHERE message_id = :mid'), {'mid': message_id}).fetchall() + assert len(rows) == 1 + assert rows[0][0] == 'message_annotations' + assert json.loads(rows[0][1]) == [ANNOTATION] + + +def test_create_message_skips_annotation_unit_for_assistant_rows(message_db): + module, _, _, engine = message_db + + message_id = module.create_conversation_message({ + 'conversation_id': 1, 'message_idx': 1, 'role': 'assistant', + 'content': 'answer', 'annotations': [ANNOTATION], + }, 'owner') + + with engine.begin() as connection: + count = connection.execute(text( + 'SELECT COUNT(*) FROM nexent.conversation_message_unit_t ' + 'WHERE message_id = :mid'), {'mid': message_id}).scalar() + assert count == 0 + + +def test_historical_turns_merge_annotations_into_model_facing_text(message_db): + module, insert_message, insert_unit, _ = message_db + annotations = normalize_message_annotations([ANNOTATION]) + payload = json.dumps([item.model_dump(mode='json') for item in annotations]) + + insert_message(1, 0, 'user', 'why stop?') + insert_message(2, 1, 'assistant', 'because of the quota') + insert_unit(1, 1, 'message_annotations', payload) + insert_message(3, 2, 'user', 'then raise it') + + turns = module.get_historical_context(1, 3, 'owner', 'tenant')['conversation_turns'] + + assert len(turns) == 1 + assert turns[0]['user_message'] == ( + '1. Quote (from reply "reply intro"):\n' + '> quoted reply text\n' + 'Comment: note\n\n---\n\nwhy stop?' + ) + assert turns[0]['assistant_final_answer'] == 'because of the quota' + + +def test_historical_turns_ignore_deleted_annotation_units(message_db): + module, insert_message, insert_unit, _ = message_db + insert_message(1, 0, 'user', 'why stop?') + insert_message(2, 1, 'assistant', 'because of the quota') + insert_unit(1, 1, 'message_annotations', json.dumps([ANNOTATION]), deleted='Y') + insert_message(3, 2, 'user', 'then raise it') + + turns = module.get_historical_context(1, 3, 'owner', 'tenant')['conversation_turns'] + + assert turns[0]['user_message'] == 'why stop?' From 3aa0ad18e2b44068b151da40410ba0a3c1a5b3f3 Mon Sep 17 00:00:00 2001 From: ljy Date: Tue, 3 Nov 2026 11:31:57 +0800 Subject: [PATCH 08/26] feat(chat): rename the selection bubble action to add annotation --- frontend/public/locales/en/common.json | 2 +- frontend/public/locales/zh/common.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 1ce218d7b2..e7802a8b26 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -488,7 +488,7 @@ "chat.questions.jump": "Jump to question {{index}}", "chat.questions.search": "Search questions…", "chat.questions.noMatches": "No matching questions", - "chat.annotation.add": "Add to chat", + "chat.annotation.add": "Add annotation", "chat.annotation.commentPlaceholder": "Add an optional comment...", "chat.annotation.editPlaceholder": "Edit comment...", "chat.annotation.confirm": "Add annotation", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index f151c0c5fa..75e6362924 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -486,7 +486,7 @@ "chat.questions.jump": "跳转到问题 {{index}}", "chat.questions.search": "搜索问题…", "chat.questions.noMatches": "没有匹配的问题", - "chat.annotation.add": "添加到对话", + "chat.annotation.add": "添加批注", "chat.annotation.commentPlaceholder": "添加可选批注…", "chat.annotation.editPlaceholder": "编辑批注…", "chat.annotation.confirm": "添加批注", From 3bd2186620e8933e7d3dd0cd5b2b7187598e7ab6 Mon Sep 17 00:00:00 2001 From: ljy Date: Tue, 3 Nov 2026 16:30:03 +0800 Subject: [PATCH 09/26] fix(chat): send annotation-only turns past the adapter query guard --- .../adapter/remote-chat-model-adapter.ts | 39 +++++++++++-------- ...-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml | 8 +++- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts index e029d00fcd..ed17e2470d 100644 --- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts @@ -1464,7 +1464,26 @@ export const remoteChatModelAdapter: ChatModelAdapter = { ? JSON.stringify(structuredNl2AgentInput) : visibleQuery; - if (!isResume && !query) { + // Reply annotations: prefer the structured metadata on the outgoing user + // message (manual append path and retries); fall back to the runConfig + // channel used when in-flight uploads keep the library submission flow + // in charge of the append. Resolved before the empty-query guard because + // an annotation-only send (quoted reply, no typed text) carries an empty + // query; the backend merges the quotes into the model-facing text. + const messageAnnotations = fromMessageAnnotationWireItems( + lastUserIndex >= 0 + ? ( + messages[lastUserIndex].metadata?.custom as + | { annotations?: unknown } + | undefined + )?.annotations + : undefined + ); + const annotations = messageAnnotations.length + ? messageAnnotations + : fromMessageAnnotationWireItems(custom?.messageAnnotations); + + if (!isResume && !query && !annotations.length) { log.warn("[ChatModelAdapter] No user query found in messages"); return; } @@ -1542,22 +1561,8 @@ export const remoteChatModelAdapter: ChatModelAdapter = { if (!isResume && !isNl2Agent && clarificationResponse) { requestBody.clarification_response = clarificationResponse; } - // Reply annotations: prefer the structured metadata on the outgoing user - // message (manual append path and retries); fall back to the runConfig - // channel used when in-flight uploads keep the library submission flow - // in charge of the append. - const messageAnnotations = fromMessageAnnotationWireItems( - lastUserIndex >= 0 - ? ( - messages[lastUserIndex].metadata?.custom as - | { annotations?: unknown } - | undefined - )?.annotations - : undefined - ); - const annotations = messageAnnotations.length - ? messageAnnotations - : fromMessageAnnotationWireItems(custom?.messageAnnotations); + // Reply annotations travel as a structured request field; an + // annotation-only send keeps its empty query (see the guard above). if (!isResume && !isNl2Agent && annotations.length) { requestBody.annotations = annotations; } diff --git a/test-e2e/changes/requirements/ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml b/test-e2e/changes/requirements/ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml index 0334d39a23..52bfe40812 100644 --- a/test-e2e/changes/requirements/ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml +++ b/test-e2e/changes/requirements/ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml @@ -94,5 +94,9 @@ change: the payload on the composer runConfig and clears it once the run settles (ordinary sends, clarification cards and edit resends cannot carry stale annotations). The composeSend double-invocation guard of the fold era is - obsolete because the manual append is idempotent. Northbound API does - not transparently expose annotations; resume runs do not carry them. + obsolete because the manual append is idempotent. Annotation-only sends + (quoted reply without typed text) resolve the annotations before the + adapter's empty-query guard so the request goes out with an empty query + and the backend merges the quotes into the model-facing text. + Northbound API does not transparently expose annotations; resume runs do + not carry them. From 60dc03e3af400b0bd3de3b362c85522ab873d6f3 Mon Sep 17 00:00:00 2001 From: ljy Date: Tue, 3 Nov 2026 16:30:18 +0800 Subject: [PATCH 10/26] feat(chat): collapse reply annotation cards into an expandable badge --- .../assistant-ui/message-annotation-cards.tsx | 57 ++++++++++++++----- .../[locale]/newchat/assistant-ui/thread.tsx | 6 +- .../component/messageAnnotation.test.tsx | 17 +++++- ...-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml | 12 ++-- 4 files changed, 68 insertions(+), 24 deletions(-) diff --git a/frontend/app/[locale]/newchat/assistant-ui/message-annotation-cards.tsx b/frontend/app/[locale]/newchat/assistant-ui/message-annotation-cards.tsx index e71e230223..399a578ef4 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/message-annotation-cards.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/message-annotation-cards.tsx @@ -2,10 +2,10 @@ "use client"; -import { memo, useMemo, type ComponentProps, type FC } from "react"; +import { memo, useMemo, useState, type ComponentProps, type FC } from "react"; import { useTranslation } from "react-i18next"; import { useAuiState } from "@assistant-ui/react"; -import { MessageSquareQuote } from "lucide-react"; +import { ChevronDown, MessageSquareQuote } from "lucide-react"; import { cn } from "@/lib/utils"; import { fromMessageAnnotationWireItems, @@ -13,13 +13,14 @@ import { } from "../utils/message-annotations"; /** - * Read-only annotation cards rendered on a user message bubble. + * Compact annotation badge rendered above a user message bubble. * - * One card per attached reply annotation: the quoted excerpt, an optional - * comment line and the source reply excerpt. Data comes from the message's - * structured `metadata.custom.annotations` (set by the optimistic append on - * send and restored from the persisted history unit on reload), so the - * cards survive page refreshes just like the model-facing context does. + * Collapsed it shows a "N annotations" pill (Codex-style); expanding it + * reveals one card per attached reply annotation: the quoted excerpt, an + * optional comment line and the source reply excerpt. Data comes from the + * message's structured `metadata.custom.annotations` (set by the optimistic + * append on send and restored from the persisted history unit on reload), so + * the badge survives page refreshes just like the model-facing context does. */ export const MessageAnnotationCards: FC< { @@ -31,6 +32,8 @@ export const MessageAnnotationCards: FC< className, ...props }) { + const [expanded, setExpanded] = useState(false); + const { t } = useTranslation(); const rawAnnotations = useAuiState((state) => state.message?.role === "user" ? ( @@ -48,15 +51,39 @@ export const MessageAnnotationCards: FC< return (
    - {items.map((item) => ( - setExpanded((value) => !value)} + className="border-muted-foreground/30 text-muted-foreground hover:bg-accent hover:text-accent-foreground inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors" + > + + {t("chat.annotation.chipLabel", { count: items.length })} + - ))} + + {expanded ? ( +
    + {items.map((item) => ( + + ))} +
    + ) : null}
    ); }); @@ -66,7 +93,7 @@ const AnnotationCard: FC<{ item: MessageAnnotationWireItem }> = ({ item }) => { return (
    diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index be775c7539..69ca9addb8 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -2057,10 +2057,10 @@ const UserMessage: FC<{
    + {/* Reply annotations ("add to chat") render as a compact badge + above the message text; data rides the structured metadata. */} + - {/* Reply annotations ("add to chat") render as cards above the - message text; data rides the structured message metadata. */} - {(quote) => } diff --git a/frontend/tests/component/messageAnnotation.test.tsx b/frontend/tests/component/messageAnnotation.test.tsx index 1163f0c95b..bc84de63b4 100644 --- a/frontend/tests/component/messageAnnotation.test.tsx +++ b/frontend/tests/component/messageAnnotation.test.tsx @@ -512,7 +512,7 @@ describe("MessageAnnotationCards", () => { expect(container.firstChild).toBeNull(); }); - test("renders one card per annotation with quote, comment and source", () => { + test("shows a collapsed badge first and reveals cards on click", () => { const { container } = render( { /> ); + const badge = screen.getByRole("button", { + name: "chat.annotation.chipLabel", + }); + expect(badge.getAttribute("aria-expanded")).toBe("false"); + expect( + container.querySelectorAll("[data-slot='aui_message-annotation-card']") + ).toHaveLength(0); + + fireEvent.click(badge); + + expect(badge.getAttribute("aria-expanded")).toBe("true"); const cards = container.querySelectorAll( "[data-slot='aui_message-annotation-card']" ); @@ -539,6 +550,10 @@ describe("MessageAnnotationCards", () => { /> ); + fireEvent.click( + screen.getByRole("button", { name: "chat.annotation.chipLabel" }) + ); + expect( container.querySelectorAll("[data-slot='aui_message-annotation-card']") ).toHaveLength(1); diff --git a/test-e2e/changes/requirements/ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml b/test-e2e/changes/requirements/ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml index 52bfe40812..b935c1973f 100644 --- a/test-e2e/changes/requirements/ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml +++ b/test-e2e/changes/requirements/ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml @@ -18,8 +18,9 @@ change: History reads return the structured annotations field. On the frontend, a send with annotations and no in-flight uploads takes over the append (thread.append) so the optimistic user message carries the annotations as - message metadata and renders read-only reference cards (quoted text, - optional comment, source reply excerpt) above the message text; cards are + message metadata and renders a collapsed annotation badge above the + message text that expands to read-only reference cards (quoted text, + optional comment, source reply excerpt); cards are restored from history after reload. With in-flight uploads the send rides the library submission flow and carries annotations via the composer runConfig channel (no optimistic cards until reload). Ordinary sends @@ -52,8 +53,9 @@ change: - createMessageAnnotation drops whitespace-only comments and applies the defensive caps; adding beyond the max count is rejected in the Thread with a localized warning. - - MessageAnnotationCards renders one read-only card per annotation - (quote, comment, source excerpt) and drops invalid entries. + - MessageAnnotationCards renders a collapsed count badge that expands + to one read-only card per annotation (quote, comment, source excerpt) + and drops invalid entries. - normalize_message_annotations validates, trims and drops unusable entries, rejects non-list payloads, returns None when nothing usable remains, and truncates oversized fields instead of rejecting them. @@ -78,7 +80,7 @@ change: The upgrade spans the request payload (additive annotations field), unit persistence, prompt construction and the user bubble, all covered by targeted UT at their owning layers: frontend component/util tests - for the wire mapping, caps, takeover send and card rendering; backend + for the wire mapping, caps, takeover send and badge/card rendering; backend tests for contract normalization/formatting, service passthrough, history projection and repository persistence/merge against isolated SQLite rows. The model-facing merge is invisible to external protocol From 3aae5d5395875f4312fffb9bbb7087bbc0e7e31b Mon Sep 17 00:00:00 2001 From: ljy Date: Tue, 3 Nov 2026 16:30:24 +0800 Subject: [PATCH 11/26] feat(chat): add hover previews and current position to the question directory --- .../[locale]/newchat/assistant-ui/thread.tsx | 12 ++ .../newchat/ui/question-directory.tsx | 175 ++++++++++++--- .../newchat/utils/question-directory.ts | 200 ++++++++++++++---- frontend/public/locales/en/common.json | 2 + frontend/public/locales/zh/common.json | 2 + .../component/questionDirectory.test.tsx | 155 +++++++++++++- .../QUESTION-DIRECTORY-JUMP-20261102.yaml | 28 ++- 7 files changed, 480 insertions(+), 94 deletions(-) diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 69ca9addb8..3e4ab68ff5 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -87,6 +87,7 @@ import { } from "../utils/message-annotations"; import { scrollThreadToMessage, + useActiveQuestionId, useQuestionDirectory, type QuestionDirectoryEntry, } from "../utils/question-directory"; @@ -927,6 +928,10 @@ const ThreadView: FC = ({ } }, []); + // Question the viewport is currently scrolled to; highlights the matching + // directory row so long conversations show "where am I" at a glance. + const activeQuestionId = useActiveQuestionId(questionDirectory); + const threadHeaderVisible = Boolean( showConversationTitle && (isShareMode || (!newChatDesign && workbenchPresentation)) @@ -962,6 +967,7 @@ const ThreadView: FC = ({ - - ))} + {filteredQuestions.map((question) => { + const isActive = question.messageId === activeMessageId; + return ( +
  • + +
  • + ); + })} {filteredQuestions.length === 0 && (
  • = ({ )} + {preview + ? createPortal( +
    +

    + {preview.entry.fullText} +

    +
    +

    + {preview.entry.replyText || t("chat.questions.noReply")} +

    +
    , + document.body, + ) + : null} ); }; diff --git a/frontend/app/[locale]/newchat/utils/question-directory.ts b/frontend/app/[locale]/newchat/utils/question-directory.ts index 2cb3157db0..8db47c9875 100644 --- a/frontend/app/[locale]/newchat/utils/question-directory.ts +++ b/frontend/app/[locale]/newchat/utils/question-directory.ts @@ -2,11 +2,17 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; /** Truncation length for the question summary shown in the directory list. */ const QUESTION_SUMMARY_MAX_LENGTH = 60; +/** Truncation length for the assistant reply excerpt shown in the preview. */ +const REPLY_EXCERPT_MAX_CHARS = 400; + +/** Viewport fraction used as the "current question" midline. */ +const ACTIVE_QUESTION_MIDLINE = 0.5; + /** * Loose shape of an assistant-ui thread message as consumed by the question * directory. Only the fields actually read are declared, mirroring the relaxed @@ -32,8 +38,10 @@ export interface QuestionDirectoryEntry { index: number; /** Short single-line label for the list. */ summary: string; - /** Full question text (or attachment name) for the hover tooltip. */ + /** Full question text (or attachment name) for the preview card. */ fullText: string; + /** Excerpt of the assistant reply that answered the question ("" if none yet). */ + replyText: string; /** Whether the turn was derived from text or from an attachment-only message. */ kind: "text" | "attachment"; } @@ -45,51 +53,85 @@ const truncateSummary = (value: string): string => { return `${line.slice(0, QUESTION_SUMMARY_MAX_LENGTH - 1)}…`; }; +const truncateReply = (value: string): string => { + const trimmed = value.trim(); + if (trimmed.length <= REPLY_EXCERPT_MAX_CHARS) return trimmed; + return `${trimmed.slice(0, REPLY_EXCERPT_MAX_CHARS - 1)}…`; +}; + +/** Joins the text parts of a message (assistant replies only). */ +const messageText = (message: QuestionMessageLike): string => + (message.content ?? []) + .filter((part) => part?.type === "text" && typeof part.text === "string") + .map((part) => (part.type === "text" ? (part.text ?? "") : "")) + .join("\n") + .trim(); + /** * Collects one directory entry per user message, in conversation order. * * Text questions use the first non-empty text part (first line, truncated) as * the summary; messages that only carried attachments fall back to the first - * attachment's name. Messages with neither are skipped. + * attachment's name. Messages with neither are skipped. Each entry also + * carries an excerpt of the first assistant reply that follows it so the + * directory can preview the answer together with the question. */ export function collectQuestionDirectory( - messages: readonly QuestionMessageLike[] + messages: readonly QuestionMessageLike[], ): QuestionDirectoryEntry[] { const entries: QuestionDirectoryEntry[] = []; + // Index of the newest question that has not received an assistant reply yet; + // assistant messages without text (tool calls, reasoning summaries) do not + // consume it so the first textual reply wins. + let pendingIndex = -1; for (const message of messages) { - if (message.role !== "user") continue; - - const fullText = - message.content - ?.find( - (part) => - part?.type === "text" && typeof part.text === "string" && part.text - ) - ?.text?.trim() ?? ""; - - let kind: QuestionDirectoryEntry["kind"]; - let source: string; - if (fullText) { - kind = "text"; - source = fullText; - } else { - const attachmentName = message.attachments?.find((a) => a?.name)?.name; - if (!attachmentName) continue; - kind = "attachment"; - source = attachmentName; + if (message.role === "user") { + const fullText = + message.content + ?.find( + (part) => + part?.type === "text" && + typeof part.text === "string" && + part.text, + ) + ?.text?.trim() ?? ""; + + let kind: QuestionDirectoryEntry["kind"]; + let source: string; + if (fullText) { + kind = "text"; + source = fullText; + } else { + const attachmentName = message.attachments?.find((a) => a?.name)?.name; + if (!attachmentName) continue; + kind = "attachment"; + source = attachmentName; + } + + const summary = truncateSummary(source); + if (!summary) continue; + + entries.push({ + key: message.id, + messageId: message.id, + index: entries.length + 1, + summary, + fullText: source, + replyText: "", + kind, + }); + pendingIndex = entries.length - 1; + continue; } - const summary = truncateSummary(source); - if (!summary) continue; - - entries.push({ - key: message.id, - messageId: message.id, - index: entries.length + 1, - summary, - fullText: source, - kind, - }); + if (message.role === "assistant" && pendingIndex >= 0) { + const pending = entries[pendingIndex]; + const reply = messageText(message); + if (pending && reply) { + pending.replyText = truncateReply(reply); + pendingIndex = -1; + } + } } return entries; } @@ -99,27 +141,33 @@ export function collectQuestionDirectory( * every streamed token, which keeps the directory live during generation. */ export function useQuestionDirectory( - messages: readonly QuestionMessageLike[] + messages: readonly QuestionMessageLike[], ): QuestionDirectoryEntry[] { return useMemo(() => collectQuestionDirectory(messages), [messages]); } /** - * Scrolls the thread viewport to the message with the given id. + * Locates the rendered message element for an assistant-ui message id. * * assistant-ui renders `data-message-id` on every message root and exposes no - * public scroll-to-message API, so the target is located through the DOM and - * scrolled natively. The target flashes briefly so the landing point is - * obvious after the smooth scroll settles. + * public message-lookup API, so the target is located through the DOM. Message + * ids are runtime-generated, but quote/backslash are escaped anyway so the + * attribute selector stays valid for any id shape. */ -export function scrollThreadToMessage(messageId: string): boolean { - if (!messageId) return false; - // Message ids are runtime-generated, but escape quote/backslash anyway so - // the attribute selector stays valid for any id shape. +export function findMessageElement(messageId: string): HTMLElement | null { const escaped = messageId.replace(/["\\]/g, "\\$&"); - const element = document.querySelector( - `[data-message-id="${escaped}"]` - ); + return document.querySelector(`[data-message-id="${escaped}"]`); +} + +/** + * Scrolls the thread viewport to the message with the given id. + * + * The target is located through the DOM and scrolled natively. The target + * flashes briefly so the landing point is obvious after the smooth scroll + * settles. + */ +export function scrollThreadToMessage(messageId: string): boolean { + const element = findMessageElement(messageId); if (!element) return false; // Breathing room between the landed message and the viewport top edge. @@ -135,3 +183,61 @@ export function scrollThreadToMessage(messageId: string): boolean { }, 1600); return true; } + +/** + * Tracks the question the thread viewport is currently scrolled to. + * + * The active question is the last one whose message top sits above the + * viewport midline (classic active-section logic), recomputed rAF-throttled on + * every scroll and once whenever the tracked question set changes. Scroll + * events do not bubble, so the listener attaches to `window` in the capture + * phase to also catch inner scroll containers. + */ +export function useActiveQuestionId( + questions: readonly QuestionDirectoryEntry[], +): string | null { + const [activeId, setActiveId] = useState(null); + // Stable dependency: entry identities change on every streamed token, but + // the tracked id list only changes when questions are added or removed. + const questionIdsKey = questions + .map((question) => question.messageId) + .join("\n"); + + useEffect(() => { + const ids = questionIdsKey ? questionIdsKey.split("\n") : []; + let frame = 0; + const compute = () => { + frame = 0; + const midline = window.innerHeight * ACTIVE_QUESTION_MIDLINE; + let current: string | null = null; + for (const id of ids) { + const element = findMessageElement(id); + // Unmounted messages (still loading, filtered runs) are skipped; the + // first rendered message below the midline ends the scan. + if (!element) continue; + if (element.getBoundingClientRect().top <= midline) { + current = id; + } else { + break; + } + } + setActiveId(current); + }; + + const scheduleCompute = () => { + if (frame) return; + frame = window.requestAnimationFrame(compute); + }; + + // The initial computation rides the same rAF path as scroll-driven ones; + // an empty question set resolves to null on the next frame. + scheduleCompute(); + window.addEventListener("scroll", scheduleCompute, true); + return () => { + window.removeEventListener("scroll", scheduleCompute, true); + if (frame) window.cancelAnimationFrame(frame); + }; + }, [questionIdsKey]); + + return activeId; +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index e7802a8b26..f20f411d14 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -488,6 +488,8 @@ "chat.questions.jump": "Jump to question {{index}}", "chat.questions.search": "Search questions…", "chat.questions.noMatches": "No matching questions", + "chat.questions.current": "Current", + "chat.questions.noReply": "No reply yet", "chat.annotation.add": "Add annotation", "chat.annotation.commentPlaceholder": "Add an optional comment...", "chat.annotation.editPlaceholder": "Edit comment...", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 75e6362924..74955ceee1 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -486,6 +486,8 @@ "chat.questions.jump": "跳转到问题 {{index}}", "chat.questions.search": "搜索问题…", "chat.questions.noMatches": "没有匹配的问题", + "chat.questions.current": "当前", + "chat.questions.noReply": "暂无回复", "chat.annotation.add": "添加批注", "chat.annotation.commentPlaceholder": "添加可选批注…", "chat.annotation.editPlaceholder": "编辑批注…", diff --git a/frontend/tests/component/questionDirectory.test.tsx b/frontend/tests/component/questionDirectory.test.tsx index 5357d4e29e..e388344af4 100644 --- a/frontend/tests/component/questionDirectory.test.tsx +++ b/frontend/tests/component/questionDirectory.test.tsx @@ -4,6 +4,7 @@ import { cleanup, fireEvent, render, + renderHook, screen, waitFor, } from "@testing-library/react"; @@ -12,6 +13,7 @@ import { afterEach, expect, test, vi } from "vitest"; import { collectQuestionDirectory, scrollThreadToMessage, + useActiveQuestionId, type QuestionMessageLike, } from "@/app/newchat/utils/question-directory"; import { QuestionDirectoryButton } from "@/app/newchat/ui/question-directory"; @@ -34,7 +36,7 @@ vi.mock("@/lib/logger", () => ({ const makeMessage = ( id: string, role: string, - overrides: Record = {} + overrides: Record = {}, ): QuestionMessageLike => ({ id, @@ -122,6 +124,25 @@ test("collectQuestionDirectory skips empty and non-user messages", () => { expect(questions).toEqual([]); }); +test("collectQuestionDirectory attaches and caps the assistant reply excerpt", () => { + const longReply = "回".repeat(450); + const questions = collectQuestionDirectory([ + makeMessage("u1", "user", { content: [{ type: "text", text: "第一问" }] }), + makeMessage("a1", "assistant", { content: [{ type: "tool" }] }), + makeMessage("a2", "assistant", { + content: [{ type: "text", text: longReply }], + }), + makeMessage("u2", "user", { content: [{ type: "text", text: "第二问" }] }), + ]); + + // Text-free assistant messages (tool calls, reasoning) do not consume the + // pending reply; the first textual reply wins and is capped at the limit. + expect(questions[0]?.replyText).toHaveLength(400); + expect(questions[0]?.replyText?.endsWith("…")).toBe(true); + // A trailing question without any reply keeps an empty excerpt. + expect(questions[1]?.replyText).toBe(""); +}); + test("scrollThreadToMessage scrolls to and highlights the target message", () => { const element = document.createElement("div"); element.setAttribute("data-message-id", "msg-42"); @@ -144,8 +165,55 @@ test("scrollThreadToMessage reports missing targets", () => { expect(scrollThreadToMessage("")).toBe(false); }); +test("useActiveQuestionId tracks the last question above the viewport midline", async () => { + const first = document.createElement("div"); + first.setAttribute("data-message-id", "u1"); + const second = document.createElement("div"); + second.setAttribute("data-message-id", "u2"); + document.body.append(first, second); + + const makeRect = (top: number) => + ({ + top, + right: 0, + bottom: 0, + left: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect; + + const getRectSpy = vi + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockImplementation(function (this: HTMLElement) { + // jsdom viewport height is 768, so the midline sits at 384. + return makeRect(this === first ? 100 : 500); + }); + + const questions = collectQuestionDirectory([ + makeMessage("u1", "user", { content: [{ type: "text", text: "第一问" }] }), + makeMessage("u2", "user", { content: [{ type: "text", text: "第二问" }] }), + ]); + const { result, unmount } = renderHook(() => useActiveQuestionId(questions)); + + // Initial computation: only the first question sits above the midline. + await waitFor(() => expect(result.current).toBe("u1")); + + // Scrolling the second question above the midline switches the marker. + getRectSpy.mockImplementation(function (this: HTMLElement) { + return makeRect(this === first ? 100 : 200); + }); + window.dispatchEvent(new Event("scroll")); + await waitFor(() => expect(result.current).toBe("u2")); + + unmount(); + getRectSpy.mockRestore(); +}); + const renderDirectoryButton = ( - props: Partial[0]> = {} + props: Partial[0]> = {}, ) => render( @@ -154,6 +222,11 @@ const renderDirectoryButton = ( makeMessage("u1", "user", { content: [{ type: "text", text: "第一问是什么" }], }), + makeMessage("a1", "assistant", { + content: [ + { type: "text", text: "这是第一问的回答,用于悬停预览。" }, + ], + }), makeMessage("u2", "user", { content: [{ type: "file", filename: "data.csv" }], attachments: [{ name: "data.csv" }], @@ -162,14 +235,14 @@ const renderDirectoryButton = ( onJump={() => {}} {...props} /> - + , ); test("QuestionDirectoryButton hides without questions", () => { const { container } = render( {}} /> - + , ); expect(container.firstChild).toBeNull(); @@ -190,7 +263,7 @@ test("QuestionDirectoryButton lists questions and jumps on click", async () => { expect(screen.getByText("data.csv")).toBeTruthy(); fireEvent.click( - screen.getByRole("button", { name: "chat.questions.jump:1" }) + screen.getByRole("button", { name: "chat.questions.jump:1" }), ); expect(onJump).toHaveBeenCalledWith("u1"); @@ -202,7 +275,7 @@ test("QuestionDirectoryButton filters questions by keyword", async () => { renderDirectoryButton(); fireEvent.click( - screen.getByRole("button", { name: "chat.questions.viewAll:2" }) + screen.getByRole("button", { name: "chat.questions.viewAll:2" }), ); const searchBox = await screen.findByRole("textbox", { name: "chat.questions.search", @@ -222,7 +295,7 @@ test("QuestionDirectoryButton shows an empty state for unmatched keywords", asyn renderDirectoryButton(); fireEvent.click( - screen.getByRole("button", { name: "chat.questions.viewAll:2" }) + screen.getByRole("button", { name: "chat.questions.viewAll:2" }), ); const searchBox = await screen.findByRole("textbox", { name: "chat.questions.search", @@ -231,7 +304,7 @@ test("QuestionDirectoryButton shows an empty state for unmatched keywords", asyn fireEvent.change(searchBox, { target: { value: "不存在的关键词" } }); await waitFor(() => - expect(screen.getByTestId("question-directory-no-matches")).toBeTruthy() + expect(screen.getByTestId("question-directory-no-matches")).toBeTruthy(), ); expect(screen.queryByText("第一问是什么")).toBeNull(); expect(screen.queryByText("data.csv")).toBeNull(); @@ -241,7 +314,7 @@ test("QuestionDirectoryButton resets the keyword when the popover closes", async renderDirectoryButton(); fireEvent.click( - screen.getByRole("button", { name: "chat.questions.viewAll:2" }) + screen.getByRole("button", { name: "chat.questions.viewAll:2" }), ); const searchBox = await screen.findByRole("textbox", { name: "chat.questions.search", @@ -251,13 +324,73 @@ test("QuestionDirectoryButton resets the keyword when the popover closes", async // Close via the trigger; reopening must show the unfiltered directory again. fireEvent.click( - screen.getByRole("button", { name: "chat.questions.viewAll:2" }) + screen.getByRole("button", { name: "chat.questions.viewAll:2" }), ); await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull()); fireEvent.click( - screen.getByRole("button", { name: "chat.questions.viewAll:2" }) + screen.getByRole("button", { name: "chat.questions.viewAll:2" }), ); await waitFor(() => expect(screen.getByText("第一问是什么")).toBeTruthy()); expect(screen.getByText("data.csv")).toBeTruthy(); }); + +test("QuestionDirectoryButton marks the current question row", async () => { + renderDirectoryButton({ activeMessageId: "u1" }); + + fireEvent.click( + screen.getByRole("button", { name: "chat.questions.viewAll:2" }), + ); + + const activeRow = await screen.findByRole("button", { + name: "chat.questions.jump:1", + }); + expect(activeRow.getAttribute("aria-current")).toBe("location"); + expect(screen.getByTestId("question-directory-active").textContent).toBe( + "chat.questions.current", + ); + + const otherRow = screen.getByRole("button", { + name: "chat.questions.jump:2", + }); + expect(otherRow.getAttribute("aria-current")).toBeNull(); +}); + +test("QuestionDirectoryButton previews the question and reply on hover", async () => { + renderDirectoryButton(); + + fireEvent.click( + screen.getByRole("button", { name: "chat.questions.viewAll:2" }), + ); + const row = await screen.findByRole("button", { + name: "chat.questions.jump:1", + }); + + fireEvent.mouseEnter(row); + + const card = await screen.findByTestId("question-directory-preview"); + expect(card.textContent).toContain("第一问是什么"); + expect(card.textContent).toContain("这是第一问的回答,用于悬停预览。"); + + fireEvent.mouseLeave(row); + await waitFor(() => + expect(screen.queryByTestId("question-directory-preview")).toBeNull(), + ); +}); + +test("QuestionDirectoryButton previews a question without a reply on hover", async () => { + renderDirectoryButton(); + + fireEvent.click( + screen.getByRole("button", { name: "chat.questions.viewAll:2" }), + ); + const row = await screen.findByRole("button", { + name: "chat.questions.jump:2", + }); + + fireEvent.mouseEnter(row); + + const card = await screen.findByTestId("question-directory-preview"); + expect(card.textContent).toContain("data.csv"); + expect(card.textContent).toContain("chat.questions.noReply"); +}); diff --git a/test-e2e/changes/requirements/QUESTION-DIRECTORY-JUMP-20261102.yaml b/test-e2e/changes/requirements/QUESTION-DIRECTORY-JUMP-20261102.yaml index 9489ea7174..310e0ed465 100644 --- a/test-e2e/changes/requirements/QUESTION-DIRECTORY-JUMP-20261102.yaml +++ b/test-e2e/changes/requirements/QUESTION-DIRECTORY-JUMP-20261102.yaml @@ -14,7 +14,13 @@ change: presentation, share mode, read-only conversation page). Jumping locates the message root via its data-message-id attribute, smooth-scrolls the viewport, and briefly highlights the target. Navigation stays available - while a run streams and in read-only/share contexts.' + while a run streams and in read-only/share contexts. Hovering (or + focusing) a directory row opens a preview card with the full question and + an excerpt of its first assistant reply (capped at 400 chars, text-free + assistant messages skipped, "no reply yet" placeholder); the row the + viewport is currently scrolled to (last question above the viewport + midline, rAF-throttled capture-phase scroll tracking) is highlighted and + labelled as the current position.' affected_features: existing: [] added: [] @@ -44,6 +50,19 @@ change: entry is clicked, filters entries by a case-insensitive keyword over the full question text (attachment turns included), shows an empty state for unmatched keywords, and resets the keyword when the popover closes. + - collectQuestionDirectory attaches an excerpt of the first assistant + reply that follows each question (text-free assistant messages do not + consume the pending reply, the excerpt is capped at 400 chars with an + ellipsis, and unanswered questions keep an empty excerpt). + - useActiveQuestionId reports the last question whose message top sits + above the viewport midline, recomputes rAF-throttled on scroll events + (capture phase, inner scroll containers included), and resolves an + empty question set to null. + - QuestionDirectoryButton highlights the row matching activeMessageId + (aria-current="location" plus a current-position label) and opens a + hover/focus preview card showing the full question and its reply + excerpt (or the no-reply placeholder); the card closes on mouse + leave/blur and when the popover closes. reason: |- The feature is a frontend navigation layer over already-proven contracts: thread message state comes from the existing runtime subscription, the @@ -53,5 +72,8 @@ change: so no formal Case is added. notes: | Core scope confirmed by the user (popover form, header entry point, - attachment-name fallback, read-only support). Deferred: scroll-spy current - position indicator, per-question answer status, keyboard shortcut. + attachment-name fallback, read-only support). The scroll-spy current + position indicator and a Codex-style hover preview card (question plus + assistant reply excerpt) were added in the same branch after comparing + with Codex's conversation timeline; per-question answer status and a + keyboard shortcut remain deferred. From b80a214f7a70ebefec7cd987232bccb78fc48789 Mon Sep 17 00:00:00 2001 From: ljy Date: Thu, 5 Nov 2026 14:28:21 +0800 Subject: [PATCH 12/26] fix(chat): keep branched conversations visible without workbench config --- .../management/db/conversation/repository.py | 18 +++- .../database/test_conversation_branch_db.py | 86 +++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/backend/management/db/conversation/repository.py b/backend/management/db/conversation/repository.py index 278f399eb7..dcf7cebed5 100644 --- a/backend/management/db/conversation/repository.py +++ b/backend/management/db/conversation/repository.py @@ -575,12 +575,24 @@ def branch_conversation( "delete_flag": "N", "agent_id": conversation.agent_id, "chat_mode": conversation.chat_mode, - "knowledge_scope": deepcopy(conversation.knowledge_scope), "runtime_metadata": deepcopy(conversation.runtime_metadata) or {}, "runtime_metadata_version": 1, - "workbench_config": deepcopy(conversation.workbench_config), - "workbench_config_version": 1, } + # SQLAlchemy serializes Python None bound to a JSONB column as the JSON + # value null (not SQL NULL), and agent-chat history filters on + # "workbench_config IS NULL": binding an absent declaration would hide + # the branched conversation from every agent-chat list. Copy nullable + # JSONB values only when the source actually declares them, mirroring + # create_conversation. + if conversation.knowledge_scope is not None: + conversation_data["knowledge_scope"] = deepcopy( + conversation.knowledge_scope + ) + if conversation.workbench_config is not None: + conversation_data["workbench_config"] = deepcopy( + conversation.workbench_config + ) + conversation_data["workbench_config_version"] = 1 if user_id: conversation_data = add_creation_tracking(conversation_data, user_id) diff --git a/test/backend/database/test_conversation_branch_db.py b/test/backend/database/test_conversation_branch_db.py index 8daf370e30..1d42069f0a 100644 --- a/test/backend/database/test_conversation_branch_db.py +++ b/test/backend/database/test_conversation_branch_db.py @@ -295,6 +295,92 @@ def test_branch_copies_turn_range_and_remaps_children(session): assert inserted["conversation_source_image_t"][0]["message_id"] == 1004 +def _conversation_insert(recorder): + """Return the single conversation INSERT statement executed by a branch.""" + statements = [ + statement + for statement in recorder.statements + if str(statement.compile(dialect=postgresql.dialect())).startswith( + "INSERT INTO nexent.conversation_record_t" + ) + ] + assert len(statements) == 1 + return statements[0] + + +def test_branch_of_agent_chat_leaves_workbench_columns_unbound(session): + """An agent-chat branch must not bind JSONB columns with Python None. + + SQLAlchemy serializes a None bound to a JSONB column as the JSON value + null instead of SQL NULL, and agent-chat history filters on + "workbench_config IS NULL": binding the absent declaration would hide + the branched conversation from every agent-chat list as soon as it stops + being the active thread. + """ + _wire( + session, + _three_turn_rows(), + conversation=_conversation_row( + workbench_config=None, knowledge_scope=None + ), + ) + recorder = _ExecuteRecorder() + session.execute.side_effect = recorder + + result = db.branch_conversation(7, 1, user_id="owner") + + assert result["workbench_config"] is None + # The RETURNING clause references workbench_config regardless, so assert + # on the VALUES binds: an unbound column falls back to SQL NULL while a + # bound None would be serialized by SQLAlchemy as the JSON value null. + inserted = params(_conversation_insert(recorder)) + assert "workbench_config" not in inserted + assert "workbench_config_version" not in inserted + assert "knowledge_scope" not in inserted + assert inserted["runtime_metadata"] == {"model": "gpt"} + assert inserted["runtime_metadata_version"] == 1 + + +def test_branch_copies_workbench_declaration_when_present(session): + """A Workbench branch keeps its declaration and restarts the version.""" + workbench = { + "schema_version": 3, + "mode": "single_agent_chat", + "agent_mounts": [{"agent_id": 5, "version_no": 3}], + } + new_row = SimpleNamespace( + conversation_id=42, + conversation_title="Trip planning(分支)", + agent_id=5, + chat_mode="execution", + knowledge_scope={"kb": True}, + runtime_metadata={}, + runtime_metadata_version=1, + workbench_config=workbench, + workbench_config_version=1, + create_time=1700000000000, + update_time=1700000000000, + ) + _wire( + session, + _three_turn_rows(), + conversation=_conversation_row( + workbench_config=workbench, workbench_config_version=5 + ), + ) + recorder = _ExecuteRecorder(new_conversation_row=new_row) + session.execute.side_effect = recorder + + result = db.branch_conversation(7, 1, user_id="owner") + + assert result["workbench_config"] == workbench + inserted = params(_conversation_insert(recorder)) + assert inserted["workbench_config"] == workbench + # The copy is independent of the source row's declaration. + assert inserted["workbench_config"] is not workbench + assert inserted["workbench_config_version"] == 1 + + def test_branch_last_turn_copies_everything(session): _wire(session, _three_turn_rows()) recorder = _ExecuteRecorder() From bb810b966a6eb9f186219ac97d7792e5b2b5aac2 Mon Sep 17 00:00:00 2001 From: ljy Date: Thu, 5 Nov 2026 14:28:29 +0800 Subject: [PATCH 13/26] fix(chat): clear question directory preview on jump and list scroll --- .../newchat/ui/question-directory.tsx | 33 +++++++++++-- .../component/questionDirectory.test.tsx | 48 +++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/frontend/app/[locale]/newchat/ui/question-directory.tsx b/frontend/app/[locale]/newchat/ui/question-directory.tsx index dc0f1325c2..eda6a63aa7 100644 --- a/frontend/app/[locale]/newchat/ui/question-directory.tsx +++ b/frontend/app/[locale]/newchat/ui/question-directory.tsx @@ -80,6 +80,18 @@ export const QuestionDirectoryButton: FC = ({ // The hover timer must never outlive the component. useEffect(() => clearPreviewTimer, []); + // Safety net: the preview card portals outside the popover, so it must + // never outlive the popover, whatever code path closed it. + useEffect(() => { + if (open) return; + if (previewTimer.current !== null) { + window.clearTimeout(previewTimer.current); + previewTimer.current = null; + } + setSearch(""); + setPreview(null); + }, [open]); + // Match against the full text (not the truncated summary) so keywords from // any line of the question still find the entry; case-insensitive. const filteredQuestions = useMemo(() => { @@ -122,6 +134,17 @@ export const QuestionDirectoryButton: FC = ({ setPreview(null); }; + // Reset the transient popover session state (keyword filter and hover + // preview). Radix reports its own dismissals (outside click, Escape, + // trigger toggle) through onOpenChange, but programmatic closes such as + // the row-click jump are not reported, so they must reset the same state + // explicitly. + const resetPopoverSession = () => { + setSearch(""); + clearPreviewTimer(); + setPreview(null); + }; + return ( = ({ // Reset the filter and drop the preview between sessions so // reopening starts from the full directory. if (!nextOpen) { - setSearch(""); - clearPreviewTimer(); - setPreview(null); + resetPopoverSession(); } }} > @@ -183,6 +204,9 @@ export const QuestionDirectoryButton: FC = ({
      {filteredQuestions.map((question) => { const isActive = question.messageId === activeMessageId; @@ -196,6 +220,9 @@ export const QuestionDirectoryButton: FC = ({ })} onClick={() => { setOpen(false); + // The programmatic close bypasses onOpenChange, so the + // portaled preview card is dropped here as well. + resetPopoverSession(); onJump(question.messageId); }} onMouseEnter={(event) => diff --git a/frontend/tests/component/questionDirectory.test.tsx b/frontend/tests/component/questionDirectory.test.tsx index e388344af4..67c61af60c 100644 --- a/frontend/tests/component/questionDirectory.test.tsx +++ b/frontend/tests/component/questionDirectory.test.tsx @@ -394,3 +394,51 @@ test("QuestionDirectoryButton previews a question without a reply on hover", asy expect(card.textContent).toContain("data.csv"); expect(card.textContent).toContain("chat.questions.noReply"); }); + +test("QuestionDirectoryButton clears the hover preview when a question is clicked", async () => { + const onJump = vi.fn(); + renderDirectoryButton({ onJump }); + + fireEvent.click( + screen.getByRole("button", { name: "chat.questions.viewAll:2" }), + ); + const row = await screen.findByRole("button", { + name: "chat.questions.jump:1", + }); + + fireEvent.mouseEnter(row); + await waitFor(() => + expect(screen.getByTestId("question-directory-preview")).toBeTruthy(), + ); + + // Clicking a row closes the popover programmatically (not through + // onOpenChange); the portaled preview card must not survive that close. + fireEvent.click(row); + + expect(onJump).toHaveBeenCalledWith("u1"); + await waitFor(() => + expect(screen.queryByTestId("question-directory-preview")).toBeNull(), + ); +}); + +test("QuestionDirectoryButton hides the hover preview when the directory list scrolls", async () => { + renderDirectoryButton(); + + fireEvent.click( + screen.getByRole("button", { name: "chat.questions.viewAll:2" }), + ); + const row = await screen.findByRole("button", { + name: "chat.questions.jump:1", + }); + + fireEvent.mouseEnter(row); + await waitFor(() => + expect(screen.getByTestId("question-directory-preview")).toBeTruthy(), + ); + + // Wheel-scrolling moves rows under a stationary pointer without firing + // mouseleave, so the preview must be dropped when the list scrolls. + fireEvent.scroll(row.closest("ul")!); + + expect(screen.queryByTestId("question-directory-preview")).toBeNull(); +}); From ad1fff8cccfd096b4c0190f725e2e5a606e5a48f Mon Sep 17 00:00:00 2001 From: ljy Date: Thu, 5 Nov 2026 14:28:34 +0800 Subject: [PATCH 14/26] feat(chat): add viewport scroll rescue component --- .../assistant-ui/viewport-scroll-rescue.tsx | 89 +++++++++ .../component/viewportScrollRescue.test.tsx | 174 ++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 frontend/app/[locale]/newchat/assistant-ui/viewport-scroll-rescue.tsx create mode 100644 frontend/tests/component/viewportScrollRescue.test.tsx diff --git a/frontend/app/[locale]/newchat/assistant-ui/viewport-scroll-rescue.tsx b/frontend/app/[locale]/newchat/assistant-ui/viewport-scroll-rescue.tsx new file mode 100644 index 0000000000..a62bd1fe78 --- /dev/null +++ b/frontend/app/[locale]/newchat/assistant-ui/viewport-scroll-rescue.tsx @@ -0,0 +1,89 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useLayoutEffect, useRef, type FC } from "react"; +import { useAuiState, useThreadViewportStore } from "@assistant-ui/react"; + +// Avoids the useLayoutEffect SSR warning when this tree renders on the +// server; mirrors the library's own useIsomorphicLayoutEffect pattern. +const useIsomorphicLayoutEffect = + typeof window === "undefined" ? useEffect : useLayoutEffect; + +/** + * Restores chat auto-scroll for the thread viewport. + * + * assistant-ui 0.15.x drives viewport auto-scroll from runtime events + * ("thread.runStart", "threads.selectionChanged") and resets its internal + * follow flag only through those events. Under the legacy remote thread list + * runtime the events never reach the viewport controller, so once the user + * scrolls away (for example to reach the "branch" action on an earlier + * message) the viewport stops following the conversation entirely: sent + * messages and streamed replies stay below the fold. + * + * The triggers are rebuilt from plain state subscriptions, which work under + * every runtime: + * 1. A run starts -> scroll to the bottom once (mirrors + * scrollToBottomOnRunStart) so the new reply becomes visible. + * 2. History finishes loading -> land at the bottom of the restored + * conversation (mirrors scrollToBottomOnThreadSwitch + initialize). + * 3. While a run streams and the user is at the bottom -> keep following on + * every message update. The store's isAtBottom is driven by real scroll + * events, so scrolling away stops the follow and scrolling back resumes. + */ +export const ViewportScrollRescue: FC = () => { + const viewport = useThreadViewportStore(); + const isRunning = useAuiState((s) => s.thread.isRunning); + const isLoading = useAuiState((s) => s.thread.isLoading); + const hasMessages = useAuiState((s) => s.thread.messages.length > 0); + // `messages` identity changes on every streamed token, which provides the + // follow cadence while content grows. + const messages = useAuiState((s) => s.thread.messages); + + // The bottom is "sticky" from the moment a run starts: the first streamed + // updates can arrive before the run-start scroll event has refreshed the + // store's isAtBottom. + const followRef = useRef(true); + + useEffect(() => { + // The user leaving the bottom cancels the sticky follow. + return viewport.subscribe((state, prev) => { + if (prev.isAtBottom && !state.isAtBottom) followRef.current = false; + }); + }, [viewport]); + + // 1. Run start: jump to the bottom so the reply is visible. + const prevRunningRef = useRef(false); + useIsomorphicLayoutEffect(() => { + const wasRunning = prevRunningRef.current; + prevRunningRef.current = isRunning; + if (!isRunning || wasRunning) return; + followRef.current = true; + viewport.getState().scrollToBottom({ behavior: "instant" }); + }, [isRunning, viewport]); + + // 2. History load: land at the bottom of the restored conversation. + const prevLoadingRef = useRef(false); + useIsomorphicLayoutEffect(() => { + const wasLoading = prevLoadingRef.current; + prevLoadingRef.current = isLoading; + if (!wasLoading || isLoading || !hasMessages) return; + viewport.getState().scrollToBottom({ behavior: "instant" }); + }, [isLoading, hasMessages, viewport]); + + // 3. Streaming follow: track each message update while the viewport stays + // at the bottom. The messages-identity guard keeps this effect inert when + // only the running flag changed (the run-start scroll already fired), so a + // run start scrolls exactly once. + const prevMessagesRef = useRef(messages); + useIsomorphicLayoutEffect(() => { + const prevMessages = prevMessagesRef.current; + prevMessagesRef.current = messages; + if (!isRunning) return; + if (prevMessages === messages) return; + if (!followRef.current && !viewport.getState().isAtBottom) return; + viewport.getState().scrollToBottom({ behavior: "instant" }); + }, [messages, isRunning, viewport]); + + return null; +}; diff --git a/frontend/tests/component/viewportScrollRescue.test.tsx b/frontend/tests/component/viewportScrollRescue.test.tsx new file mode 100644 index 0000000000..26d583231d --- /dev/null +++ b/frontend/tests/component/viewportScrollRescue.test.tsx @@ -0,0 +1,174 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { cleanup, render } from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { ViewportScrollRescue } from "@/app/newchat/assistant-ui/viewport-scroll-rescue"; + +type AuiThreadState = { + isRunning: boolean; + isLoading: boolean; + messages: unknown[]; +}; + +type ViewportState = { + isAtBottom: boolean; + scrollToBottom: (options?: { behavior?: string }) => void; +}; + +const auiMock = vi.hoisted(() => { + const scrollToBottom = vi.fn(); + const viewportState: ViewportState = { isAtBottom: false, scrollToBottom }; + const listeners = new Set<(state: ViewportState, prev: ViewportState) => void>(); + const store = { + getState: () => viewportState, + subscribe: (listener: (state: ViewportState, prev: ViewportState) => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + return { + thread: { + isRunning: false, + isLoading: false, + messages: [] as unknown[], + } as AuiThreadState, + viewportState, + scrollToBottom, + listeners, + store, + }; +}); + +vi.mock("@assistant-ui/react", () => ({ + useAuiState: (selector: (s: { thread: AuiThreadState }) => unknown) => + selector({ thread: auiMock.thread }), + useThreadViewportStore: () => auiMock.store, +})); + +const renderRescue = () => { + const utils = render(); + return { + container: utils.container, + rerender: () => utils.rerender(), + }; +}; + +/** Simulate a scroll event changing the store's isAtBottom flag. */ +const notifyViewportIsAtBottom = (next: boolean) => { + const prev: ViewportState = { ...auiMock.viewportState }; + auiMock.viewportState.isAtBottom = next; + for (const listener of auiMock.listeners) { + listener({ ...auiMock.viewportState }, prev); + } +}; + +beforeEach(() => { + auiMock.thread.isRunning = false; + auiMock.thread.isLoading = false; + auiMock.thread.messages = []; + auiMock.viewportState.isAtBottom = false; + auiMock.scrollToBottom.mockClear(); +}); + +afterEach(() => { + cleanup(); +}); + +test("renders nothing and does not scroll on initial mount", () => { + const { container } = renderRescue(); + expect(container.childElementCount).toBe(0); + expect(auiMock.scrollToBottom).not.toHaveBeenCalled(); +}); + +test("scrolls to the bottom once when a run starts", () => { + const { rerender } = renderRescue(); + + auiMock.thread.isRunning = true; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(1); + expect(auiMock.scrollToBottom).toHaveBeenCalledWith({ behavior: "instant" }); + + // Re-rendering without new state must not re-trigger the scroll. + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(1); + + // The run ending must not scroll either. + auiMock.thread.isRunning = false; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(1); +}); + +test("scrolls to the bottom when history finishes loading with messages", () => { + const { rerender } = renderRescue(); + + auiMock.thread.isLoading = true; + rerender(); + expect(auiMock.scrollToBottom).not.toHaveBeenCalled(); + + auiMock.thread.isLoading = false; + auiMock.thread.messages = [{ id: "history-1" }, { id: "history-2" }]; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(1); +}); + +test("does not scroll when history finishes loading for an empty thread", () => { + const { rerender } = renderRescue(); + + auiMock.thread.isLoading = true; + rerender(); + auiMock.thread.isLoading = false; + auiMock.thread.messages = []; + rerender(); + expect(auiMock.scrollToBottom).not.toHaveBeenCalled(); +}); + +test("follows streamed updates while the run is active", () => { + const { rerender } = renderRescue(); + + auiMock.thread.isRunning = true; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(1); + + auiMock.thread.messages = [{ id: "chunk-1" }]; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(2); + + auiMock.thread.messages = [{ id: "chunk-1" }, { id: "chunk-2" }]; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(3); +}); + +test("stops following after the user scrolls away and resumes at the bottom", () => { + const { rerender } = renderRescue(); + + auiMock.thread.isRunning = true; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(1); + + // The run-start scroll landed at the bottom; the user then scrolls away. + notifyViewportIsAtBottom(true); + notifyViewportIsAtBottom(false); + + auiMock.thread.messages = [{ id: "chunk-1" }]; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(1); + + // Scrolling back to the bottom resumes the follow on the next update. + notifyViewportIsAtBottom(true); + auiMock.thread.messages = [{ id: "chunk-1" }, { id: "chunk-2" }]; + rerender(); + expect(auiMock.scrollToBottom).toHaveBeenCalledTimes(2); +}); + +test("does not follow message updates when no run is active", () => { + const { rerender } = renderRescue(); + + auiMock.thread.messages = [{ id: "edited-1" }]; + rerender(); + auiMock.thread.messages = [{ id: "edited-1" }, { id: "edited-2" }]; + rerender(); + expect(auiMock.scrollToBottom).not.toHaveBeenCalled(); +}); From a36529a87edbfaa26568dd68d4474fba5ad793b2 Mon Sep 17 00:00:00 2001 From: ljy Date: Thu, 5 Nov 2026 14:28:42 +0800 Subject: [PATCH 15/26] feat(chat): capture nearby reply context for add-to-chat quotes --- backend/contracts/annotations.py | 23 ++- .../message-annotation-bubble.tsx | 2 + .../newchat/utils/message-annotations.ts | 132 ++++++++++++++++-- frontend/public/locales/en/common.json | 4 +- frontend/public/locales/zh/common.json | 4 +- .../component/messageAnnotation.test.tsx | 111 +++++++++++++-- frontend/types/conversation.ts | 2 + ...HAT-LOCAL-CONTEXT-20261105-verification.md | 25 ++++ .../ADD-TO-CHAT-LOCAL-CONTEXT-20261105.yaml | 46 ++++++ .../services/test_message_annotations.py | 36 ++++- 10 files changed, 354 insertions(+), 31 deletions(-) create mode 100644 test-e2e/changes/requirements/ADD-TO-CHAT-LOCAL-CONTEXT-20261105-verification.md create mode 100644 test-e2e/changes/requirements/ADD-TO-CHAT-LOCAL-CONTEXT-20261105.yaml diff --git a/backend/contracts/annotations.py b/backend/contracts/annotations.py index 942cc96d58..7fc98a73f7 100644 --- a/backend/contracts/annotations.py +++ b/backend/contracts/annotations.py @@ -24,6 +24,7 @@ ANNOTATION_COMMENT_MAX_CHARS = 500 ANNOTATION_SOURCE_EXCERPT_MAX_CHARS = 120 ANNOTATION_MAX_COUNT = 10 +ANNOTATION_CONTEXT_MAX_CHARS = 200 class MessageAnnotationItem(BaseModel): @@ -37,6 +38,8 @@ class MessageAnnotationItem(BaseModel): quoted_text: str comment: Optional[str] = None source_excerpt: Optional[str] = None + context_before: Optional[str] = None + context_after: Optional[str] = None def normalize_message_annotations( @@ -68,6 +71,8 @@ def normalize_message_annotations( continue comment = (annotation.comment or "").strip() or None excerpt = (annotation.source_excerpt or "").strip() or None + before = (annotation.context_before or "").strip() or None + after = (annotation.context_after or "").strip() or None annotations.append( MessageAnnotationItem( quoted_text=quoted[:ANNOTATION_QUOTED_TEXT_MAX_CHARS], @@ -77,6 +82,8 @@ def normalize_message_annotations( if excerpt else None ), + context_before=before[-ANNOTATION_CONTEXT_MAX_CHARS:] if before else None, + context_after=after[:ANNOTATION_CONTEXT_MAX_CHARS] if after else None, ) ) if len(annotations) >= ANNOTATION_MAX_COUNT: @@ -101,14 +108,18 @@ def parse_message_annotation_unit(unit_content: Optional[str]) -> Optional[List[ def _annotation_block(index: int, annotation: MessageAnnotationItem) -> str: - source = ( - f' (from reply "{annotation.source_excerpt}")' - if annotation.source_excerpt - else "" - ) - lines = [f"{index}. Quote{source}:"] + # The reply intro is a UI label, not reliable model-facing context. + lines = [f"{index}. Quote:"] + if annotation.context_before: + lines.append("Context before (not selected):") + lines.extend(f"> {line}" for line in annotation.context_before.split("\n")) + if annotation.context_before or annotation.context_after: + lines.append("Selected text:") for line in annotation.quoted_text.split("\n"): lines.append(f"> {line}" if line else ">") + if annotation.context_after: + lines.append("Context after (not selected):") + lines.extend(f"> {line}" for line in annotation.context_after.split("\n")) if annotation.comment: lines.append(f"Comment: {annotation.comment}") return "\n".join(lines) diff --git a/frontend/app/[locale]/newchat/assistant-ui/message-annotation-bubble.tsx b/frontend/app/[locale]/newchat/assistant-ui/message-annotation-bubble.tsx index a5d16a7761..f561738a3e 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/message-annotation-bubble.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/message-annotation-bubble.tsx @@ -136,6 +136,8 @@ export const MessageAnnotationBubble: FC<{ quotedText: current.target.text, comment: current.comment.trim() || undefined, sourceMessageId: current.target.messageId, + contextBefore: current.target.contextBefore, + contextAfter: current.target.contextAfter, }); setPhase({ kind: "hidden" }); // Drop the text selection so the bubble does not re-open for the same diff --git a/frontend/app/[locale]/newchat/utils/message-annotations.ts b/frontend/app/[locale]/newchat/utils/message-annotations.ts index 84099b7bd3..cdc586e279 100644 --- a/frontend/app/[locale]/newchat/utils/message-annotations.ts +++ b/frontend/app/[locale]/newchat/utils/message-annotations.ts @@ -22,6 +22,7 @@ const LIST_QUOTE_MAX_LENGTH = 80; export const MESSAGE_ANNOTATION_QUOTED_TEXT_MAX = 1500; export const MESSAGE_ANNOTATION_COMMENT_MAX = 500; export const MESSAGE_ANNOTATION_MAX_COUNT = 10; +export const MESSAGE_ANNOTATION_CONTEXT_MAX = 200; /** * Loose shape of an assistant-ui thread message as consumed by the @@ -39,6 +40,8 @@ export interface AnnotationMessageLike { /** A pending selection inside one assistant message, resolved from the DOM. */ export interface AnnotationSelectionTarget { + contextBefore?: string; + contextAfter?: string; /** assistant-ui message id of the message the selection belongs to. */ messageId: string; /** Normalized selected text (CRLF collapsed to LF). */ @@ -49,6 +52,8 @@ export interface AnnotationSelectionTarget { /** Draft annotation captured from a completed selection. */ export interface MessageAnnotationDraft { + contextBefore?: string; + contextAfter?: string; /** The selected text quoted verbatim from the assistant reply. */ quotedText: string; /** Optional user comment attached to the quote. */ @@ -61,7 +66,7 @@ export interface MessageAnnotationDraft { export interface MessageAnnotation extends MessageAnnotationDraft { /** Runtime identity used for list keys and edit/delete actions. */ id: string; - /** Short excerpt of the source reply, shown in the list and the outgoing text. */ + /** Display-only source label; nearby context is sent to the model instead. */ sourceExcerpt: string; } @@ -86,6 +91,8 @@ export interface MessageAnnotationsControl { * `backend/contracts/annotations.py::MessageAnnotationItem`). */ export interface MessageAnnotationWireItem { + context_before?: string | null; + context_after?: string | null; quoted_text: string; comment?: string | null; source_excerpt?: string | null; @@ -152,10 +159,62 @@ export function resolveAnnotationSelection( const messageId = messageElement.getAttribute("data-message-id") ?? ""; if (!messageId) return null; + const content = anchorElement.closest(MESSAGE_CONTENT_SELECTOR); + const range = selection.getRangeAt(0).cloneRange(); + if ( + !content || + !content.contains(range.startContainer) || + !content.contains(range.endContainer) + ) { + return null; + } + // Endpoints alone cannot detect a selection spanning a tool/reasoning card. + if ( + Array.from(content.querySelectorAll(NON_QUOTABLE_SELECTOR)).some((node) => + range.intersectsNode(node) + ) + ) { + return null; + } + return { messageId, text: text.replace(/\r\n/g, "\n"), - range: selection.getRangeAt(0).cloneRange(), + range, + ...captureAnnotationContext(content, range), + }; +} + +/** Capture positional snapshots, preferring the endpoint paragraphs. */ +function captureAnnotationContext(content: Element, range: Range) { + const readSide = (before: boolean, paragraphOnly: boolean): string => { + const node = before ? range.startContainer : range.endContainer; + const paragraph = elementFromNode(node)?.closest( + "p, li, pre, blockquote, td, th" + ); + const root = paragraphOnly ? paragraph : content; + if (!root || !content.contains(root)) return ""; + const nearby = document.createRange(); + nearby.selectNodeContents(root); + if (before) nearby.setEnd(range.startContainer, range.startOffset); + else nearby.setStart(range.endContainer, range.endOffset); + const fragment = nearby.cloneContents(); + fragment + .querySelectorAll(NON_QUOTABLE_SELECTOR) + .forEach((node) => node.remove()); + fragment + .querySelectorAll("p, li, pre, blockquote, tr, h1, h2, h3, h4, h5, h6") + .forEach((node) => node.append(document.createTextNode("\n\n"))); + const text = (fragment.textContent ?? "").replace(/\r\n/g, "\n").trim(); + return before + ? text.slice(-MESSAGE_ANNOTATION_CONTEXT_MAX) + : text.slice(0, MESSAGE_ANNOTATION_CONTEXT_MAX); + }; + const contextBefore = readSide(true, true) || readSide(true, false); + const contextAfter = readSide(false, true) || readSide(false, false); + return { + ...(contextBefore ? { contextBefore } : {}), + ...(contextAfter ? { contextAfter } : {}), }; } @@ -207,6 +266,21 @@ export function createMessageAnnotation( return { id, ...draft, + ...(draft.contextBefore + ? { + contextBefore: draft.contextBefore.slice( + -MESSAGE_ANNOTATION_CONTEXT_MAX + ), + } + : {}), + ...(draft.contextAfter + ? { + contextAfter: draft.contextAfter.slice( + 0, + MESSAGE_ANNOTATION_CONTEXT_MAX + ), + } + : {}), // Defensive caps keep the structured payload inside the backend limits. quotedText: draft.quotedText.slice(0, MESSAGE_ANNOTATION_QUOTED_TEXT_MAX), comment: @@ -227,14 +301,32 @@ export function createMessageAnnotation( export function toMessageAnnotationWireItems( annotations: readonly MessageAnnotation[] ): MessageAnnotationWireItem[] { - return annotations.slice(0, MESSAGE_ANNOTATION_MAX_COUNT).map((annotation) => ({ - quoted_text: annotation.quotedText.slice( - 0, - MESSAGE_ANNOTATION_QUOTED_TEXT_MAX - ), - comment: annotation.comment?.slice(0, MESSAGE_ANNOTATION_COMMENT_MAX) ?? null, - source_excerpt: annotation.sourceExcerpt || null, - })); + return annotations + .slice(0, MESSAGE_ANNOTATION_MAX_COUNT) + .map((annotation) => ({ + quoted_text: annotation.quotedText.slice( + 0, + MESSAGE_ANNOTATION_QUOTED_TEXT_MAX + ), + comment: + annotation.comment?.slice(0, MESSAGE_ANNOTATION_COMMENT_MAX) ?? null, + source_excerpt: annotation.sourceExcerpt || null, + ...(annotation.contextBefore + ? { + context_before: annotation.contextBefore.slice( + -MESSAGE_ANNOTATION_CONTEXT_MAX + ), + } + : {}), + ...(annotation.contextAfter + ? { + context_after: annotation.contextAfter.slice( + 0, + MESSAGE_ANNOTATION_CONTEXT_MAX + ), + } + : {}), + })); } /** @@ -265,10 +357,28 @@ export function fromMessageAnnotationWireItems( : ""; items.push({ quoted_text: quotedText.slice(0, MESSAGE_ANNOTATION_QUOTED_TEXT_MAX), - comment: comment ? comment.slice(0, MESSAGE_ANNOTATION_COMMENT_MAX) : null, + comment: comment + ? comment.slice(0, MESSAGE_ANNOTATION_COMMENT_MAX) + : null, source_excerpt: sourceExcerpt ? sourceExcerpt.slice(0, MESSAGE_ANNOTATION_QUOTED_TEXT_MAX) : null, + ...(typeof record.context_before === "string" && + record.context_before.trim() + ? { + context_before: record.context_before + .trim() + .slice(-MESSAGE_ANNOTATION_CONTEXT_MAX), + } + : {}), + ...(typeof record.context_after === "string" && + record.context_after.trim() + ? { + context_after: record.context_after + .trim() + .slice(0, MESSAGE_ANNOTATION_CONTEXT_MAX), + } + : {}), }); if (items.length >= MESSAGE_ANNOTATION_MAX_COUNT) break; } diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index f20f411d14..b94fa478e1 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -490,10 +490,10 @@ "chat.questions.noMatches": "No matching questions", "chat.questions.current": "Current", "chat.questions.noReply": "No reply yet", - "chat.annotation.add": "Add annotation", + "chat.annotation.add": "Add to chat", "chat.annotation.commentPlaceholder": "Add an optional comment...", "chat.annotation.editPlaceholder": "Edit comment...", - "chat.annotation.confirm": "Add annotation", + "chat.annotation.confirm": "Add to chat", "chat.annotation.cancel": "Cancel annotation", "chat.annotation.chipLabel_one": "{{count}} annotation", "chat.annotation.chipLabel_other": "{{count}} annotations", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 74955ceee1..3586801bdf 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -488,10 +488,10 @@ "chat.questions.noMatches": "没有匹配的问题", "chat.questions.current": "当前", "chat.questions.noReply": "暂无回复", - "chat.annotation.add": "添加批注", + "chat.annotation.add": "添加到对话", "chat.annotation.commentPlaceholder": "添加可选批注…", "chat.annotation.editPlaceholder": "编辑批注…", - "chat.annotation.confirm": "添加批注", + "chat.annotation.confirm": "添加到对话", "chat.annotation.cancel": "取消批注", "chat.annotation.chipLabel": "{{count}} 条批注", "chat.annotation.listTitle": "批注", diff --git a/frontend/tests/component/messageAnnotation.test.tsx b/frontend/tests/component/messageAnnotation.test.tsx index bc84de63b4..2b6ba4c7b8 100644 --- a/frontend/tests/component/messageAnnotation.test.tsx +++ b/frontend/tests/component/messageAnnotation.test.tsx @@ -49,10 +49,40 @@ const makeAnnotation = ( afterEach(cleanup); describe("toMessageAnnotationWireItems", () => { + test("preserves nearby snapshots through outgoing and history mappings", () => { + const annotation = createMessageAnnotation( + { + quotedText: "this approach", + sourceMessageId: "assistant-1", + contextBefore: "x".repeat(250) + "Redis ", + contextAfter: " shares state." + "y".repeat(250), + }, + [], + "snapshot" + ); + const wire = toMessageAnnotationWireItems([annotation]); + expect(wire[0].context_before).toHaveLength(200); + expect(wire[0].context_before?.endsWith("Redis ")).toBe(true); + expect(wire[0].context_after).toHaveLength(200); + expect(wire[0].context_after?.startsWith(" shares state.")).toBe(true); + const restored = fromMessageAnnotationWireItems(wire); + expect(restored[0].context_before).toBe(wire[0].context_before?.trim()); + expect(restored[0].context_after).toBe(wire[0].context_after?.trim()); + expect( + fromMessageAnnotationWireItems([ + { quoted_text: "q", context_before: 42, context_after: {} }, + ])[0].context_before + ).toBeUndefined(); + }); test("maps camelCase annotations onto the snake_case wire shape", () => { const items = toMessageAnnotationWireItems([ makeAnnotation({ comment: "note", sourceExcerpt: "reply intro" }), - makeAnnotation({ id: "a2", quotedText: "second", comment: undefined, sourceExcerpt: "" }), + makeAnnotation({ + id: "a2", + quotedText: "second", + comment: undefined, + sourceExcerpt: "", + }), ]); expect(items).toEqual([ @@ -66,8 +96,9 @@ describe("toMessageAnnotationWireItems", () => { }); test("caps the list length and truncates oversized fields", () => { - const many = Array.from({ length: MESSAGE_ANNOTATION_MAX_COUNT + 3 }, (_, i) => - makeAnnotation({ id: `a${i}`, quotedText: `${i}` }) + const many = Array.from( + { length: MESSAGE_ANNOTATION_MAX_COUNT + 3 }, + (_, i) => makeAnnotation({ id: `a${i}`, quotedText: `${i}` }) ); const items = toMessageAnnotationWireItems(many); expect(items).toHaveLength(MESSAGE_ANNOTATION_MAX_COUNT); @@ -78,7 +109,9 @@ describe("toMessageAnnotationWireItems", () => { comment: "c".repeat(MESSAGE_ANNOTATION_COMMENT_MAX + 10), }), ]); - expect(oversized[0].quoted_text).toHaveLength(MESSAGE_ANNOTATION_QUOTED_TEXT_MAX); + expect(oversized[0].quoted_text).toHaveLength( + MESSAGE_ANNOTATION_QUOTED_TEXT_MAX + ); expect(oversized[0].comment).toHaveLength(MESSAGE_ANNOTATION_COMMENT_MAX); }); }); @@ -112,7 +145,9 @@ describe("fromMessageAnnotationWireItems", () => { source_excerpt: null, }, ]); - expect(items[0].quoted_text).toHaveLength(MESSAGE_ANNOTATION_QUOTED_TEXT_MAX); + expect(items[0].quoted_text).toHaveLength( + MESSAGE_ANNOTATION_QUOTED_TEXT_MAX + ); }); test("round-trips payloads produced by the outgoing mapping", () => { @@ -239,12 +274,63 @@ describe("createMessageAnnotation", () => { "generated-id" ); - expect(annotation.quotedText).toHaveLength(MESSAGE_ANNOTATION_QUOTED_TEXT_MAX); + expect(annotation.quotedText).toHaveLength( + MESSAGE_ANNOTATION_QUOTED_TEXT_MAX + ); expect(annotation.comment).toHaveLength(MESSAGE_ANNOTATION_COMMENT_MAX); }); }); describe("resolveAnnotationSelection", () => { + test("captures the selected occurrence and freezes its surrounding paragraph", () => { + const root = document.createElement("div"); + root.dataset.role = "assistant"; + root.dataset.messageId = "a1"; + root.innerHTML = `

      irrelevant intro

      local cache: same. Redis cache: same is shared.

      `; + document.body.append(root); + const paragraph = root.querySelectorAll("p")[1]; + const node = paragraph.firstChild!; + const range = document.createRange(); + const start = node.textContent!.lastIndexOf("same"); + range.setStart(node, start); + range.setEnd(node, start + 4); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + const target = resolveAnnotationSelection(selection)!; + expect(target.text).toBe("same"); + expect(target.contextBefore).toBe("local cache: same. Redis cache:"); + expect(target.contextAfter).toBe("is shared."); + paragraph.textContent = "replacement"; + expect(target.contextAfter).toBe("is shared."); + selection.removeAllRanges(); + root.remove(); + }); + + test("uses nearby paragraphs at boundaries, excludes tools and caps both sides", () => { + const root = document.createElement("div"); + root.dataset.role = "assistant"; + root.dataset.messageId = "a1"; + root.innerHTML = `

      ${"b".repeat(250)}

      secret tool result

      quote

      ${"a".repeat(250)}

      `; + document.body.append(root); + const node = root.querySelectorAll("p")[1].firstChild!; + const range = document.createRange(); + range.selectNodeContents(node); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + const target = resolveAnnotationSelection(selection)!; + expect(target.contextBefore).toBe("b".repeat(200)); + expect(target.contextAfter).toBe("a".repeat(200)); + // Crossing an excluded card is invalid even with valid endpoints. + range.setStart(root.querySelectorAll("p")[0].firstChild!, 0); + range.setEnd(node, 5); + selection.removeAllRanges(); + selection.addRange(range); + expect(resolveAnnotationSelection(selection)).toBeNull(); + selection.removeAllRanges(); + root.remove(); + }); const buildAssistantMessage = (id: string) => { const root = document.createElement("div"); root.setAttribute("data-role", "assistant"); @@ -283,7 +369,12 @@ describe("resolveAnnotationSelection", () => { anchorNode, focusNode, toString: () => text, - getRangeAt: () => document.createRange(), + getRangeAt: () => { + const range = document.createRange(); + range.setStart(anchorNode, 0); + range.setEnd(focusNode, focusNode.textContent?.length ?? 0); + return range; + }, }) as unknown as Selection; test("resolves a selection inside one assistant message", () => { @@ -516,7 +607,11 @@ describe("MessageAnnotationCards", () => { const { container } = render( diff --git a/frontend/types/conversation.ts b/frontend/types/conversation.ts index bb1f7627c0..111b805f30 100644 --- a/frontend/types/conversation.ts +++ b/frontend/types/conversation.ts @@ -101,6 +101,8 @@ export interface ApiMessageItem { } export interface ApiMessageAnnotation { + context_before?: string | null; + context_after?: string | null; quoted_text: string; comment?: string | null; source_excerpt?: string | null; diff --git a/test-e2e/changes/requirements/ADD-TO-CHAT-LOCAL-CONTEXT-20261105-verification.md b/test-e2e/changes/requirements/ADD-TO-CHAT-LOCAL-CONTEXT-20261105-verification.md new file mode 100644 index 0000000000..54b0548397 --- /dev/null +++ b/test-e2e/changes/requirements/ADD-TO-CHAT-LOCAL-CONTEXT-20261105-verification.md @@ -0,0 +1,25 @@ +# Reply annotation nearby-context verification + +Observed on 2026-11-05. Base revision: `3aae5d5395875f4312fffb9bbb7087bbc0e7e31b`, branch `feat/conversation-artifacts-panel`, plus the current working-tree delta. Pre-existing conversation branch, scrolling, artifacts and directory edits were preserved. No merge, remote reconciliation or deployment was performed. + +## Acceptance and implementation + +- Selection captures optional before/after snapshots from the actual DOM Range, not a first-occurrence string search. Prefer the endpoint paragraph; at paragraph boundaries use nearby reply text. Each side is limited to the nearest 200 characters. +- Exclude reasoning/tool containers from captured context and reject selections crossing those containers. Preserve the existing 1500-character selected-quote and 500-character comment limits. +- Keep the 40-character source label for UI display. Model input omits that label and separates contextual excerpts from selected text and the user's question. +- Extend frontend wire readers/writers and the backend annotation model with optional context fields. Existing generic JSON message-unit persistence, history projection, current-run formatting, historical-context formatting and branch unit copying carry the snapshots without schema changes. Existing payloads remain accepted. +- Strengthen existing frontend and backend UT. No new formal D1-D5 Case was introduced; unit assertions are not reported as runtime, browser, model or persistence acceptance. + +## Checks + +- Passed: 14 existing/new contract-focused pytest cases selected from `test/backend/services/test_message_annotations.py` (normalization, formatting, context limits, malformed and legacy payloads, JSON parsing). Because the full test module imports unavailable application dependencies, its contract-only imports/constants/functions were extracted with Python AST into a temporary pytest module. These tests execute the real `contracts.annotations` implementation. Runtime used bundled Python 3.12, not the required backend Python 3.11 environment. +- Passed: temporary Node/jsdom assertions against the real frontend helper implementation, using Node TypeScript stripping. Checked actual Range selection, repeated quote positions, frozen snapshots after DOM mutation, wire/history round-trip, adjacent-paragraph fallback, 200-character limits, tool exclusion and legacy malformed optional fields. This is an ad hoc helper check, not execution of the repository Vitest suite or a browser journey. +- Passed: Prettier check on all four changed TypeScript/TSX files; copyright header check on all six changed source/test files; `git diff --check`. +- Passed: unified design- and implementation-phase asset validation with `--require-ut-change ADD-TO-CHAT-LOCAL-CONTEXT-20261105`. Initial sandbox attempts failed because of missing PyYAML and temporary Excel directory permissions; the completed checks used temporary dependencies and approved execution outside the sandbox. + +## Remaining verification and prerequisites + +- Full `test/backend/services/test_message_annotations.py` collection is blocked by missing SQLAlchemy. The configured backend Python 3.11 environment and project dependency installation are absent. Persistence/service/history tests were strengthened to include context but have not run as a complete suite. +- Repository Vitest and TypeScript project checks are blocked by absent frontend `node_modules`; npm is not on PATH. Temporary helper tooling does not replace those checks. +- Sibling `nexent-doc` is absent, so the external SPEC update remains blocked. The repository change YAML records the authorized behavior and UT responsibilities; no substitute external SPEC was created. +- No live browser/API/model execution, generated functional coverage claim or Daily/merge readiness claim is made. diff --git a/test-e2e/changes/requirements/ADD-TO-CHAT-LOCAL-CONTEXT-20261105.yaml b/test-e2e/changes/requirements/ADD-TO-CHAT-LOCAL-CONTEXT-20261105.yaml new file mode 100644 index 0000000000..e319f39ee5 --- /dev/null +++ b/test-e2e/changes/requirements/ADD-TO-CHAT-LOCAL-CONTEXT-20261105.yaml @@ -0,0 +1,46 @@ +schema_version: "1.0" +change: + change_id: ADD-TO-CHAT-LOCAL-CONTEXT-20261105 + change_type: requirement + requirement_id: ADD-TO-CHAT-LOCAL-CONTEXT-20261105 + title: Capture nearby reply context for add-to-chat quotes + description: >- + Preserve the 40-character source display label, but omit it from model + input. Capture optional context_before/context_after snapshots at selection + time, preferring the selected paragraph and limiting each side to 200 + characters nearest the selection. Fall back to nearby reply text at paragraph + boundaries. Exclude reasoning/tool content. Preserve these fields through + request normalization, JSON unit persistence and history restoration. + Legacy annotations without context remain readable and sendable. + affected_features: + existing: [] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: strengthen + test_selectors: + - frontend/tests/component/messageAnnotation.test.tsx + - test/backend/services/test_message_annotations.py + behaviors: + - Capture positional context for repeated quotes and preserve selection-time snapshots. + - Bound nearby context and exclude tool/reasoning text and invalid selections. + - Round-trip optional context through wire payloads, persistence and history. + - Format context separately from the selected quote and omit source display labels. + - Preserve legacy annotations and reject malformed payloads safely. + reason: >- + Strengthen the existing annotation UT rather than duplicate isolated + contracts in new D1 Cases. Existing annotation change records have no + formal Feature/Case bindings. D2-D5 runtime/browser/model acceptance is + not claimed by these tests; no deployment or external system is changed. + notes: >- + Baseline: ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102. Initial branch: + feat/conversation-artifacts-panel, with unrelated uncommitted changes. + The configured sibling nexent-doc repository is absent; external SPEC + synchronization remains blocked. This record captures the authorized delta + and test intentions without inventing an external SPEC or execution evidence. diff --git a/test/backend/services/test_message_annotations.py b/test/backend/services/test_message_annotations.py index 40cd2a0a6e..42b0d81755 100644 --- a/test/backend/services/test_message_annotations.py +++ b/test/backend/services/test_message_annotations.py @@ -27,6 +27,8 @@ 'quoted_text': 'quoted reply text', 'comment': 'note', 'source_excerpt': 'reply intro', + 'context_before': 'Redis shares the cache.', + 'context_after': 'Consider expiration.', } @@ -84,6 +86,33 @@ def test_format_without_annotations_returns_text_unchanged(): assert format_annotations_for_llm('why stop?', []) == 'why stop?' +def test_context_caps_keep_nearest_text_and_reject_invalid_fields(): + normalized = normalize_message_annotations([{ + 'quoted_text': 'selected', + 'context_before': 'x' * 250 + ' nearest', + 'context_after': 'nearest ' + 'y' * 250, + }]) + assert len(normalized[0].context_before) == 200 + assert normalized[0].context_before.endswith(' nearest') + assert len(normalized[0].context_after) == 200 + assert normalized[0].context_after.startswith('nearest ') + assert normalize_message_annotations([{'quoted_text': 'q', 'context_before': {}}]) is None + legacy = normalize_message_annotations([{'quoted_text': 'legacy', 'source_excerpt': 'intro'}]) + assert legacy[0].context_before is None + assert format_annotations_for_llm('', legacy) == '1. Quote:\n> legacy' + + +def test_context_is_distinct_from_selection_and_source_label_is_ui_only(): + merged = format_annotations_for_llm('why?', normalize_message_annotations([ANNOTATION])) + assert merged == ( + '1. Quote:\nContext before (not selected):\n> Redis shares the cache.\n' + 'Selected text:\n> quoted reply text\n' + 'Context after (not selected):\n> Consider expiration.\n' + 'Comment: note\n\n---\n\nwhy?' + ) + assert 'reply intro' not in merged + + def test_format_merges_blocks_comment_and_source_into_user_text(): annotations = normalize_message_annotations([ {'quoted_text': 'line one\nline two', 'comment': 'skip this', @@ -92,7 +121,7 @@ def test_format_merges_blocks_comment_and_source_into_user_text(): merged = format_annotations_for_llm('why stop?', annotations) - assert merged == ('1. Quote (from reply "reply intro"):\n' + assert merged == ('1. Quote:\n' '> line one\n> line two\n' 'Comment: skip this\n\n---\n\nwhy stop?') @@ -287,8 +316,11 @@ def test_historical_turns_merge_annotations_into_model_facing_text(message_db): assert len(turns) == 1 assert turns[0]['user_message'] == ( - '1. Quote (from reply "reply intro"):\n' + '1. Quote:\n' + 'Context before (not selected):\n> Redis shares the cache.\n' + 'Selected text:\n' '> quoted reply text\n' + 'Context after (not selected):\n> Consider expiration.\n' 'Comment: note\n\n---\n\nwhy stop?' ) assert turns[0]['assistant_final_answer'] == 'because of the quota' From 51c227cbeff8eb8cb62c8b190459bcb793a47433 Mon Sep 17 00:00:00 2001 From: ljy Date: Thu, 5 Nov 2026 14:28:47 +0800 Subject: [PATCH 16/26] feat(chat): move artifacts entry to assistant messages and wire scroll rescue --- .../[locale]/newchat/assistant-ui/thread.tsx | 183 +++++++++--------- .../[locale]/newchat/ui/artifacts-panel.tsx | 33 +++- .../ui/conversation-artifacts-context.tsx | 56 ++++++ .../component/conversationArtifacts.test.tsx | 36 ++++ 4 files changed, 219 insertions(+), 89 deletions(-) create mode 100644 frontend/app/[locale]/newchat/ui/conversation-artifacts-context.tsx diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 3e4ab68ff5..36e799f68a 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -64,9 +64,12 @@ import { import { Sources } from "../ui/sources"; import { SourcesPanel } from "../ui/sources-panel"; import { - ConversationArtifactsButton, + ConversationArtifactsMessageEntry, ConversationArtifactsPanel, } from "../ui/artifacts-panel"; +import { + ConversationArtifactsEntryProvider, +} from "../ui/conversation-artifacts-context"; import { QuestionDirectoryButton } from "../ui/question-directory"; import { markFileWithExistingUpload } from "../adapter/attachment-adapter"; import { @@ -77,6 +80,7 @@ import { } from "../utils/conversation-artifacts"; import { MessageAnnotationBubble } from "./message-annotation-bubble"; import { MessageAnnotationCards } from "./message-annotation-cards"; +import { ViewportScrollRescue } from "./viewport-scroll-rescue"; import { createMessageAnnotation, MESSAGE_ANNOTATION_COMMENT_MAX, @@ -368,14 +372,16 @@ export const Thread: FC = ({ }; const messages = useAuiState((s) => s.thread.messages); - // Conversation-wide agent-generated files feed the artifacts entry above the - // composer and the side panel it opens. Computed here so both the button and - // the panel share one aggregation pass over the thread messages. + // Conversation-wide agent-generated files feed the trailing "view all + // artifacts" entry on each assistant message and the side panel it opens. + // Computed here so the entry and the panel share one aggregation pass over + // the thread messages. const conversationArtifacts = useConversationArtifacts(messages); // Conversation-wide user questions feed the question directory entry at the // top of the thread. Same single-pass pattern as the artifacts aggregation. const questionDirectory = useQuestionDirectory(messages); const [artifactsOpen, setArtifactsOpen] = useState(false); + const openArtifactsPanel = useCallback(() => setArtifactsOpen(true), []); const currentThreadTitle = useAuiState((s) => { const currentThread = s.threads.threadItems.find( (item) => item.id === s.threads.mainThreadId @@ -580,83 +586,88 @@ export const Thread: FC = ({ > - void enterShareMode()} - onLeaveShareMode={leaveShareMode} - onToggleShareAll={toggleShareAll} - onToggleShareMessage={toggleShareMessage} - onCreateShare={createShare} - selection={selection} - onPanelClose={close} - conversationArtifacts={conversationArtifacts} - artifactsOpen={artifactsOpen} - onArtifactsOpenChange={setArtifactsOpen} - questionDirectory={questionDirectory} - messageAnnotations={messageAnnotationsControl} - /> + + void enterShareMode()} + onLeaveShareMode={leaveShareMode} + onToggleShareAll={toggleShareAll} + onToggleShareMessage={toggleShareMessage} + onCreateShare={createShare} + selection={selection} + onPanelClose={close} + conversationArtifacts={conversationArtifacts} + artifactsOpen={artifactsOpen} + onArtifactsOpenChange={setArtifactsOpen} + questionDirectory={questionDirectory} + messageAnnotations={messageAnnotationsControl} + /> + !open && setManualShareUrl(null)} @@ -1059,6 +1070,9 @@ const ThreadView: FC = ({ "relative mt-[max(24px,calc(50cqh-220px))] flex-none max-w-[890px] overflow-visible px-4 pb-0 pt-6 sm:px-8" )} > + {/* Re-implements the auto-scroll triggers that the legacy runtime + fails to deliver (run start / history load / streaming follow). */} + {workbenchLanding && (
      = ({ {!newChatDesign && (!debugLayout || hasMessages) && ( )} - {conversationArtifacts.length > 0 && ( - onArtifactsOpenChange(true)} - /> - )} {!hasMessages && emptyFooterContent && (debugLayout ? ( @@ -1928,6 +1936,7 @@ const AssistantMessage: FC<{ {skillFileAttachments?.length ? ( ) : null} + {typeof createdAgent?.agentId === "number" && createdAgent.agentId > 0 ? ( ); }; + +export interface ConversationArtifactsMessageEntryProps { + className?: string; +} + +/** + * Trailing "view all artifacts" entry rendered at the end of each assistant + * message once the conversation has agent-generated files. Reads the + * thread-level artifacts context so every message can open the shared + * {@link ConversationArtifactsPanel}; renders nothing when no provider is + * mounted or the conversation has no artifacts yet. + */ +export const ConversationArtifactsMessageEntry: FC< + ConversationArtifactsMessageEntryProps +> = ({ className }) => { + const entry = useConversationArtifactsEntry(); + if (!entry || entry.count <= 0) return null; + return ( + + ); +}; diff --git a/frontend/app/[locale]/newchat/ui/conversation-artifacts-context.tsx b/frontend/app/[locale]/newchat/ui/conversation-artifacts-context.tsx new file mode 100644 index 0000000000..1a72594420 --- /dev/null +++ b/frontend/app/[locale]/newchat/ui/conversation-artifacts-context.tsx @@ -0,0 +1,56 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { + createContext, + useContext, + useMemo, + type FC, + type ReactNode, +} from "react"; + +/** + * Conversation-wide artifacts state exposed to assistant messages. + * + * The thread root aggregates every agent-generated file and owns the side + * panel's open state, while the "view all artifacts" entry is rendered at the + * end of each assistant message. This context bridges the two without prop + * drilling through the assistant-ui message renderers. + */ +export interface ConversationArtifactsEntryValue { + /** Total agent-generated files collected across the conversation. */ + count: number; + /** Opens the conversation artifacts panel. */ + open: () => void; +} + +const ConversationArtifactsEntryContext = createContext< + ConversationArtifactsEntryValue | null +>(null); + +export interface ConversationArtifactsEntryProviderProps + extends ConversationArtifactsEntryValue { + children: ReactNode; +} + +export const ConversationArtifactsEntryProvider: FC< + ConversationArtifactsEntryProviderProps +> = ({ count, open, children }) => { + // The thread root re-renders on every streamed token; a memoized value + // keeps message-level consumers from re-rendering unless the count changed. + const value = useMemo(() => ({ count, open }), [count, open]); + return ( + + {children} + + ); +}; + +/** + * Returns the conversation artifacts entry state, or null when no provider + * is mounted (e.g. message surfaces outside the main thread). + */ +export function useConversationArtifactsEntry() { + return useContext(ConversationArtifactsEntryContext); +} diff --git a/frontend/tests/component/conversationArtifacts.test.tsx b/frontend/tests/component/conversationArtifacts.test.tsx index c48664393c..8bf8ad9f66 100644 --- a/frontend/tests/component/conversationArtifacts.test.tsx +++ b/frontend/tests/component/conversationArtifacts.test.tsx @@ -16,8 +16,12 @@ import { } from "@/app/newchat/utils/conversation-artifacts"; import { ConversationArtifactsButton, + ConversationArtifactsMessageEntry, ConversationArtifactsPanel, } from "@/app/newchat/ui/artifacts-panel"; +import { + ConversationArtifactsEntryProvider, +} from "@/app/newchat/ui/conversation-artifacts-context"; import { compositeAttachmentAdapter, markFileWithExistingUpload, @@ -421,6 +425,38 @@ test("ConversationArtifactsButton opens the panel on click", () => { expect(onClick).toHaveBeenCalledTimes(1); }); +test("ConversationArtifactsMessageEntry renders nothing without a provider", () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); +}); + +test("ConversationArtifactsMessageEntry hides while the conversation has no artifacts", () => { + const { container } = render( + {}}> + + + ); + + expect(container.firstChild).toBeNull(); +}); + +test("ConversationArtifactsMessageEntry shows the conversation total and opens the panel", () => { + const open = vi.fn(); + render( + + + + ); + + const button = screen.getByRole("button", { + name: "chat.artifacts.viewAll:2", + }); + fireEvent.click(button); + + expect(open).toHaveBeenCalledTimes(1); +}); + const renderArtifactsPanel = ( panelProps: Partial[0]> = {} ) => { From 3d271516393c338b19595dd0f151f1a21829996d Mon Sep 17 00:00:00 2001 From: ljy Date: Mon, 9 Nov 2026 15:10:00 +0800 Subject: [PATCH 17/26] feat(chat): add question bookmarks and automatic document previews --- backend/contracts/annotations.py | 18 +- .../api/conversation/question_bookmarks.py | 50 +++ .../management/db/conversation/bookmarks.py | 50 +++ backend/management/db/conversation/models.py | 6 + .../services/conversation/bookmarks.py | 27 ++ backend/runtime/api/application.py | 2 + .../v2.9.0_009_question_bookmarks.sql | 6 + .../message-annotation-bubble.tsx | 2 - .../[locale]/newchat/assistant-ui/thread.tsx | 41 ++- .../components/artifact-document-panel.tsx | 115 ++++++ .../components/use-auto-artifact-preview.ts | 173 +++++++++ .../[locale]/newchat/ui/artifacts-panel.tsx | 10 +- .../newchat/ui/question-bookmark-rail.tsx | 101 ++++++ .../newchat/ui/question-directory-rail.tsx | 263 +++++++++----- .../newchat/ui/question-directory.tsx | 293 ---------------- .../newchat/utils/message-annotations.ts | 91 +---- .../components/common/filePreviewDrawer.tsx | 12 + .../common/rawStreamPreviewViewer.module.css | 10 + .../common/rawStreamPreviewViewer.tsx | 9 +- frontend/hooks/chat/useQuestionBookmarks.ts | 84 +++++ frontend/public/locales/en/common.json | 6 +- frontend/public/locales/zh/common.json | 6 +- frontend/services/api.ts | 4 + frontend/services/conversationService.ts | 22 ++ .../component/artifactDocumentPanel.test.tsx | 120 +++++++ .../component/autoArtifactPreview.test.tsx | 175 +++++++++ .../component/docxPreviewBackground.test.tsx | 93 +++++ .../component/inlineFilePreview.test.tsx | 77 ++++ .../component/messageAnnotation.test.tsx | 47 +-- .../component/questionBookmarks.test.tsx | 231 ++++++++++++ .../component/questionDirectory.test.tsx | 331 +++++------------- .../component/rawStreamPreviewViewer.test.tsx | 2 + frontend/types/chat.ts | 3 +- frontend/types/conversation.ts | 2 - ...ELECTED-TEXT-ONLY-20261109.verification.md | 27 ++ ...NNOTATION-SELECTED-TEXT-ONLY-20261109.yaml | 45 +++ ...UTO-OPEN-DOCUMENT-20261109.verification.md | 49 +++ .../AUTO-OPEN-DOCUMENT-20261109.yaml | 51 +++ ...UESTION-BOOKMARKS-20261109.verification.md | 63 ++++ .../QUESTION-BOOKMARKS-20261109.yaml | 52 +++ ...E-QUESTION-DIRECTORY-POPOVER-20261109.yaml | 42 +++ .../services/test_message_annotations.py | 39 ++- .../services/test_question_bookmarks.py | 123 +++++++ 43 files changed, 2195 insertions(+), 778 deletions(-) create mode 100644 backend/management/api/conversation/question_bookmarks.py create mode 100644 backend/management/db/conversation/bookmarks.py create mode 100644 backend/management/services/conversation/bookmarks.py create mode 100644 deploy/sql/migrations/v2.9.0_009_question_bookmarks.sql create mode 100644 frontend/app/[locale]/newchat/components/artifact-document-panel.tsx create mode 100644 frontend/app/[locale]/newchat/components/use-auto-artifact-preview.ts create mode 100644 frontend/app/[locale]/newchat/ui/question-bookmark-rail.tsx delete mode 100644 frontend/app/[locale]/newchat/ui/question-directory.tsx create mode 100644 frontend/components/common/rawStreamPreviewViewer.module.css create mode 100644 frontend/hooks/chat/useQuestionBookmarks.ts create mode 100644 frontend/tests/component/artifactDocumentPanel.test.tsx create mode 100644 frontend/tests/component/autoArtifactPreview.test.tsx create mode 100644 frontend/tests/component/docxPreviewBackground.test.tsx create mode 100644 frontend/tests/component/inlineFilePreview.test.tsx create mode 100644 frontend/tests/component/questionBookmarks.test.tsx create mode 100644 test-e2e/changes/requirements/ANNOTATION-SELECTED-TEXT-ONLY-20261109.verification.md create mode 100644 test-e2e/changes/requirements/ANNOTATION-SELECTED-TEXT-ONLY-20261109.yaml create mode 100644 test-e2e/changes/requirements/AUTO-OPEN-DOCUMENT-20261109.verification.md create mode 100644 test-e2e/changes/requirements/AUTO-OPEN-DOCUMENT-20261109.yaml create mode 100644 test-e2e/changes/requirements/QUESTION-BOOKMARKS-20261109.verification.md create mode 100644 test-e2e/changes/requirements/QUESTION-BOOKMARKS-20261109.yaml create mode 100644 test-e2e/changes/requirements/REMOVE-QUESTION-DIRECTORY-POPOVER-20261109.yaml create mode 100644 test/backend/services/test_question_bookmarks.py diff --git a/backend/contracts/annotations.py b/backend/contracts/annotations.py index 7fc98a73f7..ad43df54a3 100644 --- a/backend/contracts/annotations.py +++ b/backend/contracts/annotations.py @@ -24,7 +24,6 @@ ANNOTATION_COMMENT_MAX_CHARS = 500 ANNOTATION_SOURCE_EXCERPT_MAX_CHARS = 120 ANNOTATION_MAX_COUNT = 10 -ANNOTATION_CONTEXT_MAX_CHARS = 200 class MessageAnnotationItem(BaseModel): @@ -32,14 +31,13 @@ class MessageAnnotationItem(BaseModel): Length caps are enforced by ``normalize_message_annotations`` (truncating rather than rejecting) so oversized client payloads degrade gracefully - instead of failing the whole send. + instead of failing the whole send. Legacy neighboring-context fields are + ignored when reading requests and persisted units. """ quoted_text: str comment: Optional[str] = None source_excerpt: Optional[str] = None - context_before: Optional[str] = None - context_after: Optional[str] = None def normalize_message_annotations( @@ -71,8 +69,6 @@ def normalize_message_annotations( continue comment = (annotation.comment or "").strip() or None excerpt = (annotation.source_excerpt or "").strip() or None - before = (annotation.context_before or "").strip() or None - after = (annotation.context_after or "").strip() or None annotations.append( MessageAnnotationItem( quoted_text=quoted[:ANNOTATION_QUOTED_TEXT_MAX_CHARS], @@ -82,8 +78,6 @@ def normalize_message_annotations( if excerpt else None ), - context_before=before[-ANNOTATION_CONTEXT_MAX_CHARS:] if before else None, - context_after=after[:ANNOTATION_CONTEXT_MAX_CHARS] if after else None, ) ) if len(annotations) >= ANNOTATION_MAX_COUNT: @@ -110,16 +104,8 @@ def parse_message_annotation_unit(unit_content: Optional[str]) -> Optional[List[ def _annotation_block(index: int, annotation: MessageAnnotationItem) -> str: # The reply intro is a UI label, not reliable model-facing context. lines = [f"{index}. Quote:"] - if annotation.context_before: - lines.append("Context before (not selected):") - lines.extend(f"> {line}" for line in annotation.context_before.split("\n")) - if annotation.context_before or annotation.context_after: - lines.append("Selected text:") for line in annotation.quoted_text.split("\n"): lines.append(f"> {line}" if line else ">") - if annotation.context_after: - lines.append("Context after (not selected):") - lines.extend(f"> {line}" for line in annotation.context_after.split("\n")) if annotation.comment: lines.append(f"Comment: {annotation.comment}") return "\n".join(lines) diff --git a/backend/management/api/conversation/question_bookmarks.py b/backend/management/api/conversation/question_bookmarks.py new file mode 100644 index 0000000000..e2642269ac --- /dev/null +++ b/backend/management/api/conversation/question_bookmarks.py @@ -0,0 +1,50 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Authenticated owner-private question bookmark endpoints.""" + +import logging +from typing import Annotated, Optional + +from fastapi import APIRouter, Body, Header, HTTPException, Path +from fastapi.responses import JSONResponse +from pydantic import StrictBool + +from contracts.errors.exceptions import ConversationNotFoundError, TokenExpiredError, UnauthorizedError, ValidationError +from gateway.authentication.auth import get_current_user_id +from management.services.conversation.bookmarks import list_question_bookmarks, set_question_bookmark + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/conversation") +PositiveId = Annotated[int, Path(gt=0, le=2147483647)] + + +def _execute(authorization, operation): + try: + user_id, tenant_id = get_current_user_id(authorization) + return JSONResponse(content={"code": 0, "message": "success", "data": operation(user_id, tenant_id)}) + except (TokenExpiredError, UnauthorizedError) as exc: + raise HTTPException(status_code=401, detail="Please login first") from exc + except ConversationNotFoundError as exc: + raise HTTPException(status_code=404, detail="Conversation or question not found") from exc + except ValidationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except Exception as exc: + logger.exception("Question bookmark operation failed") + raise HTTPException(status_code=500, detail="Question bookmark operation failed") from exc + + +@router.get("/{conversation_id}/question_bookmarks") +def get_bookmarks(conversation_id: PositiveId, authorization: Optional[str] = Header(None)): + return _execute(authorization, lambda user, tenant: list_question_bookmarks(conversation_id, user, tenant)) + + +@router.put("/{conversation_id}/question_bookmarks/{message_id}") +def put_bookmark( + conversation_id: PositiveId, + message_id: PositiveId, + bookmarked: Annotated[StrictBool, Body(embed=True)], + authorization: Optional[str] = Header(None), +): + return _execute( + authorization, lambda user, tenant: set_question_bookmark(conversation_id, message_id, bookmarked, user, tenant) + ) diff --git a/backend/management/db/conversation/bookmarks.py b/backend/management/db/conversation/bookmarks.py new file mode 100644 index 0000000000..c26ffc6406 --- /dev/null +++ b/backend/management/db/conversation/bookmarks.py @@ -0,0 +1,50 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Owner-private question bookmarks without modifying conversation activity.""" + +from sqlalchemy import select, update + +from contracts.errors.exceptions import ConversationNotFoundError +from management.db.client import get_db_session +from management.db.conversation.models import ConversationMessage, ConversationRecord + + +def _owned_conversation(conversation_id: int, user_id: str): + return ( + ConversationRecord.conversation_id == conversation_id, + ConversationRecord.created_by == user_id, + ConversationRecord.delete_flag == "N", + ) + + +def _questions(conversation_id: int): + return ( + ConversationMessage.conversation_id == conversation_id, + ConversationMessage.message_role == "user", + ConversationMessage.delete_flag == "N", + ) + + +def question_bookmarks(conversation_id: int, user_id: str, message_id=None, bookmarked=None) -> list[int]: + """Read or atomically set an explicit desired state on an owned question.""" + with get_db_session() as session: + statement = select(ConversationRecord.conversation_id).where(*_owned_conversation(conversation_id, user_id)) + # Serialize writes within this conversation; list calls remain read-only. + if message_id is not None: + statement = statement.with_for_update() + if session.scalar(statement) is None: + raise ConversationNotFoundError("Conversation not found") + if message_id is not None: + saved = session.scalar( + update(ConversationMessage) + .where(*_questions(conversation_id), ConversationMessage.message_id == message_id) + .values(question_bookmarked=bookmarked, update_time=ConversationMessage.update_time) + .returning(ConversationMessage.message_id) + ) + if saved is None: + raise ConversationNotFoundError("Question not found") + return list(session.scalars( + select(ConversationMessage.message_id) + .where(*_questions(conversation_id), ConversationMessage.question_bookmarked.is_(True)) + .order_by(ConversationMessage.message_id) + )) diff --git a/backend/management/db/conversation/models.py b/backend/management/db/conversation/models.py index 0b8e2bb15b..a1f8ebecfc 100644 --- a/backend/management/db/conversation/models.py +++ b/backend/management/db/conversation/models.py @@ -3,6 +3,7 @@ from sqlalchemy import ( TIMESTAMP, BigInteger, + Boolean, Column, Index, Integer, @@ -91,6 +92,11 @@ class ConversationMessage(TableBase): __tablename__ = "conversation_message_t" __table_args__ = {"schema": SCHEMA} + question_bookmarked = Column( + Boolean, nullable=False, server_default=text("false"), + comment="Owner's question bookmark; independent of message content and conversation activity", + ) + message_id = Column(Integer, Sequence( "conversation_message_t_message_id_seq", schema=SCHEMA), primary_key=True, nullable=False) conversation_id = Column( diff --git a/backend/management/services/conversation/bookmarks.py b/backend/management/services/conversation/bookmarks.py new file mode 100644 index 0000000000..3177176383 --- /dev/null +++ b/backend/management/services/conversation/bookmarks.py @@ -0,0 +1,27 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Validation boundary for authenticated personal question bookmarks.""" + +from contracts.errors.exceptions import UnauthorizedError, ValidationError +from management.db.conversation.bookmarks import question_bookmarks + + +def _validate(conversation_id: int, user_id: str, tenant_id: str) -> None: + if not user_id or not tenant_id: + raise UnauthorizedError("Please login first") + if len(user_id) > 100 or len(tenant_id) > 100: + raise ValidationError("Invalid bookmark identity") + if type(conversation_id) is not int or not 0 < conversation_id <= 2147483647: + raise ValidationError("Invalid conversation id") + + +def list_question_bookmarks(conversation_id: int, user_id: str, tenant_id: str) -> dict: + _validate(conversation_id, user_id, tenant_id) + return {"message_ids": question_bookmarks(conversation_id, user_id)} + + +def set_question_bookmark(conversation_id: int, message_id: int, bookmarked: bool, user_id: str, tenant_id: str) -> dict: + _validate(conversation_id, user_id, tenant_id) + if type(message_id) is not int or not 0 < message_id <= 2147483647 or type(bookmarked) is not bool: + raise ValidationError("Invalid question bookmark") + return {"message_ids": question_bookmarks(conversation_id, user_id, message_id, bookmarked)} diff --git a/backend/runtime/api/application.py b/backend/runtime/api/application.py index 69ac1431d4..0ad1f67611 100644 --- a/backend/runtime/api/application.py +++ b/backend/runtime/api/application.py @@ -14,6 +14,7 @@ from runtime.api.voice.streaming import voice_runtime_router as voice_router from management.api.conversation.conversation_management import router as conversation_management_router from management.api.conversation.workbench_pins import router as workbench_pins_router +from management.api.conversation.question_bookmarks import router as question_bookmarks_router from management.api.conversation.conversation_share import router as conversation_share_router from runtime.api.file.upload import file_management_runtime_router as file_management_router from runtime.api.skill.creation import skill_creator_router @@ -109,6 +110,7 @@ async def thread_capacity(): app.include_router(agent_automation_router) app.include_router(conversation_automation_router) app.include_router(workbench_pins_router) +app.include_router(question_bookmarks_router) app.include_router(conversation_management_router) app.include_router(conversation_share_router) app.include_router(file_management_router) diff --git a/deploy/sql/migrations/v2.9.0_009_question_bookmarks.sql b/deploy/sql/migrations/v2.9.0_009_question_bookmarks.sql new file mode 100644 index 0000000000..34f50a4027 --- /dev/null +++ b/deploy/sql/migrations/v2.9.0_009_question_bookmarks.sql @@ -0,0 +1,6 @@ +-- Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +ALTER TABLE nexent.conversation_message_t + ADD COLUMN IF NOT EXISTS question_bookmarked BOOLEAN NOT NULL DEFAULT FALSE; +COMMENT ON COLUMN nexent.conversation_message_t.question_bookmarked IS + 'Owner question bookmark; independent of message content and conversation activity'; diff --git a/frontend/app/[locale]/newchat/assistant-ui/message-annotation-bubble.tsx b/frontend/app/[locale]/newchat/assistant-ui/message-annotation-bubble.tsx index f561738a3e..a5d16a7761 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/message-annotation-bubble.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/message-annotation-bubble.tsx @@ -136,8 +136,6 @@ export const MessageAnnotationBubble: FC<{ quotedText: current.target.text, comment: current.comment.trim() || undefined, sourceMessageId: current.target.messageId, - contextBefore: current.target.contextBefore, - contextAfter: current.target.contextAfter, }); setPhase({ kind: "hidden" }); // Drop the text selection so the bubble does not re-open for the same diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 4d313f030b..185f0649fb 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -67,6 +67,9 @@ import { } from "../ui/artifacts-panel"; import { ConversationArtifactsEntryProvider } from "../ui/conversation-artifacts-context"; import { QuestionDirectoryRail } from "../ui/question-directory-rail"; +import { QuestionBookmarkRail } from "../ui/question-bookmark-rail"; +import { useAutoArtifactPreview } from "../components/use-auto-artifact-preview"; +import { ArtifactDocumentPanel } from "../components/artifact-document-panel"; import { markFileWithExistingUpload } from "../adapter/attachment-adapter"; import { fetchArtifactFile, @@ -372,7 +375,6 @@ export const Thread: FC = ({ // top of the thread. Same single-pass pattern as the artifacts aggregation. const questionDirectory = useQuestionDirectory(messages); const [artifactsOpen, setArtifactsOpen] = useState(false); - const openArtifactsPanel = useCallback(() => setArtifactsOpen(true), []); const currentThreadTitle = useAuiState((s) => { const currentThread = s.threads.threadItems.find( (item) => item.id === s.threads.mainThreadId @@ -410,6 +412,18 @@ export const Thread: FC = ({ readOnly, }); + const artifactPreview = useAutoArtifactPreview({ + scopeKey: threadId, + artifacts: conversationArtifacts, + isRunning, + isLoading, + enabled: !readOnly && !isShareMode && !configurationLayout && !debugLayout, + }); + const openArtifactsPanel = useCallback(() => { + artifactPreview.close(); + setArtifactsOpen(true); + }, [artifactPreview.close]); + const aui = useAui(); const [isBranching, setIsBranching] = useState(false); const branchDisabled = @@ -615,6 +629,7 @@ export const Thread: FC = ({ selection={selection} onPanelClose={close} conversationArtifacts={conversationArtifacts} + artifactPreview={artifactPreview} artifactsOpen={artifactsOpen} onArtifactsOpenChange={setArtifactsOpen} questionDirectory={questionDirectory} @@ -722,6 +737,7 @@ interface ThreadViewProps { selection: SourcesPanelSelection | null; onPanelClose: () => void; conversationArtifacts: ConversationArtifact[]; + artifactPreview: ReturnType; artifactsOpen: boolean; onArtifactsOpenChange: (open: boolean) => void; questionDirectory: QuestionDirectoryEntry[]; @@ -783,6 +799,7 @@ const ThreadView: FC = ({ selection, onPanelClose, conversationArtifacts, + artifactPreview, artifactsOpen, onArtifactsOpenChange, questionDirectory, @@ -969,7 +986,10 @@ const ThreadView: FC = ({ )} - = ({ messageId={selection?.messageId} sources={selection?.sources ?? []} images={selection?.images ?? []} - open={selection !== null} + open={selection !== null && !artifactPreview.selected} selectedCitationKey={selection?.selectedCitationKey} citationContext={selection?.citationContext} selectionToken={selection ?? undefined} @@ -1125,8 +1145,9 @@ const ThreadView: FC = ({ /> onArtifactsOpenChange(false)} onAddToComposer={ showComposer && !isShareMode && !readOnly @@ -1135,6 +1156,18 @@ const ThreadView: FC = ({ } /> + {/* The document surface occupies a sibling column without covering desktop chat. */} + {artifactPreview.selected && ( + { + artifactPreview.close(); + onArtifactsOpenChange(true); + }} + /> + )} + {/* Mounted only while quotable so the bubble state resets whenever the thread turns read-only, enters share mode, or switches to a preview layout. */} diff --git a/frontend/app/[locale]/newchat/components/artifact-document-panel.tsx b/frontend/app/[locale]/newchat/components/artifact-document-panel.tsx new file mode 100644 index 0000000000..dd37da7771 --- /dev/null +++ b/frontend/app/[locale]/newchat/components/artifact-document-panel.tsx @@ -0,0 +1,115 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useTranslation } from "react-i18next"; +import { Download, FolderOutput, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { FilePreviewDrawer } from "@/components/common/filePreviewDrawer"; +import { API_ENDPOINTS } from "@/services/api"; +import { storageService } from "@/services/storageService"; +import { getFileExtension } from "@/lib/officePreview"; +import { triggerUrlDownload } from "../ui/attachment"; +import type { + ArtifactAttachmentLike, + ConversationArtifact, +} from "../utils/conversation-artifacts"; +import { artifactPreviewIdentity } from "./use-auto-artifact-preview"; + +/** Original OOXML bytes use the existing browser renderer, never the PDF renderer. */ +export function artifactDocumentSource(attachment: ArtifactAttachmentLike) { + const name = attachment.name || "Generated file"; + const extension = getFileExtension(name); + const rawStream = ["docx", "xlsx", "pptx"].includes(extension); + const part = attachment.content?.[0] as { data?: string } | undefined; + const direct = + attachment.download_url || + attachment.presigned_url || + attachment.url || + part?.data || + attachment.preview_url || + ""; + const stream = attachment.object_name + ? API_ENDPOINTS.storage.file(attachment.object_name, "stream", name) + : direct; + const previewUrl = rawStream + ? stream + : attachment.object_name + ? storageService.getPreviewUrl(attachment.object_name, name) + : attachment.preview_url || direct; + return { previewUrl, downloadUrl: stream, rawStream }; +} + +interface ArtifactDocumentPanelProps { + artifact: ConversationArtifact; + onClose: () => void; + onShowArtifacts: () => void; +} + +export function ArtifactDocumentPanel({ + artifact, + onClose, + onShowArtifacts, +}: ArtifactDocumentPanelProps) { + const { t } = useTranslation(); + const attachment = artifact.attachment as ArtifactAttachmentLike; + const source = artifactDocumentSource(attachment); + const name = attachment.name || "Generated file"; + return ( + + ); +} diff --git a/frontend/app/[locale]/newchat/components/use-auto-artifact-preview.ts b/frontend/app/[locale]/newchat/components/use-auto-artifact-preview.ts new file mode 100644 index 0000000000..a552d82673 --- /dev/null +++ b/frontend/app/[locale]/newchat/components/use-auto-artifact-preview.ts @@ -0,0 +1,173 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + ArtifactAttachmentLike, + ConversationArtifact, +} from "../utils/conversation-artifacts"; + +const DOCUMENT_EXTENSIONS = new Set([ + "doc", + "docx", + "xls", + "xlsx", + "ppt", + "pptx", + "pdf", + "txt", + "md", + "markdown", + "csv", + "tsv", + "html", + "htm", +]); +const OFFICE_EXTENSIONS = new Set([ + "doc", + "docx", + "xls", + "xlsx", + "ppt", + "pptx", +]); + +export function artifactPreviewIdentity( + artifact: ConversationArtifact +): string { + return ( + (artifact.attachment as ArtifactAttachmentLike).object_name || artifact.key + ); +} + +function documentPriority(artifact: ConversationArtifact): number { + const attachment = artifact.attachment as ArtifactAttachmentLike; + const extension = attachment.name?.split(".").pop()?.toLowerCase() ?? ""; + const part = attachment.content?.[0] as { data?: string } | undefined; + if ( + !DOCUMENT_EXTENSIONS.has(extension) || + !( + attachment.object_name || + attachment.preview_url || + attachment.download_url || + attachment.presigned_url || + attachment.url || + part?.data + ) + ) + return 0; + return OFFICE_EXTENSIONS.has(extension) ? 3 : extension === "pdf" ? 2 : 1; +} + +interface AutoArtifactPreviewOptions { + scopeKey: string; + artifacts: readonly ConversationArtifact[]; + isRunning: boolean; + isLoading: boolean; + enabled: boolean; +} + +/** Observe live output publication without treating restored history as a new run. */ +export function useAutoArtifactPreview({ + scopeKey, + artifacts, + isRunning, + isLoading, + enabled, +}: AutoArtifactPreviewOptions) { + const [selection, setSelection] = useState<{ + scope: string; + identity: string; + } | null>(null); + const tracking = useRef({ + scope: scopeKey, + ready: false, + running: false, + observedRun: false, + dismissed: false, + priority: 0, + seen: new Set(), + pending: null as ConversationArtifact | null, + }); + + useEffect(() => { + if (tracking.current.scope !== scopeKey || isLoading) { + tracking.current = { + scope: scopeKey, + ready: false, + running: false, + observedRun: false, + dismissed: false, + priority: 0, + seen: new Set(), + pending: null, + }; + // Loading or switching the external runtime invalidates the old selection. + // eslint-disable-next-line react-hooks/set-state-in-effect + setSelection(null); + } + const state = tracking.current; + if (isLoading) return; + if (!state.ready) { + state.ready = true; + state.seen = new Set(artifacts.map(artifactPreviewIdentity)); + } + if (isRunning && !state.running) { + state.observedRun = true; + state.dismissed = false; + state.pending = null; + state.priority = 0; + } + state.running = isRunning; + for (const artifact of artifacts) { + const identity = artifactPreviewIdentity(artifact); + if (state.seen.has(identity)) continue; + if (documentPriority(artifact) === 0) continue; + state.seen.add(identity); + if ( + enabled && + state.observedRun && + documentPriority(artifact) > 0 && + documentPriority(artifact) >= state.priority + ) { + state.pending = artifact; + state.priority = documentPriority(artifact); + } + } + if (enabled && !isRunning && !state.dismissed && state.pending) { + const identity = artifactPreviewIdentity(state.pending); + state.pending = null; + // Live run completion is an external runtime event, not derived render state. + // eslint-disable-next-line react-hooks/set-state-in-effect + setSelection({ scope: scopeKey, identity }); + } + }, [scopeKey, artifacts, isRunning, isLoading, enabled]); + + const open = useCallback( + (artifact: ConversationArtifact) => { + setSelection({ + scope: scopeKey, + identity: artifactPreviewIdentity(artifact), + }); + }, + [scopeKey] + ); + const close = useCallback(() => { + tracking.current.dismissed = true; + tracking.current.pending = null; + setSelection(null); + }, []); + + return { + selected: + !isLoading && selection?.scope === scopeKey + ? (artifacts.find( + (artifact) => + artifactPreviewIdentity(artifact) === selection.identity + ) ?? null) + : null, + open, + close, + }; +} diff --git a/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx b/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx index 6362ccaf1c..149db26cf6 100644 --- a/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx +++ b/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx @@ -40,6 +40,8 @@ export interface ConversationArtifactsPanelProps { attachment: ArtifactAttachmentLike ) => Promise | void; className?: string; + /** Route previews into the conversation's document surface when provided. */ + onPreview?: (artifact: ConversationArtifact) => void; } /** Formats an artifact creation time without importing a date library. */ @@ -63,7 +65,7 @@ const formatGeneratedAt = (value: Date | undefined): string => { */ export const ConversationArtifactsPanel: FC< ConversationArtifactsPanelProps -> = ({ open, artifacts, onClose, onAddToComposer, className }) => { +> = ({ open, artifacts, onClose, onAddToComposer, className, onPreview }) => { const { t } = useTranslation(); const [previewing, setPreviewing] = useState( null @@ -188,7 +190,11 @@ export const ConversationArtifactsPanel: FC< setPreviewing(attachment)} + onClick={() => + onPreview + ? onPreview(artifact) + : setPreviewing(attachment) + } > diff --git a/frontend/app/[locale]/newchat/ui/question-bookmark-rail.tsx b/frontend/app/[locale]/newchat/ui/question-bookmark-rail.tsx new file mode 100644 index 0000000000..b3ca7d1405 --- /dev/null +++ b/frontend/app/[locale]/newchat/ui/question-bookmark-rail.tsx @@ -0,0 +1,101 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useQuestionBookmarks } from "@/hooks/chat/useQuestionBookmarks"; +import { useQuery } from "@tanstack/react-query"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { conversationService } from "@/services/conversationService"; +import { collapseRefreshUserMessages } from "../adapter/history-branching"; +import { + QuestionDirectoryRail, + type QuestionDirectoryRailProps, +} from "./question-directory-rail"; + +export function QuestionBookmarkRail({ + conversationId, + readOnly, + isRunning, + ...props +}: QuestionDirectoryRailProps & { + conversationId?: number; + readOnly?: boolean; + isRunning?: boolean; +}) { + const bookmarks = useQuestionBookmarks(conversationId, !readOnly); + const { user } = useAuthorizationContext(); + const runtimeIds = props.questions.map((q) => q.messageId).join(","); + const needsMapping = props.questions.some( + (q) => !/^[1-9]\d*$/.test(q.messageId) + ); + const identities = useQuery({ + queryKey: [ + "question-bookmark-identities", + user?.id, + user?.tenantId, + conversationId, + runtimeIds, + ], + enabled: bookmarks.enabled && needsMapping && !isRunning, + queryFn: () => conversationService.getDetail(conversationId!), + }); + const backendMessageIds = new Map(); + const stored = collapseRefreshUserMessages( + identities.data?.data?.[0]?.message ?? [] + ).filter((m) => m.role === "user"); + for (const [index, question] of props.questions.entries()) { + const numeric = Number(question.messageId); + const row = stored[index]; + if ( + /^[1-9]\d*$/.test(question.messageId) && + Number.isSafeInteger(numeric) + ) { + backendMessageIds.set(question.messageId, numeric); + } else if ( + stored.length === props.questions.length && + typeof row?.message === "string" && + row.message.trim() === question.fullText.trim() + ) { + const id = Number(row.message_id); + if (Number.isSafeInteger(id) && id > 0) + backendMessageIds.set(question.messageId, id); + } + } + const saved = new Set(bookmarks.ids); + return ( + saved.has(backendMessageIds.get(q.messageId)!)) + .map((q) => q.messageId) + ) + } + onBookmark={ + bookmarks.enabled + ? (id, marked) => { + const backendId = backendMessageIds.get(id); + if (backendId) bookmarks.toggle(backendId, marked); + } + : undefined + } + canBookmark={(id) => + bookmarks.ready && !bookmarks.pending && backendMessageIds.has(id) + } + bookmarkFailed={ + bookmarks.failed || + identities.isError || + Boolean( + needsMapping && + identities.isSuccess && + backendMessageIds.size !== props.questions.length + ) + } + onBookmarkRetry={() => { + bookmarks.retry(); + if (needsMapping) void identities.refetch(); + }} + /> + ); +} diff --git a/frontend/app/[locale]/newchat/ui/question-directory-rail.tsx b/frontend/app/[locale]/newchat/ui/question-directory-rail.tsx index 3147cef859..047c3710d0 100644 --- a/frontend/app/[locale]/newchat/ui/question-directory-rail.tsx +++ b/frontend/app/[locale]/newchat/ui/question-directory-rail.tsx @@ -2,27 +2,55 @@ "use client"; -import { useEffect, useRef, type FC } from "react"; -import { Tooltip as TooltipPrimitive } from "radix-ui"; +import { useEffect, useRef, useState, type FC } from "react"; +import { Popover as PopoverPrimitive } from "radix-ui"; +import { Bookmark } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; +import { Popover, PopoverContent } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; -import { - QuestionDirectoryButton, - type QuestionDirectoryButtonProps, -} from "./question-directory"; +import type { QuestionDirectoryEntry } from "../utils/question-directory"; + +export interface QuestionDirectoryRailProps { + questions: QuestionDirectoryEntry[]; + onJump: (messageId: string) => void; + activeMessageId?: string | null; + className?: string; + bookmarkedIds?: ReadonlySet; + onBookmark?: (messageId: string, bookmarked: boolean) => void; + canBookmark?: (messageId: string) => boolean; + bookmarkFailed?: boolean; + onBookmarkRetry?: () => void; +} /** Compact, persistent navigation in the conversation's reserved left gutter. */ -export const QuestionDirectoryRail: FC< - Omit -> = ({ questions, onJump, activeMessageId, className }) => { +export const QuestionDirectoryRail: FC = ({ + questions, + onJump, + activeMessageId, + className, + bookmarkedIds, + onBookmark, + canBookmark, + bookmarkFailed, + onBookmarkRetry, +}) => { const { t } = useTranslation(); const listRef = useRef(null); + const [previewId, setPreviewId] = useState(null); + const closeTimer = useRef | null>(null); + const keepPreview = () => { + if (closeTimer.current) clearTimeout(closeTimer.current); + }; + const leavePreview = () => { + keepPreview(); + closeTimer.current = setTimeout(() => setPreviewId(null), 180); + }; + useEffect( + () => () => { + if (closeTimer.current) clearTimeout(closeTimer.current); + }, + [] + ); useEffect(() => { const list = listRef.current; @@ -44,78 +72,155 @@ export const QuestionDirectoryRail: FC< ); diff --git a/frontend/app/[locale]/newchat/ui/question-directory.tsx b/frontend/app/[locale]/newchat/ui/question-directory.tsx deleted file mode 100644 index eda6a63aa7..0000000000 --- a/frontend/app/[locale]/newchat/ui/question-directory.tsx +++ /dev/null @@ -1,293 +0,0 @@ -// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - -"use client"; - -import { useEffect, useMemo, useRef, useState, type FC } from "react"; -import { createPortal } from "react-dom"; -import { ListTreeIcon, PaperclipIcon, SearchIcon } from "lucide-react"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { useTranslation } from "react-i18next"; -import type { QuestionDirectoryEntry } from "../utils/question-directory"; - -export interface QuestionDirectoryButtonProps { - /** User question turns collected across the conversation. */ - questions: QuestionDirectoryEntry[]; - /** Scrolls the thread to the clicked question's message. */ - onJump: (messageId: string) => void; - /** - * Message id of the question the viewport is currently scrolled to; the - * matching entry is highlighted and labelled as the current position. - */ - activeMessageId?: string | null; - /** - * Floating renders a compact pill chip (overlaid on the thread viewport); - * header renders a ghost icon button for the persistent header bars. - */ - variant?: "floating" | "header"; - className?: string; -} - -/** Hover delay before the preview card opens (ms). */ -const PREVIEW_OPEN_DELAY_MS = 150; -/** Fixed width of the hover preview card. */ -const PREVIEW_WIDTH = 320; -/** Rough height budget used to keep the preview card inside the viewport. */ -const PREVIEW_MAX_HEIGHT = 360; - -/** A preview card anchored to one hovered directory row. */ -interface PreviewState { - entry: QuestionDirectoryEntry; - x: number; - y: number; -} - -/** - * Entry point for the in-conversation question directory: lists every question - * asked so far and jumps the message viewport to the selected turn. Rendered - * as a floating chip when the surface has no persistent header, and as a - * header icon button otherwise; the popover list is shared by both and offers - * a keyword filter over the full question text. Hovering (or focusing) a row - * opens a preview card with the full question and an excerpt of its assistant - * reply, and the row the viewport is currently scrolled to is highlighted as - * the current position. - */ -export const QuestionDirectoryButton: FC = ({ - questions, - onJump, - activeMessageId = null, - variant = "floating", - className, -}) => { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - const [preview, setPreview] = useState(null); - const previewTimer = useRef(null); - - const clearPreviewTimer = () => { - if (previewTimer.current !== null) { - window.clearTimeout(previewTimer.current); - previewTimer.current = null; - } - }; - - // The hover timer must never outlive the component. - useEffect(() => clearPreviewTimer, []); - - // Safety net: the preview card portals outside the popover, so it must - // never outlive the popover, whatever code path closed it. - useEffect(() => { - if (open) return; - if (previewTimer.current !== null) { - window.clearTimeout(previewTimer.current); - previewTimer.current = null; - } - setSearch(""); - setPreview(null); - }, [open]); - - // Match against the full text (not the truncated summary) so keywords from - // any line of the question still find the entry; case-insensitive. - const filteredQuestions = useMemo(() => { - const keyword = search.trim().toLowerCase(); - if (!keyword) return questions; - return questions.filter((question) => - question.fullText.toLowerCase().includes(keyword), - ); - }, [questions, search]); - - if (questions.length === 0) return null; - - const label = t("chat.questions.viewAll", { count: questions.length }); - - const showPreview = ( - question: QuestionDirectoryEntry, - element: HTMLElement, - ) => { - clearPreviewTimer(); - previewTimer.current = window.setTimeout(() => { - const rect = element.getBoundingClientRect(); - // Prefer the left side of the hovered row; fall back to the right side - // and clamp so the card stays inside the viewport. - const leftX = rect.left - PREVIEW_WIDTH - 12; - const rightX = Math.min( - rect.right + 12, - window.innerWidth - PREVIEW_WIDTH - 8, - ); - const x = leftX >= 8 ? leftX : Math.max(8, rightX); - const y = Math.max( - 8, - Math.min(rect.top, window.innerHeight - PREVIEW_MAX_HEIGHT), - ); - setPreview({ entry: question, x, y }); - }, PREVIEW_OPEN_DELAY_MS); - }; - - const hidePreview = () => { - clearPreviewTimer(); - setPreview(null); - }; - - // Reset the transient popover session state (keyword filter and hover - // preview). Radix reports its own dismissals (outside click, Escape, - // trigger toggle) through onOpenChange, but programmatic closes such as - // the row-click jump are not reported, so they must reset the same state - // explicitly. - const resetPopoverSession = () => { - setSearch(""); - clearPreviewTimer(); - setPreview(null); - }; - - return ( - { - setOpen(nextOpen); - // Reset the filter and drop the preview between sessions so - // reopening starts from the full directory. - if (!nextOpen) { - resetPopoverSession(); - } - }} - > - - {variant === "header" ? ( - - ) : ( - - )} - - -
      - - {t("chat.questions.title")} - - - {t("chat.questions.total", { count: questions.length })} - -
      -
      - - setSearch(event.target.value)} - placeholder={t("chat.questions.search")} - aria-label={t("chat.questions.search")} - className="min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground" - /> -
      -
        - {filteredQuestions.map((question) => { - const isActive = question.messageId === activeMessageId; - return ( -
      • - -
      • - ); - })} - {filteredQuestions.length === 0 && ( -
      • - {t("chat.questions.noMatches")} -
      • - )} -
      -
      - {preview - ? createPortal( -
      -

      - {preview.entry.fullText} -

      -
      -

      - {preview.entry.replyText || t("chat.questions.noReply")} -

      -
      , - document.body, - ) - : null} - - ); -}; diff --git a/frontend/app/[locale]/newchat/utils/message-annotations.ts b/frontend/app/[locale]/newchat/utils/message-annotations.ts index cdc586e279..182e2e4c9a 100644 --- a/frontend/app/[locale]/newchat/utils/message-annotations.ts +++ b/frontend/app/[locale]/newchat/utils/message-annotations.ts @@ -22,7 +22,6 @@ const LIST_QUOTE_MAX_LENGTH = 80; export const MESSAGE_ANNOTATION_QUOTED_TEXT_MAX = 1500; export const MESSAGE_ANNOTATION_COMMENT_MAX = 500; export const MESSAGE_ANNOTATION_MAX_COUNT = 10; -export const MESSAGE_ANNOTATION_CONTEXT_MAX = 200; /** * Loose shape of an assistant-ui thread message as consumed by the @@ -40,8 +39,6 @@ export interface AnnotationMessageLike { /** A pending selection inside one assistant message, resolved from the DOM. */ export interface AnnotationSelectionTarget { - contextBefore?: string; - contextAfter?: string; /** assistant-ui message id of the message the selection belongs to. */ messageId: string; /** Normalized selected text (CRLF collapsed to LF). */ @@ -52,8 +49,6 @@ export interface AnnotationSelectionTarget { /** Draft annotation captured from a completed selection. */ export interface MessageAnnotationDraft { - contextBefore?: string; - contextAfter?: string; /** The selected text quoted verbatim from the assistant reply. */ quotedText: string; /** Optional user comment attached to the quote. */ @@ -66,7 +61,7 @@ export interface MessageAnnotationDraft { export interface MessageAnnotation extends MessageAnnotationDraft { /** Runtime identity used for list keys and edit/delete actions. */ id: string; - /** Display-only source label; nearby context is sent to the model instead. */ + /** Display-only source label; model input uses only the selected quote and comment. */ sourceExcerpt: string; } @@ -91,8 +86,6 @@ export interface MessageAnnotationsControl { * `backend/contracts/annotations.py::MessageAnnotationItem`). */ export interface MessageAnnotationWireItem { - context_before?: string | null; - context_after?: string | null; quoted_text: string; comment?: string | null; source_excerpt?: string | null; @@ -181,40 +174,6 @@ export function resolveAnnotationSelection( messageId, text: text.replace(/\r\n/g, "\n"), range, - ...captureAnnotationContext(content, range), - }; -} - -/** Capture positional snapshots, preferring the endpoint paragraphs. */ -function captureAnnotationContext(content: Element, range: Range) { - const readSide = (before: boolean, paragraphOnly: boolean): string => { - const node = before ? range.startContainer : range.endContainer; - const paragraph = elementFromNode(node)?.closest( - "p, li, pre, blockquote, td, th" - ); - const root = paragraphOnly ? paragraph : content; - if (!root || !content.contains(root)) return ""; - const nearby = document.createRange(); - nearby.selectNodeContents(root); - if (before) nearby.setEnd(range.startContainer, range.startOffset); - else nearby.setStart(range.endContainer, range.endOffset); - const fragment = nearby.cloneContents(); - fragment - .querySelectorAll(NON_QUOTABLE_SELECTOR) - .forEach((node) => node.remove()); - fragment - .querySelectorAll("p, li, pre, blockquote, tr, h1, h2, h3, h4, h5, h6") - .forEach((node) => node.append(document.createTextNode("\n\n"))); - const text = (fragment.textContent ?? "").replace(/\r\n/g, "\n").trim(); - return before - ? text.slice(-MESSAGE_ANNOTATION_CONTEXT_MAX) - : text.slice(0, MESSAGE_ANNOTATION_CONTEXT_MAX); - }; - const contextBefore = readSide(true, true) || readSide(true, false); - const contextAfter = readSide(false, true) || readSide(false, false); - return { - ...(contextBefore ? { contextBefore } : {}), - ...(contextAfter ? { contextAfter } : {}), }; } @@ -265,22 +224,7 @@ export function createMessageAnnotation( ): MessageAnnotation { return { id, - ...draft, - ...(draft.contextBefore - ? { - contextBefore: draft.contextBefore.slice( - -MESSAGE_ANNOTATION_CONTEXT_MAX - ), - } - : {}), - ...(draft.contextAfter - ? { - contextAfter: draft.contextAfter.slice( - 0, - MESSAGE_ANNOTATION_CONTEXT_MAX - ), - } - : {}), + sourceMessageId: draft.sourceMessageId, // Defensive caps keep the structured payload inside the backend limits. quotedText: draft.quotedText.slice(0, MESSAGE_ANNOTATION_QUOTED_TEXT_MAX), comment: @@ -311,21 +255,6 @@ export function toMessageAnnotationWireItems( comment: annotation.comment?.slice(0, MESSAGE_ANNOTATION_COMMENT_MAX) ?? null, source_excerpt: annotation.sourceExcerpt || null, - ...(annotation.contextBefore - ? { - context_before: annotation.contextBefore.slice( - -MESSAGE_ANNOTATION_CONTEXT_MAX - ), - } - : {}), - ...(annotation.contextAfter - ? { - context_after: annotation.contextAfter.slice( - 0, - MESSAGE_ANNOTATION_CONTEXT_MAX - ), - } - : {}), })); } @@ -363,22 +292,6 @@ export function fromMessageAnnotationWireItems( source_excerpt: sourceExcerpt ? sourceExcerpt.slice(0, MESSAGE_ANNOTATION_QUOTED_TEXT_MAX) : null, - ...(typeof record.context_before === "string" && - record.context_before.trim() - ? { - context_before: record.context_before - .trim() - .slice(-MESSAGE_ANNOTATION_CONTEXT_MAX), - } - : {}), - ...(typeof record.context_after === "string" && - record.context_after.trim() - ? { - context_after: record.context_after - .trim() - .slice(0, MESSAGE_ANNOTATION_CONTEXT_MAX), - } - : {}), }); if (items.length >= MESSAGE_ANNOTATION_MAX_COUNT) break; } diff --git a/frontend/components/common/filePreviewDrawer.tsx b/frontend/components/common/filePreviewDrawer.tsx index 72853058ec..6bab917cd1 100644 --- a/frontend/components/common/filePreviewDrawer.tsx +++ b/frontend/components/common/filePreviewDrawer.tsx @@ -1426,6 +1426,18 @@ export function FilePreviewDrawer(props: Readonly) { } }; + // Inline hosts own their header, download action and responsive panel layout. + if (props.presentation === "inline") { + return open ? ( +
      + {renderContent()} +
      + ) : null; + } + // Full-screen presentation: the document owns the viewport and the back arrow // returns to whatever opened it. It is portalled into `document.body` because // ancestor transforms (the page transition animates the content) turn such an diff --git a/frontend/components/common/rawStreamPreviewViewer.module.css b/frontend/components/common/rawStreamPreviewViewer.module.css new file mode 100644 index 0000000000..218095481e --- /dev/null +++ b/frontend/components/common/rawStreamPreviewViewer.module.css @@ -0,0 +1,10 @@ +/* Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. */ + +.docxPages :global(.docx-wrapper) { + background: transparent; + padding: 0; +} + +.docxPages :global(.docx-wrapper) > :global(section.docx) { + box-shadow: none; +} diff --git a/frontend/components/common/rawStreamPreviewViewer.tsx b/frontend/components/common/rawStreamPreviewViewer.tsx index 96b1d07663..0c8df22cd0 100644 --- a/frontend/components/common/rawStreamPreviewViewer.tsx +++ b/frontend/components/common/rawStreamPreviewViewer.tsx @@ -30,6 +30,7 @@ import { type SheetGrid, } from "@/lib/spreadsheetGrid"; import log from "@/lib/logger"; +import styles from "./rawStreamPreviewViewer.module.css"; const PdfViewer = dynamic( () => @@ -348,7 +349,8 @@ export function RawStreamPreviewViewer( body, docxStyleRef.current ?? undefined, { - inWrapper: false, + // Default paper styling is scoped to the renderer's page wrapper. + inWrapper: true, breakPages: true, ignoreLastRenderedPageBreak: true, renderHeaders: true, @@ -433,7 +435,10 @@ export function RawStreamPreviewViewer( )}
      -
      +
      ); diff --git a/frontend/hooks/chat/useQuestionBookmarks.ts b/frontend/hooks/chat/useQuestionBookmarks.ts new file mode 100644 index 0000000000..d1d6bf86c9 --- /dev/null +++ b/frontend/hooks/chat/useQuestionBookmarks.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useRef } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { conversationService } from "@/services/conversationService"; + +export function useQuestionBookmarks(conversationId?: number, enabled = true) { + const { user } = useAuthorizationContext(); + const client = useQueryClient(); + const saving = useRef(false); + const queryKey = [ + "question-bookmarks", + user?.id, + user?.tenantId, + conversationId, + ]; + const available = Boolean( + enabled && + user?.id && + user?.tenantId && + conversationId && + conversationId > 0 + ); + const query = useQuery({ + queryKey, + enabled: available, + queryFn: () => conversationService.getQuestionBookmarks(conversationId!), + }); + const mutation = useMutation({ + mutationFn: ({ + messageId, + bookmarked, + conversation, + }: { + messageId: number; + bookmarked: boolean; + conversation: number; + key: typeof queryKey; + }) => + conversationService.setQuestionBookmark( + conversation, + messageId, + bookmarked + ), + // Mutation variables retain their original scope if the user switches chats. + onSuccess: (ids, variables) => client.setQueryData(variables.key, ids), + onSettled: () => { + saving.current = false; + }, + }); + const sameMutationScope = mutation.variables?.key.every( + (value, index) => value === queryKey[index] + ); + return { + ids: query.data ?? [], + enabled: available, + ready: available && query.isSuccess && !query.isFetching, + pending: mutation.isPending, + failed: query.isError || Boolean(sameMutationScope && mutation.isError), + retry: () => { + mutation.reset(); + void query.refetch(); + }, + toggle: (messageId: number, bookmarked: boolean) => { + if ( + available && + query.isSuccess && + !query.isFetching && + !saving.current + ) { + saving.current = true; + mutation.mutate({ + messageId, + bookmarked, + conversation: conversationId!, + key: queryKey, + }); + } + }, + }; +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 40977ec5d3..1eab61aeb1 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -6765,5 +6765,9 @@ "workbench.creationConfirmation.hint": "Submit your choice to enter a creation mode or continue this conversation.", "workbench.creationConfirmation.noPermission": "You do not have permission to enter this creation mode.", "workbench.creationConfirmation.saveFailed": "Could not save the confirmation. Please retry.", - "workbench.creationConfirmation.originalRequest": "Original user request:" + "workbench.creationConfirmation.originalRequest": "Original user request:", + "chat.questions.bookmark": "Bookmark question", + "chat.questions.bookmarkFailed": "Could not load or save question bookmarks", + "chat.questions.bookmarkRetry": "Retry", + "chat.questions.unbookmark": "Remove bookmark" } diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index b8064314bd..c6acf07926 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -6762,5 +6762,9 @@ "workbench.creationConfirmation.hint": "选择后提交,确认进入创建模式或继续当前对话。", "workbench.creationConfirmation.noPermission": "你没有进入该创建模式的权限。", "workbench.creationConfirmation.saveFailed": "保存确认失败,请重试。", - "workbench.creationConfirmation.originalRequest": "用户原始需求:" + "workbench.creationConfirmation.originalRequest": "用户原始需求:", + "chat.questions.bookmark": "标记问题", + "chat.questions.bookmarkFailed": "问题标记未能读取或保存,请重试", + "chat.questions.bookmarkRetry": "重试", + "chat.questions.unbookmark": "取消标记" } diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 950d3fa620..ff500fe62a 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -66,6 +66,10 @@ export const API_ENDPOINTS = { agentGroupConversationIds: (agentId: number) => `${API_BASE_URL}/conversation/agent_groups/${agentId}/conversation_ids`, workbenchPins: `${API_BASE_URL}/conversation/workbench_pins`, + questionBookmarks: (id: number) => + `${API_BASE_URL}/conversation/${id}/question_bookmarks`, + questionBookmark: (id: number, messageId: number) => + `${API_BASE_URL}/conversation/${id}/question_bookmarks/${messageId}`, workbenchPin: (id: number) => `${API_BASE_URL}/conversation/${id}/workbench_pin`, create: `${API_BASE_URL}/conversation/create`, diff --git a/frontend/services/conversationService.ts b/frontend/services/conversationService.ts index 2d3bf8c115..7d33ed8947 100644 --- a/frontend/services/conversationService.ts +++ b/frontend/services/conversationService.ts @@ -42,6 +42,28 @@ const getWebSocketUrl = (endpoint: string): string => { }; export const conversationService = { + async getQuestionBookmarks(id: number): Promise { + const response = await fetchWithErrorHandling( + API_ENDPOINTS.conversation.questionBookmarks(id), + { headers: getAuthHeaders() } + ); + return (await response.json()).data.message_ids; + }, + async setQuestionBookmark( + id: number, + messageId: number, + bookmarked: boolean + ): Promise { + const response = await fetchWithErrorHandling( + API_ENDPOINTS.conversation.questionBookmark(id, messageId), + { + method: "PUT", + headers: { ...getAuthHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ bookmarked }), + } + ); + return (await response.json()).data.message_ids; + }, async getCreatorHistories( kind: ConversationTarget["type"], targetId: number, diff --git a/frontend/tests/component/artifactDocumentPanel.test.tsx b/frontend/tests/component/artifactDocumentPanel.test.tsx new file mode 100644 index 0000000000..46152b8ce7 --- /dev/null +++ b/frontend/tests/component/artifactDocumentPanel.test.tsx @@ -0,0 +1,120 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { + ArtifactDocumentPanel, + artifactDocumentSource, +} from "@/app/newchat/components/artifact-document-panel"; +import type { + ArtifactAttachmentLike, + ConversationArtifact, +} from "@/app/newchat/utils/conversation-artifacts"; + +const state = vi.hoisted(() => ({ props: vi.fn(), download: vi.fn() })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/components/common/filePreviewDrawer", () => ({ + FilePreviewDrawer: (props: unknown) => { + state.props(props); + return
      ; + }, +})); +vi.mock("@/services/api", () => ({ + API_ENDPOINTS: { + storage: { + file: (object: string, mode: string, name: string) => + `/file/${object}?mode=${mode}&name=${name}`, + }, + }, +})); +vi.mock("@/services/storageService", () => ({ + storageService: { + getPreviewUrl: (object: string, name: string) => + `/preview/${object}?name=${name}`, + }, +})); +vi.mock("@/app/newchat/ui/attachment", () => ({ + triggerUrlDownload: state.download, +})); + +const attachment = (name: string, extra = {}) => + ({ + id: "file", + name, + object_name: "workspace/report", + preview_url: "https://old/raw.docx", + ...extra, + }) as ArtifactAttachmentLike; + +test.each(["docx", "xlsx", "pptx"])( + "%s uses original bytes and the stream renderer instead of PDF", + (extension) => { + const result = artifactDocumentSource(attachment(`report.${extension}`)); + expect(result.rawStream).toBe(true); + expect(result.previewUrl).toContain("mode=stream"); + expect(result.previewUrl).not.toContain("https://old"); + expect(result.downloadUrl).toBe(result.previewUrl); + } +); + +test.each(["doc", "xls", "ppt", "pdf", "txt", "md"])( + "%s keeps the existing remote preview path", + (extension) => { + const result = artifactDocumentSource(attachment(`report.${extension}`)); + expect(result.rawStream).toBe(false); + expect(result.previewUrl).toContain("/preview/workspace/report"); + expect(result.downloadUrl).toContain("mode=stream"); + } +); + +test("falls back to a real source URL if object identity is unavailable", () => { + const result = artifactDocumentSource( + attachment("report.docx", { + object_name: "", + download_url: "https://files/original.docx", + }) + ); + expect(result.previewUrl).toBe("https://files/original.docx"); + expect(result.rawStream).toBe(true); +}); + +test("renders an inline document with close, download and return-to-artifacts controls", () => { + const onClose = vi.fn(); + const onShowArtifacts = vi.fn(); + const artifact = { + key: "a:file", + messageId: "a", + attachment: attachment("report.docx"), + } as ConversationArtifact; + render( + + ); + expect( + screen.getByRole("complementary", { name: "report.docx" }) + ).toBeVisible(); + expect(state.props).toHaveBeenLastCalledWith( + expect.objectContaining({ + open: true, + presentation: "inline", + rawStream: true, + fileName: "report.docx", + }) + ); + fireEvent.click( + screen.getByRole("button", { name: "chat.artifacts.download" }) + ); + expect(state.download).toHaveBeenCalledWith( + expect.stringContaining("mode=stream"), + "report.docx" + ); + fireEvent.click(screen.getByRole("button", { name: "chat.artifacts.title" })); + expect(onShowArtifacts).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole("button", { name: "chat.artifacts.close" })); + expect(onClose).toHaveBeenCalledOnce(); +}); diff --git a/frontend/tests/component/autoArtifactPreview.test.tsx b/frontend/tests/component/autoArtifactPreview.test.tsx new file mode 100644 index 0000000000..fb5ef22b23 --- /dev/null +++ b/frontend/tests/component/autoArtifactPreview.test.tsx @@ -0,0 +1,175 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { act, renderHook } from "@testing-library/react"; +import { expect, test } from "vitest"; +import { useAutoArtifactPreview } from "@/app/newchat/components/use-auto-artifact-preview"; +import type { ConversationArtifact } from "@/app/newchat/utils/conversation-artifacts"; + +const file = ( + name: string, + key = name, + objectName = `workspace/${name}` +): ConversationArtifact => ({ + key, + messageId: "assistant", + attachment: { + id: key, + name, + object_name: objectName, + } as ConversationArtifact["attachment"], +}); +const initial = { + scopeKey: "chat-1", + artifacts: [] as ConversationArtifact[], + isRunning: false, + isLoading: false, + enabled: true, +}; + +test("opens a new document only after the live run finishes", () => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: initial, + }); + rerender({ ...initial, isRunning: true }); + const doc = file("report.docx"); + rerender({ ...initial, isRunning: true, artifacts: [doc] }); + expect(result.current.selected).toBeNull(); + rerender({ ...initial, artifacts: [doc] }); + expect(result.current.selected).toEqual(doc); +}); + +test("history loading and conversation switching never auto-open old documents", () => { + const old = file("old.docx"); + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: { ...initial, isLoading: true }, + }); + rerender({ ...initial, artifacts: [old] }); + expect(result.current.selected).toBeNull(); + act(() => result.current.open(old)); + expect(result.current.selected).toEqual(old); + rerender({ + ...initial, + scopeKey: "chat-2", + artifacts: [file("another.pdf")], + }); + expect(result.current.selected).toBeNull(); + rerender({ ...initial, artifacts: [old] }); + expect(result.current.selected).toBeNull(); +}); + +test("prefers Office originals over companion PDF checks and intermediate scripts", () => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: initial, + }); + rerender({ ...initial, isRunning: true }); + const doc = file("report.docx"); + rerender({ + ...initial, + isRunning: true, + artifacts: [doc, file("report.pdf"), file("build.py")], + }); + rerender({ + ...initial, + artifacts: [doc, file("report.pdf"), file("build.py")], + }); + expect(result.current.selected).toEqual(doc); +}); + +test("closing suppresses late files and metadata updates until another run", () => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: initial, + }); + const doc = file("report.docx"); + rerender({ ...initial, isRunning: true, artifacts: [doc] }); + rerender({ ...initial, artifacts: [doc] }); + act(() => result.current.close()); + const renamed = file("report.docx", "restored-id"); + rerender({ ...initial, artifacts: [renamed, file("late.pdf")] }); + expect(result.current.selected).toBeNull(); + act(() => result.current.open(renamed)); + expect(result.current.selected).toEqual(renamed); + act(() => result.current.close()); + rerender({ ...initial, isRunning: true, artifacts: [renamed] }); + const next = file("new.docx"); + rerender({ ...initial, artifacts: [renamed, next] }); + expect(result.current.selected).toEqual(next); +}); + +test("supports late artifact publication after completion without repeating on token updates", () => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: initial, + }); + rerender({ ...initial, isRunning: true }); + rerender(initial); + const doc = file("published.pdf"); + rerender({ ...initial, artifacts: [doc] }); + expect(result.current.selected).toEqual(doc); + act(() => result.current.close()); + rerender({ ...initial, artifacts: [{ ...doc }] }); + expect(result.current.selected).toBeNull(); +}); + +test("a late companion PDF does not replace the Office original", () => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: initial, + }); + const doc = file("report.docx"); + rerender({ ...initial, isRunning: true, artifacts: [doc] }); + rerender({ ...initial, artifacts: [doc] }); + rerender({ ...initial, artifacts: [doc, file("report.pdf")] }); + expect(result.current.selected).toEqual(doc); +}); + +test.each(["build.js", "build.py", "archive.zip", "image.png"])( + "does not auto-open intermediate or unsupported %s", + (name) => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: initial, + }); + rerender({ ...initial, isRunning: true }); + rerender({ ...initial, artifacts: [file(name)] }); + expect(result.current.selected).toBeNull(); + } +); + +test("does not auto-open missing sources or read-only views", () => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: { ...initial, enabled: false }, + }); + rerender({ ...initial, enabled: false, isRunning: true }); + rerender({ ...initial, enabled: false, artifacts: [file("report.docx")] }); + expect(result.current.selected).toBeNull(); + rerender({ ...initial, isRunning: true }); + rerender({ ...initial, artifacts: [file("missing.docx", "missing", "")] }); + expect(result.current.selected).toBeNull(); +}); + +test("reloading the same conversation resets generation observation", () => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: initial, + }); + rerender({ ...initial, isRunning: true }); + rerender({ ...initial, isLoading: true }); + rerender({ ...initial, artifacts: [file("history.docx")] }); + expect(result.current.selected).toBeNull(); +}); + +test("waits for a real preview source when a file marker arrives before upload", () => { + const { result, rerender } = renderHook(useAutoArtifactPreview, { + initialProps: initial, + }); + rerender({ ...initial, isRunning: true }); + const pending = file("report.docx", "pending", ""); + rerender({ ...initial, isRunning: true, artifacts: [pending] }); + rerender({ ...initial, artifacts: [pending] }); + expect(result.current.selected).toBeNull(); + const uploaded = { + ...pending, + attachment: { + ...pending.attachment, + preview_url: "https://files/report.docx", + }, + } as ConversationArtifact; + rerender({ ...initial, artifacts: [uploaded] }); + expect(result.current.selected).toEqual(uploaded); +}); diff --git a/frontend/tests/component/docxPreviewBackground.test.tsx b/frontend/tests/component/docxPreviewBackground.test.tsx new file mode 100644 index 0000000000..e421cdbac4 --- /dev/null +++ b/frontend/tests/component/docxPreviewBackground.test.tsx @@ -0,0 +1,93 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { render, waitFor } from "@testing-library/react"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import JSZip from "jszip"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { RawStreamPreviewViewer } from "@/components/common/rawStreamPreviewViewer"; +import { fetchPreviewBlobWithMeta } from "@/lib/filePreviewUtils"; +import log from "@/lib/logger"; +import styles from "@/components/common/rawStreamPreviewViewer.module.css"; + +let stylesheet: HTMLStyleElement; +beforeEach(() => { + // Apply the actual module rules with their exported scope in jsdom. + const css = readFileSync( + resolve( + dirname(fileURLToPath(import.meta.url)), + "../../components/common/rawStreamPreviewViewer.module.css" + ), + "utf8" + ); + stylesheet = document.createElement("style"); + stylesheet.textContent = css + .replace(/:global\(([^)]+)\)/g, "$1") + .replaceAll(".docxPages", `.${styles.docxPages}`); + document.head.append(stylesheet); +}); +afterEach(() => stylesheet?.remove()); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/lib/logger", () => ({ + default: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})); +vi.mock("@/lib/filePreviewUtils", async (original) => ({ + ...(await original()), + fetchPreviewBlobWithMeta: vi.fn(), +})); + +async function documentBytes(background?: string) { + const zip = new JSZip(); + zip.file( + "[Content_Types].xml", + `` + ); + zip.file( + "_rels/.rels", + `` + ); + zip.file( + "word/document.xml", + `${background ? `` : ""}Document paper` + ); + return zip.generateAsync({ type: "arraybuffer" }); +} + +test.each([ + [undefined, "rgb(255, 255, 255)"], + ["FFEECC", "rgb(255, 238, 204)"], +])( + "real DOCX rendering uses paper color %s instead of a transparent page", + async (background, expected) => { + const bytes = await documentBytes(background); + vi.mocked(fetchPreviewBlobWithMeta).mockResolvedValue({ + blob: { arrayBuffer: async () => bytes }, + fileName: "report.docx", + mimeType: "", + } as never); + const { container } = render( + + ); + await waitFor(() => { + expect( + vi.mocked(log.error).mock.calls.map((call) => String(call[1])) + ).toEqual([]); + const page = container.querySelector( + "section.docx" + ) as HTMLElement | null; + expect(page).not.toBeNull(); + expect(page?.textContent).toContain("Document paper"); + expect(window.getComputedStyle(page!).backgroundColor).toBe(expected); + expect(window.getComputedStyle(page!).boxShadow).toBe("none"); + const wrapper = page!.parentElement!; + expect(window.getComputedStyle(wrapper).backgroundColor).toBe( + "rgba(0, 0, 0, 0)" + ); + expect(window.getComputedStyle(wrapper).padding).toBe("0px"); + }); + } +); diff --git a/frontend/tests/component/inlineFilePreview.test.tsx b/frontend/tests/component/inlineFilePreview.test.tsx new file mode 100644 index 0000000000..79cddc6ea6 --- /dev/null +++ b/frontend/tests/component/inlineFilePreview.test.tsx @@ -0,0 +1,77 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { render, screen, waitFor } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { FilePreviewDrawer } from "@/components/common/filePreviewDrawer"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/lib/logger", () => ({ + default: { error: vi.fn(), warn: vi.fn(), log: vi.fn() }, +})); +vi.mock("@/services/storageService", () => ({ + storageService: { getPreviewUrl: () => "/preview", downloadFile: vi.fn() }, +})); +vi.mock("@/components/common/markdownRenderer", () => ({ + MarkdownRenderer: () => null, + extractMarkdownHeadings: () => [], +})); +vi.mock("@/components/common/rawStreamPreviewViewer", () => ({ + RawStreamPreviewViewer: ({ url }: { url: string }) => ( +
      {url}
      + ), +})); + +const props = { + open: true, + onClose: vi.fn(), + objectName: "workspace/report.docx", + fileName: "report.docx", + previewUrl: "/original.docx", + rawStream: true, +}; + +test("inline mode mounts preview content without a modal or drawer", async () => { + const { container } = render( + + ); + expect( + container.querySelector('[data-slot="inline-file-preview"]') + ).not.toBeNull(); + expect(await screen.findByTestId("stream-viewer")).toHaveTextContent( + "/original.docx" + ); + expect(document.querySelector(".ant-drawer")).toBeNull(); + expect(screen.queryByRole("dialog")).toBeNull(); +}); + +test("closed inline mode does not leave a document visible", () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); +}); + +test("default drawer presentation stays available", async () => { + render(); + await waitFor(() => + expect(document.querySelector(".ant-drawer")).not.toBeNull() + ); + expect(await screen.findByTestId("stream-viewer")).toHaveTextContent( + "/original.docx" + ); +}); + +test("fullscreen presentation retains its existing back action", async () => { + const close = vi.fn(); + render( + + ); + expect( + await screen.findByRole("button", { name: "common.back" }) + ).toBeVisible(); + expect(await screen.findByTestId("stream-viewer")).toHaveTextContent( + "/original.docx" + ); +}); diff --git a/frontend/tests/component/messageAnnotation.test.tsx b/frontend/tests/component/messageAnnotation.test.tsx index 2b6ba4c7b8..9f75987a73 100644 --- a/frontend/tests/component/messageAnnotation.test.tsx +++ b/frontend/tests/component/messageAnnotation.test.tsx @@ -49,30 +49,33 @@ const makeAnnotation = ( afterEach(cleanup); describe("toMessageAnnotationWireItems", () => { - test("preserves nearby snapshots through outgoing and history mappings", () => { + test("drops legacy context from creation, outgoing and history mappings", () => { const annotation = createMessageAnnotation( { quotedText: "this approach", sourceMessageId: "assistant-1", contextBefore: "x".repeat(250) + "Redis ", contextAfter: " shares state." + "y".repeat(250), - }, + } as Parameters[0], [], "snapshot" ); const wire = toMessageAnnotationWireItems([annotation]); - expect(wire[0].context_before).toHaveLength(200); - expect(wire[0].context_before?.endsWith("Redis ")).toBe(true); - expect(wire[0].context_after).toHaveLength(200); - expect(wire[0].context_after?.startsWith(" shares state.")).toBe(true); - const restored = fromMessageAnnotationWireItems(wire); - expect(restored[0].context_before).toBe(wire[0].context_before?.trim()); - expect(restored[0].context_after).toBe(wire[0].context_after?.trim()); - expect( - fromMessageAnnotationWireItems([ - { quoted_text: "q", context_before: 42, context_after: {} }, - ])[0].context_before - ).toBeUndefined(); + expect(annotation).not.toHaveProperty("contextBefore"); + expect(annotation).not.toHaveProperty("contextAfter"); + expect(wire[0]).toEqual({ + quoted_text: "this approach", + comment: null, + source_excerpt: null, + }); + const restored = fromMessageAnnotationWireItems([ + { ...wire[0], context_before: "old before", context_after: "old after" }, + { quoted_text: "q", context_before: 42, context_after: {} }, + ]); + expect(restored).toEqual([ + wire[0], + { quoted_text: "q", comment: null, source_excerpt: null }, + ]); }); test("maps camelCase annotations onto the snake_case wire shape", () => { const items = toMessageAnnotationWireItems([ @@ -282,7 +285,7 @@ describe("createMessageAnnotation", () => { }); describe("resolveAnnotationSelection", () => { - test("captures the selected occurrence and freezes its surrounding paragraph", () => { + test("captures only the selected occurrence without surrounding paragraph text", () => { const root = document.createElement("div"); root.dataset.role = "assistant"; root.dataset.messageId = "a1"; @@ -299,15 +302,16 @@ describe("resolveAnnotationSelection", () => { selection.addRange(range); const target = resolveAnnotationSelection(selection)!; expect(target.text).toBe("same"); - expect(target.contextBefore).toBe("local cache: same. Redis cache:"); - expect(target.contextAfter).toBe("is shared."); + expect(target.messageId).toBe("a1"); + expect(target).not.toHaveProperty("contextBefore"); + expect(target).not.toHaveProperty("contextAfter"); paragraph.textContent = "replacement"; - expect(target.contextAfter).toBe("is shared."); + expect(target.text).toBe("same"); selection.removeAllRanges(); root.remove(); }); - test("uses nearby paragraphs at boundaries, excludes tools and caps both sides", () => { + test("ignores neighboring paragraphs while still rejecting selections across tools", () => { const root = document.createElement("div"); root.dataset.role = "assistant"; root.dataset.messageId = "a1"; @@ -320,8 +324,9 @@ describe("resolveAnnotationSelection", () => { selection.removeAllRanges(); selection.addRange(range); const target = resolveAnnotationSelection(selection)!; - expect(target.contextBefore).toBe("b".repeat(200)); - expect(target.contextAfter).toBe("a".repeat(200)); + expect(target.text).toBe("quote"); + expect(target).not.toHaveProperty("contextBefore"); + expect(target).not.toHaveProperty("contextAfter"); // Crossing an excluded card is invalid even with valid endpoints. range.setStart(root.querySelectorAll("p")[0].firstChild!, 0); range.setEnd(node, 5); diff --git a/frontend/tests/component/questionBookmarks.test.tsx b/frontend/tests/component/questionBookmarks.test.tsx new file mode 100644 index 0000000000..6ca315ce40 --- /dev/null +++ b/frontend/tests/component/questionBookmarks.test.tsx @@ -0,0 +1,231 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { + act, + render, + renderHook, + waitFor, + fireEvent, + screen, +} from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { afterEach, expect, test, vi } from "vitest"; +import { useQuestionBookmarks } from "@/hooks/chat/useQuestionBookmarks"; +import { QuestionBookmarkRail } from "@/app/newchat/ui/question-bookmark-rail"; +import { collectQuestionDirectory } from "@/app/newchat/utils/question-directory"; + +const state = vi.hoisted(() => ({ + user: { id: "user", tenantId: "tenant" }, + get: vi.fn(), + set: vi.fn(), + detail: vi.fn(), +})); +vi.mock("@/components/providers/AuthorizationProvider", () => ({ + useAuthorizationContext: () => ({ user: state.user }), +})); +vi.mock("@/services/conversationService", () => ({ + conversationService: { + getQuestionBookmarks: state.get, + setQuestionBookmark: state.set, + getDetail: state.detail, + }, +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, params?: { index?: number }) => + params?.index ? `${key}:${params.index}` : key, + }), +})); + +const clients: QueryClient[] = []; +function setup(initialId = 42) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + clients.push(client); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return { + client, + ...renderHook(({ id, enabled }) => useQuestionBookmarks(id, enabled), { + initialProps: { id: initialId, enabled: true }, + wrapper, + }), + }; +} +afterEach(() => { + clients.splice(0).forEach((client) => client.clear()); + vi.resetAllMocks(); + state.user = { id: "user", tenantId: "tenant" }; +}); + +test("loads saved bookmarks and persists removal with explicit state", async () => { + state.get.mockResolvedValue([17]); + state.set.mockResolvedValue([]); + const { result } = setup(); + await waitFor(() => expect(result.current.ready).toBe(true)); + expect(result.current.ids).toEqual([17]); + act(() => { + result.current.toggle(17, false); + result.current.toggle(17, false); + }); + await waitFor(() => expect(result.current.ids).toEqual([])); + expect(state.set).toHaveBeenCalledWith(42, 17, false); + expect(state.set).toHaveBeenCalledTimes(1); +}); + +test("failed saves retain known server state; pending saves cannot be repeated", async () => { + state.get.mockResolvedValue([17]); + let reject!: (error: Error) => void; + state.set.mockImplementation( + () => + new Promise((_, failure) => { + reject = failure; + }) + ); + const { result } = setup(); + await waitFor(() => expect(result.current.ready).toBe(true)); + act(() => result.current.toggle(17, false)); + await waitFor(() => expect(result.current.pending).toBe(true)); + act(() => result.current.toggle(17, false)); + expect(state.set).toHaveBeenCalledTimes(1); + act(() => reject(new Error("offline"))); + await waitFor(() => expect(result.current.failed).toBe(true)); + expect(result.current.ids).toEqual([17]); +}); + +test("a late write stays in the original account and conversation cache", async () => { + state.get.mockResolvedValue([]); + let finish!: (ids: number[]) => void; + state.set.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + const { result, rerender, client } = setup(); + await waitFor(() => expect(result.current.ready).toBe(true)); + act(() => result.current.toggle(17, true)); + await waitFor(() => expect(result.current.pending).toBe(true)); + state.user = { id: "other", tenantId: "tenant" }; + rerender({ id: 43, enabled: true }); + await waitFor(() => expect(result.current.ready).toBe(true)); + act(() => finish([17])); + await waitFor(() => expect(result.current.pending).toBe(false)); + expect(result.current.ids).toEqual([]); + expect( + client.getQueryData(["question-bookmarks", "user", "tenant", 42]) + ).toEqual([17]); +}); + +test("read-only and unsaved conversations do not fetch or mutate", async () => { + const { result, rerender } = setup(0); + act(() => result.current.toggle(17, true)); + rerender({ id: 42, enabled: false }); + act(() => result.current.toggle(17, true)); + expect(state.get).not.toHaveBeenCalled(); + expect(state.set).not.toHaveBeenCalled(); + expect(result.current.ready).toBe(false); +}); + +test("failed initial reads disable writes and can be retried", async () => { + state.get.mockRejectedValueOnce(new Error("offline")).mockResolvedValue([17]); + const { result } = setup(); + await waitFor(() => expect(result.current.failed).toBe(true)); + expect(result.current.ready).toBe(false); + act(() => result.current.toggle(17, true)); + expect(state.set).not.toHaveBeenCalled(); + act(() => result.current.retry()); + await waitFor(() => expect(result.current.ready).toBe(true)); + expect(result.current.ids).toEqual([17]); +}); + +test("newly generated runtime IDs resolve to persisted questions before bookmarking", async () => { + state.get.mockResolvedValue([]); + state.set.mockResolvedValue([17]); + state.detail.mockResolvedValue({ + data: [ + { + message: [ + { message_id: 17, message_index: 0, role: "user", message: "hello" }, + ], + }, + ], + }); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + clients.push(client); + const questions = collectQuestionDirectory([ + { + id: "runtime-id", + role: "user", + content: [{ type: "text", text: "hello" }], + }, + ]); + render( + + + + ); + fireEvent.focus( + screen.getByRole("button", { name: "chat.questions.jump:1" }) + ); + const mark = await screen.findByRole("button", { + name: "chat.questions.bookmark", + }); + await waitFor(() => expect(mark).not.toBeDisabled()); + fireEvent.click(mark); + await waitFor(() => expect(state.set).toHaveBeenCalledWith(42, 17, true)); + expect( + await screen.findByRole("button", { name: "chat.questions.unbookmark" }) + ).toHaveAttribute("aria-pressed", "true"); +}); + +test("ambiguous or mismatched runtime history cannot bookmark a different question", async () => { + state.get.mockResolvedValue([]); + state.detail.mockResolvedValue({ + data: [ + { + message: [ + { message_id: 99, role: "user", message: "another question" }, + ], + }, + ], + }); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + clients.push(client); + render( + + + + ); + fireEvent.focus( + screen.getByRole("button", { name: "chat.questions.jump:1" }) + ); + const mark = await screen.findByRole("button", { + name: "chat.questions.bookmark", + }); + await waitFor(() => expect(state.detail).toHaveBeenCalledWith(42)); + expect(mark).toBeDisabled(); + fireEvent.click(mark); + expect(state.set).not.toHaveBeenCalled(); +}); diff --git a/frontend/tests/component/questionDirectory.test.tsx b/frontend/tests/component/questionDirectory.test.tsx index e265cc95be..906748cbf9 100644 --- a/frontend/tests/component/questionDirectory.test.tsx +++ b/frontend/tests/component/questionDirectory.test.tsx @@ -16,9 +16,7 @@ import { useActiveQuestionId, type QuestionMessageLike, } from "@/app/newchat/utils/question-directory"; -import { QuestionDirectoryButton } from "@/app/newchat/ui/question-directory"; import { QuestionDirectoryRail } from "@/app/newchat/ui/question-directory-rail"; -import { TooltipProvider } from "@/components/ui/tooltip"; vi.mock("react-i18next", () => ({ useTranslation: () => ({ @@ -92,7 +90,7 @@ test("QuestionDirectoryRail previews question and reply on keyboard focus", asyn fireEvent.focus( screen.getByRole("button", { name: "chat.questions.jump:1" }) ); - const preview = await screen.findByRole("tooltip"); + const preview = await screen.findByRole("dialog"); expect(preview.textContent).toContain("question u1"); expect(preview.textContent).toContain("question a1"); }); @@ -130,7 +128,7 @@ test("QuestionDirectoryRail switches previews when moving from Q3 to adjacent Q2 if (this === second) return rect(0, 15, 32, 15); if (this === third) return rect(0, 30, 32, 15); // A tall preview beside the ticks overlaps the diagonal hover corridor. - if (this.getAttribute("role") === "tooltip") return rect(40, 0, 288, 300); + if (this.getAttribute("role") === "dialog") return rect(40, 0, 288, 300); return rect(0, 0, 0, 0); }); try { @@ -139,7 +137,7 @@ test("QuestionDirectoryRail switches previews when moving from Q3 to adjacent Q2 clientX: 31, clientY: 38, }); - expect((await screen.findByRole("tooltip")).textContent).toContain( + expect((await screen.findByRole("dialog")).textContent).toContain( "question u3" ); fireEvent.pointerLeave(third, { @@ -153,7 +151,7 @@ test("QuestionDirectoryRail switches previews when moving from Q3 to adjacent Q2 clientY: 25, }); await waitFor(() => { - const previews = screen.getAllByRole("tooltip"); + const previews = screen.getAllByRole("dialog"); expect(previews).toHaveLength(1); expect(previews[0].textContent).toContain("question u2"); expect(previews[0].textContent).not.toContain("question u3"); @@ -163,27 +161,99 @@ test("QuestionDirectoryRail switches previews when moving from Q3 to adjacent Q2 clientX: 0, clientY: 15, }); - await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull()); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); } finally { bounds.mockRestore(); } }); -test("QuestionDirectoryRail retains the searchable full directory", async () => { +test("QuestionDirectoryRail has no expanded-directory entry or search popover", () => { const questions = collectQuestionDirectory([ makeMessage("u1", "user"), makeMessage("u2", "user"), ]); - render(); + render( {}} />); + expect(screen.getAllByRole("button")).toHaveLength(2); + expect( + screen.queryByRole("button", { name: "chat.questions.viewAll:2" }) + ).toBeNull(); + expect( + screen.queryByRole("textbox", { name: "chat.questions.search" }) + ).toBeNull(); +}); + +test("bookmark action is interactive, preserves preview and requests explicit desired state", async () => { + const questions = collectQuestionDirectory([makeMessage("u1", "user")]); + const onBookmark = vi.fn(); + const { rerender, container } = render( + + ); + const tick = screen.getByRole("button", { name: "chat.questions.jump:1" }); + fireEvent.pointerMove(tick, { pointerType: "mouse" }); + const card = await screen.findByRole("dialog"); + fireEvent.pointerLeave(tick); + fireEvent.pointerEnter(card); + await new Promise((resolve) => setTimeout(resolve, 220)); + expect(screen.getByRole("dialog")).toBeVisible(); fireEvent.click( - screen.getByRole("button", { name: "chat.questions.viewAll:2" }) + screen.getByRole("button", { name: "chat.questions.bookmark" }) + ); + expect(onBookmark).toHaveBeenCalledWith("u1", true); + rerender( + ); - const search = await screen.findByRole("textbox", { - name: "chat.questions.search", + expect( + container.querySelector('[data-slot="question-rail-bookmark"]') + ).not.toBeNull(); + const remove = screen.getByRole("button", { + name: "chat.questions.unbookmark", }); - fireEvent.change(search, { target: { value: "u2" } }); - expect(screen.queryByText("question u1")).toBeNull(); - expect(screen.getByText("question u2")).toBeTruthy(); + expect(remove).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(remove); + expect(onBookmark).toHaveBeenLastCalledWith("u1", false); + fireEvent.keyDown(card, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); +}); + +test("unknown or pending bookmark state is disabled and read failures can retry", async () => { + const onBookmark = vi.fn(); + const retry = vi.fn(); + render( + false} + bookmarkFailed + onBookmarkRetry={retry} + /> + ); + fireEvent.focus( + screen.getByRole("button", { name: "chat.questions.jump:1" }) + ); + const button = await screen.findByRole("button", { + name: "chat.questions.bookmark", + }); + expect(button).toBeDisabled(); + fireEvent.click(button); + expect(onBookmark).not.toHaveBeenCalled(); + expect(screen.getByRole("alert")).toHaveTextContent( + "chat.questions.bookmarkFailed" + ); + fireEvent.click( + screen.getByRole("button", { name: "chat.questions.bookmarkRetry" }) + ); + expect(retry).toHaveBeenCalledOnce(); }); test("QuestionDirectoryRail keeps the active tick visible without scrolling chat", () => { @@ -370,234 +440,3 @@ test("useActiveQuestionId tracks the last question above the viewport midline", unmount(); getRectSpy.mockRestore(); }); - -const renderDirectoryButton = ( - props: Partial[0]> = {} -) => - render( - - {}} - {...props} - /> - - ); - -test("QuestionDirectoryButton hides without questions", () => { - const { container } = render( - - {}} /> - - ); - - expect(container.firstChild).toBeNull(); -}); - -test("QuestionDirectoryButton lists questions and jumps on click", async () => { - const onJump = vi.fn(); - renderDirectoryButton({ onJump }); - - // The trigger shows the question count. - const trigger = screen.getByRole("button", { - name: "chat.questions.viewAll:2", - }); - fireEvent.click(trigger); - - // The popover lists both questions with jump affordances. - expect(await screen.findByText("第一问是什么")).toBeTruthy(); - expect(screen.getByText("data.csv")).toBeTruthy(); - - fireEvent.click( - screen.getByRole("button", { name: "chat.questions.jump:1" }) - ); - - expect(onJump).toHaveBeenCalledWith("u1"); - // Clicking an entry closes the popover. - await waitFor(() => expect(screen.queryByText("第一问是什么")).toBeNull()); -}); - -test("QuestionDirectoryButton filters questions by keyword", async () => { - renderDirectoryButton(); - - fireEvent.click( - screen.getByRole("button", { name: "chat.questions.viewAll:2" }) - ); - const searchBox = await screen.findByRole("textbox", { - name: "chat.questions.search", - }); - - fireEvent.change(searchBox, { target: { value: "第一问" } }); - await waitFor(() => expect(screen.getByText("第一问是什么")).toBeTruthy()); - expect(screen.queryByText("data.csv")).toBeNull(); - - // Filtering is case-insensitive and matches attachment turns. - fireEvent.change(searchBox, { target: { value: "DATA" } }); - await waitFor(() => expect(screen.getByText("data.csv")).toBeTruthy()); - expect(screen.queryByText("第一问是什么")).toBeNull(); -}); - -test("QuestionDirectoryButton shows an empty state for unmatched keywords", async () => { - renderDirectoryButton(); - - fireEvent.click( - screen.getByRole("button", { name: "chat.questions.viewAll:2" }) - ); - const searchBox = await screen.findByRole("textbox", { - name: "chat.questions.search", - }); - - fireEvent.change(searchBox, { target: { value: "不存在的关键词" } }); - - await waitFor(() => - expect(screen.getByTestId("question-directory-no-matches")).toBeTruthy() - ); - expect(screen.queryByText("第一问是什么")).toBeNull(); - expect(screen.queryByText("data.csv")).toBeNull(); -}); - -test("QuestionDirectoryButton resets the keyword when the popover closes", async () => { - renderDirectoryButton(); - - fireEvent.click( - screen.getByRole("button", { name: "chat.questions.viewAll:2" }) - ); - const searchBox = await screen.findByRole("textbox", { - name: "chat.questions.search", - }); - fireEvent.change(searchBox, { target: { value: "data" } }); - await waitFor(() => expect(screen.getByText("data.csv")).toBeTruthy()); - - // Close via the trigger; reopening must show the unfiltered directory again. - fireEvent.click( - screen.getByRole("button", { name: "chat.questions.viewAll:2" }) - ); - await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull()); - fireEvent.click( - screen.getByRole("button", { name: "chat.questions.viewAll:2" }) - ); - - await waitFor(() => expect(screen.getByText("第一问是什么")).toBeTruthy()); - expect(screen.getByText("data.csv")).toBeTruthy(); -}); - -test("QuestionDirectoryButton marks the current question row", async () => { - renderDirectoryButton({ activeMessageId: "u1" }); - - fireEvent.click( - screen.getByRole("button", { name: "chat.questions.viewAll:2" }) - ); - - const activeRow = await screen.findByRole("button", { - name: "chat.questions.jump:1", - }); - expect(activeRow.getAttribute("aria-current")).toBe("location"); - expect(screen.getByTestId("question-directory-active").textContent).toBe( - "chat.questions.current" - ); - - const otherRow = screen.getByRole("button", { - name: "chat.questions.jump:2", - }); - expect(otherRow.getAttribute("aria-current")).toBeNull(); -}); - -test("QuestionDirectoryButton previews the question and reply on hover", async () => { - renderDirectoryButton(); - - fireEvent.click( - screen.getByRole("button", { name: "chat.questions.viewAll:2" }) - ); - const row = await screen.findByRole("button", { - name: "chat.questions.jump:1", - }); - - fireEvent.mouseEnter(row); - - const card = await screen.findByTestId("question-directory-preview"); - expect(card.textContent).toContain("第一问是什么"); - expect(card.textContent).toContain("这是第一问的回答,用于悬停预览。"); - - fireEvent.mouseLeave(row); - await waitFor(() => - expect(screen.queryByTestId("question-directory-preview")).toBeNull() - ); -}); - -test("QuestionDirectoryButton previews a question without a reply on hover", async () => { - renderDirectoryButton(); - - fireEvent.click( - screen.getByRole("button", { name: "chat.questions.viewAll:2" }) - ); - const row = await screen.findByRole("button", { - name: "chat.questions.jump:2", - }); - - fireEvent.mouseEnter(row); - - const card = await screen.findByTestId("question-directory-preview"); - expect(card.textContent).toContain("data.csv"); - expect(card.textContent).toContain("chat.questions.noReply"); -}); - -test("QuestionDirectoryButton clears the hover preview when a question is clicked", async () => { - const onJump = vi.fn(); - renderDirectoryButton({ onJump }); - - fireEvent.click( - screen.getByRole("button", { name: "chat.questions.viewAll:2" }) - ); - const row = await screen.findByRole("button", { - name: "chat.questions.jump:1", - }); - - fireEvent.mouseEnter(row); - await waitFor(() => - expect(screen.getByTestId("question-directory-preview")).toBeTruthy() - ); - - // Clicking a row closes the popover programmatically (not through - // onOpenChange); the portaled preview card must not survive that close. - fireEvent.click(row); - - expect(onJump).toHaveBeenCalledWith("u1"); - await waitFor(() => - expect(screen.queryByTestId("question-directory-preview")).toBeNull() - ); -}); - -test("QuestionDirectoryButton hides the hover preview when the directory list scrolls", async () => { - renderDirectoryButton(); - - fireEvent.click( - screen.getByRole("button", { name: "chat.questions.viewAll:2" }) - ); - const row = await screen.findByRole("button", { - name: "chat.questions.jump:1", - }); - - fireEvent.mouseEnter(row); - await waitFor(() => - expect(screen.getByTestId("question-directory-preview")).toBeTruthy() - ); - - // Wheel-scrolling moves rows under a stationary pointer without firing - // mouseleave, so the preview must be dropped when the list scrolls. - fireEvent.scroll(row.closest("ul")!); - - expect(screen.queryByTestId("question-directory-preview")).toBeNull(); -}); diff --git a/frontend/tests/component/rawStreamPreviewViewer.test.tsx b/frontend/tests/component/rawStreamPreviewViewer.test.tsx index 1d567c2d95..9ad3ed8f7f 100644 --- a/frontend/tests/component/rawStreamPreviewViewer.test.tsx +++ b/frontend/tests/component/rawStreamPreviewViewer.test.tsx @@ -115,8 +115,10 @@ describe("RawStreamPreviewViewer", () => { // never passes the sanitizer, so the option has to stay off. const options = vi.mocked(renderAsync).mock.calls[0]?.[3] as { renderAltChunks?: boolean; + inWrapper?: boolean; }; expect(options?.renderAltChunks).toBe(false); + expect(options?.inWrapper).toBe(true); // The link pass still runs over whatever docx-preview produced. expect(document.querySelector("a")?.hasAttribute("href")).toBe(false); }); diff --git a/frontend/types/chat.ts b/frontend/types/chat.ts index 717786e89c..78838dadd2 100644 --- a/frontend/types/chat.ts +++ b/frontend/types/chat.ts @@ -194,8 +194,9 @@ export type FilePreviewProps = { * - `"drawer"` (default): a side drawer over the current page. * - `"fullscreen"`: a full-viewport surface with a back arrow, used where a * document is the whole task (AIDP knowledge base documents). + * - `"inline"`: content only; the host owns the surrounding panel and controls. */ - presentation?: "drawer" | "fullscreen"; + presentation?: "drawer" | "fullscreen" | "inline"; } & (RemoteFilePreviewSource | LocalFilePreviewSource); // Main chat message type diff --git a/frontend/types/conversation.ts b/frontend/types/conversation.ts index f0c31b8a88..8f04902df0 100644 --- a/frontend/types/conversation.ts +++ b/frontend/types/conversation.ts @@ -113,8 +113,6 @@ export interface ApiMessageItem { } export interface ApiMessageAnnotation { - context_before?: string | null; - context_after?: string | null; quoted_text: string; comment?: string | null; source_excerpt?: string | null; diff --git a/test-e2e/changes/requirements/ANNOTATION-SELECTED-TEXT-ONLY-20261109.verification.md b/test-e2e/changes/requirements/ANNOTATION-SELECTED-TEXT-ONLY-20261109.verification.md new file mode 100644 index 0000000000..dc6a241916 --- /dev/null +++ b/test-e2e/changes/requirements/ANNOTATION-SELECTED-TEXT-ONLY-20261109.verification.md @@ -0,0 +1,27 @@ +# Selected-text-only annotations + +The frontend no longer traverses neighboring paragraphs or stores context in +selection targets, drafts, outgoing payloads or restored cards. Draft creation +uses an explicit field list, so stale runtime objects cannot reintroduce context. +The backend ignores old context keys while parsing requests and persisted JSON +units. Model-facing blocks contain only the selected quote and optional comment; +source excerpts remain display-only. No SQL migration or history rewrite is used. + +Checks performed: + +- PASS: 30 frontend tests in messageAnnotation.test.tsx, using the temporary + Vitest/React test environment outside the repository. Covers outgoing fields, + malformed legacy context, selection safety, quote/comment caps and cards. +- PASS: 15 backend tests from test_message_annotations.py selected by + `normalize or format or parse or legacy`; 7 unrelated AgentRequest/service/DB + integration tests were deselected. Uses temporary Python 3.12/Pydantic and + pure-Python SQLAlchemy dependencies, not the complete Python 3.11 baseline. +- PASS: copyright checks for all six affected source/test files; diff whitespace + check. Frontend files formatted using the project Prettier configuration. + +Full frontend build, deployed request/model-input inspection and the complete +backend integration environment have not been verified. Existing formal Cases +are preserved; no runtime/formal Case pass or acceptance completeness is claimed. +The separate nexent-doc repository remains unavailable for SPEC synchronization. +Other pending bookmark and question-rail edits were preserved. No deployment, +commit or push was performed. diff --git a/test-e2e/changes/requirements/ANNOTATION-SELECTED-TEXT-ONLY-20261109.yaml b/test-e2e/changes/requirements/ANNOTATION-SELECTED-TEXT-ONLY-20261109.yaml new file mode 100644 index 0000000000..c1f8edfc07 --- /dev/null +++ b/test-e2e/changes/requirements/ANNOTATION-SELECTED-TEXT-ONLY-20261109.yaml @@ -0,0 +1,45 @@ +schema_version: "1.0" +change: + change_id: ANNOTATION-SELECTED-TEXT-ONLY-20261109 + change_type: requirement + requirement_id: ANNOTATION-SELECTED-TEXT-ONLY-20261109 + title: Send only the selected quote and optional comment + description: >- + Stop capturing neighboring text when creating reply annotations. Omit + context_before/context_after from outgoing payloads and normalized history. + The backend ignores legacy context fields and never adds them to model-facing + annotation blocks. Preserve selected text, optional comments, source display + labels, quote/comment limits, multiple annotations, selection safety checks + and the clean persisted user-message body. No history deletion or SQL changes. + affected_features: + existing: [] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: strengthen + test_selectors: + - frontend/tests/component/messageAnnotation.test.tsx + - test/backend/services/test_message_annotations.py + behaviors: + - Capture selected text without reading neighboring paragraphs. + - Drop old context keys during creation, outgoing mapping and history normalization. + - Ignore even malformed legacy context values without discarding valid quotes. + - Model input contains only selected quote and comment per annotation. + - Retain multi-quote formatting, selection boundaries and existing length caps. + reason: >- + Strengthen existing annotation component/contract UT; preserve existing + formal Cases without duplicating UT in a new D1. Deployed model-input + verification remains separate from local tests. No model/provider, + persistence schema or API route changes. + notes: >- + Explicitly supersedes automatic neighboring-context capture in + ADD-TO-CHAT-LOCAL-CONTEXT-20261105. Existing JSON units are read compatibly; + no data migration is needed. Other pending bookmark and rail changes stay + intact. External nexent-doc is absent; SPEC synchronization remains pending. + No deployment, commit or push is included. diff --git a/test-e2e/changes/requirements/AUTO-OPEN-DOCUMENT-20261109.verification.md b/test-e2e/changes/requirements/AUTO-OPEN-DOCUMENT-20261109.verification.md new file mode 100644 index 0000000000..bfa5b53e55 --- /dev/null +++ b/test-e2e/changes/requirements/AUTO-OPEN-DOCUMENT-20261109.verification.md @@ -0,0 +1,49 @@ +# Automatic document display + +The thread observes live artifact publication, waits for the run to finish and +opens a new supported document beside chat. Restored history is the baseline, +not a new output event. Scope changes and loading clear preview selection; +manual close suppresses reopening for the same run. Artifact storage identity +deduplicates token/metadata updates. Files without a real source stay pending. +Office originals take precedence over companion PDFs; scripts are not automatic +preview candidates. The artifacts list still permits explicit preview selection. + +The document panel owns its localized title/list/download/close controls. On +large screens it is a sibling column; on narrow screens it fills the viewport. +The shared file viewer has an additive content-only inline presentation, while +its drawer and fullscreen defaults remain unchanged. Original docx/xlsx/pptx +bytes use the existing sanitized stream renderer. Legacy Office formats retain +the existing converted preview endpoint. No backend, model or file-generation +behavior changes, editing, native application launch or database changes apply. + +## Verification + +- PASS: 31 component tests across autoArtifactPreview.test.tsx, + artifactDocumentPanel.test.tsx, inlineFilePreview.test.tsx and the existing + rawStreamPreviewViewer.test.tsx. Covers completion timing, late uploads, + history/scope/loading isolation, close/manual reopen, primary-document choice, + unsupported/process files, source URLs, panel actions, inline/drawer/fullscreen + compatibility, embedded Office HTML sanitization and PPT group rendering. +- PASS: 6 existing standalone Node tests in frontend/tests/officePreview.test.ts. +- PASS: design and implementation asset validators with + --require-ut-change AUTO-OPEN-DOCUMENT-20261109. Existing formal Cases are + preserved; static asset validation is not browser/runtime acceptance. +- PASS: copyright checks on all nine affected source/test files and diff + whitespace checks. New component/hook/test files use project Prettier settings. +- LIMITED: scoped TypeScript check reports only missing next/dynamic in the six + selected affected production files. The full dependency tree has 136 + diagnostics in this temporary environment. Full project type-check/build is + BLOCKED by missing complete frontend dependencies, not reported as passing. + +Tests use temporary dependencies outside Git, including Vitest 4.1.12 and React +19.3.1. PDF loading is a controlled deferred-component boundary in component +tests; actual browser PDF rendering is not claimed. Existing OOXML parser tests +control fetched content and parser output; they are not a real generated-document +end-to-end check. Python UT is unrelated to this frontend-only change. + +Actual deployed document generation, visual layout, narrow-screen behavior and +authenticated file preview/download require browser integration verification. +The external nexent-doc and ../docs/前端ui.md sources are unavailable; SPEC +synchronization and formal D4 journey acceptance remain pending. No deployment, +production data mutation, commit, push or target-branch merge was performed. +Other pending bookmark, annotation and rail changes were preserved. diff --git a/test-e2e/changes/requirements/AUTO-OPEN-DOCUMENT-20261109.yaml b/test-e2e/changes/requirements/AUTO-OPEN-DOCUMENT-20261109.yaml new file mode 100644 index 0000000000..23dd71d5a5 --- /dev/null +++ b/test-e2e/changes/requirements/AUTO-OPEN-DOCUMENT-20261109.yaml @@ -0,0 +1,51 @@ +schema_version: "1.0" +change: + change_id: AUTO-OPEN-DOCUMENT-20261109 + change_type: requirement + requirement_id: AUTO-OPEN-DOCUMENT-20261109 + title: Automatically show newly generated documents beside chat + description: >- + After an observed live run finishes, open its newly generated previewable + document in a right-side surface while chat stays usable. Prefer Office + originals over companion PDFs and ignore executable/intermediate scripts. + Do not auto-open historical files during loading, conversation switching or + read-only views. Manual close suppresses further automatic opening for that + run; the artifacts list can reopen any file. Deduplicate by stored object + identity so metadata/token updates do not reopen a closed document. Support + preview loading/error states and download. Use a full-width surface on narrow + screens. No editing, native Word launch, model or backend changes are included. + affected_features: + existing: [] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + test_selectors: + - frontend/tests/component/autoArtifactPreview.test.tsx + - frontend/tests/component/artifactDocumentPanel.test.tsx + - frontend/tests/component/inlineFilePreview.test.tsx + - frontend/tests/component/rawStreamPreviewViewer.test.tsx + - frontend/tests/officePreview.test.ts + behaviors: + - Open only new supported documents after a live run completes. + - Preserve history/load/scope isolation, explicit close and manual reopening. + - Prefer Office originals and ignore intermediate scripts and missing sources. + - Reuse streaming OOXML rendering without treating original Office bytes as PDF. + - Keep other shared drawer/fullscreen callers compatible and download available. + reason: >- + Add focused preview lifecycle/source-selection/component tests and reuse + existing document-renderer tests. No Python behavior changes; Python UT is + unrelated. Preserve formal Cases; deployed browser layout and generated-file + integration remain separate from isolated component checks. + notes: >- + User confirmed automatic display, without editing. Existing artifact aggregation + and document viewers are reused. No temporary test dependencies are committed. + External nexent-doc and ../docs/前端ui.md are absent, so SPEC synchronization + and deployed browser acceptance remain pending. Existing bookmark/annotation + modifications are preserved. No deployment or push is performed. diff --git a/test-e2e/changes/requirements/QUESTION-BOOKMARKS-20261109.verification.md b/test-e2e/changes/requirements/QUESTION-BOOKMARKS-20261109.verification.md new file mode 100644 index 0000000000..b20d384834 --- /dev/null +++ b/test-e2e/changes/requirements/QUESTION-BOOKMARKS-20261109.verification.md @@ -0,0 +1,63 @@ +# Question bookmarks verification + +Implementation uses an owner-private boolean on user-message rows. GET and PUT +question bookmark endpoints authenticate identity and scope the operation to an +undeleted owned conversation and undeleted user message. PUT sets desired state +instead of flipping it. Message and conversation activity timestamps are preserved. +The additive migration leaves existing messages unmarked. Branch copying omits +the bookmark column, so new branches start without copied bookmarks. + +The interactive preview opens directly on each hovered/focused tick, allows a +180 ms pointer transfer into the card, and switches adjacent questions without a +tooltip grace corridor. ArrowRight enters the bookmark control; Escape closes +the preview. Bookmarked ticks and the current-reading dot are independent. + +Runtime IDs are mapped only when fetched history count and question text agree; +otherwise mutation stays disabled and the user can retry. Loaded numeric message +IDs use their persisted identity. Query keys include account, tenant and +conversation. In-flight writes retain their original query key. Failed writes +retain the last server state; unknown reads and pending writes disable mutation. + +## Executed checks + +- PASS: frontend component UT, 23 tests in questionDirectory.test.tsx and + questionBookmarks.test.tsx. Covers original navigation and adjacent-hover + regression, pointer transfer, explicit mark/unmark, Escape, disabled/error + state, retry, loaded state, duplicate clicks, late writes after account/chat + switch, read-only/unsaved chats and runtime-ID alignment/mismatch. +- PASS: 17 Python UT in test/backend/services/test_question_bookmarks.py. + Controlled session/auth boundaries with real SQLAlchemy statements and FastAPI + request validation. Checks owner scope, user-message restriction, false/true + persistence, readback, activity preservation, invalid identifiers/body values, + missing/foreign resources, authentication and sanitized storage errors. +- PASS: design and implementation asset validators with + --require-ut-change QUESTION-BOOKMARKS-20261109; no new formal execution passes + are claimed. Existing formal Cases are preserved. +- PASS: copyright checks on all 15 affected source/SQL/test files and diff + whitespace check. +- Scoped TypeScript check: zero diagnostics in the six affected production + TypeScript files using temporary dependency resolution and a CSS declaration. + This is not a full project type-check: 153 diagnostics remain in the dependency + tree because the complete frontend environment is unavailable. + +## Verification limits + +Tests used temporary dependencies outside Git: Vitest 4.1.12, React 19.3.1, +React Query 5.90.12, TypeScript 5.9.3, Python 3.12, FastAPI 0.162.2 and pure-Python +SQLAlchemy 2.0.45. Windows application control blocked the compiled SQLAlchemy +extension. These checks do not establish the locked Python 3.11/full npm baseline. +Full project build, browser layout/pointer journey, deployed API/database +persistence, cross-browser recovery and migration rollout remain BLOCKED pending +a prepared deployment/test environment. No live application data was mutated. + +The sibling nexent-doc and authoritative ../docs/前端ui.md are absent. External +SPEC synchronization, formal Feature/Case authoring and D2/D4/D5 runtime proof +remain pending; this delivery does not claim formal acceptance completeness. +No deployment, commit, push or target-branch merge was performed. + +## Deployment + +Build both main and web images with the latest tag, then use the existing Docker +deployment script with local-latest. Deployment must apply the new +v2.9.0_009_question_bookmarks.sql migration before the updated backend starts. +Do not merely restart the web container or reuse its old image. diff --git a/test-e2e/changes/requirements/QUESTION-BOOKMARKS-20261109.yaml b/test-e2e/changes/requirements/QUESTION-BOOKMARKS-20261109.yaml new file mode 100644 index 0000000000..40565e053c --- /dev/null +++ b/test-e2e/changes/requirements/QUESTION-BOOKMARKS-20261109.yaml @@ -0,0 +1,52 @@ +schema_version: "1.0" +change: + change_id: QUESTION-BOOKMARKS-20261109 + change_type: requirement + requirement_id: QUESTION-BOOKMARKS-20261109 + title: Account-persisted question bookmarks + description: >- + Add a bookmark toggle to the upper-right of each question preview. Save + explicit desired state by conversation and user-message identity on the + server. Restore it after reopening or changing browsers. Mark bookmarked + ticks independently of the current-reading dot. Support pointer entry into + the preview, immediate switching between adjacent ticks, keyboard focus and + Escape. Do not reopen the removed full directory, change model inputs, copy + bookmarks to branches or change conversation activity. Shared read-only + conversations cannot mutate bookmarks. Loading or failed reads cannot be + mistaken for a known unmarked state; failed writes retain previous state. + affected_features: + existing: [] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + test_selectors: + - frontend/tests/component/questionDirectory.test.tsx + - frontend/tests/component/questionBookmarks.test.tsx + - test/backend/services/test_question_bookmarks.py + behaviors: + - Persist and remove bookmark state with owner and user-message scope. + - Reject invalid identity, identifiers, non-user messages and foreign conversations. + - Preserve activity timestamps and isolate query caches by account and conversation. + - Keep interactive preview open during pointer transfer and switch adjacent questions. + - Restore bookmarks, expose failures and prevent duplicate pending writes. + reason: >- + Existing questionDirectory component UT only proves display/navigation; + existing pin tests concern whole Workbench conversations. Strengthen rail + UT and add isolated API/service/database and hook tests. No duplicate D1. + Cross-browser API persistence, migration rollout and browser pointer/layout + acceptance require a deployed application; these remain unverified until + runtime prerequisites are available. Existing formal Cases are preserved. + notes: >- + Baseline is the current question rail after REMOVE-QUESTION-DIRECTORY-POPOVER-20261109. + The user explicitly requests account persistence. Use one boolean column on + owner-private conversation user messages, false by default, with a new additive + migration. API ownership comes from authenticated user identity, never the body. + External SPEC/UI repositories are absent; synchronization is pending. No + deployment, production mutation or remote push is included. diff --git a/test-e2e/changes/requirements/REMOVE-QUESTION-DIRECTORY-POPOVER-20261109.yaml b/test-e2e/changes/requirements/REMOVE-QUESTION-DIRECTORY-POPOVER-20261109.yaml new file mode 100644 index 0000000000..f946097857 --- /dev/null +++ b/test-e2e/changes/requirements/REMOVE-QUESTION-DIRECTORY-POPOVER-20261109.yaml @@ -0,0 +1,42 @@ +schema_version: "1.0" +change: + change_id: REMOVE-QUESTION-DIRECTORY-POPOVER-20261109 + change_type: requirement + requirement_id: REMOVE-QUESTION-DIRECTORY-POPOVER-20261109 + title: Remove the expanded history-question directory entry + description: >- + Remove the list icon shown in the user's screenshot and its searchable + question-directory popover. Keep the left-side question ticks, hover/focus + previews, active reading-position marker and click navigation. Do not delete + conversation records or change sidebar history, model input or persistence. + Remove the now-unused popover component and its superseded UT; retain the + rail and question collection/navigation regressions. + affected_features: + existing: [] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: strengthen + test_selectors: + - frontend/tests/component/questionDirectory.test.tsx + behaviors: + - Render only question ticks, without the full-directory trigger or search popover. + - Retain tick jumping, keyboard-focus previews, hover switching and active-position scrolling. + - Retain question collection, answer excerpts and missing-message behavior. + reason: >- + This bounded frontend removal replaces obsolete popover UT with an absence + regression and retains utility/rail coverage. Python UT is unrelated; no + backend or external protocol changes and no duplicate formal D1 is needed. + notes: >- + Supersedes the full-directory secondary-entry requirement in + QUESTION-DIRECTORY-RAIL-20261105 and the popover UI in + QUESTION-DIRECTORY-JUMP-20261102. Existing formal Cases are preserved. + The screenshot scopes this removal to the list icon/popover; rail navigation + remains. External SPEC synchronization is unavailable because sibling + nexent-doc is absent. No deployment or automatic push is performed. diff --git a/test/backend/services/test_message_annotations.py b/test/backend/services/test_message_annotations.py index 42b0d81755..6cea6cb726 100644 --- a/test/backend/services/test_message_annotations.py +++ b/test/backend/services/test_message_annotations.py @@ -21,16 +21,15 @@ normalize_message_annotations, parse_message_annotation_unit, ) -from contracts.agent import AgentRequest ANNOTATION = { 'quoted_text': 'quoted reply text', 'comment': 'note', 'source_excerpt': 'reply intro', - 'context_before': 'Redis shares the cache.', - 'context_after': 'Consider expiration.', } +LEGACY_ANNOTATION = dict(ANNOTATION, context_before='Redis shares the cache.', context_after='Consider expiration.') + def test_normalize_validates_trims_and_drops_unusable_entries(): annotations = normalize_message_annotations([ @@ -86,28 +85,24 @@ def test_format_without_annotations_returns_text_unchanged(): assert format_annotations_for_llm('why stop?', []) == 'why stop?' -def test_context_caps_keep_nearest_text_and_reject_invalid_fields(): +def test_legacy_context_is_ignored_even_when_malformed(): normalized = normalize_message_annotations([{ 'quoted_text': 'selected', 'context_before': 'x' * 250 + ' nearest', 'context_after': 'nearest ' + 'y' * 250, }]) - assert len(normalized[0].context_before) == 200 - assert normalized[0].context_before.endswith(' nearest') - assert len(normalized[0].context_after) == 200 - assert normalized[0].context_after.startswith('nearest ') - assert normalize_message_annotations([{'quoted_text': 'q', 'context_before': {}}]) is None + assert normalized[0].model_dump() == {'quoted_text': 'selected', 'comment': None, 'source_excerpt': None} + malformed = normalize_message_annotations([{'quoted_text': 'q', 'context_before': {}, 'context_after': 42}]) + assert malformed[0].quoted_text == 'q' legacy = normalize_message_annotations([{'quoted_text': 'legacy', 'source_excerpt': 'intro'}]) - assert legacy[0].context_before is None + assert 'context_before' not in legacy[0].model_dump() assert format_annotations_for_llm('', legacy) == '1. Quote:\n> legacy' -def test_context_is_distinct_from_selection_and_source_label_is_ui_only(): - merged = format_annotations_for_llm('why?', normalize_message_annotations([ANNOTATION])) +def test_format_ignores_legacy_context_and_keeps_source_label_ui_only(): + merged = format_annotations_for_llm('why?', normalize_message_annotations([LEGACY_ANNOTATION])) assert merged == ( - '1. Quote:\nContext before (not selected):\n> Redis shares the cache.\n' - 'Selected text:\n> quoted reply text\n' - 'Context after (not selected):\n> Consider expiration.\n' + '1. Quote:\n> quoted reply text\n' 'Comment: note\n\n---\n\nwhy?' ) assert 'reply intro' not in merged @@ -157,7 +152,14 @@ def test_parse_unit_round_trips_and_rejects_invalid_payloads(): assert parse_message_annotation_unit(json.dumps([{'quoted_text': ''}])) is None +def test_parse_legacy_units_never_restores_context_to_model_input(): + parsed = parse_message_annotation_unit(json.dumps([LEGACY_ANNOTATION])) + assert parsed[0].model_dump() == ANNOTATION + assert format_annotations_for_llm('', parsed) == '1. Quote:\n> quoted reply text\nComment: note' + + def test_agent_request_retains_structured_annotations(): + from contracts.agent import AgentRequest request = AgentRequest(query='why stop?', conversation_id=1, annotations=[ANNOTATION]) assert request.annotations[0].comment == 'note' @@ -166,6 +168,7 @@ def test_agent_request_retains_structured_annotations(): def test_service_passes_annotations_to_atomic_message_save(monkeypatch): + from contracts.agent import AgentRequest from management.services.conversation import service captured = [] @@ -221,7 +224,8 @@ def message_db(monkeypatch): 'CREATE TABLE nexent.conversation_message_t ' '(message_id INTEGER PRIMARY KEY, conversation_id INTEGER, message_index INTEGER, ' 'message_role TEXT, message_source TEXT, message_content TEXT, minio_files TEXT, ' - 'opinion_flag TEXT, status TEXT, delete_flag TEXT, created_by TEXT, updated_by TEXT, ' + 'opinion_flag TEXT, question_bookmarked BOOLEAN NOT NULL DEFAULT FALSE, ' + 'status TEXT, delete_flag TEXT, created_by TEXT, updated_by TEXT, ' 'create_time DATETIME, update_time DATETIME)') connection.exec_driver_sql( 'CREATE TABLE nexent.conversation_message_unit_t ' @@ -317,10 +321,7 @@ def test_historical_turns_merge_annotations_into_model_facing_text(message_db): assert len(turns) == 1 assert turns[0]['user_message'] == ( '1. Quote:\n' - 'Context before (not selected):\n> Redis shares the cache.\n' - 'Selected text:\n' '> quoted reply text\n' - 'Context after (not selected):\n> Consider expiration.\n' 'Comment: note\n\n---\n\nwhy stop?' ) assert turns[0]['assistant_final_answer'] == 'because of the quota' diff --git a/test/backend/services/test_question_bookmarks.py b/test/backend/services/test_question_bookmarks.py new file mode 100644 index 0000000000..3be8432428 --- /dev/null +++ b/test/backend/services/test_question_bookmarks.py @@ -0,0 +1,123 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Bookmark validation, owner-scoped SQL and HTTP contracts with isolated I/O.""" + +import importlib.util +import sys +import types +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.dialects import postgresql + +from contracts.errors.exceptions import ConversationNotFoundError, UnauthorizedError, ValidationError + + +@pytest.fixture +def boundary(monkeypatch): + session = MagicMock() + + @contextmanager + def get_session(): + yield session + + client_module = types.ModuleType("management.db.client") + client_module.get_db_session = get_session + auth_module = types.ModuleType("gateway.authentication.auth") + auth_module.get_current_user_id = MagicMock(return_value=("user", "tenant")) + monkeypatch.setitem(sys.modules, "management.db.client", client_module) + monkeypatch.setitem(sys.modules, "gateway.authentication.auth", auth_module) + root = Path(__file__).resolve().parents[3] / "backend" + + def load(name, relative): + spec = importlib.util.spec_from_file_location(name, root / relative) + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, name, module) + spec.loader.exec_module(module) + return module + + db = load("management.db.conversation.bookmarks", "management/db/conversation/bookmarks.py") + service = load("management.services.conversation.bookmarks", "management/services/conversation/bookmarks.py") + routes = load("management.api.conversation.question_bookmarks", "management/api/conversation/question_bookmarks.py") + app = FastAPI() + app.include_router(routes.router) + return types.SimpleNamespace(db=db, service=service, routes=routes, session=session, + auth=auth_module.get_current_user_id, client=TestClient(app)) + + +def sql(statement): + return str(statement.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True})) + + +@pytest.mark.parametrize("marked", [True, False]) +def test_write_and_restore_are_owner_scoped_without_activity_updates(boundary, marked): + b = boundary + b.session.scalar.side_effect = [42, 17, 42] + b.session.scalars.return_value = [17] if marked else [] + expected = {"message_ids": [17] if marked else []} + response = b.client.put("/conversation/42/question_bookmarks/17", json={"bookmarked": marked}) + assert response.status_code == 200 + assert response.json()["data"] == expected + ownership, write = [sql(call.args[0]) for call in b.session.scalar.call_args_list] + assert "created_by = 'user'" in ownership and "delete_flag = 'N'" in ownership + assert "FOR UPDATE" in ownership + assert "conversation_id = 42" in write and "message_id = 17" in write + assert "message_role = 'user'" in write and "delete_flag = 'N'" in write + assert "update_time=nexent.conversation_message_t.update_time" in write + assert "message_content=" not in write + assert b.client.get("/conversation/42/question_bookmarks").json()["data"] == expected + + +@pytest.mark.parametrize("scalars", [[None], [42, None]]) +def test_foreign_conversation_or_invalid_question_cannot_be_updated(boundary, scalars): + boundary.session.scalar.side_effect = scalars + response = boundary.client.put("/conversation/42/question_bookmarks/17", json={"bookmarked": True}) + assert response.status_code == 404 + boundary.session.scalars.assert_not_called() + + +@pytest.mark.parametrize("path,body", [ + ("0/17", {"bookmarked": True}), ("42/0", {"bookmarked": True}), + ("42/2147483648", {"bookmarked": True}), ("42/17", {}), + ("42/17", {"bookmarked": 1}), ("42/17", {"bookmarked": "true"}), + ("42/17", {"bookmarked": None}), +]) +def test_invalid_http_input_has_no_database_side_effect(boundary, path, body): + conversation, message = path.split("/") + assert boundary.client.put( + f"/conversation/{conversation}/question_bookmarks/{message}", json=body + ).status_code == 422 + boundary.session.scalar.assert_not_called() + + +@pytest.mark.parametrize("conversation,message,marked", [(True, 17, True), (42, -1, True), (42, 17, 1)]) +def test_service_rejects_invalid_internal_callers(boundary, conversation, message, marked): + with pytest.raises(ValidationError): + boundary.service.set_question_bookmark(conversation, message, marked, "user", "tenant") + boundary.session.scalar.assert_not_called() + + +def test_unauthenticated_requests_do_not_access_storage(boundary): + boundary.auth.side_effect = UnauthorizedError("expired") + assert boundary.client.get("/conversation/42/question_bookmarks").status_code == 401 + boundary.session.scalar.assert_not_called() + + +def test_storage_failure_is_sanitized(boundary): + boundary.session.scalar.side_effect = RuntimeError("secret connection string") + response = boundary.client.get("/conversation/42/question_bookmarks") + assert response.status_code == 500 + assert "secret" not in response.text + + +def test_list_is_read_only_and_identity_required(boundary): + boundary.session.scalar.return_value = 42 + boundary.session.scalars.return_value = [] + assert boundary.service.list_question_bookmarks(42, "user", "tenant") == {"message_ids": []} + assert "FOR UPDATE" not in sql(boundary.session.scalar.call_args.args[0]) + with pytest.raises(UnauthorizedError): + boundary.service.list_question_bookmarks(42, "", "tenant") From 28315e6d4da2128c8698d4c6c59c66e5812c2f5a Mon Sep 17 00:00:00 2001 From: ljy Date: Mon, 9 Nov 2026 15:16:57 +0800 Subject: [PATCH 18/26] chore: remove chat change records and verification reports --- ...ELECTED-TEXT-ONLY-20261109.verification.md | 27 -------- ...NNOTATION-SELECTED-TEXT-ONLY-20261109.yaml | 45 ------------- ...UTO-OPEN-DOCUMENT-20261109.verification.md | 49 --------------- .../AUTO-OPEN-DOCUMENT-20261109.yaml | 51 --------------- ...UESTION-BOOKMARKS-20261109.verification.md | 63 ------------------- .../QUESTION-BOOKMARKS-20261109.yaml | 52 --------------- ...E-QUESTION-DIRECTORY-POPOVER-20261109.yaml | 42 ------------- 7 files changed, 329 deletions(-) delete mode 100644 test-e2e/changes/requirements/ANNOTATION-SELECTED-TEXT-ONLY-20261109.verification.md delete mode 100644 test-e2e/changes/requirements/ANNOTATION-SELECTED-TEXT-ONLY-20261109.yaml delete mode 100644 test-e2e/changes/requirements/AUTO-OPEN-DOCUMENT-20261109.verification.md delete mode 100644 test-e2e/changes/requirements/AUTO-OPEN-DOCUMENT-20261109.yaml delete mode 100644 test-e2e/changes/requirements/QUESTION-BOOKMARKS-20261109.verification.md delete mode 100644 test-e2e/changes/requirements/QUESTION-BOOKMARKS-20261109.yaml delete mode 100644 test-e2e/changes/requirements/REMOVE-QUESTION-DIRECTORY-POPOVER-20261109.yaml diff --git a/test-e2e/changes/requirements/ANNOTATION-SELECTED-TEXT-ONLY-20261109.verification.md b/test-e2e/changes/requirements/ANNOTATION-SELECTED-TEXT-ONLY-20261109.verification.md deleted file mode 100644 index dc6a241916..0000000000 --- a/test-e2e/changes/requirements/ANNOTATION-SELECTED-TEXT-ONLY-20261109.verification.md +++ /dev/null @@ -1,27 +0,0 @@ -# Selected-text-only annotations - -The frontend no longer traverses neighboring paragraphs or stores context in -selection targets, drafts, outgoing payloads or restored cards. Draft creation -uses an explicit field list, so stale runtime objects cannot reintroduce context. -The backend ignores old context keys while parsing requests and persisted JSON -units. Model-facing blocks contain only the selected quote and optional comment; -source excerpts remain display-only. No SQL migration or history rewrite is used. - -Checks performed: - -- PASS: 30 frontend tests in messageAnnotation.test.tsx, using the temporary - Vitest/React test environment outside the repository. Covers outgoing fields, - malformed legacy context, selection safety, quote/comment caps and cards. -- PASS: 15 backend tests from test_message_annotations.py selected by - `normalize or format or parse or legacy`; 7 unrelated AgentRequest/service/DB - integration tests were deselected. Uses temporary Python 3.12/Pydantic and - pure-Python SQLAlchemy dependencies, not the complete Python 3.11 baseline. -- PASS: copyright checks for all six affected source/test files; diff whitespace - check. Frontend files formatted using the project Prettier configuration. - -Full frontend build, deployed request/model-input inspection and the complete -backend integration environment have not been verified. Existing formal Cases -are preserved; no runtime/formal Case pass or acceptance completeness is claimed. -The separate nexent-doc repository remains unavailable for SPEC synchronization. -Other pending bookmark and question-rail edits were preserved. No deployment, -commit or push was performed. diff --git a/test-e2e/changes/requirements/ANNOTATION-SELECTED-TEXT-ONLY-20261109.yaml b/test-e2e/changes/requirements/ANNOTATION-SELECTED-TEXT-ONLY-20261109.yaml deleted file mode 100644 index c1f8edfc07..0000000000 --- a/test-e2e/changes/requirements/ANNOTATION-SELECTED-TEXT-ONLY-20261109.yaml +++ /dev/null @@ -1,45 +0,0 @@ -schema_version: "1.0" -change: - change_id: ANNOTATION-SELECTED-TEXT-ONLY-20261109 - change_type: requirement - requirement_id: ANNOTATION-SELECTED-TEXT-ONLY-20261109 - title: Send only the selected quote and optional comment - description: >- - Stop capturing neighboring text when creating reply annotations. Omit - context_before/context_after from outgoing payloads and normalized history. - The backend ignores legacy context fields and never adds them to model-facing - annotation blocks. Preserve selected text, optional comments, source display - labels, quote/comment limits, multiple annotations, selection safety checks - and the clean persisted user-message body. No history deletion or SQL changes. - affected_features: - existing: [] - added: [] - modified: [] - retired: [] - affected_cases: - existing: [] - added: [] - modified: [] - retired: [] - traditional_ut: - decision: strengthen - test_selectors: - - frontend/tests/component/messageAnnotation.test.tsx - - test/backend/services/test_message_annotations.py - behaviors: - - Capture selected text without reading neighboring paragraphs. - - Drop old context keys during creation, outgoing mapping and history normalization. - - Ignore even malformed legacy context values without discarding valid quotes. - - Model input contains only selected quote and comment per annotation. - - Retain multi-quote formatting, selection boundaries and existing length caps. - reason: >- - Strengthen existing annotation component/contract UT; preserve existing - formal Cases without duplicating UT in a new D1. Deployed model-input - verification remains separate from local tests. No model/provider, - persistence schema or API route changes. - notes: >- - Explicitly supersedes automatic neighboring-context capture in - ADD-TO-CHAT-LOCAL-CONTEXT-20261105. Existing JSON units are read compatibly; - no data migration is needed. Other pending bookmark and rail changes stay - intact. External nexent-doc is absent; SPEC synchronization remains pending. - No deployment, commit or push is included. diff --git a/test-e2e/changes/requirements/AUTO-OPEN-DOCUMENT-20261109.verification.md b/test-e2e/changes/requirements/AUTO-OPEN-DOCUMENT-20261109.verification.md deleted file mode 100644 index bfa5b53e55..0000000000 --- a/test-e2e/changes/requirements/AUTO-OPEN-DOCUMENT-20261109.verification.md +++ /dev/null @@ -1,49 +0,0 @@ -# Automatic document display - -The thread observes live artifact publication, waits for the run to finish and -opens a new supported document beside chat. Restored history is the baseline, -not a new output event. Scope changes and loading clear preview selection; -manual close suppresses reopening for the same run. Artifact storage identity -deduplicates token/metadata updates. Files without a real source stay pending. -Office originals take precedence over companion PDFs; scripts are not automatic -preview candidates. The artifacts list still permits explicit preview selection. - -The document panel owns its localized title/list/download/close controls. On -large screens it is a sibling column; on narrow screens it fills the viewport. -The shared file viewer has an additive content-only inline presentation, while -its drawer and fullscreen defaults remain unchanged. Original docx/xlsx/pptx -bytes use the existing sanitized stream renderer. Legacy Office formats retain -the existing converted preview endpoint. No backend, model or file-generation -behavior changes, editing, native application launch or database changes apply. - -## Verification - -- PASS: 31 component tests across autoArtifactPreview.test.tsx, - artifactDocumentPanel.test.tsx, inlineFilePreview.test.tsx and the existing - rawStreamPreviewViewer.test.tsx. Covers completion timing, late uploads, - history/scope/loading isolation, close/manual reopen, primary-document choice, - unsupported/process files, source URLs, panel actions, inline/drawer/fullscreen - compatibility, embedded Office HTML sanitization and PPT group rendering. -- PASS: 6 existing standalone Node tests in frontend/tests/officePreview.test.ts. -- PASS: design and implementation asset validators with - --require-ut-change AUTO-OPEN-DOCUMENT-20261109. Existing formal Cases are - preserved; static asset validation is not browser/runtime acceptance. -- PASS: copyright checks on all nine affected source/test files and diff - whitespace checks. New component/hook/test files use project Prettier settings. -- LIMITED: scoped TypeScript check reports only missing next/dynamic in the six - selected affected production files. The full dependency tree has 136 - diagnostics in this temporary environment. Full project type-check/build is - BLOCKED by missing complete frontend dependencies, not reported as passing. - -Tests use temporary dependencies outside Git, including Vitest 4.1.12 and React -19.3.1. PDF loading is a controlled deferred-component boundary in component -tests; actual browser PDF rendering is not claimed. Existing OOXML parser tests -control fetched content and parser output; they are not a real generated-document -end-to-end check. Python UT is unrelated to this frontend-only change. - -Actual deployed document generation, visual layout, narrow-screen behavior and -authenticated file preview/download require browser integration verification. -The external nexent-doc and ../docs/前端ui.md sources are unavailable; SPEC -synchronization and formal D4 journey acceptance remain pending. No deployment, -production data mutation, commit, push or target-branch merge was performed. -Other pending bookmark, annotation and rail changes were preserved. diff --git a/test-e2e/changes/requirements/AUTO-OPEN-DOCUMENT-20261109.yaml b/test-e2e/changes/requirements/AUTO-OPEN-DOCUMENT-20261109.yaml deleted file mode 100644 index 23dd71d5a5..0000000000 --- a/test-e2e/changes/requirements/AUTO-OPEN-DOCUMENT-20261109.yaml +++ /dev/null @@ -1,51 +0,0 @@ -schema_version: "1.0" -change: - change_id: AUTO-OPEN-DOCUMENT-20261109 - change_type: requirement - requirement_id: AUTO-OPEN-DOCUMENT-20261109 - title: Automatically show newly generated documents beside chat - description: >- - After an observed live run finishes, open its newly generated previewable - document in a right-side surface while chat stays usable. Prefer Office - originals over companion PDFs and ignore executable/intermediate scripts. - Do not auto-open historical files during loading, conversation switching or - read-only views. Manual close suppresses further automatic opening for that - run; the artifacts list can reopen any file. Deduplicate by stored object - identity so metadata/token updates do not reopen a closed document. Support - preview loading/error states and download. Use a full-width surface on narrow - screens. No editing, native Word launch, model or backend changes are included. - affected_features: - existing: [] - added: [] - modified: [] - retired: [] - affected_cases: - existing: [] - added: [] - modified: [] - retired: [] - traditional_ut: - decision: add - test_selectors: - - frontend/tests/component/autoArtifactPreview.test.tsx - - frontend/tests/component/artifactDocumentPanel.test.tsx - - frontend/tests/component/inlineFilePreview.test.tsx - - frontend/tests/component/rawStreamPreviewViewer.test.tsx - - frontend/tests/officePreview.test.ts - behaviors: - - Open only new supported documents after a live run completes. - - Preserve history/load/scope isolation, explicit close and manual reopening. - - Prefer Office originals and ignore intermediate scripts and missing sources. - - Reuse streaming OOXML rendering without treating original Office bytes as PDF. - - Keep other shared drawer/fullscreen callers compatible and download available. - reason: >- - Add focused preview lifecycle/source-selection/component tests and reuse - existing document-renderer tests. No Python behavior changes; Python UT is - unrelated. Preserve formal Cases; deployed browser layout and generated-file - integration remain separate from isolated component checks. - notes: >- - User confirmed automatic display, without editing. Existing artifact aggregation - and document viewers are reused. No temporary test dependencies are committed. - External nexent-doc and ../docs/前端ui.md are absent, so SPEC synchronization - and deployed browser acceptance remain pending. Existing bookmark/annotation - modifications are preserved. No deployment or push is performed. diff --git a/test-e2e/changes/requirements/QUESTION-BOOKMARKS-20261109.verification.md b/test-e2e/changes/requirements/QUESTION-BOOKMARKS-20261109.verification.md deleted file mode 100644 index b20d384834..0000000000 --- a/test-e2e/changes/requirements/QUESTION-BOOKMARKS-20261109.verification.md +++ /dev/null @@ -1,63 +0,0 @@ -# Question bookmarks verification - -Implementation uses an owner-private boolean on user-message rows. GET and PUT -question bookmark endpoints authenticate identity and scope the operation to an -undeleted owned conversation and undeleted user message. PUT sets desired state -instead of flipping it. Message and conversation activity timestamps are preserved. -The additive migration leaves existing messages unmarked. Branch copying omits -the bookmark column, so new branches start without copied bookmarks. - -The interactive preview opens directly on each hovered/focused tick, allows a -180 ms pointer transfer into the card, and switches adjacent questions without a -tooltip grace corridor. ArrowRight enters the bookmark control; Escape closes -the preview. Bookmarked ticks and the current-reading dot are independent. - -Runtime IDs are mapped only when fetched history count and question text agree; -otherwise mutation stays disabled and the user can retry. Loaded numeric message -IDs use their persisted identity. Query keys include account, tenant and -conversation. In-flight writes retain their original query key. Failed writes -retain the last server state; unknown reads and pending writes disable mutation. - -## Executed checks - -- PASS: frontend component UT, 23 tests in questionDirectory.test.tsx and - questionBookmarks.test.tsx. Covers original navigation and adjacent-hover - regression, pointer transfer, explicit mark/unmark, Escape, disabled/error - state, retry, loaded state, duplicate clicks, late writes after account/chat - switch, read-only/unsaved chats and runtime-ID alignment/mismatch. -- PASS: 17 Python UT in test/backend/services/test_question_bookmarks.py. - Controlled session/auth boundaries with real SQLAlchemy statements and FastAPI - request validation. Checks owner scope, user-message restriction, false/true - persistence, readback, activity preservation, invalid identifiers/body values, - missing/foreign resources, authentication and sanitized storage errors. -- PASS: design and implementation asset validators with - --require-ut-change QUESTION-BOOKMARKS-20261109; no new formal execution passes - are claimed. Existing formal Cases are preserved. -- PASS: copyright checks on all 15 affected source/SQL/test files and diff - whitespace check. -- Scoped TypeScript check: zero diagnostics in the six affected production - TypeScript files using temporary dependency resolution and a CSS declaration. - This is not a full project type-check: 153 diagnostics remain in the dependency - tree because the complete frontend environment is unavailable. - -## Verification limits - -Tests used temporary dependencies outside Git: Vitest 4.1.12, React 19.3.1, -React Query 5.90.12, TypeScript 5.9.3, Python 3.12, FastAPI 0.162.2 and pure-Python -SQLAlchemy 2.0.45. Windows application control blocked the compiled SQLAlchemy -extension. These checks do not establish the locked Python 3.11/full npm baseline. -Full project build, browser layout/pointer journey, deployed API/database -persistence, cross-browser recovery and migration rollout remain BLOCKED pending -a prepared deployment/test environment. No live application data was mutated. - -The sibling nexent-doc and authoritative ../docs/前端ui.md are absent. External -SPEC synchronization, formal Feature/Case authoring and D2/D4/D5 runtime proof -remain pending; this delivery does not claim formal acceptance completeness. -No deployment, commit, push or target-branch merge was performed. - -## Deployment - -Build both main and web images with the latest tag, then use the existing Docker -deployment script with local-latest. Deployment must apply the new -v2.9.0_009_question_bookmarks.sql migration before the updated backend starts. -Do not merely restart the web container or reuse its old image. diff --git a/test-e2e/changes/requirements/QUESTION-BOOKMARKS-20261109.yaml b/test-e2e/changes/requirements/QUESTION-BOOKMARKS-20261109.yaml deleted file mode 100644 index 40565e053c..0000000000 --- a/test-e2e/changes/requirements/QUESTION-BOOKMARKS-20261109.yaml +++ /dev/null @@ -1,52 +0,0 @@ -schema_version: "1.0" -change: - change_id: QUESTION-BOOKMARKS-20261109 - change_type: requirement - requirement_id: QUESTION-BOOKMARKS-20261109 - title: Account-persisted question bookmarks - description: >- - Add a bookmark toggle to the upper-right of each question preview. Save - explicit desired state by conversation and user-message identity on the - server. Restore it after reopening or changing browsers. Mark bookmarked - ticks independently of the current-reading dot. Support pointer entry into - the preview, immediate switching between adjacent ticks, keyboard focus and - Escape. Do not reopen the removed full directory, change model inputs, copy - bookmarks to branches or change conversation activity. Shared read-only - conversations cannot mutate bookmarks. Loading or failed reads cannot be - mistaken for a known unmarked state; failed writes retain previous state. - affected_features: - existing: [] - added: [] - modified: [] - retired: [] - affected_cases: - existing: [] - added: [] - modified: [] - retired: [] - traditional_ut: - decision: add - test_selectors: - - frontend/tests/component/questionDirectory.test.tsx - - frontend/tests/component/questionBookmarks.test.tsx - - test/backend/services/test_question_bookmarks.py - behaviors: - - Persist and remove bookmark state with owner and user-message scope. - - Reject invalid identity, identifiers, non-user messages and foreign conversations. - - Preserve activity timestamps and isolate query caches by account and conversation. - - Keep interactive preview open during pointer transfer and switch adjacent questions. - - Restore bookmarks, expose failures and prevent duplicate pending writes. - reason: >- - Existing questionDirectory component UT only proves display/navigation; - existing pin tests concern whole Workbench conversations. Strengthen rail - UT and add isolated API/service/database and hook tests. No duplicate D1. - Cross-browser API persistence, migration rollout and browser pointer/layout - acceptance require a deployed application; these remain unverified until - runtime prerequisites are available. Existing formal Cases are preserved. - notes: >- - Baseline is the current question rail after REMOVE-QUESTION-DIRECTORY-POPOVER-20261109. - The user explicitly requests account persistence. Use one boolean column on - owner-private conversation user messages, false by default, with a new additive - migration. API ownership comes from authenticated user identity, never the body. - External SPEC/UI repositories are absent; synchronization is pending. No - deployment, production mutation or remote push is included. diff --git a/test-e2e/changes/requirements/REMOVE-QUESTION-DIRECTORY-POPOVER-20261109.yaml b/test-e2e/changes/requirements/REMOVE-QUESTION-DIRECTORY-POPOVER-20261109.yaml deleted file mode 100644 index f946097857..0000000000 --- a/test-e2e/changes/requirements/REMOVE-QUESTION-DIRECTORY-POPOVER-20261109.yaml +++ /dev/null @@ -1,42 +0,0 @@ -schema_version: "1.0" -change: - change_id: REMOVE-QUESTION-DIRECTORY-POPOVER-20261109 - change_type: requirement - requirement_id: REMOVE-QUESTION-DIRECTORY-POPOVER-20261109 - title: Remove the expanded history-question directory entry - description: >- - Remove the list icon shown in the user's screenshot and its searchable - question-directory popover. Keep the left-side question ticks, hover/focus - previews, active reading-position marker and click navigation. Do not delete - conversation records or change sidebar history, model input or persistence. - Remove the now-unused popover component and its superseded UT; retain the - rail and question collection/navigation regressions. - affected_features: - existing: [] - added: [] - modified: [] - retired: [] - affected_cases: - existing: [] - added: [] - modified: [] - retired: [] - traditional_ut: - decision: strengthen - test_selectors: - - frontend/tests/component/questionDirectory.test.tsx - behaviors: - - Render only question ticks, without the full-directory trigger or search popover. - - Retain tick jumping, keyboard-focus previews, hover switching and active-position scrolling. - - Retain question collection, answer excerpts and missing-message behavior. - reason: >- - This bounded frontend removal replaces obsolete popover UT with an absence - regression and retains utility/rail coverage. Python UT is unrelated; no - backend or external protocol changes and no duplicate formal D1 is needed. - notes: >- - Supersedes the full-directory secondary-entry requirement in - QUESTION-DIRECTORY-RAIL-20261105 and the popover UI in - QUESTION-DIRECTORY-JUMP-20261102. Existing formal Cases are preserved. - The screenshot scopes this removal to the list icon/popover; rail navigation - remains. External SPEC synchronization is unavailable because sibling - nexent-doc is absent. No deployment or automatic push is performed. From e731abd202f331aaf989f3364d414eb6824a3e54 Mon Sep 17 00:00:00 2001 From: ljy Date: Mon, 9 Nov 2026 15:25:26 +0800 Subject: [PATCH 19/26] revert(chat): remove question bookmarks and database migration --- .../api/conversation/question_bookmarks.py | 50 ---- .../management/db/conversation/bookmarks.py | 50 ---- backend/management/db/conversation/models.py | 6 - .../services/conversation/bookmarks.py | 27 -- backend/runtime/api/application.py | 2 - .../v2.9.0_009_question_bookmarks.sql | 6 - .../[locale]/newchat/assistant-ui/thread.tsx | 6 +- .../newchat/ui/question-bookmark-rail.tsx | 101 -------- .../newchat/ui/question-directory-rail.tsx | 233 +++++------------- frontend/hooks/chat/useQuestionBookmarks.ts | 84 ------- frontend/public/locales/en/common.json | 6 +- frontend/public/locales/zh/common.json | 6 +- frontend/services/api.ts | 4 - frontend/services/conversationService.ts | 22 -- .../component/questionBookmarks.test.tsx | 231 ----------------- .../component/questionDirectory.test.tsx | 84 +------ .../services/test_message_annotations.py | 3 +- .../services/test_question_bookmarks.py | 123 --------- 18 files changed, 72 insertions(+), 972 deletions(-) delete mode 100644 backend/management/api/conversation/question_bookmarks.py delete mode 100644 backend/management/db/conversation/bookmarks.py delete mode 100644 backend/management/services/conversation/bookmarks.py delete mode 100644 deploy/sql/migrations/v2.9.0_009_question_bookmarks.sql delete mode 100644 frontend/app/[locale]/newchat/ui/question-bookmark-rail.tsx delete mode 100644 frontend/hooks/chat/useQuestionBookmarks.ts delete mode 100644 frontend/tests/component/questionBookmarks.test.tsx delete mode 100644 test/backend/services/test_question_bookmarks.py diff --git a/backend/management/api/conversation/question_bookmarks.py b/backend/management/api/conversation/question_bookmarks.py deleted file mode 100644 index e2642269ac..0000000000 --- a/backend/management/api/conversation/question_bookmarks.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - -"""Authenticated owner-private question bookmark endpoints.""" - -import logging -from typing import Annotated, Optional - -from fastapi import APIRouter, Body, Header, HTTPException, Path -from fastapi.responses import JSONResponse -from pydantic import StrictBool - -from contracts.errors.exceptions import ConversationNotFoundError, TokenExpiredError, UnauthorizedError, ValidationError -from gateway.authentication.auth import get_current_user_id -from management.services.conversation.bookmarks import list_question_bookmarks, set_question_bookmark - -logger = logging.getLogger(__name__) -router = APIRouter(prefix="/conversation") -PositiveId = Annotated[int, Path(gt=0, le=2147483647)] - - -def _execute(authorization, operation): - try: - user_id, tenant_id = get_current_user_id(authorization) - return JSONResponse(content={"code": 0, "message": "success", "data": operation(user_id, tenant_id)}) - except (TokenExpiredError, UnauthorizedError) as exc: - raise HTTPException(status_code=401, detail="Please login first") from exc - except ConversationNotFoundError as exc: - raise HTTPException(status_code=404, detail="Conversation or question not found") from exc - except ValidationError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc - except Exception as exc: - logger.exception("Question bookmark operation failed") - raise HTTPException(status_code=500, detail="Question bookmark operation failed") from exc - - -@router.get("/{conversation_id}/question_bookmarks") -def get_bookmarks(conversation_id: PositiveId, authorization: Optional[str] = Header(None)): - return _execute(authorization, lambda user, tenant: list_question_bookmarks(conversation_id, user, tenant)) - - -@router.put("/{conversation_id}/question_bookmarks/{message_id}") -def put_bookmark( - conversation_id: PositiveId, - message_id: PositiveId, - bookmarked: Annotated[StrictBool, Body(embed=True)], - authorization: Optional[str] = Header(None), -): - return _execute( - authorization, lambda user, tenant: set_question_bookmark(conversation_id, message_id, bookmarked, user, tenant) - ) diff --git a/backend/management/db/conversation/bookmarks.py b/backend/management/db/conversation/bookmarks.py deleted file mode 100644 index c26ffc6406..0000000000 --- a/backend/management/db/conversation/bookmarks.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - -"""Owner-private question bookmarks without modifying conversation activity.""" - -from sqlalchemy import select, update - -from contracts.errors.exceptions import ConversationNotFoundError -from management.db.client import get_db_session -from management.db.conversation.models import ConversationMessage, ConversationRecord - - -def _owned_conversation(conversation_id: int, user_id: str): - return ( - ConversationRecord.conversation_id == conversation_id, - ConversationRecord.created_by == user_id, - ConversationRecord.delete_flag == "N", - ) - - -def _questions(conversation_id: int): - return ( - ConversationMessage.conversation_id == conversation_id, - ConversationMessage.message_role == "user", - ConversationMessage.delete_flag == "N", - ) - - -def question_bookmarks(conversation_id: int, user_id: str, message_id=None, bookmarked=None) -> list[int]: - """Read or atomically set an explicit desired state on an owned question.""" - with get_db_session() as session: - statement = select(ConversationRecord.conversation_id).where(*_owned_conversation(conversation_id, user_id)) - # Serialize writes within this conversation; list calls remain read-only. - if message_id is not None: - statement = statement.with_for_update() - if session.scalar(statement) is None: - raise ConversationNotFoundError("Conversation not found") - if message_id is not None: - saved = session.scalar( - update(ConversationMessage) - .where(*_questions(conversation_id), ConversationMessage.message_id == message_id) - .values(question_bookmarked=bookmarked, update_time=ConversationMessage.update_time) - .returning(ConversationMessage.message_id) - ) - if saved is None: - raise ConversationNotFoundError("Question not found") - return list(session.scalars( - select(ConversationMessage.message_id) - .where(*_questions(conversation_id), ConversationMessage.question_bookmarked.is_(True)) - .order_by(ConversationMessage.message_id) - )) diff --git a/backend/management/db/conversation/models.py b/backend/management/db/conversation/models.py index a1f8ebecfc..0b8e2bb15b 100644 --- a/backend/management/db/conversation/models.py +++ b/backend/management/db/conversation/models.py @@ -3,7 +3,6 @@ from sqlalchemy import ( TIMESTAMP, BigInteger, - Boolean, Column, Index, Integer, @@ -92,11 +91,6 @@ class ConversationMessage(TableBase): __tablename__ = "conversation_message_t" __table_args__ = {"schema": SCHEMA} - question_bookmarked = Column( - Boolean, nullable=False, server_default=text("false"), - comment="Owner's question bookmark; independent of message content and conversation activity", - ) - message_id = Column(Integer, Sequence( "conversation_message_t_message_id_seq", schema=SCHEMA), primary_key=True, nullable=False) conversation_id = Column( diff --git a/backend/management/services/conversation/bookmarks.py b/backend/management/services/conversation/bookmarks.py deleted file mode 100644 index 3177176383..0000000000 --- a/backend/management/services/conversation/bookmarks.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - -"""Validation boundary for authenticated personal question bookmarks.""" - -from contracts.errors.exceptions import UnauthorizedError, ValidationError -from management.db.conversation.bookmarks import question_bookmarks - - -def _validate(conversation_id: int, user_id: str, tenant_id: str) -> None: - if not user_id or not tenant_id: - raise UnauthorizedError("Please login first") - if len(user_id) > 100 or len(tenant_id) > 100: - raise ValidationError("Invalid bookmark identity") - if type(conversation_id) is not int or not 0 < conversation_id <= 2147483647: - raise ValidationError("Invalid conversation id") - - -def list_question_bookmarks(conversation_id: int, user_id: str, tenant_id: str) -> dict: - _validate(conversation_id, user_id, tenant_id) - return {"message_ids": question_bookmarks(conversation_id, user_id)} - - -def set_question_bookmark(conversation_id: int, message_id: int, bookmarked: bool, user_id: str, tenant_id: str) -> dict: - _validate(conversation_id, user_id, tenant_id) - if type(message_id) is not int or not 0 < message_id <= 2147483647 or type(bookmarked) is not bool: - raise ValidationError("Invalid question bookmark") - return {"message_ids": question_bookmarks(conversation_id, user_id, message_id, bookmarked)} diff --git a/backend/runtime/api/application.py b/backend/runtime/api/application.py index 0ad1f67611..69ac1431d4 100644 --- a/backend/runtime/api/application.py +++ b/backend/runtime/api/application.py @@ -14,7 +14,6 @@ from runtime.api.voice.streaming import voice_runtime_router as voice_router from management.api.conversation.conversation_management import router as conversation_management_router from management.api.conversation.workbench_pins import router as workbench_pins_router -from management.api.conversation.question_bookmarks import router as question_bookmarks_router from management.api.conversation.conversation_share import router as conversation_share_router from runtime.api.file.upload import file_management_runtime_router as file_management_router from runtime.api.skill.creation import skill_creator_router @@ -110,7 +109,6 @@ async def thread_capacity(): app.include_router(agent_automation_router) app.include_router(conversation_automation_router) app.include_router(workbench_pins_router) -app.include_router(question_bookmarks_router) app.include_router(conversation_management_router) app.include_router(conversation_share_router) app.include_router(file_management_router) diff --git a/deploy/sql/migrations/v2.9.0_009_question_bookmarks.sql b/deploy/sql/migrations/v2.9.0_009_question_bookmarks.sql deleted file mode 100644 index 34f50a4027..0000000000 --- a/deploy/sql/migrations/v2.9.0_009_question_bookmarks.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - -ALTER TABLE nexent.conversation_message_t - ADD COLUMN IF NOT EXISTS question_bookmarked BOOLEAN NOT NULL DEFAULT FALSE; -COMMENT ON COLUMN nexent.conversation_message_t.question_bookmarked IS - 'Owner question bookmark; independent of message content and conversation activity'; diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 185f0649fb..4370336266 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -67,7 +67,6 @@ import { } from "../ui/artifacts-panel"; import { ConversationArtifactsEntryProvider } from "../ui/conversation-artifacts-context"; import { QuestionDirectoryRail } from "../ui/question-directory-rail"; -import { QuestionBookmarkRail } from "../ui/question-bookmark-rail"; import { useAutoArtifactPreview } from "../components/use-auto-artifact-preview"; import { ArtifactDocumentPanel } from "../components/artifact-document-panel"; import { markFileWithExistingUpload } from "../adapter/attachment-adapter"; @@ -986,10 +985,7 @@ const ThreadView: FC = ({ )} - q.messageId).join(","); - const needsMapping = props.questions.some( - (q) => !/^[1-9]\d*$/.test(q.messageId) - ); - const identities = useQuery({ - queryKey: [ - "question-bookmark-identities", - user?.id, - user?.tenantId, - conversationId, - runtimeIds, - ], - enabled: bookmarks.enabled && needsMapping && !isRunning, - queryFn: () => conversationService.getDetail(conversationId!), - }); - const backendMessageIds = new Map(); - const stored = collapseRefreshUserMessages( - identities.data?.data?.[0]?.message ?? [] - ).filter((m) => m.role === "user"); - for (const [index, question] of props.questions.entries()) { - const numeric = Number(question.messageId); - const row = stored[index]; - if ( - /^[1-9]\d*$/.test(question.messageId) && - Number.isSafeInteger(numeric) - ) { - backendMessageIds.set(question.messageId, numeric); - } else if ( - stored.length === props.questions.length && - typeof row?.message === "string" && - row.message.trim() === question.fullText.trim() - ) { - const id = Number(row.message_id); - if (Number.isSafeInteger(id) && id > 0) - backendMessageIds.set(question.messageId, id); - } - } - const saved = new Set(bookmarks.ids); - return ( - saved.has(backendMessageIds.get(q.messageId)!)) - .map((q) => q.messageId) - ) - } - onBookmark={ - bookmarks.enabled - ? (id, marked) => { - const backendId = backendMessageIds.get(id); - if (backendId) bookmarks.toggle(backendId, marked); - } - : undefined - } - canBookmark={(id) => - bookmarks.ready && !bookmarks.pending && backendMessageIds.has(id) - } - bookmarkFailed={ - bookmarks.failed || - identities.isError || - Boolean( - needsMapping && - identities.isSuccess && - backendMessageIds.size !== props.questions.length - ) - } - onBookmarkRetry={() => { - bookmarks.retry(); - if (needsMapping) void identities.refetch(); - }} - /> - ); -} diff --git a/frontend/app/[locale]/newchat/ui/question-directory-rail.tsx b/frontend/app/[locale]/newchat/ui/question-directory-rail.tsx index 047c3710d0..f43df0410a 100644 --- a/frontend/app/[locale]/newchat/ui/question-directory-rail.tsx +++ b/frontend/app/[locale]/newchat/ui/question-directory-rail.tsx @@ -2,11 +2,15 @@ "use client"; -import { useEffect, useRef, useState, type FC } from "react"; -import { Popover as PopoverPrimitive } from "radix-ui"; -import { Bookmark } from "lucide-react"; +import { useEffect, useRef, type FC } from "react"; +import { Tooltip as TooltipPrimitive } from "radix-ui"; import { useTranslation } from "react-i18next"; -import { Popover, PopoverContent } from "@/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { QuestionDirectoryEntry } from "../utils/question-directory"; @@ -15,11 +19,6 @@ export interface QuestionDirectoryRailProps { onJump: (messageId: string) => void; activeMessageId?: string | null; className?: string; - bookmarkedIds?: ReadonlySet; - onBookmark?: (messageId: string, bookmarked: boolean) => void; - canBookmark?: (messageId: string) => boolean; - bookmarkFailed?: boolean; - onBookmarkRetry?: () => void; } /** Compact, persistent navigation in the conversation's reserved left gutter. */ @@ -28,29 +27,9 @@ export const QuestionDirectoryRail: FC = ({ onJump, activeMessageId, className, - bookmarkedIds, - onBookmark, - canBookmark, - bookmarkFailed, - onBookmarkRetry, }) => { const { t } = useTranslation(); const listRef = useRef(null); - const [previewId, setPreviewId] = useState(null); - const closeTimer = useRef | null>(null); - const keepPreview = () => { - if (closeTimer.current) clearTimeout(closeTimer.current); - }; - const leavePreview = () => { - keepPreview(); - closeTimer.current = setTimeout(() => setPreviewId(null), 180); - }; - useEffect( - () => () => { - if (closeTimer.current) clearTimeout(closeTimer.current); - }, - [] - ); useEffect(() => { const list = listRef.current; @@ -76,152 +55,66 @@ export const QuestionDirectoryRail: FC = ({ className )} > -
      - {questions.map((question) => { - const active = question.messageId === activeMessageId; - const bookmarked = bookmarkedIds?.has(question.messageId) ?? false; - return ( - { - if (!open) - setPreviewId((id) => (id === question.messageId ? null : id)); - }} - > - - - - event.preventDefault()} - onCloseAutoFocus={(event) => event.preventDefault()} - onPointerEnter={keepPreview} - onPointerLeave={leavePreview} - onFocusCapture={keepPreview} - onBlurCapture={leavePreview} - className="pointer-events-auto w-72 max-w-[calc(100vw-4rem)] rounded-xl p-3 shadow-lg" - > -
      - Q{question.index} - {onBookmark && ( - - )} -
      - {bookmarkFailed && ( -
      - {t("chat.questions.bookmarkFailed")} - -
      - )} -

      - {question.fullText} -

      -
      -

      - {question.replyText || t("chat.questions.noReply")} -

      - - - ); - })} -
      + )} + + + + +
      + Q{question.index} +
      +

      + {question.fullText} +

      +
      +

      + {question.replyText || t("chat.questions.noReply")} +

      + + + + ); + })} +
      + ); }; diff --git a/frontend/hooks/chat/useQuestionBookmarks.ts b/frontend/hooks/chat/useQuestionBookmarks.ts deleted file mode 100644 index d1d6bf86c9..0000000000 --- a/frontend/hooks/chat/useQuestionBookmarks.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - -"use client"; - -import { useRef } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { conversationService } from "@/services/conversationService"; - -export function useQuestionBookmarks(conversationId?: number, enabled = true) { - const { user } = useAuthorizationContext(); - const client = useQueryClient(); - const saving = useRef(false); - const queryKey = [ - "question-bookmarks", - user?.id, - user?.tenantId, - conversationId, - ]; - const available = Boolean( - enabled && - user?.id && - user?.tenantId && - conversationId && - conversationId > 0 - ); - const query = useQuery({ - queryKey, - enabled: available, - queryFn: () => conversationService.getQuestionBookmarks(conversationId!), - }); - const mutation = useMutation({ - mutationFn: ({ - messageId, - bookmarked, - conversation, - }: { - messageId: number; - bookmarked: boolean; - conversation: number; - key: typeof queryKey; - }) => - conversationService.setQuestionBookmark( - conversation, - messageId, - bookmarked - ), - // Mutation variables retain their original scope if the user switches chats. - onSuccess: (ids, variables) => client.setQueryData(variables.key, ids), - onSettled: () => { - saving.current = false; - }, - }); - const sameMutationScope = mutation.variables?.key.every( - (value, index) => value === queryKey[index] - ); - return { - ids: query.data ?? [], - enabled: available, - ready: available && query.isSuccess && !query.isFetching, - pending: mutation.isPending, - failed: query.isError || Boolean(sameMutationScope && mutation.isError), - retry: () => { - mutation.reset(); - void query.refetch(); - }, - toggle: (messageId: number, bookmarked: boolean) => { - if ( - available && - query.isSuccess && - !query.isFetching && - !saving.current - ) { - saving.current = true; - mutation.mutate({ - messageId, - bookmarked, - conversation: conversationId!, - key: queryKey, - }); - } - }, - }; -} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 1eab61aeb1..40977ec5d3 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -6765,9 +6765,5 @@ "workbench.creationConfirmation.hint": "Submit your choice to enter a creation mode or continue this conversation.", "workbench.creationConfirmation.noPermission": "You do not have permission to enter this creation mode.", "workbench.creationConfirmation.saveFailed": "Could not save the confirmation. Please retry.", - "workbench.creationConfirmation.originalRequest": "Original user request:", - "chat.questions.bookmark": "Bookmark question", - "chat.questions.bookmarkFailed": "Could not load or save question bookmarks", - "chat.questions.bookmarkRetry": "Retry", - "chat.questions.unbookmark": "Remove bookmark" + "workbench.creationConfirmation.originalRequest": "Original user request:" } diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index c6acf07926..b8064314bd 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -6762,9 +6762,5 @@ "workbench.creationConfirmation.hint": "选择后提交,确认进入创建模式或继续当前对话。", "workbench.creationConfirmation.noPermission": "你没有进入该创建模式的权限。", "workbench.creationConfirmation.saveFailed": "保存确认失败,请重试。", - "workbench.creationConfirmation.originalRequest": "用户原始需求:", - "chat.questions.bookmark": "标记问题", - "chat.questions.bookmarkFailed": "问题标记未能读取或保存,请重试", - "chat.questions.bookmarkRetry": "重试", - "chat.questions.unbookmark": "取消标记" + "workbench.creationConfirmation.originalRequest": "用户原始需求:" } diff --git a/frontend/services/api.ts b/frontend/services/api.ts index ff500fe62a..950d3fa620 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -66,10 +66,6 @@ export const API_ENDPOINTS = { agentGroupConversationIds: (agentId: number) => `${API_BASE_URL}/conversation/agent_groups/${agentId}/conversation_ids`, workbenchPins: `${API_BASE_URL}/conversation/workbench_pins`, - questionBookmarks: (id: number) => - `${API_BASE_URL}/conversation/${id}/question_bookmarks`, - questionBookmark: (id: number, messageId: number) => - `${API_BASE_URL}/conversation/${id}/question_bookmarks/${messageId}`, workbenchPin: (id: number) => `${API_BASE_URL}/conversation/${id}/workbench_pin`, create: `${API_BASE_URL}/conversation/create`, diff --git a/frontend/services/conversationService.ts b/frontend/services/conversationService.ts index 7d33ed8947..2d3bf8c115 100644 --- a/frontend/services/conversationService.ts +++ b/frontend/services/conversationService.ts @@ -42,28 +42,6 @@ const getWebSocketUrl = (endpoint: string): string => { }; export const conversationService = { - async getQuestionBookmarks(id: number): Promise { - const response = await fetchWithErrorHandling( - API_ENDPOINTS.conversation.questionBookmarks(id), - { headers: getAuthHeaders() } - ); - return (await response.json()).data.message_ids; - }, - async setQuestionBookmark( - id: number, - messageId: number, - bookmarked: boolean - ): Promise { - const response = await fetchWithErrorHandling( - API_ENDPOINTS.conversation.questionBookmark(id, messageId), - { - method: "PUT", - headers: { ...getAuthHeaders(), "Content-Type": "application/json" }, - body: JSON.stringify({ bookmarked }), - } - ); - return (await response.json()).data.message_ids; - }, async getCreatorHistories( kind: ConversationTarget["type"], targetId: number, diff --git a/frontend/tests/component/questionBookmarks.test.tsx b/frontend/tests/component/questionBookmarks.test.tsx deleted file mode 100644 index 6ca315ce40..0000000000 --- a/frontend/tests/component/questionBookmarks.test.tsx +++ /dev/null @@ -1,231 +0,0 @@ -// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - -import { - act, - render, - renderHook, - waitFor, - fireEvent, - screen, -} from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { ReactNode } from "react"; -import { afterEach, expect, test, vi } from "vitest"; -import { useQuestionBookmarks } from "@/hooks/chat/useQuestionBookmarks"; -import { QuestionBookmarkRail } from "@/app/newchat/ui/question-bookmark-rail"; -import { collectQuestionDirectory } from "@/app/newchat/utils/question-directory"; - -const state = vi.hoisted(() => ({ - user: { id: "user", tenantId: "tenant" }, - get: vi.fn(), - set: vi.fn(), - detail: vi.fn(), -})); -vi.mock("@/components/providers/AuthorizationProvider", () => ({ - useAuthorizationContext: () => ({ user: state.user }), -})); -vi.mock("@/services/conversationService", () => ({ - conversationService: { - getQuestionBookmarks: state.get, - setQuestionBookmark: state.set, - getDetail: state.detail, - }, -})); -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string, params?: { index?: number }) => - params?.index ? `${key}:${params.index}` : key, - }), -})); - -const clients: QueryClient[] = []; -function setup(initialId = 42) { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - clients.push(client); - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); - return { - client, - ...renderHook(({ id, enabled }) => useQuestionBookmarks(id, enabled), { - initialProps: { id: initialId, enabled: true }, - wrapper, - }), - }; -} -afterEach(() => { - clients.splice(0).forEach((client) => client.clear()); - vi.resetAllMocks(); - state.user = { id: "user", tenantId: "tenant" }; -}); - -test("loads saved bookmarks and persists removal with explicit state", async () => { - state.get.mockResolvedValue([17]); - state.set.mockResolvedValue([]); - const { result } = setup(); - await waitFor(() => expect(result.current.ready).toBe(true)); - expect(result.current.ids).toEqual([17]); - act(() => { - result.current.toggle(17, false); - result.current.toggle(17, false); - }); - await waitFor(() => expect(result.current.ids).toEqual([])); - expect(state.set).toHaveBeenCalledWith(42, 17, false); - expect(state.set).toHaveBeenCalledTimes(1); -}); - -test("failed saves retain known server state; pending saves cannot be repeated", async () => { - state.get.mockResolvedValue([17]); - let reject!: (error: Error) => void; - state.set.mockImplementation( - () => - new Promise((_, failure) => { - reject = failure; - }) - ); - const { result } = setup(); - await waitFor(() => expect(result.current.ready).toBe(true)); - act(() => result.current.toggle(17, false)); - await waitFor(() => expect(result.current.pending).toBe(true)); - act(() => result.current.toggle(17, false)); - expect(state.set).toHaveBeenCalledTimes(1); - act(() => reject(new Error("offline"))); - await waitFor(() => expect(result.current.failed).toBe(true)); - expect(result.current.ids).toEqual([17]); -}); - -test("a late write stays in the original account and conversation cache", async () => { - state.get.mockResolvedValue([]); - let finish!: (ids: number[]) => void; - state.set.mockImplementation( - () => - new Promise((resolve) => { - finish = resolve; - }) - ); - const { result, rerender, client } = setup(); - await waitFor(() => expect(result.current.ready).toBe(true)); - act(() => result.current.toggle(17, true)); - await waitFor(() => expect(result.current.pending).toBe(true)); - state.user = { id: "other", tenantId: "tenant" }; - rerender({ id: 43, enabled: true }); - await waitFor(() => expect(result.current.ready).toBe(true)); - act(() => finish([17])); - await waitFor(() => expect(result.current.pending).toBe(false)); - expect(result.current.ids).toEqual([]); - expect( - client.getQueryData(["question-bookmarks", "user", "tenant", 42]) - ).toEqual([17]); -}); - -test("read-only and unsaved conversations do not fetch or mutate", async () => { - const { result, rerender } = setup(0); - act(() => result.current.toggle(17, true)); - rerender({ id: 42, enabled: false }); - act(() => result.current.toggle(17, true)); - expect(state.get).not.toHaveBeenCalled(); - expect(state.set).not.toHaveBeenCalled(); - expect(result.current.ready).toBe(false); -}); - -test("failed initial reads disable writes and can be retried", async () => { - state.get.mockRejectedValueOnce(new Error("offline")).mockResolvedValue([17]); - const { result } = setup(); - await waitFor(() => expect(result.current.failed).toBe(true)); - expect(result.current.ready).toBe(false); - act(() => result.current.toggle(17, true)); - expect(state.set).not.toHaveBeenCalled(); - act(() => result.current.retry()); - await waitFor(() => expect(result.current.ready).toBe(true)); - expect(result.current.ids).toEqual([17]); -}); - -test("newly generated runtime IDs resolve to persisted questions before bookmarking", async () => { - state.get.mockResolvedValue([]); - state.set.mockResolvedValue([17]); - state.detail.mockResolvedValue({ - data: [ - { - message: [ - { message_id: 17, message_index: 0, role: "user", message: "hello" }, - ], - }, - ], - }); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - clients.push(client); - const questions = collectQuestionDirectory([ - { - id: "runtime-id", - role: "user", - content: [{ type: "text", text: "hello" }], - }, - ]); - render( - - - - ); - fireEvent.focus( - screen.getByRole("button", { name: "chat.questions.jump:1" }) - ); - const mark = await screen.findByRole("button", { - name: "chat.questions.bookmark", - }); - await waitFor(() => expect(mark).not.toBeDisabled()); - fireEvent.click(mark); - await waitFor(() => expect(state.set).toHaveBeenCalledWith(42, 17, true)); - expect( - await screen.findByRole("button", { name: "chat.questions.unbookmark" }) - ).toHaveAttribute("aria-pressed", "true"); -}); - -test("ambiguous or mismatched runtime history cannot bookmark a different question", async () => { - state.get.mockResolvedValue([]); - state.detail.mockResolvedValue({ - data: [ - { - message: [ - { message_id: 99, role: "user", message: "another question" }, - ], - }, - ], - }); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - clients.push(client); - render( - - - - ); - fireEvent.focus( - screen.getByRole("button", { name: "chat.questions.jump:1" }) - ); - const mark = await screen.findByRole("button", { - name: "chat.questions.bookmark", - }); - await waitFor(() => expect(state.detail).toHaveBeenCalledWith(42)); - expect(mark).toBeDisabled(); - fireEvent.click(mark); - expect(state.set).not.toHaveBeenCalled(); -}); diff --git a/frontend/tests/component/questionDirectory.test.tsx b/frontend/tests/component/questionDirectory.test.tsx index 906748cbf9..553f4b8ee5 100644 --- a/frontend/tests/component/questionDirectory.test.tsx +++ b/frontend/tests/component/questionDirectory.test.tsx @@ -90,7 +90,7 @@ test("QuestionDirectoryRail previews question and reply on keyboard focus", asyn fireEvent.focus( screen.getByRole("button", { name: "chat.questions.jump:1" }) ); - const preview = await screen.findByRole("dialog"); + const preview = await screen.findByRole("tooltip"); expect(preview.textContent).toContain("question u1"); expect(preview.textContent).toContain("question a1"); }); @@ -128,7 +128,7 @@ test("QuestionDirectoryRail switches previews when moving from Q3 to adjacent Q2 if (this === second) return rect(0, 15, 32, 15); if (this === third) return rect(0, 30, 32, 15); // A tall preview beside the ticks overlaps the diagonal hover corridor. - if (this.getAttribute("role") === "dialog") return rect(40, 0, 288, 300); + if (this.getAttribute("role") === "tooltip") return rect(40, 0, 288, 300); return rect(0, 0, 0, 0); }); try { @@ -137,7 +137,7 @@ test("QuestionDirectoryRail switches previews when moving from Q3 to adjacent Q2 clientX: 31, clientY: 38, }); - expect((await screen.findByRole("dialog")).textContent).toContain( + expect((await screen.findByRole("tooltip")).textContent).toContain( "question u3" ); fireEvent.pointerLeave(third, { @@ -151,7 +151,7 @@ test("QuestionDirectoryRail switches previews when moving from Q3 to adjacent Q2 clientY: 25, }); await waitFor(() => { - const previews = screen.getAllByRole("dialog"); + const previews = screen.getAllByRole("tooltip"); expect(previews).toHaveLength(1); expect(previews[0].textContent).toContain("question u2"); expect(previews[0].textContent).not.toContain("question u3"); @@ -161,7 +161,7 @@ test("QuestionDirectoryRail switches previews when moving from Q3 to adjacent Q2 clientX: 0, clientY: 15, }); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull()); } finally { bounds.mockRestore(); } @@ -182,80 +182,6 @@ test("QuestionDirectoryRail has no expanded-directory entry or search popover", ).toBeNull(); }); -test("bookmark action is interactive, preserves preview and requests explicit desired state", async () => { - const questions = collectQuestionDirectory([makeMessage("u1", "user")]); - const onBookmark = vi.fn(); - const { rerender, container } = render( - - ); - const tick = screen.getByRole("button", { name: "chat.questions.jump:1" }); - fireEvent.pointerMove(tick, { pointerType: "mouse" }); - const card = await screen.findByRole("dialog"); - fireEvent.pointerLeave(tick); - fireEvent.pointerEnter(card); - await new Promise((resolve) => setTimeout(resolve, 220)); - expect(screen.getByRole("dialog")).toBeVisible(); - fireEvent.click( - screen.getByRole("button", { name: "chat.questions.bookmark" }) - ); - expect(onBookmark).toHaveBeenCalledWith("u1", true); - rerender( - - ); - expect( - container.querySelector('[data-slot="question-rail-bookmark"]') - ).not.toBeNull(); - const remove = screen.getByRole("button", { - name: "chat.questions.unbookmark", - }); - expect(remove).toHaveAttribute("aria-pressed", "true"); - fireEvent.click(remove); - expect(onBookmark).toHaveBeenLastCalledWith("u1", false); - fireEvent.keyDown(card, { key: "Escape" }); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); -}); - -test("unknown or pending bookmark state is disabled and read failures can retry", async () => { - const onBookmark = vi.fn(); - const retry = vi.fn(); - render( - false} - bookmarkFailed - onBookmarkRetry={retry} - /> - ); - fireEvent.focus( - screen.getByRole("button", { name: "chat.questions.jump:1" }) - ); - const button = await screen.findByRole("button", { - name: "chat.questions.bookmark", - }); - expect(button).toBeDisabled(); - fireEvent.click(button); - expect(onBookmark).not.toHaveBeenCalled(); - expect(screen.getByRole("alert")).toHaveTextContent( - "chat.questions.bookmarkFailed" - ); - fireEvent.click( - screen.getByRole("button", { name: "chat.questions.bookmarkRetry" }) - ); - expect(retry).toHaveBeenCalledOnce(); -}); - test("QuestionDirectoryRail keeps the active tick visible without scrolling chat", () => { const questions = collectQuestionDirectory([ makeMessage("u1", "user"), diff --git a/test/backend/services/test_message_annotations.py b/test/backend/services/test_message_annotations.py index 6cea6cb726..3b3759fb68 100644 --- a/test/backend/services/test_message_annotations.py +++ b/test/backend/services/test_message_annotations.py @@ -224,8 +224,7 @@ def message_db(monkeypatch): 'CREATE TABLE nexent.conversation_message_t ' '(message_id INTEGER PRIMARY KEY, conversation_id INTEGER, message_index INTEGER, ' 'message_role TEXT, message_source TEXT, message_content TEXT, minio_files TEXT, ' - 'opinion_flag TEXT, question_bookmarked BOOLEAN NOT NULL DEFAULT FALSE, ' - 'status TEXT, delete_flag TEXT, created_by TEXT, updated_by TEXT, ' + 'opinion_flag TEXT, status TEXT, delete_flag TEXT, created_by TEXT, updated_by TEXT, ' 'create_time DATETIME, update_time DATETIME)') connection.exec_driver_sql( 'CREATE TABLE nexent.conversation_message_unit_t ' diff --git a/test/backend/services/test_question_bookmarks.py b/test/backend/services/test_question_bookmarks.py deleted file mode 100644 index 3be8432428..0000000000 --- a/test/backend/services/test_question_bookmarks.py +++ /dev/null @@ -1,123 +0,0 @@ -# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - -"""Bookmark validation, owner-scoped SQL and HTTP contracts with isolated I/O.""" - -import importlib.util -import sys -import types -from contextlib import contextmanager -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient -from sqlalchemy.dialects import postgresql - -from contracts.errors.exceptions import ConversationNotFoundError, UnauthorizedError, ValidationError - - -@pytest.fixture -def boundary(monkeypatch): - session = MagicMock() - - @contextmanager - def get_session(): - yield session - - client_module = types.ModuleType("management.db.client") - client_module.get_db_session = get_session - auth_module = types.ModuleType("gateway.authentication.auth") - auth_module.get_current_user_id = MagicMock(return_value=("user", "tenant")) - monkeypatch.setitem(sys.modules, "management.db.client", client_module) - monkeypatch.setitem(sys.modules, "gateway.authentication.auth", auth_module) - root = Path(__file__).resolve().parents[3] / "backend" - - def load(name, relative): - spec = importlib.util.spec_from_file_location(name, root / relative) - module = importlib.util.module_from_spec(spec) - monkeypatch.setitem(sys.modules, name, module) - spec.loader.exec_module(module) - return module - - db = load("management.db.conversation.bookmarks", "management/db/conversation/bookmarks.py") - service = load("management.services.conversation.bookmarks", "management/services/conversation/bookmarks.py") - routes = load("management.api.conversation.question_bookmarks", "management/api/conversation/question_bookmarks.py") - app = FastAPI() - app.include_router(routes.router) - return types.SimpleNamespace(db=db, service=service, routes=routes, session=session, - auth=auth_module.get_current_user_id, client=TestClient(app)) - - -def sql(statement): - return str(statement.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True})) - - -@pytest.mark.parametrize("marked", [True, False]) -def test_write_and_restore_are_owner_scoped_without_activity_updates(boundary, marked): - b = boundary - b.session.scalar.side_effect = [42, 17, 42] - b.session.scalars.return_value = [17] if marked else [] - expected = {"message_ids": [17] if marked else []} - response = b.client.put("/conversation/42/question_bookmarks/17", json={"bookmarked": marked}) - assert response.status_code == 200 - assert response.json()["data"] == expected - ownership, write = [sql(call.args[0]) for call in b.session.scalar.call_args_list] - assert "created_by = 'user'" in ownership and "delete_flag = 'N'" in ownership - assert "FOR UPDATE" in ownership - assert "conversation_id = 42" in write and "message_id = 17" in write - assert "message_role = 'user'" in write and "delete_flag = 'N'" in write - assert "update_time=nexent.conversation_message_t.update_time" in write - assert "message_content=" not in write - assert b.client.get("/conversation/42/question_bookmarks").json()["data"] == expected - - -@pytest.mark.parametrize("scalars", [[None], [42, None]]) -def test_foreign_conversation_or_invalid_question_cannot_be_updated(boundary, scalars): - boundary.session.scalar.side_effect = scalars - response = boundary.client.put("/conversation/42/question_bookmarks/17", json={"bookmarked": True}) - assert response.status_code == 404 - boundary.session.scalars.assert_not_called() - - -@pytest.mark.parametrize("path,body", [ - ("0/17", {"bookmarked": True}), ("42/0", {"bookmarked": True}), - ("42/2147483648", {"bookmarked": True}), ("42/17", {}), - ("42/17", {"bookmarked": 1}), ("42/17", {"bookmarked": "true"}), - ("42/17", {"bookmarked": None}), -]) -def test_invalid_http_input_has_no_database_side_effect(boundary, path, body): - conversation, message = path.split("/") - assert boundary.client.put( - f"/conversation/{conversation}/question_bookmarks/{message}", json=body - ).status_code == 422 - boundary.session.scalar.assert_not_called() - - -@pytest.mark.parametrize("conversation,message,marked", [(True, 17, True), (42, -1, True), (42, 17, 1)]) -def test_service_rejects_invalid_internal_callers(boundary, conversation, message, marked): - with pytest.raises(ValidationError): - boundary.service.set_question_bookmark(conversation, message, marked, "user", "tenant") - boundary.session.scalar.assert_not_called() - - -def test_unauthenticated_requests_do_not_access_storage(boundary): - boundary.auth.side_effect = UnauthorizedError("expired") - assert boundary.client.get("/conversation/42/question_bookmarks").status_code == 401 - boundary.session.scalar.assert_not_called() - - -def test_storage_failure_is_sanitized(boundary): - boundary.session.scalar.side_effect = RuntimeError("secret connection string") - response = boundary.client.get("/conversation/42/question_bookmarks") - assert response.status_code == 500 - assert "secret" not in response.text - - -def test_list_is_read_only_and_identity_required(boundary): - boundary.session.scalar.return_value = 42 - boundary.session.scalars.return_value = [] - assert boundary.service.list_question_bookmarks(42, "user", "tenant") == {"message_ids": []} - assert "FOR UPDATE" not in sql(boundary.session.scalar.call_args.args[0]) - with pytest.raises(UnauthorizedError): - boundary.service.list_question_bookmarks(42, "", "tenant") From e5ad484eefbc9fd36a0d84edb6864011986aa95a Mon Sep 17 00:00:00 2001 From: ljy Date: Mon, 9 Nov 2026 15:56:59 +0800 Subject: [PATCH 20/26] chore: remove remaining conversation change documentation --- ...AIL-STALE-PREVIEW-20261105-verification.md | 29 ----- .../QUESTION-RAIL-STALE-PREVIEW-20261105.yaml | 54 --------- ...-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml | 104 ------------------ ...HAT-LOCAL-CONTEXT-20261105-verification.md | 25 ----- .../ADD-TO-CHAT-LOCAL-CONTEXT-20261105.yaml | 46 -------- ...-TO-CHAT-MESSAGE-ANNOTATIONS-20261102.yaml | 76 ------------- ...NCH-CONVERSATION-TO-NEW-CHAT-20261102.yaml | 73 ------------ ...H-NUMBERED-TITLES-20261106-verification.md | 22 ---- .../BRANCH-NUMBERED-TITLES-20261106.yaml | 46 -------- .../CHAT-ARTIFACTS-PANEL-20261031.yaml | 56 ---------- ...TIFACTS-TOP-ENTRY-20261106-verification.md | 26 ----- .../CHAT-ARTIFACTS-TOP-ENTRY-20261106.yaml | 44 -------- .../QUESTION-DIRECTORY-JUMP-20261102.yaml | 79 ------------- ...ON-DIRECTORY-RAIL-20261105-verification.md | 26 ----- .../QUESTION-DIRECTORY-RAIL-20261105.yaml | 44 -------- 15 files changed, 750 deletions(-) delete mode 100644 test-e2e/changes/bugs/QUESTION-RAIL-STALE-PREVIEW-20261105-verification.md delete mode 100644 test-e2e/changes/bugs/QUESTION-RAIL-STALE-PREVIEW-20261105.yaml delete mode 100644 test-e2e/changes/requirements/ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml delete mode 100644 test-e2e/changes/requirements/ADD-TO-CHAT-LOCAL-CONTEXT-20261105-verification.md delete mode 100644 test-e2e/changes/requirements/ADD-TO-CHAT-LOCAL-CONTEXT-20261105.yaml delete mode 100644 test-e2e/changes/requirements/ADD-TO-CHAT-MESSAGE-ANNOTATIONS-20261102.yaml delete mode 100644 test-e2e/changes/requirements/BRANCH-CONVERSATION-TO-NEW-CHAT-20261102.yaml delete mode 100644 test-e2e/changes/requirements/BRANCH-NUMBERED-TITLES-20261106-verification.md delete mode 100644 test-e2e/changes/requirements/BRANCH-NUMBERED-TITLES-20261106.yaml delete mode 100644 test-e2e/changes/requirements/CHAT-ARTIFACTS-PANEL-20261031.yaml delete mode 100644 test-e2e/changes/requirements/CHAT-ARTIFACTS-TOP-ENTRY-20261106-verification.md delete mode 100644 test-e2e/changes/requirements/CHAT-ARTIFACTS-TOP-ENTRY-20261106.yaml delete mode 100644 test-e2e/changes/requirements/QUESTION-DIRECTORY-JUMP-20261102.yaml delete mode 100644 test-e2e/changes/requirements/QUESTION-DIRECTORY-RAIL-20261105-verification.md delete mode 100644 test-e2e/changes/requirements/QUESTION-DIRECTORY-RAIL-20261105.yaml diff --git a/test-e2e/changes/bugs/QUESTION-RAIL-STALE-PREVIEW-20261105-verification.md b/test-e2e/changes/bugs/QUESTION-RAIL-STALE-PREVIEW-20261105-verification.md deleted file mode 100644 index 83cd7eada7..0000000000 --- a/test-e2e/changes/bugs/QUESTION-RAIL-STALE-PREVIEW-20261105-verification.md +++ /dev/null @@ -1,29 +0,0 @@ -# Adjacent question preview regression - -Observed 2026-11-05 on base `51c227cbe` plus the existing question-rail working-tree changes. The supplied screenshot reports Q3 still displayed while hovering Q2. - -## Capability and scope - -The question rail maps one native button per current-conversation question to a jump callback. The reading position controls the dark tick and dot independently from the hovered/focused preview. A non-interactive tooltip shows the hovered question and its answer excerpt. Full-directory search remains available. The fix changes only rail TooltipProvider behavior; collection, answer text, jumping and current-position tracking are retained. - -## Root cause and correction - -Radix Tooltip's default hoverable-content behavior creates a pointer grace polygon between the trigger and the preview. A tall preview creates a polygon overlapping adjacent ticks. While the pointer is inside that corridor, provider pointer-in-transit state prevents the next trigger from opening. The existing `pointer-events-none` CSS on preview content does not disable this internal geometry handling. - -Set `disableHoverableContent` on the rail's TooltipProvider. Leaving a tick closes its display-only preview and lets the next tick open, without the hover corridor. Initial hover delay remains 150ms; keyboard focus and the independent reading-position marker remain unchanged. Do not change the shared Tooltip component or other tooltips. - -## Acceptance and test responsibility - -| AC | Expected behavior | Traditional UT | Formal D1-D5 | -| --- | --- | --- | --- | -| AC-001 | Move Q3 to Q2 inside the former grace corridor: exactly one Q2 preview replaces Q3 | Added adjacent-pointer test in questionDirectory.test.tsx, real Radix with controlled bounding rectangles | No existing bound Case; no duplicate D1 added | -| AC-002 | Leaving Q2 dismisses its preview | Same regression test | No live-browser acceptance claimed | -| AC-003 | Focus preview, jumps, search and active rail scrolling remain valid | Reuse the other 23 directory component tests | Existing formal assets retained | - -## Evidence - -- Before fix: the focused regression failed with expected `question u2`, received `Q3question u3chat.questions.noReply`. The test positions Q2/Q3 at adjacent 15px rows and uses a tall tooltip rectangle to reproduce the grace polygon. -- After fix: all 24 tests in `frontend/tests/component/questionDirectory.test.tsx` passed with Vitest 4.1.12/jsdom 30.1.3. Temporary dependency resolver/configuration matched the preceding rail verification; no project dependency files changed. Configuration removed after tests. -- Copyright, formatting and whitespace checks passed. Design/implementation strict asset validation is run for both the rail requirement and this bug record. -- Python UT is unrelated. Full locked frontend type-check/build and real-browser visual validation remain unavailable in this checkout without installed project dependencies. Temporary dependency versions differ from the project lockfile. -- The sibling nexent-doc repository is absent. This repository record captures the regression evidence; external SPEC synchronization remains pending. No external SPEC or runtime evidence is fabricated. diff --git a/test-e2e/changes/bugs/QUESTION-RAIL-STALE-PREVIEW-20261105.yaml b/test-e2e/changes/bugs/QUESTION-RAIL-STALE-PREVIEW-20261105.yaml deleted file mode 100644 index 4ffbb90c99..0000000000 --- a/test-e2e/changes/bugs/QUESTION-RAIL-STALE-PREVIEW-20261105.yaml +++ /dev/null @@ -1,54 +0,0 @@ -schema_version: "1.0" -change: - change_id: QUESTION-RAIL-STALE-PREVIEW-20261105 - change_type: bugfix - bug_id: QUESTION-RAIL-STALE-PREVIEW-20261105 - title: Switch question rail previews when hovering adjacent ticks - observed_behavior: Moving the mouse from Q3 to adjacent Q2 can leave the Q3 preview visible. - expected_behavior: Preview follows the hovered tick and closes when the mouse leaves it. - escaped_stage: unknown - feature_contract_changed: false - description: >- - A large Q3 tooltip creates a Radix hover grace corridor overlapping the - adjacent Q2 tick. Moving the mouse to Q2 leaves Q3 displayed because the - provider's pointer-in-transit state suppresses Q2 activation. Disable - hoverable content for this display-only rail preview; preserve pointer, - keyboard, current-position and navigation behavior elsewhere. - affected_features: - existing: [] - added: [] - modified: [] - retired: [] - affected_cases: - existing: [] - added: [] - modified: [] - retired: [] - coverage_gap: - type: missing_assertion - description: >- - Existing rail tests cover keyboard focus and click but not moving between - adjacent ticks inside a tall preview's hover grace corridor. A new UT - reproduces the stale Q3 content before the fix. No formal Case binding - exists for this frontend-only interaction in the owning change record. - traditional_ut: - decision: strengthen - test_selectors: - - frontend/tests/component/questionDirectory.test.tsx - behaviors: - - Moving from Q3 to Q2 replaces Q3 with exactly one Q2 preview. - - Leaving Q2 dismisses its preview without entering the display-only card. - - Existing keyboard focus, click, search and active rail scrolling remain valid. - reason: >- - Strengthen existing component tests using real Radix Tooltip behavior and - controlled element geometry; the new test fails before the product fix. - Python UT is unrelated because no Python behavior changes. No duplicate - formal D1 Case or new external runtime boundary is introduced. - notes: >- - Owner baseline QUESTION-DIRECTORY-RAIL-20261105, retaining the original - QUESTION-DIRECTORY-JUMP-20261102 behavior. Sibling nexent-doc is unavailable; - external SPEC synchronization remains blocked. The supplied screenshot and - the failing adjacent-pointer regression establish this fix's scope. Keep - current-position marking independent of hover preview; Q3 may remain the - reading position while the Q2 preview is shown. Raw model/tool text in a - reply excerpt is a separate concern and is not changed in this fix. diff --git a/test-e2e/changes/requirements/ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml b/test-e2e/changes/requirements/ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml deleted file mode 100644 index b935c1973f..0000000000 --- a/test-e2e/changes/requirements/ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102.yaml +++ /dev/null @@ -1,104 +0,0 @@ -schema_version: "1.0" -change: - change_id: ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102 - change_type: requirement - requirement_id: ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102 - title: 回复选中批注结构化传输(LibreChat 式) - description: - 'Upgrades the reply-annotation ("add to chat") channel from frontend-only - text folding to a structured implementation. Pending annotations are now - sent as AgentRequest.annotations (snake_case wire items - quoted_text/comment/source_excerpt, capped at 10 entries / 1500 / 500 / - 120 chars, truncated rather than rejected) alongside the clean composer - text. The backend persists them as a message_annotations unit on the user - message (mirroring clarification_response) and merges the numbered quote - blocks into the model-facing text at request time: the current turn in - prepare_agent_run (create_run query kwarg) and past turns in - get_historical_context, while the stored message_content stays clean. - History reads return the structured annotations field. On the frontend, - a send with annotations and no in-flight uploads takes over the append - (thread.append) so the optimistic user message carries the annotations as - message metadata and renders a collapsed annotation badge above the - message text that expands to read-only reference cards (quoted text, - optional comment, source reply excerpt); cards are - restored from history after reload. With in-flight uploads the send rides - the library submission flow and carries annotations via the composer - runConfig channel (no optimistic cards until reload). Ordinary sends - without annotations are untouched, rejected sends keep the annotations - and restore the draft, and stale runConfig payloads are cleared so they - cannot leak into clarification sends or edit resends.' - affected_features: - existing: [] - added: [] - modified: [] - retired: [] - affected_cases: - existing: [] - added: [] - modified: [] - retired: [] - traditional_ut: - decision: add - test_selectors: - - frontend/tests/component/messageAnnotation.test.tsx - - test/backend/services/test_message_annotations.py - - test/backend/database/test_conversation_db.py - behaviors: - - toMessageAnnotationWireItems maps camelCase annotations onto the - snake_case wire shape, caps the list at the max count and truncates - oversized quoted text/comment fields. - - fromMessageAnnotationWireItems validates entries (dropping unusable - ones), returns an empty list for non-array payloads, re-applies the - defensive caps and round-trips the outgoing mapping. - - createMessageAnnotation drops whitespace-only comments and applies - the defensive caps; adding beyond the max count is rejected in the - Thread with a localized warning. - - MessageAnnotationCards renders a collapsed count badge that expands - to one read-only card per annotation (quote, comment, source excerpt) - and drops invalid entries. - - normalize_message_annotations validates, trims and drops unusable - entries, rejects non-list payloads, returns None when nothing usable - remains, and truncates oversized fields instead of rejecting them. - - format_annotations_for_llm returns the text unchanged without - annotations, composes numbered quote blocks ("> " lines, optional - comment, source excerpt header), joins multiple blocks, and omits - the "---" separator for annotation-only sends. - - parse_message_annotation_unit round-trips persisted payloads and - rejects malformed/empty ones. - - AgentRequest retains structured annotations; save_conversation_user - passes the normalized payload to the atomic message save. - - get_conversation_history returns the annotations field without - replacing the readable message text (malformed units are skipped). - - create_conversation_message persists annotations as a - message_annotations unit for user rows only (assistant rows skip the - unit); historical turns merge the annotation units into the - model-facing user_message while deleted units are ignored. - - test_conversation_db.py get_historical_context mock sequence updated - for the additional annotation-unit query (regression guard for the - existing summary/boundary assertions). - reason: |- - The upgrade spans the request payload (additive annotations field), - unit persistence, prompt construction and the user bubble, all covered - by targeted UT at their owning layers: frontend component/util tests - for the wire mapping, caps, takeover send and badge/card rendering; backend - tests for contract normalization/formatting, service passthrough, - history projection and repository persistence/merge against isolated - SQLite rows. The model-facing merge is invisible to external protocol - boundaries and the northbound API does not expose the field, so no - formal Case is added. - notes: | - Supersedes the frontend-only folding implementation recorded in - ADD-TO-CHAT-MESSAGE-ANNOTATIONS-20261102: the fold function, its labels - (quoteLabel/sourceLabel) and related tests were removed; formatting now - happens in the backend (fixed English labels) so the stored user text - stays clean. Manual takeover uses the library's own - composeEventHandlers(preventDefault) contract; the fallback path parks - the payload on the composer runConfig and clears it once the run settles - (ordinary sends, clarification cards and edit resends cannot carry stale - annotations). The composeSend double-invocation guard of the fold era is - obsolete because the manual append is idempotent. Annotation-only sends - (quoted reply without typed text) resolve the annotations before the - adapter's empty-query guard so the request goes out with an empty query - and the backend merges the quotes into the model-facing text. - Northbound API does not transparently expose annotations; resume runs do - not carry them. diff --git a/test-e2e/changes/requirements/ADD-TO-CHAT-LOCAL-CONTEXT-20261105-verification.md b/test-e2e/changes/requirements/ADD-TO-CHAT-LOCAL-CONTEXT-20261105-verification.md deleted file mode 100644 index 54b0548397..0000000000 --- a/test-e2e/changes/requirements/ADD-TO-CHAT-LOCAL-CONTEXT-20261105-verification.md +++ /dev/null @@ -1,25 +0,0 @@ -# Reply annotation nearby-context verification - -Observed on 2026-11-05. Base revision: `3aae5d5395875f4312fffb9bbb7087bbc0e7e31b`, branch `feat/conversation-artifacts-panel`, plus the current working-tree delta. Pre-existing conversation branch, scrolling, artifacts and directory edits were preserved. No merge, remote reconciliation or deployment was performed. - -## Acceptance and implementation - -- Selection captures optional before/after snapshots from the actual DOM Range, not a first-occurrence string search. Prefer the endpoint paragraph; at paragraph boundaries use nearby reply text. Each side is limited to the nearest 200 characters. -- Exclude reasoning/tool containers from captured context and reject selections crossing those containers. Preserve the existing 1500-character selected-quote and 500-character comment limits. -- Keep the 40-character source label for UI display. Model input omits that label and separates contextual excerpts from selected text and the user's question. -- Extend frontend wire readers/writers and the backend annotation model with optional context fields. Existing generic JSON message-unit persistence, history projection, current-run formatting, historical-context formatting and branch unit copying carry the snapshots without schema changes. Existing payloads remain accepted. -- Strengthen existing frontend and backend UT. No new formal D1-D5 Case was introduced; unit assertions are not reported as runtime, browser, model or persistence acceptance. - -## Checks - -- Passed: 14 existing/new contract-focused pytest cases selected from `test/backend/services/test_message_annotations.py` (normalization, formatting, context limits, malformed and legacy payloads, JSON parsing). Because the full test module imports unavailable application dependencies, its contract-only imports/constants/functions were extracted with Python AST into a temporary pytest module. These tests execute the real `contracts.annotations` implementation. Runtime used bundled Python 3.12, not the required backend Python 3.11 environment. -- Passed: temporary Node/jsdom assertions against the real frontend helper implementation, using Node TypeScript stripping. Checked actual Range selection, repeated quote positions, frozen snapshots after DOM mutation, wire/history round-trip, adjacent-paragraph fallback, 200-character limits, tool exclusion and legacy malformed optional fields. This is an ad hoc helper check, not execution of the repository Vitest suite or a browser journey. -- Passed: Prettier check on all four changed TypeScript/TSX files; copyright header check on all six changed source/test files; `git diff --check`. -- Passed: unified design- and implementation-phase asset validation with `--require-ut-change ADD-TO-CHAT-LOCAL-CONTEXT-20261105`. Initial sandbox attempts failed because of missing PyYAML and temporary Excel directory permissions; the completed checks used temporary dependencies and approved execution outside the sandbox. - -## Remaining verification and prerequisites - -- Full `test/backend/services/test_message_annotations.py` collection is blocked by missing SQLAlchemy. The configured backend Python 3.11 environment and project dependency installation are absent. Persistence/service/history tests were strengthened to include context but have not run as a complete suite. -- Repository Vitest and TypeScript project checks are blocked by absent frontend `node_modules`; npm is not on PATH. Temporary helper tooling does not replace those checks. -- Sibling `nexent-doc` is absent, so the external SPEC update remains blocked. The repository change YAML records the authorized behavior and UT responsibilities; no substitute external SPEC was created. -- No live browser/API/model execution, generated functional coverage claim or Daily/merge readiness claim is made. diff --git a/test-e2e/changes/requirements/ADD-TO-CHAT-LOCAL-CONTEXT-20261105.yaml b/test-e2e/changes/requirements/ADD-TO-CHAT-LOCAL-CONTEXT-20261105.yaml deleted file mode 100644 index e319f39ee5..0000000000 --- a/test-e2e/changes/requirements/ADD-TO-CHAT-LOCAL-CONTEXT-20261105.yaml +++ /dev/null @@ -1,46 +0,0 @@ -schema_version: "1.0" -change: - change_id: ADD-TO-CHAT-LOCAL-CONTEXT-20261105 - change_type: requirement - requirement_id: ADD-TO-CHAT-LOCAL-CONTEXT-20261105 - title: Capture nearby reply context for add-to-chat quotes - description: >- - Preserve the 40-character source display label, but omit it from model - input. Capture optional context_before/context_after snapshots at selection - time, preferring the selected paragraph and limiting each side to 200 - characters nearest the selection. Fall back to nearby reply text at paragraph - boundaries. Exclude reasoning/tool content. Preserve these fields through - request normalization, JSON unit persistence and history restoration. - Legacy annotations without context remain readable and sendable. - affected_features: - existing: [] - added: [] - modified: [] - retired: [] - affected_cases: - existing: [] - added: [] - modified: [] - retired: [] - traditional_ut: - decision: strengthen - test_selectors: - - frontend/tests/component/messageAnnotation.test.tsx - - test/backend/services/test_message_annotations.py - behaviors: - - Capture positional context for repeated quotes and preserve selection-time snapshots. - - Bound nearby context and exclude tool/reasoning text and invalid selections. - - Round-trip optional context through wire payloads, persistence and history. - - Format context separately from the selected quote and omit source display labels. - - Preserve legacy annotations and reject malformed payloads safely. - reason: >- - Strengthen the existing annotation UT rather than duplicate isolated - contracts in new D1 Cases. Existing annotation change records have no - formal Feature/Case bindings. D2-D5 runtime/browser/model acceptance is - not claimed by these tests; no deployment or external system is changed. - notes: >- - Baseline: ADD-TO-CHAT-ANNOTATIONS-STRUCTURED-20261102. Initial branch: - feat/conversation-artifacts-panel, with unrelated uncommitted changes. - The configured sibling nexent-doc repository is absent; external SPEC - synchronization remains blocked. This record captures the authorized delta - and test intentions without inventing an external SPEC or execution evidence. diff --git a/test-e2e/changes/requirements/ADD-TO-CHAT-MESSAGE-ANNOTATIONS-20261102.yaml b/test-e2e/changes/requirements/ADD-TO-CHAT-MESSAGE-ANNOTATIONS-20261102.yaml deleted file mode 100644 index 409ebc3d38..0000000000 --- a/test-e2e/changes/requirements/ADD-TO-CHAT-MESSAGE-ANNOTATIONS-20261102.yaml +++ /dev/null @@ -1,76 +0,0 @@ -schema_version: "1.0" -change: - change_id: ADD-TO-CHAT-MESSAGE-ANNOTATIONS-20261102 - change_type: requirement - requirement_id: ADD-TO-CHAT-MESSAGE-ANNOTATIONS-20261102 - title: 回复选中批注(添加到对话) - description: - 'Selecting text inside one assistant reply shows a floating "Add to chat" - bubble above the selection. Confirming it (optionally after typing a - comment) stores a pending annotation and renders a count chip above the - composer input; hovering the chip opens the annotation list where each - entry can be edited or deleted and the trailing button clears all. - Annotations attach to the next outgoing message only: sending folds each - one into the message text as a numbered quote block (quoted reply text - prefixed with ">", an optional comment line, and the first-40-char excerpt - of the source reply), followed by a "---" separator and the user-typed - text. An annotation-only send (empty composer text) is allowed. The fold - is idempotent for the send-button/form-submit double invocation; a - rejected run keeps the annotations and restores the user draft, and the - annotations are consumed once the run accepts. Switching threads resets - pending annotations. Selections are quotable only inside the content area - of a single assistant message (reasoning, tool, execution-code, and - subagent containers are excluded); the bubble is disabled in read-only - threads, share mode, skill-file chats, and the preview layouts.' - affected_features: - existing: [] - added: [] - modified: [] - retired: [] - affected_cases: - existing: [] - added: [] - modified: [] - retired: [] - traditional_ut: - decision: add - test_selectors: - - frontend/tests/component/messageAnnotation.test.tsx - behaviors: - - formatAnnotationsIntoMessage returns the text unchanged without - annotations, composes numbered quote blocks with the "from reply" - excerpt and optional comment lines, and joins the user text behind a - "---" separator (annotation-only sends keep the block alone). - - truncateAnnotationText collapses whitespace and truncates to the - limit with an ellipsis. - - getAnnotationSourceExcerpt joins only the text parts of the source - assistant message, returns "" for user or unknown messages, and - truncates long replies to the excerpt limit. - - createMessageAnnotation keeps the quoted text verbatim, trims the - comment (dropping whitespace-only values), and captures the source - excerpt at creation time. - - resolveAnnotationSelection accepts a selection whose endpoints sit in - the content area of the same assistant message, normalizes CRLF, and - rejects collapsed/empty selections, reasoning and tool containers, - cross-message selections, and user-message selections. - - ComposerAnnotationChip renders nothing without annotations, opens - the annotation list on hover, deletes a single entry, clears all - entries, saves an inline-edited trimmed comment, and cancels editing - without saving. - reason: |- - The feature is pure frontend composition at send time: no backend - route, payload, or protocol changes are involved, and the send path - reuses the existing ordinary-send rejection/restore flow. The targeted - component UT covers the new unit behavior (message composition, - selection resolution, chip list interactions) at its owning layer; the - existing workbench Composer suite still passes unchanged. No new - external protocol or cross-component runtime contract is introduced, - so no formal Case is added. - notes: | - Core scope confirmed by the user: the bubble only offers "Add to chat" - (no side chat), an optional comment is enough to send (empty composer - text allowed), each annotation carries the first-40-char excerpt of its - source reply, and the chip expands its list on hover. Quoted text is - sent inside the message content (markdown-style "> " lines) so the model - resolves the reference from the full conversation history without any - message-reference protocol. diff --git a/test-e2e/changes/requirements/BRANCH-CONVERSATION-TO-NEW-CHAT-20261102.yaml b/test-e2e/changes/requirements/BRANCH-CONVERSATION-TO-NEW-CHAT-20261102.yaml deleted file mode 100644 index 0a813de0ac..0000000000 --- a/test-e2e/changes/requirements/BRANCH-CONVERSATION-TO-NEW-CHAT-20261102.yaml +++ /dev/null @@ -1,73 +0,0 @@ -schema_version: "1.0" -change: - change_id: BRANCH-CONVERSATION-TO-NEW-CHAT-20261102 - change_type: requirement - requirement_id: BRANCH-CONVERSATION-TO-NEW-CHAT-20261102 - title: 会话分支到新聊天 - description: - 'Add a "branch to new chat" action on the assistant message "..." menu. - Branching creates a new conversation that copies the conversation-level - configuration (agent, chat mode, knowledge scope, runtime metadata, - workbench config) plus every message, message unit, search source, and - image source of all turns up to and including the selected answer (the - question is included). The new conversation title reuses the original - title suffixed with "(分支)" (suffix never stacks, truncated to the - 100-char column). The original conversation and its MinIO file - references stay untouched (branch shares file references instead of - copying objects). After branching, the client switches to the new - conversation thread and refreshes the thread list. User feedback - (like/dislike) is not carried over. The action is unavailable while a - run streams, while loading, in read-only contexts, or before the - backend conversation id exists.' - affected_features: - existing: [] - added: [] - modified: [] - retired: [] - affected_cases: - existing: [] - added: [] - modified: [] - retired: [] - traditional_ut: - decision: add - test_selectors: - - test/backend/database/test_conversation_branch_db.py - - test/backend/services/test_conversation_management_service.py - - test/backend/app/test_conversation_management_app.py - - frontend/tests/component/conversationBranch.test.tsx - behaviors: - - repository branch_conversation takes the user-quota advisory lock - before row locks, rejects non-positive or out-of-range turn indexes, - collapses same-index rows to the newest (refresh duplicates and - regenerated answers), copies the active rows through the boundary - turn including its first assistant answer, remaps message/unit/search - /image foreign keys to the new rows, keeps opinion feedback and later - turns out, and never stacks or over-lengthens the title suffix. - - branch_conversation_service returns the repository payload, maps a - missing conversation to ConversationNotFoundError, rejects non-positive - turn indexes with ValueError, and wraps unexpected repository failures. - - POST /conversation/branch maps invalid turns to 400, missing - conversations to 404, expired sessions to 401, unexpected errors to - 500, and returns the new conversation payload on success. - - computeBranchTurnIndex derives the 1-based turn number of the selected - answer (counting user messages up to its position) and returns null - for unknown ids or answers without a preceding question. - - ConversationBranchMenuItem renders the localized label, forwards the - aui message id to the branch handler, reflects the disabled state, - and renders nothing without the branch context. - reason: |- - The feature crosses app/service/database layers but each boundary is - already exercised through the existing conversation-management suites; - the targeted UT covers the new unit behavior (turn boundary selection, - dedup collapse, remapping, error mapping, menu wiring) at its owning - layer. No new external protocol or cross-component runtime contract is - introduced, so no formal Case is added. - notes: | - Core scope confirmed by the user (branch point includes the selected - Q&A pair, entry point is the assistant message "..." menu only, the - branched title keeps only a "(分支)" suffix, and the original - conversation is never modified). Turn counting aligns the frontend - (user messages up to the selected answer) with the backend (active - user rows in message_index order), so no message-id mapping request is - needed. diff --git a/test-e2e/changes/requirements/BRANCH-NUMBERED-TITLES-20261106-verification.md b/test-e2e/changes/requirements/BRANCH-NUMBERED-TITLES-20261106-verification.md deleted file mode 100644 index d1fce126bf..0000000000 --- a/test-e2e/changes/requirements/BRANCH-NUMBERED-TITLES-20261106-verification.md +++ /dev/null @@ -1,22 +0,0 @@ -# Numbered branch titles - -Observed 2026-11-06 on base `51c227cbe` plus existing uncommitted frontend changes, which were preserved. - -## Acceptance and implementation - -- `build_branch_title` in `backend/management/db/conversation/branch_titles.py` produces `Original title (2)`, skips occupied titles, increments an existing trailing numeric suffix and strips the historical Chinese branch marker when creating a new branch. It does not rename historical rows. -- The 100-character cap includes the suffix; collision checks are repeated after truncation and when the number gains a digit. Empty titles produce `(2)`. Non-numeric parentheses remain part of the base. -- `branch_conversation` reads only active titles of the current owner within its existing transaction and user advisory lock before inserting the new record. Other users/deleted conversations do not reserve names. The existing lock serializes same-owner branch allocations, but no global uniqueness against manual renames or live concurrency proof is claimed. -- Routes, request/response fields, frontend navigation, copied history and transaction ownership remain unchanged. - -## Evidence - -- Passed: 12 focused tests in `test/backend/database/test_branch_titles.py` importing the real helper normally, using temporary pytest dependencies and bundled Python 3.12. -- Passed: 24 tests total across that file and `test/backend/database/test_conversation_branch_db.py`. The combined isolated run substitutes the import-time `management.db.client` module with a MagicMock to avoid SDK/storage/engine bootstrap; test fixtures supply the mocked sessions already used by the repository suite. The real repository functions, real SQLAlchemy models/statements and real title helper execute. No copied product implementation or live DB is used. -- The normal combined invocation initially failed collection because SQLAlchemy was absent, then because SDK httpx was absent. An attempted storage-only import substitution hit the missing psycopg bootstrap. These are environment limitations, not product-test failures. Temporary SQLAlchemy 2.1.8 differs from the project's configured 2.0 series, and Python 3.12 differs from required backend 3.11; the configured environment must rerun the suite before a product PR. -- Existing child-query assertions were shifted to account for the additional title query. New repository coverage verifies active-owner filters and insertion of `(4)` when `(2)` and `(3)` are occupied. Existing copy/remap, quota-lock ordering and invalid-turn checks pass in the isolated run. -- Copyright headers, Python AST parsing and `git diff --check` passed. Design- and implementation-phase strict asset validation passed with `--require-ut-change BRANCH-NUMBERED-TITLES-20261106`. - -## Limits - -No live PostgreSQL, concurrent requests, browser journey, deployment or Daily readiness was verified. No new formal D1 duplicates the unit assertions; existing formal Cases are unchanged. External SPEC synchronization remains pending because sibling nexent-doc is unavailable. No project dependency manifests or workflows were modified. diff --git a/test-e2e/changes/requirements/BRANCH-NUMBERED-TITLES-20261106.yaml b/test-e2e/changes/requirements/BRANCH-NUMBERED-TITLES-20261106.yaml deleted file mode 100644 index 4e1aaef992..0000000000 --- a/test-e2e/changes/requirements/BRANCH-NUMBERED-TITLES-20261106.yaml +++ /dev/null @@ -1,46 +0,0 @@ -schema_version: "1.0" -change: - change_id: BRANCH-NUMBERED-TITLES-20261106 - change_type: requirement - requirement_id: BRANCH-NUMBERED-TITLES-20261106 - title: Use numbered titles for branched conversations - description: >- - Replace the legacy Chinese branch suffix with a space and ASCII number, - beginning at (2), as requested in the screenshot. Skip titles already used - by the current user's active conversations; branches of numbered branches - increment the trailing number without stacking suffixes. Strip the legacy - branch marker when creating a new branch, without renaming historical rows. - Keep the 100-character title limit including the numeric suffix. Perform - title allocation inside the existing user-locked branch transaction. - affected_features: - existing: [] - added: [] - modified: [] - retired: [] - affected_cases: - existing: [] - added: [] - modified: [] - retired: [] - traditional_ut: - decision: strengthen - test_selectors: - - test/backend/database/test_branch_titles.py - - test/backend/database/test_conversation_branch_db.py - behaviors: - - Format normal, Unicode, empty and legacy source titles with numeric suffixes. - - Increment numbered branches and skip occupied titles without stacking suffixes. - - Truncate titles including digit-width boundaries while preserving the number. - - Query only active titles owned by the source user inside the existing quota lock. - - Preserve branch copying, ownership checks and invalid-turn rejection. - reason: >- - Title formatting and repository query wiring are isolated unit contracts. - Strengthen existing branch UT and add focused title-helper UT rather than - duplicate them in formal D1. No route, payload, schema or frontend change. - Live database concurrency and browser acceptance are not claimed by UT. - notes: >- - Baseline BRANCH-CONVERSATION-TO-NEW-CHAT-20261102. The sibling nexent-doc - repository is unavailable; external SPEC synchronization remains pending. - Preserve existing uncommitted frontend changes. Existing data is not renamed. - The user advisory lock serializes branch allocations for one owner; this is - not a global uniqueness guarantee against unrelated manual title renames. diff --git a/test-e2e/changes/requirements/CHAT-ARTIFACTS-PANEL-20261031.yaml b/test-e2e/changes/requirements/CHAT-ARTIFACTS-PANEL-20261031.yaml deleted file mode 100644 index 20c1cbf11f..0000000000 --- a/test-e2e/changes/requirements/CHAT-ARTIFACTS-PANEL-20261031.yaml +++ /dev/null @@ -1,56 +0,0 @@ -schema_version: "1.0" -change: - change_id: CHAT-ARTIFACTS-PANEL-20261031 - change_type: requirement - requirement_id: CHAT-ARTIFACTS-PANEL-20261031 - title: 对话产物面板聚合多轮生成的文件 - description: - 'Collect every agent-generated file across a conversation and expose - it through a persistent entry above the composer ("view all artifacts") that - opens a side panel listing each artifact with preview (shared FilePreviewDrawer) - and download, plus an "add to chat" action that re-attaches the artifact into - the composer. Aggregation covers both the streaming path (marker text parts - carrying skillFileAttachments) and the history-restore path (skillFileUploadsRegistry).' - affected_features: - existing: [] - added: [] - modified: [] - retired: [] - affected_cases: - existing: [] - added: [] - modified: [] - retired: [] - traditional_ut: - decision: strengthen - test_selectors: - - frontend/tests/component/conversationArtifacts.test.tsx - behaviors: - - collectConversationArtifacts gathers attachments from assistant messages via - streamed cumulative parts, falls back to the history registry for restored - messages, keeps keys unique across streaming chunks whose attachment ids restart, - skips user messages, and tolerates invalid createdAt values. - - ConversationArtifactsPanel renders each artifact with icon, size and generated-at - metadata, opens the shared FilePreviewDrawer for preview, prefers download_url - and falls back to object storage or raw preview URLs, hides when closed, and - offers the add-to-chat action only when an onAddToComposer handler is provided - (read-only/share contexts omit the prop), with a per-entry in-flight guard. - - fetchArtifactFile downloads the artifact bytes via the backend stream endpoint - for object_name, falls back to the attachment's own URLs, and rejects when no - source exists. - - compositeAttachmentAdapter.send reuses the stored MinIO object for files marked - with markFileWithExistingUpload (no second upload, minio_files metadata intact) - and keeps uploading unmarked files through storageService. - - ConversationArtifactsButton stays hidden without artifacts and opens the panel - on click. - reason: |- - The feature is a frontend aggregation over already-proven contracts: the SSE - files/skill_files chunk parsing, the FilePreviewDrawer rendering, and the - storageService download path all have existing coverage and are reused - unchanged. No new inter-component, service or protocol boundary is introduced, - so per the D1 admission rules ("a feature need not add a D1 if UT and - higher-layer tests already supply the necessary proof") the targeted UT is - sufficient and no formal Case is added. - notes: | - Core scope requested by the user; extension candidates (artifact grouping by turn, - share-page availability, office online preview) are intentionally deferred. diff --git a/test-e2e/changes/requirements/CHAT-ARTIFACTS-TOP-ENTRY-20261106-verification.md b/test-e2e/changes/requirements/CHAT-ARTIFACTS-TOP-ENTRY-20261106-verification.md deleted file mode 100644 index ad510de524..0000000000 --- a/test-e2e/changes/requirements/CHAT-ARTIFACTS-TOP-ENTRY-20261106-verification.md +++ /dev/null @@ -1,26 +0,0 @@ -# Persistent top-right artifacts entry - -Observed 2026-11-06 on base `51c227cbe` plus the existing uncommitted question-rail changes. Those changes are preserved. - -## Acceptance and implementation - -- Replace the assistant-message entry with one `ConversationArtifactsToolbarEntry` in ThreadView, after the optional conversation header and before the message viewport. It is outside the scroll viewport and aligned to the right edge of the conversation column, immediately left of an open side panel. -- The toolbar occupies 48px only when the current conversation has artifacts. Without a provider or with a zero count, it returns null and reserves no space. It does not cover messages or scroll away with them. -- Continue reading the existing aggregate conversation count from the provider. Later text-only questions/answers retain the entry; new files update the count; an empty conversation hides it. Do not introduce a global ever-generated flag that leaks visibility between conversations. -- Clicking opens the existing panel through the existing callback. Preview, download and add-to-chat behavior are retained. No backend or persistence changes. -- Rename the component and update context documentation to reflect toolbar ownership; remove the assistant-message call site entirely. - -## Verification - -- Passed: 50 tests across `frontend/tests/component/conversationArtifacts.test.tsx` and `frontend/tests/component/questionDirectory.test.tsx`, using actual repository components with Vitest 4.1.12 and temporary dependencies outside the repository. The temporary Vite resolver configuration was removed after execution. Package versions differ from the repository lockfile; dependency manifests and lockfiles were not changed. -- The added lifecycle test verifies hidden initial state, first generated file, a single toolbar entry, visibility through subsequent text-only turns, opening the panel, updated count after another file, and reset on an empty conversation. Existing panel preview/download/add-to-chat tests pass. -- jsdom emits its existing unsupported document-navigation notice during download tests; all tests pass. -- Copyright checks passed for all four modified source/test files; Prettier and `git diff --check` passed. -- Strict design- and implementation-phase asset validation are run with `--require-ut-change CHAT-ARTIFACTS-TOP-ENTRY-20261106`. These are asset checks, not formal browser/runtime execution. - -## Limits - -- No full locked frontend type-check/lint/build or live application visual verification was performed because project dependencies are absent. No screenshot equivalence or D4 journey result is claimed. -- Python UT is unrelated to this frontend-only placement change. Existing formal Cases are preserved; no duplicate D1 or new external boundary is introduced. -- External SPEC synchronization remains pending because sibling nexent-doc and ../docs/前端ui.md are absent. The user's screenshot and explicit position/visibility clarification define this change. -- No deployment, remote merge or Daily readiness is claimed. diff --git a/test-e2e/changes/requirements/CHAT-ARTIFACTS-TOP-ENTRY-20261106.yaml b/test-e2e/changes/requirements/CHAT-ARTIFACTS-TOP-ENTRY-20261106.yaml deleted file mode 100644 index 90c0b33157..0000000000 --- a/test-e2e/changes/requirements/CHAT-ARTIFACTS-TOP-ENTRY-20261106.yaml +++ /dev/null @@ -1,44 +0,0 @@ -schema_version: "1.0" -change: - change_id: CHAT-ARTIFACTS-TOP-ENTRY-20261106 - change_type: requirement - requirement_id: CHAT-ARTIFACTS-TOP-ENTRY-20261106 - title: Keep the conversation artifacts entry at the top right - description: >- - Move the repeated assistant-message artifacts entry into a single top-right - toolbar outside the message scroll viewport. Hide the entire toolbar while - the current conversation has no generated artifacts. Once artifacts exist, - retain the entry through later questions and answers, update its accumulated - count, and open the existing shared artifacts panel. Derive visibility from - the current conversation, so a new empty conversation hides it again. - affected_features: - existing: [] - added: [] - modified: [] - retired: [] - affected_cases: - existing: [] - added: [] - modified: [] - retired: [] - traditional_ut: - decision: strengthen - test_selectors: - - frontend/tests/component/conversationArtifacts.test.tsx - behaviors: - - Hide the toolbar without artifacts/provider and open the existing panel on click. - - Show exactly one top toolbar entry when the first file arrives. - - Retain visibility through later text-only turns and update accumulated counts. - - Hide when switching to a conversation with no generated files. - - Retain artifact preview, download and add-to-chat behavior. - reason: >- - Frontend placement and visibility reuse existing conversation aggregation - and side-panel state. Strengthen the owning component suite; no Python - behavior, backend contract or new formal proof boundary is introduced. - Preserve all formal Cases without adding a duplicate D1. - notes: >- - Baseline CHAT-ARTIFACTS-PANEL-20261031 and the existing message-entry - implementation. User explicitly requests the screenshot's top-right - position and persistent visibility after generation. Preserve uncommitted - question-rail changes. Sibling nexent-doc and ../docs/前端ui.md are unavailable; - external SPEC synchronization remains pending. No deployment is performed. diff --git a/test-e2e/changes/requirements/QUESTION-DIRECTORY-JUMP-20261102.yaml b/test-e2e/changes/requirements/QUESTION-DIRECTORY-JUMP-20261102.yaml deleted file mode 100644 index 310e0ed465..0000000000 --- a/test-e2e/changes/requirements/QUESTION-DIRECTORY-JUMP-20261102.yaml +++ /dev/null @@ -1,79 +0,0 @@ -schema_version: "1.0" -change: - change_id: QUESTION-DIRECTORY-JUMP-20261102 - change_type: requirement - requirement_id: QUESTION-DIRECTORY-JUMP-20261102 - title: 会话内问题目录跳转 - description: - 'List every user question asked during a single agent conversation in a - directory popover ("问题目录") and jump the message viewport to the - selected turn. The popover offers a keyword filter over the full question - text (case-insensitive, resets when it closes). The entry renders as a - floating chip when the surface has no persistent header (main newchat - flow) and as a header icon button on surfaces that have one (workbench - presentation, share mode, read-only conversation page). Jumping locates - the message root via its data-message-id attribute, smooth-scrolls the - viewport, and briefly highlights the target. Navigation stays available - while a run streams and in read-only/share contexts. Hovering (or - focusing) a directory row opens a preview card with the full question and - an excerpt of its first assistant reply (capped at 400 chars, text-free - assistant messages skipped, "no reply yet" placeholder); the row the - viewport is currently scrolled to (last question above the viewport - midline, rAF-throttled capture-phase scroll tracking) is highlighted and - labelled as the current position.' - affected_features: - existing: [] - added: [] - modified: [] - retired: [] - affected_cases: - existing: [] - added: [] - modified: [] - retired: [] - traditional_ut: - decision: add - test_selectors: - - frontend/tests/component/questionDirectory.test.tsx - behaviors: - - collectQuestionDirectory extracts user messages in conversation order with - 1-based indices, uses the first non-empty text part (first line, truncated - to 60 chars with ellipsis, full text preserved for the tooltip), falls - back to the first attachment name for attachment-only turns, and skips - assistant messages plus turns with neither text nor attachments. - - scrollThreadToMessage locates the message root through its - data-message-id attribute, smooth-scrolls it into view, flashes a - highlight, and reports missing or empty targets as false. - - QuestionDirectoryButton hides without questions, opens the directory - popover from the trigger, lists each question with its jump affordance, - invokes onJump with the target message id, closes the popover after an - entry is clicked, filters entries by a case-insensitive keyword over the - full question text (attachment turns included), shows an empty state - for unmatched keywords, and resets the keyword when the popover closes. - - collectQuestionDirectory attaches an excerpt of the first assistant - reply that follows each question (text-free assistant messages do not - consume the pending reply, the excerpt is capped at 400 chars with an - ellipsis, and unanswered questions keep an empty excerpt). - - useActiveQuestionId reports the last question whose message top sits - above the viewport midline, recomputes rAF-throttled on scroll events - (capture phase, inner scroll containers included), and resolves an - empty question set to null. - - QuestionDirectoryButton highlights the row matching activeMessageId - (aria-current="location" plus a current-position label) and opens a - hover/focus preview card showing the full question and its reply - excerpt (or the no-reply placeholder); the card closes on mouse - leave/blur and when the popover closes. - reason: |- - The feature is a frontend navigation layer over already-proven contracts: - thread message state comes from the existing runtime subscription, the - popover reuses the shared Radix-based popover component, and no new - inter-component, service, or protocol boundary is introduced. Per the D1 - admission rules the targeted UT supplies the necessary component proof, - so no formal Case is added. - notes: | - Core scope confirmed by the user (popover form, header entry point, - attachment-name fallback, read-only support). The scroll-spy current - position indicator and a Codex-style hover preview card (question plus - assistant reply excerpt) were added in the same branch after comparing - with Codex's conversation timeline; per-question answer status and a - keyboard shortcut remain deferred. diff --git a/test-e2e/changes/requirements/QUESTION-DIRECTORY-RAIL-20261105-verification.md b/test-e2e/changes/requirements/QUESTION-DIRECTORY-RAIL-20261105-verification.md deleted file mode 100644 index 25f129ad42..0000000000 --- a/test-e2e/changes/requirements/QUESTION-DIRECTORY-RAIL-20261105-verification.md +++ /dev/null @@ -1,26 +0,0 @@ -# Question directory rail verification - -Observed on 2026-11-05. Base revision `51c227cbe`, plus the working-tree delta. The working tree was clean before this change. - -## Implementation and acceptance - -- The user's attached screenshot is the visual reference: a left-side column of short neutral ticks, one per question, with a darker active tick and a small dot. -- Added `frontend/app/[locale]/newchat/ui/question-directory-rail.tsx`. Reuses existing directory collection, active-question identity and jump callbacks. Native buttons support keyboard activation; shared Radix tooltips provide question/reply previews on hover/focus. -- Mounted the same rail outside the message viewport in normal chat, workbench/share mode and the read-only conversation. Reserve a 32px left gutter whenever questions exist; empty conversations do not render the rail or reserve space. -- Bound long tick lists to the available rail height. Active-item synchronization changes only the rail's scrollTop; it never invokes scrollIntoView on the chat. Coarse pointers get larger tick hit targets and a persistent full-directory entry. -- Retained the existing searchable question directory as a compact secondary button, revealed on rail hover/focus and always shown for coarse pointers. Existing search, preview and navigation contracts remain unchanged. - -## Evidence - -- Passed: 23 tests in `frontend/tests/component/questionDirectory.test.tsx`, including five new rail tests for empty state, active marker and jump identity, keyboard-focus preview, searchable directory access, and keeping active ticks visible without scrolling the chat. -- Execution: Vitest 4.1.12 with jsdom 30.1.3 using temporary dependencies outside the repository and a temporary Vite configuration mapping those dependencies into the actual repository components/tests. Command: `node /vitest/vitest.mjs run --config `. The temporary configuration was removed after verification. The project dependency files were not modified. Temporary package versions differ from the project lockfile; this is not the full locked frontend build. -- Initial sandbox execution was blocked by temporary-cache rename permissions. Approved outside-sandbox execution passed after fixing temporary CommonJS/ESM test dependency resolution. -- Passed: copyright headers for all three changed source/test files, Prettier formatting and `git diff --check`. -- Passed: strict design- and implementation-phase formal asset validation with `--require-ut-change QUESTION-DIRECTORY-RAIL-20261105`. These are asset checks, not formal runtime Case execution. - -## Limits - -- The full frontend type-check/lint/build and a real running application visual check were not run: project node_modules is absent. No browser screenshot, D4 journey, performance result or pixel-equivalence claim is made. -- No Python production code changes, so Python UT is unrelated. No new formal D1-D5 Case duplicates the component tests; existing Cases are preserved. -- The sibling nexent-doc repository and ../docs/前端ui.md are absent. External SPEC synchronization remains pending; the user's screenshot and current requirement change record define the requested presentation. -- No remote merge, deployment or Daily readiness is claimed. diff --git a/test-e2e/changes/requirements/QUESTION-DIRECTORY-RAIL-20261105.yaml b/test-e2e/changes/requirements/QUESTION-DIRECTORY-RAIL-20261105.yaml deleted file mode 100644 index 911b1093de..0000000000 --- a/test-e2e/changes/requirements/QUESTION-DIRECTORY-RAIL-20261105.yaml +++ /dev/null @@ -1,44 +0,0 @@ -schema_version: "1.0" -change: - change_id: QUESTION-DIRECTORY-RAIL-20261105 - change_type: requirement - requirement_id: QUESTION-DIRECTORY-RAIL-20261105 - title: Replace question-directory entry with a left-side question rail - description: >- - Use the user-provided screenshot as the visual reference. Render one muted - horizontal tick per question in a persistent left gutter outside the message - scroll area. The current question has a darker tick and a dot. Hover/focus - previews question/reply; click or keyboard activation jumps to the message. - Keep the searchable directory available through a compact secondary entry. - Hide the rail for empty conversations, bound long lists to available height, - and use the same rail in editable and read-only conversation surfaces. - affected_features: - existing: [] - added: [] - modified: [] - retired: [] - affected_cases: - existing: [] - added: [] - modified: [] - retired: [] - traditional_ut: - decision: strengthen - test_selectors: - - frontend/tests/component/questionDirectory.test.tsx - behaviors: - - Hide the rail with no questions and render one accessible tick per question. - - Mark only the active question and jump using the original message identity. - - Preview question and reply through hover and keyboard focus. - - Retain the searchable directory, existing collection and scrolling contracts. - reason: >- - This frontend-only navigation presentation reuses existing directory data - and jump behavior. Strengthen component UT without duplicating it in a new - D1 Case. No API, storage or model behavior changes; Python UT is unrelated. - Formal browser/layout acceptance remains separate from component checks. - notes: >- - Baseline QUESTION-DIRECTORY-JUMP-20261102. Initial working tree is clean at - 51c227cbe. The sibling nexent-doc and ../docs/前端ui.md are unavailable; - external SPEC synchronization remains pending. The screenshot and explicit - user request define the visual change. No remote reconciliation or deployment - is authorized or claimed. From a2c8b5019e75f1f0a7d23c26078c454fc278eb34 Mon Sep 17 00:00:00 2001 From: ljy Date: Mon, 9 Nov 2026 16:20:47 +0800 Subject: [PATCH 21/26] fix(tests): construct complete artifact attachments for type checking --- .../component/artifactDocumentPanel.test.tsx | 23 +++++++------ .../component/autoArtifactPreview.test.tsx | 32 +++++++++++-------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/frontend/tests/component/artifactDocumentPanel.test.tsx b/frontend/tests/component/artifactDocumentPanel.test.tsx index 46152b8ce7..662d3d5591 100644 --- a/frontend/tests/component/artifactDocumentPanel.test.tsx +++ b/frontend/tests/component/artifactDocumentPanel.test.tsx @@ -39,14 +39,19 @@ vi.mock("@/app/newchat/ui/attachment", () => ({ triggerUrlDownload: state.download, })); -const attachment = (name: string, extra = {}) => - ({ - id: "file", - name, - object_name: "workspace/report", - preview_url: "https://old/raw.docx", - ...extra, - }) as ArtifactAttachmentLike; +const attachment = ( + name: string, + extra: Partial = {} +): ArtifactAttachmentLike => ({ + id: "file", + name, + type: "file", + status: { type: "complete" }, + content: [], + object_name: "workspace/report", + preview_url: "https://old/raw.docx", + ...extra, +}); test.each(["docx", "xlsx", "pptx"])( "%s uses original bytes and the stream renderer instead of PDF", @@ -87,7 +92,7 @@ test("renders an inline document with close, download and return-to-artifacts co key: "a:file", messageId: "a", attachment: attachment("report.docx"), - } as ConversationArtifact; + } satisfies ConversationArtifact; render( ({ - key, - messageId: "assistant", - attachment: { +): ConversationArtifact => { + const attachment: ArtifactAttachmentLike = { id: key, name, + type: "file", + status: { type: "complete" }, + content: [], object_name: objectName, - } as ConversationArtifact["attachment"], -}); + }; + return { key, messageId: "assistant", attachment }; +}; const initial = { scopeKey: "chat-1", artifacts: [] as ConversationArtifact[], @@ -163,13 +168,14 @@ test("waits for a real preview source when a file marker arrives before upload", rerender({ ...initial, isRunning: true, artifacts: [pending] }); rerender({ ...initial, artifacts: [pending] }); expect(result.current.selected).toBeNull(); - const uploaded = { + const uploadedAttachment: ArtifactAttachmentLike = { + ...pending.attachment, + preview_url: "https://files/report.docx", + }; + const uploaded: ConversationArtifact = { ...pending, - attachment: { - ...pending.attachment, - preview_url: "https://files/report.docx", - }, - } as ConversationArtifact; + attachment: uploadedAttachment, + }; rerender({ ...initial, artifacts: [uploaded] }); expect(result.current.selected).toEqual(uploaded); }); From ecaba75a68d0707190a35ddfdd5db180626a350c Mon Sep 17 00:00:00 2001 From: ljy Date: Mon, 9 Nov 2026 16:39:49 +0800 Subject: [PATCH 22/26] fix(contracts): keep reply annotations in conversation business contract --- backend/contracts/agent.py | 3 +- backend/contracts/annotations.py | 134 ------------------ backend/contracts/conversation.py | 123 +++++++++++++++- .../management/db/conversation/repository.py | 16 ++- backend/management/services/agent/run.py | 2 +- .../services/conversation/service.py | 13 +- .../newchat/utils/message-annotations.ts | 4 +- .../services/test_message_annotations.py | 2 +- 8 files changed, 142 insertions(+), 155 deletions(-) delete mode 100644 backend/contracts/annotations.py diff --git a/backend/contracts/agent.py b/backend/contracts/agent.py index 64773f2e4b..4dd97c77b9 100644 --- a/backend/contracts/agent.py +++ b/backend/contracts/agent.py @@ -18,10 +18,9 @@ model_validator, ) -from contracts.annotations import MessageAnnotationItem from contracts.clarification import ClarificationResponse from contracts.constants.constants import WORKBENCH_OFFICIAL_SKILL_NAMES -from contracts.conversation import ConversationTarget, HistoryItem +from contracts.conversation import ConversationTarget, HistoryItem, MessageAnnotationItem from contracts.knowledge import ConversationKnowledgeScopeRequest, WorkbenchMode from contracts.skill import SkillResolution, SkillZipEntry from contracts.tool import ToolParamsRequest diff --git a/backend/contracts/annotations.py b/backend/contracts/annotations.py deleted file mode 100644 index ad43df54a3..0000000000 --- a/backend/contracts/annotations.py +++ /dev/null @@ -1,134 +0,0 @@ -# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -"""Structured reply annotations attached to an outgoing user message. - -The "add to chat" flow lets a user quote excerpts of an assistant reply -(optionally with a comment) and attach them to the next outgoing message. -Annotations travel as a structured field on ``AgentRequest``, are persisted -on the user message as a ``message_annotations`` unit, and are merged into -the model-facing text at request time so the stored message text stays -clean (mirroring how ``clarification_response`` is handled). -""" - -import json -from typing import Any, List, Optional - -from pydantic import BaseModel - -# Unit type used for the per-message annotation payload persisted on user -# messages through conversation_message_unit_t. -MESSAGE_ANNOTATIONS_UNIT_TYPE = "message_annotations" - -# Defensive caps mirroring the client-side limits; the backend truncates -# rather than rejects so oversized payloads never break a send. -ANNOTATION_QUOTED_TEXT_MAX_CHARS = 1500 -ANNOTATION_COMMENT_MAX_CHARS = 500 -ANNOTATION_SOURCE_EXCERPT_MAX_CHARS = 120 -ANNOTATION_MAX_COUNT = 10 - - -class MessageAnnotationItem(BaseModel): - """One quoted excerpt attached to an outgoing user message. - - Length caps are enforced by ``normalize_message_annotations`` (truncating - rather than rejecting) so oversized client payloads degrade gracefully - instead of failing the whole send. Legacy neighboring-context fields are - ignored when reading requests and persisted units. - """ - - quoted_text: str - comment: Optional[str] = None - source_excerpt: Optional[str] = None - - -def normalize_message_annotations( - raw: Any, -) -> Optional[List[MessageAnnotationItem]]: - """Validate raw annotation payloads, applying defensive caps. - - Accepts a list of annotation dicts (or models) as delivered by the - request body or read back from a persisted unit. Entries without usable - quoted text are dropped, over-length fields are truncated, and the list - is capped to ``ANNOTATION_MAX_COUNT``. Returns ``None`` when nothing - usable remains so callers can skip persistence and merging entirely. - """ - if not isinstance(raw, list): - return None - - annotations: List[MessageAnnotationItem] = [] - for item in raw: - try: - annotation = ( - item - if isinstance(item, MessageAnnotationItem) - else MessageAnnotationItem.model_validate(item) - ) - except (ValueError, TypeError): - continue - quoted = annotation.quoted_text.strip() - if not quoted: - continue - comment = (annotation.comment or "").strip() or None - excerpt = (annotation.source_excerpt or "").strip() or None - annotations.append( - MessageAnnotationItem( - quoted_text=quoted[:ANNOTATION_QUOTED_TEXT_MAX_CHARS], - comment=comment[:ANNOTATION_COMMENT_MAX_CHARS] if comment else None, - source_excerpt=( - excerpt[:ANNOTATION_SOURCE_EXCERPT_MAX_CHARS] - if excerpt - else None - ), - ) - ) - if len(annotations) >= ANNOTATION_MAX_COUNT: - break - - return annotations or None - - -def parse_message_annotation_unit(unit_content: Optional[str]) -> Optional[List[MessageAnnotationItem]]: - """Parse a persisted ``message_annotations`` unit payload. - - Returns the normalized annotations, or ``None`` for missing/invalid - payloads so history readers can skip the field entirely. - """ - if not unit_content: - return None - try: - payload = json.loads(unit_content) - except (ValueError, TypeError): - return None - return normalize_message_annotations(payload) - - -def _annotation_block(index: int, annotation: MessageAnnotationItem) -> str: - # The reply intro is a UI label, not reliable model-facing context. - lines = [f"{index}. Quote:"] - for line in annotation.quoted_text.split("\n"): - lines.append(f"> {line}" if line else ">") - if annotation.comment: - lines.append(f"Comment: {annotation.comment}") - return "\n".join(lines) - - -def format_annotations_for_llm( - text: str, annotations: Optional[List[MessageAnnotationItem]] -) -> str: - """Merge annotation blocks into the model-facing user text. - - Each annotation becomes a numbered quote block (blockquote excerpt plus - an optional comment line); the blocks are joined with blank lines and - separated from the user's own text by a horizontal rule so the model - receives both the quoted reply excerpts and the follow-up question in - one message. With no annotations the text is returned unchanged. - """ - if not annotations: - return text - - blocks = [ - _annotation_block(index, annotation) - for index, annotation in enumerate(annotations, start=1) - ] - quote_block = "\n\n".join(blocks) - body = text or "" - return f"{quote_block}\n\n---\n\n{body}" if body else quote_block diff --git a/backend/contracts/conversation.py b/backend/contracts/conversation.py index a1fb76a48a..e194504e6d 100644 --- a/backend/contracts/conversation.py +++ b/backend/contracts/conversation.py @@ -1,6 +1,6 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -"""Internal message provenance and opaque activity pagination cursors.""" +"""Conversation messages, reply annotations and opaque activity pagination cursors.""" import base64 import hashlib @@ -11,10 +11,129 @@ from pydantic import BaseModel, ConfigDict, Field, StrictInt -from contracts.annotations import MessageAnnotationItem from contracts.clarification import ClarificationResponse +# Unit type used for the per-message annotation payload persisted on user +# messages through conversation_message_unit_t. +MESSAGE_ANNOTATIONS_UNIT_TYPE = "message_annotations" + +# Defensive caps mirroring the client-side limits; the backend truncates +# rather than rejects so oversized payloads never break a send. +ANNOTATION_QUOTED_TEXT_MAX_CHARS = 1500 +ANNOTATION_COMMENT_MAX_CHARS = 500 +ANNOTATION_SOURCE_EXCERPT_MAX_CHARS = 120 +ANNOTATION_MAX_COUNT = 10 + + +class MessageAnnotationItem(BaseModel): + """One quoted excerpt attached to an outgoing user message. + + Length caps are enforced by ``normalize_message_annotations`` (truncating + rather than rejecting) so oversized client payloads degrade gracefully + instead of failing the whole send. Legacy neighboring-context fields are + ignored when reading requests and persisted units. + """ + + quoted_text: str + comment: Optional[str] = None + source_excerpt: Optional[str] = None + + +def normalize_message_annotations( + raw: Any, +) -> Optional[List[MessageAnnotationItem]]: + """Validate raw annotation payloads, applying defensive caps. + + Accepts a list of annotation dicts (or models) as delivered by the + request body or read back from a persisted unit. Entries without usable + quoted text are dropped, over-length fields are truncated, and the list + is capped to ``ANNOTATION_MAX_COUNT``. Returns ``None`` when nothing + usable remains so callers can skip persistence and merging entirely. + """ + if not isinstance(raw, list): + return None + + annotations: List[MessageAnnotationItem] = [] + for item in raw: + try: + annotation = ( + item + if isinstance(item, MessageAnnotationItem) + else MessageAnnotationItem.model_validate(item) + ) + except (ValueError, TypeError): + continue + quoted = annotation.quoted_text.strip() + if not quoted: + continue + comment = (annotation.comment or "").strip() or None + excerpt = (annotation.source_excerpt or "").strip() or None + annotations.append( + MessageAnnotationItem( + quoted_text=quoted[:ANNOTATION_QUOTED_TEXT_MAX_CHARS], + comment=comment[:ANNOTATION_COMMENT_MAX_CHARS] if comment else None, + source_excerpt=( + excerpt[:ANNOTATION_SOURCE_EXCERPT_MAX_CHARS] + if excerpt + else None + ), + ) + ) + if len(annotations) >= ANNOTATION_MAX_COUNT: + break + + return annotations or None + + +def parse_message_annotation_unit(unit_content: Optional[str]) -> Optional[List[MessageAnnotationItem]]: + """Parse a persisted ``message_annotations`` unit payload. + + Returns the normalized annotations, or ``None`` for missing/invalid + payloads so history readers can skip the field entirely. + """ + if not unit_content: + return None + try: + payload = json.loads(unit_content) + except (ValueError, TypeError): + return None + return normalize_message_annotations(payload) + + +def _annotation_block(index: int, annotation: MessageAnnotationItem) -> str: + # The reply intro is a UI label, not reliable model-facing context. + lines = [f"{index}. Quote:"] + for line in annotation.quoted_text.split("\n"): + lines.append(f"> {line}" if line else ">") + if annotation.comment: + lines.append(f"Comment: {annotation.comment}") + return "\n".join(lines) + + +def format_annotations_for_llm( + text: str, annotations: Optional[List[MessageAnnotationItem]] +) -> str: + """Merge annotation blocks into the model-facing user text. + + Each annotation becomes a numbered quote block (blockquote excerpt plus + an optional comment line); the blocks are joined with blank lines and + separated from the user's own text by a horizontal rule so the model + receives both the quoted reply excerpts and the follow-up question in + one message. With no annotations the text is returned unchanged. + """ + if not annotations: + return text + + blocks = [ + _annotation_block(index, annotation) + for index, annotation in enumerate(annotations, start=1) + ] + quote_block = "\n\n".join(blocks) + body = text or "" + return f"{quote_block}\n\n---\n\n{body}" if body else quote_block + + class MessageSource(str, Enum): CHAT = "CHAT" AUTOMATION = "AUTOMATION" diff --git a/backend/management/db/conversation/repository.py b/backend/management/db/conversation/repository.py index a7c97dc072..550df238f4 100644 --- a/backend/management/db/conversation/repository.py +++ b/backend/management/db/conversation/repository.py @@ -1,11 +1,5 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -from contracts.annotations import ( - MESSAGE_ANNOTATIONS_UNIT_TYPE, - format_annotations_for_llm, - normalize_message_annotations, - parse_message_annotation_unit, -) from contracts.constants.constants import CHAT_MODE_VALUES, HISTORY_SUMMARY_UNIT_TYPE from contracts.conversation import ConversationHistory from contracts.errors.exceptions import HistorySummaryPersistenceError @@ -20,7 +14,15 @@ from contracts.constants.constants import MESSAGE_ROLE from contracts.errors.codes import ErrorCode from contracts.errors.exceptions import AppException, ConversationNotFoundError, RuntimeMetadataVersionConflict, WorkbenchConfigVersionConflict, WorkbenchError -from contracts.conversation import MessageSource, decode_activity_cursor, encode_activity_cursor +from contracts.conversation import ( + MESSAGE_ANNOTATIONS_UNIT_TYPE, + MessageSource, + decode_activity_cursor, + encode_activity_cursor, + format_annotations_for_llm, + normalize_message_annotations, + parse_message_annotation_unit, +) from management.db.models import KnowledgeRecord from management.utils.orm import as_dict diff --git a/backend/management/services/agent/run.py b/backend/management/services/agent/run.py index 6d9b759415..d5e7fc4203 100644 --- a/backend/management/services/agent/run.py +++ b/backend/management/services/agent/run.py @@ -25,7 +25,7 @@ from contracts.constants.constants import MESSAGE_ROLE from contracts.errors.exceptions import MemoryPreparationException from contracts.agent import AgentRequest -from contracts.annotations import ( +from contracts.conversation import ( format_annotations_for_llm, normalize_message_annotations, ) diff --git a/backend/management/services/conversation/service.py b/backend/management/services/conversation/service.py index 92a72d2a19..e5d53e17e8 100644 --- a/backend/management/services/conversation/service.py +++ b/backend/management/services/conversation/service.py @@ -3,11 +3,6 @@ import json import logging -from contracts.annotations import ( - MESSAGE_ANNOTATIONS_UNIT_TYPE, - normalize_message_annotations, - parse_message_annotation_unit, -) from contracts.conversation import MessageSource from management.services.agent.context import ( @@ -23,7 +18,13 @@ from contracts.constants.constants import MODEL_CONFIG_MAPPING from contracts.constants.constants import MESSAGE_ROLE, DEFAULT_EN_TITLE, DEFAULT_ZH_TITLE from contracts.agent import AgentRequest -from contracts.conversation import MessageRequest, MessageUnit +from contracts.conversation import ( + MESSAGE_ANNOTATIONS_UNIT_TYPE, + MessageRequest, + MessageUnit, + normalize_message_annotations, + parse_message_annotation_unit, +) from contracts.errors.exceptions import AppException, ConversationNotFoundError, ValidationError from contracts.constants.constants import CHAT_MODE_VALUES from management.db.conversation.repository import branch_conversation, create_conversation, create_conversation_message, create_message_unit, create_source_image, create_source_search, delete_conversation, delete_conversations_batch, get_conversation, get_conversation_history, get_historical_context, get_latest_assistant_message_id, get_latest_user_message_id, get_message_id_by_index, get_source_images_by_conversation, get_source_images_by_message, get_source_searches_by_conversation, get_source_searches_by_message, persist_assistant_run_batch as persist_assistant_run_batch_db, rename_conversation, save_history_summary, update_conversation_agent_id, update_conversation_chat_mode, update_conversation_knowledge_scope, replace_conversation_workbench_config, replace_conversation_workbench_and_metadata, update_conversation_message_content, update_conversation_message_status, update_message_minio_files, update_message_opinion, update_message_unit_content, update_message_unit_status diff --git a/frontend/app/[locale]/newchat/utils/message-annotations.ts b/frontend/app/[locale]/newchat/utils/message-annotations.ts index 182e2e4c9a..5e2b97c8ec 100644 --- a/frontend/app/[locale]/newchat/utils/message-annotations.ts +++ b/frontend/app/[locale]/newchat/utils/message-annotations.ts @@ -18,7 +18,7 @@ const SOURCE_EXCERPT_MAX_LENGTH = 40; /** Default truncation length for quoted text shown inside the chip list. */ const LIST_QUOTE_MAX_LENGTH = 80; -/** Defensive caps mirrored by the backend (see backend/contracts/annotations.py). */ +/** Defensive caps mirrored by the backend (see backend/contracts/conversation.py). */ export const MESSAGE_ANNOTATION_QUOTED_TEXT_MAX = 1500; export const MESSAGE_ANNOTATION_COMMENT_MAX = 500; export const MESSAGE_ANNOTATION_MAX_COUNT = 10; @@ -83,7 +83,7 @@ export interface MessageAnnotationsControl { /** * Wire shape of one persisted reply annotation (snake_case, matching - * `backend/contracts/annotations.py::MessageAnnotationItem`). + * `backend/contracts/conversation.py::MessageAnnotationItem`). */ export interface MessageAnnotationWireItem { quoted_text: string; diff --git a/test/backend/services/test_message_annotations.py b/test/backend/services/test_message_annotations.py index 3b3759fb68..de4f8567f0 100644 --- a/test/backend/services/test_message_annotations.py +++ b/test/backend/services/test_message_annotations.py @@ -12,7 +12,7 @@ from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool -from contracts.annotations import ( +from contracts.conversation import ( ANNOTATION_COMMENT_MAX_CHARS, ANNOTATION_MAX_COUNT, ANNOTATION_QUOTED_TEXT_MAX_CHARS, From 80f726ae73c493e17b6f0ef5880fd607f48f64d6 Mon Sep 17 00:00:00 2001 From: ljy Date: Mon, 9 Nov 2026 17:04:16 +0800 Subject: [PATCH 23/26] fix(chat): address branch errors, keyboard focus and artifact concurrency --- .../conversation/conversation_management.py | 5 +- .../newchat/assistant-ui/annotation-chip.tsx | 2 + .../[locale]/newchat/ui/artifacts-panel.tsx | 12 +++-- .../component/conversationArtifacts.test.tsx | 54 +++++++++++++++++++ .../component/messageAnnotation.test.tsx | 19 +++++++ .../app/test_conversation_management_app.py | 7 ++- 6 files changed, 91 insertions(+), 8 deletions(-) diff --git a/backend/management/api/conversation/conversation_management.py b/backend/management/api/conversation/conversation_management.py index 48a01f5406..9e53ca0264 100644 --- a/backend/management/api/conversation/conversation_management.py +++ b/backend/management/api/conversation/conversation_management.py @@ -318,7 +318,10 @@ async def branch_conversation_endpoint( raise except Exception as e: logging.exception(f"Failed to branch conversation: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to branch conversation", + ) from e @router.delete("/{conversation_id}", response_model=ConversationResponse) diff --git a/frontend/app/[locale]/newchat/assistant-ui/annotation-chip.tsx b/frontend/app/[locale]/newchat/assistant-ui/annotation-chip.tsx index 560e2b6daa..4490878c6f 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/annotation-chip.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/annotation-chip.tsx @@ -133,6 +133,8 @@ export const ComposerAnnotationChip: FC<{ align="start" onMouseEnter={openNow} onMouseLeave={scheduleClose} + onFocusCapture={openNow} + onBlurCapture={scheduleClose} className="w-80 p-2" >

      diff --git a/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx b/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx index 149db26cf6..bfb1b2c9e0 100644 --- a/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx +++ b/frontend/app/[locale]/newchat/ui/artifacts-panel.tsx @@ -2,7 +2,7 @@ "use client"; -import { useState, type FC } from "react"; +import { useRef, useState, type FC } from "react"; import { DownloadIcon, EyeIcon, @@ -70,9 +70,9 @@ export const ConversationArtifactsPanel: FC< const [previewing, setPreviewing] = useState( null ); - // Key of the entry currently being added to the composer; guards against - // double-activating the fetch-and-attach flow while it is in flight. + // Serialize additions across entries. The ref also blocks clicks before React flushes state. const [addingKey, setAddingKey] = useState(null); + const addingRef = useRef(false); if (!open) return null; @@ -80,11 +80,13 @@ export const ConversationArtifactsPanel: FC< key: string, attachment: ArtifactAttachmentLike ) => { - if (!onAddToComposer || addingKey === key) return; + if (!onAddToComposer || addingRef.current) return; + addingRef.current = true; setAddingKey(key); try { await onAddToComposer(attachment); } finally { + addingRef.current = false; setAddingKey(null); } }; @@ -209,7 +211,7 @@ export const ConversationArtifactsPanel: FC< void handleAddToComposer(artifact.key, attachment) } diff --git a/frontend/tests/component/conversationArtifacts.test.tsx b/frontend/tests/component/conversationArtifacts.test.tsx index 78229a93de..1ab762c7f8 100644 --- a/frontend/tests/component/conversationArtifacts.test.tsx +++ b/frontend/tests/component/conversationArtifacts.test.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. import { + act, fireEvent, render, screen, @@ -573,6 +574,59 @@ test("ConversationArtifactsPanel hides add-to-chat without a handler", () => { ).toBeNull(); }); +test("ConversationArtifactsPanel serializes rapid additions across different files", async () => { + let finish!: () => void; + const pending = new Promise((resolve) => { + finish = resolve; + }); + const onAddToComposer = vi + .fn() + .mockReturnValueOnce(pending) + .mockResolvedValue(undefined); + const artifacts = collectConversationArtifacts([ + { + id: "multi", + role: "assistant", + content: [ + { + type: "text", + skillFileAttachments: [ + makeAttachment("a", "one.docx"), + makeAttachment("b", "two.docx"), + ], + }, + ], + }, + ]); + renderArtifactsPanel({ artifacts, onAddToComposer }); + const buttons = screen.getAllByRole("button", { + name: "chat.artifacts.addToChat", + }); + act(() => { + fireEvent.click(buttons[0]); + fireEvent.click(buttons[1]); + fireEvent.click(buttons[0]); + }); + expect(onAddToComposer).toHaveBeenCalledTimes(1); + expect( + buttons.every((button) => (button as HTMLButtonElement).disabled) + ).toBe(true); + await act(async () => { + finish(); + await pending; + }); + await waitFor(() => + expect( + buttons.every((button) => !(button as HTMLButtonElement).disabled) + ).toBe(true) + ); + fireEvent.click(buttons[1]); + await waitFor(() => expect(onAddToComposer).toHaveBeenCalledTimes(2)); + expect(onAddToComposer).toHaveBeenLastCalledWith( + expect.objectContaining({ name: "two.docx" }) + ); +}); + test("ConversationArtifactsPanel ignores repeat clicks while adding", () => { const onAddToComposer = vi.fn( () => new Promise(() => {}) // never settles diff --git a/frontend/tests/component/messageAnnotation.test.tsx b/frontend/tests/component/messageAnnotation.test.tsx index 9f75987a73..5ad68b9972 100644 --- a/frontend/tests/component/messageAnnotation.test.tsx +++ b/frontend/tests/component/messageAnnotation.test.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. import { + act, cleanup, fireEvent, render, @@ -490,6 +491,24 @@ describe("ComposerAnnotationChip", () => { expect(container.firstChild).toBeNull(); }); + test("keeps the list open when keyboard focus enters its actions", async () => { + renderChip(baseAnnotations()); + const trigger = screen.getByRole("button", { + name: "chat.annotation.listTitle", + }); + fireEvent.focus(trigger); + const edit = screen.getAllByRole("button", { + name: "chat.annotation.editComment", + })[0]; + fireEvent.blur(trigger); + fireEvent.focus(edit); + await act(() => new Promise((resolve) => setTimeout(resolve, 220))); + expect(screen.getByRole("dialog")).toBeVisible(); + fireEvent.blur(edit); + await act(() => new Promise((resolve) => setTimeout(resolve, 220))); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + test("opens the annotation list on hover and lists every entry", () => { renderChip(baseAnnotations()); diff --git a/test/backend/app/test_conversation_management_app.py b/test/backend/app/test_conversation_management_app.py index a8ed1f2639..d4eb5736f4 100644 --- a/test/backend/app/test_conversation_management_app.py +++ b/test/backend/app/test_conversation_management_app.py @@ -964,11 +964,11 @@ async def test_branch_conversation_expired_token(conversation_mocks): @pytest.mark.asyncio async def test_branch_conversation_failure(conversation_mocks): - """Unexpected service errors are mapped to 500""" + """Unexpected service errors return a sanitized 500 without database diagnostics.""" conversation_mocks["get_current_user_id"].return_value = ( "user_id", "tenant_id") conversation_mocks["branch_conversation"].side_effect = Exception( - "branch error") + "Database error: SELECT * FROM private_table; connection password=secret") request_obj = MagicMock() request_obj.conversation_id = 7 @@ -978,3 +978,6 @@ async def test_branch_conversation_failure(conversation_mocks): await branch_conversation_endpoint(request_obj, authorization="Bearer test-token") assert exc_info.value.status_code == 500 + assert exc_info.value.detail == "Failed to branch conversation" + assert "private_table" not in exc_info.value.detail + assert "secret" not in exc_info.value.detail From a2783a212f1ef29a17e99906a386fda2e4d7c054 Mon Sep 17 00:00:00 2001 From: ljy Date: Mon, 9 Nov 2026 17:51:16 +0800 Subject: [PATCH 24/26] fix: preserve selected answer when branching conversations --- backend/contracts/conversation.py | 3 + .../conversation/conversation_management.py | 7 +- .../management/db/conversation/repository.py | 91 ++++++------ .../services/conversation/service.py | 11 +- .../conversation-branch-context.ts | 2 +- .../conversation-branch-menu-item.tsx | 5 +- .../[locale]/newchat/assistant-ui/thread.tsx | 4 +- .../newchat/utils/conversation-branch.ts | 6 +- frontend/services/conversationService.ts | 16 ++- .../component/conversationBranch.test.tsx | 17 ++- .../conversationBranchRequest.test.ts | 62 +++++++++ .../app/test_conversation_management_app.py | 36 +++-- .../test_conversation_branch_selection.py | 130 ++++++++++++++++++ .../test_conversation_management_service.py | 15 ++ 14 files changed, 338 insertions(+), 67 deletions(-) create mode 100644 frontend/tests/component/conversationBranchRequest.test.ts create mode 100644 test/backend/database/test_conversation_branch_selection.py diff --git a/backend/contracts/conversation.py b/backend/contracts/conversation.py index e194504e6d..e1b03ca9fc 100644 --- a/backend/contracts/conversation.py +++ b/backend/contracts/conversation.py @@ -226,6 +226,9 @@ class BranchConversationRequest(BaseModel): conversation_id: int # 1-based Q&A turn (inclusive) that marks the branch point turn_index: int + # Persisted answer ID, or the runtime's 1-based answer version before reload. + source_message_id: Optional[StrictInt] = Field(default=None, gt=0) + answer_index: Optional[StrictInt] = Field(default=None, gt=0) class BatchDeleteConversationRequest(BaseModel): diff --git a/backend/management/api/conversation/conversation_management.py b/backend/management/api/conversation/conversation_management.py index 9e53ca0264..8a27c63196 100644 --- a/backend/management/api/conversation/conversation_management.py +++ b/backend/management/api/conversation/conversation_management.py @@ -301,8 +301,13 @@ async def branch_conversation_endpoint( """ try: user_id, tenant_id = get_current_user_id(authorization) + target = { + key: value + for key, value in request.model_dump(include={"source_message_id", "answer_index"}).items() + if value is not None + } branched = branch_conversation_service( - request.conversation_id, request.turn_index, user_id + request.conversation_id, request.turn_index, user_id, **target ) return ConversationResponse(code=0, message="success", data=branched) except ValueError as exc: diff --git a/backend/management/db/conversation/repository.py b/backend/management/db/conversation/repository.py index 550df238f4..b36db4325b 100644 --- a/backend/management/db/conversation/repository.py +++ b/backend/management/db/conversation/repository.py @@ -502,50 +502,58 @@ def create_message_units(message_units: List[Dict[str, Any]], message_id: int, c return unit_ids -def _select_active_branch_rows(rows: Sequence[ConversationMessage]) -> List[ConversationMessage]: - """Collapse rows sharing a message_index to the newest (active) row. - - Refresh flows may re-insert a user row with the same index and regenerated - answers append new assistant rows with the index of the previous attempt; - in both cases the row with the highest message_id is the active one. - """ - active_by_index: Dict[int, ConversationMessage] = {} - for row in rows: - active_by_index[int(row.message_index)] = row - return [active_by_index[index] for index in sorted(active_by_index)] - - def _select_branch_turn_rows( - active_rows: Sequence[ConversationMessage], + rows: Sequence[ConversationMessage], turn_index: int, + source_message_id: Optional[int] = None, + answer_index: Optional[int] = None, ) -> List[ConversationMessage]: - """Return the active rows up to and including the requested turn's answer. + """Select one answer per turn, ending at the explicitly selected version. - ``turn_index`` is 1-based over the active user rows in message_index order. - The copied tail keeps rows that follow the boundary user row until the - first assistant row (inclusive), so system/tool rows inside the turn and - the current answer are carried over while later turns stay behind. + Keep regenerated assistant rows until version selection. Collapsing them + by message_index first would lose older answers still visible in the UI. + Runtime answer ordinals follow the same message_index/message_id ordering + as history loading; a persisted ID takes precedence when available. """ - boundary_position = None - user_seen = 0 - for position, row in enumerate(active_rows): + latest_users = { + int(row.message_index): row + for row in rows + if row.message_role == MESSAGE_ROLE["USER"] + } + turns: List[List[ConversationMessage]] = [] + preamble: List[ConversationMessage] = [] + for row in rows: if row.message_role == MESSAGE_ROLE["USER"]: - user_seen += 1 - if user_seen == turn_index: - boundary_position = position + if row is not latest_users[int(row.message_index)]: + continue + turns.append([row]) + elif turns: + turns[-1].append(row) + else: + preamble.append(row) + if turn_index < 1 or turn_index > len(turns): + raise ValueError(f"turn_index {turn_index} exceeds the {len(turns)} turns in the conversation") + + branch_rows = list(preamble) + for index, turn in enumerate(turns[:turn_index], start=1): + answers = [row for row in turn if row.message_role == MESSAGE_ROLE["ASSISTANT"]] + selected = answers[-1] if answers else None + if index == turn_index: + if source_message_id is not None: + selected = next( + (row for row in answers if int(row.message_id) == source_message_id), None + ) + if selected is None: + raise ValueError("source_message_id does not identify an answer in the selected turn") + elif answer_index is not None: + if not 1 <= answer_index <= len(answers): + raise ValueError("answer_index does not identify an answer in the selected turn") + selected = answers[answer_index - 1] + for row in turn: + if row.message_role != MESSAGE_ROLE["ASSISTANT"] or row is selected: + branch_rows.append(row) + if row is selected: break - if boundary_position is None: - raise ValueError( - f"turn_index {turn_index} exceeds the {user_seen} turns in the conversation" - ) - - branch_rows = list(active_rows[: boundary_position + 1]) - for row in active_rows[boundary_position + 1:]: - if row.message_role == MESSAGE_ROLE["USER"]: - break - branch_rows.append(row) - if row.message_role == MESSAGE_ROLE["ASSISTANT"]: - break return branch_rows @@ -553,6 +561,8 @@ def branch_conversation( conversation_id: int, turn_index: int, user_id: Optional[str] = None, + source_message_id: Optional[int] = None, + answer_index: Optional[int] = None, ) -> Optional[Dict[str, Any]]: """ Branch a conversation into a new one up to and including the given turn. @@ -567,6 +577,8 @@ def branch_conversation( conversation_id: Source conversation ID (integer) turn_index: 1-based Q&A turn (inclusive) marking the branch point user_id: User that must own the source conversation + source_message_id: Exact persisted answer to retain in the final turn + answer_index: 1-based answer version for messages with runtime-only IDs Returns: Optional[Dict[str, Any]]: New conversation data, or None when the @@ -604,8 +616,9 @@ def branch_conversation( .with_for_update() ).all() - active_rows = _select_active_branch_rows(message_rows) - branch_rows = _select_branch_turn_rows(active_rows, turn_index) + branch_rows = _select_branch_turn_rows( + message_rows, turn_index, source_message_id=source_message_id, answer_index=answer_index + ) # The existing user advisory lock serializes numbering for branches # created by the same owner. Other users and deleted rows do not count. diff --git a/backend/management/services/conversation/service.py b/backend/management/services/conversation/service.py index e5d53e17e8..d9fee031ce 100644 --- a/backend/management/services/conversation/service.py +++ b/backend/management/services/conversation/service.py @@ -447,6 +447,8 @@ def branch_conversation_service( conversation_id: int, turn_index: int, user_id: str, + source_message_id: Optional[int] = None, + answer_index: Optional[int] = None, ) -> Dict[str, Any]: """ Branch a conversation into a new one up to and including the given turn @@ -455,6 +457,8 @@ def branch_conversation_service( conversation_id: Source conversation ID turn_index: 1-based Q&A turn (inclusive) marking the branch point user_id: User ID that must own the source conversation + source_message_id: Exact persisted answer in the requested turn + answer_index: 1-based runtime answer version when no persisted ID is available Returns: Dict[str, Any]: New conversation data @@ -465,8 +469,13 @@ def branch_conversation_service( if turn_index < 1: raise ValueError("turn_index must be a 1-based turn number") try: + target = {} + if source_message_id is not None: + target["source_message_id"] = source_message_id + if answer_index is not None: + target["answer_index"] = answer_index conversation_data = branch_conversation( - conversation_id, turn_index, user_id=user_id + conversation_id, turn_index, user_id=user_id, **target ) if conversation_data is None: raise ConversationNotFoundError( diff --git a/frontend/app/[locale]/newchat/assistant-ui/conversation-branch-context.ts b/frontend/app/[locale]/newchat/assistant-ui/conversation-branch-context.ts index b68e9b2ff4..fe76b1f3c8 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/conversation-branch-context.ts +++ b/frontend/app/[locale]/newchat/assistant-ui/conversation-branch-context.ts @@ -6,5 +6,5 @@ import { createContext } from "react"; export const ConversationBranchContext = createContext<{ disabled: boolean; - branchFromMessage: (assistantMessageId: string) => void; + branchFromMessage: (assistantMessageId: string, answerIndex: number) => void; } | null>(null); diff --git a/frontend/app/[locale]/newchat/assistant-ui/conversation-branch-menu-item.tsx b/frontend/app/[locale]/newchat/assistant-ui/conversation-branch-menu-item.tsx index 401ec5506b..eb6a51d43b 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/conversation-branch-menu-item.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/conversation-branch-menu-item.tsx @@ -16,7 +16,10 @@ export function ConversationBranchMenuItem() { return ( branch.branchFromMessage(aui.message.getState().id)} + onSelect={() => { + const selected = aui.message.getState(); + branch.branchFromMessage(selected.id, selected.branchNumber); + }} className="flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm outline-none hover:bg-accent focus:bg-accent data-[disabled]:opacity-50" > diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 4370336266..53e9746542 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -432,7 +432,7 @@ export const Thread: FC = ({ // including the one the selected assistant message answers, then switch to // the branched thread. The original conversation stays untouched. const branchFromMessage = useCallback( - (assistantMessageId: string) => { + (assistantMessageId: string, answerIndex: number) => { if (!conversationId || branchDisabled) return; const turnIndex = computeBranchTurnIndex(messages, assistantMessageId); if (turnIndex === null) { @@ -441,7 +441,7 @@ export const Thread: FC = ({ } setIsBranching(true); conversationService - .branch(conversationId, turnIndex) + .branch(conversationId, turnIndex, assistantMessageId, answerIndex) .then(async (data) => { const newConversationId = Number(data?.conversation_id); if (!Number.isInteger(newConversationId) || newConversationId <= 0) { diff --git a/frontend/app/[locale]/newchat/utils/conversation-branch.ts b/frontend/app/[locale]/newchat/utils/conversation-branch.ts index 1e0c65bff2..ec93084383 100644 --- a/frontend/app/[locale]/newchat/utils/conversation-branch.ts +++ b/frontend/app/[locale]/newchat/utils/conversation-branch.ts @@ -11,9 +11,9 @@ export type BranchMessageLike = { * A turn is one user message plus its answer(s); the turn number therefore * equals the count of user messages from the thread start up to and including * the turn of the selected assistant message. The backend resolves the same - * turn over its active (deduplicated) user rows, so both sides agree even - * after refresh duplicates or regenerated answers. Returns null when the - * message is missing from the list or no user message precedes it. + * turn over its active (deduplicated) user rows. The selected answer ID/version + * is sent separately because a turn can contain regenerated answers. Returns + * null when the message is missing or no user message precedes it. */ export function computeBranchTurnIndex( messages: readonly BranchMessageLike[], diff --git a/frontend/services/conversationService.ts b/frontend/services/conversationService.ts index 2d3bf8c115..3a824e3c72 100644 --- a/frontend/services/conversationService.ts +++ b/frontend/services/conversationService.ts @@ -348,13 +348,27 @@ export const conversationService = { // Branch a conversation into a new one up to and including the given turn // (1-based). Returns the new conversation payload. - async branch(conversationId: number, turnIndex: number) { + async branch( + conversationId: number, + turnIndex: number, + assistantMessageId?: string, + answerIndex?: number + ) { + // Restored messages carry database IDs; live messages carry runtime UUIDs. + const sourceMessageId = + assistantMessageId && /^[1-9]\d*$/.test(assistantMessageId) + ? Number(assistantMessageId) + : undefined; const response = await fetch(API_ENDPOINTS.conversation.branch, { method: "POST", headers: getAuthHeaders(), body: JSON.stringify({ conversation_id: conversationId, turn_index: turnIndex, + source_message_id: Number.isSafeInteger(sourceMessageId) + ? sourceMessageId + : undefined, + answer_index: answerIndex, }), }); diff --git a/frontend/tests/component/conversationBranch.test.tsx b/frontend/tests/component/conversationBranch.test.tsx index 1488b444f0..220577ee6c 100644 --- a/frontend/tests/component/conversationBranch.test.tsx +++ b/frontend/tests/component/conversationBranch.test.tsx @@ -11,7 +11,7 @@ import { ConversationBranchMenuItem } from "@/app/newchat/assistant-ui/conversat import { ConversationBranchContext } from "@/app/newchat/assistant-ui/conversation-branch-context"; const auiMocks = vi.hoisted(() => ({ - getState: vi.fn(() => ({ id: "assistant-2" })), + getState: vi.fn(() => ({ id: "assistant-2", branchNumber: 2 })), })); vi.mock("react-i18next", () => ({ @@ -52,7 +52,10 @@ const makeMessage = (id: string, role: string): BranchMessageLike => ({ const renderMenuItem = ( contextValue: { disabled: boolean; - branchFromMessage: (assistantMessageId: string) => void; + branchFromMessage: ( + assistantMessageId: string, + answerIndex: number + ) => void; } | null ) => render( @@ -114,7 +117,15 @@ test("menu item invokes the branch handler with the aui message id", () => { fireEvent.click(screen.getByTestId("branch-item")); expect(branchFromMessage).toHaveBeenCalledTimes(1); - expect(branchFromMessage).toHaveBeenCalledWith("assistant-2"); + expect(branchFromMessage).toHaveBeenCalledWith("assistant-2", 2); +}); + +test("menu item reads the selected answer version at click time", () => { + const branchFromMessage = vi.fn(); + renderMenuItem({ disabled: false, branchFromMessage }); + auiMocks.getState.mockReturnValueOnce({ id: "123", branchNumber: 1 }); + fireEvent.click(screen.getByTestId("branch-item")); + expect(branchFromMessage).toHaveBeenCalledWith("123", 1); }); test("menu item surfaces the localized branch label", () => { diff --git a/frontend/tests/component/conversationBranchRequest.test.ts b/frontend/tests/component/conversationBranchRequest.test.ts new file mode 100644 index 0000000000..789a2daf6c --- /dev/null +++ b/frontend/tests/component/conversationBranchRequest.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { beforeEach, expect, test, vi } from "vitest"; +import { conversationService } from "@/services/conversationService"; + +const mocks = vi.hoisted(() => ({ fetch: vi.fn() })); +vi.mock("@/lib/auth", () => ({ + getAuthHeaders: () => ({ Authorization: "Bearer test" }), + fetchWithAuth: mocks.fetch, +})); +vi.mock("@/services/api", () => ({ + API_ENDPOINTS: { conversation: { branch: "/conversation/branch" } }, + ApiError: class extends Error {}, + fetchWithErrorHandling: vi.fn(), +})); +vi.mock("i18next", () => ({ t: (key: string) => key })); + +beforeEach(() => { + mocks.fetch.mockReset(); + mocks.fetch.mockResolvedValue({ + json: async () => ({ code: 0, data: { conversation_id: 42 } }), + }); +}); + +test("restored replies send their exact persisted ID and selected version", async () => { + await expect(conversationService.branch(7, 2, "123", 1)).resolves.toEqual({ + conversation_id: 42, + }); + expect(JSON.parse(mocks.fetch.mock.calls[0][1].body)).toEqual({ + conversation_id: 7, + turn_index: 2, + source_message_id: 123, + answer_index: 1, + }); +}); + +test.each(["runtime-uuid", "msg_123", "9007199254740993"])( + "live or unsafe ID %s uses the runtime answer version", + async (id) => { + await conversationService.branch(7, 2, id, 2); + expect(JSON.parse(mocks.fetch.mock.calls[0][1].body)).toEqual({ + conversation_id: 7, + turn_index: 2, + answer_index: 2, + }); + } +); + +test("legacy callers retain the original request body", async () => { + await conversationService.branch(7, 2); + expect(JSON.parse(mocks.fetch.mock.calls[0][1].body)).toEqual({ + conversation_id: 7, + turn_index: 2, + }); +}); + +test("failed branch requests do not return a new conversation", async () => { + mocks.fetch.mockRejectedValue(new Error("Network failed")); + await expect(conversationService.branch(7, 2, "123", 2)).rejects.toThrow( + "Network failed" + ); +}); diff --git a/test/backend/app/test_conversation_management_app.py b/test/backend/app/test_conversation_management_app.py index d4eb5736f4..ce30be0429 100644 --- a/test/backend/app/test_conversation_management_app.py +++ b/test/backend/app/test_conversation_management_app.py @@ -10,6 +10,7 @@ from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient +from contracts.conversation import BranchConversationRequest from contracts.errors.codes import ErrorCode from contracts.errors.exceptions import AppException, ValidationError @@ -893,9 +894,7 @@ async def test_branch_conversation_success(conversation_mocks): "user_id", "tenant_id") conversation_mocks["branch_conversation"].return_value = dummy_response - request_obj = MagicMock() - request_obj.conversation_id = 7 - request_obj.turn_index = 2 + request_obj = BranchConversationRequest(conversation_id=7, turn_index=2) result = await branch_conversation_endpoint(request_obj, authorization=mock_auth_header) @@ -913,9 +912,7 @@ async def test_branch_conversation_invalid_turn(conversation_mocks): conversation_mocks["branch_conversation"].side_effect = ValueError( "turn_index must be a 1-based turn number") - request_obj = MagicMock() - request_obj.conversation_id = 7 - request_obj.turn_index = 0 + request_obj = BranchConversationRequest(conversation_id=7, turn_index=0) with pytest.raises(HTTPException) as exc_info: await branch_conversation_endpoint(request_obj, authorization="Bearer test-token") @@ -934,9 +931,7 @@ async def test_branch_conversation_not_found(conversation_mocks): conversation_mocks["branch_conversation"].side_effect = ConversationNotFoundError( "Conversation does not exist or has been deleted") - request_obj = MagicMock() - request_obj.conversation_id = 7 - request_obj.turn_index = 2 + request_obj = BranchConversationRequest(conversation_id=7, turn_index=2) with pytest.raises(HTTPException) as exc_info: await branch_conversation_endpoint(request_obj, authorization="Bearer test-token") @@ -952,9 +947,7 @@ async def test_branch_conversation_expired_token(conversation_mocks): conversation_mocks["get_current_user_id"].side_effect = TokenExpiredError( "expired") - request_obj = MagicMock() - request_obj.conversation_id = 7 - request_obj.turn_index = 2 + request_obj = BranchConversationRequest(conversation_id=7, turn_index=2) with pytest.raises(HTTPException) as exc_info: await branch_conversation_endpoint(request_obj, authorization="Bearer test-token") @@ -970,9 +963,7 @@ async def test_branch_conversation_failure(conversation_mocks): conversation_mocks["branch_conversation"].side_effect = Exception( "Database error: SELECT * FROM private_table; connection password=secret") - request_obj = MagicMock() - request_obj.conversation_id = 7 - request_obj.turn_index = 2 + request_obj = BranchConversationRequest(conversation_id=7, turn_index=2) with pytest.raises(HTTPException) as exc_info: await branch_conversation_endpoint(request_obj, authorization="Bearer test-token") @@ -981,3 +972,18 @@ async def test_branch_conversation_failure(conversation_mocks): assert exc_info.value.detail == "Failed to branch conversation" assert "private_table" not in exc_info.value.detail assert "secret" not in exc_info.value.detail + + +@pytest.mark.asyncio +@pytest.mark.parametrize("target", [ + {"source_message_id": 123}, + {"answer_index": 2}, + {"source_message_id": 123, "answer_index": 2}, +]) +async def test_branch_conversation_forwards_selected_answer(conversation_mocks, target): + conversation_mocks["get_current_user_id"].return_value = ("user_id", "tenant_id") + conversation_mocks["branch_conversation"].return_value = {"conversation_id": 42} + request_obj = BranchConversationRequest(conversation_id=7, turn_index=2, **target) + result = await branch_conversation_endpoint(request_obj, authorization="Bearer test-token") + assert result.data == {"conversation_id": 42} + conversation_mocks["branch_conversation"].assert_called_once_with(7, 2, "user_id", **target) diff --git a/test/backend/database/test_conversation_branch_selection.py b/test/backend/database/test_conversation_branch_selection.py new file mode 100644 index 0000000000..9cd339e4e2 --- /dev/null +++ b/test/backend/database/test_conversation_branch_selection.py @@ -0,0 +1,130 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Branch boundaries must retain the answer version selected by the user.""" + +from types import SimpleNamespace + +import pytest + +from contracts.conversation import BranchConversationRequest +from management.db.conversation.repository import _select_branch_turn_rows + + +def row(message_id, index, role): + return SimpleNamespace(message_id=message_id, message_index=index, message_role=role) + + +@pytest.fixture(params=[1, 2]) +def regenerated_rows(request): + return [ + row(11, 0, "user"), + row(12, 1, "assistant"), + row(13, request.param, "assistant"), + row(14, 3, "user"), + row(15, 4, "assistant"), + ] + + +@pytest.mark.parametrize("message_id", [12, 13]) +def test_exact_answer_id_selects_either_version(regenerated_rows, message_id): + selected = _select_branch_turn_rows(regenerated_rows, 1, source_message_id=message_id) + assert [item.message_id for item in selected] == [11, message_id] + + +@pytest.mark.parametrize("answer_index,message_id", [(1, 12), (2, 13)]) +def test_live_answer_ordinal_selects_either_version(regenerated_rows, answer_index, message_id): + selected = _select_branch_turn_rows(regenerated_rows, 1, answer_index=answer_index) + assert [item.message_id for item in selected] == [11, message_id] + + +def test_exact_id_takes_precedence_over_runtime_ordinal(regenerated_rows): + selected = _select_branch_turn_rows(regenerated_rows, 1, source_message_id=13, answer_index=1) + assert [item.message_id for item in selected] == [11, 13] + + +def test_legacy_request_and_earlier_turns_keep_latest_answer(regenerated_rows): + assert [item.message_id for item in _select_branch_turn_rows(regenerated_rows, 1)] == [11, 13] + assert [item.message_id for item in _select_branch_turn_rows(regenerated_rows, 2)] == [11, 13, 14, 15] + + +@pytest.mark.parametrize("target", [ + {"source_message_id": 999}, + {"source_message_id": 11}, + {"source_message_id": 15}, + {"answer_index": 3}, + {"answer_index": 0}, +]) +def test_invalid_or_other_turn_answer_is_rejected(regenerated_rows, target): + with pytest.raises(ValueError): + _select_branch_turn_rows(regenerated_rows, 1, **target) + + +@pytest.mark.parametrize("field", ["source_message_id", "answer_index"]) +@pytest.mark.parametrize("value", [0, -1, True, 1.5, "2"]) +def test_request_rejects_invalid_answer_identifiers(field, value): + with pytest.raises(ValueError): + BranchConversationRequest(conversation_id=7, turn_index=1, **{field: value}) + + +def test_refresh_duplicate_user_does_not_create_an_extra_turn(): + rows = [row(11, 0, "user"), row(12, 0, "user"), row(13, 1, "assistant"), + row(14, 2, "user"), row(15, 3, "assistant")] + selected = _select_branch_turn_rows(rows, 2, source_message_id=15) + assert [item.message_id for item in selected] == [12, 13, 14, 15] + + +def test_unanswered_boundary_cannot_borrow_the_next_turn_answer(): + rows = [row(11, 0, "user"), row(12, 1, "user"), row(13, 2, "assistant")] + assert [item.message_id for item in _select_branch_turn_rows(rows, 1)] == [11] + with pytest.raises(ValueError): + _select_branch_turn_rows(rows, 1, source_message_id=13) + + +@pytest.mark.parametrize("selected_id", [14, 15]) +def test_copy_and_child_queries_use_only_selected_answer(monkeypatch, selected_id): + from contextlib import contextmanager + from unittest.mock import MagicMock + + from management.db.conversation import repository as db + from test.backend.database.test_conversation_branch_db import ( + _ExecuteRecorder, _three_turn_rows, _wire, params, sql, + ) + + session = MagicMock() + session.scalar.return_value = 1 + + @contextmanager + def sessions(): + yield session + + monkeypatch.setattr(db, "get_db_session", sessions) + _wire(session, _three_turn_rows()) + recorder = _ExecuteRecorder() + session.execute.side_effect = recorder + result = db.branch_conversation(7, 2, user_id="owner", source_message_id=selected_id) + assert result["branched_message_count"] == 4 + messages = [params(stmt) for stmt in recorder.statements + if getattr(getattr(stmt, "table", None), "name", None) == "conversation_message_t"] + assert [item["message_content"] for item in messages] == ["Q1", "A1", "Q2", "A2-old" if selected_id == 14 else "A2-new"] + for call in session.scalars.call_args_list[3:6]: + assert f"message_id IN (11, 12, 13, {selected_id})" in sql(call.args[0]) + + +def test_invalid_answer_is_rejected_before_any_insert(monkeypatch): + from contextlib import contextmanager + from unittest.mock import MagicMock + + from management.db.conversation import repository as db + from test.backend.database.test_conversation_branch_db import _three_turn_rows, _wire + + session = MagicMock() + session.scalar.return_value = 1 + + @contextmanager + def sessions(): + yield session + + monkeypatch.setattr(db, "get_db_session", sessions) + _wire(session, _three_turn_rows()) + with pytest.raises(ValueError): + db.branch_conversation(7, 2, user_id="owner", source_message_id=999) + assert not any(getattr(call.args[0], "is_insert", False) for call in session.execute.call_args_list) diff --git a/test/backend/services/test_conversation_management_service.py b/test/backend/services/test_conversation_management_service.py index 55591c7dc4..6ec1a7e77b 100644 --- a/test/backend/services/test_conversation_management_service.py +++ b/test/backend/services/test_conversation_management_service.py @@ -6,6 +6,8 @@ import types from unittest.mock import patch +import pytest + # Mock storage client factory and MinIO config before any imports that would initialize MinIO from unittest.mock import MagicMock storage_client_mock = MagicMock() @@ -2107,5 +2109,18 @@ def test_branch_conversation_service_wraps_repository_failure(): branch_conversation_service(7, 2, "user-1") +@pytest.mark.parametrize("target", [ + {"source_message_id": 123}, + {"answer_index": 2}, + {"source_message_id": 123, "answer_index": 2}, +]) +def test_branch_conversation_service_forwards_selected_answer(target): + with patch( + "management.services.conversation.service.branch_conversation", return_value={"conversation_id": 42} + ) as repo: + assert branch_conversation_service(7, 2, "user-1", **target) == {"conversation_id": 42} + repo.assert_called_once_with(7, 2, user_id="user-1", **target) + + if __name__ == '__main__': unittest.main() From 660d203b07bcc8f6849ed35bca7f769d78fb1300 Mon Sep 17 00:00:00 2001 From: ljy Date: Tue, 10 Nov 2026 10:32:49 +0800 Subject: [PATCH 25/26] fix: remove highlight frame when navigating chat history --- .../app/[locale]/newchat/utils/question-directory.ts | 12 ++---------- frontend/tests/component/questionDirectory.test.tsx | 5 +++-- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/frontend/app/[locale]/newchat/utils/question-directory.ts b/frontend/app/[locale]/newchat/utils/question-directory.ts index 8db47c9875..75213c9e1a 100644 --- a/frontend/app/[locale]/newchat/utils/question-directory.ts +++ b/frontend/app/[locale]/newchat/utils/question-directory.ts @@ -162,9 +162,8 @@ export function findMessageElement(messageId: string): HTMLElement | null { /** * Scrolls the thread viewport to the message with the given id. * - * The target is located through the DOM and scrolled natively. The target - * flashes briefly so the landing point is obvious after the smooth scroll - * settles. + * The target is located through the DOM and scrolled natively without adding + * a visual highlight to the message. */ export function scrollThreadToMessage(messageId: string): boolean { const element = findMessageElement(messageId); @@ -174,13 +173,6 @@ export function scrollThreadToMessage(messageId: string): boolean { element.style.scrollMarginTop = "16px"; element.scrollIntoView({ behavior: "smooth", block: "start" }); - // Brief highlight so the jump target is visible after scrolling. - element.style.transition = "box-shadow 0.4s ease-out"; - element.style.boxShadow = "0 0 0 3px rgba(59, 130, 246, 0.45)"; - window.setTimeout(() => { - element.style.boxShadow = ""; - element.style.transition = ""; - }, 1600); return true; } diff --git a/frontend/tests/component/questionDirectory.test.tsx b/frontend/tests/component/questionDirectory.test.tsx index 553f4b8ee5..2546cb4622 100644 --- a/frontend/tests/component/questionDirectory.test.tsx +++ b/frontend/tests/component/questionDirectory.test.tsx @@ -298,7 +298,7 @@ test("collectQuestionDirectory attaches and caps the assistant reply excerpt", ( expect(questions[1]?.replyText).toBe(""); }); -test("scrollThreadToMessage scrolls to and highlights the target message", () => { +test("scrollThreadToMessage scrolls to the target without a highlight frame", () => { const element = document.createElement("div"); element.setAttribute("data-message-id", "msg-42"); const scrollIntoView = vi.fn(); @@ -312,7 +312,8 @@ test("scrollThreadToMessage scrolls to and highlights the target message", () => behavior: "smooth", block: "start", }); - expect(element.style.boxShadow).not.toBe(""); + expect(element.style.boxShadow).toBe(""); + expect(element.style.transition).toBe(""); }); test("scrollThreadToMessage reports missing targets", () => { From 71916cc5d33cddd4ac012b208528550ec4df4c9c Mon Sep 17 00:00:00 2001 From: ljy Date: Fri, 13 Nov 2026 11:02:00 +0800 Subject: [PATCH 26/26] fix(preview): use white DOCX backdrop and default page layout --- .../common/rawStreamPreviewViewer.module.css | 4 + .../common/rawStreamPreviewViewer.tsx | 4 +- frontend/lib/officePreview.ts | 18 ++++ frontend/styles/globals.css | 2 + .../tests/component/docxPageLayout.test.tsx | 88 +++++++++++++++++++ .../component/rawStreamPreviewViewer.test.tsx | 21 +++++ .../bugs/DOCX-PREVIEW-PAGE-LAYOUT-20261113.md | 67 ++++++++++++++ 7 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 frontend/tests/component/docxPageLayout.test.tsx create mode 100644 test-e2e/changes/bugs/DOCX-PREVIEW-PAGE-LAYOUT-20261113.md diff --git a/frontend/components/common/rawStreamPreviewViewer.module.css b/frontend/components/common/rawStreamPreviewViewer.module.css index 218095481e..00da112649 100644 --- a/frontend/components/common/rawStreamPreviewViewer.module.css +++ b/frontend/components/common/rawStreamPreviewViewer.module.css @@ -1,5 +1,9 @@ /* Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. */ +.docxViewport { + background: var(--color-bg-3); +} + .docxPages :global(.docx-wrapper) { background: transparent; padding: 0; diff --git a/frontend/components/common/rawStreamPreviewViewer.tsx b/frontend/components/common/rawStreamPreviewViewer.tsx index 0c8df22cd0..04272ded95 100644 --- a/frontend/components/common/rawStreamPreviewViewer.tsx +++ b/frontend/components/common/rawStreamPreviewViewer.tsx @@ -15,6 +15,7 @@ import { type PreviewAccessReason, } from "@/lib/filePreviewUtils"; import { + applyDocxPageDefaults, neutralizeUnsafeLinks, officeHtmlToText, percentageOf, @@ -367,6 +368,7 @@ export function RawStreamPreviewViewer( // with no scheme filter, so the anchors it produced are disarmed before // a reader can click them. neutralizeUnsafeLinks(body); + applyDocxPageDefaults(body); } else if (kind === "xlsx") { const excelModule = await import("exceljs"); const ExcelJS = (excelModule.default ?? @@ -433,7 +435,7 @@ export function RawStreamPreviewViewer( : renderMessage(t("filePreview.previewFailed"))}

      )} -
      +
      ( + ".docx-wrapper > section.docx" + )) { + if (!page.style.width) page.style.width = "min(210mm, 100%)"; + if (!page.style.height && !page.style.minHeight) { + page.style.minHeight = "297mm"; + } + for (const side of ["top", "right", "bottom", "left"]) { + const property = `padding-${side}`; + if (!page.style.getPropertyValue(property)) { + page.style.setProperty(property, "25.4mm"); + } + } + } +} + /** * Renderer that handles the bytes a document stream returned. * diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css index c13cd2fa2d..10e2cbe590 100644 --- a/frontend/styles/globals.css +++ b/frontend/styles/globals.css @@ -51,6 +51,8 @@ @layer base { :root { + --palette-gray-0: #ffffff; + --color-bg-3: var(--palette-gray-0); --navigation-surface: lab(97.0939% -0.108689 -0.749958); --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; diff --git a/frontend/tests/component/docxPageLayout.test.tsx b/frontend/tests/component/docxPageLayout.test.tsx new file mode 100644 index 0000000000..b4a7e314e7 --- /dev/null +++ b/frontend/tests/component/docxPageLayout.test.tsx @@ -0,0 +1,88 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import JSZip from "jszip"; +import { renderAsync } from "docx-preview"; +import { expect, test } from "vitest"; +import { applyDocxPageDefaults } from "@/lib/officePreview"; + +async function renderDocument(section = "") { + const zip = new JSZip(); + zip.file( + "[Content_Types].xml", + `` + ); + zip.file( + "_rels/.rels", + `` + ); + zip.file( + "word/document.xml", + `你好!这是一个简单的Word文档。${section}` + ); + const body = document.createElement("div"); + const styles = document.createElement("div"); + document.body.append(body, styles); + await renderAsync( + await zip.generateAsync({ type: "arraybuffer" }), + body, + styles, + { inWrapper: true, renderAltChunks: false } + ); + return { + body, + styles, + page: body.querySelector("section.docx")!, + }; +} + +test("gives a real generated DOCX without section metadata a full paper page and margins", async () => { + const { body, styles, page } = await renderDocument(); + try { + expect(page.style.width).toBe(""); + expect(page.style.minHeight).toBe(""); + applyDocxPageDefaults(body); + expect(page.style.width).toBe("min(210mm, 100%)"); + expect(page.style.minHeight).toBe("297mm"); + expect(page.style.paddingTop).toBe("25.4mm"); + expect(page.textContent).toContain("这是一个简单的Word文档"); + } finally { + body.remove(); + styles.remove(); + } +}); + +test("preserves explicit landscape dimensions and zero/custom margins from the document", async () => { + const { body, styles, page } = await renderDocument( + '' + ); + try { + const original = page.getAttribute("style"); + expect(page.style.width).not.toBe(""); + expect(page.style.minHeight).not.toBe(""); + applyDocxPageDefaults(body); + expect(page.getAttribute("style")).toBe(original); + } finally { + body.remove(); + styles.remove(); + } +}); + +test("fills only missing values and leaves non-document content untouched", () => { + const body = document.createElement("div"); + body.innerHTML = + '

      One

      Two

      Other
      '; + applyDocxPageDefaults(body); + const pages = body.querySelectorAll("section.docx"); + expect(pages[0].style.width).toBe("500px"); + expect(pages[0].style.height).toBe("700px"); + expect(pages[0].style.minHeight).toBe(""); + expect(pages[0].style.paddingTop).toBe("0px"); + expect(pages[0].style.paddingRight).toBe("25.4mm"); + expect(pages[1].style.minHeight).toBe("297mm"); + expect( + body.querySelector("section:not(.docx)")!.style.cssText + ).toBe("width: 300px;"); + const once = body.innerHTML; + applyDocxPageDefaults(body); + expect(body.innerHTML).toBe(once); +}); diff --git a/frontend/tests/component/rawStreamPreviewViewer.test.tsx b/frontend/tests/component/rawStreamPreviewViewer.test.tsx index 9ad3ed8f7f..a3a47c376d 100644 --- a/frontend/tests/component/rawStreamPreviewViewer.test.tsx +++ b/frontend/tests/component/rawStreamPreviewViewer.test.tsx @@ -139,6 +139,27 @@ describe("RawStreamPreviewViewer", () => { ).not.toBeNull(); }); + it("applies paper defaults to a DOCX preview with no section layout", async () => { + vi.mocked(fetchPreviewBlobWithMeta).mockResolvedValue( + servedDocument("simple_document.docx") as never + ); + vi.mocked(renderAsync).mockImplementationOnce(async (_data, body) => { + body.innerHTML = + '

      Short generated document

      '; + return {} as never; + }); + render( + + ); + expect(await screen.findByText("Short generated document")).toBeVisible(); + const page = document.querySelector("section.docx")!; + expect(page.style.minHeight).toBe("297mm"); + expect(page.style.paddingTop).toBe("25.4mm"); + }); + it("anchors a rotated pptx group around its own centre", () => { // Group centre: (100 + 400 / 2) / 960 and (50 + 200 / 2) / 540. expect( diff --git a/test-e2e/changes/bugs/DOCX-PREVIEW-PAGE-LAYOUT-20261113.md b/test-e2e/changes/bugs/DOCX-PREVIEW-PAGE-LAYOUT-20261113.md new file mode 100644 index 0000000000..607046d623 --- /dev/null +++ b/test-e2e/changes/bugs/DOCX-PREVIEW-PAGE-LAYOUT-20261113.md @@ -0,0 +1,67 @@ +# DOCX preview background and missing page layout — 2026-11-13 + +## Reproduction and scope + +The conversation artifact viewer opened a generated `simple_document.docx` +containing three paragraphs and no `w:sectPr`, page size or page margins. The +pasted XML demonstrates the missing section metadata. The shared viewer used +`bg-gray-200` around a renderer page that collapsed to its text height. This +produced a small white text strip and a large gray panel, as in the user image. + +`RawStreamPreviewViewer` owns byte retrieval, format selection, Office rendering +and unsafe-link hardening; chat artifact panes reuse it. The document is not +rewritten or uploaded by this fix. No deployment or environment data is changed. + +## Expected behavior and implementation + +- The DOCX viewport uses the shared white sliding-panel background token. + Existing transparent renderer wrapper and shadow removal remain scoped. +- Missing page width/height use an A4 fallback; missing padding uses one-inch + page margins. The fallback width fits a narrow panel. Explicit inline page + dimensions, orientation, height and zero/custom margins are preserved. +- Changes are confined to actual `.docx-wrapper > section.docx` pages. Reapplying + defaults is idempotent; adjacent content is untouched. +- PDF, spreadsheet and presentation rendering are unchanged. Existing unsafe + link neutralization and disabled HTML altChunk rendering are retained. +- Document-defined page backgrounds are not replaced; only the viewer backdrop + changes to white. + +## Evidence and verification boundaries + +Traditional UT decision: strengthen the shared viewer component test and add +targeted DOCX tests; reuse existing sanitizer and file-kind unit tests. There is +no Python/backend behavior change requiring Python UT. Existing UT/formal assets +are preserved. No additional D1 quota or formal execution PASS is claimed. + +- `frontend/tests/component/docxPageLayout.test.tsx`: real JSZip OOXML and real + docx-preview rendering prove missing metadata, landscape/custom margins, + partially specified layouts, multiple pages, idempotence and sibling isolation. +- `frontend/tests/component/rawStreamPreviewViewer.test.tsx`: proves defaults + are called after rendering; existing altChunk/link and PPTX group assertions + remain active. +- `frontend/tests/component/officePreviewSanitizer.test.tsx`: existing 19 tests. +- `frontend/tests/officePreview.test.ts`: existing six native Node tests. + +Results: 26 component tests and six native Node tests passed. Component tests ran +in an isolated temporary directory with Vitest 4.1.12, React 19.2.3 and the +repository's docx-preview 0.4.1. This is not the complete frontend dependency set. + +Real browser checks used an independent headless Edge profile and temporary HTML +produced by the real DOCX renderer and application defaults/styles. Default and +narrow views had white viewport/page backgrounds and a full paper height; +landscape dimensions and zero top margin were preserved. The default view was +also visually inspected. These fixture checks are separate from formal D4 and +do not establish the deployed application's end-to-end behavior. + +The sibling SPEC repository/UI source is unavailable; SPEC integration and formal +ownership mapping remain incomplete. Full application type-check/build and live +conversation verification remain unverified because dependencies are not +installed in the checkout and this is a code-only fix. Formal asset validation +requires the missing local openpyxl dependency; no formal acceptance completion +is claimed. Source copyright, formatting and diff whitespace checks apply. + +Copyright and `git diff --check` passed. Targeted TypeScript, module CSS and tests +passed Prettier. The global stylesheet retains pre-existing formatting warnings +in its accordion keyframes and scrollbar selector; formatting a temporary copy +confirmed that neither added token declaration caused those warnings. Those +unrelated sections were intentionally not rewritten.