Compare commits

...

5 Commits

Author SHA1 Message Date
mt
c17ba298fb fix(ui): 发送按钮 SVG 属性改为 JSX 驼峰格式
- stroke-width → strokeWidth, stroke-linecap → strokeLinecap, stroke-linejoin → strokeLinejoin
2026-06-11 09:50:32 +08:00
mt
7d5e25e325 feat(input): 附件引用弹窗新增搜索过滤框
- DropdownMenu 内新增 Input 搜索框,autoFocus
- filterMentionCandidates 同时受 mentionQuery 和 mentionSearchText 双重过滤
- 搜索时重置高亮索引避免越界
- 上/下箭头将焦点交还给候选列表复用键盘导航
- 所有关闭路径统一重置搜索文字
- 弹窗打开时自动 refetch 最新文件列表
2026-06-11 09:50:29 +08:00
mt
f3c160f103 feat(artifact): artifact markdown 表格复用 CopyButton
- ArtifactFilePreview 中 Streamdown 的 table 组件覆盖为 MarkdownTable
- artifact 区表格复制/下载行为与聊天区一致
2026-06-11 09:50:22 +08:00
mt
407618baf0 refactor(table): 表格复制按钮复用 CopyButton,下载改为 markdown+BOM
- MarkdownTable 导出为公共组件
- 复制按钮直接复用 CopyButton,行为与 iframe 复制一致
- 表格数据通过 tableRef 在 render 阶段同步计算
- useLayoutEffect 确保首次渲染后即可获取正确数据
- 下载按钮改为 markdown 格式 (.md),UTF-8 with BOM
- 移除废弃的 escapeCsvCell / toCsvTable
2026-06-11 09:50:19 +08:00
mt
1637a0e71c fix(copy): copyToClipboard 始终发送 postMessage
- 移除 copyToClipboard 内独立的 isInIframe 判断
- 改为始终调用 sendToParent,由 sendToParent 内部统一判断 iframe 环境
- 与 openSkillDialog 等其他 iframe 通信保持一致
2026-06-11 09:50:15 +08:00
5 changed files with 132 additions and 101 deletions

View File

