diff --git a/README.md b/README.md
index 0bc4cb5..4d6c998 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ Your AI conversations deserve a better clipboard.
[](https://gemini.google.com)
[](https://chat.deepseek.com)
[](https://grok.com)
+~[](https://www.kimi.ai)
[](https://github.com)
@@ -167,12 +168,12 @@ Then load the unpacked extension from `apps/browser-extension/dist/chrome-mv3-de
### Usage
-1. Navigate to any supported platform (ChatGPT, Claude, Gemini, DeepSeek, Grok, GitHub)
+1. Navigate to any supported platform (ChatGPT, Claude, Gemini, DeepSeek, Grok, Kimi, GitHub)
2. Start or open a conversation
3. Click the **CtxPort copy button** that appears in the chat, or press `Alt+Shift+C`
4. Paste your Context Bundle wherever you need it
-For sidebar list copy: hover over any conversation in the left sidebar to reveal the copy icon.
+For sidebar list copy: use the copy icon shown next to conversations in the left sidebar.
---
@@ -183,6 +184,7 @@ For sidebar list copy: hover over any conversation in the left sidebar to reveal
- [x] Gemini support
- [x] DeepSeek support
- [x] Grok support
+- [x] Kimi support
- [x] GitHub Issues & PRs support
- [x] Sidebar list copy
- [x] Multiple copy formats
diff --git a/README_cn.md b/README_cn.md
index 3904c37..6c660ae 100644
--- a/README_cn.md
+++ b/README_cn.md
@@ -22,6 +22,7 @@
[](https://gemini.google.com)
[](https://chat.deepseek.com)
[](https://grok.com)
+[](https://www.kimi.ai)
[](https://github.com)
@@ -167,12 +168,12 @@ pnpm dev:ext
### 使用方法
-1. 打开任何支持的平台(ChatGPT, Claude, Gemini, DeepSeek, Grok, GitHub)
+1. 打开任何支持的平台(ChatGPT, Claude, Gemini, DeepSeek, Grok, Kimi, GitHub)
2. 开始或打开一段对话
3. 点击对话中出现的 **CtxPort 复制按钮**,或按 `Alt+Shift+C`
4. 将 Context Bundle 粘贴到任何你需要的地方
-侧边栏列表复制:悬停在左侧边栏的任意对话上,即可看到复制图标。
+侧边栏列表复制:点击左侧边栏对话旁显示的复制图标。
---
@@ -183,6 +184,7 @@ pnpm dev:ext
- [x] Gemini 支持
- [x] DeepSeek 支持
- [x] Grok 支持
+- [x] Kimi 支持
- [x] GitHub Issues & PRs 支持
- [x] 侧边栏列表复制
- [x] 多种复制格式
diff --git a/apps/browser-extension/src/components/list-copy-icon.tsx b/apps/browser-extension/src/components/list-copy-icon.tsx
index 446252a..ea7a649 100644
--- a/apps/browser-extension/src/components/list-copy-icon.tsx
+++ b/apps/browser-extension/src/components/list-copy-icon.tsx
@@ -88,13 +88,12 @@ export function ListCopyIcon({ conversationId, onToast }: ListCopyIconProps) {
setTimeout(() => {
if (mountedRef.current) setState("idle");
}, 1500);
- } catch (_err) {
+ } catch (err) {
if (!mountedRef.current) return;
setState("error");
onToast({
title: "CtxPort \u00b7 Copy failed",
- subtitle:
- "Fetch failed. Please open the conversation and use the in-page copy button.",
+ subtitle: formatCopyError(err),
type: "error",
});
setTimeout(() => {
@@ -183,6 +182,36 @@ export function ListCopyIcon({ conversationId, onToast }: ListCopyIconProps) {
);
}
+function formatCopyError(error: unknown): string {
+ if (error instanceof Error) return error.message;
+ if (error && typeof error === "object") {
+ const details = error as { detail?: unknown; message?: unknown };
+ if (typeof details.detail === "string" && details.detail) {
+ return details.detail;
+ }
+ if (typeof details.message === "string" && details.message) {
+ return details.message;
+ }
+ try {
+ return (
+ JSON.stringify(error, Object.getOwnPropertyNames(error)) ??
+ "Unexpected object error"
+ );
+ } catch {
+ return "Unable to describe the error object.";
+ }
+ }
+ if (typeof error === "string" && error) return error;
+ if (
+ typeof error === "number" ||
+ typeof error === "boolean" ||
+ typeof error === "bigint"
+ ) {
+ return `${error}`;
+ }
+ return "Unable to fetch this conversation.";
+}
+
function iconColor(state: IconState): string {
switch (state) {
case "success":
diff --git a/packages/core-plugins/src/index.ts b/packages/core-plugins/src/index.ts
index 6873007..8a0eba0 100644
--- a/packages/core-plugins/src/index.ts
+++ b/packages/core-plugins/src/index.ts
@@ -28,6 +28,7 @@ export {
geminiPlugin,
githubPlugin,
grokPlugin,
+ kimiPlugin,
} from "./plugins";
// Host permissions constant (for WXT config)
@@ -37,6 +38,7 @@ import { deepseekPlugin } from "./plugins/deepseek/plugin";
import { geminiPlugin } from "./plugins/gemini/plugin";
import { githubPlugin } from "./plugins/github/plugin";
import { grokPlugin } from "./plugins/grok/plugin";
+import { kimiPlugin } from "./plugins/kimi/plugin";
export const EXTENSION_HOST_PERMISSIONS = [
...chatgptPlugin.urls.hosts,
...claudePlugin.urls.hosts,
@@ -44,4 +46,5 @@ export const EXTENSION_HOST_PERMISSIONS = [
...geminiPlugin.urls.hosts,
...githubPlugin.urls.hosts,
...grokPlugin.urls.hosts,
+ ...kimiPlugin.urls.hosts,
];
diff --git a/packages/core-plugins/src/plugins/chatgpt/plugin.ts b/packages/core-plugins/src/plugins/chatgpt/plugin.ts
index 813c7c5..fd96c8e 100644
--- a/packages/core-plugins/src/plugins/chatgpt/plugin.ts
+++ b/packages/core-plugins/src/plugins/chatgpt/plugin.ts
@@ -50,8 +50,8 @@ export const chatgptPlugin: Plugin = {
'div[data-testid="conversation-header"] .flex.items-center',
],
copyButtonPosition: "prepend",
- listItemLinkSelector: 'nav a[href^="/c/"], nav a[href^="/g/"]',
- listItemIdPattern: /\/(?:c|g)\/([a-zA-Z0-9-]+)$/,
+ listItemLinkSelector: 'nav a[href*="/c/"]',
+ listItemIdPattern: /\/c\/([a-zA-Z0-9-]+)(?:[/?#]|$)/,
mainContentSelector: "main",
sidebarSelector: "nav",
}),
diff --git a/packages/core-plugins/src/plugins/deepseek/plugin.test.ts b/packages/core-plugins/src/plugins/deepseek/plugin.test.ts
new file mode 100644
index 0000000..7abd3be
--- /dev/null
+++ b/packages/core-plugins/src/plugins/deepseek/plugin.test.ts
@@ -0,0 +1,83 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { deepseekPlugin } from "./plugin";
+
+describe("DeepSeek conversation extraction", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("extracts messages from history responses using fragments", async () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => JSON.stringify({ value: "test-token" }),
+ });
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ code: 0,
+ data: {
+ biz_data: {
+ chat_session: { title: "Fragment conversation" },
+ chat_messages: [
+ {
+ message_id: 1,
+ role: "USER",
+ fragments: [
+ { type: "REQUEST", content: "Question" },
+ ],
+ },
+ {
+ message_id: 2,
+ role: "ASSISTANT",
+ fragments: [
+ { type: "THINK", content: "Private reasoning" },
+ { type: "RESPONSE", content: "First sentence." },
+ { type: "RESPONSE", content: "Second sentence." },
+ ],
+ },
+ ],
+ },
+ },
+ }),
+ }),
+ );
+
+ const bundle = await deepseekPlugin.fetchById!("session-id");
+
+ expect(bundle.nodes.map((node) => node.content)).toEqual([
+ "Question",
+ "First sentence.\nSecond sentence.",
+ ]);
+ });
+
+ it("continues to extract messages from top-level content responses", async () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => JSON.stringify({ value: "test-token" }),
+ });
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ code: 0,
+ data: {
+ biz_data: {
+ chat_messages: [
+ { message_id: 1, role: "USER", content: "Question" },
+ { message_id: 2, role: "ASSISTANT", content: "Answer" },
+ ],
+ },
+ },
+ }),
+ }),
+ );
+
+ const bundle = await deepseekPlugin.fetchById!("session-id");
+
+ expect(bundle.nodes.map((node) => node.content)).toEqual([
+ "Question",
+ "Answer",
+ ]);
+ });
+});
diff --git a/packages/core-plugins/src/plugins/deepseek/plugin.ts b/packages/core-plugins/src/plugins/deepseek/plugin.ts
index 9444fa5..2ba4a35 100644
--- a/packages/core-plugins/src/plugins/deepseek/plugin.ts
+++ b/packages/core-plugins/src/plugins/deepseek/plugin.ts
@@ -3,7 +3,7 @@ import { createAppError } from "@ctxport/core-schema";
import type { Plugin, PluginContext } from "../../types";
import { generateId } from "../../utils";
import { createChatInjector } from "../shared/chat-injector";
-import type { DeepSeekHistoryResponse } from "./types";
+import type { DeepSeekHistoryResponse, DeepSeekMessage } from "./types";
const HOST_PATTERN = /^https:\/\/chat\.deepseek\.com\//i;
const CONVERSATION_PATTERN =
@@ -51,7 +51,7 @@ export const deepseekPlugin: Plugin = {
],
copyButtonPosition: "prepend",
listItemLinkSelector: 'a[href*="/a/chat/"]',
- listItemIdPattern: /\/a\/chat\/(?:s\/)?([a-zA-Z0-9-]+)$/,
+ listItemIdPattern: /\/a\/chat\/(?:s\/)?([a-zA-Z0-9-]+)(?:[/?#]|$)/,
mainContentSelector:
'main, [class*="chat-container"], [class*="conversation"]',
sidebarSelector: 'nav, [class*="sidebar"], [class*="session-list"]',
@@ -137,6 +137,20 @@ function normalizeRole(role: string): "user" | "assistant" | null {
return null;
}
+function extractMessageText(
+ message: DeepSeekMessage,
+ role: "user" | "assistant",
+): string {
+ const fragmentType = role === "user" ? "REQUEST" : "RESPONSE";
+ const fragmentText = message.fragments
+ ?.filter((fragment) => fragment.type?.toUpperCase() === fragmentType)
+ .map((fragment) => fragment.content?.trim() ?? "")
+ .filter(Boolean)
+ .join("\n");
+
+ return fragmentText || message.content?.trim() || "";
+}
+
function parseConversation(
data: DeepSeekHistoryResponse,
url: string,
@@ -160,8 +174,8 @@ function parseConversation(
const role = normalizeRole(message.role);
if (!role) continue;
- // Use content only (skip thinking_content — DeepThink reasoning)
- const text = message.content?.trim();
+ // Ignore thinking fragments; only include visible prompts and responses.
+ const text = extractMessageText(message, role);
if (!text) continue;
const last = grouped[grouped.length - 1];
diff --git a/packages/core-plugins/src/plugins/deepseek/types.ts b/packages/core-plugins/src/plugins/deepseek/types.ts
index d902f3c..200f44d 100644
--- a/packages/core-plugins/src/plugins/deepseek/types.ts
+++ b/packages/core-plugins/src/plugins/deepseek/types.ts
@@ -23,6 +23,8 @@ export interface DeepSeekMessage {
role: string;
/** Final answer text content */
content?: string;
+ /** Text fragments used by another DeepSeek history API response variant. */
+ fragments?: DeepSeekMessageFragment[];
/** Thinking/reasoning content (DeepThink R1) */
thinking_content?: string;
/** Message accumulated token count */
@@ -32,3 +34,8 @@ export interface DeepSeekMessage {
/** Files attached to message */
files?: unknown[];
}
+
+export interface DeepSeekMessageFragment {
+ type?: string;
+ content?: string;
+}
diff --git a/packages/core-plugins/src/plugins/index.ts b/packages/core-plugins/src/plugins/index.ts
index ce00365..ae831ac 100644
--- a/packages/core-plugins/src/plugins/index.ts
+++ b/packages/core-plugins/src/plugins/index.ts
@@ -5,6 +5,7 @@ import { deepseekPlugin } from "./deepseek/plugin";
import { geminiPlugin } from "./gemini/plugin";
import { githubPlugin } from "./github/plugin";
import { grokPlugin } from "./grok/plugin";
+import { kimiPlugin } from "./kimi/plugin";
export function registerBuiltinPlugins(): void {
registerPlugin(chatgptPlugin);
@@ -13,6 +14,7 @@ export function registerBuiltinPlugins(): void {
registerPlugin(geminiPlugin);
registerPlugin(githubPlugin);
registerPlugin(grokPlugin);
+ registerPlugin(kimiPlugin);
}
export { chatgptPlugin } from "./chatgpt/plugin";
@@ -21,3 +23,4 @@ export { deepseekPlugin } from "./deepseek/plugin";
export { geminiPlugin } from "./gemini/plugin";
export { githubPlugin } from "./github/plugin";
export { grokPlugin } from "./grok/plugin";
+export { kimiPlugin } from "./kimi/plugin";
diff --git a/packages/core-plugins/src/plugins/kimi/plugin.test.ts b/packages/core-plugins/src/plugins/kimi/plugin.test.ts
new file mode 100644
index 0000000..16054d1
--- /dev/null
+++ b/packages/core-plugins/src/plugins/kimi/plugin.test.ts
@@ -0,0 +1,98 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { kimiPlugin } from "./plugin";
+
+describe("Kimi plugin", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("matches Kimi chat URLs and extracts messages in conversation order", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ messages: [
+ {
+ id: "assistant-1",
+ parentId: "user-1",
+ role: "assistant",
+ childrenMessageIds: [],
+ blocks: [{ text: { content: "Answer" } }],
+ },
+ {
+ id: "root",
+ parentId: "00000000-0000-0000-0000-000000000000",
+ role: "system",
+ childrenMessageIds: ["user-1"],
+ },
+ {
+ id: "user-1",
+ parentId: "root",
+ role: "user",
+ childrenMessageIds: ["assistant-1"],
+ blocks: [{ text: { content: "Question" } }],
+ },
+ ],
+ }),
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ vi.stubGlobal("document", { title: "Example chat - Kimi" });
+
+ expect(kimiPlugin.urls.match("https://www.kimi.ai/chat/chat-id")).toBe(
+ true,
+ );
+ expect(kimiPlugin.urls.match("https://www.kimi.com/chat/chat-id")).toBe(
+ true,
+ );
+
+ const bundle = await kimiPlugin.extract({
+ url: "https://www.kimi.ai/chat/chat-id",
+ document: { title: "Example chat - Kimi" } as Document,
+ });
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/ListMessages"),
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({ chatId: "chat-id" }),
+ }),
+ );
+ expect(bundle.nodes.map((node) => node.content)).toEqual([
+ "Question",
+ "Answer",
+ ]);
+ expect(bundle.source.platform).toBe("kimi");
+ });
+
+ it("supports responses wrapped in data and joins text blocks", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ data: {
+ messages: [
+ {
+ id: "user",
+ role: "user",
+ blocks: [
+ { text: { content: "First part" } },
+ { text: { content: "Second part" } },
+ ],
+ },
+ ],
+ },
+ }),
+ }),
+ );
+ vi.stubGlobal("document", { title: "Kimi" });
+
+ const bundle = await kimiPlugin.extract({
+ url: "https://kimi.ai/chat/chat-id",
+ document: { title: "Kimi" } as Document,
+ });
+
+ expect(bundle.nodes.map((node) => node.content)).toEqual([
+ "First part\n\nSecond part",
+ ]);
+ });
+});
diff --git a/packages/core-plugins/src/plugins/kimi/plugin.ts b/packages/core-plugins/src/plugins/kimi/plugin.ts
new file mode 100644
index 0000000..5659bfd
--- /dev/null
+++ b/packages/core-plugins/src/plugins/kimi/plugin.ts
@@ -0,0 +1,194 @@
+import type { ContentBundle } from "@ctxport/core-schema";
+import { createAppError } from "@ctxport/core-schema";
+import type { Plugin, PluginContext } from "../../types";
+import { generateId } from "../../utils";
+import { createChatInjector } from "../shared/chat-injector";
+import type { KimiMessage, KimiMessagesResponse } from "./types";
+
+const HOST_PATTERN = /^https:\/\/(?:www\.)?kimi\.(?:ai|com)\//i;
+const CONVERSATION_PATTERN =
+ /^https?:\/\/(?:www\.)?kimi\.(?:ai|com)\/chat\/([a-zA-Z0-9-]+)/i;
+const API_PATH = "/apiv2/kimi.gateway.chat.v1.ChatService/ListMessages";
+
+export const kimiPlugin: Plugin = {
+ id: "kimi",
+ version: "1.0.0",
+ name: "Kimi",
+
+ urls: {
+ hosts: [
+ "https://kimi.ai/*",
+ "https://www.kimi.ai/*",
+ "https://kimi.com/*",
+ "https://www.kimi.com/*",
+ ],
+ match: (url) => HOST_PATTERN.test(url),
+ },
+
+ async extract(ctx: PluginContext): Promise {
+ const conversationId = extractConversationId(ctx.url);
+ if (!conversationId) {
+ throw createAppError("E-PARSE-001", "Not a Kimi conversation page");
+ }
+
+ const data = await fetchMessages(ctx.url, conversationId);
+ return parseConversation(data, ctx.url, ctx.document.title);
+ },
+
+ async fetchById(conversationId: string): Promise {
+ const data = await fetchMessages(window.location.origin, conversationId);
+ const url = `${window.location.origin}/chat/${conversationId}`;
+ return parseConversation(data, url, document.title);
+ },
+
+ injector: createChatInjector({
+ platform: "kimi",
+ copyButtonSelectors: ["main header", 'main [class*="chat-input"]'],
+ copyButtonPosition: "prepend",
+ listItemLinkSelector: 'a[href*="/chat/"]',
+ listItemIdPattern: /\/chat\/([a-zA-Z0-9-]+)(?:[/?#]|$)/,
+ mainContentSelector: "main",
+ sidebarSelector: "nav",
+ }),
+
+ theme: {
+ light: {
+ primary: "#536dfe",
+ secondary: "#eef0ff",
+ fg: "#ffffff",
+ secondaryFg: "#3949ab",
+ },
+ dark: {
+ primary: "#8795ff",
+ secondary: "#252a48",
+ fg: "#11152b",
+ secondaryFg: "#c5cbff",
+ },
+ },
+};
+
+function extractConversationId(url: string): string | null {
+ return CONVERSATION_PATTERN.exec(url)?.[1] ?? null;
+}
+
+async function fetchMessages(
+ pageUrl: string,
+ conversationId: string,
+): Promise {
+ const origin = new URL(pageUrl).origin;
+ const accessToken = getAccessToken();
+ const response = await fetch(`${origin}${API_PATH}`, {
+ method: "POST",
+ cache: "no-store",
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json",
+ "connect-protocol-version": "1",
+ "x-msh-platform": "web",
+ "x-msh-version": "1.0.0",
+ "x-language": "en-US",
+ ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
+ },
+ body: JSON.stringify({ chatId: conversationId }),
+ });
+
+ if (!response.ok) {
+ throw createAppError(
+ "E-PARSE-005",
+ `Kimi API responded with ${response.status}`,
+ );
+ }
+
+ return (await response.json()) as KimiMessagesResponse;
+}
+
+function getAccessToken(): string | null {
+ try {
+ return localStorage.getItem("access_token");
+ } catch {
+ return null;
+ }
+}
+
+function getMessages(data: KimiMessagesResponse): KimiMessage[] {
+ const messages = data.messages ?? data.data?.messages ?? [];
+ return Array.isArray(messages) ? messages : [];
+}
+
+function orderMessages(messages: KimiMessage[]): KimiMessage[] {
+ const messagesById = new Map(
+ messages.filter((message) => message.id).map((message) => [message.id!, message]),
+ );
+ const root = messages.find(
+ (message) =>
+ message.role === "system" ||
+ message.parentId === "00000000-0000-0000-0000-000000000000",
+ );
+ if (!root?.id) return [...messages].reverse();
+
+ const ordered: KimiMessage[] = [];
+ const visited = new Set();
+ const visit = (id: string) => {
+ const message = messagesById.get(id);
+ if (!message || visited.has(id)) return;
+ visited.add(id);
+ if (message.role !== "system") ordered.push(message);
+ for (const childId of message.childrenMessageIds ?? []) visit(childId);
+ };
+
+ visit(root.id);
+ return ordered.length ? ordered : [...messages].reverse();
+}
+
+function getMessageText(message: KimiMessage): string {
+ return (message.blocks ?? [])
+ .map((block) => block.text?.content?.trim() ?? "")
+ .filter(Boolean)
+ .join("\n\n");
+}
+
+function parseConversation(
+ data: KimiMessagesResponse,
+ url: string,
+ pageTitle: string,
+): ContentBundle {
+ const messages = orderMessages(getMessages(data));
+ const contentNodes: ContentBundle["nodes"] = [];
+
+ for (const message of messages) {
+ const text = getMessageText(message);
+ if (!text) continue;
+ const role = message.role?.toLowerCase();
+ if (role !== "user" && role !== "assistant") continue;
+
+ contentNodes.push({
+ id: generateId(),
+ participantId: role,
+ content: text,
+ order: contentNodes.length,
+ type: "message",
+ timestamp: message.createTime,
+ });
+ }
+
+ if (contentNodes.length === 0) {
+ throw createAppError("E-PARSE-005", "No messages found in Kimi conversation");
+ }
+
+ return {
+ id: generateId(),
+ title: pageTitle.replace(/\s*[-|]\s*Kimi.*$/i, "").trim() || undefined,
+ participants: [
+ { id: "user", name: "User", role: "user" },
+ { id: "assistant", name: "Kimi", role: "assistant" },
+ ],
+ nodes: contentNodes,
+ source: {
+ platform: "kimi",
+ url,
+ extractedAt: new Date().toISOString(),
+ pluginId: "kimi",
+ pluginVersion: "1.0.0",
+ },
+ };
+}
diff --git a/packages/core-plugins/src/plugins/kimi/types.ts b/packages/core-plugins/src/plugins/kimi/types.ts
new file mode 100644
index 0000000..83141d7
--- /dev/null
+++ b/packages/core-plugins/src/plugins/kimi/types.ts
@@ -0,0 +1,21 @@
+export interface KimiMessageBlock {
+ text?: {
+ content?: string;
+ };
+}
+
+export interface KimiMessage {
+ id?: string;
+ parentId?: string;
+ role?: string;
+ childrenMessageIds?: string[] | null;
+ blocks?: KimiMessageBlock[];
+ createTime?: string;
+}
+
+export interface KimiMessagesResponse {
+ messages?: KimiMessage[];
+ data?: {
+ messages?: KimiMessage[];
+ };
+}
diff --git a/packages/core-plugins/src/plugins/shared/chat-injector.ts b/packages/core-plugins/src/plugins/shared/chat-injector.ts
index 8e55652..9592367 100644
--- a/packages/core-plugins/src/plugins/shared/chat-injector.ts
+++ b/packages/core-plugins/src/plugins/shared/chat-injector.ts
@@ -71,6 +71,12 @@ export function createChatInjector(config: ChatInjectorConfig): PluginInjector {
let observers: MutationObserver[] = [];
let timers: ReturnType[] = [];
let callbacks: InjectorCallbacks | null = null;
+ const injectedTargets = new Set();
+
+ function markTargetInjected(el: HTMLElement, type: string): void {
+ markInjected(el, type);
+ injectedTargets.add(el);
+ }
function tryInjectCopyButton(): void {
if (!callbacks) return;
@@ -96,7 +102,7 @@ export function createChatInjector(config: ChatInjectorConfig): PluginInjector {
break;
}
- markInjected(target, "copy-btn");
+ markTargetInjected(target, "copy-btn");
callbacks.renderCopyButton(container);
return;
}
@@ -124,11 +130,9 @@ export function createChatInjector(config: ChatInjectorConfig): PluginInjector {
container.style.position = "absolute";
container.style.right = "36px";
container.style.top = "50%";
- container.style.opacity = "0";
- container.style.transform = "translateY(-50%) scale(0.85)";
- container.style.transition =
- "opacity 150ms cubic-bezier(0.55, 0, 1, 0.45), transform 150ms cubic-bezier(0.55, 0, 1, 0.45)";
- container.style.pointerEvents = "none";
+ container.style.opacity = "1";
+ container.style.transform = "translateY(-50%)";
+ container.style.pointerEvents = "auto";
container.style.zIndex = "10";
const parent = link.closest("li") ?? link;
@@ -138,23 +142,9 @@ export function createChatInjector(config: ChatInjectorConfig): PluginInjector {
parent.style.position = "relative";
}
parent.appendChild(container);
- parent.addEventListener("mouseenter", () => {
- container.style.opacity = "1";
- container.style.transform = "translateY(-50%) scale(1)";
- container.style.transition =
- "opacity 150ms cubic-bezier(0.16, 1, 0.3, 1), transform 150ms cubic-bezier(0.22, 1.2, 0.36, 1)";
- container.style.pointerEvents = "auto";
- });
- parent.addEventListener("mouseleave", () => {
- container.style.opacity = "0";
- container.style.transform = "translateY(-50%) scale(0.85)";
- container.style.transition =
- "opacity 150ms cubic-bezier(0.55, 0, 1, 0.45), transform 150ms cubic-bezier(0.55, 0, 1, 0.45)";
- container.style.pointerEvents = "none";
- });
}
- markInjected(link, "list-icon");
+ markTargetInjected(link, "list-icon");
callbacks.renderListIcon(container, id);
}
}
@@ -185,12 +175,8 @@ export function createChatInjector(config: ChatInjectorConfig): PluginInjector {
const debouncedTry = debouncedObserverCallback(() =>
tryInjectListIcons(),
);
- const sidebar =
- document.querySelector(config.sidebarSelector) ??
- document.querySelector("nav") ??
- document.body;
const observer = new MutationObserver(debouncedTry);
- observer.observe(sidebar, { childList: true, subtree: true });
+ observer.observe(document.body, { childList: true, subtree: true });
observers.push(observer);
}, INJECTION_DELAY_MS);
timers.push(listTimer);
@@ -203,6 +189,10 @@ export function createChatInjector(config: ChatInjectorConfig): PluginInjector {
timers = [];
removeAllByClass(copyBtnClass);
removeAllByClass(listIconClass);
+ for (const target of injectedTargets) {
+ target.removeAttribute(CTXPORT_ATTR);
+ }
+ injectedTargets.clear();
callbacks = null;
},
};