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
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"rules": {
"react/react-in-jsx-scope": "off"
},
"ignorePatterns": ["build/**", "build.mjs", "src/utils/is-mobile.mjs"],
"ignorePatterns": ["build/**", "test-results/**", "build.mjs", "src/utils/is-mobile.mjs"],
"settings": {
"react": {
"version": "detect"
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
.vscode/
node_modules/
build/
test-results/
.coverage/
coverage/
.DS_Store
Expand Down
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
build/
test-results/
src/manifest.json
src/manifest.v2.json
src/manifest.v2.json
75 changes: 75 additions & 0 deletions IMAGE-SUPPORT.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# 侧栏图片适配版

基于 ChatGPTBox 2.7.1;图片功能最初开发于 `ab221d2`,现已合并到此 fork 的 `c5ddb90` 之后。这是源码修改版,并非插件商店发布版。

## 2026-09-12:Blackboard PDF 侧栏修复(images-2)

用户实际错误为 `chrome.sidePanel.open requires a valid windowId`,来自上一版的参数校验:PDF 菜单回调缺少有效窗口 ID 时,代码在调用浏览器 API 前就拒绝打开。现在使用回调提供的有效窗口 ID;缺失时同步传 `WINDOW_ID_CURRENT`,由浏览器解析当前窗口,避免异步查询丢失用户手势。保留全局侧栏路径:独立 Edge 测试发现按 PDF 标签页打开可以返回成功却不创建可见侧栏,因此不以 API 成功返回作为验收依据。

侧栏与独立窗口共用图片组件,使用标准 `IndependentPanel.html` 入口。历史调试版本曾显示内部版本标记;正式提交已移除该标记、查询参数和 fork 专用版本名。打开侧栏无需读取 PDF 地址、重新下载 PDF 或向 PDF 阅读器注入代码。

同时将右键点击监听器的注册移到异步菜单初始化之前,避免扩展后台刚被唤醒时遗漏第一次点击。更新后需在扩展管理页点击“重新加载”,再重新打开侧栏;仅刷新 PDF 页面不会加载新的后台代码。

本轮 `npm test` 全部通过,覆盖缺失窗口 ID、无效窗口 ID、缺失整个 tab 对象,确保在用户点击时同步调用 API。格式、lint 和构建通过。原生 Edge 侧栏测试确认 `{}` 和只有 `id` 的回调均通过 `WINDOW_ID_CURRENT` 打开新版侧栏,约 382 像素宽时图片按钮、提示和文件输入可见,选择本地 PNG 后生成正确图片预览,未增加 PDF 请求或额外打开对话标签页。自动化测试使用独立 Edge 环境和本地 PDF,没有访问学校账户;Blackboard 页面由用户手动复测,并在 README 中保留运行截图。

## 安装

**安装包与源码包不同:**

- `chromium.zip`:已经编译好的浏览器安装包。包含 `manifest.json`、`content-script.js` 等文件,没有 `src`、`package.json` 和构建环境。这是正常打包结果,请直接加载,不要在其中执行 `npm run build`。
- `chatgptbox-image-support-source.zip`:完整修改版源码,包含 `src/content-script`、`src/components`、`src/services`、测试、`package.json`、`package-lock.json`、`build.mjs` 和隐藏配置文件。需要自己开发或重新构建时使用此包。

1. 在 Edge 打开 `edge://extensions`,或在 Chrome 打开 `chrome://extensions`。
2. 打开“开发人员模式”,选择“加载解压缩的扩展”。
3. 选择本项目的 `build/chromium` 文件夹(其中应包含 `manifest.json`)。也可以解压 `build/chromium.zip` 后选择解压目录。
4. 商店版可以保留;为避免同一网页出现两个入口,使用开发版期间可暂时关闭商店版。
5. 开发版使用独立的扩展存储,需要重新配置服务商和 API Key。

## 使用

在侧栏选择 OpenAI 兼容的 Chat Completions API 接入,配置支持图片输入的模型。支持该协议不代表每个模型都能看图;模型和端点需要由服务商提供图片理解能力。

- 点击图片按钮选择本地文件。
- 截图后在输入区域按 Ctrl + V 粘贴。
- 将图片文件拖入输入区域。
- 发送前可预览、移除图片。可以只发送图片,也可以同时输入问题。
- 后续提问会在配置的历史范围内保留此前图片;重试也会带上对应图片。
- 清空对话会同时清除当前图片草稿。

接受 PNG、JPEG、WebP、GIF,每次最多 4 张,每张最多 4 MiB,每次合计最多 12 MiB。较大的截图请先压缩。完整会话还设有图片总量检查,达到限制时请新建或清空对话。

本次未实现 ChatGPT / Claude 等网页登录模式的文件上传协议。使用这些模式时,图片入口会提示切换到受支持的 API。旧式文本 Completions 接口也不支持图片输入。

## 图片与存储

图片作为 base64 数据直接随消息发送给所选 API 服务商,不经过新增中转服务。会话保存沿用插件原有的本地存储机制,因此图片也会随会话保存;导出完整会话 JSON 时会包含图片。Markdown 文本导出仍只包含文字。清除草稿不会删除已经发送并保存在会话中的图片;请使用清空或删除会话。

## 重新构建

请先解压 **源码包**,安装 Node.js 22 或更新版本。在解压得到的 `chatgptbox-image-support` 文件夹中打开终端,确认该目录下存在 `package.json` 和 `build.mjs`。首次安装依赖需要网络连接。

```powershell
npm ci --ignore-scripts
npm test
npm run build
```

Windows PowerShell 若提示禁止运行 `npm.ps1`,可使用 `npm.cmd ci --ignore-scripts`、`npm.cmd test` 和 `npm.cmd run build`,无需修改系统执行策略。若提示找不到 `package.json`,通常是在安装包目录或源码包外层目录运行了命令。

构建成功后加载生成的 `build/chromium` 目录。源码包没有包含 `node_modules`,因此解压后应先执行安装依赖命令。维护者可运行 `node scripts/package-image-source.mjs` 重新生成源码 ZIP。

重新构建后,在扩展管理页点击开发版的“重新加载”,然后重新打开侧栏并刷新需要使用插件的网页。商店自动更新不会更新此开发版。

## 验证记录

2026-09-10 完成交付检查:

- 初始图片功能实现阶段的 `npm test`:全部通过,包括图片大小与格式限制、多模态请求、图片历史及重试保留。
- `npm run lint`:通过。
- `npm run build`:通过,生成 Chromium / Firefox 构建及 ZIP。构建缓存出现快照警告,不影响生成安装包。
- 实际加载开发版到独立的无头 Edge 浏览器,在 380 像素宽的 `IndependentPanel.html` 中验证文件选择、删除预览、粘贴事件、拖入事件、纯图片发送、重试、文字追问保留图片、清空草稿与不支持模式的拦截,全部通过。
- 本地模拟服务确认请求包含 `image_url` 图片数据;未调用真实模型,没有使用个人账户。

浏览器脚本位于 `tests/manual/images-smoke.cjs`,运行前将 `CODEX_NODE_MODULES` 指向已安装 Playwright 的依赖目录。该脚本测试侧栏共用页面,未操作浏览器原生侧栏容器;粘贴与拖入通过浏览器事件模拟,未验证操作系统剪贴板。Chrome、Firefox 和真实服务商的图片理解效果未做实测。

源码归档脚本必须在 Git checkout 中运行,只收录 Git 已跟踪且通过过滤的文件。解压后的源码仍可正常安装依赖和构建,但不能直接重新运行该 Git 归档脚本。打包前应检查已跟踪源码不含秘密;文件名过滤不能代替内容审查。
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,37 @@ English   |   [Indonesia](README_IN.md)   | &

- You can also use [Ollama](https://github.com/ChatGPTBox-dev/chatGPTBox/issues/616#issuecomment-1975186467) / https://openrouter.ai/docs#models with ChatGPTBox's `Custom Model` mode

## Image support update

This fork adds image input to ChatGPTBox conversations and improves the Chromium side panel on PDF pages.

- Attach PNG, JPEG, WebP, or GIF files from the input box, or paste and drop images.
- Preview and remove images before sending; image-only messages are supported.
- Preserve images in conversation history, follow-up requests, and retries.
- Send multimodal `image_url` content through OpenAI-compatible Chat Completions APIs.
- Open the Edge/Chrome side panel when a PDF viewer context-menu callback omits its window ID.
- Keep the native side panel usable at narrow widths without clipping the attachment controls.

Image input is enabled for OpenAI-compatible API modes. The selected model and API endpoint must support vision. Web-login modes and legacy text Completions endpoints do not support attachments in this fork. Limits are 4 images per message, 4 MiB per image, and 12 MiB total per message.

To build and load the Chromium extension:

```powershell
npm ci --ignore-scripts
npm test
npm run build
```

Open `edge://extensions` or `chrome://extensions`, enable Developer mode, choose **Load unpacked**, and select `build/chromium`. See [IMAGE-SUPPORT.zh-CN.md](IMAGE-SUPPORT.zh-CN.md) for detailed usage, privacy, troubleshooting, and validation notes.

**Native side panel on a Blackboard-hosted PDF**

![Native side panel processing attached PDF screenshots](screenshots/blackboard-pdf-native-side-panel.png)

**Independent conversation window on the same PDF**

![Independent conversation window processing attached PDF screenshots](screenshots/blackboard-pdf-independent-window.png)

## ✨ Features

- 🌈 Call up the chat dialog box on any page at any time. (<kbd>Ctrl</kbd>+<kbd>B</kbd>)
Expand Down
31 changes: 31 additions & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,37 @@

- 离线/自托管模型 现已支持, 在`自定义模型`模式下使用, 具体查看 [Ollama](https://github.com/ChatGPTBox-dev/chatGPTBox/issues/616#issuecomment-1975186467) / [RWKV-Runner](https://github.com/josStorer/RWKV-Runner), 你还可以部署wenda (https://github.com/wenda-LLM/wenda), 配合自定义模型模式使用, 从而调用各类本地模型, 参考 [#397](https://github.com/ChatGPTBox-dev/chatGPTBox/issues/397) 修改API URL

## 图片支持更新

此 fork 为 ChatGPTBox 对话增加了图片输入,并改善了 Chromium 浏览器在 PDF 页面中的原生侧栏行为。

- 可从输入框选择 PNG、JPEG、WebP 或 GIF,也支持粘贴截图和拖入图片。
- 发送前可以预览和删除图片,并支持仅发送图片。
- 后续提问、历史消息和重试会保留相应图片。
- 通过 OpenAI 兼容的 Chat Completions API 发送多模态 `image_url` 内容。
- Edge/Chrome 的 PDF 查看器即使没有向右键菜单回调提供窗口 ID,也可以打开侧栏。
- 窄侧栏中图片按钮与输入区域保持可见,不会被裁切。

图片功能用于 OpenAI 兼容 API 模式,所选模型和 API 服务必须支持视觉输入。此 fork 暂不支持网页登录模式和旧式文本 Completions 接口的图片附件。限制为每次最多 4 张、每张最多 4 MiB、单次合计最多 12 MiB。

构建 Chromium 版本:

```powershell
npm ci --ignore-scripts
npm test
npm run build
```

打开 `edge://extensions` 或 `chrome://extensions`,启用开发人员模式,选择“加载解压缩的扩展”,然后选择 `build/chromium`。详细用法、隐私说明、故障排查和验证记录见 [IMAGE-SUPPORT.zh-CN.md](IMAGE-SUPPORT.zh-CN.md)。

**Blackboard PDF 中的原生侧栏**

![原生侧栏正在处理附加的 PDF 截图](screenshots/blackboard-pdf-native-side-panel.png)

**同一 PDF 上的独立对话窗口**

![独立对话窗口正在处理附加的 PDF 截图](screenshots/blackboard-pdf-independent-window.png)

## ✨ Features

- 🌈 在任何页面随时呼出聊天对话框 (<kbd>Ctrl</kbd>+<kbd>B</kbd>)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/blackboard-pdf-native-side-panel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
183 changes: 183 additions & 0 deletions scripts/package-image-source.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import archiver from 'archiver'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { constants, createWriteStream } from 'node:fs'
import { lstat, mkdir, open } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'

const defaultRoot = fileURLToPath(new URL('../', import.meta.url))

// Source archives are distributable artifacts. Keep their roots explicit so an
// unrelated local file at the repository root can never be packaged by accident.
export const SOURCE_ARCHIVE_ROOTS = Object.freeze([
'.github',
'badges',
'safari',
'screenshots',
'scripts',
'src',
'tests',
'.eslintrc.json',
'.gitattributes',
'.gitignore',
'.nvmrc',
'.prettierignore',
'.prettierrc',
'AGENTS.md',
'build.mjs',
'CURRENT_CHANGE.md',
'IMAGE-SUPPORT.zh-CN.md',
'LICENSE',
'package-lock.json',
'package.json',
'README.md',
'README_IN.md',
'README_JA.md',
'README_TR.md',
'README_ZH.md',
'SOURCE_CODE_REVIEW.md',
])

const excludedDirectoryNames = new Set([
'.git',
'.cache',
'.coverage',
'build',
'coverage',
'node_modules',
'test-results',
])

const sensitiveFilePatterns = [
/^\.env(?:\..*)?$/i,
/^\.npmrc$/i,
/^\.netrc$/i,
/^(?:token|api[-_]?key)(?:\..*)?$/i,
/^config\.json$/i,
/^auth(?:\..*)?$/i,
Comment thread
loeness marked this conversation as resolved.
/^credentials?(?:\..*)?$/i,
/^id_(?:dsa|ecdsa|ed25519|rsa)(?:\..*)?$/i,
/^secrets?(?:\..*)?$/i,
/\.(?:cer|crt|der|jks|key|keystore|p12|pem|pfx)$/i,
]

export function isSensitiveSourcePath(relativePath) {
const segments = relativePath.split(/[\\/]/).filter(Boolean)
if (segments.some((segment) => excludedDirectoryNames.has(segment))) return true
const basename = segments.at(-1) ?? ''
return sensitiveFilePatterns.some((pattern) => pattern.test(basename))
}

const execFileAsync = promisify(execFile)

export async function collectSourceFiles(root = defaultRoot) {
// Require Git rather than falling back to a recursive walk in extracted ZIPs.
const { stdout } = await execFileAsync('git', ['ls-files', '--cached', '-z'], {
cwd: root,
maxBuffer: 16 * 1024 * 1024,
})
const files = []
for (const relativePath of new Set(stdout.split('\0').filter(Boolean))) {
if (
!SOURCE_ARCHIVE_ROOTS.some(
(allowed) => relativePath === allowed || relativePath.startsWith(`${allowed}/`),
)
)
continue
if (isSensitiveSourcePath(relativePath)) continue
const segments = relativePath.split('/')
if (segments.some((part) => part === '..') || path.isAbsolute(relativePath)) continue
files.push(relativePath)
}
return files.sort()
}

function isSameFileSnapshot(left, right) {
return (
left.dev === right.dev &&
left.ino === right.ino &&
left.size === right.size &&
left.mtimeMs === right.mtimeMs &&
left.ctimeMs === right.ctimeMs
)
}

export async function readVerifiedSourceFile(root, relativePath) {
const segments = relativePath.split('/')
if (
segments.length === 0 ||
segments.some((part) => !part || part === '..') ||
path.isAbsolute(relativePath)
) {
throw new Error(`Unsafe source path: ${relativePath}`)
}

let expectedStat
for (let i = 1; i <= segments.length; i++) {
const candidateStat = await lstat(path.join(root, ...segments.slice(0, i)))
if (candidateStat.isSymbolicLink()) {
throw new Error(`Source archive path contains a symbolic link: ${relativePath}`)
}
if (i === segments.length) expectedStat = candidateStat
}
if (!expectedStat?.isFile())
throw new Error(`Source archive entry is not a file: ${relativePath}`)

const absolutePath = path.join(root, ...segments)
const noFollowFlag = constants.O_NOFOLLOW ?? 0
const fileHandle = await open(absolutePath, constants.O_RDONLY | noFollowFlag)
try {
const openedStat = await fileHandle.stat()
if (!openedStat.isFile() || !isSameFileSnapshot(expectedStat, openedStat)) {
throw new Error(`Source archive entry changed while opening: ${relativePath}`)
}

const contents = await fileHandle.readFile()
const completedStat = await fileHandle.stat()
if (contents.byteLength !== openedStat.size || !isSameFileSnapshot(openedStat, completedStat)) {
throw new Error(`Source archive entry changed while reading: ${relativePath}`)
}
return contents
} finally {
await fileHandle.close()
}
}

export async function createImageSourceArchive(root = defaultRoot) {
const files = await collectSourceFiles(root)
const sourceEntries = []
for (const relativePath of files) {
sourceEntries.push({
relativePath,
contents: await readVerifiedSourceFile(root, relativePath),
})
}
const outputDirectory = path.join(root, 'build')
await mkdir(outputDirectory, { recursive: true })
const outputPath = path.join(outputDirectory, 'chatgptbox-image-support-source.zip')
const output = createWriteStream(outputPath)
const archive = archiver('zip', { zlib: { level: 9 } })
const completed = new Promise((resolve, reject) => {
output.on('close', resolve)
output.on('error', reject)
archive.on('error', reject)
archive.on('warning', reject)
})
archive.pipe(output)

for (const entry of sourceEntries) {
archive.append(entry.contents, {
name: `chatgptbox-image-support/${entry.relativePath.replaceAll('\\', '/')}`,
})
}
await archive.finalize()
await completed
console.log(`Created ${outputPath} (${archive.pointer()} bytes)`)
return outputPath
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await createImageSourceArchive()
}
16 changes: 16 additions & 0 deletions src/_locales/de/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@
"Exclusively use Custom Site Regex for website matching, ignoring built-in rules": "Nur benutzerdefinierten Website-Regex verwenden, um Website-Übereinstimmungen zu finden und interne Regeln ignorieren",
"Input Query": "Eingabeaufforderung",
"Resize input box": "Größe des Eingabefelds ändern",
"Images require an OpenAI-compatible vision API.": "Bilder erfordern eine OpenAI-kompatible Vision-API.",
"Choose a model that supports image input.": "Wählen Sie ein Modell, das Bildeingaben unterstützt.",
"Attach images": "Bilder anhängen",
"Drop images here or paste a screenshot": "Bilder hierher ziehen oder einen Screenshot einfügen",
"Attached images": "Angehängte Bilder",
"Image": "Bild",
"Reading image": "Bild wird gelesen",
"Remove image": "Bild entfernen",
"Images must be PNG, JPEG, WEBP, or GIF.": "Bilder müssen PNG, JPEG, WEBP oder GIF sein.",
"Each image must be 4 MiB or smaller.": "Jedes Bild darf höchstens 4 MiB groß sein.",
"You can attach up to 4 images.": "Sie können bis zu 4 Bilder anhängen.",
"Total image size must be 12 MiB or smaller.": "Die Gesamtgröße der Bilder darf höchstens 12 MiB betragen.",
"This file is not a supported image.": "Diese Datei ist kein unterstütztes Bild.",
"Unable to read image.": "Bild kann nicht gelesen werden.",
"Unable to send images.": "Bilder können nicht gesendet werden.",
"Describe these images": "Beschreiben Sie diese Bilder",
"Append Query": "Am Ende anhängen",
"Prepend Query": "Am Anfang hinzufügen",
"Wechat Pay": "WeChat-Pay",
Expand Down
Loading