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
34 changes: 34 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 2021,
sourceType: "module",
ecmaFeatures: { jsx: true },
},
plugins: ["@typescript-eslint"],
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
env: {
node: true,
browser: true,
es2021: true,
},
ignorePatterns: [
"**/node_modules/**",
"**/dist/**",
"**/out/**",
"**/out-test/**",
"**/.turbo/**",
"**/release/**",
"**/*.js",
],
rules: {
"no-undef": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-empty-function": "warn",
"@typescript-eslint/no-non-null-assertion": "off",
"no-empty": ["warn", { allowEmptyCatch: true }],
},
};
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ VS Code 插件,支持浏览 AtCoder 竞赛题目、LaTeX 数学公式渲染、
- 📋 **题目浏览** — 输入比赛代号,一键加载所有题目列表与题面
- 📐 **LaTeX 渲染** — 服务端 KaTeX 预渲染,WebView 直接展示
- 🌐 **DeepL 翻译** — 题面段落自动翻译为中文(免费接口 / 官方 API 双模式)
- 🤖 **AI 翻译** — 兼容 OpenAI 接口(默认 DeepSeek),侧边栏可配置 API 地址 / 模型 / Key,保留 HTML 与 KaTeX 公式
- 📝 **报名比赛** — 支持评级(Rated)/ 非评级报名,自动处理 CSRF 与多步骤表单
- 📤 **提交代码** — 内置提交面板,提交后自动轮询判题状态
- 🧾 **提交历史 & 详情** — 查看个人提交列表、代码、逐测试点判定(Judge Result)
Expand Down Expand Up @@ -56,6 +57,7 @@ VS Code 插件,支持浏览 AtCoder 竞赛题目、LaTeX 数学公式渲染、
│ │ ├── standings.ts # 榜单 JSON 拉取
│ │ ├── homepage.ts # 首页比赛列表解析
│ │ ├── deepl.ts # DeepL 翻译(免费 / API)
│ │ ├── ai.ts # AI 翻译(OpenAI 兼容,默认 DeepSeek)
│ │ ├── cph.ts # CPH 问题构建与导出
│ │ ├── copy.ts # Markdown 复制
│ │ ├── types.ts # IncomingMessage / 提交记录类型
Expand Down Expand Up @@ -139,6 +141,15 @@ pnpm dev

`Ctrl+Shift+P` → `Set DeepL API Key`(免费模式无需配置)

### 设置 AI 翻译(OpenAI 兼容接口)

1. 侧边栏底部点击「AI 翻译设置」展开面板
2. 填写 `API 地址`(默认 `https://api.deepseek.com/v1`)、`模型`(默认 `deepseek-chat`)与 `API Key`
3. 点击「保存」;API Key 仅保存在 VS Code 加密凭据库中,不会明文保存
4. 题面工具栏的翻译模式下拉选择「AI」即可

也可用命令面板 `Set AI API Key` 单独设置 Key。任何 OpenAI 兼容服务(DeepSeek / OpenAI / 自建网关等)均可使用。

## 开发指南

### 脚本
Expand Down
6 changes: 5 additions & 1 deletion apps/vscode-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
{
"command": "extension.setAtCoderCookie",
"title": "Set AtCoder Login Cookie"
},
{
"command": "extension.setAiApiKey",
"title": "Set AI API Key"
}
],
"viewsContainers": {
Expand Down Expand Up @@ -60,7 +64,7 @@
"watch": "webpack --mode development --watch --progress",
"dev": "webpack --mode development --watch --progress",
"test": "node ./out/test/runTest.js",
"test:unit": "tsc -p tsconfig.test.json && node out-test/atcoder.test.js",
"test:unit": "tsc -p tsconfig.test.json && node out-test/atcoder.test.js && node out-test/ai.test.js",
"lint": "eslint src --ext ts",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist && rm -rf out",
"package": "vsce package --no-dependencies --allow-missing-repository --out ../../release/extension.vsix"
Expand Down
30 changes: 30 additions & 0 deletions apps/vscode-extension/src/ai.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as assert from "assert";
import { buildTranslateBody, parseTranslateResponse } from "./tools/ai";

interface ParsedBody {
model: string;
temperature: number;
stream: boolean;
messages: Array<{ role: string; content: string }>;
}

const body = JSON.parse(buildTranslateBody("hello", "ZH", "deepseek-chat")) as ParsedBody;
assert.strictEqual(body.model, "deepseek-chat", "model should be passed through");
assert.strictEqual(body.stream, false, "stream should be false");
assert.strictEqual(body.temperature, 0, "temperature should be 0");
assert.strictEqual(body.messages.length, 2, "should contain system + user messages");
assert.strictEqual(body.messages[0].role, "system", "first message should be system");
assert.ok(body.messages[0].content.includes("Simplified Chinese"), "system prompt should target Chinese");
assert.strictEqual(body.messages[1].role, "user", "second message should be user");
assert.strictEqual(body.messages[1].content, "hello", "user content should be the input text");
console.log("ai buildTranslateBody test passed");

const success = JSON.stringify({ choices: [{ message: { content: "你好" } }] });
assert.strictEqual(parseTranslateResponse(success), "你好", "should extract message content");
console.log("ai parseTranslateResponse success test passed");

const errorBody = JSON.stringify({ error: { message: "invalid key" } });
assert.strictEqual(parseTranslateResponse(errorBody), null, "error body should yield null");
assert.strictEqual(parseTranslateResponse("not json"), null, "invalid json should yield null");
assert.strictEqual(parseTranslateResponse(""), null, "empty body should yield null");
console.log("ai parseTranslateResponse failure test passed");
16 changes: 16 additions & 0 deletions apps/vscode-extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,21 @@ function registerSetDeeplApiKey(context: vscode.ExtensionContext) {
});
}

