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. [![Gemini](https://img.shields.io/badge/Gemini-8E75B2?style=for-the-badge&logo=google&logoColor=white)](https://gemini.google.com) [![DeepSeek](https://img.shields.io/badge/DeepSeek-0066FF?style=for-the-badge&logo=deepseek&logoColor=white)](https://chat.deepseek.com) [![Grok](https://img.shields.io/badge/Grok-000000?style=for-the-badge&logo=x&logoColor=white)](https://grok.com) +~[![Kimi](https://img.shields.io/badge/Kimi-536dfe?style=for-the-badge)](https://www.kimi.ai) [![GitHub](https://img.shields.io/badge/GitHub-181717?style=for-the-badge&logo=github&logoColor=white)](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 @@ [![Gemini](https://img.shields.io/badge/Gemini-8E75B2?style=for-the-badge&logo=google&logoColor=white)](https://gemini.google.com) [![DeepSeek](https://img.shields.io/badge/DeepSeek-0066FF?style=for-the-badge&logo=deepseek&logoColor=white)](https://chat.deepseek.com) [![Grok](https://img.shields.io/badge/Grok-000000?style=for-the-badge&logo=x&logoColor=white)](https://grok.com) +[![Kimi](https://img.shields.io/badge/Kimi-536dfe?style=for-the-badge)](https://www.kimi.ai) [![GitHub](https://img.shields.io/badge/GitHub-181717?style=for-the-badge&logo=github&logoColor=white)](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; }, };