Skip to content

Commit 4b9e2f6

Browse files
authored
Merge pull request #103 from DEEIX-AI/stream
feat: implement cancellation of pending message generations
2 parents 456f8b6 + 44a8dca commit 4b9e2f6

9 files changed

Lines changed: 157 additions & 12 deletions

File tree

backend/internal/application/conversation/generation_stream.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,25 @@ func EnsureMessageGenerationRunID(raw string) string {
5353

5454
// CancelMessageGeneration 取消用户显式停止的流式生成;浏览器刷新不会走这个路径。
5555
func (s *Service) CancelMessageGeneration(ctx context.Context, userID uint, runID string) bool {
56-
return s.generationStreams.cancel(ctx, userID, normalizeRunID(runID))
56+
normalizedRunID := normalizeRunID(runID)
57+
canceled := s.generationStreams.cancel(ctx, userID, normalizedRunID)
58+
if !canceled || s == nil || s.repo == nil {
59+
return canceled
60+
}
61+
markCtx := ctx
62+
var cancel context.CancelFunc
63+
if markCtx == nil || markCtx.Err() != nil {
64+
markCtx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
65+
defer cancel()
66+
}
67+
_, _ = s.repo.CancelPendingGenerationMessagesByRunID(
68+
markCtx,
69+
userID,
70+
normalizedRunID,
71+
classifyRunErrorCode(ErrMessageGenerationCanceled),
72+
ErrMessageGenerationCanceled.Error(),
73+
)
74+
return true
5775
}
5876

5977
// PublishMessageGenerationEvent 发布生成流事件,并返回带 seq 的实际载荷。

backend/internal/infra/persistence/postgres/conversation/repository.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,51 @@ func (r *Repo) UpdateMessageState(
889889
Error)
890890
}
891891

