chore: import Local-Cursor v1.1.8 source snapshot

Includes the current working directory changes. Historical commits and release binaries are excluded to limit Gitea storage.
Source base: d7515973fbfd6dd2001fa156c483455b8081c7cb
This commit is contained in:
FuDie0915
2026-11-10 01:24:28 +08:00
commit db73cf5a39
337 changed files with 147502 additions and 0 deletions

2
frontend/.yarnrc Normal file
View File

@@ -0,0 +1,2 @@
registry "https://registry.npmmirror.com"
network-timeout 120000

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Local-Cursor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

10
frontend/jsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@bindings/*": ["bindings/*"]
}
},
"include": ["src/**/*"]
}

38
frontend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build:dev": "node ./scripts/run-vite-build.mjs --minify false --mode development",
"build": "node ./scripts/run-vite-build.mjs --mode production",
"preview": "vite preview"
},
"dependencies": {
"@floating-ui/dom": "^1.7.4",
"@iconify/json": "^2.2.447",
"@wailsio/runtime": "latest",
"chart.js": "^4.5.1",
"copy-text-to-clipboard": "^3.2.2",
"dayjs": "^1.11.20",
"resize-observer-polyfill": "^1.5.1",
"vue": "^3.5.22",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@iconify/tailwind": "^1.2.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.1.1",
"autoprefixer": "^10.5.0",
"code-inspector-plugin": "^1.4.3",
"postcss": "^8.5.14",
"tailwindcss": "^3.4.17",
"vite": "^7.1.11",
"vite-plugin-top-level-await": "^1.6.0"
},
"browserslist": [
"Safari >= 13"
]
}

View File

