Skip to content
Open
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

<br />
Expand Down Expand Up @@ -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.

---

Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README_cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

<br />
Expand Down Expand Up @@ -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 粘贴到任何你需要的地方

侧边栏列表复制:悬停在左侧边栏的任意对话上,即可看到复制图标。
侧边栏列表复制:点击左侧边栏对话旁显示的复制图标。

---

Expand All @@ -183,6 +184,7 @@ pnpm dev:ext
- [x] Gemini 支持
- [x] DeepSeek 支持
- [x] Grok 支持
- [x] Kimi 支持
- [x] GitHub Issues & PRs 支持
- [x] 侧边栏列表复制
- [x] 多种复制格式
Expand Down
35 changes: 32 additions & 3 deletions apps/browser-extension/src/components/list-copy-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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":
Expand Down
3 changes: 3 additions & 0 deletions packages/core-plugins/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export {
geminiPlugin,
githubPlugin,
grokPlugin,
kimiPlugin,
} from "./plugins";

// Host permissions constant (for WXT config)
Expand All @@ -37,11 +38,13 @@ 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,
...deepseekPlugin.urls.hosts,
...geminiPlugin.urls.hosts,
...githubPlugin.urls.hosts,
...grokPlugin.urls.hosts,
...kimiPlugin.urls.hosts,
];
4 changes: 2 additions & 2 deletions packages/core-plugins/src/plugins/chatgpt/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
Expand Down
83 changes: 83 additions & 0 deletions packages/core-plugins/src/plugins/deepseek/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
});
});
22 changes: 18 additions & 4 deletions packages/core-plugins/src/plugins/deepseek/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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"]',
Expand Down Expand Up @@ -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,
Expand All @@ -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];
Expand Down
7 changes: 7 additions & 0 deletions packages/core-plugins/src/plugins/deepseek/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -32,3 +34,8 @@ export interface DeepSeekMessage {
/** Files attached to message */
files?: unknown[];
}

export interface DeepSeekMessageFragment {
type?: string;
content?: string;
}
3 changes: 3 additions & 0 deletions packages/core-plugins/src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -13,6 +14,7 @@ export function registerBuiltinPlugins(): void {
registerPlugin(geminiPlugin);
registerPlugin(githubPlugin);
registerPlugin(grokPlugin);
registerPlugin(kimiPlugin);
}

export { chatgptPlugin } from "./chatgpt/plugin";
Expand All @@ -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";
Loading
Loading