892+
// CancelPendingGenerationMessagesByRunID 将用户显式取消的 pending 回合更新为稳定终态。
893+
func (r *Repo) CancelPendingGenerationMessagesByRunID(
894+
ctx context.Context,
895+
userID uint,
896+
runID string,
897+
errorCode string,
898+
errorMessage string,
899+
) (bool, error) {
900+
normalizedRunID := strings.TrimSpace(runID)
901+
if userID == 0 || normalizedRunID == "" {
902+
return false, repository.ErrInvalidInput
903+
}
904+
normalizedErrorCode := strings.TrimSpace(errorCode)
905+
normalizedErrorMessage := truncateText(strings.TrimSpace(errorMessage), 255)
906+
returnedRows := int64(0)
907+
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
908+
userResult := tx.Model(&models.Message{}).
909+
Where("user_id = ? AND run_id = ? AND role = ? AND status = ?", userID, normalizedRunID, "user", "pending").
910+
Updates(map[string]interface{}{
911+
"status": "success",
912+
"error_code": "",
913+
"error_message": "",
914+
})
915+
if userResult.Error != nil {
916+
return userResult.Error
917+
}
918+
assistantResult := tx.Model(&models.Message{}).
919+
Where("user_id = ? AND run_id = ? AND role = ? AND status = ?", userID, normalizedRunID, "assistant", "pending").
920+
Updates(map[string]interface{}{
921+
"status": "canceled",
922+
"error_code": normalizedErrorCode,
923+
"error_message": normalizedErrorMessage,
924+
})
925+
if assistantResult.Error != nil {
926+
return assistantResult.Error
927+
}
928+
returnedRows = userResult.RowsAffected + assistantResult.RowsAffected
929+
return nil
930+
})
931+
if err != nil {
932+
return false, translateError(err)
933+
}
934+
return returnedRows > 0, nil
935+
}
936+
892937
// InterruptPendingAssistantMessageByRunID 将失去活跃生成流的 pending assistant 标记为错误。
893938
func (r *Repo) InterruptPendingAssistantMessageByRunID(
894939
ctx context.Context,

backend/internal/repository/conversation_core.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ type MessageRepository interface {
7171
GetMessageByPublicIDForUser(ctx context.Context, userID uint, publicID string) (*domainconversation.Message, error)
7272
UpdateMessageUsage(ctx context.Context, messageID uint, inputTokens int64, outputTokens int64, cacheReadTokens int64, cacheWriteTokens int64, reasoningTokens int64) error
7373
UpdateMessageState(ctx context.Context, messageID uint, status string, errorCode string, errorMessage string) error
74+
CancelPendingGenerationMessagesByRunID(ctx context.Context, userID uint, runID string, errorCode string, errorMessage string) (bool, error)
7475
InterruptPendingAssistantMessageByRunID(ctx context.Context, userID uint, runID string, errorCode string, errorMessage string) (bool, error)
7576
UpdateAssistantMessageCompletion(ctx context.Context, messageID uint, content string, outputTokens int64, reasoningTokens int64, latencyMS int64, status string, errorCode string, errorMessage string) error
7677
UpdateMessageBilling(ctx context.Context, messageID uint, billedCurrency string, billedNanousd int64, pricingSnapshot string) error

backend/internal/transport/http/server.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,13 +302,26 @@ func isRegularFile(filePath string) bool {
302302
}
303303

304304
func applyFrontendCacheHeaders(c *gin.Context, requestPath string) {
305-
if strings.HasPrefix(requestPath, "/_next/static/") || strings.HasPrefix(requestPath, "/fonts/") {
305+
if isImmutableFrontendAsset(requestPath) {
306306
c.Header("Cache-Control", "public, max-age=31536000, immutable")
307307
return
308308
}
309+
if isNextExportDataAsset(requestPath) {
310+
c.Header("Cache-Control", "public, max-age=86400, stale-while-revalidate=604800")
311+
return
312+
}
309313
c.Header("Cache-Control", "public, max-age=3600")
310314
}
311315

316+
func isImmutableFrontendAsset(requestPath string) bool {
317+
return strings.HasPrefix(requestPath, "/_next/static/") || strings.HasPrefix(requestPath, "/fonts/")
318+
}
319+
320+
func isNextExportDataAsset(requestPath string) bool {
321+
fileName := path.Base(requestPath)
322+
return strings.HasPrefix(fileName, "__next.") && strings.EqualFold(path.Ext(fileName), ".txt")
323+
}
324+
312325
func readyzHandler(hc HealthChecker) gin.HandlerFunc {
313326
return func(c *gin.Context) {
314327
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)

backend/internal/transport/http/server_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,57 @@ func TestFrontendStaticFallbackServesExportedPage(t *testing.T) {
3434
if strings.TrimSpace(recorder.Body.String()) != "chat page" {
3535
t.Fatalf("expected chat page, got %q", recorder.Body.String())
3636
}
37+
if got := recorder.Header().Get("Cache-Control"); got != "no-cache" {
38+
t.Fatalf("expected exported page no-cache, got %q", got)
39+
}
40+
}
41+
42+
func TestFrontendStaticCachesNextExportData(t *testing.T) {
43+
gin.SetMode(gin.TestMode)
44+
root := t.TempDir()
45+
if err := os.WriteFile(filepath.Join(root, "__next._tree.txt"), []byte("tree"), 0o644); err != nil {
46+
t.Fatalf("write next data: %v", err)
47+
}
48+
49+
engine := gin.New()
50+
registerFrontendStatic(engine, root, nil)
51+
52+
recorder := httptest.NewRecorder()
53+
request := httptest.NewRequest(http.MethodGet, "/__next._tree.txt?conversation_id=demo&_rsc=abc", nil)
54+
engine.ServeHTTP(recorder, request)
55+
56+
if recorder.Code != http.StatusOK {
57+
t.Fatalf("expected status 200, got %d", recorder.Code)
58+
}
59+
if got := recorder.Header().Get("Cache-Control"); got != "public, max-age=86400, stale-while-revalidate=604800" {
60+
t.Fatalf("expected next export data cache header, got %q", got)
61+
}
62+
}
63+
64+
func TestFrontendStaticCachesImmutableBuildAssets(t *testing.T) {
65+
gin.SetMode(gin.TestMode)
66+
root := t.TempDir()
67+
chunkDir := filepath.Join(root, "_next", "static", "chunks")
68+
if err := os.MkdirAll(chunkDir, 0o755); err != nil {
69+
t.Fatalf("create chunk dir: %v", err)
70+
}
71+
if err := os.WriteFile(filepath.Join(chunkDir, "app.js"), []byte("chunk"), 0o644); err != nil {
72+
t.Fatalf("write chunk: %v", err)
73+
}
74+
75+
engine := gin.New()
76+
registerFrontendStatic(engine, root, nil)
77+
78+
recorder := httptest.NewRecorder()
79+
request := httptest.NewRequest(http.MethodGet, "/_next/static/chunks/app.js", nil)
80+
engine.ServeHTTP(recorder, request)
81+
82+
if recorder.Code != http.StatusOK {
83+
t.Fatalf("expected status 200, got %d", recorder.Code)
84+
}
85+
if got := recorder.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
86+
t.Fatalf("expected immutable cache header, got %q", got)
87+
}
3788
}
3889

3990
func TestFrontendStaticFallbackSkipsAPIPaths(t *testing.T) {

frontend/features/chat/components/app-chat-area.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,7 @@ export function AppChatArea() {
370370
setAttachments,
371371
releaseAttachments,
372372
activeGenerationRunsRef,
373+
resumingRunID,
373374
});
374375
const generating = sending || Boolean(resumingRunID);
375376
const uploadDropDisabled = generating || loading || uploading;

frontend/features/chat/hooks/use-chat-branch-state.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,13 @@ export function useChatBranchState({
165165
resetToken,
166166
messages,
167167
pendingExchange,
168+
liveRunIDs,
168169
}: {
169170
conversationID: string | null;
170171
resetToken: number;
171172
messages: MessageDTO[];
172173
pendingExchange: PendingExchange | null;
174+
liveRunIDs?: ReadonlySet<string>;
173175
}) {
174176
const t = useTranslations("chat.messages");
175177
const [branchSelections, setBranchSelections] = React.useState<Record<string, string>>({});
@@ -181,13 +183,17 @@ export function useChatBranchState({
181183
const serverTreeMessages = React.useMemo(
182184
() =>
183185
messages.map((item) =>
184-
mapServerMessage(item, {
185-
generationInterrupted: t("generationInterrupted"),
186-
streamInterrupted: t("streamInterrupted"),
187-
imageRunning: t("imageRunning"),
188-
}),
186+
mapServerMessage(
187+
item,
188+
{
189+
generationInterrupted: t("generationInterrupted"),
190+
streamInterrupted: t("streamInterrupted"),
191+
imageRunning: t("imageRunning"),
192+
},
193+
{ liveRunIDs },
194+
),
189195
),
190-
[messages, t],
196+
[liveRunIDs, messages, t],
191197
);
192198
const serverMessagePublicIDs = React.useMemo(
193199
() => new Set(serverTreeMessages.map((item) => item.publicID).filter(Boolean)),
@@ -234,7 +240,7 @@ export function useChatBranchState({
234240
item.role === "assistant" &&
235241
((pendingExchange.runID && item.runID === pendingExchange.runID) ||
236242
item.publicID === (pendingExchange.assistantPublicID || pendingExchange.tempAssistantPublicID)) &&
237-
item.isPending,
243+
item.isStreaming,
238244
),
239245
);
240246

frontend/features/chat/hooks/use-chat-runtime.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export function useChatRuntime({
3535
setAttachments,
3636
releaseAttachments,
3737
activeGenerationRunsRef,
38+
resumingRunID = "",
3839
}: {
3940
conversationID: string | null;
4041
resetToken: number;
@@ -58,16 +59,22 @@ export function useChatRuntime({
5859
setAttachments: React.Dispatch<React.SetStateAction<PendingAttachment[]>>;
5960
releaseAttachments: (items: PendingAttachment[]) => void;
6061
activeGenerationRunsRef?: React.RefObject<Set<string>>;
62+
resumingRunID?: string;
6163
}) {
6264
const [showConversationLayout, setShowConversationLayout] = React.useState(false);
6365
const [pendingExchange, setPendingExchange] = React.useState<PendingExchange | null>(null);
6466
const previousResetTokenRef = React.useRef(resetToken);
67+
const liveServerRunIDs = React.useMemo(() => {
68+
const normalized = resumingRunID.trim();
69+
return normalized ? new Set([normalized]) : undefined;
70+
}, [resumingRunID]);
6571

6672
const branchState = useChatBranchState({
6773
conversationID,
6874
resetToken,
6975
messages,
7076
pendingExchange,
77+
liveRunIDs: liveServerRunIDs,
7178
});
7279

7380
const submitState = useChatSubmitStream({

frontend/features/chat/model/chat-thread.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ export function mapServerMessage(
160160
labels: { generationInterrupted: string; streamInterrupted?: string; imageRunning?: string } = {
161161
generationInterrupted: "Generation interrupted",
162162
},
163+
options: { liveRunIDs?: ReadonlySet<string> } = {},
163164
): ChatAreaMessage {
164165
const publicID = item.publicID.trim();
165166
const msg: ChatAreaMessage = {
@@ -210,9 +211,11 @@ export function mapServerMessage(
210211
};
211212
}
212213
if (item.status === "pending") {
213-
msg.isPending = true;
214-
msg.isStreaming = true;
215-
msg.activityLabel = item.contentType === "image" ? labels.imageRunning : undefined;
214+
const liveRunID = item.runID?.trim() || "";
215+
const live = Boolean(liveRunID && options.liveRunIDs?.has(liveRunID));
216+
msg.isPending = live;
217+
msg.isStreaming = live;
218+
msg.activityLabel = live && item.contentType === "image" ? labels.imageRunning : undefined;
216219
}
217220
}
218221
return msg;

0 commit comments

Comments
 (0)