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
3 changes: 2 additions & 1 deletion .github/ISSUE_TEMPLATE/founder-beta-en.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: Founder Beta application (English)
description: Apply for one of the first 15 macOS beta spots
description: Apply for one of up to 15 eligible macOS beta spots
title: "[Founder Beta]: "
labels: ["founder-beta"]
body:
Expand Down Expand Up @@ -39,6 +39,7 @@ body:
description: This is used only to identify which public channels reach people who need the product.
options:
- DEV Community
- Hashnode
- GitHub
- Hacker News
- Reddit
Expand Down
3 changes: 2 additions & 1 deletion .github/ISSUE_TEMPLATE/founder-beta.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: Founder Beta application
description: Apply for one of the first 15 macOS testing places
description: Apply for one of up to 15 eligible macOS testing places
title: "[Founder Beta]: "
labels: ["founder-beta"]
body:
Expand Down Expand Up @@ -39,6 +39,7 @@ body:
description: 只用于判断哪些公开渠道能找到真正需要这个工具的人。
options:
- DEV Community
- Hashnode
- GitHub
- Hacker News
- Reddit
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22.13.0
cache: npm
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

## Web 开发

需要 Node.js 24 或更高版本。
需要 Node.js 22.13 或更高版本。

```bash
npm ci
Expand Down
45 changes: 42 additions & 3 deletions docs/building-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,47 @@ The signed and notarized beta installer is not available yet. Developers can run
## Prerequisites

- macOS 12 or later
- Node.js 24 or later
- Node.js 22.13 or later
- Rust stable
- Xcode Command Line Tools

Install the Rust toolchain if needed:
Confirm that the required command-line tools are available:

```bash
git --version
node --version
rustc --version
xcode-select -p
```

If Xcode Command Line Tools or Rust is missing, install them first:

```bash
xcode-select --install
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```

After Rust finishes installing, open a new terminal and run:

```bash
rustup toolchain install stable
```

Use the [Node.js download page](https://nodejs.org/en/download) for an installer or alternative installation method. `node --version` must report `v22.13.0` or later.

## Run the desktop app

From the repository root:
Clone the repository and install the locked dependency versions:

```bash
git clone https://github.com/fly1d/wordwise.git
cd wordwise
npm ci
```

Start the desktop app:

```bash
npm run tauri dev
```

Expand All @@ -35,10 +60,24 @@ Use one of these contextual engines:
- Ollama on the same Mac, with `qwen3:4b` or a larger Qwen model recommended
- An OpenAI-compatible API endpoint and your own API key

Local mode needs no API key. Install and start Ollama from the [macOS download page](https://ollama.com/download/mac), then run this in another terminal:

```bash
ollama pull qwen3:4b
```

Return to Wordwise and keep **Automatic** selected, or choose **Local Ollama**. Select `What's under the hood?` in any app and press `Option + T`. A successful first run shows a full Chinese translation, token-aligned results, and the `Ollama · qwen3:4b` engine label.

Automatic mode uses an available Ollama model first, then a configured cloud API. It reports a setup error when neither is available. The offline dictionary is an explicit word-lookup mode and is not presented as contextual sentence translation.

API keys remain in application memory and are not written to browser storage or committed to the repository. Never include keys, selected text, or private documents in a public GitHub issue.

## Common blockers

- The shortcut cannot read the selection: check Wordwise under **System Settings -> Privacy & Security -> Accessibility**, then restart the development app.
- Automatic mode reports that no engine is configured: make sure Ollama is running and use `ollama list` to confirm that the model was downloaded.
- A development port is already in use: stop the existing Wordwise development process and try again.

## Verify a change

Run the repository quality gates before opening a pull request:
Expand Down
45 changes: 42 additions & 3 deletions docs/building.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,47 @@
## 环境要求

- macOS 12 或更高版本
- Node.js 24 或更高版本
- Node.js 22.13 或更高版本
- Rust stable
- Xcode Command Line Tools

