Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions src/llm/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,17 +634,34 @@ export async function callLlmWithContinuation(plugin, system, user, options, opt
let text = String(first.text || "");
let finishReason = first.finishReason;
let continuations = 0;
while (isTruncatedFinishReason(finishReason) && continuations < maxContinuations && text.trim()) {
// 思考模式可能耗尽 max_tokens,返回截断状态却没有可见正文;先关闭思考模式重试一次,
// 避免空正文直接阻断续写。对未识别的端点,applyThinkingParam 会保持请求不变。
const emptyTruncated = !text.trim() && isTruncatedFinishReason(finishReason);
const effOptions = emptyTruncated ? Object.assign({}, options, { thinkingMode: "fast" }) : options;
if (emptyTruncated) {
try {
const retry = await callLlmWithMeta(plugin, system, user, effOptions);
text = String(retry.text || "");
finishReason = retry.finishReason;
} catch { /* 重试失败时保留原始结果,交给下方兜底续写 */ }
}
while (isTruncatedFinishReason(finishReason) && continuations < maxContinuations) {
continuations++;
const messages = [
{ role: "system", content: system },
{ role: "user", content: user },
{ role: "assistant", content: text },
{ role: "user", content: "你上一条回复因长度上限被截断了。请直接从断点处继续输出剩余内容、无缝衔接,不要重复任何已输出的文字、不要重新开头、不要加任何前言或结束语,直接接着写。" },
];
const messages = text.trim()
? [
{ role: "system", content: system },
{ role: "user", content: user },
{ role: "assistant", content: text },
{ role: "user", content: "你上一条回复因长度上限被截断了。请直接从断点处继续输出剩余内容、无缝衔接,不要重复任何已输出的文字、不要重新开头、不要加任何前言或结束语,直接接着写。" },
]
: [
{ role: "system", content: system },
{ role: "user", content: user },
{ role: "user", content: "你上一次生成因思考或输出过长被截断,且没有产生任何可见内容。请忽略截断状态,直接完整回答原始任务,不要提及截断、不要加任何前言。" },
];
let data;
try {
data = await requestLlmChatCompletion(plugin, messages, options);
data = await requestLlmChatCompletion(plugin, messages, effOptions);
} catch {
break; // 续写失败就用已有内容,不让整体失败
}
Expand Down
179 changes: 179 additions & 0 deletions tests/llm-continuation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("obsidian", () => ({
requestUrl: vi.fn(),
}));

import { callLlmWithContinuation } from "../src/llm/core";

function jsonResponse(content, finishReason) {
return {
ok: true,
status: 200,
headers: { get: () => null },
json: async () => ({
choices: [{ message: { role: "assistant", content }, finish_reason: finishReason }],
}),
text: async () => "",
};
}

function errorResponse(status, message) {
return {
ok: false,
status,
headers: { get: () => null },
text: async () => JSON.stringify({ error: { message } }),
};
}

function makePlugin() {
return {
settings: {
llmEndpoint: "https://api.deepseek.com/v1",
llmApiKey: "test-key",
llmModel: "model",
},
addTaskMeter: vi.fn(),
};
}

describe("callLlmWithContinuation", () => {
let fetchMock;
let previousWindow;

beforeEach(() => {
previousWindow = globalThis.window;
fetchMock = vi.fn();
globalThis.window = { fetch: fetchMock, setTimeout, clearTimeout };
});

afterEach(() => {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});

it("拼接有正文但被长度上限截断的后续输出", async () => {
const requestBodies = [];
fetchMock.mockImplementation(async (_url, init) => {
requestBodies.push(JSON.parse(init.body));
return requestBodies.length === 1
? jsonResponse("正文", "length")
: jsonResponse("后半", "stop");
});
const optionReceivers = [];
const options = {
stream: false,
noRetry: true,
skipQueue: true,
get thinkingMode() {
optionReceivers.push(this);
return "reasoning";
},
};

const result = await callLlmWithContinuation(makePlugin(), "系统提示", "原始任务", options);

expect(result).toEqual({
text: "正文后半",
finishReason: "stop",
truncated: false,
continuations: 1,
});
expect(requestBodies[1].messages[2]).toEqual({ role: "assistant", content: "正文" });
expect(optionReceivers[1]).toBe(options);
});

it("空输出被截断时关闭思考模式重试原始任务", async () => {
const requestBodies = [];
fetchMock.mockImplementation(async (_url, init) => {
requestBodies.push(JSON.parse(init.body));
return requestBodies.length === 1
? jsonResponse("", "length")
: jsonResponse("完整正文", "stop");
});

const result = await callLlmWithContinuation(makePlugin(), "系统提示", "原始任务", {
stream: false,
noRetry: true,
skipQueue: true,
thinkingMode: "reasoning",
});

expect(result).toEqual({
text: "完整正文",
finishReason: "stop",
truncated: false,
continuations: 0,
});
expect(requestBodies[1].messages).toEqual(requestBodies[0].messages);
expect(requestBodies[1].thinking).toEqual({ type: "disabled" });
});

it("fast 重试仍被截断时继续拼接已获得的正文", async () => {
const requestBodies = [];
fetchMock.mockImplementation(async (_url, init) => {
requestBodies.push(JSON.parse(init.body));
if (requestBodies.length === 1) return jsonResponse("", "length");
if (requestBodies.length === 2) return jsonResponse("部分", "length");
return jsonResponse("后续", "stop");
});

const result = await callLlmWithContinuation(makePlugin(), "系统提示", "原始任务", {
stream: false,
noRetry: true,
skipQueue: true,
thinkingMode: "reasoning",
});

expect(result).toEqual({
text: "部分后续",
finishReason: "stop",
truncated: false,
continuations: 1,
});
expect(requestBodies[2].thinking).toEqual({ type: "disabled" });
expect(requestBodies[2].messages[2]).toEqual({ role: "assistant", content: "部分" });
});

it("fast 重试失败时使用没有 assistant 预填的完整回答兜底", async () => {
const requestBodies = [];
fetchMock.mockImplementation(async (_url, init) => {
requestBodies.push(JSON.parse(init.body));
if (requestBodies.length === 1) return jsonResponse("", "length");
if (requestBodies.length === 2) return errorResponse(400, "retry failed");
return jsonResponse("完整回答", "stop");
});

const result = await callLlmWithContinuation(makePlugin(), "系统提示", "原始任务", {
stream: false,
noRetry: true,
skipQueue: true,
thinkingMode: "reasoning",
});

expect(result.text).toBe("完整回答");
expect(result.continuations).toBe(1);
expect(requestBodies[2].messages.some((message) => message.role === "assistant")).toBe(false);
expect(requestBodies[2].messages[2].content).toContain("请忽略截断状态,直接完整回答原始任务");
});

it("空响应始终被截断时仍在有界次数内终止", async () => {
let requestCount = 0;
fetchMock.mockImplementation(async () => {
requestCount++;
return jsonResponse("", "length");
});
const maxContinuations = 2;

const result = await callLlmWithContinuation(makePlugin(), "系统提示", "原始任务", {
stream: false,
noRetry: true,
skipQueue: true,
thinkingMode: "reasoning",
}, { maxContinuations });

expect(result.continuations).toBeLessThanOrEqual(maxContinuations);
expect(requestCount).toBeLessThanOrEqual(2 + maxContinuations);
});
});