接入VectorEngine画布Agent规划
切换画布 Agent 与通用 LLM 代理到 VectorEngine gpt-5.4-mini。 为画布 Agent 注入上一轮生成结果并默认承接上一张图编辑。 补齐规范图工具分流、规范展板 prompt 要求和 JSON 残片回复兜底。 同步前后端契约、测试脚本、环境示例和项目文档。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user