Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Q
qianjiangb2b
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
AI-甘富林
qianjiangb2b
Commits
e6c277d6
Commit
e6c277d6
authored
Jun 16, 2026
by
AI-甘富林
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
后台ai对话bug修复
parent
79f2746f
Changes
1
Hide whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
305 additions
and
48 deletions
+305
-48
index.ts
supabase/functions/coze-langchain/index.ts
+305
-48
No files found.
supabase/functions/coze-langchain/index.ts
View file @
e6c277d6
...
...
@@ -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" }] } }
## 输出格式
(严格遵循此 JSON 结构)
## 输出格式
**请先输出纯文本回答**(直接写文字,不要JSON包裹),然后在 \`\`\`json 代码块中输出 UI Schema。
JSON 代码块格式如下:
{
"answer": "对用户问题的简洁回答",
"ui_schema": {
"layout": "vertical",
"gap": "md",
"components": [
{ "type": "组件类型", "props": { ... } }
]
},
"suggested_actions": ["建议操作1", "建议操作2"]
"ui_schema": { "layout": "vertical", "gap": "md", "components": [...] },
"suggested_actions": [...]
}
注意:纯文本回答在前,不要包在 JSON 里。JSON 代码块只放 ui_schema 和 suggested_actions,不要放 answer 字段。
## 核心规则
1. 每个组件必须有 type 和 props 两个字段
2. 所有属性(如 title、columns、rows)都必须放在 props 内部,不能放在顶层
...
...
@@ -314,48 +312,53 @@ interface GenUIResponse {
*/
function
parseAIResponse
(
raw
:
string
):
GenUIResponse
{
let
jsonContent
=
raw
.
trim
();
// 移除 markdown 代码块包裹
const
jsonMatch
=
jsonContent
.
match
(
/```
(?:
json
)?\s
*
([\s\S]
*
?)
```/
);
if
(
jsonMatch
)
{
jsonContent
=
jsonMatch
[
1
].
trim
();
let
textAnswer
=
""
;
// 优先处理新格式: 纯文本 + ```json 代码块
const
fencedMatch
=
jsonContent
.
match
(
/```
(?:
json
)?\s
*
([\s\S]
*
?)
```/
);
if
(
fencedMatch
)
{
// 代码块之前的内容作为纯文本回答
textAnswer
=
jsonContent
.
substring
(
0
,
jsonContent
.
indexOf
(
"```"
)).
trim
();
jsonContent
=
fencedMatch
[
1
].
trim
();
}
else
{
// 兼容旧格式: 整个响应是一个 JSON 对象
const
jsonStart
=
jsonContent
.
indexOf
(
"{"
);
const
jsonEnd
=
jsonContent
.
lastIndexOf
(
"}"
);
if
(
jsonStart
!==
-
1
&&
jsonEnd
!==
-
1
&&
jsonStart
<
jsonEnd
)
{
jsonContent
=
jsonContent
.
substring
(
jsonStart
,
jsonEnd
+
1
);
}
}
// 提取 JSON 对象
const
jsonStart
=
jsonContent
.
indexOf
(
'{'
);
const
jsonEnd
=
jsonContent
.
lastIndexOf
(
'}'
);
if
(
jsonStart
!==
-
1
&&
jsonEnd
!==
-
1
&&
jsonStart
<
jsonEnd
)
{
jsonContent
=
jsonContent
.
substring
(
jsonStart
,
jsonEnd
+
1
);
try
{
const
parsed
=
JSON
.
parse
(
jsonContent
);
// 修复组件格式:确保每个组件都有 type 和 props
let
uiSchema
=
parsed
.
ui_schema
;
if
(
uiSchema
?.
components
)
{
uiSchema
.
components
=
uiSchema
.
components
.
map
((
comp
:
any
)
=>
{
if
(
comp
.
type
&&
!
comp
.
props
)
{
const
{
type
,
...
rest
}
=
comp
;
return
{
type
,
props
:
rest
};
}
return
comp
;
});
}
// 尝试解析 JSON 提取 ui_schema 和 suggested_actions
try
{
const
parsed
=
JSON
.
parse
(
jsonContent
);
// 修复组件格式:确保每个组件都有 type 和 props
let
uiSchema
=
parsed
.
ui_schema
;
if
(
uiSchema
?.
components
)
{
uiSchema
.
components
=
uiSchema
.
components
.
map
((
comp
:
any
)
=>
{
if
(
comp
.
type
&&
!
comp
.
props
)
{
const
{
type
,
...
rest
}
=
comp
;
return
{
type
,
props
:
rest
};
}
return
comp
;
});
}
console
.
log
(
`[CozeLangchain] AI JSON parsed, has ui_schema:
${
!!
uiSchema
}
`
);
console
.
log
(
`[CozeLangchain] AI JSON parsed, has ui_schema:
${
!!
uiSchema
}
`
);
return
{
answer
:
parsed
.
answer
||
'已为您生成界面'
,
ui_schema
:
uiSchema
,
suggested_actions
:
parsed
.
suggested_actions
,
};
}
catch
{
console
.
log
(
'[CozeLangchain] AI JSON parse failed, returning raw text'
);
}
return
{
// 优先用新格式的纯文本回答,其次用 JSON 内的 answer 字段
answer
:
textAnswer
||
parsed
.
answer
||
"已为您生成界面"
,
ui_schema
:
uiSchema
,
suggested_actions
:
parsed
.
suggested_actions
,
}
;
}
catch
{
console
.
log
(
"[CozeLangchain] AI JSON parse failed, returning raw text"
);
}
// 无法解析 JSON,返回原始文本作为 answer
return
{
answer
:
raw
||
'抱歉,AI 返回内容为空,请稍后重试。'
};
return
{
answer
:
textAnswer
||
raw
||
"抱歉,AI 返回内容为空,请稍后重试。"
};
}
/**
...
...
@@ -421,6 +424,255 @@ ${dataContext}
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) => {
const
supabase
=
createClient
(
supabaseUrl
,
supabaseKey
);
const
body
:
CozeLangchainRequest
=
await
req
.
json
();
const
{
text
,
session_id
,
agent_type
,
skip_data_context
=
false
}
=
body
;
const
{
text
,
s
tream
=
false
,
s
ession_id
,
agent_type
,
skip_data_context
=
false
}
=
body
;
if
(
!
text
||
typeof
text
!==
"string"
)
{
throw
new
Error
(
"text 参数是必填的字符串"
);
...
...
@@ -450,7 +702,7 @@ serve(async (req) => {
const
detectedAgentType
=
agent_type
||
detectAgentType
(
text
);
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
=
''
;
...
...
@@ -459,7 +711,12 @@ serve(async (req) => {
console
.
log
(
`[CozeLangchain] Data context length:
${
dataContext
.
length
}
`
);
}
// 调用 AI 大模型(aiProvider 内部自动处理路由和降级)
// === 流式分支:SSE 实时输出 ===
if
(
stream
)
{
return
callAIStream
(
text
,
dataContext
,
detectedAgentType
,
currentSessionId
,
traceId
);
}
// === 非流式分支:完整 JSON 响应(向后兼容) ===
let
result
:
GenUIResponse
;
try
{
result
=
await
callAI
(
text
,
dataContext
,
detectedAgentType
);
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment