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
99f78a49
Commit
99f78a49
authored
Jun 17, 2026
by
AI-甘富林
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
前端代码修复
parent
77a77a8f
Changes
4
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
260 additions
and
295 deletions
+260
-295
ChatPage.tsx
src/pages/ChatPage.tsx
+44
-114
ShopPage.tsx
src/pages/ShopPage.tsx
+69
-44
cozeClient.ts
src/utils/cozeClient.ts
+126
-131
difyStreamClient.ts
src/utils/difyStreamClient.ts
+21
-6
No files found.
src/pages/ChatPage.tsx
View file @
99f78a49
This diff is collapsed.
Click to expand it.
src/pages/ShopPage.tsx
View file @
99f78a49
import
{
useState
,
useEffect
,
useCallback
,
useRef
}
from
"react"
;
import
{
flushSync
}
from
"react-dom"
;
import
{
supabase
}
from
"@/integrations/supabase/client"
;
import
{
ProductCard
}
from
"@/components/chat/ProductCard"
;
import
{
Button
}
from
"@/components/ui/button"
;
...
...
@@ -305,6 +306,22 @@ export default function ShopPage() {
// AI response handler
const
simulateAIResponse
=
useCallback
(
async
(
userMessage
:
string
)
=>
{
setIsAiTyping
(
true
);
const
streamMsgId
=
`ai_
${
Date
.
now
()}
`
;
// 先插入一个占位消息,后续流式更新
const
placeholderMessage
:
ChatMessageType
=
{
id
:
streamMsgId
,
content
:
''
,
sender
:
"ai"
,
timestamp
:
new
Date
().
toLocaleTimeString
(
'zh-CN'
,
{
hour
:
'2-digit'
,
minute
:
'2-digit'
}),
type
:
"text"
,
agent
:
currentAgent
,
};
setMessages
(
prev
=>
[...
prev
,
placeholderMessage
]);
try
{
let
currentProducts
=
products
;
if
(
currentProducts
.
length
===
0
)
{
...
...
@@ -324,6 +341,8 @@ export default function ShopPage() {
description
:
p
.
description
}));
// ─── 流式接收 AI 回复文字 ───
let
fullContent
=
''
;
const
{
data
:
intentCard
}
=
await
callCozeEdge
({
message
:
userMessage
,
userId
:
user
?.
id
||
'anonymous'
,
...
...
@@ -333,16 +352,33 @@ export default function ShopPage() {
description
:
currentProducts
[
0
].
description
||
''
,
price
:
Number
(
currentProducts
[
0
].
price
)
},
catalog
catalog
,
onText
:
(
text
:
string
)
=>
{
fullContent
+=
text
;
flushSync
(()
=>
{
setMessages
(
prev
=>
prev
.
map
(
msg
=>
msg
.
id
===
streamMsgId
?
{
...
msg
,
content
:
fullContent
}
:
msg
));
});
}
});
if
(
!
intentCard
)
throw
new
Error
(
'系统繁忙,请稍后再试'
);
let
aiContent
=
intentCard
.
title
||
'收到您的消息'
;
// 清洗 JSON 代码块,避免裸露协议数据出现在正文
let
aiContent
=
fullContent
.
replace
(
/```
(?:
json
)?\s
*
\{[\s\S]
*
?
"
(?:
picks|intent
)
"
[\s\S]
*
?\}\s
*```/gi
,
''
)
.
replace
(
/
\{\s
*"
(?:
picks|intent
)
"
\s
*:
[\s\S]
*
?\}\s
*$/g
,
''
)
.
trim
();
if
(
!
aiContent
)
{
aiContent
=
intentCard
.
title
||
'收到您的消息'
;
if
(
intentCard
.
highlights
&&
intentCard
.
highlights
.
length
>
0
)
{
aiContent
+=
'
\
n
\
n'
+
intentCard
.
highlights
.
map
(
h
=>
`•
${
h
}
`
).
join
(
'
\
n'
);
}
}
if
(
intentCard
.
picks
&&
intentCard
.
picks
.
length
>
0
)
{
aiContent
+=
'
\
n
\
n为您推荐了 '
+
intentCard
.
picks
.
length
+
' 个商品'
;
...
...
@@ -371,49 +407,38 @@ export default function ShopPage() {
};
}
const
aiMessage
:
ChatMessageType
=
{
id
:
`ai_
${
Date
.
now
()}
`
,
content
:
aiContent
,
sender
:
"ai"
,
timestamp
:
new
Date
().
toLocaleTimeString
(
'zh-CN'
,
{
hour
:
'2-digit'
,
minute
:
'2-digit'
}),
type
:
messageType
,
agent
:
currentAgent
,
metadata
:
responseMetadata
};
setMessages
(
prev
=>
[...
prev
,
aiMessage
]);
// 更新占位消息为最终内容 + 类型 + 元数据
setMessages
(
prev
=>
prev
.
map
(
msg
=>
msg
.
id
===
streamMsgId
?
{
...
msg
,
content
:
aiContent
,
type
:
messageType
,
metadata
:
responseMetadata
}
:
msg
));
if
(
activeConversationId
)
{
await
saveMessage
({
content
:
ai
Message
.
c
ontent
,
sender_type
:
aiMessage
.
sender
,
message_type
:
aiMessage
.
t
ype
as
DBMessage
[
'message_type'
],
metadata
:
aiMessage
.
m
etadata
content
:
ai
C
ontent
,
sender_type
:
"ai"
,
message_type
:
messageT
ype
as
DBMessage
[
'message_type'
],
metadata
:
responseM
etadata
},
activeConversationId
);
}
}
catch
(
error
)
{
console
.
error
(
'AI回复失败:'
,
error
);
const
errorMessage
:
ChatMessageType
=
{
id
:
`ai_error_
${
Date
.
now
()}
`
,
content
:
`抱歉,遇到了技术问题,请稍后再试。`
,
sender
:
"ai"
,
timestamp
:
new
Date
().
toLocaleTimeString
(
'zh-CN'
,
{
hour
:
'2-digit'
,
minute
:
'2-digit'
}),
type
:
"text"
,
agent
:
currentAgent
};
setMessages
(
prev
=>
[...
prev
,
errorMessage
]);
// 把占位消息替换为错误消息
setMessages
(
prev
=>
prev
.
map
(
msg
=>
msg
.
id
===
streamMsgId
?
{
...
msg
,
content
:
'抱歉,遇到了技术问题,请稍后再试。'
}
:
msg
));
}
finally
{
setIsAiTyping
(
false
);
}
},
[
currentAgent
,
products
,
activeConversationId
,
saveMessage
,
user
]);
const
handleSendMessage
=
useCallback
(
async
(
content
:
string
)
=>
{
// 防止并发流式调用(如快速连点建议按钮)
if
(
isAiTyping
)
return
;
const
userMessage
:
ChatMessageType
=
{
id
:
`user_
${
Date
.
now
()}
`
,
content
,
...
...
@@ -437,7 +462,7 @@ export default function ShopPage() {
}
await
simulateAIResponse
(
content
);
},
[
simulateAIResponse
,
activeConversationId
,
saveMessage
]);
},
[
simulateAIResponse
,
activeConversationId
,
saveMessage
,
isAiTyping
]);
return
(
<
div
className=
"min-h-screen bg-background"
>
...
...
src/utils/cozeClient.ts
View file @
99f78a49
/**
* Consumer Chat Edge Function Client
* C端消费者聊天 - 统一调用入口
*
* 注意:callCozeEdge 是历史兼容名称;当前优先走 dify-chat-stream。
* C端消费者聊天 —— 直接大模型调用(AI Hub)
*/
import
{
supabase
}
from
'@/integrations/supabase/client'
;
...
...
@@ -59,7 +57,11 @@ type StreamDonePayload = Partial<CozeResponse> & {
type
?:
'done'
;
};
function
processDifyStreamLine
(
line
:
string
,
state
:
{
fullContent
:
string
;
doneData
:
StreamDonePayload
|
null
})
{
function
processStreamLine
(
line
:
string
,
state
:
{
fullContent
:
string
;
doneData
:
StreamDonePayload
|
null
},
onText
?:
(
text
:
string
)
=>
void
)
{
const
trimmedLine
=
line
.
trim
();
if
(
!
trimmedLine
||
trimmedLine
.
startsWith
(
':'
)
||
trimmedLine
===
'data: [DONE]'
)
return
;
if
(
!
trimmedLine
.
startsWith
(
'data:'
))
return
;
...
...
@@ -68,22 +70,30 @@ function processDifyStreamLine(line: string, state: { fullContent: string; doneD
try
{
const
event
=
JSON
.
parse
(
jsonStr
);
if
(
event
.
type
===
'text'
)
{
state
.
fullContent
+=
event
.
content
||
''
;
const
content
=
event
.
content
||
''
;
state
.
fullContent
+=
content
;
onText
?.(
content
);
}
else
if
(
event
.
type
===
'done'
)
{
state
.
doneData
=
event
;
}
else
if
(
event
.
type
===
'error'
)
{
throw
new
Error
(
event
.
message
||
'
Dify
流式响应失败'
);
throw
new
Error
(
event
.
message
||
'
AI
流式响应失败'
);
}
}
catch
(
error
)
{
if
(
error
instanceof
Error
&&
!
jsonStr
.
startsWith
(
'{'
))
{
// JSON.parse 抛出 SyntaxError,应静默跳过该行
// 只有业务层主动抛出的 Error 才向上传播(如 type==='error' 事件)
if
(
error
instanceof
SyntaxError
)
{
if
(
jsonStr
&&
!
jsonStr
.
startsWith
(
'{'
))
{
state
.
fullContent
+=
jsonStr
;
}
return
;
}
throw
error
;
}
}
export
async
function
callCozeEdge
(
options
:
CozeRequestOptions
):
Promise
<
{
export
async
function
callCozeEdge
(
options
:
CozeRequestOptions
&
{
onText
?:
(
text
:
string
)
=>
void
}
):
Promise
<
{
data
:
CozeResponse
|
null
;
traceId
:
string
;
status
:
number
;
...
...
@@ -104,21 +114,12 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{
};
try
{
console
.
log
(
'🚀 [CHAT CLIENT]
开始调用 dify
-chat-stream'
);
console
.
log
(
'📤
请求体:'
,
JSON
.
stringify
(
requestBody
,
null
,
2
));
console
.
log
(
'🚀 [CHAT CLIENT]
调用 ai
-chat-stream'
);
console
.
log
(
'📤
消息:'
,
options
.
message
?.
substring
(
0
,
60
));
const
{
data
:
{
session
}
}
=
await
supabase
.
auth
.
getSession
();
console
.
log
(
'🔐 用户登录状态:'
,
session
?
'已登录'
:
'未登录'
);
let
lastError
:
any
=
null
;
let
data
:
any
=
null
;
let
error
:
any
=
null
;
// 重试逻辑
for
(
let
attempt
=
1
;
attempt
<=
2
;
attempt
++
)
{
console
.
log
(
`🔄 尝试
${
attempt
}
/2...`
);
try
{
// 单次调用 + 超时保护(65秒)
const
invokePromise
=
fetch
(
`
${
SUPABASE_URL
}
/functions/v1/dify-chat-stream`
,
{
method
:
'POST'
,
headers
:
{
...
...
@@ -135,82 +136,82 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{
});
const response = await Promise.race([invokePromise, timeoutPromise]);
if (!response.ok) {
throw new Error(`
Dify
请求失败
(
$
{
response
.
status
}):
$
{
await
response
.
text
()}
`
);
const errorText = await response.text();
console.error('❌ [CHAT CLIENT] 请求失败:', response.status, errorText);
throw new Error(`
请求失败
(
$
{
response
.
status
}):
$
{
errorText
.
substring
(
0
,
200
)}
`
);
}
if
(
!
response
.
body
)
{
throw
new
Error
(
'响应体为空'
);
}
// ─── 读取 SSE 流 ───
const
reader
=
response
.
body
.
getReader
();
const
decoder
=
new
TextDecoder
();
const
onText
=
options
.
onText
;
const
state
=
{
fullContent
:
''
,
doneData
:
null
as
StreamDonePayload
|
null
};
let
buffer
=
''
;
// rAF 批量包装 onText:每帧最多触发一次,确保浏览器在帧之间有机会绘制
// 根因:await setTimeout(0) 从 Promise 延续中创建不触发 4ms 嵌套惩罚,
// 全部回调在 <1ms 内触发完毕,浏览器只在 VSync 边界绘制一次 → 用户看不到流式效果
let
pendingText
=
''
;
let
rafId
:
number
|
null
=
null
;
const
batchedOnText
=
onText
?
(
text
:
string
)
=>
{
pendingText
+=
text
;
if
(
rafId
===
null
)
{
rafId
=
requestAnimationFrame
(()
=>
{
if
(
pendingText
)
{
onText
(
pendingText
);
pendingText
=
''
;
}
rafId
=
null
;
});
}
}
:
undefined
;
try
{
while
(
true
)
{
const
{
done
,
value
}
=
await
reader
.
read
();
if
(
done
)
{
if
(
buffer
.
trim
())
processDifyStreamLine
(
buffer
,
state
);
if
(
buffer
.
trim
())
processStreamLine
(
buffer
,
state
,
batchedOnText
);
break
;
}
buffer
+=
decoder
.
decode
(
value
,
{
stream
:
true
});
const
lines
=
buffer
.
split
(
'
\
n'
);
buffer
=
lines
.
pop
()
||
''
;
for
(
const
line
of
lines
)
processDifyStreamLine
(
line
,
state
);
for
(
const
line
of
lines
)
{
processStreamLine
(
line
,
state
,
batchedOnText
);
}
data
=
{
...(
state
.
doneData
||
{}),
title
:
state
.
doneData
?.
title
||
options
.
message
,
answer
:
state
.
fullContent
,
};
error
=
null
;
lastError
=
null
;
if
(
data
)
{
break
;
}
lastError
=
error
;
if
(
attempt
===
1
)
{
console
.
log
(
'⏳ Edge Function 可能正在启动,2秒后重试...'
);
await
new
Promise
(
resolve
=>
setTimeout
(
resolve
,
2000
));
}
finally
{
// 确保 rAF 回调被取消、剩余文本被刷新,即使 processStreamLine 抛出异常也不会泄漏
if
(
rafId
!==
null
)
{
cancelAnimationFrame
(
rafId
);
rafId
=
null
;
}
}
catch
(
e
)
{
lastError
=
e
;
console
.
error
(
`❌ 尝试
${
attempt
}
失败:`
,
e
);
if
(
attempt
===
1
)
{
await
new
Promise
(
resolve
=>
setTimeout
(
resolve
,
2000
));
if
(
pendingText
&&
onText
)
{
onText
(
pendingText
);
pendingText
=
''
;
}
}
}
error
=
error
||
lastError
;
if
(
error
)
{
console
.
error
(
'❌ Dify Chat Functions 错误:'
,
error
);
const
errorMessage
=
error
.
message
||
String
(
error
);
let
friendlyMessage
=
'抱歉,系统暂时繁忙'
;
if
(
errorMessage
.
includes
(
'Failed to fetch'
)
||
errorMessage
.
includes
(
'NetworkError'
))
{
friendlyMessage
=
'网络连接不稳定,请重新发送消息试试'
;
}
else
if
(
errorMessage
.
includes
(
'timeout'
)
||
errorMessage
.
includes
(
'超时'
))
{
friendlyMessage
=
'AI响应超时(65秒),请重新发送消息'
;
}
const
rawData
=
{
...(
state
.
doneData
||
{}),
answer
:
state
.
fullContent
};
if
(
rawData
.
error
)
{
return
{
data
:
null
,
traceId
,
status
:
0
,
error
:
friendlyMessage
traceId
:
rawData
.
meta
?.
probe_id
||
traceId
,
status
:
20
0
,
error
:
`系统繁忙,请稍后再试(追踪码:
${
rawData
.
meta
?.
probe_id
||
traceId
}
)
`
};
}
if
(
!
data
)
{
if (!
state.doneData && !state.fullContent
) {
return {
data: null,
traceId,
...
...
@@ -219,18 +220,7 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{
};
}
console
.
log
(
'✅ 成功获取 Dify 响应'
);
const
rawData
=
data
as
any
;
if
(
rawData
.
error
)
{
return
{
data
:
null
,
traceId
:
rawData
.
meta
?.
probe_id
||
traceId
,
status
:
200
,
error
:
`系统繁忙,请稍后再试(追踪码:
${
rawData
.
meta
?.
probe_id
||
traceId
}
)
`
};
}
console.log('✅ [CHAT CLIENT] 成功获取响应, picks:', rawData.picks?.length || 0);
const cozeResponse: CozeResponse = {
title: rawData.title || options.message,
...
...
@@ -241,7 +231,7 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{
comparison: rawData.comparison,
next: rawData.next || [],
meta: {
platform: rawData.meta?.platform || '
dify-stream
',
platform: rawData.meta?.platform || '
ai-hub
',
user: rawData.meta?.user || options.userId,
catalog_count: rawData.meta?.catalog_count ?? 0,
probe_id: rawData.meta?.probe_id || traceId
...
...
@@ -250,12 +240,6 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{
answer: rawData.answer
};
console.log('📊 Dify 响应:', {
platform: cozeResponse.meta.platform,
picks: cozeResponse.picks.length,
conversation_id: cozeResponse.conversation_id || '(new)'
});
return {
data: cozeResponse,
traceId: cozeResponse.meta.probe_id,
...
...
@@ -263,13 +247,24 @@ export async function callCozeEdge(options: CozeRequestOptions): Promise<{
};
} catch (error) {
console.error('❌ 调用 Coze Edge Function 失败:', error);
console.error('❌ [CHAT CLIENT] 调用失败:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
let friendlyMessage = '抱歉,系统暂时繁忙';
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('NetworkError')) {
friendlyMessage = '网络连接不稳定,请重新发送消息试试';
} else if (errorMessage.includes('timeout') || errorMessage.includes('超时')) {
friendlyMessage = 'AI响应超时,请重新发送消息';
} else if (errorMessage.includes('AI_HUB') || errorMessage.includes('LOVABLE')) {
friendlyMessage = 'AI服务未配置,请联系管理员';
}
return {
data: null,
traceId,
status: 0,
error:
'网络连接不稳定,请重新发送消息试试'
error:
friendlyMessage
};
}
}
src/utils/difyStreamClient.ts
View file @
99f78a49
...
...
@@ -51,6 +51,9 @@ export async function streamDifyChat(
try
{
console
.
log
(
'🚀 [DIFY STREAM] 开始流式调用'
);
const
controller
=
new
AbortController
();
const
timeoutId
=
setTimeout
(()
=>
controller
.
abort
(),
65000
);
const
response
=
await
fetch
(
`
${
SUPABASE_URL
}
/functions/v1/dify-chat-stream`
,
{
method
:
'POST'
,
headers
:
{
...
...
@@ -60,8 +63,11 @@ export async function streamDifyChat(
'X-User-Id': options.userId || 'guest',
},
body: JSON.stringify(requestBody),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`
Dify
请求失败
(
$
{
response
.
status
}):
$
{
errorText
}
`
);
...
...
@@ -79,7 +85,7 @@ export async function streamDifyChat(
const
{
done
,
value
}
=
await
reader
.
read
();
if
(
done
)
{
if
(
buffer
.
trim
())
{
processSSELine
(
buffer
,
callbacks
);
await
processSSELine
(
buffer
,
callbacks
);
}
break
;
}
...
...
@@ -89,18 +95,22 @@ export async function streamDifyChat(
buffer
=
lines
.
pop
()
||
''
;
for
(
const
line
of
lines
)
{
processSSELine
(
line
,
callbacks
);
await
processSSELine
(
line
,
callbacks
);
}
}
console
.
log
(
'✅ [DIFY STREAM] 流式调用完成'
);
}
catch
(
error
)
{
console
.
error
(
'❌ [DIFY STREAM] 错误:'
,
error
);
if
(
error
instanceof
DOMException
&&
error
.
name
===
'AbortError'
)
{
callbacks
.
onError
(
'AI响应超时(65秒),请重新发送消息'
);
}
else
{
callbacks
.
onError
(
error
instanceof
Error
?
error
.
message
:
'流式请求失败'
);
}
}
}
function
processSSELine
(
line
:
string
,
callbacks
:
CozeStreamCallbacks
)
{
async
function
processSSELine
(
line
:
string
,
callbacks
:
CozeStreamCallbacks
)
{
const
trimmedLine
=
line
.
trim
();
if
(
!
trimmedLine
||
trimmedLine
.
startsWith
(
':'
))
return
;
if
(
trimmedLine
===
'data: [DONE]'
)
return
;
...
...
@@ -114,15 +124,20 @@ function processSSELine(line: string, callbacks: CozeStreamCallbacks) {
callbacks
.
onText
(
event
.
content
);
break
;
case
'done'
:
callbacks
.
onDone
(
event
);
await
callbacks
.
onDone
(
event
);
break
;
case
'error'
:
callbacks
.
onError
(
event
.
message
);
break
;
}
}
catch
(
e
)
{
// JSON.parse SyntaxError or callback rejection — safe fallback
if
(
jsonStr
&&
!
jsonStr
.
startsWith
(
'{'
))
{
try
{
callbacks
.
onText
(
jsonStr
);
}
catch
{
// onText itself failed; nothing more we can do
}
}
}
}
...
...
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