@ -1159,7 +1159,7 @@ export const PromptInputSubmit = ({
// let Icon = <ArrowUpIcon className="size-4" />; // let Icon = <ArrowUpIcon className="size-4" />;
let Icon = <svg width="12" height="16" viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg"> let Icon = <svg width="12" height="16" viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.75 14.75V0.75M0.75 5.75L5.75 0.75L10.75 5.75" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path d="M5.75 14.75V0.75M0.75 5.75L5.75 0.75L10.75 5.75" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>; </svg>;

View File

@ -14,6 +14,7 @@ import {
useState, useState,
type CSSProperties, type CSSProperties,
type ComponentProps, type ComponentProps,
type ComponentPropsWithoutRef,
type HTMLAttributes, type HTMLAttributes,
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -40,6 +41,7 @@ import { CodeEditor } from "@/components/workspace/code-editor";
import { useArtifactContent } from "@/core/artifacts/hooks"; import { useArtifactContent } from "@/core/artifacts/hooks";
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils"; import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { MarkdownTable } from "@/components/workspace/messages/markdown-content";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files"; import { checkCodeFile, getFileName } from "@/core/utils/files";
import { useMarkdownDownload } from "@/core/utils/markdown-download"; import { useMarkdownDownload } from "@/core/utils/markdown-download";
@ -909,11 +911,26 @@ export function ArtifactFilePreview({
threadId: string; threadId: string;
filepath?: string; filepath?: string;
}) { }) {
const { t } = useI18n();
const zoomScale = zoom / 100; const zoomScale = zoom / 100;
const normalizedContent = useMemo(() => { const normalizedContent = useMemo(() => {
return rewriteArtifactImagePaths(content ?? "", threadId, filepath); return rewriteArtifactImagePaths(content ?? "", threadId, filepath);
}, [content, threadId, filepath]); }, [content, threadId, filepath]);
const streamdownComponents = useMemo(
() => ({
a: CitationLink,
table: (props: ComponentPropsWithoutRef<"table">) => (
<MarkdownTable
copyLabel={t.clipboard.copyToClipboard}
downloadLabel={t.common.download}
{...props}
/>
),
}),
[t.clipboard.copyToClipboard, t.common.download],
);
if (language === "markdown") { if (language === "markdown") {
return ( return (
<div <div
@ -923,7 +940,7 @@ export function ArtifactFilePreview({
<Streamdown <Streamdown
className="w-full" className="w-full"
{...streamdownPlugins} {...streamdownPlugins}
components={{ a: CitationLink }} components={streamdownComponents}
> >
{normalizedContent} {normalizedContent}
</Streamdown> </Streamdown>

View File

@ -70,6 +70,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Tag } from "@/components/ui/tag"; import { Tag } from "@/components/ui/tag";
import { useReferenceFiles } from "@/core/artifacts/references"; import { useReferenceFiles } from "@/core/artifacts/references";
import { urlOfArtifact } from "@/core/artifacts/utils"; import { urlOfArtifact } from "@/core/artifacts/utils";
@ -286,6 +287,7 @@ export function InputBox({
const [memoryPanelOpen, setMemoryPanelOpen] = useState(false); const [memoryPanelOpen, setMemoryPanelOpen] = useState(false);
const [references, setReferences] = useState<PromptInputReference[]>([]); const [references, setReferences] = useState<PromptInputReference[]>([]);
const [mentionQuery, setMentionQuery] = useState(""); const [mentionQuery, setMentionQuery] = useState("");
const [mentionSearchText, setMentionSearchText] = useState("");
const [mentionOpen, setMentionOpen] = useState(false); const [mentionOpen, setMentionOpen] = useState(false);
const [activeMentionIndex, setActiveMentionIndex] = useState(0); const [activeMentionIndex, setActiveMentionIndex] = useState(0);
const [mentionRange, setMentionRange] = useState<{ const [mentionRange, setMentionRange] = useState<{
@ -294,7 +296,15 @@ export function InputBox({
} | null>(null); } | null>(null);
const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false); const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false);
const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false); const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false);
const { data: referenceFilesData } = useReferenceFiles(threadIdFromProps); const { data: referenceFilesData, refetch: refetchReferenceFiles } =
useReferenceFiles(threadIdFromProps);
// 打开附件引用弹窗时刷新数据
useEffect(() => {
if (mentionOpen) {
refetchReferenceFiles();
}
}, [mentionOpen, refetchReferenceFiles]);
// Welcome 态下禁用收缩,始终保持展开 // Welcome 态下禁用收缩,始终保持展开
const effectiveIsFocused = const effectiveIsFocused =
@ -478,15 +488,24 @@ export function InputBox({
const filteredMentionCandidates = useMemo(() => { const filteredMentionCandidates = useMemo(() => {
const query = mentionQuery.trim().toLowerCase(); const query = mentionQuery.trim().toLowerCase();
if (!query) { const search = mentionSearchText.trim().toLowerCase();
return mentionCandidates; let result = mentionCandidates;
if (query) {
result = result.filter((candidate) =>
`${candidate.filename} ${candidate.typeLabel} ${candidate.pathTail}`
.toLowerCase()
.includes(query),
);
} }
return mentionCandidates.filter((candidate) => if (search) {
`${candidate.filename} ${candidate.typeLabel} ${candidate.pathTail}` result = result.filter((candidate) =>
.toLowerCase() `${candidate.filename} ${candidate.typeLabel} ${candidate.pathTail}`
.includes(query), .toLowerCase()
); .includes(search),
}, [mentionCandidates, mentionQuery]); );
}
return result;
}, [mentionCandidates, mentionQuery, mentionSearchText]);
const handleModelSelect = useCallback( const handleModelSelect = useCallback(
(model_name: string) => { (model_name: string) => {
onContextChange?.({ onContextChange?.({
@ -594,6 +613,7 @@ export function InputBox({
}); });
} }
setMentionQuery(""); setMentionQuery("");
setMentionSearchText("");
setMentionOpen(false); setMentionOpen(false);
setActiveMentionIndex(0); setActiveMentionIndex(0);
setMentionRange(null); setMentionRange(null);
@ -634,6 +654,7 @@ export function InputBox({
if (!token) { if (!token) {
setMentionOpen(false); setMentionOpen(false);
setMentionQuery(""); setMentionQuery("");
setMentionSearchText("");
setActiveMentionIndex(0); setActiveMentionIndex(0);
setMentionRange(null); setMentionRange(null);
return; return;
@ -683,6 +704,7 @@ export function InputBox({
} }
} else if (event.key === "Escape") { } else if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
setMentionSearchText("");
setMentionOpen(false); setMentionOpen(false);
setMentionRange(null); setMentionRange(null);
} }
@ -861,6 +883,7 @@ export function InputBox({
onOpenChange={(open) => { onOpenChange={(open) => {
setMentionOpen(open); setMentionOpen(open);
if (!open) { if (!open) {
setMentionSearchText("");
setMentionRange(null); setMentionRange(null);
} }
}} }}
@ -888,7 +911,30 @@ export function InputBox({
<DropdownMenuLabel className="p-0 text-sm text-ws-fg-primary"> <DropdownMenuLabel className="p-0 text-sm text-ws-fg-primary">
{t.inputBox.addReference} {t.inputBox.addReference}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator className="mx-0 mt-[20px] mb-0" /> <Input
className="mt-3 h-8 text-sm"
placeholder="搜索文件..."
value={mentionSearchText}
autoFocus
onChange={(e) => {
setMentionSearchText(e.target.value);
setActiveMentionIndex(0);
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
// 将焦点交还给 dropdown让现有的键盘导航逻辑处理
const items =
document.querySelectorAll<HTMLElement>(
'[data-testid="mention-candidate-item"]',
);
if (items.length > 0) {
(items[0] as HTMLElement).focus();
}
}
}}
/>
<DropdownMenuSeparator className="mx-0 mt-3 mb-0" />
<DropdownMenuGroup className="flex min-h-0 flex-col gap-[10px] px-0"> <DropdownMenuGroup className="flex min-h-0 flex-col gap-[10px] px-0">
<ScrollArea className="h-[320px] pt-[20px]" hideScrollbar={false}> <ScrollArea className="h-[320px] pt-[20px]" hideScrollbar={false}>
{filteredMentionCandidates.map((candidate, index) => { {filteredMentionCandidates.map((candidate, index) => {

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react"; import { DownloadIcon } from "lucide-react";
import { useCallback, useMemo, useState, type MouseEvent } from "react"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { import type {
AnchorHTMLAttributes, AnchorHTMLAttributes,
ComponentPropsWithoutRef, ComponentPropsWithoutRef,
@ -14,7 +14,9 @@ import {
} from "@/components/ai-elements/message"; } from "@/components/ai-elements/message";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
import { cn, copyToClipboard } from "@/lib/utils"; import { CopyButton } from "@/components/workspace/copy-button";
import { Tooltip } from "@/components/workspace/tooltip";
import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link"; import { CitationLink } from "../citations/citation-link";
@ -56,21 +58,9 @@ function toMarkdownTable(data: TableData): string {
return [headerLine, dividerLine, ...rowLines].join("\n"); return [headerLine, dividerLine, ...rowLines].join("\n");
} }
function escapeCsvCell(value: string): string { function downloadMarkdownFile(content: string, filename: string) {
if (!/[",\n\r]/.test(value)) return value;
return `"${value.replaceAll('"', '""')}"`;
}
function toCsvTable(data: TableData): string {
if (data.headers.length === 0) return "";
return [data.headers, ...data.rows]
.map((row) => row.map(escapeCsvCell).join(","))
.join("\n");
}
function downloadCsvFile(content: string, filename: string) {
const blob = new Blob(["\uFEFF", content], { const blob = new Blob(["\uFEFF", content], {
type: "text/csv;charset=utf-8", type: "text/markdown;charset=utf-8",
}); });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const anchor = document.createElement("a"); const anchor = document.createElement("a");
@ -80,58 +70,43 @@ function downloadCsvFile(content: string, filename: string) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
function MarkdownTable({ export function MarkdownTable({
className, className,
children, children,
copyLabel, copyLabel: _copyLabel,
downloadLabel, downloadLabel,
...props ...props
}: ComponentPropsWithoutRef<"table"> & { }: ComponentPropsWithoutRef<"table"> & {
copyLabel: string; copyLabel: string;
downloadLabel: string; downloadLabel: string;
}) { }) {
const [copied, setCopied] = useState(false); const tableRef = useRef<HTMLTableElement>(null);
const [, forceUpdate] = useState(0);
const getTableData = useCallback((event: MouseEvent<HTMLButtonElement>) => { // 首次 mount 后 tableRef 才被赋值,用 useLayoutEffect 在 paint 前强制刷新
const wrapper = event.currentTarget.closest( useLayoutEffect(() => {
'[data-streamdown="table-wrapper"]', forceUpdate((n) => n + 1);
);
const table = wrapper?.querySelector("table");
if (!(table instanceof HTMLTableElement)) return null;
return parseTableData(table);
}, []); }, []);
const handleCopy = useCallback( // 在 render 阶段直接从 DOM ref 计算,不依赖 effect 异步更新
async (event: MouseEvent<HTMLButtonElement>) => { // tableRef 在上一次渲染的 commit 阶段已设置,本次渲染可用
const data = getTableData(event); const clipboardData = (() => {
if (!data) return; const table = tableRef.current;
if (!table) return "";
const data = parseTableData(table);
if (!data) return "";
return toMarkdownTable(data);
})();
const markdown = toMarkdownTable(data); const handleDownload = useCallback(() => {
if (!markdown) return; const table = tableRef.current;
if (!table) return;
try { const data = parseTableData(table);
await copyToClipboard(markdown); if (!data) return;
setCopied(true); const markdown = toMarkdownTable(data);
window.setTimeout(() => setCopied(false), 2000); if (!markdown) return;
} catch { downloadMarkdownFile(markdown, "table.md");
// no-op }, []);
}
},
[getTableData],
);
const handleDownload = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
const data = getTableData(event);
if (!data) return;
const csv = toCsvTable(data);
if (!csv) return;
downloadCsvFile(csv, "table.csv");
},
[getTableData],
);
return ( return (
<div <div
@ -139,25 +114,20 @@ function MarkdownTable({
data-streamdown="table-wrapper" data-streamdown="table-wrapper"
> >
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<button <CopyButton className="text-muted-foreground hover:bg-transparent hover:text-foreground cursor-pointer p-1 transition-all" clipboardData={clipboardData} />
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all" <Tooltip content={downloadLabel}>
onClick={handleCopy} <button
title={copyLabel} className="h-[32px] w-[32px] text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all"
type="button" onClick={handleDownload}
> type="button"
{copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />} >
</button> <DownloadIcon size={16} />
<button </button>
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all" </Tooltip>
onClick={handleDownload}
title={downloadLabel}
type="button"
>
<DownloadIcon size={14} />
</button>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table <table
ref={tableRef}
className={cn( className={cn(
"border-border w-full border-collapse border", "border-border w-full border-collapse border",
className, className,

View File

@ -18,29 +18,27 @@ export const externalLinkClassNoUnderline = "text-primary hover:underline";
* In iframe context, sends message to parent window to handle clipboard operation. * In iframe context, sends message to parent window to handle clipboard operation.
*/ */
export async function copyToClipboard(text: string): Promise<void> { export async function copyToClipboard(text: string): Promise<void> {
const isInIframe = window.self !== window.top;
const message = { const message = {
type: POST_MESSAGE_TYPES.COPY_TO_CLIPBOARD, type: POST_MESSAGE_TYPES.COPY_TO_CLIPBOARD,
text, text,
} as const; } as const;
if (isInIframe) { console.log("[copyToClipboard] called, text length:", text.length);
try {
// Request parent window to copy // 始终发送 postMessage由 sendToParent 内部判断是否为 iframe 环境
sendToParent(message); // 与 openSkillDialog 等其他 iframe 通信保持一致
console.log( try {
"[copyToClipboard] iframe mode → postMessage to parent", sendToParent(message);
message, } catch {
); // no-op
return;
} catch (error) {
console.warn("[copyToClipboard] iframe postMessage failed", error);
}
} }
// Direct clipboard access when not in iframe // 同时也尝试直接写剪贴板(非 iframe 场景兜底)
console.log("[copyToClipboard] direct mode", message); try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
} catch {
// no-op: 在 iframe 环境下由父窗口处理
}
} }
/** /**