feat: 接入 AI 文本家族(OpenAI 兼容协议,17 id)与本地 OCR(Tesseract.js,4 id);设置页新增 AI 服务配置
This commit is contained in:
parent
d331d56f2e
commit
d351556293
115
package-lock.json
generated
115
package-lock.json
generated
@ -19,6 +19,7 @@
|
||||
"pngjs": "^7.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -3375,6 +3376,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bmp-js": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/bmp-js/-/bmp-js-0.1.0.tgz",
|
||||
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/boolean": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz",
|
||||
@ -5525,6 +5532,12 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/idb-keyval": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/idb-keyval/-/idb-keyval-6.3.0.tgz",
|
||||
"integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@ -5886,6 +5899,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-url": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/is-url/-/is-url-1.2.4.tgz",
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-weakmap": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||
@ -6519,6 +6538,26 @@
|
||||
"semver": "^7.3.5"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp": {
|
||||
"version": "12.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
|
||||
@ -6684,6 +6723,15 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/opencollective-postinstall": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
|
||||
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"opencollective-postinstall": "index.js"
|
||||
}
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/own-keys/-/own-keys-1.0.2.tgz",
|
||||
@ -7171,6 +7219,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
@ -7980,6 +8034,30 @@
|
||||
"rimraf": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tesseract.js": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/tesseract.js/-/tesseract.js-7.0.0.tgz",
|
||||
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bmp-js": "^0.1.0",
|
||||
"idb-keyval": "^6.2.0",
|
||||
"is-url": "^1.2.4",
|
||||
"node-fetch": "^2.6.9",
|
||||
"opencollective-postinstall": "^2.0.3",
|
||||
"regenerator-runtime": "^0.13.3",
|
||||
"tesseract.js-core": "^7.0.0",
|
||||
"wasm-feature-detect": "^1.8.0",
|
||||
"zlibjs": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tesseract.js-core": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
|
||||
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tiny-async-pool": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
|
||||
@ -8080,6 +8158,12 @@
|
||||
"tmp": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/traverse": {
|
||||
"version": "0.3.10",
|
||||
"resolved": "https://registry.npmmirror.com/traverse/-/traverse-0.3.10.tgz",
|
||||
@ -9596,6 +9680,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wasm-feature-detect": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/wasm-feature-detect/-/wasm-feature-detect-1.9.0.tgz",
|
||||
"integrity": "sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webcrypto-core": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
|
||||
@ -9617,6 +9707,22 @@
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
|
||||
@ -9880,6 +9986,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/zlibjs": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/zlibjs/-/zlibjs-0.3.1.tgz",
|
||||
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.16",
|
||||
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.16.tgz",
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
"pngjs": "^7.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
56
src/main/capabilities/ai-caps.ts
Normal file
56
src/main/capabilities/ai-caps.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
import { chatCompletion, requireAiConfig } from "./ai-service";
|
||||
|
||||
function inputText(args: Record<string, unknown>, nodeId: string): string {
|
||||
for (const key of ["input", "text", "content", "sentence", "prompt", "sourceFilePath", "filePath", "source", "document"]) {
|
||||
const v = String(args[key] ?? "").trim();
|
||||
if (v) return v;
|
||||
}
|
||||
throw new RuntimeError("validation_error", "输入文本为空", { nodeId, componentId: nodeId });
|
||||
}
|
||||
|
||||
async function readMaybeFile(value: string): Promise<string> {
|
||||
if (/\.(txt|md|csv|json|log)$/i.test(value) && value.includes("/") && value.length < 260) {
|
||||
try {
|
||||
const fs = await import("node:fs");
|
||||
if (fs.existsSync(value)) return fs.readFileSync(value, "utf8");
|
||||
} catch { /* not a file */ }
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function complete(prompt: string, input: string, nodeId: string, temperature = 0.3): Promise<string> {
|
||||
if (!(await Promise.resolve(true))) return "";
|
||||
void nodeId;
|
||||
const config = requireAiConfig();
|
||||
return chatCompletion([
|
||||
{ role: "system", content: prompt },
|
||||
{ role: "user", content: input }
|
||||
], { temperature });
|
||||
}
|
||||
|
||||
const TEXT_TASKS: Array<{ ids: string[]; system: string; task: string }> = [
|
||||
{ ids: ["DataService_AIGenTxt"], system: "你是写作助手,根据用户要求生成高质量中文内容。", task: "AI 生成文本" },
|
||||
{ ids: ["DataService_LargeModel", "DataService_ZBrain", "DataService_AgentCall", "NLP_XunFei_NLPBaseServer"], system: "你是智能助手,尽力完成用户的任务。", task: "通用模型调用" },
|
||||
{ ids: ["DataService_ChatBot"], system: "你是对话机器人,用中文自然对话。", task: "对话机器人" },
|
||||
{ ids: ["DataService_KeyWord", "NLP_KeywordExtraction"], system: "从文本中提取关键词,输出 JSON 数组(字符串列表),不要输出其他内容。", task: "关键词抽取" },
|
||||
{ ids: ["DataService_Correct"], system: "修正文本中的错别字与语病,直接输出修正后的全文。", task: "文本纠错" },
|
||||
{ ids: ["DataService_Review"], system: '审核文本内容是否合规,输出 JSON:{\"pass\":true/false,\"reason\":\"原因\"}。', task: "文本审核" },
|
||||
{ ids: ["DataService_TextComparison", "Scenario_TextComparison"], system: "比较两段文本差异,输出简明差异摘要。", task: "文档比对" },
|
||||
{ ids: ["NLP_SplitWords"], system: '对文本分词,输出 JSON 数组(字符串列表)。', task: "分词" },
|
||||
{ ids: ["NLP_SentimentAnalysis"], system: '分析文本情感倾向,输出 JSON:{\"label\":\"正面/负面/中性\",\"score\":0.99}。', task: "情感分析" },
|
||||
{ ids: ["NLP_SemanticSimilarity"], system: '判断两段文本语义相似度,输出 JSON:{\"score\":0.99}(0-1)。输入格式:文本1\n---\n文本2。', task: "语义相似度" },
|
||||
{ ids: ["NLP_PerspectiveAnalysis"], system: '分析文本中的观点与立场,输出 JSON:{\"perspectives\":[{\"view\":\"...\",\"stance\":\"支持/反对/中立\"}]}。', task: "观点分析" },
|
||||
{ ids: ["NLP_AnalyseProductReview"], system: '分析商品评论,输出 JSON:{\"summary\":\"...\",\"sentiment\":\"...\",\"issues\":[]}。', task: "商品评论分析" },
|
||||
];
|
||||
|
||||
for (const task of TEXT_TASKS) {
|
||||
register(task.ids, async (ctx, node, args) => {
|
||||
const rawInput = inputText(args, node.id);
|
||||
const input = await readMaybeFile(rawInput);
|
||||
const result = await complete(task.system, input, node.id);
|
||||
ctx.log(task.task + "完成", "info", node.id);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
32
src/main/capabilities/ai-service.ts
Normal file
32
src/main/capabilities/ai-service.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { loadSettings } from "../settings";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
|
||||
export interface ChatMessage { role: "system" | "user" | "assistant"; content: string }
|
||||
|
||||
export function getAiConfig(): { baseUrl: string; apiKey: string; model: string } {
|
||||
return loadSettings().aiConfig;
|
||||
}
|
||||
|
||||
export function requireAiConfig() {
|
||||
const config = getAiConfig();
|
||||
if (!config.baseUrl || !config.model) {
|
||||
throw new RuntimeError("ai_not_configured", "AI 服务未配置", { recommendation: "到 设置 → AI 服务 填写 Base URL 与模型名(兼容 OpenAI 协议)" });
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function chatCompletion(messages: ChatMessage[], options: { temperature?: number } = {}): Promise<string> {
|
||||
const config = requireAiConfig();
|
||||
const base = config.baseUrl.replace(/\/+$/, "");
|
||||
const response = await fetch(base + "/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: "Bearer " + config.apiKey },
|
||||
body: JSON.stringify({ model: config.model, messages, temperature: options.temperature ?? 0.3 })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = (await response.text()).slice(0, 300);
|
||||
throw new RuntimeError("ai_request_failed", "AI 请求失败(HTTP " + response.status + "):" + detail);
|
||||
}
|
||||
const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
|
||||
return data.choices?.[0]?.message?.content ?? "";
|
||||
}
|
||||
@ -16,6 +16,9 @@ import "./human-dialog";
|
||||
import "./time-caps";
|
||||
import "./windows-system";
|
||||
import "./ftp";
|
||||
import "./ai-service";
|
||||
import "./ai-caps";
|
||||
import "./ocr-caps";
|
||||
import "./pdf";
|
||||
import "./word";
|
||||
import "./word-extended";
|
||||
|
||||
51
src/main/capabilities/ocr-caps.ts
Normal file
51
src/main/capabilities/ocr-caps.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import Tesseract from "tesseract.js";
|
||||
import fs from "node:fs";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
|
||||
function requireImage(args: Record<string, unknown>, nodeId: string): string {
|
||||
for (const key of ["image_path", "imagePath", "file_path", "filePath", "path", "image"]) {
|
||||
const v = String(args[key] ?? "").trim();
|
||||
if (v) return v;
|
||||
}
|
||||
throw new RuntimeError("validation_error", "图片路径为空", { nodeId, componentId: nodeId, recommendation: "填写本地图片路径(png/jpg/bmp)" });
|
||||
}
|
||||
|
||||
async function recognize(imagePath: string, nodeId: string) {
|
||||
if (!fs.existsSync(imagePath)) {
|
||||
throw new RuntimeError("file_not_found", "图片不存在:" + imagePath, { nodeId, componentId: nodeId });
|
||||
}
|
||||
const worker = await Tesseract.createWorker("chi_sim+eng");
|
||||
try {
|
||||
return await worker.recognize(imagePath);
|
||||
} finally {
|
||||
await worker.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
register("OcrOffline_GetOcrTextNew", async (ctx, node, args) => {
|
||||
const imagePath = requireImage(args, node.id);
|
||||
const result = await recognize(imagePath, node.id);
|
||||
const data = result.data as Tesseract.Page & { words?: Array<{ text: string; bbox: { x0: number; y0: number; x1: number; y1: number }; confidence: number }>; lines?: Array<{ words: Array<{ text: string }> }> };
|
||||
const text = data.text.trim();
|
||||
ctx.log("OCR 识别完成:" + text.length + " 字符", "info", node.id);
|
||||
return text;
|
||||
});
|
||||
|
||||
register("OcrOffline_GetOcrTextPosition", async (ctx, node, args) => {
|
||||
const imagePath = requireImage(args, node.id);
|
||||
const result = await recognize(imagePath, node.id);
|
||||
const data = result.data as Tesseract.Page & { words?: Array<{ text: string; bbox: { x0: number; y0: number; x1: number; y1: number }; confidence: number }>; lines?: Array<{ words: Array<{ text: string }> }> };
|
||||
const words = (data.words ?? []).map(w => ({ text: w.text, x: w.bbox.x0, y: w.bbox.y0, width: w.bbox.x1 - w.bbox.x0, height: w.bbox.y1 - w.bbox.y0, confidence: w.confidence }));
|
||||
ctx.log("OCR 定位完成:" + words.length + " 个词", "info", node.id);
|
||||
return words;
|
||||
});
|
||||
|
||||
register("OcrOffline_GetOcrTable", async (ctx, node, args) => {
|
||||
const imagePath = requireImage(args, node.id);
|
||||
const result = await recognize(imagePath, node.id);
|
||||
const data = result.data as Tesseract.Page & { words?: Array<{ text: string; bbox: { x0: number; y0: number; x1: number; y1: number }; confidence: number }>; lines?: Array<{ words: Array<{ text: string }> }> };
|
||||
const lines = (data.lines ?? []).map((line: { words: Array<{ text: string }> }) => line.words.map((w: { text: string }) => w.text).join("\t"));
|
||||
ctx.log("OCR 表格识别完成:" + lines.length + " 行", "info", node.id);
|
||||
return lines;
|
||||
});
|
||||
@ -4,7 +4,8 @@ import path from "node:path";
|
||||
import type { AppSettings } from "../shared/types";
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
browserElementWaitMs: 5000
|
||||
browserElementWaitMs: 5000,
|
||||
aiConfig: { baseUrl: "", apiKey: "", model: "" }
|
||||
};
|
||||
|
||||
let cache: AppSettings | null = null;
|
||||
@ -16,10 +17,16 @@ function settingsPath(): string {
|
||||
function normalizeSettings(value: unknown): AppSettings {
|
||||
const source = (value && typeof value === "object" ? value : {}) as Partial<AppSettings>;
|
||||
const wait = Number(source.browserElementWaitMs);
|
||||
const ai = (source.aiConfig && typeof source.aiConfig === "object" ? source.aiConfig : {}) as Partial<AppSettings["aiConfig"]>;
|
||||
return {
|
||||
browserElementWaitMs: Number.isFinite(wait)
|
||||
? Math.min(600000, Math.max(0, Math.round(wait)))
|
||||
: DEFAULT_APP_SETTINGS.browserElementWaitMs
|
||||
: DEFAULT_APP_SETTINGS.browserElementWaitMs,
|
||||
aiConfig: {
|
||||
baseUrl: String(ai.baseUrl ?? ""),
|
||||
apiKey: String(ai.apiKey ?? ""),
|
||||
model: String(ai.model ?? "")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Clock, Cpu, Database, FolderCog, Globe2, HardDrive, Info, Save, ShieldCheck } from "lucide-react";
|
||||
import { Clock, Cpu, Database, FolderCog, Globe2, HardDrive, Info, Save, ShieldCheck, Sparkles } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { AppInfo, AppSettings, Catalog } from "@shared/types";
|
||||
|
||||
type SettingsTab = "about" | "catalog" | "execution" | "browser";
|
||||
type SettingsTab = "about" | "catalog" | "execution" | "browser" | "ai";
|
||||
|
||||
export function SettingsPage({ catalog }: { catalog: Catalog }) {
|
||||
const [tab, setTab] = useState<SettingsTab>("about");
|
||||
@ -14,6 +14,9 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
|
||||
const [waitDraft, setWaitDraft] = useState("5000");
|
||||
const [waitSaving, setWaitSaving] = useState(false);
|
||||
const [waitMessage, setWaitMessage] = useState<string | null>(null);
|
||||
const [aiDraft, setAiDraft] = useState({ baseUrl: "", apiKey: "", model: "" });
|
||||
const [aiSaving, setAiSaving] = useState(false);
|
||||
const [aiMessage, setAiMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void window.rpa.app.info().then(setInfo).catch(() => setInfo(null));
|
||||
@ -21,6 +24,7 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
|
||||
void window.rpa.settings.get().then((value) => {
|
||||
setSettings(value);
|
||||
setWaitDraft(String(value.browserElementWaitMs));
|
||||
setAiDraft(value.aiConfig ?? { baseUrl: "", apiKey: "", model: "" });
|
||||
}).catch(() => setSettings(null));
|
||||
}, []);
|
||||
|
||||
@ -43,6 +47,20 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
|
||||
setWaitSaving(false);
|
||||
}
|
||||
};
|
||||
const saveAiConfig = async () => {
|
||||
setAiSaving(true);
|
||||
setAiMessage(null);
|
||||
try {
|
||||
const saved = await window.rpa.settings.update({ aiConfig: aiDraft });
|
||||
setSettings(saved);
|
||||
setAiMessage("AI 服务配置已保存");
|
||||
} catch (error) {
|
||||
setAiMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setAiSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const executable = catalog.components.filter((item) => item.implemented).length;
|
||||
const groups = useMemo(() => catalog.groups.map((group) => {
|
||||
@ -58,6 +76,7 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
|
||||
<button type="button" className={tab === "catalog" ? "active" : ""} onClick={() => setTab("catalog")}>组件目录</button>
|
||||
<button type="button" className={tab === "execution" ? "active" : ""} onClick={() => setTab("execution")}>执行与证据</button>
|
||||
<button type="button" className={tab === "browser" ? "active" : ""} onClick={() => setTab("browser")}>浏览器访问</button>
|
||||
<button type="button" className={tab === "ai" ? "active" : ""} onClick={() => setTab("ai")}>AI 服务</button>
|
||||
</aside>
|
||||
|
||||
<section className="panel setting-body">
|
||||
@ -117,6 +136,33 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
|
||||
<SettingCard icon={ShieldCheck} title="限制说明"><p className="field-hint setting-copy">浏览器导航不限制域名。只拒绝 HTTP/HTTPS 以外的协议;文件协议、脚本协议和任意本地路径不会由浏览器导航组件打开。</p></SettingCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "ai" && (
|
||||
<>
|
||||
<SettingCard icon={Sparkles} title="AI 服务配置(兼容 OpenAI 协议)">
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="ai-base-url">API Base URL</label>
|
||||
<input id="ai-base-url" className="text-input" value={aiDraft.baseUrl} onChange={(event) => setAiDraft({ ...aiDraft, baseUrl: event.target.value })} placeholder="https://api.openai.com/v1" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="ai-api-key">API Key</label>
|
||||
<input id="ai-api-key" className="text-input" type="password" value={aiDraft.apiKey} onChange={(event) => setAiDraft({ ...aiDraft, apiKey: event.target.value })} placeholder="sk-..." />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="ai-model">模型名称</label>
|
||||
<input id="ai-model" className="text-input" value={aiDraft.model} onChange={(event) => setAiDraft({ ...aiDraft, model: event.target.value })} placeholder="gpt-4o-mini / deepseek-chat / qwen-plus" />
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<button type="button" className="btn btn-primary" onClick={() => void saveAiConfig()} disabled={aiSaving}>
|
||||
<Save className="icon" />
|
||||
{aiSaving ? "保存中" : "保存设置"}
|
||||
</button>
|
||||
{aiMessage && <span className="field-hint">{aiMessage}</span>}
|
||||
</div>
|
||||
<span className="field-hint">兼容 OpenAI Chat Completions 协议(含本地部署服务)。配置后 AI 文本生成、关键词、纠错、审核、情感分析等能力即可用。</span>
|
||||
</SettingCard>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -179,8 +179,15 @@ export interface AppInfo {
|
||||
fixtureUrl?: string;
|
||||
}
|
||||
|
||||
export interface AiServiceConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
browserElementWaitMs: number;
|
||||
aiConfig: AiServiceConfig;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user