Merge pull request #271 from leookun/feat/shangtang

refactor: checkpoint ui
This commit is contained in:
leokun
2026-08-07 02:21:54 +08:00
committed by GitHub
44 changed files with 1928 additions and 1624 deletions
+1
View File
@@ -17,6 +17,7 @@
"copy-text-to-clipboard": "^3.2.2",
"dayjs": "^1.11.20",
"resize-observer-polyfill": "^1.5.1",
"sortablejs": "^1.15.7",
"vue": "^3.5.22",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.6.3"
@@ -196,6 +196,7 @@ onBeforeUnmount(() => {
<div
v-show="visible"
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4"
@click.self="closeAd"
>
<Transition name="ad-frame">
<iframe
@@ -2,6 +2,7 @@
import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { useMessage } from "@/composables/useMessage";
import { showModal } from "@/composables/useModal";
import {
disconnectCursorAccount,
@@ -13,6 +14,7 @@ import { Browser } from "@wailsio/runtime";
import { computed, onMounted, onUnmounted, ref } from "vue";
const CURSOR_ACCOUNT_CONTRIBUTOR_URL = "https://github.com/aike0210";
const message = useMessage();
const cursorAccountStatus = ref({
state: "signed_out",
@@ -35,18 +37,16 @@ const cursorAccountStateText = computed(() => {
return "未连接";
});
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 handleOpenContributor() {
try {
await Browser.OpenURL(CURSOR_ACCOUNT_CONTRIBUTOR_URL);
} catch (error) {
await showActionError("打开贡献者主页失败", toUserError(error));
showActionError("打开贡献者主页失败", toUserError(error));
}
}
@@ -59,7 +59,7 @@ async function handleCursorAccountLogin() {
try {
cursorAccountStatus.value = await startCursorAccountLogin();
} catch (error) {
await showActionError("登录失败", toUserError(error));
showActionError("登录失败", toUserError(error));
await refreshCursorAccountStatus().catch(() => {});
} finally {
cursorAccountBusy.value = false;
@@ -80,7 +80,7 @@ async function handleCursorAccountDisconnect() {
try {
cursorAccountStatus.value = await disconnectCursorAccount();
} catch (error) {
await showActionError("退出登录失败", toUserError(error));
showActionError("退出登录失败", toUserError(error));
} finally {
cursorAccountBusy.value = false;
}
@@ -51,7 +51,6 @@ const props = defineProps({
type: Object,
default: () => createEmptyModelAdapter(),
},
errorMessage: { type: String, default: "" },
});
const emit = defineEmits(["cancel", "save"]);
@@ -286,13 +285,6 @@ function handleSave() {
/>
</label>
<div
v-if="errorMessage"
class="mt-4 rounded-[8px] border border-[#4b1d1d] bg-[#2a1313] px-3 py-2 text-sm text-[#fca5a5]"
>
{{ errorMessage }}
</div>
<div class="mt-5 flex justify-end gap-2">
<Button variant="default" @click="handleCancel">取消</Button>
<Button variant="primary" @click="handleSave">保存</Button>
@@ -44,7 +44,7 @@ const summaryText = computed(() => {
return "测试中...";
}
if (normalizedStatus.value === "error") {
return "测试失败";
return "测试失败,请查看原始信息";
}
return props.emptyText;
});
@@ -100,16 +100,16 @@ const summaryClass = computed(() => {
</script>
<template>
<div class="rounded-[8px] border px-3 py-3" :class="panelClass">
<div class="rounded-[8px] border-none 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'"
:class="compact ? 'text-[11px] uppercase tracking-[0.08em] text-white/80' : 'text-[11px] uppercase tracking-[0.08em] text-white/80'"
>
{{ title }}
</div>
<div v-if="rawResponseText" class="center-row gap-1 text-[11px] text-[#8f8f8f]">
<div v-if="rawResponseText" class="center-row gap-1 text-[11px] text-white/80">
<span>原始返回</span>
<Tooltip :content="rawResponseText" copyable />
</div>
@@ -118,12 +118,7 @@ const summaryClass = computed(() => {
{{ summaryText }}
</div>
</div>
<span
v-if="stale"
class="shrink-0 rounded-[999px] border border-[#8a6d1a] px-2 py-1 text-xs text-[#f6d77a]"
>
需重测
</span>
</div>
<div v-if="stale" class="mt-2 text-xs text-[#f6d77a]">
@@ -134,19 +129,19 @@ const summaryClass = computed(() => {
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="rounded-[8px] bg-black/20 px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">总耗时</div>
<div class="mt-1 text-sm text-[#d4d4d4]">{{ formatDuration(result?.totalDurationMS) }}</div>
<div class="mt-1 text-sm text-white/80">{{ formatDuration(result?.totalDurationMS) }}</div>
</div>
<div class="rounded-[8px] bg-[#1c1c1c] px-3 py-2">
<div class="rounded-[8px] bg-black/20 px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">输出 Token</div>
<div class="mt-1 text-sm text-[#d4d4d4]">{{ result?.outputTokens ?? 0 }}</div>
<div class="mt-1 text-sm text-white/80">{{ result?.outputTokens ?? 0 }}</div>
</div>
</div>
<div
v-if="normalizedStatus === 'success' && result?.tokensEstimated"
class="mt-2 text-xs text-[#8f8f8f]"
class="mt-2 text-xs text-white/80"
>
输出 Token 为估算值
</div>
@@ -1,16 +1,15 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Combobox from "@/components/ui/Combobox.vue";
import Input from "@/components/ui/Input.vue";
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue";
import MultiSelect from "@/components/ui/MultiSelect.vue";
import Select from "@/components/ui/Select.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { getModelEditorContext } from "@/services/clientApi";
import { useMessage } from "@/composables/useMessage";
import {
ANTHROPIC_THINKING_EFFORT_DEFAULT,
appState,
buildModelAdapterTestRequestHash,
buildModelAdaptersFromModelIDs,
createEmptyModelAdapter,
CUSTOM_HEADERS_DEFAULT_JSON,
EXTRA_PARAMS_DEFAULT_JSON,
@@ -25,12 +24,10 @@ import {
OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
runModelAdapterTest,
saveModelAdapterAt,
saveModelAdaptersFromModelIDs,
toUserError,
validateModelAdapters,
} from "@/state/appState";
import { Window } from "@wailsio/runtime";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
const modelTypeTabs = [
{ label: "OpenAI", value: "openai", icon: "icon-[bxl--openai]" },
@@ -59,17 +56,22 @@ const openAIEndpointOptions = [
{ label: "自定义路径(请输入完整请求地址)", value: OPENAI_ENDPOINT_CUSTOM, icon: "icon-[mdi--pencil-outline]" },
];
const editorIndex = ref(-1);
const draft = reactive(createEmptyModelAdapter());
const errorMessage = ref("");
const loading = ref(true);
const props = defineProps({
index: { type: Number, default: -1 },
adapter: { type: Object, default: () => createEmptyModelAdapter() },
});
const emit = defineEmits(["close", "saved"]);
const message = useMessage();
const editorIndex = ref(props.index);
const draft = reactive(normalizeModelAdapter(props.adapter));
if (!draft.type) {
draft.type = "openai";
}
const lastTestAdapterID = ref("");
const localTestFailure = ref("");
const modelPrefix = ref("");
const existingModelPrefixCleared = ref(false);
const availableModelIDs = ref([]);
const selectedModelIDs = ref([]);
const modelSelectionMode = ref("auto");
const availableModelIDs = ref(draft.modelID ? [draft.modelID] : []);
const modelListLoading = ref(false);
const modelListRequestSeq = ref(0);
let modelListDebounceTimer = 0;
@@ -97,31 +99,10 @@ const modelOptions = computed(() => availableModelIDs.value.map((modelID) => ({
value: modelID,
icon: "icon-[mdi--cube-outline]",
})));
const isManualModelInput = computed(() => modelSelectionMode.value === "manual");
const activeModelIDs = computed(() => (
isManualModelInput.value
? [String(draft.modelID || "").trim()].filter(Boolean)
: selectedModelIDs.value
const canFetchModels = computed(() => Boolean(
draft.type && String(draft.baseURL || "").trim() && String(draft.apiKey || "").trim(),
));
const primaryModelID = computed(() => (
isManualModelInput.value
? String(draft.modelID || "").trim()
: selectedModelIDs.value.includes(draft.modelID) ? draft.modelID : selectedModelIDs.value[0] || ""
));
const selectedTestAdapter = computed(() => {
if (isManualModelInput.value) {
const modelID = primaryModelID.value;
return normalizeModelAdapter({
...draft,
modelID,
displayName: modelID,
});
}
const adapters = buildModelAdaptersFromModelIDs(draft, selectedModelIDs.value, modelPrefix.value);
return adapters.find((adapter) => adapter.modelID === primaryModelID.value)
?? adapters[0]
?? normalizeModelAdapter(draft);
});
const selectedTestAdapter = computed(() => normalizeModelAdapter(draft));
const currentRequestHash = computed(() => buildModelAdapterTestRequestHash(selectedTestAdapter.value));
const directModelTestResult = computed(() => getModelAdapterTestResult(selectedTestAdapter.value));
const rememberedModelTestResult = computed(() =>
@@ -139,8 +120,6 @@ const modelTestSummary = computed(() => {
return activeModelTestResult.value?.summaryText || "尚未测试";
});
const title = computed(() => (editorIndex.value >= 0 ? "编辑模型配置" : "新增模型配置"));
function ensureOpenAIExtraParamsJSON() {
if (!String(draft.openAIExtraParamsJSON || "").trim()) {
draft.openAIExtraParamsJSON = OPENAI_EXTRA_PARAMS_DEFAULT_JSON;
@@ -167,8 +146,7 @@ function ensureAnthropicThinkingEffort() {
const fieldTips = {
displayName: "仅用于界面展示,便于你区分不同模型。",
modelID: "可多选。保存时只会写入选中的模型,每个模型生成一条配置。",
manualModelID: "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
modelID: "可以直接输入模型标识,或从服务端返回的列表中选择。",
baseURL: "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
apiKey: "调用该模型服务需要使用的访问密钥。",
contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
@@ -183,54 +161,20 @@ const fieldTips = {
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.modelID) {
availableModelIDs.value = [draft.modelID];
selectedModelIDs.value = [draft.modelID];
}
modelPrefix.value = draft.displayName || "";
if (!draft.type) {
draft.type = "openai";
}
} catch (_error) {
Object.assign(draft, createEmptyModelAdapter());
draft.type = "openai";
} finally {
loading.value = false;
}
}
function syncSelectionWithAvailable() {
const available = availableModelIDs.value;
const kept = selectedModelIDs.value.filter((modelID) => available.includes(modelID));
selectedModelIDs.value = kept;
draft.modelID = kept.includes(draft.modelID) ? draft.modelID : kept[0] || "";
}
function handleModelSelectionChange(values) {
selectedModelIDs.value = values;
draft.modelID = values.includes(draft.modelID) ? draft.modelID : values[0] || "";
}
async function refreshModelList() {
const baseURL = String(draft.baseURL || "").trim();
const apiKey = String(draft.apiKey || "").trim();
if (!baseURL || !apiKey || !draft.type) {
modelSelectionMode.value = "auto";
availableModelIDs.value = draft.modelID ? [draft.modelID] : [];
syncSelectionWithAvailable();
modelListRequestSeq.value += 1;
availableModelIDs.value = [];
modelListLoading.value = false;
return [];
}
const requestSeq = modelListRequestSeq.value + 1;
modelListRequestSeq.value = requestSeq;
modelSelectionMode.value = "auto";
modelListLoading.value = true;
availableModelIDs.value = [];
try {
const models = await fetchAvailableModelIDs({
type: draft.type,
@@ -242,19 +186,11 @@ async function refreshModelList() {
if (requestSeq !== modelListRequestSeq.value) {
return availableModelIDs.value;
}
if (editorIndex.value >= 0 && !existingModelPrefixCleared.value) {
modelPrefix.value = "";
existingModelPrefixCleared.value = true;
}
modelSelectionMode.value = "auto";
availableModelIDs.value = models;
syncSelectionWithAvailable();
return models;
} catch (_error) {
if (requestSeq === modelListRequestSeq.value) {
modelSelectionMode.value = "manual";
availableModelIDs.value = [];
selectedModelIDs.value = [];
}
return availableModelIDs.value;
} finally {
@@ -265,33 +201,17 @@ async function refreshModelList() {
}
async function persistDraft() {
const models = activeModelIDs.value;
if (models.length === 0) {
const error = isManualModelInput.value ? "请填写模型标识" : "请先选择要保存的模型";
errorMessage.value = error;
return { ok: false, error, adapter: null };
}
const selectedModelID = primaryModelID.value || models[0];
const adapter = normalizeModelAdapter({
...draft,
modelID: selectedModelID,
displayName: isManualModelInput.value
? String(modelPrefix.value || selectedModelID).trim()
: `${String(modelPrefix.value || "模型").trim()}-${selectedModelID}`,
});
const adapter = normalizeModelAdapter(draft);
const singleCheck = validateModelAdapters([adapter]);
if (singleCheck) {
errorMessage.value = singleCheck;
message(singleCheck);
return { ok: false, error: singleCheck, adapter: null };
}
const result = isManualModelInput.value
? await saveModelAdapterAt(editorIndex.value, adapter)
: await saveModelAdaptersFromModelIDs(adapter, models, modelPrefix.value, selectedModelID);
const result = await saveModelAdapterAt(editorIndex.value, adapter);
if (!result.ok) {
errorMessage.value = result.error;
message(result.error);
return { ok: false, error: result.error, adapter: null };
}
@@ -303,7 +223,6 @@ async function persistDraft() {
} else {
Object.assign(draft, adapter);
}
errorMessage.value = "";
return {
ok: true,
error: "",
@@ -316,18 +235,19 @@ async function handleSave() {
if (!result.ok) {
return;
}
await Window.Close();
emit("saved", result.adapter);
emit("close");
}
async function handleCancel() {
await Window.Close();
function handleCancel() {
emit("close");
}
function handleModelTypeChange(type) {
draft.type = type;
modelSelectionMode.value = "auto";
modelListRequestSeq.value += 1;
modelListLoading.value = false;
availableModelIDs.value = [];
selectedModelIDs.value = [];
draft.modelID = "";
if (type === "openai" && !draft.openAIEndpoint) {
draft.openAIEndpoint = OPENAI_ENDPOINT_RESPONSES;
@@ -409,22 +329,18 @@ watch(
const baseURL = String(draft.baseURL || "").trim();
const apiKey = String(draft.apiKey || "").trim();
if (!baseURL || !apiKey) {
modelSelectionMode.value = "auto";
modelListRequestSeq.value += 1;
modelListLoading.value = false;
availableModelIDs.value = draft.modelID ? [draft.modelID] : [];
syncSelectionWithAvailable();
availableModelIDs.value = [];
return;
}
modelListDebounceTimer = window.setTimeout(() => {
void refreshModelList();
}, 600);
},
{ immediate: true },
);
onMounted(async () => {
await loadContext();
});
onBeforeUnmount(() => {
window.clearTimeout(modelListDebounceTimer);
});
@@ -432,24 +348,14 @@ onBeforeUnmount(() => {
<template>
<div class="flex h-full flex-col text-[#e5e5e5]">
<div class="flex shrink-0 items-center justify-between px-4 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-[#a3a3a3]">
加载中...
</div>
<div v-else class="flex-1 overflow-y-auto min-h-0 px-4 pb-4">
<div class="flex-shrink-0 p-4" v-if="localTestFailure || activeModelTestResult">
<ModelAdapterTestCard
:result="localTestFailure ? { status: 'error', error: '测试失败', summaryText: '测试失败', rawResponse: modelTestSummary } : activeModelTestResult"
:stale="modelTestResultStale"
:show-metrics="true"
/>
</div>
<div class="flex-1 min-h-0 overflow-y-auto px-4 py-4 scroll-shadow-bottom">
<div class="flex flex-col gap-4">
<div class="center-row gap-2">
<button
@@ -497,11 +403,11 @@ onBeforeUnmount(() => {
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="isManualModelInput ? fieldTips.displayName : fieldTips.modelID" />
<span>{{ isManualModelInput ? "显示名称" : "模型前缀" }}</span>
<Tooltip :content="fieldTips.displayName" />
<span>显示名称</span>
</span>
<input
v-model="modelPrefix"
v-model="draft.displayName"
type="text"
placeholder="例如:GPT-5"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
@@ -510,26 +416,28 @@ onBeforeUnmount(() => {
<div class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="isManualModelInput ? fieldTips.manualModelID : fieldTips.modelID" />
<span>{{ isManualModelInput ? "模型标识" : "选择模型" }}</span>
<Tooltip :content="fieldTips.modelID" />
<span>模型标识</span>
</span>
<input
v-if="isManualModelInput"
<Combobox
v-model="draft.modelID"
type="text"
placeholder="例如:gpt-4.1"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
<MultiSelect
v-else
:model-value="selectedModelIDs"
:options="modelOptions"
:disabled="modelListLoading || modelOptions.length === 0"
:placeholder="modelListLoading ? '正在获取模型...' : '请输入接口地址和访问密钥'"
:summary-formatter="(count, total) => `已选择 ${count} / ${total} 个模型`"
:loading="modelListLoading"
placeholder="例如:gpt-4.1"
empty-text="没有匹配的模型"
aria-label="选择模型"
@update:model-value="handleModelSelectionChange"
/>
>
<template #append>
<button
type="button"
class="center-row h-9 shrink-0 gap-1.5 whitespace-nowrap rounded-[6px] border border-[#3f3f3f] bg-[#292929] px-[8px] text-sm text-[#d4d4d4] outline-none transition-colors hover:border-[#505050] hover:bg-[#303030] hover:text-white focus-visible:border-[#10AD5D] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="modelListLoading || !canFetchModels"
@click="refreshModelList"
>
<span>获取模型</span>
</button>
</template>
</Combobox>
</div>
<label class="flex flex-col gap-1">
@@ -696,19 +604,16 @@ onBeforeUnmount(() => {
/>
</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-[#4b1d1d] bg-[#2a1313] px-3 py-2 text-sm text-[#fca5a5]"
>
{{ errorMessage }}
</div>
</div>
</div>
<div class="flex shrink-0 items-center justify-end gap-2 px-4 py-3">
<Button variant="default" :disabled="appState.configSaving" @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>
</template>
+3 -6
View File
@@ -1,12 +1,9 @@
<script setup></script>
<template>
<div
class="rounded-[8px] p-[1px]"
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
class="rounded-[8px] border border-[#343434] bg-[#292929] p-4 "
>
<div class="rounded-[7px] bg-[#292929] p-4">
<slot />
</div>
<slot />
</div>
</template>
+337
View File
@@ -0,0 +1,337 @@
<script setup>
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
import { computed, nextTick, onBeforeUnmount, ref, useId, watch, watchPostEffect } from "vue";
const props = defineProps({
modelValue: { type: String, default: "" },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: "" },
emptyText: { type: String, default: "没有匹配项" },
loading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
ariaLabel: { type: String, default: "" },
});
const emit = defineEmits(["update:modelValue", "change", "blur"]);
const rootRef = ref(null);
const inputRef = ref(null);
const menuRef = ref(null);
const optionRefs = ref([]);
const isOpen = ref(false);
const activeIndex = ref(-1);
const showAllOptions = ref(false);
const menuStyle = ref({});
const listboxID = useId();
const normalizedOptions = computed(() => props.options.map((option, optionIndex) => {
if (typeof option === "string") {
return { label: option, value: option, icon: "", optionIndex };
}
return {
label: option?.label ?? option?.value ?? "",
value: option?.value ?? "",
icon: option?.icon ?? option?.iconClass ?? "",
optionIndex,
};
}));
const filteredOptions = computed(() => {
const query = String(props.modelValue || "").trim().toLocaleLowerCase();
if (showAllOptions.value || !query) {
return normalizedOptions.value;
}
return normalizedOptions.value.filter((option) => (
String(option.label).toLocaleLowerCase().includes(query)
|| String(option.value).toLocaleLowerCase().includes(query)
));
});
const activeOption = computed(() => filteredOptions.value[activeIndex.value] ?? null);
const activeDescendant = computed(() => (
isOpen.value && activeOption.value ? `${listboxID}-option-${activeOption.value.optionIndex}` : undefined
));
function setOptionRef(el, index) {
if (el) {
optionRefs.value[index] = el;
return;
}
delete optionRefs.value[index];
}
function focusActiveOption() {
nextTick(() => optionRefs.value[activeOption.value?.optionIndex]?.scrollIntoView({ block: "nearest" }));
}
function openMenu({ showAll = false } = {}) {
if (props.disabled || normalizedOptions.value.length === 0) {
return;
}
showAllOptions.value = showAll;
isOpen.value = true;
const selectedIndex = filteredOptions.value.findIndex((option) => option.value === props.modelValue);
activeIndex.value = selectedIndex >= 0 ? selectedIndex : filteredOptions.value.length ? 0 : -1;
nextTick(updatePosition);
}
function closeMenu({ restoreFocus = false, notifyBlur = false } = {}) {
if (!isOpen.value) {
if (notifyBlur) {
emit("blur");
}
return;
}
isOpen.value = false;
activeIndex.value = -1;
if (restoreFocus) {
nextTick(() => inputRef.value?.focus());
}
if (notifyBlur) {
emit("blur");
}
}
function handleInput(event) {
emit("update:modelValue", event.target.value);
showAllOptions.value = false;
isOpen.value = normalizedOptions.value.length > 0;
activeIndex.value = filteredOptions.value.length ? 0 : -1;
}
function selectOption(option) {
if (!option) {
return;
}
emit("update:modelValue", option.value);
emit("change", option.value);
closeMenu();
}
function toggleMenu() {
if (isOpen.value) {
closeMenu({ restoreFocus: true });
return;
}
openMenu({ showAll: true });
nextTick(() => inputRef.value?.focus());
}
function moveActiveIndex(step) {
if (!isOpen.value) {
openMenu({ showAll: true });
return;
}
const total = filteredOptions.value.length;
if (!total) {
return;
}
const current = activeIndex.value >= 0 ? activeIndex.value : 0;
activeIndex.value = (current + step + total) % total;
focusActiveOption();
}
function handleInputKeydown(event) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveActiveIndex(1);
break;
case "ArrowUp":
event.preventDefault();
moveActiveIndex(-1);
break;
case "Enter":
if (isOpen.value && activeIndex.value >= 0) {
event.preventDefault();
selectOption(filteredOptions.value[activeIndex.value]);
}
break;
case "Escape":
if (isOpen.value) {
event.preventDefault();
closeMenu({ restoreFocus: true });
}
break;
case "Tab":
closeMenu({ notifyBlur: true });
break;
default:
break;
}
}
function handlePointerDown(event) {
if (rootRef.value?.contains(event.target) || menuRef.value?.contains(event.target)) {
return;
}
closeMenu({ notifyBlur: true });
}
function updatePosition() {
if (!rootRef.value || !menuRef.value) {
return;
}
computePosition(rootRef.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, {
width: `${rects.reference.width}px`,
maxHeight: `${Math.max(availableHeight, 160)}px`,
});
},
padding: 12,
}),
],
}).then(({ x, y }) => {
menuStyle.value = { left: `${x}px`, top: `${y}px` };
});
}
watch(filteredOptions, (options) => {
if (!isOpen.value) {
return;
}
activeIndex.value = options.length ? Math.min(Math.max(activeIndex.value, 0), options.length - 1) : -1;
nextTick(updatePosition);
});
watch(normalizedOptions, (options) => {
if (options.length === 0) {
closeMenu();
}
});
watchPostEffect((cleanup) => {
if (!isOpen.value || !rootRef.value || !menuRef.value) {
return;
}
const stopAutoUpdate = autoUpdate(rootRef.value, menuRef.value, updatePosition);
cleanup(stopAutoUpdate);
});
watch(isOpen, (open) => {
if (open) {
document.addEventListener("pointerdown", handlePointerDown);
return;
}
document.removeEventListener("pointerdown", handlePointerDown);
});
watch(() => props.disabled, (disabled) => {
if (disabled) {
closeMenu();
}
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", handlePointerDown);
});
</script>
<template>
<div class="flex w-full min-w-0 items-center gap-2">
<div
ref="rootRef"
class="flex h-9 min-w-0 flex-1 items-center rounded-[6px] border border-[#3f3f3f] bg-[#232323] transition-colors focus-within:border-[#10AD5D]"
>
<input
ref="inputRef"
:value="modelValue"
type="text"
role="combobox"
autocomplete="off"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
class="min-w-0 flex-1 bg-transparent px-3 text-sm text-[#e5e5e5] outline-none placeholder:text-[#7b7b7b] disabled:cursor-not-allowed disabled:opacity-60"
:placeholder="placeholder"
:disabled="disabled"
:aria-label="ariaLabel || undefined"
:aria-expanded="isOpen"
:aria-controls="listboxID"
:aria-activedescendant="activeDescendant"
aria-autocomplete="list"
aria-haspopup="listbox"
@focus="openMenu({ showAll: true })"
@input="handleInput"
@change="emit('change', $event.target.value)"
@keydown="handleInputKeydown"
/>
<button
type="button"
class="center-row h-full w-9 shrink-0 text-[#8f8f8f] outline-none transition-colors hover:text-[#d4d4d4] disabled:cursor-not-allowed disabled:opacity-60"
:disabled="disabled || loading"
:aria-label="ariaLabel || undefined"
:aria-expanded="isOpen"
tabindex="-1"
@mousedown.prevent
@click="toggleMenu"
>
<span v-if="loading" class="icon-[mdi--loading] animate-spin text-[17px]"></span>
<span v-else class="icon-[mdi--chevron-down] text-[18px] transition-transform duration-200" :class="isOpen ? 'rotate-180' : ''"></span>
</button>
</div>
<div v-if="$slots.append" class="shrink-0">
<slot name="append" />
</div>
</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-show="isOpen && normalizedOptions.length"
ref="menuRef"
:id="listboxID"
role="listbox"
class="fixed z-[999] overflow-y-auto rounded-[8px] border border-[#3f3f3f] bg-[#232323] p-1 shadow-[0_16px_30px_-12px_rgba(0,0,0,0.7)]"
:style="menuStyle"
>
<ul v-show="filteredOptions.length" role="presentation" class="py-1">
<li
v-for="option in normalizedOptions"
v-show="filteredOptions.includes(option)"
:key="option.value"
role="presentation"
>
<button
:ref="(el) => setOptionRef(el, option.optionIndex)"
:id="`${listboxID}-option-${option.optionIndex}`"
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-[#10AD5D]/15 text-[#10d06f]'
: 'text-[#e5e5e5] hover:bg-[#303030]',
activeOption === option ? 'bg-[#303030]' : '',
]"
:aria-selected="option.value === modelValue"
tabindex="-1"
@mousedown.prevent
@click="selectOption(option)"
@mouseenter="activeIndex = filteredOptions.indexOf(option)"
>
<span class="flex min-w-0 items-center gap-2">
<span v-if="option.icon" :class="[option.icon, 'shrink-0 text-[16px]']" aria-hidden="true"></span>
<span class="truncate">{{ option.label }}</span>
</span>
</button>
</li>
</ul>
<div v-show="!filteredOptions.length" class="px-3 py-2 text-sm text-[#8f8f8f]">{{ emptyText }}</div>
</div>
</Transition>
</Teleport>
</template>
+138
View File
@@ -0,0 +1,138 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
const props = defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: "" },
size: {
type: String,
default: "md",
validator: (value) => ["md", "lg", "xl"].includes(value),
},
closeOnBackdrop: { type: Boolean, default: true },
closeOnEscape: { type: Boolean, default: true },
closeDisabled: { type: Boolean, default: false },
});
const emit = defineEmits(["close"]);
const panelRef = ref(null);
const closeButtonRef = ref(null);
let previouslyFocusedElement = null;
const panelClass = computed(() => ({
md: "max-w-[420px]",
lg: "max-w-[680px]",
xl: "h-full max-h-[760px] max-w-[880px]",
}[props.size]));
function requestClose() {
if (!props.closeDisabled) {
emit("close");
}
}
function handleBackdropClick() {
if (props.closeOnBackdrop) {
requestClose();
}
}
function handleKeydown(event) {
if (event.key === "Escape" && props.open && props.closeOnEscape) {
requestClose();
return;
}
if (event.key !== "Tab" || !props.open || !panelRef.value) {
return;
}
const focusable = Array.from(panelRef.value.querySelectorAll(
"button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex='-1'])",
));
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
watch(
() => props.open,
(open) => {
if (open) {
previouslyFocusedElement = document.activeElement;
document.addEventListener("keydown", handleKeydown);
nextTick(() => {
const firstField = panelRef.value?.querySelector(
"input:not([disabled]), textarea:not([disabled]), select:not([disabled])",
);
(firstField || closeButtonRef.value)?.focus();
});
return;
}
document.removeEventListener("keydown", handleKeydown);
if (previouslyFocusedElement instanceof HTMLElement && document.contains(previouslyFocusedElement)) {
previouslyFocusedElement.focus();
}
previouslyFocusedElement = null;
},
{ immediate: true },
);
onBeforeUnmount(() => {
document.removeEventListener("keydown", handleKeydown);
});
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-150 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-show="open"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 px-7 pb-[38px] pt-[calc(48px+env(safe-area-inset-top))]"
@click.self="handleBackdropClick"
>
<section
ref="panelRef"
role="dialog"
aria-modal="true"
:aria-label="title || undefined"
class="flex w-full max-h-[660px] min-h-0 flex-col overflow-hidden rounded-[8px] border border-[#3a3a3a] bg-[#202020] shadow-[0_24px_64px_rgba(0,0,0,0.65)]"
:class="panelClass"
@click.stop
>
<header class="flex h-12 shrink-0 items-center justify-between border-b border-[#343434] px-4">
<h2 class="min-w-0 truncate text-base font-medium text-white">{{ title }}</h2>
<button
ref="closeButtonRef"
type="button"
class="center-row w-[28px] h-[28px] justify-center size-8 shrink-0 rounded-[6px] text-[#8f8f8f] outline-none transition-colors hover:bg-[#303030] hover:text-white focus-visible:ring-2 focus-visible:ring-[#10AD5D]/40 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="closeDisabled"
aria-label="关闭"
@click="requestClose"
>
<span class="icon-[mdi--close] text-[19px]"></span>
</button>
</header>
<div class="min-h-0 flex-1 overflow-hidden">
<slot />
</div>
</section>
</div>
</Transition>
</Teleport>
</template>
+17 -54
View File
@@ -2,56 +2,24 @@
import { messageState, provideMessage } from "@/composables/useMessage";
provideMessage();
const MESSAGE_THEME = {
success: {
containerClass: "bg-[#10AD5D] text-white",
iconClass: "icon-[dashicons--yes]",
iconExtraClass: "",
},
error: {
containerClass: "bg-[#D84C4C] text-white",
iconClass: "",
iconExtraClass: "",
},
info: {
containerClass: "bg-[#F08A24] text-white",
iconClass: "",
iconExtraClass: "",
},
loading: {
containerClass: "bg-[#3a3a3a] 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>
<Teleport to="body">
<div class="pointer-events-none fixed inset-x-0 top-[calc(48px+env(safe-area-inset-top))] z-[11000] flex justify-center px-4">
<Transition name="message-slide" mode="out-in">
<div
v-if="messageState.current"
:key="messageState.current.id"
role="status"
aria-live="polite"
class="max-w-[min(520px,calc(100vw-32px))] rounded-[8px] border border-[#454545] bg-[#2b2b2b] px-4 py-2.5 text-center text-sm leading-5 text-[#ededed] shadow-[0_10px_30px_rgba(0,0,0,0.38)]"
>
{{ messageState.current.content }}
</div>
</Transition>
</div>
</Teleport>
</template>
<style scoped>
@@ -62,7 +30,7 @@ function resolveTheme(type) {
.message-slide-enter-from {
opacity: 0;
transform: translateY(-12px);
transform: translateY(-8px);
}
.message-slide-enter-to,
@@ -73,11 +41,6 @@ function resolveTheme(type) {
.message-slide-leave-to {
opacity: 0;
transform: translateY(-12px);
transform: translateY(-8px);
}
</style>
+2 -3
View File
@@ -39,11 +39,10 @@ function onMaskClick() {
<Transition name="modal-content">
<div
v-show="visible"
class="relative z-10 w-full max-w-[360px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
class="relative z-10 w-full max-w-[360px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)] border border-[#3A3A3A] bg-[#292929]"
@click.stop
>
<div class="rounded-[7px] bg-[#292929] p-5">
<div class="rounded-[7px] p-5">
<h3 class="mb-3 text-base font-medium text-white">
{{ title }}
</h3>
-362
View File
@@ -1,362 +0,0 @@
<script setup>
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
import { computed, onBeforeUnmount, nextTick, ref, watch, watchPostEffect } from "vue";
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
options: {
type: Array,
default: () => [],
},
placeholder: { type: String, default: "请选择" },
disabled: { type: Boolean, default: false },
ariaLabel: { type: String, default: "" },
summaryFormatter: { type: Function, default: null },
});
const emit = defineEmits(["update:modelValue", "change"]);
const rootRef = ref(null);
const buttonRef = ref(null);
const menuRef = ref(null);
const selectAllRef = ref(null);
const optionRefs = ref([]);
const isOpen = ref(false);
const menuStyle = ref({});
// -1 表示"全选"按钮,0..n-1 表示选项,共同组成一个可循环的键盘焦点环
const activeIndex = ref(-1);
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 ?? "",
};
}));
const selectedValues = computed(() => new Set(props.modelValue ?? []));
const allSelected = computed(() =>
normalizedOptions.value.length > 0
&& normalizedOptions.value.every((option) => selectedValues.value.has(option.value)),
);
const summaryLabel = computed(() => {
const count = selectedValues.value.size;
if (count === 0) {
return props.placeholder;
}
if (props.summaryFormatter) {
return props.summaryFormatter(count, normalizedOptions.value.length);
}
return `已选择 ${count}`;
});
function emitSelection(values) {
emit("update:modelValue", values);
emit("change", values);
}
function toggleOption(option) {
const next = normalizedOptions.value
.filter((item) => (item.value === option.value
? !selectedValues.value.has(item.value)
: selectedValues.value.has(item.value)))
.map((item) => item.value);
emitSelection(next);
}
function toggleSelectAll() {
if (allSelected.value) {
emitSelection([]);
return;
}
emitSelection(normalizedOptions.value.map((option) => option.value));
}
function setOptionRef(el, index) {
if (el) {
optionRefs.value[index] = el;
return;
}
delete optionRefs.value[index];
}
function focusActiveOption() {
nextTick(() => {
if (activeIndex.value < 0) {
selectAllRef.value?.focus();
return;
}
optionRefs.value[activeIndex.value]?.focus();
});
}
function moveActiveIndex(step) {
if (!isOpen.value) {
openMenu();
return;
}
const total = normalizedOptions.value.length;
if (total === 0) {
return;
}
// 焦点环长度为 total + 1(含全选),内部用 0..total 表示,再映射回 -1..total-1
const ringSize = total + 1;
const current = activeIndex.value + 1;
activeIndex.value = ((current + step + ringSize) % ringSize) - 1;
focusActiveOption();
}
function openMenu() {
if (props.disabled || isOpen.value) {
return;
}
isOpen.value = true;
const firstSelected = normalizedOptions.value.findIndex((option) => selectedValues.value.has(option.value));
activeIndex.value = firstSelected;
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());
}
}
function toggleMenu() {
if (isOpen.value) {
closeMenu();
return;
}
openMenu();
}
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();
if (option) {
toggleOption(option);
break;
}
toggleSelectAll();
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, 200)}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(isOpen, (open) => {
if (open) {
document.addEventListener("pointerdown", handlePointerDown);
return;
}
document.removeEventListener("pointerdown", handlePointerDown);
});
watch(() => props.disabled, (disabled) => {
if (disabled) {
closeMenu();
}
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", handlePointerDown);
});
</script>
<template>
<div ref="rootRef" class="relative">
<button
ref="buttonRef"
type="button"
:disabled="disabled"
class="flex h-9 w-full items-center justify-between gap-2 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-left text-sm text-[#e5e5e5] outline-none transition-colors focus:border-[#10AD5D] disabled:cursor-not-allowed disabled:opacity-60"
:aria-expanded="isOpen"
:aria-label="ariaLabel || undefined"
aria-haspopup="listbox"
@click="toggleMenu"
@keydown="handleButtonKeydown"
>
<span class="flex min-w-0 flex-1 items-center gap-2" :class="selectedValues.size ? 'text-[#e5e5e5]' : 'text-[#7b7b7b]'">
<span class="truncate">{{ summaryLabel }}</span>
</span>
<span
class="pointer-events-none center-row text-[#8f8f8f] transition-transform duration-200"
:class="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] flex flex-col overflow-hidden rounded-[8px] border border-[#3f3f3f] bg-[#232323] p-1 shadow-[0_16px_30px_-12px_rgba(0,0,0,0.7)]"
:style="menuStyle"
>
<button
ref="selectAllRef"
type="button"
class="flex w-full items-center gap-2 rounded-[6px] px-3 py-2 text-left text-sm text-[#d4d4d4] outline-none transition-colors hover:bg-[#303030]"
:class="activeIndex === -1 ? 'bg-[#303030]' : ''"
@click="toggleSelectAll"
@mouseenter="activeIndex = -1"
@keydown="handleOptionKeydown($event, null, -1)"
>
<span :class="[allSelected ? 'icon-[mdi--checkbox-marked]' : 'icon-[mdi--checkbox-blank-outline]', 'text-[16px] shrink-0']"></span>
<span class="truncate">{{ allSelected ? "取消全选" : "全选" }}</span>
</button>
<ul role="listbox" aria-multiselectable="true" 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 gap-2 rounded-[6px] px-3 py-2 text-left text-sm outline-none transition-colors"
:class="[
selectedValues.has(option.value)
? 'bg-[#10AD5D]/15 text-[#10d06f]'
: 'text-[#e5e5e5] hover:bg-[#303030]',
activeIndex === index ? 'bg-[#303030]' : '',
]"
:aria-selected="selectedValues.has(option.value)"
tabindex="0"
@click="toggleOption(option)"
@mouseenter="activeIndex = index"
@keydown="handleOptionKeydown($event, option, index)"
>
<span
:class="[
selectedValues.has(option.value) ? 'icon-[mdi--checkbox-marked]' : 'icon-[mdi--checkbox-blank-outline]',
'text-[16px] shrink-0',
]"
></span>
<span v-if="option.icon" :class="[option.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
<span class="truncate">{{ option.label }}</span>
</button>
</li>
</ul>
</div>
</Transition>
</Teleport>
</template>
+1 -1
View File
@@ -117,7 +117,7 @@ onBeforeUnmount(() => {
<button
ref="triggerRef"
type="button"
class="center-row h-[16px] w-[16px] cursor-help rounded-full text-[#727272] transition-colors duration-150 hover:text-[#cfcfcf]"
class="center-row h-[16px] w-[16px] cursor-help rounded-full text-white/60 transition-colors duration-150 hover:text-white/80"
@mouseenter="showTooltip"
@mouseleave="scheduleHideTooltip"
@focus="showTooltip"
+17 -37
View File
@@ -35,10 +35,9 @@ function removeMessage(id, options = {}) {
messageState.current = null;
}
function showMessage(options = {}) {
const type = typeof options.type === "string" ? options.type : "info";
const content = String(options.content || "").trim();
if (!content) {
function showMessage(content, options = {}) {
const normalizedContent = String(content || "").trim();
if (!normalizedContent) {
return null;
}
@@ -48,14 +47,11 @@ function showMessage(options = {}) {
const duration = Number.isFinite(options.duration)
? Math.max(0, options.duration)
: type === "loading"
? 0
: 2400;
: 2400;
const id = `message-${Date.now()}-${messageSeed += 1}`;
const item = {
id,
type,
content,
content: normalizedContent,
shownAt: Date.now(),
timer: null,
};
@@ -70,40 +66,24 @@ function showMessage(options = {}) {
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 });
}
},
};
export function message(content, options = {}) {
return showMessage(content, options);
}
const defaultMessageApi = createMessageApi();
message.remove = removeMessage;
message.clear = () => {
if (messageState.current) {
removeMessage(messageState.current.id, { force: true });
}
};
export function provideMessage() {
provide(MESSAGE_API_SYMBOL, defaultMessageApi);
return defaultMessageApi;
provide(MESSAGE_API_SYMBOL, message);
return message;
}
export function useMessage() {
return inject(MESSAGE_API_SYMBOL, defaultMessageApi);
return inject(MESSAGE_API_SYMBOL, message);
}
export { messageState, showMessage, removeMessage };
export { messageState, showMessage, removeMessage };
File diff suppressed because it is too large Load Diff
+12 -17
View File
@@ -1,6 +1,5 @@
{
"02216368edc68816": "No release notes",
"02bc2e95bf49e587": "No",
"03b11112dc970014": "Base URL",
"04f632dd4f034d5e": "{0} context window must be a positive integer",
"051836569928a9f9": "Edit",
@@ -30,7 +29,6 @@
"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.",
"2a24519398684ed5": "Visit Homepage",
"2c18d5e2d70b45db": "Model prefix",
"2cd0f3be8738a86c": "Cancel",
"2d706f7981b45a7b": "Local settings saved",
"2f9daa828907b93f": "Delete",
@@ -42,14 +40,11 @@
"3468b57e3edbc599": "Aggregated from turn summaries scanned from the history.",
"35076178fe79a210": "Configuration changed. Please test again.",
"358f07b2c1445ab1": "Author's Message",
"36c149a9b3e8dca0": "models configured yet.",
"37d23612f78a2e63": "Restart Now to Update",
"392d0dceb45998d3": "Extreme",
"393df9bb13ea4900": "Hit",
"3a5040b68abf75f9": "Select all",
"3ab8cc15939f3b5c": "Log out",
"3af7e5489e61ea51": "Refreshing",
"3b2e5f2fba1bcbc7": "Enter the API address and access key",
"3c2a9f9901109e75": "{0} type only supports OpenAI or Anthropic",
"3d13868593ae4eeb": "Interface Language",
"3d52574ce1500561": "Not connected",
@@ -63,19 +58,20 @@
"472642d58d3d5a6d": "Formula: {0}",
"4923eeb7bd75cccd": "{0} model ID cannot be empty",
"497c85690c4cc0fc": "No data",
"4b5e0ae1288a9695": "No matching models",
"4c0a929bb86ce912": "Current: {0}",
"4d2b6e53be6002e5": "Cache Statistics Strategy: {0} ({1})",
"4d8c1c5b42830791": "Unknown",
"4f0982ba1d37e51b": "Current outbound requests use environment variable proxy",
"51194c3ad014fb29": "Retest required",
"5205125c0e91d346": "Maximum tokens an Anthropic model may generate in a single response. Leave blank to use the default.",
"54e6745ff43c9c74": "Sorting failed",
"56627c94a9decee6": "Max Output Tokens",
"58c6b0935a7216da": "Failed to open contributor profile",
"593a972852ba0004": "Cursor Assistant | Permanently Free | Custom API",
"59a2195a01a8b35b": "{0} must be a valid JSON object",
"5a5c8318ef649672": "{0} selected",
"5aa8f5590c940829": "Non-cache Input: {0}",
"5beb1206c532729f": "Maximum number of tokens allowed in a single response. Leave blank to use the default.",
"5c55a67935af8f45": "All",
"5d1687a4a41883fd": "Stopping...",
"5e709712ce012f5d": "Current outbound requests use environment variable proxy: {0}",
"6106f0a12583a334": "Refresh failed",
@@ -97,27 +93,26 @@
"6e584e3d5ce64aa0": "Save Settings",
"6ec87609a8769425": "Default {0} / Count as creation {1}",
"72f6c3525c0192a7": "When enabled, cache creation is included in the denominator",
"736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.",
"737225e2904673fc": "Estimated output tokens: {0}",
"7520bd50a5ee5471": "Stop testing {0}/{1}",
"753d8bb0da9913ce": "Duplication failed",
"75b5f4c68c79322c": "Select at least one model to save",
"774d6e1b7cb89751": "Default Definition",
"77c9e582e85583af": "Test failed",
"7923d007483ae04c": "No models to save",
"7a26bf794e9fb6bf": "Used only for display in the UI, so you can distinguish different models.",
"7b6187c41e88b70c": "Testing...",
"7bf8e2c07e084d09": "Model Editor",
"7df7641e5e741346": "Cache Read / (Cache Read + Cache Creation + Non-cache Input)",
"7e9e334aeb0bdc07": "Service operation failed",
"7f68ebad19ba6bcd": "Check for Updates",
"80296f4aa3f4543b": "Cache Read/Write",
"81123c56d5d880d0": "API Key",
"8139cb3dd11f5a67": "When enabled, the JSON object will override the final request headers. Duplicate headers are determined by this field, and values must be strings.",
"826c0e5a4407befa": "You can select multiple models. Only selected models will be saved, with one configuration generated for each model.",
"8151e8704a7ca89e": "No matches",
"83913e71fcf7ff60": "Refresh successful",
"83be9cac28873059": "Cursor Control Plane Account",
"83fcfb4c1f2c1641": "Fetch Models",
"8672864e90417138": "Max",
"86df7ec743047234": "Service running",
"891bfee3bbe52d3c": "Enter a model ID",
"899add6275682210": "Uses 200000 by default when left blank",
"8a4ef3e48e4e8a5a": "Enabled",
"8c1935935600e336": "Model Test",
@@ -130,7 +125,6 @@
"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",
"93faf55cd25c8319": "This software is completely free. If you were charged, you were likely scammed.\nWelcome to visit the author's homepage at https://space.bilibili.com/311706663/upload/video\nto see more updates, sharing guides, and future content.",
"942ff2d88baca0c6": "Checking for updates...",
@@ -143,7 +137,6 @@
"9c41b3a9e12ac994": "Thinking Effort",
"9d2ca261281a158a": "Later",
"9d2fb46c0ba890b9": "Custom Path",
"9dc0825fba5422e4": "Loading...",
"9e02529bcaef36c6": "Duplicate model channel detected. Please check the combination of url, modelID, apiKey, displayName, and endpoint",
"a026f37e613cf48b": "Output Tokens",
"a0d36236c523667c": "{0} Anthropic thinking effort only supports low, medium, high, xhigh, max",
@@ -161,6 +154,7 @@
"ac217e4d1ca410f1": "New version available",
"ad79540418be700a": "Open the settings folder, or manage model settings separately",
"ae5a738238463a92": "Hide API Key",
"aeb1e3a8ff46cda1": "No models have been configured yet.",
"aed55419ce62f08e": "Switching...",
"b10041a13f5c55b1": "Model Output: {0} × ${1}/1M = {2}",
"b1c27820fec23edb": "High",
@@ -181,14 +175,15 @@
"c228558cf257fc49": "Delete failed",
"c3d46b387eeadb23": "This only logs the Cursor account out of cursor-byok; it does not log out of the Cursor client. Continue?",
"c5af02060847d167": "Thinking effort for Anthropic adaptive thinking. Requests will consistently use the new thinking.type=adaptive.",
"c6868592796ac2b2": "No {0} models have been configured yet.",
"c69f5bce63b9f14c": "Settings Folder",
"c72d5dc20cd27118": "{0} / {1} models selected",
"c8a52b66651d294c": "Failed to log out",
"c8c14507b2d37395": "Reasoning Effort",
"c98e118e0a43f078": "Model",
"c9dd59beefd7144f": "Cache Read / (Cache Read + Non-cache Input)",
"ca00a39fcea70dc6": "Starting...",
"ca1d1059408b3837": "Invalid turns: {0}",
"cc5049729a2c10f1": "Test failed. Check the raw details.",
"cd7ca5fb221e1c53": "{0} cannot be empty",
"cfa6c803eb3fc713": "Waiting for browser login",
"d0325067fed88e5a": "Cache hit rate {0}",
@@ -216,7 +211,6 @@
"e4c0daa3c4bea691": "Thanks to @aike0210 for contributing the Cursor control-plane account feature.",
"e53580f8031f13c0": "Complete login in the browser, then return to Cursor and reopen the plugin marketplace",
"e552c2accdbf5178": "Add Model",
"e6943d5cbfb863e0": "Fetching models...",
"e6faccfddce722e8": "Cache read tokens: {0}",
"e8a0a6053998ebfa": "Logged in",
"eaffd48cd2ea9f1a": "e.g. https://api.anthropic.com",
@@ -224,12 +218,13 @@
"ec3b17a75db49e24": "{0} t/s | First token {1}",
"ec99e5c45d648fd6": "Update failed",
"ee95057c6b0335d2": "Current outbound requests use system proxy: {0}",
"f0b6a23368dd47cc": "Enter a model ID directly, or select one from the list returned by the server.",
"f1aa7326f38b4c09": "Drag to reorder",
"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",
"f3fae6cccb9004b1": "Custom header name cannot be empty",
"f474a4108aba4c4c": "Stop Service",
"f4d4bae588c4c0ff": "Deselect all",
"f4f0ead1116b5b62": "Enable",
"f56c6c82203b33f6": "Notice",
"f61e03f047b786d5": "{0} max output tokens must be a positive integer",
+12 -17
View File
@@ -1,6 +1,5 @@
{
"02216368edc68816": "更新内容はありません",
"02bc2e95bf49e587": "まだ",
"03b11112dc970014": "ベース URL",
"04f632dd4f034d5e": "{0} のコンテキストウィンドウは正の整数である必要があります",
"051836569928a9f9": "編集",
@@ -30,7 +29,6 @@
"281eb6d08c9960d0": "{0} の思考予算 Token は正の整数である必要があります",
"28aeffc70ceb4267": "この画面の表示言語を切り替えます。設定はすぐに反映され、この端末に保存されます",
"2a24519398684ed5": "ホームページへ",
"2c18d5e2d70b45db": "モデルのプレフィックス",
"2cd0f3be8738a86c": "キャンセル",
"2d706f7981b45a7b": "ローカル設定を保存しました",
"2f9daa828907b93f": "削除",
@@ -42,14 +40,11 @@
"3468b57e3edbc599": "履歴からスキャンした各ターンの summary を集計しています。",
"35076178fe79a210": "設定が変更されました。再テストしてください",
"358f07b2c1445ab1": "著者からのメッセージ",
"36c149a9b3e8dca0": "モデルは設定されていません。",
"37d23612f78a2e63": "今すぐ再起動して更新",
"392d0dceb45998d3": "最高",
"393df9bb13ea4900": "ヒット",
"3a5040b68abf75f9": "すべて選択",
"3ab8cc15939f3b5c": "ログアウト",
"3af7e5489e61ea51": "更新中",
"3b2e5f2fba1bcbc7": "インターフェースのアドレスとアクセスキーを入力してください",
"3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします",
"3d13868593ae4eeb": "表示言語",
"3d52574ce1500561": "未接続",
@@ -63,19 +58,20 @@
"472642d58d3d5a6d": "数式:{0}",
"4923eeb7bd75cccd": "{0} のモデル ID は必須です",
"497c85690c4cc0fc": "データなし",
"4b5e0ae1288a9695": "一致するモデルがありません",
"4c0a929bb86ce912": "現在:{0}",
"4d2b6e53be6002e5": "キャッシュ統計ポリシー:{0}{1})",
"4d8c1c5b42830791": "不明",
"4f0982ba1d37e51b": "現在のアウトバウンドリクエストは環境変数プロキシを使用しています",
"51194c3ad014fb29": "再テストが必要",
"5205125c0e91d346": "Anthropic モデルが1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
"54e6745ff43c9c74": "並べ替えに失敗しました",
"56627c94a9decee6": "最大出力 Token",
"58c6b0935a7216da": "コントリビューターのプロフィールを開けませんでした",
"593a972852ba0004": "Cursor アシスタント | 永久無料 | カスタム API",
"59a2195a01a8b35b": "{0}は有効なJSONオブジェクトである必要があります",
"5a5c8318ef649672": "{0}件を選択中",
"5aa8f5590c940829": "非キャッシュ入力:{0}",
"5beb1206c532729f": "1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
"5c55a67935af8f45": "すべて",
"5d1687a4a41883fd": "停止中...",
"5e709712ce012f5d": "現在のアウトバウンドリクエストは環境変数プロキシを使用しています:{0}",
"6106f0a12583a334": "再読み込みに失敗しました",
@@ -97,27 +93,26 @@
"6e584e3d5ce64aa0": "設定を保存",
"6ec87609a8769425": "デフォルト {0} / 作成カウント {1}",
"72f6c3525c0192a7": "有効にすると、キャッシュ作成が分母に含まれます",
"736c9dc2a04c65fd": "モデル設定が変更されました。更新してからもう一度お試しください。",
"737225e2904673fc": "推定出力 Token: {0}",
"7520bd50a5ee5471": "テスト停止 {0}/{1}",
"753d8bb0da9913ce": "複製に失敗しました",
"75b5f4c68c79322c": "保存するモデルを1つ以上選択してください",
"774d6e1b7cb89751": "デフォルト定義",
"77c9e582e85583af": "テスト失敗",
"7923d007483ae04c": "保存できるモデルがありません",
"7a26bf794e9fb6bf": "UIでモデルを区別するための表示専用です。",
"7b6187c41e88b70c": "テスト中...",
"7bf8e2c07e084d09": "モデル編集",
"7df7641e5e741346": "キャッシュ読み取り / (キャッシュ読み取り + キャッシュ作成 + 非キャッシュ入力)",
"7e9e334aeb0bdc07": "サービス操作に失敗しました",
"7f68ebad19ba6bcd": "アップデートを確認",
"80296f4aa3f4543b": "キャッシュ読み書き",
"81123c56d5d880d0": "API キー",
"8139cb3dd11f5a67": "有効にすると、JSONオブジェクトが最終的なリクエストヘッダーを上書きします。同名のヘッダーはこの設定が優先され、値は文字列である必要があります。",
"826c0e5a4407befa": "複数のモデルを選択できます。保存されるのは選択したモデルだけで、モデルごとに1つの設定が作成されます。",
"8151e8704a7ca89e": "一致する項目がありません",
"83913e71fcf7ff60": "更新しました",
"83be9cac28873059": "Cursor コントロールプレーンアカウント",
"83fcfb4c1f2c1641": "モデルを取得",
"8672864e90417138": "最大",
"86df7ec743047234": "サービス稼働中",
"891bfee3bbe52d3c": "モデルIDを入力してください",
"899add6275682210": "空欄で 200000",
"8a4ef3e48e4e8a5a": "有効",
"8c1935935600e336": "モデルテスト",
@@ -130,7 +125,6 @@
"917b1c1f18d0276b": "保存中...",
"9196835e388d2550": "すべてテスト",
"91cba5c107a51892": "/ 異常",
"92059fe6cd713db4": "サーバーに実際に送信されるモデル名。例: gpt-4.1、claude-sonnet。",
"93e08803675e378b": "モデルID",
"93faf55cd25c8319": "このソフトウェアは完全に無料です。もし料金を請求された場合は、詐欺の可能性が高いです。\n著者のホームページ https://space.bilibili.com/311706663/upload/video にアクセスして、更新情報や利用方法などを確認してください。",
"942ff2d88baca0c6": "アップデートを確認中...",
@@ -143,7 +137,6 @@
"9c41b3a9e12ac994": "思考強度",
"9d2ca261281a158a": "後で",
"9d2fb46c0ba890b9": "カスタムパス",
"9dc0825fba5422e4": "読み込み中...",
"9e02529bcaef36c6": "モデルチャネルが重複しています。url、modelID、apiKey、displayName、endpointの組み合わせを確認してください",
"a026f37e613cf48b": "出力 Token",
"a0d36236c523667c": "{0} のAnthropic思考強度はlow、medium、high、xhigh、maxのみをサポートしています",
@@ -161,6 +154,7 @@
"ac217e4d1ca410f1": "新しいバージョンがあります",
"ad79540418be700a": "設定フォルダーを開くか、モデル設定を個別に管理できます",
"ae5a738238463a92": "API キーを隠す",
"aeb1e3a8ff46cda1": "まだモデルが設定されていません。",
"aed55419ce62f08e": "切替中...",
"b10041a13f5c55b1": "モデル出力:{0} × ${1}/1M = {2}",
"b1c27820fec23edb": "高",
@@ -181,14 +175,15 @@
"c228558cf257fc49": "削除に失敗しました",
"c3d46b387eeadb23": "cursor-byok 内の Cursor アカウントからのみログアウトします。Cursor クライアントからはログアウトしません。続行しますか?",
"c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。",
"c6868592796ac2b2": "まだ {0} モデルが設定されていません。",
"c69f5bce63b9f14c": "設定フォルダー",
"c72d5dc20cd27118": "{0} / {1} モデルを選択中",
"c8a52b66651d294c": "ログアウトに失敗しました",
"c8c14507b2d37395": "推論強度",
"c98e118e0a43f078": "モデル",
"c9dd59beefd7144f": "キャッシュ読み取り / (キャッシュ読み取り + 非キャッシュ入力)",
"ca00a39fcea70dc6": "起動中...",
"ca1d1059408b3837": "異常ターン: {0}",
"cc5049729a2c10f1": "テストに失敗しました。元の詳細情報を確認してください。",
"cd7ca5fb221e1c53": "{0}は空にできません",
"cfa6c803eb3fc713": "ブラウザでのログインを待っています",
"d0325067fed88e5a": "キャッシュヒット率 {0}",
@@ -216,7 +211,6 @@
"e4c0daa3c4bea691": "Cursor コントロールプレーンアカウント機能への @aike0210 の貢献に感謝します。",
"e53580f8031f13c0": "ブラウザでログインを完了し、Cursor に戻ってプラグインマーケットを開き直してください",
"e552c2accdbf5178": "モデルを追加",
"e6943d5cbfb863e0": "モデルを取得中...",
"e6faccfddce722e8": "キャッシュ読込 Token: {0}",
"e8a0a6053998ebfa": "ログイン済み",
"eaffd48cd2ea9f1a": "例: https://api.anthropic.com",
@@ -224,12 +218,13 @@
"ec3b17a75db49e24": "{0} t/s | 初回 Token {1}",
"ec99e5c45d648fd6": "アップデートに失敗しました",
"ee95057c6b0335d2": "現在のアウトバウンドリクエストはシステムプロキシを使用しています:{0}",
"f0b6a23368dd47cc": "モデルIDを直接入力するか、サーバーから返された一覧から選択します。",
"f1aa7326f38b4c09": "ドラッグして並べ替え",
"f1e0fc261d42fe29": "モデル一覧にホバーしたときに表示されるメモです。",
"f363622480699c52": "推論強度は reasoning_effort をサポートする一部のモデルでのみ有効です。すべてのモデルが対応しているわけではありません。値が高いほど安定しやすい反面、遅くなることがあります。",
"f3a76d896853c1df": "ミス",
"f3fae6cccb9004b1": "カスタムヘッダー名は空にできません",
"f474a4108aba4c4c": "サービスを停止",
"f4d4bae588c4c0ff": "すべて選択解除",
"f4f0ead1116b5b62": "有効化",
"f56c6c82203b33f6": "お知らせ",
"f61e03f047b786d5": "{0} の最大出力 Token は正の整数である必要があります",
+12 -17
View File
@@ -1,6 +1,5 @@
{
"02216368edc68816": "Нет примечаний к выпуску",
"02bc2e95bf49e587": "Пока нет настроенных",
"03b11112dc970014": "Базовый URL",
"04f632dd4f034d5e": "Размер контекстного окна {0} должен быть положительным целым числом",
"051836569928a9f9": "Изменить",
@@ -30,7 +29,6 @@
"281eb6d08c9960d0": "Бюджет токенов рассуждения {0} должен быть положительным целым числом",
"28aeffc70ceb4267": "Измените язык интерфейса. Настройка применяется сразу и сохраняется на этом устройстве.",
"2a24519398684ed5": "Перейти на домашнюю страницу",
"2c18d5e2d70b45db": "Префикс модели",
"2cd0f3be8738a86c": "Отмена",
"2d706f7981b45a7b": "Локальные настройки сохранены",
"2f9daa828907b93f": "Удалить",
@@ -42,14 +40,11 @@
"3468b57e3edbc599": "Сводка составлена по данным ходов, найденным в истории.",
"35076178fe79a210": "Конфигурация изменена. Выполните проверку снова.",
"358f07b2c1445ab1": "Сообщение автора",
"36c149a9b3e8dca0": "моделей.",
"37d23612f78a2e63": "Перезапустить и обновить",
"392d0dceb45998d3": "Очень высокая",
"393df9bb13ea4900": "Попадание",
"3a5040b68abf75f9": "Выбрать все",
"3ab8cc15939f3b5c": "Выйти",
"3af7e5489e61ea51": "Обновление",
"3b2e5f2fba1bcbc7": "Введите адрес интерфейса и ключ доступа",
"3c2a9f9901109e75": "Тип {0} поддерживает только OpenAI или Anthropic",
"3d13868593ae4eeb": "Язык интерфейса",
"3d52574ce1500561": "Не подключено",
@@ -63,19 +58,20 @@
"472642d58d3d5a6d": "Формула: {0}",
"4923eeb7bd75cccd": "Идентификатор модели {0} не может быть пустым",
"497c85690c4cc0fc": "Нет данных",
"4b5e0ae1288a9695": "Подходящих моделей нет",
"4c0a929bb86ce912": "Сейчас: {0}",
"4d2b6e53be6002e5": "Стратегия статистики кеша: {0} ({1})",
"4d8c1c5b42830791": "Неизвестно",
"4f0982ba1d37e51b": "Исходящие запросы используют прокси из переменных окружения",
"51194c3ad014fb29": "Требуется повторная проверка",
"5205125c0e91d346": "Максимальное число токенов, которое модель Anthropic может сгенерировать за один ответ. Оставьте поле пустым для значения по умолчанию.",
"54e6745ff43c9c74": "Не удалось изменить порядок",
"56627c94a9decee6": "Макс. выходных токенов",
"58c6b0935a7216da": "Не удалось открыть профиль участника",
"593a972852ba0004": "Cursor Assistant | Всегда бесплатно | Пользовательский API",
"59a2195a01a8b35b": "{0} должен быть допустимым объектом JSON",
"5a5c8318ef649672": "Выбрано: {0}",
"5aa8f5590c940829": "Ввод без кеша: {0}",
"5beb1206c532729f": "Максимальное число токенов в одном ответе. Оставьте поле пустым для значения по умолчанию.",
"5c55a67935af8f45": "Все",
"5d1687a4a41883fd": "Остановка...",
"5e709712ce012f5d": "Исходящие запросы используют прокси из переменных окружения: {0}",
"6106f0a12583a334": "Не удалось обновить",
@@ -97,27 +93,26 @@
"6e584e3d5ce64aa0": "Сохранить настройки",
"6ec87609a8769425": "По умолчанию {0} / С учетом создания {1}",
"72f6c3525c0192a7": "Если включено, создание кеша учитывается в знаменателе",
"736c9dc2a04c65fd": "Конфигурация моделей изменилась. Обновите данные и повторите попытку.",
"737225e2904673fc": "Расчетные выходные токены: {0}",
"7520bd50a5ee5471": "Остановить проверку {0}/{1}",
"753d8bb0da9913ce": "Не удалось дублировать",
"75b5f4c68c79322c": "Выберите хотя бы одну модель для сохранения",
"774d6e1b7cb89751": "Стандартный расчет",
"77c9e582e85583af": "Проверка не пройдена",
"7923d007483ae04c": "Нет моделей для сохранения",
"7a26bf794e9fb6bf": "Используется только для отображения в интерфейсе, чтобы различать модели.",
"7b6187c41e88b70c": "Проверка...",
"7bf8e2c07e084d09": "Редактор модели",
"7df7641e5e741346": "Чтение кеша / (Чтение кеша + Создание кеша + Ввод без кеша)",
"7e9e334aeb0bdc07": "Не удалось выполнить операцию с сервисом",
"7f68ebad19ba6bcd": "Проверить обновления",
"80296f4aa3f4543b": "Чтение/запись кеша",
"81123c56d5d880d0": "Ключ API",
"8139cb3dd11f5a67": "Если включено, объект JSON переопределит итоговые заголовки запроса. При совпадении имен используются значения отсюда; все значения должны быть строками.",
"826c0e5a4407befa": "Можно выбрать несколько моделей. Будут сохранены только выбранные модели, для каждой будет создана отдельная конфигурация.",
"8151e8704a7ca89e": "Совпадений нет",
"83913e71fcf7ff60": "Обновление выполнено",
"83be9cac28873059": "Аккаунт управляющего уровня Cursor",
"83fcfb4c1f2c1641": "Получить модели",
"8672864e90417138": "Максимальная",
"86df7ec743047234": "Сервис запущен",
"891bfee3bbe52d3c": "Введите идентификатор модели",
"899add6275682210": "Если оставить пустым, используется 200000",
"8a4ef3e48e4e8a5a": "Включено",
"8c1935935600e336": "Проверка модели",
@@ -130,7 +125,6 @@
"917b1c1f18d0276b": "Сохранение...",
"9196835e388d2550": "Проверить все",
"91cba5c107a51892": "/ Ошибочные",
"92059fe6cd713db4": "Имя модели, фактически отправляемое серверу, например gpt-4.1 или claude-sonnet.",
"93e08803675e378b": "Идентификатор модели",
"93faf55cd25c8319": "Это программное обеспечение полностью бесплатно. Если с вас взяли плату, скорее всего, вас обманули.\\nПосетите страницу автора: https://space.bilibili.com/311706663/upload/video\\nТам публикуются обновления, руководства и другие материалы.",
"942ff2d88baca0c6": "Проверка обновлений...",
@@ -143,7 +137,6 @@
"9c41b3a9e12ac994": "Интенсивность рассуждений",
"9d2ca261281a158a": "Позже",
"9d2fb46c0ba890b9": "Пользовательский путь",
"9dc0825fba5422e4": "Загрузка...",
"9e02529bcaef36c6": "Обнаружен повторяющийся канал модели. Проверьте сочетание url, modelID, apiKey, displayName и endpoint",
"a026f37e613cf48b": "Выходные токены",
"a0d36236c523667c": "Интенсивность рассуждений Anthropic для {0} поддерживает только low, medium, high, xhigh и max",
@@ -161,6 +154,7 @@
"ac217e4d1ca410f1": "Доступна новая версия",
"ad79540418be700a": "Открыть папку настроек или отдельно управлять настройками моделей",
"ae5a738238463a92": "Скрыть ключ API",
"aeb1e3a8ff46cda1": "Модели пока не настроены.",
"aed55419ce62f08e": "Переключение...",
"b10041a13f5c55b1": "Вывод модели: {0} × ${1}/1M = {2}",
"b1c27820fec23edb": "Высокая",
@@ -181,14 +175,15 @@
"c228558cf257fc49": "Не удалось удалить",
"c3d46b387eeadb23": "Будет выполнен выход только из аккаунта Cursor в cursor-byok. В клиенте Cursor вы останетесь в системе. Продолжить?",
"c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.",
"c6868592796ac2b2": "Модели {0} пока не настроены.",
"c69f5bce63b9f14c": "Папка настроек",
"c72d5dc20cd27118": "Выбрано моделей: {0} / {1}",
"c8a52b66651d294c": "Не удалось выйти",
"c8c14507b2d37395": "Интенсивность рассуждений",
"c98e118e0a43f078": "Модель",
"c9dd59beefd7144f": "Чтение кеша / (Чтение кеша + Ввод без кеша)",
"ca00a39fcea70dc6": "Запуск...",
"ca1d1059408b3837": "Ошибочных ходов: {0}",
"cc5049729a2c10f1": "Тест не пройден. Проверьте исходные сведения.",
"cd7ca5fb221e1c53": "{0} не может быть пустым",
"cfa6c803eb3fc713": "Ожидание входа в браузере",
"d0325067fed88e5a": "Доля попаданий в кеш: {0}",
@@ -216,7 +211,6 @@
"e4c0daa3c4bea691": "Спасибо @aike0210 за вклад в функцию аккаунта панели управления Cursor.",
"e53580f8031f13c0": "Завершите вход в браузере, затем вернитесь в Cursor и снова откройте магазин плагинов",
"e552c2accdbf5178": "Добавить модель",
"e6943d5cbfb863e0": "Получение моделей...",
"e6faccfddce722e8": "Токены чтения из кеша: {0}",
"e8a0a6053998ebfa": "Выполнен вход",
"eaffd48cd2ea9f1a": "например, https://api.anthropic.com",
@@ -224,12 +218,13 @@
"ec3b17a75db49e24": "{0} т/с | Первый токен {1}",
"ec99e5c45d648fd6": "Не удалось обновить",
"ee95057c6b0335d2": "Исходящие запросы используют системный прокси: {0}",
"f0b6a23368dd47cc": "Введите идентификатор модели вручную или выберите его из списка, полученного от сервера.",
"f1aa7326f38b4c09": "Перетащите, чтобы изменить порядок",
"f1e0fc261d42fe29": "Примечание, отображаемое при наведении на модель в списке.",
"f363622480699c52": "Интенсивность рассуждений применяется только к моделям с поддержкой reasoning_effort. Чем выше значение, тем обычно стабильнее результат, но ответ может формироваться медленнее.",
"f3a76d896853c1df": "Промах",
"f3fae6cccb9004b1": "Имя пользовательского заголовка не может быть пустым",
"f474a4108aba4c4c": "Остановить сервис",
"f4d4bae588c4c0ff": "Снять выделение со всех",
"f4f0ead1116b5b62": "Включить",
"f56c6c82203b33f6": "Уведомление",
"f61e03f047b786d5": "Максимальное число выходных токенов {0} должно быть положительным целым числом",
+12 -17
View File
@@ -1,6 +1,5 @@
{
"02216368edc68816": "无更新说明",
"02bc2e95bf49e587": "当前还没有配置任何",
"03b11112dc970014": "接口地址",
"04f632dd4f034d5e": "{0} 的上下文窗口必须为正整数",
"051836569928a9f9": "编辑",
@@ -30,7 +29,6 @@
"281eb6d08c9960d0": "{0} 的思考预算 Token 必须为正整数",
"28aeffc70ceb4267": "切换当前界面显示语言,设置会立即生效并保存在本机",
"2a24519398684ed5": "访问主页",
"2c18d5e2d70b45db": "模型前缀",
"2cd0f3be8738a86c": "取消",
"2d706f7981b45a7b": "本地配置已保存",
"2f9daa828907b93f": "删除",
@@ -42,14 +40,11 @@
"3468b57e3edbc599": "按历史记录里扫描到的回合 summary 汇总。",
"35076178fe79a210": "配置已变更,请重新测试",
"358f07b2c1445ab1": "作者寄语",
"36c149a9b3e8dca0": "模型。",
"37d23612f78a2e63": "立即重启更新",
"392d0dceb45998d3": "极高",
"393df9bb13ea4900": "命中",
"3a5040b68abf75f9": "全选",
"3ab8cc15939f3b5c": "退出登录",
"3af7e5489e61ea51": "刷新中",
"3b2e5f2fba1bcbc7": "请输入接口地址和访问密钥",
"3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic",
"3d13868593ae4eeb": "界面语言",
"3d52574ce1500561": "未连接",
@@ -63,19 +58,20 @@
"472642d58d3d5a6d": "公式:{0}",
"4923eeb7bd75cccd": "{0} 的模型标识不能为空",
"497c85690c4cc0fc": "暂无数据",
"4b5e0ae1288a9695": "没有匹配的模型",
"4c0a929bb86ce912": "当前:{0}",
"4d2b6e53be6002e5": "缓存统计策略:{0}{1}",
"4d8c1c5b42830791": "未知",
"4f0982ba1d37e51b": "当前出站请求使用环境变量代理",
"51194c3ad014fb29": "需重测",
"5205125c0e91d346": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。",
"54e6745ff43c9c74": "排序失败",
"56627c94a9decee6": "最大输出 Token",
"58c6b0935a7216da": "打开贡献者主页失败",
"593a972852ba0004": "Cursor助手|永久免费|自定义API",
"59a2195a01a8b35b": "{0}必须是合法 JSON 对象",
"5a5c8318ef649672": "已选择 {0} 项",
"5aa8f5590c940829": "非缓存输入:{0}",
"5beb1206c532729f": "单次回复允许生成的最大 Token 数。留空时使用默认值。",
"5c55a67935af8f45": "全部",
"5d1687a4a41883fd": "停止中...",
"5e709712ce012f5d": "当前出站请求使用环境变量代理:{0}",
"6106f0a12583a334": "刷新失败",
@@ -97,27 +93,26 @@
"6e584e3d5ce64aa0": "保存配置",
"6ec87609a8769425": "默认 {0} / 计入创建 {1}",
"72f6c3525c0192a7": "开启后把缓存创建纳入分母",
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
"737225e2904673fc": "输出推算:{0}",
"7520bd50a5ee5471": "停止测试 {0}/{1}",
"753d8bb0da9913ce": "复制失败",
"75b5f4c68c79322c": "请先选择要保存的模型",
"774d6e1b7cb89751": "默认口径",
"77c9e582e85583af": "测试失败",
"7923d007483ae04c": "没有可保存的模型",
"7a26bf794e9fb6bf": "仅用于界面展示,便于你区分不同模型。",
"7b6187c41e88b70c": "测试中...",
"7bf8e2c07e084d09": "模型编辑",
"7df7641e5e741346": "缓存读取 /(缓存读取 + 缓存创建 + 非缓存输入)",
"7e9e334aeb0bdc07": "服务操作失败",
"7f68ebad19ba6bcd": "检查更新",
"80296f4aa3f4543b": "缓存读写",
"81123c56d5d880d0": "访问密钥",
"8139cb3dd11f5a67": "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。",
"826c0e5a4407befa": "可多选。保存时只会写入选中的模型,每个模型生成一条配置。",
"8151e8704a7ca89e": "没有匹配项",
"83913e71fcf7ff60": "刷新成功",
"83be9cac28873059": "Cursor 控制面账号",
"83fcfb4c1f2c1641": "获取模型",
"8672864e90417138": "最高",
"86df7ec743047234": "服务运行中",
"891bfee3bbe52d3c": "请填写模型标识",
"899add6275682210": "留空时默认 200000",
"8a4ef3e48e4e8a5a": "已开启",
"8c1935935600e336": "模型测试",
@@ -130,7 +125,6 @@
"917b1c1f18d0276b": "保存中...",
"9196835e388d2550": "测试全部",
"91cba5c107a51892": "/ 异常",
"92059fe6cd713db4": "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
"93e08803675e378b": "模型标识",
"93faf55cd25c8319": "本软件是纯免费软件,如果你被收费,那大概率就是被骗了。\n欢迎点击访问作者主页 https://space.bilibili.com/311706663/upload/video\n查看更多更新动态、使用分享和后续内容。",
"942ff2d88baca0c6": "检查更新中...",
@@ -143,7 +137,6 @@
"9c41b3a9e12ac994": "思考强度",
"9d2ca261281a158a": "稍后",
"9d2fb46c0ba890b9": "自定义路径",
"9dc0825fba5422e4": "加载中...",
"9e02529bcaef36c6": "模型渠道重复,请检查 url、modelID、apiKey、displayName、endpoint 组合",
"a026f37e613cf48b": "输出 Token",
"a0d36236c523667c": "{0} 的 Anthropic 思考强度仅支持 low、medium、high、xhigh、max",
@@ -161,6 +154,7 @@
"ac217e4d1ca410f1": "发现新版本",
"ad79540418be700a": "打开设置目录,或单独管理模型配置",
"ae5a738238463a92": "隐藏访问密钥",
"aeb1e3a8ff46cda1": "当前还没有配置任何模型。",
"aed55419ce62f08e": "切换中...",
"b10041a13f5c55b1": "模型输出:{0} × ${1}/1M = {2}",
"b1c27820fec23edb": "高",
@@ -181,14 +175,15 @@
"c228558cf257fc49": "删除失败",
"c3d46b387eeadb23": "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?",
"c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
"c6868592796ac2b2": "当前还没有配置任何 {0} 模型。",
"c69f5bce63b9f14c": "设置文件夹",
"c72d5dc20cd27118": "已选择 {0} / {1} 个模型",
"c8a52b66651d294c": "退出登录失败",
"c8c14507b2d37395": "推理强度",
"c98e118e0a43f078": "模型",
"c9dd59beefd7144f": "缓存读取 /(缓存读取 + 非缓存输入)",
"ca00a39fcea70dc6": "启动中...",
"ca1d1059408b3837": "异常轮次:{0}",
"cc5049729a2c10f1": "测试失败,请查看原始信息",
"cd7ca5fb221e1c53": "{0}不能为空",
"cfa6c803eb3fc713": "等待浏览器登录",
"d0325067fed88e5a": "缓存命中率 {0}",
@@ -216,7 +211,6 @@
"e4c0daa3c4bea691": "感谢 @aike0210 对 Cursor 控制面账号功能的贡献。",
"e53580f8031f13c0": "请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场",
"e552c2accdbf5178": "新增模型",
"e6943d5cbfb863e0": "正在获取模型...",
"e6faccfddce722e8": "缓存读取:{0}",
"e8a0a6053998ebfa": "已经登录",
"eaffd48cd2ea9f1a": "例如:https://api.anthropic.com",
@@ -224,12 +218,13 @@
"ec3b17a75db49e24": "{0} t/s | 首字 {1}",
"ec99e5c45d648fd6": "更新失败",
"ee95057c6b0335d2": "当前出站请求使用系统代理:{0}",
"f0b6a23368dd47cc": "可以直接输入模型标识,或从服务端返回的列表中选择。",
"f1aa7326f38b4c09": "拖拽排序",
"f1e0fc261d42fe29": "模型列表 hover 时显示的备注说明。",
"f363622480699c52": "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。",
"f3a76d896853c1df": "未命中",
"f3fae6cccb9004b1": "自定义请求头名称不能为空",
"f474a4108aba4c4c": "关闭服务",
"f4d4bae588c4c0ff": "取消全选",
"f4f0ead1116b5b62": "启用",
"f56c6c82203b33f6": "提示",
"f61e03f047b786d5": "{0} 的最大输出 Token 必须为正整数",
+8 -12
View File
@@ -89,7 +89,7 @@ async function handleCheckForUpdates() {
if (updateViewState.footerBusy || updateViewState.footerDownloading) {
return;
}
const loadingMessageID = message.loading("检查更新中...");
const loadingMessageID = message("检查更新中...", { duration: 0 });
try {
await checkForAppUpdates();
} finally {
@@ -107,13 +107,9 @@ async function loadFooterAuthorInfo() {
}
}
async function showActionError(title, error) {
await showModal({
title,
content: String(error || "操作失败").trim() || "操作失败",
confirmText: "确定",
showCancel: false,
});
function showActionError(title, error) {
const detail = String(error || "操作失败").trim() || "操作失败";
message(`${title}${detail}`);
}
async function handleOpenAuthorHome() {
@@ -133,7 +129,7 @@ async function handleOpenAuthorHome() {
try {
await openFooterAuthorHome();
} catch (error) {
await showActionError("打开主页失败", error);
showActionError("打开主页失败", error);
}
}
@@ -141,7 +137,7 @@ async function handleOpenUsageDocs() {
try {
await Browser.OpenURL(usageDocsURL);
} catch (error) {
await showActionError("打开使用教程失败", error);
showActionError("打开使用教程失败", error);
}
}
@@ -175,7 +171,7 @@ onUnmounted(() => {
:class="{ '!justify-center': !isWindows }"
>
<div class="center-row gap-2" style="font-family: var(--font-num);">
<img v-if="showIcon" :src="Logo" class="w-[18px] h-[18px]" />
<!-- <img v-if="showIcon" :src="Logo" class="w-[18px] h-[18px]" /> -->
<div>{{ title }}</div>
</div>
<div
@@ -203,7 +199,7 @@ onUnmounted(() => {
<footer
v-if="showFooter"
class="flex !pr-1 h-[30px] shrink-0 items-center gap-[8px] border-t border-[#242424] px-[14px] text-[12px] text-[#8f8f8f]"
class="flex !pr-1 h-[30px] shrink-0 items-center gap-[8px] px-[14px] text-[12px] text-[#8f8f8f]"
>
<div
v-if="proxyBadgeText"
-6
View File
@@ -1,7 +1,6 @@
import { createRouter, createWebHashHistory } from "vue-router";
import Home from "@/views/Home.vue";
import ModelConfig from "@/views/ModelConfig.vue";
import ModelEditor from "@/views/ModelEditor.vue";
const router = createRouter({
history: createWebHashHistory(),
@@ -16,11 +15,6 @@ const router = createRouter({
component: ModelConfig,
meta: { showIcon: false, title: "模型配置", directlyClose: true },
},
{
path: "/model-editor",
component: ModelEditor,
meta: { showIcon: false, title: "模型编辑", directlyClose: true },
},
],
});
-12
View File
@@ -18,12 +18,10 @@ import {
GetAppVersion,
GetFooterAuthorInfo,
InstallReadyUpdate,
GetModelEditorContext,
OpenConfigWindow,
OpenFooterAuthorHome,
OpenHistoryWindow,
OpenModelConfigWindow,
OpenModelEditorWindow,
} from "@bindings/cursor/internal/bridge/windowservice.js";
import { Call } from "@wailsio/runtime";
@@ -133,16 +131,6 @@ export function openModelConfig() {
return withApiLogging("OpenModelConfigWindow", undefined, () => 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) => {
+79 -115
View File
@@ -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();
}
+42 -4
View File
@@ -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;
+9 -12
View File
@@ -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));
}
}
+18 -14
View File
@@ -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(() => {
</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-4 overflow-y-auto p-4 pt-0 text-[#e5e5e5]">
<div class="flex h-full min-h-0 flex-col gap-4 overflow-y-auto scroll-shadow-bottom p-4 pt-0 text-[#e5e5e5]">
<HomeMetricsCard
:metrics="appState.homeMetrics"
:loading="appState.homeMetricsLoading"
@@ -148,7 +152,7 @@ onBeforeUnmount(() => {
<Card>
<div class="flex flex-col gap-4">
<div class="flex items-start justify-between gap-4">
<div class="center-row justify-between gap-4">
<div class="flex flex-col gap-1">
<div class="text-sm" :class="appViewState.serviceStatusClass">
{{ appViewState.serviceStatusText }}
@@ -172,7 +176,7 @@ onBeforeUnmount(() => {
<CursorAccountCard />
<Card>
<Card class="">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-base font-medium text-white">本地配置</h2>
+225 -55
View File
@@ -1,39 +1,56 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import ContentModal from "@/components/ui/ContentModal.vue";
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue";
import { showModal } from "@/composables/useModal";
import ModelEditor from "@/components/ModelEditor.vue";
import { useMessage } from "@/composables/useMessage";
import Sortable from "sortablejs";
import {
appState,
createEmptyModelAdapter,
deleteModelAdapterAt,
duplicateModelAdapterAt,
getModelAdapterTestResultByID,
openModelEditorWindow,
reloadUserConfig,
runModelAdapterTest,
saveModelAdapterOrder,
startModelAdapterTest,
toUserError,
} from "@/state/appState";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
const BATCH_TEST_CONCURRENCY = 10;
const message = useMessage();
const typeTabs = [
{ label: "全部", value: "all", icon: "icon-[mdi--view-grid-outline]" },
{ label: "OpenAI", value: "openai", icon: "icon-[bxl--openai]" },
{ label: "Anthropic", value: "anthropic", icon: "icon-[logos--claude-icon]" },
];
const activeType = ref("openai");
const activeType = ref("all");
const batchTesting = ref(false);
const batchStopping = ref(false);
const batchTotal = ref(0);
const batchCompleted = ref(0);
const editorOpen = ref(false);
const editorIndex = ref(-1);
const editorAdapter = ref(null);
const editorSession = ref(0);
const modelGrid = ref(null);
const sortSaving = ref(false);
const batchActiveCalls = new Set();
let batchStopRequested = false;
let sortable = null;
const filteredAdapters = computed(() =>
appState.modelAdapters.filter((adapter) => adapter.type === activeType.value),
const filteredAdapters = computed(() => (
activeType.value === "all"
? appState.modelAdapters
: appState.modelAdapters.filter((adapter) => adapter.type === activeType.value)
));
const filteredAdapterOrderKey = computed(() =>
filteredAdapters.value.map((adapter) => adapter.id).join("\n"),
);
const batchButtonText = computed(() => {
if (batchStopping.value) {
@@ -44,24 +61,30 @@ const batchButtonText = computed(() => {
}
return `停止测试 ${batchCompleted.value}/${batchTotal.value}`;
});
const editorTitle = computed(() => (editorIndex.value >= 0 ? "编辑模型配置" : "新增模型配置"));
const emptyStateText = computed(() => (
activeType.value === "all"
? "当前还没有配置任何模型。"
: `当前还没有配置任何 ${typeLabel(activeType.value)} 模型。`
));
watch(
() => appState.modelAdapters,
(adapters) => {
if (adapters.some((adapter) => adapter.type === activeType.value)) {
if (
activeType.value === "all"
|| 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";
activeType.value = "all";
},
{ deep: true, immediate: true },
);
async function showActionError(title, error) {
await showModal({
title,
content: String(error || "服务错误").trim() || "服务错误",
});
function showActionError(title, error) {
const detail = String(error || "服务错误").trim() || "服务错误";
message(`${title}${detail}`);
}
function maskSecret(value) {
@@ -92,41 +115,167 @@ function formatHost(value) {
}
}
async function openEditor(index = -1) {
const adapter = index >= 0
function openEditor(index = -1) {
editorIndex.value = index;
editorAdapter.value = index >= 0
? appState.modelAdapters[index]
: {
...createEmptyModelAdapter(),
type: activeType.value,
type: activeType.value === "anthropic" ? "anthropic" : "openai",
};
try {
await openModelEditorWindow(index, adapter);
} catch (error) {
await showActionError("打开失败", toUserError(error));
editorSession.value += 1;
editorOpen.value = true;
}
function closeEditor() {
if (appState.configSaving) {
return;
}
editorOpen.value = false;
}
function handleEditorSaved(adapter) {
if (activeType.value !== "all" && adapter?.type) {
activeType.value = adapter.type;
}
}
function destroySortable() {
if (!sortable) {
return;
}
sortable.destroy();
sortable = null;
}
function syncSortable() {
const element = modelGrid.value;
if (!element) {
destroySortable();
return;
}
if (!sortable || sortable.el !== element) {
destroySortable();
sortable = Sortable.create(element, {
animation: 160,
dataIdAttr: "data-model-id",
draggable: ".model-sort-item",
handle: ".model-sort-handle",
ghostClass: "opacity-40",
chosenClass: "!border-[#10AD5D]",
dragClass: "cursor-grabbing",
onEnd: (event) => {
void handleModelSort(event);
},
});
}
sortable.option(
"disabled",
sortSaving.value || appState.configSaving || batchTesting.value,
);
sortable.sort(filteredAdapters.value.map((adapter) => adapter.id), false);
}
async function restoreModelOrder(previousAdapters) {
try {
await reloadUserConfig({ modelAdaptersOnly: true });
} catch (_error) {
appState.modelAdapters = previousAdapters;
}
}
async function handleModelSort(event) {
const oldIndex = event.oldDraggableIndex ?? event.oldIndex;
const newIndex = event.newDraggableIndex ?? event.newIndex;
if (
!Number.isInteger(oldIndex)
|| !Number.isInteger(newIndex)
|| oldIndex === newIndex
) {
syncSortable();
return;
}
const reorderedTypeAdapters = filteredAdapters.value.slice();
const [movedAdapter] = reorderedTypeAdapters.splice(oldIndex, 1);
if (!movedAdapter || newIndex < 0 || newIndex > reorderedTypeAdapters.length) {
syncSortable();
return;
}
reorderedTypeAdapters.splice(newIndex, 0, movedAdapter);
const previousAdapters = appState.modelAdapters.slice();
let nextAdapters = reorderedTypeAdapters;
if (activeType.value !== "all") {
let typeIndex = 0;
nextAdapters = previousAdapters.map((adapter) => {
if (adapter.type !== activeType.value) {
return adapter;
}
const nextAdapter = reorderedTypeAdapters[typeIndex];
typeIndex += 1;
return nextAdapter;
});
}
nextAdapters = nextAdapters
.map((adapter, index) => ({
...adapter,
sort: index + 1,
}));
sortSaving.value = true;
appState.modelAdapters = nextAdapters;
try {
const result = await saveModelAdapterOrder(nextAdapters.map((adapter) => adapter.id));
if (!result.ok) {
await restoreModelOrder(previousAdapters);
showActionError("排序失败", result.error);
}
} catch (error) {
await restoreModelOrder(previousAdapters);
showActionError("排序失败", toUserError(error));
} finally {
sortSaving.value = false;
await nextTick();
syncSortable();
}
}
watch(
() => [
modelGrid.value,
activeType.value,
filteredAdapterOrderKey.value,
appState.configSaving,
batchTesting.value,
],
() => {
void nextTick().then(syncSortable);
},
{ flush: "post" },
);
async function handleDeleteModelAdapter(index) {
const target = appState.modelAdapters[index];
if (!target) {
await showActionError("删除失败", "模型配置不存在,无法删除");
showActionError("删除失败", "模型配置不存在,无法删除");
return;
}
const result = await deleteModelAdapterAt(index);
if (!result.ok) {
await showActionError("删除失败", result.error);
showActionError("删除失败", result.error);
}
}
async function handleDuplicateModelAdapter(index) {
const target = appState.modelAdapters[index];
if (!target) {
await showActionError("复制失败", "模型配置不存在,无法复制");
showActionError("复制失败", "模型配置不存在,无法复制");
return;
}
const result = await duplicateModelAdapterAt(index);
if (!result.ok) {
await showActionError("复制失败", result.error);
showActionError("复制失败", result.error);
}
}
@@ -211,17 +360,20 @@ async function handleTestAllModelAdapters() {
onMounted(async () => {
await reloadUserConfig({ modelAdaptersOnly: true }).catch(() => { });
await nextTick();
syncSortable();
});
onBeforeUnmount(() => {
void stopBatchTesting();
destroySortable();
});
</script>
<template>
<div class="flex h-full min-h-0 flex-col p-4 pt-0 text-[#e5e5e5] overflow-hidden">
<div class="flex h-full min-h-0 flex-col pt-0 text-[#e5e5e5] overflow-hidden">
<div class="shrink-0 pb-4">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center justify-between gap-4 px-4">
<div class="center-row gap-2">
<button
v-for="tab in typeTabs"
@@ -240,37 +392,49 @@ onBeforeUnmount(() => {
<div class="center-row gap-2">
<Button
variant="default"
:disabled="appState.configSaving || (!batchTesting && filteredAdapters.length === 0)"
:disabled="sortSaving || appState.configSaving || (!batchTesting && filteredAdapters.length === 0)"
@click="handleTestAllModelAdapters"
>
{{ batchButtonText }}
</Button>
<Button variant="primary" :disabled="appState.configSaving || batchTesting" @click="openEditor()">新增模型</Button>
<Button variant="primary" :disabled="sortSaving || appState.configSaving || batchTesting" @click="openEditor()">新增模型</Button>
</div>
</div>
</div>
<div class="min-h-0 flex-1">
<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-[8px] border border-dashed border-[#3a3a3a] bg-[#232323] px-4 text-sm text-[#a3a3a3]">
当前还没有配置任何 {{ typeLabel(activeType) }} 模型
class="flex h-full min-h-[220px] items-center justify-center rounded-[8px] px-4 text-sm text-[#a3a3a3]">
{{ emptyStateText }}
</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))]">
<div v-else class="h-full min-h-0 overflow-y-auto scroll-shadow-bottom p-4 pt-0">
<div
ref="modelGrid"
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}`"
class="model-sort-item group relative pb-2"
:data-model-id="adapter.id"
>
<div class="flex h-full min-h-[154px] flex-col justify-between gap-3">
<button
type="button"
class="model-sort-handle w-[30px] h-[30px] center-row justify-center absolute left-2 top-2 z-10 shrink-0 touch-none cursor-grab rounded-[6px] border border-transparent bg-transparent text-transparent opacity-0 outline-none transition-[opacity,color,border-color,background-color] focus-visible:border-[#10AD5D] focus-visible:bg-[#333333] focus-visible:text-white focus-visible:opacity-100 active:cursor-grabbing group-hover:border-[#454545] group-hover:bg-[#333333] group-hover:text-white group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30"
:disabled="sortSaving || appState.configSaving || batchTesting"
aria-label="拖拽排序"
title="拖拽排序"
@click.stop
>
<span class="icon-[icon-park-outline--drag] text-[20px]"></span>
</button>
<div class="flex h-[150px] 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="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-[#8f8f8f]">{{ adapter.modelID }}</div>
<div v-if="adapter.type === 'openai'" class="mt-0.5 truncate text-xs text-[#737373]">
{{ adapter.openAIEndpoint || "/v1/responses" }}
</div>
</div>
<span
class="center-row shrink-0 gap-1 rounded-[999px] border border-[#3f3f3f] px-[7px] py-[4px] text-[11px] font-medium text-[#cfcfcf]"
@@ -281,17 +445,6 @@ onBeforeUnmount(() => {
</span>
</div>
<div class="grid grid-cols-2 gap-2 text-sm text-[#a3a3a3]">
<div class="rounded-[8px] bg-[#232323] px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">Host</div>
<div class="mt-1 truncate text-[#d4d4d4]" :title="adapter.baseURL">{{ formatHost(adapter.baseURL) }}</div>
</div>
<div class="rounded-[8px] bg-[#232323] px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">API Key</div>
<div class="mt-1 truncate text-[#d4d4d4]">{{ maskSecret(adapter.apiKey) }}</div>
</div>
</div>
<ModelAdapterTestCard
compact
title="测试"
@@ -300,17 +453,17 @@ onBeforeUnmount(() => {
/>
</div>
<div class="center-row flex-wrap justify-end gap-2 border-t border-[#343434] pt-3">
<div class="center-row flex-wrap justify-end gap-2 pt-0">
<Button
variant="default"
:disabled="appState.configSaving || batchTesting || isAdapterTesting(adapter)"
:disabled="sortSaving || 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"
<Button variant="default" :disabled="sortSaving || appState.configSaving" @click="openEditor(appState.modelAdapters.indexOf(adapter))">编辑</Button>
<Button variant="default" :disabled="sortSaving || appState.configSaving" @click="handleDuplicateModelAdapter(appState.modelAdapters.indexOf(adapter))">复制</Button>
<Button variant="text" :disabled="sortSaving || appState.configSaving"
@click="handleDeleteModelAdapter(appState.modelAdapters.indexOf(adapter))">删除</Button>
</div>
</div>
@@ -319,4 +472,21 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
<ContentModal
:open="editorOpen"
:title="editorTitle"
size="xl"
:close-disabled="appState.configSaving"
@close="closeEditor"
>
<ModelEditor
v-if="editorOpen && editorAdapter"
:key="editorSession"
:index="editorIndex"
:adapter="editorAdapter"
@saved="handleEditorSaved"
@close="closeEditor"
/>
</ContentModal>
</template>
+5
View File
@@ -1587,6 +1587,11 @@ semver@^6.3.1:
resolved "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
sortablejs@^1.15.7:
version "1.15.7"
resolved "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz#83a0bddc472117ee328dea20b2e6f490fed20f86"
integrity sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==
source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
+3 -3
View File
@@ -215,9 +215,9 @@ func Run(resources EmbeddedResources) error {
mainWindow = app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: appName,
Width: 700,
Height: 520,
MinWidth: 640,
MinHeight: 480,
Height: 530,
MinWidth: 700,
MinHeight: 530,
DisableResize: false,
Frameless: goruntime.GOOS == "windows",
URL: "/",
+1 -1
View File
@@ -839,7 +839,7 @@ func (adapter *OpenAIAdapter) streamChatCompletions(ctx context.Context, req Str
}
}
if choice.FinishReason != nil {
if choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != "" {
if err := flushTaggedContentTail(); err != nil {
return fail(err)
}
@@ -0,0 +1,95 @@
package modeladapter
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestOpenAIChatCompletionsIgnoresBlankFinishReason(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "text/event-stream")
chunks := []string{
`{"model":"deepseek-v4-flash","choices":[{"delta":{"reasoning_content":"first"},"finish_reason":""}]}`,
`{"model":"deepseek-v4-flash","choices":[{"delta":{"reasoning_content":" second"},"finish_reason":""}]}`,
`{"model":"deepseek-v4-flash","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"Ls","arguments":""}}]},"finish_reason":""}]}`,
`{"model":"deepseek-v4-flash","choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"path\":"}}]},"finish_reason":""}]}`,
`{"model":"deepseek-v4-flash","choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"\"/tmp\"}"}}]},"finish_reason":"tool_calls"}]}`,
`{"model":"deepseek-v4-flash","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":7}}`,
}
for _, chunk := range chunks {
_, _ = fmt.Fprintf(writer, "data: %s\n\n", chunk)
}
_, _ = fmt.Fprint(writer, "data: [DONE]\n\n")
}))
defer server.Close()
adapter := &OpenAIAdapter{client: server.Client()}
events := make([]ModelEvent, 0, 8)
err := adapter.Stream(context.Background(), StreamRequest{
RequestID: "request-1",
RunID: "run-1",
ModelCallID: "model-call-1",
BaseURL: server.URL,
APIKey: "test-key",
ProviderModelID: "deepseek-v4-flash",
OpenAIEndpoint: "/v1/chat/completions",
Messages: []Message{{Role: "user", Content: "list files"}},
MaxTokens: 128,
RequestKnobs: map[string]any{},
}, func(event ModelEvent) error {
events = append(events, event)
return nil
})
if err != nil {
t.Fatalf("stream failed: %v", err)
}
assertOpenAIEventKindCount(t, events, ModelEventKindThinkingDelta, 2)
assertOpenAIEventKindCount(t, events, ModelEventKindThinkingCompleted, 1)
assertOpenAIEventKindCount(t, events, ModelEventKindToolLikeCompleted, 1)
assertOpenAIEventKindCount(t, events, ModelEventKindTurnFinished, 1)
toolEvent := firstOpenAIEventForTest(events, ModelEventKindToolLikeCompleted)
if toolEvent == nil || toolEvent.ToolInvocation == nil {
t.Fatalf("completed tool invocation missing: %#v", toolEvent)
}
if toolEvent.ToolInvocation.ToolName != "Ls" {
t.Fatalf("tool name = %q, want Ls", toolEvent.ToolInvocation.ToolName)
}
if got := string(toolEvent.ToolInvocation.ArgsJSON); got != `{"path":"/tmp"}` {
t.Fatalf("tool args = %q, want %q", got, `{"path":"/tmp"}`)
}
finished := firstOpenAIEventForTest(events, ModelEventKindTurnFinished)
if finished == nil || finished.FinishReason != "tool_calls" {
t.Fatalf("finish reason = %q, want tool_calls", finished.FinishReason)
}
if finished.InputTokens != 12 || finished.OutputTokens != 7 {
t.Fatalf("usage = input:%d output:%d, want input:12 output:7", finished.InputTokens, finished.OutputTokens)
}
}
func assertOpenAIEventKindCount(t *testing.T, events []ModelEvent, kind ModelEventKind, want int) {
t.Helper()
got := 0
for _, event := range events {
if event.Kind == kind {
got++
}
}
if got != want {
t.Fatalf("event kind %s count = %d, want %d; events=%#v", kind, got, want, events)
}
}
func firstOpenAIEventForTest(events []ModelEvent, kind ModelEventKind) *ModelEvent {
for index := range events {
if events[index].Kind == kind {
return &events[index]
}
}
return nil
}
+40 -5
View File
@@ -94,6 +94,13 @@ func (service *Service) queueCheckpointProjection(stream *ActiveStream, projecti
if service.checkpointProjectionReady(stream) {
return service.publishReadyCheckpoint(stream)
}
// Keep the latest live UI state ahead of an immediate client abort. Blob writes are
// ordered before this snapshot; acknowledgements still gate terminal completion.
if completion == nil {
if err := service.publishPendingCheckpoint(stream); err != nil {
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf("publish pending checkpoint: %w", err))
}
}
service.scheduleStreamTimer(
stream,
providerTimerKey(streamTimerCheckpointBlobs, ""),
@@ -106,6 +113,31 @@ func (service *Service) queueCheckpointProjection(stream *ActiveStream, projecti
return nil
}
func (service *Service) publishPendingCheckpoint(stream *ActiveStream) error {
if service == nil || stream == nil {
return nil
}
stream.mu.Lock()
pending := stream.PendingCheckpoint
if pending == nil || pending.Published {
stream.mu.Unlock()
return nil
}
pending.Published = true
state := pending.State
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
if err := service.broker.Publish(stream.RequestID, StreamEvent{Message: buildCheckpointMessage(state)}); err != nil {
stream.mu.Lock()
if stream.PendingCheckpoint == pending {
pending.Published = false
}
stream.mu.Unlock()
return err
}
return nil
}
func (service *Service) checkpointProjectionReady(stream *ActiveStream) bool {
if stream == nil {
return false
@@ -176,15 +208,18 @@ func (service *Service) publishReadyCheckpoint(stream *ActiveStream) error {
stream.PendingCheckpoint = nil
state := pending.State
completion := clonePendingTurnCompletion(pending.Completion)
published := pending.Published
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
if err := service.broker.Publish(stream.RequestID, StreamEvent{Message: buildCheckpointMessage(state)}); err != nil {
if completion != nil {
log.Printf("forwarder checkpoint publish skipped before successful terminal request_id=%s err=%v", stream.RequestID, err)
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
if !published {
if err := service.broker.Publish(stream.RequestID, StreamEvent{Message: buildCheckpointMessage(state)}); err != nil {
if completion != nil {
log.Printf("forwarder checkpoint publish skipped before successful terminal request_id=%s err=%v", stream.RequestID, err)
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
}
return err
}
return err
}
if completion != nil {
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
@@ -8,26 +8,34 @@ import (
"cursor/gen/agentv1"
)
func TestCheckpointBlobSyncPublishesCheckpointAfterAcknowledgements(t *testing.T) {
func TestCheckpointBlobSyncPublishesNonTerminalCheckpointBeforeAcknowledgements(t *testing.T) {
service, stream, projection := testCheckpointBlobProjection(t)
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err)
}
events := readCheckpointTestEvents(t, service, stream)
if len(events) != len(projection.Blobs) {
t.Fatalf("events before ACK = %d, want %d Blob writes", len(events), len(projection.Blobs))
if len(events) != len(projection.Blobs)+1 {
t.Fatalf("events before ACK = %d, want %d Blob writes and one checkpoint", len(events), len(projection.Blobs))
}
for _, event := range events {
for _, event := range events[:len(projection.Blobs)] {
if event.Message.GetKvServerMessage().GetSetBlobArgs() == nil {
t.Fatalf("event before ACK = %#v, want set_blob_args", event.Message)
}
}
if checkpoint := events[len(events)-1].Message.GetConversationCheckpointUpdate(); checkpoint == nil || len(checkpoint.GetTurns()) != 1 {
t.Fatalf("last event before ACK = %#v, want one Blob-backed turn", events[len(events)-1].Message)
}
acknowledgeCheckpointBlobs(t, service, stream)
events = readCheckpointTestEvents(t, service, stream)
checkpoint := events[len(events)-1].Message.GetConversationCheckpointUpdate()
if checkpoint == nil || len(checkpoint.GetTurns()) != 1 {
t.Fatalf("last event checkpoint = %#v, want one Blob-backed turn", checkpoint)
checkpointCount := 0
for _, event := range events {
if event.Message.GetConversationCheckpointUpdate() != nil {
checkpointCount++
}
}
if checkpointCount != 1 {
t.Fatalf("checkpoints after ACK = %d, want 1", checkpointCount)
}
}
@@ -40,6 +48,12 @@ func TestCheckpointBlobSyncPublishesCheckpointBeforeSuccessfulTerminal(t *testin
if err := service.queueCheckpointProjection(stream, projection, completion); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err)
}
eventsBeforeACK := readCheckpointTestEvents(t, service, stream)
for _, event := range eventsBeforeACK {
if event.Message.GetConversationCheckpointUpdate() != nil || event.Message.GetInteractionUpdate().GetTurnEnded() != nil || event.End {
t.Fatalf("event before ACK = %#v, want only Blob writes", event)
}
}
acknowledgeCheckpointBlobs(t, service, stream)
events := readCheckpointTestEvents(t, service, stream)
@@ -84,11 +98,21 @@ func TestCheckpointBlobTimeoutDoesNotFailSuccessfulTurn(t *testing.T) {
}
}
func TestCancellationDiscardsPendingCheckpointAndIgnoresLateAcknowledgements(t *testing.T) {
func TestCancellationKeepsPublishedCheckpointAndIgnoresLateAcknowledgements(t *testing.T) {
service, stream, projection := testCheckpointBlobProjection(t)
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err)
}
eventsBeforeCancel := readCheckpointTestEvents(t, service, stream)
checkpointBeforeCancel := 0
for _, event := range eventsBeforeCancel {
if event.Message.GetConversationCheckpointUpdate() != nil {
checkpointBeforeCancel++
}
}
if checkpointBeforeCancel != 1 {
t.Fatalf("checkpoints before cancel = %d, want 1", checkpointBeforeCancel)
}
stream.mu.Lock()
requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites))
for requestID := range stream.PendingCheckpointBlobWrites {
@@ -114,16 +138,19 @@ func TestCancellationDiscardsPendingCheckpointAndIgnoresLateAcknowledgements(t *
}
events := readCheckpointTestEvents(t, service, stream)
var checkpoint, canceledEnd bool
checkpointCount := 0
var canceledEnd bool
for _, event := range events {
checkpoint = checkpoint || event.Message.GetConversationCheckpointUpdate() != nil
if event.Message.GetConversationCheckpointUpdate() != nil {
checkpointCount++
}
canceledEnd = canceledEnd || event.End && event.TerminalErrorCode == "canceled"
}
stream.mu.Lock()
pending := stream.PendingCheckpoint
stream.mu.Unlock()
if checkpoint || !canceledEnd || pending != nil {
t.Fatalf("cancel events checkpoint=%v canceled_end=%v pending=%v", checkpoint, canceledEnd, pending != nil)
if checkpointCount != 1 || !canceledEnd || pending != nil {
t.Fatalf("cancel events checkpoints=%d canceled_end=%v pending=%v", checkpointCount, canceledEnd, pending != nil)
}
}
+24 -3
View File
@@ -621,10 +621,22 @@ func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpoin
}
grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry)
}
turnIDs := make([][]byte, 0, len(order))
logicalTurns := make([][]HistoryEntry, 0, len(order))
for _, turnSeq := range order {
entries := grouped[turnSeq]
if checkpointTurnHasUserMessage(entries) {
logicalTurns = append(logicalTurns, append([]HistoryEntry(nil), entries...))
continue
}
if len(logicalTurns) == 0 {
continue
}
last := len(logicalTurns) - 1
logicalTurns[last] = append(logicalTurns[last], entries...)
}
turnIDs := make([][]byte, 0, len(logicalTurns))
for _, entries := range logicalTurns {
completedToolCalls, err := collectCheckpointCompletedToolCalls(entries)
if err != nil {
return nil, err
@@ -734,7 +746,7 @@ func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpoin
})
}
}
if len(userMessageID) == 0 && len(steps) == 0 {
if len(userMessageID) == 0 {
continue
}
stepIDs := make([][]byte, 0, len(steps))
@@ -765,6 +777,15 @@ func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpoin
return turnIDs, nil
}
func checkpointTurnHasUserMessage(entries []HistoryEntry) bool {
for _, entry := range entries {
if strings.TrimSpace(entry.Kind) == "user_message" {
return true
}
}
return false
}
func collectCheckpointCompletedToolCalls(entries []HistoryEntry) (map[string]json.RawMessage, error) {
completed := make(map[string]json.RawMessage)
for _, entry := range entries {
@@ -87,6 +87,94 @@ func TestProjectCheckpointProjectionBuildsResolvableForkState(t *testing.T) {
}
}
func TestProjectCheckpointProjectionMergesResumeActivityIntoPreviousUserTurn(t *testing.T) {
userPayload, err := protojson.Marshal(&agentv1.UserMessage{
Text: "original question",
MessageId: "message-1",
})
if err != nil {
t.Fatalf("marshal user message: %v", err)
}
firstAnswer := newAssistantTextEntry(1, "request-1", "before resume", "", "")
firstAnswer.Seq = 2
resumedAnswer := newAssistantTextEntry(2, "request-resume", "after resume", "", "")
resumedAnswer.Seq = 3
conversation := &ConversationFile{
ConversationID: "conversation-1",
RootConversationID: "conversation-1",
Mode: "agent",
NextTurnSeq: 3,
NextEntrySeq: 4,
Entries: []HistoryEntry{
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload},
firstAnswer,
resumedAnswer,
},
}
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
if len(projection.State.GetTurns()) != 1 {
t.Fatalf("checkpoint turns = %d, want one logical user turn", len(projection.State.GetTurns()))
}
blobs := make(map[string][]byte, len(projection.Blobs))
for _, blob := range projection.Blobs {
blobs[string(blob.ID)] = blob.Data
}
turn := &agentv1.ConversationTurnStructure{}
if err := proto.Unmarshal(blobs[string(projection.State.GetTurns()[0])], turn); err != nil {
t.Fatalf("decode checkpoint turn: %v", err)
}
agentTurn := turn.GetAgentConversationTurn()
if agentTurn == nil {
t.Fatal("checkpoint turn does not contain an agent turn")
}
if len(agentTurn.GetUserMessage()) == 0 {
t.Fatal("checkpoint turn lost the original user message Blob id")
}
if _, ok := blobs[string(agentTurn.GetUserMessage())]; !ok {
t.Fatal("checkpoint turn references a missing user message Blob")
}
if agentTurn.GetRequestId() != "request-1" {
t.Fatalf("checkpoint request id = %q, want original request", agentTurn.GetRequestId())
}
steps := checkpointProjectionSteps(t, projection)
if len(steps) != 2 || steps[0].GetAssistantMessage().GetText() != "before resume" || steps[1].GetAssistantMessage().GetText() != "after resume" {
t.Fatalf("checkpoint steps did not preserve resumed activity: %#v", steps)
}
replay, err := promptengine.DecodeReplayMessages(projection.State.GetRootPromptMessagesJson())
if err != nil {
t.Fatalf("decode root prompt replay: %v", err)
}
if len(replay) != 3 || replay[0].Role != "user" || replay[1].Content != "before resume" || replay[2].Content != "after resume" {
t.Fatalf("checkpoint turn merge changed model replay: %#v", replay)
}
}
func TestProjectCheckpointProjectionOmitsActivityWithoutAnyUserMessage(t *testing.T) {
conversation := &ConversationFile{
ConversationID: "conversation-1",
Mode: "agent",
NextTurnSeq: 2,
Entries: []HistoryEntry{
newAssistantTextEntry(1, "request-resume", "orphaned resume output", "", ""),
},
}
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
if len(projection.State.GetTurns()) != 0 || len(projection.Blobs) != 0 {
t.Fatalf("checkpoint emitted a turn without a user message: %#v", projection)
}
}
func TestProjectCheckpointProjectionKeepsForkPointIsolatedFromLaterHistory(t *testing.T) {
firstUser, err := protojson.Marshal(&agentv1.UserMessage{Text: "first question", MessageId: "message-1"})
if err != nil {
@@ -288,6 +376,7 @@ func TestProjectCheckpointProjectionKeepsStartedToolCallWhenResultPayloadIsMissi
Mode: "agent",
NextTurnSeq: 2,
Entries: []HistoryEntry{
testCheckpointUserEntry(t),
newToolCallEntry(1, "request-1", "call-1", "Read", "", "", startedToolCall),
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "read failed", "", nil),
},
@@ -316,6 +405,7 @@ func TestProjectCheckpointProjectionAppendsLegacyResultWithoutToolCallEntry(t *t
Mode: "agent",
NextTurnSeq: 2,
Entries: []HistoryEntry{
testCheckpointUserEntry(t),
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "not readable", "", completedToolCall),
},
}
+7
View File
@@ -1736,6 +1736,13 @@ func (service *Service) handleToolInvocation(stream *ActiveStream, invocation ru
stream.ToolInvocationCount++
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
if !isKnownToolName(trimmedToolName) {
displayToolName := trimmedToolName
if displayToolName == "" {
displayToolName = "<empty>"
}
return service.completePreDispatchToolError(stream, invocation, nil, false, false, fmt.Errorf("Model hallucination: attempted to invoke a nonexistent tool: %s", displayToolName))
}
if !isToolAllowedInMode(mode, subagentTypeName, trimmedToolName) {
return service.completePreDispatchToolError(stream, invocation, nil, false, false, fmt.Errorf("tool invocation is not enabled in mode %s: %s", mode.String(), invocation.ToolName))
}
@@ -181,6 +181,25 @@ func supportedToolNamesForMode(mode agentv1.AgentMode) map[string]struct{} {
}
}
func isKnownToolName(toolName string) bool {
trimmedToolName := strings.TrimSpace(toolName)
if trimmedToolName == "" {
return false
}
for _, supported := range []map[string]struct{}{
agentModeToolNames,
askModeToolNames,
planModeToolNames,
debugModeToolNames,
multitaskModeToolNames,
} {
if _, ok := supported[trimmedToolName]; ok {
return true
}
}
return false
}
func isToolAllowedInMode(mode agentv1.AgentMode, subagentTypeName string, toolName string) bool {
trimmedToolName := strings.TrimSpace(toolName)
if trimmedToolName == "" {
+1
View File
@@ -228,6 +228,7 @@ type pendingCheckpointPublish struct {
State *agentv1.ConversationStateStructure
Required map[string]struct{}
Completion *pendingTurnCompletion
Published bool
}
type PendingCompaction struct {
@@ -16,6 +16,7 @@ func (store *Store) LegacyRuntimeSnapshot(ctx context.Context) (legacyruntime.Ru
for _, item := range cfg.ModelAdapters {
adapters = append(adapters, legacyruntime.ModelAdapterConfig{
ID: item.ID,
Sort: item.Sort,
DisplayName: item.DisplayName,
Type: item.Type,
BaseURL: item.BaseURL,
@@ -147,6 +147,7 @@ func (manager *Manager) LegacyRuntimeSnapshot(_ context.Context) (legacyruntime.
for _, item := range cfg.ModelAdapters {
adapters = append(adapters, legacyruntime.ModelAdapterConfig{
ID: item.ID,
Sort: item.Sort,
DisplayName: item.DisplayName,
Type: item.Type,
BaseURL: item.BaseURL,
+24
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net"
"sort"
"strconv"
"strings"
@@ -21,6 +22,7 @@ const (
type ModelAdapterConfig struct {
ID string `json:"id,omitempty" yaml:"-"`
Sort int `json:"sort" yaml:"sort"`
DisplayName string `json:"displayName" yaml:"displayName"`
Type string `json:"type" yaml:"type"`
BaseURL string `json:"baseURL" yaml:"baseURL"`
@@ -104,6 +106,7 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
}
nextType := normalizeModelAdapterType(item.Type)
next := ModelAdapterConfig{
Sort: item.Sort,
DisplayName: strings.TrimSpace(item.DisplayName),
Type: nextType,
BaseURL: baseURL,
@@ -164,9 +167,30 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
seenChannelIDs[next.ID] = struct{}{}
normalized = append(normalized, next)
}
normalizeModelAdapterSorts(normalized)
return normalized, nil
}
func normalizeModelAdapterSorts(adapters []ModelAdapterConfig) {
sort.SliceStable(adapters, func(leftIndex, rightIndex int) bool {
left := adapters[leftIndex].Sort
right := adapters[rightIndex].Sort
switch {
case left <= 0 && right <= 0:
return false
case left <= 0:
return false
case right <= 0:
return true
default:
return left < right
}
})
for index := range adapters {
adapters[index].Sort = index + 1
}
}
func validateJSONMap(value string, fieldName string) error {
text := strings.TrimSpace(value)
if text == "" {
@@ -0,0 +1,60 @@
package config
import "testing"
func testModelAdapter(displayName string, sortValue int) ModelAdapterConfig {
return ModelAdapterConfig{
Sort: sortValue,
DisplayName: displayName,
Type: "openai",
BaseURL: "https://api.example.com/v1",
APIKey: "test-key",
TooltipData: displayName,
ModelID: displayName,
ReasoningEffort: "medium",
OpenAIEndpoint: "/v1/responses",
}
}
func TestNormalizeModelAdapterConfigsPreservesLegacyArrayOrder(t *testing.T) {
adapters, err := NormalizeModelAdapterConfigs([]ModelAdapterConfig{
testModelAdapter("first", 0),
testModelAdapter("second", 0),
testModelAdapter("third", 0),
})
if err != nil {
t.Fatalf("NormalizeModelAdapterConfigs returned error: %v", err)
}
for index, expectedName := range []string{"first", "second", "third"} {
if adapters[index].DisplayName != expectedName {
t.Fatalf("adapter %d = %q, want %q", index, adapters[index].DisplayName, expectedName)
}
if adapters[index].Sort != index+1 {
t.Fatalf("adapter %d sort = %d, want %d", index, adapters[index].Sort, index+1)
}
}
}
func TestNormalizeModelAdapterConfigsUsesStableExplicitSort(t *testing.T) {
adapters, err := NormalizeModelAdapterConfigs([]ModelAdapterConfig{
testModelAdapter("legacy", 0),
testModelAdapter("third", 30),
testModelAdapter("first", 10),
testModelAdapter("second-a", 20),
testModelAdapter("second-b", 20),
})
if err != nil {
t.Fatalf("NormalizeModelAdapterConfigs returned error: %v", err)
}
expectedNames := []string{"first", "second-a", "second-b", "third", "legacy"}
for index, expectedName := range expectedNames {
if adapters[index].DisplayName != expectedName {
t.Fatalf("adapter %d = %q, want %q", index, adapters[index].DisplayName, expectedName)
}
if adapters[index].Sort != index+1 {
t.Fatalf("adapter %d sort = %d, want %d", index, adapters[index].Sort, index+1)
}
}
}
+2 -101
View File
@@ -15,19 +15,11 @@ import (
"github.com/wailsapp/wails/v3/pkg/events"
)
// modelEditorContext 保存当前模型编辑器窗口的初始化上下文。
type modelEditorContext struct {
Index int `json:"index"`
AdapterJSON string `json:"adapterJSON"`
}
// WindowService 定义了当前模块中的 WindowService 类型。
type WindowService struct {
app *application.App
updater *updater.Manager
modelConfigWindow *application.WebviewWindow
modelEditorWindow *application.WebviewWindow
editorCtx *modelEditorContext
mu sync.RWMutex
}
@@ -102,8 +94,8 @@ func (s *WindowService) OpenModelConfigWindow() {
Title: "模型配置",
Width: 980,
Height: 700,
MinWidth: 820,
MinHeight: 560,
MinWidth: 980,
MinHeight: 700,
DisableResize: false,
Frameless: goruntime.GOOS == "windows",
URL: "/#/model-config",
@@ -144,97 +136,6 @@ func (s *WindowService) OpenModelConfigWindow() {
s.modelConfigWindow = win
}
// OpenModelEditorWindow 打开模型编辑器独立窗口。
// index < 0 表示新增,>= 0 表示编辑对应索引的适配器。
// adapterJSON 为编辑器初始数据的 JSON 字符串。
func (s *WindowService) OpenModelEditorWindow(index int, adapterJSON string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.app == nil {
return
}
s.editorCtx = &modelEditorContext{
Index: index,
AdapterJSON: adapterJSON,
}
if s.modelEditorWindow != nil {
s.modelEditorWindow.Show()
s.modelEditorWindow.Focus()
return
}
title := "新增模型配置"
if index >= 0 {
title = "编辑模型配置"
}
win := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: title,
Width: 840,
Height: 680,
MinWidth: 740,
MinHeight: 600,
DisableResize: false,
Frameless: goruntime.GOOS == "windows",
URL: fmt.Sprintf("/#/model-editor?index=%d", index),
Hidden: false,
HideOnEscape: false,
MinimiseButtonState: application.ButtonEnabled,
MaximiseButtonState: application.ButtonEnabled,
CloseButtonState: application.ButtonEnabled,
BackgroundColour: application.RGBA{Red: 25, Green: 25, Blue: 25, Alpha: 255},
Mac: application.MacWindow{
Backdrop: application.MacBackdropLiquidGlass,
DisableShadow: false,
TitleBar: application.MacTitleBar{
AppearsTransparent: true,
Hide: false,
HideTitle: true,
FullSizeContent: true,
UseToolbar: false,
HideToolbarSeparator: true,
},
WebviewPreferences: application.MacWebviewPreferences{
FullscreenEnabled: u.False,
TextInteractionEnabled: u.True,
AllowsBackForwardNavigationGestures: u.False,
},
},
Windows: application.WindowsWindow{
HiddenOnTaskbar: false,
},
})
win.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
s.mu.Lock()
defer s.mu.Unlock()
s.modelEditorWindow = nil
s.editorCtx = nil
})
s.modelEditorWindow = win
}
// GetModelEditorContext 返回当前编辑器窗口的初始化上下文。
func (s *WindowService) GetModelEditorContext() map[string]any {
s.mu.RLock()
defer s.mu.RUnlock()
if s.editorCtx == nil {
return map[string]any{
"index": -1,
"adapterJSON": "{}",
}
}
return map[string]any{
"index": s.editorCtx.Index,
"adapterJSON": s.editorCtx.AdapterJSON,
}
}
// OpenHistoryWindow 用于处理与 OpenHistoryWindow 相关的逻辑。
func (s *WindowService) OpenHistoryWindow() {
_ = os.MkdirAll(client.ResolveLogsRootPath(), 0o755)
+25
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
@@ -36,6 +37,8 @@ const (
// ModelAdapterConfig 定义了当前模块中的 ModelAdapterConfig 类型。
type ModelAdapterConfig struct {
ID string `json:"id,omitempty"`
// Sort 表示模型渠道的展示顺序。
Sort int `json:"sort"`
// DisplayName 表示当前声明中的 DisplayName。
DisplayName string `json:"displayName"`
// Type 表示当前声明中的 Type。
@@ -103,6 +106,7 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
return nil, err
}
next := ModelAdapterConfig{
Sort: item.Sort,
DisplayName: strings.TrimSpace(item.DisplayName),
Type: normalizeModelAdapterType(item.Type),
BaseURL: baseURL,
@@ -163,9 +167,30 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
seenChannelIDs[next.ID] = struct{}{}
normalized = append(normalized, next)
}
normalizeModelAdapterSorts(normalized)
return normalized, nil
}
func normalizeModelAdapterSorts(adapters []ModelAdapterConfig) {
sort.SliceStable(adapters, func(leftIndex, rightIndex int) bool {
left := adapters[leftIndex].Sort
right := adapters[rightIndex].Sort
switch {
case left <= 0 && right <= 0:
return false
case left <= 0:
return false
case right <= 0:
return true
default:
return left < right
}
})
for index := range adapters {
adapters[index].Sort = index + 1
}
}
func validateJSONMap(value string, fieldName string) error {
text := strings.TrimSpace(value)
if text == "" {