function registerSetAiApiKey(context: vscode.ExtensionContext) {
return vscode.commands.registerCommand("extension.setAiApiKey", async () => {
const key = await vscode.window.showInputBox({
prompt: t("ext.promptAiKey"),
password: true,
placeHolder: t("ext.placeholderAiKey"),
ignoreFocusOut: true,
});
if (key?.trim()) {
await context.secrets.store("aiApiKey", key.trim());
vscode.window.showInformationMessage(t("ext.aiKeySaved"));
}
});
}

function registerSetAtCoderCookie(context: vscode.ExtensionContext) {
return vscode.commands.registerCommand("extension.setAtCoderCookie", async () => {
const cookie = await vscode.window.showInputBox({
Expand Down Expand Up @@ -218,6 +233,7 @@ export async function activate(context: vscode.ExtensionContext) {
}

context.subscriptions.push(registerSetDeeplApiKey(context));
context.subscriptions.push(registerSetAiApiKey(context));
context.subscriptions.push(registerSetAtCoderCookie(context));
context.subscriptions.push(
vscode.commands.registerCommand("extension.showWebview", createShowWebview(context))
Expand Down
2 changes: 1 addition & 1 deletion apps/vscode-extension/src/tools/SignUpContest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export async function fetchContest(contest: string): Promise<ContestPage> {
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
const title = titleMatch ? titleMatch[1].trim() : "Untitled";
const formRegex = new RegExp(
`<form[^>]*action="[^"]*${contest}\/register"[^>]*>([\\s\\S]*?)<\\/form>`,
`<form[^>]*action="[^"]*${contest}/register"[^>]*>([\\s\\S]*?)<\\/form>`,
"i"
);
const from = html.match(formRegex)?.[1];
Expand Down
204 changes: 204 additions & 0 deletions apps/vscode-extension/src/tools/ai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import * as https from "https";
import * as http from "http";
import * as stream from "stream";
import * as zlib from "zlib";
import { t } from "./i18n";

const MAX_ATTEMPTS = 3;
const DEFAULT_TEMPERATURE = 0;
const REQUEST_TIMEOUT = 60000;

export interface AiTranslateConfig {
baseUrl: string
model: string
}

interface TranslateMessage {
role: "system" | "user";
content: string;
}

interface TranslateBody {
model: string
temperature: number
stream: false
messages: TranslateMessage[]
}

interface RawResponse {
statusCode?: number;
contentType?: string;
body: string;
}

interface Endpoint {
hostname: string;
port?: string;
path: string;
}

const SYSTEM_PROMPT =
"You are a professional translator for competitive programming problem statements. " +
"Translate the user's HTML content into {lang}. " +
"Preserve ALL HTML tags and attributes exactly, and keep math formulas " +
"(e.g. $...$, \\(...\\), \\[...\\] and <span class=\"katex\">...</span>) unchanged. " +
"Do not translate code, identifiers, variables or URLs. " +
"Output ONLY the translated content, with no explanations and no markdown fences.";

const LANG_NAMES: Record<string, string> = {
ZH: "Simplified Chinese",
EN: "English",
JA: "Japanese",
};

function resolveTargetLang(targetLang: string): string {
return LANG_NAMES[targetLang.toUpperCase()] ?? targetLang;
}

export function buildTranslateBody(text: string, targetLang: string, model: string): string {
const body: TranslateBody = {
model,
temperature: DEFAULT_TEMPERATURE,
stream: false,
messages: [
{ role: "system", content: SYSTEM_PROMPT.replace("{lang}", resolveTargetLang(targetLang)) },
{ role: "user", content: text },
],
};
return JSON.stringify(body);
}

function parseEndpoint(baseUrl: string): Endpoint {
const normalized = baseUrl.trim().replace(/\/+$/, "");
const url = new URL(`${normalized}/chat/completions`);
return {
hostname: url.hostname,
port: url.port || undefined,
path: url.pathname + url.search,
};
}

function readResponse(res: http.IncomingMessage): Promise<string> {
const rawEncoding = res.headers["content-encoding"];
const encoding = Array.isArray(rawEncoding) ? rawEncoding.join(",") : (rawEncoding ?? "");
let readable: stream.Readable = res;
if (encoding.includes("br")) readable = res.pipe(zlib.createBrotliDecompress());
else if (encoding.includes("gzip")) readable = res.pipe(zlib.createGunzip());
else if (encoding.includes("deflate")) readable = res.pipe(zlib.createInflate());

return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
readable.on("data", (chunk: Buffer) => chunks.push(chunk));
readable.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
readable.on("error", reject);
});
}

function requestAi(options: https.RequestOptions, postData: string): Promise<RawResponse> {
return new Promise((resolve, reject) => {
let settled = false;
const settle = (fn: () => void) => { if (!settled) { settled = true; fn(); } };

const req = https.request(options, (res) => {
readResponse(res).then(
(body) => settle(() => resolve({ statusCode: res.statusCode, contentType: res.headers["content-type"], body })),
(err) => settle(() => reject(err)),
);
});

req.setTimeout(REQUEST_TIMEOUT, () => req.destroy(new Error(t("ai.timeout"))));
req.on("error", (err: Error) => settle(() => reject(err)));
req.write(postData);
req.end();
});
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function requestWithRetry(options: https.RequestOptions, postData: string): Promise<RawResponse> {
let last: RawResponse = { statusCode: 429, body: "" };
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const res = await requestAi(options, postData);
last = res;
if (res.statusCode === 429 && attempt < MAX_ATTEMPTS) {
const delay = 1000 * 2 ** (attempt - 1);
console.log(`[ai] 限流 429(第 ${attempt}/${MAX_ATTEMPTS} 次),${delay}ms 后重试`);
await sleep(delay);
continue;
}
return res;
}
return last;
}

function stripBom(text: string): string {
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}

function parseJson(body: string): Record<string, unknown> | null {
try {
const json: unknown = JSON.parse(stripBom(body));
return json && typeof json === "object" ? (json as Record<string, unknown>) : null;
} catch {
return null;
}
}

function getPath(obj: unknown, path: string): unknown {
return path.split(".").reduce<unknown>((acc, key) => {
if (acc && typeof acc === "object" && key in (acc as Record<string, unknown>)) {
return (acc as Record<string, unknown>)[key];
}
return undefined;
}, obj);
}

export function parseTranslateResponse(body: string): string | null {
const json = parseJson(body);
const content = json ? getPath(json, "choices.0.message.content") : undefined;
return typeof content === "string" ? content : null;
}

function buildTranslateError(statusCode: number | undefined, body: string): Error {
if (statusCode === 429) return new Error(t("ai.rateLimited"));
const json = parseJson(body);
const message = json ? getPath(json, "error.message") : undefined;
if (typeof message === "string" && message) return new Error(message);
return new Error(t("ai.httpError", { status: statusCode ?? "?" }));
}

export async function translateTextAI(
text: string,
targetLang: string,
config: AiTranslateConfig,
apiKey: string,
): Promise<string> {
const postData = buildTranslateBody(text, targetLang, config.model);
const endpoint = parseEndpoint(config.baseUrl);

const res = await requestWithRetry(
{
hostname: endpoint.hostname,
port: endpoint.port,
path: endpoint.path,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(postData),
Authorization: `Bearer ${apiKey}`,
},
},
postData,
);

if (res.statusCode && res.statusCode >= 400) {
throw buildTranslateError(res.statusCode, res.body);
}
const translated = parseTranslateResponse(res.body);
if (translated !== null) {
return translated;
}
throw new Error(t("ai.unexpectedStructure"));
}
Loading
Loading