Commit e6c277d6 authored by AI-甘富林's avatar AI-甘富林

后台ai对话bug修复

parent 79f2746f
...@@ -68,20 +68,18 @@ const GENUI_COMPONENT_LIBRARY = ` ...@@ -68,20 +68,18 @@ const GENUI_COMPONENT_LIBRARY = `
用于展示待审批事项 用于展示待审批事项
{ "type": "approval_list", "props": { "title": "审批列表", "approvals": [{ "id": "唯一ID", "type": "marketing|product", "title": "标题", "description": "描述", "agent": "发起人", "priority": "high|medium|low" }] } } { "type": "approval_list", "props": { "title": "审批列表", "approvals": [{ "id": "唯一ID", "type": "marketing|product", "title": "标题", "description": "描述", "agent": "发起人", "priority": "high|medium|low" }] } }
## 输出格式(严格遵循此 JSON 结构) ## 输出格式
**请先输出纯文本回答**(直接写文字,不要JSON包裹),然后在 \`\`\`json 代码块中输出 UI Schema。
JSON 代码块格式如下:
{ {
"answer": "对用户问题的简洁回答", "ui_schema": { "layout": "vertical", "gap": "md", "components": [...] },
"ui_schema": { "suggested_actions": [...]
"layout": "vertical",
"gap": "md",
"components": [
{ "type": "组件类型", "props": { ... } }
]
},
"suggested_actions": ["建议操作1", "建议操作2"]
} }
注意:纯文本回答在前,不要包在 JSON 里。JSON 代码块只放 ui_schema 和 suggested_actions,不要放 answer 字段。
## 核心规则 ## 核心规则
1. 每个组件必须有 type 和 props 两个字段 1. 每个组件必须有 type 和 props 两个字段
2. 所有属性(如 title、columns、rows)都必须放在 props 内部,不能放在顶层 2. 所有属性(如 title、columns、rows)都必须放在 props 内部,不能放在顶层
...@@ -314,19 +312,24 @@ interface GenUIResponse { ...@@ -314,19 +312,24 @@ interface GenUIResponse {
*/ */
function parseAIResponse(raw: string): GenUIResponse { function parseAIResponse(raw: string): GenUIResponse {
let jsonContent = raw.trim(); let jsonContent = raw.trim();
let textAnswer = "";
// 移除 markdown 代码块包裹
const jsonMatch = jsonContent.match(/```(?:json)?\s*([\s\S]*?)```/); // 优先处理新格式: 纯文本 + ```json 代码块
if (jsonMatch) { const fencedMatch = jsonContent.match(/```(?:json)?\s*([\s\S]*?)```/);
jsonContent = jsonMatch[1].trim(); if (fencedMatch) {
} // 代码块之前的内容作为纯文本回答
textAnswer = jsonContent.substring(0, jsonContent.indexOf("```")).trim();
// 提取 JSON 对象 jsonContent = fencedMatch[1].trim();
const jsonStart = jsonContent.indexOf('{'); } else {
const jsonEnd = jsonContent.lastIndexOf('}'); // 兼容旧格式: 整个响应是一个 JSON 对象
const jsonStart = jsonContent.indexOf("{");
const jsonEnd = jsonContent.lastIndexOf("}");
if (jsonStart !== -1 && jsonEnd !== -1 && jsonStart < jsonEnd) { if (jsonStart !== -1 && jsonEnd !== -1 && jsonStart < jsonEnd) {
jsonContent = jsonContent.substring(jsonStart, jsonEnd + 1); jsonContent = jsonContent.substring(jsonStart, jsonEnd + 1);
}
}
// 尝试解析 JSON 提取 ui_schema 和 suggested_actions
try { try {
const parsed = JSON.parse(jsonContent); const parsed = JSON.parse(jsonContent);
...@@ -345,17 +348,17 @@ function parseAIResponse(raw: string): GenUIResponse { ...@@ -345,17 +348,17 @@ function parseAIResponse(raw: string): GenUIResponse {
console.log(`[CozeLangchain] AI JSON parsed, has ui_schema: ${!!uiSchema}`); console.log(`[CozeLangchain] AI JSON parsed, has ui_schema: ${!!uiSchema}`);
return { return {
answer: parsed.answer || '已为您生成界面', // 优先用新格式的纯文本回答,其次用 JSON 内的 answer 字段
answer: textAnswer || parsed.answer || "已为您生成界面",
ui_schema: uiSchema, ui_schema: uiSchema,
suggested_actions: parsed.suggested_actions, suggested_actions: parsed.suggested_actions,
}; };
} catch { } catch {
console.log('[CozeLangchain] AI JSON parse failed, returning raw text'); console.log("[CozeLangchain] AI JSON parse failed, returning raw text");
}
} }
// 无法解析 JSON,返回原始文本作为 answer // 无法解析 JSON,返回原始文本作为 answer
return { answer: raw || '抱歉,AI 返回内容为空,请稍后重试。' }; return { answer: textAnswer || raw || "抱歉,AI 返回内容为空,请稍后重试。" };
} }
/** /**
...@@ -421,6 +424,255 @@ ${dataContext} ...@@ -421,6 +424,255 @@ ${dataContext}
return parseAIResponse(content); return parseAIResponse(content);
} }
/**
* 流式调用千问大模型,SSE 实时转发增量文本
* 流结束后发送 done 帧(含解析后的 ui_schema)
*/
async function callAIStream(
text: string,
dataContext: string,
agentType: AgentType,
sessionId: string,
traceId: string
): Promise<Response> {
const apiKey = Deno.env.get("QWEN_API_KEY");
if (!apiKey) {
return new Response(JSON.stringify({
success: false,
error: "QWEN_API_KEY 未配置",
trace_id: traceId,
}), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const systemPrompt = `${GENUI_COMPONENT_LIBRARY}
## 当前 Agent 角色
${getAgentDisplayName(agentType)}
## 当前数据上下文
${dataContext}
请根据用户问题和数据,生成合适的 UI Schema 和回答。`;
console.log(`[CozeLangchain] Calling Qwen API (stream) for ${agentType}...`);
const qwenResponse = await fetch(
"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "qwen-plus",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: text },
],
temperature: 0.7,
max_tokens: 4000,
stream: true,
}),
},
);
if (!qwenResponse.ok) {
const errorText = await qwenResponse.text();
console.error(`[CozeLangchain] Qwen API stream error (${qwenResponse.status}):`, errorText);
return new Response(JSON.stringify({
success: false,
error: `AI 调用失败 (${qwenResponse.status})`,
trace_id: traceId,
}), {
status: 502,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
if (!qwenResponse.body) {
return new Response(JSON.stringify({
success: false,
error: "AI 响应体为空",
trace_id: traceId,
}), {
status: 502,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const HEARTBEAT_MS = 30_000;
let fullText = "";
let streamedLength = 0; // 已流式发送的字符数,避免重复发送
let inJsonBlock = false; // 是否已进入 ```json 代码块
const stream = new ReadableStream({
async start(controller) {
let closed = false;
let heartbeat: number | undefined;
const safeEnqueue = (chunk: string) => {
if (closed) return;
try {
controller.enqueue(encoder.encode(chunk));
} catch {
cleanup();
}
};
const cleanup = () => {
if (closed) return;
closed = true;
if (heartbeat !== undefined) clearInterval(heartbeat);
try { controller.close(); } catch { /* already closed */ }
};
// 心跳注释帧,防止网关 30s 无数据断流
heartbeat = setInterval(() => {
safeEnqueue(`: ping ${Date.now()}\n\n`);
}, HEARTBEAT_MS) as unknown as number;
// 将纯文本内容(JSON 代码块之前的部分)流式发送给客户端
const streamTextContent = () => {
if (inJsonBlock || closed) return;
// 检测是否已出现 ```json 标记
const jsonMarker = fullText.indexOf("```json");
const jsonMarkerAlt = fullText.indexOf("```");
let textEnd = fullText.length;
if (jsonMarker !== -1) {
textEnd = jsonMarker;
inJsonBlock = true;
} else if (jsonMarkerAlt !== -1) {
textEnd = jsonMarkerAlt;
inJsonBlock = true;
}
// 发送尚未流式传输的纯文本部分
if (textEnd > streamedLength) {
const toStream = fullText.substring(streamedLength, textEnd);
streamedLength = textEnd;
if (toStream) {
const sseData = JSON.stringify({ type: "text", content: toStream });
safeEnqueue(`data: ${sseData}\n\n`);
}
}
};
try {
const reader = qwenResponse.body!.getReader();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith(":")) continue;
let dataContent = trimmedLine;
if (trimmedLine.startsWith("data:")) {
dataContent = trimmedLine.slice(5).trim();
}
if (!dataContent || dataContent === "[DONE]") continue;
try {
const parsed = JSON.parse(dataContent);
// 千问 DashScope 兼容格式: choices[0].delta.content
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
fullText += content;
// 只流式输出纯文本部分(```json 之前的内容)
streamTextContent();
}
} catch {
// 非 JSON 行,跳过
}
}
}
// 处理残留 buffer
if (buffer.trim()) {
const trimmed = buffer.trim();
let dataContent = trimmed;
if (trimmed.startsWith("data:")) {
dataContent = trimmed.slice(5).trim();
}
if (dataContent && dataContent !== "[DONE]") {
try {
const parsed = JSON.parse(dataContent);
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
fullText += content;
const sseData = JSON.stringify({ type: "text", content });
safeEnqueue(`data: ${sseData}\n\n`);
}
} catch { /* skip */ }
}
}
// 解析完整响应,提取 ui_schema
const genUIResult = parseAIResponse(fullText);
console.log(
`[CozeLangchain] Stream complete, text length: ${fullText.length}, has ui_schema: ${!!genUIResult.ui_schema}`
);
// 发送最终 done 帧
const doneData = JSON.stringify({
type: "done",
answer: genUIResult.answer,
ui_schema: genUIResult.ui_schema,
suggested_actions: genUIResult.suggested_actions,
agent_type: agentType,
session_id: sessionId,
trace_id: traceId,
});
safeEnqueue(`data: ${doneData}\n\n`);
safeEnqueue("data: [DONE]\n\n");
} catch (err) {
console.error("[CozeLangchain] Stream error:", err);
const errorData = JSON.stringify({
type: "error",
message: err instanceof Error ? err.message : "流式响应中断",
trace_id: traceId,
});
safeEnqueue(`data: ${errorData}\n\n`);
} finally {
cleanup();
}
},
cancel() {
// cleanup 通过 closed 标记处理
},
});
return new Response(stream, {
status: 200,
headers: {
...corsHeaders,
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
"X-Trace-Id": traceId,
"X-Agent-Type": agentType,
},
});
}
// ======================== // ========================
// 主处理函数 // 主处理函数
// ======================== // ========================
...@@ -440,7 +692,7 @@ serve(async (req) => { ...@@ -440,7 +692,7 @@ serve(async (req) => {
const supabase = createClient(supabaseUrl, supabaseKey); const supabase = createClient(supabaseUrl, supabaseKey);
const body: CozeLangchainRequest = await req.json(); const body: CozeLangchainRequest = await req.json();
const { text, session_id, agent_type, skip_data_context = false } = body; const { text, stream = false, session_id, agent_type, skip_data_context = false } = body;
if (!text || typeof text !== "string") { if (!text || typeof text !== "string") {
throw new Error("text 参数是必填的字符串"); throw new Error("text 参数是必填的字符串");
...@@ -450,7 +702,7 @@ serve(async (req) => { ...@@ -450,7 +702,7 @@ serve(async (req) => {
const detectedAgentType = agent_type || detectAgentType(text); const detectedAgentType = agent_type || detectAgentType(text);
const currentSessionId = session_id || `session_${crypto.randomUUID().replace(/-/g, '').substring(0, 20)}`; const currentSessionId = session_id || `session_${crypto.randomUUID().replace(/-/g, '').substring(0, 20)}`;
console.log(`[CozeLangchain] Processing: agent=${detectedAgentType}, session=${currentSessionId}`); console.log(`[CozeLangchain] Processing: agent=${detectedAgentType}, session=${currentSessionId}, stream=${stream}`);
// 获取数据上下文 // 获取数据上下文
let dataContext = ''; let dataContext = '';
...@@ -459,7 +711,12 @@ serve(async (req) => { ...@@ -459,7 +711,12 @@ serve(async (req) => {
console.log(`[CozeLangchain] Data context length: ${dataContext.length}`); console.log(`[CozeLangchain] Data context length: ${dataContext.length}`);
} }
// 调用 AI 大模型(aiProvider 内部自动处理路由和降级) // === 流式分支:SSE 实时输出 ===
if (stream) {
return callAIStream(text, dataContext, detectedAgentType, currentSessionId, traceId);
}
// === 非流式分支:完整 JSON 响应(向后兼容) ===
let result: GenUIResponse; let result: GenUIResponse;
try { try {
result = await callAI(text, dataContext, detectedAgentType); result = await callAI(text, dataContext, detectedAgentType);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment