Compare commits

...
Author SHA1 Message Date
leookun 9eb24bb4d4 feat: add shell tool call delta message handling
Implemented the buildShellToolCallDeltaMessage function to map client shell output to the delta format for Cursor's terminal bubble. This includes handling both stdout and stderr events. Updated the service to publish these messages when processing execution results. Additionally, added a test for enabling terminal output UI streaming in the bootstrap statsig configuration.
2026-08-07 22:39:58 +08:00
leokunandGitHub 426bdd6592 Merge pull request #276 from widwei/fix/anthropic-thinking-carrier
fix(anthropic): pass back thinking block for assistant turns without reasoning
2026-08-07 17:43:00 +08:00
weiwei fa36dc2c60 fix(anthropic): pass back thinking block for assistant turns without reasoning
DeepSeek 类 thinking 模型在 adaptive thinking 下部分 tool-call 轮次不输出
thinking 块,重放时适配器不生成 thinking 块,上游 Anthropic 兼容 API 在
thinking 模式下要求每个 assistant 轮次回传 thinking 块,导致
"The content[].thinking in the thinking mode must be passed back to the API."
400 且毒化会话历史,后续所有请求持续失败。

引入 thinking carrier:缺 reasoning 的轮次复用请求内最近一个有
reasoning+signature 的 assistant 轮次兜底,无 carrier 时输出空 thinking 块;
thinking 关闭时行为不变。与 openai.go 已有空 reasoning_content 兜底对称。

