feat(monitoring): 关联反馈记录与消息ID,新增反馈导出 (#2120)

* feat(monitoring): link feedback to LangBot message ID and add feedback export

- Add pipeline→adapter notification hook so monitoring message ID is
  passed back to WecomBotAdapter after creation
- Store stream_id→monitoring_message_id mapping with 10-min TTL cleanup
- Replace feedback record stream_id with LangBot monitoring message ID
  so feedback can be linked to actual message records
- Rename streamId label to "Related Query ID" in all 7 i18n locales
- Remove non-functional message ID jump button from FeedbackList
- Add feedback export option to ExportDropdown (backend already implemented)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(monitoring): add combined refresh handler for monitoring and feedback data

* fix(wecombot): improve stream ID mapping and error logging in WecomBotAdapter

* feat(lark): add monitoring message ID mapping for feedback correlation

* feat(lark): rename monitoring message ID mappings for clarity and consistency
feat(feedback): add button to view conversation for feedback items

* feat(bot-session-monitor): add feedback handling for bot messages with visual indicators

* feat(bot-session-monitor): enhance feedback display with hover content for like/dislike indicators

* fix(dingtalk): use voice recognition text instead of raw audio binary

When DingTalk sends a voice message to the bot, the callback JSON contains
a 'recognition' field with the speech-to-text result (powered by Qwen).

Previously, LangBot only extracted the 'downloadCode' to download the raw
audio binary and passed it as 'file_base64' to LLM APIs, which caused
400 errors since most models don't support this content type.

This patch:
- Extracts the 'recognition' field from DingTalk audio message content
- Uses it as plain text input to the LLM instead of raw audio
- Falls back to audio binary only when no recognition text is available
- Fixes duplicate text issue for audio messages with recognition

Fixes voice messages returning 'Request failed' on all LLM models.

* fix: add filereader for dingtalk,lark (#2122)

* fix: add filereader for dingtalk

* feat: add lark

* feat: update uv.lock

* chore: update version to 4.9.6 in pyproject.toml, __init__.py, and uv.lock

* fix: update langbot-plugin version to 0.3.8

* fix: update langbot-plugin version to 0.3.8

* fix(wecombot): extend StreamSession TTL for feedback sessions to prevent context data loss

StreamSessionManager.cleanup() removes sessions after 60s TTL, but feedback
events (like → cancel → dislike) can arrive later. When the session expires
before the dislike event, all context fields (session_id, user_id, message_id,
stream_id) are lost because get_session_by_feedback_id() returns None.

Fix: Sessions with registered feedback_ids now use a 10-minute TTL, aligned
with the adapter's _stream_to_monitoring_msg TTL in wecombot.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: 6mvp6 <13727783693@163.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: fdc310 <2213070223@qq.com>
Co-authored-by: haiyangbg <zhouhaiyangaa@gmail.com>
Co-authored-by: Guanchao Wang <wangcham233@gmail.com>
Co-authored-by: Rock Chin <1010553892@qq.com>
This commit is contained in:
6mvp6
2026-04-18 12:56:41 +08:00
committed by GitHub
parent 917edb3413
commit f8010a20eb
15 changed files with 241 additions and 47 deletions

View File

@@ -10,7 +10,15 @@ import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { Ban, Bot, Copy, Check, Workflow } from 'lucide-react';
import {
Ban,
Bot,
Copy,
Check,
Workflow,
ThumbsUp,
ThumbsDown,
} from 'lucide-react';
import {
MessageChainComponent,
Plain,
@@ -54,6 +62,12 @@ interface SessionMessage {
role?: string | null;
}
interface SessionFeedback {
feedback_type: number; // 1=like, 2=dislike
feedback_content?: string | null;
stream_id?: string | null;
}
export interface BotSessionMonitorHandle {
refreshSessions: () => Promise<void>;
}
@@ -75,6 +89,9 @@ const BotSessionMonitor = forwardRef<
const [loadingSessions, setLoadingSessions] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
const [copiedUserId, setCopiedUserId] = useState(false);
const [feedbackMap, setFeedbackMap] = useState<
Record<string, SessionFeedback>
>({});
const messagesContainerRef = useRef<HTMLDivElement>(null);
const parseSessionType = (sessionId: string): string | null => {
@@ -117,21 +134,50 @@ const BotSessionMonitor = forwardRef<
[loadSessions],
);
const loadMessages = useCallback(async (sessionId: string) => {
setLoadingMessages(true);
try {
const response = await httpClient.getSessionMessages(sessionId);
const sorted = (response.messages ?? []).sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);
setMessages(sorted);
} catch (error) {
console.error('Failed to load session messages:', error);
} finally {
setLoadingMessages(false);
}
}, []);
const loadMessages = useCallback(
async (sessionId: string) => {
setLoadingMessages(true);
try {
const messagesRes = await httpClient.getSessionMessages(sessionId);
const sorted = (messagesRes.messages ?? []).sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);
setMessages(sorted);
// Collect user message IDs for feedback matching
const userMsgIds = new Set(
sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id),
);
if (userMsgIds.size > 0) {
// Fetch feedback for this bot, then match by stream_id locally
const feedbackRes = await httpClient.get<{
feedback: SessionFeedback[];
}>(
`/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`,
);
const map: Record<string, SessionFeedback> = {};
if (feedbackRes?.feedback) {
for (const fb of feedbackRes.feedback) {
if (fb.stream_id && userMsgIds.has(fb.stream_id)) {
map[fb.stream_id] = fb;
}
}
}
setFeedbackMap(map);
} else {
setFeedbackMap({});
}
} catch (error) {
console.error('Failed to load session messages:', error);
} finally {
setLoadingMessages(false);
}
},
[botId],
);
useEffect(() => {
loadSessions();
@@ -479,11 +525,21 @@ const BotSessionMonitor = forwardRef<
{t('bots.sessionMonitor.noMessages')}
</div>
) : (
messages.map((msg) => {
messages.map((msg, msgIndex) => {
const isUser = isUserMessage(msg);
const isDiscarded =
msg.status === 'discarded' ||
msg.pipeline_id === PIPELINE_DISCARD;
// For bot replies, find feedback linked to the preceding user message
let msgFeedback: SessionFeedback | undefined;
if (!isUser) {
for (let i = msgIndex - 1; i >= 0; i--) {
if (isUserMessage(messages[i])) {
msgFeedback = feedbackMap[messages[i].id];
break;
}
}
}
return (
<div
key={msg.id}
@@ -543,6 +599,30 @@ const BotSessionMonitor = forwardRef<
{msg.runner_name}
</span>
)}
{/* Feedback indicator — same line, pushed right */}
{!isUser &&
msgFeedback &&
(msgFeedback.feedback_type === 1 ? (
<span className="inline-flex items-center gap-1 ml-auto text-green-600 dark:text-green-400 cursor-default relative group">
<ThumbsUp className="w-3 h-3 flex-shrink-0" />
{t('monitoring.feedback.like')}
{msgFeedback.feedback_content && (
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
{msgFeedback.feedback_content}
</span>
)}
</span>
) : (
<span className="inline-flex items-center gap-1 ml-auto text-red-500 dark:text-red-400 cursor-default relative group">
<ThumbsDown className="w-3 h-3 flex-shrink-0" />
{t('monitoring.feedback.dislike')}
{msgFeedback.feedback_content && (
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
{msgFeedback.feedback_content}
</span>
)}
</span>
))}
</div>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import {
AlertCircle,
Users,
Layers,
ThumbsUp,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -25,7 +26,8 @@ export type ExportType =
| 'llm-calls'
| 'embedding-calls'
| 'errors'
| 'sessions';
| 'sessions'
| 'feedback';
interface ExportDropdownProps {
filterState: FilterState;
@@ -162,6 +164,11 @@ export function ExportDropdown({ filterState }: ExportDropdownProps) {
label: t('monitoring.export.sessions'),
icon: <Users className="w-4 h-4 mr-2" />,
},
{
type: 'feedback',
label: t('monitoring.export.feedback'),
icon: <ThumbsUp className="w-4 h-4 mr-2" />,
},
];
return (

View File

@@ -127,6 +127,20 @@ export function FeedbackList({
{item.platform}
</span>
)}
{item.streamId && onViewMessage && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs"
onClick={(e) => {
e.stopPropagation();
onViewMessage(item.streamId!);
}}
>
<ExternalLink className="w-3 h-3 mr-1" />
{t('monitoring.messageList.viewConversation')}
</Button>
)}
</div>
{item.feedbackContent && (
@@ -221,21 +235,8 @@ export function FeedbackList({
<div className="text-gray-500 dark:text-gray-400">
{t('monitoring.feedback.messageId')}
</div>
<div className="font-medium text-gray-900 dark:text-white truncate flex items-center gap-1">
<span className="truncate">{item.messageId}</span>
{onViewMessage && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs shrink-0"
onClick={(e) => {
e.stopPropagation();
onViewMessage(item.messageId!);
}}
>
<ExternalLink className="w-3 h-3" />
</Button>
)}
<div className="font-medium text-gray-900 dark:text-white truncate">
{item.messageId}
</div>
</div>
)}

View File

@@ -1,4 +1,4 @@
import React, { Suspense, useState, useMemo } from 'react';
import React, { Suspense, useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
@@ -69,6 +69,9 @@ function MonitoringPageContent() {
useMonitoringFilters();
const { data, loading, refetch } = useMonitoringData(filterState);
// Counter to force feedbackTimeRange recomputation on manual refresh
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
// Get time range for feedback data
const feedbackTimeRange = useMemo(() => {
const now = new Date();
@@ -106,7 +109,8 @@ function MonitoringPageContent() {
startTime: startTime?.toISOString(),
endTime: endTime.toISOString(),
};
}, [filterState.timeRange, filterState.customDateRange]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterState.timeRange, filterState.customDateRange, feedbackRefreshKey]);
// Feedback data hook
const {
@@ -127,6 +131,12 @@ function MonitoringPageContent() {
limit: 50,
});
// Combined refresh handler for both monitoring and feedback data
const handleRefresh = useCallback(() => {
refetch();
setFeedbackRefreshKey((k) => k + 1);
}, [refetch]);
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
null,
);
@@ -265,7 +275,7 @@ function MonitoringPageContent() {
<Button
variant="outline"
size="sm"
onClick={refetch}
onClick={handleRefresh}
className="shadow-sm flex-shrink-0"
>
<svg