mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-18 03:57:06 +08:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e265d415e | ||
|
|
0d1cedda5b | ||
|
|
c7fe198110 | ||
|
|
8c4baf7ade | ||
|
|
4e0d2ee703 | ||
|
|
e1dacab268 | ||
|
|
320b9fb7b9 | ||
|
|
c55c575a58 | ||
|
|
d3adfffbd8 | ||
|
|
4e9335d82f | ||
|
|
374ff9c217 | ||
|
|
06ab0d8dae | ||
|
|
f5a7347f44 | ||
|
|
a9b406ec30 |
@@ -342,3 +342,15 @@ go run ./cmd/cursor-proxy-debugger
|
|||||||
- `PendingInteraction`
|
- `PendingInteraction`
|
||||||
- 同一 backend 进程内的 `RunSSE` 重连,要优先看 checkpoint / `pending_tool_calls` 里的 live pending
|
- 同一 backend 进程内的 `RunSSE` 重连,要优先看 checkpoint / `pending_tool_calls` 里的 live pending
|
||||||
- backend 重启后,不要把 checkpoint 当持久恢复点;跨轮承接与持久恢复只看 `history/<conversationId>/state.json` + `history/<conversationId>/context.json`
|
- backend 重启后,不要把 checkpoint 当持久恢复点;跨轮承接与持久恢复只看 `history/<conversationId>/state.json` + `history/<conversationId>/context.json`
|
||||||
|
|
||||||
|
### 5.1 checkpoint 投影必须幂等且只有一个事实源
|
||||||
|
|
||||||
|
- 把 checkpoint 当作 `state.json + context.json` 的纯投影,不要把它写成第二套语义历史。
|
||||||
|
- 不要创建或维护 `checkpoint.json`、checkpoint history、独立 checkpoint entry 序列等持久化事实源。
|
||||||
|
- 允许在当前 stream 内存中保留 latest checkpoint 供 retry/resume 使用;进程重启后必须能从唯一事实源重新投影。
|
||||||
|
- 对同一份 semantic history 重复投影时,要求 state、turn 顺序、blob ID 和 blob 内容在语义上完全一致;投影函数不得修改输入 history。
|
||||||
|
- 把重复发送视为同一快照的幂等覆盖,不要追加一条新的会话历史;内容寻址 blob 的重复写入必须可安全忽略。
|
||||||
|
- 将 `turns` 投影为 UI 可恢复的完整结构,保留所有需要展示的 `ThinkingMessage`、`ToolCall` 和工具结果;不要为了模型 prompt 过滤而删除 UI step。
|
||||||
|
- 将 `root_prompt_messages_json` 单独投影为模型 replay;只在这条投影上应用 provider/context 过滤,不能反向改变 `turns`。
|
||||||
|
- 将工具完成结果合并回同一 `ToolCall`,保留开始态的 `args`、调用 ID 和开始时间,再补齐 `result` 与完成时间;不要制造协议不存在的独立 `ToolResult` step。
|
||||||
|
- 用 TDD 覆盖至少这些性质:重复投影相等、投影不修改 history、开始态字段在结果合并后仍存在、UI turns 保留思考/工具内容而模型 replay 仍遵守独立过滤规则。
|
||||||
|
|||||||
@@ -29,9 +29,16 @@ tasks:
|
|||||||
- cp ./proto/from_extensions/aiserver_v1.proto ./proto/aiserver_v1.proto
|
- cp ./proto/from_extensions/aiserver_v1.proto ./proto/aiserver_v1.proto
|
||||||
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/agent/v1;agentv1";|option go_package = "cursor/gen/agentv1;agentv1";|' ./proto/agent_v1.proto
|
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/agent/v1;agentv1";|option go_package = "cursor/gen/agentv1;agentv1";|' ./proto/agent_v1.proto
|
||||||
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/aiserver/v1;aiserverv1";|option go_package = "cursor/gen/aiserverv1;aiserverv1";|' ./proto/aiserver_v1.proto
|
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/aiserver/v1;aiserverv1";|option go_package = "cursor/gen/aiserverv1;aiserverv1";|' ./proto/aiserver_v1.proto
|
||||||
|
- ./proto/check_proto_sync.sh
|
||||||
- rm -rf ./gen/agentv1 ./gen/aiserverv1
|
- rm -rf ./gen/agentv1 ./gen/aiserverv1
|
||||||
- task: generate:proto
|
- task: generate:proto
|
||||||
|
|
||||||
|
check:proto:
|
||||||
|
summary: 检查根 proto 与扩展提取快照是否一致
|
||||||
|
dir: '{{.ROOT_DIR}}'
|
||||||
|
cmds:
|
||||||
|
- ./proto/check_proto_sync.sh
|
||||||
|
|
||||||
generate:proto:
|
generate:proto:
|
||||||
summary: 生成 proto Go/Connect 代码
|
summary: 生成 proto Go/Connect 代码
|
||||||
dir: '{{.ROOT_DIR}}'
|
dir: '{{.ROOT_DIR}}'
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ info:
|
|||||||
description: "Cursor助手"
|
description: "Cursor助手"
|
||||||
copyright: "© 2026, Cursor助手"
|
copyright: "© 2026, Cursor助手"
|
||||||
comments: "Cursor助手"
|
comments: "Cursor助手"
|
||||||
version: "0.0.44"
|
version: "0.0.45"
|
||||||
|
|
||||||
dev_mode:
|
dev_mode:
|
||||||
root_path: .
|
root_path: .
|
||||||
|
|||||||
@@ -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.44</string>
|
<string>0.0.45</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>0.0.44</string>
|
<string>0.0.45</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0.0</string>
|
<string>12.0.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
@@ -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.44</string>
|
<string>0.0.45</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>0.0.44</string>
|
<string>0.0.45</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0.0</string>
|
<string>12.0.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
name: "Cursor助手"
|
name: "Cursor助手"
|
||||||
arch: ${GOARCH}
|
arch: ${GOARCH}
|
||||||
platform: "linux"
|
platform: "linux"
|
||||||
version: "0.0.44"
|
version: "0.0.45"
|
||||||
section: "default"
|
section: "default"
|
||||||
priority: "extra"
|
priority: "extra"
|
||||||
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
|
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"fixed": {
|
"fixed": {
|
||||||
"file_version": "0.0.44"
|
"file_version": "0.0.45"
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"0000": {
|
"0000": {
|
||||||
"ProductVersion": "0.0.44",
|
"ProductVersion": "0.0.45",
|
||||||
"CompanyName": "Cursor助手",
|
"CompanyName": "Cursor助手",
|
||||||
"FileDescription": "Cursor助手",
|
"FileDescription": "Cursor助手",
|
||||||
"LegalCopyright": "© 2026, Cursor助手",
|
"LegalCopyright": "© 2026, Cursor助手",
|
||||||
|
|||||||
@@ -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.44"
|
!define INFO_PRODUCTVERSION "0.0.45"
|
||||||
!endif
|
!endif
|
||||||
!ifndef INFO_COPYRIGHT
|
!ifndef INFO_COPYRIGHT
|
||||||
!define INFO_COPYRIGHT "© 2026, Cursor助手"
|
!define INFO_COPYRIGHT "© 2026, Cursor助手"
|
||||||
|
|||||||
@@ -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.44" processorArchitecture="*"/>
|
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.45" 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="*"/>
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
<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>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@
|
|||||||
"15d124b200ddabed": "Maximum number of context tokens the model can accept in a single request. Leave blank to use the default.",
|
"15d124b200ddabed": "Maximum number of context tokens the model can accept in a single request. Leave blank to use the default.",
|
||||||
"185aebe19c77425d": "{0} must be a JSON object",
|
"185aebe19c77425d": "{0} must be a JSON object",
|
||||||
"18b7312022cd1840": "Start Service",
|
"18b7312022cd1840": "Start Service",
|
||||||
|
"1afed6a81a2512d2": "Select model",
|
||||||
"1baddde657dd2720": "Current outbound requests use system proxy",
|
"1baddde657dd2720": "Current outbound requests use system proxy",
|
||||||
"1bc77f5ab979f4c1": "Add Model Settings",
|
"1bc77f5ab979f4c1": "Add Model Settings",
|
||||||
"1c631615c1d85c9e": "Log in to Cursor",
|
"1c631615c1d85c9e": "Log in to Cursor",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"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",
|
||||||
@@ -44,8 +46,10 @@
|
|||||||
"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",
|
||||||
@@ -69,6 +73,7 @@
|
|||||||
"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.",
|
||||||
"5d1687a4a41883fd": "Stopping...",
|
"5d1687a4a41883fd": "Stopping...",
|
||||||
@@ -95,8 +100,10 @@
|
|||||||
"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",
|
"7bf8e2c07e084d09": "Model Editor",
|
||||||
@@ -106,15 +113,16 @@
|
|||||||
"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.",
|
||||||
"83be9cac28873059": "Cursor Control Plane Account",
|
"83be9cac28873059": "Cursor Control Plane Account",
|
||||||
"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",
|
||||||
"8cbcf741e727dbf7": "Model Settings",
|
"8cbcf741e727dbf7": "Model Settings",
|
||||||
"8d1de152be6360ce": "Valid ratio: {0}",
|
"8d1de152be6360ce": "Valid ratio: {0}",
|
||||||
"8e2dc7b0d2e8f6f8": "e.g. OpenAI - GPT-4.1",
|
|
||||||
"8f6f8d979c981ced": "Copied",
|
"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.",
|
"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",
|
"8faa670b512b6b9b": "Open Model Settings",
|
||||||
@@ -160,6 +168,7 @@
|
|||||||
"b571037dc396a00c": "Total request tokens include both prompt and model output.",
|
"b571037dc396a00c": "Total request tokens include both prompt and model output.",
|
||||||
"b765005f69fa971f": "e.g. gpt-4.1",
|
"b765005f69fa971f": "e.g. gpt-4.1",
|
||||||
"b76a22622020f849": "Select interface protocol endpoint. When selecting 'Custom Path', please enter the complete request URL in the API address bar (including the /chat/completions or /responses suffix). The system will automatically detect the protocol type based on the trailing segment.",
|
"b76a22622020f849": "Select interface protocol endpoint. When selecting 'Custom Path', please enter the complete request URL in the API address bar (including the /chat/completions or /responses suffix). The system will automatically detect the protocol type based on the trailing segment.",
|
||||||
|
"b7ceef2fbfeb4a85": "e.g. GPT-5",
|
||||||
"b870928f8f9a24c4": "API Endpoint",
|
"b870928f8f9a24c4": "API Endpoint",
|
||||||
"b90a8ac9c488ce46": "Select language",
|
"b90a8ac9c488ce46": "Select language",
|
||||||
"ba3c66f90fd11725": "System proxy identified",
|
"ba3c66f90fd11725": "System proxy identified",
|
||||||
@@ -173,6 +182,7 @@
|
|||||||
"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.",
|
||||||
"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",
|
||||||
@@ -206,6 +216,7 @@
|
|||||||
"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",
|
||||||
@@ -218,6 +229,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"15d124b200ddabed": "モデルが1回のリクエストで受け取れる最大コンテキスト Token 数。空欄の場合はデフォルト値を使用します。",
|
"15d124b200ddabed": "モデルが1回のリクエストで受け取れる最大コンテキスト Token 数。空欄の場合はデフォルト値を使用します。",
|
||||||
"185aebe19c77425d": "{0}はJSONオブジェクトである必要があります",
|
"185aebe19c77425d": "{0}はJSONオブジェクトである必要があります",
|
||||||
"18b7312022cd1840": "サービスを開始",
|
"18b7312022cd1840": "サービスを開始",
|
||||||
|
"1afed6a81a2512d2": "モデルを選択",
|
||||||
"1baddde657dd2720": "現在のアウトバウンドリクエストはシステムプロキシを使用しています",
|
"1baddde657dd2720": "現在のアウトバウンドリクエストはシステムプロキシを使用しています",
|
||||||
"1bc77f5ab979f4c1": "モデル設定を追加",
|
"1bc77f5ab979f4c1": "モデル設定を追加",
|
||||||
"1c631615c1d85c9e": "Cursor にログイン",
|
"1c631615c1d85c9e": "Cursor にログイン",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"281eb6d08c9960d0": "{0} の思考予算 Token は正の整数である必要があります",
|
"281eb6d08c9960d0": "{0} の思考予算 Token は正の整数である必要があります",
|
||||||
"28aeffc70ceb4267": "この画面の表示言語を切り替えます。設定はすぐに反映され、この端末に保存されます",
|
"28aeffc70ceb4267": "この画面の表示言語を切り替えます。設定はすぐに反映され、この端末に保存されます",
|
||||||
"2a24519398684ed5": "ホームページへ",
|
"2a24519398684ed5": "ホームページへ",
|
||||||
|
"2c18d5e2d70b45db": "モデルのプレフィックス",
|
||||||
"2cd0f3be8738a86c": "キャンセル",
|
"2cd0f3be8738a86c": "キャンセル",
|
||||||
"2d706f7981b45a7b": "ローカル設定を保存しました",
|
"2d706f7981b45a7b": "ローカル設定を保存しました",
|
||||||
"2f9daa828907b93f": "削除",
|
"2f9daa828907b93f": "削除",
|
||||||
@@ -44,8 +46,10 @@
|
|||||||
"37d23612f78a2e63": "今すぐ再起動して更新",
|
"37d23612f78a2e63": "今すぐ再起動して更新",
|
||||||
"392d0dceb45998d3": "最高",
|
"392d0dceb45998d3": "最高",
|
||||||
"393df9bb13ea4900": "ヒット",
|
"393df9bb13ea4900": "ヒット",
|
||||||
|
"3a5040b68abf75f9": "すべて選択",
|
||||||
"3ab8cc15939f3b5c": "ログアウト",
|
"3ab8cc15939f3b5c": "ログアウト",
|
||||||
"3af7e5489e61ea51": "更新中",
|
"3af7e5489e61ea51": "更新中",
|
||||||
|
"3b2e5f2fba1bcbc7": "インターフェースのアドレスとアクセスキーを入力してください",
|
||||||
"3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします",
|
"3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします",
|
||||||
"3d13868593ae4eeb": "表示言語",
|
"3d13868593ae4eeb": "表示言語",
|
||||||
"3d52574ce1500561": "未接続",
|
"3d52574ce1500561": "未接続",
|
||||||
@@ -69,6 +73,7 @@
|
|||||||
"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 数。空欄の場合はデフォルト値を使用します。",
|
||||||
"5d1687a4a41883fd": "停止中...",
|
"5d1687a4a41883fd": "停止中...",
|
||||||
@@ -95,9 +100,11 @@
|
|||||||
"737225e2904673fc": "推定出力 Token: {0}",
|
"737225e2904673fc": "推定出力 Token: {0}",
|
||||||
"7520bd50a5ee5471": "テスト停止 {0}/{1}",
|
"7520bd50a5ee5471": "テスト停止 {0}/{1}",
|
||||||
"753d8bb0da9913ce": "複製に失敗しました",
|
"753d8bb0da9913ce": "複製に失敗しました",
|
||||||
|
"75b5f4c68c79322c": "保存するモデルを1つ以上選択してください",
|
||||||
"774d6e1b7cb89751": "デフォルト定義",
|
"774d6e1b7cb89751": "デフォルト定義",
|
||||||
"77c9e582e85583af": "テスト失敗",
|
"77c9e582e85583af": "テスト失敗",
|
||||||
"7a26bf794e9fb6bf": "UI 上の表示専用で、異なるモデルを見分けやすくします。",
|
"7923d007483ae04c": "保存できるモデルがありません",
|
||||||
|
"7a26bf794e9fb6bf": "UIでモデルを区別するための表示専用です。",
|
||||||
"7b6187c41e88b70c": "テスト中...",
|
"7b6187c41e88b70c": "テスト中...",
|
||||||
"7bf8e2c07e084d09": "モデル編集",
|
"7bf8e2c07e084d09": "モデル編集",
|
||||||
"7df7641e5e741346": "キャッシュ読み取り / (キャッシュ読み取り + キャッシュ作成 + 非キャッシュ入力)",
|
"7df7641e5e741346": "キャッシュ読み取り / (キャッシュ読み取り + キャッシュ作成 + 非キャッシュ入力)",
|
||||||
@@ -106,15 +113,16 @@
|
|||||||
"80296f4aa3f4543b": "キャッシュ読み書き",
|
"80296f4aa3f4543b": "キャッシュ読み書き",
|
||||||
"81123c56d5d880d0": "API キー",
|
"81123c56d5d880d0": "API キー",
|
||||||
"8139cb3dd11f5a67": "有効にすると、JSONオブジェクトが最終的なリクエストヘッダーを上書きします。同名のヘッダーはこの設定が優先され、値は文字列である必要があります。",
|
"8139cb3dd11f5a67": "有効にすると、JSONオブジェクトが最終的なリクエストヘッダーを上書きします。同名のヘッダーはこの設定が優先され、値は文字列である必要があります。",
|
||||||
|
"826c0e5a4407befa": "複数のモデルを選択できます。保存されるのは選択したモデルだけで、モデルごとに1つの設定が作成されます。",
|
||||||
"83be9cac28873059": "Cursor コントロールプレーンアカウント",
|
"83be9cac28873059": "Cursor コントロールプレーンアカウント",
|
||||||
"8672864e90417138": "最大",
|
"8672864e90417138": "最大",
|
||||||
"86df7ec743047234": "サービス稼働中",
|
"86df7ec743047234": "サービス稼働中",
|
||||||
|
"891bfee3bbe52d3c": "モデルIDを入力してください",
|
||||||
"899add6275682210": "空欄で 200000",
|
"899add6275682210": "空欄で 200000",
|
||||||
"8a4ef3e48e4e8a5a": "有効",
|
"8a4ef3e48e4e8a5a": "有効",
|
||||||
"8c1935935600e336": "モデルテスト",
|
"8c1935935600e336": "モデルテスト",
|
||||||
"8cbcf741e727dbf7": "モデル設定",
|
"8cbcf741e727dbf7": "モデル設定",
|
||||||
"8d1de152be6360ce": "有効率: {0}",
|
"8d1de152be6360ce": "有効率: {0}",
|
||||||
"8e2dc7b0d2e8f6f8": "例: OpenAI - GPT-4.1",
|
|
||||||
"8f6f8d979c981ced": "コピーしました",
|
"8f6f8d979c981ced": "コピーしました",
|
||||||
"8f8baf5d18dd0492": "有効にすると、JSONオブジェクトがOpenAIのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。OpenAIのservice_tierはauto、default、flex、scale、priorityをサポートしており、priorityは高優先度/Fastのシナリオで使用できます。",
|
"8f8baf5d18dd0492": "有効にすると、JSONオブジェクトがOpenAIのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。OpenAIのservice_tierはauto、default、flex、scale、priorityをサポートしており、priorityは高優先度/Fastのシナリオで使用できます。",
|
||||||
"8faa670b512b6b9b": "モデル設定を開く",
|
"8faa670b512b6b9b": "モデル設定を開く",
|
||||||
@@ -122,7 +130,7 @@
|
|||||||
"917b1c1f18d0276b": "保存中...",
|
"917b1c1f18d0276b": "保存中...",
|
||||||
"9196835e388d2550": "すべてテスト",
|
"9196835e388d2550": "すべてテスト",
|
||||||
"91cba5c107a51892": "/ 異常",
|
"91cba5c107a51892": "/ 異常",
|
||||||
"92059fe6cd713db4": "実際にサーバーへ送信されるモデル名です。例: gpt-4.1 または claude-sonnet。",
|
"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": "アップデートを確認中...",
|
||||||
@@ -160,6 +168,7 @@
|
|||||||
"b571037dc396a00c": "総リクエスト Token には Prompt とモデル出力の両方が含まれます。",
|
"b571037dc396a00c": "総リクエスト Token には Prompt とモデル出力の両方が含まれます。",
|
||||||
"b765005f69fa971f": "例: gpt-4.1",
|
"b765005f69fa971f": "例: gpt-4.1",
|
||||||
"b76a22622020f849": "インターフェースプロトコルのエンドポイントを選択します。「カスタムパス」を選択する場合は、APIアドレスバーに完全なリクエストURL(/chat/completions または /responses のサフィックスを含む)を入力してください。システムは末尾 of セグメントに基づいてプロトコルタイプを自動的に判断します。",
|
"b76a22622020f849": "インターフェースプロトコルのエンドポイントを選択します。「カスタムパス」を選択する場合は、APIアドレスバーに完全なリクエストURL(/chat/completions または /responses のサフィックスを含む)を入力してください。システムは末尾 of セグメントに基づいてプロトコルタイプを自動的に判断します。",
|
||||||
|
"b7ceef2fbfeb4a85": "例: GPT-5",
|
||||||
"b870928f8f9a24c4": "APIエンドポイント",
|
"b870928f8f9a24c4": "APIエンドポイント",
|
||||||
"b90a8ac9c488ce46": "言語を選択",
|
"b90a8ac9c488ce46": "言語を選択",
|
||||||
"ba3c66f90fd11725": "システムプロキシを認識しました",
|
"ba3c66f90fd11725": "システムプロキシを認識しました",
|
||||||
@@ -173,6 +182,7 @@
|
|||||||
"c3d46b387eeadb23": "cursor-byok 内の Cursor アカウントからのみログアウトします。Cursor クライアントからはログアウトしません。続行しますか?",
|
"c3d46b387eeadb23": "cursor-byok 内の Cursor アカウントからのみログアウトします。Cursor クライアントからはログアウトしません。続行しますか?",
|
||||||
"c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。",
|
"c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。",
|
||||||
"c69f5bce63b9f14c": "設定フォルダー",
|
"c69f5bce63b9f14c": "設定フォルダー",
|
||||||
|
"c72d5dc20cd27118": "{0} / {1} モデルを選択中",
|
||||||
"c8a52b66651d294c": "ログアウトに失敗しました",
|
"c8a52b66651d294c": "ログアウトに失敗しました",
|
||||||
"c8c14507b2d37395": "推論強度",
|
"c8c14507b2d37395": "推論強度",
|
||||||
"c98e118e0a43f078": "モデル",
|
"c98e118e0a43f078": "モデル",
|
||||||
@@ -206,6 +216,7 @@
|
|||||||
"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",
|
||||||
@@ -218,6 +229,7 @@
|
|||||||
"f3a76d896853c1df": "ミス",
|
"f3a76d896853c1df": "ミス",
|
||||||
"f3fae6cccb9004b1": "カスタムヘッダー名は空にできません",
|
"f3fae6cccb9004b1": "カスタムヘッダー名は空にできません",
|
||||||
"f474a4108aba4c4c": "サービスを停止",
|
"f474a4108aba4c4c": "サービスを停止",
|
||||||
|
"f4d4bae588c4c0ff": "すべて選択解除",
|
||||||
"f4f0ead1116b5b62": "有効化",
|
"f4f0ead1116b5b62": "有効化",
|
||||||
"f56c6c82203b33f6": "お知らせ",
|
"f56c6c82203b33f6": "お知らせ",
|
||||||
"f61e03f047b786d5": "{0} の最大出力 Token は正の整数である必要があります",
|
"f61e03f047b786d5": "{0} の最大出力 Token は正の整数である必要があります",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"15d124b200ddabed": "Максимальное число токенов контекста, которое модель может принять за один запрос. Оставьте поле пустым для значения по умолчанию.",
|
"15d124b200ddabed": "Максимальное число токенов контекста, которое модель может принять за один запрос. Оставьте поле пустым для значения по умолчанию.",
|
||||||
"185aebe19c77425d": "{0} должен быть объектом JSON",
|
"185aebe19c77425d": "{0} должен быть объектом JSON",
|
||||||
"18b7312022cd1840": "Запустить сервис",
|
"18b7312022cd1840": "Запустить сервис",
|
||||||
|
"1afed6a81a2512d2": "Выберите модель",
|
||||||
"1baddde657dd2720": "Исходящие запросы используют системный прокси",
|
"1baddde657dd2720": "Исходящие запросы используют системный прокси",
|
||||||
"1bc77f5ab979f4c1": "Добавить настройки модели",
|
"1bc77f5ab979f4c1": "Добавить настройки модели",
|
||||||
"1c631615c1d85c9e": "Войти в Cursor",
|
"1c631615c1d85c9e": "Войти в Cursor",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"281eb6d08c9960d0": "Бюджет токенов рассуждения {0} должен быть положительным целым числом",
|
"281eb6d08c9960d0": "Бюджет токенов рассуждения {0} должен быть положительным целым числом",
|
||||||
"28aeffc70ceb4267": "Измените язык интерфейса. Настройка применяется сразу и сохраняется на этом устройстве.",
|
"28aeffc70ceb4267": "Измените язык интерфейса. Настройка применяется сразу и сохраняется на этом устройстве.",
|
||||||
"2a24519398684ed5": "Перейти на домашнюю страницу",
|
"2a24519398684ed5": "Перейти на домашнюю страницу",
|
||||||
|
"2c18d5e2d70b45db": "Префикс модели",
|
||||||
"2cd0f3be8738a86c": "Отмена",
|
"2cd0f3be8738a86c": "Отмена",
|
||||||
"2d706f7981b45a7b": "Локальные настройки сохранены",
|
"2d706f7981b45a7b": "Локальные настройки сохранены",
|
||||||
"2f9daa828907b93f": "Удалить",
|
"2f9daa828907b93f": "Удалить",
|
||||||
@@ -44,8 +46,10 @@
|
|||||||
"37d23612f78a2e63": "Перезапустить и обновить",
|
"37d23612f78a2e63": "Перезапустить и обновить",
|
||||||
"392d0dceb45998d3": "Очень высокая",
|
"392d0dceb45998d3": "Очень высокая",
|
||||||
"393df9bb13ea4900": "Попадание",
|
"393df9bb13ea4900": "Попадание",
|
||||||
|
"3a5040b68abf75f9": "Выбрать все",
|
||||||
"3ab8cc15939f3b5c": "Выйти",
|
"3ab8cc15939f3b5c": "Выйти",
|
||||||
"3af7e5489e61ea51": "Обновление",
|
"3af7e5489e61ea51": "Обновление",
|
||||||
|
"3b2e5f2fba1bcbc7": "Введите адрес интерфейса и ключ доступа",
|
||||||
"3c2a9f9901109e75": "Тип {0} поддерживает только OpenAI или Anthropic",
|
"3c2a9f9901109e75": "Тип {0} поддерживает только OpenAI или Anthropic",
|
||||||
"3d13868593ae4eeb": "Язык интерфейса",
|
"3d13868593ae4eeb": "Язык интерфейса",
|
||||||
"3d52574ce1500561": "Не подключено",
|
"3d52574ce1500561": "Не подключено",
|
||||||
@@ -69,6 +73,7 @@
|
|||||||
"58c6b0935a7216da": "Не удалось открыть профиль участника",
|
"58c6b0935a7216da": "Не удалось открыть профиль участника",
|
||||||
"593a972852ba0004": "Cursor Assistant | Всегда бесплатно | Пользовательский API",
|
"593a972852ba0004": "Cursor Assistant | Всегда бесплатно | Пользовательский API",
|
||||||
"59a2195a01a8b35b": "{0} должен быть допустимым объектом JSON",
|
"59a2195a01a8b35b": "{0} должен быть допустимым объектом JSON",
|
||||||
|
"5a5c8318ef649672": "Выбрано: {0}",
|
||||||
"5aa8f5590c940829": "Ввод без кеша: {0}",
|
"5aa8f5590c940829": "Ввод без кеша: {0}",
|
||||||
"5beb1206c532729f": "Максимальное число токенов в одном ответе. Оставьте поле пустым для значения по умолчанию.",
|
"5beb1206c532729f": "Максимальное число токенов в одном ответе. Оставьте поле пустым для значения по умолчанию.",
|
||||||
"5d1687a4a41883fd": "Остановка...",
|
"5d1687a4a41883fd": "Остановка...",
|
||||||
@@ -95,9 +100,11 @@
|
|||||||
"737225e2904673fc": "Расчетные выходные токены: {0}",
|
"737225e2904673fc": "Расчетные выходные токены: {0}",
|
||||||
"7520bd50a5ee5471": "Остановить проверку {0}/{1}",
|
"7520bd50a5ee5471": "Остановить проверку {0}/{1}",
|
||||||
"753d8bb0da9913ce": "Не удалось дублировать",
|
"753d8bb0da9913ce": "Не удалось дублировать",
|
||||||
|
"75b5f4c68c79322c": "Выберите хотя бы одну модель для сохранения",
|
||||||
"774d6e1b7cb89751": "Стандартный расчет",
|
"774d6e1b7cb89751": "Стандартный расчет",
|
||||||
"77c9e582e85583af": "Проверка не пройдена",
|
"77c9e582e85583af": "Проверка не пройдена",
|
||||||
"7a26bf794e9fb6bf": "Используется только для отображения в интерфейсе и помогает различать модели.",
|
"7923d007483ae04c": "Нет моделей для сохранения",
|
||||||
|
"7a26bf794e9fb6bf": "Используется только для отображения в интерфейсе, чтобы различать модели.",
|
||||||
"7b6187c41e88b70c": "Проверка...",
|
"7b6187c41e88b70c": "Проверка...",
|
||||||
"7bf8e2c07e084d09": "Редактор модели",
|
"7bf8e2c07e084d09": "Редактор модели",
|
||||||
"7df7641e5e741346": "Чтение кеша / (Чтение кеша + Создание кеша + Ввод без кеша)",
|
"7df7641e5e741346": "Чтение кеша / (Чтение кеша + Создание кеша + Ввод без кеша)",
|
||||||
@@ -106,15 +113,16 @@
|
|||||||
"80296f4aa3f4543b": "Чтение/запись кеша",
|
"80296f4aa3f4543b": "Чтение/запись кеша",
|
||||||
"81123c56d5d880d0": "Ключ API",
|
"81123c56d5d880d0": "Ключ API",
|
||||||
"8139cb3dd11f5a67": "Если включено, объект JSON переопределит итоговые заголовки запроса. При совпадении имен используются значения отсюда; все значения должны быть строками.",
|
"8139cb3dd11f5a67": "Если включено, объект JSON переопределит итоговые заголовки запроса. При совпадении имен используются значения отсюда; все значения должны быть строками.",
|
||||||
|
"826c0e5a4407befa": "Можно выбрать несколько моделей. Будут сохранены только выбранные модели, для каждой будет создана отдельная конфигурация.",
|
||||||
"83be9cac28873059": "Аккаунт управляющего уровня Cursor",
|
"83be9cac28873059": "Аккаунт управляющего уровня Cursor",
|
||||||
"8672864e90417138": "Максимальная",
|
"8672864e90417138": "Максимальная",
|
||||||
"86df7ec743047234": "Сервис запущен",
|
"86df7ec743047234": "Сервис запущен",
|
||||||
|
"891bfee3bbe52d3c": "Введите идентификатор модели",
|
||||||
"899add6275682210": "Если оставить пустым, используется 200000",
|
"899add6275682210": "Если оставить пустым, используется 200000",
|
||||||
"8a4ef3e48e4e8a5a": "Включено",
|
"8a4ef3e48e4e8a5a": "Включено",
|
||||||
"8c1935935600e336": "Проверка модели",
|
"8c1935935600e336": "Проверка модели",
|
||||||
"8cbcf741e727dbf7": "Настройки модели",
|
"8cbcf741e727dbf7": "Настройки модели",
|
||||||
"8d1de152be6360ce": "Доля успешных: {0}",
|
"8d1de152be6360ce": "Доля успешных: {0}",
|
||||||
"8e2dc7b0d2e8f6f8": "например, OpenAI - GPT-4.1",
|
|
||||||
"8f6f8d979c981ced": "Скопировано",
|
"8f6f8d979c981ced": "Скопировано",
|
||||||
"8f8baf5d18dd0492": "Если включено, объект JSON переопределит тело запроса OpenAI. При совпадении полей используются значения отсюда. OpenAI service_tier поддерживает auto, default, flex, scale и priority; priority можно использовать для сценариев с высоким приоритетом/Fast.",
|
"8f8baf5d18dd0492": "Если включено, объект JSON переопределит тело запроса OpenAI. При совпадении полей используются значения отсюда. OpenAI service_tier поддерживает auto, default, flex, scale и priority; priority можно использовать для сценариев с высоким приоритетом/Fast.",
|
||||||
"8faa670b512b6b9b": "Открыть настройки модели",
|
"8faa670b512b6b9b": "Открыть настройки модели",
|
||||||
@@ -122,7 +130,7 @@
|
|||||||
"917b1c1f18d0276b": "Сохранение...",
|
"917b1c1f18d0276b": "Сохранение...",
|
||||||
"9196835e388d2550": "Проверить все",
|
"9196835e388d2550": "Проверить все",
|
||||||
"91cba5c107a51892": "/ Ошибочные",
|
"91cba5c107a51892": "/ Ошибочные",
|
||||||
"92059fe6cd713db4": "Имя модели, которое фактически отправляется серверу, например gpt-4.1 или claude-sonnet.",
|
"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": "Проверка обновлений...",
|
||||||
@@ -160,6 +168,7 @@
|
|||||||
"b571037dc396a00c": "Общее число токенов запроса включает Prompt и вывод модели.",
|
"b571037dc396a00c": "Общее число токенов запроса включает Prompt и вывод модели.",
|
||||||
"b765005f69fa971f": "например, gpt-4.1",
|
"b765005f69fa971f": "например, gpt-4.1",
|
||||||
"b76a22622020f849": "Выберите конечную точку протокола. Для варианта «Пользовательский путь» укажите полный URL запроса в поле адреса API, включая суффикс /chat/completions или /responses. Тип протокола будет определен автоматически по последнему сегменту.",
|
"b76a22622020f849": "Выберите конечную точку протокола. Для варианта «Пользовательский путь» укажите полный URL запроса в поле адреса API, включая суффикс /chat/completions или /responses. Тип протокола будет определен автоматически по последнему сегменту.",
|
||||||
|
"b7ceef2fbfeb4a85": "например, GPT-5",
|
||||||
"b870928f8f9a24c4": "Конечная точка API",
|
"b870928f8f9a24c4": "Конечная точка API",
|
||||||
"b90a8ac9c488ce46": "Выберите язык",
|
"b90a8ac9c488ce46": "Выберите язык",
|
||||||
"ba3c66f90fd11725": "Обнаружен системный прокси",
|
"ba3c66f90fd11725": "Обнаружен системный прокси",
|
||||||
@@ -173,6 +182,7 @@
|
|||||||
"c3d46b387eeadb23": "Будет выполнен выход только из аккаунта Cursor в cursor-byok. В клиенте Cursor вы останетесь в системе. Продолжить?",
|
"c3d46b387eeadb23": "Будет выполнен выход только из аккаунта Cursor в cursor-byok. В клиенте Cursor вы останетесь в системе. Продолжить?",
|
||||||
"c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.",
|
"c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.",
|
||||||
"c69f5bce63b9f14c": "Папка настроек",
|
"c69f5bce63b9f14c": "Папка настроек",
|
||||||
|
"c72d5dc20cd27118": "Выбрано моделей: {0} / {1}",
|
||||||
"c8a52b66651d294c": "Не удалось выйти",
|
"c8a52b66651d294c": "Не удалось выйти",
|
||||||
"c8c14507b2d37395": "Интенсивность рассуждений",
|
"c8c14507b2d37395": "Интенсивность рассуждений",
|
||||||
"c98e118e0a43f078": "Модель",
|
"c98e118e0a43f078": "Модель",
|
||||||
@@ -206,6 +216,7 @@
|
|||||||
"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",
|
||||||
@@ -218,6 +229,7 @@
|
|||||||
"f3a76d896853c1df": "Промах",
|
"f3a76d896853c1df": "Промах",
|
||||||
"f3fae6cccb9004b1": "Имя пользовательского заголовка не может быть пустым",
|
"f3fae6cccb9004b1": "Имя пользовательского заголовка не может быть пустым",
|
||||||
"f474a4108aba4c4c": "Остановить сервис",
|
"f474a4108aba4c4c": "Остановить сервис",
|
||||||
|
"f4d4bae588c4c0ff": "Снять выделение со всех",
|
||||||
"f4f0ead1116b5b62": "Включить",
|
"f4f0ead1116b5b62": "Включить",
|
||||||
"f56c6c82203b33f6": "Уведомление",
|
"f56c6c82203b33f6": "Уведомление",
|
||||||
"f61e03f047b786d5": "Максимальное число выходных токенов {0} должно быть положительным целым числом",
|
"f61e03f047b786d5": "Максимальное число выходных токенов {0} должно быть положительным целым числом",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"15d124b200ddabed": "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
|
"15d124b200ddabed": "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
|
||||||
"185aebe19c77425d": "{0}必须是 JSON 对象",
|
"185aebe19c77425d": "{0}必须是 JSON 对象",
|
||||||
"18b7312022cd1840": "启动服务",
|
"18b7312022cd1840": "启动服务",
|
||||||
|
"1afed6a81a2512d2": "选择模型",
|
||||||
"1baddde657dd2720": "当前出站请求使用系统代理",
|
"1baddde657dd2720": "当前出站请求使用系统代理",
|
||||||
"1bc77f5ab979f4c1": "新增模型配置",
|
"1bc77f5ab979f4c1": "新增模型配置",
|
||||||
"1c631615c1d85c9e": "登录 Cursor",
|
"1c631615c1d85c9e": "登录 Cursor",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"281eb6d08c9960d0": "{0} 的思考预算 Token 必须为正整数",
|
"281eb6d08c9960d0": "{0} 的思考预算 Token 必须为正整数",
|
||||||
"28aeffc70ceb4267": "切换当前界面显示语言,设置会立即生效并保存在本机",
|
"28aeffc70ceb4267": "切换当前界面显示语言,设置会立即生效并保存在本机",
|
||||||
"2a24519398684ed5": "访问主页",
|
"2a24519398684ed5": "访问主页",
|
||||||
|
"2c18d5e2d70b45db": "模型前缀",
|
||||||
"2cd0f3be8738a86c": "取消",
|
"2cd0f3be8738a86c": "取消",
|
||||||
"2d706f7981b45a7b": "本地配置已保存",
|
"2d706f7981b45a7b": "本地配置已保存",
|
||||||
"2f9daa828907b93f": "删除",
|
"2f9daa828907b93f": "删除",
|
||||||
@@ -44,8 +46,10 @@
|
|||||||
"37d23612f78a2e63": "立即重启更新",
|
"37d23612f78a2e63": "立即重启更新",
|
||||||
"392d0dceb45998d3": "极高",
|
"392d0dceb45998d3": "极高",
|
||||||
"393df9bb13ea4900": "命中",
|
"393df9bb13ea4900": "命中",
|
||||||
|
"3a5040b68abf75f9": "全选",
|
||||||
"3ab8cc15939f3b5c": "退出登录",
|
"3ab8cc15939f3b5c": "退出登录",
|
||||||
"3af7e5489e61ea51": "刷新中",
|
"3af7e5489e61ea51": "刷新中",
|
||||||
|
"3b2e5f2fba1bcbc7": "请输入接口地址和访问密钥",
|
||||||
"3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic",
|
"3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic",
|
||||||
"3d13868593ae4eeb": "界面语言",
|
"3d13868593ae4eeb": "界面语言",
|
||||||
"3d52574ce1500561": "未连接",
|
"3d52574ce1500561": "未连接",
|
||||||
@@ -69,6 +73,7 @@
|
|||||||
"58c6b0935a7216da": "打开贡献者主页失败",
|
"58c6b0935a7216da": "打开贡献者主页失败",
|
||||||
"593a972852ba0004": "Cursor助手|永久免费|自定义API",
|
"593a972852ba0004": "Cursor助手|永久免费|自定义API",
|
||||||
"59a2195a01a8b35b": "{0}必须是合法 JSON 对象",
|
"59a2195a01a8b35b": "{0}必须是合法 JSON 对象",
|
||||||
|
"5a5c8318ef649672": "已选择 {0} 项",
|
||||||
"5aa8f5590c940829": "非缓存输入:{0}",
|
"5aa8f5590c940829": "非缓存输入:{0}",
|
||||||
"5beb1206c532729f": "单次回复允许生成的最大 Token 数。留空时使用默认值。",
|
"5beb1206c532729f": "单次回复允许生成的最大 Token 数。留空时使用默认值。",
|
||||||
"5d1687a4a41883fd": "停止中...",
|
"5d1687a4a41883fd": "停止中...",
|
||||||
@@ -95,8 +100,10 @@
|
|||||||
"737225e2904673fc": "输出推算:{0}",
|
"737225e2904673fc": "输出推算:{0}",
|
||||||
"7520bd50a5ee5471": "停止测试 {0}/{1}",
|
"7520bd50a5ee5471": "停止测试 {0}/{1}",
|
||||||
"753d8bb0da9913ce": "复制失败",
|
"753d8bb0da9913ce": "复制失败",
|
||||||
|
"75b5f4c68c79322c": "请先选择要保存的模型",
|
||||||
"774d6e1b7cb89751": "默认口径",
|
"774d6e1b7cb89751": "默认口径",
|
||||||
"77c9e582e85583af": "测试失败",
|
"77c9e582e85583af": "测试失败",
|
||||||
|
"7923d007483ae04c": "没有可保存的模型",
|
||||||
"7a26bf794e9fb6bf": "仅用于界面展示,便于你区分不同模型。",
|
"7a26bf794e9fb6bf": "仅用于界面展示,便于你区分不同模型。",
|
||||||
"7b6187c41e88b70c": "测试中...",
|
"7b6187c41e88b70c": "测试中...",
|
||||||
"7bf8e2c07e084d09": "模型编辑",
|
"7bf8e2c07e084d09": "模型编辑",
|
||||||
@@ -106,15 +113,16 @@
|
|||||||
"80296f4aa3f4543b": "缓存读写",
|
"80296f4aa3f4543b": "缓存读写",
|
||||||
"81123c56d5d880d0": "访问密钥",
|
"81123c56d5d880d0": "访问密钥",
|
||||||
"8139cb3dd11f5a67": "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。",
|
"8139cb3dd11f5a67": "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。",
|
||||||
|
"826c0e5a4407befa": "可多选。保存时只会写入选中的模型,每个模型生成一条配置。",
|
||||||
"83be9cac28873059": "Cursor 控制面账号",
|
"83be9cac28873059": "Cursor 控制面账号",
|
||||||
"8672864e90417138": "最高",
|
"8672864e90417138": "最高",
|
||||||
"86df7ec743047234": "服务运行中",
|
"86df7ec743047234": "服务运行中",
|
||||||
|
"891bfee3bbe52d3c": "请填写模型标识",
|
||||||
"899add6275682210": "留空时默认 200000",
|
"899add6275682210": "留空时默认 200000",
|
||||||
"8a4ef3e48e4e8a5a": "已开启",
|
"8a4ef3e48e4e8a5a": "已开启",
|
||||||
"8c1935935600e336": "模型测试",
|
"8c1935935600e336": "模型测试",
|
||||||
"8cbcf741e727dbf7": "模型配置",
|
"8cbcf741e727dbf7": "模型配置",
|
||||||
"8d1de152be6360ce": "有效占比:{0}",
|
"8d1de152be6360ce": "有效占比:{0}",
|
||||||
"8e2dc7b0d2e8f6f8": "例如:OpenAI - GPT-4.1",
|
|
||||||
"8f6f8d979c981ced": "已复制",
|
"8f6f8d979c981ced": "已复制",
|
||||||
"8f8baf5d18dd0492": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority;priority 可用于高优先级/Fast 类场景。",
|
"8f8baf5d18dd0492": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority;priority 可用于高优先级/Fast 类场景。",
|
||||||
"8faa670b512b6b9b": "打开模型配置",
|
"8faa670b512b6b9b": "打开模型配置",
|
||||||
@@ -160,6 +168,7 @@
|
|||||||
"b571037dc396a00c": "总请求 Token 包含 Prompt 和模型输出。",
|
"b571037dc396a00c": "总请求 Token 包含 Prompt 和模型输出。",
|
||||||
"b765005f69fa971f": "例如:gpt-4.1",
|
"b765005f69fa971f": "例如:gpt-4.1",
|
||||||
"b76a22622020f849": "选择接口协议端点。选“自定义路径”时,请在接口地址栏填写完整请求地址(含 /chat/completions 或 /responses 路径后缀),系统会根据末段自动判断协议形态。",
|
"b76a22622020f849": "选择接口协议端点。选“自定义路径”时,请在接口地址栏填写完整请求地址(含 /chat/completions 或 /responses 路径后缀),系统会根据末段自动判断协议形态。",
|
||||||
|
"b7ceef2fbfeb4a85": "例如:GPT-5",
|
||||||
"b870928f8f9a24c4": "接口端点",
|
"b870928f8f9a24c4": "接口端点",
|
||||||
"b90a8ac9c488ce46": "选择语言",
|
"b90a8ac9c488ce46": "选择语言",
|
||||||
"ba3c66f90fd11725": "已识别系统代理",
|
"ba3c66f90fd11725": "已识别系统代理",
|
||||||
@@ -173,6 +182,7 @@
|
|||||||
"c3d46b387eeadb23": "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?",
|
"c3d46b387eeadb23": "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?",
|
||||||
"c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
|
"c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
|
||||||
"c69f5bce63b9f14c": "设置文件夹",
|
"c69f5bce63b9f14c": "设置文件夹",
|
||||||
|
"c72d5dc20cd27118": "已选择 {0} / {1} 个模型",
|
||||||
"c8a52b66651d294c": "退出登录失败",
|
"c8a52b66651d294c": "退出登录失败",
|
||||||
"c8c14507b2d37395": "推理强度",
|
"c8c14507b2d37395": "推理强度",
|
||||||
"c98e118e0a43f078": "模型",
|
"c98e118e0a43f078": "模型",
|
||||||
@@ -206,6 +216,7 @@
|
|||||||
"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",
|
||||||
@@ -218,6 +229,7 @@
|
|||||||
"f3a76d896853c1df": "未命中",
|
"f3a76d896853c1df": "未命中",
|
||||||
"f3fae6cccb9004b1": "自定义请求头名称不能为空",
|
"f3fae6cccb9004b1": "自定义请求头名称不能为空",
|
||||||
"f474a4108aba4c4c": "关闭服务",
|
"f474a4108aba4c4c": "关闭服务",
|
||||||
|
"f4d4bae588c4c0ff": "取消全选",
|
||||||
"f4f0ead1116b5b62": "启用",
|
"f4f0ead1116b5b62": "启用",
|
||||||
"f56c6c82203b33f6": "提示",
|
"f56c6c82203b33f6": "提示",
|
||||||
"f61e03f047b786d5": "{0} 的最大输出 Token 必须为正整数",
|
"f61e03f047b786d5": "{0} 的最大输出 Token 必须为正整数",
|
||||||
|
|||||||
@@ -161,3 +161,9 @@ export function getModelAdapterTestResults() {
|
|||||||
Call.ByName(`${PROXY_SERVICE_NAME}.GetModelAdapterTestResults`),
|
Call.ByName(`${PROXY_SERVICE_NAME}.GetModelAdapterTestResults`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchModelAdapterModels(payload) {
|
||||||
|
return withApiLogging("FetchModelAdapterModels", payload, () =>
|
||||||
|
Call.ByName(`${PROXY_SERVICE_NAME}.FetchModelAdapterModels`, payload),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
startProxyService,
|
startProxyService,
|
||||||
stopProxyService,
|
stopProxyService,
|
||||||
testModelAdapter,
|
testModelAdapter,
|
||||||
|
fetchModelAdapterModels,
|
||||||
} from "@/services/clientApi";
|
} from "@/services/clientApi";
|
||||||
|
|
||||||
const APP_STATE_STORAGE_KEY = "cursor-client:runtime-state:v2";
|
const APP_STATE_STORAGE_KEY = "cursor-client:runtime-state:v2";
|
||||||
@@ -1143,6 +1144,100 @@ export async function saveModelAdapterAt(index, adapter) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAvailableModelIDs(payload) {
|
||||||
|
const result = await fetchModelAdapterModels(payload);
|
||||||
|
return asArray(result?.models)
|
||||||
|
.map((item) => asString(item))
|
||||||
|
.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);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import Button from "@/components/ui/Button.vue";
|
import Button from "@/components/ui/Button.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 { getModelEditorContext } from "@/services/clientApi";
|
||||||
@@ -9,9 +10,11 @@ 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,
|
||||||
|
fetchAvailableModelIDs,
|
||||||
getModelAdapterTestResult,
|
getModelAdapterTestResult,
|
||||||
getModelAdapterTestResultByID,
|
getModelAdapterTestResultByID,
|
||||||
isModelAdapterTestResultStale,
|
isModelAdapterTestResultStale,
|
||||||
@@ -22,11 +25,12 @@ 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 { Window } from "@wailsio/runtime";
|
||||||
import { computed, onMounted, 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]" },
|
||||||
@@ -61,6 +65,14 @@ const errorMessage = ref("");
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const lastTestAdapterID = ref("");
|
const lastTestAdapterID = ref("");
|
||||||
const localTestFailure = ref("");
|
const localTestFailure = ref("");
|
||||||
|
const modelPrefix = ref("");
|
||||||
|
const existingModelPrefixCleared = ref(false);
|
||||||
|
const availableModelIDs = ref([]);
|
||||||
|
const selectedModelIDs = ref([]);
|
||||||
|
const modelSelectionMode = ref("auto");
|
||||||
|
const modelListLoading = ref(false);
|
||||||
|
const modelListRequestSeq = ref(0);
|
||||||
|
let modelListDebounceTimer = 0;
|
||||||
|
|
||||||
function createOptionalPositiveIntegerModel(key) {
|
function createOptionalPositiveIntegerModel(key) {
|
||||||
return computed({
|
return computed({
|
||||||
@@ -80,14 +92,44 @@ const contextWindowTokensInput = createOptionalPositiveIntegerModel("contextWind
|
|||||||
const interfacePlaceholder = computed(() =>
|
const interfacePlaceholder = computed(() =>
|
||||||
draft.type === "anthropic" ? "例如:https://api.anthropic.com" : "例如:https://api.openai.com/v1",
|
draft.type === "anthropic" ? "例如:https://api.anthropic.com" : "例如:https://api.openai.com/v1",
|
||||||
);
|
);
|
||||||
const currentRequestHash = computed(() => buildModelAdapterTestRequestHash(draft));
|
const modelOptions = computed(() => availableModelIDs.value.map((modelID) => ({
|
||||||
const directModelTestResult = computed(() => getModelAdapterTestResult(draft));
|
label: modelID,
|
||||||
|
value: modelID,
|
||||||
|
icon: "icon-[mdi--cube-outline]",
|
||||||
|
})));
|
||||||
|
const isManualModelInput = computed(() => modelSelectionMode.value === "manual");
|
||||||
|
const activeModelIDs = computed(() => (
|
||||||
|
isManualModelInput.value
|
||||||
|
? [String(draft.modelID || "").trim()].filter(Boolean)
|
||||||
|
: selectedModelIDs.value
|
||||||
|
));
|
||||||
|
const primaryModelID = computed(() => (
|
||||||
|
isManualModelInput.value
|
||||||
|
? String(draft.modelID || "").trim()
|
||||||
|
: selectedModelIDs.value.includes(draft.modelID) ? draft.modelID : selectedModelIDs.value[0] || ""
|
||||||
|
));
|
||||||
|
const selectedTestAdapter = computed(() => {
|
||||||
|
if (isManualModelInput.value) {
|
||||||
|
const modelID = primaryModelID.value;
|
||||||
|
return normalizeModelAdapter({
|
||||||
|
...draft,
|
||||||
|
modelID,
|
||||||
|
displayName: modelID,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const adapters = buildModelAdaptersFromModelIDs(draft, selectedModelIDs.value, modelPrefix.value);
|
||||||
|
return adapters.find((adapter) => adapter.modelID === primaryModelID.value)
|
||||||
|
?? adapters[0]
|
||||||
|
?? normalizeModelAdapter(draft);
|
||||||
|
});
|
||||||
|
const currentRequestHash = computed(() => buildModelAdapterTestRequestHash(selectedTestAdapter.value));
|
||||||
|
const directModelTestResult = computed(() => getModelAdapterTestResult(selectedTestAdapter.value));
|
||||||
const rememberedModelTestResult = computed(() =>
|
const rememberedModelTestResult = computed(() =>
|
||||||
lastTestAdapterID.value ? getModelAdapterTestResultByID(lastTestAdapterID.value) : null,
|
lastTestAdapterID.value ? getModelAdapterTestResultByID(lastTestAdapterID.value) : null,
|
||||||
);
|
);
|
||||||
const activeModelTestResult = computed(() => directModelTestResult.value || rememberedModelTestResult.value);
|
const activeModelTestResult = computed(() => directModelTestResult.value || rememberedModelTestResult.value);
|
||||||
const modelTestResultStale = computed(() =>
|
const modelTestResultStale = computed(() =>
|
||||||
isModelAdapterTestResultStale(draft, activeModelTestResult.value),
|
isModelAdapterTestResultStale(selectedTestAdapter.value, activeModelTestResult.value),
|
||||||
);
|
);
|
||||||
const isCurrentConfigTesting = computed(() => directModelTestResult.value?.status === "running");
|
const isCurrentConfigTesting = computed(() => directModelTestResult.value?.status === "running");
|
||||||
const modelTestSummary = computed(() => {
|
const modelTestSummary = computed(() => {
|
||||||
@@ -125,7 +167,8 @@ function ensureAnthropicThinkingEffort() {
|
|||||||
|
|
||||||
const fieldTips = {
|
const fieldTips = {
|
||||||
displayName: "仅用于界面展示,便于你区分不同模型。",
|
displayName: "仅用于界面展示,便于你区分不同模型。",
|
||||||
modelID: "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
|
modelID: "可多选。保存时只会写入选中的模型,每个模型生成一条配置。",
|
||||||
|
manualModelID: "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
|
||||||
baseURL: "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
|
baseURL: "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
|
||||||
apiKey: "调用该模型服务需要使用的访问密钥。",
|
apiKey: "调用该模型服务需要使用的访问密钥。",
|
||||||
contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
|
contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
|
||||||
@@ -146,6 +189,11 @@ async function loadContext() {
|
|||||||
editorIndex.value = typeof ctx.index === "number" ? ctx.index : -1;
|
editorIndex.value = typeof ctx.index === "number" ? ctx.index : -1;
|
||||||
const parsed = JSON.parse(ctx.adapterJSON || "{}");
|
const parsed = JSON.parse(ctx.adapterJSON || "{}");
|
||||||
Object.assign(draft, normalizeModelAdapter(parsed));
|
Object.assign(draft, normalizeModelAdapter(parsed));
|
||||||
|
if (draft.modelID) {
|
||||||
|
availableModelIDs.value = [draft.modelID];
|
||||||
|
selectedModelIDs.value = [draft.modelID];
|
||||||
|
}
|
||||||
|
modelPrefix.value = draft.displayName || "";
|
||||||
if (!draft.type) {
|
if (!draft.type) {
|
||||||
draft.type = "openai";
|
draft.type = "openai";
|
||||||
}
|
}
|
||||||
@@ -157,8 +205,81 @@ async function loadContext() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncSelectionWithAvailable() {
|
||||||
|
const available = availableModelIDs.value;
|
||||||
|
const kept = selectedModelIDs.value.filter((modelID) => available.includes(modelID));
|
||||||
|
selectedModelIDs.value = kept;
|
||||||
|
draft.modelID = kept.includes(draft.modelID) ? draft.modelID : kept[0] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModelSelectionChange(values) {
|
||||||
|
selectedModelIDs.value = values;
|
||||||
|
draft.modelID = values.includes(draft.modelID) ? draft.modelID : values[0] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshModelList() {
|
||||||
|
const baseURL = String(draft.baseURL || "").trim();
|
||||||
|
const apiKey = String(draft.apiKey || "").trim();
|
||||||
|
if (!baseURL || !apiKey || !draft.type) {
|
||||||
|
modelSelectionMode.value = "auto";
|
||||||
|
availableModelIDs.value = draft.modelID ? [draft.modelID] : [];
|
||||||
|
syncSelectionWithAvailable();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestSeq = modelListRequestSeq.value + 1;
|
||||||
|
modelListRequestSeq.value = requestSeq;
|
||||||
|
modelSelectionMode.value = "auto";
|
||||||
|
modelListLoading.value = true;
|
||||||
|
try {
|
||||||
|
const models = await fetchAvailableModelIDs({
|
||||||
|
type: draft.type,
|
||||||
|
baseURL,
|
||||||
|
apiKey,
|
||||||
|
customHeadersEnabled: draft.customHeadersEnabled,
|
||||||
|
customHeadersJSON: draft.customHeadersJSON,
|
||||||
|
});
|
||||||
|
if (requestSeq !== modelListRequestSeq.value) {
|
||||||
|
return availableModelIDs.value;
|
||||||
|
}
|
||||||
|
if (editorIndex.value >= 0 && !existingModelPrefixCleared.value) {
|
||||||
|
modelPrefix.value = "";
|
||||||
|
existingModelPrefixCleared.value = true;
|
||||||
|
}
|
||||||
|
modelSelectionMode.value = "auto";
|
||||||
|
availableModelIDs.value = models;
|
||||||
|
syncSelectionWithAvailable();
|
||||||
|
return models;
|
||||||
|
} catch (_error) {
|
||||||
|
if (requestSeq === modelListRequestSeq.value) {
|
||||||
|
modelSelectionMode.value = "manual";
|
||||||
|
availableModelIDs.value = [];
|
||||||
|
selectedModelIDs.value = [];
|
||||||
|
}
|
||||||
|
return availableModelIDs.value;
|
||||||
|
} finally {
|
||||||
|
if (requestSeq === modelListRequestSeq.value) {
|
||||||
|
modelListLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function persistDraft() {
|
async function persistDraft() {
|
||||||
const adapter = normalizeModelAdapter(draft);
|
const models = activeModelIDs.value;
|
||||||
|
if (models.length === 0) {
|
||||||
|
const error = isManualModelInput.value ? "请填写模型标识" : "请先选择要保存的模型";
|
||||||
|
errorMessage.value = error;
|
||||||
|
return { ok: false, error, adapter: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedModelID = primaryModelID.value || models[0];
|
||||||
|
const adapter = normalizeModelAdapter({
|
||||||
|
...draft,
|
||||||
|
modelID: selectedModelID,
|
||||||
|
displayName: isManualModelInput.value
|
||||||
|
? String(modelPrefix.value || selectedModelID).trim()
|
||||||
|
: `${String(modelPrefix.value || "模型").trim()}-${selectedModelID}`,
|
||||||
|
});
|
||||||
|
|
||||||
const singleCheck = validateModelAdapters([adapter]);
|
const singleCheck = validateModelAdapters([adapter]);
|
||||||
if (singleCheck) {
|
if (singleCheck) {
|
||||||
@@ -166,7 +287,9 @@ async function persistDraft() {
|
|||||||
return { ok: false, error: singleCheck, adapter: null };
|
return { ok: false, error: singleCheck, adapter: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await saveModelAdapterAt(editorIndex.value, adapter);
|
const result = isManualModelInput.value
|
||||||
|
? await saveModelAdapterAt(editorIndex.value, adapter)
|
||||||
|
: await saveModelAdaptersFromModelIDs(adapter, models, modelPrefix.value, selectedModelID);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
errorMessage.value = result.error;
|
errorMessage.value = result.error;
|
||||||
return { ok: false, error: result.error, adapter: null };
|
return { ok: false, error: result.error, adapter: null };
|
||||||
@@ -177,12 +300,14 @@ async function persistDraft() {
|
|||||||
}
|
}
|
||||||
if (result.adapter) {
|
if (result.adapter) {
|
||||||
Object.assign(draft, normalizeModelAdapter(result.adapter));
|
Object.assign(draft, normalizeModelAdapter(result.adapter));
|
||||||
|
} else {
|
||||||
|
Object.assign(draft, adapter);
|
||||||
}
|
}
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
error: "",
|
error: "",
|
||||||
adapter: result.adapter ? normalizeModelAdapter(result.adapter) : normalizeModelAdapter(draft),
|
adapter: result.adapter ? normalizeModelAdapter(result.adapter) : normalizeModelAdapter(adapter),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +325,10 @@ async function handleCancel() {
|
|||||||
|
|
||||||
function handleModelTypeChange(type) {
|
function handleModelTypeChange(type) {
|
||||||
draft.type = type;
|
draft.type = type;
|
||||||
|
modelSelectionMode.value = "auto";
|
||||||
|
availableModelIDs.value = [];
|
||||||
|
selectedModelIDs.value = [];
|
||||||
|
draft.modelID = "";
|
||||||
if (type === "openai" && !draft.openAIEndpoint) {
|
if (type === "openai" && !draft.openAIEndpoint) {
|
||||||
draft.openAIEndpoint = OPENAI_ENDPOINT_RESPONSES;
|
draft.openAIEndpoint = OPENAI_ENDPOINT_RESPONSES;
|
||||||
} else if (type === "anthropic") {
|
} else if (type === "anthropic") {
|
||||||
@@ -273,9 +402,32 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [draft.type, draft.baseURL, draft.apiKey, draft.customHeadersEnabled, draft.customHeadersJSON],
|
||||||
|
() => {
|
||||||
|
window.clearTimeout(modelListDebounceTimer);
|
||||||
|
const baseURL = String(draft.baseURL || "").trim();
|
||||||
|
const apiKey = String(draft.apiKey || "").trim();
|
||||||
|
if (!baseURL || !apiKey) {
|
||||||
|
modelSelectionMode.value = "auto";
|
||||||
|
modelListLoading.value = false;
|
||||||
|
availableModelIDs.value = draft.modelID ? [draft.modelID] : [];
|
||||||
|
syncSelectionWithAvailable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modelListDebounceTimer = window.setTimeout(() => {
|
||||||
|
void refreshModelList();
|
||||||
|
}, 600);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadContext();
|
await loadContext();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.clearTimeout(modelListDebounceTimer);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -318,26 +470,13 @@ onMounted(async () => {
|
|||||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<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="fieldTips.displayName" />
|
<Tooltip :content="fieldTips.baseURL" />
|
||||||
<span>显示名称</span>
|
<span>接口地址</span>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
v-model="draft.displayName"
|
v-model="draft.baseURL"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="例如:OpenAI - GPT-4.1"
|
:placeholder="interfacePlaceholder"
|
||||||
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="flex flex-col gap-1">
|
|
||||||
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
|
|
||||||
<Tooltip :content="fieldTips.modelID" />
|
|
||||||
<span>模型标识</span>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
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]"
|
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -358,17 +497,41 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<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="fieldTips.baseURL" />
|
<Tooltip :content="isManualModelInput ? fieldTips.displayName : fieldTips.modelID" />
|
||||||
<span>接口地址</span>
|
<span>{{ isManualModelInput ? "显示名称" : "模型前缀" }}</span>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
v-model="draft.baseURL"
|
v-model="modelPrefix"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="interfacePlaceholder"
|
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]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
|
||||||
|
<Tooltip :content="isManualModelInput ? fieldTips.manualModelID : fieldTips.modelID" />
|
||||||
|
<span>{{ isManualModelInput ? "模型标识" : "选择模型" }}</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
v-if="isManualModelInput"
|
||||||
|
v-model="draft.modelID"
|
||||||
|
type="text"
|
||||||
|
placeholder="例如:gpt-4.1"
|
||||||
|
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||||
|
/>
|
||||||
|
<MultiSelect
|
||||||
|
v-else
|
||||||
|
:model-value="selectedModelIDs"
|
||||||
|
:options="modelOptions"
|
||||||
|
:disabled="modelListLoading || modelOptions.length === 0"
|
||||||
|
:placeholder="modelListLoading ? '正在获取模型...' : '请输入接口地址和访问密钥'"
|
||||||
|
:summary-formatter="(count, total) => `已选择 ${count} / ${total} 个模型`"
|
||||||
|
aria-label="选择模型"
|
||||||
|
@update:model-value="handleModelSelectionChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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="fieldTips.contextWindowTokens" />
|
<Tooltip :content="fieldTips.contextWindowTokens" />
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import (
|
|||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
goruntime "runtime"
|
goruntime "runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -37,6 +39,8 @@ const (
|
|||||||
appName = "Cursor助手"
|
appName = "Cursor助手"
|
||||||
// adRefreshInterval 表示后台广告拉取间隔。
|
// adRefreshInterval 表示后台广告拉取间隔。
|
||||||
adRefreshInterval = 3 * time.Minute
|
adRefreshInterval = 3 * time.Minute
|
||||||
|
// disableWebViewSandboxEnv allows affected VDI users to opt out of the WebView2 sandbox.
|
||||||
|
disableWebViewSandboxEnv = "CURSOR_BYOK_DISABLE_WEBVIEW_SANDBOX"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EmbeddedResources 定义了当前模块中的 EmbeddedResources 类型。
|
// EmbeddedResources 定义了当前模块中的 EmbeddedResources 类型。
|
||||||
@@ -134,6 +138,9 @@ func Run(resources EmbeddedResources) error {
|
|||||||
Assets: application.AssetOptions{
|
Assets: application.AssetOptions{
|
||||||
Handler: application.AssetFileServerFS(resources.Assets),
|
Handler: application.AssetFileServerFS(resources.Assets),
|
||||||
},
|
},
|
||||||
|
Windows: application.WindowsOptions{
|
||||||
|
AdditionalBrowserArgs: windowsAdditionalBrowserArgs(),
|
||||||
|
},
|
||||||
Mac: application.MacOptions{
|
Mac: application.MacOptions{
|
||||||
ActivationPolicy: application.ActivationPolicyAccessory,
|
ActivationPolicy: application.ActivationPolicyAccessory,
|
||||||
ApplicationShouldTerminateAfterLastWindowClosed: false,
|
ApplicationShouldTerminateAfterLastWindowClosed: false,
|
||||||
@@ -415,6 +422,14 @@ func Run(resources EmbeddedResources) error {
|
|||||||
return app.Run()
|
return app.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func windowsAdditionalBrowserArgs() []string {
|
||||||
|
disableSandbox, err := strconv.ParseBool(strings.TrimSpace(os.Getenv(disableWebViewSandboxEnv)))
|
||||||
|
if err != nil || !disableSandbox {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{"--no-sandbox"}
|
||||||
|
}
|
||||||
|
|
||||||
func browserReachableLoopbackBaseURL(listenAddr string) string {
|
func browserReachableLoopbackBaseURL(listenAddr string) string {
|
||||||
host, port, err := net.SplitHostPort(strings.TrimSpace(listenAddr))
|
host, port, err := net.SplitHostPort(strings.TrimSpace(listenAddr))
|
||||||
if err != nil || strings.TrimSpace(port) == "" {
|
if err != nil || strings.TrimSpace(port) == "" {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWindowsAdditionalBrowserArgs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "unset"},
|
||||||
|
{name: "enabled with one", env: "1", want: []string{"--no-sandbox"}},
|
||||||
|
{name: "enabled with true", env: " true ", want: []string{"--no-sandbox"}},
|
||||||
|
{name: "disabled", env: "false"},
|
||||||
|
{name: "invalid", env: "yes"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Setenv(disableWebViewSandboxEnv, tt.env)
|
||||||
|
if got := windowsAdditionalBrowserArgs(); !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Fatalf("windowsAdditionalBrowserArgs() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
TurnPhaseWaitingExternal TurnPhase = "waiting_external"
|
TurnPhaseWaitingExternal TurnPhase = "waiting_external"
|
||||||
TurnPhaseAwaitingUser TurnPhase = "awaiting_user"
|
TurnPhaseAwaitingUser TurnPhase = "awaiting_user"
|
||||||
TurnPhaseCompacting TurnPhase = "compacting"
|
TurnPhaseCompacting TurnPhase = "compacting"
|
||||||
|
TurnPhaseCheckpointing TurnPhase = "checkpointing"
|
||||||
TurnPhaseCompleted TurnPhase = "completed"
|
TurnPhaseCompleted TurnPhase = "completed"
|
||||||
TurnPhaseFailed TurnPhase = "failed"
|
TurnPhaseFailed TurnPhase = "failed"
|
||||||
TurnPhaseCanceled TurnPhase = "canceled"
|
TurnPhaseCanceled TurnPhase = "canceled"
|
||||||
@@ -66,6 +67,7 @@ const (
|
|||||||
streamTimerNonStreamingRecovery streamTimerKind = "non_streaming_recovery"
|
streamTimerNonStreamingRecovery streamTimerKind = "non_streaming_recovery"
|
||||||
streamTimerShellForeground streamTimerKind = "shell_foreground"
|
streamTimerShellForeground streamTimerKind = "shell_foreground"
|
||||||
streamTimerShellTransportClose streamTimerKind = "shell_transport_close"
|
streamTimerShellTransportClose streamTimerKind = "shell_transport_close"
|
||||||
|
streamTimerCheckpointBlobs streamTimerKind = "checkpoint_blobs"
|
||||||
streamTimerOrphanCancel streamTimerKind = "orphan_cancel"
|
streamTimerOrphanCancel streamTimerKind = "orphan_cancel"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -318,6 +320,9 @@ func (service *Service) handleStreamCommand(stream *ActiveStream, command stream
|
|||||||
case streamCommandCancel:
|
case streamCommandCancel:
|
||||||
return service.handleCancelIntent(command.Intent)
|
return service.handleCancelIntent(command.Intent)
|
||||||
case streamCommandMetadata:
|
case streamCommandMetadata:
|
||||||
|
if strings.TrimSpace(command.Intent.Kind) == "kv_result" {
|
||||||
|
return service.handleCheckpointBlobResult(stream, command.Intent.KVClientMessage)
|
||||||
|
}
|
||||||
return service.handleMetadataIntent(command.Intent)
|
return service.handleMetadataIntent(command.Intent)
|
||||||
case streamCommandExecResult:
|
case streamCommandExecResult:
|
||||||
return service.handleExecResult(command.Intent)
|
return service.handleExecResult(command.Intent)
|
||||||
@@ -1003,6 +1008,8 @@ func (service *Service) handleTimerEvent(stream *ActiveStream, payload *streamTi
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return service.recoverShellWithoutTerminal(stream, current, shellRecoveryReasonTransportClosed)
|
return service.recoverShellWithoutTerminal(stream, current, shellRecoveryReasonTransportClosed)
|
||||||
|
case streamTimerCheckpointBlobs:
|
||||||
|
return service.handleCheckpointBlobTimeout(stream)
|
||||||
case streamTimerOrphanCancel:
|
case streamTimerOrphanCancel:
|
||||||
stream.mu.Lock()
|
stream.mu.Lock()
|
||||||
subscriberCount := len(stream.Subscribers)
|
subscriberCount := len(stream.Subscribers)
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ func (broker *StreamBroker) OpenStream(requestID string, conversationID string,
|
|||||||
if existing.BackgroundShellActions == nil {
|
if existing.BackgroundShellActions == nil {
|
||||||
existing.BackgroundShellActions = make(map[string]time.Time)
|
existing.BackgroundShellActions = make(map[string]time.Time)
|
||||||
}
|
}
|
||||||
|
if existing.PendingCheckpointBlobWrites == nil {
|
||||||
|
existing.PendingCheckpointBlobWrites = make(map[uint32]string)
|
||||||
|
}
|
||||||
|
if existing.ConfirmedCheckpointBlobs == nil {
|
||||||
|
existing.ConfirmedCheckpointBlobs = make(map[string]struct{})
|
||||||
|
}
|
||||||
existing.UpdatedAt = time.Now().UTC()
|
existing.UpdatedAt = time.Now().UTC()
|
||||||
existing.mu.Unlock()
|
existing.mu.Unlock()
|
||||||
return existing, nil
|
return existing, nil
|
||||||
@@ -102,6 +108,8 @@ func (broker *StreamBroker) OpenStream(requestID string, conversationID string,
|
|||||||
BackgroundShellsByMessageID: make(map[uint32]string),
|
BackgroundShellsByMessageID: make(map[uint32]string),
|
||||||
BackgroundShellsByExecID: make(map[string]string),
|
BackgroundShellsByExecID: make(map[string]string),
|
||||||
BackgroundShellActions: make(map[string]time.Time),
|
BackgroundShellActions: make(map[string]time.Time),
|
||||||
|
PendingCheckpointBlobWrites: make(map[uint32]string),
|
||||||
|
ConfirmedCheckpointBlobs: make(map[string]struct{}),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package forwarder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
|
"cursor/gen/agentv1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const checkpointBlobWriteTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
type pendingCheckpointBlobWrite struct {
|
||||||
|
requestID uint32
|
||||||
|
blob CheckpointBlob
|
||||||
|
}
|
||||||
|
|
||||||
|
func clonePendingTurnCompletion(completion *pendingTurnCompletion) *pendingTurnCompletion {
|
||||||
|
if completion == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := *completion
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) queueCheckpointProjection(stream *ActiveStream, projection *CheckpointProjection, completion *pendingTurnCompletion) error {
|
||||||
|
if service == nil || stream == nil || projection == nil || projection.State == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
state, ok := proto.Clone(projection.State).(*agentv1.ConversationStateStructure)
|
||||||
|
if !ok || state == nil {
|
||||||
|
return fmt.Errorf("clone checkpoint state")
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.mu.Lock()
|
||||||
|
if stream.PendingCheckpointBlobWrites == nil {
|
||||||
|
stream.PendingCheckpointBlobWrites = make(map[uint32]string)
|
||||||
|
}
|
||||||
|
if stream.ConfirmedCheckpointBlobs == nil {
|
||||||
|
stream.ConfirmedCheckpointBlobs = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
if completion == nil && stream.PendingCheckpoint != nil {
|
||||||
|
completion = stream.PendingCheckpoint.Completion
|
||||||
|
}
|
||||||
|
required := make(map[string]struct{}, len(projection.Blobs))
|
||||||
|
pendingKeys := make(map[string]struct{}, len(stream.PendingCheckpointBlobWrites))
|
||||||
|
for _, key := range stream.PendingCheckpointBlobWrites {
|
||||||
|
pendingKeys[key] = struct{}{}
|
||||||
|
}
|
||||||
|
toWrite := make([]pendingCheckpointBlobWrite, 0, len(projection.Blobs))
|
||||||
|
for _, blob := range projection.Blobs {
|
||||||
|
key := string(blob.ID)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
required[key] = struct{}{}
|
||||||
|
if _, confirmed := stream.ConfirmedCheckpointBlobs[key]; confirmed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, pending := pendingKeys[key]; pending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stream.NextCheckpointBlobRequestID++
|
||||||
|
if stream.NextCheckpointBlobRequestID == 0 {
|
||||||
|
stream.NextCheckpointBlobRequestID++
|
||||||
|
}
|
||||||
|
requestID := stream.NextCheckpointBlobRequestID
|
||||||
|
stream.PendingCheckpointBlobWrites[requestID] = key
|
||||||
|
pendingKeys[key] = struct{}{}
|
||||||
|
toWrite = append(toWrite, pendingCheckpointBlobWrite{requestID: requestID, blob: blob})
|
||||||
|
}
|
||||||
|
stream.PendingCheckpoint = &pendingCheckpointPublish{
|
||||||
|
State: state,
|
||||||
|
Required: required,
|
||||||
|
Completion: clonePendingTurnCompletion(completion),
|
||||||
|
}
|
||||||
|
if completion != nil {
|
||||||
|
stream.Phase = TurnPhaseCheckpointing
|
||||||
|
}
|
||||||
|
stream.UpdatedAt = time.Now().UTC()
|
||||||
|
stream.mu.Unlock()
|
||||||
|
|
||||||
|
for _, write := range toWrite {
|
||||||
|
if err := service.broker.Publish(stream.RequestID, StreamEvent{
|
||||||
|
Message: buildSetCheckpointBlobMessage(write.requestID, write.blob),
|
||||||
|
}); err != nil {
|
||||||
|
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf("publish checkpoint blob: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if service.checkpointProjectionReady(stream) {
|
||||||
|
return service.publishReadyCheckpoint(stream)
|
||||||
|
}
|
||||||
|
service.scheduleStreamTimer(
|
||||||
|
stream,
|
||||||
|
providerTimerKey(streamTimerCheckpointBlobs, ""),
|
||||||
|
checkpointBlobWriteTimeout,
|
||||||
|
streamTimerCheckpointBlobs,
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
"checkpoint blob write timeout",
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) checkpointProjectionReady(stream *ActiveStream) bool {
|
||||||
|
if stream == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
defer stream.mu.Unlock()
|
||||||
|
if stream.PendingCheckpoint == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for key := range stream.PendingCheckpoint.Required {
|
||||||
|
if _, confirmed := stream.ConfirmedCheckpointBlobs[key]; !confirmed {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) handleCheckpointBlobResult(stream *ActiveStream, message *agentv1.KvClientMessage) error {
|
||||||
|
if service == nil || stream == nil || message == nil || message.GetSetBlobResult() == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
key, ok := stream.PendingCheckpointBlobWrites[message.GetId()]
|
||||||
|
if ok {
|
||||||
|
delete(stream.PendingCheckpointBlobWrites, message.GetId())
|
||||||
|
}
|
||||||
|
required := false
|
||||||
|
if ok && stream.PendingCheckpoint != nil {
|
||||||
|
_, required = stream.PendingCheckpoint.Required[key]
|
||||||
|
}
|
||||||
|
if ok && message.GetSetBlobResult().GetError() == nil {
|
||||||
|
stream.ConfirmedCheckpointBlobs[key] = struct{}{}
|
||||||
|
}
|
||||||
|
stream.UpdatedAt = time.Now().UTC()
|
||||||
|
stream.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if blobErr := message.GetSetBlobResult().GetError(); blobErr != nil && required {
|
||||||
|
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf(
|
||||||
|
"client rejected checkpoint blob %s: %s",
|
||||||
|
hex.EncodeToString([]byte(key)),
|
||||||
|
firstNonEmpty(strings.TrimSpace(blobErr.GetMessage()), "unknown error"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if service.checkpointProjectionReady(stream) {
|
||||||
|
return service.publishReadyCheckpoint(stream)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) publishReadyCheckpoint(stream *ActiveStream) error {
|
||||||
|
if service == nil || stream == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
pending := stream.PendingCheckpoint
|
||||||
|
if pending == nil {
|
||||||
|
stream.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for key := range pending.Required {
|
||||||
|
if _, confirmed := stream.ConfirmedCheckpointBlobs[key]; !confirmed {
|
||||||
|
stream.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream.PendingCheckpoint = nil
|
||||||
|
state := pending.State
|
||||||
|
completion := clonePendingTurnCompletion(pending.Completion)
|
||||||
|
stream.UpdatedAt = time.Now().UTC()
|
||||||
|
stream.mu.Unlock()
|
||||||
|
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
|
||||||
|
if err := service.broker.Publish(stream.RequestID, StreamEvent{Message: buildCheckpointMessage(state)}); err != nil {
|
||||||
|
if completion != nil {
|
||||||
|
log.Printf("forwarder checkpoint publish skipped before successful terminal request_id=%s err=%v", stream.RequestID, err)
|
||||||
|
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if completion != nil {
|
||||||
|
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) handleCheckpointBlobTimeout(stream *ActiveStream) error {
|
||||||
|
if stream == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
pendingCount := len(stream.PendingCheckpointBlobWrites)
|
||||||
|
stream.mu.Unlock()
|
||||||
|
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf("%d checkpoint blob writes timed out", pendingCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) finishAfterCheckpointSyncFailure(stream *ActiveStream, cause error) error {
|
||||||
|
if stream == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
pending := stream.PendingCheckpoint
|
||||||
|
stream.PendingCheckpoint = nil
|
||||||
|
stream.PendingCheckpointBlobWrites = make(map[uint32]string)
|
||||||
|
stream.UpdatedAt = time.Now().UTC()
|
||||||
|
stream.mu.Unlock()
|
||||||
|
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
|
||||||
|
if cause != nil {
|
||||||
|
log.Printf("forwarder checkpoint blob sync skipped request_id=%s conversation_id=%s err=%v", stream.RequestID, stream.ConversationID, cause)
|
||||||
|
}
|
||||||
|
if pending != nil && pending.Completion != nil {
|
||||||
|
return service.finishSuccessfulTurnAfterCheckpoint(stream, *pending.Completion)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) discardPendingCheckpoint(stream *ActiveStream, reason string) {
|
||||||
|
if stream == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
stream.PendingCheckpoint = nil
|
||||||
|
stream.PendingCheckpointBlobWrites = make(map[uint32]string)
|
||||||
|
stream.UpdatedAt = time.Now().UTC()
|
||||||
|
stream.mu.Unlock()
|
||||||
|
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
|
||||||
|
if strings.TrimSpace(reason) != "" {
|
||||||
|
log.Printf("forwarder pending checkpoint discarded request_id=%s conversation_id=%s reason=%s", stream.RequestID, stream.ConversationID, strings.TrimSpace(reason))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package forwarder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
|
||||||
|
"cursor/gen/agentv1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckpointBlobSyncPublishesCheckpointAfterAcknowledgements(t *testing.T) {
|
||||||
|
service, stream, projection := testCheckpointBlobProjection(t)
|
||||||
|
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
|
||||||
|
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
events := readCheckpointTestEvents(t, service, stream)
|
||||||
|
if len(events) != len(projection.Blobs) {
|
||||||
|
t.Fatalf("events before ACK = %d, want %d Blob writes", len(events), len(projection.Blobs))
|
||||||
|
}
|
||||||
|
for _, event := range events {
|
||||||
|
if event.Message.GetKvServerMessage().GetSetBlobArgs() == nil {
|
||||||
|
t.Fatalf("event before ACK = %#v, want set_blob_args", event.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
acknowledgeCheckpointBlobs(t, service, stream)
|
||||||
|
events = readCheckpointTestEvents(t, service, stream)
|
||||||
|
checkpoint := events[len(events)-1].Message.GetConversationCheckpointUpdate()
|
||||||
|
if checkpoint == nil || len(checkpoint.GetTurns()) != 1 {
|
||||||
|
t.Fatalf("last event checkpoint = %#v, want one Blob-backed turn", checkpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckpointBlobSyncPublishesCheckpointBeforeSuccessfulTerminal(t *testing.T) {
|
||||||
|
service, stream, projection := testCheckpointBlobProjection(t)
|
||||||
|
completion := &pendingTurnCompletion{
|
||||||
|
RequestID: stream.RequestID,
|
||||||
|
Usage: turnUsageSnapshot{InputTokens: 11, OutputTokens: 7},
|
||||||
|
}
|
||||||
|
if err := service.queueCheckpointProjection(stream, projection, completion); err != nil {
|
||||||
|
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
acknowledgeCheckpointBlobs(t, service, stream)
|
||||||
|
|
||||||
|
events := readCheckpointTestEvents(t, service, stream)
|
||||||
|
checkpointIndex, turnEndedIndex, endIndex := -1, -1, -1
|
||||||
|
for index, event := range events {
|
||||||
|
switch {
|
||||||
|
case event.Message.GetConversationCheckpointUpdate() != nil:
|
||||||
|
checkpointIndex = index
|
||||||
|
case event.Message.GetInteractionUpdate().GetTurnEnded() != nil:
|
||||||
|
turnEndedIndex = index
|
||||||
|
case event.End:
|
||||||
|
endIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if checkpointIndex < 0 || turnEndedIndex <= checkpointIndex || endIndex <= turnEndedIndex {
|
||||||
|
t.Fatalf("terminal order checkpoint=%d turn_ended=%d end=%d", checkpointIndex, turnEndedIndex, endIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckpointBlobTimeoutDoesNotFailSuccessfulTurn(t *testing.T) {
|
||||||
|
service, stream, projection := testCheckpointBlobProjection(t)
|
||||||
|
completion := &pendingTurnCompletion{
|
||||||
|
RequestID: stream.RequestID,
|
||||||
|
Usage: turnUsageSnapshot{InputTokens: 11, OutputTokens: 7},
|
||||||
|
}
|
||||||
|
if err := service.queueCheckpointProjection(stream, projection, completion); err != nil {
|
||||||
|
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := service.handleCheckpointBlobTimeout(stream); err != nil {
|
||||||
|
t.Fatalf("handleCheckpointBlobTimeout() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := readCheckpointTestEvents(t, service, stream)
|
||||||
|
var checkpoint, turnEnded, successfulEnd bool
|
||||||
|
for _, event := range events {
|
||||||
|
checkpoint = checkpoint || event.Message.GetConversationCheckpointUpdate() != nil
|
||||||
|
turnEnded = turnEnded || event.Message.GetInteractionUpdate().GetTurnEnded() != nil
|
||||||
|
successfulEnd = successfulEnd || event.End && event.TerminalErrorCode == ""
|
||||||
|
}
|
||||||
|
if checkpoint || !turnEnded || !successfulEnd {
|
||||||
|
t.Fatalf("timeout events checkpoint=%v turn_ended=%v successful_end=%v", checkpoint, turnEnded, successfulEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancellationDiscardsPendingCheckpointAndIgnoresLateAcknowledgements(t *testing.T) {
|
||||||
|
service, stream, projection := testCheckpointBlobProjection(t)
|
||||||
|
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
|
||||||
|
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites))
|
||||||
|
for requestID := range stream.PendingCheckpointBlobWrites {
|
||||||
|
requestIDs = append(requestIDs, requestID)
|
||||||
|
}
|
||||||
|
stream.mu.Unlock()
|
||||||
|
if err := service.handleCancelIntent(InboundIntent{
|
||||||
|
Kind: "cancel",
|
||||||
|
RequestID: stream.RequestID,
|
||||||
|
CancelReason: "user stopped",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handleCancelIntent() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, requestID := range requestIDs {
|
||||||
|
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
|
||||||
|
Id: requestID,
|
||||||
|
Message: &agentv1.KvClientMessage_SetBlobResult{
|
||||||
|
SetBlobResult: &agentv1.SetBlobResult{},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("late ACK %d error = %v", requestID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events := readCheckpointTestEvents(t, service, stream)
|
||||||
|
var checkpoint, canceledEnd bool
|
||||||
|
for _, event := range events {
|
||||||
|
checkpoint = checkpoint || event.Message.GetConversationCheckpointUpdate() != nil
|
||||||
|
canceledEnd = canceledEnd || event.End && event.TerminalErrorCode == "canceled"
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
pending := stream.PendingCheckpoint
|
||||||
|
stream.mu.Unlock()
|
||||||
|
if checkpoint || !canceledEnd || pending != nil {
|
||||||
|
t.Fatalf("cancel events checkpoint=%v canceled_end=%v pending=%v", checkpoint, canceledEnd, pending != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCheckpointBlobProjection(t *testing.T) (*Service, *ActiveStream, *CheckpointProjection) {
|
||||||
|
t.Helper()
|
||||||
|
broker := NewStreamBroker()
|
||||||
|
service := &Service{
|
||||||
|
store: NewConversationFileStore(t.TempDir()),
|
||||||
|
projector: NewHistoryProjector(),
|
||||||
|
broker: broker,
|
||||||
|
}
|
||||||
|
stream, err := broker.OpenStream(
|
||||||
|
"request-1", "conversation-1", 1, "default", "default",
|
||||||
|
agentv1.AgentMode_AGENT_MODE_AGENT, "hello",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenStream() error = %v", err)
|
||||||
|
}
|
||||||
|
conversation := &ConversationFile{
|
||||||
|
ConversationID: "conversation-1",
|
||||||
|
RootConversationID: "conversation-1",
|
||||||
|
Mode: "agent",
|
||||||
|
NextTurnSeq: 2,
|
||||||
|
NextEntrySeq: 3,
|
||||||
|
TokenDetailsMaxTokens: projectedConversationMaxTokens,
|
||||||
|
Entries: []HistoryEntry{
|
||||||
|
testCheckpointUserEntry(t),
|
||||||
|
newAssistantTextEntry(1, "request-1", "hi", "", ""),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
projection, err := service.projector.ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := service.replaceCheckpointConversation(stream, conversation); err != nil {
|
||||||
|
t.Fatalf("replaceCheckpointConversation() error = %v", err)
|
||||||
|
}
|
||||||
|
return service, stream, projection
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCheckpointUserEntry(t *testing.T) HistoryEntry {
|
||||||
|
t.Helper()
|
||||||
|
payload, err := protojson.Marshal(&agentv1.UserMessage{Text: "hello", MessageId: "message-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal user message: %v", err)
|
||||||
|
}
|
||||||
|
return HistoryEntry{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: payload}
|
||||||
|
}
|
||||||
|
|
||||||
|
func acknowledgeCheckpointBlobs(t *testing.T, service *Service, stream *ActiveStream) {
|
||||||
|
t.Helper()
|
||||||
|
for {
|
||||||
|
stream.mu.Lock()
|
||||||
|
requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites))
|
||||||
|
for requestID := range stream.PendingCheckpointBlobWrites {
|
||||||
|
requestIDs = append(requestIDs, requestID)
|
||||||
|
}
|
||||||
|
stream.mu.Unlock()
|
||||||
|
if len(requestIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, requestID := range requestIDs {
|
||||||
|
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
|
||||||
|
Id: requestID,
|
||||||
|
Message: &agentv1.KvClientMessage_SetBlobResult{
|
||||||
|
SetBlobResult: &agentv1.SetBlobResult{},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handleCheckpointBlobResult(%d) error = %v", requestID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCheckpointTestEvents(t *testing.T, service *Service, stream *ActiveStream) []StreamEvent {
|
||||||
|
t.Helper()
|
||||||
|
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFromCursor() error = %v", err)
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
@@ -245,6 +245,22 @@ func buildCheckpointMessage(state *agentv1.ConversationStateStructure) *agentv1.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildSetCheckpointBlobMessage(id uint32, blob CheckpointBlob) *agentv1.AgentServerMessage {
|
||||||
|
return &agentv1.AgentServerMessage{
|
||||||
|
Message: &agentv1.AgentServerMessage_KvServerMessage{
|
||||||
|
KvServerMessage: &agentv1.KvServerMessage{
|
||||||
|
Id: id,
|
||||||
|
Message: &agentv1.KvServerMessage_SetBlobArgs{
|
||||||
|
SetBlobArgs: &agentv1.SetBlobArgs{
|
||||||
|
BlobId: append([]byte(nil), blob.ID...),
|
||||||
|
BlobData: append([]byte(nil), blob.Data...),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// buildExecAbortMessage 构造对客户端执行桥的 abort 控制消息。
|
// buildExecAbortMessage 构造对客户端执行桥的 abort 控制消息。
|
||||||
func buildExecAbortMessage(pending runtimecore.PendingExec) *agentv1.AgentServerMessage {
|
func buildExecAbortMessage(pending runtimecore.PendingExec) *agentv1.AgentServerMessage {
|
||||||
return &agentv1.AgentServerMessage{
|
return &agentv1.AgentServerMessage{
|
||||||
|
|||||||
@@ -690,9 +690,21 @@ func appendEntriesInPlace(conversation *ConversationFile, entries []HistoryEntry
|
|||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
assigned := make([]HistoryEntry, 0, len(entries))
|
assigned := make([]HistoryEntry, 0, len(entries))
|
||||||
|
existingIdempotencyKeys := make(map[string]struct{})
|
||||||
|
for _, existing := range conversation.Entries {
|
||||||
|
if key := strings.TrimSpace(existing.IdempotencyKey); key != "" {
|
||||||
|
existingIdempotencyKeys[key] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
maxTurnSeq := conversation.NextTurnSeq - 1
|
maxTurnSeq := conversation.NextTurnSeq - 1
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
next := entry
|
next := entry
|
||||||
|
if key := strings.TrimSpace(next.IdempotencyKey); key != "" {
|
||||||
|
if _, exists := existingIdempotencyKeys[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existingIdempotencyKeys[key] = struct{}{}
|
||||||
|
}
|
||||||
if next.CreatedAt.IsZero() {
|
if next.CreatedAt.IsZero() {
|
||||||
next.CreatedAt = now
|
next.CreatedAt = now
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package forwarder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppendEntriesDeduplicatesIdempotencyKey(t *testing.T) {
|
||||||
|
store := NewConversationFileStore(t.TempDir())
|
||||||
|
entry := HistoryEntry{
|
||||||
|
TurnSeq: 1,
|
||||||
|
RequestID: "request-1",
|
||||||
|
IdempotencyKey: "provider-interrupted-output:test",
|
||||||
|
Role: "assistant",
|
||||||
|
Kind: "assistant_text",
|
||||||
|
Payload: json.RawMessage(`{"text":"partial"}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, assigned, err := store.AppendEntries("conversation-1", []HistoryEntry{entry}); err != nil {
|
||||||
|
t.Fatalf("first AppendEntries() error = %v", err)
|
||||||
|
} else if len(assigned) != 1 {
|
||||||
|
t.Fatalf("first AppendEntries() assigned = %d, want 1", len(assigned))
|
||||||
|
}
|
||||||
|
if _, assigned, err := store.AppendEntries("conversation-1", []HistoryEntry{entry}); err != nil {
|
||||||
|
t.Fatalf("duplicate AppendEntries() error = %v", err)
|
||||||
|
} else if len(assigned) != 0 {
|
||||||
|
t.Fatalf("duplicate AppendEntries() assigned = %d, want 0", len(assigned))
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation, err := store.LoadConversation("conversation-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConversation() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(conversation.Entries) != 1 {
|
||||||
|
t.Fatalf("persisted entries = %d, want 1", len(conversation.Entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelPersistsInterruptedProviderOutputIdempotently(t *testing.T) {
|
||||||
|
service, stream, _ := testCheckpointBlobProjection(t)
|
||||||
|
conversation, _, _, err := service.snapshotCheckpointConversation(stream)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("snapshotCheckpointConversation() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.store.SaveConversationWithEntries(stream.ConversationID, conversation, conversation.Entries); err != nil {
|
||||||
|
t.Fatalf("SaveConversationWithEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.mu.Lock()
|
||||||
|
stream.CurrentModelCallID = "model-call-1"
|
||||||
|
stream.ProviderAccumulatedText = "partial answer"
|
||||||
|
stream.ProviderAccumulatedReasoning = "partial reasoning"
|
||||||
|
stream.mu.Unlock()
|
||||||
|
|
||||||
|
cancel := InboundIntent{
|
||||||
|
Kind: "cancel",
|
||||||
|
RequestID: stream.RequestID,
|
||||||
|
CancelReason: "[canceled] Superseded by newer request",
|
||||||
|
}
|
||||||
|
if err := service.handleCancelIntent(cancel); err != nil {
|
||||||
|
t.Fatalf("first handleCancelIntent() error = %v", err)
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
stream.ProviderAccumulatedText = "late duplicate fragment"
|
||||||
|
stream.mu.Unlock()
|
||||||
|
if err := service.handleCancelIntent(cancel); err != nil {
|
||||||
|
t.Fatalf("duplicate handleCancelIntent() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
persisted, err := service.store.LoadConversation(stream.ConversationID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConversation() error = %v", err)
|
||||||
|
}
|
||||||
|
assistantEntries := 0
|
||||||
|
cancelEntries := 0
|
||||||
|
for _, entry := range persisted.Entries {
|
||||||
|
if entry.Kind == "metadata" {
|
||||||
|
var payload metadataPayload
|
||||||
|
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||||
|
t.Fatalf("decode metadata entry: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Type == "control" && readStringValue(payload.Value["status"]) == "canceled" {
|
||||||
|
cancelEntries++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry.Kind != "assistant_text" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var payload assistantTextPayload
|
||||||
|
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||||
|
t.Fatalf("decode assistant entry: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Text == "partial answer" {
|
||||||
|
assistantEntries++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if assistantEntries != 1 {
|
||||||
|
t.Fatalf("persisted interrupted assistant entries = %d, want 1", assistantEntries)
|
||||||
|
}
|
||||||
|
if cancelEntries != 1 {
|
||||||
|
t.Fatalf("persisted cancel metadata entries = %d, want 1", cancelEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
replay, err := service.projector.ProjectPromptReplay(persisted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectPromptReplay() error = %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, message := range replay {
|
||||||
|
if message.Role == "assistant" && strings.TrimSpace(message.Content) == "partial answer" && strings.TrimSpace(message.ReasoningContent) == "partial reasoning" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("replay = %#v, want interrupted assistant output", replay)
|
||||||
|
}
|
||||||
|
checkpoint, err := service.projector.ProjectCheckpointProjection(persisted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
if checkpoint == nil || checkpoint.State == nil || len(checkpoint.State.GetTurns()) != 1 {
|
||||||
|
t.Fatalf("checkpoint state = %#v, want interrupted turn", checkpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelPreservesPersistedTurnActivityWithoutLiveAccumulator(t *testing.T) {
|
||||||
|
service, stream, _ := testCheckpointBlobProjection(t)
|
||||||
|
conversation, _, _, err := service.snapshotCheckpointConversation(stream)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("snapshotCheckpointConversation() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.store.SaveConversationWithEntries(stream.ConversationID, conversation, conversation.Entries); err != nil {
|
||||||
|
t.Fatalf("SaveConversationWithEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.handleCancelIntent(InboundIntent{
|
||||||
|
Kind: "cancel",
|
||||||
|
RequestID: stream.RequestID,
|
||||||
|
CancelReason: "new_message_submitted",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handleCancelIntent() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
persisted, err := service.store.LoadConversation(stream.ConversationID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConversation() error = %v", err)
|
||||||
|
}
|
||||||
|
replay, err := service.projector.ProjectPromptReplay(persisted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectPromptReplay() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, message := range replay {
|
||||||
|
if message.Role == "assistant" && strings.TrimSpace(message.Content) == "hi" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("replay = %#v, want persisted assistant activity", replay)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectPromptReplayPreservesLegacyCanceledTurnActivity(t *testing.T) {
|
||||||
|
cancelEntry := newMetadataEntry(1, "request-1", "control", map[string]any{
|
||||||
|
"status": "canceled",
|
||||||
|
"reason": "new_message_submitted",
|
||||||
|
"replay_policy": cancelReplayPolicyKeepStableInput,
|
||||||
|
})
|
||||||
|
conversation := &ConversationFile{
|
||||||
|
ConversationID: "conversation-1",
|
||||||
|
NextTurnSeq: 2,
|
||||||
|
Entries: []HistoryEntry{
|
||||||
|
newAssistantTextEntry(1, "request-1", "persisted activity", "", ""),
|
||||||
|
cancelEntry,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
replay, err := NewHistoryProjector().ProjectPromptReplay(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectPromptReplay() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, message := range replay {
|
||||||
|
if message.Role == "assistant" && strings.TrimSpace(message.Content) == "persisted activity" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("replay = %#v, want legacy canceled activity", replay)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
package forwarder
|
package forwarder
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -19,6 +20,51 @@ const projectedConversationMaxTokens = 130000
|
|||||||
type HistoryProjector struct {
|
type HistoryProjector struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CheckpointBlob struct {
|
||||||
|
ID []byte
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckpointProjection struct {
|
||||||
|
State *agentv1.ConversationStateStructure
|
||||||
|
Blobs []CheckpointBlob
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkpointBlobGraph struct {
|
||||||
|
blobs map[[sha256.Size]byte][]byte
|
||||||
|
order [][sha256.Size]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCheckpointBlobGraph() *checkpointBlobGraph {
|
||||||
|
return &checkpointBlobGraph{blobs: make(map[[sha256.Size]byte][]byte)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (graph *checkpointBlobGraph) add(data []byte) []byte {
|
||||||
|
if graph == nil || len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id := sha256.Sum256(data)
|
||||||
|
if _, exists := graph.blobs[id]; !exists {
|
||||||
|
graph.blobs[id] = append([]byte(nil), data...)
|
||||||
|
graph.order = append(graph.order, id)
|
||||||
|
}
|
||||||
|
return append([]byte(nil), id[:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (graph *checkpointBlobGraph) list() []CheckpointBlob {
|
||||||
|
if graph == nil || len(graph.order) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
blobs := make([]CheckpointBlob, 0, len(graph.order))
|
||||||
|
for _, id := range graph.order {
|
||||||
|
blobs = append(blobs, CheckpointBlob{
|
||||||
|
ID: append([]byte(nil), id[:]...),
|
||||||
|
Data: append([]byte(nil), graph.blobs[id]...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return blobs
|
||||||
|
}
|
||||||
|
|
||||||
// NewHistoryProjector 创建 history 投影器。
|
// NewHistoryProjector 创建 history 投影器。
|
||||||
func NewHistoryProjector() *HistoryProjector {
|
func NewHistoryProjector() *HistoryProjector {
|
||||||
return &HistoryProjector{}
|
return &HistoryProjector{}
|
||||||
@@ -309,6 +355,7 @@ const (
|
|||||||
cancelReplayPolicyDropTurn = "drop_turn"
|
cancelReplayPolicyDropTurn = "drop_turn"
|
||||||
cancelReplayPolicyDropUnstarted = "drop_unstarted_turn"
|
cancelReplayPolicyDropUnstarted = "drop_unstarted_turn"
|
||||||
cancelReplayPolicyKeepStableInput = "keep_stable_input"
|
cancelReplayPolicyKeepStableInput = "keep_stable_input"
|
||||||
|
cancelReplayPolicyKeepInterrupted = "keep_interrupted_output"
|
||||||
)
|
)
|
||||||
|
|
||||||
func sanitizeCanceledReplayEntries(entries []HistoryEntry) []HistoryEntry {
|
func sanitizeCanceledReplayEntries(entries []HistoryEntry) []HistoryEntry {
|
||||||
@@ -324,12 +371,18 @@ func sanitizeCanceledReplayEntries(entries []HistoryEntry) []HistoryEntry {
|
|||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.TurnSeq > 0 {
|
if entry.TurnSeq > 0 {
|
||||||
if policy, canceled := canceledTurns[entry.TurnSeq]; canceled {
|
if policy, canceled := canceledTurns[entry.TurnSeq]; canceled {
|
||||||
if policy == cancelReplayPolicyDropUnstarted {
|
if policy == cancelReplayPolicyKeepInterrupted {
|
||||||
if _, active := activeCanceledTurns[entry.TurnSeq]; active {
|
filtered = append(filtered, entry)
|
||||||
policy = cancelReplayPolicyKeepStableInput
|
continue
|
||||||
} else {
|
|
||||||
policy = cancelReplayPolicyDropTurn
|
|
||||||
}
|
}
|
||||||
|
if policy != cancelReplayPolicyDropTurn {
|
||||||
|
if _, active := activeCanceledTurns[entry.TurnSeq]; active {
|
||||||
|
filtered = append(filtered, entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if policy == cancelReplayPolicyDropUnstarted {
|
||||||
|
policy = cancelReplayPolicyDropTurn
|
||||||
}
|
}
|
||||||
if policy == cancelReplayPolicyDropTurn || !isStableCanceledTurnInputEntry(entry) {
|
if policy == cancelReplayPolicyDropTurn || !isStableCanceledTurnInputEntry(entry) {
|
||||||
continue
|
continue
|
||||||
@@ -404,6 +457,8 @@ func normalizeCancelReplayPolicy(policy string, reason string) string {
|
|||||||
return cancelReplayPolicyDropUnstarted
|
return cancelReplayPolicyDropUnstarted
|
||||||
case cancelReplayPolicyKeepStableInput:
|
case cancelReplayPolicyKeepStableInput:
|
||||||
return cancelReplayPolicyKeepStableInput
|
return cancelReplayPolicyKeepStableInput
|
||||||
|
case cancelReplayPolicyKeepInterrupted:
|
||||||
|
return cancelReplayPolicyKeepInterrupted
|
||||||
default:
|
default:
|
||||||
return cancelReplayPolicyForReason(reason)
|
return cancelReplayPolicyForReason(reason)
|
||||||
}
|
}
|
||||||
@@ -475,6 +530,16 @@ func isHistoricalReplayToolResult(conversation *ConversationFile, entry HistoryE
|
|||||||
|
|
||||||
// ProjectLegacyCheckpoint 按需从 JSON history 投影出兼容旧客户端的 checkpoint 结构。
|
// ProjectLegacyCheckpoint 按需从 JSON history 投影出兼容旧客户端的 checkpoint 结构。
|
||||||
func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *ConversationFile) (*agentv1.ConversationStateStructure, error) {
|
func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *ConversationFile) (*agentv1.ConversationStateStructure, error) {
|
||||||
|
projection, err := projector.ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil || projection == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return projection.State, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectCheckpointProjection 同时返回 checkpoint 状态及其引用的内容寻址 Blob。
|
||||||
|
func (projector *HistoryProjector) ProjectCheckpointProjection(conversation *ConversationFile) (*CheckpointProjection, error) {
|
||||||
|
blobs := newCheckpointBlobGraph()
|
||||||
state := &agentv1.ConversationStateStructure{
|
state := &agentv1.ConversationStateStructure{
|
||||||
TokenDetails: &agentv1.ConversationTokenDetails{
|
TokenDetails: &agentv1.ConversationTokenDetails{
|
||||||
UsedTokens: conversationTokenDetailsUsedTokens(conversation),
|
UsedTokens: conversationTokenDetailsUsedTokens(conversation),
|
||||||
@@ -488,7 +553,7 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
|
|||||||
if conversation == nil {
|
if conversation == nil {
|
||||||
mode := agentv1.AgentMode_AGENT_MODE_AGENT
|
mode := agentv1.AgentMode_AGENT_MODE_AGENT
|
||||||
state.Mode = &mode
|
state.Mode = &mode
|
||||||
return state, nil
|
return &CheckpointProjection{State: state}, nil
|
||||||
}
|
}
|
||||||
mode, err := parseModeAlias(conversation.Mode)
|
mode, err := parseModeAlias(conversation.Mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -506,158 +571,11 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
|
|||||||
if structuredState.HasTodos {
|
if structuredState.HasTodos {
|
||||||
state.Todos = encodeConversationTodoBytes(structuredState.Todos)
|
state.Todos = encodeConversationTodoBytes(structuredState.Todos)
|
||||||
}
|
}
|
||||||
grouped := make(map[int64][]HistoryEntry)
|
turnIDs, err := projectCheckpointTurnBlobs(conversation, blobs)
|
||||||
order := make([]int64, 0, conversation.NextTurnSeq)
|
|
||||||
for _, entry := range checkpointProjectionEntries(conversation.Entries) {
|
|
||||||
if entry.TurnSeq <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := grouped[entry.TurnSeq]; !ok {
|
|
||||||
order = append(order, entry.TurnSeq)
|
|
||||||
}
|
|
||||||
grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, turnSeq := range order {
|
|
||||||
entries := grouped[turnSeq]
|
|
||||||
var rawUserMessage []byte
|
|
||||||
var turnRequestID string
|
|
||||||
steps := make([][]byte, 0, len(entries))
|
|
||||||
seenToolCalls := make(map[string]struct{})
|
|
||||||
openToolCalls := make(map[string]struct{})
|
|
||||||
for _, entry := range entries {
|
|
||||||
if turnRequestID == "" {
|
|
||||||
turnRequestID = strings.TrimSpace(entry.RequestID)
|
|
||||||
}
|
|
||||||
switch strings.TrimSpace(entry.Kind) {
|
|
||||||
case "user_message":
|
|
||||||
userMessage := &agentv1.UserMessage{}
|
|
||||||
if err := protojson.Unmarshal(entry.Payload, userMessage); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode checkpoint user_message: %w", err)
|
|
||||||
}
|
|
||||||
payload, err := proto.Marshal(userMessage)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rawUserMessage = payload
|
state.Turns = turnIDs
|
||||||
case "assistant_text":
|
|
||||||
var payload assistantTextPayload
|
|
||||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(payload.Text) == "" && strings.TrimSpace(payload.ReasoningContent) != "" && len(openToolCalls) > 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
|
||||||
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
steps = append(steps, stepPayload)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(payload.Text) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
|
|
||||||
Message: &agentv1.ConversationStep_AssistantMessage{
|
|
||||||
AssistantMessage: &agentv1.AssistantMessage{Text: strings.TrimSpace(payload.Text)},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
steps = append(steps, stepPayload)
|
|
||||||
case "tool_call":
|
|
||||||
var payload toolCallEntryPayload
|
|
||||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
|
||||||
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
steps = append(steps, stepPayload)
|
|
||||||
}
|
|
||||||
toolCall := &agentv1.ToolCall{}
|
|
||||||
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
|
|
||||||
Message: &agentv1.ConversationStep_ToolCall{
|
|
||||||
ToolCall: toolCall,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
steps = append(steps, stepPayload)
|
|
||||||
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
|
|
||||||
seenToolCalls[toolCallID] = struct{}{}
|
|
||||||
openToolCalls[toolCallID] = struct{}{}
|
|
||||||
}
|
|
||||||
case "tool_result":
|
|
||||||
var payload toolResultEntryPayload
|
|
||||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
|
|
||||||
if _, ok := seenToolCalls[toolCallID]; ok {
|
|
||||||
delete(openToolCalls, toolCallID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
|
||||||
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
steps = append(steps, stepPayload)
|
|
||||||
}
|
|
||||||
if len(payload.ToolCall) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
toolCall := &agentv1.ToolCall{}
|
|
||||||
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
|
|
||||||
Message: &agentv1.ConversationStep_ToolCall{
|
|
||||||
ToolCall: toolCall,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
steps = append(steps, stepPayload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(rawUserMessage) == 0 && len(steps) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
agentTurn := &agentv1.AgentConversationTurnStructure{
|
|
||||||
UserMessage: rawUserMessage,
|
|
||||||
Steps: steps,
|
|
||||||
}
|
|
||||||
if turnRequestID != "" {
|
|
||||||
agentTurn.RequestId = &turnRequestID
|
|
||||||
}
|
|
||||||
turnPayload, err := proto.Marshal(&agentv1.ConversationTurnStructure{
|
|
||||||
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
|
|
||||||
AgentConversationTurn: agentTurn,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
state.Turns = append(state.Turns, turnPayload)
|
|
||||||
}
|
|
||||||
replayMessages, err := projector.ProjectPromptReplay(conversation)
|
replayMessages, err := projector.ProjectPromptReplay(conversation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -685,16 +603,192 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
state.RootPromptMessagesJson = rootPromptMessages
|
state.RootPromptMessagesJson = rootPromptMessages
|
||||||
return state, nil
|
return &CheckpointProjection{State: state, Blobs: blobs.list()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func marshalThinkingStep(text string) ([]byte, error) {
|
func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpointBlobGraph) ([][]byte, error) {
|
||||||
return proto.Marshal(&agentv1.ConversationStep{
|
if conversation == nil || blobs == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
grouped := make(map[int64][]HistoryEntry)
|
||||||
|
order := make([]int64, 0, conversation.NextTurnSeq)
|
||||||
|
for _, entry := range checkpointProjectionEntries(conversation.Entries) {
|
||||||
|
if entry.TurnSeq <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := grouped[entry.TurnSeq]; !ok {
|
||||||
|
order = append(order, entry.TurnSeq)
|
||||||
|
}
|
||||||
|
grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
turnIDs := make([][]byte, 0, len(order))
|
||||||
|
for _, turnSeq := range order {
|
||||||
|
entries := grouped[turnSeq]
|
||||||
|
completedToolCalls, err := collectCheckpointCompletedToolCalls(entries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var userMessageID []byte
|
||||||
|
var turnRequestID string
|
||||||
|
steps := make([]*agentv1.ConversationStep, 0, len(entries))
|
||||||
|
seenToolCalls := make(map[string]struct{})
|
||||||
|
openToolCalls := make(map[string]struct{})
|
||||||
|
for _, entry := range entries {
|
||||||
|
if turnRequestID == "" {
|
||||||
|
turnRequestID = strings.TrimSpace(entry.RequestID)
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(entry.Kind) {
|
||||||
|
case "user_message":
|
||||||
|
userMessage := &agentv1.UserMessage{}
|
||||||
|
if err := protojson.Unmarshal(entry.Payload, userMessage); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode checkpoint user_message: %w", err)
|
||||||
|
}
|
||||||
|
payload, err := proto.Marshal(userMessage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
userMessageID = blobs.add(payload)
|
||||||
|
case "assistant_text":
|
||||||
|
var payload assistantTextPayload
|
||||||
|
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(payload.Text) == "" && strings.TrimSpace(payload.ReasoningContent) != "" && len(openToolCalls) > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||||
|
steps = append(steps, &agentv1.ConversationStep{
|
||||||
Message: &agentv1.ConversationStep_ThinkingMessage{
|
Message: &agentv1.ConversationStep_ThinkingMessage{
|
||||||
ThinkingMessage: &agentv1.ThinkingMessage{Text: text},
|
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(payload.Text) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
steps = append(steps, &agentv1.ConversationStep{
|
||||||
|
Message: &agentv1.ConversationStep_AssistantMessage{
|
||||||
|
AssistantMessage: &agentv1.AssistantMessage{Text: strings.TrimSpace(payload.Text)},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case "tool_call":
|
||||||
|
var payload toolCallEntryPayload
|
||||||
|
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||||
|
steps = append(steps, &agentv1.ConversationStep{
|
||||||
|
Message: &agentv1.ConversationStep_ThinkingMessage{
|
||||||
|
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
toolCall := &agentv1.ToolCall{}
|
||||||
|
toolCallID := strings.TrimSpace(payload.ToolCallID)
|
||||||
|
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if completedPayload := completedToolCalls[toolCallID]; len(completedPayload) > 0 {
|
||||||
|
completedToolCall := &agentv1.ToolCall{}
|
||||||
|
if err := protojson.Unmarshal(completedPayload, completedToolCall); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
proto.Merge(toolCall, completedToolCall)
|
||||||
|
}
|
||||||
|
steps = append(steps, &agentv1.ConversationStep{
|
||||||
|
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
|
||||||
|
})
|
||||||
|
if toolCallID != "" {
|
||||||
|
seenToolCalls[toolCallID] = struct{}{}
|
||||||
|
openToolCalls[toolCallID] = struct{}{}
|
||||||
|
}
|
||||||
|
case "tool_result":
|
||||||
|
var payload toolResultEntryPayload
|
||||||
|
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
toolCallID := strings.TrimSpace(payload.ToolCallID)
|
||||||
|
if toolCallID != "" {
|
||||||
|
delete(openToolCalls, toolCallID)
|
||||||
|
}
|
||||||
|
if _, ok := seenToolCalls[toolCallID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||||
|
steps = append(steps, &agentv1.ConversationStep{
|
||||||
|
Message: &agentv1.ConversationStep_ThinkingMessage{
|
||||||
|
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(payload.ToolCall) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
toolCall := &agentv1.ToolCall{}
|
||||||
|
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
steps = append(steps, &agentv1.ConversationStep{
|
||||||
|
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(userMessageID) == 0 && len(steps) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stepIDs := make([][]byte, 0, len(steps))
|
||||||
|
for _, step := range steps {
|
||||||
|
stepID, err := addCheckpointStepBlob(blobs, step)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stepIDs = append(stepIDs, stepID)
|
||||||
|
}
|
||||||
|
agentTurn := &agentv1.AgentConversationTurnStructure{
|
||||||
|
UserMessage: userMessageID,
|
||||||
|
Steps: stepIDs,
|
||||||
|
}
|
||||||
|
if turnRequestID != "" {
|
||||||
|
agentTurn.RequestId = &turnRequestID
|
||||||
|
}
|
||||||
|
turnPayload, err := proto.Marshal(&agentv1.ConversationTurnStructure{
|
||||||
|
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
|
||||||
|
AgentConversationTurn: agentTurn,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
turnIDs = append(turnIDs, blobs.add(turnPayload))
|
||||||
|
}
|
||||||
|
return turnIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectCheckpointCompletedToolCalls(entries []HistoryEntry) (map[string]json.RawMessage, error) {
|
||||||
|
completed := make(map[string]json.RawMessage)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.TrimSpace(entry.Kind) != "tool_result" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var payload toolResultEntryPayload
|
||||||
|
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" && len(payload.ToolCall) > 0 {
|
||||||
|
completed[toolCallID] = payload.ToolCall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return completed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addCheckpointStepBlob(blobs *checkpointBlobGraph, step *agentv1.ConversationStep) ([]byte, error) {
|
||||||
|
payload, err := proto.Marshal(step)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return blobs.add(payload), nil
|
||||||
|
}
|
||||||
|
|
||||||
func conversationTokenDetailsUsedTokens(conversation *ConversationFile) uint32 {
|
func conversationTokenDetailsUsedTokens(conversation *ConversationFile) uint32 {
|
||||||
if conversation == nil {
|
if conversation == nil {
|
||||||
@@ -1132,7 +1226,7 @@ func trimReplayDanglingAssistantToolCalls(messages []modeladapter.Message) []mod
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldPersistToolResultName(toolName string) bool {
|
func shouldPersistCheckpointReplayToolResultName(toolName string) bool {
|
||||||
switch strings.TrimSpace(toolName) {
|
switch strings.TrimSpace(toolName) {
|
||||||
case "PatchEdit", "PatchEditLines", "PatchEditSpan", "Edit", "Write", "GenerateImage":
|
case "PatchEdit", "PatchEditLines", "PatchEditSpan", "Edit", "Write", "GenerateImage":
|
||||||
return true
|
return true
|
||||||
@@ -1141,60 +1235,6 @@ func shouldPersistToolResultName(toolName string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterCheckpointTurns(rawTurns [][]byte) [][]byte {
|
|
||||||
if len(rawTurns) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
filtered := make([][]byte, 0, len(rawTurns))
|
|
||||||
for _, rawTurn := range rawTurns {
|
|
||||||
if len(rawTurn) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
turn := &agentv1.ConversationTurnStructure{}
|
|
||||||
if err := proto.Unmarshal(rawTurn, turn); err != nil {
|
|
||||||
filtered = append(filtered, append([]byte(nil), rawTurn...))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
agentTurn := turn.GetAgentConversationTurn()
|
|
||||||
if agentTurn == nil {
|
|
||||||
filtered = append(filtered, append([]byte(nil), rawTurn...))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
nextSteps := make([][]byte, 0, len(agentTurn.GetSteps()))
|
|
||||||
for _, rawStep := range agentTurn.GetSteps() {
|
|
||||||
if len(rawStep) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
step := &agentv1.ConversationStep{}
|
|
||||||
if err := proto.Unmarshal(rawStep, step); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if toolCall := step.GetToolCall(); toolCall != nil && !shouldPersistToolResultName(inferToolName(toolCall)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nextSteps = append(nextSteps, append([]byte(nil), rawStep...))
|
|
||||||
}
|
|
||||||
if len(agentTurn.GetUserMessage()) == 0 && len(nextSteps) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
encoded, err := proto.Marshal(&agentv1.ConversationTurnStructure{
|
|
||||||
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
|
|
||||||
AgentConversationTurn: &agentv1.AgentConversationTurnStructure{
|
|
||||||
UserMessage: append([]byte(nil), agentTurn.GetUserMessage()...),
|
|
||||||
Steps: nextSteps,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
filtered = append(filtered, append([]byte(nil), rawTurn...))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filtered = append(filtered, encoded)
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []promptengine.Message {
|
func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []promptengine.Message {
|
||||||
if len(messages) == 0 {
|
if len(messages) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -1205,7 +1245,7 @@ func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []pro
|
|||||||
if strings.TrimSpace(message.Role) == "assistant" && len(message.ToolCalls) > 0 {
|
if strings.TrimSpace(message.Role) == "assistant" && len(message.ToolCalls) > 0 {
|
||||||
nextToolCalls := make([]promptengine.ToolCallDescriptor, 0, len(message.ToolCalls))
|
nextToolCalls := make([]promptengine.ToolCallDescriptor, 0, len(message.ToolCalls))
|
||||||
for _, toolCall := range message.ToolCalls {
|
for _, toolCall := range message.ToolCalls {
|
||||||
if !shouldPersistToolResultName(toolCall.Function.Name) {
|
if !shouldPersistCheckpointReplayToolResultName(toolCall.Function.Name) {
|
||||||
skippedToolCallIDs[strings.TrimSpace(toolCall.ID)] = struct{}{}
|
skippedToolCallIDs[strings.TrimSpace(toolCall.ID)] = struct{}{}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1222,7 +1262,7 @@ func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []pro
|
|||||||
if _, ok := skippedToolCallIDs[strings.TrimSpace(message.ToolCallID)]; ok {
|
if _, ok := skippedToolCallIDs[strings.TrimSpace(message.ToolCallID)]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !shouldPersistToolResultName(message.Name) {
|
if !shouldPersistCheckpointReplayToolResultName(message.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
package forwarder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
|
"cursor/gen/agentv1"
|
||||||
|
promptengine "cursor/internal/backend/agent/prompt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProjectCheckpointProjectionBuildsResolvableForkState(t *testing.T) {
|
||||||
|
userPayload, err := protojson.Marshal(&agentv1.UserMessage{
|
||||||
|
Text: "parent question",
|
||||||
|
MessageId: "message-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal user message: %v", err)
|
||||||
|
}
|
||||||
|
conversation := &ConversationFile{
|
||||||
|
ConversationID: "conversation-1",
|
||||||
|
RootConversationID: "conversation-1",
|
||||||
|
Mode: "agent",
|
||||||
|
NextTurnSeq: 2,
|
||||||
|
NextEntrySeq: 3,
|
||||||
|
TokenDetailsMaxTokens: projectedConversationMaxTokens,
|
||||||
|
Entries: []HistoryEntry{
|
||||||
|
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload},
|
||||||
|
newAssistantTextEntry(1, "request-1", "parent answer", "", ""),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
state := projection.State
|
||||||
|
if len(state.GetTurns()) != 1 {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() turns = %d, want 1 Blob-backed turn", len(state.GetTurns()))
|
||||||
|
}
|
||||||
|
blobs := make(map[string][]byte, len(projection.Blobs))
|
||||||
|
for _, blob := range projection.Blobs {
|
||||||
|
digest := sha256.Sum256(blob.Data)
|
||||||
|
if len(blob.ID) != sha256.Size || string(blob.ID) != string(digest[:]) {
|
||||||
|
t.Fatalf("invalid content-addressed Blob id=%x", blob.ID)
|
||||||
|
}
|
||||||
|
blobs[string(blob.ID)] = blob.Data
|
||||||
|
}
|
||||||
|
turnPayload, ok := blobs[string(state.GetTurns()[0])]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("turn references a missing Blob")
|
||||||
|
}
|
||||||
|
turn := &agentv1.ConversationTurnStructure{}
|
||||||
|
if err := proto.Unmarshal(turnPayload, turn); err != nil {
|
||||||
|
t.Fatalf("decode turn Blob: %v", err)
|
||||||
|
}
|
||||||
|
agentTurn := turn.GetAgentConversationTurn()
|
||||||
|
if agentTurn == nil {
|
||||||
|
t.Fatal("turn Blob does not contain an agent turn")
|
||||||
|
}
|
||||||
|
if _, ok := blobs[string(agentTurn.GetUserMessage())]; !ok {
|
||||||
|
t.Fatal("turn references a missing user message Blob")
|
||||||
|
}
|
||||||
|
for _, stepID := range agentTurn.GetSteps() {
|
||||||
|
if _, ok := blobs[string(stepID)]; !ok {
|
||||||
|
t.Fatal("turn references a missing step Blob")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, err := importedConversationStateModelMessages(state)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("importedConversationStateModelMessages() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(messages) != 2 {
|
||||||
|
t.Fatalf("imported messages = %d, want parent user and assistant context", len(messages))
|
||||||
|
}
|
||||||
|
if messages[0].Role != "user" || !strings.Contains(messages[0].Content, "parent question") {
|
||||||
|
t.Fatalf("first imported message = %#v", messages[0])
|
||||||
|
}
|
||||||
|
if messages[1].Role != "assistant" || messages[1].Content != "parent answer" {
|
||||||
|
t.Fatalf("second imported message = %#v", messages[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectCheckpointProjectionKeepsForkPointIsolatedFromLaterHistory(t *testing.T) {
|
||||||
|
firstUser, err := protojson.Marshal(&agentv1.UserMessage{Text: "first question", MessageId: "message-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal first user message: %v", err)
|
||||||
|
}
|
||||||
|
conversation := &ConversationFile{
|
||||||
|
ConversationID: "conversation-1",
|
||||||
|
RootConversationID: "conversation-1",
|
||||||
|
Mode: "agent",
|
||||||
|
NextTurnSeq: 2,
|
||||||
|
NextEntrySeq: 3,
|
||||||
|
TokenDetailsMaxTokens: projectedConversationMaxTokens,
|
||||||
|
Entries: []HistoryEntry{
|
||||||
|
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: firstUser},
|
||||||
|
newAssistantTextEntry(1, "request-1", "first answer", "", ""),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
projector := NewHistoryProjector()
|
||||||
|
midpoint, err := projector.ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("midpoint projection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secondUser, err := protojson.Marshal(&agentv1.UserMessage{Text: "second question", MessageId: "message-2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal second user message: %v", err)
|
||||||
|
}
|
||||||
|
appendEntriesInPlace(conversation, []HistoryEntry{
|
||||||
|
{TurnSeq: 2, RequestID: "request-2", Role: "user", Kind: "user_message", Payload: secondUser},
|
||||||
|
newAssistantTextEntry(2, "request-2", "second answer", "", ""),
|
||||||
|
})
|
||||||
|
latest, err := projector.ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("latest projection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
midpointMessages, err := importedConversationStateModelMessages(midpoint.State)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import midpoint messages: %v", err)
|
||||||
|
}
|
||||||
|
latestMessages, err := importedConversationStateModelMessages(latest.State)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import latest messages: %v", err)
|
||||||
|
}
|
||||||
|
if len(midpoint.State.GetTurns()) != 1 || len(midpointMessages) != 2 {
|
||||||
|
t.Fatalf("midpoint turns=%d messages=%d, want 1 turn and 2 messages", len(midpoint.State.GetTurns()), len(midpointMessages))
|
||||||
|
}
|
||||||
|
if len(latest.State.GetTurns()) != 2 || len(latestMessages) != 4 {
|
||||||
|
t.Fatalf("latest turns=%d messages=%d, want 2 turns and 4 messages", len(latest.State.GetTurns()), len(latestMessages))
|
||||||
|
}
|
||||||
|
if midpointMessages[1].Content != "first answer" || latestMessages[3].Content != "second answer" {
|
||||||
|
t.Fatalf("fork snapshots are not isolated: midpoint=%#v latest=%#v", midpointMessages, latestMessages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectCheckpointProjectionMergesToolCallWithCompletedResult(t *testing.T) {
|
||||||
|
userPayload, err := protojson.Marshal(&agentv1.UserMessage{Text: "inspect file", MessageId: "message-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal user message: %v", err)
|
||||||
|
}
|
||||||
|
startedAt := uint64(100)
|
||||||
|
toolCallID := "call-1"
|
||||||
|
startedToolCall := checkpointTestToolCallPayload(t, &agentv1.ToolCall{
|
||||||
|
ToolCallId: &toolCallID,
|
||||||
|
StartedAtMs: &startedAt,
|
||||||
|
Tool: &agentv1.ToolCall_ReadToolCall{
|
||||||
|
ReadToolCall: &agentv1.ReadToolCall{
|
||||||
|
Args: &agentv1.ReadToolArgs{Path: "/tmp/example.txt"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
completedAt := uint64(200)
|
||||||
|
completedToolCall := checkpointTestToolCallPayload(t, &agentv1.ToolCall{
|
||||||
|
CompletedAtMs: &completedAt,
|
||||||
|
Tool: &agentv1.ToolCall_ReadToolCall{
|
||||||
|
ReadToolCall: &agentv1.ReadToolCall{
|
||||||
|
Result: &agentv1.ReadToolResult{
|
||||||
|
Result: &agentv1.ReadToolResult_Success{
|
||||||
|
Success: &agentv1.ReadToolSuccess{
|
||||||
|
Path: "/tmp/example.txt",
|
||||||
|
TotalLines: 1,
|
||||||
|
Output: &agentv1.ReadToolSuccess_Content{Content: "file contents"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
conversation := &ConversationFile{
|
||||||
|
ConversationID: "conversation-1",
|
||||||
|
Mode: "agent",
|
||||||
|
NextTurnSeq: 2,
|
||||||
|
Entries: []HistoryEntry{
|
||||||
|
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload},
|
||||||
|
newAssistantTextEntry(1, "request-1", "before", "", ""),
|
||||||
|
newToolCallEntry(1, "request-1", "call-1", "Read", "", "", startedToolCall),
|
||||||
|
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "file contents", "", completedToolCall),
|
||||||
|
newAssistantTextEntry(1, "request-1", "after", "", ""),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(projection.Blobs) != 5 {
|
||||||
|
t.Fatalf("checkpoint blobs = %d, want user, three final steps, and turn", len(projection.Blobs))
|
||||||
|
}
|
||||||
|
steps := checkpointProjectionSteps(t, projection)
|
||||||
|
if len(steps) != 3 {
|
||||||
|
t.Fatalf("checkpoint steps = %d, want assistant, completed Read, assistant", len(steps))
|
||||||
|
}
|
||||||
|
if steps[0].GetAssistantMessage().GetText() != "before" || steps[2].GetAssistantMessage().GetText() != "after" {
|
||||||
|
t.Fatalf("checkpoint step ordering changed: %#v", steps)
|
||||||
|
}
|
||||||
|
mergedToolCall := steps[1].GetToolCall()
|
||||||
|
readCall := mergedToolCall.GetReadToolCall()
|
||||||
|
if readCall == nil || readCall.GetResult().GetSuccess().GetContent() != "file contents" {
|
||||||
|
t.Fatalf("checkpoint Read step does not contain completed result: %#v", steps[1].GetToolCall())
|
||||||
|
}
|
||||||
|
if readCall.GetArgs().GetPath() != "/tmp/example.txt" || mergedToolCall.GetToolCallId() != toolCallID || mergedToolCall.GetStartedAtMs() != startedAt || mergedToolCall.GetCompletedAtMs() != completedAt {
|
||||||
|
t.Fatalf("checkpoint Read step lost started-call fields: %#v", mergedToolCall)
|
||||||
|
}
|
||||||
|
|
||||||
|
replay, err := promptengine.DecodeReplayMessages(projection.State.GetRootPromptMessagesJson())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode root prompt replay: %v", err)
|
||||||
|
}
|
||||||
|
for _, message := range replay {
|
||||||
|
if message.Name == "Read" || len(message.ToolCalls) > 0 {
|
||||||
|
t.Fatalf("UI-only Read result leaked into root prompt replay: %#v", replay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectCheckpointProjectionIsIdempotentAndDoesNotMutateHistory(t *testing.T) {
|
||||||
|
userPayload, err := protojson.Marshal(&agentv1.UserMessage{Text: "inspect file", MessageId: "message-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal user message: %v", err)
|
||||||
|
}
|
||||||
|
completedToolCall := checkpointTestReadToolCall(t, &agentv1.ReadToolResult{
|
||||||
|
Result: &agentv1.ReadToolResult_Success{
|
||||||
|
Success: &agentv1.ReadToolSuccess{
|
||||||
|
Path: "/tmp/example.txt",
|
||||||
|
TotalLines: 1,
|
||||||
|
Output: &agentv1.ReadToolSuccess_Content{Content: "file contents"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
conversation := &ConversationFile{
|
||||||
|
ConversationID: "conversation-1",
|
||||||
|
Mode: "agent",
|
||||||
|
NextTurnSeq: 2,
|
||||||
|
Entries: []HistoryEntry{
|
||||||
|
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload},
|
||||||
|
newToolCallEntry(1, "request-1", "call-1", "Read", "", "", checkpointTestReadToolCall(t, nil)),
|
||||||
|
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "file contents", "", completedToolCall),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
before, err := json.Marshal(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal conversation before projection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
projector := NewHistoryProjector()
|
||||||
|
first, err := projector.ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first projection: %v", err)
|
||||||
|
}
|
||||||
|
second, err := projector.ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second projection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !proto.Equal(first.State, second.State) {
|
||||||
|
t.Fatalf("repeated projection changed checkpoint state: first=%#v second=%#v", first.State, second.State)
|
||||||
|
}
|
||||||
|
if len(first.Blobs) != len(second.Blobs) {
|
||||||
|
t.Fatalf("repeated projection changed blob count: first=%d second=%d", len(first.Blobs), len(second.Blobs))
|
||||||
|
}
|
||||||
|
for index := range first.Blobs {
|
||||||
|
if !bytes.Equal(first.Blobs[index].ID, second.Blobs[index].ID) || !bytes.Equal(first.Blobs[index].Data, second.Blobs[index].Data) {
|
||||||
|
t.Fatalf("repeated projection changed blob %d", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
after, err := json.Marshal(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal conversation after projection: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(before, after) {
|
||||||
|
t.Fatalf("checkpoint projection mutated semantic history:\nbefore=%s\nafter=%s", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectCheckpointProjectionKeepsStartedToolCallWhenResultPayloadIsMissing(t *testing.T) {
|
||||||
|
startedToolCall := checkpointTestReadToolCall(t, nil)
|
||||||
|
conversation := &ConversationFile{
|
||||||
|
ConversationID: "conversation-1",
|
||||||
|
Mode: "agent",
|
||||||
|
NextTurnSeq: 2,
|
||||||
|
Entries: []HistoryEntry{
|
||||||
|
newToolCallEntry(1, "request-1", "call-1", "Read", "", "", startedToolCall),
|
||||||
|
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "read failed", "", nil),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
steps := checkpointProjectionSteps(t, projection)
|
||||||
|
if len(steps) != 1 {
|
||||||
|
t.Fatalf("checkpoint steps = %d, want the original Read step", len(steps))
|
||||||
|
}
|
||||||
|
readCall := steps[0].GetToolCall().GetReadToolCall()
|
||||||
|
if readCall == nil || readCall.GetArgs().GetPath() != "/tmp/example.txt" || readCall.GetResult() != nil {
|
||||||
|
t.Fatalf("checkpoint did not preserve the original Read call: %#v", steps[0].GetToolCall())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectCheckpointProjectionAppendsLegacyResultWithoutToolCallEntry(t *testing.T) {
|
||||||
|
completedToolCall := checkpointTestReadToolCall(t, &agentv1.ReadToolResult{
|
||||||
|
Result: &agentv1.ReadToolResult_Error{Error: &agentv1.ReadToolError{ErrorMessage: "not readable"}},
|
||||||
|
})
|
||||||
|
conversation := &ConversationFile{
|
||||||
|
ConversationID: "conversation-1",
|
||||||
|
Mode: "agent",
|
||||||
|
NextTurnSeq: 2,
|
||||||
|
Entries: []HistoryEntry{
|
||||||
|
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "not readable", "", completedToolCall),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||||
|
}
|
||||||
|
steps := checkpointProjectionSteps(t, projection)
|
||||||
|
if len(steps) != 1 || steps[0].GetToolCall().GetReadToolCall().GetResult().GetError().GetErrorMessage() != "not readable" {
|
||||||
|
t.Fatalf("legacy result-only Read step was not preserved: %#v", steps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkpointTestReadToolCall(t *testing.T, result *agentv1.ReadToolResult) []byte {
|
||||||
|
t.Helper()
|
||||||
|
return checkpointTestToolCallPayload(t, &agentv1.ToolCall{
|
||||||
|
Tool: &agentv1.ToolCall_ReadToolCall{
|
||||||
|
ReadToolCall: &agentv1.ReadToolCall{
|
||||||
|
Args: &agentv1.ReadToolArgs{Path: "/tmp/example.txt"},
|
||||||
|
Result: result,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkpointTestToolCallPayload(t *testing.T, toolCall *agentv1.ToolCall) []byte {
|
||||||
|
t.Helper()
|
||||||
|
payload, err := protojson.Marshal(toolCall)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal Read tool call: %v", err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkpointProjectionSteps(t *testing.T, projection *CheckpointProjection) []*agentv1.ConversationStep {
|
||||||
|
t.Helper()
|
||||||
|
if projection == nil || projection.State == nil || len(projection.State.GetTurns()) != 1 {
|
||||||
|
t.Fatalf("checkpoint turns = %#v, want exactly one turn", projection)
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
steps := make([]*agentv1.ConversationStep, 0, len(agentTurn.GetSteps()))
|
||||||
|
for _, stepID := range agentTurn.GetSteps() {
|
||||||
|
step := &agentv1.ConversationStep{}
|
||||||
|
if err := proto.Unmarshal(blobs[string(stepID)], step); err != nil {
|
||||||
|
t.Fatalf("decode checkpoint step: %v", err)
|
||||||
|
}
|
||||||
|
steps = append(steps, step)
|
||||||
|
}
|
||||||
|
return steps
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package forwarder
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -834,14 +836,22 @@ func (service *Service) handleCancelIntent(intent InboundIntent) error {
|
|||||||
}
|
}
|
||||||
hasCheckpoint := checkpointConversationInitialized(stream)
|
hasCheckpoint := checkpointConversationInitialized(stream)
|
||||||
if hasCheckpoint {
|
if hasCheckpoint {
|
||||||
|
preservedInterruptedOutput, err := service.persistInterruptedProviderOutput(stream)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
cancelReason := firstNonEmpty(intent.CancelReason, "user aborted")
|
cancelReason := firstNonEmpty(intent.CancelReason, "user aborted")
|
||||||
_, err := service.appendConversationEntries(stream, stream.ConversationID, []HistoryEntry{
|
replayPolicy := cancelReplayPolicyForReason(cancelReason)
|
||||||
newMetadataEntry(stream.TurnSeq, intent.RequestID, "control", map[string]any{
|
if preservedInterruptedOutput || checkpointTurnHasReplayActivity(stream) {
|
||||||
|
replayPolicy = cancelReplayPolicyKeepInterrupted
|
||||||
|
}
|
||||||
|
cancelEntry := newMetadataEntry(stream.TurnSeq, intent.RequestID, "control", map[string]any{
|
||||||
"status": "canceled",
|
"status": "canceled",
|
||||||
"reason": cancelReason,
|
"reason": cancelReason,
|
||||||
"replay_policy": cancelReplayPolicyForReason(cancelReason),
|
"replay_policy": replayPolicy,
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
cancelEntry.IdempotencyKey = cancelMetadataIdempotencyKey(stream.TurnSeq, intent.RequestID)
|
||||||
|
_, err = service.appendConversationEntries(stream, stream.ConversationID, []HistoryEntry{cancelEntry})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -858,9 +868,7 @@ func (service *Service) handleCancelIntent(intent InboundIntent) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if hasCheckpoint {
|
if hasCheckpoint {
|
||||||
if err := service.publishCheckpoint(stream.RequestID, stream.ConversationID); err != nil {
|
service.discardPendingCheckpoint(stream, "checkpoint superseded by cancellation")
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
clearPendingProviderCompletion(stream)
|
clearPendingProviderCompletion(stream)
|
||||||
stream.mu.Lock()
|
stream.mu.Lock()
|
||||||
@@ -871,6 +879,89 @@ func (service *Service) handleCancelIntent(intent InboundIntent) error {
|
|||||||
return service.broker.Cancel(intent.RequestID, firstNonEmpty(intent.CancelReason, "[canceled] User aborted request"))
|
return service.broker.Cancel(intent.RequestID, firstNonEmpty(intent.CancelReason, "[canceled] User aborted request"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkpointTurnHasReplayActivity(stream *ActiveStream) bool {
|
||||||
|
if stream == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
defer stream.mu.Unlock()
|
||||||
|
if stream.CheckpointConversation == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, entry := range stream.CheckpointConversation.Entries {
|
||||||
|
if entry.TurnSeq == stream.TurnSeq && isCanceledTurnActivityEntry(entry) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistInterruptedProviderOutput commits the current provider pass before cancellation.
|
||||||
|
// The entry key is stable for this provider pass, so repeated cancellation handling is a no-op.
|
||||||
|
func (service *Service) persistInterruptedProviderOutput(stream *ActiveStream) (bool, error) {
|
||||||
|
if stream == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
turnSeq := stream.TurnSeq
|
||||||
|
requestID := strings.TrimSpace(stream.RequestID)
|
||||||
|
modelCallID := strings.TrimSpace(stream.CurrentModelCallID)
|
||||||
|
providerPass := stream.ProviderPassCount
|
||||||
|
text := stream.ProviderAccumulatedText
|
||||||
|
reasoning := stream.ProviderAccumulatedReasoning
|
||||||
|
reasoningSignature := stream.ProviderAccumulatedReasoningSignature
|
||||||
|
reasoningSignatureSource := stream.ProviderAccumulatedReasoningSignatureSource
|
||||||
|
reasoningItemID := stream.ProviderAccumulatedReasoningItemID
|
||||||
|
reasoningStatus := stream.ProviderAccumulatedReasoningStatus
|
||||||
|
reasoningSummary := append([]byte(nil), stream.ProviderAccumulatedReasoningSummary...)
|
||||||
|
stream.mu.Unlock()
|
||||||
|
if strings.TrimSpace(text) == "" && !hasReplayableReasoningPayload(reasoning, reasoningSignature, reasoningSignatureSource) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
key := interruptedProviderOutputIdempotencyKey(turnSeq, requestID, modelCallID, providerPass)
|
||||||
|
_, err := service.appendConversationEntries(stream, stream.ConversationID, []HistoryEntry{
|
||||||
|
{
|
||||||
|
TurnSeq: turnSeq,
|
||||||
|
RequestID: requestID,
|
||||||
|
IdempotencyKey: key,
|
||||||
|
Role: "assistant",
|
||||||
|
Kind: "assistant_text",
|
||||||
|
Payload: newAssistantTextPayload(
|
||||||
|
text,
|
||||||
|
reasoning,
|
||||||
|
reasoningSignature,
|
||||||
|
reasoningSignatureSource,
|
||||||
|
reasoningItemID,
|
||||||
|
reasoningStatus,
|
||||||
|
reasoningSummary,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func interruptedProviderOutputIdempotencyKey(turnSeq int64, requestID string, modelCallID string, providerPass int) string {
|
||||||
|
payload := strings.Join([]string{
|
||||||
|
"provider_interrupted_output",
|
||||||
|
fmt.Sprintf("%d", turnSeq),
|
||||||
|
strings.TrimSpace(requestID),
|
||||||
|
strings.TrimSpace(modelCallID),
|
||||||
|
fmt.Sprintf("%d", providerPass),
|
||||||
|
}, "\x00")
|
||||||
|
digest := sha256.Sum256([]byte(payload))
|
||||||
|
return "provider-interrupted-output:" + hex.EncodeToString(digest[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelMetadataIdempotencyKey(turnSeq int64, requestID string) string {
|
||||||
|
payload := strings.Join([]string{
|
||||||
|
"cancel",
|
||||||
|
fmt.Sprintf("%d", turnSeq),
|
||||||
|
strings.TrimSpace(requestID),
|
||||||
|
}, "\x00")
|
||||||
|
digest := sha256.Sum256([]byte(payload))
|
||||||
|
return "cancel:" + hex.EncodeToString(digest[:])
|
||||||
|
}
|
||||||
|
|
||||||
// handleExecResult 处理客户端返回的执行桥结果,并在终态时把 tool_result 写回 history。
|
// handleExecResult 处理客户端返回的执行桥结果,并在终态时把 tool_result 写回 history。
|
||||||
func (service *Service) handleExecResult(intent InboundIntent) error {
|
func (service *Service) handleExecResult(intent InboundIntent) error {
|
||||||
stream, ok := service.broker.Get(intent.RequestID)
|
stream, ok := service.broker.Get(intent.RequestID)
|
||||||
@@ -2122,9 +2213,15 @@ func (service *Service) completeSuccessfulTurn(stream *ActiveStream, completion
|
|||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if err := service.publishCheckpoint(requestID, conversationID); err != nil {
|
return service.publishCheckpointWithCompletion(requestID, conversationID, &completion)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *Service) finishSuccessfulTurnAfterCheckpoint(stream *ActiveStream, completion pendingTurnCompletion) error {
|
||||||
|
if stream == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
requestID := firstNonEmpty(strings.TrimSpace(completion.RequestID), strings.TrimSpace(stream.RequestID))
|
||||||
|
usage := completion.Usage
|
||||||
if err := service.broker.Publish(requestID, StreamEvent{
|
if err := service.broker.Publish(requestID, StreamEvent{
|
||||||
Message: buildTurnEndedMessage(usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens),
|
Message: buildTurnEndedMessage(usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -2151,7 +2248,11 @@ func (service *Service) failStreamIfNonTerminal(stream *ActiveStream, terminalCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// publishCheckpoint 按当前内存会话镜像投影出 checkpoint,并广播给所有 RunSSE 订阅者。
|
// publishCheckpoint 按当前内存会话镜像投影出 checkpoint,并广播给所有 RunSSE 订阅者。
|
||||||
func (service *Service) publishCheckpoint(requestID string, _ string) error {
|
func (service *Service) publishCheckpoint(requestID string, conversationID string) error {
|
||||||
|
return service.publishCheckpointWithCompletion(requestID, conversationID, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) publishCheckpointWithCompletion(requestID string, _ string, completion *pendingTurnCompletion) error {
|
||||||
stream, ok := service.broker.Get(requestID)
|
stream, ok := service.broker.Get(requestID)
|
||||||
if !ok || stream == nil {
|
if !ok || stream == nil {
|
||||||
return fmt.Errorf("request is not active: %s", requestID)
|
return fmt.Errorf("request is not active: %s", requestID)
|
||||||
@@ -2160,15 +2261,16 @@ func (service *Service) publishCheckpoint(requestID string, _ string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
state, err := service.projector.ProjectLegacyCheckpoint(conversation)
|
projection, err := service.projector.ProjectCheckpointProjection(conversation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
state.PendingToolCalls = buildPendingToolCalls(pendingExecs, pendingInteractions)
|
if projection == nil || projection.State == nil {
|
||||||
service.rewriteCheckpointTokenDetailsForClient(stream, conversation, state)
|
return fmt.Errorf("checkpoint projection is empty")
|
||||||
return service.broker.Publish(requestID, StreamEvent{
|
}
|
||||||
Message: buildCheckpointMessage(state),
|
projection.State.PendingToolCalls = buildPendingToolCalls(pendingExecs, pendingInteractions)
|
||||||
})
|
service.rewriteCheckpointTokenDetailsForClient(stream, conversation, projection.State)
|
||||||
|
return service.queueCheckpointProjection(stream, projection, completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) rewriteCheckpointTokenDetailsForClient(stream *ActiveStream, conversation *ConversationFile, state *agentv1.ConversationStateStructure) {
|
func (service *Service) rewriteCheckpointTokenDetailsForClient(stream *ActiveStream, conversation *ConversationFile, state *agentv1.ConversationStateStructure) {
|
||||||
@@ -2422,6 +2524,16 @@ func newAssistantTextEntry(turnSeq int64, requestID string, text string, reasoni
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newAssistantTextEntryWithProviderMetadata(turnSeq int64, requestID string, text string, reasoningContent string, reasoningSignature string, reasoningSignatureSource string, reasoningItemID string, reasoningStatus string, reasoningSummary json.RawMessage) HistoryEntry {
|
func newAssistantTextEntryWithProviderMetadata(turnSeq int64, requestID string, text string, reasoningContent string, reasoningSignature string, reasoningSignatureSource string, reasoningItemID string, reasoningStatus string, reasoningSummary json.RawMessage) HistoryEntry {
|
||||||
|
return HistoryEntry{
|
||||||
|
TurnSeq: turnSeq,
|
||||||
|
RequestID: strings.TrimSpace(requestID),
|
||||||
|
Role: "assistant",
|
||||||
|
Kind: "assistant_text",
|
||||||
|
Payload: newAssistantTextPayload(text, reasoningContent, reasoningSignature, reasoningSignatureSource, reasoningItemID, reasoningStatus, reasoningSummary),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAssistantTextPayload(text string, reasoningContent string, reasoningSignature string, reasoningSignatureSource string, reasoningItemID string, reasoningStatus string, reasoningSummary json.RawMessage) json.RawMessage {
|
||||||
payload, _ := json.Marshal(assistantTextPayload{
|
payload, _ := json.Marshal(assistantTextPayload{
|
||||||
Text: text,
|
Text: text,
|
||||||
ReasoningContent: reasoningContent,
|
ReasoningContent: reasoningContent,
|
||||||
@@ -2431,13 +2543,7 @@ func newAssistantTextEntryWithProviderMetadata(turnSeq int64, requestID string,
|
|||||||
ReasoningStatus: strings.TrimSpace(reasoningStatus),
|
ReasoningStatus: strings.TrimSpace(reasoningStatus),
|
||||||
ReasoningSummary: append(json.RawMessage(nil), reasoningSummary...),
|
ReasoningSummary: append(json.RawMessage(nil), reasoningSummary...),
|
||||||
})
|
})
|
||||||
return HistoryEntry{
|
return payload
|
||||||
TurnSeq: turnSeq,
|
|
||||||
RequestID: strings.TrimSpace(requestID),
|
|
||||||
Role: "assistant",
|
|
||||||
Kind: "assistant_text",
|
|
||||||
Payload: payload,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newToolCallEntry 构造 tool_call entry。
|
// newToolCallEntry 构造 tool_call entry。
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ type HistoryEntry struct {
|
|||||||
Seq int64 `json:"seq"`
|
Seq int64 `json:"seq"`
|
||||||
TurnSeq int64 `json:"turn_seq"`
|
TurnSeq int64 `json:"turn_seq"`
|
||||||
RequestID string `json:"request_id,omitempty"`
|
RequestID string `json:"request_id,omitempty"`
|
||||||
|
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
@@ -163,6 +164,10 @@ type ActiveStream struct {
|
|||||||
ProviderUsage turnUsageSnapshot
|
ProviderUsage turnUsageSnapshot
|
||||||
ProviderTerminalToolInvocation bool
|
ProviderTerminalToolInvocation bool
|
||||||
PendingCompaction *PendingCompaction
|
PendingCompaction *PendingCompaction
|
||||||
|
PendingCheckpointBlobWrites map[uint32]string
|
||||||
|
ConfirmedCheckpointBlobs map[string]struct{}
|
||||||
|
NextCheckpointBlobRequestID uint32
|
||||||
|
PendingCheckpoint *pendingCheckpointPublish
|
||||||
|
|
||||||
Backlog []StreamEvent
|
Backlog []StreamEvent
|
||||||
Subscribers map[string]*StreamSubscriber
|
Subscribers map[string]*StreamSubscriber
|
||||||
@@ -219,6 +224,12 @@ type pendingTurnCompletion struct {
|
|||||||
Disposition pendingCompletionDisposition
|
Disposition pendingCompletionDisposition
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type pendingCheckpointPublish struct {
|
||||||
|
State *agentv1.ConversationStateStructure
|
||||||
|
Required map[string]struct{}
|
||||||
|
Completion *pendingTurnCompletion
|
||||||
|
}
|
||||||
|
|
||||||
type PendingCompaction struct {
|
type PendingCompaction struct {
|
||||||
Trigger string
|
Trigger string
|
||||||
ContextTokens int64
|
ContextTokens int64
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ type ModelAdapterTestResult = client.ModelAdapterTestResult
|
|||||||
// ModelAdapterTestResultsPayload 定义测速结果事件载荷。
|
// ModelAdapterTestResultsPayload 定义测速结果事件载荷。
|
||||||
type ModelAdapterTestResultsPayload = client.ModelAdapterTestResultsPayload
|
type ModelAdapterTestResultsPayload = client.ModelAdapterTestResultsPayload
|
||||||
|
|
||||||
|
// ModelAdapterModelsRequest 定义模型列表查询请求。
|
||||||
|
type ModelAdapterModelsRequest = client.ModelAdapterModelsRequest
|
||||||
|
|
||||||
|
// ModelAdapterModelsResult 定义模型列表查询结果。
|
||||||
|
type ModelAdapterModelsResult = client.ModelAdapterModelsResult
|
||||||
|
|
||||||
// CursorAccountStatus 是可安全展示给桌面前端的独立 Cursor 账号状态。
|
// CursorAccountStatus 是可安全展示给桌面前端的独立 Cursor 账号状态。
|
||||||
type CursorAccountStatus = client.CursorAccountStatus
|
type CursorAccountStatus = client.CursorAccountStatus
|
||||||
|
|
||||||
@@ -119,6 +125,11 @@ func (s *ProxyService) GetModelAdapterTestResults() []ModelAdapterTestResult {
|
|||||||
return s.core.GetModelAdapterTestResults()
|
return s.core.GetModelAdapterTestResults()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchModelAdapterModels 用于从模型服务读取可用模型列表。
|
||||||
|
func (s *ProxyService) FetchModelAdapterModels(input ModelAdapterModelsRequest) (ModelAdapterModelsResult, error) {
|
||||||
|
return s.core.FetchModelAdapterModels(input)
|
||||||
|
}
|
||||||
|
|
||||||
// GetDeviceID 用于处理与 GetDeviceID 相关的逻辑。
|
// GetDeviceID 用于处理与 GetDeviceID 相关的逻辑。
|
||||||
func (s *ProxyService) GetDeviceID() (string, error) {
|
func (s *ProxyService) GetDeviceID() (string, error) {
|
||||||
return s.core.GetDeviceID()
|
return s.core.GetDeviceID()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -31,8 +32,48 @@ const (
|
|||||||
modelAdapterTestDefaultMaxTokens = 65_536
|
modelAdapterTestDefaultMaxTokens = 65_536
|
||||||
modelAdapterTestEmptyTextError = "未收到文本输出,无法计算测速结果"
|
modelAdapterTestEmptyTextError = "未收到文本输出,无法计算测速结果"
|
||||||
modelAdapterTestMaxErrorBodyBytes = 8192
|
modelAdapterTestMaxErrorBodyBytes = 8192
|
||||||
|
modelAdapterListTimeout = 20 * time.Second
|
||||||
|
modelAdapterListMaxBodyBytes = 8 << 20
|
||||||
|
modelAdapterListPageSize = 1000
|
||||||
|
modelAdapterListMaxPages = 50
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// modelListProviderRule 收敛各家模型列表接口的协议差异,避免判断散落到多个函数。
|
||||||
|
type modelListProviderRule struct {
|
||||||
|
// paths 按优先级排列,逐个尝试直到某个返回可用模型
|
||||||
|
paths []string
|
||||||
|
authHeader string
|
||||||
|
authPrefix string
|
||||||
|
extraHeader map[string]string
|
||||||
|
// paginated 为真时按 limit + after_id 游标翻页,直到 has_more 为 false
|
||||||
|
paginated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var modelListProviderRules = map[string]modelListProviderRule{
|
||||||
|
"openai": {
|
||||||
|
paths: []string{"/models"},
|
||||||
|
authHeader: "Authorization",
|
||||||
|
authPrefix: "Bearer ",
|
||||||
|
},
|
||||||
|
"anthropic": {
|
||||||
|
paths: []string{"/models"},
|
||||||
|
authHeader: "x-api-key",
|
||||||
|
extraHeader: map[string]string{"anthropic-version": "2023-06-01"},
|
||||||
|
paginated: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelListVersionSegments 用于判断 base url 是否已带版本前缀,带了就不再补 /v1。
|
||||||
|
var modelListVersionSegments = map[string]bool{
|
||||||
|
"v1": true,
|
||||||
|
"v1beta": true,
|
||||||
|
"v2": true,
|
||||||
|
"beta": true,
|
||||||
|
"openai": true,
|
||||||
|
"compat": true,
|
||||||
|
"compatible": true,
|
||||||
|
}
|
||||||
|
|
||||||
type ModelAdapterTestStatus string
|
type ModelAdapterTestStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -58,6 +99,20 @@ type ModelAdapterTestResult struct {
|
|||||||
TestedAt string `json:"testedAt"`
|
TestedAt string `json:"testedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ModelAdapterModelsRequest 定义从兼容接口读取模型列表所需的最小配置。
|
||||||
|
type ModelAdapterModelsRequest struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
BaseURL string `json:"baseURL"`
|
||||||
|
APIKey string `json:"apiKey"`
|
||||||
|
CustomHeadersEnabled bool `json:"customHeadersEnabled"`
|
||||||
|
CustomHeadersJSON string `json:"customHeadersJSON"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelAdapterModelsResult 定义可供前端下拉选择的模型列表。
|
||||||
|
type ModelAdapterModelsResult struct {
|
||||||
|
Models []string `json:"models"`
|
||||||
|
}
|
||||||
|
|
||||||
// ModelAdapterTestResultsPayload 用于向前端广播当前测速结果快照。
|
// ModelAdapterTestResultsPayload 用于向前端广播当前测速结果快照。
|
||||||
type ModelAdapterTestResultsPayload struct {
|
type ModelAdapterTestResultsPayload struct {
|
||||||
Results []ModelAdapterTestResult `json:"results"`
|
Results []ModelAdapterTestResult `json:"results"`
|
||||||
@@ -108,6 +163,254 @@ func (s *ProxyService) GetModelAdapterTestResults() []ModelAdapterTestResult {
|
|||||||
return s.snapshotModelAdapterTestResults()
|
return s.snapshotModelAdapterTestResults()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ProxyService) FetchModelAdapterModels(input ModelAdapterModelsRequest) (ModelAdapterModelsResult, error) {
|
||||||
|
_ = s
|
||||||
|
provider := strings.ToLower(strings.TrimSpace(input.Type))
|
||||||
|
baseURL := strings.TrimSpace(input.BaseURL)
|
||||||
|
apiKey := strings.TrimSpace(input.APIKey)
|
||||||
|
rule, supported := modelListProviderRules[provider]
|
||||||
|
if !supported {
|
||||||
|
return ModelAdapterModelsResult{}, errors.New("模型类型仅支持 OpenAI 或 Anthropic")
|
||||||
|
}
|
||||||
|
if baseURL == "" {
|
||||||
|
return ModelAdapterModelsResult{}, errors.New("接口地址不能为空")
|
||||||
|
}
|
||||||
|
if apiKey == "" {
|
||||||
|
return ModelAdapterModelsResult{}, errors.New("访问密钥不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), modelAdapterListTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, endpoint := range buildModelListEndpointCandidates(rule, baseURL) {
|
||||||
|
models, err := fetchModelListEndpoint(ctx, rule, endpoint, apiKey, input)
|
||||||
|
if err == nil {
|
||||||
|
return ModelAdapterModelsResult{Models: models}, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return ModelAdapterModelsResult{}, lastErr
|
||||||
|
}
|
||||||
|
return ModelAdapterModelsResult{}, errors.New("未找到可用的模型列表接口")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildModelListEndpointCandidates(rule modelListProviderRule, rawBaseURL string) []string {
|
||||||
|
base := strings.TrimRight(strings.TrimSpace(rawBaseURL), "/")
|
||||||
|
for _, suffix := range []string{"/chat/completions", "/responses", "/messages"} {
|
||||||
|
if strings.HasSuffix(strings.ToLower(base), suffix) {
|
||||||
|
base = base[:len(base)-len(suffix)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base = strings.TrimRight(base, "/")
|
||||||
|
|
||||||
|
tail := strings.ToLower(base[strings.LastIndex(base, "/")+1:])
|
||||||
|
var candidates []string
|
||||||
|
switch {
|
||||||
|
case tail == "models" || tail == "model":
|
||||||
|
// 用户已经填到模型列表地址本身,直接用
|
||||||
|
candidates = []string{base}
|
||||||
|
case modelListVersionSegments[tail]:
|
||||||
|
candidates = prefixModelListPaths(base, "", rule.paths)
|
||||||
|
default:
|
||||||
|
// base 没带版本段,优先试 /v1,再退回裸路径
|
||||||
|
candidates = append(
|
||||||
|
prefixModelListPaths(base, "/v1", rule.paths),
|
||||||
|
prefixModelListPaths(base, "", rule.paths)...,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
endpoints := make([]string, 0, len(candidates))
|
||||||
|
for _, endpoint := range candidates {
|
||||||
|
if _, err := url.ParseRequestURI(endpoint); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[endpoint]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[endpoint] = struct{}{}
|
||||||
|
endpoints = append(endpoints, endpoint)
|
||||||
|
}
|
||||||
|
return endpoints
|
||||||
|
}
|
||||||
|
|
||||||
|
func prefixModelListPaths(base string, version string, paths []string) []string {
|
||||||
|
endpoints := make([]string, 0, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
endpoints = append(endpoints, base+version+path)
|
||||||
|
}
|
||||||
|
return endpoints
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchModelListEndpoint(ctx context.Context, rule modelListProviderRule, endpoint string, apiKey string, input ModelAdapterModelsRequest) ([]string, error) {
|
||||||
|
collected := []string{}
|
||||||
|
cursor := ""
|
||||||
|
for page := 0; page < modelAdapterListMaxPages; page++ {
|
||||||
|
requestURL := endpoint
|
||||||
|
if rule.paginated {
|
||||||
|
requestURL = appendModelListCursor(endpoint, cursor)
|
||||||
|
}
|
||||||
|
payload, err := requestModelListPayload(ctx, rule, requestURL, apiKey, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
collected = append(collected, extractModelIDs(payload)...)
|
||||||
|
if !rule.paginated {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cursor = nextModelListCursor(payload)
|
||||||
|
if cursor == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if page == modelAdapterListMaxPages-1 {
|
||||||
|
return nil, fmt.Errorf("模型列表分页超过 %d 页,结果可能不完整", modelAdapterListMaxPages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
models := normalizeFetchedModelIDs(collected)
|
||||||
|
if len(models) == 0 {
|
||||||
|
return nil, errors.New("模型列表响应中没有可用模型")
|
||||||
|
}
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestModelListPayload(
|
||||||
|
ctx context.Context,
|
||||||
|
rule modelListProviderRule,
|
||||||
|
requestURL string,
|
||||||
|
apiKey string,
|
||||||
|
input ModelAdapterModelsRequest,
|
||||||
|
) (any, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set(rule.authHeader, rule.authPrefix+apiKey)
|
||||||
|
for key, value := range rule.extraHeader {
|
||||||
|
req.Header.Set(key, value)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
applyModelListCustomHeaders(req.Header, input)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, modelAdapterListMaxBodyBytes))
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, readErr
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
message := strings.TrimSpace(string(body))
|
||||||
|
if len(message) > modelAdapterTestMaxErrorBodyBytes {
|
||||||
|
message = message[:modelAdapterTestMaxErrorBodyBytes]
|
||||||
|
}
|
||||||
|
if message == "" {
|
||||||
|
message = resp.Status
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("读取模型列表失败:%s", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload any
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("模型列表响应不是合法 JSON:%w", err)
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendModelListCursor(endpoint string, cursor string) string {
|
||||||
|
query := url.Values{}
|
||||||
|
query.Set("limit", strconv.Itoa(modelAdapterListPageSize))
|
||||||
|
if cursor != "" {
|
||||||
|
query.Set("after_id", cursor)
|
||||||
|
}
|
||||||
|
separator := "?"
|
||||||
|
if strings.Contains(endpoint, "?") {
|
||||||
|
separator = "&"
|
||||||
|
}
|
||||||
|
return endpoint + separator + query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextModelListCursor(payload any) string {
|
||||||
|
object, ok := payload.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if hasMore, _ := object["has_more"].(bool); !hasMore {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cursor, _ := object["last_id"].(string)
|
||||||
|
return strings.TrimSpace(cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyModelListCustomHeaders(header http.Header, input ModelAdapterModelsRequest) {
|
||||||
|
if !input.CustomHeadersEnabled || strings.TrimSpace(input.CustomHeadersJSON) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var parsed map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(input.CustomHeadersJSON), &parsed); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for key, value := range parsed {
|
||||||
|
if strings.TrimSpace(key) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
header.Set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractModelIDs(value any) []string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
if strings.TrimSpace(typed) == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return []string{typed}
|
||||||
|
case []any:
|
||||||
|
models := make([]string, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
models = append(models, extractModelIDs(item)...)
|
||||||
|
}
|
||||||
|
return models
|
||||||
|
case map[string]any:
|
||||||
|
for _, key := range []string{"id", "name"} {
|
||||||
|
if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models := []string{}
|
||||||
|
for _, key := range []string{"data", "models"} {
|
||||||
|
if child, ok := typed[key]; ok {
|
||||||
|
models = append(models, extractModelIDs(child)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return models
|
||||||
|
default:
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFetchedModelIDs(input []string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
models := make([]string, 0, len(input))
|
||||||
|
for _, item := range input {
|
||||||
|
model := strings.TrimSpace(item)
|
||||||
|
if model == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[model]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[model] = struct{}{}
|
||||||
|
models = append(models, model)
|
||||||
|
}
|
||||||
|
sort.Strings(models)
|
||||||
|
return models
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProxyService) TestModelAdapter(adapter serverconfig.ModelAdapterConfig) (ModelAdapterTestResult, error) {
|
func (s *ProxyService) TestModelAdapter(adapter serverconfig.ModelAdapterConfig) (ModelAdapterTestResult, error) {
|
||||||
requestHash := buildModelAdapterTestRequestHash(adapter)
|
requestHash := buildModelAdapterTestRequestHash(adapter)
|
||||||
adapterID := buildModelAdapterTestCacheKey(adapter, requestHash)
|
adapterID := buildModelAdapterTestCacheKey(adapter, requestHash)
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildModelListEndpointCandidates(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
provider string
|
||||||
|
baseURL string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "openai 带版本段不再补 v1",
|
||||||
|
provider: "openai",
|
||||||
|
baseURL: "https://api.openai.com/v1",
|
||||||
|
want: []string{"https://api.openai.com/v1/models"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "openai 裸域名优先试 v1",
|
||||||
|
provider: "openai",
|
||||||
|
baseURL: "https://api.openai.com",
|
||||||
|
want: []string{"https://api.openai.com/v1/models", "https://api.openai.com/models"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "anthropic 裸域名优先试 v1",
|
||||||
|
provider: "anthropic",
|
||||||
|
baseURL: "https://api.anthropic.com",
|
||||||
|
want: []string{"https://api.anthropic.com/v1/models", "https://api.anthropic.com/models"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "anthropic 带版本段不再补 v1",
|
||||||
|
provider: "anthropic",
|
||||||
|
baseURL: "https://api.anthropic.com/v1",
|
||||||
|
want: []string{"https://api.anthropic.com/v1/models"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "剥离 chat completions 后缀",
|
||||||
|
provider: "openai",
|
||||||
|
baseURL: "https://api.example.com/v1/chat/completions",
|
||||||
|
want: []string{"https://api.example.com/v1/models"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "剥离 responses 后缀",
|
||||||
|
provider: "openai",
|
||||||
|
baseURL: "https://api.example.com/v1/responses",
|
||||||
|
want: []string{"https://api.example.com/v1/models"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "剥离 anthropic messages 后缀",
|
||||||
|
provider: "anthropic",
|
||||||
|
baseURL: "https://api.example.com/v1/messages",
|
||||||
|
want: []string{"https://api.example.com/v1/models"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "已填到 models 地址本身则原样使用",
|
||||||
|
provider: "openai",
|
||||||
|
baseURL: "https://api.example.com/openai/v1/models",
|
||||||
|
want: []string{"https://api.example.com/openai/v1/models"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "自定义网关前缀会补 v1",
|
||||||
|
provider: "openai",
|
||||||
|
baseURL: "https://gateway.example.com/proxy",
|
||||||
|
want: []string{
|
||||||
|
"https://gateway.example.com/proxy/v1/models",
|
||||||
|
"https://gateway.example.com/proxy/models",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "尾部斜杠不影响推导",
|
||||||
|
provider: "anthropic",
|
||||||
|
baseURL: " https://api.anthropic.com/v1/ ",
|
||||||
|
want: []string{"https://api.anthropic.com/v1/models"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
rule, ok := modelListProviderRules[test.provider]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("provider %q 没有对应规则", test.provider)
|
||||||
|
}
|
||||||
|
got := buildModelListEndpointCandidates(rule, test.baseURL)
|
||||||
|
if !reflect.DeepEqual(got, test.want) {
|
||||||
|
t.Fatalf("buildModelListEndpointCandidates(%q) = %v, want %v", test.baseURL, got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchModelAdapterModelsOpenAIUsesBearer(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotAuth string
|
||||||
|
var gotAnthropicVersion string
|
||||||
|
var gotAPIKeyHeader string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
gotAnthropicVersion = r.Header.Get("anthropic-version")
|
||||||
|
gotAPIKeyHeader = r.Header.Get("x-api-key")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5"},{"id":"gpt-4o"}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
service := &ProxyService{}
|
||||||
|
result, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
|
||||||
|
Type: "openai",
|
||||||
|
BaseURL: server.URL + "/v1",
|
||||||
|
APIKey: "sk-test",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotPath != "/v1/models" {
|
||||||
|
t.Fatalf("请求路径 = %q, want /v1/models", gotPath)
|
||||||
|
}
|
||||||
|
if gotAuth != "Bearer sk-test" {
|
||||||
|
t.Fatalf("Authorization = %q, want Bearer sk-test", gotAuth)
|
||||||
|
}
|
||||||
|
if gotAPIKeyHeader != "" {
|
||||||
|
t.Fatalf("openai 不应发送 x-api-key,实际 = %q", gotAPIKeyHeader)
|
||||||
|
}
|
||||||
|
if gotAnthropicVersion != "" {
|
||||||
|
t.Fatalf("openai 不应发送 anthropic-version,实际 = %q", gotAnthropicVersion)
|
||||||
|
}
|
||||||
|
want := []string{"gpt-4o", "gpt-5"}
|
||||||
|
if !reflect.DeepEqual(result.Models, want) {
|
||||||
|
t.Fatalf("Models = %v, want %v", result.Models, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchModelAdapterModelsAnthropicUsesAPIKeyHeader(t *testing.T) {
|
||||||
|
var gotAuth string
|
||||||
|
var gotAPIKeyHeader string
|
||||||
|
var gotAnthropicVersion string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
gotAPIKeyHeader = r.Header.Get("x-api-key")
|
||||||
|
gotAnthropicVersion = r.Header.Get("anthropic-version")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"id":"claude-sonnet-4"}],"has_more":false}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
service := &ProxyService{}
|
||||||
|
result, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
|
||||||
|
Type: "anthropic",
|
||||||
|
BaseURL: server.URL + "/v1",
|
||||||
|
APIKey: "sk-ant-test",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotAPIKeyHeader != "sk-ant-test" {
|
||||||
|
t.Fatalf("x-api-key = %q, want sk-ant-test", gotAPIKeyHeader)
|
||||||
|
}
|
||||||
|
if gotAnthropicVersion != "2023-06-01" {
|
||||||
|
t.Fatalf("anthropic-version = %q, want 2023-06-01", gotAnthropicVersion)
|
||||||
|
}
|
||||||
|
if gotAuth != "" {
|
||||||
|
t.Fatalf("anthropic 不应发送 Authorization,实际 = %q", gotAuth)
|
||||||
|
}
|
||||||
|
want := []string{"claude-sonnet-4"}
|
||||||
|
if !reflect.DeepEqual(result.Models, want) {
|
||||||
|
t.Fatalf("Models = %v, want %v", result.Models, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchModelAdapterModelsAnthropicFollowsCursor(t *testing.T) {
|
||||||
|
var requestedQueries []string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestedQueries = append(requestedQueries, r.URL.RawQuery)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Query().Get("after_id") {
|
||||||
|
case "":
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"id":"claude-a"}],"has_more":true,"last_id":"claude-a"}`))
|
||||||
|
case "claude-a":
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"id":"claude-b"}],"has_more":true,"last_id":"claude-b"}`))
|
||||||
|
default:
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"id":"claude-c"}],"has_more":false}`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
service := &ProxyService{}
|
||||||
|
result, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
|
||||||
|
Type: "anthropic",
|
||||||
|
BaseURL: server.URL + "/v1",
|
||||||
|
APIKey: "sk-ant-test",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"claude-a", "claude-b", "claude-c"}
|
||||||
|
if !reflect.DeepEqual(result.Models, want) {
|
||||||
|
t.Fatalf("Models = %v, want %v", result.Models, want)
|
||||||
|
}
|
||||||
|
if len(requestedQueries) != 3 {
|
||||||
|
t.Fatalf("请求次数 = %d, want 3(两次翻页后停止)", len(requestedQueries))
|
||||||
|
}
|
||||||
|
for _, query := range requestedQueries {
|
||||||
|
if !strings.Contains(query, "limit=1000") {
|
||||||
|
t.Fatalf("翻页请求缺少 limit 参数:%q", query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(requestedQueries[1], "after_id=claude-a") {
|
||||||
|
t.Fatalf("第二页未带上游标:%q", requestedQueries[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchModelAdapterModelsOpenAIDoesNotPaginate(t *testing.T) {
|
||||||
|
requestCount := 0
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestCount++
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
t.Errorf("openai 不应附加分页参数,实际 = %q", r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5"}],"has_more":true,"last_id":"gpt-5"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
service := &ProxyService{}
|
||||||
|
if _, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
|
||||||
|
Type: "openai",
|
||||||
|
BaseURL: server.URL + "/v1",
|
||||||
|
APIKey: "sk-test",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestCount != 1 {
|
||||||
|
t.Fatalf("请求次数 = %d, want 1(openai 忽略 has_more)", requestCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchModelAdapterModelsReadsLargeBody(t *testing.T) {
|
||||||
|
models := make([]map[string]string, 0, 400)
|
||||||
|
for index := 0; index < 400; index++ {
|
||||||
|
models = append(models, map[string]string{
|
||||||
|
"id": "vendor/model-with-a-fairly-long-identifier-" + strings.Repeat("x", 40) + "-" + string(rune('a'+index%26)) + strconv.Itoa(index),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(map[string]any{"data": models})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("构造响应失败:%v", err)
|
||||||
|
}
|
||||||
|
if len(body) <= modelAdapterTestMaxErrorBodyBytes {
|
||||||
|
t.Fatalf("测试响应体只有 %d 字节,需要大于 %d 才能覆盖截断场景", len(body), modelAdapterTestMaxErrorBodyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
service := &ProxyService{}
|
||||||
|
result, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
|
||||||
|
Type: "openai",
|
||||||
|
BaseURL: server.URL + "/v1",
|
||||||
|
APIKey: "sk-test",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
|
||||||
|
}
|
||||||
|
if len(result.Models) != len(models) {
|
||||||
|
t.Fatalf("Models 数量 = %d, want %d", len(result.Models), len(models))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchModelAdapterModelsSupportsStringItems(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":["gpt-4o","gpt-4.1"]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
service := &ProxyService{}
|
||||||
|
result, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
|
||||||
|
Type: "openai",
|
||||||
|
BaseURL: server.URL + "/v1",
|
||||||
|
APIKey: "sk-test",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
|
||||||
|
}
|
||||||
|
want := []string{"gpt-4.1", "gpt-4o"}
|
||||||
|
if !reflect.DeepEqual(result.Models, want) {
|
||||||
|
t.Fatalf("Models = %v, want %v", result.Models, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchModelAdapterModelsRejectsPaginationTruncation(t *testing.T) {
|
||||||
|
requestCount := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
requestCount++
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"id":"claude-model"}],"has_more":true,"last_id":"next"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
service := &ProxyService{}
|
||||||
|
_, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
|
||||||
|
Type: "anthropic",
|
||||||
|
BaseURL: server.URL + "/v1",
|
||||||
|
APIKey: "sk-ant-test",
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "结果可能不完整") {
|
||||||
|
t.Fatalf("期望分页截断错误,实际 = %v", err)
|
||||||
|
}
|
||||||
|
if requestCount != modelAdapterListMaxPages {
|
||||||
|
t.Fatalf("请求次数 = %d, want %d", requestCount, modelAdapterListMaxPages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchModelAdapterModelsRejectsInvalidInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
request ModelAdapterModelsRequest
|
||||||
|
}{
|
||||||
|
{name: "未知类型", request: ModelAdapterModelsRequest{Type: "gemini", BaseURL: "https://x.com", APIKey: "k"}},
|
||||||
|
{name: "缺少地址", request: ModelAdapterModelsRequest{Type: "openai", APIKey: "k"}},
|
||||||
|
{name: "缺少密钥", request: ModelAdapterModelsRequest{Type: "openai", BaseURL: "https://x.com"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
service := &ProxyService{}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if _, err := service.FetchModelAdapterModels(test.request); err == nil {
|
||||||
|
t.Fatal("期望返回错误,实际为 nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+1413
-1379
File diff suppressed because it is too large
Load Diff
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cursor-proto-sync.XXXXXX")"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TEMP_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
for PROTO_NAME in agent_v1.proto aiserver_v1.proto; do
|
||||||
|
ROOT_PROTO="$SCRIPT_DIR/$PROTO_NAME"
|
||||||
|
EXTRACTED_PROTO="$SCRIPT_DIR/from_extensions/$PROTO_NAME"
|
||||||
|
if [[ ! -f "$ROOT_PROTO" || ! -f "$EXTRACTED_PROTO" ]]; then
|
||||||
|
echo "Missing proto pair for $PROTO_NAME" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sed -E 's|^option go_package = ".*";$|option go_package = "__NORMALIZED__";|' "$ROOT_PROTO" > "$TEMP_DIR/root-$PROTO_NAME"
|
||||||
|
sed -E 's|^option go_package = ".*";$|option go_package = "__NORMALIZED__";|' "$EXTRACTED_PROTO" > "$TEMP_DIR/extracted-$PROTO_NAME"
|
||||||
|
if ! cmp -s "$TEMP_DIR/root-$PROTO_NAME" "$TEMP_DIR/extracted-$PROTO_NAME"; then
|
||||||
|
echo "Proto snapshot is out of sync: $PROTO_NAME" >&2
|
||||||
|
diff -u "$TEMP_DIR/root-$PROTO_NAME" "$TEMP_DIR/extracted-$PROTO_NAME" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
@@ -9,6 +9,23 @@ OUTPUT_DEFAULT="$SCRIPT_DIR/from_extensions"
|
|||||||
INPUT_PATH="${1:-$INPUT_DEFAULT}"
|
INPUT_PATH="${1:-$INPUT_DEFAULT}"
|
||||||
OUTPUT_DIR="${2:-$OUTPUT_DEFAULT}"
|
OUTPUT_DIR="${2:-$OUTPUT_DEFAULT}"
|
||||||
|
|
||||||
|
canonicalize_path() {
|
||||||
|
local path="$1"
|
||||||
|
local parent
|
||||||
|
local base
|
||||||
|
if [[ -d "$path" ]]; then
|
||||||
|
(cd "$path" && pwd -P)
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
parent="$(dirname "$path")"
|
||||||
|
base="$(basename "$path")"
|
||||||
|
if [[ ! -d "$parent" ]]; then
|
||||||
|
echo "Parent directory does not exist: $parent" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf '%s/%s\n' "$(cd "$parent" && pwd -P)" "$base"
|
||||||
|
}
|
||||||
|
|
||||||
# Resolve input: accept either a single JS file or an extensions root directory.
|
# Resolve input: accept either a single JS file or an extensions root directory.
|
||||||
if [[ -d "$INPUT_PATH" ]]; then
|
if [[ -d "$INPUT_PATH" ]]; then
|
||||||
CANDIDATES=(
|
CANDIDATES=(
|
||||||
@@ -26,7 +43,10 @@ if [[ -d "$INPUT_PATH" ]]; then
|
|||||||
if [[ -n "$FOUND_CANDIDATE" ]]; then
|
if [[ -n "$FOUND_CANDIDATE" ]]; then
|
||||||
INPUT_PATH="$FOUND_CANDIDATE"
|
INPUT_PATH="$FOUND_CANDIDATE"
|
||||||
else
|
else
|
||||||
mapfile -t JS_FILES < <(find "$INPUT_PATH" -type f -path "*/dist/main.js" | sort)
|
JS_FILES=()
|
||||||
|
while IFS= read -r JS_FILE; do
|
||||||
|
JS_FILES+=("$JS_FILE")
|
||||||
|
done < <(find "$INPUT_PATH" -type f -path "*/dist/main.js" | sort)
|
||||||
if [[ ${#JS_FILES[@]} -eq 1 ]]; then
|
if [[ ${#JS_FILES[@]} -eq 1 ]]; then
|
||||||
INPUT_PATH="${JS_FILES[0]}"
|
INPUT_PATH="${JS_FILES[0]}"
|
||||||
elif [[ ${#JS_FILES[@]} -eq 0 ]]; then
|
elif [[ ${#JS_FILES[@]} -eq 0 ]]; then
|
||||||
@@ -48,11 +68,57 @@ if [[ ! -f "$INPUT_PATH" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rm -rf "$OUTPUT_DIR"
|
INPUT_PATH="$(canonicalize_path "$INPUT_PATH")"
|
||||||
mkdir -p "$OUTPUT_DIR"
|
OUTPUT_DIR="$(canonicalize_path "$OUTPUT_DIR")"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
CURRENT_DIR="$(pwd -P)"
|
||||||
|
|
||||||
|
case "$OUTPUT_DIR" in
|
||||||
|
"/"|"$HOME"|"$REPO_ROOT"|"$SCRIPT_DIR"|"$CURRENT_DIR")
|
||||||
|
echo "Refusing unsafe output directory: $OUTPUT_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
case "$INPUT_PATH" in
|
||||||
|
"$OUTPUT_DIR"|"$OUTPUT_DIR"/*)
|
||||||
|
echo "Refusing output directory that contains the input bundle: $OUTPUT_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
OUTPUT_PARENT="$(dirname "$OUTPUT_DIR")"
|
||||||
|
OUTPUT_BASENAME="$(basename "$OUTPUT_DIR")"
|
||||||
|
TEMP_DIR="$(mktemp -d "$OUTPUT_PARENT/.${OUTPUT_BASENAME}.tmp.XXXXXX")"
|
||||||
|
BACKUP_DIR=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then
|
||||||
|
rm -rf "$TEMP_DIR"
|
||||||
|
fi
|
||||||
|
if [[ -n "$BACKUP_DIR" && -e "$BACKUP_DIR" ]]; then
|
||||||
|
if [[ ! -e "$OUTPUT_DIR" ]]; then
|
||||||
|
mv "$BACKUP_DIR" "$OUTPUT_DIR"
|
||||||
|
else
|
||||||
|
rm -rf "$BACKUP_DIR"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
go run "$SCRIPT_DIR/ext_tool" \
|
go run "$SCRIPT_DIR/ext_tool" \
|
||||||
-input "$INPUT_PATH" \
|
-input "$INPUT_PATH" \
|
||||||
-output "$OUTPUT_DIR" \
|
-output "$TEMP_DIR" \
|
||||||
-skip-format \
|
-skip-format \
|
||||||
-strict
|
-strict
|
||||||
|
|
||||||
|
if [[ -e "$OUTPUT_DIR" ]]; then
|
||||||
|
BACKUP_DIR="$(mktemp -d "$OUTPUT_PARENT/.${OUTPUT_BASENAME}.backup.XXXXXX")"
|
||||||
|
rmdir "$BACKUP_DIR"
|
||||||
|
mv "$OUTPUT_DIR" "$BACKUP_DIR"
|
||||||
|
fi
|
||||||
|
mv "$TEMP_DIR" "$OUTPUT_DIR"
|
||||||
|
TEMP_DIR=""
|
||||||
|
if [[ -n "$BACKUP_DIR" ]]; then
|
||||||
|
rm -rf "$BACKUP_DIR"
|
||||||
|
BACKUP_DIR=""
|
||||||
|
fi
|
||||||
|
|||||||
+2500
-739
File diff suppressed because it is too large
Load Diff
+1413
-1379
File diff suppressed because it is too large
Load Diff
@@ -9,4 +9,5 @@ Tg群组:
|
|||||||
https://t.me/cursor_byok
|
https://t.me/cursor_byok
|
||||||
|
|
||||||
- 支持cursor-cli
|
- 支持cursor-cli
|
||||||
|
- 修复对话中错误可能导致的消失问题
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user