KnowledgeFeedbackModal.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. import { useEffect, useState } from "react";
  2. import type { FC } from "react";
  3. import { Modal, RadioGroup, Radio, Select, Toast } from "@douyinfe/semi-ui";
  4. import type { RadioChangeEvent } from "@douyinfe/semi-ui/lib/es/radio";
  5. import { traceApi } from "../../api/traceApi";
  6. import type { KnowledgeLogEntry, FeedbackAction, KnowledgeFeedbackState } from "../../types/goal";
  7. import type { KnowledgeFeedbackListItem } from "../../api/traceApi";
  8. import styles from "./KnowledgeFeedbackModal.module.css";
  9. interface KnowledgeFeedbackModalProps {
  10. visible: boolean;
  11. traceId: string;
  12. onClose: () => void;
  13. onSubmitted?: () => void;
  14. onNavigateToMessage?: (goalId: string, sequence: number) => void;
  15. }
  16. const EVAL_STATUS_OPTIONS = [
  17. { value: "helpful", label: "✅ helpful(有效)" },
  18. { value: "harmful", label: "🚫 harmful(有害)" },
  19. { value: "unused", label: "⬜ unused(未使用)" },
  20. { value: "irrelevant", label: "⚠️ irrelevant(无关)" },
  21. { value: "neutral", label: "➖ neutral(中性)" },
  22. ];
  23. const EVAL_STATUS_BADGE: Record<string, { label: string; cls: string }> = {
  24. helpful: { label: "helpful", cls: styles.badgeHelpful },
  25. harmful: { label: "harmful", cls: styles.badgeHarmful },
  26. unused: { label: "unused", cls: styles.badgeNeutral },
  27. irrelevant: { label: "irrelevant", cls: styles.badgeNeutral },
  28. neutral: { label: "neutral", cls: styles.badgeNeutral },
  29. };
  30. export const KnowledgeFeedbackModal: FC<KnowledgeFeedbackModalProps> = ({
  31. visible,
  32. traceId,
  33. onClose,
  34. onSubmitted,
  35. onNavigateToMessage,
  36. }) => {
  37. const [entries, setEntries] = useState<KnowledgeLogEntry[]>([]);
  38. const [feedbackMap, setFeedbackMap] = useState<Record<string, KnowledgeFeedbackState>>({});
  39. const [isLoading, setIsLoading] = useState(false);
  40. const [isSubmitting, setIsSubmitting] = useState(false);
  41. const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
  42. // 加载 knowledge_log,并去重(同一 knowledge_id 取最新注入)
  43. useEffect(() => {
  44. if (!visible || !traceId) return;
  45. setIsLoading(true);
  46. traceApi
  47. .fetchKnowledgeLog(traceId)
  48. .then((log) => {
  49. // 去重:同一 knowledge_id 保留最新注入的条目
  50. const seen = new Map<string, KnowledgeLogEntry>();
  51. for (const entry of log.entries) {
  52. const existing = seen.get(entry.knowledge_id);
  53. if (!existing || entry.injected_at_sequence > existing.injected_at_sequence) {
  54. seen.set(entry.knowledge_id, entry);
  55. }
  56. }
  57. const deduped = Array.from(seen.values());
  58. setEntries(deduped);
  59. // 默认所有条目选「跳过」
  60. const initial: Record<string, KnowledgeFeedbackState> = {};
  61. deduped.forEach((e) => {
  62. // 若已有用户反馈,恢复之前的选择
  63. if (e.user_feedback) {
  64. initial[e.knowledge_id] = {
  65. action: e.user_feedback.action as FeedbackAction,
  66. eval_status: e.user_feedback.eval_status,
  67. feedback_text: e.user_feedback.feedback_text,
  68. };
  69. } else {
  70. initial[e.knowledge_id] = { action: "skip" };
  71. }
  72. });
  73. setFeedbackMap(initial);
  74. })
  75. .catch(() => {
  76. Toast.error("加载知识日志失败");
  77. })
  78. .finally(() => setIsLoading(false));
  79. }, [visible, traceId]);
  80. const toggleExpand = (id: string) => {
  81. setExpandedIds((prev) => {
  82. const next = new Set(prev);
  83. if (next.has(id)) next.delete(id);
  84. else next.add(id);
  85. return next;
  86. });
  87. };
  88. const setFeedback = (id: string, patch: Partial<KnowledgeFeedbackState>) => {
  89. setFeedbackMap((prev) => ({
  90. ...prev,
  91. [id]: { ...prev[id], ...patch },
  92. }));
  93. };
  94. const confirmedCount = Object.values(feedbackMap).filter(
  95. (f) => f.action !== "skip"
  96. ).length;
  97. const handleSubmit = async () => {
  98. setIsSubmitting(true);
  99. const feedbackList: KnowledgeFeedbackListItem[] = entries
  100. .filter((e) => feedbackMap[e.knowledge_id]?.action !== "skip")
  101. .map((e) => ({
  102. knowledge_id: e.knowledge_id,
  103. action: feedbackMap[e.knowledge_id].action,
  104. eval_status: feedbackMap[e.knowledge_id].eval_status,
  105. feedback_text: feedbackMap[e.knowledge_id].feedback_text,
  106. source: {
  107. trace_id: traceId,
  108. goal_id: e.goal_id,
  109. sequence: e.injected_at_sequence,
  110. feedback_by: "user",
  111. feedback_at: new Date().toISOString(),
  112. },
  113. }));
  114. try {
  115. await traceApi.submitKnowledgeFeedback(traceId, { feedback_list: feedbackList });
  116. localStorage.setItem(`knowledge_feedback_submitted_${traceId}`, "true");
  117. Toast.success(`反馈提交成功,共反馈 ${feedbackList.length} 条知识`);
  118. onSubmitted?.();
  119. onClose();
  120. } catch {
  121. Toast.error("提交失败,请重试");
  122. } finally {
  123. setIsSubmitting(false);
  124. }
  125. };
  126. const footer = (
  127. <div className={styles.footer}>
  128. <button className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
  129. 取消
  130. </button>
  131. <button
  132. className={styles.submitBtn}
  133. onClick={handleSubmit}
  134. disabled={isSubmitting || entries.length === 0}
  135. >
  136. {isSubmitting ? "提交中..." : `提交反馈(已反馈 ${confirmedCount}/${entries.length} 条)`}
  137. </button>
  138. </div>
  139. );
  140. return (
  141. <Modal
  142. title={`知识使用反馈 — ${traceId}`}
  143. visible={visible}
  144. onCancel={onClose}
  145. footer={footer}
  146. width={720}
  147. style={{ maxHeight: "90vh" }}
  148. bodyStyle={{ overflowY: "auto", maxHeight: "calc(90vh - 120px)", padding: "16px 24px" }}
  149. maskClosable={false}
  150. >
  151. {isLoading ? (
  152. <div className={styles.loading}>加载中...</div>
  153. ) : entries.length === 0 ? (
  154. <div className={styles.empty}>本次 Trace 暂无知识注入记录</div>
  155. ) : (
  156. <>
  157. <div className={styles.hint}>
  158. 💡 提示:只需对有信心的知识做反馈,不确定的可以跳过(默认)
  159. </div>
  160. <div className={styles.entriesList}>
  161. {entries.map((entry) => {
  162. const fb = feedbackMap[entry.knowledge_id] ?? { action: "skip" };
  163. const isExpanded = expandedIds.has(entry.knowledge_id);
  164. const evalBadge = entry.eval_result?.eval_status
  165. ? EVAL_STATUS_BADGE[entry.eval_result.eval_status]
  166. : null;
  167. return (
  168. <div key={entry.knowledge_id} className={styles.entryCard}>
  169. {/* 卡片头部 */}
  170. <div className={styles.entryHeader}>
  171. <span className={styles.entryId} title={entry.knowledge_id}>
  172. {entry.knowledge_id.slice(0, 28)}…
  173. </span>
  174. <span
  175. className={`${styles.entryMeta} ${onNavigateToMessage ? styles.entryMetaLink : ""}`}
  176. title={onNavigateToMessage ? "点击跳转到该消息" : undefined}
  177. onClick={
  178. onNavigateToMessage
  179. ? () => {
  180. onNavigateToMessage(entry.goal_id, entry.injected_at_sequence);
  181. onClose();
  182. }
  183. : undefined
  184. }
  185. >
  186. <strong>Goal {entry.goal_id}</strong>
  187. {" · "}
  188. <strong>seq {entry.injected_at_sequence}</strong>
  189. </span>
  190. {evalBadge && (
  191. <span className={`${styles.badge} ${evalBadge.cls}`}>
  192. Agent: {evalBadge.label}
  193. </span>
  194. )}
  195. {!entry.eval_result && (
  196. <span className={`${styles.badge} ${styles.badgeNeutral}`}>未评估</span>
  197. )}
  198. </div>
  199. {/* 任务场景 */}
  200. <div className={styles.entryTask}>{entry.task}</div>
  201. {/* Agent 评估详情 */}
  202. <div className={styles.entryEval}>
  203. {entry.eval_result ? (
  204. <>
  205. {typeof entry.eval_result.score === "number" && (
  206. <span className={styles.evalScore}>
  207. 评分 {entry.eval_result.score}/5
  208. </span>
  209. )}
  210. {entry.eval_result.reason ? (
  211. <p className={styles.evalReasoning}>{entry.eval_result.reason}</p>
  212. ) : (
  213. <span className={styles.evalNoReasoning}>(无评估说明)</span>
  214. )}
  215. </>
  216. ) : (
  217. <span className={styles.evalNoReasoning}>Agent 暂未评估此知识</span>
  218. )}
  219. </div>
  220. {/* 知识内容(可展开) */}
  221. <div className={styles.entryContentWrapper}>
  222. <span
  223. className={styles.expandToggle}
  224. onClick={() => toggleExpand(entry.knowledge_id)}
  225. >
  226. {isExpanded ? "▲ 收起内容" : "▼ 展开内容"}
  227. </span>
  228. {isExpanded && (
  229. <pre className={styles.entryContent}>{entry.content}</pre>
  230. )}
  231. </div>
  232. {/* 用户反馈选择 */}
  233. <div className={styles.feedbackRow}>
  234. <span className={styles.feedbackLabel}>您的反馈:</span>
  235. <RadioGroup
  236. value={fb.action}
  237. onChange={(e: RadioChangeEvent) =>
  238. setFeedback(entry.knowledge_id, { action: e.target.value as FeedbackAction })
  239. }
  240. direction="horizontal"
  241. >
  242. <Radio value="skip">跳过(不确定)</Radio>
  243. <Radio value="confirm" disabled={!entry.eval_result}>
  244. 同意 Agent 评估
  245. </Radio>
  246. <Radio value="override">不同意,重新评估</Radio>
  247. </RadioGroup>
  248. </div>
  249. {/* 重新评估子表单 */}
  250. {fb.action === "override" && (
  251. <div className={styles.overrideForm}>
  252. <div className={styles.overrideRow}>
  253. <span className={styles.overrideLabel}>评估结果:</span>
  254. <Select<string>
  255. value={fb.eval_status}
  256. onChange={(value) => {
  257. if (typeof value === "string") {
  258. setFeedback(entry.knowledge_id, { eval_status: value });
  259. }
  260. }}
  261. optionList={EVAL_STATUS_OPTIONS}
  262. style={{ width: 220 }}
  263. placeholder="请选择评估结果"
  264. />
  265. </div>
  266. <div className={styles.overrideRow}>
  267. <span className={styles.overrideLabel}>说明(可选):</span>
  268. <textarea
  269. className={styles.feedbackTextarea}
  270. value={fb.feedback_text ?? ""}
  271. onChange={(e) =>
  272. setFeedback(entry.knowledge_id, { feedback_text: e.target.value })
  273. }
  274. placeholder="请输入反馈说明(可选)"
  275. rows={2}
  276. />
  277. </div>
  278. </div>
  279. )}
  280. </div>
  281. );
  282. })}
  283. </div>
  284. </>
  285. )}
  286. </Modal>
  287. );
  288. };