@@ -0,0 +1,704 @@
import { createHash } from "crypto";
import fs from "fs";
import path from "path";
import MagicString from "magic-string";
import { parse as parseJavaScript } from "@babel/parser";
import traverseModule from "@babel/traverse";
import { parse as parseTemplate } from "@vue/compiler-dom";
import { parse as parseSFC } from "@vue/compiler-sfc";
import { normalizePath } from "vite";
const traverse = traverseModule.default ?? traverseModule;
const SOURCE_LANGUAGE = "zh-CN";
const SUPPORTED_LOCALES = ["zh-CN", "en-US", "ja-JP"];
const HAN_REGEX = /\p{Script=Han}/u;
const JS_HELPERS = {
localized: "__i18nLocalized",
localizedTemplate: "__i18nLocalizedTemplate",
};
const TEMPLATE_HELPERS = {
localized: "$ls",
localizedTemplate: "$lt",
};
const RUNTIME_IMPORT = "@/i18n/runtime";
const BABEL_PLUGINS = [
"jsx",
"typescript",
"classProperties",
"classPrivateProperties",
"classPrivateMethods",
"topLevelAwait",
"importAttributes",
];
function containsHan(value) {
return typeof value === "string" && HAN_REGEX.test(value);
}
function toJSONLiteral(value) {
return JSON.stringify(value);
}
function toSingleQuotedLiteral(value) {
return `'${String(value)
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029")}'`;
}
function hashMessageID(message) {
return createHash("sha256").update(message).digest("hex").slice(0, 16);
}
function stripQuery(id) {
return id.split("?")[0];
}
function isSourceFile(id) {
const cleanID = stripQuery(id);
return /\.(?:js|jsx|ts|tsx|vue)$/.test(cleanID);
}
function isExcludedFile(rootDir, id) {
const cleanID = normalizePath(stripQuery(id));
if (cleanID.includes("/node_modules/")) {
return true;
}
const relativePath = normalizePath(path.relative(rootDir, cleanID));
if (!relativePath.startsWith("src/")) {
return true;
}
return relativePath.startsWith("src/i18n/");
}
function readJSONFile(filePath, fallback) {
if (!fs.existsSync(filePath)) {
return fallback;
}
const raw = fs.readFileSync(filePath, "utf8").trim();
if (!raw) {
return fallback;
}
return JSON.parse(raw);
}
function ensureDirectory(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function writeJSONFile(filePath, payload) {
ensureDirectory(filePath);
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`);
}
function buildRef(filePath, rootDir, loc) {
return {
file: normalizePath(path.relative(rootDir, filePath)),
line: loc?.line ?? 1,
column: loc?.column ?? 1,
};
}
function buildMessageRecord(filePath, rootDir, canonical, placeholders, loc) {
const id = hashMessageID(canonical);
return {
id,
source: canonical,
kind: placeholders > 0 ? "template" : "text",
placeholders,
ref: buildRef(filePath, rootDir, loc),
};
}
function mergeMessageRecords(records) {
const entries = new Map();
for (const record of records) {
const current = entries.get(record.id);
if (!current) {
entries.set(record.id, {
source: record.source,
kind: record.kind,
placeholders: record.placeholders,
refs: [record.ref],
});
continue;
}
if (current.source !== record.source) {
throw new Error(
`[static-i18n] Message id collision for ${record.id}: ${current.source} <> ${record.source}`,
);
}
current.refs.push(record.ref);
}
const sortedEntries = {};
for (const id of Array.from(entries.keys()).sort()) {
const entry = entries.get(id);
sortedEntries[id] = {
source: entry.source,
kind: entry.kind,
placeholders: entry.placeholders,
refs: entry.refs.sort((left, right) =>
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.column - right.column),
};
}
return { entries: sortedEntries };
}
function mergeLocaleMessages(existingMessages, catalogEntries, locale) {
const nextMessages = {};
for (const id of Object.keys(catalogEntries)) {
if (locale === SOURCE_LANGUAGE) {
nextMessages[id] = catalogEntries[id].source;
continue;
}
const currentValue = existingMessages?.[id];
nextMessages[id] = typeof currentValue === "string" ? currentValue : "";
}
return nextMessages;
}
function walkSourceFiles(dirPath, visitor) {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const nextPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules") {
continue;
}
walkSourceFiles(nextPath, visitor);
continue;
}
visitor(nextPath);
}
}
function parseProgram(code, filename) {
try {
return parseJavaScript(code, {
sourceType: "module",
sourceFilename: filename,
plugins: BABEL_PLUGINS,
});
} catch (error) {
throw new Error(`[static-i18n] Failed to parse ${filename}: ${error.message}`);
}
}
function createTemplateCanonical(node) {
const parts = [];
for (let index = 0; index < node.quasis.length; index += 1) {
const quasi = node.quasis[index];
parts.push(quasi.value.cooked ?? quasi.value.raw ?? "");
if (index < node.expressions.length) {
parts.push(`{${index}}`);
}
}
return parts.join("");
}
function shouldIgnoreStringLiteral(path) {
const parent = path.parentPath;
if (!parent) {
return false;
}
if (
parent.isImportDeclaration() ||
parent.isExportAllDeclaration() ||
parent.isExportNamedDeclaration()
) {
return true;
}
if (parent.isDirective()) {
return true;
}
if (
(parent.isObjectProperty() || parent.isObjectMethod()) &&
path.key === "key" &&
parent.node.computed !== true
) {
return true;
}
if (
(parent.isMemberExpression() || parent.isOptionalMemberExpression?.()) &&
path.key === "property" &&
parent.node.computed !== true
) {
return true;
}
if (
(parent.isClassMethod?.() || parent.isClassProperty?.() || parent.isClassPrivateProperty?.()) &&
path.key === "key"
) {
return true;
}
return false;
}
function shouldIgnoreTemplateLiteral(path) {
return path.parentPath?.isTaggedTemplateExpression?.() === true;
}
function createStringLiteralReplacement(path, helperNames, record, quoteLiteral) {
if (path.parentPath?.isJSXAttribute?.() && path.key === "value") {
return `{${helperNames.localized}(${quoteLiteral(record.id)}, ${quoteLiteral(record.source)})}`;
}
return `${helperNames.localized}(${quoteLiteral(record.id)}, ${quoteLiteral(record.source)})`;
}
function createTemplateLiteralReplacement(path, code, helperNames, record, quoteLiteral) {
if (path.node.expressions.length === 0) {
return `${helperNames.localized}(${quoteLiteral(record.id)}, ${quoteLiteral(record.source)})`;
}
const args = path.node.expressions.map((expression) => code.slice(expression.start, expression.end));
const payload = `[${args.join(", ")}]`;
return `${helperNames.localizedTemplate}(${quoteLiteral(record.id)}, ${quoteLiteral(record.source)}, ${payload})`;
}
function collectJSReplacements(code, ast, filePath, rootDir, helperNames, options = {}) {
const replacements = [];
const records = [];
const helperUsage = {
localized: false,
localizedTemplate: false,
};
const refLoc = options.refLoc ?? null;
const quoteLiteral = options.quoteLiteral ?? toJSONLiteral;
function resolveLoc(node) {
if (refLoc) {
return refLoc;
}
return node?.loc?.start
? {
line: node.loc.start.line,
column: node.loc.start.column + 1,
}
: { line: 1, column: 1 };
}
traverse(ast, {
noScope: true,
StringLiteral(path) {
if (shouldIgnoreStringLiteral(path) || !containsHan(path.node.value)) {
return;
}
const record = buildMessageRecord(filePath, rootDir, path.node.value, 0, resolveLoc(path.node));
records.push(record);
helperUsage.localized = true;
replacements.push({
start: path.node.start,
end: path.node.end,
text: createStringLiteralReplacement(path, helperNames, record, quoteLiteral),
});
},
TemplateLiteral(path) {
if (shouldIgnoreTemplateLiteral(path)) {
return;
}
const canonical = createTemplateCanonical(path.node);
if (!containsHan(canonical)) {
return;
}
const record = buildMessageRecord(
filePath,
rootDir,
canonical,
path.node.expressions.length,
resolveLoc(path.node),
);
records.push(record);
if (path.node.expressions.length === 0) {
helperUsage.localized = true;
} else {
helperUsage.localizedTemplate = true;
}
replacements.push({
start: path.node.start,
end: path.node.end,
text: createTemplateLiteralReplacement(path, code, helperNames, record, quoteLiteral),
});
},
});
return {
replacements,
records,
helperUsage,
};
}
function applyReplacements(code, replacements) {
if (!replacements.length) {
return null;
}
const magicString = new MagicString(code);
const sortedReplacements = [...replacements].sort((left, right) => right.start - left.start);
for (const replacement of sortedReplacements) {
magicString.overwrite(replacement.start, replacement.end, replacement.text);
}
return magicString;
}
function ensureRuntimeImport(code, helperUsage) {
if (!helperUsage.localized && !helperUsage.localizedTemplate) {
return code;
}
const pieces = [];
if (helperUsage.localized) {
pieces.push(`localized as ${JS_HELPERS.localized}`);
}
if (helperUsage.localizedTemplate) {
pieces.push(`localizedTemplate as ${JS_HELPERS.localizedTemplate}`);
}
return `import { ${pieces.join(", ")} } from "${RUNTIME_IMPORT}";\n${code}`;
}
function transformJavaScript(code, filePath, rootDir, options = {}) {
const ast = parseProgram(code, filePath);
const result = collectJSReplacements(
code,
ast,
filePath,
rootDir,
options.helperNames ?? JS_HELPERS,
{
refLoc: options.refLoc,
quoteLiteral: options.quoteLiteral,
},
);
const magicString = applyReplacements(code, result.replacements);
const transformedCode = options.injectImport === false
? magicString?.toString() ?? code
: ensureRuntimeImport(magicString?.toString() ?? code, result.helperUsage);
return {
code: transformedCode,
changed: transformedCode !== code,
records: result.records,
map: magicString
? magicString.generateMap({
source: filePath,
hires: true,
})
: null,
};
}
function translateTemplateExpression(expression, filePath, rootDir, refLoc) {
const wrappedCode = `(${expression})`;
try {
const transformed = transformJavaScript(wrappedCode, filePath, rootDir, {
helperNames: TEMPLATE_HELPERS,
injectImport: false,
refLoc,
quoteLiteral: toSingleQuotedLiteral,
});
const nextCode = transformed.code.slice(1, -1);
return {
code: nextCode,
changed: nextCode !== expression,
records: transformed.records,
};
} catch (_error) {
const transformed = transformJavaScript(expression, filePath, rootDir, {
helperNames: TEMPLATE_HELPERS,
injectImport: false,
refLoc,
quoteLiteral: toSingleQuotedLiteral,
});
return {
code: transformed.code,
changed: transformed.code !== expression,
records: transformed.records,
};
}
}
function createTextNodeReplacement(source, record) {
const trimmed = source.trim();
if (!trimmed) {
return null;
}
const leadingLength = source.indexOf(trimmed);
const leading = leadingLength > 0 ? source.slice(0, leadingLength) : "";
const trailing = source.slice(leadingLength + trimmed.length);
return `${leading}{{ $ls(${toSingleQuotedLiteral(record.id)}, ${toSingleQuotedLiteral(record.source)}) }}${trailing}`;
}
function walkTemplateNode(node, visitor) {
visitor(node);
if (Array.isArray(node.branches)) {
node.branches.forEach((branch) => walkTemplateNode(branch, visitor));
}
if (Array.isArray(node.children)) {
node.children.forEach((child) => walkTemplateNode(child, visitor));
}
if (node.type === 1 && Array.isArray(node.props)) {
for (const prop of node.props) {
visitor(prop, node);
if (prop.exp) {
visitor(prop.exp, prop);
}
if (prop.arg) {
visitor(prop.arg, prop);
}
}
}
if (node.type === 5 && node.content) {
visitor(node.content, node);
}
}
function transformVueTemplate(templateCode, filePath, rootDir) {
const ast = parseTemplate(templateCode, { comments: true });
const replacements = [];
const records = [];
walkTemplateNode(ast, (node, parent) => {
if (node.type === 2 && containsHan(node.content)) {
const record = buildMessageRecord(
filePath,
rootDir,
node.content.trim(),
0,
{
line: node.loc.start.line,
column: node.loc.start.column + 1,
},
);
const replacement = createTextNodeReplacement(node.loc.source, record);
if (!replacement) {
return;
}
records.push(record);
replacements.push({
start: node.loc.start.offset,
end: node.loc.end.offset,
text: replacement,
});
return;
}
if (node.type === 6 && node.value && containsHan(node.value.content)) {
const record = buildMessageRecord(
filePath,
rootDir,
node.value.content,
0,
{
line: node.loc.start.line,
column: node.loc.start.column + 1,
},
);
records.push(record);
replacements.push({
start: node.loc.start.offset,
end: node.loc.end.offset,
text: `:${node.name}="$ls(${toSingleQuotedLiteral(record.id)}, ${toSingleQuotedLiteral(record.source)})"`,
});
return;
}
if (
node.type === 4 &&
typeof node.content === "string" &&
containsHan(node.content) &&
parent &&
((parent.type === 5) || (parent.type === 7 && parent.exp === node))
) {
const transformed = translateTemplateExpression(
node.content,
filePath,
rootDir,
{
line: node.loc.start.line,
column: node.loc.start.column + 1,
},
);
if (!transformed.changed) {
return;
}
records.push(...transformed.records);
replacements.push({
start: node.loc.start.offset,
end: node.loc.end.offset,
text: transformed.code,
});
}
});
const magicString = applyReplacements(templateCode, replacements);
return {
code: magicString?.toString() ?? templateCode,
changed: Boolean(magicString),
records,
};
}
function transformVueSFC(code, filePath, rootDir) {
const { descriptor } = parseSFC(code, { filename: filePath });
const magicString = new MagicString(code);
const records = [];
let changed = false;
if (descriptor.template) {
const templateResult = transformVueTemplate(descriptor.template.content, filePath, rootDir);
records.push(...templateResult.records);
if (templateResult.changed) {
changed = true;
magicString.overwrite(
descriptor.template.loc.start.offset,
descriptor.template.loc.end.offset,
templateResult.code,
);
}
}
for (const block of [descriptor.script, descriptor.scriptSetup].filter(Boolean)) {
const scriptResult = transformJavaScript(block.content, filePath, rootDir, {
helperNames: JS_HELPERS,
injectImport: true,
});
records.push(...scriptResult.records);
if (scriptResult.changed) {
changed = true;
magicString.overwrite(block.loc.start.offset, block.loc.end.offset, scriptResult.code);
}
}
return {
code: changed ? magicString.toString() : code,
changed,
records,
map: changed
? magicString.generateMap({
source: filePath,
hires: true,
})
: null,
};
}
function transformSourceCode(code, filePath, rootDir) {
if (filePath.endsWith(".vue")) {
return transformVueSFC(code, filePath, rootDir);
}
return transformJavaScript(code, filePath, rootDir, {
helperNames: JS_HELPERS,
injectImport: true,
});
}
function collectCatalogRecords(rootDir) {
const srcDir = path.join(rootDir, "src");
const records = [];
walkSourceFiles(srcDir, (filePath) => {
const cleanPath = normalizePath(filePath);
if (!isSourceFile(cleanPath) || isExcludedFile(rootDir, cleanPath)) {
return;
}
const code = fs.readFileSync(cleanPath, "utf8");
const result = transformSourceCode(code, cleanPath, rootDir);
records.push(...result.records);
});
return records;
}
function syncCatalogFiles(rootDir) {
const records = collectCatalogRecords(rootDir);
const catalog = mergeMessageRecords(records);
const generatedDir = path.join(rootDir, "src/i18n/generated");
const localesDir = path.join(rootDir, "src/i18n/locales");
writeJSONFile(path.join(generatedDir, "catalog.json"), catalog);
for (const locale of SUPPORTED_LOCALES) {
const localePath = path.join(localesDir, `${locale}.json`);
const previousMessages = readJSONFile(localePath, {});
const nextMessages = mergeLocaleMessages(previousMessages, catalog.entries, locale);
writeJSONFile(localePath, nextMessages);
}
}
export function staticI18nPlugin() {
let rootDir = process.cwd();
const shouldScan = process.argv.includes("--scan") || process.env.STATIC_I18N_SCAN === "true";
return {
name: "cursor-static-i18n",
enforce: "pre",
configResolved(config) {
rootDir = config.root;
},
buildStart() {
if (!shouldScan) {
return;
}
syncCatalogFiles(rootDir);
},
transform(code, id) {
if (!isSourceFile(id) || isExcludedFile(rootDir, id)) {
return null;
}
const filePath = stripQuery(id);
const result = transformSourceCode(code, filePath, rootDir);
if (!result.changed) {
return null;
}
return {
code: result.code,
map: result.map,
};
},
};
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>

After

Width:  |  Height:  |  Size: 995 B

281
frontend/public/style.css Normal file
View File

@@ -0,0 +1,281 @@
:root {
--bg: #f4f5f7;
--card: #ffffff;
--text: #1f2937;
--muted: #6b7280;
--line: #e5e7eb;
--btn: #111827;
--btn-text: #ffffff;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
background: var(--bg);
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
padding: 0;
}
.app-shell {
width: 100vw;
height: 100vh;
border-radius: 10px;
background: var(--card);
overflow: hidden;
display: flex;
flex-direction: column;
}
.window-header {
height: 30px;
min-height: 30px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
border-bottom: 1px solid var(--line);
background: #f8f8f8;
color: #6b7280;
user-select: none;
}
body.os-windows .window-header {
justify-content: space-between;
}
.header-spacer {
display: none;
width: 48px;
height: 22px;
}
body.os-windows .header-spacer {
display: block;
}
.header-title {
font-size: 12px;
line-height: 1;
font-weight: 500;
}
.window-actions {
display: none;
align-items: center;
gap: 4px;
}
body.os-windows .window-actions {
display: flex;
}
.window-btn {
width: 22px;
height: 22px;
border: 0;
border-radius: 4px;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.window-btn img {
width: 14px;
height: 14px;
opacity: 0.7;
}
.window-btn:hover {
background: #e5e7eb;
}
.window-btn:hover img {
opacity: 1;
}
.window-btn.close-btn:hover {
background: #ef4444;
}
.window-btn.close-btn:hover img {
filter: brightness(0) invert(1);
}
.panel {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 12px;
}
.panel-top,
.panel-bottom {
display: grid;
gap: 12px;
}
.row {
display: grid;
grid-template-columns: 74px 1fr;
align-items: center;
gap: 8px;
}
.windows-only {
display: none;
}
body.os-windows .windows-only {
display: grid;
}
.field {
display: grid;
grid-template-columns: 1fr 56px;
gap: 8px;
align-items: center;
}
.label {
color: var(--muted);
font-size: 13px;
}
input {
width: 100%;
height: 32px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0 10px;
font-size: 13px;
outline: none;
background: #fff;
}
input:focus {
border-color: #9ca3af;
}
input[readonly] {
background: #f9fafb;
}
.value {
font-size: 13px;
}
.toggle-btn {
width: 100%;
height: 38px;
border: none;
border-radius: 8px;
background: var(--btn);
color: var(--btn-text);
font-size: 13px;
cursor: pointer;
}
.toggle-btn.enabled {
background: #374151;
}
.toggle-btn.waiting-init {
background: #9ca3af;
}
.init-btn {
width: 100%;
height: 38px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
color: #374151;
font-size: 13px;
cursor: pointer;
}
.init-btn:hover:not(:disabled) {
border-color: #9ca3af;
background: #f9fafb;
}
.query-btn {
height: 30px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding-left: 0;
padding-right: 0;
font-size: 12px;
color: #374151;
cursor: pointer;
}
.query-btn:disabled,
.init-btn:disabled,
.toggle-btn:disabled,
.window-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.expire-inline {
margin: 0;
font-size: 12px;
color: var(--muted);
}
.init-status-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.init-status {
font-size: 12px;
}
.init-status[data-state="idle"] {
color: #6b7280;
}
.init-status[data-state="checking"] {
color: #2563eb;
}
.init-status[data-state="ready"] {
color: #047857;
}
.init-status[data-state="pending"] {
color: #92400e;
}
.init-status[data-state="error"] {
color: #b91c1c;
}
.action-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}

BIN
frontend/public/wails.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -0,0 +1,27 @@
import { spawn } from "child_process";
import { createRequire } from "module";
import path from "path";
const require = createRequire(import.meta.url);
const vitePackagePath = require.resolve("vite/package.json");
const viteBin = path.join(path.dirname(vitePackagePath), "bin", "vite.js");
const extraArgs = process.argv.slice(2);
const shouldScan = extraArgs.includes("--scan");
const forwardedArgs = extraArgs.filter((arg) => arg !== "--scan");
const child = spawn(process.execPath, [viteBin, "build", ...forwardedArgs], {
stdio: "inherit",
env: {
...process.env,
STATIC_I18N_SCAN: shouldScan ? "true" : process.env.STATIC_I18N_SCAN || "false",
},
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});

34
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,34 @@
<template>
<MainLayout />
<MessageProvider />
<Modal
:visible="modalState.visible"
:title="modalState.title"
:content="modalState.content"
:confirm-text="modalState.confirmText"
:cancel-text="modalState.cancelText"
:show-cancel="modalState.showCancel"
:confirm-disabled="modalState.confirmDisabled"
@confirm="resolveModal(true)"
@cancel="resolveModal(false)"
/>
<InputModal
:visible="inputModalState.visible"
:title="inputModalState.title"
:content="inputModalState.content"
:placeholder="inputModalState.placeholder"
:model-value="inputModalState.value"
@update:model-value="inputModalState.value = $event"
@confirm="resolveInputModal(true)"
@cancel="resolveInputModal(false)"
/>
</template>
<script setup>
import MainLayout from "@/layouts/MainLayout.vue";
import Modal from "@/components/ui/Modal.vue";
import MessageProvider from "@/components/ui/MessageProvider.vue";
import { modalState, resolveModal } from "@/composables/useModal";
import InputModal from "@/components/ui/InputModal.vue";
import { inputModalState, resolveInputModal } from "@/composables/useInputModal";
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -0,0 +1,417 @@
<script setup>
import CacheHitRateChart from "@/components/charts/CacheHitRateChart.vue";
import Switch from "@/components/ui/Switch.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { appState, saveHomeMetricsRefreshInterval, saveIncludeCacheWriteInHitRate, saveHomeMetricsPersistent } from "@/state/appState";
import { formatCompactInteger, formatInteger } from "@/utils/numberFormat";
import { computed, ref, watch } from "vue";
const emit = defineEmits(["refresh"]);
const TOKEN_PRICE_PER_MILLION = {
input: 5,
output: 25,
cacheRead: 0.5,
cacheWrite: 6.25,
};
const props = defineProps({
metrics: {
type: Object,
required: true,
},
loading: {
type: Boolean,
default: false,
},
error: {
type: String,
default: "",
},
});
const homeMetricsConfigSaving = ref(false);
const homeMetricsConfigError = ref("");
const homeMetricsModeSaving = ref(false);
const refreshIntervalInput = ref(String(appState.homeMetricsRefreshInterval ?? 60));
const refreshIntervalSaving = ref(false);
watch(
() => appState.homeMetricsRefreshInterval,
(value) => {
refreshIntervalInput.value = String(value ?? 60);
},
);
async function commitRefreshInterval() {
const parsed = Number(refreshIntervalInput.value);
const nextValue = Number.isFinite(parsed) ? Math.round(parsed) : NaN;
if (!Number.isFinite(nextValue) || nextValue === appState.homeMetricsRefreshInterval) {
refreshIntervalInput.value = String(appState.homeMetricsRefreshInterval ?? 60);
return;
}
refreshIntervalSaving.value = true;
try {
const result = await saveHomeMetricsRefreshInterval(nextValue);
if (!result?.ok) {
homeMetricsConfigError.value = result?.error || "保存失败";
}
} catch (error) {
homeMetricsConfigError.value = error?.message || "保存失败";
} finally {
refreshIntervalSaving.value = false;
refreshIntervalInput.value = String(appState.homeMetricsRefreshInterval ?? 60);
}
}
function normalizeNumber(value) {
const number = Number(value);
if (!Number.isFinite(number)) {
return 0;
}
return Math.round(number);
}
function formatMetricValue(value) {
const full = formatInteger(value);
const compact = formatCompactInteger(value);
return full === compact ? full : `${full} (${compact})`;
}
function formatRateLabel(value) {
const rate = Number(value);
if (!Number.isFinite(rate)) {
return "暂无数据";
}
return `${(Math.max(0, Math.min(1, rate)) * 100).toFixed(2)}%`;
}
function calculateRate(numerator, denominator) {
const top = normalizeNumber(numerator);
const bottom = normalizeNumber(denominator);
if (bottom <= 0) {
return null;
}
return top / bottom;
}
function priceTokens(tokens, pricePerMillion) {
return (normalizeNumber(tokens) / 1_000_000) * pricePerMillion;
}
function formatUSD(value) {
const amount = Number(value);
if (!Number.isFinite(amount)) {
return "$0.00";
}
if (amount > 0 && amount < 0.01) {
return "<$0.01";
}
return `$${amount.toFixed(2)}`;
}
const cacheReadTokensTotal = computed(() => normalizeNumber(props.metrics?.cacheReadTokens));
const cacheWriteTokensTotal = computed(() => normalizeNumber(props.metrics?.cacheWriteTokens));
const inputTokensTotal = computed(() => {
const promptTokensTotal = normalizeNumber(props.metrics?.promptTokensTotal);
return Math.max(0, promptTokensTotal - cacheReadTokensTotal.value - cacheWriteTokensTotal.value);
});
// 命中率口径统一由后端按 includeCacheWriteInHitRate 配置计算,前端只消费结果。
const selectedCacheHitRate = computed(() => {
const rate = Number(props.metrics?.cacheHitRate);
return Number.isFinite(rate) ? rate : null;
});
const includeCacheWriteInHitRate = computed(() => appState.includeCacheWriteInHitRate);
const homeMetricsPersistent = computed(() => appState.homeMetricsPersistent);
const homeMetricsModeLabel = computed(() => homeMetricsPersistent.value ? "累计统计" : "本次运行");
async function toggleHomeMetricsPersistent(value) {
const nextValue = Boolean(value);
homeMetricsModeSaving.value = true;
try {
const result = await saveHomeMetricsPersistent(nextValue);
if (!result?.ok) {
homeMetricsConfigError.value = result?.error || "保存失败";
}
} catch (error) {
homeMetricsConfigError.value = error?.message || "保存失败";
} finally {
homeMetricsModeSaving.value = false;
}
}
const selectedCacheRateModeLabel = computed(() =>
includeCacheWriteInHitRate.value ? "计入缓存创建" : "默认口径",
);
const validTurnsRate = computed(() => {
const turnsTotal = normalizeNumber(props.metrics?.turnsTotal);
if (turnsTotal <= 0) {
return null;
}
return normalizeNumber(props.metrics?.validTurnsTotal) / turnsTotal;
});
const completionTokensTotal = computed(() => {
const requestTokensTotal = normalizeNumber(props.metrics?.requestTokensTotal);
const promptTokensTotal = normalizeNumber(props.metrics?.promptTokensTotal);
return Math.max(0, requestTokensTotal - promptTokensTotal);
});
const estimatedTokenCost = computed(() => {
const input = priceTokens(inputTokensTotal.value, TOKEN_PRICE_PER_MILLION.input);
const output = priceTokens(completionTokensTotal.value, TOKEN_PRICE_PER_MILLION.output);
const cacheRead = priceTokens(cacheReadTokensTotal.value, TOKEN_PRICE_PER_MILLION.cacheRead);
const cacheWrite = priceTokens(cacheWriteTokensTotal.value, TOKEN_PRICE_PER_MILLION.cacheWrite);
return {
input,
output,
cacheRead,
cacheWrite,
total: input + output + cacheRead + cacheWrite,
};
});
const cacheTooltipContent = computed(() => {
const formula = includeCacheWriteInHitRate.value
? "缓存读取 /(缓存读取 + 缓存创建 + 非缓存输入)"
: "缓存读取 /(缓存读取 + 非缓存输入)";
return [
`当前:${formatRateLabel(selectedCacheHitRate.value)}`,
`公式:${formula}`,
].join("\n");
});
const turnsTooltipContent = computed(() =>
[
"按历史记录里扫描到的回合 summary 汇总。",
"",
`总轮次:${formatMetricValue(props.metrics?.turnsTotal)}`,
`有效轮次:${formatMetricValue(props.metrics?.validTurnsTotal)}`,
`异常轮次:${formatMetricValue(props.metrics?.invalidTurnsTotal)}`,
`有效占比:${formatRateLabel(validTurnsRate.value)}`,
].join("\n"),
);
const tokensTooltipContent = computed(() =>
[
"总请求 Token 包含 Prompt 和模型输出。",
"",
`总请求:${formatMetricValue(props.metrics?.requestTokensTotal)}`,
`Prompt:${formatMetricValue(props.metrics?.promptTokensTotal)}`,
`输出推算:${formatMetricValue(completionTokensTotal.value)}`,
`非缓存输入:${formatMetricValue(inputTokensTotal.value)}`,
`缓存读取:${formatMetricValue(cacheReadTokensTotal.value)}`,
`缓存写入:${formatMetricValue(cacheWriteTokensTotal.value)}`,
"",
"缓存读写已计入 Prompt 侧统计。",
].join("\n"),
);
const costTooltipContent = computed(() =>
[
"按 Claude Opus 4.7 价格估算。",
`缓存统计策略:${selectedCacheRateModeLabel.value}(${formatRateLabel(selectedCacheHitRate.value)})`,
"",
`普通输入:${formatMetricValue(inputTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.input}/1M = ${formatUSD(estimatedTokenCost.value.input)}`,
`模型输出:${formatMetricValue(completionTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.output}/1M = ${formatUSD(estimatedTokenCost.value.output)}`,
`缓存读取:${formatMetricValue(cacheReadTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.cacheRead}/1M = ${formatUSD(estimatedTokenCost.value.cacheRead)}`,
`缓存写入:${formatMetricValue(cacheWriteTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.cacheWrite}/1M = ${formatUSD(estimatedTokenCost.value.cacheWrite)}`,
"",
`合计:${formatUSD(estimatedTokenCost.value.total)}`,
].join("\n"),
);
async function toggleIncludeCacheWriteInHitRate(value) {
const nextValue = Boolean(value);
homeMetricsConfigSaving.value = true;
homeMetricsConfigError.value = "";
try {
const result = await saveIncludeCacheWriteInHitRate(nextValue);
if (!result?.ok) {
homeMetricsConfigError.value = result?.error || "保存失败";
}
} catch (error) {
homeMetricsConfigError.value = error?.message || "保存失败";
} finally {
homeMetricsConfigSaving.value = false;
}
}
</script>
<template>
<div>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-[14px] font-medium text-white/80">会话统计</h2>
<div class="flex items-center gap-3 text-xs text-[#666]">
<div class="flex items-center gap-1">
<span class="text-[#666]">{{ homeMetricsModeLabel }}</span>
<Tooltip>
<div class="w-[280px] space-y-2">
<Switch
compact
label="累计统计"
description="开启时展示全部历史数据,关闭时仅展示本次运行以来的增量"
enabled-text="当前展示累计数据"
disabled-text="当前展示本次运行增量"
:enabled="homeMetricsPersistent"
:busy="homeMetricsModeSaving"
:disabled="homeMetricsModeSaving"
@change="toggleHomeMetricsPersistent"
/>
<div class="text-[11px] text-[#666] leading-[16px]">
两种模式下后端始终写入持久化数据,切换不会清除历史记录。
</div>
<div class="flex items-center justify-between gap-2 pt-1">
<span class="text-[11px] text-[#666] leading-[16px]">定时刷新</span>
<div class="flex items-center gap-1">
<input
v-model="refreshIntervalInput"
type="text"
inputmode="numeric"
class="h-6 w-[56px] rounded-[4px] border border-[#383838] bg-[#141414] px-2 text-[11px] text-white outline-none focus:border-white"
:disabled="refreshIntervalSaving"
@keydown.enter="commitRefreshInterval"
@blur="commitRefreshInterval"
/>
<span class="text-[11px] text-[#666]">秒</span>
</div>
</div>
</div>
</Tooltip>
<span class="icon-[mdi--chart-timeline-variant-shimmer] text-[14px] text-[#666]"></span>
</div>
<span>刷新统计</span>
<button
type="button"
class="flex items-center justify-center h-[24px] w-[24px] rounded-[4px] border border-[#383838] bg-[#141414] text-[#a8a8a8] transition-colors duration-150 hover:border-[#666] hover:text-white disabled:cursor-not-allowed disabled:opacity-60"
:disabled="loading"
:title="loading ? '刷新中' : '刷新统计'"
@click="emit('refresh')"
>
<span
class="icon-[mdi--refresh] text-[14px]"
:class="{ '!animate-spin': loading }"
></span>
</button>
</div>
</div>
<div
class="grid grid-cols-4 gap-0 overflow-hidden rounded-[8px] border border-[#2a2a2a] bg-[#0a0a0a] min-h-[130px]"
>
<div class="min-w-0 px-4 py-4 flex flex-col gap-3">
<div class="flex items-center justify-start gap-1 text-xs text-[#666]">
<span>缓存命中率</span>
<Tooltip>
<div class="w-[280px] space-y-3">
<div class="border-b border-[#2a2a2a] pb-3">
<Switch
compact
label="计入缓存创建"
description="开启后把缓存创建纳入分母"
enabled-text="当前按复用率口径显示"
disabled-text="当前按默认命中率口径显示"
:enabled="includeCacheWriteInHitRate"
:busy="homeMetricsConfigSaving"
:disabled="homeMetricsConfigSaving"
@change="toggleIncludeCacheWriteInHitRate"
/>
</div>
<div class="whitespace-pre-wrap">{{ cacheTooltipContent }}</div>
<div v-if="homeMetricsConfigError" class="text-[11px] text-[#ff4444]">
{{ homeMetricsConfigError }}
</div>
</div>
</Tooltip>
</div>
<CacheHitRateChart :rate="selectedCacheHitRate" />
</div>
<div
class="min-w-0 border-l border-[#2a2a2a] px-4 py-4 flex flex-col justify-between"
>
<div class="flex items-center justify-start gap-1 text-xs text-[#666]">
<span>对话轮次</span>
<Tooltip :content="turnsTooltipContent" />
</div>
<div>
<div
class="text-[30px] leading-none text-white"
:title="formatInteger(metrics.turnsTotal)"
>
{{ formatCompactInteger(metrics.turnsTotal) }}
</div>
<div class="mt-3 text-xs leading-5 text-[#666]">
有效
<span :title="formatInteger(metrics.validTurnsTotal)">
{{ formatCompactInteger(metrics.validTurnsTotal) }}
</span>
/ 异常
<span :title="formatInteger(metrics.invalidTurnsTotal)">
{{ formatCompactInteger(metrics.invalidTurnsTotal) }}
</span>
</div>
</div>
</div>
<div
class="min-w-0 border-l border-[#2a2a2a] px-4 py-4 flex flex-col justify-between"
>
<div class="flex items-center justify-start gap-1 text-xs text-[#666]">
<span>Token 消耗</span>
<Tooltip :content="tokensTooltipContent" />
</div>
<div>
<div
class="truncate text-[30px] leading-none text-white"
style="font-family: var(--font-num)"
:title="formatInteger(metrics.requestTokensTotal)"
>
{{ formatCompactInteger(metrics.requestTokensTotal) }}
</div>
<div class="mt-3 text-xs leading-5 text-[#666]">
Prompt
<span :title="formatInteger(metrics.promptTokensTotal)">
{{ formatCompactInteger(metrics.promptTokensTotal) }}
</span>
</div>
</div>
</div>
<div
class="min-w-0 border-l border-[#2a2a2a] px-4 py-4 flex flex-col justify-between"
>
<div class="flex items-center justify-start gap-1 text-xs text-[#666]">
<span>价值估算</span>
<Tooltip :content="costTooltipContent" />
</div>
<div>
<div
class="truncate text-[30px] leading-none text-white"
style="font-family: var(--font-num)"
:title="formatUSD(estimatedTokenCost.total)"
>
{{ formatUSD(estimatedTokenCost.total) }}
</div>
<div class="mt-3 text-xs leading-5 text-[#666]">
缓存读写
<span :title="formatUSD(estimatedTokenCost.cacheRead + estimatedTokenCost.cacheWrite)">
{{ formatUSD(estimatedTokenCost.cacheRead + estimatedTokenCost.cacheWrite) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,32 @@
<script setup>
import Select from "@/components/ui/Select.vue";
import { useLocale } from "@/i18n/runtime";
const props = defineProps({
border: { type: Boolean, default: true },
compact: { type: Boolean, default: false },
ariaLabel: { type: String, default: "界面语言" },
buttonClass: { type: String, default: "" },
menuClass: { type: String, default: "" },
wrapperClass: { type: String, default: "w-[180px] max-w-full" },
placeholder: { type: String, default: "选择语言" },
});
const { locale, localeOptions, setLocale } = useLocale();
</script>
<template>
<div :class="wrapperClass">
<Select
:model-value="locale"
:options="localeOptions"
:border="border"
:compact="compact"
:aria-label="ariaLabel"
:button-class="buttonClass"
:menu-class="menuClass"
:placeholder="placeholder"
@update:model-value="setLocale"
/>
</div>
</template>

View File

@@ -0,0 +1,154 @@
<script setup>
import { computed } from "vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { formatDuration } from "@/state/appState";
const props = defineProps({
result: {
type: Object,
default: null,
},
stale: {
type: Boolean,
default: false,
},
compact: {
type: Boolean,
default: false,
},
showMetrics: {
type: Boolean,
default: false,
},
title: {
type: String,
default: "模型测试",
},
emptyText: {
type: String,
default: "尚未测试",
},
});
const normalizedStatus = computed(() => {
const status = String(props.result?.status || "").trim().toLowerCase();
return ["running", "success", "error"].includes(status) ? status : "idle";
});
const summaryText = computed(() => {
const text = String(props.result?.summaryText || "").trim();
if (text) {
return text;
}
if (normalizedStatus.value === "running") {
return "测试中...";
}
if (normalizedStatus.value === "error") {
return "测试失败";
}
return props.emptyText;
});
const rawResponseText = computed(() => {
const raw = String(props.result?.rawResponse || "").trim();
if (raw) {
return raw;
}
if (normalizedStatus.value === "error") {
return String(props.result?.error || "").trim();
}
return "";
});
const panelClass = computed(() => {
if (props.stale) {
return "border-[#333] bg-[#0f0f0f]";
}
if (normalizedStatus.value === "running") {
return "border-[#444] bg-[#141414]";
}
if (normalizedStatus.value === "error") {
return "border-[#555] bg-[#141414]";
}
if (normalizedStatus.value === "success" && props.result?.tokensEstimated) {
return "border-[#444] bg-[#1c1c1c]";
}
if (normalizedStatus.value === "success") {
return "border-[#555] bg-[#1c1c1c]";
}
return "border-[#343434] bg-[#1c1c1c]";
});
const summaryClass = computed(() => {
if (props.stale) {
return "text-[#666]";
}
if (normalizedStatus.value === "running") {
return "text-[#a8a8a8]";
}
if (normalizedStatus.value === "error") {
return "text-white";
}
if (normalizedStatus.value === "success" && props.result?.tokensEstimated) {
return "text-[#a8a8a8]";
}
if (normalizedStatus.value === "success") {
return "text-white";
}
return "text-[#a8a8a8]";
});
</script>
<template>
<div class="rounded-[8px] border px-3 py-3" :class="panelClass">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<div
:class="compact ? 'text-[11px] uppercase tracking-[0.08em] text-[#666]' : 'text-sm font-medium text-white'"
>
{{ title }}
</div>
<div v-if="rawResponseText" class="center-row gap-1 text-[11px] text-[#666]">
<span>原始返回</span>
<Tooltip :content="rawResponseText" copyable />
</div>
</div>
<div class="mt-1 text-sm leading-relaxed" :class="summaryClass">
{{ summaryText }}
</div>
</div>
<span
v-if="stale"
class="shrink-0 rounded-[999px] border border-[#444] px-2 py-1 text-xs text-[#a8a8a8]"
>
需重测
</span>
</div>
<div v-if="stale" class="mt-2 text-xs text-[#666]">
配置已变更,请重新测试
</div>
<div
v-if="showMetrics && normalizedStatus === 'success'"
class="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2"
>
<div class="rounded-[8px] bg-[#1c1c1c] px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">总耗时</div>
<div class="mt-1 text-sm text-white">{{ formatDuration(result?.totalDurationMS) }}</div>
</div>
<div class="rounded-[8px] bg-[#1c1c1c] px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">输出 Token</div>
<div class="mt-1 text-sm text-white">{{ result?.outputTokens ?? 0 }}</div>
</div>
</div>
<div
v-if="normalizedStatus === 'success' && result?.tokensEstimated"
class="mt-2 text-xs text-[#666]"
>
输出 Token 为估算值
</div>
</div>
</template>

View File

@@ -0,0 +1,128 @@
<script setup>
import {
ArcElement,
Chart as ChartJS,
Tooltip,
} from "chart.js";
import { computed } from "vue";
import { Doughnut } from "vue-chartjs";
ChartJS.register(ArcElement, Tooltip);
const props = defineProps({
rate: {
type: Number,
default: 0,
},
});
const percentage = computed(() => {
const rate = Number(props.rate);
if (!Number.isFinite(rate)) {
return 0;
}
return Math.max(0, Math.min(100, rate * 100));
});
const label = computed(() => {
const rate = Number(props.rate);
if (!Number.isFinite(rate)) {
return "--";
}
return `${percentage.value.toFixed(2)}%`;
});
function getSegmentBorderRadius(dataIndex) {
const radius = 5;
if (percentage.value <= 0) {
return dataIndex === 1
? {
outerStart: radius,
outerEnd: radius,
innerStart: radius,
innerEnd: radius,
}
: 0;
}
if (percentage.value >= 100) {
return dataIndex === 0
? {
outerStart: radius,
outerEnd: radius,
innerStart: radius,
innerEnd: radius,
}
: 0;
}
return dataIndex === 0
? {
outerStart: radius,
outerEnd: 0,
innerStart: radius,
innerEnd: 0,
}
: {
outerStart: 0,
outerEnd: radius,
innerStart: 0,
innerEnd: radius,
};
}
const chartData = computed(() => ({
labels: ["命中", "未命中"],
datasets: [
{
data: [percentage.value, Math.max(0, 100 - percentage.value)],
backgroundColor: ["#ffffff", "#2a2a2a"],
borderWidth: 0,
hoverBorderWidth: 0,
selfJoin: false,
borderRadius: ({ dataIndex }) => getSegmentBorderRadius(dataIndex),
},
],
}));
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
cutout: "82%",
rotation: -90,
circumference: 180,
animation: {
duration: 450,
},
events: [],
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: false,
},
},
};
</script>
<template>
<div class="flex flex-col items-center gap-3">
<div
class="relative h-[82px] w-[132px] shrink-0"
role="img"
:aria-label="`缓存命中率 ${label}`"
>
<Doughnut class="h-full w-full" :data="chartData" :options="chartOptions" />
<div class="pointer-events-none absolute inset-x-0 bottom-[10px] flex justify-center">
<div
class="text-[20px] leading-none text-white"
style="font-family: var(--font-num)"
>
{{ label }}
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
defineProps({
variant: {
type: String,
default: "default",
validator: (v) => ["default", "primary", "text"].includes(v),
},
});
</script>
<template>
<button
v-if="variant === 'text'"
type="button"
class="!whitespace-nowrap shrink-0 cursor-pointer text-sm text-[#a8a8a8] transition-colors duration-150 hover:text-white active:text-white"
>
<slot />
</button>
<button
v-else
type="button"
class="!whitespace-nowrap cursor-pointer inline-flex items-center gap-[6px] min-h-[28px] rounded-[4px] px-[10px] py-[5px] text-[12px] font-medium transition-all duration-150 active:scale-[.98] border"
:class="{
'bg-[#141414] border-[#383838] text-white hover:bg-[#1c1c1c] hover:border-[#666]': variant === 'default',
'bg-white border-white text-black hover:bg-[#a8a8a8] hover:border-[#a8a8a8]': variant === 'primary',
}"
>
<slot />
</button>
</template>

View File

@@ -0,0 +1,6 @@
<script setup></script>
<template>
<div class="rounded-[8px] border border-[#2a2a2a] bg-[#0a0a0a] p-4">
<slot />
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup>
import { computed, ref, useAttrs, watch } from "vue";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
modelValue: { type: String, default: "" },
type: { type: String, default: "text" },
placeholder: { type: String, default: "" },
disabled: { type: Boolean, default: false },
allowVisibilityToggle: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue"]);
const attrs = useAttrs();
const isPasswordVisible = ref(false);
const canToggleVisibility = computed(() => props.type === "password" && props.allowVisibilityToggle);
const inputType = computed(() => {
if (!canToggleVisibility.value) {
return props.type;
}
return isPasswordVisible.value ? "text" : "password";
});
watch(
() => [props.type, props.allowVisibilityToggle],
([type, allowVisibilityToggle]) => {
if (type !== "password" || !allowVisibilityToggle) {
isPasswordVisible.value = false;
}
},
{ immediate: true },
);
function handleInput(event) {
emit("update:modelValue", event?.target?.value ?? "");
}
function toggleVisibility() {
if (!canToggleVisibility.value || props.disabled) {
return;
}
isPasswordVisible.value = !isPasswordVisible.value;
}
</script>
<template>
<div class="relative w-full">
<input
v-bind="attrs"
:value="modelValue"
:type="inputType"
:placeholder="placeholder"
:disabled="disabled"
class="h-9 w-full rounded-[4px] border border-[#383838] bg-[#141414] px-3 text-sm text-white outline-none transition-colors focus:border-white disabled:cursor-not-allowed disabled:opacity-60"
:class="canToggleVisibility ? 'pr-10' : ''"
@input="handleInput"
/>
<button
v-if="canToggleVisibility"
type="button"
class="absolute inset-y-0 right-0 center-row px-3 text-[#666] transition-colors hover:text-white focus:text-white focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
:aria-label="isPasswordVisible ? '隐藏访问密钥' : '显示访问密钥'"
:aria-pressed="isPasswordVisible"
:disabled="disabled"
@click="toggleVisibility"
>
<span
:class="[
isPasswordVisible ? 'icon-[mdi--eye-off-outline]' : 'icon-[mdi--eye-outline]',
'text-[18px]',
]"
></span>
</button>
</div>
</template>

View File

@@ -0,0 +1,97 @@
<script setup>
import Button from "@/components/ui/Button.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
title: { type: String, default: "提示" },
content: { type: String, default: "" },
placeholder: { type: String, default: "" },
modelValue: { type: String, default: "" },
});
const emit = defineEmits(["update:visible", "update:modelValue", "confirm", "cancel"]);
function handleConfirm() {
emit("confirm");
emit("update:visible", false);
}
function handleCancel() {
emit("cancel");
emit("update:visible", false);
}
function onMaskClick() {
handleCancel();
}
function onInput(event) {
emit("update:modelValue", event?.target?.value ?? "");
}
function onEnter(event) {
event.preventDefault();
handleConfirm();
}
</script>
<template>
<Teleport to="body">
<Transition name="modal-mask">
<div
v-show="visible"
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/60 p-4"
@click.self="onMaskClick"
>
<Transition name="modal-content">
<div
v-show="visible"
class="relative z-10 w-full max-w-[380px] overflow-hidden rounded-[8px] border border-[#383838] bg-[#141414] p-5 shadow-[0_25px_50px_-12px_rgba(0,0,0,0.8)]"
@click.stop
>
<h3 class="mb-3 text-base font-medium text-white">
{{ title }}
</h3>
<p class="mb-3 text-sm leading-relaxed text-[#a8a8a8]">
{{ content }}
</p>
<input
:value="modelValue"
:placeholder="placeholder"
type="text"
class="mb-5 h-9 w-full rounded-[4px] border border-[#383838] bg-[#141414] px-3 text-sm text-white outline-none focus:border-white"
@input="onInput"
@keydown.enter="onEnter"
/>
<div class="flex justify-end gap-2">
<Button variant="default" @click="handleCancel">取消</Button>
<Button variant="primary" @click="handleConfirm">确定</Button>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-mask-enter-active,
.modal-mask-leave-active {
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
}
.modal-mask-enter-from,
.modal-mask-leave-to {
opacity: 0;
backdrop-filter: blur(0);
}
.modal-content-enter-active,
.modal-content-leave-active {
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-content-enter-from,
.modal-content-leave-to {
opacity: 0;
transform: scale(0.9) translateY(-10px);
}
</style>

View File

@@ -0,0 +1,83 @@
<script setup>
import { messageState, provideMessage } from "@/composables/useMessage";
provideMessage();
const MESSAGE_THEME = {
success: {
containerClass: "bg-white text-black",
iconClass: "icon-[dashicons--yes]",
iconExtraClass: "",
},
error: {
containerClass: "bg-[#1c1c1c] text-white border border-[#555]",
iconClass: "",
iconExtraClass: "",
},
info: {
containerClass: "bg-[#1c1c1c] text-white",
iconClass: "",
iconExtraClass: "",
},
loading: {
containerClass: "bg-[#1c1c1c] text-white",
iconClass: "icon-[mingcute--loading-fill]",
iconExtraClass: "animate-spin",
},
};
function resolveTheme(type) {
return MESSAGE_THEME[type] || MESSAGE_THEME.info;
}
</script>
<template>
<div class="pointer-events-none fixed inset-x-0 top-4 z-[1000] flex justify-center px-4">
<Transition name="message-slide" mode="out-in">
<div
v-if="messageState.current"
:key="messageState.current.id"
class="pointer-events-auto inline-flex max-w-full items-center gap-2 rounded-full px-4 py-2 text-sm shadow-[0_8px_24px_rgba(0,0,0,0.28)]"
:class="resolveTheme(messageState.current.type).containerClass"
>
<span
v-if="resolveTheme(messageState.current.type).iconClass"
class="text-[14px]"
:class="[
resolveTheme(messageState.current.type).iconClass,
resolveTheme(messageState.current.type).iconExtraClass,
]"
/>
<span class="leading-none whitespace-nowrap">{{ messageState.current.content }}</span>
</div>
</Transition>
</div>
</template>
<style scoped>
.message-slide-enter-active,
.message-slide-leave-active {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.message-slide-enter-from {
opacity: 0;
transform: translateY(-12px);
}
.message-slide-enter-to,
.message-slide-leave-from {
opacity: 1;
transform: translateY(0);
}
.message-slide-leave-to {
opacity: 0;
transform: translateY(-12px);
}
</style>

View File

@@ -0,0 +1,82 @@
<script setup>
import Button from "@/components/ui/Button.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
title: { type: String, default: "提示" },
content: { type: String, default: "" },
confirmText: { type: String, default: "确定" },
cancelText: { type: String, default: "取消" },
showCancel: { type: Boolean, default: true },
confirmDisabled: { type: Boolean, default: false },
});
const emit = defineEmits(["update:visible", "confirm", "cancel"]);
function handleConfirm() {
emit("confirm");
emit("update:visible", false);
}
function handleCancel() {
emit("cancel");
emit("update:visible", false);
}
function onMaskClick() {
handleCancel();
}
</script>
<template>
<Teleport to="body">
<Transition name="modal-mask">
<div
v-show="visible"
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/60 p-4"
@click.self="onMaskClick"
>
<Transition name="modal-content">
<div
v-show="visible"
class="relative z-10 w-full max-w-[360px] overflow-hidden rounded-[8px] border border-[#383838] bg-[#141414] p-5 shadow-[0_25px_50px_-12px_rgba(0,0,0,0.8)]"
@click.stop
>
<h3 class="mb-3 text-base font-medium text-white">
{{ title }}
</h3>
<p class="mb-5 max-h-[55vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed text-[#a8a8a8]">
{{ content }}
</p>
<div class="flex justify-end gap-2">
<Button v-if="showCancel" variant="default" @click="handleCancel">{{ cancelText }}</Button>
<Button variant="primary" :disabled="confirmDisabled" @click="handleConfirm">{{ confirmText }}</Button>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-mask-enter-active,
.modal-mask-leave-active {
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
}
.modal-mask-enter-from,
.modal-mask-leave-to {
opacity: 0;
backdrop-filter: blur(0);
}
.modal-content-enter-active,
.modal-content-leave-active {
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-content-enter-from,
.modal-content-leave-to {
opacity: 0;
transform: scale(0.9) translateY(-10px);
}
</style>

View File

@@ -0,0 +1,347 @@
<script setup>
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
import { computed, nextTick, onBeforeUnmount, ref, watch, watchPostEffect } from "vue";
const props = defineProps({
modelValue: { type: String, default: "" },
options: {
type: Array,
default: () => [],
},
placeholder: { type: String, default: "请选择" },
disabled: { type: Boolean, default: false },
border: { type: Boolean, default: true },
compact: { type: Boolean, default: false },
ariaLabel: { type: String, default: "" },
buttonClass: { type: String, default: "" },
menuClass: { type: String, default: "" },
});
const emit = defineEmits(["update:modelValue", "change", "blur"]);
const rootRef = ref(null);
const buttonRef = ref(null);
const menuRef = ref(null);
const optionRefs = ref([]);
const isOpen = ref(false);
const activeIndex = ref(-1);
const menuStyle = ref({});
const normalizedOptions = computed(() => props.options.map((option) => {
if (typeof option === "string") {
return { label: option, value: option };
}
return {
label: option?.label ?? option?.value ?? "",
value: option?.value ?? "",
icon: option?.icon ?? option?.iconClass ?? "",
};
}));
const selectedOption = computed(() => normalizedOptions.value.find((option) => option.value === props.modelValue) ?? null);
const selectedLabel = computed(() => selectedOption.value?.label || props.placeholder);
function setOptionRef(el, index) {
if (el) {
optionRefs.value[index] = el;
return;
}
delete optionRefs.value[index];
}
function focusActiveOption() {
nextTick(() => {
const option = optionRefs.value[activeIndex.value];
option?.focus();
});
}
function openMenu() {
if (props.disabled || isOpen.value) {
return;
}
isOpen.value = true;
const selectedIndex = normalizedOptions.value.findIndex((option) => option.value === props.modelValue);
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
nextTick(() => {
updatePosition();
focusActiveOption();
});
}
function closeMenu({ restoreFocus = false } = {}) {
if (!isOpen.value) {
return;
}
isOpen.value = false;
activeIndex.value = -1;
optionRefs.value = [];
menuStyle.value = {};
if (restoreFocus) {
nextTick(() => buttonRef.value?.focus());
}
emit("blur");
}
function toggleMenu() {
if (isOpen.value) {
closeMenu();
return;
}
openMenu();
}
function selectOption(option) {
if (!option || option.value === props.modelValue) {
closeMenu({ restoreFocus: true });
return;
}
emit("update:modelValue", option.value);
emit("change", option.value);
closeMenu({ restoreFocus: true });
}
function moveActiveIndex(step) {
if (!normalizedOptions.value.length) {
return;
}
if (!isOpen.value) {
openMenu();
return;
}
const total = normalizedOptions.value.length;
const current = activeIndex.value >= 0 ? activeIndex.value : 0;
activeIndex.value = (current + step + total) % total;
focusActiveOption();
}
function handleButtonKeydown(event) {
if (props.disabled) {
return;
}
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveActiveIndex(1);
break;
case "ArrowUp":
event.preventDefault();
moveActiveIndex(-1);
break;
case "Enter":
case " ":
event.preventDefault();
toggleMenu();
break;
case "Escape":
if (isOpen.value) {
event.preventDefault();
closeMenu();
}
break;
default:
break;
}
}
function handleOptionKeydown(event, option, index) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
activeIndex.value = index;
moveActiveIndex(1);
break;
case "ArrowUp":
event.preventDefault();
activeIndex.value = index;
moveActiveIndex(-1);
break;
case "Enter":
case " ":
event.preventDefault();
selectOption(option);
break;
case "Escape":
event.preventDefault();
closeMenu({ restoreFocus: true });
break;
case "Tab":
closeMenu();
break;
default:
break;
}
}
function handlePointerDown(event) {
if (rootRef.value?.contains(event.target) || menuRef.value?.contains(event.target)) {
return;
}
closeMenu();
}
function updatePosition() {
if (!buttonRef.value || !menuRef.value) {
return;
}
computePosition(buttonRef.value, menuRef.value, {
placement: "bottom-start",
middleware: [
offset(6),
flip({ padding: 12 }),
shift({ padding: 12 }),
size({
apply({ rects, elements, availableHeight }) {
Object.assign(elements.floating.style, {
minWidth: `${rects.reference.width}px`,
maxHeight: `${Math.max(availableHeight, 160)}px`,
});
},
padding: 12,
}),
],
}).then(({ x, y }) => {
menuStyle.value = {
left: `${x}px`,
top: `${y}px`,
};
});
}
watchPostEffect((cleanup) => {
if (!isOpen.value || !buttonRef.value || !menuRef.value) {
return;
}
const stopAutoUpdate = autoUpdate(buttonRef.value, menuRef.value, updatePosition);
cleanup(() => {
stopAutoUpdate();
});
});
watch(() => props.modelValue, () => {
if (!isOpen.value) {
return;
}
const selectedIndex = normalizedOptions.value.findIndex((option) => option.value === props.modelValue);
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
});
watch(isOpen, (open) => {
if (open) {
document.addEventListener("pointerdown", handlePointerDown);
return;
}
document.removeEventListener("pointerdown", handlePointerDown);
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", handlePointerDown);
});
</script>
<template>
<div ref="rootRef" class="relative">
<button
ref="buttonRef"
type="button"
:disabled="disabled"
class="flex h-9 items-center rounded-[4px] bg-[#141414] px-3 text-left text-sm text-white outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-60"
:class="[
border
? 'w-full justify-between gap-2 border border-[#383838] focus:border-white'
: compact
? 'w-full justify-center gap-1 border border-transparent focus-visible:ring-2 focus-visible:ring-white/30'
: 'w-auto justify-start gap-2 border border-transparent focus-visible:ring-2 focus-visible:ring-white/30',
buttonClass,
]"
:aria-expanded="isOpen"
:aria-label="ariaLabel || undefined"
aria-haspopup="listbox"
@click="toggleMenu"
@keydown="handleButtonKeydown"
>
<span
class="flex min-w-0 items-center gap-2"
:class="[
border ? 'flex-1' : compact ? 'min-w-0 flex-1' : 'shrink-0',
selectedOption
? (border ? 'text-white' : 'text-current')
: 'text-[#666]',
]"
>
<span v-if="selectedOption?.icon" :class="[selectedOption.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
<span class="truncate">{{ selectedLabel }}</span>
</span>
<span
v-if="!compact"
class="pointer-events-none center-row transition-transform duration-200"
:class="[border ? 'text-[#666]' : 'text-current', isOpen ? 'rotate-180' : '']"
>
<span class="icon-[mdi--chevron-down] text-[18px]"></span>
</span>
</button>
</div>
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<div
v-if="isOpen"
ref="menuRef"
class="fixed z-[999] overflow-hidden rounded-[8px] border border-[#383838] bg-[#141414] p-1 shadow-[0_16px_30px_-12px_rgba(0,0,0,0.8)]"
:class="menuClass"
:style="menuStyle"
>
<ul role="listbox" class="overflow-y-auto py-1">
<li v-for="(option, index) in normalizedOptions" :key="option.value">
<button
:ref="(el) => setOptionRef(el, index)"
type="button"
role="option"
class="flex w-full items-center rounded-[6px] px-3 py-2 text-left text-sm outline-none transition-colors"
:class="[
option.value === modelValue
? 'bg-white/10 text-white'
: 'text-white hover:bg-[#1c1c1c]',
activeIndex === index ? 'bg-[#1c1c1c]' : '',
]"
:aria-selected="option.value === modelValue"
tabindex="0"
@click="selectOption(option)"
@mouseenter="activeIndex = index"
@keydown="handleOptionKeydown($event, option, index)"
>
<span class="flex min-w-0 items-center gap-2">
<span v-if="option.icon" :class="[option.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
<span class="truncate">{{ option.label }}</span>
</span>
</button>
</li>
</ul>
</div>
</Transition>
</Teleport>
</template>

View File

@@ -0,0 +1,65 @@
<script setup>
const props = defineProps({
enabled: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
busy: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
label: { type: String, default: "" },
description: { type: String, default: "" },
enabledText: { type: String, default: "已开启" },
disabledText: { type: String, default: "已关闭" },
busyText: { type: String, default: "切换中..." },
});
const emit = defineEmits(["change"]);
function handleToggle() {
if (props.disabled || props.busy) {
return;
}
emit("change", !props.enabled);
}
</script>
<template>
<div
class="flex items-center justify-between gap-4"
:class="compact ? 'py-0' : 'py-1'"
>
<div class="flex min-w-0 flex-col" :class="compact ? 'gap-[2px]' : 'gap-1'">
<div :class="compact ? 'text-[12px]' : 'text-sm'" class="font-medium text-white">
{{ label }}
</div>
<div
v-if="description"
:class="compact ? 'text-[11px] leading-[16px]' : 'text-xs'"
class="text-[#a8a8a8]"
>
{{ description }}
</div>
<div
:class="[
compact ? 'text-[11px] leading-[16px]' : 'text-xs',
enabled ? 'text-white' : 'text-[#666]',
]"
>
{{ busy ? busyText : enabled ? enabledText : disabledText }}
</div>
</div>
<button
type="button"
role="switch"
:aria-checked="enabled"
:disabled="disabled || busy"
class="relative inline-flex h-[18px] w-[34px] shrink-0 cursor-pointer rounded-full outline-none transition-all duration-200 ease-out disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-white/30"
:class="enabled ? 'bg-white' : 'bg-[#1c1c1c] border border-[#383838]'"
@click="handleToggle"
>
<span
class="absolute top-[2px] left-[2px] inline-flex h-[12px] w-[12px] rounded-full transition-all duration-200 ease-out"
:class="enabled ? 'translate-x-[16px] bg-black' : 'translate-x-0 bg-[#666]'"
/>
</button>
</div>
</template>

View File

@@ -0,0 +1,156 @@
<script setup>
import { autoUpdate, computePosition, flip, offset, shift } from "@floating-ui/dom";
import copyTextToClipboard from "copy-text-to-clipboard";
import { computed, nextTick, onBeforeUnmount, ref, useSlots, watchPostEffect } from "vue";
const props = defineProps({
content: { type: String, default: "" },
copyable: { type: Boolean, default: false },
copyText: { type: String, default: "" },
});
const slots = useSlots();
const HIDE_DELAY_MS = 300;
const COPY_RESET_DELAY_MS = 1500;
const triggerRef = ref(null);
const tooltipRef = ref(null);
const isOpen = ref(false);
const tooltipStyle = ref({});
const copied = ref(false);
let hideTimer = null;
let copyResetTimer = null;
const copyValue = computed(() => String(props.copyText || props.content || "").trim());
const hasContent = computed(() => !!props.content || !!slots.default);
const showCopyButton = computed(() => props.copyable && !!copyValue.value);
function showTooltip() {
if (!hasContent.value) {
return;
}
clearHideTimer();
isOpen.value = true;
nextTick(() => {
updatePosition();
});
}
function hideTooltip() {
isOpen.value = false;
}
function clearHideTimer() {
if (hideTimer) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
}
function clearCopyResetTimer() {
if (copyResetTimer) {
window.clearTimeout(copyResetTimer);
copyResetTimer = null;
}
}
function scheduleHideTooltip() {
clearHideTimer();
hideTimer = window.setTimeout(() => {
hideTooltip();
hideTimer = null;
}, HIDE_DELAY_MS);
}
function updatePosition() {
if (!triggerRef.value || !tooltipRef.value) {
return;
}
computePosition(triggerRef.value, tooltipRef.value, {
placement: "top",
middleware: [
offset(10),
flip({ padding: 12 }),
shift({ padding: 12 }),
],
}).then(({ x, y }) => {
tooltipStyle.value = {
left: `${x}px`,
top: `${y}px`,
};
});
}
function handleCopy() {
if (!copyValue.value) {
return;
}
copyTextToClipboard(copyValue.value);
copied.value = true;
clearCopyResetTimer();
copyResetTimer = window.setTimeout(() => {
copied.value = false;
copyResetTimer = null;
}, COPY_RESET_DELAY_MS);
}
watchPostEffect((cleanup) => {
if (!isOpen.value || !triggerRef.value || !tooltipRef.value) {
return;
}
const stop = autoUpdate(triggerRef.value, tooltipRef.value, updatePosition);
cleanup(() => {
stop();
});
});
onBeforeUnmount(() => {
clearHideTimer();
clearCopyResetTimer();
hideTooltip();
});
</script>
<template>
<span class="inline-flex">
<button
ref="triggerRef"
type="button"
class="center-row h-[16px] w-[16px] cursor-help rounded-full text-[#666] transition-colors duration-150 hover:text-white"
@mouseenter="showTooltip"
@mouseleave="scheduleHideTooltip"
@focus="showTooltip"
@blur="scheduleHideTooltip"
>
<span class="icon-[mdi--information-outline] text-[14px]"></span>
</button>
<Teleport to="body">
<div
v-if="isOpen"
ref="tooltipRef"
class="fixed z-[10000] flex max-h-[320px] max-w-[420px] flex-col overflow-hidden rounded-[8px] border border-[#383838] bg-[#141414] px-3 py-2 text-left text-[12px] leading-relaxed text-white shadow-[0_12px_32px_rgba(0,0,0,0.6)]"
:style="tooltipStyle"
@mouseenter="showTooltip"
@mouseleave="scheduleHideTooltip"
>
<div v-if="showCopyButton" class="mb-2 flex shrink-0 justify-end">
<button
type="button"
class="center-row gap-1 rounded-[6px] border border-[#383838] bg-[#1c1c1c] px-2 py-1 text-[11px] text-white transition-colors duration-150 hover:border-[#666] hover:bg-[#2a2a2a]"
@click="handleCopy"
>
<span :class="copied ? 'icon-[mdi--check]' : 'icon-[mdi--content-copy]'" class="text-[13px]"></span>
<span>{{ copied ? "已复制" : "拷贝" }}</span>
</button>
</div>
<div class="min-h-0 overflow-auto break-words">
<slot>
<div class="whitespace-pre-wrap">{{ content }}</div>
</slot>
</div>
</div>
</Teleport>
</span>
</template>

View File

@@ -0,0 +1,36 @@
import { reactive } from "vue";
export const inputModalState = reactive({
visible: false,
title: "提示",
content: "",
placeholder: "",
value: "",
_resolve: null,
});
/**
* 显示输入弹窗,返回 Promise<string|null>
* @param {Object} options - { title, content, placeholder, defaultValue }
* @returns {Promise<string|null>} - string=确定后的输入值, null=取消
*/
export function showInputModal(options = {}) {
return new Promise((resolve) => {
inputModalState.visible = true;
inputModalState.title = options.title ?? "提示";
inputModalState.content = options.content ?? "";
inputModalState.placeholder = options.placeholder ?? "";
inputModalState.value = String(options.defaultValue ?? "");
inputModalState._resolve = resolve;
});
}
export function resolveInputModal(ok) {
const value = String(inputModalState.value ?? "").trim();
inputModalState.visible = false;
inputModalState._resolve?.(ok ? value : null);
inputModalState._resolve = null;
if (!ok) {
inputModalState.value = "";
}
}

View File

@@ -0,0 +1,109 @@
import { inject, provide, reactive } from "vue";
const MESSAGE_API_SYMBOL = Symbol("message-api");
const MIN_VISIBLE_MS = 300;
let messageSeed = 0;
const messageState = reactive({
current: null,
});
function clearMessageTimer(item) {
if (item?.timer) {
clearTimeout(item.timer);
item.timer = null;
}
}
function removeMessage(id, options = {}) {
if (!messageState.current || messageState.current.id !== id) {
return;
}
const current = messageState.current;
const elapsed = Date.now() - current.shownAt;
const force = options.force === true;
if (!force && elapsed < MIN_VISIBLE_MS) {
clearMessageTimer(current);
current.timer = window.setTimeout(() => {
removeMessage(id, { force: true });
}, MIN_VISIBLE_MS - elapsed);
return;
}
clearMessageTimer(current);
messageState.current = null;
}
function showMessage(options = {}) {
const type = typeof options.type === "string" ? options.type : "info";
const content = String(options.content || "").trim();
if (!content) {
return null;
}
if (messageState.current) {
clearMessageTimer(messageState.current);
}
const duration = Number.isFinite(options.duration)
? Math.max(0, options.duration)
: type === "loading"
? 0
: 2400;
const id = `message-${Date.now()}-${messageSeed += 1}`;
const item = {
id,
type,
content,
shownAt: Date.now(),
timer: null,
};
if (duration > 0) {
item.timer = window.setTimeout(() => {
removeMessage(id);
}, Math.max(duration, MIN_VISIBLE_MS));
}
messageState.current = item;
return id;
}
export function createMessageApi() {
return {
state: messageState,
show: showMessage,
success(content, options = {}) {
return showMessage({ ...options, type: "success", content });
},
error(content, options = {}) {
return showMessage({ ...options, type: "error", content });
},
info(content, options = {}) {
return showMessage({ ...options, type: "info", content });
},
loading(content, options = {}) {
return showMessage({ ...options, type: "loading", content });
},
remove: removeMessage,
clear() {
if (messageState.current) {
removeMessage(messageState.current.id, { force: true });
}
},
};
}
const defaultMessageApi = createMessageApi();
export function provideMessage() {
provide(MESSAGE_API_SYMBOL, defaultMessageApi);
return defaultMessageApi;
}
export function useMessage() {
return inject(MESSAGE_API_SYMBOL, defaultMessageApi);
}
export { messageState, showMessage, removeMessage };

View File

@@ -0,0 +1,37 @@
import { reactive } from "vue";
export const modalState = reactive({
visible: false,
title: "提示",
content: "",
confirmText: "确定",
cancelText: "取消",
showCancel: true,
confirmDisabled: false,
_resolve: null,
});
/**
* 显示确认弹窗,返回 Promise<boolean>
* @param {Object} options - { title, content }
* @returns {Promise<boolean>} - true=确定, false=取消
*/
export function showModal(options = {}) {
return new Promise((resolve) => {
modalState.visible = true;
modalState.title = options.title ?? "提示";
modalState.content = options.content ?? "";
modalState.confirmText = options.confirmText ?? "确定";
modalState.cancelText = options.cancelText ?? "取消";
modalState.showCancel = options.showCancel ?? true;
modalState.confirmDisabled = options.confirmDisabled ?? false;
modalState._resolve = resolve;
});
}
export function resolveModal(ok) {
modalState.visible = false;
const resolve = modalState._resolve;
modalState._resolve = null;
resolve?.(ok);
}

View File

@@ -0,0 +1,10 @@
export const LOCALE_STORAGE_KEY = "cursor-client:locale:v1";
export const LOCALE_STORAGE_SOURCE_KEY = "cursor-client:locale-source:v1";
export const SOURCE_LOCALE = "zh-CN";
export const DEFAULT_LOCALE = "en-US";
export const SUPPORTED_LOCALES = ["zh-CN", "en-US", "ja-JP"];
export const LOCALE_OPTIONS = [
{ label: "简体中文", value: "zh-CN" },
{ label: "English", value: "en-US" },
{ label: "日本語", value: "ja-JP" },
];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,176 @@
{
"02216368edc68816": "No release notes",
"02bc2e95bf49e587": "No",
"03b11112dc970014": "Base URL",
"045261cc748d4300": "Token budget allowed during Anthropic's thinking phase. Leave blank to use the default.",
"047ec6b71d0cec08": "Control whether requests on the whitelist main path go through the local service or return to the original Cursor upstream endpoint",
"04f632dd4f034d5e": "{0} context window must be a positive integer",
"051836569928a9f9": "Edit",
"054d763265603305": "e.g. 278000 (leave blank to use the default)",
"05c8a9238c702efa": "Direct Cursor Mode",
"0647728439b5da2e": "You can configure the routing mode and model channels. Runtime logs are stored in",
"092b520558eff5f2": "Not tested",
"0b0e7478e41fe677": "{0} tooltip text cannot be empty",
"0c3b4cf7aa259edb": "Operation failed",
"0dde813d719dbd01": "Failed to open homepage",
"0e40a09ab4e664ab": "Duplicate model channel detected. Check the combination of url, modelID, apiKey, and displayName",
"1117a2f86030d03b": "Cache reads and writes are included in Prompt-side statistics.",
"11afd2a534395b18": "Valid",
"124be3f86f197802": "Token Usage",
"13b61c5f697b6700": "Cache Hit Rate",
"15d124b200ddabed": "Maximum number of context tokens the model can accept in a single request. Leave blank to use the default.",
"18b7312022cd1840": "Start Service",
"1af38868896cf53d": "Routing mode only supports local or upstream",
"1b7d24b212e52c54": "{0} reasoning effort only supports low, medium, high, and xhigh",
"1bc77f5ab979f4c1": "Add Model Settings",
"1e238093b79b3165": "Uses 278000 by default when left blank",
"24343a2096988d42": "Failed to open",
"253d4a3428c648fb": "Cache write tokens: {0}",
"258e4620e2108793": "Uses 4096 by default when left blank",
"26a3855aed1d8d17": "Service not running",
"281eb6d08c9960d0": "{0} thinking budget token must be a positive integer",
"28aeffc70ceb4267": "Change the display language for this interface. The setting takes effect immediately and is saved on this device.",
"2caeaec539e78898": "Thinking Budget Token",
"2cd0f3be8738a86c": "Cancel",
"2d706f7981b45a7b": "Local settings saved",
"2f9daa828907b93f": "Delete",
"30bb57a50caedc38": "e.g. For everyday code completion and Q&A",
"32b3c9a50003f77a": "Output tokens are estimated",
"33d2d273e2bd5f88": "Failed to open user guide",
"3468b57e3edbc599": "Aggregated from turn summaries scanned from the history.",
"35076178fe79a210": "Configuration changed. Please test again.",
"36c149a9b3e8dca0": "models configured yet.",
"37d23612f78a2e63": "Restart Now to Update",
"392d0dceb45998d3": "Extreme",
"393df9bb13ea4900": "Hit",
"3af7e5489e61ea51": "Refreshing",
"3bf8512aa520ed21": "Local Service Mode",
"3c2a9f9901109e75": "{0} type only supports OpenAI or Anthropic",
"3d13868593ae4eeb": "Interface Language",
"3ea83f9f55062582": "Release date: {0}",
"3edda85621fd03b2": "model adapters",
"42aa8e01e98c0d8c": "Total Duration",
"468adaa418ee1475": "e.g. https://api.openai.com/v1",
"4923eeb7bd75cccd": "{0} model ID cannot be empty",
"497c85690c4cc0fc": "No data",
"4b8d11bf235e9213": "Current hit rate: {0}",
"4d8c1c5b42830791": "Unknown",
"51194c3ad014fb29": "Retest required",
"5205125c0e91d346": "Maximum tokens an Anthropic model may generate in a single response. Leave blank to use the default.",
"56627c94a9decee6": "Max Output Tokens",
"5beb1206c532729f": "Maximum number of tokens allowed in a single response. Leave blank to use the default.",
"5d1687a4a41883fd": "Stopping...",
"6106f0a12583a334": "Refresh failed",
"62873083fcaed27d": "Session Statistics",
"6309a3bb5ba4c714": "Save failed",
"636e3deffc1e960a": "Model {0}",
"63d90d977348ab1f": "Duplicate",
"64d2730f2ae37997": "Raw Response",
"65cc5fd2e6ce6e75": "Backend started, proxy not started",
"66af574b8948fe83": "{0} API key cannot be empty",
"675109292da4eb36": "Not tested yet",
"699fe7ade5407687": "Direct Mode",
"6a7b96f399e58138": "e.g. sk-xxxxxx",
"6aa8f49cc992dfd7": "Test",
"6ae23d6d7cb18592": "Service error",
"6e584e3d5ce64aa0": "Save Settings",
"737225e2904673fc": "Estimated output tokens: {0}",
"7520bd50a5ee5471": "Stop testing {0}/{1}",
"753d8bb0da9913ce": "Duplication failed",
"77c9e582e85583af": "Test failed",
"7a26bf794e9fb6bf": "Used only for display in the UI, so you can distinguish different models.",
"7b6187c41e88b70c": "Testing...",
"7bf8e2c07e084d09": "Model Editor",
"7e9e334aeb0bdc07": "Service operation failed",
"7f68ebad19ba6bcd": "Check for Updates",
"81123c56d5d880d0": "API Key",
"86df7ec743047234": "Service running",
"87ed126f7bd1121e": "Routing Mode",
"899add6275682210": "Uses 500000 by default when left blank",
"8c0d84831a3c3d5b": "Currently in Local Service Mode",
"8c1935935600e336": "Model Test",
"8cbcf741e727dbf7": "Model Settings",
"8d1de152be6360ce": "Valid ratio: {0}",
"8e2dc7b0d2e8f6f8": "e.g. OpenAI - GPT-4.1",
"8f6f8d979c981ced": "Copied",
"8faa670b512b6b9b": "Open Model Settings",
"917b1c1f18d0276b": "Saving...",
"9196835e388d2550": "Test All",
"91cba5c107a51892": "/ Invalid",
"92059fe6cd713db4": "The model name actually sent to the server, for example gpt-4.1 or claude-sonnet.",
"93e08803675e378b": "Model ID",
"942ff2d88baca0c6": "Checking for updates...",
"986678eccf56dc28": "Service status is being updated. Please try again later.",
"991e374fce0f4492": "Local-Cursor — Based on Cursor-BYOK",
"9970736b36ff2b68": "The base URL of the model service, usually an OpenAI- or Anthropic-compatible endpoint.",
"9b17fa889b307f7f": "Valid turns: {0}",
"9c38b6e9bf94abec": "Switched to Direct Cursor Mode",
"9cd4ac17428b86e4": "Cache hit rate = cache read tokens / prompt tokens",
"9d2ca261281a158a": "Later",
"9dc0825fba5422e4": "Loading...",
"a026f37e613cf48b": "Output Tokens",
"a1a038dfa16c3ede": "You're already on the latest version (v{0}).",
"a3030bf8f16dc63c": "Save",
"a325d25c69e7256d": "Model settings not found; cannot duplicate",
"a4dd8bb7e8b6eb31": "Show API Key",
"a55a88237df85d98": "Currently in Direct Mode",
"a567bdaa11367f26": "Medium",
"a5f1bd344c92e195": "The API key required to call this model service.",
"a693d69af48bfe48": "Save and Test",
"a98585871c5313ff": "Display Name",
"aa9e366f68d3d097": "Low",
"ac217e4d1ca410f1": "New version available",
"ad79540418be700a": "Open the settings folder, or manage model settings separately",
"ae5a738238463a92": "Hide API Key",
"aed55419ce62f08e": "Switching...",
"b1c27820fec23edb": "High",
"b42049dcf8a05ef7": "Switched to Local Service Mode",
"b571037dc396a00c": "Total request tokens include both prompt and model output.",
"b765005f69fa971f": "e.g. gpt-4.1",
"b90a8ac9c488ce46": "Select language",
"ba40014ff496f64e": "Type",
"bb074b86a98f6911": "Context Window",
"bbacfde55a92869f": "A higher hit rate means more repeated context is being reused.",
"bc87a4121a0873b3": "Refresh Stats",
"bd4464ea88d3f24a": "Total turns: {0}",
"bef280f9eb392495": "Conversation Turns",
"c228558cf257fc49": "Delete failed",
"c3e9c3c60020b8b7": "Select Mode",
"c69f5bce63b9f14c": "Settings Folder",
"c6f743953145f40b": "e.g. 4096 (leave blank to use the default)",
"c8c14507b2d37395": "Reasoning Effort",
"c98e118e0a43f078": "Model",
"ca00a39fcea70dc6": "Starting...",
"ca1d1059408b3837": "Invalid turns: {0}",
"ce46f23cea3bf3c5": "When enabled, Cursor connects directly to the official service. Do not enable this.",
"d0325067fed88e5a": "Cache hit rate {0}",
"d08fd4224abcd69d": "Switch failed",
"d1bde4a4e057b2c7": "[MainLayout] Failed to load author info",
"d20ab96566d33f25": "{0} display name cannot be empty",
"d2243e1d44b2a94e": "Edit Model Settings",
"d3209b935ae86797": "Model settings not found; cannot delete",
"d373809ab86ba93b": "Copy",
"d3b1da3088ddd334": "Model test failed",
"d7da2aabd35772ec": "e.g. 500000 (leave blank to use the default)",
"da590a8fe3ce4de0": "Please select",
"daede9881787abe7": "Notes",
"dbee6e7139243362": "{0} base URL cannot be empty",
"dc82c5e8fb2ab777": "Version: v{0}",
"de8184da1ef88d03": "Configured",
"e14c41ef2b7253c9": "Total request tokens: {0}",
"e406825e0a72d2c2": "Local Settings",
"e552c2accdbf5178": "Add Model",
"e6faccfddce722e8": "Cache read tokens: {0}",
"eaffd48cd2ea9f1a": "e.g. https://api.anthropic.com",
"ec3b17a75db49e24": "{0} t/s | First token {1}",
"ec99e5c45d648fd6": "Update failed",
"f1e0fc261d42fe29": "Notes shown when hovering over the model list.",
"f363622480699c52": "Reasoning effort only applies to some models that support reasoning_effort. Not all models do. Higher values are usually more stable, but may also be slower.",
"f3a76d896853c1df": "Miss",
"f474a4108aba4c4c": "Stop Service",
"f56c6c82203b33f6": "Notice",
"f61e03f047b786d5": "{0} max output tokens must be a positive integer",
"fac2a67ad87807c4": "OK",
"fb7a4c81729ed0ca": "Stopping...",
"fec45092945f8790": "User Guide"
}

View File

@@ -0,0 +1,176 @@
{
"02216368edc68816": "更新内容はありません",
"02bc2e95bf49e587": "まだ",
"03b11112dc970014": "ベース URL",
"045261cc748d4300": "Anthropic の思考フェーズで使用できる予算 Token 数。空欄の場合はデフォルト値を使用します。",
"047ec6b71d0cec08": "ホワイトリストのメイン経路のリクエストをローカルサービス経由にするか、元の Cursor 上流アドレスに戻すかを制御します",
"04f632dd4f034d5e": "{0} のコンテキストウィンドウは正の整数である必要があります",
"051836569928a9f9": "編集",
"054d763265603305": "例: 278000(空欄でデフォルト値)",
"05c8a9238c702efa": "Cursor 直結モード",
"0647728439b5da2e": "ルーティングモードとモデルチャネルを設定できます。実行ログは次にあります",
"092b520558eff5f2": "未テスト",
"0b0e7478e41fe677": "{0} のツールチップは必須です",
"0c3b4cf7aa259edb": "操作に失敗しました",
"0dde813d719dbd01": "ホームページを開けませんでした",
"0e40a09ab4e664ab": "モデルチャネルが重複しています。url、modelID、apiKey、displayName の組み合わせを確認してください",
"1117a2f86030d03b": "キャッシュの読み書きは Prompt 側の統計に含まれます。",
"11afd2a534395b18": "有効",
"124be3f86f197802": "Token 使用量",
"13b61c5f697b6700": "キャッシュヒット率",
"15d124b200ddabed": "モデルが1回のリクエストで受け取れる最大コンテキスト Token 数。空欄の場合はデフォルト値を使用します。",
"18b7312022cd1840": "サービスを開始",
"1af38868896cf53d": "ルーティングモードは local または upstream のみサポートします",
"1b7d24b212e52c54": "{0} の推論強度は low、medium、high、xhigh のみサポートします",
"1bc77f5ab979f4c1": "モデル設定を追加",
"1e238093b79b3165": "空欄で 278000",
"24343a2096988d42": "開けませんでした",
"253d4a3428c648fb": "キャッシュ書き込み Token: {0}",
"258e4620e2108793": "空欄で 4096",
"26a3855aed1d8d17": "サービスは起動していません",
"281eb6d08c9960d0": "{0} の思考予算 Token は正の整数である必要があります",
"28aeffc70ceb4267": "この画面の表示言語を切り替えます。設定はすぐに反映され、この端末に保存されます",
"2caeaec539e78898": "思考予算 Token",
"2cd0f3be8738a86c": "キャンセル",
"2d706f7981b45a7b": "ローカル設定を保存しました",
"2f9daa828907b93f": "削除",
"30bb57a50caedc38": "例: 日常的なコード補完や Q&A 用",
"32b3c9a50003f77a": "出力 Token は推定値です",
"33d2d273e2bd5f88": "ユーザーガイドを開けませんでした",
"3468b57e3edbc599": "履歴からスキャンした各ターンの summary を集計しています。",
"35076178fe79a210": "設定が変更されました。再テストしてください",
"36c149a9b3e8dca0": "モデルは設定されていません。",
"37d23612f78a2e63": "今すぐ再起動して更新",
"392d0dceb45998d3": "最高",
"393df9bb13ea4900": "ヒット",
"3af7e5489e61ea51": "更新中",
"3bf8512aa520ed21": "ローカルサービスモード",
"3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします",
"3d13868593ae4eeb": "表示言語",
"3ea83f9f55062582": "公開日時: {0}",
"3edda85621fd03b2": "件のモデルアダプター",
"42aa8e01e98c0d8c": "総所要時間",
"468adaa418ee1475": "例: https://api.openai.com/v1",
"4923eeb7bd75cccd": "{0} のモデル ID は必須です",
"497c85690c4cc0fc": "データなし",
"4b8d11bf235e9213": "現在のヒット率: {0}",
"4d8c1c5b42830791": "不明",
"51194c3ad014fb29": "再テストが必要",
"5205125c0e91d346": "Anthropic モデルが1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
"56627c94a9decee6": "最大出力 Token",
"5beb1206c532729f": "1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
"5d1687a4a41883fd": "停止中...",
"6106f0a12583a334": "再読み込みに失敗しました",
"62873083fcaed27d": "セッション統計",
"6309a3bb5ba4c714": "保存に失敗しました",
"636e3deffc1e960a": "モデル {0}",
"63d90d977348ab1f": "複製",
"64d2730f2ae37997": "生のレスポンス",
"65cc5fd2e6ce6e75": "バックエンドは起動済み、プロキシは未起動です",
"66af574b8948fe83": "{0} の API キーは必須です",
"675109292da4eb36": "まだテストしていません",
"699fe7ade5407687": "直結モード",
"6a7b96f399e58138": "例: sk-xxxxxx",
"6aa8f49cc992dfd7": "テスト",
"6ae23d6d7cb18592": "サービスエラー",
"6e584e3d5ce64aa0": "設定を保存",
"737225e2904673fc": "推定出力 Token: {0}",
"7520bd50a5ee5471": "テスト停止 {0}/{1}",
"753d8bb0da9913ce": "複製に失敗しました",
"77c9e582e85583af": "テスト失敗",
"7a26bf794e9fb6bf": "UI 上の表示専用で、異なるモデルを見分けやすくします。",
"7b6187c41e88b70c": "テスト中...",
"7bf8e2c07e084d09": "モデル編集",
"7e9e334aeb0bdc07": "サービス操作に失敗しました",
"7f68ebad19ba6bcd": "アップデートを確認",
"81123c56d5d880d0": "API キー",
"86df7ec743047234": "サービス稼働中",
"87ed126f7bd1121e": "ルーティングモード",
"899add6275682210": "空欄で 500000",
"8c0d84831a3c3d5b": "現在はローカルサービスモードです",
"8c1935935600e336": "モデルテスト",
"8cbcf741e727dbf7": "モデル設定",
"8d1de152be6360ce": "有効率: {0}",
"8e2dc7b0d2e8f6f8": "例: OpenAI - GPT-4.1",
"8f6f8d979c981ced": "コピーしました",
"8faa670b512b6b9b": "モデル設定を開く",
"917b1c1f18d0276b": "保存中...",
"9196835e388d2550": "すべてテスト",
"91cba5c107a51892": "/ 異常",
"92059fe6cd713db4": "実際にサーバーへ送信されるモデル名です。例: gpt-4.1 または claude-sonnet。",
"93e08803675e378b": "モデル ID",
"942ff2d88baca0c6": "アップデートを確認中...",
"986678eccf56dc28": "サービス状態を更新中です。しばらくしてからもう一度お試しください",
"991e374fce0f4492": "Local-Cursor — Based on Cursor-BYOK",
"9970736b36ff2b68": "モデルサービスの API ルート URL。通常は OpenAI または Anthropic 互換のエンドポイントです。",
"9b17fa889b307f7f": "有効ターン: {0}",
"9c38b6e9bf94abec": "Cursor 直結モードに切り替えました",
"9cd4ac17428b86e4": "キャッシュヒット率 = キャッシュ読込 Token / Prompt Token",
"9d2ca261281a158a": "後で",
"9dc0825fba5422e4": "読み込み中...",
"a026f37e613cf48b": "出力 Token",
"a1a038dfa16c3ede": "すでに最新バージョンです(v{0})。",
"a3030bf8f16dc63c": "保存",
"a325d25c69e7256d": "モデル設定が存在しないため複製できません",
"a4dd8bb7e8b6eb31": "API キーを表示",
"a55a88237df85d98": "現在は直結モードです",
"a567bdaa11367f26": "中",
"a5f1bd344c92e195": "このモデルサービスを呼び出すために必要な API キーです。",
"a693d69af48bfe48": "保存してテスト",
"a98585871c5313ff": "表示名",
"aa9e366f68d3d097": "低",
"ac217e4d1ca410f1": "新しいバージョンがあります",
"ad79540418be700a": "設定フォルダーを開くか、モデル設定を個別に管理できます",
"ae5a738238463a92": "API キーを隠す",
"aed55419ce62f08e": "切替中...",
"b1c27820fec23edb": "高",
"b42049dcf8a05ef7": "ローカルサービスモードに切り替えました",
"b571037dc396a00c": "総リクエスト Token には Prompt とモデル出力の両方が含まれます。",
"b765005f69fa971f": "例: gpt-4.1",
"b90a8ac9c488ce46": "言語を選択",
"ba40014ff496f64e": "タイプ",
"bb074b86a98f6911": "コンテキストウィンドウ",
"bbacfde55a92869f": "ヒット率が高いほど、重複するコンテキストがより多く再利用されていることを示します。",
"bc87a4121a0873b3": "統計を更新",
"bd4464ea88d3f24a": "総ターン: {0}",
"bef280f9eb392495": "会話ターン",
"c228558cf257fc49": "削除に失敗しました",
"c3e9c3c60020b8b7": "モードを選択",
"c69f5bce63b9f14c": "設定フォルダー",
"c6f743953145f40b": "例: 4096(空欄でデフォルト値)",
"c8c14507b2d37395": "推論強度",
"c98e118e0a43f078": "モデル",
"ca00a39fcea70dc6": "起動中...",
"ca1d1059408b3837": "異常ターン: {0}",
"ce46f23cea3bf3c5": "有効にすると、Cursor は公式サービスへ直接接続します。オンにしないでください",
"d0325067fed88e5a": "キャッシュヒット率 {0}",
"d08fd4224abcd69d": "切替に失敗しました",
"d1bde4a4e057b2c7": "[MainLayout] 作者情報の読み込みに失敗しました",
"d20ab96566d33f25": "{0} の表示名は必須です",
"d2243e1d44b2a94e": "モデル設定を編集",
"d3209b935ae86797": "モデル設定が存在しないため削除できません",
"d373809ab86ba93b": "コピー",
"d3b1da3088ddd334": "モデルテストに失敗しました",
"d7da2aabd35772ec": "例: 500000(空欄でデフォルト値)",
"da590a8fe3ce4de0": "選択してください",
"daede9881787abe7": "メモ",
"dbee6e7139243362": "{0} のベース URL は必須です",
"dc82c5e8fb2ab777": "バージョン: v{0}",
"de8184da1ef88d03": "設定済み",
"e14c41ef2b7253c9": "総リクエスト Token: {0}",
"e406825e0a72d2c2": "ローカル設定",
"e552c2accdbf5178": "モデルを追加",
"e6faccfddce722e8": "キャッシュ読込 Token: {0}",
"eaffd48cd2ea9f1a": "例: https://api.anthropic.com",
"ec3b17a75db49e24": "{0} t/s | 初回 Token {1}",
"ec99e5c45d648fd6": "アップデートに失敗しました",
"f1e0fc261d42fe29": "モデル一覧にホバーしたときに表示されるメモです。",
"f363622480699c52": "推論強度は reasoning_effort をサポートする一部のモデルでのみ有効です。すべてのモデルが対応しているわけではありません。値が高いほど安定しやすい反面、遅くなることがあります。",
"f3a76d896853c1df": "ミス",
"f474a4108aba4c4c": "サービスを停止",
"f56c6c82203b33f6": "お知らせ",
"f61e03f047b786d5": "{0} の最大出力 Token は正の整数である必要があります",
"fac2a67ad87807c4": "OK",
"fb7a4c81729ed0ca": "停止中...",
"fec45092945f8790": "ユーザーガイド"
}

View File

@@ -0,0 +1,176 @@
{
"02216368edc68816": "无更新说明",
"02bc2e95bf49e587": "当前还没有配置任何",
"03b11112dc970014": "接口地址",
"045261cc748d4300": "Anthropic 思考阶段允许消耗的预算 Token 数。留空时使用默认值。",
"047ec6b71d0cec08": "控制白名单主链路请求走本地服务,还是回到原始 Cursor 上游地址",
"04f632dd4f034d5e": "{0} 的上下文窗口必须为正整数",
"051836569928a9f9": "编辑",
"054d763265603305": "例如:278000(留空用默认值)",
"05c8a9238c702efa": "直连 Cursor 模式",
"0647728439b5da2e": "可配置运行模式和模型渠道;运行日志位于",
"092b520558eff5f2": "未测试",
"0b0e7478e41fe677": "{0} 的悬停提示不能为空",
"0c3b4cf7aa259edb": "操作失败",
"0dde813d719dbd01": "打开主页失败",
"0e40a09ab4e664ab": "模型渠道重复,请检查 url、modelID、apiKey、displayName 组合",
"1117a2f86030d03b": "缓存读写已计入 Prompt 侧统计。",
"11afd2a534395b18": "有效",
"124be3f86f197802": "Token 消耗",
"13b61c5f697b6700": "缓存命中率",
"15d124b200ddabed": "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
"18b7312022cd1840": "启动服务",
"1af38868896cf53d": "运行模式仅支持 local 或 upstream",
"1b7d24b212e52c54": "{0} 的推理强度仅支持 low、medium、high、xhigh",
"1bc77f5ab979f4c1": "新增模型配置",
"1e238093b79b3165": "留空时默认 278000",
"24343a2096988d42": "打开失败",
"253d4a3428c648fb": "缓存写入:{0}",
"258e4620e2108793": "留空时默认 4096",
"26a3855aed1d8d17": "服务未启动",
"281eb6d08c9960d0": "{0} 的思考预算 Token 必须为正整数",
"28aeffc70ceb4267": "切换当前界面显示语言,设置会立即生效并保存在本机",
"2caeaec539e78898": "思考预算 Token",
"2cd0f3be8738a86c": "取消",
"2d706f7981b45a7b": "本地配置已保存",
"2f9daa828907b93f": "删除",
"30bb57a50caedc38": "例如:用于日常代码补全与问答",
"32b3c9a50003f77a": "输出 Token 为估算值",
"33d2d273e2bd5f88": "打开使用教程失败",
"3468b57e3edbc599": "按历史记录里扫描到的回合 summary 汇总。",
"35076178fe79a210": "配置已变更,请重新测试",
"36c149a9b3e8dca0": "模型。",
"37d23612f78a2e63": "立即重启更新",
"392d0dceb45998d3": "极高",
"393df9bb13ea4900": "命中",
"3af7e5489e61ea51": "刷新中",
"3bf8512aa520ed21": "本地服务模式",
"3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic",
"3d13868593ae4eeb": "界面语言",
"3ea83f9f55062582": "发布时间:{0}",
"3edda85621fd03b2": "个模型适配器",
"42aa8e01e98c0d8c": "总耗时",
"468adaa418ee1475": "例如:https://api.openai.com/v1",
"4923eeb7bd75cccd": "{0} 的模型标识不能为空",
"497c85690c4cc0fc": "暂无数据",
"4b8d11bf235e9213": "当前命中率:{0}",
"4d8c1c5b42830791": "未知",
"51194c3ad014fb29": "需重测",
"5205125c0e91d346": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。",
"56627c94a9decee6": "最大输出 Token",
"5beb1206c532729f": "单次回复允许生成的最大 Token 数。留空时使用默认值。",
"5d1687a4a41883fd": "停止中...",
"6106f0a12583a334": "刷新失败",
"62873083fcaed27d": "会话统计",
"6309a3bb5ba4c714": "保存失败",
"636e3deffc1e960a": "模型 {0}",
"63d90d977348ab1f": "复制",
"64d2730f2ae37997": "原始返回",
"65cc5fd2e6ce6e75": "后端已启动,代理未启动",
"66af574b8948fe83": "{0} 的访问密钥不能为空",
"675109292da4eb36": "尚未测试",
"699fe7ade5407687": "直连模式",
"6a7b96f399e58138": "例如:sk-xxxxxx",
"6aa8f49cc992dfd7": "测试",
"6ae23d6d7cb18592": "服务错误",
"6e584e3d5ce64aa0": "保存配置",
"737225e2904673fc": "输出推算:{0}",
"7520bd50a5ee5471": "停止测试 {0}/{1}",
"753d8bb0da9913ce": "复制失败",
"77c9e582e85583af": "测试失败",
"7a26bf794e9fb6bf": "仅用于界面展示,便于你区分不同模型。",
"7b6187c41e88b70c": "测试中...",
"7bf8e2c07e084d09": "模型编辑",
"7e9e334aeb0bdc07": "服务操作失败",
"7f68ebad19ba6bcd": "检查更新",
"81123c56d5d880d0": "访问密钥",
"86df7ec743047234": "服务运行中",
"87ed126f7bd1121e": "运行模式",
"899add6275682210": "留空时默认 500000",
"8c0d84831a3c3d5b": "当前为本地服务模式",
"8c1935935600e336": "模型测试",
"8cbcf741e727dbf7": "模型配置",
"8d1de152be6360ce": "有效占比:{0}",
"8e2dc7b0d2e8f6f8": "例如:OpenAI - GPT-4.1",
"8f6f8d979c981ced": "已复制",
"8faa670b512b6b9b": "打开模型配置",
"917b1c1f18d0276b": "保存中...",
"9196835e388d2550": "测试全部",
"91cba5c107a51892": "/ 异常",
"92059fe6cd713db4": "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
"93e08803675e378b": "模型标识",
"942ff2d88baca0c6": "检查更新中...",
"986678eccf56dc28": "服务状态更新中,请稍后再试",
"991e374fce0f4492": "Local-Cursor — Based on Cursor-BYOK",
"9970736b36ff2b68": "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
"9b17fa889b307f7f": "有效轮次:{0}",
"9c38b6e9bf94abec": "已切换到直连 Cursor 模式",
"9cd4ac17428b86e4": "缓存命中率 = 缓存读取 Token / Prompt Token",
"9d2ca261281a158a": "稍后",
"9dc0825fba5422e4": "加载中...",
"a026f37e613cf48b": "输出 Token",
"a1a038dfa16c3ede": "当前已是最新版本(v{0})。",
"a3030bf8f16dc63c": "保存",
"a325d25c69e7256d": "模型配置不存在,无法复制",
"a4dd8bb7e8b6eb31": "显示访问密钥",
"a55a88237df85d98": "当前为直连模式",
"a567bdaa11367f26": "中",
"a5f1bd344c92e195": "调用该模型服务需要使用的访问密钥。",
"a693d69af48bfe48": "保存并测试",
"a98585871c5313ff": "显示名称",
"aa9e366f68d3d097": "低",
"ac217e4d1ca410f1": "发现新版本",
"ad79540418be700a": "打开设置目录,或单独管理模型配置",
"ae5a738238463a92": "隐藏访问密钥",
"aed55419ce62f08e": "切换中...",
"b1c27820fec23edb": "高",
"b42049dcf8a05ef7": "已切换到本地服务模式",
"b571037dc396a00c": "总请求 Token 包含 Prompt 和模型输出。",
"b765005f69fa971f": "例如:gpt-4.1",
"b90a8ac9c488ce46": "选择语言",
"ba40014ff496f64e": "类型",
"bb074b86a98f6911": "上下文窗口",
"bbacfde55a92869f": "命中率越高,说明重复上下文复用得越多。",
"bc87a4121a0873b3": "刷新统计",
"bd4464ea88d3f24a": "总轮次:{0}",
"bef280f9eb392495": "对话轮次",
"c228558cf257fc49": "删除失败",
"c3e9c3c60020b8b7": "选择模式",
"c69f5bce63b9f14c": "设置文件夹",
"c6f743953145f40b": "例如:4096(留空用默认值)",
"c8c14507b2d37395": "推理强度",
"c98e118e0a43f078": "模型",
"ca00a39fcea70dc6": "启动中...",
"ca1d1059408b3837": "异常轮次:{0}",
"ce46f23cea3bf3c5": "开启后,Cursor将直接接通官方,请勿开启",
"d0325067fed88e5a": "缓存命中率 {0}",
"d08fd4224abcd69d": "切换失败",
"d1bde4a4e057b2c7": "[MainLayout] 加载作者信息失败",
"d20ab96566d33f25": "{0} 的显示名称不能为空",
"d2243e1d44b2a94e": "编辑模型配置",
"d3209b935ae86797": "模型配置不存在,无法删除",
"d373809ab86ba93b": "拷贝",
"d3b1da3088ddd334": "模型测试失败",
"d7da2aabd35772ec": "例如:500000(留空用默认值)",
"da590a8fe3ce4de0": "请选择",
"daede9881787abe7": "备注",
"dbee6e7139243362": "{0} 的接口地址不能为空",
"dc82c5e8fb2ab777": "版本:v{0}",
"de8184da1ef88d03": "已配置",
"e14c41ef2b7253c9": "总请求:{0}",
"e406825e0a72d2c2": "本地配置",
"e552c2accdbf5178": "新增模型",
"e6faccfddce722e8": "缓存读取:{0}",
"eaffd48cd2ea9f1a": "例如:https://api.anthropic.com",
"ec3b17a75db49e24": "{0} t/s | 首字 {1}",
"ec99e5c45d648fd6": "更新失败",
"f1e0fc261d42fe29": "模型列表 hover 时显示的备注说明。",
"f363622480699c52": "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。",
"f3a76d896853c1df": "未命中",
"f474a4108aba4c4c": "关闭服务",
"f56c6c82203b33f6": "提示",
"f61e03f047b786d5": "{0} 的最大输出 Token 必须为正整数",
"fac2a67ad87807c4": "确定",
"fb7a4c81729ed0ca": "关闭中...",
"fec45092945f8790": "使用教程"
}

View File

@@ -0,0 +1,185 @@
import { computed, ref } from "vue";
import {
DEFAULT_LOCALE,
LOCALE_OPTIONS,
LOCALE_STORAGE_KEY,
LOCALE_STORAGE_SOURCE_KEY,
SOURCE_LOCALE,
SUPPORTED_LOCALES,
} from "@/i18n/config";
import zhCNMessages from "@/i18n/locales/zh-CN.json";
import enUSMessages from "@/i18n/locales/en-US.json";
import jaJPMessages from "@/i18n/locales/ja-JP.json";
const localeMessages = {
"zh-CN": zhCNMessages,
"en-US": enUSMessages,
"ja-JP": jaJPMessages,
};
const languageLocaleMap = {
zh: "zh-CN",
en: "en-US",
ja: "ja-JP",
};
function isSupportedLocale(locale) {
return SUPPORTED_LOCALES.includes(locale);
}
function matchSupportedLocale(locale) {
const normalized = String(locale || "").trim().replace(/_/g, "-");
if (!normalized) {
return "";
}
const lowered = normalized.toLowerCase();
const exactMatch = SUPPORTED_LOCALES.find((supportedLocale) => supportedLocale.toLowerCase() === lowered);
if (exactMatch) {
return exactMatch;
}
const primaryLanguage = lowered.split("-")[0];
return languageLocaleMap[primaryLanguage] || "";
}
function getSystemLocaleCandidates() {
const candidates = [];
if (typeof navigator !== "undefined") {
if (Array.isArray(navigator.languages)) {
candidates.push(...navigator.languages);
}
candidates.push(navigator.language);
}
if (typeof Intl !== "undefined" && typeof Intl.DateTimeFormat === "function") {
candidates.push(Intl.DateTimeFormat().resolvedOptions()?.locale);
}
return candidates;
}
function resolveSystemLocale() {
for (const candidate of getSystemLocaleCandidates()) {
const matchedLocale = matchSupportedLocale(candidate);
if (matchedLocale) {
return matchedLocale;
}
}
return DEFAULT_LOCALE;
}
function resolveInitialLocale() {
if (typeof window === "undefined" || typeof window.localStorage === "undefined") {
return resolveSystemLocale();
}
const storedLocale = window.localStorage.getItem(LOCALE_STORAGE_KEY);
const storedSource = window.localStorage.getItem(LOCALE_STORAGE_SOURCE_KEY);
if (storedSource === "manual") {
return matchSupportedLocale(storedLocale) || resolveSystemLocale();
}
window.localStorage.removeItem(LOCALE_STORAGE_KEY);
window.localStorage.removeItem(LOCALE_STORAGE_SOURCE_KEY);
return resolveSystemLocale();
}
function applyLocaleToDocument(locale) {
if (typeof document !== "undefined") {
document.documentElement.lang = locale;
}
}
function persistManualLocale(locale) {
if (typeof window === "undefined" || typeof window.localStorage === "undefined") {
return;
}
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
window.localStorage.setItem(LOCALE_STORAGE_SOURCE_KEY, "manual");
}
function resolveMessage(id, fallback) {
const activeMessages = localeMessages[currentLocale.value] || {};
const sourceMessages = localeMessages[SOURCE_LOCALE] || {};
return activeMessages[id] || sourceMessages[id] || fallback || "";
}
function interpolateMessage(template, args = []) {
return template.replace(/\{(\d+)\}/g, (_match, index) => {
const value = args[Number(index)];
return value == null ? "" : String(value);
});
}
class LocalizedText extends String {
constructor(id, fallback, args = null) {
super(fallback);
this.id = id;
this.fallback = fallback;
this.args = args;
}
toString() {
const text = resolveMessage(this.id, this.fallback);
return Array.isArray(this.args) ? interpolateMessage(text, this.args) : text;
}
valueOf() {
return this.toString();
}
toJSON() {
return this.toString();
}
[Symbol.toPrimitive]() {
return this.toString();
}
}
const currentLocale = ref(resolveInitialLocale());
applyLocaleToDocument(currentLocale.value);
const localizedCache = new Map();
export function getLocale() {
return currentLocale.value;
}
export function setLocale(locale) {
const nextLocale = matchSupportedLocale(locale) || DEFAULT_LOCALE;
currentLocale.value = nextLocale;
persistManualLocale(nextLocale);
applyLocaleToDocument(nextLocale);
return nextLocale;
}
export function useLocale() {
return {
locale: currentLocale,
localeOptions: LOCALE_OPTIONS,
currentLocale: computed(() => currentLocale.value),
setLocale,
};
}
export function localized(id, fallback) {
const cacheKey = `${id}:${fallback}`;
if (!localizedCache.has(cacheKey)) {
localizedCache.set(cacheKey, new LocalizedText(id, fallback));
}
return localizedCache.get(cacheKey);
}
export function localizedTemplate(id, fallback, args = []) {
return new LocalizedText(id, fallback, args);
}
export function installI18nRuntime(app) {
app.config.globalProperties.$ls = localized;
app.config.globalProperties.$lt = localizedTemplate;
}

View File

@@ -0,0 +1,156 @@
<script setup>
import { Window } from "@wailsio/runtime";
import LocaleSelect from "@/components/LocaleSelect.vue";
import { appState, syncServiceState } from "@/state/appState";
import { isWindows } from "@/utils/isWindows";
import { computed, onMounted, onUnmounted } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const title = computed(() => route.meta.title ?? "Local-Cursor");
const directlyClose = computed(() => route.meta.directlyClose === true);
const showFooter = computed(() => route.path === "/");
let proxyStateTimer = null;
const proxyStatePollIntervalMs = 10000;
const netProxyEndpoint = computed(
() => appState.netProxyHttps || appState.netProxyHttp || "",
);
const proxyBadgeText = computed(() => {
if (appState.netProxyUsingSystem) {
return "已识别系统代理";
}
return "";
});
const proxyBadgeTitle = computed(() => {
if (appState.netProxyUsingSystem) {
return netProxyEndpoint.value
? `当前出站请求使用系统代理:${netProxyEndpoint.value}`
: "当前出站请求使用系统代理";
}
if (appState.netProxyUsingEnv) {
return netProxyEndpoint.value
? `当前出站请求使用环境变量代理:${netProxyEndpoint.value}`
: "当前出站请求使用环境变量代理";
}
if (appState.netProxyPacIgnored) {
return "检测到系统 PAC/自动代理,当前版本按直连处理";
}
return "当前出站请求未使用系统代理";
});
async function minimizeWindow() {
await Window.Minimise();
}
async function closeWindow() {
if (directlyClose.value) {
await Window.Close();
return;
}
await new Promise((resolve) => setTimeout(resolve, 200));
await Window.Hide();
}
onMounted(() => {
proxyStateTimer = window.setInterval(() => {
if (showFooter.value) {
void syncServiceState().catch(() => {});
}
}, proxyStatePollIntervalMs);
});
onUnmounted(() => {
if (proxyStateTimer) {
window.clearInterval(proxyStateTimer);
proxyStateTimer = null;
}
});
</script>
<template>
<div class="flex h-screen w-screen overflow-hidden">
<!-- drag region for frameless window -->
<div
class="fixed top-0 left-0 h-[40px] z-9999"
style="--wails-draggable: drag; width: 52px;"
></div>
<!-- ═══ SIDEBAR ═══ -->
<nav class="flex flex-col items-center w-[52px] shrink-0 bg-[#0a0a0a] border-r border-[#2a2a2a] pt-[12px] gap-[4px] z-50">
<div class="flex items-center justify-center w-[28px] h-[28px] border-[1.5px] border-white rounded-[6px] font-extrabold text-[13px] tracking-tight mb-[16px]"
style="--wails-draggable: no-drag"
>
LC
</div>
<div class="flex-1"></div>
<!-- nav buttons handled by router -->
<!-- bottom: language + version -->
<div class="flex flex-col items-center gap-[6px] pb-[10px]">
<LocaleSelect
compact
:border="false"
aria-label="界面语言"
wrapper-class="w-full"
button-class="!h-[24px] !w-full !bg-transparent !px-1 !text-[12px] !text-[#666] hover:!text-[#fff]"
menu-class="text-[12px] min-w-[120px]"
/>
<span v-if="appState.appVersion" class="text-[10px] tabular-nums text-[#444] leading-none">
v{{ appState.appVersion }}
</span>
</div>
</nav>
<!-- ═══ MAIN ═══ -->
<div class="flex flex-1 min-w-0 flex-col">
<!-- topbar -->
<header
class="flex h-[40px] shrink-0 items-center justify-between px-[16px] bg-black border-b border-[#2a2a2a] relative"
style="--wails-draggable: drag"
>
<span class="font-semibold text-[13px] tracking-wide">{{ title }}</span>
<div
v-if="isWindows"
class="absolute right-[10px] top-[8px] flex items-center gap-[1px] z-99999"
>
<button
class="flex items-center justify-center w-[30px] h-[23px] rounded-[4px] text-[#666] hover:bg-[#1c1c1c] hover:text-white cursor-pointer"
@click="minimizeWindow"
>
<span class="icon-[ic--round-minus] text-[20px]"></span>
</button>
<button
class="flex items-center justify-center w-[30px] h-[23px] rounded-[4px] text-[#666] hover:bg-[#1c1c1c] hover:text-white cursor-pointer"
@click="closeWindow"
>
<span class="icon-[ic--round-close] text-[20px]"></span>
</button>
</div>
</header>
<!-- content -->
<main class="flex-1 min-h-0 overflow-hidden flex flex-col">
<router-view />
</main>
<!-- statusbar (only on home) -->
<footer
v-if="showFooter"
class="flex h-[28px] shrink-0 items-center justify-between px-[12px] border-t border-[#2a2a2a] bg-[#0a0a0a] text-[10px] text-[#666]"
>
<div class="flex items-center gap-[12px]">
<span>{{ appState.serviceRunning ? "● 服务运行中" : "● 服务已停止" }}</span>
</div>
<div class="flex items-center gap-[12px]">
<span
v-if="proxyBadgeText"
class="flex items-center gap-[2px]"
:title="proxyBadgeTitle"
>
<span class="icon-[mdi--wifi] text-[15px]"></span>
<span class="truncate">{{ proxyBadgeText }}</span>
</span>
</div>
</footer>
</div>
</div>
</template>

40
frontend/src/main.js Normal file
View File

@@ -0,0 +1,40 @@
import { createApp } from "vue";
import ResizeObserver from "resize-observer-polyfill";
import App from "@/App.vue";
import { installI18nRuntime } from "@/i18n/runtime";
import router from "@/router";
import { bootstrapAppState } from "@/state/appState";
import "@/style/global.css";
import "@/style/tailwind.css";
if (typeof window !== "undefined" && typeof window.ResizeObserver === "undefined") {
window.ResizeObserver = ResizeObserver;
}
function updateFlexGapSupportClass() {
if (typeof document === "undefined" || !document.body) {
return;
}
const flex = document.createElement("div");
flex.style.position = "absolute";
flex.style.visibility = "hidden";
flex.style.display = "flex";
flex.style.flexDirection = "column";
flex.style.rowGap = "1px";
flex.appendChild(document.createElement("div"));
flex.appendChild(document.createElement("div"));
document.body.appendChild(flex);
document.documentElement.classList.toggle("no-flex-gap", flex.scrollHeight !== 1);
flex.parentNode?.removeChild(flex);
}
updateFlexGapSupportClass();
const app = createApp(App);
installI18nRuntime(app);
app.use(router);
app.mount("#root");
bootstrapAppState().catch(() => {
// 启动阶段失败时保持界面可用,错误在业务交互中再提示。
});

View File

@@ -0,0 +1,33 @@
import { createRouter, createWebHashHistory } from "vue-router";
import Home from "@/views/Home.vue";
import ModelConfig from "@/views/ModelConfig.vue";
import ModelEditor from "@/views/ModelEditor.vue";
import Skills from "@/views/Skills.vue";
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: "/",
component: Home,
meta: { showIcon: true, title: "Local-Cursor", directlyClose: false },
},
{
path: "/model-config",
component: ModelConfig,
meta: { showIcon: false, title: "模型配置", directlyClose: true },
},
{
path: "/model-editor",
component: ModelEditor,
meta: { showIcon: false, title: "模型编辑", directlyClose: true },
},
{
path: "/skills",
component: Skills,
meta: { showIcon: false, title: "Skill 管理", directlyClose: true },
},
],
});
export default router;

View File

@@ -0,0 +1,185 @@
import {
GetState,
LoadUserConfig,
SaveUserConfig,
StartProxy,
StopProxy,
} from "@bindings/cursor/internal/bridge/proxyservice.js";
import { GetHomeMetricsSummary } from "@bindings/cursor/internal/bridge/metricsservice.js";
import {
GetAppVersion,
GetCursorStatus,
GetModelEditorContext,
LocalizeCursor,
OpenCLITerminal,
OpenConfigWindow,
OpenCursor,
OpenHistoryWindow,
OpenModelConfigWindow,
OpenModelEditorWindow,
OpenSkillWindow,
SelectDirectory,
} from "@bindings/cursor/internal/bridge/windowservice.js";
import {
DeleteProjectRecord,
GetProjectSkillStates,
ListRecentProjects,
ListSkills,
SetProjectSkill,
} from "@bindings/cursor/internal/bridge/skillservice.js";
import { Call } from "@wailsio/runtime";
const API_LOG_PREFIX = "[clientApi]";
const PROXY_SERVICE_NAME = "cursor/internal/bridge.ProxyService";
function logSuccess(name, payload, result) {
console.log(`${API_LOG_PREFIX} ${name} response`, {
payload,
result,
});
}
function logError(name, payload, error) {
console.error(`${API_LOG_PREFIX} ${name} error`, {
payload,
error,
});
}
function withApiLogging(name, payload, runner) {
return Promise.resolve()
.then(() => runner())
.then((result) => {
logSuccess(name, payload, result);
return result;
})
.catch((error) => {
logError(name, payload, error);
throw error;
});
}
export function loadUserConfig() {
return withApiLogging("LoadUserConfig", undefined, () => LoadUserConfig());
}
export function saveUserConfig(payload) {
return withApiLogging("SaveUserConfig", payload, () => SaveUserConfig(payload));
}
export function getProxyState() {
return withApiLogging("GetState", undefined, () => GetState());
}
export function getHomeMetricsSummary() {
return withApiLogging("GetHomeMetricsSummary", undefined, () => GetHomeMetricsSummary());
}
export function startProxyService() {
return withApiLogging("StartProxy", undefined, () => StartProxy());
}
export function stopProxyService() {
return withApiLogging("StopProxy", undefined, () => StopProxy());
}
export function openLogsDirectory() {
return withApiLogging("OpenHistoryWindow", undefined, () => OpenHistoryWindow());
}
export function openConfigWindow() {
return withApiLogging("OpenConfigWindow", undefined, () => OpenConfigWindow());
}
export function getAppVersion() {
return withApiLogging("GetAppVersion", undefined, () => GetAppVersion());
}
export function openModelConfig() {
return withApiLogging("OpenModelConfigWindow", undefined, () => OpenModelConfigWindow());
}
export function openModelEditor(index, adapterJSON) {
return withApiLogging("OpenModelEditorWindow", { index, adapterJSON }, () =>
OpenModelEditorWindow(index, adapterJSON),
);
}
export function openCLITerminal() {
return withApiLogging("OpenCLITerminal", undefined, () => OpenCLITerminal());
}
export function localizeCursor(installDir = "") {
return withApiLogging("LocalizeCursor", { installDir }, () => LocalizeCursor(installDir));
}
export function selectDirectory() {
return withApiLogging("SelectDirectory", undefined, () => SelectDirectory());
}
export function getCursorStatus() {
return withApiLogging("GetCursorStatus", undefined, () => GetCursorStatus());
}
export function openCursor(installDir = "") {
return withApiLogging("OpenCursor", { installDir }, () => OpenCursor(installDir));
}
export function openSkillWindow() {
return withApiLogging("OpenSkillWindow", undefined, () => OpenSkillWindow());
}
export function listRecentProjects() {
return withApiLogging("ListRecentProjects", undefined, () => ListRecentProjects());
}
export function deleteProjectRecord(projectPath) {
return withApiLogging("DeleteProjectRecord", { projectPath }, () =>
DeleteProjectRecord(projectPath),
);
}
export function listSkills() {
return withApiLogging("ListSkills", undefined, () => ListSkills());
}
export function getProjectSkillStates(projectPath) {
return withApiLogging("GetProjectSkillStates", { projectPath }, () =>
GetProjectSkillStates(projectPath),
);
}
export function setProjectSkill(projectPath, skillName, enabled) {
return withApiLogging("SetProjectSkill", { projectPath, skillName, enabled }, () =>
SetProjectSkill(projectPath, skillName, enabled),
);
}
export function getModelEditorContext() {
return withApiLogging("GetModelEditorContext", undefined, () => GetModelEditorContext());
}
export function testModelAdapter(adapter) {
return Call.ByName(`${PROXY_SERVICE_NAME}.TestModelAdapter`, adapter).then(
(result) => {
logSuccess("TestModelAdapter", adapter, result);
return result;
},
(error) => {
logError("TestModelAdapter", adapter, error);
throw error;
},
);
}
export function listRemoteModels(adapterType, baseURL, apiKey) {
return withApiLogging("ListRemoteModels", { adapterType, baseURL }, () =>
Call.ByName(`${PROXY_SERVICE_NAME}.ListRemoteModels`, adapterType, baseURL, apiKey),
);
}
export function getModelAdapterTestResults() {
return withApiLogging("GetModelAdapterTestResults", undefined, () =>
Call.ByName(`${PROXY_SERVICE_NAME}.GetModelAdapterTestResults`),
);
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,188 @@
@font-face {
font-family: "PingFang-Medium";
src: url("./fonts/PingFang-Medium.ttf") format("truetype");
font-weight: 500;
font-style: normal;
}
html,
body,
#root {
margin: 0;
height: 100vh;
width: 100vw;
overflow: hidden;
background: #000000;
font-family: "PingFang-Medium", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #ffffff;
font-size: 13px;
}
* {
box-sizing: border-box;
}
/* Global native scrollbar style: thin thumb, transparent track */
* {
scrollbar-width: thin;
scrollbar-color: rgba(80, 80, 80, 0.8) transparent;
}
:root{
color-scheme: dark;
}
/* WebKit/Blink (Chrome, Safari, Edge, Opera) */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track,
::-webkit-scrollbar-track-piece,
::-webkit-scrollbar-corner {
background: transparent;
}
::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(80, 80, 80, 0.8);
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(120, 120, 120, 0.9);
}
/* macOS overlay scrollbar style */
@supports (scrollbar-width: thin) {
:root {
scrollbar-width: thin;
}
}
/* Prevent layout shift when scrollbar appears */
.overflow-auto,
.overflow-scroll {
scrollbar-gutter: stable;
}
.no-flex-gap .flex.gap-1:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-1:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-1 > * + * {
margin-left: 0.25rem;
}
.no-flex-gap .flex.flex-col.gap-1 > * + *,
.no-flex-gap .center-col.gap-1 > * + * {
margin-top: 0.25rem;
}
.no-flex-gap .flex.gap-1\.5:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-1\.5:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-1\.5 > * + * {
margin-left: 0.375rem;
}
.no-flex-gap .flex.flex-col.gap-1\.5 > * + *,
.no-flex-gap .center-col.gap-1\.5 > * + * {
margin-top: 0.375rem;
}
.no-flex-gap .flex.gap-2:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-2:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-2 > * + * {
margin-left: 0.5rem;
}
.no-flex-gap .flex.flex-col.gap-2 > * + *,
.no-flex-gap .center-col.gap-2 > * + * {
margin-top: 0.5rem;
}
.no-flex-gap .flex.gap-2\.5:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-2\.5:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-2\.5 > * + * {
margin-left: 0.625rem;
}
.no-flex-gap .flex.flex-col.gap-2\.5 > * + *,
.no-flex-gap .center-col.gap-2\.5 > * + * {
margin-top: 0.625rem;
}
.no-flex-gap .flex.gap-3:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-3:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-3 > * + * {
margin-left: 0.75rem;
}
.no-flex-gap .flex.flex-col.gap-3 > * + *,
.no-flex-gap .center-col.gap-3 > * + * {
margin-top: 0.75rem;
}
.no-flex-gap .flex.gap-4:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-4:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-4 > * + * {
margin-left: 1rem;
}
.no-flex-gap .flex.flex-col.gap-4 > * + *,
.no-flex-gap .center-col.gap-4 > * + * {
margin-top: 1rem;
}
.no-flex-gap .flex.gap-\[1px\]:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-\[1px\]:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-\[1px\] > * + * {
margin-left: 1px;
}
.no-flex-gap .flex.flex-col.gap-\[1px\] > * + *,
.no-flex-gap .center-col.gap-\[1px\] > * + * {
margin-top: 1px;
}
.no-flex-gap .flex.gap-\[2px\]:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-\[2px\]:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-\[2px\] > * + * {
margin-left: 2px;
}
.no-flex-gap .flex.flex-col.gap-\[2px\] > * + *,
.no-flex-gap .center-col.gap-\[2px\] > * + * {
margin-top: 2px;
}
.no-flex-gap .flex.gap-\[6px\]:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-\[6px\]:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-\[6px\] > * + * {
margin-left: 6px;
}
.no-flex-gap .flex.flex-col.gap-\[6px\] > * + *,
.no-flex-gap .center-col.gap-\[6px\] > * + * {
margin-top: 6px;
}
.no-flex-gap .flex.gap-\[8px\]:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-\[8px\]:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-\[8px\] > * + * {
margin-left: 8px;
}
.no-flex-gap .flex.flex-col.gap-\[8px\] > * + *,
.no-flex-gap .center-col.gap-\[8px\] > * + * {
margin-top: 8px;
}
.no-flex-gap .flex.gap-\[10px\]:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-\[10px\]:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-\[10px\] > * + * {
margin-left: 10px;
}
.no-flex-gap .flex.flex-col.gap-\[10px\] > * + *,
.no-flex-gap .center-col.gap-\[10px\] > * + * {
margin-top: 10px;
}

View File

@@ -0,0 +1,26 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "HFKos";
src: url("./fonts/HFKos-R.ttf") format("truetype");
font-weight: 400;
font-style: normal;
}
@layer base {
:root {
--font-num: "HFKos", "PingFang-Medium", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
}
@layer components {
.center-row {
@apply flex flex-row items-center;
}
.center-col {
@apply flex flex-col items-center;
}
}

View File

@@ -0,0 +1,4 @@
import { IsWindows } from "@bindings/cursor/internal/bridge/proxyservice.js";
import { ref } from "vue";
export const isWindows = ref(Boolean(await IsWindows()));

View File

@@ -0,0 +1,39 @@
const INTEGER_FORMATTER = new Intl.NumberFormat("en-US");
const COMPACT_UNITS = [
{ value: 1000000000000, suffix: "T" },
{ value: 1000000000, suffix: "B" },
{ value: 1000000, suffix: "M" },
{ value: 1000, suffix: "K" },
];
function normalizeInteger(value) {
const number = Number(value);
if (!Number.isFinite(number)) {
return 0;
}
return Math.round(number);
}
function trimTrailingZeros(text) {
return text.replace(/\.0$/, "").replace(/(\.\d*[1-9])0+$/, "$1");
}
export function formatInteger(value) {
return INTEGER_FORMATTER.format(normalizeInteger(value));
}
export function formatCompactInteger(value) {
const number = normalizeInteger(value);
const absNumber = Math.abs(number);
const unit = COMPACT_UNITS.find(({ value: threshold }) => absNumber >= threshold);
if (!unit) {
return formatInteger(number);
}
const scaled = number / unit.value;
const fractionDigits = Math.abs(scaled) < 100 ? 1 : 0;
return `${trimTrailingZeros(scaled.toFixed(fractionDigits))}${unit.suffix}`;
}

315
frontend/src/views/Home.vue Normal file
View File

@@ -0,0 +1,315 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import Switch from "@/components/ui/Switch.vue";
import HomeMetricsCard from "@/components/HomeMetricsCard.vue";
import { useMessage } from "@/composables/useMessage";
import { showModal } from "@/composables/useModal";
import {
appState,
appViewState,
openConfigWindow,
openModelConfigWindow,
saveRoutingMode,
syncHomeMetrics,
syncServiceState,
toUserError,
toggleService,
} from "@/state/appState";
import {
getCursorStatus,
localizeCursor,
openCLITerminal,
openCursor,
openSkillWindow,
selectDirectory,
} from "@/services/clientApi";
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
const directModeEnabled = computed(() => appState.routingMode === "upstream");
const message = useMessage();
const cliTerminalBusy = ref(false);
const localizing = ref(false);
// ── Cursor 连接状态(心跳检测) ──
const cursorStatus = reactive({
loading: true,
installFound: false,
installPath: "",
processRunning: false,
});
const cursorStatusTimer = ref(null);
const cursorHeartbeatMs = 15000;
async function syncCursorStatus() {
try {
const status = await getCursorStatus();
cursorStatus.installFound = Boolean(status?.installFound);
cursorStatus.installPath = String(status?.installPath || "");
cursorStatus.processRunning = Boolean(status?.processRunning);
} catch {
// 心跳失败不打断界面,保留上次状态。
} finally {
cursorStatus.loading = false;
}
}
async function handleOpenCursor() {
try {
await openCursor(cursorStatus.installFound ? cursorStatus.installPath : "");
// 与心跳错峰,立即刷新一次进程状态。
void syncCursorStatus();
} catch (error) {
await showActionError("启动失败", toUserError(error));
}
}
async function handlePickCursorDir() {
try {
const dir = await selectDirectory();
if (!dir) {
return;
}
cursorStatus.installPath = dir;
cursorStatus.installFound = true;
message.success("已手动指定 Cursor 安装目录");
} catch (error) {
await showActionError("选择目录失败", toUserError(error));
}
}
onUnmounted(() => {
if (cursorStatusTimer.value) {
window.clearInterval(cursorStatusTimer.value);
}
});
async function showActionError(title, error) {
await showModal({
title,
content: String(error || "服务错误").trim() || "服务错误",
});
}
async function handleToggleService() {
const result = await toggleService();
if (!result.ok) {
await showActionError("服务操作失败", result.error);
}
}
async function handleRefreshState() {
const [serviceStateResult] = await Promise.allSettled([
syncServiceState(),
syncHomeMetrics(),
]);
if (serviceStateResult.status === "rejected") {
await showActionError("刷新失败", toUserError(serviceStateResult.reason));
}
}
async function handleRefreshMetrics() {
await syncHomeMetrics().catch(() => {});
}
async function handleOpenConfig() {
try {
await openConfigWindow();
} catch (error) {
await showActionError("打开失败", toUserError(error));
}
}
async function handleOpenModelConfig() {
try {
await openModelConfigWindow();
} catch (error) {
await showActionError("打开失败", toUserError(error));
}
}
async function handleDirectModeChange(enabled) {
const result = await saveRoutingMode(enabled ? "upstream" : "local");
if (!result.ok) {
await showActionError("切换失败", result.error);
return;
}
message.success(enabled ? "已切换到直连 Cursor 模式" : "已切换到本地服务模式");
}
async function handleOpenCLITerminal() {
if (cliTerminalBusy.value) {
return;
}
cliTerminalBusy.value = true;
try {
const result = await openCLITerminal();
if (!result?.ok) {
await showActionError("CLI 终端", result?.error || "打开失败");
return;
}
message.success("CLI 终端已打开,请在终端中 cd 到项目目录后运行 cursor-agent");
} catch (error) {
await showActionError("CLI 终端", toUserError(error));
} finally {
cliTerminalBusy.value = false;
}
}
async function handleOpenSkillWindow() {
try {
await openSkillWindow();
} catch (error) {
await showActionError("打开失败", toUserError(error));
}
}
async function handleLocalizeCursor() {
if (localizing.value) {
return;
}
const confirmed = await showModal({
title: "汉化 Cursor",
content: "将关闭所有 Cursor 进程并运行社区汉化包,未保存的工作可能丢失。是否继续?",
confirmText: "继续",
});
if (!confirmed) {
return;
}
localizing.value = true;
try {
// 优先使用状态卡片中已知的目录(含手动指定),否则走后端自动检测。
const output = await localizeCursor(cursorStatus.installFound ? cursorStatus.installPath : "");
message.success(typeof output === "string" && output.trim() ? output.trim() : "汉化完成,请重新启动 Cursor");
} catch (error) {
await showActionError("汉化失败", toUserError(error));
} finally {
localizing.value = false;
}
}
onMounted(() => {
void handleRefreshState();
void syncCursorStatus();
cursorStatusTimer.value = window.setInterval(() => {
void syncCursorStatus();
}, cursorHeartbeatMs);
});
</script>
<template>
<div class="flex flex-col gap-[12px] p-[16px] text-white overflow-hidden h-full">
<!-- cursor connection status -->
<Card>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0">
<span
class="w-[10px] h-[10px] rounded-full shrink-0"
:class="cursorStatus.loading ? 'bg-[#666] animate-pulse' : cursorStatus.installFound ? 'bg-[#22c55e]' : 'bg-[#ff4444]'"
/>
<div class="min-w-0">
<div class="text-[14px] font-medium">
{{ cursorStatus.loading ? "正在检测 Cursor..." : cursorStatus.installFound ? "Cursor 已连接" : "未找到 Cursor 安装目录" }}
</div>
<div class="text-[12px] text-[#a8a8a8] truncate" :title="cursorStatus.installPath">
{{ cursorStatus.installPath || "未检测到安装路径,可手动指定" }}
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span
class="rounded-[999px] border px-[10px] py-[4px] text-[11px]"
:class="cursorStatus.processRunning
? 'border-[#22c55e]/40 text-[#22c55e]'
: 'border-[#666] text-[#a8a8a8]'"
>
{{ cursorStatus.processRunning ? "Cursor 运行中" : "Cursor 未运行" }}
</span>
<Button variant="default" :disabled="!cursorStatus.installFound" @click="handleOpenCursor">
打开 Cursor
</Button>
<Button variant="default" :disabled="cursorStatus.loading" @click="handlePickCursorDir">
手动指定
</Button>
</div>
</div>
</Card>
<!-- service status -->
<Card>
<div class="flex flex-col gap-[12px]">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-1">
<div class="text-sm" :class="appViewState.serviceStatusClass">
{{ appViewState.serviceStatusText }}
</div>
</div>
<Button variant="primary" :disabled="appState.serviceBusy" @click="handleToggleService">
<span class="icon-[mdi--pause] text-[16px]" v-if="appState.serviceRunning"></span>
<span class="icon-[mdi--play] text-[16px]" v-else></span>
<span> {{ appViewState.serviceButtonText }}</span>
</Button>
</div>
<div v-if="appState.serviceLastError"
class="rounded-[4px] border border-[#ff4444]/30 bg-[#ff4444]/5 px-3 py-2 text-sm text-[#ff4444]"
>
{{ appState.serviceLastError }}
</div>
<Switch
label="直连模式"
description="开启后,Cursor将直接接通官方,请勿开启"
enabled-text="当前为直连模式"
disabled-text="当前为本地服务模式"
:enabled="directModeEnabled"
:busy="appState.configSaving"
:disabled="appState.configSaving"
@change="handleDirectModeChange"
/>
</div>
</Card>
<!-- metrics -->
<HomeMetricsCard
:metrics="appState.homeMetrics"
:loading="appState.homeMetricsLoading"
:error="appState.homeMetricsError"
@refresh="handleRefreshMetrics"
/>
<!-- quick actions -->
<Card>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-[14px] font-medium text-white">本地配置</h2>
<div class="text-[12px] text-[#a8a8a8]">打开设置目录,或单独管理模型配置</div>
</div>
<div class="flex items-center gap-2">
<Button variant="default" @click="handleOpenConfig">设置文件夹</Button>
<Button variant="default" @click="handleOpenSkillWindow">Skill 管理</Button>
<Button variant="default" :disabled="localizing" @click="handleLocalizeCursor">
{{ localizing ? "汉化中..." : "汉化 Cursor" }}
</Button>
<Button variant="primary" @click="handleOpenModelConfig">模型配置</Button>
</div>
</div>
</Card>
<!-- cli terminal -->
<Card>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-[14px] font-medium text-white">CLI 终端</h2>
<div class="text-[12px] text-[#a8a8a8]">以管理员权限打开终端,预配置 wrapper 环境变量</div>
</div>
<div class="flex items-center gap-2">
<Button variant="default" :disabled="cliTerminalBusy" @click="handleOpenCLITerminal">
<span class="icon-[mdi--console-line] text-[16px]"></span>
<span>{{ cliTerminalBusy ? "打开中..." : "打开终端" }}</span>
</Button>
</div>
</div>
</Card>
</div>
</template>

View File

@@ -0,0 +1,484 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue";
import { showModal } from "@/composables/useModal";
import {
appState,
batchAddModelAdapters,
createEmptyModelAdapter,
deleteModelAdapterAt,
duplicateModelAdapterAt,
fetchRemoteModels,
getModelAdapterTestResultByID,
openModelEditorWindow,
reloadUserConfig,
runModelAdapterTest,
startModelAdapterTest,
toUserError,
} from "@/state/appState";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
const BATCH_TEST_CONCURRENCY = 10;
const fetchModal = reactive({
visible: false,
loading: false,
error: "",
baseURL: "",
apiKey: "",
models: [],
selected: new Set(),
adding: false,
});
const fetchSelectedCount = computed(() => fetchModal.selected.size);
function openFetchModal() {
const existing = appState.modelAdapters.find((adapter) => adapter.type === activeType.value);
fetchModal.baseURL = existing?.baseURL || "";
fetchModal.apiKey = existing?.apiKey || "";
fetchModal.models = [];
fetchModal.selected = new Set();
fetchModal.error = "";
fetchModal.visible = true;
}
function closeFetchModal() {
if (fetchModal.loading || fetchModal.adding) {
return;
}
fetchModal.visible = false;
}
function toggleFetchModel(modelID) {
const next = new Set(fetchModal.selected);
if (next.has(modelID)) {
next.delete(modelID);
} else {
next.add(modelID);
}
fetchModal.selected = next;
}
function toggleFetchAll() {
const allSelected = fetchModal.models.length > 0 && fetchModal.selected.size === fetchModal.models.length;
fetchModal.selected = allSelected ? new Set() : new Set(fetchModal.models.map((model) => model.id));
}
async function handleFetchModels() {
fetchModal.error = "";
fetchModal.loading = true;
try {
const models = await fetchRemoteModels(activeType.value, fetchModal.baseURL, fetchModal.apiKey);
fetchModal.models = models.map((id) => ({ id, selected: true }));
fetchModal.selected = new Set(models);
} catch (error) {
fetchModal.error = String(toUserError(error)?.message || error || "拉取失败");
} finally {
fetchModal.loading = false;
}
}
async function handleBatchAddModels() {
if (fetchModal.selected.size === 0) {
return;
}
fetchModal.error = "";
fetchModal.adding = true;
try {
const result = await batchAddModelAdapters(
fetchModal.models
.filter((model) => fetchModal.selected.has(model.id))
.map((model) => ({
type: activeType.value,
baseURL: fetchModal.baseURL,
apiKey: fetchModal.apiKey,
modelID: model.id,
displayName: model.id,
})),
);
if (!result.ok) {
fetchModal.error = result.error || "保存失败";
return;
}
fetchModal.visible = false;
} catch (error) {
fetchModal.error = String(toUserError(error)?.message || error || "保存失败");
} finally {
fetchModal.adding = false;
}
}
const typeTabs = [
{ label: "OpenAI", value: "openai", icon: "icon-[bxl--openai]" },
{ label: "Anthropic", value: "anthropic", icon: "icon-[logos--claude-icon]" },
];
const activeType = ref("openai");
const batchTesting = ref(false);
const batchStopping = ref(false);
const batchTotal = ref(0);
const batchCompleted = ref(0);
const batchActiveCalls = new Set();
let batchStopRequested = false;
const filteredAdapters = computed(() =>
appState.modelAdapters.filter((adapter) => adapter.type === activeType.value),
);
const batchButtonText = computed(() => {
if (batchStopping.value) {
return "停止中...";
}
if (!batchTesting.value) {
return "测试全部";
}
return `停止测试 ${batchCompleted.value}/${batchTotal.value}`;
});
watch(
() => appState.modelAdapters,
(adapters) => {
if (adapters.some((adapter) => adapter.type === activeType.value)) {
return;
}
const fallback = typeTabs.find((tab) => adapters.some((adapter) => adapter.type === tab.value));
activeType.value = fallback?.value ?? "openai";
},
{ deep: true, immediate: true },
);
async function showActionError(title, error) {
await showModal({
title,
content: String(error || "服务错误").trim() || "服务错误",
});
}
function maskSecret(value) {
const text = String(value || "").trim();
if (!text) {
return "-";
}
if (text.length <= 8) {
return `${"*".repeat(Math.max(text.length - 2, 0))}${text.slice(-2)}`;
}
return `${text.slice(0, 4)}****${text.slice(-4)}`;
}
function typeLabel(type) {
return type === "anthropic" ? "Anthropic" : "OpenAI";
}
function formatHost(value) {
const text = String(value || "").trim();
if (!text) {
return "-";
}
try {
const parsed = new URL(text);
return parsed.host || text;
} catch {
return text.replace(/^https?:\/\//, "");
}
}
async function openEditor(index = -1) {
const adapter = index >= 0
? appState.modelAdapters[index]
: {
...createEmptyModelAdapter(),
type: activeType.value,
};
try {
await openModelEditorWindow(index, adapter);
} catch (error) {
await showActionError("打开失败", toUserError(error));
}
}
async function handleDeleteModelAdapter(index) {
const target = appState.modelAdapters[index];
if (!target) {
await showActionError("删除失败", "模型配置不存在,无法删除");
return;
}
const result = await deleteModelAdapterAt(index);
if (!result.ok) {
await showActionError("删除失败", result.error);
}
}
async function handleDuplicateModelAdapter(index) {
const target = appState.modelAdapters[index];
if (!target) {
await showActionError("复制失败", "模型配置不存在,无法复制");
return;
}
const result = await duplicateModelAdapterAt(index);
if (!result.ok) {
await showActionError("复制失败", result.error);
}
}
function getAdapterTestResult(adapter) {
return getModelAdapterTestResultByID(adapter?.id);
}
function isAdapterTesting(adapter) {
return getAdapterTestResult(adapter)?.status === "running";
}
async function handleTestModelAdapter(adapter) {
try {
await runModelAdapterTest(adapter);
} catch (_error) {
// 失败结果会通过事件同步到界面,这里不再额外弹窗打断用户。
}
}
function isCancelError(error) {
return String(error?.name || "").trim() === "CancelError";
}
async function stopBatchTesting() {
if (!batchTesting.value || batchStopping.value) {
return;
}
batchStopRequested = true;
batchStopping.value = true;
const activeCalls = Array.from(batchActiveCalls);
await Promise.allSettled(
activeCalls.map((call) => (typeof call?.cancel === "function" ? call.cancel("batch-stop") : undefined)),
);
}
async function handleTestAllModelAdapters() {
if (batchTesting.value) {
await stopBatchTesting();
return;
}
const adapters = filteredAdapters.value.slice();
if (adapters.length === 0) {
return;
}
batchStopRequested = false;
batchTesting.value = true;
batchStopping.value = false;
batchTotal.value = adapters.length;
batchCompleted.value = 0;
let nextIndex = 0;
try {
const workers = Array.from({ length: Math.min(BATCH_TEST_CONCURRENCY, adapters.length) }, async () => {
while (!batchStopRequested) {
const currentIndex = nextIndex;
nextIndex += 1;
if (currentIndex >= adapters.length) {
return;
}
const adapter = adapters[currentIndex];
const call = startModelAdapterTest(adapter);
batchActiveCalls.add(call);
try {
await call;
} catch (error) {
if (!isCancelError(error) && !batchStopRequested) {
// 单个失败结果由卡片自行展示,这里继续后续测试。
}
} finally {
batchActiveCalls.delete(call);
batchCompleted.value += 1;
}
}
});
await Promise.allSettled(workers);
} finally {
batchActiveCalls.clear();
batchStopRequested = false;
batchTesting.value = false;
batchStopping.value = false;
}
}
onMounted(async () => {
await reloadUserConfig({ modelAdaptersOnly: true }).catch(() => { });
});
onBeforeUnmount(() => {
void stopBatchTesting();
});
</script>
<template>
<div class="flex h-full min-h-0 flex-col p-4 text-white overflow-hidden">
<div class="shrink-0 pb-4">
<div class="flex items-center justify-between gap-4">
<div class="center-row gap-2">
<button
v-for="tab in typeTabs"
:key="tab.value"
type="button"
class="center-row gap-2 rounded-[8px] border px-3 py-2 text-sm transition-colors duration-150"
:class="activeType === tab.value
? 'border-white bg-white/10 text-white'
: 'border-[#2a2a2a] bg-[#141414] text-[#a8a8a8] hover:border-[#666] hover:text-white'"
@click="activeType = tab.value"
>
<span :class="[tab.icon, 'text-[16px]']"></span>
<span>{{ tab.label }}</span>
</button>
</div>
<div class="center-row gap-2">
<Button
variant="default"
:disabled="appState.configSaving || (!batchTesting && filteredAdapters.length === 0)"
@click="handleTestAllModelAdapters"
>
{{ batchButtonText }}
</Button>
<Button
variant="default"
:disabled="appState.configSaving || batchTesting || fetchModal.visible"
@click="openFetchModal"
>
拉取模型
</Button>
<Button variant="primary" :disabled="appState.configSaving || batchTesting" @click="openEditor()">新增模型</Button>
</div>
</div>
</div>
<div class="min-h-0 flex-1">
<div v-if="filteredAdapters.length === 0"
class="flex h-full min-h-[220px] items-center justify-center rounded-[4px] border border-dashed border-[#2a2a2a] bg-[#141414] px-4 text-sm text-[#a8a8a8]">
当前还没有配置任何 {{ typeLabel(activeType) }} 模型。
</div>
<div v-else class="h-full min-h-0 overflow-y-auto pr-1">
<div class="grid gap-3 pb-1 [grid-template-columns:repeat(auto-fill,minmax(250px,1fr))]">
<Card
v-for="(adapter, index) in filteredAdapters"
:key="adapter.id || `${adapter.baseURL}-${adapter.modelID}-${index}`"
>
<div class="flex h-full min-h-[154px] flex-col justify-between gap-3">
<div class="flex flex-col gap-2.5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="truncate text-base font-medium text-white">{{ adapter.displayName }}</div>
<div class="mt-1 truncate text-sm text-[#666]">{{ adapter.modelID }}</div>
<div v-if="adapter.type === 'openai'" class="mt-0.5 truncate text-xs text-[#666]">
{{ adapter.openAIEndpoint || "/v1/responses" }}
</div>
</div>
<span
class="center-row shrink-0 gap-1 rounded-[999px] border border-[#383838] px-[7px] py-[4px] text-[11px] font-medium text-white"
>
<span class="icon-[bxl--openai] text-[14px] !text-white" v-if="adapter.type === 'openai'"></span>
<span class="icon-[logos--claude-icon] text-[14px]" v-else></span>
<span>{{ typeLabel(adapter.type) }}</span>
</span>
</div>
<div class="grid grid-cols-2 gap-2 text-sm text-[#a8a8a8]">
<div class="rounded-[4px] bg-[#141414] px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">Host</div>
<div class="mt-1 truncate text-white" :title="adapter.baseURL">{{ formatHost(adapter.baseURL) }}</div>
</div>
<div class="rounded-[4px] bg-[#141414] px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">API Key</div>
<div class="mt-1 truncate text-white">{{ maskSecret(adapter.apiKey) }}</div>
</div>
</div>
<ModelAdapterTestCard
compact
title="测试"
empty-text="未测试"
:result="getAdapterTestResult(adapter)"
/>
</div>
<div class="center-row flex-wrap justify-end gap-2 border-t border-[#2a2a2a] pt-3">
<Button
variant="default"
:disabled="appState.configSaving || batchTesting || isAdapterTesting(adapter)"
@click="handleTestModelAdapter(adapter)"
>
{{ isAdapterTesting(adapter) ? "测试中..." : "测试" }}
</Button>
<Button variant="default" :disabled="appState.configSaving" @click="openEditor(appState.modelAdapters.indexOf(adapter))">编辑</Button>
<Button variant="default" :disabled="appState.configSaving" @click="handleDuplicateModelAdapter(appState.modelAdapters.indexOf(adapter))">复制</Button>
<Button variant="text" :disabled="appState.configSaving"
@click="handleDeleteModelAdapter(appState.modelAdapters.indexOf(adapter))">删除</Button>
</div>
</div>
</Card>
</div>
</div>
</div>
<Teleport to="body">
<div v-if="fetchModal.visible" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4" @click.self="closeFetchModal">
<div class="flex max-h-[80vh] w-full max-w-[520px] flex-col rounded-[8px] border border-[#2a2a2a] bg-[#141414] p-4 text-white">
<div class="flex items-center justify-between pb-3">
<span class="text-base font-medium">拉取 {{ typeLabel(activeType) }} 模型列表</span>
<button type="button" class="text-[#a8a8a8] hover:text-white" :disabled="fetchModal.loading || fetchModal.adding" @click="closeFetchModal">✕</button>
</div>
<div class="flex flex-col gap-2 pb-3">
<input
v-model="fetchModal.baseURL"
type="text"
placeholder="接口地址,例如 https://api.example.com"
class="h-8 rounded-[4px] border border-[#383838] bg-[#0f0f0f] px-2 text-sm outline-none focus:border-white"
:disabled="fetchModal.loading || fetchModal.adding"
/>
<input
v-model="fetchModal.apiKey"
type="password"
placeholder="访问密钥"
class="h-8 rounded-[4px] border border-[#383838] bg-[#0f0f0f] px-2 text-sm outline-none focus:border-white"
:disabled="fetchModal.loading || fetchModal.adding"
/>
<div class="flex items-center justify-between">
<Button variant="default" :disabled="fetchModal.loading || fetchModal.adding || !fetchModal.baseURL || !fetchModal.apiKey" @click="handleFetchModels">
{{ fetchModal.loading ? "拉取中..." : "拉取列表" }}
</Button>
<span v-if="fetchModal.models.length > 0" class="text-xs text-[#a8a8a8]">
已选 {{ fetchSelectedCount }}/{{ fetchModal.models.length }}
<button type="button" class="ml-2 text-white underline" @click="toggleFetchAll">全选/反选</button>
</span>
</div>
<div v-if="fetchModal.error" class="text-xs text-red-400">{{ fetchModal.error }}</div>
</div>
<div class="min-h-[120px] flex-1 overflow-y-auto rounded-[4px] border border-[#2a2a2a] bg-[#0f0f0f] p-2">
<div v-if="fetchModal.models.length === 0" class="flex h-full items-center justify-center text-sm text-[#666]">
{{ fetchModal.loading ? "正在拉取模型列表..." : "点击「拉取列表」获取可用模型" }}
</div>
<label
v-for="model in fetchModal.models"
:key="model.id"
class="flex cursor-pointer items-center gap-2 rounded-[4px] px-2 py-1.5 text-sm hover:bg-white/5"
>
<input
type="checkbox"
:checked="fetchModal.selected.has(model.id)"
class="accent-white"
@change="toggleFetchModel(model.id)"
/>
<span class="truncate">{{ model.id }}</span>
</label>
</div>
<div class="flex justify-end gap-2 pt-3">
<Button variant="text" :disabled="fetchModal.loading || fetchModal.adding" @click="closeFetchModal">取消</Button>
<Button variant="primary" :disabled="fetchModal.loading || fetchModal.adding || fetchSelectedCount === 0" @click="handleBatchAddModels">
{{ fetchModal.adding ? "添加中..." : `添加已选 ${fetchSelectedCount} 个` }}
</Button>
</div>
</div>
</div>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,548 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Input from "@/components/ui/Input.vue";
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue";
import Select from "@/components/ui/Select.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { getModelEditorContext } from "@/services/clientApi";
import {
ANTHROPIC_THINKING_EFFORT_DEFAULT,
appState,
buildModelAdapterTestRequestHash,
createEmptyModelAdapter,
CUSTOM_HEADERS_DEFAULT_JSON,
EXTRA_PARAMS_DEFAULT_JSON,
getModelAdapterTestResult,
getModelAdapterTestResultByID,
isModelAdapterTestResultStale,
normalizeModelAdapter,
OPENAI_ENDPOINT_CHAT_COMPLETIONS,
OPENAI_ENDPOINT_RESPONSES,
OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
runModelAdapterTest,
saveModelAdapterAt,
toUserError,
validateModelAdapters,
} from "@/state/appState";
import { Window } from "@wailsio/runtime";
import { computed, onMounted, reactive, ref, watch } from "vue";
const modelTypeTabs = [
{ label: "OpenAI", value: "openai", icon: "icon-[bxl--openai]" },
{ label: "Anthropic", value: "anthropic", icon: "icon-[logos--claude-icon]" },
];
const reasoningEffortOptions = [
{ label: "低", value: "low", icon: "icon-[mdi--head-outline]" },
{ label: "中", value: "medium", icon: "icon-[mdi--head-lightbulb-outline]" },
{ label: "高", value: "high", icon: "icon-[mdi--brain]" },
{ label: "极高", value: "xhigh", icon: "icon-[mdi--head-cog-outline]" },
];
const anthropicThinkingEffortOptions = [
{ label: "低", value: "low", icon: "icon-[mdi--head-outline]" },
{ label: "中", value: "medium", icon: "icon-[mdi--head-lightbulb-outline]" },
{ label: "高", value: "high", icon: "icon-[mdi--brain]" },
{ label: "极高", value: "xhigh", icon: "icon-[mdi--head-cog-outline]" },
{ label: "Max", value: "max", icon: "icon-[mdi--brain]" },
];
const openAIEndpointOptions = [
{ label: "/v1/responses", value: OPENAI_ENDPOINT_RESPONSES, icon: "icon-[mdi--api]" },
{ label: "/v1/chat/completions", value: OPENAI_ENDPOINT_CHAT_COMPLETIONS, icon: "icon-[mdi--message-text-outline]" },
];
const editorIndex = ref(-1);
const draft = reactive(createEmptyModelAdapter());
const errorMessage = ref("");
const loading = ref(true);
const lastTestAdapterID = ref("");
const localTestFailure = ref("");
function createOptionalPositiveIntegerModel(key) {
return computed({
get() {
return draft[key] > 0 ? String(draft[key]) : "";
},
set(value) {
const text = String(value || "").trim();
draft[key] = /^\d+$/.test(text) && Number(text) > 0 ? Number(text) : 0;
},
});
}
const maxCompletionTokensInput = createOptionalPositiveIntegerModel("maxCompletionTokens");
const anthropicMaxTokensInput = createOptionalPositiveIntegerModel("anthropicMaxTokens");
const contextWindowTokensInput = createOptionalPositiveIntegerModel("contextWindowTokens");
const interfacePlaceholder = computed(() =>
draft.type === "anthropic" ? "例如:https://api.anthropic.com" : "例如:https://api.openai.com/v1",
);
const currentRequestHash = computed(() => buildModelAdapterTestRequestHash(draft));
const directModelTestResult = computed(() => getModelAdapterTestResult(draft));
const rememberedModelTestResult = computed(() =>
lastTestAdapterID.value ? getModelAdapterTestResultByID(lastTestAdapterID.value) : null,
);
const activeModelTestResult = computed(() => directModelTestResult.value || rememberedModelTestResult.value);
const modelTestResultStale = computed(() =>
isModelAdapterTestResultStale(draft, activeModelTestResult.value),
);
const isCurrentConfigTesting = computed(() => directModelTestResult.value?.status === "running");
const modelTestSummary = computed(() => {
if (localTestFailure.value) {
return localTestFailure.value;
}
return activeModelTestResult.value?.summaryText || "尚未测试";
});
const title = computed(() => (editorIndex.value >= 0 ? "编辑模型配置" : "新增模型配置"));
function ensureOpenAIExtraParamsJSON() {
if (!String(draft.openAIExtraParamsJSON || "").trim()) {
draft.openAIExtraParamsJSON = OPENAI_EXTRA_PARAMS_DEFAULT_JSON;
}
}
function ensureCustomHeadersJSON() {
if (!String(draft.customHeadersJSON || "").trim()) {
draft.customHeadersJSON = CUSTOM_HEADERS_DEFAULT_JSON;
}
}
function ensureAnthropicExtraParamsJSON() {
if (!String(draft.anthropicExtraParamsJSON || "").trim()) {
draft.anthropicExtraParamsJSON = EXTRA_PARAMS_DEFAULT_JSON;
}
}
function ensureAnthropicThinkingEffort() {
if (!String(draft.anthropicThinkingEffort || "").trim()) {
draft.anthropicThinkingEffort = ANTHROPIC_THINKING_EFFORT_DEFAULT;
}
}
const fieldTips = {
displayName: "仅用于界面展示,便于你区分不同模型。",
modelID: "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
baseURL: "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
apiKey: "调用该模型服务需要使用的访问密钥。",
contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
reasoningEffort: "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。",
maxCompletionTokens: "单次回复允许生成的最大 Token 数。留空时使用默认值。",
openAIEndpoint: "OpenAI 兼容接口使用的协议端点。未选择时默认使用 /v1/responses。",
openAIExtraParams: "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。",
customHeaders: "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。",
anthropicExtraParams: "开启后会把 JSON 对象覆盖到 Anthropic 请求体。同名字段以这里为准。",
anthropicMaxTokens: "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。",
anthropicThinkingEffort: "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
tooltipData: "模型列表 hover 时显示的备注说明。",
};
async function loadContext() {
try {
const ctx = await getModelEditorContext();
editorIndex.value = typeof ctx.index === "number" ? ctx.index : -1;
const parsed = JSON.parse(ctx.adapterJSON || "{}");
Object.assign(draft, normalizeModelAdapter(parsed));
if (!draft.type) {
draft.type = "openai";
}
} catch (_error) {
Object.assign(draft, createEmptyModelAdapter());
draft.type = "openai";
} finally {
loading.value = false;
}
}
async function persistDraft() {
const adapter = normalizeModelAdapter(draft);
const singleCheck = validateModelAdapters([adapter]);
if (singleCheck) {
errorMessage.value = singleCheck;
return { ok: false, error: singleCheck, adapter: null };
}
const result = await saveModelAdapterAt(editorIndex.value, adapter);
if (!result.ok) {
errorMessage.value = result.error;
return { ok: false, error: result.error, adapter: null };
}
if (typeof result.index === "number") {
editorIndex.value = result.index;
}
if (result.adapter) {
Object.assign(draft, normalizeModelAdapter(result.adapter));
}
errorMessage.value = "";
return {
ok: true,
error: "",
adapter: result.adapter ? normalizeModelAdapter(result.adapter) : normalizeModelAdapter(draft),
};
}
async function handleSave() {
const result = await persistDraft();
if (!result.ok) {
return;
}
await Window.Close();
}
async function handleCancel() {
await Window.Close();
}
function handleModelTypeChange(type) {
draft.type = type;
if (type === "openai" && !draft.openAIEndpoint) {
draft.openAIEndpoint = OPENAI_ENDPOINT_RESPONSES;
} else if (type === "anthropic") {
ensureAnthropicThinkingEffort();
}
}
async function handleTest() {
localTestFailure.value = "";
try {
const saved = await persistDraft();
if (!saved.ok || !saved.adapter) {
return;
}
const result = await runModelAdapterTest(saved.adapter);
if (result?.adapterID) {
lastTestAdapterID.value = result.adapterID;
}
} catch (error) {
const latest = getModelAdapterTestResult(draft);
if (latest?.adapterID) {
lastTestAdapterID.value = latest.adapterID;
return;
}
localTestFailure.value = toUserError(error);
}
}
watch(
directModelTestResult,
(result) => {
if (!result?.adapterID) {
return;
}
lastTestAdapterID.value = result.adapterID;
if (result.status !== "running") {
localTestFailure.value = "";
}
},
{ immediate: true },
);
watch(currentRequestHash, () => {
localTestFailure.value = "";
});
watch(
() => draft.openAIExtraParamsEnabled,
(enabled) => {
if (enabled) {
ensureOpenAIExtraParamsJSON();
}
},
);
watch(
() => draft.customHeadersEnabled,
(enabled) => {
if (enabled) {
ensureCustomHeadersJSON();
}
},
);
watch(
() => draft.anthropicExtraParamsEnabled,
(enabled) => {
if (enabled) {
ensureAnthropicExtraParamsJSON();
}
},
);
onMounted(async () => {
await loadContext();
});
</script>
<template>
<div class="flex h-full flex-col text-white">
<div class="flex shrink-0 items-center justify-between px-4 pt-2 pb-2">
<h2 class="text-base font-medium text-white">{{ title }}</h2>
<div class="flex items-center gap-2">
<Button variant="default" @click="handleCancel">取消</Button>
<Button variant="default" :disabled="isCurrentConfigTesting || appState.configSaving" @click="handleTest">
{{ isCurrentConfigTesting ? "测试中..." : "保存并测试" }}
</Button>
<Button variant="primary" :disabled="appState.configSaving" @click="handleSave">
{{ appState.configSaving ? "保存中..." : "保存" }}
</Button>
</div>
</div>
<div v-if="loading" class="flex flex-1 items-center justify-center text-sm text-[#a8a8a8]">
加载中...
</div>
<div v-else class="flex-1 overflow-y-auto min-h-0 px-4 pb-4">
<div class="flex flex-col gap-4">
<div class="center-row gap-2">
<button
v-for="tab in modelTypeTabs"
:key="tab.value"
type="button"
class="center-row gap-2 rounded-[8px] border px-3 py-2 text-sm transition-colors duration-150"
:class="draft.type === tab.value
? 'border-white bg-white/10 text-white'
: 'border-[#2a2a2a] bg-[#141414] text-[#a8a8a8] hover:border-[#666] hover:text-white'"
@click="handleModelTypeChange(tab.value)"
>
<span :class="[tab.icon, 'text-[16px]']"></span>
<span>{{ tab.label }}</span>
</button>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.displayName" />
<span>显示名称</span>
</span>
<input
v-model="draft.displayName"
type="text"
placeholder="例如:OpenAI - GPT-4.1"
class="h-9 rounded-[4px] border border-[#383838] bg-[#141414] px-3 text-sm text-white outline-none focus:border-white"
/>
</label>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.modelID" />
<span>模型标识</span>
</span>
<input
v-model="draft.modelID"
type="text"
placeholder="例如:gpt-4.1"
class="h-9 rounded-[4px] border border-[#383838] bg-[#141414] px-3 text-sm text-white outline-none focus:border-white"
/>
</label>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.apiKey" />
<span>访问密钥</span>
</span>
<Input
v-model="draft.apiKey"
type="password"
allow-visibility-toggle
placeholder="例如:sk-xxxxxx"
autocomplete="off"
/>
</label>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.baseURL" />
<span>接口地址</span>
</span>
<input
v-model="draft.baseURL"
type="text"
:placeholder="interfacePlaceholder"
class="h-9 rounded-[4px] border border-[#383838] bg-[#141414] px-3 text-sm text-white outline-none focus:border-white"
/>
</label>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.contextWindowTokens" />
<span>上下文窗口</span>
</span>
<input
v-model="contextWindowTokensInput"
type="text"
inputmode="numeric"
placeholder="例如:500000(留空用默认值)"
class="h-9 rounded-[4px] border border-[#383838] bg-[#141414] px-3 text-sm text-white outline-none focus:border-white"
/>
</label>
<label v-if="draft.type === 'openai'" class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.reasoningEffort" />
<span>推理强度</span>
</span>
<Select
v-model="draft.reasoningEffort"
:options="reasoningEffortOptions"
/>
</label>
<label v-if="draft.type === 'anthropic'" class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.anthropicMaxTokens" />
<span>最大输出 Token</span>
</span>
<input
v-model="anthropicMaxTokensInput"
type="text"
inputmode="numeric"
placeholder="例如:131072(留空用默认值)"
class="h-9 rounded-[4px] border border-[#383838] bg-[#141414] px-3 text-sm text-white outline-none focus:border-white"
/>
</label>
<label v-if="draft.type === 'anthropic'" class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.anthropicThinkingEffort" />
<span>思考强度</span>
</span>
<Select
v-model="draft.anthropicThinkingEffort"
:options="anthropicThinkingEffortOptions"
/>
</label>
</div>
<div v-if="draft.type === 'openai'" class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.maxCompletionTokens" />
<span>最大输出 Token</span>
</span>
<input
v-model="maxCompletionTokensInput"
type="text"
inputmode="numeric"
placeholder="例如:278000(留空用默认值)"
class="h-9 rounded-[4px] border border-[#383838] bg-[#141414] px-3 text-sm text-white outline-none focus:border-white"
/>
</label>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.openAIEndpoint" />
<span>接口端点</span>
</span>
<Select
v-model="draft.openAIEndpoint"
:options="openAIEndpointOptions"
/>
</label>
</div>
<div v-if="draft.type === 'openai'" class="rounded-[8px] border border-[#2a2a2a] bg-[#141414] p-3">
<div class="flex items-center justify-between gap-3">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.openAIExtraParams" />
<span>额外参数 JSON</span>
</span>
<label class="center-row gap-2 text-xs text-[#a8a8a8]">
<input
v-model="draft.openAIExtraParamsEnabled"
type="checkbox"
class="size-4 accent-white"
/>
<span>启用</span>
</label>
</div>
<textarea
v-if="draft.openAIExtraParamsEnabled"
v-model="draft.openAIExtraParamsJSON"
rows="5"
spellcheck="false"
class="mt-3 min-h-[120px] w-full resize-none rounded-[4px] border border-[#383838] bg-[#141414] px-3 py-2 font-mono text-xs text-white outline-none focus:border-white"
/>
</div>
<div v-if="draft.type === 'anthropic'" class="rounded-[8px] border border-[#2a2a2a] bg-[#141414] p-3">
<div class="flex items-center justify-between gap-3">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.anthropicExtraParams" />
<span>Anthropic 额外参数 JSON</span>
</span>
<label class="center-row gap-2 text-xs text-[#a8a8a8]">
<input
v-model="draft.anthropicExtraParamsEnabled"
type="checkbox"
class="size-4 accent-white"
/>
<span>启用</span>
</label>
</div>
<textarea
v-if="draft.anthropicExtraParamsEnabled"
v-model="draft.anthropicExtraParamsJSON"
rows="5"
spellcheck="false"
class="mt-3 min-h-[120px] w-full resize-none rounded-[4px] border border-[#383838] bg-[#141414] px-3 py-2 font-mono text-xs text-white outline-none focus:border-white"
/>
</div>
<div class="rounded-[8px] border border-[#2a2a2a] bg-[#141414] p-3">
<div class="flex items-center justify-between gap-3">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.customHeaders" />
<span>自定义请求头 JSON</span>
</span>
<label class="center-row gap-2 text-xs text-[#a8a8a8]">
<input
v-model="draft.customHeadersEnabled"
type="checkbox"
class="size-4 accent-white"
/>
<span>启用</span>
</label>
</div>
<textarea
v-if="draft.customHeadersEnabled"
v-model="draft.customHeadersJSON"
rows="5"
spellcheck="false"
class="mt-3 min-h-[120px] w-full resize-none rounded-[4px] border border-[#383838] bg-[#141414] px-3 py-2 font-mono text-xs text-white outline-none focus:border-white"
/>
</div>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.tooltipData" />
<span>备注</span>
</span>
<textarea
v-model="draft.tooltipData"
rows="3"
placeholder="例如:用于日常代码补全与问答"
class="min-h-[96px] resize-none rounded-[4px] border border-[#383838] bg-[#141414] px-3 py-2 text-sm text-white outline-none focus:border-white"
/>
</label>
<ModelAdapterTestCard
:result="localTestFailure ? { status: 'error', error: '测试失败', summaryText: '测试失败', rawResponse: modelTestSummary } : activeModelTestResult"
:stale="modelTestResultStale"
:show-metrics="true"
/>
<div
v-if="errorMessage"
class="rounded-[8px] border border-[#555]/30 bg-[#141414] px-3 py-2 text-sm text-white"
>
{{ errorMessage }}
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,249 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import Switch from "@/components/ui/Switch.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { showModal } from "@/composables/useModal";
import {
deleteProjectRecord,
getProjectSkillStates,
listRecentProjects,
listSkills,
setProjectSkill,
} from "@/services/clientApi";
import { toUserError } from "@/state/appState";
import { onMounted, reactive } from "vue";
const state = reactive({
loading: false,
error: "",
skills: [],
projects: [],
// key: `${projectPath}|${skillName}` → boolean
switches: {},
busy: {},
});
function switchKey(projectPath, skillName) {
return `${projectPath}|${skillName}`;
}
// 命令说明默认折叠,按 skill 名记录展开状态。
const expanded = reactive({});
function toggleExpanded(skillName) {
expanded[skillName] = !expanded[skillName];
}
function formatTime(ms) {
if (!ms) {
return "未知时间";
}
return new Date(ms).toLocaleString();
}
async function loadAll() {
state.loading = true;
state.error = "";
try {
const [projects, skills] = await Promise.all([
listRecentProjects(),
listSkills(),
]);
state.projects = Array.isArray(projects) ? projects : [];
state.skills = Array.isArray(skills) ? skills : [];
const switches = {};
for (const project of state.projects) {
let states = {};
try {
states = (await getProjectSkillStates(project.path)) || {};
} catch {
states = {};
}
for (const skill of state.skills) {
switches[switchKey(project.path, skill.name)] = Boolean(states[skill.name]);
}
}
state.switches = switches;
} catch (error) {
state.error = String(toUserError(error)?.message || error || "加载失败");
} finally {
state.loading = false;
}
}
async function handleToggle(projectPath, skillName, enabled) {
const key = switchKey(projectPath, skillName);
if (state.busy[key]) {
return;
}
state.busy[key] = true;
const previous = Boolean(state.switches[key]);
// 乐观更新,失败后回滚。
state.switches[key] = enabled;
try {
await setProjectSkill(projectPath, skillName, enabled);
} catch (error) {
state.switches[key] = previous;
await showModal({
title: enabled ? "导入失败" : "移除失败",
content: String(toUserError(error)?.message || error || "操作失败"),
showCancel: false,
});
} finally {
state.busy[key] = false;
}
}
async function handleDeleteProject(project) {
const confirmed = await showModal({
title: "删除项目记录",
content:
`将从 Cursor 中删除项目 ${project.path} 的记录,包括:最近打开列表条目、workspaceStorage 目录。\n` +
"注意:该目录内含此项目的全部 AI 聊天记录和扩展工作区状态,删除后不可恢复;项目文件本身不受影响。确定删除?",
});
if (!confirmed) {
return;
}
state.busy[project.path] = true;
try {
await deleteProjectRecord(project.path);
await loadAll();
} catch (error) {
await showModal({
title: "删除失败",
content: String(toUserError(error)?.message || error || "操作失败"),
showCancel: false,
});
} finally {
state.busy[project.path] = false;
}
}
onMounted(() => {
void loadAll();
});
</script>
<template>
<div class="flex flex-col gap-[16px] p-[20px] text-white overflow-y-auto h-full">
<Card>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-[14px] font-medium text-white">内置 Skill</h2>
<div class="text-[12px] text-[#a8a8a8]">
通过开关将 Skill 导入到项目(.cursor/skills/),关闭即移除
</div>
</div>
<Button variant="default" :disabled="state.loading" @click="loadAll">刷新</Button>
</div>
</Card>
<Card v-for="skill in state.skills" :key="skill.name" class="mb-[16px]">
<div class="flex flex-col gap-3">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="text-[14px] font-medium text-white">{{ skill.name }}</h2>
<div class="text-[12px] text-[#a8a8a8]">{{ skill.description }}</div>
</div>
</div>
<div v-if="skill.commands && skill.commands.length > 0" class="rounded-[4px] bg-[#141414]">
<button
type="button"
class="flex w-full items-center justify-between px-3 py-2 text-[11px] text-[#666] hover:text-white"
@click="toggleExpanded(skill.name)"
>
<span>斜杠命令({{ skill.commands.length }} 个,导入后在目标项目的 / 命令列表中出现)</span>
<span class="icon-[mdi--chevron-down] text-[14px] transition-transform" :class="expanded[skill.name] ? 'rotate-180' : ''" />
</button>
<div v-if="expanded[skill.name]" class="flex flex-col gap-1.5 px-3 pb-3">
<div
v-for="command in skill.commands"
:key="command.name"
class="flex items-start gap-3"
>
<code class="shrink-0 rounded-[3px] bg-[#0f0f0f] px-1.5 py-0.5 text-[11px] text-white">/{{ command.name }}</code>
<span class="text-[11px] leading-[16px] text-[#a8a8a8]">{{ command.description }}</span>
</div>
</div>
</div>
</div>
</Card>
<div v-if="state.error"
class="rounded-[4px] border border-[#ff4444]/30 bg-[#ff4444]/5 px-3 py-2 text-sm text-[#ff4444]">
{{ state.error }}
</div>
<div v-if="!state.loading && state.projects.length === 0"
class="flex min-h-[160px] items-center justify-center rounded-[4px] border border-dashed border-[#2a2a2a] bg-[#141414] px-4 text-sm text-[#a8a8a8]">
未找到打开过的 Cursor 项目
</div>
<Card v-if="state.projects.length > 0">
<div class="overflow-x-auto">
<table class="w-full min-w-[560px] border-collapse">
<thead>
<tr class="border-b border-[#2a2a2a]">
<th class="px-2 py-2 text-left text-[11px] font-medium text-[#666]">项目</th>
<th
v-for="skill in state.skills"
:key="skill.name"
class="whitespace-nowrap px-2 py-2 text-center text-[11px] font-medium text-[#666]"
>
<span class="inline-flex items-center gap-1">
{{ skill.name }}
<Tooltip :content="skill.description || '内置 Skill'" />
</span>
</th>
<th class="w-[36px] px-2 py-2"></th>
</tr>
</thead>
<tbody>
<tr
v-for="project in state.projects"
:key="project.path"
class="group border-b border-[#1e1e1e] last:border-b-0 hover:bg-[#1a1a1a]"
>
<td class="max-w-[320px] px-2 py-2">
<div class="truncate text-[12px] text-white" :title="project.path">
{{ project.path }}
</div>
<div class="text-[10px] text-[#666]">{{ formatTime(project.lastOpenedMs) }}</div>
</td>
<td
v-for="skill in state.skills"
:key="skill.name"
class="px-2 py-2 text-center"
>
<div class="inline-flex">
<Switch
enabled-text="已导入"
disabled-text="未导入"
:enabled="Boolean(state.switches[switchKey(project.path, skill.name)])"
:busy="Boolean(state.busy[switchKey(project.path, skill.name)])"
@change="(value) => handleToggle(project.path, skill.name, value)"
/>
</div>
</td>
<td class="px-2 py-2 text-right">
<button
type="button"
class="inline-flex h-[24px] w-[24px] items-center justify-center rounded-[4px] text-[#666] opacity-0 transition-opacity hover:bg-[#ff4444]/10 hover:text-[#ff4444] focus:opacity-100 group-hover:opacity-100"
:disabled="Boolean(state.busy[project.path])"
title="删除此项目记录"
@click="handleDeleteProject(project)"
>
<span
class="text-[15px]"
:class="state.busy[project.path] ? 'icon-[mdi--loading] animate-spin' : 'icon-[mdi--delete-outline]'"
/>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</Card>
</div>
</template>

View File

@@ -0,0 +1,77 @@
const { addDynamicIconSelectors } = require("@iconify/tailwind");
const iconSafelist = [
"icon-[ant-design--bilibili-outlined]",
"icon-[bxl--openai]",
"icon-[cil--badge]",
"icon-[dashicons--yes]",
"icon-[ic--round-close]",
"icon-[ic--round-minus]",
"icon-[logos--claude-icon]",
"icon-[mdi--api]",
"icon-[mdi--brain]",
"icon-[mdi--check]",
"icon-[mdi--chevron-down]",
"icon-[mdi--content-copy]",
"icon-[mdi--eye-off-outline]",
"icon-[mdi--eye-outline]",
"icon-[mdi--file-document-outline]",
"icon-[mdi--head-cog-outline]",
"icon-[mdi--head-lightbulb-outline]",
"icon-[mdi--head-outline]",
"icon-[mdi--information-outline]",
"icon-[mdi--message-text-outline]",
"icon-[mdi--pause]",
"icon-[mdi--play]",
"icon-[mdi--refresh]",
"icon-[mdi--wifi]",
"icon-[mingcute--loading-fill]",
];
module.exports = {
content: ["./index.html", "./src/**/*.{vue,js,jsx,ts,tsx}"],
safelist: [...iconSafelist, "z-999", "z-9999", "z-99999"],
theme: {
extend: {
colors: {
primary: {
50: "#f0f7ff",
100: "#e6f4ff",
200: "#bae0ff",
300: "#91caff",
400: "#69b1ff",
500: "#4096ff",
600: "#1677ff",
700: "#0958d9",
800: "#003eb3",
900: "#002c8c",
950: "#001d66",
DEFAULT: "#1677ff",
},
},
fontFamily: {
num: [
"HFKos",
"PingFang-Medium",
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"\"Segoe UI\"",
"Roboto",
"sans-serif",
],
},
fontSize: {
xs: ["12px", { lineHeight: "16px" }],
sm: ["13px", { lineHeight: "18px" }],
lg: ["20px", { lineHeight: "28px" }],
},
zIndex: {
999: "999",
9999: "9999",
99999: "99999",
},
},
},
plugins: [addDynamicIconSelectors()],
};

37
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,37 @@
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
import wails from "@wailsio/runtime/plugins/vite";
import { codeInspectorPlugin } from "code-inspector-plugin";
import path from "path";
import { defineConfig } from "vite";
import topLevelAwait from "vite-plugin-top-level-await";
import { staticI18nPlugin } from "./plugins/static-i18n-plugin.js";
const isDev = process.env.NODE_ENV === "development";
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@bindings": path.resolve(__dirname, "./bindings"),
},
},
build: {
target: ["es2019", "safari13"],
cssTarget: "safari13",
},
plugins: [
isDev &&
codeInspectorPlugin({
bundler: "vite",
editor: "code",
hotKeys: ["ctrlKey"],
}),
wails("./bindings"),
topLevelAwait(),
staticI18nPlugin(),
vue(),
vueJsx(),
].filter(Boolean),
});

1758
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff