Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions web/src/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { copyText } from "./clipboard";
import { copyAndSay, copyText } from "./clipboard";
import { S } from "./i18n";
import { toast } from "./toast";

afterEach(() => vi.unstubAllGlobals());
vi.mock("./toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});

interface FakeArea {
value: string;
Expand Down Expand Up @@ -98,3 +104,21 @@ describe("copyText", () => {
}
});
});

describe("copyAndSay", () => {
it("says what was copied when the copy worked", async () => {
vi.stubGlobal("navigator", {});
page(true);
expect(await copyAndSay("ingest-token", "Token copied")).toBe(true);
expect(toast.success).toHaveBeenCalledWith("Token copied");
expect(toast.error).not.toHaveBeenCalled();
});

it("says it could not copy when neither way worked", async () => {
vi.stubGlobal("navigator", {});
page(false);
expect(await copyAndSay("ingest-token", "Token copied")).toBe(false);
expect(toast.error).toHaveBeenCalledWith(S.toast.copyFailed);
expect(toast.success).not.toHaveBeenCalled();
});
});
12 changes: 12 additions & 0 deletions web/src/clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { S } from "./i18n";
import { toast } from "./toast";

/** 把一段文字放进剪贴板,交回放没放成。
*
* `navigator.clipboard` 只在安全上下文里有:https,或者 localhost。在局域网里用
Expand Down Expand Up @@ -35,3 +38,12 @@ export async function copyText(text: string): Promise<boolean> {
focused?.focus?.();
}
}

/** 复制,再用一条提示说出结果:成了说 `copied`,不成说复制不了。
* 各页的复制按钮都走这里;对话里那几个自己换图标,只在失败时提示 */
export async function copyAndSay(text: string, copied: string): Promise<boolean> {
const ok = await copyText(text);
if (ok) toast.success(copied);
else toast.error(S.toast.copyFailed);
return ok;
}
3 changes: 2 additions & 1 deletion web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ export const en = {
created: "Created",
deleted: "Deleted",
added: "Added to the ontology",
// 哪一处复制不成都说这一句:页面没有剪贴板 API,浏览器也不让用老办法复制
copyFailed: "Could not copy. Select the text and copy it by hand.",
},
account: {
/* 账户区字标:Persona——你在这座城里的身份面具 */
Expand Down Expand Up @@ -875,7 +877,6 @@ export const en = {
copyAnswer: "Copy answer",
copyCode: "Copy code",
copied: "Copied",
copyFailed: "Could not copy. Select the text and copy it by hand.",
},
graph: {
// 还没判出类型的实体(0009)。不是一个类,是"这一格还空着"
Expand Down
2 changes: 1 addition & 1 deletion web/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export const zh: Strings = {
created: "已创建",
deleted: "已删除",
added: "已加入本体",
copyFailed: "无法复制,请选中文字后手动复制。",
},
account: {
brand: "Utopia Persona",
Expand Down Expand Up @@ -791,7 +792,6 @@ export const zh: Strings = {
copyAnswer: "复制回答",
copyCode: "复制代码",
copied: "已复制",
copyFailed: "无法复制,请选中文字后手动复制。",
},
graph: {
untyped: "未分类",
Expand Down
3 changes: 2 additions & 1 deletion web/src/pages/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
import { S } from "../i18n";
import { rehypeCitations } from "../citations";
import { followsBottom } from "../chatScroll";
import { copyAndSay } from "../clipboard";
import { chatMarkdown, SourceList, SourcesProvider } from "./chatCitations";
import { CopyButton } from "./chatCopy";
import { toast } from "../toast";
Expand Down Expand Up @@ -731,7 +732,7 @@ export function Chat() {
{S.ask.rename}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => navigator.clipboard?.writeText(c.title || "")}
onSelect={() => copyAndSay(c.title || "", S.ask.copied)}
>
{S.ask.copyTitle}
</DropdownMenuItem>
Expand Down
14 changes: 4 additions & 10 deletions web/src/pages/Library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "../sourceKinds";
import { useKb, useKbId } from "../kb";
import { toast } from "../toast";
import { copyAndSay } from "../clipboard";
import {
Button,
Checkbox,
Expand Down Expand Up @@ -1151,12 +1152,7 @@ function ErrorModal({
<Button
variant="secondary"
size="sm"
onClick={() =>
navigator.clipboard
.writeText(text)
.then(() => toast.success(S.library.errorCopied))
.catch(() => {})
}
onClick={() => copyAndSay(text, S.library.errorCopied)}
>
{S.library.copyError}
</Button>
Expand Down Expand Up @@ -1198,8 +1194,6 @@ function TokenModal({
});
const token = tokenQuery.data?.ingest_token ?? null;

const copy = (text: string, msg: string) =>
navigator.clipboard.writeText(text).then(() => toast.success(msg)).catch(() => {});
const endpoint = `${location.origin}/api/v1/sources/${sourceId}/ingest`;
return (
<Dialog
Expand Down Expand Up @@ -1239,7 +1233,7 @@ function TokenModal({
variant="secondary"
className="h-auto w-full justify-start whitespace-normal break-all py-3 text-left font-mono"
title={S.library.copyEndpoint}
onClick={() => copy(token, S.library.tokenCopied)}
onClick={() => copyAndSay(token, S.library.tokenCopied)}
>
{token}
</Button>
Expand All @@ -1252,7 +1246,7 @@ function TokenModal({
variant="secondary"
className="h-auto w-full justify-start whitespace-pre-wrap break-all py-2 text-left font-mono text-ink-2"
title={S.library.copyEndpoint}
onClick={() => copy(endpoint, S.library.endpointCopied)}
onClick={() => copyAndSay(endpoint, S.library.endpointCopied)}
>
POST {endpoint}
{"\n"}Authorization: Bearer &lt;token&gt;
Expand Down
14 changes: 5 additions & 9 deletions web/src/pages/Tokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,11 @@ import {
Tr,
buttonLike,} from "../ui";
import { toast } from "../toast";
import { copyAndSay } from "../clipboard";

const ymd = (iso: string) => iso.slice(0, 10);
const EXPIRY_CHOICES = [30, 90, 365, 0] as const;

function copyText(text: string) {
navigator.clipboard
?.writeText(text)
.then(() => toast.success(S.account.copied))
.catch(() => {});
}

/** Claude Code / Claude Desktop 一族的 Streamable HTTP 写法。每个库一个端点(0014:
* 令牌限定到库,端点也按库分),所以片段里要把库选出来 */
function mcpSnippet(kbId: string, kbName: string, token: string): string {
Expand All @@ -64,8 +58,10 @@ function CopyButton({ text, small }: { text: string; small?: boolean }) {
return (
<Button variant="secondary" size="sm"
className={buttonLike("ghost", small ? "sm" : "md")}
onClick={() => {
copyText(text);
onClick={async () => {
// 复制成了才换成「已复制」。从前先换字再复制,在 http 打开的页面上什么也没
// 复制,按钮也照样说已复制
if (!(await copyAndSay(text, S.account.copied))) return;
setDone(true);
setTimeout(() => setDone(false), 1500);
}}
Expand Down
2 changes: 1 addition & 1 deletion web/src/pages/chatCopy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function CopyButton({ label, text }: { label: string; text: () => string
label={copied ? S.ask.copied : label}
onClick={async () => {
if (await copyText(text())) setCopied(true);
else toast.error(S.ask.copyFailed);
else toast.error(S.toast.copyFailed);
}}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
Expand Down
56 changes: 36 additions & 20 deletions web/tests/chat-view.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -808,29 +808,29 @@ test(
await f.close();
}
});
// A page served over https or localhost has navigator.clipboard
const withApi = () => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: async (text) => { window.__copied = text; } },
});
};
// A base opened over plain http on a local network does not; record what
// execCommand("copy") would copy: the selected text of a text area
const withoutApi = () => {
Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
document.execCommand = (command) => {
const area = [...document.querySelectorAll("textarea")].find(
(a) => a.selectionEnd > a.selectionStart,
);
if (command !== "copy" || !area) return false;
window.__copied = area.value.slice(area.selectionStart, area.selectionEnd);
return true;
};
};
await t.test("an answer and each of its code blocks can be copied, with or without the Clipboard API", async () => {
const sql = "SELECT month, sum(amount) AS revenue FROM orders GROUP BY month";
const answer = `Revenue by month:\n\n\`\`\`sql\n${sql}\n\`\`\`\n\nIt grew every month.`;
// A page served over https or localhost has navigator.clipboard
const withApi = () => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: async (text) => { window.__copied = text; } },
});
};
// A base opened over plain http on a local network does not; record what
// execCommand("copy") would copy: the selected text of a text area
const withoutApi = () => {
Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
document.execCommand = (command) => {
const area = [...document.querySelectorAll("textarea")].find(
(a) => a.selectionEnd > a.selectionStart,
);
if (command !== "copy" || !area) return false;
window.__copied = area.value.slice(area.selectionStart, area.selectionEnd);
return true;
};
};
for (const init of [withApi, withoutApi]) {
const f = await open(
"/kb/one/chat/a",
Expand Down Expand Up @@ -901,5 +901,21 @@ test(
await f.close();
}
});
await t.test("a conversation's title is copied from its menu, with or without the Clipboard API", async () => {
for (const init of [withApi, withoutApi]) {
const f = await open("/kb/one/chat/b", undefined, init);
try {
await f.page.getByText("Answer beta", { exact: true }).waitFor();
await f.page.getByRole("button", { name: "More", exact: true }).first().click();
await f.page.getByRole("menuitem", { name: "Copy title" }).click();
// The copy says so, and the title is what reached the clipboard
await f.page.getByText("Copied", { exact: true }).waitFor();
assert.equal(await f.page.evaluate(() => window.__copied), "Conversation a", init.name);
assert.deepEqual(f.errors, [], init.name);
} finally {
await f.close();
}
}
});
},
);