diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..aed11d4 --- /dev/null +++ b/.eslintrc.cjs @@ -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 }], + }, +}; diff --git a/README.md b/README.md index 2b07b62..a8a65dd 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ VS Code 插件,支持浏览 AtCoder 竞赛题目、LaTeX 数学公式渲染、 - 📋 **题目浏览** — 输入比赛代号,一键加载所有题目列表与题面 - 📐 **LaTeX 渲染** — 服务端 KaTeX 预渲染,WebView 直接展示 - 🌐 **DeepL 翻译** — 题面段落自动翻译为中文(免费接口 / 官方 API 双模式) +- 🤖 **AI 翻译** — 兼容 OpenAI 接口(默认 DeepSeek),侧边栏可配置 API 地址 / 模型 / Key,保留 HTML 与 KaTeX 公式 - 📝 **报名比赛** — 支持评级(Rated)/ 非评级报名,自动处理 CSRF 与多步骤表单 - 📤 **提交代码** — 内置提交面板,提交后自动轮询判题状态 - 🧾 **提交历史 & 详情** — 查看个人提交列表、代码、逐测试点判定(Judge Result) @@ -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 / 提交记录类型 @@ -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 / 自建网关等)均可使用。 + ## 开发指南 ### 脚本 diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index b2323b3..33de1d9 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -33,6 +33,10 @@ { "command": "extension.setAtCoderCookie", "title": "Set AtCoder Login Cookie" + }, + { + "command": "extension.setAiApiKey", + "title": "Set AI API Key" } ], "viewsContainers": { @@ -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" diff --git a/apps/vscode-extension/src/ai.test.ts b/apps/vscode-extension/src/ai.test.ts new file mode 100644 index 0000000..c919e12 --- /dev/null +++ b/apps/vscode-extension/src/ai.test.ts @@ -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"); diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 09c1bfb..bb87cd1 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -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({ @@ -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)) diff --git a/apps/vscode-extension/src/tools/SignUpContest.ts b/apps/vscode-extension/src/tools/SignUpContest.ts index bee85ff..7742278 100644 --- a/apps/vscode-extension/src/tools/SignUpContest.ts +++ b/apps/vscode-extension/src/tools/SignUpContest.ts @@ -16,7 +16,7 @@ export async function fetchContest(contest: string): Promise { const titleMatch = html.match(/([^<]+)<\/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]; diff --git a/apps/vscode-extension/src/tools/ai.ts b/apps/vscode-extension/src/tools/ai.ts new file mode 100644 index 0000000..14be0e1 --- /dev/null +++ b/apps/vscode-extension/src/tools/ai.ts @@ -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")); +} \ No newline at end of file diff --git a/apps/vscode-extension/src/tools/command.ts b/apps/vscode-extension/src/tools/command.ts index 511533c..1fca53f 100644 --- a/apps/vscode-extension/src/tools/command.ts +++ b/apps/vscode-extension/src/tools/command.ts @@ -17,6 +17,8 @@ import { handleFetchStandings, handleGetContests, handleFetchSubmissionDetail, + handleGetAiConfig, + handleSetAiConfig, } from "./handle"; const loadCommands = new Set(["loadContest", "loadProblem", "openBrowser"]); @@ -25,6 +27,7 @@ const cookieCommands = new Set(["getCookie", "setCookie"]); const problemCommands = new Set(["registerContest", "copyMarkdown", "alert", "sendCph"]); const submitCommands = new Set(["fetchSubmitPage", "submitCode", "fetchSubmissionHistory", "fetchSubmissionDetail"]); const contestCommands = new Set(["fetchStandings", "getContests", "openContest", "openSubmission"]); +const configCommands = new Set(["getAiConfig", "setAiConfig"]); export async function runCommand(message: IncomingMessage, context: vscode.ExtensionContext, sendToWebview: (payload: Record<string, unknown>) => void,) { if (loadCommands.has(message.command!)) await runLoadCommand(message, sendToWebview); @@ -33,9 +36,23 @@ export async function runCommand(message: IncomingMessage, context: vscode.Exten else if (problemCommands.has(message.command!)) await runProblem(message, context, sendToWebview); else if (submitCommands.has(message.command!)) await runSubmit(message, context, sendToWebview); else if (contestCommands.has(message.command!)) await runContest(message, context, sendToWebview); + else if (configCommands.has(message.command!)) await runConfig(message, context, sendToWebview); else throw new Error(t("cmd.unknown")); } +async function runConfig(command: IncomingMessage, context: vscode.ExtensionContext, sendToWebview: (payload: Record<string, unknown>) => void,): Promise<boolean> { + switch (command.command) { + case "getAiConfig": + await handleGetAiConfig(context, sendToWebview); + return true; + case "setAiConfig": + await handleSetAiConfig(command, context, sendToWebview); + return true; + default: + return false; + } +} + async function runContest(command: IncomingMessage, context: vscode.ExtensionContext, sendToWebview: (payload: Record<string, unknown>) => void,): Promise<boolean> { switch (command.command) { case "fetchStandings": diff --git a/apps/vscode-extension/src/tools/handle.ts b/apps/vscode-extension/src/tools/handle.ts index 756240b..8aee257 100644 --- a/apps/vscode-extension/src/tools/handle.ts +++ b/apps/vscode-extension/src/tools/handle.ts @@ -3,6 +3,7 @@ import { AtCoderProblem, fetchAtCoderProblem, fetchAtCoderTasks } from "../atcod import { CfError, ProxyError, LoginRequiredError, setSessionCookie, fetchSubStatus, fetchSubmitHistory, getSessionCookie } from "./fetch"; import { fetchContest, signedUpContest, fetchContestAnnouncement } from "./SignUpContest"; import { translateTextRaw, translateTextFree } from "./deepl"; +import { translateTextAI, AiTranslateConfig } from "./ai"; import { fetchSubmitPage, submitCodeWithRedirect } from "./submit"; import { buildCphProblem, sendToCph } from "./cph"; import { fetchStandings } from "./standings"; @@ -68,41 +69,104 @@ export async function handleTranslate( targetLang: string | undefined, context: vscode.ExtensionContext, send: (payload: Record<string, unknown>) => void, - translationMode?: "api" | "free", + translationMode?: "api" | "free" | "ai", ) { const lang = targetLang ?? "ZH"; const texts = payload ?? {}; - const translated: Record<string, string> = {}; try { - if (translationMode === "free") { - for (const [key, value] of Object.entries(texts)) { - if (typeof value === "string" && value.trim()) { - send({ type: "loading", text: t("load.translateItem", { name: key }) }); - translated[key] = await translateTextFree(value, lang); - } - } - } else { - const apiKey = await context.secrets.get("deeplApiKey"); - if (!apiKey) { - const set = t("deepl.setKey"); - const choice = await vscode.window.showErrorMessage(t("deepl.setKeyFirst"), set); - if (choice === set) vscode.commands.executeCommand("extension.setDeeplApiKey"); - send({ type: "error", text: t("deepl.noKey") }); - return; - } - for (const [key, value] of Object.entries(texts)) { - if (typeof value === "string" && value.trim()) { - send({ type: "loading", text: t("load.translateItem", { name: key }) }); - translated[key] = await translateTextRaw(value, lang, apiKey); - } - } - } + const translateOne = await resolveTranslator(context, send, lang, translationMode); + if (!translateOne) return; + const translated = await translateAll(texts, send, translateOne); send({ type: "translation", translated }); } catch (error) { send({ type: "error", text: error instanceof Error ? error.message : t("err.translate") }); } } +async function resolveTranslator( + context: vscode.ExtensionContext, + send: (payload: Record<string, unknown>) => void, + lang: string, + translationMode: "api" | "free" | "ai" | undefined, +): Promise<((value: string) => Promise<string>) | null> { + if (translationMode === "free") { + return (value) => translateTextFree(value, lang); + } + if (translationMode === "ai") { + return createAiTranslator(context, send, lang); + } + const apiKey = await context.secrets.get("deeplApiKey"); + if (!apiKey) { + const set = t("deepl.setKey"); + const choice = await vscode.window.showErrorMessage(t("deepl.setKeyFirst"), set); + if (choice === set) vscode.commands.executeCommand("extension.setDeeplApiKey"); + send({ type: "error", text: t("deepl.noKey") }); + return null; + } + return (value) => translateTextRaw(value, lang, apiKey); +} + +async function createAiTranslator( + context: vscode.ExtensionContext, + send: (payload: Record<string, unknown>) => void, + lang: string, +): Promise<((value: string) => Promise<string>) | null> { + const apiKey = await context.secrets.get("aiApiKey"); + if (!apiKey) { + const set = t("ai.setKey"); + const choice = await vscode.window.showErrorMessage(t("ai.setKeyFirst"), set); + if (choice === set) vscode.commands.executeCommand("extension.setAiApiKey"); + send({ type: "error", text: t("ai.noKey") }); + return null; + } + const config = readAiConfig(context); + return (value) => translateTextAI(value, lang, config, apiKey); +} + +async function translateAll( + texts: Record<string, string>, + send: (payload: Record<string, unknown>) => void, + translateOne: (value: string) => Promise<string>, +): Promise<Record<string, string>> { + const translated: Record<string, string> = {}; + for (const [key, value] of Object.entries(texts)) { + if (typeof value === "string" && value.trim()) { + send({ type: "loading", text: t("load.translateItem", { name: key }) }); + translated[key] = await translateOne(value); + } + } + return translated; +} + +const DEFAULT_AI_BASE_URL = "https://api.deepseek.com/v1"; +const DEFAULT_AI_MODEL = "deepseek-chat"; + +function readAiConfig(context: vscode.ExtensionContext): AiTranslateConfig { + const baseUrl = context.globalState.get<string>("aiBaseUrl") || DEFAULT_AI_BASE_URL; + const model = context.globalState.get<string>("aiModel") || DEFAULT_AI_MODEL; + return { baseUrl, model }; +} + +export async function handleGetAiConfig( + context: vscode.ExtensionContext, + send: (payload: Record<string, unknown>) => void, +) { + const config = readAiConfig(context); + const hasAiKey = !!(await context.secrets.get("aiApiKey")); + send({ type: "aiConfig", aiBaseUrl: config.baseUrl, aiModel: config.model, hasAiKey }); +} + +export async function handleSetAiConfig( + message: { aiBaseUrl?: string; aiModel?: string; aiApiKey?: string }, + context: vscode.ExtensionContext, + send: (payload: Record<string, unknown>) => void, +) { + if (message.aiBaseUrl?.trim()) await context.globalState.update("aiBaseUrl", message.aiBaseUrl.trim()); + if (message.aiModel?.trim()) await context.globalState.update("aiModel", message.aiModel.trim()); + if (message.aiApiKey?.trim()) await context.secrets.store("aiApiKey", message.aiApiKey.trim()); + await handleGetAiConfig(context, send); +} + export async function handleProblemLoad(contest: string, task: string, send: (payload: Record<string, unknown>) => void) { send({ type: "loading", text: t("load.problem", { contest, task }) }); try { diff --git a/apps/vscode-extension/src/tools/i18n/en.ts b/apps/vscode-extension/src/tools/i18n/en.ts index 2a90894..f38cd44 100644 --- a/apps/vscode-extension/src/tools/i18n/en.ts +++ b/apps/vscode-extension/src/tools/i18n/en.ts @@ -6,6 +6,9 @@ export const en: Record<keyof ZhDict, string> = { "ext.promptDeeplKey": "Enter DeepL API Key", "ext.placeholderDeeplKey": "e.g. xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:fx", "ext.deeplKeySaved": "DeepL API Key saved", + "ext.promptAiKey": "Enter AI API Key", + "ext.placeholderAiKey": "e.g. sk-xxxxxxxxxxxxxxxx", + "ext.aiKeySaved": "AI API Key saved", "ext.promptCookie": "Paste your AtCoder Cookie (REVEL_SESSION only)", "ext.placeholderCookie": "REVEL_SESSION=abcdef1234567890abcdef1234567890", "ext.cookieFormatWarn": "The Cookie format looks incorrect. Add the REVEL_SESSION= prefix?", @@ -94,6 +97,14 @@ export const en: Record<keyof ZhDict, string> = { "deepl.failed": "Translation request failed: {msg}", "deepl.failedSimple": "Translation request failed", + "ai.setKeyFirst": "Please set an AI API Key first", + "ai.setKey": "Set API Key", + "ai.noKey": "AI API Key is not set", + "ai.rateLimited": "AI translation is rate limited (HTTP 429), please try again later", + "ai.httpError": "AI translation API error ({status})", + "ai.unexpectedStructure": "AI translation API returned an unrecognized response structure", + "ai.timeout": "AI translation request timed out", + "submit.pageFetchFailed": "Cannot fetch the submit page: the contest may have ended, or the submit page structure has changed.", "submit.noCsrf": "Cannot get CSRF Token, please check whether the Cookie is valid", "submit.success": "Code submitted successfully", diff --git a/apps/vscode-extension/src/tools/i18n/ja.ts b/apps/vscode-extension/src/tools/i18n/ja.ts index e3ddf2b..082950d 100644 --- a/apps/vscode-extension/src/tools/i18n/ja.ts +++ b/apps/vscode-extension/src/tools/i18n/ja.ts @@ -6,6 +6,9 @@ export const ja: Record<keyof ZhDict, string> = { "ext.promptDeeplKey": "DeepL API Key を入力してください", "ext.placeholderDeeplKey": "例 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:fx", "ext.deeplKeySaved": "DeepL API Key を保存しました", + "ext.promptAiKey": "AI API Key を入力してください", + "ext.placeholderAiKey": "例 sk-xxxxxxxxxxxxxxxx", + "ext.aiKeySaved": "AI API Key を保存しました", "ext.promptCookie": "AtCoder の Cookie を貼り付けてください(REVEL_SESSION のみ)", "ext.placeholderCookie": "REVEL_SESSION=abcdef1234567890abcdef1234567890", "ext.cookieFormatWarn": "Cookie の形式が正しくないようです。REVEL_SESSION= の接頭辞を追加しますか?", @@ -94,6 +97,14 @@ export const ja: Record<keyof ZhDict, string> = { "deepl.failed": "翻訳リクエストに失敗しました: {msg}", "deepl.failedSimple": "翻訳リクエストに失敗しました", + "ai.setKeyFirst": "先に AI API Key を設定してください", + "ai.setKey": "API Key を設定", + "ai.noKey": "AI API Key が設定されていません", + "ai.rateLimited": "AI 翻訳がレート制限されました(HTTP 429)。しばらくしてからもう一度お試しください", + "ai.httpError": "AI 翻訳 API エラー ({status})", + "ai.unexpectedStructure": "AI 翻訳 API が異常な応答を返しました: レスポンス構造を認識できません", + "ai.timeout": "AI 翻訳リクエストがタイムアウトしました", + "submit.pageFetchFailed": "提出ページを取得できませんでした。コンテストが終了しているか、提出ページの構造が変更された可能性があります。", "submit.noCsrf": "CSRF Token を取得できませんでした。Cookie が有効か確認してください", "submit.success": "コードの提出に成功しました", diff --git a/apps/vscode-extension/src/tools/i18n/zh.ts b/apps/vscode-extension/src/tools/i18n/zh.ts index c4a999a..cf876d3 100644 --- a/apps/vscode-extension/src/tools/i18n/zh.ts +++ b/apps/vscode-extension/src/tools/i18n/zh.ts @@ -4,6 +4,9 @@ export const zh = { "ext.promptDeeplKey": "请输入 DeepL API Key", "ext.placeholderDeeplKey": "例如 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:fx", "ext.deeplKeySaved": "DeepL API Key 已保存", + "ext.promptAiKey": "请输入 AI API Key", + "ext.placeholderAiKey": "例如 sk-xxxxxxxxxxxxxxxx", + "ext.aiKeySaved": "AI API Key 已保存", "ext.promptCookie": "粘贴 AtCoder 的 Cookie(仅需 REVEL_SESSION)", "ext.placeholderCookie": "REVEL_SESSION=abcdef1234567890abcdef1234567890", "ext.cookieFormatWarn": "Cookie 格式似乎不正确,是否添加 REVEL_SESSION= 前缀?", @@ -92,6 +95,14 @@ export const zh = { "deepl.failed": "翻译请求失败: {msg}", "deepl.failedSimple": "翻译请求失败", + "ai.setKeyFirst": "请先设置 AI API Key", + "ai.setKey": "设置 API Key", + "ai.noKey": "未设置 AI API Key", + "ai.rateLimited": "AI 翻译被限流(HTTP 429),请稍后再试", + "ai.httpError": "AI 翻译接口错误 ({status})", + "ai.unexpectedStructure": "AI 翻译接口返回异常:响应结构无法识别", + "ai.timeout": "AI 翻译请求超时", + "submit.pageFetchFailed": "无法获取提交页面:比赛可能已结束,或提交页面结构发生了变化。", "submit.noCsrf": "无法获取 CSRF Token,请检查 Cookie 是否有效", "submit.success": "代码提交成功", diff --git a/apps/vscode-extension/src/tools/types.ts b/apps/vscode-extension/src/tools/types.ts index 9ffd8ed..341f9ec 100644 --- a/apps/vscode-extension/src/tools/types.ts +++ b/apps/vscode-extension/src/tools/types.ts @@ -11,7 +11,10 @@ export interface IncomingMessage { url?: string; payload?: Record<string, string>; targetLang?: string; - translationMode?: "api" | "free"; + translationMode?: "api" | "free" | "ai"; + aiBaseUrl?: string; + aiModel?: string; + aiApiKey?: string; text?: string; rated?: boolean; problem?: AtCoderProblem; diff --git a/packages/webview/package.json b/packages/webview/package.json index 981126a..f3ecf1c 100644 --- a/packages/webview/package.json +++ b/packages/webview/package.json @@ -7,6 +7,7 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", + "lint": "eslint src/", "clean": "rm -rf dist" }, "dependencies": { diff --git a/packages/webview/src/SidebarApp.tsx b/packages/webview/src/SidebarApp.tsx index 0e61b7a..a8f263e 100644 --- a/packages/webview/src/SidebarApp.tsx +++ b/packages/webview/src/SidebarApp.tsx @@ -3,6 +3,7 @@ import { Button, Spinner } from "@template/ui"; import { useVSCode } from "./VSCodeProvider"; import { useI18n } from "./i18n"; import { SubmissionList } from "./components/SubmissionHistory"; +import { AiSettingsPanel } from "./components/AiSettingsPanel"; import { useWebviewMessage } from "./hooks/useWebviewMessage"; import { formatStart } from "./utils/format"; import type { HomepageContest, SubmissionRecord } from "./types"; @@ -26,6 +27,11 @@ const SidebarApp: React.FC = () => { const [hasCookie, setHasCookie] = React.useState<boolean | null>(null); const [showLogin, setShowLogin] = React.useState(false); const [cookieInput, setCookieInput] = React.useState(""); + const [showAiSettings, setShowAiSettings] = React.useState(false); + const [aiBaseUrl, setAiBaseUrl] = React.useState(""); + const [aiModel, setAiModel] = React.useState(""); + const [aiKeyInput, setAiKeyInput] = React.useState(""); + const [hasAiKey, setHasAiKey] = React.useState(false); const currentContestRef = React.useRef<string | null>(null); currentContestRef.current = currentContest; @@ -52,6 +58,17 @@ const SidebarApp: React.FC = () => { setStatus(t("cookie.cleared")); }; + const handleAiSave = () => { + setStatus(t("ai.saved")); + vscode.postMessage({ + command: "setAiConfig", + aiBaseUrl: aiBaseUrl.trim(), + aiModel: aiModel.trim(), + aiApiKey: aiKeyInput.trim(), + }); + setAiKeyInput(""); + }; + const fetchHistory = (contest: string) => { setLoadingHistory(true); setStatus(t("status.fetchingHistory", { contest })); @@ -67,6 +84,7 @@ const SidebarApp: React.FC = () => { React.useEffect(() => { fetchContests(); vscode.postMessage({ command: "getCookie" }); + vscode.postMessage({ command: "getAiConfig" }); }, []); useWebviewMessage({ @@ -109,6 +127,11 @@ const SidebarApp: React.FC = () => { setShowLogin(true); setStatus(t("cookie.loginRequired")); }, + aiConfig: (message) => { + if (typeof message.aiBaseUrl === "string") setAiBaseUrl(message.aiBaseUrl); + if (typeof message.aiModel === "string") setAiModel(message.aiModel); + if (typeof message.hasAiKey === "boolean") setHasAiKey(message.hasAiKey); + }, error: (message) => { setStatus(message.text ?? t("err.operationFailed")); setLoadingList(false); @@ -160,6 +183,28 @@ const SidebarApp: React.FC = () => { )} </div> )} + <div className="p-2 border-b border-[var(--vscode-panel-border)]"> + <Button + size="sm" + variant="secondary" + onClick={() => setShowAiSettings((v) => !v)} + className="h-[24px] text-[11px] w-full" + > + {showAiSettings ? t("ui.collapse") : t("ai.title")} + </Button> + </div> + {showAiSettings && ( + <AiSettingsPanel + baseUrl={aiBaseUrl} + model={aiModel} + hasAiKey={hasAiKey} + apiKeyInput={aiKeyInput} + onBaseUrlChange={setAiBaseUrl} + onModelChange={setAiModel} + onApiKeyChange={setAiKeyInput} + onSave={handleAiSave} + /> + )} <div className="flex-1 flex flex-col min-h-0 border-b border-[var(--vscode-panel-border)]"> <div className="p-2 flex items-center justify-between border-b border-[var(--vscode-panel-border)]"> <div className="text-[12px] font-semibold">{t("ui.contestsTitle")}</div> diff --git a/packages/webview/src/components/AiSettingsPanel.tsx b/packages/webview/src/components/AiSettingsPanel.tsx new file mode 100644 index 0000000..a8cd42a --- /dev/null +++ b/packages/webview/src/components/AiSettingsPanel.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { Button } from "@template/ui"; +import { useI18n } from "../i18n"; + +interface AiSettingsPanelProps { + baseUrl: string; + model: string; + hasAiKey: boolean; + apiKeyInput: string; + onBaseUrlChange: (value: string) => void; + onModelChange: (value: string) => void; + onApiKeyChange: (value: string) => void; + onSave: () => void; +} + +const inputClass = + "w-full h-[26px] text-[12px] px-2 rounded border border-[var(--vscode-input-border,#6e7681)] bg-[var(--vscode-input-background)] text-[var(--vscode-input-foreground)] outline-none focus:border-[var(--vscode-focusBorder)]"; + +const AiSettingsPanel: React.FC<AiSettingsPanelProps> = ({ + baseUrl, + model, + hasAiKey, + apiKeyInput, + onBaseUrlChange, + onModelChange, + onApiKeyChange, + onSave, +}) => { + const { t } = useI18n(); + + return ( + <div className="p-2 space-y-2 border-b border-[var(--vscode-panel-border)] bg-[var(--vscode-textBlockQuote-background)]"> + <div className="text-[12px] font-semibold">{t("ai.title")}</div> + <div className="text-[11px] opacity-70 leading-relaxed">{t("ai.description")}</div> + <label className="block space-y-1"> + <span className="text-[11px] opacity-70">{t("ai.baseUrl")}</span> + <input + type="text" + value={baseUrl} + onChange={(e) => onBaseUrlChange(e.target.value)} + placeholder={t("ai.baseUrlPlaceholder")} + className={inputClass} + /> + </label> + <label className="block space-y-1"> + <span className="text-[11px] opacity-70">{t("ai.model")}</span> + <input + type="text" + value={model} + onChange={(e) => onModelChange(e.target.value)} + placeholder={t("ai.modelPlaceholder")} + className={inputClass} + /> + </label> + <label className="block space-y-1"> + <span className="text-[11px] opacity-70">{t("ai.apiKey")}</span> + <input + type="password" + value={apiKeyInput} + onChange={(e) => onApiKeyChange(e.target.value)} + placeholder={hasAiKey ? t("ai.apiKeyPlaceholderHas") : t("ai.apiKeyPlaceholderEmpty")} + className={inputClass} + /> + </label> + <div className="flex items-center gap-2"> + <Button onClick={onSave} size="sm" className="h-[26px] text-[11px]"> + {t("ui.save")} + </Button> + </div> + </div> + ); +}; + +export { AiSettingsPanel }; diff --git a/packages/webview/src/components/ProblemView.tsx b/packages/webview/src/components/ProblemView.tsx index 54f0802..38a82fb 100644 --- a/packages/webview/src/components/ProblemView.tsx +++ b/packages/webview/src/components/ProblemView.tsx @@ -10,9 +10,9 @@ interface ProblemViewProps { problem: ContestProblem; translated: Record<string, string> | null; translating: boolean; - translationMode: "api" | "free"; + translationMode: "api" | "free" | "ai"; onTranslate: () => void; - onTranslationModeChange: (mode: "api" | "free") => void; + onTranslationModeChange: (mode: "api" | "free" | "ai") => void; onCopyMarkdown: () => void; onExportCph: () => void; } @@ -53,12 +53,13 @@ const ProblemView: React.FC<ProblemViewProps> = ({ </Button> <select value={translationMode} - onChange={(e) => onTranslationModeChange(e.target.value as "api" | "free")} + onChange={(e) => onTranslationModeChange(e.target.value as "api" | "free" | "ai")} className="h-[26px] text-[11px] px-1 rounded border border-[var(--vscode-input-border,#6e7681)] bg-[var(--vscode-input-background)] text-[var(--vscode-input-foreground)] outline-none" title={t("ui.translationMode")} > <option value="free">{t("ui.free")}</option> <option value="api">{t("ui.api")}</option> + <option value="ai">{t("ui.ai")}</option> </select> <Button onClick={onCopyMarkdown} size="sm" variant="secondary" className="h-[26px] text-[11px]"> {t("ui.copyMarkdown")} diff --git a/packages/webview/src/hooks/useTranslation.ts b/packages/webview/src/hooks/useTranslation.ts index 6eff8f1..5307d9e 100644 --- a/packages/webview/src/hooks/useTranslation.ts +++ b/packages/webview/src/hooks/useTranslation.ts @@ -7,8 +7,8 @@ interface UseTranslationReturn { translatedCache: Record<string, Record<string, string>>; translated: Record<string, string> | null; translating: boolean; - translationMode: "api" | "free"; - setTranslationMode: React.Dispatch<React.SetStateAction<"api" | "free">>; + translationMode: "api" | "free" | "ai"; + setTranslationMode: React.Dispatch<React.SetStateAction<"api" | "free" | "ai">>; setTranslating: React.Dispatch<React.SetStateAction<boolean>>; translate: (problem: ContestProblem | null) => void; applyTranslation: (message: WebviewMessage) => void; @@ -24,7 +24,7 @@ export function useTranslation( const [translatedCache, setTranslatedCache] = React.useState<Record<string, Record<string, string>>>({}); const [translated, setTranslated] = React.useState<Record<string, string> | null>(null); const [translating, setTranslating] = React.useState(false); - const [translationMode, setTranslationMode] = React.useState<"api" | "free">("free"); + const [translationMode, setTranslationMode] = React.useState<"api" | "free" | "ai">("free"); const translate = (problem: ContestProblem | null) => { if (!problem) return; diff --git a/packages/webview/src/i18n/en.ts b/packages/webview/src/i18n/en.ts index 0d1ee29..3d53904 100644 --- a/packages/webview/src/i18n/en.ts +++ b/packages/webview/src/i18n/en.ts @@ -95,6 +95,18 @@ export const en: Record<keyof ZhDict, string> = { "ui.translationMode": "Translation mode", "ui.free": "Free", "ui.api": "API", + "ui.ai": "AI", + + "ai.title": "AI Translation Settings", + "ai.description": "OpenAI-compatible API. The API Key is stored only in local encrypted storage and never saved in plaintext.", + "ai.baseUrl": "API Base URL", + "ai.baseUrlPlaceholder": "https://api.deepseek.com/v1", + "ai.model": "Model", + "ai.modelPlaceholder": "deepseek-chat", + "ai.apiKey": "API Key", + "ai.apiKeyPlaceholderHas": "API Key saved, enter a new value to overwrite", + "ai.apiKeyPlaceholderEmpty": "Paste API Key", + "ai.saved": "AI translation settings saved", "ui.copyMarkdown": "Copy Markdown", "ui.exportCph": "Export CPH", "ui.exportCphTitle": "Export to CPH (requires Competitive Programming Helper)", diff --git a/packages/webview/src/i18n/ja.ts b/packages/webview/src/i18n/ja.ts index 0e6dbdb..56c137e 100644 --- a/packages/webview/src/i18n/ja.ts +++ b/packages/webview/src/i18n/ja.ts @@ -93,6 +93,18 @@ export const ja: Record<keyof ZhDict, string> = { "ui.translationMode": "翻訳モード", "ui.free": "無料", "ui.api": "API", + "ui.ai": "AI", + + "ai.title": "AI 翻訳設定", + "ai.description": "OpenAI 互換 API。API Key はローカルの暗号化ストレージにのみ保存され、平文では保存されません。", + "ai.baseUrl": "API アドレス", + "ai.baseUrlPlaceholder": "https://api.deepseek.com/v1", + "ai.model": "モデル", + "ai.modelPlaceholder": "deepseek-chat", + "ai.apiKey": "API Key", + "ai.apiKeyPlaceholderHas": "API Key は保存済みです。上書きするには新しい値を入力してください", + "ai.apiKeyPlaceholderEmpty": "API Key を貼り付けてください", + "ai.saved": "AI 翻訳設定を保存しました", "ui.copyMarkdown": "Markdown をコピー", "ui.exportCph": "CPH にエクスポート", "ui.exportCphTitle": "CPH にエクスポート(Competitive Programming Helper が必要)", diff --git a/packages/webview/src/i18n/zh.ts b/packages/webview/src/i18n/zh.ts index af2f534..9bcb554 100644 --- a/packages/webview/src/i18n/zh.ts +++ b/packages/webview/src/i18n/zh.ts @@ -90,6 +90,18 @@ export const zh = { "ui.translationMode": "翻译模式", "ui.free": "免费", "ui.api": "API", + "ui.ai": "AI", + + "ai.title": "AI 翻译设置", + "ai.description": "兼容 OpenAI 接口。API Key 仅保存在本地加密存储中,不会明文保存。", + "ai.baseUrl": "API 地址", + "ai.baseUrlPlaceholder": "https://api.deepseek.com/v1", + "ai.model": "模型", + "ai.modelPlaceholder": "deepseek-chat", + "ai.apiKey": "API Key", + "ai.apiKeyPlaceholderHas": "已保存 API Key,输入新值可覆盖", + "ai.apiKeyPlaceholderEmpty": "粘贴 API Key", + "ai.saved": "AI 翻译设置已保存", "ui.copyMarkdown": "复制 Markdown", "ui.exportCph": "导出 CPH", "ui.exportCphTitle": "导出到 CPH(需已安装 Competitive Programming Helper)", diff --git a/packages/webview/src/types.ts b/packages/webview/src/types.ts index 2e81368..41f8a15 100644 --- a/packages/webview/src/types.ts +++ b/packages/webview/src/types.ts @@ -77,7 +77,7 @@ export interface SubmissionDetail { export interface WebviewMessage { type?: string; - command?: 'alert' | 'error' | 'loadContest' | 'loadProblem' | 'openBrowser' | 'translate' | 'setApiKey' | 'setCookie' | 'getCookie' | 'loginRequired' | 'registerContest' | 'copyMarkdown' | 'sendCph' | 'fetchSubmitPage' | 'submitCode' | 'fetchSubmissionHistory' | 'fetchSubmissionDetail' | 'openSubmission' | 'fetchStandings' | 'getContests' | 'openContest'; + command?: 'alert' | 'error' | 'loadContest' | 'loadProblem' | 'openBrowser' | 'translate' | 'setApiKey' | 'setCookie' | 'getCookie' | 'getAiConfig' | 'setAiConfig' | 'loginRequired' | 'registerContest' | 'copyMarkdown' | 'sendCph' | 'fetchSubmitPage' | 'submitCode' | 'fetchSubmissionHistory' | 'fetchSubmissionDetail' | 'openSubmission' | 'fetchStandings' | 'getContests' | 'openContest'; statusMessage?: string; text?: string; success?: boolean; @@ -91,7 +91,11 @@ export interface WebviewMessage { payload?: Record<string, string>; url?: string; targetLang?: string; - translationMode?: "api" | "free"; + translationMode?: "api" | "free" | "ai"; + aiBaseUrl?: string; + aiModel?: string; + aiApiKey?: string; + hasAiKey?: boolean; translated?: Record<string, string>; tasks?: Array<{ label: string; value: string; url: string; status?: string }>; problem?: ContestProblem;