Compare commits

...
Author SHA1 Message Date
leokunandGitHub e9d3845de0 Merge pull request #295 from Sxuan-Coder/feat/config-import-export
feat(config): 支持完整配置导入导出
2026-08-19 11:36:43 +08:00
leokunandGitHub d4bfa8b938 Merge pull request #308 from Sxuan-Coder/fix/replay-dangling-tool-call-output
fix: 修复工具调用与结果之间穿插文本时,调用被误删导致 Responses API 400(#307)
2026-08-19 11:36:29 +08:00
leokunandGitHub dead4477be Merge pull request #306 from ProtectCookies/main
fix(network): 修复长耗时请求被误报「Network disconnected」中断的问题
2026-08-19 11:36:02 +08:00
上玄 b7f8237f3b fix: keep tool call in provider sanitize when text interleaves call and result
trimDanglingAssistantToolCalls in the provider router held the same
consecutive-tool-message assumption as the replay projector: when a
model emits function_call items before explanation text within one
response (e.g. gpt-5.3-codex-spark), the sanitized sequence becomes
assistant[tool_call] -> assistant[text] -> tool[result] and responded
calls were stripped at the provider boundary even though the projector
already replayed them correctly. The orphan tool result then forced the
Responses adapter to synthesize a placeholder function_call with empty
arguments, degrading the arguments the model sees on in-loop requests.

Widen the response collection window to skip interleaved plain
assistant text messages and drop orphan tool results, mirroring the
projector fix.
2026-08-14 19:59:47 +08:00
上玄 5d04b5b08b fix: keep tool call replay when assistant text interleaves call and result
trimReplayDanglingAssistantToolCalls only collected tool results that
immediately followed the assistant tool-call message. Models such as
gpt-5.3-codex-spark may emit the function_call item before the
explanation text within one response, so history replay order becomes
assistant[tool_call] -> assistant[text] -> tool[result]. The call was
misjudged as dangling and stripped while the tool result survived,
producing a function_call_output without a matching function_call that
the Responses API rejects with 400.

- widen the response collection window to skip interleaved plain
  assistant text messages, and drop orphan tool results in the same pass
- synthesize a placeholder function_call (or drop the output when the
  tool name is unknown) in normalizeOpenAIResponsesInput so conversations
  already persisted with corrupted history can resume
2026-08-14 16:51:45 +08:00
杨超 a90c8d9560 fix(network): 修复proto打包报错问题 2026-08-14 14:13:43 +08:00
杨超 e9dc2e098d fix(network): 修复长耗时请求被误报「Network disconnected」中断的问题
客户端扩展在慢请求(如提交信息生成)开始约 10 秒后会探测
NetworkService/IsConnected,非 200 应答即判定断网并中止进行中的请求。
此前该探测落入通配路由返回 404,导致模型仍在正常返回时界面先报断连。

- host.go: 注册 IsConnected 精确路由,恒定以 200 + 空 IsConnectedResponse 应答
- client.go: newProtoMessage 补齐 aiserver.v1.IsConnectedResponse 类型,
  避免 mock 编码失败导致探测返回 502
- state_db.go: 新增强开 statsig gate 机制(cursorStateEnabledStatsigGates),
  强制启用 disable_network_change_monitor_local 从源头关闭该探测;
  disableCursorStatsigGates 泛化为 syncCursorStatsigGateOverrides
2026-08-14 12:19:47 +08:00
leokun a3ec2a0dfc release: 0.0.48 2026-08-12 20:32:19 +08:00
leokunandGitHub 38472a3c63 Merge pull request #299 from leookun/fix/issue-298-per-user-ca
Fix shared root CA by generating one per installation
2026-08-12 20:11:43 +08:00
上玄 3fc04eeb4f feat(config): 支持完整配置导入导出 2026-08-12 09:34:02 +08:00
36 changed files with 1599 additions and 226 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ info:
description: "Cursor助手"
copyright: "© 2026, Cursor助手"
comments: "Cursor助手"
version: "0.0.47"
version: "0.0.48"
dev_mode:
root_path: .
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.47</string>
<string>0.0.48</string>
<key>CFBundleVersion</key>
<string>0.0.47</string>
<string>0.0.48</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>LSUIElement</key>
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.47</string>
<string>0.0.48</string>
<key>CFBundleVersion</key>
<string>0.0.47</string>
<string>0.0.48</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>LSUIElement</key>
+27 -14
View File
@@ -6,7 +6,7 @@
name: "Cursor助手"
arch: ${GOARCH}
platform: "linux"
version: "0.0.47"
version: "0.0.48"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
@@ -24,24 +24,24 @@ contents:
- src: "./build/linux/Cursor助手.desktop"
dst: "/usr/share/applications/Cursor助手.desktop"
# Default dependencies for Debian 12/Ubuntu 22.04+ with WebKit 4.1
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
depends:
- libgtk-3-0
- libwebkit2gtk-4.1-0
- libgtk-4-1
- libwebkitgtk-6.0-4
# Distribution-specific overrides for different package formats and WebKit versions
# Distribution-specific overrides for different package formats
overrides:
# RPM packages for RHEL/CentOS/AlmaLinux/Rocky Linux (WebKit 4.0)
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
rpm:
depends:
- gtk3
- webkit2gtk4.1
- gtk4
- webkitgtk6.0
# Arch Linux packages (WebKit 4.1)
# Arch Linux packages
archlinux:
depends:
- gtk3
- webkit2gtk-4.1
- gtk4
- webkitgtk-6.0
# scripts section to ensure desktop database is updated after install
scripts:
@@ -50,13 +50,26 @@ scripts:
# preremove: "./build/linux/nfpm/scripts/preremove.sh"
# postremove: "./build/linux/nfpm/scripts/postremove.sh"
# If you build your app with -tags gtk3 (legacy WebKit2GTK 4.1 stack — supported through v3.0.x, removed in v3.1),
# replace the depends/overrides above with these:
#
# depends:
# - libgtk-3-0
# - libwebkit2gtk-4.1-0
# overrides:
# rpm:
# depends:
# - gtk3
# - webkit2gtk4.1
# archlinux:
# depends:
# - gtk3
# - webkit2gtk-4.1
#
# replaces:
# - foobar
# provides:
# - bar
# depends:
# - gtk3
# - libwebkit2gtk
# recommends:
# - whatever
# suggests:
+2 -2
View File
@@ -1,10 +1,10 @@
{
"fixed": {
"file_version": "0.0.47"
"file_version": "0.0.48"
},
"info": {
"0000": {
"ProductVersion": "0.0.47",
"ProductVersion": "0.0.48",
"CompanyName": "Cursor助手",
"FileDescription": "Cursor助手",
"LegalCopyright": "© 2026, Cursor助手",
+26 -1
View File
@@ -14,7 +14,7 @@
!define INFO_PRODUCTNAME "Cursor助手"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.0.47"
!define INFO_PRODUCTVERSION "0.0.48"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2026, Cursor助手"
@@ -27,9 +27,17 @@
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef WAILS_INSTALL_SCOPE
!define WAILS_INSTALL_SCOPE "machine"
!endif
!ifndef REQUEST_EXECUTION_LEVEL
!if "${WAILS_INSTALL_SCOPE}" == "user"
!define REQUEST_EXECUTION_LEVEL "user"
!else
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
@@ -115,6 +123,18 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
!if "${WAILS_INSTALL_SCOPE}" == "user"
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0"
!else
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
@@ -125,13 +145,18 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!endif
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
!if "${WAILS_INSTALL_SCOPE}" == "user"
DeleteRegKey HKCU "${UNINST_KEY}"
!else
DeleteRegKey HKLM "${UNINST_KEY}"
!endif
!macroend
!macro wails.setShellContext
+1 -1
View File
@@ -1,6 +1,6 @@
<?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">
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.47" processorArchitecture="*"/>
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.48" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
@@ -0,0 +1,92 @@
import { ref } from "vue";
import { Dialogs } from "@wailsio/runtime";
import { showModal } from "@/composables/useModal";
import {
appState,
exportUserConfigToFile,
importUserConfigFromFile,
toUserError,
} from "@/state/appState";
const YAML_FILE_FILTER = [{ DisplayName: "YAML 配置文件", Pattern: "*.yaml;*.yml" }];
export function useConfigTransfer({ message, showActionError }) {
const busy = ref(false);
async function exportConfig() {
const confirmed = await showModal({
title: "导出完整配置",
content: "导出文件会包含全部模型配置、API Key 和自定义请求头。请将文件保存在安全位置,避免泄露;Windows 上的访问权限取决于目标文件夹的安全设置。",
confirmText: "继续导出",
cancelText: "取消",
showCancel: true,
});
if (!confirmed) {
return;
}
try {
const path = await Dialogs.SaveFile({
Title: "导出 cursor-byok 配置",
Filename: "cursor-byok-config.yaml",
Filters: YAML_FILE_FILTER,
CanCreateDirectories: true,
AllowsOtherFiletypes: false,
});
if (!path) {
return;
}
busy.value = true;
const exportedPath = await exportUserConfigToFile(path);
message(`配置已导出到 ${exportedPath}`);
} catch (error) {
showActionError("导出失败", toUserError(error));
} finally {
busy.value = false;
}
}
async function importConfig() {
if (appState.serviceRunning || appState.backendRunning || appState.proxyRunning) {
showActionError("导入失败", "服务运行中不能导入完整配置,请先停止服务");
return;
}
try {
const path = await Dialogs.OpenFile({
Title: "导入 cursor-byok 配置",
Filters: YAML_FILE_FILTER,
CanChooseFiles: true,
CanChooseDirectories: false,
AllowsMultipleSelection: false,
AllowsOtherFiletypes: false,
});
if (!path) {
return;
}
const confirmed = await showModal({
title: "覆盖当前配置",
content: "导入会替换当前完整配置,包括模型、API Key、服务地址和其他设置。建议先导出备份,是否继续?",
confirmText: "确认导入",
cancelText: "取消",
showCancel: true,
});
if (!confirmed) {
return;
}
busy.value = true;
const imported = await importUserConfigFromFile(path);
message(`配置导入成功,共导入 ${imported.modelAdapters.length} 个模型`);
} catch (error) {
showActionError("导入失败", toUserError(error));
} finally {
busy.value = false;
}
}
return {
configTransferBusy: busy,
handleExportConfig: exportConfig,
handleImportConfig: importConfig,
};
}
+318 -89
View File
@@ -1,5 +1,22 @@
{
"entries": {
"01aaebc96af187a9": {
"source": "导入失败",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/composables/useConfigTransfer.js",
"line": 51,
"column": 23
},
{
"file": "src/composables/useConfigTransfer.js",
"line": 81,
"column": 23
}
]
},
"02216368edc68816": {
"source": "无更新说明",
"kind": "text",
@@ -7,7 +24,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 1007,
"line": 1012,
"column": 14
}
]
@@ -31,7 +48,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 443,
"line": 445,
"column": 14
}
]
@@ -43,7 +60,7 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 464,
"line": 485,
"column": 152
}
]
@@ -72,7 +89,7 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 451,
"line": 472,
"column": 19
}
]
@@ -96,7 +113,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 479,
"line": 481,
"column": 14
}
]
@@ -197,7 +214,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 299,
"line": 301,
"column": 14
}
]
@@ -209,7 +226,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 984,
"line": 989,
"column": 47
}
]
@@ -245,7 +262,7 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 64,
"line": 65,
"column": 73
}
]
@@ -296,11 +313,28 @@
},
{
"file": "src/state/appState.js",
"line": 325,
"line": 327,
"column": 36
}
]
},
"23353f7d54f291af": {
"source": "已有配置操作正在进行,请稍后再试",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/state/appState.js",
"line": 613,
"column": 32
},
{
"file": "src/state/appState.js",
"line": 1136,
"column": 21
}
]
},
"24343a2096988d42": {
"source": "打开失败",
"kind": "text",
@@ -342,7 +376,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 975,
"line": 980,
"column": 12
}
]
@@ -354,7 +388,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 482,
"line": 484,
"column": 14
}
]
@@ -401,6 +435,16 @@
"line": 9,
"column": 40
},
{
"file": "src/composables/useConfigTransfer.js",
"line": 21,
"column": 19
},
{
"file": "src/composables/useConfigTransfer.js",
"line": 70,
"column": 21
},
{
"file": "src/composables/useModal.js",
"line": 8,
@@ -413,7 +457,7 @@
},
{
"file": "src/state/appState.js",
"line": 1049,
"line": 1054,
"column": 12
}
]
@@ -437,7 +481,7 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 467,
"line": 488,
"column": 94
}
]
@@ -461,17 +505,17 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 463,
"line": 465,
"column": 16
},
{
"file": "src/state/appState.js",
"line": 469,
"line": 471,
"column": 16
},
{
"file": "src/state/appState.js",
"line": 475,
"line": 477,
"column": 16
}
]
@@ -536,6 +580,18 @@
}
]
},
"361ea3efbffac8ba": {
"source": "继续导出",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/composables/useConfigTransfer.js",
"line": 20,
"column": 20
}
]
},
"37d23612f78a2e63": {
"source": "立即重启更新",
"kind": "text",
@@ -543,11 +599,23 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 1041,
"line": 1046,
"column": 14
}
]
},
"392bd7c9d0ffcad8": {
"source": "覆盖当前配置",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/composables/useConfigTransfer.js",
"line": 67,
"column": 16
}
]
},
"392d0dceb45998d3": {
"source": "极高",
"kind": "text",
@@ -628,7 +696,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 428,
"line": 430,
"column": 14
}
]
@@ -674,7 +742,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 1005,
"line": 1010,
"column": 5
}
]
@@ -703,6 +771,18 @@
}
]
},
"429ea3af443dd80b": {
"source": "导出配置",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 411,
"column": 13
}
]
},
"42aa8e01e98c0d8c": {
"source": "总耗时",
"kind": "text",
@@ -770,7 +850,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 440,
"line": 442,
"column": 14
}
]
@@ -787,6 +867,18 @@
}
]
},
"4a8d6841b4023edf": {
"source": "确认导入",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/composables/useConfigTransfer.js",
"line": 69,
"column": 22
}
]
},
"4b5e0ae1288a9695": {
"source": "没有匹配的模型",
"kind": "text",
@@ -830,7 +922,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 117,
"line": 119,
"column": 12
}
]
@@ -864,6 +956,18 @@
}
]
},
"4ff5e6073183ced5": {
"source": "YAML 配置文件",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/composables/useConfigTransfer.js",
"line": 11,
"column": 42
}
]
},
"5205125c0e91d346": {
"source": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。",
"kind": "text",
@@ -883,12 +987,12 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 232,
"line": 239,
"column": 23
},
{
"file": "src/views/ModelConfig.vue",
"line": 236,
"line": 243,
"column": 21
}
]
@@ -946,7 +1050,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 302,
"line": 304,
"column": 12
}
]
@@ -982,7 +1086,7 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 27,
"line": 28,
"column": 12
}
]
@@ -994,7 +1098,7 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 57,
"line": 58,
"column": 12
}
]
@@ -1069,7 +1173,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 426,
"line": 428,
"column": 20
}
]
@@ -1081,7 +1185,7 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 465,
"line": 486,
"column": 169
}
]
@@ -1093,7 +1197,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 318,
"line": 320,
"column": 14
}
]
@@ -1117,7 +1221,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 973,
"line": 978,
"column": 14
}
]
@@ -1129,7 +1233,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 434,
"line": 436,
"column": 14
}
]
@@ -1194,12 +1298,12 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 450,
"line": 471,
"column": 19
},
{
"file": "src/views/ModelConfig.vue",
"line": 462,
"line": 483,
"column": 22
}
]
@@ -1221,7 +1325,7 @@
},
{
"file": "src/state/appState.js",
"line": 27,
"line": 29,
"column": 31
},
{
@@ -1246,12 +1350,12 @@
},
{
"file": "src/views/ModelConfig.vue",
"line": 86,
"line": 87,
"column": 34
},
{
"file": "src/views/ModelConfig.vue",
"line": 86,
"line": 87,
"column": 52
}
]
@@ -1268,6 +1372,18 @@
}
]
},
"6e4445df69575d79": {
"source": "导出失败",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/composables/useConfigTransfer.js",
"line": 43,
"column": 23
}
]
},
"6e584e3d5ce64aa0": {
"source": "保存配置",
"kind": "text",
@@ -1311,7 +1427,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 1220,
"line": 1246,
"column": 14
}
]
@@ -1335,7 +1451,7 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 62,
"line": 63,
"column": 10
}
]
@@ -1347,12 +1463,12 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 273,
"line": 280,
"column": 21
},
{
"file": "src/views/ModelConfig.vue",
"line": 278,
"line": 285,
"column": 21
}
]
@@ -1415,16 +1531,28 @@
},
{
"file": "src/state/appState.js",
"line": 203,
"line": 205,
"column": 12
},
{
"file": "src/views/ModelConfig.vue",
"line": 462,
"line": 483,
"column": 22
}
]
},
"7cd744da6bc8c0f1": {
"source": "导入 cursor-byok 配置",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/composables/useConfigTransfer.js",
"line": 56,
"column": 16
}
]
},
"7df7641e5e741346": {
"source": "缓存读取 /(缓存读取 + 缓存创建 + 非缓存输入)",
"kind": "text",
@@ -1461,7 +1589,7 @@
},
{
"file": "src/state/appState.js",
"line": 1026,
"line": 1031,
"column": 16
}
]
@@ -1574,11 +1702,23 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 970,
"line": 975,
"column": 14
}
]
},
"877b27aa9c9c2691": {
"source": "配置已导出到 {0}",
"kind": "template",
"placeholders": 1,
"refs": [
{
"file": "src/composables/useConfigTransfer.js",
"line": 41,
"column": 15
}
]
},
"899add6275682210": {
"source": "留空时默认 200000",
"kind": "text",
@@ -1666,6 +1806,18 @@
}
]
},
"8d828cb97168d7cd": {
"source": "配置导入成功,共导入 {0} 个模型",
"kind": "template",
"placeholders": 1,
"refs": [
{
"file": "src/composables/useConfigTransfer.js",
"line": 79,
"column": 15
}
]
},
"8f6f8d979c981ced": {
"source": "已复制",
"kind": "text",
@@ -1709,7 +1861,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 458,
"line": 460,
"column": 14
}
]
@@ -1738,7 +1890,7 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 60,
"line": 61,
"column": 12
}
]
@@ -1810,12 +1962,12 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 1325,
"line": 1351,
"column": 32
},
{
"file": "src/state/appState.js",
"line": 1346,
"line": 1372,
"column": 32
}
]
@@ -1875,7 +2027,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 1047,
"line": 1052,
"column": 14
}
]
@@ -1899,7 +2051,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 486,
"line": 488,
"column": 14
}
]
@@ -1923,7 +2075,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 452,
"line": 454,
"column": 14
}
]
@@ -1935,12 +2087,12 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 993,
"line": 998,
"column": 12
},
{
"file": "src/state/appState.js",
"line": 1036,
"line": 1041,
"column": 87
}
]
@@ -1969,12 +2121,12 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 1270,
"line": 1296,
"column": 14
},
{
"file": "src/views/ModelConfig.vue",
"line": 273,
"line": 280,
"column": 29
}
]
@@ -2105,7 +2257,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 1022,
"line": 1027,
"column": 16
}
]
@@ -2141,7 +2293,7 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 67,
"line": 68,
"column": 7
}
]
@@ -2197,6 +2349,18 @@
}
]
},
"b48b6518293a604e": {
"source": "导入配置",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 404,
"column": 13
}
]
},
"b5409d4049286061": {
"source": "自定义路径(请输入完整请求地址)",
"kind": "text",
@@ -2348,7 +2512,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 446,
"line": 448,
"column": 14
}
]
@@ -2384,12 +2548,12 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 261,
"line": 268,
"column": 21
},
{
"file": "src/views/ModelConfig.vue",
"line": 266,
"line": 273,
"column": 21
}
]
@@ -2425,7 +2589,7 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 68,
"line": 69,
"column": 7
}
]
@@ -2442,6 +2606,18 @@
}
]
},
"c733ec1c8af53c9a": {
"source": "导出完整配置",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/composables/useConfigTransfer.js",
"line": 18,
"column": 14
}
]
},
"c8a52b66651d294c": {
"source": "退出登录失败",
"kind": "text",
@@ -2473,17 +2649,17 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 1241,
"line": 1267,
"column": 28
},
{
"file": "src/state/appState.js",
"line": 1243,
"line": 1269,
"column": 38
},
{
"file": "src/state/appState.js",
"line": 1278,
"line": 1304,
"column": 93
}
]
@@ -2507,7 +2683,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 982,
"line": 987,
"column": 51
}
]
@@ -2543,11 +2719,23 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 294,
"line": 296,
"column": 12
}
]
},
"cf14b829486f866e": {
"source": "导入会替换当前完整配置,包括模型、API Key、服务地址和其他设置。建议先导出备份,是否继续?",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/composables/useConfigTransfer.js",
"line": 68,
"column": 18
}
]
},
"cfa6c803eb3fc713": {
"source": "等待浏览器登录",
"kind": "text",
@@ -2579,7 +2767,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 437,
"line": 439,
"column": 14
}
]
@@ -2591,7 +2779,7 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 64,
"line": 65,
"column": 62
}
]
@@ -2603,12 +2791,12 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 1189,
"line": 1215,
"column": 14
},
{
"file": "src/views/ModelConfig.vue",
"line": 261,
"line": 268,
"column": 29
}
]
@@ -2632,12 +2820,12 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 206,
"line": 208,
"column": 38
},
{
"file": "src/state/appState.js",
"line": 236,
"line": 238,
"column": 50
}
]
@@ -2654,7 +2842,7 @@
},
{
"file": "src/state/appState.js",
"line": 308,
"line": 310,
"column": 49
}
]
@@ -2683,7 +2871,7 @@
},
{
"file": "src/state/appState.js",
"line": 329,
"line": 331,
"column": 36
}
]
@@ -2741,7 +2929,7 @@
},
{
"file": "src/state/appState.js",
"line": 258,
"line": 260,
"column": 18
}
]
@@ -2753,7 +2941,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 431,
"line": 433,
"column": 14
}
]
@@ -2765,7 +2953,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 1004,
"line": 1009,
"column": 5
}
]
@@ -2782,6 +2970,18 @@
}
]
},
"df5e382e16994c53": {
"source": "导出 cursor-byok 配置",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/composables/useConfigTransfer.js",
"line": 30,
"column": 16
}
]
},
"e01c5dae36cf8c35": {
"source": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。",
"kind": "text",
@@ -2794,6 +2994,23 @@
}
]
},
"e09a2127af23b1d6": {
"source": "服务运行中不能导入完整配置,请先停止服务",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/composables/useConfigTransfer.js",
"line": 51,
"column": 31
},
{
"file": "src/state/appState.js",
"line": 1133,
"column": 21
}
]
},
"e14c41ef2b7253c9": {
"source": "总请求:{0}",
"kind": "template",
@@ -2866,8 +3083,8 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 400,
"column": 123
"line": 421,
"column": 145
}
]
},
@@ -2883,6 +3100,18 @@
}
]
},
"e899a318b5cf571e": {
"source": "导出文件会包含全部模型配置、API Key 和自定义请求头。请将文件保存在安全位置,避免泄露;Windows 上的访问权限取决于目标文件夹的安全设置。",
"kind": "text",
"placeholders": 0,
"refs": [
{
"file": "src/composables/useConfigTransfer.js",
"line": 19,
"column": 16
}
]
},
"e8a0a6053998ebfa": {
"source": "已经登录",
"kind": "text",
@@ -2926,7 +3155,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 212,
"line": 214,
"column": 10
}
]
@@ -2938,7 +3167,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 1024,
"line": 1029,
"column": 16
}
]
@@ -2974,12 +3203,12 @@
"refs": [
{
"file": "src/views/ModelConfig.vue",
"line": 426,
"line": 447,
"column": 15
},
{
"file": "src/views/ModelConfig.vue",
"line": 427,
"line": 448,
"column": 15
}
]
@@ -3015,7 +3244,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 315,
"line": 317,
"column": 14
}
]
@@ -3027,7 +3256,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 984,
"line": 989,
"column": 38
}
]
@@ -3115,12 +3344,12 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 449,
"line": 451,
"column": 14
},
{
"file": "src/state/appState.js",
"line": 455,
"line": 457,
"column": 14
}
]
@@ -3164,7 +3393,7 @@
},
{
"file": "src/state/appState.js",
"line": 1043,
"line": 1048,
"column": 12
}
]
@@ -3176,7 +3405,7 @@
"refs": [
{
"file": "src/state/appState.js",
"line": 982,
"line": 987,
"column": 40
}
]
+17
View File
@@ -1,4 +1,5 @@
{
"01aaebc96af187a9": "Import failed",
"02216368edc68816": "No release notes",
"03b11112dc970014": "Base URL",
"04f632dd4f034d5e": "{0} context window must be a positive integer",
@@ -22,6 +23,7 @@
"1c631615c1d85c9e": "Log in to Cursor",
"1e238093b79b3165": "Uses 65536 by default when left blank",
"21296ab18ad9af25": "Extra Params JSON",
"23353f7d54f291af": "Another configuration operation is in progress. Please try again later.",
"24343a2096988d42": "Failed to open",
"253d4a3428c648fb": "Cache write tokens: {0}",
"26a3855aed1d8d17": "Service not running",
@@ -37,7 +39,9 @@
"3463c5585c246df9": "Total: {0}",
"3468b57e3edbc599": "Aggregated from turn summaries scanned from the history.",
"35076178fe79a210": "Configuration changed. Please test again.",
"361ea3efbffac8ba": "Continue Export",
"37d23612f78a2e63": "Restart Now to Update",
"392bd7c9d0ffcad8": "Overwrite Current Configuration",
"392d0dceb45998d3": "Extreme",
"393df9bb13ea4900": "Hit",
"3ab8cc15939f3b5c": "Log out",
@@ -48,6 +52,7 @@
"3ea83f9f55062582": "Release date: {0}",
"3edda85621fd03b2": "model adapters",
"3fd47edce45b3603": "Close",
"429ea3af443dd80b": "Export Configuration",
"42aa8e01e98c0d8c": "Total Duration",
"438bb77c1ecdfab0": "Cache Write: {0} × ${1}/1M = {2}",
"46056425a48ef05b": "Currently displayed using the reuse rate definition",
@@ -55,12 +60,14 @@
"472642d58d3d5a6d": "Formula: {0}",
"4923eeb7bd75cccd": "{0} model ID cannot be empty",
"497c85690c4cc0fc": "No data",
"4a8d6841b4023edf": "Confirm Import",
"4b5e0ae1288a9695": "No matching models",
"4c0a929bb86ce912": "Current: {0}",
"4d2b6e53be6002e5": "Cache Statistics Strategy: {0} ({1})",
"4d8c1c5b42830791": "Unknown",
"4e30d7c9ed2b0eee": "Not set",
"4f0982ba1d37e51b": "Current outbound requests use environment variable proxy",
"4ff5e6073183ced5": "YAML Configuration Files",
"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",
@@ -88,6 +95,7 @@
"6aa8f49cc992dfd7": "Test",
"6ae23d6d7cb18592": "Service error",
"6d7872164df9b36e": "Normal Input: {0} × ${1}/1M = {2}",
"6e4445df69575d79": "Export failed",
"6e584e3d5ce64aa0": "Save Settings",
"6ec87609a8769425": "Default {0} / Count as creation {1}",
"72f6c3525c0192a7": "When enabled, cache creation is included in the denominator",
@@ -99,6 +107,7 @@
"77c9e582e85583af": "Test failed",
"7a26bf794e9fb6bf": "Used only for display in the UI, so you can distinguish different models.",
"7b6187c41e88b70c": "Testing...",
"7cd744da6bc8c0f1": "Import cursor-byok Configuration",
"7df7641e5e741346": "Cache Read / (Cache Read + Cache Creation + Non-cache Input)",
"7e9e334aeb0bdc07": "Service operation failed",
"7f68ebad19ba6bcd": "Check for Updates",
@@ -111,12 +120,14 @@
"83fcfb4c1f2c1641": "Fetch Models",
"8672864e90417138": "Max",
"86df7ec743047234": "Service running",
"877b27aa9c9c2691": "Configuration exported to {0}",
"899add6275682210": "Uses 200000 by default when left blank",
"8a4ef3e48e4e8a5a": "Enabled",
"8b8428f714611458": "Only select a reasoning effort when the model supports reasoning_effort. When set to Not set, the request omits this parameter. Higher values are usually more stable, but may also be slower.",
"8c1935935600e336": "Model Test",
"8cbcf741e727dbf7": "Model Settings",
"8d1de152be6360ce": "Valid ratio: {0}",
"8d828cb97168d7cd": "Configuration imported successfully. Imported {0} models.",
"8f6f8d979c981ced": "Copied",
"8f8baf5d18dd0492": "When enabled, the JSON object will override the OpenAI request body. Duplicate fields are determined by this field. OpenAI service_tier supports auto, default, flex, scale, priority; priority can be used for high-priority/Fast scenarios.",
"8faa670b512b6b9b": "Open Model Settings",
@@ -155,6 +166,7 @@
"aed55419ce62f08e": "Switching...",
"b10041a13f5c55b1": "Model Output: {0} × ${1}/1M = {2}",
"b1c27820fec23edb": "High",
"b48b6518293a604e": "Import Configuration",
"b5409d4049286061": "Custom Path (Please enter the full request URL)",
"b571037dc396a00c": "Total request tokens include both prompt and model output.",
"b765005f69fa971f": "e.g. gpt-4.1",
@@ -175,6 +187,7 @@
"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",
"c733ec1c8af53c9a": "Export Full Configuration",
"c8a52b66651d294c": "Failed to log out",
"c8c14507b2d37395": "Reasoning Effort",
"c98e118e0a43f078": "Model",
@@ -183,6 +196,7 @@
"ca1d1059408b3837": "Invalid turns: {0}",
"cc5049729a2c10f1": "Test failed. Check the raw details.",
"cd7ca5fb221e1c53": "{0} cannot be empty",
"cf14b829486f866e": "Importing will replace the entire current configuration, including models, API keys, service addresses, and other settings. We recommend exporting a backup first. Continue?",
"cfa6c803eb3fc713": "Waiting for browser login",
"d0325067fed88e5a": "Cache hit rate {0}",
"d20ab96566d33f25": "{0} display name cannot be empty",
@@ -200,7 +214,9 @@
"dbee6e7139243362": "{0} base URL cannot be empty",
"dc82c5e8fb2ab777": "Version: v{0}",
"de8184da1ef88d03": "Configured",
"df5e382e16994c53": "Export cursor-byok Configuration",
"e01c5dae36cf8c35": "When enabled, the JSON object will override the OpenAI request body. Duplicate fields are determined by this field. OpenAI service_tier supports auto, default, flex, scale, priority.",
"e09a2127af23b1d6": "The full configuration cannot be imported while the service is running. Stop the service first.",
"e14c41ef2b7253c9": "Total request tokens: {0}",
"e406825e0a72d2c2": "Local Settings",
"e4343921c928a856": "Login failed",
@@ -208,6 +224,7 @@
"e53580f8031f13c0": "Complete login in the browser, then return to Cursor and reopen the plugin marketplace",
"e552c2accdbf5178": "Add Model",
"e6faccfddce722e8": "Cache read tokens: {0}",
"e899a318b5cf571e": "The exported file contains all model settings, API keys, and custom headers. Store it securely to prevent disclosure. On Windows, access depends on the security settings of the destination folder.",
"e8a0a6053998ebfa": "Logged in",
"eaffd48cd2ea9f1a": "e.g. https://api.anthropic.com",
"eb1be07f2ca6e506": "Estimated based on Claude Opus 4.7 pricing.",
+17
View File
@@ -1,4 +1,5 @@
{
"01aaebc96af187a9": "インポートに失敗しました",
"02216368edc68816": "更新内容はありません",
"03b11112dc970014": "ベース URL",
"04f632dd4f034d5e": "{0} のコンテキストウィンドウは正の整数である必要があります",
@@ -22,6 +23,7 @@
"1c631615c1d85c9e": "Cursor にログイン",
"1e238093b79b3165": "空欄で 65536",
"21296ab18ad9af25": "追加パラメータ JSON",
"23353f7d54f291af": "別の設定操作を実行中です。しばらくしてからもう一度お試しください。",
"24343a2096988d42": "開けませんでした",
"253d4a3428c648fb": "キャッシュ書き込み Token: {0}",
"26a3855aed1d8d17": "サービスは起動していません",
@@ -37,7 +39,9 @@
"3463c5585c246df9": "合計:{0}",
"3468b57e3edbc599": "履歴からスキャンした各ターンの summary を集計しています。",
"35076178fe79a210": "設定が変更されました。再テストしてください",
"361ea3efbffac8ba": "エクスポートを続行",
"37d23612f78a2e63": "今すぐ再起動して更新",
"392bd7c9d0ffcad8": "現在の設定を上書き",
"392d0dceb45998d3": "最高",
"393df9bb13ea4900": "ヒット",
"3ab8cc15939f3b5c": "ログアウト",
@@ -48,6 +52,7 @@
"3ea83f9f55062582": "公開日時: {0}",
"3edda85621fd03b2": "件のモデルアダプター",
"3fd47edce45b3603": "閉じる",
"429ea3af443dd80b": "設定をエクスポート",
"42aa8e01e98c0d8c": "総所要時間",
"438bb77c1ecdfab0": "キャッシュ書き込み:{0} × ${1}/1M = {2}",
"46056425a48ef05b": "現在、再利用率の定義に従って表示されています",
@@ -55,12 +60,14 @@
"472642d58d3d5a6d": "数式:{0}",
"4923eeb7bd75cccd": "{0} のモデル ID は必須です",
"497c85690c4cc0fc": "データなし",
"4a8d6841b4023edf": "インポートを確認",
"4b5e0ae1288a9695": "一致するモデルがありません",
"4c0a929bb86ce912": "現在:{0}",
"4d2b6e53be6002e5": "キャッシュ統計ポリシー:{0}{1})",
"4d8c1c5b42830791": "不明",
"4e30d7c9ed2b0eee": "設定しない",
"4f0982ba1d37e51b": "現在のアウトバウンドリクエストは環境変数プロキシを使用しています",
"4ff5e6073183ced5": "YAML 設定ファイル",
"5205125c0e91d346": "Anthropic モデルが1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
"54e6745ff43c9c74": "並べ替えに失敗しました",
"56627c94a9decee6": "最大出力 Token",
@@ -88,6 +95,7 @@
"6aa8f49cc992dfd7": "テスト",
"6ae23d6d7cb18592": "サービスエラー",
"6d7872164df9b36e": "通常入力:{0} × ${1}/1M = {2}",
"6e4445df69575d79": "エクスポートに失敗しました",
"6e584e3d5ce64aa0": "設定を保存",
"6ec87609a8769425": "デフォルト {0} / 作成カウント {1}",
"72f6c3525c0192a7": "有効にすると、キャッシュ作成が分母に含まれます",
@@ -99,6 +107,7 @@
"77c9e582e85583af": "テスト失敗",
"7a26bf794e9fb6bf": "UIでモデルを区別するための表示専用です。",
"7b6187c41e88b70c": "テスト中...",
"7cd744da6bc8c0f1": "cursor-byok 設定をインポート",
"7df7641e5e741346": "キャッシュ読み取り / (キャッシュ読み取り + キャッシュ作成 + 非キャッシュ入力)",
"7e9e334aeb0bdc07": "サービス操作に失敗しました",
"7f68ebad19ba6bcd": "アップデートを確認",
@@ -111,12 +120,14 @@
"83fcfb4c1f2c1641": "モデルを取得",
"8672864e90417138": "最大",
"86df7ec743047234": "サービス稼働中",
"877b27aa9c9c2691": "設定を {0} にエクスポートしました",
"899add6275682210": "空欄で 200000",
"8a4ef3e48e4e8a5a": "有効",
"8b8428f714611458": "モデルが reasoning_effort に対応している場合のみ推論強度を選択してください。「設定しない」を選ぶと、リクエストにこのパラメータは含まれません。値が高いほど安定しやすい反面、遅くなることがあります。",
"8c1935935600e336": "モデルテスト",
"8cbcf741e727dbf7": "モデル設定",
"8d1de152be6360ce": "有効率: {0}",
"8d828cb97168d7cd": "設定のインポートが完了しました。{0} 件のモデルをインポートしました",
"8f6f8d979c981ced": "コピーしました",
"8f8baf5d18dd0492": "有効にすると、JSONオブジェクトがOpenAIのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。OpenAIのservice_tierはauto、default、flex、scale、priorityをサポートしており、priorityは高優先度/Fastのシナリオで使用できます。",
"8faa670b512b6b9b": "モデル設定を開く",
@@ -155,6 +166,7 @@
"aed55419ce62f08e": "切替中...",
"b10041a13f5c55b1": "モデル出力:{0} × ${1}/1M = {2}",
"b1c27820fec23edb": "高",
"b48b6518293a604e": "設定をインポート",
"b5409d4049286061": "カスタムパス(完全なリクエストURLを入力してください)",
"b571037dc396a00c": "総リクエスト Token には Prompt とモデル出力の両方が含まれます。",
"b765005f69fa971f": "例: gpt-4.1",
@@ -175,6 +187,7 @@
"c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。",
"c6868592796ac2b2": "まだ {0} モデルが設定されていません。",
"c69f5bce63b9f14c": "設定フォルダー",
"c733ec1c8af53c9a": "完全な設定をエクスポート",
"c8a52b66651d294c": "ログアウトに失敗しました",
"c8c14507b2d37395": "推論強度",
"c98e118e0a43f078": "モデル",
@@ -183,6 +196,7 @@
"ca1d1059408b3837": "異常ターン: {0}",
"cc5049729a2c10f1": "テストに失敗しました。元の詳細情報を確認してください。",
"cd7ca5fb221e1c53": "{0}は空にできません",
"cf14b829486f866e": "インポートすると、モデル、API Key、サービスアドレス、その他の設定を含む現在の設定全体が置き換えられます。先にバックアップをエクスポートすることを推奨します。続行しますか?",
"cfa6c803eb3fc713": "ブラウザでのログインを待っています",
"d0325067fed88e5a": "キャッシュヒット率 {0}",
"d20ab96566d33f25": "{0} の表示名は必須です",
@@ -200,7 +214,9 @@
"dbee6e7139243362": "{0} のベース URL は必須です",
"dc82c5e8fb2ab777": "バージョン: v{0}",
"de8184da1ef88d03": "設定済み",
"df5e382e16994c53": "cursor-byok 設定をエクスポート",
"e01c5dae36cf8c35": "有効にすると、JSONオブジェクトがOpenAIのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。OpenAIのservice_tierはauto、default、flex、scale、priorityをサポートしています。",
"e09a2127af23b1d6": "サービスの実行中は設定全体をインポートできません。先にサービスを停止してください。",
"e14c41ef2b7253c9": "総リクエスト Token: {0}",
"e406825e0a72d2c2": "ローカル設定",
"e4343921c928a856": "ログインに失敗しました",
@@ -208,6 +224,7 @@
"e53580f8031f13c0": "ブラウザでログインを完了し、Cursor に戻ってプラグインマーケットを開き直してください",
"e552c2accdbf5178": "モデルを追加",
"e6faccfddce722e8": "キャッシュ読込 Token: {0}",
"e899a318b5cf571e": "エクスポートファイルには、すべてのモデル設定、API キー、カスタムヘッダーが含まれます。漏えいを防ぐため、安全な場所に保存してください。Windows では、アクセス権限は保存先フォルダーのセキュリティ設定に依存します。",
"e8a0a6053998ebfa": "ログイン済み",
"eaffd48cd2ea9f1a": "例: https://api.anthropic.com",
"eb1be07f2ca6e506": "Claude Opus 4.7の価格に基づいて見積もられます。",
+17
View File
@@ -1,4 +1,5 @@
{
"01aaebc96af187a9": "Не удалось импортировать",
"02216368edc68816": "Нет примечаний к выпуску",
"03b11112dc970014": "Базовый URL",
"04f632dd4f034d5e": "Размер контекстного окна {0} должен быть положительным целым числом",
@@ -22,6 +23,7 @@
"1c631615c1d85c9e": "Войти в Cursor",
"1e238093b79b3165": "Если оставить пустым, используется 65536",
"21296ab18ad9af25": "Дополнительные параметры JSON",
"23353f7d54f291af": "Уже выполняется другая операция с конфигурацией. Повторите попытку позже.",
"24343a2096988d42": "Не удалось открыть",
"253d4a3428c648fb": "Токены записи в кеш: {0}",
"26a3855aed1d8d17": "Сервис не запущен",
@@ -37,7 +39,9 @@
"3463c5585c246df9": "Итого: {0}",
"3468b57e3edbc599": "Сводка составлена по данным ходов, найденным в истории.",
"35076178fe79a210": "Конфигурация изменена. Выполните проверку снова.",
"361ea3efbffac8ba": "Продолжить экспорт",
"37d23612f78a2e63": "Перезапустить и обновить",
"392bd7c9d0ffcad8": "Заменить текущую конфигурацию",
"392d0dceb45998d3": "Очень высокая",
"393df9bb13ea4900": "Попадание",
"3ab8cc15939f3b5c": "Выйти",
@@ -48,6 +52,7 @@
"3ea83f9f55062582": "Дата выпуска: {0}",
"3edda85621fd03b2": "адаптеров моделей",
"3fd47edce45b3603": "Закрыть",
"429ea3af443dd80b": "Экспортировать конфигурацию",
"42aa8e01e98c0d8c": "Общая длительность",
"438bb77c1ecdfab0": "Запись в кеш: {0} × ${1}/1M = {2}",
"46056425a48ef05b": "Сейчас используется расчет по коэффициенту повторного использования",
@@ -55,12 +60,14 @@
"472642d58d3d5a6d": "Формула: {0}",
"4923eeb7bd75cccd": "Идентификатор модели {0} не может быть пустым",
"497c85690c4cc0fc": "Нет данных",
"4a8d6841b4023edf": "Подтвердить импорт",
"4b5e0ae1288a9695": "Подходящих моделей нет",
"4c0a929bb86ce912": "Сейчас: {0}",
"4d2b6e53be6002e5": "Стратегия статистики кеша: {0} ({1})",
"4d8c1c5b42830791": "Неизвестно",
"4e30d7c9ed2b0eee": "Не задано",
"4f0982ba1d37e51b": "Исходящие запросы используют прокси из переменных окружения",
"4ff5e6073183ced5": "Файлы конфигурации YAML",
"5205125c0e91d346": "Максимальное число токенов, которое модель Anthropic может сгенерировать за один ответ. Оставьте поле пустым для значения по умолчанию.",
"54e6745ff43c9c74": "Не удалось изменить порядок",
"56627c94a9decee6": "Макс. выходных токенов",
@@ -88,6 +95,7 @@
"6aa8f49cc992dfd7": "Проверить",
"6ae23d6d7cb18592": "Ошибка сервиса",
"6d7872164df9b36e": "Обычный ввод: {0} × ${1}/1M = {2}",
"6e4445df69575d79": "Не удалось экспортировать",
"6e584e3d5ce64aa0": "Сохранить настройки",
"6ec87609a8769425": "По умолчанию {0} / С учетом создания {1}",
"72f6c3525c0192a7": "Если включено, создание кеша учитывается в знаменателе",
@@ -99,6 +107,7 @@
"77c9e582e85583af": "Проверка не пройдена",
"7a26bf794e9fb6bf": "Используется только для отображения в интерфейсе, чтобы различать модели.",
"7b6187c41e88b70c": "Проверка...",
"7cd744da6bc8c0f1": "Импорт конфигурации cursor-byok",
"7df7641e5e741346": "Чтение кеша / (Чтение кеша + Создание кеша + Ввод без кеша)",
"7e9e334aeb0bdc07": "Не удалось выполнить операцию с сервисом",
"7f68ebad19ba6bcd": "Проверить обновления",
@@ -111,12 +120,14 @@
"83fcfb4c1f2c1641": "Получить модели",
"8672864e90417138": "Максимальная",
"86df7ec743047234": "Сервис запущен",
"877b27aa9c9c2691": "Конфигурация экспортирована в {0}",
"899add6275682210": "Если оставить пустым, используется 200000",
"8a4ef3e48e4e8a5a": "Включено",
"8b8428f714611458": "Выбирайте интенсивность рассуждений только для моделей с поддержкой reasoning_effort. Если выбрать «Не задано», этот параметр не будет добавлен в запрос. Более высокие значения обычно дают более стабильный результат, но могут замедлить ответ.",
"8c1935935600e336": "Проверка модели",
"8cbcf741e727dbf7": "Настройки модели",
"8d1de152be6360ce": "Доля успешных: {0}",
"8d828cb97168d7cd": "Конфигурация успешно импортирована. Импортировано моделей: {0}",
"8f6f8d979c981ced": "Скопировано",
"8f8baf5d18dd0492": "Если включено, объект JSON переопределит тело запроса OpenAI. При совпадении полей используются значения отсюда. OpenAI service_tier поддерживает auto, default, flex, scale и priority; priority можно использовать для сценариев с высоким приоритетом/Fast.",
"8faa670b512b6b9b": "Открыть настройки модели",
@@ -155,6 +166,7 @@
"aed55419ce62f08e": "Переключение...",
"b10041a13f5c55b1": "Вывод модели: {0} × ${1}/1M = {2}",
"b1c27820fec23edb": "Высокая",
"b48b6518293a604e": "Импортировать конфигурацию",
"b5409d4049286061": "Пользовательский путь (введите полный URL запроса)",
"b571037dc396a00c": "Общее число токенов запроса включает Prompt и вывод модели.",
"b765005f69fa971f": "например, gpt-4.1",
@@ -175,6 +187,7 @@
"c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.",
"c6868592796ac2b2": "Модели {0} пока не настроены.",
"c69f5bce63b9f14c": "Папка настроек",
"c733ec1c8af53c9a": "Экспортировать всю конфигурацию",
"c8a52b66651d294c": "Не удалось выйти",
"c8c14507b2d37395": "Интенсивность рассуждений",
"c98e118e0a43f078": "Модель",
@@ -183,6 +196,7 @@
"ca1d1059408b3837": "Ошибочных ходов: {0}",
"cc5049729a2c10f1": "Тест не пройден. Проверьте исходные сведения.",
"cd7ca5fb221e1c53": "{0} не может быть пустым",
"cf14b829486f866e": "Импорт заменит всю текущую конфигурацию, включая модели, API-ключи, адреса сервисов и другие настройки. Рекомендуется сначала экспортировать резервную копию. Продолжить?",
"cfa6c803eb3fc713": "Ожидание входа в браузере",
"d0325067fed88e5a": "Доля попаданий в кеш: {0}",
"d20ab96566d33f25": "Отображаемое имя {0} не может быть пустым",
@@ -200,7 +214,9 @@
"dbee6e7139243362": "Базовый URL {0} не может быть пустым",
"dc82c5e8fb2ab777": "Версия: v{0}",
"de8184da1ef88d03": "Настроено",
"df5e382e16994c53": "Экспорт конфигурации cursor-byok",
"e01c5dae36cf8c35": "Если включено, объект JSON переопределит тело запроса OpenAI. При совпадении полей используются значения отсюда. OpenAI service_tier поддерживает auto, default, flex, scale и priority.",
"e09a2127af23b1d6": "Нельзя импортировать полную конфигурацию во время работы сервиса. Сначала остановите сервис.",
"e14c41ef2b7253c9": "Всего токенов запроса: {0}",
"e406825e0a72d2c2": "Локальные настройки",
"e4343921c928a856": "Не удалось войти",
@@ -208,6 +224,7 @@
"e53580f8031f13c0": "Завершите вход в браузере, затем вернитесь в Cursor и снова откройте магазин плагинов",
"e552c2accdbf5178": "Добавить модель",
"e6faccfddce722e8": "Токены чтения из кеша: {0}",
"e899a318b5cf571e": "Экспортированный файл содержит все настройки моделей, ключи API и пользовательские заголовки. Храните его в безопасном месте. В Windows доступ зависит от параметров безопасности папки назначения.",
"e8a0a6053998ebfa": "Выполнен вход",
"eaffd48cd2ea9f1a": "например, https://api.anthropic.com",
"eb1be07f2ca6e506": "Расчет основан на тарифах Claude Opus 4.7.",
+17
View File
@@ -1,4 +1,5 @@
{
"01aaebc96af187a9": "导入失败",
"02216368edc68816": "无更新说明",
"03b11112dc970014": "接口地址",
"04f632dd4f034d5e": "{0} 的上下文窗口必须为正整数",
@@ -22,6 +23,7 @@
"1c631615c1d85c9e": "登录 Cursor",
"1e238093b79b3165": "留空时默认 65536",
"21296ab18ad9af25": "额外参数 JSON",
"23353f7d54f291af": "已有配置操作正在进行,请稍后再试",
"24343a2096988d42": "打开失败",
"253d4a3428c648fb": "缓存写入:{0}",
"26a3855aed1d8d17": "服务未启动",
@@ -37,7 +39,9 @@
"3463c5585c246df9": "合计:{0}",
"3468b57e3edbc599": "按历史记录里扫描到的回合 summary 汇总。",
"35076178fe79a210": "配置已变更,请重新测试",
"361ea3efbffac8ba": "继续导出",
"37d23612f78a2e63": "立即重启更新",
"392bd7c9d0ffcad8": "覆盖当前配置",
"392d0dceb45998d3": "极高",
"393df9bb13ea4900": "命中",
"3ab8cc15939f3b5c": "退出登录",
@@ -48,6 +52,7 @@
"3ea83f9f55062582": "发布时间:{0}",
"3edda85621fd03b2": "个模型适配器",
"3fd47edce45b3603": "关闭",
"429ea3af443dd80b": "导出配置",
"42aa8e01e98c0d8c": "总耗时",
"438bb77c1ecdfab0": "缓存写入:{0} × ${1}/1M = {2}",
"46056425a48ef05b": "当前按复用率口径显示",
@@ -55,12 +60,14 @@
"472642d58d3d5a6d": "公式:{0}",
"4923eeb7bd75cccd": "{0} 的模型标识不能为空",
"497c85690c4cc0fc": "暂无数据",
"4a8d6841b4023edf": "确认导入",
"4b5e0ae1288a9695": "没有匹配的模型",
"4c0a929bb86ce912": "当前:{0}",
"4d2b6e53be6002e5": "缓存统计策略:{0}{1}",
"4d8c1c5b42830791": "未知",
"4e30d7c9ed2b0eee": "不设置",
"4f0982ba1d37e51b": "当前出站请求使用环境变量代理",
"4ff5e6073183ced5": "YAML 配置文件",
"5205125c0e91d346": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。",
"54e6745ff43c9c74": "排序失败",
"56627c94a9decee6": "最大输出 Token",
@@ -88,6 +95,7 @@
"6aa8f49cc992dfd7": "测试",
"6ae23d6d7cb18592": "服务错误",
"6d7872164df9b36e": "普通输入:{0} × ${1}/1M = {2}",
"6e4445df69575d79": "导出失败",
"6e584e3d5ce64aa0": "保存配置",
"6ec87609a8769425": "默认 {0} / 计入创建 {1}",
"72f6c3525c0192a7": "开启后把缓存创建纳入分母",
@@ -99,6 +107,7 @@
"77c9e582e85583af": "测试失败",
"7a26bf794e9fb6bf": "仅用于界面展示,便于你区分不同模型。",
"7b6187c41e88b70c": "测试中...",
"7cd744da6bc8c0f1": "导入 cursor-byok 配置",
"7df7641e5e741346": "缓存读取 /(缓存读取 + 缓存创建 + 非缓存输入)",
"7e9e334aeb0bdc07": "服务操作失败",
"7f68ebad19ba6bcd": "检查更新",
@@ -111,12 +120,14 @@
"83fcfb4c1f2c1641": "获取模型",
"8672864e90417138": "最高",
"86df7ec743047234": "服务运行中",
"877b27aa9c9c2691": "配置已导出到 {0}",
"899add6275682210": "留空时默认 200000",
"8a4ef3e48e4e8a5a": "已开启",
"8b8428f714611458": "仅当模型支持 reasoning_effort 时才选择推理强度;选择“不设置”后,请求不会携带该参数。越高通常越稳,但也可能更慢。",
"8c1935935600e336": "模型测试",
"8cbcf741e727dbf7": "模型配置",
"8d1de152be6360ce": "有效占比:{0}",
"8d828cb97168d7cd": "配置导入成功,共导入 {0} 个模型",
"8f6f8d979c981ced": "已复制",
"8f8baf5d18dd0492": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、prioritypriority 可用于高优先级/Fast 类场景。",
"8faa670b512b6b9b": "打开模型配置",
@@ -155,6 +166,7 @@
"aed55419ce62f08e": "切换中...",
"b10041a13f5c55b1": "模型输出:{0} × ${1}/1M = {2}",
"b1c27820fec23edb": "高",
"b48b6518293a604e": "导入配置",
"b5409d4049286061": "自定义路径(请输入完整请求地址)",
"b571037dc396a00c": "总请求 Token 包含 Prompt 和模型输出。",
"b765005f69fa971f": "例如:gpt-4.1",
@@ -175,6 +187,7 @@
"c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
"c6868592796ac2b2": "当前还没有配置任何 {0} 模型。",
"c69f5bce63b9f14c": "设置文件夹",
"c733ec1c8af53c9a": "导出完整配置",
"c8a52b66651d294c": "退出登录失败",
"c8c14507b2d37395": "推理强度",
"c98e118e0a43f078": "模型",
@@ -183,6 +196,7 @@
"ca1d1059408b3837": "异常轮次:{0}",
"cc5049729a2c10f1": "测试失败,请查看原始信息",
"cd7ca5fb221e1c53": "{0}不能为空",
"cf14b829486f866e": "导入会替换当前完整配置,包括模型、API Key、服务地址和其他设置。建议先导出备份,是否继续?",
"cfa6c803eb3fc713": "等待浏览器登录",
"d0325067fed88e5a": "缓存命中率 {0}",
"d20ab96566d33f25": "{0} 的显示名称不能为空",
@@ -200,7 +214,9 @@
"dbee6e7139243362": "{0} 的接口地址不能为空",
"dc82c5e8fb2ab777": "版本:v{0}",
"de8184da1ef88d03": "已配置",
"df5e382e16994c53": "导出 cursor-byok 配置",
"e01c5dae36cf8c35": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。",
"e09a2127af23b1d6": "服务运行中不能导入完整配置,请先停止服务",
"e14c41ef2b7253c9": "总请求:{0}",
"e406825e0a72d2c2": "本地配置",
"e4343921c928a856": "登录失败",
@@ -208,6 +224,7 @@
"e53580f8031f13c0": "请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场",
"e552c2accdbf5178": "新增模型",
"e6faccfddce722e8": "缓存读取:{0}",
"e899a318b5cf571e": "导出文件会包含全部模型配置、API Key 和自定义请求头。请将文件保存在安全位置,避免泄露;Windows 上的访问权限取决于目标文件夹的安全设置。",
"e8a0a6053998ebfa": "已经登录",
"eaffd48cd2ea9f1a": "例如:https://api.anthropic.com",
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
+22
View File
@@ -63,6 +63,28 @@ export function saveUserConfig(payload) {
return withApiLogging("SaveUserConfig", payload, () => SaveUserConfig(payload));
}
export function exportUserConfig(path) {
return withApiLogging("ExportUserConfig", { path }, () =>
Call.ByName(`${PROXY_SERVICE_NAME}.ExportUserConfig`, path),
);
}
export function importUserConfig(path) {
return Call.ByName(`${PROXY_SERVICE_NAME}.ImportUserConfig`, path).then(
(result) => {
console.log(`${API_LOG_PREFIX} ImportUserConfig response`, {
path,
modelCount: Array.isArray(result?.modelAdapters) ? result.modelAdapters.length : 0,
});
return result;
},
(error) => {
logError("ImportUserConfig", { path }, error);
throw error;
},
);
}
export function getCursorAccountStatus() {
return withApiLogging("GetCursorAccountStatus", undefined, () => GetCursorAccountStatus());
}
+26
View File
@@ -3,11 +3,13 @@ import { Events } from "@wailsio/runtime";
import dayjs from "dayjs";
import {
checkForUpdates,
exportUserConfig as exportUserConfigFile,
getAppVersion,
getHomeMetricsSummary,
getModelAdapterTestResults,
installReadyUpdate,
getProxyState,
importUserConfig as importUserConfigFile,
openConfigWindow as openConfig,
loadUserConfig,
openLogsDirectory,
@@ -607,6 +609,9 @@ async function loadPersistedUserConfig() {
}
async function persistConfigPayload(config, { modelAdaptersOnly = false } = {}) {
if (appState.configSaving) {
return { ok: false, error: "已有配置操作正在进行,请稍后再试" };
}
const payload = buildConfigPayload(config);
const validationError = validateModelAdapters(payload.modelAdapters);
if (validationError) {
@@ -1119,6 +1124,27 @@ export async function persistUserConfig() {
});
}
export async function exportUserConfigToFile(path) {
return exportUserConfigFile(path);
}
export async function importUserConfigFromFile(path) {
if (appState.serviceRunning || appState.backendRunning || appState.proxyRunning) {
throw new Error("服务运行中不能导入完整配置,请先停止服务");
}
if (appState.configSaving) {
throw new Error("已有配置操作正在进行,请稍后再试");
}
appState.configSaving = true;
try {
const imported = normalizeConfig(await importUserConfigFile(path));
applyConfigToState(imported);
return imported;
} finally {
appState.configSaving = false;
}
}
export async function saveIncludeCacheWriteInHitRate(value) {
const currentConfig = await loadPersistedUserConfig();
const previousValue = appState.includeCacheWriteInHitRate;
+23 -2
View File
@@ -5,6 +5,7 @@ import ContentModal from "@/components/ui/ContentModal.vue";
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue";
import ModelEditor from "@/components/ModelEditor.vue";
import { useMessage } from "@/composables/useMessage";
import { useConfigTransfer } from "@/composables/useConfigTransfer";
import Sortable from "sortablejs";
import {
appState,
@@ -87,6 +88,12 @@ function showActionError(title, error) {
message(`${title}${detail}`);
}
const {
configTransferBusy,
handleExportConfig,
handleImportConfig,
} = useConfigTransfer({ message, showActionError });
function maskSecret(value) {
const text = String(value || "").trim();
if (!text) {
@@ -392,12 +399,26 @@ onBeforeUnmount(() => {
<div class="center-row gap-2">
<Button
variant="default"
:disabled="sortSaving || appState.configSaving || (!batchTesting && filteredAdapters.length === 0)"
:disabled="sortSaving || appState.configSaving || batchTesting || configTransferBusy || appState.serviceRunning || appState.backendRunning || appState.proxyRunning"
@click="handleImportConfig"
>
导入配置
</Button>
<Button
variant="default"
:disabled="sortSaving || appState.configSaving || batchTesting || configTransferBusy"
@click="handleExportConfig"
>
导出配置
</Button>
<Button
variant="default"
:disabled="sortSaving || appState.configSaving || configTransferBusy || (!batchTesting && filteredAdapters.length === 0)"
@click="handleTestAllModelAdapters"
>
{{ batchButtonText }}
</Button>
<Button variant="primary" :disabled="sortSaving || appState.configSaving || batchTesting" @click="openEditor()">新增模型</Button>
<Button variant="primary" :disabled="sortSaving || appState.configSaving || batchTesting || configTransferBusy" @click="openEditor()">新增模型</Button>
</div>
</div>
</div>
+24
View File
@@ -1956,6 +1956,7 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
instructionParts := make([]string, 0, 2)
items := make([]map[string]any, 0, len(messages))
responsesCallIDs := make(map[string]string)
emittedCallIDs := make(map[string]struct{})
activeAssistantReasoningKey := ""
for _, message := range messages {
role := strings.TrimSpace(message.Role)
@@ -1968,6 +1969,26 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
}
if role == "tool" && strings.TrimSpace(message.ToolCallID) != "" {
callID := openAIResponsesToolMessageCallID(message, responsesCallIDs)
if strings.TrimSpace(callID) != "" {
if _, ok := emittedCallIDs[callID]; !ok {
// 历史损坏时可能出现没有配对 function_call 的工具结果
// (例如旧版本回放逻辑剥离了 assistant 调用但保留了结果)。
// Responses API 会直接拒绝这种 input,这里补一个占位 function_call
// 让旧会话可以继续;无法补齐时丢弃该结果。
if name := strings.TrimSpace(message.Name); name != "" {
items = append(items, map[string]any{
"type": "function_call",
"call_id": callID,
"name": name,
"arguments": "{}",
"status": "completed",
})
emittedCallIDs[callID] = struct{}{}
} else {
continue
}
}
}
var output any = openAIResponsesMessageText(message)
if hasImageContentParts(message.ContentParts) {
content, err := openAIResponsesMessageContent(message, false)
@@ -2034,6 +2055,9 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
toolItem["status"] = "completed"
}
items = append(items, toolItem)
if strings.TrimSpace(callID) != "" {
emittedCallIDs[strings.TrimSpace(callID)] = struct{}{}
}
}
}
}
@@ -0,0 +1,96 @@
package modeladapter
import "testing"
// 历史损坏时(旧版本回放逻辑剥离了 assistant 调用但保留了结果),
// function_call_output 会缺少配对的 function_callResponses API 会拒绝。
// 这里验证 adapter 会为孤儿结果补一个占位 function_call,让旧会话可以继续。
func TestNormalizeOpenAIResponsesInputSynthesizesCallForOrphanToolOutput(t *testing.T) {
messages := []Message{
{Role: "user", Content: "query"},
{Role: "assistant", Content: "我先快速定位上下文"},
{Role: "tool", Name: "Grep", ToolCallID: "call_xrN6", Content: "grep result"},
{Role: "user", Content: "next"},
}
_, items, err := normalizeOpenAIResponsesInput(messages)
if err != nil {
t.Fatalf("normalizeOpenAIResponsesInput failed: %v", err)
}
var callIndexes []int
var outputIndexes []int
for index, item := range items {
if item["type"] == "function_call" && item["call_id"] == "call_xrN6" {
callIndexes = append(callIndexes, index)
}
if item["type"] == "function_call_output" && item["call_id"] == "call_xrN6" {
outputIndexes = append(outputIndexes, index)
}
}
if len(callIndexes) != 1 {
t.Fatalf("expected 1 synthesized function_call, got %d: %+v", len(callIndexes), items)
}
if len(outputIndexes) != 1 || outputIndexes[0] != callIndexes[0]+1 {
t.Fatalf("expected function_call_output right after synthesized function_call, calls=%v outputs=%v", callIndexes, outputIndexes)
}
if got := items[callIndexes[0]]["name"]; got != "Grep" {
t.Fatalf("expected synthesized function_call name Grep, got %v", got)
}
}
// 连工具名都没有的孤儿结果只能丢弃,避免上游 400。
func TestNormalizeOpenAIResponsesInputDropsNamelessOrphanToolOutput(t *testing.T) {
messages := []Message{
{Role: "user", Content: "query"},
{Role: "tool", ToolCallID: "call_unknown", Content: "orphan result"},
{Role: "user", Content: "next"},
}
_, items, err := normalizeOpenAIResponsesInput(messages)
if err != nil {
t.Fatalf("normalizeOpenAIResponsesInput failed: %v", err)
}
for _, item := range items {
if item["type"] == "function_call_output" {
t.Fatalf("expected orphan function_call_output to be dropped, got %+v", item)
}
}
}
// 正常配对的调用与结果不应受防御逻辑影响。
func TestNormalizeOpenAIResponsesInputKeepsPairedCallAndOutput(t *testing.T) {
messages := []Message{
{Role: "user", Content: "query"},
{
Role: "assistant",
ToolCalls: []ToolCallDescriptor{{
ID: "call_xrN6",
Type: "function",
Function: ToolCallFunctionShape{
Name: "Grep",
Arguments: `{"pattern":"ref"}`,
},
}},
},
{Role: "tool", Name: "Grep", ToolCallID: "call_xrN6", Content: "grep result"},
{Role: "user", Content: "next"},
}
_, items, err := normalizeOpenAIResponsesInput(messages)
if err != nil {
t.Fatalf("normalizeOpenAIResponsesInput failed: %v", err)
}
var calls, outputs int
for _, item := range items {
if item["type"] == "function_call" && item["call_id"] == "call_xrN6" {
calls++
}
if item["type"] == "function_call_output" && item["call_id"] == "call_xrN6" {
outputs++
}
}
if calls != 1 || outputs != 1 {
t.Fatalf("expected exactly one paired function_call/output, got calls=%d outputs=%d", calls, outputs)
}
}
+57 -32
View File
@@ -339,57 +339,82 @@ func mergeProviderReasoningMetadata(last *Message, current Message) {
}
}
// providerToolResponseWindowEnd 返回 assistant tool-call 消息的响应收集窗口右边界(不含)。
// 窗口内除 tool 结果消息外,还允许出现同轮穿插的纯文本 assistant 消息:
// 部分模型(如 gpt-5.3-codex-spark)会在同一条响应里先输出 function_call 再输出说明文本,
// 回放顺序为 assistant[tool_call] -> assistant[text] -> tool[result],若只收集紧邻的
// tool 消息,会把有结果回放的调用误判为悬空。
func providerToolResponseWindowEnd(messages []Message, index int) int {
end := index + 1
for end < len(messages) {
candidate := messages[end]
switch {
case strings.TrimSpace(candidate.Role) == "tool":
end++
case strings.TrimSpace(candidate.Role) == "assistant" && len(candidate.ToolCalls) == 0:
end++
default:
return end
}
}
return end
}
func trimDanglingAssistantToolCalls(input []Message) []Message {
if len(input) == 0 {
return nil
}
trimmed := make([]Message, 0, len(input))
for index := 0; index < len(input); index++ {
message := cloneProviderMessage(input[index])
survivingToolCallIDs := make(map[string]struct{})
for index, message := range input {
if strings.TrimSpace(message.Role) != "assistant" || len(message.ToolCalls) == 0 {
trimmed = append(trimmed, message)
continue
}
end := index + 1
responded := make(map[string]struct{}, len(message.ToolCalls))
for end < len(input) && strings.TrimSpace(input[end].Role) == "tool" {
toolCallID := strings.TrimSpace(input[end].ToolCallID)
if toolCallID != "" {
for scan := index + 1; scan < providerToolResponseWindowEnd(input, index); scan++ {
if strings.TrimSpace(input[scan].Role) != "tool" {
continue
}
if toolCallID := strings.TrimSpace(input[scan].ToolCallID); toolCallID != "" {
responded[toolCallID] = struct{}{}
}
end++
}
nextToolCalls := make([]ToolCallDescriptor, 0, len(message.ToolCalls))
allowedToolCallIDs := make(map[string]struct{}, len(message.ToolCalls))
for _, toolCall := range message.ToolCalls {
toolCallID := strings.TrimSpace(toolCall.ID)
if _, ok := responded[toolCallID]; !ok {
if toolCallID := strings.TrimSpace(toolCall.ID); toolCallID != "" {
if _, ok := responded[toolCallID]; ok {
survivingToolCallIDs[toolCallID] = struct{}{}
}
}
}
}
trimmed := make([]Message, 0, len(input))
for _, item := range input {
message := cloneProviderMessage(item)
if strings.TrimSpace(message.Role) == "assistant" && len(message.ToolCalls) > 0 {
nextToolCalls := make([]ToolCallDescriptor, 0, len(message.ToolCalls))
for _, toolCall := range message.ToolCalls {
if _, ok := survivingToolCallIDs[strings.TrimSpace(toolCall.ID)]; !ok {
continue
}
item := toolCall
item.Index = len(nextToolCalls)
nextToolCalls = append(nextToolCalls, item)
allowedToolCallIDs[toolCallID] = struct{}{}
toolCall.Index = len(nextToolCalls)
nextToolCalls = append(nextToolCalls, toolCall)
}
if len(nextToolCalls) > 0 {
message.ToolCalls = nextToolCalls
trimmed = append(trimmed, message)
for toolIndex := index + 1; toolIndex < end; toolIndex++ {
toolMessage := cloneProviderMessage(input[toolIndex])
if _, ok := allowedToolCallIDs[strings.TrimSpace(toolMessage.ToolCallID)]; !ok {
if len(nextToolCalls) == 0 {
if strings.TrimSpace(message.Content) == "" && len(message.ContentParts) == 0 && strings.TrimSpace(message.ReasoningContent) == "" {
continue
}
trimmed = append(trimmed, toolMessage)
}
} else if strings.TrimSpace(message.Content) != "" || len(message.ContentParts) > 0 || strings.TrimSpace(message.ReasoningContent) != "" {
message.ToolCalls = nil
trimmed = append(trimmed, message)
} else {
message.ToolCalls = nextToolCalls
}
index = end - 1
trimmed = append(trimmed, message)
continue
}
if strings.TrimSpace(message.Role) == "tool" && strings.TrimSpace(message.ToolCallID) != "" {
if _, ok := survivingToolCallIDs[strings.TrimSpace(message.ToolCallID)]; !ok {
continue
}
}
trimmed = append(trimmed, message)
}
return trimmed
}
@@ -0,0 +1,77 @@
package modeladapter
import "testing"
func routerToolCall(id string, name string) ToolCallDescriptor {
return ToolCallDescriptor{
ID: id,
Type: "function",
Function: ToolCallFunctionShape{
Name: name,
Arguments: `{"pattern":"ref"}`,
},
}
}
// 模型在同一条响应里先输出 function_call 再输出说明文本时,
// sanitize 后的消息序列为 assistant[tool_call] -> assistant[text] -> tool[result]。
// trimDanglingAssistantToolCalls 需要越过中间的文本消息收集工具结果,
// 否则会产生孤儿 function_call_outputResponses API 400)。
func TestTrimDanglingAssistantToolCallsKeepsInterleavedTextResponses(t *testing.T) {
input := []Message{
{Role: "user", Content: "query"},
{
Role: "assistant",
ToolCalls: []ToolCallDescriptor{
routerToolCall("call_1", "Grep"),
routerToolCall("call_2", "Read"),
},
},
{Role: "tool", Name: "Grep", ToolCallID: "call_1", Content: "grep result"},
{Role: "assistant", Content: "我先快速定位上下文"},
{Role: "tool", Name: "Read", ToolCallID: "call_2", Content: "read result"},
{Role: "user", Content: "next"},
}
trimmed := sanitizeProviderMessages(input)
if len(trimmed) != 6 {
t.Fatalf("expected 6 messages, got %d: %+v", len(trimmed), trimmed)
}
if len(trimmed[1].ToolCalls) != 2 {
t.Fatalf("expected both tool calls to survive, got %+v", trimmed[1].ToolCalls)
}
if trimmed[3].Role != "assistant" || trimmed[3].Content == "" {
t.Fatalf("expected interleaved assistant text to survive, got %+v", trimmed[3])
}
if trimmed[4].ToolCallID != "call_2" {
t.Fatalf("expected call_2 result to survive, got %+v", trimmed[4])
}
}
// 完全没有结果回放的调用仍应被剥离,且孤儿 tool 结果不得保留。
func TestTrimDanglingAssistantToolCallsDropsUnrespondedCallsAndOrphanResults(t *testing.T) {
input := []Message{
{Role: "user", Content: "query"},
{
Role: "assistant",
ToolCalls: []ToolCallDescriptor{
routerToolCall("call_1", "Grep"),
routerToolCall("call_2", "Read"),
},
},
{Role: "tool", Name: "Grep", ToolCallID: "call_1", Content: "grep result"},
{Role: "tool", Name: "Read", ToolCallID: "call_3", Content: "orphan result"},
{Role: "user", Content: "next"},
}
trimmed := sanitizeProviderMessages(input)
if len(trimmed) != 4 {
t.Fatalf("expected 4 messages, got %d: %+v", len(trimmed), trimmed)
}
if len(trimmed[1].ToolCalls) != 1 || trimmed[1].ToolCalls[0].ID != "call_1" {
t.Fatalf("expected only call_1 to survive, got %+v", trimmed[1].ToolCalls)
}
if trimmed[2].ToolCallID != "call_1" {
t.Fatalf("expected only call_1 result to survive, got %+v", trimmed[2])
}
}
@@ -31,12 +31,14 @@ func TestToolImageProviderEncodings(t *testing.T) {
if err != nil {
t.Fatalf("normalizeOpenAIResponsesInput() error = %v", err)
}
if len(items) != 1 || items[0]["type"] != "function_call_output" {
// 孤儿 tool 结果(无前置 assistant 调用)会补一个占位 function_call
// 保证每个 function_call_output 都有配对调用。
if len(items) != 2 || items[0]["type"] != "function_call" || items[0]["call_id"] != "call-1" || items[1]["type"] != "function_call_output" {
t.Fatalf("openai responses items = %#v", items)
}
content, ok := items[0]["output"].([]map[string]any)
content, ok := items[1]["output"].([]map[string]any)
if !ok || len(content) != 2 {
t.Fatalf("openai responses output = %#v", items[0]["output"])
t.Fatalf("openai responses output = %#v", items[1]["output"])
}
if content[0]["type"] != "input_text" || content[1]["type"] != "input_image" {
t.Fatalf("openai responses content = %#v", content)
+57 -32
View File
@@ -1196,57 +1196,82 @@ func mergeReplayReasoningMetadata(last *modeladapter.Message, current modeladapt
}
}
// replayToolResponseWindowEnd 返回 assistant tool-call 消息的响应收集窗口右边界(不含)。
// 窗口内除了 tool 结果消息,还允许出现同轮穿插的纯文本 assistant 消息:
// 部分模型(如 gpt-5.3-codex-spark)会在同一条响应里先输出 function_call 再输出说明文本,
// 落盘顺序为 tool_call → assistant_text → tool_result,若只收集紧邻的 tool 消息,
// 会把有结果回放的调用误判为悬空。
func replayToolResponseWindowEnd(messages []modeladapter.Message, index int) int {
end := index + 1
for end < len(messages) {
candidate := messages[end]
switch {
case strings.TrimSpace(candidate.Role) == "tool":
end++
case strings.TrimSpace(candidate.Role) == "assistant" && len(candidate.ToolCalls) == 0:
end++
default:
return end
}
}
return end
}
func trimReplayDanglingAssistantToolCalls(messages []modeladapter.Message) []modeladapter.Message {
if len(messages) == 0 {
return nil
}
trimmed := make([]modeladapter.Message, 0, len(messages))
for index := 0; index < len(messages); index++ {
message := cloneReplayModelMessage(messages[index])
survivingToolCallIDs := make(map[string]struct{})
for index, message := range messages {
if strings.TrimSpace(message.Role) != "assistant" || len(message.ToolCalls) == 0 {
trimmed = append(trimmed, message)
continue
}
end := index + 1
responded := make(map[string]struct{}, len(message.ToolCalls))
for end < len(messages) && strings.TrimSpace(messages[end].Role) == "tool" {
toolCallID := strings.TrimSpace(messages[end].ToolCallID)
if toolCallID != "" {
for scan := index + 1; scan < replayToolResponseWindowEnd(messages, index); scan++ {
if strings.TrimSpace(messages[scan].Role) != "tool" {
continue
}
if toolCallID := strings.TrimSpace(messages[scan].ToolCallID); toolCallID != "" {
responded[toolCallID] = struct{}{}
}
end++
}
nextToolCalls := make([]modeladapter.ToolCallDescriptor, 0, len(message.ToolCalls))
allowedToolCallIDs := make(map[string]struct{}, len(message.ToolCalls))
for _, toolCall := range message.ToolCalls {
toolCallID := strings.TrimSpace(toolCall.ID)
if _, ok := responded[toolCallID]; !ok {
if toolCallID := strings.TrimSpace(toolCall.ID); toolCallID != "" {
if _, ok := responded[toolCallID]; ok {
survivingToolCallIDs[toolCallID] = struct{}{}
}
}
}
}
trimmed := make([]modeladapter.Message, 0, len(messages))
for _, item := range messages {
message := cloneReplayModelMessage(item)
if strings.TrimSpace(message.Role) == "assistant" && len(message.ToolCalls) > 0 {
nextToolCalls := make([]modeladapter.ToolCallDescriptor, 0, len(message.ToolCalls))
for _, toolCall := range message.ToolCalls {
if _, ok := survivingToolCallIDs[strings.TrimSpace(toolCall.ID)]; !ok {
continue
}
item := toolCall
item.Index = len(nextToolCalls)
nextToolCalls = append(nextToolCalls, item)
allowedToolCallIDs[toolCallID] = struct{}{}
toolCall.Index = len(nextToolCalls)
nextToolCalls = append(nextToolCalls, toolCall)
}
if len(nextToolCalls) > 0 {
message.ToolCalls = nextToolCalls
trimmed = append(trimmed, message)
for toolIndex := index + 1; toolIndex < end; toolIndex++ {
toolMessage := cloneReplayModelMessage(messages[toolIndex])
if _, ok := allowedToolCallIDs[strings.TrimSpace(toolMessage.ToolCallID)]; !ok {
if len(nextToolCalls) == 0 {
if strings.TrimSpace(message.Content) == "" && len(message.ContentParts) == 0 && !hasReplayableReasoningPayload(message.ReasoningContent, message.ReasoningSignature, message.ReasoningSignatureSource) {
continue
}
trimmed = append(trimmed, toolMessage)
}
} else if strings.TrimSpace(message.Content) != "" || len(message.ContentParts) > 0 || hasReplayableReasoningPayload(message.ReasoningContent, message.ReasoningSignature, message.ReasoningSignatureSource) {
message.ToolCalls = nil
trimmed = append(trimmed, message)
} else {
message.ToolCalls = nextToolCalls
}
index = end - 1
trimmed = append(trimmed, message)
continue
}
if strings.TrimSpace(message.Role) == "tool" && strings.TrimSpace(message.ToolCallID) != "" {
if _, ok := survivingToolCallIDs[strings.TrimSpace(message.ToolCallID)]; !ok {
continue
}
}
trimmed = append(trimmed, message)
}
return trimmed
}
@@ -0,0 +1,89 @@
package forwarder
import (
"testing"
modeladapter "cursor/internal/backend/agent/model"
)
func replayToolCall(id string, name string) modeladapter.ToolCallDescriptor {
return modeladapter.ToolCallDescriptor{
ID: id,
Type: "function",
Function: modeladapter.ToolCallFunctionShape{
Name: name,
Arguments: "{}",
},
}
}
// 模型在同一条响应里先输出 function_call 再输出说明文本时,
// 历史回放顺序为 assistant[tool_call] -> assistant[text] -> tool[result]。
// trimReplayDanglingAssistantToolCalls 需要越过中间的文本消息收集工具结果。
func TestTrimReplayDanglingAssistantToolCallsKeepsInterleavedTextResponses(t *testing.T) {
messages := []modeladapter.Message{
{Role: "user", Content: "query"},
{Role: "assistant", ToolCalls: []modeladapter.ToolCallDescriptor{replayToolCall("call_1", "Grep")}},
{Role: "assistant", Content: "我先快速定位上下文"},
{Role: "tool", Name: "Grep", ToolCallID: "call_1", Content: "grep result"},
{Role: "user", Content: "next"},
}
trimmed := trimReplayDanglingAssistantToolCalls(messages)
if len(trimmed) != 5 {
t.Fatalf("expected 5 messages, got %d: %+v", len(trimmed), trimmed)
}
if len(trimmed[1].ToolCalls) != 1 || trimmed[1].ToolCalls[0].ID != "call_1" {
t.Fatalf("expected assistant tool call call_1 to survive, got %+v", trimmed[1].ToolCalls)
}
if trimmed[3].Role != "tool" || trimmed[3].ToolCallID != "call_1" {
t.Fatalf("expected tool result call_1 to survive, got %+v", trimmed[3])
}
}
// 没有任何结果回放的调用仍应被剥离;被剥离调用对应的 tool 结果(若存在)也不得保留。
func TestTrimReplayDanglingAssistantToolCallsDropsUnrespondedCallsAndOrphanResults(t *testing.T) {
messages := []modeladapter.Message{
{Role: "user", Content: "query"},
{Role: "assistant", ToolCalls: []modeladapter.ToolCallDescriptor{
replayToolCall("call_1", "Grep"),
replayToolCall("call_2", "Read"),
}},
{Role: "tool", Name: "Grep", ToolCallID: "call_1", Content: "grep result"},
{Role: "tool", Name: "Read", ToolCallID: "call_3", Content: "orphan result"},
{Role: "user", Content: "next"},
}
trimmed := trimReplayDanglingAssistantToolCalls(messages)
if len(trimmed) != 4 {
t.Fatalf("expected 4 messages, got %d: %+v", len(trimmed), trimmed)
}
if len(trimmed[1].ToolCalls) != 1 || trimmed[1].ToolCalls[0].ID != "call_1" {
t.Fatalf("expected only call_1 to survive, got %+v", trimmed[1].ToolCalls)
}
if trimmed[2].ToolCallID != "call_1" {
t.Fatalf("expected only call_1 result to survive, got %+v", trimmed[2])
}
}
// 调用全部悬空但消息携带可回放 reasoning 时,保留为无调用的 assistant 消息。
func TestTrimReplayDanglingAssistantToolCallsKeepsReasoningOnlyShell(t *testing.T) {
messages := []modeladapter.Message{
{Role: "user", Content: "query"},
{
Role: "assistant",
ToolCalls: []modeladapter.ToolCallDescriptor{replayToolCall("call_1", "Grep")},
ReasoningSignature: "sig",
ReasoningSignatureSource: modeladapter.ReasoningSignatureSourceOpenAIResponses,
},
{Role: "user", Content: "next"},
}
trimmed := trimReplayDanglingAssistantToolCalls(messages)
if len(trimmed) != 3 {
t.Fatalf("expected 3 messages, got %d: %+v", len(trimmed), trimmed)
}
if len(trimmed[1].ToolCalls) != 0 {
t.Fatalf("expected tool calls to be trimmed, got %+v", trimmed[1].ToolCalls)
}
}
+14
View File
@@ -691,6 +691,20 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
return nil
}),
),
// The always-local extension probes NetworkService/IsConnected roughly 10s
// after any slow request starts. A 404 here is treated as "network
// disconnected" and aborts in-flight work (e.g. commit message
// generation) even while the model is still streaming. Always answer OK.
server.POST("/aiserver.v1.NetworkService/IsConnected",
server.Name("network_is_connected"),
server.ConnectUnary(),
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
Name: "network_is_connected",
StatusCode: http.StatusOK,
MockProtoType: "aiserver.v1.IsConnectedResponse",
MockBuilder: upstream.EmptyMockBuilder,
})),
),
server.Any("/aiserver.v1.NetworkService/*",
server.Name("network_service"),
server.HTTP(),
@@ -451,6 +451,8 @@ func newProtoMessage(typeName string) (proto.Message, error) {
return &aiserverv1.SubmitLogsResponse{}, nil
case "aiserver.v1.TrackEventsResponse":
return &aiserverv1.TrackEventsResponse{}, nil
case "aiserver.v1.IsConnectedResponse":
return &aiserverv1.IsConnectedResponse{}, nil
default:
return nil, fmt.Errorf("unsupported proto message type %q", typeName)
}
+10
View File
@@ -100,6 +100,16 @@ func (s *ProxyService) SaveUserConfig(cfg UserConfig) error {
return s.core.SaveUserConfig(cfg)
}
// ExportUserConfig 将当前完整配置导出为 YAML 文件。
func (s *ProxyService) ExportUserConfig(path string) (string, error) {
return s.core.ExportUserConfig(path)
}
// ImportUserConfig 从 YAML 文件校验并替换当前完整配置。
func (s *ProxyService) ImportUserConfig(path string) (UserConfig, error) {
return s.core.ImportUserConfig(path)
}
// GetCursorAccountStatus 返回 cursor-byok 独立 Cursor 账号的脱敏状态。
func (s *ProxyService) GetCursorAccountStatus() CursorAccountStatus {
return s.core.GetCursorAccountStatus()
+12
View File
@@ -17,6 +17,12 @@ func (s *ProxyService) LoadUserConfig() (UserConfig, error) {
if s == nil {
return serverconfig.DefaultConfig(), nil
}
s.configMu.Lock()
defer s.configMu.Unlock()
return s.loadUserConfig()
}
func (s *ProxyService) loadUserConfig() (UserConfig, error) {
app := application.Get()
ctx := context.Background()
if app != nil {
@@ -36,6 +42,12 @@ func (s *ProxyService) SaveUserConfig(cfg UserConfig) error {
if s == nil {
return nil
}
s.configMu.Lock()
defer s.configMu.Unlock()
return s.saveUserConfig(cfg)
}
func (s *ProxyService) saveUserConfig(cfg UserConfig) error {
app := application.Get()
ctx := context.Background()
if app != nil {
+195
View File
@@ -0,0 +1,195 @@
package client
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
serverconfig "cursor/internal/backend/server/config"
"gopkg.in/yaml.v3"
)
const maxConfigTransferFileSize = 4 << 20
type importedUserConfigDocument struct {
serverconfig.Config `yaml:",inline"`
LegacyRouting any `yaml:"routing,omitempty"`
}
// ExportUserConfig 将当前完整配置导出为 YAML 文件。
func (s *ProxyService) ExportUserConfig(path string) (string, error) {
if s == nil {
return "", errors.New("配置服务未初始化")
}
targetPath := normalizeConfigExportPath(path)
if targetPath == "" {
return "", errors.New("导出路径不能为空")
}
s.configMu.Lock()
defer s.configMu.Unlock()
cfg, err := s.loadUserConfig()
if err != nil {
return "", fmt.Errorf("读取当前配置失败: %w", err)
}
data, err := yaml.Marshal(cfg)
if err != nil {
return "", fmt.Errorf("序列化导出配置失败: %w", err)
}
if err := writeExportedUserConfig(targetPath, data); err != nil {
return "", err
}
return targetPath, nil
}
// ImportUserConfig 从 YAML 文件校验并替换当前完整配置。
func (s *ProxyService) ImportUserConfig(path string) (UserConfig, error) {
if s == nil {
return serverconfig.Config{}, errors.New("配置服务未初始化")
}
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
s.configMu.Lock()
defer s.configMu.Unlock()
if err := ensureConfigImportAllowed(s.GetState()); err != nil {
return serverconfig.Config{}, err
}
data, err := readImportedUserConfig(path)
if err != nil {
return serverconfig.Config{}, err
}
cfg, err := decodeImportedUserConfig(data)
if err != nil {
return serverconfig.Config{}, err
}
if err := s.saveUserConfig(cfg); err != nil {
return serverconfig.Config{}, fmt.Errorf("保存导入配置失败: %w", err)
}
persisted, err := s.loadUserConfig()
if err != nil {
return serverconfig.Config{}, fmt.Errorf("重新读取导入配置失败: %w", err)
}
return persisted, nil
}
func normalizeConfigExportPath(path string) string {
trimmed := strings.TrimSpace(path)
if trimmed == "" {
return ""
}
extension := strings.ToLower(filepath.Ext(trimmed))
if extension != ".yaml" && extension != ".yml" {
trimmed += ".yaml"
}
return filepath.Clean(trimmed)
}
func writeExportedUserConfig(path string, data []byte) error {
directory := filepath.Dir(path)
file, err := os.CreateTemp(directory, ".cursor-byok-config-*.tmp")
if err != nil {
return fmt.Errorf("创建导出配置临时文件失败: %w", err)
}
temporaryPath := file.Name()
defer os.Remove(temporaryPath)
if err := file.Chmod(0o600); err != nil {
_ = file.Close()
return fmt.Errorf("设置导出配置权限失败: %w", err)
}
if _, err := file.Write(data); err != nil {
_ = file.Close()
return fmt.Errorf("写入导出配置失败: %w", err)
}
if err := file.Sync(); err != nil {
_ = file.Close()
return fmt.Errorf("同步导出配置失败: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("关闭导出配置失败: %w", err)
}
if err := replaceExportFile(temporaryPath, path); err != nil {
return fmt.Errorf("替换导出配置失败: %w", err)
}
return nil
}
func ensureConfigImportAllowed(state ProxyState) error {
if state.BackendRunning || state.ProxyRunning || state.Running {
return errors.New("服务运行中不能导入完整配置,请先停止服务")
}
return nil
}
func readImportedUserConfig(path string) ([]byte, error) {
sourcePath := strings.TrimSpace(path)
if sourcePath == "" {
return nil, errors.New("导入路径不能为空")
}
file, err := os.Open(sourcePath)
if err != nil {
return nil, fmt.Errorf("打开导入配置失败: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("读取导入配置信息失败: %w", err)
}
if !info.Mode().IsRegular() {
return nil, errors.New("导入配置必须是普通文件")
}
if info.Size() > maxConfigTransferFileSize {
return nil, fmt.Errorf("导入配置不能超过 %d MiB", maxConfigTransferFileSize>>20)
}
data, err := io.ReadAll(io.LimitReader(file, maxConfigTransferFileSize+1))
if err != nil {
return nil, fmt.Errorf("读取导入配置失败: %w", err)
}
if len(data) > maxConfigTransferFileSize {
return nil, fmt.Errorf("导入配置不能超过 %d MiB", maxConfigTransferFileSize>>20)
}
return data, nil
}
func decodeImportedUserConfig(data []byte) (serverconfig.Config, error) {
if err := validateImportedUserConfigDocument(data); err != nil {
return serverconfig.Config{}, err
}
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
var document importedUserConfigDocument
if err := decoder.Decode(&document); err != nil {
return serverconfig.Config{}, fmt.Errorf("导入配置包含未知字段或无效 YAML: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err == nil {
return serverconfig.Config{}, errors.New("导入配置只能包含单个 YAML 文档")
} else if !errors.Is(err, io.EOF) {
return serverconfig.Config{}, fmt.Errorf("解析导入配置尾部失败: %w", err)
}
normalized, err := serverconfig.NormalizeConfig(document.Config)
if err != nil {
return serverconfig.Config{}, fmt.Errorf("导入配置校验失败: %w", err)
}
return normalized, nil
}
func validateImportedUserConfigDocument(data []byte) error {
decoder := yaml.NewDecoder(bytes.NewReader(data))
var document yaml.Node
if err := decoder.Decode(&document); err != nil {
if errors.Is(err, io.EOF) {
return errors.New("导入配置不能为空")
}
return fmt.Errorf("导入配置不是有效 YAML: %w", err)
}
if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
return errors.New("导入配置顶层必须是 YAML 对象")
}
return nil
}
@@ -0,0 +1,9 @@
//go:build !windows
package client
import "os"
func replaceExportFile(sourcePath, targetPath string) error {
return os.Rename(sourcePath, targetPath)
}
@@ -0,0 +1,21 @@
//go:build windows
package client
import "golang.org/x/sys/windows"
func replaceExportFile(sourcePath, targetPath string) error {
source, err := windows.UTF16PtrFromString(sourcePath)
if err != nil {
return err
}
target, err := windows.UTF16PtrFromString(targetPath)
if err != nil {
return err
}
return windows.MoveFileEx(
source,
target,
windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH,
)
}
+245
View File
@@ -0,0 +1,245 @@
package client
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
serverconfig "cursor/internal/backend/server/config"
"gopkg.in/yaml.v3"
)
func TestExportAndImportUserConfigRoundTrip(t *testing.T) {
source := newConfigTransferTestService(t)
want := serverconfig.DefaultConfig()
want.Log = true
want.ModelAdapters = []serverconfig.ModelAdapterConfig{{
DisplayName: "迁移模型",
Type: "openai",
BaseURL: "https://provider.example/v1",
APIKey: "migration-secret",
TooltipData: "迁移备注",
ModelID: "model-a",
ReasoningEffort: "medium",
OpenAIEndpoint: "/v1/responses",
}}
if err := source.SaveUserConfig(want); err != nil {
t.Fatalf("SaveUserConfig() error = %v", err)
}
exportPath, err := source.ExportUserConfig(filepath.Join(t.TempDir(), "cursor-byok-backup"))
if err != nil {
t.Fatalf("ExportUserConfig() error = %v", err)
}
if filepath.Ext(exportPath) != ".yaml" {
t.Fatalf("ExportUserConfig() path = %q, want .yaml extension", exportPath)
}
if runtime.GOOS != "windows" {
info, statErr := os.Stat(exportPath)
if statErr != nil {
t.Fatalf("Stat() error = %v", statErr)
}
if gotMode := info.Mode().Perm(); gotMode != 0o600 {
t.Fatalf("export mode = %o, want 600", gotMode)
}
}
target := newConfigTransferTestService(t)
got, err := target.ImportUserConfig(exportPath)
if err != nil {
t.Fatalf("ImportUserConfig() error = %v", err)
}
if !got.Log || len(got.ModelAdapters) != 1 {
t.Fatalf("ImportUserConfig() = %#v", got)
}
adapter := got.ModelAdapters[0]
if adapter.DisplayName != "迁移模型" || adapter.APIKey != "migration-secret" || adapter.ModelID != "model-a" {
t.Fatalf("imported adapter = %#v", adapter)
}
persisted, err := target.LoadUserConfig()
if err != nil {
t.Fatalf("LoadUserConfig() error = %v", err)
}
if len(persisted.ModelAdapters) != 1 || persisted.ModelAdapters[0].APIKey != "migration-secret" {
t.Fatalf("persisted config = %#v", persisted)
}
}
func TestWriteExportedUserConfigReplacesExistingFile(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "config.yaml")
if err := os.WriteFile(path, []byte("old"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := writeExportedUserConfig(path, []byte("new")); err != nil {
t.Fatalf("writeExportedUserConfig() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "new" {
t.Fatalf("exported content = %q, want new", data)
}
matches, err := filepath.Glob(filepath.Join(directory, ".cursor-byok-config-*.tmp"))
if err != nil || len(matches) != 0 {
t.Fatalf("temporary exports = %v, error = %v", matches, err)
}
}
func TestEnsureConfigImportAllowedRejectsRunningService(t *testing.T) {
for _, state := range []ProxyState{
{BackendRunning: true},
{ProxyRunning: true},
{Running: true},
} {
if err := ensureConfigImportAllowed(state); err == nil {
t.Fatalf("ensureConfigImportAllowed(%+v) error = nil", state)
}
}
if err := ensureConfigImportAllowed(ProxyState{}); err != nil {
t.Fatalf("ensureConfigImportAllowed(stopped) error = %v", err)
}
}
func TestImportUserConfigWaitsForLifecycleTransition(t *testing.T) {
service := newConfigTransferTestService(t)
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("modelAdapters: []\n"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
service.lifecycleMu.Lock()
done := make(chan error, 1)
go func() {
_, err := service.ImportUserConfig(path)
done <- err
}()
select {
case err := <-done:
service.lifecycleMu.Unlock()
t.Fatalf("ImportUserConfig() completed during lifecycle transition: %v", err)
case <-time.After(100 * time.Millisecond):
}
service.lifecycleMu.Unlock()
select {
case err := <-done:
if err != nil {
t.Fatalf("ImportUserConfig() error = %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("ImportUserConfig() did not resume after lifecycle transition")
}
}
func TestDecodeImportedUserConfigRejectsNonMappingDocuments(t *testing.T) {
for _, raw := range []string{"", "null\n", "[]\n", "value\n"} {
if _, err := decodeImportedUserConfig([]byte(raw)); err == nil {
t.Fatalf("decodeImportedUserConfig(%q) error = nil", raw)
}
}
}
func TestDecodeImportedUserConfigAcceptsEmptyReasoningEffort(t *testing.T) {
raw := []byte("modelAdapters:\n - displayName: model\n type: openai\n baseURL: https://example.com/v1\n apiKey: secret\n tooltipData: migrated model\n modelID: model-a\n reasoningEffort: ''\n openAIEndpoint: /v1/responses\n")
got, err := decodeImportedUserConfig(raw)
if err != nil {
t.Fatalf("decodeImportedUserConfig() error = %v", err)
}
if got.ModelAdapters[0].ReasoningEffort != "" {
t.Fatalf("reasoningEffort = %q, want empty", got.ModelAdapters[0].ReasoningEffort)
}
}
func TestImportUserConfigRejectsUnknownFieldsWithoutOverwriting(t *testing.T) {
service := newConfigTransferTestService(t)
current := serverconfig.DefaultConfig()
current.Log = true
if err := service.SaveUserConfig(current); err != nil {
t.Fatalf("SaveUserConfig() error = %v", err)
}
path := filepath.Join(t.TempDir(), "unknown.yaml")
if err := os.WriteFile(path, []byte("modelAdapters: []\nunknownSetting: true\n"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if _, err := service.ImportUserConfig(path); err == nil || !strings.Contains(err.Error(), "未知字段") {
t.Fatalf("ImportUserConfig() error = %v, want unknown field error", err)
}
persisted, err := service.LoadUserConfig()
if err != nil {
t.Fatalf("LoadUserConfig() error = %v", err)
}
if !persisted.Log {
t.Fatal("invalid import overwrote the existing config")
}
}
func TestImportUserConfigRejectsMultipleDocuments(t *testing.T) {
service := newConfigTransferTestService(t)
path := filepath.Join(t.TempDir(), "multiple.yaml")
content := "modelAdapters: []\n---\nmodelAdapters: []\n"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if _, err := service.ImportUserConfig(path); err == nil || !strings.Contains(err.Error(), "单个 YAML 文档") {
t.Fatalf("ImportUserConfig() error = %v, want multiple document error", err)
}
}
func TestImportUserConfigRejectsOversizedFile(t *testing.T) {
service := newConfigTransferTestService(t)
path := filepath.Join(t.TempDir(), "oversized.yaml")
content := make([]byte, maxConfigTransferFileSize+1)
if err := os.WriteFile(path, content, 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if _, err := service.ImportUserConfig(path); err == nil || !strings.Contains(err.Error(), "不能超过") {
t.Fatalf("ImportUserConfig() error = %v, want size limit error", err)
}
}
func TestDecodeImportedUserConfigNormalizesValues(t *testing.T) {
raw := []byte("backendListenAddr: ' 127.0.0.1:12345 '\nproxyListenAddr: '127.0.0.1:12346'\nmodelAdapters: []\n")
got, err := decodeImportedUserConfig(raw)
if err != nil {
t.Fatalf("decodeImportedUserConfig() error = %v", err)
}
if got.BackendListenAddr != "127.0.0.1:12345" || got.ProxyListenAddr != "127.0.0.1:12346" {
t.Fatalf("decodeImportedUserConfig() = %#v", got)
}
encoded, err := yaml.Marshal(got)
if err != nil {
t.Fatalf("yaml.Marshal() error = %v", err)
}
if !strings.Contains(string(encoded), "backendListenAddr: 127.0.0.1:12345") {
t.Fatalf("encoded config = %s", encoded)
}
}
func TestDecodeImportedUserConfigAcceptsLegacyRouting(t *testing.T) {
raw := []byte("modelAdapters: []\nrouting:\n strategy: legacy\n")
got, err := decodeImportedUserConfig(raw)
if err != nil {
t.Fatalf("decodeImportedUserConfig() error = %v", err)
}
if len(got.ModelAdapters) != 0 {
t.Fatalf("decodeImportedUserConfig() = %#v", got)
}
}
func newConfigTransferTestService(t *testing.T) *ProxyService {
t.Helper()
root := t.TempDir()
return &ProxyService{
store: serverconfig.NewStore(filepath.Join(root, "config.yaml"), filepath.Join(root, "logs")),
}
}
+4
View File
@@ -54,6 +54,8 @@ type ProxyState struct {
// StartProxy 用于处理与 StartProxy 相关的逻辑。
func (s *ProxyService) StartProxy() (ProxyState, error) {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
logger.Infof("start service requested config_path=%s logs_root=%s", s.configPath, s.logsRoot)
fail := func(step string, err error) (ProxyState, error) {
logger.Errorf("start service failed step=%s err=%v", step, err)
@@ -127,6 +129,8 @@ func (s *ProxyService) StartProxy() (ProxyState, error) {
// StopProxy 用于处理与 StopProxy 相关的逻辑。
func (s *ProxyService) StopProxy() (ProxyState, error) {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
logger.Infof("stop service requested")
fail := func(step string, err error) (ProxyState, error) {
logger.Errorf("stop service failed step=%s err=%v", step, err)
+2
View File
@@ -49,6 +49,8 @@ type ProxyService struct {
// configMu 表示当前声明中的 configMu。
configMu sync.Mutex
// lifecycleMu 串行化服务启停与完整配置导入,避免过渡状态下切换配置。
lifecycleMu sync.Mutex
// configPath 表示当前声明中的 configPath。
configPath string
// store 表示统一的配置存储。
+26 -11
View File
@@ -33,6 +33,14 @@ var cursorStateDisabledStatsigGates = []string{
"disable_terminal_output_ui_streaming",
}
// cursorStateEnabledStatsigGates forces gates to true. Disabling the network
// change monitor stops the always-local extension from probing
// NetworkService/IsConnected and aborting slow in-flight requests (such as
// commit message generation) with "Network disconnected".
var cursorStateEnabledStatsigGates = []string{
"disable_network_change_monitor_local",
}
// InjectCursorUserInfo synchronizes the Cursor user-level auth cache used by the
// Settings page. It does not modify the installed Cursor app bundle.
func InjectCursorUserInfo(email, token string) error {
@@ -50,12 +58,13 @@ func InjectCursorUserInfo(email, token string) error {
}
logger.Infof(
"injectCursorUserInfo synced path=%s email=%s membership=%s subscription=%s disabled_statsig_gates=%s",
"injectCursorUserInfo synced path=%s email=%s membership=%s subscription=%s disabled_statsig_gates=%s enabled_statsig_gates=%s",
stateDBPath,
values["cursorAuth/cachedEmail"],
values["cursorAuth/stripeMembershipType"],
values["cursorAuth/stripeSubscriptionStatus"],
strings.Join(cursorStateDisabledStatsigGates, ","),
strings.Join(cursorStateEnabledStatsigGates, ","),
)
return nil
}
@@ -120,7 +129,7 @@ func syncCursorAuthStateDB(path string, values map[string]string) error {
}
}
if err := disableCursorStatsigGates(ctx, tx); err != nil {
if err := syncCursorStatsigGateOverrides(ctx, tx); err != nil {
return err
}
@@ -131,7 +140,7 @@ func syncCursorAuthStateDB(path string, values map[string]string) error {
return nil
}
func disableCursorStatsigGates(ctx context.Context, tx *sql.Tx) error {
func syncCursorStatsigGateOverrides(ctx context.Context, tx *sql.Tx) error {
var raw []byte
err := tx.QueryRowContext(ctx, "SELECT value FROM ItemTable WHERE key = ?", cursorStateStatsigBootstrapKey).Scan(&raw)
if err != nil {
@@ -154,9 +163,15 @@ func disableCursorStatsigGates(ctx context.Context, tx *sql.Tx) error {
hashUsed, _ := payload["hash_used"].(string)
for _, gate := range cursorStateDisabledStatsigGates {
disableCursorStatsigGate(featureGates, gate)
setCursorStatsigGate(featureGates, gate, false, "local_disabled")
if strings.EqualFold(hashUsed, "djb2") {
disableCursorStatsigGate(featureGates, cursorStateDJB2Hash(gate))
setCursorStatsigGate(featureGates, cursorStateDJB2Hash(gate), false, "local_disabled")
}
}
for _, gate := range cursorStateEnabledStatsigGates {
setCursorStatsigGate(featureGates, gate, true, "local_enabled")
if strings.EqualFold(hashUsed, "djb2") {
setCursorStatsigGate(featureGates, cursorStateDJB2Hash(gate), true, "local_enabled")
}
}
@@ -170,21 +185,21 @@ func disableCursorStatsigGates(ctx context.Context, tx *sql.Tx) error {
return nil
}
func disableCursorStatsigGate(featureGates map[string]any, key string) {
func setCursorStatsigGate(featureGates map[string]any, key string, value bool, rule string) {
gate, _ := featureGates[key].(map[string]any)
if gate == nil {
gate = map[string]any{
"name": key,
"rule_id": "local_disabled",
"ruleID": "local_disabled",
"group_name": "local_disabled",
"groupName": "local_disabled",
"rule_id": rule,
"ruleID": rule,
"group_name": rule,
"groupName": rule,
"id_type": "userID",
"idType": "userID",
}
featureGates[key] = gate
}
gate["value"] = false
gate["value"] = value
}
func cursorStateDJB2Hash(value string) string {
+13
View File
@@ -17785,6 +17785,14 @@ message IsAllowedFreeTrialUsageResponse {
bool is_allowed = 1;
}
// Copied from: local:aiserver.v1.IsConnectedRequest
message IsConnectedRequest {
}
// Copied from: local:aiserver.v1.IsConnectedResponse
message IsConnectedResponse {
}
// Copied from: local:aiserver.v1.IsCursorPredictionEnabledRequest (var: An)
message IsCursorPredictionEnabledRequest {
}
@@ -36919,6 +36927,11 @@ service MetricsService {
rpc ReportGauge(ReportMetricsRequest) returns (ReportMetricsResponse) {}
}
// Source: aiserver.v1.NetworkService
service NetworkService {
rpc IsConnected(IsConnectedRequest) returns (IsConnectedResponse) {}
}
// Source: aiserver.v1.PerformanceEventService (var: i)
service PerformanceEventService {
rpc SubmitPerformanceEvents(SubmitPerformanceEventsRequest) returns (SubmitPerformanceEventsResponse) {}
+3 -6
View File
@@ -8,9 +8,6 @@ QQ交流群:
Tg群组:
https://t.me/cursor_byok
- 修复对话中断时回复内容丢失
- 重构检查点压缩,提升稳定性
- 修复OpenAI推理摘要显示
- 修复Anthropic思考块缺失
- 支持Shell工具流式输出
- 修复CLI模型名称显示
- 修复Read Image
- 支持不设置思考强度
- 修复证书安全问题,需要重启一下cursor生效