Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/internal/application/conversation/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ type SendMessageInput struct {
type SendMessageResult struct {
UserMessage model.Message
AssistantMessage model.Message
Billable bool
UpstreamID uint
UpstreamName string
PlatformModelName string
Expand Down
6 changes: 5 additions & 1 deletion backend/internal/application/conversation/service_branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,11 @@ func (s *Service) resolveMessageBranch(
}
}
if branchReason == "default" {
ancestorMessages, parentMessage = normalizeDefaultBranchContext(ancestorMessages, parentMessage)
normalizedAncestors, contextParent := normalizeDefaultBranchContext(ancestorMessages, parentMessage)
ancestorMessages = normalizedAncestors
if strings.TrimSpace(parentPublicID) == "" {
parentMessage = contextParent
}
}

state := &messageBranchState{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ func buildInterruptedSendMessageResult(input persistInterruptedMessageGeneration
result := &SendMessageResult{
UserMessage: *input.UserMessage,
AssistantMessage: *input.AssistantMessage,
Billable: true,
EffectiveOptions: input.EffectiveOptions,
UsageSpeed: input.Usage.Speed,
UsageServiceTier: input.Usage.ServiceTier,
Expand Down
21 changes: 21 additions & 0 deletions backend/internal/application/conversation/service_message_send.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,26 @@ func (s *Service) sendMessageInternal(
}
}
runState.finalize(ctx, retErr)
if retErr != nil && result == nil && userMessage != nil && assistantMessage != nil {
latencyMS := time.Since(startedAt).Milliseconds()
if latencyMS < 0 {
latencyMS = 0
}
result = &SendMessageResult{
UserMessage: *userMessage,
AssistantMessage: *assistantMessage,
Billable: false,
LatencyMS: latencyMS,
}
if resolvedRoute != nil {
result.UpstreamID = resolvedRoute.UpstreamID
result.UpstreamName = resolvedRoute.UpstreamName
result.PlatformModelName = resolvedRoute.PlatformModelName
result.RoutedBindingCode = resolvedRoute.BindingCode
result.UpstreamModelName = resolvedRoute.UpstreamModel
result.UpstreamProtocol = resolvedRoute.Protocol
}
}
}()

resolvedAttachments, err := s.resolveAttachments(ctx, input.UserID, input.FileIDs)
Expand Down Expand Up @@ -1291,6 +1311,7 @@ func (s *Service) sendMessageInternal(
return &SendMessageResult{
UserMessage: *userMessage,
AssistantMessage: *assistantMessage,
Billable: true,
UpstreamID: run.UpstreamID,
UpstreamName: run.UpstreamName,
PlatformModelName: route.PlatformModelName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,14 @@ func (h *Handler) SendMessage(c *gin.Context) {
result, err := h.service.SendMessage(c.Request.Context(), input)
if err != nil {
if result != nil {
if !result.Billable {
if releaseErr := h.releaseSendMessageUsageReservation(reservation, "模型调用失败退回预扣"); releaseErr != nil {
handleSendMessageBillingError(c, releaseErr)
return
}
handleSendMessageError(c, err)
return
}
if billingErr := h.recordAndApplySendMessageBilling(c.Request.Context(), middleware.MustUserID(c), conversation, req, result, reservation); billingErr != nil {
if shouldReleaseReservationAfterBillingError(billingErr) {
_ = h.releaseSendMessageUsageReservation(reservation, "计费失败退回预扣")
Expand Down Expand Up @@ -439,6 +447,22 @@ func (h *Handler) StreamMessage(c *gin.Context) {
})
if err != nil {
if result != nil {
if !result.Billable {
if releaseErr := h.releaseSendMessageUsageReservation(reservation, "模型调用失败退回预扣"); releaseErr != nil {
_ = flushStreamEvent(billingStreamErrorPayload(releaseErr))
h.service.FinishMessageGeneration(input.ClientRunID)
return
}
payload := streamErrorPayload(err)
payload["data"] = toSendMessageResponse(result)
if debug := appconversation.MessageErrorDebug(err); debug != nil {
payload["debug"] = debug
}
_ = flushStreamEvent(payload)
h.service.FinishMessageGeneration(input.ClientRunID)
h.recordStreamSendMessageAuditAsync(c, conversation, req, result, "stream_message")
return
}
billingCtx, billingCancel := context.WithTimeout(context.Background(), 10*time.Second)
billingErr := h.recordAndApplySendMessageBilling(billingCtx, middleware.MustUserID(c), conversation, req, result, reservation)
billingCancel()
Expand Down
8 changes: 5 additions & 3 deletions frontend/features/chat/components/message/message-meta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { Button } from "@/components/ui/button";
import { resolveAccessToken } from "@/shared/auth/resolve-access-token";
import { upsertUserMemory } from "@/shared/api/memory";
import { useLocalizedErrorMessage } from "@/i18n/use-localized-error";
import { resolvePersistedPublicID } from "@/features/chat/model/message-submit";
import { billingRateMultiplierNote, cacheWriteBillingLabel, cacheWriteBillingNote } from "@/shared/lib/billing-display";
import type { BillingDisplayLabels } from "@/shared/lib/billing-display";
import type { ChatBillingCost, ChatMessageBranchNavigator } from "@/features/chat/types/messages";
Expand Down Expand Up @@ -207,14 +208,15 @@ export function UserMessageMeta({
const t = useTranslations("chat.messages");
const { locale } = useAppLocale();
const dateLabel = formatMessageDate(item.createdAt, locale);
const hasPersistedMessage = Boolean(resolvePersistedPublicID(item.publicID));
const canShowBranchNavigator = Boolean(showBranchNavigator && item.branchNavigator && !busy && !item.isPending);

return (
<MetaContainer align="end" alwaysVisible={alwaysVisible}>
{dateLabel ? <span className="mr-1 shrink-0 tabular-nums">{dateLabel}</span> : null}
{!readOnly ? (
<div className="flex items-center">
{showRetry ? (
{showRetry && hasPersistedMessage ? (
<MetaIconButton
label={t("retryMessage")}
disabled={item.isPending}
Expand All @@ -225,7 +227,7 @@ export function UserMessageMeta({
) : null}
<MetaIconButton
label={t("editMessage")}
disabled={item.isPending}
disabled={item.isPending || !hasPersistedMessage}
onClick={onEdit}
>
<Brush size={14} strokeWidth={1.8} animateOnHover="default" />
Expand Down Expand Up @@ -891,7 +893,7 @@ export function AssistantMessageMeta({
const t = useTranslations("chat.messages");
const isLive = Boolean(item.isPending || item.isStreaming);
const canRetry = !readOnly && !busy && !isLive;
const canContinue = Boolean(canRetry && item.publicID && item.status === "interrupted");
const canContinue = Boolean(canRetry && resolvePersistedPublicID(item.publicID) && item.status === "interrupted");
const canShowBranchNavigator = Boolean(showBranchNavigator && item.branchNavigator && !busy && !isLive);

return (
Expand Down
10 changes: 8 additions & 2 deletions frontend/features/chat/hooks/use-chat-branch-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ function mergePendingAssistantState(messages: ChatAreaMessage[], pendingExchange
if (!sameAssistant) {
return item;
}
const serverStatus = item.status?.trim().toLowerCase() || "success";
const serverHasTerminalState = !item.isPending && !item.isStreaming && serverStatus !== "pending";
const existingAlert = item.inlineAlert;
const nextAlert = pendingAlert
? {
Expand All @@ -132,7 +134,7 @@ function mergePendingAssistantState(messages: ChatAreaMessage[], pendingExchange
: existingAlert;
return {
...item,
content: pendingText ? pendingText : item.content,
content: serverHasTerminalState && item.content ? item.content : pendingText ? pendingText : item.content,
contentType: pendingExchange.assistantContentType ?? item.contentType,
isPending: pendingExchange.assistantPending,
isStreaming: pendingExchange.assistantStreaming,
Expand All @@ -149,7 +151,11 @@ function mergePendingAssistantState(messages: ChatAreaMessage[], pendingExchange
latencyMS: pendingExchange.assistantLatencyMS ?? item.latencyMS,
compactDone: pendingExchange.compactDone ?? item.compactDone,
platformModelName: pendingExchange.platformModelName ?? item.platformModelName,
status: pendingExchange.assistantPending ? "pending" : pendingExchange.assistantStatus ?? item.status,
status: pendingExchange.assistantPending
? "pending"
: serverHasTerminalState
? item.status
: pendingExchange.assistantStatus ?? item.status,
};
});
}
Expand Down
56 changes: 46 additions & 10 deletions frontend/features/chat/hooks/use-chat-message-submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,11 +282,28 @@ export function useChatMessageSubmit({
}
const userPublicID = pendingExchange.userPublicID || pendingExchange.tempUserPublicID;
const assistantPublicID = pendingExchange.assistantPublicID || pendingExchange.tempAssistantPublicID;
if (!serverMessagePublicIDs.has(userPublicID) || !serverMessagePublicIDs.has(assistantPublicID)) {
if (serverMessagePublicIDs.has(userPublicID) && serverMessagePublicIDs.has(assistantPublicID)) {
setPendingExchange(null);
return;
}
setPendingExchange(null);
}, [pendingExchange, serverMessagePublicIDs, setPendingExchange]);

const pendingRunID = pendingExchange.runID?.trim();
if (!pendingRunID || pendingExchange.assistantPending) {
return;
}
const serverAssistant = combinedMessages.find(
(item) =>
item.role === "assistant" &&
item.runID === pendingRunID &&
resolvePersistedPublicID(item.publicID) &&
!item.isPending &&
!item.isStreaming &&
item.status !== "pending",
);
if (serverAssistant) {
setPendingExchange(null);
}
}, [combinedMessages, pendingExchange, serverMessagePublicIDs, setPendingExchange]);

const submitMessage = React.useCallback(
async ({
Expand Down Expand Up @@ -654,6 +671,7 @@ export function useChatMessageSubmit({
}
reload();
} catch (error) {
flushStreamTextNow();
resetStreamBuffer();
if (streamAbortController.signal.aborted) {
shouldKeepConversationLayout = true;
Expand Down Expand Up @@ -761,28 +779,36 @@ export function useChatMessageSubmit({

const onSendMessage = React.useCallback(async () => {
const content = draft.trim();
const parentMessage = resolveDefaultSubmissionParentMessage(visibleMessages);
const parentMessagePublicID =
resolvePersistedPublicID(currentLeafMessage?.publicID) ??
resolveDefaultSubmissionParentMessage(visibleMessages)?.publicID ??
null;
await submitMessage({
content,
currentAttachments: attachments,
resetComposer: true,
parentMessagePublicID: parentMessage?.publicID ?? currentLeafMessage?.publicID ?? null,
parentMessagePublicID,
branchReason: "default",
});
}, [attachments, currentLeafMessage?.publicID, draft, submitMessage, visibleMessages]);

const onRetryUserMessage = React.useCallback(
async (message: ChatAreaMessage) => {
const sourceMessagePublicID = resolvePersistedPublicID(message.publicID);
if (!sourceMessagePublicID) {
toast.error(t("retryReplyFailed"), { description: t("continueReplyUnavailable") });
return;
}
await submitMessage({
content: message.content.trim(),
currentAttachments: toPendingAttachments(message),
resetComposer: false,
parentMessagePublicID: message.parentPublicID,
sourceMessagePublicID: message.publicID,
sourceMessagePublicID,
branchReason: "retry",
});
},
[submitMessage],
[submitMessage, t],
);

const onRetryAssistantMessage = React.useCallback(
Expand All @@ -792,12 +818,17 @@ export function useChatMessageSubmit({
toast.error(t("retryReplyFailed"), { description: t("retryReplyMissingUser") });
return;
}
const sourceMessagePublicID = resolvePersistedPublicID(parentUser.publicID);
if (!sourceMessagePublicID) {
toast.error(t("retryReplyFailed"), { description: t("continueReplyUnavailable") });
return;
}
await submitMessage({
content: parentUser.content.trim(),
currentAttachments: toPendingAttachments(parentUser),
resetComposer: false,
parentMessagePublicID: parentUser.parentPublicID,
sourceMessagePublicID: parentUser.publicID,
sourceMessagePublicID,
branchReason: "retry",
});
},
Expand Down Expand Up @@ -825,17 +856,22 @@ export function useChatMessageSubmit({

const onEditUserMessage = React.useCallback(
async (message: ChatAreaMessage, content: string) => {
const sourceMessagePublicID = resolvePersistedPublicID(message.publicID);
if (!sourceMessagePublicID) {
toast.error(t("retryReplyFailed"), { description: t("continueReplyUnavailable") });
return false;
}
const ok = await submitMessage({
content: content.trim(),
currentAttachments: toPendingAttachments(message),
resetComposer: false,
parentMessagePublicID: message.parentPublicID,
sourceMessagePublicID: message.publicID,
sourceMessagePublicID,
branchReason: "edit",
});
return ok;
},
[submitMessage],
[submitMessage, t],
);

const onCycleMessageBranch = React.useCallback(
Expand Down
Loading