| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308 |
- import { useEffect, useState } from "react";
- import type { FC } from "react";
- import { Modal, RadioGroup, Radio, Select, Toast } from "@douyinfe/semi-ui";
- import type { RadioChangeEvent } from "@douyinfe/semi-ui/lib/es/radio";
- import { traceApi } from "../../api/traceApi";
- import type { KnowledgeLogEntry, FeedbackAction, KnowledgeFeedbackState } from "../../types/goal";
- import type { KnowledgeFeedbackListItem } from "../../api/traceApi";
- import styles from "./KnowledgeFeedbackModal.module.css";
- interface KnowledgeFeedbackModalProps {
- visible: boolean;
- traceId: string;
- onClose: () => void;
- onSubmitted?: () => void;
- onNavigateToMessage?: (goalId: string, sequence: number) => void;
- }
- const EVAL_STATUS_OPTIONS = [
- { value: "helpful", label: "✅ helpful(有效)" },
- { value: "harmful", label: "🚫 harmful(有害)" },
- { value: "unused", label: "⬜ unused(未使用)" },
- { value: "irrelevant", label: "⚠️ irrelevant(无关)" },
- { value: "neutral", label: "➖ neutral(中性)" },
- ];
- const EVAL_STATUS_BADGE: Record<string, { label: string; cls: string }> = {
- helpful: { label: "helpful", cls: styles.badgeHelpful },
- harmful: { label: "harmful", cls: styles.badgeHarmful },
- unused: { label: "unused", cls: styles.badgeNeutral },
- irrelevant: { label: "irrelevant", cls: styles.badgeNeutral },
- neutral: { label: "neutral", cls: styles.badgeNeutral },
- };
- export const KnowledgeFeedbackModal: FC<KnowledgeFeedbackModalProps> = ({
- visible,
- traceId,
- onClose,
- onSubmitted,
- onNavigateToMessage,
- }) => {
- const [entries, setEntries] = useState<KnowledgeLogEntry[]>([]);
- const [feedbackMap, setFeedbackMap] = useState<Record<string, KnowledgeFeedbackState>>({});
- const [isLoading, setIsLoading] = useState(false);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
- // 加载 knowledge_log,并去重(同一 knowledge_id 取最新注入)
- useEffect(() => {
- if (!visible || !traceId) return;
- setIsLoading(true);
- traceApi
- .fetchKnowledgeLog(traceId)
- .then((log) => {
- // 去重:同一 knowledge_id 保留最新注入的条目
- const seen = new Map<string, KnowledgeLogEntry>();
- for (const entry of log.entries) {
- const existing = seen.get(entry.knowledge_id);
- if (!existing || entry.injected_at_sequence > existing.injected_at_sequence) {
- seen.set(entry.knowledge_id, entry);
- }
- }
- const deduped = Array.from(seen.values());
- setEntries(deduped);
- // 默认所有条目选「跳过」
- const initial: Record<string, KnowledgeFeedbackState> = {};
- deduped.forEach((e) => {
- // 若已有用户反馈,恢复之前的选择
- if (e.user_feedback) {
- initial[e.knowledge_id] = {
- action: e.user_feedback.action as FeedbackAction,
- eval_status: e.user_feedback.eval_status,
- feedback_text: e.user_feedback.feedback_text,
- };
- } else {
- initial[e.knowledge_id] = { action: "skip" };
- }
- });
- setFeedbackMap(initial);
- })
- .catch(() => {
- Toast.error("加载知识日志失败");
- })
- .finally(() => setIsLoading(false));
- }, [visible, traceId]);
- const toggleExpand = (id: string) => {
- setExpandedIds((prev) => {
- const next = new Set(prev);
- if (next.has(id)) next.delete(id);
- else next.add(id);
- return next;
- });
- };
- const setFeedback = (id: string, patch: Partial<KnowledgeFeedbackState>) => {
- setFeedbackMap((prev) => ({
- ...prev,
- [id]: { ...prev[id], ...patch },
- }));
- };
- const confirmedCount = Object.values(feedbackMap).filter(
- (f) => f.action !== "skip"
- ).length;
- const handleSubmit = async () => {
- setIsSubmitting(true);
- const feedbackList: KnowledgeFeedbackListItem[] = entries
- .filter((e) => feedbackMap[e.knowledge_id]?.action !== "skip")
- .map((e) => ({
- knowledge_id: e.knowledge_id,
- action: feedbackMap[e.knowledge_id].action,
- eval_status: feedbackMap[e.knowledge_id].eval_status,
- feedback_text: feedbackMap[e.knowledge_id].feedback_text,
- source: {
- trace_id: traceId,
- goal_id: e.goal_id,
- sequence: e.injected_at_sequence,
- feedback_by: "user",
- feedback_at: new Date().toISOString(),
- },
- }));
- try {
- await traceApi.submitKnowledgeFeedback(traceId, { feedback_list: feedbackList });
- localStorage.setItem(`knowledge_feedback_submitted_${traceId}`, "true");
- Toast.success(`反馈提交成功,共反馈 ${feedbackList.length} 条知识`);
- onSubmitted?.();
- onClose();
- } catch {
- Toast.error("提交失败,请重试");
- } finally {
- setIsSubmitting(false);
- }
- };
- const footer = (
- <div className={styles.footer}>
- <button className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
- 取消
- </button>
- <button
- className={styles.submitBtn}
- onClick={handleSubmit}
- disabled={isSubmitting || entries.length === 0}
- >
- {isSubmitting ? "提交中..." : `提交反馈(已反馈 ${confirmedCount}/${entries.length} 条)`}
- </button>
- </div>
- );
- return (
- <Modal
- title={`知识使用反馈 — ${traceId}`}
- visible={visible}
- onCancel={onClose}
- footer={footer}
- width={720}
- style={{ maxHeight: "90vh" }}
- bodyStyle={{ overflowY: "auto", maxHeight: "calc(90vh - 120px)", padding: "16px 24px" }}
- maskClosable={false}
- >
- {isLoading ? (
- <div className={styles.loading}>加载中...</div>
- ) : entries.length === 0 ? (
- <div className={styles.empty}>本次 Trace 暂无知识注入记录</div>
- ) : (
- <>
- <div className={styles.hint}>
- 💡 提示:只需对有信心的知识做反馈,不确定的可以跳过(默认)
- </div>
- <div className={styles.entriesList}>
- {entries.map((entry) => {
- const fb = feedbackMap[entry.knowledge_id] ?? { action: "skip" };
- const isExpanded = expandedIds.has(entry.knowledge_id);
- const evalBadge = entry.eval_result?.eval_status
- ? EVAL_STATUS_BADGE[entry.eval_result.eval_status]
- : null;
- return (
- <div key={entry.knowledge_id} className={styles.entryCard}>
- {/* 卡片头部 */}
- <div className={styles.entryHeader}>
- <span className={styles.entryId} title={entry.knowledge_id}>
- {entry.knowledge_id.slice(0, 28)}…
- </span>
- <span
- className={`${styles.entryMeta} ${onNavigateToMessage ? styles.entryMetaLink : ""}`}
- title={onNavigateToMessage ? "点击跳转到该消息" : undefined}
- onClick={
- onNavigateToMessage
- ? () => {
- onNavigateToMessage(entry.goal_id, entry.injected_at_sequence);
- onClose();
- }
- : undefined
- }
- >
- <strong>Goal {entry.goal_id}</strong>
- {" · "}
- <strong>seq {entry.injected_at_sequence}</strong>
- </span>
- {evalBadge && (
- <span className={`${styles.badge} ${evalBadge.cls}`}>
- Agent: {evalBadge.label}
- </span>
- )}
- {!entry.eval_result && (
- <span className={`${styles.badge} ${styles.badgeNeutral}`}>未评估</span>
- )}
- </div>
- {/* 任务场景 */}
- <div className={styles.entryTask}>{entry.task}</div>
- {/* Agent 评估详情 */}
- <div className={styles.entryEval}>
- {entry.eval_result ? (
- <>
- {typeof entry.eval_result.score === "number" && (
- <span className={styles.evalScore}>
- 评分 {entry.eval_result.score}/5
- </span>
- )}
- {entry.eval_result.reason ? (
- <p className={styles.evalReasoning}>{entry.eval_result.reason}</p>
- ) : (
- <span className={styles.evalNoReasoning}>(无评估说明)</span>
- )}
- </>
- ) : (
- <span className={styles.evalNoReasoning}>Agent 暂未评估此知识</span>
- )}
- </div>
- {/* 知识内容(可展开) */}
- <div className={styles.entryContentWrapper}>
- <span
- className={styles.expandToggle}
- onClick={() => toggleExpand(entry.knowledge_id)}
- >
- {isExpanded ? "▲ 收起内容" : "▼ 展开内容"}
- </span>
- {isExpanded && (
- <pre className={styles.entryContent}>{entry.content}</pre>
- )}
- </div>
- {/* 用户反馈选择 */}
- <div className={styles.feedbackRow}>
- <span className={styles.feedbackLabel}>您的反馈:</span>
- <RadioGroup
- value={fb.action}
- onChange={(e: RadioChangeEvent) =>
- setFeedback(entry.knowledge_id, { action: e.target.value as FeedbackAction })
- }
- direction="horizontal"
- >
- <Radio value="skip">跳过(不确定)</Radio>
- <Radio value="confirm" disabled={!entry.eval_result}>
- 同意 Agent 评估
- </Radio>
- <Radio value="override">不同意,重新评估</Radio>
- </RadioGroup>
- </div>
- {/* 重新评估子表单 */}
- {fb.action === "override" && (
- <div className={styles.overrideForm}>
- <div className={styles.overrideRow}>
- <span className={styles.overrideLabel}>评估结果:</span>
- <Select<string>
- value={fb.eval_status}
- onChange={(value) => {
- if (typeof value === "string") {
- setFeedback(entry.knowledge_id, { eval_status: value });
- }
- }}
- optionList={EVAL_STATUS_OPTIONS}
- style={{ width: 220 }}
- placeholder="请选择评估结果"
- />
- </div>
- <div className={styles.overrideRow}>
- <span className={styles.overrideLabel}>说明(可选):</span>
- <textarea
- className={styles.feedbackTextarea}
- value={fb.feedback_text ?? ""}
- onChange={(e) =>
- setFeedback(entry.knowledge_id, { feedback_text: e.target.value })
- }
- placeholder="请输入反馈说明(可选)"
- rows={2}
- />
- </div>
- </div>
- )}
- </div>
- );
- })}
- </div>
- </>
- )}
- </Modal>
- );
- };
|