如果还没有 Rust 工具链,先运行:
先确认命令行工具可用:

```bash
git --version
node --version
rustc --version
xcode-select -p
```

如果缺少 Xcode Command Line Tools 或 Rust,分别运行:

```bash
xcode-select --install
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```

Rust 安装完成后重新打开终端,再运行:

```bash
rustup toolchain install stable
```

Node.js 安装包和其他安装方式见 [Node.js 下载页](https://nodejs.org/zh-cn/download)。运行 `node --version` 时应显示 `v22.13.0` 或更高版本。

## 运行桌面客户端

在仓库根目录运行
克隆仓库并安装锁定版本的依赖

```bash
git clone https://github.com/fly1d/wordwise.git
cd wordwise
npm ci
```

启动桌面客户端:

```bash
npm run tauri dev
```

Expand All @@ -35,10 +60,24 @@ npm run tauri dev
- 本机 Ollama,建议使用 `qwen3:4b` 或更大的 Qwen 模型
- OpenAI 兼容 API 地址和你自己的 API Key

本地模式不需要 API Key。从 [Ollama macOS 下载页](https://ollama.com/download/mac) 安装并启动 Ollama,然后在另一个终端运行:

```bash
ollama pull qwen3:4b
```

回到逐词,保留“自动选择”或选择“Ollama 本地”。在任意应用中选中 `What's under the hood?`,按下 `Option + T`。首次成功时应同时看到完整中文译文、逐词结果,以及 `Ollama · qwen3:4b` 引擎标签。

自动模式会先使用可用的 Ollama,再使用已配置的云端 API。两者都不可用时,客户端会提示配置。离线词典是单独的查词模式,不会被冒充成语境整句翻译。

API Key 只保存在应用运行内存中,不会写入浏览器存储或提交到仓库。不要在公开 GitHub Issue 中提交 API Key、选中文字或私有文档。

## 常见阻塞

- 快捷键没有读到选区:检查“系统设置 -> 隐私与安全性 -> 辅助功能”中的逐词权限,然后重新启动开发客户端。
- 自动模式提示没有引擎:确认 Ollama 应用正在运行,并用 `ollama list` 检查模型是否已经下载。
- 端口被占用:退出已有的逐词开发进程后重试。

## 验证修改

提交 pull request 前运行:
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"license": "MIT",
"type": "module",
"engines": {
"node": ">=24"
"node": ">=22.13.0"
},
"scripts": {
"dev": "concurrently -k -n api,web -c yellow,cyan \"tsx watch server/index.ts\" \"vite --host 0.0.0.0\"",
Expand Down
66 changes: 65 additions & 1 deletion server/providers.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { isOfficialOpenAIUrl } from "./providers.js";
import { isOfficialOpenAIUrl, normalizePayload } from "./providers.js";
import { tokenize } from "./tokenize.js";

describe("OpenAI endpoint classification", () => {
it("accepts only OpenAI's domain and its subdomains", () => {
Expand All @@ -13,3 +14,66 @@ describe("OpenAI endpoint classification", () => {
expect(isOfficialOpenAIUrl("https://notopenai.com/v1")).toBe(false);
});
});

describe("model output normalization", () => {
const tokens = tokenize("LLM APIs");
const validPayload = {
fullTranslation: "大语言模型 API",
segments: [
{ id: 0, source: "LLM", kind: "word", translation: "大语言模型" },
{ id: 1, source: "APIs", kind: "word", translation: "应用程序接口" },
],
};

it("reconstructs successful output from trusted tokens", () => {
expect(normalizePayload(validPayload, tokens)).toEqual(validPayload);
});

it("rejects missing, extra, duplicate, or unknown token ids", () => {
expect(() =>
normalizePayload({ ...validPayload, segments: validPayload.segments.slice(0, 1) }, tokens),
).toThrow("预期 2 个");
expect(() =>
normalizePayload(
{ ...validPayload, segments: [...validPayload.segments, validPayload.segments[1]] },
tokens,
),
).toThrow("预期 2 个");
expect(() =>
normalizePayload(
{ ...validPayload, segments: [validPayload.segments[0], validPayload.segments[0]] },
tokens,
),
).toThrow("重复返回了词元 0");
expect(() =>
normalizePayload(
{
...validPayload,
segments: [validPayload.segments[0], { ...validPayload.segments[1], id: 2 }],
},
tokens,
),
).toThrow("未知词元 2");
});

it("rejects rewritten token metadata and blank translations", () => {
expect(() =>
normalizePayload(
{
...validPayload,
segments: [validPayload.segments[0], { ...validPayload.segments[1], source: "API" }],
},
tokens,
),
).toThrow("改写了词元 1");
expect(() =>
normalizePayload(
{
...validPayload,
segments: [validPayload.segments[0], { ...validPayload.segments[1], translation: " " }],
},
tokens,
),
).toThrow("没有翻译词元 1");
});
});
38 changes: 32 additions & 6 deletions server/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,35 +114,61 @@ function parseJsonContent(content: string): unknown {
return JSON.parse(trimmed.slice(start, end + 1));
}

function normalizePayload(raw: unknown, tokens: Token[]): TranslationPayload {
export function normalizePayload(raw: unknown, tokens: Token[]): TranslationPayload {
if (!raw || typeof raw !== "object") throw new Error("模型返回格式无效");
const candidate = raw as { fullTranslation?: unknown; segments?: unknown };
if (typeof candidate.fullTranslation !== "string" || !Array.isArray(candidate.segments)) {
throw new Error("模型返回缺少译文或逐词结果");
}
if (!candidate.fullTranslation.trim()) throw new Error("模型返回的完整译文为空");
if (candidate.segments.length !== tokens.length) {
throw new Error(`模型返回了 ${candidate.segments.length} 个词元,预期 ${tokens.length} 个`);
}

const expectedById = new Map(tokens.map((token) => [token.id, token]));
const byId = new Map<number, Record<string, unknown>>();
for (const item of candidate.segments) {
if (item && typeof item === "object" && typeof (item as { id?: unknown }).id === "number") {
byId.set((item as { id: number }).id, item as Record<string, unknown>);
if (!item || typeof item !== "object") throw new Error("模型返回了无效的词元");

const segment = item as Record<string, unknown>;
if (!Number.isSafeInteger(segment.id) || (segment.id as number) < 0) {
throw new Error("模型返回了无效的词元 ID");
}

const id = segment.id as number;
if (byId.has(id)) throw new Error(`模型重复返回了词元 ${id}`);

const expected = expectedById.get(id);
if (!expected) throw new Error(`模型返回了未知词元 ${id}`);
if (segment.source !== expected.source || segment.kind !== expected.kind) {
throw new Error(`模型改写了词元 ${id}: ${expected.source}`);
}
if (typeof segment.translation !== "string" || !segment.translation.trim()) {
throw new Error(`模型没有翻译词元 ${id}`);
}

byId.set(id, segment);
}

const segments: Segment[] = tokens.map((token) => {
const translated = byId.get(token.id);
if (!translated || typeof translated.translation !== "string") {
if (!translated) {
throw new Error(`模型漏掉了词元 ${token.id}: ${token.source}`);
}
const translation = translated.translation;
if (typeof translation !== "string") {
throw new Error(`模型没有翻译词元 ${token.id}`);
}
return {
...token,
translation: translated.translation,
translation,
...(typeof translated.note === "string" && translated.note
? { note: translated.note }
: {}),
};
});

return { fullTranslation: candidate.fullTranslation, segments };
return { fullTranslation: candidate.fullTranslation.trim(), segments };
}

async function translateWithOllama(
Expand Down
Loading
Loading