接入VectorEngine画布Agent规划

切换画布 Agent 与通用 LLM 代理到 VectorEngine gpt-5.4-mini。

为画布 Agent 注入上一轮生成结果并默认承接上一张图编辑。

补齐规范图工具分流、规范展板 prompt 要求和 JSON 残片回复兜底。

同步前后端契约、测试脚本、环境示例和项目文档。
This commit is contained in:
2026-07-05 17:42:27 +08:00
parent a4a7aed024
commit d7d98acfd4
20 changed files with 945 additions and 116 deletions
@@ -150,6 +150,7 @@ function upsertGenerationRecord(
? {
...record,
...nextRecord,
summary: nextRecord.summary ?? record.summary,
taskId: nextRecord.taskId ?? record.taskId,
model: nextRecord.model ?? record.model,
images: nextRecord.images.length ? nextRecord.images : record.images,
@@ -416,6 +417,7 @@ export function useEditorAgentConversation({
const nextRecord: EditorAgentGenerationRecord = {
toolCallId: event.data.toolCallId,
toolName: event.data.toolName,
summary: event.data.summary ?? null,
taskId: event.data.taskId ?? null,
status,
model: event.data.model ?? null,
@@ -450,6 +452,7 @@ export function useEditorAgentConversation({
const nextRecord: EditorAgentGenerationRecord = {
toolCallId: event.data.toolCallId,
toolName: event.data.toolName,
summary: null,
taskId: null,
status: 'completed',
model: event.data.model,
+61 -1
View File
@@ -1,6 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { streamPlainTextCompletion } from './llmClient';
import {
requestPlainTextCompletion,
streamPlainTextCompletion,
} from './llmClient';
function createSseResponse(body: string) {
const encoder = new TextEncoder();
@@ -48,4 +51,61 @@ describe('llmClient streamPlainTextCompletion', () => {
expect(onUpdate).toHaveBeenNthCalledWith(2, '溪上春风');
expect(onUpdate).toHaveBeenCalledTimes(2);
});
it('reads api-server SSE delta events', async () => {
const onUpdate = vi.fn();
const fetchMock = vi.fn().mockResolvedValue(
createSseResponse(
[
'event: delta\r\n',
'data: {"delta":"你","content":"你","finishReason":null}\r\n\r\n',
'event: delta\r\n',
'data: {"delta":"好","content":"你好","finishReason":null}\r\n\r\n',
'event: complete\r\n',
'data: {"id":"resp_01","model":"gpt-5.4-mini","content":"你好","finishReason":"stop"}\r\n\r\n',
'data: [DONE]\r\n\r\n',
].join(''),
),
);
vi.stubGlobal('fetch', fetchMock);
const result = await streamPlainTextCompletion('system', 'user', {
onUpdate,
});
expect(result).toBe('你好');
expect(onUpdate).toHaveBeenNthCalledWith(1, '你');
expect(onUpdate).toHaveBeenNthCalledWith(2, '你好');
expect(onUpdate).toHaveBeenCalledTimes(2);
});
});
describe('llmClient requestPlainTextCompletion', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('reads api-server response envelope content', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
data: {
id: 'resp_01',
model: 'gpt-5.4-mini',
content: '代理成功',
finishReason: 'stop',
},
error: null,
meta: {requestId: 'req_01'},
}),
),
);
vi.stubGlobal('fetch', fetchMock);
const result = await requestPlainTextCompletion('system', 'user');
expect(result).toBe('代理成功');
});
});
+104 -20
View File
@@ -1,5 +1,5 @@
import type {TextStreamOptions} from './aiTypes';
import { fetchWithApiAuth } from './apiClient';
import { fetchWithApiAuth, type ApiRequestOptions } from './apiClient';
import { parseSseJsonObject, readSseStream } from './sseStream';
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
@@ -65,6 +65,67 @@ function readLlmStreamDeltaContent(parsed: Record<string, unknown>) {
return typeof content === 'string' && content.length > 0 ? content : null;
}
function readProjectLlmDeltaContent(parsed: Record<string, unknown>) {
const delta = parsed.delta;
return typeof delta === 'string' && delta.length > 0 ? delta : null;
}
function readProjectLlmCompleteContent(parsed: Record<string, unknown>) {
const content = parsed.content;
return typeof content === 'string' && content.length > 0 ? content : null;
}
function readOpenAiMessageContent(parsed: Record<string, unknown>) {
const choices = parsed.choices;
if (!Array.isArray(choices)) {
return null;
}
const [firstChoice] = choices;
if (typeof firstChoice !== 'object' || firstChoice === null) {
return null;
}
const message = (firstChoice as {message?: unknown}).message;
if (typeof message !== 'object' || message === null) {
return null;
}
const content = (message as {content?: unknown}).content;
return typeof content === 'string' && content.length > 0 ? content : null;
}
function readLlmResponseContent(parsed: unknown) {
if (typeof parsed !== 'object' || parsed === null) {
return null;
}
const record = parsed as Record<string, unknown>;
const directContent = readProjectLlmCompleteContent(record);
if (directContent) {
return directContent;
}
const openAiContent = readOpenAiMessageContent(record);
if (openAiContent) {
return openAiContent;
}
const data = record.data;
if (typeof data === 'object' && data !== null) {
return readLlmResponseContent(data);
}
return null;
}
function readLlmStreamErrorMessage(parsed: Record<string, unknown>) {
const message = parsed.message;
return typeof message === 'string' && message.trim()
? message.trim()
: 'LLM stream returned an error event.';
}
const NODE_ENV = getNodeEnv();
const IS_SERVER_RUNTIME = typeof window === 'undefined';
const SERVER_API_KEY =
@@ -145,7 +206,11 @@ function normalizeLlmError(error: unknown): never {
throw error;
}
function requestLlmEndpoint(input: string, init: RequestInit = {}) {
function requestLlmEndpoint(
input: string,
init: RequestInit = {},
options: ApiRequestOptions = {},
) {
const headers = resolveHeaders(init.headers);
if (IS_SERVER_RUNTIME && SERVER_API_KEY.trim()) {
headers.Authorization = `Bearer ${SERVER_API_KEY.trim()}`;
@@ -158,7 +223,7 @@ function requestLlmEndpoint(input: string, init: RequestInit = {}) {
return IS_SERVER_RUNTIME
? fetch(input, nextInit)
: fetchWithApiAuth(input, nextInit);
: fetchWithApiAuth(input, nextInit, options);
}
export function isLlmConnectivityError(error: unknown): error is LlmConnectivityError {
@@ -221,8 +286,8 @@ async function requestMessageContent(
}
const data = JSON.parse(rawResponseText);
const content = data?.choices?.[0]?.message?.content;
if (!content || typeof content !== 'string') {
const content = readLlmResponseContent(data);
if (!content) {
throw new Error('LLM response did not include message content.');
}
@@ -279,19 +344,23 @@ export async function streamPlainTextCompletion(
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await requestLlmEndpoint(`${API_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
model: MODEL,
stream: true,
messages: [
{role: 'system' as const, content: systemPrompt},
{role: 'user' as const, content: userPrompt},
],
}),
signal: controller.signal,
});
const response = await requestLlmEndpoint(
`${API_BASE_URL}/chat/completions`,
{
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
model: MODEL,
stream: true,
messages: [
{role: 'system' as const, content: systemPrompt},
{role: 'user' as const, content: userPrompt},
],
}),
signal: controller.signal,
},
{omitEnvelopeHeader: true},
);
if (!response.ok) {
const rawResponseText = await response.text();
@@ -314,13 +383,28 @@ export async function streamPlainTextCompletion(
let accumulatedText = '';
await readSseStream(response, ({ data }) => {
await readSseStream(response, ({ data, eventName }) => {
if (data === '[DONE]') {
return false;
}
const parsed = parseSseJsonObject(data);
const delta = parsed ? readLlmStreamDeltaContent(parsed) : null;
if (parsed && eventName === 'error') {
throw new Error(readLlmStreamErrorMessage(parsed));
}
if (parsed && eventName === 'complete') {
const content = readLlmResponseContent(parsed);
if (content && content !== accumulatedText) {
accumulatedText = content;
options.onUpdate?.(accumulatedText);
}
return;
}
const delta = parsed
? (readLlmStreamDeltaContent(parsed) ?? readProjectLlmDeltaContent(parsed))
: null;
if (delta) {
accumulatedText += delta;
options.onUpdate?.(accumulatedText);