@@ -2,10 +2,15 @@ package conversation
22
33import (
44 "context"
5+ "errors"
56 "strings"
67 "time"
78
9+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/channel"
810 model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation"
11+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm"
12+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/pkg/traceid"
13+ "go.uber.org/zap"
914)
1015
1116type persistMessageGenerationInput struct {
@@ -25,6 +30,42 @@ type persistMessageGenerationInput struct {
2530 ToolCallRows []model.ToolCall
2631}
2732
33+ type persistInterruptedMessageGenerationInput struct {
34+ SendInput SendMessageInput
35+ UserMessage * model.Message
36+ AssistantMessage * model.Message
37+ AssistantText string
38+ EstimatedInputTokens int64
39+ Usage llm.Usage
40+ AssistantLatency int64
41+ Error error
42+ ToolCallRows []model.ToolCall
43+ TraceRecorder * messageTraceRecorder
44+ Route * channel.ResolvedRoute
45+ EffectiveOptions map [string ]interface {}
46+ ServerSideToolUsage map [string ]int64
47+ StartedAt time.Time
48+ }
49+
50+ type interruptedMessageGenerationMetrics struct {
51+ InputTokens int64
52+ OutputTokens int64
53+ LatencyMS int64
54+ ErrorCode string
55+ ErrorMessage string
56+ CacheReadTokens int64
57+ CacheWriteTokens int64
58+ ReasoningTokens int64
59+ }
60+
61+ type persistMessageToolCallsInput struct {
62+ SendInput SendMessageInput
63+ UserMessageID uint
64+ AssistantMessageID uint
65+ RunID string
66+ Rows []model.ToolCall
67+ }
68+
2869func (s * Service ) persistSuccessfulMessageGeneration (ctx context.Context , input persistMessageGenerationInput ) error {
2970 input .UserMessage .InputTokens = input .InputTokens
3071 input .UserMessage .CacheReadTokens = input .CacheReadTokens
@@ -56,31 +97,14 @@ func (s *Service) persistSuccessfulMessageGeneration(ctx context.Context, input
5697 input .AssistantMessage .LatencyMS = input .AssistantLatency
5798 input .AssistantMessage .Status = "success"
5899
59- if len (input .ToolCallRows ) > 0 {
60- for i := range input .ToolCallRows {
61- if input .ToolCallRows [i ].ConversationID == 0 {
62- input .ToolCallRows [i ].ConversationID = input .SendInput .ConversationID
63- }
64- if input .ToolCallRows [i ].UserID == 0 {
65- input .ToolCallRows [i ].UserID = input .SendInput .UserID
66- }
67- if input .ToolCallRows [i ].MessageID == 0 {
68- input .ToolCallRows [i ].MessageID = input .AssistantMessage .ID
69- }
70- if strings .TrimSpace (input .ToolCallRows [i ].RunID ) == "" {
71- input .ToolCallRows [i ].RunID = input .AssistantMessage .RunID
72- }
73- }
74- if err := s .repo .CreateConversationToolCalls (ctx , input .ToolCallRows ); err != nil {
75- return err
76- }
77- s .persistToolContextArtifacts (ctx , toolContextArtifactInput {
78- ConversationID : input .SendInput .ConversationID ,
79- UserID : input .SendInput .UserID ,
80- MessageID : input .UserMessage .ID ,
81- RunID : input .AssistantMessage .RunID ,
82- Rows : input .ToolCallRows ,
83- })
100+ if err := s .persistMessageToolCalls (ctx , persistMessageToolCallsInput {
101+ SendInput : input .SendInput ,
102+ UserMessageID : input .UserMessage .ID ,
103+ AssistantMessageID : input .AssistantMessage .ID ,
104+ RunID : input .AssistantMessage .RunID ,
105+ Rows : input .ToolCallRows ,
106+ }); err != nil {
107+ return err
84108 }
85109
86110 s .updateStatefulResponseAsync (input .SendInput .ConversationID , input .ResponseID , input .StatefulPromptFingerprint )
@@ -90,6 +114,211 @@ func (s *Service) persistSuccessfulMessageGeneration(ctx context.Context, input
90114 return nil
91115}
92116
117+ // persistInterruptedMessageGeneration 在模型调用已经产生可见内容或工具轨迹后失败时,保留本轮 assistant 消息。
118+ // 显式取消由取消流程单独处理,避免把用户主动停止误标为异常中断。
119+ func (s * Service ) persistInterruptedMessageGeneration (ctx context.Context , input persistInterruptedMessageGenerationInput ) * SendMessageResult {
120+ if ! shouldPersistInterruptedMessageGeneration (input ) {
121+ return nil
122+ }
123+ persistCtx := ctx
124+ var cancel context.CancelFunc
125+ if persistCtx == nil || persistCtx .Err () != nil {
126+ persistCtx , cancel = context .WithTimeout (context .Background (), 5 * time .Second )
127+ defer cancel ()
128+ }
129+
130+ metrics := resolveInterruptedMessageGenerationMetrics (input )
131+
132+ if err := s .repo .UpdateMessageUsage (
133+ persistCtx ,
134+ input .UserMessage .ID ,
135+ metrics .InputTokens ,
136+ 0 ,
137+ metrics .CacheReadTokens ,
138+ metrics .CacheWriteTokens ,
139+ 0 ,
140+ ); err != nil {
141+ s .logger .Warn ("persist_interrupted_user_usage_failed" ,
142+ zap .String ("trace_id" , traceid .FromContext (ctx )),
143+ zap .Uint ("message_id" , input .UserMessage .ID ),
144+ zap .Error (err ),
145+ )
146+ }
147+ if err := s .repo .UpdateMessageState (persistCtx , input .UserMessage .ID , "success" , "" , "" ); err != nil {
148+ s .logger .Warn ("persist_interrupted_user_state_failed" ,
149+ zap .String ("trace_id" , traceid .FromContext (ctx )),
150+ zap .Uint ("message_id" , input .UserMessage .ID ),
151+ zap .Error (err ),
152+ )
153+ }
154+ if err := s .repo .UpdateAssistantMessageCompletion (
155+ persistCtx ,
156+ input .AssistantMessage .ID ,
157+ input .AssistantText ,
158+ metrics .OutputTokens ,
159+ metrics .ReasoningTokens ,
160+ metrics .LatencyMS ,
161+ "interrupted" ,
162+ metrics .ErrorCode ,
163+ metrics .ErrorMessage ,
164+ ); err != nil {
165+ s .logger .Error ("persist_interrupted_assistant_completion_failed" ,
166+ zap .String ("trace_id" , traceid .FromContext (ctx )),
167+ zap .Uint ("message_id" , input .AssistantMessage .ID ),
168+ zap .Error (err ),
169+ )
170+ return nil
171+ }
172+ applyInterruptedMessageGenerationState (input , metrics )
173+
174+ if err := s .persistMessageToolCalls (persistCtx , persistMessageToolCallsInput {
175+ SendInput : input .SendInput ,
176+ UserMessageID : input .UserMessage .ID ,
177+ AssistantMessageID : input .AssistantMessage .ID ,
178+ RunID : input .AssistantMessage .RunID ,
179+ Rows : input .ToolCallRows ,
180+ }); err != nil {
181+ s .logger .Warn ("persist_interrupted_tool_calls_failed" ,
182+ zap .String ("trace_id" , traceid .FromContext (ctx )),
183+ zap .Uint ("message_id" , input .AssistantMessage .ID ),
184+ zap .Error (err ),
185+ )
186+ }
187+ if input .TraceRecorder != nil {
188+ input .TraceRecorder .fail (input .Error )
189+ input .TraceRecorder .attachToMessage (input .AssistantMessage )
190+ }
191+
192+ return buildInterruptedSendMessageResult (input , metrics )
193+ }
194+
195+ // shouldPersistInterruptedMessageGeneration 只在已有可展示内容或可追踪工具结果时保留中断消息。
196+ func shouldPersistInterruptedMessageGeneration (input persistInterruptedMessageGenerationInput ) bool {
197+ if input .Error == nil || input .UserMessage == nil || input .AssistantMessage == nil {
198+ return false
199+ }
200+ if errors .Is (input .Error , ErrMessageGenerationCanceled ) {
201+ return false
202+ }
203+ hasRetainedToolTrace := len (input .ToolCallRows ) > 0 || len (input .ServerSideToolUsage ) > 0
204+ return strings .TrimSpace (input .AssistantText ) != "" || hasRetainedToolTrace
205+ }
206+
207+ // resolveInterruptedMessageGenerationMetrics 统一处理中断消息的真实 usage 与估算兜底。
208+ func resolveInterruptedMessageGenerationMetrics (input persistInterruptedMessageGenerationInput ) interruptedMessageGenerationMetrics {
209+ inputTokens := input .Usage .InputTokens
210+ if inputTokens <= 0 {
211+ inputTokens = input .EstimatedInputTokens
212+ }
213+ outputTokens := input .Usage .OutputTokens
214+ if outputTokens <= 0 && strings .TrimSpace (input .AssistantText ) != "" {
215+ outputTokens = estimateTokens (input .AssistantText )
216+ }
217+ latencyMS := input .AssistantLatency
218+ if latencyMS < 0 {
219+ latencyMS = time .Since (input .StartedAt ).Milliseconds ()
220+ }
221+ if latencyMS < 0 {
222+ latencyMS = 0
223+ }
224+ return interruptedMessageGenerationMetrics {
225+ InputTokens : inputTokens ,
226+ OutputTokens : outputTokens ,
227+ LatencyMS : latencyMS ,
228+ ErrorCode : classifyRunErrorCode (input .Error ),
229+ ErrorMessage : truncateError (messageErrorSummary (input .Error ), 255 ),
230+ CacheReadTokens : input .Usage .CacheReadTokens ,
231+ CacheWriteTokens : input .Usage .CacheWriteTokens ,
232+ ReasoningTokens : input .Usage .ReasoningTokens ,
233+ }
234+ }
235+
236+ // applyInterruptedMessageGenerationState 同步内存消息对象,保证后续响应、run 记录和持久化状态一致。
237+ func applyInterruptedMessageGenerationState (input persistInterruptedMessageGenerationInput , metrics interruptedMessageGenerationMetrics ) {
238+ input .UserMessage .Status = "success"
239+ input .UserMessage .ErrorCode = ""
240+ input .UserMessage .ErrorMessage = ""
241+ input .UserMessage .InputTokens = metrics .InputTokens
242+ input .UserMessage .CacheReadTokens = metrics .CacheReadTokens
243+ input .UserMessage .CacheWriteTokens = metrics .CacheWriteTokens
244+ input .UserMessage .TokenUsage = metrics .InputTokens + metrics .CacheReadTokens + metrics .CacheWriteTokens
245+
246+ input .AssistantMessage .Content = input .AssistantText
247+ input .AssistantMessage .TokenUsage = metrics .OutputTokens + metrics .ReasoningTokens
248+ input .AssistantMessage .OutputTokens = metrics .OutputTokens
249+ input .AssistantMessage .ReasoningTokens = metrics .ReasoningTokens
250+ input .AssistantMessage .LatencyMS = metrics .LatencyMS
251+ input .AssistantMessage .Status = "interrupted"
252+ input .AssistantMessage .ErrorCode = metrics .ErrorCode
253+ input .AssistantMessage .ErrorMessage = metrics .ErrorMessage
254+ }
255+
256+ // buildInterruptedSendMessageResult 构造中断回复响应,供 handler 继续走计费和前端展示链路。
257+ func buildInterruptedSendMessageResult (input persistInterruptedMessageGenerationInput , metrics interruptedMessageGenerationMetrics ) * SendMessageResult {
258+ result := & SendMessageResult {
259+ UserMessage : * input .UserMessage ,
260+ AssistantMessage : * input .AssistantMessage ,
261+ EffectiveOptions : input .EffectiveOptions ,
262+ UsageSpeed : input .Usage .Speed ,
263+ UsageServiceTier : input .Usage .ServiceTier ,
264+ CacheWrite5mTokens : input .Usage .CacheWrite5mTokens ,
265+ CacheWrite1hTokens : input .Usage .CacheWrite1hTokens ,
266+ ServerSideToolUsage : input .ServerSideToolUsage ,
267+ LatencyMS : metrics .LatencyMS ,
268+ }
269+ if input .Route != nil {
270+ result .UpstreamID = input .Route .UpstreamID
271+ result .UpstreamName = input .Route .UpstreamName
272+ result .PlatformModelName = input .Route .PlatformModelName
273+ result .RoutedBindingCode = input .Route .BindingCode
274+ result .UpstreamModelName = input .Route .UpstreamModel
275+ result .UpstreamProtocol = input .Route .Protocol
276+ }
277+ return result
278+ }
279+
280+ // persistMessageToolCalls 持久化工具调用并写入上下文 artifact,成功和中断路径共用同一套归属规则。
281+ func (s * Service ) persistMessageToolCalls (ctx context.Context , input persistMessageToolCallsInput ) error {
282+ rows := normalizeMessageToolCallRows (input )
283+ if len (rows ) == 0 {
284+ return nil
285+ }
286+ if err := s .repo .CreateConversationToolCalls (ctx , rows ); err != nil {
287+ return err
288+ }
289+ s .persistToolContextArtifacts (ctx , toolContextArtifactInput {
290+ ConversationID : input .SendInput .ConversationID ,
291+ UserID : input .SendInput .UserID ,
292+ MessageID : input .UserMessageID ,
293+ RunID : input .RunID ,
294+ Rows : rows ,
295+ })
296+ return nil
297+ }
298+
299+ // normalizeMessageToolCallRows 补齐工具调用归属字段,避免不同路径写入的 trace 缺少 message/run 关联。
300+ func normalizeMessageToolCallRows (input persistMessageToolCallsInput ) []model.ToolCall {
301+ if len (input .Rows ) == 0 {
302+ return nil
303+ }
304+ rows := append ([]model.ToolCall (nil ), input .Rows ... )
305+ for i := range rows {
306+ if rows [i ].ConversationID == 0 {
307+ rows [i ].ConversationID = input .SendInput .ConversationID
308+ }
309+ if rows [i ].UserID == 0 {
310+ rows [i ].UserID = input .SendInput .UserID
311+ }
312+ if rows [i ].MessageID == 0 {
313+ rows [i ].MessageID = input .AssistantMessageID
314+ }
315+ if strings .TrimSpace (rows [i ].RunID ) == "" {
316+ rows [i ].RunID = input .RunID
317+ }
318+ }
319+ return rows
320+ }
321+
93322func (s * Service ) updateStatefulResponseAsync (conversationID uint , responseID string , promptFingerprint string ) {
94323 respID := strings .TrimSpace (responseID )
95324 if respID == "" {
0 commit comments