OpenModelConfigWindow());
}
-export function openModelEditor(index, adapterJSON) {
- return withApiLogging("OpenModelEditorWindow", { index, adapterJSON }, () =>
- OpenModelEditorWindow(index, adapterJSON),
- );
-}
-
-export function getModelEditorContext() {
- return withApiLogging("GetModelEditorContext", undefined, () => GetModelEditorContext());
-}
-
export function testModelAdapter(adapter) {
return Call.ByName(`${PROXY_SERVICE_NAME}.TestModelAdapter`, adapter).then(
(result) => {
diff --git a/frontend/src/state/appState.js b/frontend/src/state/appState.js
index b2dc19f..cb73ebe 100644
--- a/frontend/src/state/appState.js
+++ b/frontend/src/state/appState.js
@@ -12,7 +12,6 @@ import {
loadUserConfig,
openLogsDirectory,
openModelConfig,
- openModelEditor,
saveUserConfig,
startProxyService,
stopProxyService,
@@ -248,6 +247,7 @@ function normalizeModelAdapterTestResults(source) {
export function createEmptyModelAdapter() {
return {
id: "",
+ sort: 0,
displayName: "",
type: "openai",
baseURL: "",
@@ -355,6 +355,7 @@ export function normalizeModelAdapter(source) {
: "";
return {
id: asString(raw.id),
+ sort: asPositiveInteger(raw.sort),
displayName: asString(raw.displayName || raw.name),
type: SUPPORTED_MODEL_ADAPTER_TYPES.has(normalizedType) ? normalizedType : "",
baseURL: normalizeBaseURL(raw.baseURL || raw.url),
@@ -392,7 +393,29 @@ export function normalizeModelAdapter(source) {
}
export function normalizeModelAdapters(source) {
- return asArray(source).map((item) => normalizeModelAdapter(item));
+ return asArray(source)
+ .map((item, sourceIndex) => ({
+ adapter: normalizeModelAdapter(item),
+ sourceIndex,
+ }))
+ .sort((left, right) => {
+ const leftSort = left.adapter.sort;
+ const rightSort = right.adapter.sort;
+ if (leftSort <= 0 && rightSort <= 0) {
+ return left.sourceIndex - right.sourceIndex;
+ }
+ if (leftSort <= 0) {
+ return 1;
+ }
+ if (rightSort <= 0) {
+ return -1;
+ }
+ return leftSort - rightSort || left.sourceIndex - right.sourceIndex;
+ })
+ .map(({ adapter }, index) => ({
+ ...adapter,
+ sort: index + 1,
+ }));
}
export function validateModelAdapters(source) {
@@ -400,9 +423,6 @@ export function validateModelAdapters(source) {
const seenIdentityKeys = new Set();
for (const [index, adapter] of adapters.entries()) {
const prefix = `模型 ${index + 1}`;
- if (!adapter.displayName) {
- return `${prefix} 的显示名称不能为空`;
- }
if (!SUPPORTED_MODEL_ADAPTER_TYPES.has(adapter.type)) {
return `${prefix} 的类型仅支持 OpenAI 或 Anthropic`;
}
@@ -412,15 +432,27 @@ export function validateModelAdapters(source) {
if (!adapter.apiKey) {
return `${prefix} 的访问密钥不能为空`;
}
- if (!adapter.tooltipData) {
- return `${prefix} 的悬停提示不能为空`;
+ if (!adapter.displayName) {
+ return `${prefix} 的显示名称不能为空`;
}
if (!adapter.modelID) {
return `${prefix} 的模型标识不能为空`;
}
+ if (adapter.contextWindowTokens && (!Number.isInteger(adapter.contextWindowTokens) || adapter.contextWindowTokens <= 0)) {
+ return `${prefix} 的上下文窗口必须为正整数`;
+ }
if (adapter.type === "openai" && !SUPPORTED_REASONING_EFFORTS.has(adapter.reasoningEffort)) {
return `${prefix} 的推理强度仅支持 low、medium、high、xhigh、max`;
}
+ if (adapter.type === "anthropic" && adapter.anthropicMaxTokens && (!Number.isInteger(adapter.anthropicMaxTokens) || adapter.anthropicMaxTokens <= 0)) {
+ return `${prefix} 的最大输出 Token 必须为正整数`;
+ }
+ if (adapter.type === "anthropic" && !SUPPORTED_ANTHROPIC_THINKING_EFFORTS.has(adapter.anthropicThinkingEffort)) {
+ return `${prefix} 的 Anthropic 思考强度仅支持 low、medium、high、xhigh、max`;
+ }
+ if (adapter.type === "openai" && adapter.maxCompletionTokens && (!Number.isInteger(adapter.maxCompletionTokens) || adapter.maxCompletionTokens <= 0)) {
+ return `${prefix} 的最大输出 Token 必须为正整数`;
+ }
if (adapter.type === "openai" && !isValidOpenAIEndpoint(adapter.openAIEndpoint)) {
return `${prefix} 的 OpenAI 端点仅支持 /v1/responses、/v1/chat/completions 或以 / 开头的自定义路径`;
}
@@ -430,29 +462,20 @@ export function validateModelAdapters(source) {
return `${prefix} 的 ${extraParamsError}`;
}
}
- if (adapter.customHeadersEnabled) {
- const customHeadersError = validateHeadersJSON(adapter.customHeadersJSON);
- if (customHeadersError) {
- return `${prefix} 的 ${customHeadersError}`;
- }
- }
if (adapter.type === "anthropic" && adapter.anthropicExtraParamsEnabled) {
const extraParamsError = validateAnthropicExtraParamsJSON(adapter.anthropicExtraParamsJSON);
if (extraParamsError) {
return `${prefix} 的 ${extraParamsError}`;
}
}
- if (adapter.type === "anthropic" && !SUPPORTED_ANTHROPIC_THINKING_EFFORTS.has(adapter.anthropicThinkingEffort)) {
- return `${prefix} 的 Anthropic 思考强度仅支持 low、medium、high、xhigh、max`;
+ if (adapter.customHeadersEnabled) {
+ const customHeadersError = validateHeadersJSON(adapter.customHeadersJSON);
+ if (customHeadersError) {
+ return `${prefix} 的 ${customHeadersError}`;
+ }
}
- if (adapter.contextWindowTokens && (!Number.isInteger(adapter.contextWindowTokens) || adapter.contextWindowTokens <= 0)) {
- return `${prefix} 的上下文窗口必须为正整数`;
- }
- if (adapter.maxCompletionTokens && (!Number.isInteger(adapter.maxCompletionTokens) || adapter.maxCompletionTokens <= 0)) {
- return `${prefix} 的最大输出 Token 必须为正整数`;
- }
- if (adapter.anthropicMaxTokens && (!Number.isInteger(adapter.anthropicMaxTokens) || adapter.anthropicMaxTokens <= 0)) {
- return `${prefix} 的最大输出 Token 必须为正整数`;
+ if (!adapter.tooltipData) {
+ return `${prefix} 的悬停提示不能为空`;
}
if (adapter.thinkingBudgetTokens && (!Number.isInteger(adapter.thinkingBudgetTokens) || adapter.thinkingBudgetTokens <= 0)) {
return `${prefix} 的思考预算 Token 必须为正整数`;
@@ -1151,93 +1174,6 @@ export async function fetchAvailableModelIDs(payload) {
.filter(Boolean);
}
-function buildPrefixedModelDisplayName(prefix, modelID) {
- const normalizedPrefix = asString(prefix) || "模型";
- return `${normalizedPrefix}-${asString(modelID)}`;
-}
-
-export function buildModelAdaptersFromModelIDs(source, modelIDs, prefix) {
- const base = normalizeModelAdapter(source);
- const seen = new Set();
- return asArray(modelIDs)
- .map((item) => asString(item))
- .filter((modelID) => {
- if (!modelID || seen.has(modelID)) {
- return false;
- }
- seen.add(modelID);
- return true;
- })
- .map((modelID) => normalizeModelAdapter({
- ...base,
- id: "",
- modelID,
- displayName: buildPrefixedModelDisplayName(prefix, modelID),
- tooltipData: base.tooltipData || "备注",
- }));
-}
-
-function findModelAdapterUpsertIndex(adapters, target) {
- return adapters.findIndex((adapter) => {
- const current = normalizeModelAdapter(adapter);
- return current.type === target.type
- && normalizeBaseURL(current.baseURL) === normalizeBaseURL(target.baseURL)
- && current.apiKey === target.apiKey
- && current.modelID === target.modelID
- && current.displayName === target.displayName
- && (current.type !== "openai" || current.openAIEndpoint === target.openAIEndpoint);
- });
-}
-
-export async function saveModelAdaptersFromModelIDs(source, modelIDs, prefix, selectedModelID = "") {
- const generatedAdapters = buildModelAdaptersFromModelIDs(source, modelIDs, prefix);
- if (generatedAdapters.length === 0) {
- return { ok: false, error: "没有可保存的模型" };
- }
-
- const generatedError = validateModelAdapters(generatedAdapters);
- if (generatedError) {
- return { ok: false, error: generatedError };
- }
-
- const currentConfig = await loadPersistedUserConfig();
- const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters);
- const targetModelID = asString(selectedModelID) || generatedAdapters[0]?.modelID || "";
- let selectedIndex = -1;
-
- for (const adapter of generatedAdapters) {
- const index = findModelAdapterUpsertIndex(nextAdapters, adapter);
- if (index >= 0) {
- nextAdapters.splice(index, 1, adapter);
- if (selectedIndex < 0 && adapter.modelID === targetModelID) {
- selectedIndex = index;
- }
- continue;
- }
- nextAdapters.push(adapter);
- if (selectedIndex < 0 && adapter.modelID === targetModelID) {
- selectedIndex = nextAdapters.length - 1;
- }
- }
-
- const result = await persistConfigPayload(
- {
- ...currentConfig,
- modelAdapters: nextAdapters,
- },
- { modelAdaptersOnly: true },
- );
- if (!result.ok) {
- return result;
- }
- return {
- ...result,
- index: selectedIndex,
- adapter: selectedIndex >= 0 ? appState.modelAdapters[selectedIndex] ?? null : null,
- count: generatedAdapters.length,
- };
-}
-
export async function deleteModelAdapterAt(index) {
const currentConfig = await loadPersistedUserConfig();
const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters);
@@ -1260,6 +1196,39 @@ export async function deleteModelAdapterAt(index) {
);
}
+export async function saveModelAdapterOrder(adapterIDs) {
+ const orderedIDs = asArray(adapterIDs)
+ .map((item) => asString(item))
+ .filter(Boolean);
+ const currentConfig = await loadPersistedUserConfig();
+ const currentAdapters = normalizeModelAdapters(currentConfig.modelAdapters);
+ const adaptersByID = new Map(currentAdapters.map((adapter) => [adapter.id, adapter]));
+ const uniqueIDs = new Set(orderedIDs);
+
+ if (
+ orderedIDs.length !== currentAdapters.length
+ || uniqueIDs.size !== currentAdapters.length
+ || orderedIDs.some((id) => !adaptersByID.has(id))
+ ) {
+ return {
+ ok: false,
+ error: "模型配置已发生变化,请刷新后重试",
+ };
+ }
+
+ const nextAdapters = orderedIDs.map((id, index) => ({
+ ...adaptersByID.get(id),
+ sort: index + 1,
+ }));
+ return persistConfigPayload(
+ {
+ ...currentConfig,
+ modelAdapters: nextAdapters,
+ },
+ { modelAdaptersOnly: true },
+ );
+}
+
function splitDisplayNameSeed(value) {
const text = asString(value);
const match = text.match(/^(.*?)(?:\s*[-+](\d+))?$/);
@@ -1403,11 +1372,6 @@ export async function openModelConfigWindow() {
await openModelConfig();
}
-export async function openModelEditorWindow(index, adapter) {
- const adapterJSON = JSON.stringify(normalizeModelAdapter(adapter));
- await openModelEditor(index, adapterJSON);
-}
-
export async function checkForAppUpdates() {
await checkForUpdates();
}
diff --git a/frontend/src/style/global.css b/frontend/src/style/global.css
index 4f2e9d0..378103f 100644
--- a/frontend/src/style/global.css
+++ b/frontend/src/style/global.css
@@ -13,8 +13,15 @@ body,
width: 100vw;
overflow: hidden;
background: #191919;
- font-family: "PingFang-Medium", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
- color: #F7F7F7;
+ font-family:
+ "PingFang-Medium",
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ Roboto,
+ sans-serif;
+ color: #f7f7f7;
font-size: 13px;
}
@@ -28,10 +35,41 @@ body,
scrollbar-width: thin;
scrollbar-color: rgba(100, 100, 100, 0.8) transparent;
}
-:root{
+:root {
color-scheme: dark;
+ --x-maskImage: linear-gradient(
+ to bottom,
+ rgba(0, 0, 0, 0) 0px,
+ black 18px,
+ black calc(100% - 0px),
+ rgba(0, 0, 0, 0) 100%
+ );
+ --x-WebkitMaskImage: linear-gradient(
+ to bottom,
+ rgba(0, 0, 0, 0) 0px,
+ black 18px,
+ black calc(100% - 0px),
+ rgba(0, 0, 0, 0) 100%
+ );
+}
+.scroll-shadow {
+ -webkit-mask-image: var(--x-maskImage);
+ mask-image: var(--x-maskImage);
+}
+.scroll-shadow-bottom {
+ -webkit-mask-image: linear-gradient(
+ to bottom,
+ black 0,
+ black calc(100% - 16px),
+ transparent 100%
+ );
+ mask-image: linear-gradient(
+ to bottom,
+ black 0,
+ black calc(100% - 16px),
+ transparent 100%
+ );
}
-
/* WebKit/Blink (Chrome, Safari, Edge, Opera) */
::-webkit-scrollbar {
width: 6px;
diff --git a/frontend/src/views/Config.vue b/frontend/src/views/Config.vue
index 2ef635f..3e9db26 100644
--- a/frontend/src/views/Config.vue
+++ b/frontend/src/views/Config.vue
@@ -2,7 +2,7 @@
import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import LocaleSelect from "@/components/LocaleSelect.vue";
-import { showModal } from "@/composables/useModal";
+import { useMessage } from "@/composables/useMessage";
import {
appState,
openModelConfigWindow,
@@ -12,30 +12,27 @@ import {
} from "@/state/appState";
import { onMounted } from "vue";
-async function showActionError(title, error) {
- await showModal({
- title,
- content: String(error || "服务错误").trim() || "服务错误",
- });
+const message = useMessage();
+
+function showActionError(title, error) {
+ const detail = String(error || "服务错误").trim() || "服务错误";
+ message(`${title}:${detail}`);
}
async function handleSaveConfig() {
const result = await persistUserConfig();
if (!result.ok) {
- await showActionError("保存失败", result.error);
+ showActionError("保存失败", result.error);
return;
}
- await showModal({
- title: "提示",
- content: "本地配置已保存",
- });
+ message("本地配置已保存");
}
async function handleOpenModelConfig() {
try {
await openModelConfigWindow();
} catch (error) {
- await showActionError("打开失败", toUserError(error));
+ showActionError("打开失败", toUserError(error));
}
}
diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue
index 733b390..90332ff 100644
--- a/frontend/src/views/Home.vue
+++ b/frontend/src/views/Home.vue
@@ -3,7 +3,7 @@ import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import HomeMetricsCard from "@/components/HomeMetricsCard.vue";
import CursorAccountCard from "@/components/CursorAccountCard.vue";
-import { showModal } from "@/composables/useModal";
+import { useMessage } from "@/composables/useMessage";
import { getAdRuntime } from "@/services/clientApi";
import {
appState,
@@ -20,6 +20,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
const AD_UPDATED_EVENT = "ad:updated";
const OPEN_AD_EVENT = "cursor:open-ad";
+const message = useMessage();
const adRuntime = ref(null);
let unsubscribeAdUpdated = null;
@@ -79,17 +80,15 @@ function handleOpenHomeAd(slotId) {
window.dispatchEvent(new CustomEvent(OPEN_AD_EVENT, { detail: { slotId: asString(slotId) } }));
}
-async function showActionError(title, error) {
- await showModal({
- title,
- content: String(error || "服务错误").trim() || "服务错误",
- });
+function showActionError(title, error) {
+ const detail = String(error || "服务错误").trim() || "服务错误";
+ message(`${title}:${detail}`);
}
async function handleToggleService() {
const result = await toggleService();
if (!result.ok) {
- await showActionError("服务操作失败", result.error);
+ showActionError("服务操作失败", result.error);
}
}
@@ -99,19 +98,24 @@ async function handleRefreshState() {
syncHomeMetrics(),
]);
if (serviceStateResult.status === "rejected") {
- await showActionError("刷新失败", toUserError(serviceStateResult.reason));
+ showActionError("刷新失败", toUserError(serviceStateResult.reason));
}
}
async function handleRefreshMetrics() {
- await syncHomeMetrics().catch(() => {});
+ const result = await syncHomeMetrics();
+ if (result.ok) {
+ message("刷新成功");
+ return;
+ }
+ showActionError("刷新失败", result.error);
}
async function handleOpenConfig() {
try {
await openConfigWindow();
} catch (error) {
- await showActionError("打开失败", toUserError(error));
+ showActionError("打开失败", toUserError(error));
}
}
@@ -119,7 +123,7 @@ async function handleOpenModelConfig() {
try {
await openModelConfigWindow();
} catch (error) {
- await showActionError("打开失败", toUserError(error));
+ showActionError("打开失败", toUserError(error));
}
}
@@ -136,7 +140,7 @@ onBeforeUnmount(() => {
-
+
{
-
+
{{ appViewState.serviceStatusText }}
@@ -172,7 +176,7 @@ onBeforeUnmount(() => {
-
+
本地配置
diff --git a/frontend/src/views/ModelConfig.vue b/frontend/src/views/ModelConfig.vue
index 15f6e65..9e83b2b 100644
--- a/frontend/src/views/ModelConfig.vue
+++ b/frontend/src/views/ModelConfig.vue
@@ -1,39 +1,56 @@
-
+
-
+
-
+
- 当前还没有配置任何 {{ typeLabel(activeType) }} 模型。
+ class="flex h-full min-h-[220px] items-center justify-center rounded-[8px] px-4 text-sm text-[#a3a3a3]">
+ {{ emptyStateText }}
-