Closes #268
2026-08-07 17:17:03 +08:00
leokunandGitHub 4e649d676f Merge pull request #272 from leookun/release/0.0.46
Release/0.0.46
2026-08-07 02:41:46 +08:00
leookun 917a711b42 release: 0.0.46 2026-08-07 02:41:14 +08:00
leookun 270bcdb54e Merge branch 'main' into release/0.0.46 2026-08-07 02:28:54 +08:00
leokunandGitHub c6d719d9b9 Merge pull request #271 from leookun/feat/shangtang
refactor: checkpoint ui
2026-08-07 02:21:54 +08:00
leookun a7f9494828 fix: checkpoint 2026-08-07 02:19:29 +08:00
leookun 225de8bb85 refactor: UI 2026-08-07 01:50:20 +08:00
leookun 5bd39ed317 docs: update PULL_REQUEST_TEMPLATE 2026-08-06 21:56:19 +08:00
leokunandGitHub 26502ebec8 Merge pull request #269 from leookun/feat/idempotent-checkpoint-recovery
Feat/idempotent checkpoint recovery
2026-08-06 21:13:56 +08:00
leookun da15109312 Merge branch 'hotfix/fork-chat' into release/0.0.46 2026-08-05 23:35:12 +08:00
61 changed files with 2445 additions and 1651 deletions
-6
View File
@@ -10,9 +10,3 @@
<!-- 描述如何验证这个变更 / Describe how to verify this change --> <!-- 描述如何验证这个变更 / Describe how to verify this change -->
## 检查清单 / Checklist
- [ ] 代码已通过 `gofmt``go vet` / Code passes `gofmt` and `go vet`
- [ ] 前端构建无报错 / Frontend builds without errors
- [ ] 新增 UI 文案已同步所有 locale 文件 / New UI strings synced to all locale files
- [ ] 提交信息符合 Conventional Commits 规范 / Commit messages follow Conventional Commits
+1 -1
View File
@@ -8,7 +8,7 @@ info:
description: "Cursor助手" description: "Cursor助手"
copyright: "© 2026, Cursor助手" copyright: "© 2026, Cursor助手"
comments: "Cursor助手" comments: "Cursor助手"
version: "0.0.45" version: "0.0.46"
dev_mode: dev_mode:
root_path: . root_path: .
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.0.45</string> <string>0.0.46</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>0.0.45</string> <string>0.0.46</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>12.0.0</string> <string>12.0.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.0.45</string> <string>0.0.46</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>0.0.45</string> <string>0.0.46</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>12.0.0</string> <string>12.0.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>
+1 -1
View File
@@ -6,7 +6,7 @@
name: "Cursor助手" name: "Cursor助手"
arch: ${GOARCH} arch: ${GOARCH}
platform: "linux" platform: "linux"
version: "0.0.45" version: "0.0.46"
section: "default" section: "default"
priority: "extra" priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}> maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
+2 -2
View File
@@ -1,10 +1,10 @@
{ {
"fixed": { "fixed": {
"file_version": "0.0.45" "file_version": "0.0.46"
}, },
"info": { "info": {
"0000": { "0000": {
"ProductVersion": "0.0.45", "ProductVersion": "0.0.46",
"CompanyName": "Cursor助手", "CompanyName": "Cursor助手",
"FileDescription": "Cursor助手", "FileDescription": "Cursor助手",
"LegalCopyright": "© 2026, Cursor助手", "LegalCopyright": "© 2026, Cursor助手",
+1 -1
View File
@@ -14,7 +14,7 @@
!define INFO_PRODUCTNAME "Cursor助手" !define INFO_PRODUCTNAME "Cursor助手"
!endif !endif
!ifndef INFO_PRODUCTVERSION !ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.0.45" !define INFO_PRODUCTVERSION "0.0.46"
!endif !endif
!ifndef INFO_COPYRIGHT !ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2026, Cursor助手" !define INFO_COPYRIGHT "© 2026, Cursor助手"
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3"> <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.45" processorArchitecture="*"/> <assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.46" processorArchitecture="*"/>
<dependency> <dependency>
<dependentAssembly> <dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/> <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
+1
View File
@@ -17,6 +17,7 @@
"copy-text-to-clipboard": "^3.2.2", "copy-text-to-clipboard": "^3.2.2",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"sortablejs": "^1.15.7",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.3",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
@@ -196,6 +196,7 @@ onBeforeUnmount(() => {
<div <div
v-show="visible" v-show="visible"
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4" 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"> <Transition name="ad-frame">
<iframe <iframe
@@ -2,6 +2,7 @@
import Button from "@/components/ui/Button.vue"; import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue"; import Card from "@/components/ui/Card.vue";
import Tooltip from "@/components/ui/Tooltip.vue"; import Tooltip from "@/components/ui/Tooltip.vue";
import { useMessage } from "@/composables/useMessage";
import { showModal } from "@/composables/useModal"; import { showModal } from "@/composables/useModal";
import { import {
disconnectCursorAccount, disconnectCursorAccount,
@@ -13,6 +14,7 @@ import { Browser } from "@wailsio/runtime";
import { computed, onMounted, onUnmounted, ref } from "vue"; import { computed, onMounted, onUnmounted, ref } from "vue";
const CURSOR_ACCOUNT_CONTRIBUTOR_URL = "https://github.com/aike0210"; const CURSOR_ACCOUNT_CONTRIBUTOR_URL = "https://github.com/aike0210";
const message = useMessage();
const cursorAccountStatus = ref({ const cursorAccountStatus = ref({
state: "signed_out", state: "signed_out",
@@ -35,18 +37,16 @@ const cursorAccountStateText = computed(() => {
return "未连接"; return "未连接";
}); });
async function showActionError(title, error) { function showActionError(title, error) {
await showModal({ const detail = String(error || "服务错误").trim() || "服务错误";
title, message(`${title}${detail}`);
content: String(error || "服务错误").trim() || "服务错误",
});
} }
async function handleOpenContributor() { async function handleOpenContributor() {
try { try {
await Browser.OpenURL(CURSOR_ACCOUNT_CONTRIBUTOR_URL); await Browser.OpenURL(CURSOR_ACCOUNT_CONTRIBUTOR_URL);
} catch (error) { } catch (error) {
await showActionError("打开贡献者主页失败", toUserError(error)); showActionError("打开贡献者主页失败", toUserError(error));
} }
} }
@@ -59,7 +59,7 @@ async function handleCursorAccountLogin() {
try { try {
cursorAccountStatus.value = await startCursorAccountLogin(); cursorAccountStatus.value = await startCursorAccountLogin();
} catch (error) { } catch (error) {
await showActionError("登录失败", toUserError(error)); showActionError("登录失败", toUserError(error));
await refreshCursorAccountStatus().catch(() => {}); await refreshCursorAccountStatus().catch(() => {});
} finally { } finally {
cursorAccountBusy.value = false; cursorAccountBusy.value = false;
@@ -80,7 +80,7 @@ async function handleCursorAccountDisconnect() {
try { try {
cursorAccountStatus.value = await disconnectCursorAccount(); cursorAccountStatus.value = await disconnectCursorAccount();
} catch (error) { } catch (error) {
await showActionError("退出登录失败", toUserError(error)); showActionError("退出登录失败", toUserError(error));
} finally { } finally {
cursorAccountBusy.value = false; cursorAccountBusy.value = false;
} }
@@ -51,7 +51,6 @@ const props = defineProps({
type: Object, type: Object,
default: () => createEmptyModelAdapter(), default: () => createEmptyModelAdapter(),
}, },
errorMessage: { type: String, default: "" },
}); });
const emit = defineEmits(["cancel", "save"]); const emit = defineEmits(["cancel", "save"]);
@@ -286,13 +285,6 @@ function handleSave() {
/> />
</label> </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"> <div class="mt-5 flex justify-end gap-2">
<Button variant="default" @click="handleCancel">取消</Button> <Button variant="default" @click="handleCancel">取消</Button>
<Button variant="primary" @click="handleSave">保存</Button> <Button variant="primary" @click="handleSave">保存</Button>
@@ -44,7 +44,7 @@ const summaryText = computed(() => {
return "测试中..."; return "测试中...";
} }
if (normalizedStatus.value === "error") { if (normalizedStatus.value === "error") {
return "测试失败"; return "测试失败,请查看原始信息";
} }
return props.emptyText; return props.emptyText;
}); });
@@ -100,16 +100,16 @@ const summaryClass = computed(() => {
</script> </script>
<template> <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="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<div <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 }} {{ title }}
</div> </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> <span>原始返回</span>
<Tooltip :content="rawResponseText" copyable /> <Tooltip :content="rawResponseText" copyable />
</div> </div>
@@ -118,12 +118,7 @@ const summaryClass = computed(() => {
{{ summaryText }} {{ summaryText }}
</div> </div>
</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>
<div v-if="stale" class="mt-2 text-xs text-[#f6d77a]"> <div v-if="stale" class="mt-2 text-xs text-[#f6d77a]">
@@ -134,19 +129,19 @@ const summaryClass = computed(() => {
v-if="showMetrics && normalizedStatus === 'success'" v-if="showMetrics && normalizedStatus === 'success'"
class="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2" 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="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>
<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="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> </div>
<div <div
v-if="normalizedStatus === 'success' && result?.tokensEstimated" v-if="normalizedStatus === 'success' && result?.tokensEstimated"
class="mt-2 text-xs text-[#8f8f8f]" class="mt-2 text-xs text-white/80"
> >
输出 Token 为估算值 输出 Token 为估算值
</div> </div>
@@ -1,16 +1,15 @@
<script setup> <script setup>
import Button from "@/components/ui/Button.vue"; import Button from "@/components/ui/Button.vue";
import Combobox from "@/components/ui/Combobox.vue";
import Input from "@/components/ui/Input.vue"; import Input from "@/components/ui/Input.vue";
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue"; import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue";
import MultiSelect from "@/components/ui/MultiSelect.vue";
import Select from "@/components/ui/Select.vue"; import Select from "@/components/ui/Select.vue";
import Tooltip from "@/components/ui/Tooltip.vue"; import Tooltip from "@/components/ui/Tooltip.vue";
import { getModelEditorContext } from "@/services/clientApi"; import { useMessage } from "@/composables/useMessage";
import { import {
ANTHROPIC_THINKING_EFFORT_DEFAULT, ANTHROPIC_THINKING_EFFORT_DEFAULT,
appState, appState,
buildModelAdapterTestRequestHash, buildModelAdapterTestRequestHash,
buildModelAdaptersFromModelIDs,
createEmptyModelAdapter, createEmptyModelAdapter,
CUSTOM_HEADERS_DEFAULT_JSON, CUSTOM_HEADERS_DEFAULT_JSON,
EXTRA_PARAMS_DEFAULT_JSON, EXTRA_PARAMS_DEFAULT_JSON,
@@ -25,12 +24,10 @@ import {
OPENAI_EXTRA_PARAMS_DEFAULT_JSON, OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
runModelAdapterTest, runModelAdapterTest,
saveModelAdapterAt, saveModelAdapterAt,
saveModelAdaptersFromModelIDs,
toUserError, toUserError,
validateModelAdapters, validateModelAdapters,
} from "@/state/appState"; } from "@/state/appState";
import { Window } from "@wailsio/runtime"; import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
const modelTypeTabs = [ const modelTypeTabs = [
{ label: "OpenAI", value: "openai", icon: "icon-[bxl--openai]" }, { label: "OpenAI", value: "openai", icon: "icon-[bxl--openai]" },
@@ -59,17 +56,22 @@ const openAIEndpointOptions = [
{ label: "自定义路径(请输入完整请求地址)", value: OPENAI_ENDPOINT_CUSTOM, icon: "icon-[mdi--pencil-outline]" }, { label: "自定义路径(请输入完整请求地址)", value: OPENAI_ENDPOINT_CUSTOM, icon: "icon-[mdi--pencil-outline]" },
]; ];
const editorIndex = ref(-1); const props = defineProps({
const draft = reactive(createEmptyModelAdapter()); index: { type: Number, default: -1 },
const errorMessage = ref(""); adapter: { type: Object, default: () => createEmptyModelAdapter() },
const loading = ref(true); });
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 lastTestAdapterID = ref("");
const localTestFailure = ref(""); const localTestFailure = ref("");
const modelPrefix = ref(""); const availableModelIDs = ref(draft.modelID ? [draft.modelID] : []);
const existingModelPrefixCleared = ref(false);
const availableModelIDs = ref([]);
const selectedModelIDs = ref([]);
const modelSelectionMode = ref("auto");
const modelListLoading = ref(false); const modelListLoading = ref(false);
const modelListRequestSeq = ref(0); const modelListRequestSeq = ref(0);
let modelListDebounceTimer = 0; let modelListDebounceTimer = 0;
@@ -97,31 +99,10 @@ const modelOptions = computed(() => availableModelIDs.value.map((modelID) => ({
value: modelID, value: modelID,
icon: "icon-[mdi--cube-outline]", icon: "icon-[mdi--cube-outline]",
}))); })));
const isManualModelInput = computed(() => modelSelectionMode.value === "manual"); const canFetchModels = computed(() => Boolean(
const activeModelIDs = computed(() => ( draft.type && String(draft.baseURL || "").trim() && String(draft.apiKey || "").trim(),
isManualModelInput.value
? [String(draft.modelID || "").trim()].filter(Boolean)
: selectedModelIDs.value
)); ));
const primaryModelID = computed(() => ( const selectedTestAdapter = computed(() => normalizeModelAdapter(draft));
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 currentRequestHash = computed(() => buildModelAdapterTestRequestHash(selectedTestAdapter.value)); const currentRequestHash = computed(() => buildModelAdapterTestRequestHash(selectedTestAdapter.value));
const directModelTestResult = computed(() => getModelAdapterTestResult(selectedTestAdapter.value)); const directModelTestResult = computed(() => getModelAdapterTestResult(selectedTestAdapter.value));
const rememberedModelTestResult = computed(() => const rememberedModelTestResult = computed(() =>
@@ -139,8 +120,6 @@ const modelTestSummary = computed(() => {
return activeModelTestResult.value?.summaryText || "尚未测试"; return activeModelTestResult.value?.summaryText || "尚未测试";
}); });
const title = computed(() => (editorIndex.value >= 0 ? "编辑模型配置" : "新增模型配置"));
function ensureOpenAIExtraParamsJSON() { function ensureOpenAIExtraParamsJSON() {
if (!String(draft.openAIExtraParamsJSON || "").trim()) { if (!String(draft.openAIExtraParamsJSON || "").trim()) {
draft.openAIExtraParamsJSON = OPENAI_EXTRA_PARAMS_DEFAULT_JSON; draft.openAIExtraParamsJSON = OPENAI_EXTRA_PARAMS_DEFAULT_JSON;
@@ -167,8 +146,7 @@ function ensureAnthropicThinkingEffort() {
const fieldTips = { const fieldTips = {
displayName: "仅用于界面展示,便于你区分不同模型。", displayName: "仅用于界面展示,便于你区分不同模型。",
modelID: "可多选。保存时只会写入选中的模型,每个模型生成一条配置。", modelID: "可以直接输入模型标识,或从服务端返回的列表中选择。",
manualModelID: "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
baseURL: "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。", baseURL: "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
apiKey: "调用该模型服务需要使用的访问密钥。", apiKey: "调用该模型服务需要使用的访问密钥。",
contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。", contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
@@ -183,54 +161,20 @@ const fieldTips = {
tooltipData: "模型列表 hover 时显示的备注说明。", 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() { async function refreshModelList() {
const baseURL = String(draft.baseURL || "").trim(); const baseURL = String(draft.baseURL || "").trim();
const apiKey = String(draft.apiKey || "").trim(); const apiKey = String(draft.apiKey || "").trim();
if (!baseURL || !apiKey || !draft.type) { if (!baseURL || !apiKey || !draft.type) {
modelSelectionMode.value = "auto"; modelListRequestSeq.value += 1;
availableModelIDs.value = draft.modelID ? [draft.modelID] : []; availableModelIDs.value = [];
syncSelectionWithAvailable(); modelListLoading.value = false;
return []; return [];
} }
const requestSeq = modelListRequestSeq.value + 1; const requestSeq = modelListRequestSeq.value + 1;
modelListRequestSeq.value = requestSeq; modelListRequestSeq.value = requestSeq;
modelSelectionMode.value = "auto";
modelListLoading.value = true; modelListLoading.value = true;
availableModelIDs.value = [];
try { try {
const models = await fetchAvailableModelIDs({ const models = await fetchAvailableModelIDs({
type: draft.type, type: draft.type,
@@ -242,19 +186,11 @@ async function refreshModelList() {
if (requestSeq !== modelListRequestSeq.value) { if (requestSeq !== modelListRequestSeq.value) {
return availableModelIDs.value; return availableModelIDs.value;
} }
if (editorIndex.value >= 0 && !existingModelPrefixCleared.value) {
modelPrefix.value = "";
existingModelPrefixCleared.value = true;
}
modelSelectionMode.value = "auto";
availableModelIDs.value = models; availableModelIDs.value = models;
syncSelectionWithAvailable();
return models; return models;
} catch (_error) { } catch (_error) {
if (requestSeq === modelListRequestSeq.value) { if (requestSeq === modelListRequestSeq.value) {
modelSelectionMode.value = "manual";
availableModelIDs.value = []; availableModelIDs.value = [];
selectedModelIDs.value = [];
} }
return availableModelIDs.value; return availableModelIDs.value;
} finally { } finally {
@@ -265,33 +201,17 @@ async function refreshModelList() {
} }
async function persistDraft() { async function persistDraft() {
const models = activeModelIDs.value; const adapter = normalizeModelAdapter(draft);
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 singleCheck = validateModelAdapters([adapter]); const singleCheck = validateModelAdapters([adapter]);
if (singleCheck) { if (singleCheck) {
errorMessage.value = singleCheck; message(singleCheck);
return { ok: false, error: singleCheck, adapter: null }; return { ok: false, error: singleCheck, adapter: null };
} }
const result = isManualModelInput.value const result = await saveModelAdapterAt(editorIndex.value, adapter);
? await saveModelAdapterAt(editorIndex.value, adapter)
: await saveModelAdaptersFromModelIDs(adapter, models, modelPrefix.value, selectedModelID);
if (!result.ok) { if (!result.ok) {
errorMessage.value = result.error; message(result.error);
return { ok: false, error: result.error, adapter: null }; return { ok: false, error: result.error, adapter: null };
} }
@@ -303,7 +223,6 @@ async function persistDraft() {
} else { } else {
Object.assign(draft, adapter); Object.assign(draft, adapter);
} }
errorMessage.value = "";
return { return {
ok: true, ok: true,
error: "", error: "",
@@ -316,18 +235,19 @@ async function handleSave() {
if (!result.ok) { if (!result.ok) {
return; return;
} }
await Window.Close(); emit("saved", result.adapter);
emit("close");
} }
async function handleCancel() { function handleCancel() {
await Window.Close(); emit("close");
} }
function handleModelTypeChange(type) { function handleModelTypeChange(type) {
draft.type = type; draft.type = type;
modelSelectionMode.value = "auto"; modelListRequestSeq.value += 1;
modelListLoading.value = false;
availableModelIDs.value = []; availableModelIDs.value = [];
selectedModelIDs.value = [];
draft.modelID = ""; draft.modelID = "";
if (type === "openai" && !draft.openAIEndpoint) { if (type === "openai" && !draft.openAIEndpoint) {
draft.openAIEndpoint = OPENAI_ENDPOINT_RESPONSES; draft.openAIEndpoint = OPENAI_ENDPOINT_RESPONSES;
@@ -409,22 +329,18 @@ watch(
const baseURL = String(draft.baseURL || "").trim(); const baseURL = String(draft.baseURL || "").trim();
const apiKey = String(draft.apiKey || "").trim(); const apiKey = String(draft.apiKey || "").trim();
if (!baseURL || !apiKey) { if (!baseURL || !apiKey) {
modelSelectionMode.value = "auto"; modelListRequestSeq.value += 1;
modelListLoading.value = false; modelListLoading.value = false;
availableModelIDs.value = draft.modelID ? [draft.modelID] : []; availableModelIDs.value = [];
syncSelectionWithAvailable();
return; return;
} }
modelListDebounceTimer = window.setTimeout(() => { modelListDebounceTimer = window.setTimeout(() => {
void refreshModelList(); void refreshModelList();
}, 600); }, 600);
}, },
{ immediate: true },
); );
onMounted(async () => {
await loadContext();
});
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.clearTimeout(modelListDebounceTimer); window.clearTimeout(modelListDebounceTimer);
}); });
@@ -432,24 +348,14 @@ onBeforeUnmount(() => {
<template> <template>
<div class="flex h-full flex-col text-[#e5e5e5]"> <div class="flex h-full flex-col text-[#e5e5e5]">
<div class="flex shrink-0 items-center justify-between px-4 pb-2"> <div class="flex-shrink-0 p-4" v-if="localTestFailure || activeModelTestResult">
<h2 class="text-base font-medium text-white">{{ title }}</h2> <ModelAdapterTestCard
<div class="flex items-center gap-2"> :result="localTestFailure ? { status: 'error', error: '测试失败', summaryText: '测试失败', rawResponse: modelTestSummary } : activeModelTestResult"
<Button variant="default" @click="handleCancel">取消</Button> :stale="modelTestResultStale"
<Button variant="default" :disabled="isCurrentConfigTesting || appState.configSaving" @click="handleTest"> :show-metrics="true"
{{ isCurrentConfigTesting ? "测试中..." : "保存并测试" }} />
</Button> </div>
<Button variant="primary" :disabled="appState.configSaving" @click="handleSave"> <div class="flex-1 min-h-0 overflow-y-auto px-4 py-4 scroll-shadow-bottom">
{{ 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 flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="center-row gap-2"> <div class="center-row gap-2">
<button <button
@@ -497,11 +403,11 @@ onBeforeUnmount(() => {
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]"> <span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="isManualModelInput ? fieldTips.displayName : fieldTips.modelID" /> <Tooltip :content="fieldTips.displayName" />
<span>{{ isManualModelInput ? "显示名称" : "模型前缀" }}</span> <span>显示名称</span>
</span> </span>
<input <input
v-model="modelPrefix" v-model="draft.displayName"
type="text" type="text"
placeholder="例如:GPT-5" placeholder="例如:GPT-5"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]" 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"> <div class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]"> <span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="isManualModelInput ? fieldTips.manualModelID : fieldTips.modelID" /> <Tooltip :content="fieldTips.modelID" />
<span>{{ isManualModelInput ? "模型标识" : "选择模型" }}</span> <span>模型标识</span>
</span> </span>
<input <Combobox
v-if="isManualModelInput"
v-model="draft.modelID" 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" :options="modelOptions"
:disabled="modelListLoading || modelOptions.length === 0" :loading="modelListLoading"
:placeholder="modelListLoading ? '正在获取模型...' : '请输入接口地址和访问密钥'" placeholder="例如:gpt-4.1"
:summary-formatter="(count, total) => `已选择 ${count} / ${total} 个模型`" empty-text="没有匹配的模型"
aria-label="选择模型" 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> </div>
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
@@ -696,19 +604,16 @@ onBeforeUnmount(() => {
/> />
</label> </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> </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> </div>
</template> </template>
+3 -6
View File
@@ -1,12 +1,9 @@
<script setup></script> <script setup></script>
<template> <template>
<div <div
class="rounded-[8px] p-[1px]" class="rounded-[8px] border border-[#343434] bg-[#292929] p-4 "
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
> >
<div class="rounded-[7px] bg-[#292929] p-4"> <slot />
<slot />
</div>
</div> </div>
</template> </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"; import { messageState, provideMessage } from "@/composables/useMessage";
provideMessage(); 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> </script>
<template> <template>
<div class="pointer-events-none fixed inset-x-0 top-4 z-[1000] flex justify-center px-4"> <Teleport to="body">
<Transition name="message-slide" mode="out-in"> <div class="pointer-events-none fixed inset-x-0 top-[calc(48px+env(safe-area-inset-top))] z-[11000] flex justify-center px-4">
<div <Transition name="message-slide" mode="out-in">
v-if="messageState.current" <div
:key="messageState.current.id" v-if="messageState.current"
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)]" :key="messageState.current.id"
:class="resolveTheme(messageState.current.type).containerClass" role="status"
> aria-live="polite"
<span 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)]"
v-if="resolveTheme(messageState.current.type).iconClass" >
class="text-[14px]" {{ messageState.current.content }}
:class="[ </div>
resolveTheme(messageState.current.type).iconClass, </Transition>
resolveTheme(messageState.current.type).iconExtraClass, </div>
]" </Teleport>
/>
<span class="leading-none whitespace-nowrap">{{ messageState.current.content }}</span>
</div>
</Transition>
</div>
</template> </template>
<style scoped> <style scoped>
@@ -62,7 +30,7 @@ function resolveTheme(type) {
.message-slide-enter-from { .message-slide-enter-from {
opacity: 0; opacity: 0;
transform: translateY(-12px); transform: translateY(-8px);
} }
.message-slide-enter-to, .message-slide-enter-to,
@@ -73,11 +41,6 @@ function resolveTheme(type) {
.message-slide-leave-to { .message-slide-leave-to {
opacity: 0; opacity: 0;
transform: translateY(-12px); transform: translateY(-8px);
} }
</style> </style>
+2 -3
View File
@@ -39,11 +39,10 @@ function onMaskClick() {
<Transition name="modal-content"> <Transition name="modal-content">
<div <div
v-show="visible" 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)]" 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]"
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
@click.stop @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"> <h3 class="mb-3 text-base font-medium text-white">
{{ title }} {{ title }}
</h3> </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 <button
ref="triggerRef" ref="triggerRef"
type="button" 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" @mouseenter="showTooltip"
@mouseleave="scheduleHideTooltip" @mouseleave="scheduleHideTooltip"
@focus="showTooltip" @focus="showTooltip"
+17 -37
View File
@@ -35,10 +35,9 @@ function removeMessage(id, options = {}) {
messageState.current = null; messageState.current = null;
} }
function showMessage(options = {}) { function showMessage(content, options = {}) {
const type = typeof options.type === "string" ? options.type : "info"; const normalizedContent = String(content || "").trim();
const content = String(options.content || "").trim(); if (!normalizedContent) {
if (!content) {
return null; return null;
} }
@@ -48,14 +47,11 @@ function showMessage(options = {}) {
const duration = Number.isFinite(options.duration) const duration = Number.isFinite(options.duration)
? Math.max(0, options.duration) ? Math.max(0, options.duration)
: type === "loading" : 2400;
? 0
: 2400;
const id = `message-${Date.now()}-${messageSeed += 1}`; const id = `message-${Date.now()}-${messageSeed += 1}`;
const item = { const item = {
id, id,
type, content: normalizedContent,
content,
shownAt: Date.now(), shownAt: Date.now(),
timer: null, timer: null,
}; };
@@ -70,40 +66,24 @@ function showMessage(options = {}) {
return id; return id;
} }
export function createMessageApi() { export function message(content, options = {}) {
return { return showMessage(content, options);
state: messageState,
show: showMessage,
success(content, options = {}) {
return showMessage({ ...options, type: "success", content });
},
error(content, options = {}) {
return showMessage({ ...options, type: "error", content });
},
info(content, options = {}) {
return showMessage({ ...options, type: "info", content });
},
loading(content, options = {}) {
return showMessage({ ...options, type: "loading", content });
},
remove: removeMessage,
clear() {
if (messageState.current) {
removeMessage(messageState.current.id, { force: true });
}
},
};
} }
const defaultMessageApi = createMessageApi(); message.remove = removeMessage;
message.clear = () => {
if (messageState.current) {
removeMessage(messageState.current.id, { force: true });
}
};
export function provideMessage() { export function provideMessage() {
provide(MESSAGE_API_SYMBOL, defaultMessageApi); provide(MESSAGE_API_SYMBOL, message);
return defaultMessageApi; return message;
} }
export function useMessage() { 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", "02216368edc68816": "No release notes",
"02bc2e95bf49e587": "No",
"03b11112dc970014": "Base URL", "03b11112dc970014": "Base URL",
"04f632dd4f034d5e": "{0} context window must be a positive integer", "04f632dd4f034d5e": "{0} context window must be a positive integer",
"051836569928a9f9": "Edit", "051836569928a9f9": "Edit",
@@ -30,7 +29,6 @@
"281eb6d08c9960d0": "{0} thinking budget token must be a positive integer", "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.", "28aeffc70ceb4267": "Change the display language for this interface. The setting takes effect immediately and is saved on this device.",
"2a24519398684ed5": "Visit Homepage", "2a24519398684ed5": "Visit Homepage",
"2c18d5e2d70b45db": "Model prefix",
"2cd0f3be8738a86c": "Cancel", "2cd0f3be8738a86c": "Cancel",
"2d706f7981b45a7b": "Local settings saved", "2d706f7981b45a7b": "Local settings saved",
"2f9daa828907b93f": "Delete", "2f9daa828907b93f": "Delete",
@@ -42,14 +40,11 @@
"3468b57e3edbc599": "Aggregated from turn summaries scanned from the history.", "3468b57e3edbc599": "Aggregated from turn summaries scanned from the history.",
"35076178fe79a210": "Configuration changed. Please test again.", "35076178fe79a210": "Configuration changed. Please test again.",
"358f07b2c1445ab1": "Author's Message", "358f07b2c1445ab1": "Author's Message",
"36c149a9b3e8dca0": "models configured yet.",
"37d23612f78a2e63": "Restart Now to Update", "37d23612f78a2e63": "Restart Now to Update",
"392d0dceb45998d3": "Extreme", "392d0dceb45998d3": "Extreme",
"393df9bb13ea4900": "Hit", "393df9bb13ea4900": "Hit",
"3a5040b68abf75f9": "Select all",
"3ab8cc15939f3b5c": "Log out", "3ab8cc15939f3b5c": "Log out",
"3af7e5489e61ea51": "Refreshing", "3af7e5489e61ea51": "Refreshing",
"3b2e5f2fba1bcbc7": "Enter the API address and access key",
"3c2a9f9901109e75": "{0} type only supports OpenAI or Anthropic", "3c2a9f9901109e75": "{0} type only supports OpenAI or Anthropic",
"3d13868593ae4eeb": "Interface Language", "3d13868593ae4eeb": "Interface Language",
"3d52574ce1500561": "Not connected", "3d52574ce1500561": "Not connected",
@@ -63,19 +58,20 @@
"472642d58d3d5a6d": "Formula: {0}", "472642d58d3d5a6d": "Formula: {0}",
"4923eeb7bd75cccd": "{0} model ID cannot be empty", "4923eeb7bd75cccd": "{0} model ID cannot be empty",
"497c85690c4cc0fc": "No data", "497c85690c4cc0fc": "No data",
"4b5e0ae1288a9695": "No matching models",
"4c0a929bb86ce912": "Current: {0}", "4c0a929bb86ce912": "Current: {0}",
"4d2b6e53be6002e5": "Cache Statistics Strategy: {0} ({1})", "4d2b6e53be6002e5": "Cache Statistics Strategy: {0} ({1})",
"4d8c1c5b42830791": "Unknown", "4d8c1c5b42830791": "Unknown",
"4f0982ba1d37e51b": "Current outbound requests use environment variable proxy", "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.", "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", "56627c94a9decee6": "Max Output Tokens",
"58c6b0935a7216da": "Failed to open contributor profile", "58c6b0935a7216da": "Failed to open contributor profile",
"593a972852ba0004": "Cursor Assistant | Permanently Free | Custom API", "593a972852ba0004": "Cursor Assistant | Permanently Free | Custom API",
"59a2195a01a8b35b": "{0} must be a valid JSON object", "59a2195a01a8b35b": "{0} must be a valid JSON object",
"5a5c8318ef649672": "{0} selected",
"5aa8f5590c940829": "Non-cache Input: {0}", "5aa8f5590c940829": "Non-cache Input: {0}",
"5beb1206c532729f": "Maximum number of tokens allowed in a single response. Leave blank to use the default.", "5beb1206c532729f": "Maximum number of tokens allowed in a single response. Leave blank to use the default.",
"5c55a67935af8f45": "All",
"5d1687a4a41883fd": "Stopping...", "5d1687a4a41883fd": "Stopping...",
"5e709712ce012f5d": "Current outbound requests use environment variable proxy: {0}", "5e709712ce012f5d": "Current outbound requests use environment variable proxy: {0}",
"6106f0a12583a334": "Refresh failed", "6106f0a12583a334": "Refresh failed",
@@ -97,27 +93,26 @@
"6e584e3d5ce64aa0": "Save Settings", "6e584e3d5ce64aa0": "Save Settings",
"6ec87609a8769425": "Default {0} / Count as creation {1}", "6ec87609a8769425": "Default {0} / Count as creation {1}",
"72f6c3525c0192a7": "When enabled, cache creation is included in the denominator", "72f6c3525c0192a7": "When enabled, cache creation is included in the denominator",
"736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.",
"737225e2904673fc": "Estimated output tokens: {0}", "737225e2904673fc": "Estimated output tokens: {0}",
"7520bd50a5ee5471": "Stop testing {0}/{1}", "7520bd50a5ee5471": "Stop testing {0}/{1}",
"753d8bb0da9913ce": "Duplication failed", "753d8bb0da9913ce": "Duplication failed",
"75b5f4c68c79322c": "Select at least one model to save",
"774d6e1b7cb89751": "Default Definition", "774d6e1b7cb89751": "Default Definition",
"77c9e582e85583af": "Test failed", "77c9e582e85583af": "Test failed",
"7923d007483ae04c": "No models to save",
"7a26bf794e9fb6bf": "Used only for display in the UI, so you can distinguish different models.", "7a26bf794e9fb6bf": "Used only for display in the UI, so you can distinguish different models.",
"7b6187c41e88b70c": "Testing...", "7b6187c41e88b70c": "Testing...",
"7bf8e2c07e084d09": "Model Editor",
"7df7641e5e741346": "Cache Read / (Cache Read + Cache Creation + Non-cache Input)", "7df7641e5e741346": "Cache Read / (Cache Read + Cache Creation + Non-cache Input)",
"7e9e334aeb0bdc07": "Service operation failed", "7e9e334aeb0bdc07": "Service operation failed",
"7f68ebad19ba6bcd": "Check for Updates", "7f68ebad19ba6bcd": "Check for Updates",
"80296f4aa3f4543b": "Cache Read/Write", "80296f4aa3f4543b": "Cache Read/Write",
"81123c56d5d880d0": "API Key", "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.", "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", "83be9cac28873059": "Cursor Control Plane Account",
"83fcfb4c1f2c1641": "Fetch Models",
"8672864e90417138": "Max", "8672864e90417138": "Max",
"86df7ec743047234": "Service running", "86df7ec743047234": "Service running",
"891bfee3bbe52d3c": "Enter a model ID",
"899add6275682210": "Uses 200000 by default when left blank", "899add6275682210": "Uses 200000 by default when left blank",
"8a4ef3e48e4e8a5a": "Enabled", "8a4ef3e48e4e8a5a": "Enabled",
"8c1935935600e336": "Model Test", "8c1935935600e336": "Model Test",
@@ -130,7 +125,6 @@
"917b1c1f18d0276b": "Saving...", "917b1c1f18d0276b": "Saving...",
"9196835e388d2550": "Test All", "9196835e388d2550": "Test All",
"91cba5c107a51892": "/ Invalid", "91cba5c107a51892": "/ Invalid",
"92059fe6cd713db4": "The model name actually sent to the server, for example gpt-4.1 or claude-sonnet.",
"93e08803675e378b": "Model ID", "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.", "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...", "942ff2d88baca0c6": "Checking for updates...",
@@ -143,7 +137,6 @@
"9c41b3a9e12ac994": "Thinking Effort", "9c41b3a9e12ac994": "Thinking Effort",
"9d2ca261281a158a": "Later", "9d2ca261281a158a": "Later",
"9d2fb46c0ba890b9": "Custom Path", "9d2fb46c0ba890b9": "Custom Path",
"9dc0825fba5422e4": "Loading...",
"9e02529bcaef36c6": "Duplicate model channel detected. Please check the combination of url, modelID, apiKey, displayName, and endpoint", "9e02529bcaef36c6": "Duplicate model channel detected. Please check the combination of url, modelID, apiKey, displayName, and endpoint",
"a026f37e613cf48b": "Output Tokens", "a026f37e613cf48b": "Output Tokens",
"a0d36236c523667c": "{0} Anthropic thinking effort only supports low, medium, high, xhigh, max", "a0d36236c523667c": "{0} Anthropic thinking effort only supports low, medium, high, xhigh, max",
@@ -161,6 +154,7 @@
"ac217e4d1ca410f1": "New version available", "ac217e4d1ca410f1": "New version available",
"ad79540418be700a": "Open the settings folder, or manage model settings separately", "ad79540418be700a": "Open the settings folder, or manage model settings separately",
"ae5a738238463a92": "Hide API Key", "ae5a738238463a92": "Hide API Key",
"aeb1e3a8ff46cda1": "No models have been configured yet.",
"aed55419ce62f08e": "Switching...", "aed55419ce62f08e": "Switching...",
"b10041a13f5c55b1": "Model Output: {0} × ${1}/1M = {2}", "b10041a13f5c55b1": "Model Output: {0} × ${1}/1M = {2}",
"b1c27820fec23edb": "High", "b1c27820fec23edb": "High",
@@ -181,14 +175,15 @@
"c228558cf257fc49": "Delete failed", "c228558cf257fc49": "Delete failed",
"c3d46b387eeadb23": "This only logs the Cursor account out of cursor-byok; it does not log out of the Cursor client. Continue?", "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.", "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", "c69f5bce63b9f14c": "Settings Folder",
"c72d5dc20cd27118": "{0} / {1} models selected",
"c8a52b66651d294c": "Failed to log out", "c8a52b66651d294c": "Failed to log out",
"c8c14507b2d37395": "Reasoning Effort", "c8c14507b2d37395": "Reasoning Effort",
"c98e118e0a43f078": "Model", "c98e118e0a43f078": "Model",
"c9dd59beefd7144f": "Cache Read / (Cache Read + Non-cache Input)", "c9dd59beefd7144f": "Cache Read / (Cache Read + Non-cache Input)",
"ca00a39fcea70dc6": "Starting...", "ca00a39fcea70dc6": "Starting...",
"ca1d1059408b3837": "Invalid turns: {0}", "ca1d1059408b3837": "Invalid turns: {0}",
"cc5049729a2c10f1": "Test failed. Check the raw details.",
"cd7ca5fb221e1c53": "{0} cannot be empty", "cd7ca5fb221e1c53": "{0} cannot be empty",
"cfa6c803eb3fc713": "Waiting for browser login", "cfa6c803eb3fc713": "Waiting for browser login",
"d0325067fed88e5a": "Cache hit rate {0}", "d0325067fed88e5a": "Cache hit rate {0}",
@@ -216,7 +211,6 @@
"e4c0daa3c4bea691": "Thanks to @aike0210 for contributing the Cursor control-plane account feature.", "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", "e53580f8031f13c0": "Complete login in the browser, then return to Cursor and reopen the plugin marketplace",
"e552c2accdbf5178": "Add Model", "e552c2accdbf5178": "Add Model",
"e6943d5cbfb863e0": "Fetching models...",
"e6faccfddce722e8": "Cache read tokens: {0}", "e6faccfddce722e8": "Cache read tokens: {0}",
"e8a0a6053998ebfa": "Logged in", "e8a0a6053998ebfa": "Logged in",
"eaffd48cd2ea9f1a": "e.g. https://api.anthropic.com", "eaffd48cd2ea9f1a": "e.g. https://api.anthropic.com",
@@ -224,12 +218,13 @@
"ec3b17a75db49e24": "{0} t/s | First token {1}", "ec3b17a75db49e24": "{0} t/s | First token {1}",
"ec99e5c45d648fd6": "Update failed", "ec99e5c45d648fd6": "Update failed",
"ee95057c6b0335d2": "Current outbound requests use system proxy: {0}", "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.", "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.", "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", "f3a76d896853c1df": "Miss",
"f3fae6cccb9004b1": "Custom header name cannot be empty", "f3fae6cccb9004b1": "Custom header name cannot be empty",
"f474a4108aba4c4c": "Stop Service", "f474a4108aba4c4c": "Stop Service",
"f4d4bae588c4c0ff": "Deselect all",
"f4f0ead1116b5b62": "Enable", "f4f0ead1116b5b62": "Enable",
"f56c6c82203b33f6": "Notice", "f56c6c82203b33f6": "Notice",
"f61e03f047b786d5": "{0} max output tokens must be a positive integer", "f61e03f047b786d5": "{0} max output tokens must be a positive integer",
+12 -17
View File
@@ -1,6 +1,5 @@
{ {
"02216368edc68816": "更新内容はありません", "02216368edc68816": "更新内容はありません",
"02bc2e95bf49e587": "まだ",
"03b11112dc970014": "ベース URL", "03b11112dc970014": "ベース URL",
"04f632dd4f034d5e": "{0} のコンテキストウィンドウは正の整数である必要があります", "04f632dd4f034d5e": "{0} のコンテキストウィンドウは正の整数である必要があります",
"051836569928a9f9": "編集", "051836569928a9f9": "編集",
@@ -30,7 +29,6 @@
"281eb6d08c9960d0": "{0} の思考予算 Token は正の整数である必要があります", "281eb6d08c9960d0": "{0} の思考予算 Token は正の整数である必要があります",
"28aeffc70ceb4267": "この画面の表示言語を切り替えます。設定はすぐに反映され、この端末に保存されます", "28aeffc70ceb4267": "この画面の表示言語を切り替えます。設定はすぐに反映され、この端末に保存されます",
"2a24519398684ed5": "ホームページへ", "2a24519398684ed5": "ホームページへ",
"2c18d5e2d70b45db": "モデルのプレフィックス",
"2cd0f3be8738a86c": "キャンセル", "2cd0f3be8738a86c": "キャンセル",
"2d706f7981b45a7b": "ローカル設定を保存しました", "2d706f7981b45a7b": "ローカル設定を保存しました",
"2f9daa828907b93f": "削除", "2f9daa828907b93f": "削除",
@@ -42,14 +40,11 @@
"3468b57e3edbc599": "履歴からスキャンした各ターンの summary を集計しています。", "3468b57e3edbc599": "履歴からスキャンした各ターンの summary を集計しています。",
"35076178fe79a210": "設定が変更されました。再テストしてください", "35076178fe79a210": "設定が変更されました。再テストしてください",
"358f07b2c1445ab1": "著者からのメッセージ", "358f07b2c1445ab1": "著者からのメッセージ",
"36c149a9b3e8dca0": "モデルは設定されていません。",
"37d23612f78a2e63": "今すぐ再起動して更新", "37d23612f78a2e63": "今すぐ再起動して更新",
"392d0dceb45998d3": "最高", "392d0dceb45998d3": "最高",
"393df9bb13ea4900": "ヒット", "393df9bb13ea4900": "ヒット",
"3a5040b68abf75f9": "すべて選択",
"3ab8cc15939f3b5c": "ログアウト", "3ab8cc15939f3b5c": "ログアウト",
"3af7e5489e61ea51": "更新中", "3af7e5489e61ea51": "更新中",
"3b2e5f2fba1bcbc7": "インターフェースのアドレスとアクセスキーを入力してください",
"3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします", "3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします",
"3d13868593ae4eeb": "表示言語", "3d13868593ae4eeb": "表示言語",
"3d52574ce1500561": "未接続", "3d52574ce1500561": "未接続",
@@ -63,19 +58,20 @@
"472642d58d3d5a6d": "数式:{0}", "472642d58d3d5a6d": "数式:{0}",
"4923eeb7bd75cccd": "{0} のモデル ID は必須です", "4923eeb7bd75cccd": "{0} のモデル ID は必須です",
"497c85690c4cc0fc": "データなし", "497c85690c4cc0fc": "データなし",
"4b5e0ae1288a9695": "一致するモデルがありません",
"4c0a929bb86ce912": "現在:{0}", "4c0a929bb86ce912": "現在:{0}",
"4d2b6e53be6002e5": "キャッシュ統計ポリシー:{0}{1})", "4d2b6e53be6002e5": "キャッシュ統計ポリシー:{0}{1})",
"4d8c1c5b42830791": "不明", "4d8c1c5b42830791": "不明",
"4f0982ba1d37e51b": "現在のアウトバウンドリクエストは環境変数プロキシを使用しています", "4f0982ba1d37e51b": "現在のアウトバウンドリクエストは環境変数プロキシを使用しています",
"51194c3ad014fb29": "再テストが必要",
"5205125c0e91d346": "Anthropic モデルが1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。", "5205125c0e91d346": "Anthropic モデルが1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
"54e6745ff43c9c74": "並べ替えに失敗しました",
"56627c94a9decee6": "最大出力 Token", "56627c94a9decee6": "最大出力 Token",
"58c6b0935a7216da": "コントリビューターのプロフィールを開けませんでした", "58c6b0935a7216da": "コントリビューターのプロフィールを開けませんでした",
"593a972852ba0004": "Cursor アシスタント | 永久無料 | カスタム API", "593a972852ba0004": "Cursor アシスタント | 永久無料 | カスタム API",
"59a2195a01a8b35b": "{0}は有効なJSONオブジェクトである必要があります", "59a2195a01a8b35b": "{0}は有効なJSONオブジェクトである必要があります",
"5a5c8318ef649672": "{0}件を選択中",
"5aa8f5590c940829": "非キャッシュ入力:{0}", "5aa8f5590c940829": "非キャッシュ入力:{0}",
"5beb1206c532729f": "1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。", "5beb1206c532729f": "1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
"5c55a67935af8f45": "すべて",
"5d1687a4a41883fd": "停止中...", "5d1687a4a41883fd": "停止中...",
"5e709712ce012f5d": "現在のアウトバウンドリクエストは環境変数プロキシを使用しています:{0}", "5e709712ce012f5d": "現在のアウトバウンドリクエストは環境変数プロキシを使用しています:{0}",
"6106f0a12583a334": "再読み込みに失敗しました", "6106f0a12583a334": "再読み込みに失敗しました",
@@ -97,27 +93,26 @@
"6e584e3d5ce64aa0": "設定を保存", "6e584e3d5ce64aa0": "設定を保存",
"6ec87609a8769425": "デフォルト {0} / 作成カウント {1}", "6ec87609a8769425": "デフォルト {0} / 作成カウント {1}",
"72f6c3525c0192a7": "有効にすると、キャッシュ作成が分母に含まれます", "72f6c3525c0192a7": "有効にすると、キャッシュ作成が分母に含まれます",
"736c9dc2a04c65fd": "モデル設定が変更されました。更新してからもう一度お試しください。",
"737225e2904673fc": "推定出力 Token: {0}", "737225e2904673fc": "推定出力 Token: {0}",
"7520bd50a5ee5471": "テスト停止 {0}/{1}", "7520bd50a5ee5471": "テスト停止 {0}/{1}",
"753d8bb0da9913ce": "複製に失敗しました", "753d8bb0da9913ce": "複製に失敗しました",
"75b5f4c68c79322c": "保存するモデルを1つ以上選択してください",
"774d6e1b7cb89751": "デフォルト定義", "774d6e1b7cb89751": "デフォルト定義",
"77c9e582e85583af": "テスト失敗", "77c9e582e85583af": "テスト失敗",
"7923d007483ae04c": "保存できるモデルがありません",
"7a26bf794e9fb6bf": "UIでモデルを区別するための表示専用です。", "7a26bf794e9fb6bf": "UIでモデルを区別するための表示専用です。",
"7b6187c41e88b70c": "テスト中...", "7b6187c41e88b70c": "テスト中...",
"7bf8e2c07e084d09": "モデル編集",
"7df7641e5e741346": "キャッシュ読み取り / (キャッシュ読み取り + キャッシュ作成 + 非キャッシュ入力)", "7df7641e5e741346": "キャッシュ読み取り / (キャッシュ読み取り + キャッシュ作成 + 非キャッシュ入力)",
"7e9e334aeb0bdc07": "サービス操作に失敗しました", "7e9e334aeb0bdc07": "サービス操作に失敗しました",
"7f68ebad19ba6bcd": "アップデートを確認", "7f68ebad19ba6bcd": "アップデートを確認",
"80296f4aa3f4543b": "キャッシュ読み書き", "80296f4aa3f4543b": "キャッシュ読み書き",
"81123c56d5d880d0": "API キー", "81123c56d5d880d0": "API キー",
"8139cb3dd11f5a67": "有効にすると、JSONオブジェクトが最終的なリクエストヘッダーを上書きします。同名のヘッダーはこの設定が優先され、値は文字列である必要があります。", "8139cb3dd11f5a67": "有効にすると、JSONオブジェクトが最終的なリクエストヘッダーを上書きします。同名のヘッダーはこの設定が優先され、値は文字列である必要があります。",
"826c0e5a4407befa": "複数のモデルを選択できます。保存されるのは選択したモデルだけで、モデルごとに1つの設定が作成されます。", "8151e8704a7ca89e": "一致する項目がありません",
"83913e71fcf7ff60": "更新しました",
"83be9cac28873059": "Cursor コントロールプレーンアカウント", "83be9cac28873059": "Cursor コントロールプレーンアカウント",
"83fcfb4c1f2c1641": "モデルを取得",
"8672864e90417138": "最大", "8672864e90417138": "最大",
"86df7ec743047234": "サービス稼働中", "86df7ec743047234": "サービス稼働中",
"891bfee3bbe52d3c": "モデルIDを入力してください",
"899add6275682210": "空欄で 200000", "899add6275682210": "空欄で 200000",
"8a4ef3e48e4e8a5a": "有効", "8a4ef3e48e4e8a5a": "有効",
"8c1935935600e336": "モデルテスト", "8c1935935600e336": "モデルテスト",
@@ -130,7 +125,6 @@
"917b1c1f18d0276b": "保存中...", "917b1c1f18d0276b": "保存中...",
"9196835e388d2550": "すべてテスト", "9196835e388d2550": "すべてテスト",
"91cba5c107a51892": "/ 異常", "91cba5c107a51892": "/ 異常",
"92059fe6cd713db4": "サーバーに実際に送信されるモデル名。例: gpt-4.1、claude-sonnet。",
"93e08803675e378b": "モデルID", "93e08803675e378b": "モデルID",
"93faf55cd25c8319": "このソフトウェアは完全に無料です。もし料金を請求された場合は、詐欺の可能性が高いです。\n著者のホームページ https://space.bilibili.com/311706663/upload/video にアクセスして、更新情報や利用方法などを確認してください。", "93faf55cd25c8319": "このソフトウェアは完全に無料です。もし料金を請求された場合は、詐欺の可能性が高いです。\n著者のホームページ https://space.bilibili.com/311706663/upload/video にアクセスして、更新情報や利用方法などを確認してください。",
"942ff2d88baca0c6": "アップデートを確認中...", "942ff2d88baca0c6": "アップデートを確認中...",
@@ -143,7 +137,6 @@
"9c41b3a9e12ac994": "思考強度", "9c41b3a9e12ac994": "思考強度",
"9d2ca261281a158a": "後で", "9d2ca261281a158a": "後で",
"9d2fb46c0ba890b9": "カスタムパス", "9d2fb46c0ba890b9": "カスタムパス",
"9dc0825fba5422e4": "読み込み中...",
"9e02529bcaef36c6": "モデルチャネルが重複しています。url、modelID、apiKey、displayName、endpointの組み合わせを確認してください", "9e02529bcaef36c6": "モデルチャネルが重複しています。url、modelID、apiKey、displayName、endpointの組み合わせを確認してください",
"a026f37e613cf48b": "出力 Token", "a026f37e613cf48b": "出力 Token",
"a0d36236c523667c": "{0} のAnthropic思考強度はlow、medium、high、xhigh、maxのみをサポートしています", "a0d36236c523667c": "{0} のAnthropic思考強度はlow、medium、high、xhigh、maxのみをサポートしています",
@@ -161,6 +154,7 @@
"ac217e4d1ca410f1": "新しいバージョンがあります", "ac217e4d1ca410f1": "新しいバージョンがあります",
"ad79540418be700a": "設定フォルダーを開くか、モデル設定を個別に管理できます", "ad79540418be700a": "設定フォルダーを開くか、モデル設定を個別に管理できます",
"ae5a738238463a92": "API キーを隠す", "ae5a738238463a92": "API キーを隠す",
"aeb1e3a8ff46cda1": "まだモデルが設定されていません。",
"aed55419ce62f08e": "切替中...", "aed55419ce62f08e": "切替中...",
"b10041a13f5c55b1": "モデル出力:{0} × ${1}/1M = {2}", "b10041a13f5c55b1": "モデル出力:{0} × ${1}/1M = {2}",
"b1c27820fec23edb": "高", "b1c27820fec23edb": "高",
@@ -181,14 +175,15 @@
"c228558cf257fc49": "削除に失敗しました", "c228558cf257fc49": "削除に失敗しました",
"c3d46b387eeadb23": "cursor-byok 内の Cursor アカウントからのみログアウトします。Cursor クライアントからはログアウトしません。続行しますか?", "c3d46b387eeadb23": "cursor-byok 内の Cursor アカウントからのみログアウトします。Cursor クライアントからはログアウトしません。続行しますか?",
"c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。", "c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。",
"c6868592796ac2b2": "まだ {0} モデルが設定されていません。",
"c69f5bce63b9f14c": "設定フォルダー", "c69f5bce63b9f14c": "設定フォルダー",
"c72d5dc20cd27118": "{0} / {1} モデルを選択中",
"c8a52b66651d294c": "ログアウトに失敗しました", "c8a52b66651d294c": "ログアウトに失敗しました",
"c8c14507b2d37395": "推論強度", "c8c14507b2d37395": "推論強度",
"c98e118e0a43f078": "モデル", "c98e118e0a43f078": "モデル",
"c9dd59beefd7144f": "キャッシュ読み取り / (キャッシュ読み取り + 非キャッシュ入力)", "c9dd59beefd7144f": "キャッシュ読み取り / (キャッシュ読み取り + 非キャッシュ入力)",
"ca00a39fcea70dc6": "起動中...", "ca00a39fcea70dc6": "起動中...",
"ca1d1059408b3837": "異常ターン: {0}", "ca1d1059408b3837": "異常ターン: {0}",
"cc5049729a2c10f1": "テストに失敗しました。元の詳細情報を確認してください。",
"cd7ca5fb221e1c53": "{0}は空にできません", "cd7ca5fb221e1c53": "{0}は空にできません",
"cfa6c803eb3fc713": "ブラウザでのログインを待っています", "cfa6c803eb3fc713": "ブラウザでのログインを待っています",
"d0325067fed88e5a": "キャッシュヒット率 {0}", "d0325067fed88e5a": "キャッシュヒット率 {0}",
@@ -216,7 +211,6 @@
"e4c0daa3c4bea691": "Cursor コントロールプレーンアカウント機能への @aike0210 の貢献に感謝します。", "e4c0daa3c4bea691": "Cursor コントロールプレーンアカウント機能への @aike0210 の貢献に感謝します。",
"e53580f8031f13c0": "ブラウザでログインを完了し、Cursor に戻ってプラグインマーケットを開き直してください", "e53580f8031f13c0": "ブラウザでログインを完了し、Cursor に戻ってプラグインマーケットを開き直してください",
"e552c2accdbf5178": "モデルを追加", "e552c2accdbf5178": "モデルを追加",
"e6943d5cbfb863e0": "モデルを取得中...",
"e6faccfddce722e8": "キャッシュ読込 Token: {0}", "e6faccfddce722e8": "キャッシュ読込 Token: {0}",
"e8a0a6053998ebfa": "ログイン済み", "e8a0a6053998ebfa": "ログイン済み",
"eaffd48cd2ea9f1a": "例: https://api.anthropic.com", "eaffd48cd2ea9f1a": "例: https://api.anthropic.com",
@@ -224,12 +218,13 @@
"ec3b17a75db49e24": "{0} t/s | 初回 Token {1}", "ec3b17a75db49e24": "{0} t/s | 初回 Token {1}",
"ec99e5c45d648fd6": "アップデートに失敗しました", "ec99e5c45d648fd6": "アップデートに失敗しました",
"ee95057c6b0335d2": "現在のアウトバウンドリクエストはシステムプロキシを使用しています:{0}", "ee95057c6b0335d2": "現在のアウトバウンドリクエストはシステムプロキシを使用しています:{0}",
"f0b6a23368dd47cc": "モデルIDを直接入力するか、サーバーから返された一覧から選択します。",
"f1aa7326f38b4c09": "ドラッグして並べ替え",
"f1e0fc261d42fe29": "モデル一覧にホバーしたときに表示されるメモです。", "f1e0fc261d42fe29": "モデル一覧にホバーしたときに表示されるメモです。",
"f363622480699c52": "推論強度は reasoning_effort をサポートする一部のモデルでのみ有効です。すべてのモデルが対応しているわけではありません。値が高いほど安定しやすい反面、遅くなることがあります。", "f363622480699c52": "推論強度は reasoning_effort をサポートする一部のモデルでのみ有効です。すべてのモデルが対応しているわけではありません。値が高いほど安定しやすい反面、遅くなることがあります。",
"f3a76d896853c1df": "ミス", "f3a76d896853c1df": "ミス",
"f3fae6cccb9004b1": "カスタムヘッダー名は空にできません", "f3fae6cccb9004b1": "カスタムヘッダー名は空にできません",
"f474a4108aba4c4c": "サービスを停止", "f474a4108aba4c4c": "サービスを停止",
"f4d4bae588c4c0ff": "すべて選択解除",
"f4f0ead1116b5b62": "有効化", "f4f0ead1116b5b62": "有効化",
"f56c6c82203b33f6": "お知らせ", "f56c6c82203b33f6": "お知らせ",
"f61e03f047b786d5": "{0} の最大出力 Token は正の整数である必要があります", "f61e03f047b786d5": "{0} の最大出力 Token は正の整数である必要があります",
+12 -17
View File
@@ -1,6 +1,5 @@
{ {
"02216368edc68816": "Нет примечаний к выпуску", "02216368edc68816": "Нет примечаний к выпуску",
"02bc2e95bf49e587": "Пока нет настроенных",
"03b11112dc970014": "Базовый URL", "03b11112dc970014": "Базовый URL",
"04f632dd4f034d5e": "Размер контекстного окна {0} должен быть положительным целым числом", "04f632dd4f034d5e": "Размер контекстного окна {0} должен быть положительным целым числом",
"051836569928a9f9": "Изменить", "051836569928a9f9": "Изменить",
@@ -30,7 +29,6 @@
"281eb6d08c9960d0": "Бюджет токенов рассуждения {0} должен быть положительным целым числом", "281eb6d08c9960d0": "Бюджет токенов рассуждения {0} должен быть положительным целым числом",
"28aeffc70ceb4267": "Измените язык интерфейса. Настройка применяется сразу и сохраняется на этом устройстве.", "28aeffc70ceb4267": "Измените язык интерфейса. Настройка применяется сразу и сохраняется на этом устройстве.",
"2a24519398684ed5": "Перейти на домашнюю страницу", "2a24519398684ed5": "Перейти на домашнюю страницу",
"2c18d5e2d70b45db": "Префикс модели",
"2cd0f3be8738a86c": "Отмена", "2cd0f3be8738a86c": "Отмена",
"2d706f7981b45a7b": "Локальные настройки сохранены", "2d706f7981b45a7b": "Локальные настройки сохранены",
"2f9daa828907b93f": "Удалить", "2f9daa828907b93f": "Удалить",
@@ -42,14 +40,11 @@
"3468b57e3edbc599": "Сводка составлена по данным ходов, найденным в истории.", "3468b57e3edbc599": "Сводка составлена по данным ходов, найденным в истории.",
"35076178fe79a210": "Конфигурация изменена. Выполните проверку снова.", "35076178fe79a210": "Конфигурация изменена. Выполните проверку снова.",
"358f07b2c1445ab1": "Сообщение автора", "358f07b2c1445ab1": "Сообщение автора",
"36c149a9b3e8dca0": "моделей.",
"37d23612f78a2e63": "Перезапустить и обновить", "37d23612f78a2e63": "Перезапустить и обновить",
"392d0dceb45998d3": "Очень высокая", "392d0dceb45998d3": "Очень высокая",
"393df9bb13ea4900": "Попадание", "393df9bb13ea4900": "Попадание",
"3a5040b68abf75f9": "Выбрать все",
"3ab8cc15939f3b5c": "Выйти", "3ab8cc15939f3b5c": "Выйти",
"3af7e5489e61ea51": "Обновление", "3af7e5489e61ea51": "Обновление",
"3b2e5f2fba1bcbc7": "Введите адрес интерфейса и ключ доступа",
"3c2a9f9901109e75": "Тип {0} поддерживает только OpenAI или Anthropic", "3c2a9f9901109e75": "Тип {0} поддерживает только OpenAI или Anthropic",
"3d13868593ae4eeb": "Язык интерфейса", "3d13868593ae4eeb": "Язык интерфейса",
"3d52574ce1500561": "Не подключено", "3d52574ce1500561": "Не подключено",
@@ -63,19 +58,20 @@
"472642d58d3d5a6d": "Формула: {0}", "472642d58d3d5a6d": "Формула: {0}",
"4923eeb7bd75cccd": "Идентификатор модели {0} не может быть пустым", "4923eeb7bd75cccd": "Идентификатор модели {0} не может быть пустым",
"497c85690c4cc0fc": "Нет данных", "497c85690c4cc0fc": "Нет данных",
"4b5e0ae1288a9695": "Подходящих моделей нет",
"4c0a929bb86ce912": "Сейчас: {0}", "4c0a929bb86ce912": "Сейчас: {0}",
"4d2b6e53be6002e5": "Стратегия статистики кеша: {0} ({1})", "4d2b6e53be6002e5": "Стратегия статистики кеша: {0} ({1})",
"4d8c1c5b42830791": "Неизвестно", "4d8c1c5b42830791": "Неизвестно",
"4f0982ba1d37e51b": "Исходящие запросы используют прокси из переменных окружения", "4f0982ba1d37e51b": "Исходящие запросы используют прокси из переменных окружения",
"51194c3ad014fb29": "Требуется повторная проверка",
"5205125c0e91d346": "Максимальное число токенов, которое модель Anthropic может сгенерировать за один ответ. Оставьте поле пустым для значения по умолчанию.", "5205125c0e91d346": "Максимальное число токенов, которое модель Anthropic может сгенерировать за один ответ. Оставьте поле пустым для значения по умолчанию.",
"54e6745ff43c9c74": "Не удалось изменить порядок",
"56627c94a9decee6": "Макс. выходных токенов", "56627c94a9decee6": "Макс. выходных токенов",
"58c6b0935a7216da": "Не удалось открыть профиль участника", "58c6b0935a7216da": "Не удалось открыть профиль участника",
"593a972852ba0004": "Cursor Assistant | Всегда бесплатно | Пользовательский API", "593a972852ba0004": "Cursor Assistant | Всегда бесплатно | Пользовательский API",
"59a2195a01a8b35b": "{0} должен быть допустимым объектом JSON", "59a2195a01a8b35b": "{0} должен быть допустимым объектом JSON",
"5a5c8318ef649672": "Выбрано: {0}",
"5aa8f5590c940829": "Ввод без кеша: {0}", "5aa8f5590c940829": "Ввод без кеша: {0}",
"5beb1206c532729f": "Максимальное число токенов в одном ответе. Оставьте поле пустым для значения по умолчанию.", "5beb1206c532729f": "Максимальное число токенов в одном ответе. Оставьте поле пустым для значения по умолчанию.",
"5c55a67935af8f45": "Все",
"5d1687a4a41883fd": "Остановка...", "5d1687a4a41883fd": "Остановка...",
"5e709712ce012f5d": "Исходящие запросы используют прокси из переменных окружения: {0}", "5e709712ce012f5d": "Исходящие запросы используют прокси из переменных окружения: {0}",
"6106f0a12583a334": "Не удалось обновить", "6106f0a12583a334": "Не удалось обновить",
@@ -97,27 +93,26 @@
"6e584e3d5ce64aa0": "Сохранить настройки", "6e584e3d5ce64aa0": "Сохранить настройки",
"6ec87609a8769425": "По умолчанию {0} / С учетом создания {1}", "6ec87609a8769425": "По умолчанию {0} / С учетом создания {1}",
"72f6c3525c0192a7": "Если включено, создание кеша учитывается в знаменателе", "72f6c3525c0192a7": "Если включено, создание кеша учитывается в знаменателе",
"736c9dc2a04c65fd": "Конфигурация моделей изменилась. Обновите данные и повторите попытку.",
"737225e2904673fc": "Расчетные выходные токены: {0}", "737225e2904673fc": "Расчетные выходные токены: {0}",
"7520bd50a5ee5471": "Остановить проверку {0}/{1}", "7520bd50a5ee5471": "Остановить проверку {0}/{1}",
"753d8bb0da9913ce": "Не удалось дублировать", "753d8bb0da9913ce": "Не удалось дублировать",
"75b5f4c68c79322c": "Выберите хотя бы одну модель для сохранения",
"774d6e1b7cb89751": "Стандартный расчет", "774d6e1b7cb89751": "Стандартный расчет",
"77c9e582e85583af": "Проверка не пройдена", "77c9e582e85583af": "Проверка не пройдена",
"7923d007483ae04c": "Нет моделей для сохранения",
"7a26bf794e9fb6bf": "Используется только для отображения в интерфейсе, чтобы различать модели.", "7a26bf794e9fb6bf": "Используется только для отображения в интерфейсе, чтобы различать модели.",
"7b6187c41e88b70c": "Проверка...", "7b6187c41e88b70c": "Проверка...",
"7bf8e2c07e084d09": "Редактор модели",
"7df7641e5e741346": "Чтение кеша / (Чтение кеша + Создание кеша + Ввод без кеша)", "7df7641e5e741346": "Чтение кеша / (Чтение кеша + Создание кеша + Ввод без кеша)",
"7e9e334aeb0bdc07": "Не удалось выполнить операцию с сервисом", "7e9e334aeb0bdc07": "Не удалось выполнить операцию с сервисом",
"7f68ebad19ba6bcd": "Проверить обновления", "7f68ebad19ba6bcd": "Проверить обновления",
"80296f4aa3f4543b": "Чтение/запись кеша", "80296f4aa3f4543b": "Чтение/запись кеша",
"81123c56d5d880d0": "Ключ API", "81123c56d5d880d0": "Ключ API",
"8139cb3dd11f5a67": "Если включено, объект JSON переопределит итоговые заголовки запроса. При совпадении имен используются значения отсюда; все значения должны быть строками.", "8139cb3dd11f5a67": "Если включено, объект JSON переопределит итоговые заголовки запроса. При совпадении имен используются значения отсюда; все значения должны быть строками.",
"826c0e5a4407befa": "Можно выбрать несколько моделей. Будут сохранены только выбранные модели, для каждой будет создана отдельная конфигурация.", "8151e8704a7ca89e": "Совпадений нет",
"83913e71fcf7ff60": "Обновление выполнено",
"83be9cac28873059": "Аккаунт управляющего уровня Cursor", "83be9cac28873059": "Аккаунт управляющего уровня Cursor",
"83fcfb4c1f2c1641": "Получить модели",
"8672864e90417138": "Максимальная", "8672864e90417138": "Максимальная",
"86df7ec743047234": "Сервис запущен", "86df7ec743047234": "Сервис запущен",
"891bfee3bbe52d3c": "Введите идентификатор модели",
"899add6275682210": "Если оставить пустым, используется 200000", "899add6275682210": "Если оставить пустым, используется 200000",
"8a4ef3e48e4e8a5a": "Включено", "8a4ef3e48e4e8a5a": "Включено",
"8c1935935600e336": "Проверка модели", "8c1935935600e336": "Проверка модели",
@@ -130,7 +125,6 @@
"917b1c1f18d0276b": "Сохранение...", "917b1c1f18d0276b": "Сохранение...",
"9196835e388d2550": "Проверить все", "9196835e388d2550": "Проверить все",
"91cba5c107a51892": "/ Ошибочные", "91cba5c107a51892": "/ Ошибочные",
"92059fe6cd713db4": "Имя модели, фактически отправляемое серверу, например gpt-4.1 или claude-sonnet.",
"93e08803675e378b": "Идентификатор модели", "93e08803675e378b": "Идентификатор модели",
"93faf55cd25c8319": "Это программное обеспечение полностью бесплатно. Если с вас взяли плату, скорее всего, вас обманули.\\nПосетите страницу автора: https://space.bilibili.com/311706663/upload/video\\nТам публикуются обновления, руководства и другие материалы.", "93faf55cd25c8319": "Это программное обеспечение полностью бесплатно. Если с вас взяли плату, скорее всего, вас обманули.\\nПосетите страницу автора: https://space.bilibili.com/311706663/upload/video\\nТам публикуются обновления, руководства и другие материалы.",
"942ff2d88baca0c6": "Проверка обновлений...", "942ff2d88baca0c6": "Проверка обновлений...",
@@ -143,7 +137,6 @@
"9c41b3a9e12ac994": "Интенсивность рассуждений", "9c41b3a9e12ac994": "Интенсивность рассуждений",
"9d2ca261281a158a": "Позже", "9d2ca261281a158a": "Позже",
"9d2fb46c0ba890b9": "Пользовательский путь", "9d2fb46c0ba890b9": "Пользовательский путь",
"9dc0825fba5422e4": "Загрузка...",
"9e02529bcaef36c6": "Обнаружен повторяющийся канал модели. Проверьте сочетание url, modelID, apiKey, displayName и endpoint", "9e02529bcaef36c6": "Обнаружен повторяющийся канал модели. Проверьте сочетание url, modelID, apiKey, displayName и endpoint",
"a026f37e613cf48b": "Выходные токены", "a026f37e613cf48b": "Выходные токены",
"a0d36236c523667c": "Интенсивность рассуждений Anthropic для {0} поддерживает только low, medium, high, xhigh и max", "a0d36236c523667c": "Интенсивность рассуждений Anthropic для {0} поддерживает только low, medium, high, xhigh и max",
@@ -161,6 +154,7 @@
"ac217e4d1ca410f1": "Доступна новая версия", "ac217e4d1ca410f1": "Доступна новая версия",
"ad79540418be700a": "Открыть папку настроек или отдельно управлять настройками моделей", "ad79540418be700a": "Открыть папку настроек или отдельно управлять настройками моделей",
"ae5a738238463a92": "Скрыть ключ API", "ae5a738238463a92": "Скрыть ключ API",
"aeb1e3a8ff46cda1": "Модели пока не настроены.",
"aed55419ce62f08e": "Переключение...", "aed55419ce62f08e": "Переключение...",
"b10041a13f5c55b1": "Вывод модели: {0} × ${1}/1M = {2}", "b10041a13f5c55b1": "Вывод модели: {0} × ${1}/1M = {2}",
"b1c27820fec23edb": "Высокая", "b1c27820fec23edb": "Высокая",
@@ -181,14 +175,15 @@
"c228558cf257fc49": "Не удалось удалить", "c228558cf257fc49": "Не удалось удалить",
"c3d46b387eeadb23": "Будет выполнен выход только из аккаунта Cursor в cursor-byok. В клиенте Cursor вы останетесь в системе. Продолжить?", "c3d46b387eeadb23": "Будет выполнен выход только из аккаунта Cursor в cursor-byok. В клиенте Cursor вы останетесь в системе. Продолжить?",
"c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.", "c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.",
"c6868592796ac2b2": "Модели {0} пока не настроены.",
"c69f5bce63b9f14c": "Папка настроек", "c69f5bce63b9f14c": "Папка настроек",
"c72d5dc20cd27118": "Выбрано моделей: {0} / {1}",
"c8a52b66651d294c": "Не удалось выйти", "c8a52b66651d294c": "Не удалось выйти",
"c8c14507b2d37395": "Интенсивность рассуждений", "c8c14507b2d37395": "Интенсивность рассуждений",
"c98e118e0a43f078": "Модель", "c98e118e0a43f078": "Модель",
"c9dd59beefd7144f": "Чтение кеша / (Чтение кеша + Ввод без кеша)", "c9dd59beefd7144f": "Чтение кеша / (Чтение кеша + Ввод без кеша)",
"ca00a39fcea70dc6": "Запуск...", "ca00a39fcea70dc6": "Запуск...",
"ca1d1059408b3837": "Ошибочных ходов: {0}", "ca1d1059408b3837": "Ошибочных ходов: {0}",
"cc5049729a2c10f1": "Тест не пройден. Проверьте исходные сведения.",
"cd7ca5fb221e1c53": "{0} не может быть пустым", "cd7ca5fb221e1c53": "{0} не может быть пустым",
"cfa6c803eb3fc713": "Ожидание входа в браузере", "cfa6c803eb3fc713": "Ожидание входа в браузере",
"d0325067fed88e5a": "Доля попаданий в кеш: {0}", "d0325067fed88e5a": "Доля попаданий в кеш: {0}",
@@ -216,7 +211,6 @@
"e4c0daa3c4bea691": "Спасибо @aike0210 за вклад в функцию аккаунта панели управления Cursor.", "e4c0daa3c4bea691": "Спасибо @aike0210 за вклад в функцию аккаунта панели управления Cursor.",
"e53580f8031f13c0": "Завершите вход в браузере, затем вернитесь в Cursor и снова откройте магазин плагинов", "e53580f8031f13c0": "Завершите вход в браузере, затем вернитесь в Cursor и снова откройте магазин плагинов",
"e552c2accdbf5178": "Добавить модель", "e552c2accdbf5178": "Добавить модель",
"e6943d5cbfb863e0": "Получение моделей...",
"e6faccfddce722e8": "Токены чтения из кеша: {0}", "e6faccfddce722e8": "Токены чтения из кеша: {0}",
"e8a0a6053998ebfa": "Выполнен вход", "e8a0a6053998ebfa": "Выполнен вход",
"eaffd48cd2ea9f1a": "например, https://api.anthropic.com", "eaffd48cd2ea9f1a": "например, https://api.anthropic.com",
@@ -224,12 +218,13 @@
"ec3b17a75db49e24": "{0} т/с | Первый токен {1}", "ec3b17a75db49e24": "{0} т/с | Первый токен {1}",
"ec99e5c45d648fd6": "Не удалось обновить", "ec99e5c45d648fd6": "Не удалось обновить",
"ee95057c6b0335d2": "Исходящие запросы используют системный прокси: {0}", "ee95057c6b0335d2": "Исходящие запросы используют системный прокси: {0}",
"f0b6a23368dd47cc": "Введите идентификатор модели вручную или выберите его из списка, полученного от сервера.",
"f1aa7326f38b4c09": "Перетащите, чтобы изменить порядок",
"f1e0fc261d42fe29": "Примечание, отображаемое при наведении на модель в списке.", "f1e0fc261d42fe29": "Примечание, отображаемое при наведении на модель в списке.",
"f363622480699c52": "Интенсивность рассуждений применяется только к моделям с поддержкой reasoning_effort. Чем выше значение, тем обычно стабильнее результат, но ответ может формироваться медленнее.", "f363622480699c52": "Интенсивность рассуждений применяется только к моделям с поддержкой reasoning_effort. Чем выше значение, тем обычно стабильнее результат, но ответ может формироваться медленнее.",
"f3a76d896853c1df": "Промах", "f3a76d896853c1df": "Промах",
"f3fae6cccb9004b1": "Имя пользовательского заголовка не может быть пустым", "f3fae6cccb9004b1": "Имя пользовательского заголовка не может быть пустым",
"f474a4108aba4c4c": "Остановить сервис", "f474a4108aba4c4c": "Остановить сервис",
"f4d4bae588c4c0ff": "Снять выделение со всех",
"f4f0ead1116b5b62": "Включить", "f4f0ead1116b5b62": "Включить",
"f56c6c82203b33f6": "Уведомление", "f56c6c82203b33f6": "Уведомление",
"f61e03f047b786d5": "Максимальное число выходных токенов {0} должно быть положительным целым числом", "f61e03f047b786d5": "Максимальное число выходных токенов {0} должно быть положительным целым числом",
+12 -17
View File
@@ -1,6 +1,5 @@
{ {
"02216368edc68816": "无更新说明", "02216368edc68816": "无更新说明",
"02bc2e95bf49e587": "当前还没有配置任何",
"03b11112dc970014": "接口地址", "03b11112dc970014": "接口地址",
"04f632dd4f034d5e": "{0} 的上下文窗口必须为正整数", "04f632dd4f034d5e": "{0} 的上下文窗口必须为正整数",
"051836569928a9f9": "编辑", "051836569928a9f9": "编辑",
@@ -30,7 +29,6 @@
"281eb6d08c9960d0": "{0} 的思考预算 Token 必须为正整数", "281eb6d08c9960d0": "{0} 的思考预算 Token 必须为正整数",
"28aeffc70ceb4267": "切换当前界面显示语言,设置会立即生效并保存在本机", "28aeffc70ceb4267": "切换当前界面显示语言,设置会立即生效并保存在本机",
"2a24519398684ed5": "访问主页", "2a24519398684ed5": "访问主页",
"2c18d5e2d70b45db": "模型前缀",
"2cd0f3be8738a86c": "取消", "2cd0f3be8738a86c": "取消",
"2d706f7981b45a7b": "本地配置已保存", "2d706f7981b45a7b": "本地配置已保存",
"2f9daa828907b93f": "删除", "2f9daa828907b93f": "删除",
@@ -42,14 +40,11 @@
"3468b57e3edbc599": "按历史记录里扫描到的回合 summary 汇总。", "3468b57e3edbc599": "按历史记录里扫描到的回合 summary 汇总。",
"35076178fe79a210": "配置已变更,请重新测试", "35076178fe79a210": "配置已变更,请重新测试",
"358f07b2c1445ab1": "作者寄语", "358f07b2c1445ab1": "作者寄语",
"36c149a9b3e8dca0": "模型。",
"37d23612f78a2e63": "立即重启更新", "37d23612f78a2e63": "立即重启更新",
"392d0dceb45998d3": "极高", "392d0dceb45998d3": "极高",
"393df9bb13ea4900": "命中", "393df9bb13ea4900": "命中",
"3a5040b68abf75f9": "全选",
"3ab8cc15939f3b5c": "退出登录", "3ab8cc15939f3b5c": "退出登录",
"3af7e5489e61ea51": "刷新中", "3af7e5489e61ea51": "刷新中",
"3b2e5f2fba1bcbc7": "请输入接口地址和访问密钥",
"3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic", "3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic",
"3d13868593ae4eeb": "界面语言", "3d13868593ae4eeb": "界面语言",
"3d52574ce1500561": "未连接", "3d52574ce1500561": "未连接",
@@ -63,19 +58,20 @@
"472642d58d3d5a6d": "公式:{0}", "472642d58d3d5a6d": "公式:{0}",
"4923eeb7bd75cccd": "{0} 的模型标识不能为空", "4923eeb7bd75cccd": "{0} 的模型标识不能为空",
"497c85690c4cc0fc": "暂无数据", "497c85690c4cc0fc": "暂无数据",
"4b5e0ae1288a9695": "没有匹配的模型",
"4c0a929bb86ce912": "当前:{0}", "4c0a929bb86ce912": "当前:{0}",
"4d2b6e53be6002e5": "缓存统计策略:{0}{1}", "4d2b6e53be6002e5": "缓存统计策略:{0}{1}",
"4d8c1c5b42830791": "未知", "4d8c1c5b42830791": "未知",
"4f0982ba1d37e51b": "当前出站请求使用环境变量代理", "4f0982ba1d37e51b": "当前出站请求使用环境变量代理",
"51194c3ad014fb29": "需重测",
"5205125c0e91d346": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。", "5205125c0e91d346": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。",
"54e6745ff43c9c74": "排序失败",
"56627c94a9decee6": "最大输出 Token", "56627c94a9decee6": "最大输出 Token",
"58c6b0935a7216da": "打开贡献者主页失败", "58c6b0935a7216da": "打开贡献者主页失败",
"593a972852ba0004": "Cursor助手|永久免费|自定义API", "593a972852ba0004": "Cursor助手|永久免费|自定义API",
"59a2195a01a8b35b": "{0}必须是合法 JSON 对象", "59a2195a01a8b35b": "{0}必须是合法 JSON 对象",
"5a5c8318ef649672": "已选择 {0} 项",
"5aa8f5590c940829": "非缓存输入:{0}", "5aa8f5590c940829": "非缓存输入:{0}",
"5beb1206c532729f": "单次回复允许生成的最大 Token 数。留空时使用默认值。", "5beb1206c532729f": "单次回复允许生成的最大 Token 数。留空时使用默认值。",
"5c55a67935af8f45": "全部",
"5d1687a4a41883fd": "停止中...", "5d1687a4a41883fd": "停止中...",
"5e709712ce012f5d": "当前出站请求使用环境变量代理:{0}", "5e709712ce012f5d": "当前出站请求使用环境变量代理:{0}",
"6106f0a12583a334": "刷新失败", "6106f0a12583a334": "刷新失败",
@@ -97,27 +93,26 @@
"6e584e3d5ce64aa0": "保存配置", "6e584e3d5ce64aa0": "保存配置",
"6ec87609a8769425": "默认 {0} / 计入创建 {1}", "6ec87609a8769425": "默认 {0} / 计入创建 {1}",
"72f6c3525c0192a7": "开启后把缓存创建纳入分母", "72f6c3525c0192a7": "开启后把缓存创建纳入分母",
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
"737225e2904673fc": "输出推算:{0}", "737225e2904673fc": "输出推算:{0}",
"7520bd50a5ee5471": "停止测试 {0}/{1}", "7520bd50a5ee5471": "停止测试 {0}/{1}",
"753d8bb0da9913ce": "复制失败", "753d8bb0da9913ce": "复制失败",
"75b5f4c68c79322c": "请先选择要保存的模型",
"774d6e1b7cb89751": "默认口径", "774d6e1b7cb89751": "默认口径",
"77c9e582e85583af": "测试失败", "77c9e582e85583af": "测试失败",
"7923d007483ae04c": "没有可保存的模型",
"7a26bf794e9fb6bf": "仅用于界面展示,便于你区分不同模型。", "7a26bf794e9fb6bf": "仅用于界面展示,便于你区分不同模型。",
"7b6187c41e88b70c": "测试中...", "7b6187c41e88b70c": "测试中...",
"7bf8e2c07e084d09": "模型编辑",
"7df7641e5e741346": "缓存读取 /(缓存读取 + 缓存创建 + 非缓存输入)", "7df7641e5e741346": "缓存读取 /(缓存读取 + 缓存创建 + 非缓存输入)",
"7e9e334aeb0bdc07": "服务操作失败", "7e9e334aeb0bdc07": "服务操作失败",
"7f68ebad19ba6bcd": "检查更新", "7f68ebad19ba6bcd": "检查更新",
"80296f4aa3f4543b": "缓存读写", "80296f4aa3f4543b": "缓存读写",
"81123c56d5d880d0": "访问密钥", "81123c56d5d880d0": "访问密钥",
"8139cb3dd11f5a67": "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。", "8139cb3dd11f5a67": "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。",
"826c0e5a4407befa": "可多选。保存时只会写入选中的模型,每个模型生成一条配置。", "8151e8704a7ca89e": "没有匹配项",
"83913e71fcf7ff60": "刷新成功",
"83be9cac28873059": "Cursor 控制面账号", "83be9cac28873059": "Cursor 控制面账号",
"83fcfb4c1f2c1641": "获取模型",
"8672864e90417138": "最高", "8672864e90417138": "最高",
"86df7ec743047234": "服务运行中", "86df7ec743047234": "服务运行中",
"891bfee3bbe52d3c": "请填写模型标识",
"899add6275682210": "留空时默认 200000", "899add6275682210": "留空时默认 200000",
"8a4ef3e48e4e8a5a": "已开启", "8a4ef3e48e4e8a5a": "已开启",
"8c1935935600e336": "模型测试", "8c1935935600e336": "模型测试",
@@ -130,7 +125,6 @@
"917b1c1f18d0276b": "保存中...", "917b1c1f18d0276b": "保存中...",
"9196835e388d2550": "测试全部", "9196835e388d2550": "测试全部",
"91cba5c107a51892": "/ 异常", "91cba5c107a51892": "/ 异常",
"92059fe6cd713db4": "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
"93e08803675e378b": "模型标识", "93e08803675e378b": "模型标识",
"93faf55cd25c8319": "本软件是纯免费软件,如果你被收费,那大概率就是被骗了。\n欢迎点击访问作者主页 https://space.bilibili.com/311706663/upload/video\n查看更多更新动态、使用分享和后续内容。", "93faf55cd25c8319": "本软件是纯免费软件,如果你被收费,那大概率就是被骗了。\n欢迎点击访问作者主页 https://space.bilibili.com/311706663/upload/video\n查看更多更新动态、使用分享和后续内容。",
"942ff2d88baca0c6": "检查更新中...", "942ff2d88baca0c6": "检查更新中...",
@@ -143,7 +137,6 @@
"9c41b3a9e12ac994": "思考强度", "9c41b3a9e12ac994": "思考强度",
"9d2ca261281a158a": "稍后", "9d2ca261281a158a": "稍后",
"9d2fb46c0ba890b9": "自定义路径", "9d2fb46c0ba890b9": "自定义路径",
"9dc0825fba5422e4": "加载中...",
"9e02529bcaef36c6": "模型渠道重复,请检查 url、modelID、apiKey、displayName、endpoint 组合", "9e02529bcaef36c6": "模型渠道重复,请检查 url、modelID、apiKey、displayName、endpoint 组合",
"a026f37e613cf48b": "输出 Token", "a026f37e613cf48b": "输出 Token",
"a0d36236c523667c": "{0} 的 Anthropic 思考强度仅支持 low、medium、high、xhigh、max", "a0d36236c523667c": "{0} 的 Anthropic 思考强度仅支持 low、medium、high、xhigh、max",
@@ -161,6 +154,7 @@
"ac217e4d1ca410f1": "发现新版本", "ac217e4d1ca410f1": "发现新版本",
"ad79540418be700a": "打开设置目录,或单独管理模型配置", "ad79540418be700a": "打开设置目录,或单独管理模型配置",
"ae5a738238463a92": "隐藏访问密钥", "ae5a738238463a92": "隐藏访问密钥",
"aeb1e3a8ff46cda1": "当前还没有配置任何模型。",
"aed55419ce62f08e": "切换中...", "aed55419ce62f08e": "切换中...",
"b10041a13f5c55b1": "模型输出:{0} × ${1}/1M = {2}", "b10041a13f5c55b1": "模型输出:{0} × ${1}/1M = {2}",
"b1c27820fec23edb": "高", "b1c27820fec23edb": "高",
@@ -181,14 +175,15 @@
"c228558cf257fc49": "删除失败", "c228558cf257fc49": "删除失败",
"c3d46b387eeadb23": "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?", "c3d46b387eeadb23": "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?",
"c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。", "c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
"c6868592796ac2b2": "当前还没有配置任何 {0} 模型。",
"c69f5bce63b9f14c": "设置文件夹", "c69f5bce63b9f14c": "设置文件夹",
"c72d5dc20cd27118": "已选择 {0} / {1} 个模型",
"c8a52b66651d294c": "退出登录失败", "c8a52b66651d294c": "退出登录失败",
"c8c14507b2d37395": "推理强度", "c8c14507b2d37395": "推理强度",
"c98e118e0a43f078": "模型", "c98e118e0a43f078": "模型",
"c9dd59beefd7144f": "缓存读取 /(缓存读取 + 非缓存输入)", "c9dd59beefd7144f": "缓存读取 /(缓存读取 + 非缓存输入)",
"ca00a39fcea70dc6": "启动中...", "ca00a39fcea70dc6": "启动中...",
"ca1d1059408b3837": "异常轮次:{0}", "ca1d1059408b3837": "异常轮次:{0}",
"cc5049729a2c10f1": "测试失败,请查看原始信息",
"cd7ca5fb221e1c53": "{0}不能为空", "cd7ca5fb221e1c53": "{0}不能为空",
"cfa6c803eb3fc713": "等待浏览器登录", "cfa6c803eb3fc713": "等待浏览器登录",
"d0325067fed88e5a": "缓存命中率 {0}", "d0325067fed88e5a": "缓存命中率 {0}",
@@ -216,7 +211,6 @@
"e4c0daa3c4bea691": "感谢 @aike0210 对 Cursor 控制面账号功能的贡献。", "e4c0daa3c4bea691": "感谢 @aike0210 对 Cursor 控制面账号功能的贡献。",
"e53580f8031f13c0": "请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场", "e53580f8031f13c0": "请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场",
"e552c2accdbf5178": "新增模型", "e552c2accdbf5178": "新增模型",
"e6943d5cbfb863e0": "正在获取模型...",
"e6faccfddce722e8": "缓存读取:{0}", "e6faccfddce722e8": "缓存读取:{0}",
"e8a0a6053998ebfa": "已经登录", "e8a0a6053998ebfa": "已经登录",
"eaffd48cd2ea9f1a": "例如:https://api.anthropic.com", "eaffd48cd2ea9f1a": "例如:https://api.anthropic.com",
@@ -224,12 +218,13 @@
"ec3b17a75db49e24": "{0} t/s | 首字 {1}", "ec3b17a75db49e24": "{0} t/s | 首字 {1}",
"ec99e5c45d648fd6": "更新失败", "ec99e5c45d648fd6": "更新失败",
"ee95057c6b0335d2": "当前出站请求使用系统代理:{0}", "ee95057c6b0335d2": "当前出站请求使用系统代理:{0}",
"f0b6a23368dd47cc": "可以直接输入模型标识,或从服务端返回的列表中选择。",
"f1aa7326f38b4c09": "拖拽排序",
"f1e0fc261d42fe29": "模型列表 hover 时显示的备注说明。", "f1e0fc261d42fe29": "模型列表 hover 时显示的备注说明。",
"f363622480699c52": "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。", "f363622480699c52": "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。",
"f3a76d896853c1df": "未命中", "f3a76d896853c1df": "未命中",
"f3fae6cccb9004b1": "自定义请求头名称不能为空", "f3fae6cccb9004b1": "自定义请求头名称不能为空",
"f474a4108aba4c4c": "关闭服务", "f474a4108aba4c4c": "关闭服务",
"f4d4bae588c4c0ff": "取消全选",
"f4f0ead1116b5b62": "启用", "f4f0ead1116b5b62": "启用",
"f56c6c82203b33f6": "提示", "f56c6c82203b33f6": "提示",
"f61e03f047b786d5": "{0} 的最大输出 Token 必须为正整数", "f61e03f047b786d5": "{0} 的最大输出 Token 必须为正整数",
+8 -12
View File
@@ -89,7 +89,7 @@ async function handleCheckForUpdates() {
if (updateViewState.footerBusy || updateViewState.footerDownloading) { if (updateViewState.footerBusy || updateViewState.footerDownloading) {
return; return;
} }
const loadingMessageID = message.loading("检查更新中..."); const loadingMessageID = message("检查更新中...", { duration: 0 });
try { try {
await checkForAppUpdates(); await checkForAppUpdates();
} finally { } finally {
@@ -107,13 +107,9 @@ async function loadFooterAuthorInfo() {
} }
} }
async function showActionError(title, error) { function showActionError(title, error) {
await showModal({ const detail = String(error || "操作失败").trim() || "操作失败";
title, message(`${title}${detail}`);
content: String(error || "操作失败").trim() || "操作失败",
confirmText: "确定",
showCancel: false,
});
} }
async function handleOpenAuthorHome() { async function handleOpenAuthorHome() {
@@ -133,7 +129,7 @@ async function handleOpenAuthorHome() {
try { try {
await openFooterAuthorHome(); await openFooterAuthorHome();
} catch (error) { } catch (error) {
await showActionError("打开主页失败", error); showActionError("打开主页失败", error);
} }
} }
@@ -141,7 +137,7 @@ async function handleOpenUsageDocs() {
try { try {
await Browser.OpenURL(usageDocsURL); await Browser.OpenURL(usageDocsURL);
} catch (error) { } catch (error) {
await showActionError("打开使用教程失败", error); showActionError("打开使用教程失败", error);
} }
} }
@@ -175,7 +171,7 @@ onUnmounted(() => {
:class="{ '!justify-center': !isWindows }" :class="{ '!justify-center': !isWindows }"
> >
<div class="center-row gap-2" style="font-family: var(--font-num);"> <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>{{ title }}</div>
</div> </div>
<div <div
@@ -203,7 +199,7 @@ onUnmounted(() => {
<footer <footer
v-if="showFooter" 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 <div
v-if="proxyBadgeText" v-if="proxyBadgeText"
-6
View File
@@ -1,7 +1,6 @@
import { createRouter, createWebHashHistory } from "vue-router"; import { createRouter, createWebHashHistory } from "vue-router";
import Home from "@/views/Home.vue"; import Home from "@/views/Home.vue";
import ModelConfig from "@/views/ModelConfig.vue"; import ModelConfig from "@/views/ModelConfig.vue";
import ModelEditor from "@/views/ModelEditor.vue";
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
@@ -16,11 +15,6 @@ const router = createRouter({
component: ModelConfig, component: ModelConfig,
meta: { showIcon: false, title: "模型配置", directlyClose: true }, 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, GetAppVersion,
GetFooterAuthorInfo, GetFooterAuthorInfo,
InstallReadyUpdate, InstallReadyUpdate,
GetModelEditorContext,
OpenConfigWindow, OpenConfigWindow,
OpenFooterAuthorHome, OpenFooterAuthorHome,
OpenHistoryWindow, OpenHistoryWindow,
OpenModelConfigWindow, OpenModelConfigWindow,
OpenModelEditorWindow,
} from "@bindings/cursor/internal/bridge/windowservice.js"; } from "@bindings/cursor/internal/bridge/windowservice.js";
import { Call } from "@wailsio/runtime"; import { Call } from "@wailsio/runtime";
@@ -133,16 +131,6 @@ export function openModelConfig() {
return withApiLogging("OpenModelConfigWindow", undefined, () => OpenModelConfigWindow()); 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) { export function testModelAdapter(adapter) {
return Call.ByName(`${PROXY_SERVICE_NAME}.TestModelAdapter`, adapter).then( return Call.ByName(`${PROXY_SERVICE_NAME}.TestModelAdapter`, adapter).then(
(result) => { (result) => {
+79 -115
View File
@@ -12,7 +12,6 @@ import {
loadUserConfig, loadUserConfig,
openLogsDirectory, openLogsDirectory,
openModelConfig, openModelConfig,
openModelEditor,
saveUserConfig, saveUserConfig,
startProxyService, startProxyService,
stopProxyService, stopProxyService,
@@ -248,6 +247,7 @@ function normalizeModelAdapterTestResults(source) {
export function createEmptyModelAdapter() { export function createEmptyModelAdapter() {
return { return {
id: "", id: "",
sort: 0,
displayName: "", displayName: "",
type: "openai", type: "openai",
baseURL: "", baseURL: "",
@@ -355,6 +355,7 @@ export function normalizeModelAdapter(source) {
: ""; : "";
return { return {
id: asString(raw.id), id: asString(raw.id),
sort: asPositiveInteger(raw.sort),
displayName: asString(raw.displayName || raw.name), displayName: asString(raw.displayName || raw.name),
type: SUPPORTED_MODEL_ADAPTER_TYPES.has(normalizedType) ? normalizedType : "", type: SUPPORTED_MODEL_ADAPTER_TYPES.has(normalizedType) ? normalizedType : "",
baseURL: normalizeBaseURL(raw.baseURL || raw.url), baseURL: normalizeBaseURL(raw.baseURL || raw.url),
@@ -392,7 +393,29 @@ export function normalizeModelAdapter(source) {
} }
export function normalizeModelAdapters(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) { export function validateModelAdapters(source) {
@@ -400,9 +423,6 @@ export function validateModelAdapters(source) {
const seenIdentityKeys = new Set(); const seenIdentityKeys = new Set();
for (const [index, adapter] of adapters.entries()) { for (const [index, adapter] of adapters.entries()) {
const prefix = `模型 ${index + 1}`; const prefix = `模型 ${index + 1}`;
if (!adapter.displayName) {
return `${prefix} 的显示名称不能为空`;
}
if (!SUPPORTED_MODEL_ADAPTER_TYPES.has(adapter.type)) { if (!SUPPORTED_MODEL_ADAPTER_TYPES.has(adapter.type)) {
return `${prefix} 的类型仅支持 OpenAI 或 Anthropic`; return `${prefix} 的类型仅支持 OpenAI 或 Anthropic`;
} }
@@ -412,15 +432,27 @@ export function validateModelAdapters(source) {
if (!adapter.apiKey) { if (!adapter.apiKey) {
return `${prefix} 的访问密钥不能为空`; return `${prefix} 的访问密钥不能为空`;
} }
if (!adapter.tooltipData) { if (!adapter.displayName) {
return `${prefix}悬停提示不能为空`; return `${prefix}显示名称不能为空`;
} }
if (!adapter.modelID) { if (!adapter.modelID) {
return `${prefix} 的模型标识不能为空`; return `${prefix} 的模型标识不能为空`;
} }
if (adapter.contextWindowTokens && (!Number.isInteger(adapter.contextWindowTokens) || adapter.contextWindowTokens <= 0)) {
return `${prefix} 的上下文窗口必须为正整数`;
}
if (adapter.type === "openai" && !SUPPORTED_REASONING_EFFORTS.has(adapter.reasoningEffort)) { if (adapter.type === "openai" && !SUPPORTED_REASONING_EFFORTS.has(adapter.reasoningEffort)) {
return `${prefix} 的推理强度仅支持 low、medium、high、xhigh、max`; 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)) { if (adapter.type === "openai" && !isValidOpenAIEndpoint(adapter.openAIEndpoint)) {
return `${prefix} 的 OpenAI 端点仅支持 /v1/responses、/v1/chat/completions 或以 / 开头的自定义路径`; return `${prefix} 的 OpenAI 端点仅支持 /v1/responses、/v1/chat/completions 或以 / 开头的自定义路径`;
} }
@@ -430,29 +462,20 @@ export function validateModelAdapters(source) {
return `${prefix}${extraParamsError}`; return `${prefix}${extraParamsError}`;
} }
} }
if (adapter.customHeadersEnabled) {
const customHeadersError = validateHeadersJSON(adapter.customHeadersJSON);
if (customHeadersError) {
return `${prefix}${customHeadersError}`;
}
}
if (adapter.type === "anthropic" && adapter.anthropicExtraParamsEnabled) { if (adapter.type === "anthropic" && adapter.anthropicExtraParamsEnabled) {
const extraParamsError = validateAnthropicExtraParamsJSON(adapter.anthropicExtraParamsJSON); const extraParamsError = validateAnthropicExtraParamsJSON(adapter.anthropicExtraParamsJSON);
if (extraParamsError) { if (extraParamsError) {
return `${prefix}${extraParamsError}`; return `${prefix}${extraParamsError}`;
} }
} }
if (adapter.type === "anthropic" && !SUPPORTED_ANTHROPIC_THINKING_EFFORTS.has(adapter.anthropicThinkingEffort)) { if (adapter.customHeadersEnabled) {
return `${prefix} 的 Anthropic 思考强度仅支持 low、medium、high、xhigh、max`; const customHeadersError = validateHeadersJSON(adapter.customHeadersJSON);
if (customHeadersError) {
return `${prefix}${customHeadersError}`;
}
} }
if (adapter.contextWindowTokens && (!Number.isInteger(adapter.contextWindowTokens) || adapter.contextWindowTokens <= 0)) { if (!adapter.tooltipData) {
return `${prefix}上下文窗口必须为正整数`; 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.thinkingBudgetTokens && (!Number.isInteger(adapter.thinkingBudgetTokens) || adapter.thinkingBudgetTokens <= 0)) { if (adapter.thinkingBudgetTokens && (!Number.isInteger(adapter.thinkingBudgetTokens) || adapter.thinkingBudgetTokens <= 0)) {
return `${prefix} 的思考预算 Token 必须为正整数`; return `${prefix} 的思考预算 Token 必须为正整数`;
@@ -1151,93 +1174,6 @@ export async function fetchAvailableModelIDs(payload) {
.filter(Boolean); .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) { export async function deleteModelAdapterAt(index) {
const currentConfig = await loadPersistedUserConfig(); const currentConfig = await loadPersistedUserConfig();
const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters); 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) { function splitDisplayNameSeed(value) {
const text = asString(value); const text = asString(value);
const match = text.match(/^(.*?)(?:\s*[-+](\d+))?$/); const match = text.match(/^(.*?)(?:\s*[-+](\d+))?$/);
@@ -1403,11 +1372,6 @@ export async function openModelConfigWindow() {
await openModelConfig(); await openModelConfig();
} }
export async function openModelEditorWindow(index, adapter) {
const adapterJSON = JSON.stringify(normalizeModelAdapter(adapter));
await openModelEditor(index, adapterJSON);
}
export async function checkForAppUpdates() { export async function checkForAppUpdates() {
await checkForUpdates(); await checkForUpdates();
} }
+42 -4
View File
@@ -13,8 +13,15 @@ body,
width: 100vw; width: 100vw;
overflow: hidden; overflow: hidden;
background: #191919; background: #191919;
font-family: "PingFang-Medium", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family:
color: #F7F7F7; "PingFang-Medium",
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
color: #f7f7f7;
font-size: 13px; font-size: 13px;
} }
@@ -28,10 +35,41 @@ body,
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(100, 100, 100, 0.8) transparent; scrollbar-color: rgba(100, 100, 100, 0.8) transparent;
} }
:root{ :root {
color-scheme: dark; 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/Blink (Chrome, Safari, Edge, Opera) */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
+9 -12
View File
@@ -2,7 +2,7 @@
import Button from "@/components/ui/Button.vue"; import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue"; import Card from "@/components/ui/Card.vue";
import LocaleSelect from "@/components/LocaleSelect.vue"; import LocaleSelect from "@/components/LocaleSelect.vue";
import { showModal } from "@/composables/useModal"; import { useMessage } from "@/composables/useMessage";
import { import {
appState, appState,
openModelConfigWindow, openModelConfigWindow,
@@ -12,30 +12,27 @@ import {
} from "@/state/appState"; } from "@/state/appState";
import { onMounted } from "vue"; import { onMounted } from "vue";
async function showActionError(title, error) { const message = useMessage();
await showModal({
title, function showActionError(title, error) {
content: String(error || "服务错误").trim() || "服务错误", const detail = String(error || "服务错误").trim() || "服务错误";
}); message(`${title}${detail}`);
} }
async function handleSaveConfig() { async function handleSaveConfig() {
const result = await persistUserConfig(); const result = await persistUserConfig();
if (!result.ok) { if (!result.ok) {
await showActionError("保存失败", result.error); showActionError("保存失败", result.error);
return; return;
} }
await showModal({ message("本地配置已保存");
title: "提示",
content: "本地配置已保存",
});
} }
async function handleOpenModelConfig() { async function handleOpenModelConfig() {
try { try {
await openModelConfigWindow(); await openModelConfigWindow();
} catch (error) { } 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 Card from "@/components/ui/Card.vue";
import HomeMetricsCard from "@/components/HomeMetricsCard.vue"; import HomeMetricsCard from "@/components/HomeMetricsCard.vue";
import CursorAccountCard from "@/components/CursorAccountCard.vue"; import CursorAccountCard from "@/components/CursorAccountCard.vue";
import { showModal } from "@/composables/useModal"; import { useMessage } from "@/composables/useMessage";
import { getAdRuntime } from "@/services/clientApi"; import { getAdRuntime } from "@/services/clientApi";
import { import {
appState, appState,
@@ -20,6 +20,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
const AD_UPDATED_EVENT = "ad:updated"; const AD_UPDATED_EVENT = "ad:updated";
const OPEN_AD_EVENT = "cursor:open-ad"; const OPEN_AD_EVENT = "cursor:open-ad";
const message = useMessage();
const adRuntime = ref(null); const adRuntime = ref(null);
let unsubscribeAdUpdated = null; let unsubscribeAdUpdated = null;
@@ -79,17 +80,15 @@ function handleOpenHomeAd(slotId) {
window.dispatchEvent(new CustomEvent(OPEN_AD_EVENT, { detail: { slotId: asString(slotId) } })); window.dispatchEvent(new CustomEvent(OPEN_AD_EVENT, { detail: { slotId: asString(slotId) } }));
} }
async function showActionError(title, error) { function showActionError(title, error) {
await showModal({ const detail = String(error || "服务错误").trim() || "服务错误";
title, message(`${title}${detail}`);
content: String(error || "服务错误").trim() || "服务错误",
});
} }
async function handleToggleService() { async function handleToggleService() {
const result = await toggleService(); const result = await toggleService();
if (!result.ok) { if (!result.ok) {
await showActionError("服务操作失败", result.error); showActionError("服务操作失败", result.error);
} }
} }
@@ -99,19 +98,24 @@ async function handleRefreshState() {
syncHomeMetrics(), syncHomeMetrics(),
]); ]);
if (serviceStateResult.status === "rejected") { if (serviceStateResult.status === "rejected") {
await showActionError("刷新失败", toUserError(serviceStateResult.reason)); showActionError("刷新失败", toUserError(serviceStateResult.reason));
} }
} }
async function handleRefreshMetrics() { async function handleRefreshMetrics() {
await syncHomeMetrics().catch(() => {}); const result = await syncHomeMetrics();
if (result.ok) {
message("刷新成功");
return;
}
showActionError("刷新失败", result.error);
} }
async function handleOpenConfig() { async function handleOpenConfig() {
try { try {
await openConfigWindow(); await openConfigWindow();
} catch (error) { } catch (error) {
await showActionError("打开失败", toUserError(error)); showActionError("打开失败", toUserError(error));
} }
} }
@@ -119,7 +123,7 @@ async function handleOpenModelConfig() {
try { try {
await openModelConfigWindow(); await openModelConfigWindow();
} catch (error) { } catch (error) {
await showActionError("打开失败", toUserError(error)); showActionError("打开失败", toUserError(error));
} }
} }
@@ -136,7 +140,7 @@ onBeforeUnmount(() => {
</script> </script>
<template> <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 <HomeMetricsCard
:metrics="appState.homeMetrics" :metrics="appState.homeMetrics"
:loading="appState.homeMetricsLoading" :loading="appState.homeMetricsLoading"
@@ -148,7 +152,7 @@ onBeforeUnmount(() => {
<Card> <Card>
<div class="flex flex-col gap-4"> <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="flex flex-col gap-1">
<div class="text-sm" :class="appViewState.serviceStatusClass"> <div class="text-sm" :class="appViewState.serviceStatusClass">
{{ appViewState.serviceStatusText }} {{ appViewState.serviceStatusText }}
@@ -172,7 +176,7 @@ onBeforeUnmount(() => {
<CursorAccountCard /> <CursorAccountCard />
<Card> <Card class="">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
<h2 class="text-base font-medium text-white">本地配置</h2> <h2 class="text-base font-medium text-white">本地配置</h2>
+225 -55
View File
@@ -1,39 +1,56 @@
<script setup> <script setup>
import Button from "@/components/ui/Button.vue"; import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue"; import Card from "@/components/ui/Card.vue";
import ContentModal from "@/components/ui/ContentModal.vue";
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.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 { import {
appState, appState,
createEmptyModelAdapter, createEmptyModelAdapter,
deleteModelAdapterAt, deleteModelAdapterAt,
duplicateModelAdapterAt, duplicateModelAdapterAt,
getModelAdapterTestResultByID, getModelAdapterTestResultByID,
openModelEditorWindow,
reloadUserConfig, reloadUserConfig,
runModelAdapterTest, runModelAdapterTest,
saveModelAdapterOrder,
startModelAdapterTest, startModelAdapterTest,
toUserError, toUserError,
} from "@/state/appState"; } 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 BATCH_TEST_CONCURRENCY = 10;
const message = useMessage();
const typeTabs = [ const typeTabs = [
{ label: "全部", value: "all", icon: "icon-[mdi--view-grid-outline]" },
{ label: "OpenAI", value: "openai", icon: "icon-[bxl--openai]" }, { label: "OpenAI", value: "openai", icon: "icon-[bxl--openai]" },
{ label: "Anthropic", value: "anthropic", icon: "icon-[logos--claude-icon]" }, { label: "Anthropic", value: "anthropic", icon: "icon-[logos--claude-icon]" },
]; ];
const activeType = ref("openai"); const activeType = ref("all");
const batchTesting = ref(false); const batchTesting = ref(false);
const batchStopping = ref(false); const batchStopping = ref(false);
const batchTotal = ref(0); const batchTotal = ref(0);
const batchCompleted = 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(); const batchActiveCalls = new Set();
let batchStopRequested = false; let batchStopRequested = false;
let sortable = null;
const filteredAdapters = computed(() => const filteredAdapters = computed(() => (
appState.modelAdapters.filter((adapter) => adapter.type === activeType.value), 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(() => { const batchButtonText = computed(() => {
if (batchStopping.value) { if (batchStopping.value) {
@@ -44,24 +61,30 @@ const batchButtonText = computed(() => {
} }
return `停止测试 ${batchCompleted.value}/${batchTotal.value}`; return `停止测试 ${batchCompleted.value}/${batchTotal.value}`;
}); });
const editorTitle = computed(() => (editorIndex.value >= 0 ? "编辑模型配置" : "新增模型配置"));
const emptyStateText = computed(() => (
activeType.value === "all"
? "当前还没有配置任何模型。"
: `当前还没有配置任何 ${typeLabel(activeType.value)} 模型。`
));
watch( watch(
() => appState.modelAdapters, () => appState.modelAdapters,
(adapters) => { (adapters) => {
if (adapters.some((adapter) => adapter.type === activeType.value)) { if (
activeType.value === "all"
|| adapters.some((adapter) => adapter.type === activeType.value)
) {
return; return;
} }
const fallback = typeTabs.find((tab) => adapters.some((adapter) => adapter.type === tab.value)); activeType.value = "all";
activeType.value = fallback?.value ?? "openai";
}, },
{ deep: true, immediate: true }, { deep: true, immediate: true },
); );
async function showActionError(title, error) { function showActionError(title, error) {
await showModal({ const detail = String(error || "服务错误").trim() || "服务错误";
title, message(`${title}${detail}`);
content: String(error || "服务错误").trim() || "服务错误",
});
} }
function maskSecret(value) { function maskSecret(value) {
@@ -92,41 +115,167 @@ function formatHost(value) {
} }
} }
async function openEditor(index = -1) { function openEditor(index = -1) {
const adapter = index >= 0 editorIndex.value = index;
editorAdapter.value = index >= 0
? appState.modelAdapters[index] ? appState.modelAdapters[index]
: { : {
...createEmptyModelAdapter(), ...createEmptyModelAdapter(),
type: activeType.value, type: activeType.value === "anthropic" ? "anthropic" : "openai",
}; };
try { editorSession.value += 1;
await openModelEditorWindow(index, adapter); editorOpen.value = true;
} catch (error) { }
await showActionError("打开失败", toUserError(error));
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) { async function handleDeleteModelAdapter(index) {
const target = appState.modelAdapters[index]; const target = appState.modelAdapters[index];
if (!target) { if (!target) {
await showActionError("删除失败", "模型配置不存在,无法删除"); showActionError("删除失败", "模型配置不存在,无法删除");
return; return;
} }
const result = await deleteModelAdapterAt(index); const result = await deleteModelAdapterAt(index);
if (!result.ok) { if (!result.ok) {
await showActionError("删除失败", result.error); showActionError("删除失败", result.error);
} }
} }
async function handleDuplicateModelAdapter(index) { async function handleDuplicateModelAdapter(index) {
const target = appState.modelAdapters[index]; const target = appState.modelAdapters[index];
if (!target) { if (!target) {
await showActionError("复制失败", "模型配置不存在,无法复制"); showActionError("复制失败", "模型配置不存在,无法复制");
return; return;
} }
const result = await duplicateModelAdapterAt(index); const result = await duplicateModelAdapterAt(index);
if (!result.ok) { if (!result.ok) {
await showActionError("复制失败", result.error); showActionError("复制失败", result.error);
} }
} }
@@ -211,17 +360,20 @@ async function handleTestAllModelAdapters() {
onMounted(async () => { onMounted(async () => {
await reloadUserConfig({ modelAdaptersOnly: true }).catch(() => { }); await reloadUserConfig({ modelAdaptersOnly: true }).catch(() => { });
await nextTick();
syncSortable();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
void stopBatchTesting(); void stopBatchTesting();
destroySortable();
}); });
</script> </script>
<template> <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="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"> <div class="center-row gap-2">
<button <button
v-for="tab in typeTabs" v-for="tab in typeTabs"
@@ -240,37 +392,49 @@ onBeforeUnmount(() => {
<div class="center-row gap-2"> <div class="center-row gap-2">
<Button <Button
variant="default" variant="default"
:disabled="appState.configSaving || (!batchTesting && filteredAdapters.length === 0)" :disabled="sortSaving || appState.configSaving || (!batchTesting && filteredAdapters.length === 0)"
@click="handleTestAllModelAdapters" @click="handleTestAllModelAdapters"
> >
{{ batchButtonText }} {{ batchButtonText }}
</Button> </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>
</div> </div>
<div class="min-h-0 flex-1"> <div class="min-h-0 flex-1 ">
<div v-if="filteredAdapters.length === 0" <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]"> class="flex h-full min-h-[220px] items-center justify-center rounded-[8px] px-4 text-sm text-[#a3a3a3]">
当前还没有配置任何 {{ typeLabel(activeType) }} 模型 {{ emptyStateText }}
</div> </div>
<div v-else class="h-full min-h-0 overflow-y-auto pr-1"> <div v-else class="h-full min-h-0 overflow-y-auto scroll-shadow-bottom p-4 pt-0">
<div class="grid gap-3 pb-1 [grid-template-columns:repeat(auto-fill,minmax(250px,1fr))]"> <div
ref="modelGrid"
class="grid gap-3 pb-1 [grid-template-columns:repeat(auto-fill,minmax(250px,1fr))]"
>
<Card <Card
v-for="(adapter, index) in filteredAdapters" v-for="(adapter, index) in filteredAdapters"
:key="adapter.id || `${adapter.baseURL}-${adapter.modelID}-${index}`" :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 flex-col gap-2.5">
<div class="flex items-start justify-between gap-3"> <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="truncate text-base font-medium text-white">{{ adapter.displayName }}</div>
<div class="mt-1 truncate text-sm text-[#8f8f8f]">{{ adapter.modelID }}</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> </div>
<span <span
class="center-row shrink-0 gap-1 rounded-[999px] border border-[#3f3f3f] px-[7px] py-[4px] text-[11px] font-medium text-[#cfcfcf]" 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> </span>
</div> </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 <ModelAdapterTestCard
compact compact
title="测试" title="测试"
@@ -300,17 +453,17 @@ onBeforeUnmount(() => {
/> />
</div> </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 <Button
variant="default" variant="default"
:disabled="appState.configSaving || batchTesting || isAdapterTesting(adapter)" :disabled="sortSaving || appState.configSaving || batchTesting || isAdapterTesting(adapter)"
@click="handleTestModelAdapter(adapter)" @click="handleTestModelAdapter(adapter)"
> >
{{ isAdapterTesting(adapter) ? "测试中..." : "测试" }} {{ isAdapterTesting(adapter) ? "测试中..." : "测试" }}
</Button> </Button>
<Button variant="default" :disabled="appState.configSaving" @click="openEditor(appState.modelAdapters.indexOf(adapter))">编辑</Button> <Button variant="default" :disabled="sortSaving || appState.configSaving" @click="openEditor(appState.modelAdapters.indexOf(adapter))">编辑</Button>
<Button variant="default" :disabled="appState.configSaving" @click="handleDuplicateModelAdapter(appState.modelAdapters.indexOf(adapter))">复制</Button> <Button variant="default" :disabled="sortSaving || appState.configSaving" @click="handleDuplicateModelAdapter(appState.modelAdapters.indexOf(adapter))">复制</Button>
<Button variant="text" :disabled="appState.configSaving" <Button variant="text" :disabled="sortSaving || appState.configSaving"
@click="handleDeleteModelAdapter(appState.modelAdapters.indexOf(adapter))">删除</Button> @click="handleDeleteModelAdapter(appState.modelAdapters.indexOf(adapter))">删除</Button>
</div> </div>
</div> </div>
@@ -319,4 +472,21 @@ onBeforeUnmount(() => {
</div> </div>
</div> </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> </template>
+5
View File
@@ -1587,6 +1587,11 @@ semver@^6.3.1:
resolved "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" resolved "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== 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: source-map-js@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" 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{ mainWindow = app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: appName, Title: appName,
Width: 700, Width: 700,
Height: 520, Height: 530,
MinWidth: 640, MinWidth: 700,
MinHeight: 480, MinHeight: 530,
DisableResize: false, DisableResize: false,
Frameless: goruntime.GOOS == "windows", Frameless: goruntime.GOOS == "windows",
URL: "/", URL: "/",
+28 -7
View File
@@ -1134,10 +1134,20 @@ func isAnthropicCacheableBlock(block map[string]any) bool {
} }
} }
// anthropicThinkingCarrier 记录请求内最近一个有 reasoning+signature 的 assistant 轮次。
// thinking 模式下上游要求每个 assistant 轮次都回传 thinking 块;当某轮次(如 DeepSeek
// adaptive thinking 跳过思考的 tool-call 轮次)没有 reasoning 时,用 carrier 的
// thinking+signature 兜底,避免上游 "thinking must be passed back" 400。
type anthropicThinkingCarrier struct {
reasoning string
signature string
}
func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool, relocateImages bool) ([]string, []anthropicMessage, error) { func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool, relocateImages bool) ([]string, []anthropicMessage, error) {
systemParts := make([]string, 0, len(input)) systemParts := make([]string, 0, len(input))
messages := make([]anthropicMessage, 0, len(input)) messages := make([]anthropicMessage, 0, len(input))
pendingToolResults := make([]map[string]any, 0, 2) pendingToolResults := make([]map[string]any, 0, 2)
var thinkingCarrier *anthropicThinkingCarrier
flushToolResults := func() { flushToolResults := func() {
if len(pendingToolResults) == 0 { if len(pendingToolResults) == 0 {
return return
@@ -1175,7 +1185,15 @@ func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool, r
}) })
case "user", "assistant": case "user", "assistant":
flushToolResults() flushToolResults()
contentBlocks, err := anthropicProviderContentBlocks(message, thinkingEnabled) if thinkingEnabled && role == "assistant" {
if reasoning := strings.TrimSpace(message.ReasoningContent); reasoning != "" {
thinkingCarrier = &anthropicThinkingCarrier{
reasoning: reasoning,
signature: anthropicThinkingSignature(message),
}
}
}
contentBlocks, err := anthropicProviderContentBlocks(message, thinkingEnabled, thinkingCarrier)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -1284,7 +1302,7 @@ func isAnthropicImageBlock(block map[string]any) bool {
return strings.TrimSpace(anthropicStringField(block, "type")) == "image" return strings.TrimSpace(anthropicStringField(block, "type")) == "image"
} }
func anthropicProviderContentBlocks(message Message, thinkingEnabled bool) ([]map[string]any, error) { func anthropicProviderContentBlocks(message Message, thinkingEnabled bool, carrier *anthropicThinkingCarrier) ([]map[string]any, error) {
blocks, err := anthropicContentBlocks(message) blocks, err := anthropicContentBlocks(message)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1293,11 +1311,17 @@ func anthropicProviderContentBlocks(message Message, thinkingEnabled bool) ([]ma
return blocks, nil return blocks, nil
} }
reasoning := strings.TrimSpace(message.ReasoningContent)
signature := anthropicThinkingSignature(message)
if reasoning == "" && carrier != nil {
reasoning = carrier.reasoning
signature = carrier.signature
}
thinkingBlock := map[string]any{ thinkingBlock := map[string]any{
"type": "thinking", "type": "thinking",
"thinking": message.ReasoningContent, "thinking": reasoning,
} }
if signature := anthropicThinkingSignature(message); signature != "" { if signature != "" {
thinkingBlock["signature"] = signature thinkingBlock["signature"] = signature
} }
return append([]map[string]any{thinkingBlock}, blocks...), nil return append([]map[string]any{thinkingBlock}, blocks...), nil
@@ -1381,9 +1405,6 @@ func shouldIncludeAnthropicThinkingBlock(message Message, thinkingEnabled bool)
if strings.TrimSpace(message.Role) != "assistant" { if strings.TrimSpace(message.Role) != "assistant" {
return false return false
} }
if strings.TrimSpace(message.ReasoningContent) == "" {
return false
}
return true return true
} }
@@ -0,0 +1,164 @@
package modeladapter
import (
"strings"
"testing"
)
// TestNormalizeAnthropicProviderMessagesThinkingCarrier 验证 thinking 模式下,
// 缺少 reasoning 的 assistant 轮次(如 DeepSeek adaptive thinking 跳过思考的
// tool-call 轮次)会用请求内最近一个 carrier 的 thinking+signature 兜底,
// 保证每个 assistant 轮次都有 thinking 块,避免上游 "thinking must be passed
// back to the API" 400。
func TestNormalizeAnthropicProviderMessagesThinkingCarrier(t *testing.T) {
carrierToolCall := []ToolCallDescriptor{{
ID: "call-2",
Type: "function",
Function: ToolCallFunctionShape{
Name: "read",
Arguments: `{}`,
},
}}
input := []Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "let me check", ReasoningContent: "R1", ReasoningSignature: "S1"},
{Role: "user", Content: "tool result 1"},
{Role: "assistant", ToolCalls: carrierToolCall}, // 无 reasoning → 用 carrier
{Role: "user", Content: "tool result 2"},
{Role: "assistant", Content: "done", ReasoningContent: "R2", ReasoningSignature: "S2"},
}
_, messages, err := normalizeAnthropicProviderMessages(input, true, false)
if err != nil {
t.Fatalf("normalize: %v", err)
}
if len(messages) != 6 {
t.Fatalf("expected 6 messages, got %d", len(messages))
}
// 第 2 条(有 reasoning)应保留自己的 thinking。
assertAnthropicThinkingBlock(t, messages[1], "R1", "S1")
// 第 4 条(无 reasoning 的 tool-call 轮次)应复用 carrier 的 thinking+signature。
assertAnthropicThinkingBlock(t, messages[3], "R1", "S1")
// 第 5 条应为 tool_result 消息(合并路径不适用时,tool-call 轮次独立成消息)。
if role := messages[4].Role; role != "user" {
t.Fatalf("expected messages[4] role=user, got %s", role)
}
// 第 6 条有自己的 thinking。
assertAnthropicThinkingBlock(t, messages[5], "R2", "S2")
// tool-call 轮次应包含 tool_use 块。
hasToolUse := false
for _, block := range messages[3].Content {
if strings.TrimSpace(anthropicStringField(block, "type")) == "tool_use" {
hasToolUse = true
}
}
if !hasToolUse {
t.Fatal("expected tool_use block on the carrier-fallback assistant message")
}
}
// TestNormalizeAnthropicProviderMessagesThinkingCarrierFirstTurn 验证请求内第一条
// assistant 轮次就缺 reasoning 且无 carrier 时,兜底输出空 thinking 块。
func TestNormalizeAnthropicProviderMessagesThinkingCarrierFirstTurn(t *testing.T) {
input := []Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "ok"},
}
_, messages, err := normalizeAnthropicProviderMessages(input, true, false)
if err != nil {
t.Fatalf("normalize: %v", err)
}
if len(messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(messages))
}
if got := anthropicStringField(messages[1].Content[0], "type"); got != "thinking" {
t.Fatalf("expected first block type=thinking, got %s", got)
}
if got := anthropicStringField(messages[1].Content[0], "thinking"); got != "" {
t.Fatalf("expected empty fallback thinking, got %q", got)
}
}
// TestNormalizeAnthropicProviderMessagesThinkingDisabled 验证 thinking 关闭时
// 不输出任何 thinking 块(回归保护)。
func TestNormalizeAnthropicProviderMessagesThinkingDisabled(t *testing.T) {
input := []Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "ok", ReasoningContent: "R1", ReasoningSignature: "S1"},
{Role: "assistant", ToolCalls: []ToolCallDescriptor{{
ID: "call-2",
Type: "function",
Function: ToolCallFunctionShape{
Name: "read",
Arguments: `{}`,
},
}}},
}
_, messages, err := normalizeAnthropicProviderMessages(input, false, false)
if err != nil {
t.Fatalf("normalize: %v", err)
}
for index, message := range messages {
for _, block := range message.Content {
if blockType := anthropicStringField(block, "type"); blockType == "thinking" {
t.Fatalf("unexpected thinking block at messages[%d]", index)
}
}
}
}
// TestNormalizeAnthropicProviderMessagesThinkingMerge 验证有 reasoning 的纯
// tool-call 轮次仍按既有逻辑合并进上一条 assistant 消息(thinking 去重,无回归)。
func TestNormalizeAnthropicProviderMessagesThinkingMerge(t *testing.T) {
input := []Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "let me check", ReasoningContent: "R1", ReasoningSignature: "S1"},
{Role: "assistant", ToolCalls: []ToolCallDescriptor{{
ID: "call-2",
Type: "function",
Function: ToolCallFunctionShape{
Name: "read",
Arguments: `{}`,
},
}}, ReasoningContent: "R1", ReasoningSignature: "S1"},
}
_, messages, err := normalizeAnthropicProviderMessages(input, true, false)
if err != nil {
t.Fatalf("normalize: %v", err)
}
if len(messages) != 2 {
t.Fatalf("expected 2 messages (tool-call merged), got %d", len(messages))
}
assertAnthropicThinkingBlock(t, messages[1], "R1", "S1")
hasToolUse := false
for _, block := range messages[1].Content {
if blockType := anthropicStringField(block, "type"); blockType == "tool_use" {
hasToolUse = true
}
}
if !hasToolUse {
t.Fatal("expected merged tool_use block on messages[1]")
}
}
func assertAnthropicThinkingBlock(t *testing.T, message anthropicMessage, wantThinking string, wantSignature string) {
t.Helper()
if len(message.Content) == 0 {
t.Fatalf("expected non-empty content for %s message", message.Role)
}
first := message.Content[0]
if blockType := anthropicStringField(first, "type"); blockType != "thinking" {
t.Fatalf("expected first block type=thinking, got %s", blockType)
}
if got := anthropicStringField(first, "thinking"); got != wantThinking {
t.Fatalf("expected thinking=%q, got %q", wantThinking, got)
}
if got := anthropicStringField(first, "signature"); got != wantSignature {
t.Fatalf("expected signature=%q, got %q", wantSignature, got)
}
}
+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 { if err := flushTaggedContentTail(); err != nil {
return fail(err) 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) { if service.checkpointProjectionReady(stream) {
return service.publishReadyCheckpoint(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( service.scheduleStreamTimer(
stream, stream,
providerTimerKey(streamTimerCheckpointBlobs, ""), providerTimerKey(streamTimerCheckpointBlobs, ""),
@@ -106,6 +113,31 @@ func (service *Service) queueCheckpointProjection(stream *ActiveStream, projecti
return nil 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 { func (service *Service) checkpointProjectionReady(stream *ActiveStream) bool {
if stream == nil { if stream == nil {
return false return false
@@ -176,15 +208,18 @@ func (service *Service) publishReadyCheckpoint(stream *ActiveStream) error {
stream.PendingCheckpoint = nil stream.PendingCheckpoint = nil
state := pending.State state := pending.State
completion := clonePendingTurnCompletion(pending.Completion) completion := clonePendingTurnCompletion(pending.Completion)
published := pending.Published
stream.UpdatedAt = time.Now().UTC() stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock() stream.mu.Unlock()
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, "")) clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
if err := service.broker.Publish(stream.RequestID, StreamEvent{Message: buildCheckpointMessage(state)}); err != nil { if !published {
if completion != nil { if err := service.broker.Publish(stream.RequestID, StreamEvent{Message: buildCheckpointMessage(state)}); err != nil {
log.Printf("forwarder checkpoint publish skipped before successful terminal request_id=%s err=%v", stream.RequestID, err) if completion != nil {
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion) 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 { if completion != nil {
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion) return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
@@ -8,26 +8,34 @@ import (
"cursor/gen/agentv1" "cursor/gen/agentv1"
) )
func TestCheckpointBlobSyncPublishesCheckpointAfterAcknowledgements(t *testing.T) { func TestCheckpointBlobSyncPublishesNonTerminalCheckpointBeforeAcknowledgements(t *testing.T) {
service, stream, projection := testCheckpointBlobProjection(t) service, stream, projection := testCheckpointBlobProjection(t)
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil { if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err) t.Fatalf("queueCheckpointProjection() error = %v", err)
} }
events := readCheckpointTestEvents(t, service, stream) events := readCheckpointTestEvents(t, service, stream)
if len(events) != len(projection.Blobs) { if len(events) != len(projection.Blobs)+1 {
t.Fatalf("events before ACK = %d, want %d Blob writes", len(events), len(projection.Blobs)) 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 { if event.Message.GetKvServerMessage().GetSetBlobArgs() == nil {
t.Fatalf("event before ACK = %#v, want set_blob_args", event.Message) 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) acknowledgeCheckpointBlobs(t, service, stream)
events = readCheckpointTestEvents(t, service, stream) events = readCheckpointTestEvents(t, service, stream)
checkpoint := events[len(events)-1].Message.GetConversationCheckpointUpdate() checkpointCount := 0
if checkpoint == nil || len(checkpoint.GetTurns()) != 1 { for _, event := range events {
t.Fatalf("last event checkpoint = %#v, want one Blob-backed turn", checkpoint) 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 { if err := service.queueCheckpointProjection(stream, projection, completion); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err) 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) acknowledgeCheckpointBlobs(t, service, stream)
events := readCheckpointTestEvents(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) service, stream, projection := testCheckpointBlobProjection(t)
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil { if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err) 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() stream.mu.Lock()
requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites)) requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites))
for requestID := range stream.PendingCheckpointBlobWrites { for requestID := range stream.PendingCheckpointBlobWrites {
@@ -114,16 +138,19 @@ func TestCancellationDiscardsPendingCheckpointAndIgnoresLateAcknowledgements(t *
} }
events := readCheckpointTestEvents(t, service, stream) events := readCheckpointTestEvents(t, service, stream)
var checkpoint, canceledEnd bool checkpointCount := 0
var canceledEnd bool
for _, event := range events { for _, event := range events {
checkpoint = checkpoint || event.Message.GetConversationCheckpointUpdate() != nil if event.Message.GetConversationCheckpointUpdate() != nil {
checkpointCount++
}
canceledEnd = canceledEnd || event.End && event.TerminalErrorCode == "canceled" canceledEnd = canceledEnd || event.End && event.TerminalErrorCode == "canceled"
} }
stream.mu.Lock() stream.mu.Lock()
pending := stream.PendingCheckpoint pending := stream.PendingCheckpoint
stream.mu.Unlock() stream.mu.Unlock()
if checkpoint || !canceledEnd || pending != nil { if checkpointCount != 1 || !canceledEnd || pending != nil {
t.Fatalf("cancel events checkpoint=%v canceled_end=%v pending=%v", checkpoint, canceledEnd, pending != nil) t.Fatalf("cancel events checkpoints=%d canceled_end=%v pending=%v", checkpointCount, canceledEnd, pending != nil)
} }
} }
+35
View File
@@ -201,6 +201,41 @@ func buildShellOutputDeltaMessage(delta *agentv1.ShellOutputDeltaUpdate) *agentv
} }
} }
// buildShellToolCallDeltaMessage maps client shell output to the delta consumed by Cursor's terminal bubble.
func buildShellToolCallDeltaMessage(callID string, modelCallID string, output *agentv1.ShellOutputDeltaUpdate) *agentv1.AgentServerMessage {
if output == nil {
return nil
}
var delta *agentv1.ShellToolCallDelta
switch event := output.GetEvent().(type) {
case *agentv1.ShellOutputDeltaUpdate_Stdout:
content := event.Stdout.GetData()
if content == "" {
return nil
}
delta = &agentv1.ShellToolCallDelta{
Delta: &agentv1.ShellToolCallDelta_Stdout{
Stdout: &agentv1.ShellToolCallStdoutDelta{Content: content},
},
}
case *agentv1.ShellOutputDeltaUpdate_Stderr:
content := event.Stderr.GetData()
if content == "" {
return nil
}
delta = &agentv1.ShellToolCallDelta{
Delta: &agentv1.ShellToolCallDelta_Stderr{
Stderr: &agentv1.ShellToolCallStderrDelta{Content: content},
},
}
default:
return nil
}
return buildToolCallDeltaMessage(callID, modelCallID, &agentv1.ToolCallDelta{
Delta: &agentv1.ToolCallDelta_ShellToolCallDelta{ShellToolCallDelta: delta},
})
}
// buildTurnEndedMessage 构造 turn 结束消息,并携带标准化后的 token 统计。 // buildTurnEndedMessage 构造 turn 结束消息,并携带标准化后的 token 统计。
func buildTurnEndedMessage(inputTokens int64, outputTokens int64, cacheReadTokens int64, cacheWriteTokens int64) *agentv1.AgentServerMessage { func buildTurnEndedMessage(inputTokens int64, outputTokens int64, cacheReadTokens int64, cacheWriteTokens int64) *agentv1.AgentServerMessage {
inputTokensValue := inputTokens inputTokensValue := inputTokens
+24 -3
View File
@@ -621,10 +621,22 @@ func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpoin
} }
grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry) grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry)
} }
logicalTurns := make([][]HistoryEntry, 0, len(order))
turnIDs := make([][]byte, 0, len(order))
for _, turnSeq := range order { for _, turnSeq := range order {
entries := grouped[turnSeq] 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) completedToolCalls, err := collectCheckpointCompletedToolCalls(entries)
if err != nil { if err != nil {
return nil, err 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 continue
} }
stepIDs := make([][]byte, 0, len(steps)) stepIDs := make([][]byte, 0, len(steps))
@@ -765,6 +777,15 @@ func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpoin
return turnIDs, nil 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) { func collectCheckpointCompletedToolCalls(entries []HistoryEntry) (map[string]json.RawMessage, error) {
completed := make(map[string]json.RawMessage) completed := make(map[string]json.RawMessage)
for _, entry := range entries { 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) { func TestProjectCheckpointProjectionKeepsForkPointIsolatedFromLaterHistory(t *testing.T) {
firstUser, err := protojson.Marshal(&agentv1.UserMessage{Text: "first question", MessageId: "message-1"}) firstUser, err := protojson.Marshal(&agentv1.UserMessage{Text: "first question", MessageId: "message-1"})
if err != nil { if err != nil {
@@ -288,6 +376,7 @@ func TestProjectCheckpointProjectionKeepsStartedToolCallWhenResultPayloadIsMissi
Mode: "agent", Mode: "agent",
NextTurnSeq: 2, NextTurnSeq: 2,
Entries: []HistoryEntry{ Entries: []HistoryEntry{
testCheckpointUserEntry(t),
newToolCallEntry(1, "request-1", "call-1", "Read", "", "", startedToolCall), newToolCallEntry(1, "request-1", "call-1", "Read", "", "", startedToolCall),
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "read failed", "", nil), newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "read failed", "", nil),
}, },
@@ -316,6 +405,7 @@ func TestProjectCheckpointProjectionAppendsLegacyResultWithoutToolCallEntry(t *t
Mode: "agent", Mode: "agent",
NextTurnSeq: 2, NextTurnSeq: 2,
Entries: []HistoryEntry{ Entries: []HistoryEntry{
testCheckpointUserEntry(t),
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "not readable", "", completedToolCall), newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "not readable", "", completedToolCall),
}, },
} }
+12
View File
@@ -1003,6 +1003,11 @@ func (service *Service) handleExecResult(intent InboundIntent) error {
}); err != nil { }); err != nil {
return err return err
} }
if message := buildShellToolCallDeltaMessage(pending.ToolCallID, pending.ModelCallID, result.ShellOutputDelta); message != nil {
if err := service.broker.Publish(intent.RequestID, StreamEvent{Message: message}); err != nil {
return err
}
}
} }
if !result.IsTerminal { if !result.IsTerminal {
return nil return nil
@@ -1736,6 +1741,13 @@ func (service *Service) handleToolInvocation(stream *ActiveStream, invocation ru
stream.ToolInvocationCount++ stream.ToolInvocationCount++
stream.UpdatedAt = time.Now().UTC() stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock() 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) { 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)) return service.completePreDispatchToolError(stream, invocation, nil, false, false, fmt.Errorf("tool invocation is not enabled in mode %s: %s", mode.String(), invocation.ToolName))
} }
@@ -0,0 +1,150 @@
package forwarder
import (
"testing"
"cursor/gen/agentv1"
execbridge "cursor/internal/backend/agent/bridge/exec"
runtimecore "cursor/internal/backend/agent/core"
)
func TestHandleExecResultPublishesShellToolCallDelta(t *testing.T) {
tests := []struct {
name string
shellStream func() *agentv1.ShellStream
wantStdout string
wantStderr string
}{
{
name: "stdout",
shellStream: func() *agentv1.ShellStream {
return &agentv1.ShellStream{Event: &agentv1.ShellStream_Stdout{
Stdout: &agentv1.ShellStreamStdout{Data: "stdout chunk\n"},
}}
},
wantStdout: "stdout chunk\n",
},
{
name: "stderr",
shellStream: func() *agentv1.ShellStream {
return &agentv1.ShellStream{Event: &agentv1.ShellStream_Stderr{
Stderr: &agentv1.ShellStreamStderr{Data: "stderr chunk\n"},
}}
},
wantStderr: "stderr chunk\n",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
broker := NewStreamBroker()
service := &Service{
broker: broker,
execBridge: execbridge.NewBridge(),
}
stream, err := broker.OpenStream(
"request-1", "conversation-1", 1, "default", "default",
agentv1.AgentMode_AGENT_MODE_AGENT, "run command",
)
if err != nil {
t.Fatalf("OpenStream() error = %v", err)
}
pending := runtimecore.PendingExec{
MessageID: 42,
ExecID: "exec-shell-1",
ModelCallID: "model-call-1",
ToolCallID: "tool-call-1",
ExecKind: "shell",
}
stream.mu.Lock()
stream.PendingExecs[pending.ExecID] = pending
stream.mu.Unlock()
if err := service.handleExecResult(InboundIntent{
Kind: "exec_result",
RequestID: "request-1",
ExecClientMessage: &agentv1.ExecClientMessage{
Id: pending.MessageID,
ExecId: pending.ExecID,
Message: &agentv1.ExecClientMessage_ShellStream{
ShellStream: test.shellStream(),
},
},
}); err != nil {
t.Fatalf("handleExecResult() error = %v", err)
}
events, err := broker.ReadFromCursor("request-1", 0)
if err != nil {
t.Fatalf("ReadFromCursor() error = %v", err)
}
if len(events) != 2 {
t.Fatalf("published events = %d, want compatibility and tool-call deltas", len(events))
}
var compatibilityCount, toolCallDeltaCount int
for _, event := range events {
update := event.Message.GetInteractionUpdate()
if update.GetShellOutputDelta() != nil {
compatibilityCount++
}
deltaUpdate := update.GetToolCallDelta()
if deltaUpdate == nil {
continue
}
toolCallDeltaCount++
if deltaUpdate.GetCallId() != pending.ToolCallID || deltaUpdate.GetModelCallId() != pending.ModelCallID {
t.Fatalf("tool-call delta ids = call %q model %q", deltaUpdate.GetCallId(), deltaUpdate.GetModelCallId())
}
shellDelta := deltaUpdate.GetToolCallDelta().GetShellToolCallDelta()
if shellDelta == nil || shellDelta.GetStdout().GetContent() != test.wantStdout || shellDelta.GetStderr().GetContent() != test.wantStderr {
t.Fatalf("shell tool-call delta = %#v", shellDelta)
}
}
if compatibilityCount != 1 || toolCallDeltaCount != 1 {
t.Fatalf("published compatibility=%d tool_call_delta=%d, want one each", compatibilityCount, toolCallDeltaCount)
}
})
}
}
func TestBuildShellToolCallDeltaMessageIgnoresNonOutputEvents(t *testing.T) {
tests := []struct {
name string
output *agentv1.ShellOutputDeltaUpdate
}{
{name: "nil"},
{
name: "start",
output: &agentv1.ShellOutputDeltaUpdate{Event: &agentv1.ShellOutputDeltaUpdate_Start{
Start: &agentv1.ShellStreamStart{},
}},
},
{
name: "exit",
output: &agentv1.ShellOutputDeltaUpdate{Event: &agentv1.ShellOutputDeltaUpdate_Exit{
Exit: &agentv1.ShellStreamExit{},
}},
},
{
name: "empty stdout",
output: &agentv1.ShellOutputDeltaUpdate{Event: &agentv1.ShellOutputDeltaUpdate_Stdout{
Stdout: &agentv1.ShellStreamStdout{},
}},
},
{
name: "empty stderr",
output: &agentv1.ShellOutputDeltaUpdate{Event: &agentv1.ShellOutputDeltaUpdate_Stderr{
Stderr: &agentv1.ShellStreamStderr{},
}},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if message := buildShellToolCallDeltaMessage("tool-call-1", "model-call-1", test.output); message != nil {
t.Fatalf("buildShellToolCallDeltaMessage() = %#v, want nil", message)
}
})
}
}
@@ -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 { func isToolAllowedInMode(mode agentv1.AgentMode, subagentTypeName string, toolName string) bool {
trimmedToolName := strings.TrimSpace(toolName) trimmedToolName := strings.TrimSpace(toolName)
if trimmedToolName == "" { if trimmedToolName == "" {
+1
View File
@@ -228,6 +228,7 @@ type pendingCheckpointPublish struct {
State *agentv1.ConversationStateStructure State *agentv1.ConversationStateStructure
Required map[string]struct{} Required map[string]struct{}
Completion *pendingTurnCompletion Completion *pendingTurnCompletion
Published bool
} }
type PendingCompaction struct { type PendingCompaction struct {
@@ -16,6 +16,7 @@ func (store *Store) LegacyRuntimeSnapshot(ctx context.Context) (legacyruntime.Ru
for _, item := range cfg.ModelAdapters { for _, item := range cfg.ModelAdapters {
adapters = append(adapters, legacyruntime.ModelAdapterConfig{ adapters = append(adapters, legacyruntime.ModelAdapterConfig{
ID: item.ID, ID: item.ID,
Sort: item.Sort,
DisplayName: item.DisplayName, DisplayName: item.DisplayName,
Type: item.Type, Type: item.Type,
BaseURL: item.BaseURL, BaseURL: item.BaseURL,
@@ -147,6 +147,7 @@ func (manager *Manager) LegacyRuntimeSnapshot(_ context.Context) (legacyruntime.
for _, item := range cfg.ModelAdapters { for _, item := range cfg.ModelAdapters {
adapters = append(adapters, legacyruntime.ModelAdapterConfig{ adapters = append(adapters, legacyruntime.ModelAdapterConfig{
ID: item.ID, ID: item.ID,
Sort: item.Sort,
DisplayName: item.DisplayName, DisplayName: item.DisplayName,
Type: item.Type, Type: item.Type,
BaseURL: item.BaseURL, BaseURL: item.BaseURL,
+24
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net" "net"
"sort"
"strconv" "strconv"
"strings" "strings"
@@ -21,6 +22,7 @@ const (
type ModelAdapterConfig struct { type ModelAdapterConfig struct {
ID string `json:"id,omitempty" yaml:"-"` ID string `json:"id,omitempty" yaml:"-"`
Sort int `json:"sort" yaml:"sort"`
DisplayName string `json:"displayName" yaml:"displayName"` DisplayName string `json:"displayName" yaml:"displayName"`
Type string `json:"type" yaml:"type"` Type string `json:"type" yaml:"type"`
BaseURL string `json:"baseURL" yaml:"baseURL"` BaseURL string `json:"baseURL" yaml:"baseURL"`
@@ -104,6 +106,7 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
} }
nextType := normalizeModelAdapterType(item.Type) nextType := normalizeModelAdapterType(item.Type)
next := ModelAdapterConfig{ next := ModelAdapterConfig{
Sort: item.Sort,
DisplayName: strings.TrimSpace(item.DisplayName), DisplayName: strings.TrimSpace(item.DisplayName),
Type: nextType, Type: nextType,
BaseURL: baseURL, BaseURL: baseURL,
@@ -164,9 +167,30 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
seenChannelIDs[next.ID] = struct{}{} seenChannelIDs[next.ID] = struct{}{}
normalized = append(normalized, next) normalized = append(normalized, next)
} }
normalizeModelAdapterSorts(normalized)
return normalized, nil 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 { func validateJSONMap(value string, fieldName string) error {
text := strings.TrimSpace(value) text := strings.TrimSpace(value)
if text == "" { 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)
}
}
}
+1 -1
View File
@@ -145,7 +145,7 @@ var bootstrapStatsigTemplate = statsigBootstrapTemplate{
bootstrapStatsigGlassCustomThemeSupport: buildEnabledStatsigGate(bootstrapStatsigGlassCustomThemeSupport), bootstrapStatsigGlassCustomThemeSupport: buildEnabledStatsigGate(bootstrapStatsigGlassCustomThemeSupport),
bootstrapStatsigGlassAutomationsUI: buildEnabledStatsigGate(bootstrapStatsigGlassAutomationsUI), bootstrapStatsigGlassAutomationsUI: buildEnabledStatsigGate(bootstrapStatsigGlassAutomationsUI),
bootstrapStatsigTerminalUI2: buildEnabledStatsigGate(bootstrapStatsigTerminalUI2), bootstrapStatsigTerminalUI2: buildEnabledStatsigGate(bootstrapStatsigTerminalUI2),
bootstrapStatsigDisableTerminalOutputUIStreaming: buildEnabledStatsigGate(bootstrapStatsigDisableTerminalOutputUIStreaming), bootstrapStatsigDisableTerminalOutputUIStreaming: buildDisabledStatsigGate(bootstrapStatsigDisableTerminalOutputUIStreaming),
bootstrapStatsigBrowserCanvas: buildEnabledStatsigGate(bootstrapStatsigBrowserCanvas), bootstrapStatsigBrowserCanvas: buildEnabledStatsigGate(bootstrapStatsigBrowserCanvas),
bootstrapStatsigEnableMultitaskMode: buildEnabledStatsigGate(bootstrapStatsigEnableMultitaskMode), bootstrapStatsigEnableMultitaskMode: buildEnabledStatsigGate(bootstrapStatsigEnableMultitaskMode),
bootstrapStatsigDecomposeAlwaysLocalExtHostGate: buildDisabledStatsigGate(bootstrapStatsigDecomposeAlwaysLocalExtHostGate), bootstrapStatsigDecomposeAlwaysLocalExtHostGate: buildDisabledStatsigGate(bootstrapStatsigDecomposeAlwaysLocalExtHostGate),
@@ -73,3 +73,26 @@ func TestBuildBootstrapStatsigConfigJSONDisablesAlwaysLocalDecompositionGate(t *
t.Fatalf("unexpected rule_id: %q", ruleID) t.Fatalf("unexpected rule_id: %q", ruleID)
} }
} }
func TestBuildBootstrapStatsigConfigJSONEnablesTerminalOutputUIStreaming(t *testing.T) {
payload, err := buildBootstrapStatsigConfigJSON(12345, "test-auth-id")
if err != nil {
t.Fatalf("build bootstrap statsig config: %v", err)
}
var decoded statsigBootstrapTemplate
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode bootstrap statsig config: %v", err)
}
gate, ok := decoded.FeatureGates[bootstrapStatsigDisableTerminalOutputUIStreaming]
if !ok {
t.Fatalf("missing feature gate %q", bootstrapStatsigDisableTerminalOutputUIStreaming)
}
if value, _ := gate["value"].(bool); value {
t.Fatalf("expected %q to be disabled", bootstrapStatsigDisableTerminalOutputUIStreaming)
}
if ruleID, _ := gate["rule_id"].(string); ruleID != "local_disabled" {
t.Fatalf("unexpected rule_id: %q", ruleID)
}
}
+2 -101
View File
@@ -15,19 +15,11 @@ import (
"github.com/wailsapp/wails/v3/pkg/events" "github.com/wailsapp/wails/v3/pkg/events"
) )
// modelEditorContext 保存当前模型编辑器窗口的初始化上下文。
type modelEditorContext struct {
Index int `json:"index"`
AdapterJSON string `json:"adapterJSON"`
}
// WindowService 定义了当前模块中的 WindowService 类型。 // WindowService 定义了当前模块中的 WindowService 类型。
type WindowService struct { type WindowService struct {
app *application.App app *application.App
updater *updater.Manager updater *updater.Manager
modelConfigWindow *application.WebviewWindow modelConfigWindow *application.WebviewWindow
modelEditorWindow *application.WebviewWindow
editorCtx *modelEditorContext
mu sync.RWMutex mu sync.RWMutex
} }
@@ -102,8 +94,8 @@ func (s *WindowService) OpenModelConfigWindow() {
Title: "模型配置", Title: "模型配置",
Width: 980, Width: 980,
Height: 700, Height: 700,
MinWidth: 820, MinWidth: 980,
MinHeight: 560, MinHeight: 700,
DisableResize: false, DisableResize: false,
Frameless: goruntime.GOOS == "windows", Frameless: goruntime.GOOS == "windows",
URL: "/#/model-config", URL: "/#/model-config",
@@ -144,97 +136,6 @@ func (s *WindowService) OpenModelConfigWindow() {
s.modelConfigWindow = win 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 相关的逻辑。 // OpenHistoryWindow 用于处理与 OpenHistoryWindow 相关的逻辑。
func (s *WindowService) OpenHistoryWindow() { func (s *WindowService) OpenHistoryWindow() {
_ = os.MkdirAll(client.ResolveLogsRootPath(), 0o755) _ = os.MkdirAll(client.ResolveLogsRootPath(), 0o755)
+1
View File
@@ -30,6 +30,7 @@ const (
var cursorStateDisabledStatsigGates = []string{ var cursorStateDisabledStatsigGates = []string{
"decompose_always_local_ext_host", "decompose_always_local_ext_host",
"cursor_extensions_isolation_v2", "cursor_extensions_isolation_v2",
"disable_terminal_output_ui_streaming",
} }
// InjectCursorUserInfo synchronizes the Cursor user-level auth cache used by the // InjectCursorUserInfo synchronizes the Cursor user-level auth cache used by the
+94
View File
@@ -0,0 +1,94 @@
package cursor
import (
"context"
"database/sql"
"encoding/json"
"path/filepath"
"testing"
)
func TestSyncCursorAuthStateDBDisablesCachedTerminalOutputUIStreamingIdempotently(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.vscdb")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatalf("open temporary state db: %v", err)
}
if _, err := db.Exec("CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); err != nil {
db.Close()
t.Fatalf("create ItemTable: %v", err)
}
bootstrap := map[string]any{
"feature_gates": map[string]any{
"disable_terminal_output_ui_streaming": map[string]any{
"value": true,
"rule_id": "local_enabled",
"groupName": "local_enabled",
},
"unrelated_gate": map[string]any{"value": true},
},
"hash_used": "none",
}
raw, err := json.Marshal(bootstrap)
if err != nil {
db.Close()
t.Fatalf("encode bootstrap: %v", err)
}
if _, err := db.Exec("INSERT INTO ItemTable(key, value) VALUES(?, ?)", cursorStateStatsigBootstrapKey, raw); err != nil {
db.Close()
t.Fatalf("insert bootstrap: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("close setup db: %v", err)
}
values := map[string]string{"cursorAuth/cachedEmail": "local@example.com"}
if err := syncCursorAuthStateDB(path, values); err != nil {
t.Fatalf("first state sync: %v", err)
}
first := readCursorStatsigBootstrapForTest(t, path)
assertCursorStatsigGateValueForTest(t, first, "disable_terminal_output_ui_streaming", false)
assertCursorStatsigGateValueForTest(t, first, "unrelated_gate", true)
if err := syncCursorAuthStateDB(path, values); err != nil {
t.Fatalf("second state sync: %v", err)
}
second := readCursorStatsigBootstrapForTest(t, path)
if string(second) != string(first) {
t.Fatalf("repeated sync changed bootstrap:\nfirst: %s\nsecond: %s", first, second)
}
}
func readCursorStatsigBootstrapForTest(t *testing.T, path string) []byte {
t.Helper()
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatalf("open state db: %v", err)
}
defer db.Close()
var raw []byte
if err := db.QueryRowContext(context.Background(), "SELECT value FROM ItemTable WHERE key = ?", cursorStateStatsigBootstrapKey).Scan(&raw); err != nil {
t.Fatalf("read bootstrap: %v", err)
}
return raw
}
func assertCursorStatsigGateValueForTest(t *testing.T, raw []byte, name string, want bool) {
t.Helper()
var payload struct {
FeatureGates map[string]struct {
Value bool `json:"value"`
} `json:"feature_gates"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("decode bootstrap: %v", err)
}
gate, ok := payload.FeatureGates[name]
if !ok {
t.Fatalf("missing gate %q", name)
}
if gate.Value != want {
t.Fatalf("gate %q value=%t, want %t", name, gate.Value, want)
}
}
+25
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"sort"
"strings" "strings"
"time" "time"
@@ -36,6 +37,8 @@ const (
// ModelAdapterConfig 定义了当前模块中的 ModelAdapterConfig 类型。 // ModelAdapterConfig 定义了当前模块中的 ModelAdapterConfig 类型。
type ModelAdapterConfig struct { type ModelAdapterConfig struct {
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
// Sort 表示模型渠道的展示顺序。
Sort int `json:"sort"`
// DisplayName 表示当前声明中的 DisplayName。 // DisplayName 表示当前声明中的 DisplayName。
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
// Type 表示当前声明中的 Type。 // Type 表示当前声明中的 Type。
@@ -103,6 +106,7 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
return nil, err return nil, err
} }
next := ModelAdapterConfig{ next := ModelAdapterConfig{
Sort: item.Sort,
DisplayName: strings.TrimSpace(item.DisplayName), DisplayName: strings.TrimSpace(item.DisplayName),
Type: normalizeModelAdapterType(item.Type), Type: normalizeModelAdapterType(item.Type),
BaseURL: baseURL, BaseURL: baseURL,
@@ -163,9 +167,30 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
seenChannelIDs[next.ID] = struct{}{} seenChannelIDs[next.ID] = struct{}{}
normalized = append(normalized, next) normalized = append(normalized, next)
} }
normalizeModelAdapterSorts(normalized)
return normalized, nil 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 { func validateJSONMap(value string, fieldName string) error {
text := strings.TrimSpace(value) text := strings.TrimSpace(value)
if text == "" { if text == "" {
+6 -3
View File
@@ -8,6 +8,9 @@ QQ交流群:
Tg群组: Tg群组:
https://t.me/cursor_byok https://t.me/cursor_byok
- 支持cursor-cli - 修复检查点,支持Fork Chat
- 修复对话中错误可能导致的消失问题 - 修复打断对话的上下文丢失问题
- 重构UI
- 支持拖动模型排序
- 支持一键拉模型
- 支持非主流chat端点