RecallTestPage.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  1. import { useCallback, useEffect, useRef, useState } from 'react'
  2. import {
  3. Tabs,
  4. Card,
  5. Input,
  6. InputNumber,
  7. Select,
  8. Button,
  9. Space,
  10. message,
  11. Empty,
  12. Typography,
  13. Row,
  14. Col,
  15. Tag,
  16. Tooltip,
  17. } from 'antd'
  18. import {
  19. PlayCircleOutlined,
  20. FileTextOutlined,
  21. PictureOutlined,
  22. ReadOutlined,
  23. SearchOutlined,
  24. ApartmentOutlined,
  25. BulbOutlined,
  26. VideoCameraOutlined,
  27. } from '@ant-design/icons'
  28. import RecallResultList from '../components/RecallResultList'
  29. import VideoPlayer from '../components/VideoPlayer'
  30. import DeconstructTree from '../components/DeconstructTree'
  31. import AIUnderstandingPanel from '../components/AIUnderstandingPanel'
  32. import { toHttps } from '../utils/url'
  33. import {
  34. getAiUnderstanding,
  35. getDeconstructPoints,
  36. getVideoDetail,
  37. matchByText,
  38. } from '../api/recall'
  39. import {
  40. ALL_CONFIG_CODE,
  41. buildGroupedConfigOptions,
  42. getConfigDisplayLabel,
  43. listAllConfigCodes,
  44. useConfigCodes,
  45. } from '../api/configCodes'
  46. import type {
  47. AIUnderstandingVO,
  48. DeconstructPointsVO,
  49. RecallResultVO,
  50. VideoBasicVO,
  51. VideoMatchEnrichedVO,
  52. } from '../api/types'
  53. const { TextArea } = Input
  54. const { Text, Title } = Typography
  55. /** 从 URL ?videoId=xxx 读取并校验,无效返回 null */
  56. function readUrlVideoId(): number | null {
  57. const raw = new URLSearchParams(window.location.search).get('videoId')
  58. if (!raw) return null
  59. const n = Number(raw)
  60. return Number.isFinite(n) && n > 0 ? n : null
  61. }
  62. /** 从 URL ?queryText=xxx 读取,空值返 null */
  63. function readUrlQueryText(): string | null {
  64. const raw = new URLSearchParams(window.location.search).get('queryText')
  65. if (!raw) return null
  66. const trimmed = raw.trim()
  67. return trimmed.length > 0 ? trimmed : null
  68. }
  69. /** 从 URL ?configCode=xxx 读取,后端字典动态加载,这里只做形态校验(非空+大写) */
  70. function readUrlConfigCode(): string | null {
  71. const raw = new URLSearchParams(window.location.search).get('configCode')
  72. if (!raw) return null
  73. const upper = raw.trim().toUpperCase()
  74. return upper.length > 0 ? upper : null
  75. }
  76. /**
  77. * Tab1 "全部维度召回" — configCode → 该维度可发起的召回文本数组
  78. *
  79. * VIDEO_TITLE: 视频原标题 (detail.title)
  80. * VIDEO_TOPIC: 解构 topic 字段 (单条)
  81. * VIDEO_INSPIRATION / VIDEO_KEYPOINT / VIDEO_PURPOSE: 解构 highValuePoints 按 type 过滤后的 name 列表 (多条)
  82. * RESULT_LOG_TOPIC/THEME/KEYWORDS/NARRATION: AI 内容理解 4 个字段 (AI 数据未就绪时返回空, 自动跳过)
  83. * 其他: 暂无映射, 跳过
  84. */
  85. function textsForConfigCode(
  86. code: string,
  87. detail: VideoBasicVO | null,
  88. points: DeconstructPointsVO | null,
  89. ai: AIUnderstandingVO | null,
  90. ): string[] {
  91. const single = (s: string | null | undefined) => {
  92. const t = s?.trim()
  93. return t ? [t] : []
  94. }
  95. if (code === 'VIDEO_TITLE') return single(detail?.title)
  96. if (code === 'VIDEO_TOPIC') return single(points?.topic)
  97. if (code === 'VIDEO_INSPIRATION' || code === 'VIDEO_KEYPOINT' || code === 'VIDEO_PURPOSE') {
  98. const target =
  99. code === 'VIDEO_INSPIRATION' ? '灵感点' : code === 'VIDEO_KEYPOINT' ? '关键点' : '目的点'
  100. return (points?.highValuePoints ?? [])
  101. .filter((p) => p.type === target)
  102. .map((p) => p.name?.trim())
  103. .filter((n): n is string => !!n)
  104. }
  105. if (code === 'RESULT_LOG_TOPIC') return single(ai?.contentTopic)
  106. if (code === 'RESULT_LOG_THEME') return single(ai?.videoTheme)
  107. if (code === 'RESULT_LOG_KEYWORDS') return single(ai?.videoKeywords)
  108. if (code === 'RESULT_LOG_NARRATION') return single(ai?.videoNarration)
  109. return []
  110. }
  111. /** 从召回结果里剔除指定视频ID, 同时刷新 modality 计数 */
  112. function filterOutSelf(data: RecallResultVO, excludeId: number | null): RecallResultVO {
  113. if (!excludeId) return data
  114. const items = data.items.filter((x) => x.id !== excludeId)
  115. return {
  116. items,
  117. videoCount: items.filter((x) => x.modality === 'VIDEO').length,
  118. materialCount: items.filter((x) => x.modality === 'MATERIAL').length,
  119. articleCount: items.filter((x) => x.modality === 'ARTICLE').length,
  120. total: items.length,
  121. }
  122. }
  123. export default function RecallTestPage() {
  124. // URL 上有 queryText → 默认进入 Tab2 (优先级高于 videoId,与 Grafana 跳转用例一致)
  125. // 用 defaultActiveKey 而非受控 activeKey,保留用户后续手动切 Tab 的自由
  126. const initialTabKey = readUrlQueryText() ? 'text' : 'video'
  127. return (
  128. <Tabs
  129. className="recall-main-tabs"
  130. defaultActiveKey={initialTabKey}
  131. size="large"
  132. items={[
  133. {
  134. key: 'video',
  135. label: (
  136. <span>
  137. <PlayCircleOutlined /> 视频相似度召回
  138. </span>
  139. ),
  140. children: <VideoIdTab />,
  141. },
  142. {
  143. key: 'text',
  144. label: (
  145. <span>
  146. <FileTextOutlined /> 自定义文本相似度召回
  147. </span>
  148. ),
  149. children: <TextRecallTab />,
  150. },
  151. {
  152. key: 'material',
  153. label: (
  154. <span>
  155. <PictureOutlined /> 素材解构 <Tag color="default" style={{ marginLeft: 4 }}>开发中</Tag>
  156. </span>
  157. ),
  158. disabled: true,
  159. children: null,
  160. },
  161. {
  162. key: 'article',
  163. label: (
  164. <span>
  165. <ReadOutlined /> 长文相似度召回 <Tag color="default" style={{ marginLeft: 4 }}>开发中</Tag>
  166. </span>
  167. ),
  168. disabled: true,
  169. children: null,
  170. },
  171. ]}
  172. />
  173. )
  174. }
  175. // ============================================================================
  176. // Tab1: 票圈视频ID — 三栏并列布局(视频详情 / AI理解 / 解构层级 都是召回维度)
  177. // ============================================================================
  178. /** 进入页面默认查询的视频ID, 避免空白态 */
  179. const DEFAULT_VIDEO_ID = 64632804
  180. function VideoIdTab() {
  181. /** URL 上 ?videoId=xxx 优先于默认 ID;有则首次查询后自动按选题召回 */
  182. const urlVideoId = readUrlVideoId()
  183. const [videoId, setVideoId] = useState<number | null>(urlVideoId ?? DEFAULT_VIDEO_ID)
  184. const pendingAutoRecallRef = useRef(urlVideoId !== null)
  185. const [detail, setDetail] = useState<VideoBasicVO | null>(null)
  186. const [ai, setAi] = useState<AIUnderstandingVO | null>(null)
  187. const [points, setPoints] = useState<DeconstructPointsVO | null>(null)
  188. const [loadingMain, setLoadingMain] = useState(false)
  189. const [queried, setQueried] = useState(false)
  190. const configCodes = useConfigCodes()
  191. /** 视频Tab 不再有顶部维度选择, 维度由解构层级里每条点的"以此召回"按钮直接传入 */
  192. const [topN, setTopN] = useState<number>(10)
  193. const [recall, setRecall] = useState<RecallResultVO | null>(null)
  194. const [loadingRecall, setLoadingRecall] = useState(false)
  195. /** 召回标题: 拆成"维度Tag(高亮)" + "文本描述" */
  196. const [recallMeta, setRecallMeta] = useState<{
  197. dimensionLabel: string
  198. dimensionCode: string
  199. description: string
  200. } | null>(null)
  201. const runQuery = useCallback(async (id: number) => {
  202. setLoadingMain(true)
  203. setQueried(true)
  204. setRecall(null)
  205. try {
  206. const [d, a, p] = await Promise.all([
  207. getVideoDetail(id).catch(() => null),
  208. getAiUnderstanding(id).catch(() => null),
  209. getDeconstructPoints(id).catch(() => null),
  210. ])
  211. setDetail(d)
  212. setAi(a)
  213. setPoints(p)
  214. if (!d) message.warning('未查询到视频详情')
  215. } finally {
  216. setLoadingMain(false)
  217. }
  218. }, [])
  219. const onQuery = () => {
  220. if (!videoId || videoId <= 0) {
  221. message.warning('请输入有效的视频ID')
  222. return
  223. }
  224. runQuery(videoId)
  225. }
  226. /** 进入页面自动用默认ID查一次, 避免空白态 */
  227. const autoQueriedRef = useRef(false)
  228. useEffect(() => {
  229. if (autoQueriedRef.current) return
  230. autoQueriedRef.current = true
  231. if (videoId && videoId > 0) {
  232. runQuery(videoId)
  233. }
  234. }, [runQuery, videoId])
  235. /**
  236. * 召回请求代际计数 — 每次发起召回自增, 只有最新一代回包才更新 state
  237. * 防止"先点全部维度(并发 N 个), 再点单节点召回, 全部那批晚到的回包覆盖单节点结果"
  238. */
  239. const submitGenRef = useRef(0)
  240. const onRecallByText = useCallback(
  241. async (text: string, configCodeOverride?: string) => {
  242. const trimmed = text.trim()
  243. if (!trimmed) return
  244. const finalConfigCode = configCodeOverride || 'VIDEO_TOPIC'
  245. const myGen = ++submitGenRef.current
  246. const isStale = () => myGen !== submitGenRef.current
  247. setLoadingRecall(true)
  248. const preview = trimmed.length > 24 ? trimmed.slice(0, 24) + '…' : trimmed
  249. const codeLabel = getConfigDisplayLabel(finalConfigCode, configCodes)
  250. setRecallMeta({
  251. dimensionLabel: codeLabel,
  252. dimensionCode: finalConfigCode,
  253. description: `基于文本 "${preview}"`,
  254. })
  255. try {
  256. const data = await matchByText({ queryText: trimmed, configCode: finalConfigCode, topN })
  257. if (isStale()) return
  258. setRecall(filterOutSelf(data, videoId))
  259. message.success(`召回 ${data.total} 条`)
  260. } catch {
  261. if (!isStale()) message.error('召回失败')
  262. } finally {
  263. if (!isStale()) setLoadingRecall(false)
  264. }
  265. },
  266. [topN, videoId, configCodes],
  267. )
  268. /**
  269. * 全部维度召回 — 遍历后端字典里的每个 configCode, 各自从解构/AI 字段取文本,
  270. * Promise.allSettled 并发, 合并后按 (modality, id, configCode) 去重保留最高 score
  271. */
  272. const onRecallAllDims = useCallback(async () => {
  273. const allCodes = listAllConfigCodes(configCodes)
  274. if (allCodes.length === 0) {
  275. message.warning('维度字典尚未加载完成, 请稍后再试')
  276. return
  277. }
  278. const calls: { configCode: string; text: string }[] = []
  279. for (const code of allCodes) {
  280. for (const text of textsForConfigCode(code, detail, points, ai)) {
  281. calls.push({ configCode: code, text })
  282. }
  283. }
  284. if (calls.length === 0) {
  285. message.warning('当前视频无可用解构内容, 无法发起全部维度召回')
  286. return
  287. }
  288. const myGen = ++submitGenRef.current
  289. const isStale = () => myGen !== submitGenRef.current
  290. setLoadingRecall(true)
  291. setRecallMeta({
  292. dimensionLabel: `全部(${allCodes.length} 个维度)`,
  293. dimensionCode: ALL_CONFIG_CODE,
  294. description: `基于视频解构所有可用节点, 共 ${calls.length} 次召回`,
  295. })
  296. try {
  297. const settled = await Promise.allSettled(
  298. calls.map((c) => matchByText({ queryText: c.text, configCode: c.configCode, topN })),
  299. )
  300. if (isStale()) return
  301. // 合并 + 去重 (modality, id, configCode) 保留最高 score
  302. const dedup = new Map<string, VideoMatchEnrichedVO>()
  303. let failedCount = 0
  304. settled.forEach((s) => {
  305. if (s.status === 'fulfilled') {
  306. for (const it of s.value.items) {
  307. const key = `${it.modality}-${it.id}-${it.configCode ?? ''}`
  308. const prev = dedup.get(key)
  309. if (!prev || (it.score ?? -Infinity) > (prev.score ?? -Infinity)) {
  310. dedup.set(key, it)
  311. }
  312. }
  313. } else {
  314. failedCount++
  315. }
  316. })
  317. const items = Array.from(dedup.values()).sort(
  318. (a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity),
  319. )
  320. const merged: RecallResultVO = {
  321. items,
  322. videoCount: items.filter((x) => x.modality === 'VIDEO').length,
  323. materialCount: items.filter((x) => x.modality === 'MATERIAL').length,
  324. articleCount: items.filter((x) => x.modality === 'ARTICLE').length,
  325. total: items.length,
  326. }
  327. setRecall(filterOutSelf(merged, videoId))
  328. if (failedCount > 0) {
  329. message.warning(
  330. `${failedCount}/${calls.length} 次召回失败, 已展示其余结果, 共 ${merged.total} 条`,
  331. )
  332. } else {
  333. message.success(`全部维度召回完成, 共 ${merged.total} 条`)
  334. }
  335. } catch {
  336. if (!isStale()) message.error('召回失败')
  337. } finally {
  338. if (!isStale()) setLoadingRecall(false)
  339. }
  340. }, [detail, points, ai, configCodes, topN, videoId])
  341. /** URL 传入 videoId 时, 首次查询结束后自动按"选题"维度召回 (0 点击) */
  342. useEffect(() => {
  343. if (!pendingAutoRecallRef.current) return
  344. if (!autoQueriedRef.current) return
  345. if (loadingMain) return
  346. pendingAutoRecallRef.current = false
  347. if (points?.topic && points.topic.trim()) {
  348. onRecallByText(points.topic, 'VIDEO_TOPIC')
  349. }
  350. }, [loadingMain, points, onRecallByText])
  351. return (
  352. <Space direction="vertical" size={16} style={{ width: '100%' }}>
  353. {/* 查询条 */}
  354. <Card size="small" bodyStyle={{ padding: '12px 16px' }}>
  355. <Space wrap size={[12, 8]}>
  356. <Text strong>视频ID</Text>
  357. <InputNumber
  358. placeholder="请输入"
  359. min={1}
  360. value={videoId}
  361. onChange={(v) => setVideoId(v)}
  362. style={{ width: 200 }}
  363. onPressEnter={onQuery}
  364. controls={false}
  365. />
  366. <Button type="primary" icon={<SearchOutlined />} loading={loadingMain} onClick={onQuery}>
  367. 查询
  368. </Button>
  369. <span style={{ color: '#d9d9d9' }}>|</span>
  370. <Text strong>TopN</Text>
  371. <InputNumber min={1} max={20} value={topN} onChange={(v) => setTopN(v ?? 10)} style={{ width: 80 }} />
  372. <Tooltip title="遍历所有维度, 各自从解构/AI 取文本, 合并去重">
  373. <Button
  374. type="primary"
  375. icon={<SearchOutlined />}
  376. loading={loadingRecall}
  377. disabled={loadingMain || !points}
  378. onClick={onRecallAllDims}
  379. >
  380. 全部维度召回
  381. </Button>
  382. </Tooltip>
  383. <Text type="secondary" style={{ fontSize: 12 }}>
  384. 或在下方解构层级点单条"以此召回"
  385. </Text>
  386. </Space>
  387. </Card>
  388. {queried && (
  389. <>
  390. {/* 三栏并列(顺序: 选题解构 / AI识别(老) / 视频详情) */}
  391. <Row gutter={[16, 16]}>
  392. {/* 选题解构 (放第一位) */}
  393. <Col xs={24} md={8}>
  394. <Card
  395. size="small"
  396. title={
  397. <Space size={8}>
  398. <ApartmentOutlined style={{ color: '#52c41a', fontSize: 18 }} />
  399. <span style={{ fontSize: 17, fontWeight: 700 }}>选题解构</span>
  400. <Tag color="green-inverse" style={{ marginLeft: 4 }}>召回维度</Tag>
  401. </Space>
  402. }
  403. loading={loadingMain}
  404. style={{ height: '100%' }}
  405. bodyStyle={{ maxHeight: 560, overflowY: 'auto' }}
  406. >
  407. <DeconstructTree data={points} loading={loadingMain} onRecallByText={onRecallByText} />
  408. </Card>
  409. </Col>
  410. {/* 视频内容理解 - 放第二位 */}
  411. <Col xs={24} md={8}>
  412. <Card
  413. size="small"
  414. title={
  415. <Space size={8}>
  416. <BulbOutlined style={{ color: '#fa8c16', fontSize: 18 }} />
  417. <span style={{ fontSize: 17, fontWeight: 700 }}>视频内容理解</span>
  418. <Tag color="orange-inverse" style={{ marginLeft: 4 }}>召回维度</Tag>
  419. </Space>
  420. }
  421. loading={loadingMain}
  422. style={{ height: '100%' }}
  423. >
  424. <AIUnderstandingPanel data={ai} loading={loadingMain} />
  425. </Card>
  426. </Col>
  427. {/* 视频详情 + 播放器 - 放第三位 */}
  428. <Col xs={24} md={8}>
  429. <Card
  430. size="small"
  431. title={
  432. <Space size={8}>
  433. <VideoCameraOutlined style={{ color: '#1677ff', fontSize: 18 }} />
  434. <span style={{ fontSize: 17, fontWeight: 700 }}>视频详情</span>
  435. </Space>
  436. }
  437. loading={loadingMain}
  438. style={{ height: '100%' }}
  439. >
  440. {detail ? (
  441. <Space direction="vertical" size={10} style={{ width: '100%' }}>
  442. <DetailRow label="ID" value={String(detail.videoId)} />
  443. <DetailRow label="标题" value={detail.title ?? '-'} />
  444. <DetailRow
  445. label="播放量"
  446. value={
  447. <Text type={detail.playCount === '--' ? 'secondary' : undefined}>
  448. {detail.playCount}
  449. </Text>
  450. }
  451. hint={detail.playCount === '--' ? '字段缺失,占位' : undefined}
  452. />
  453. <div style={{ marginTop: 4 }}>
  454. {detail.videoUrl ? (
  455. <VideoPlayer src={toHttps(detail.videoUrl)} poster={toHttps(detail.cover) || undefined} />
  456. ) : (
  457. <Empty description="无视频地址" image={Empty.PRESENTED_IMAGE_SIMPLE} />
  458. )}
  459. </div>
  460. </Space>
  461. ) : (
  462. <Empty description="未查询到视频" image={Empty.PRESENTED_IMAGE_SIMPLE} />
  463. )}
  464. </Card>
  465. </Col>
  466. </Row>
  467. {/* 召回结果 */}
  468. <Card
  469. size="small"
  470. title={<RecallTitle meta={recallMeta} />}
  471. >
  472. <RecallResultList result={recall} loading={loadingRecall} />
  473. </Card>
  474. </>
  475. )}
  476. </Space>
  477. )
  478. }
  479. function DetailRow({
  480. label,
  481. value,
  482. hint,
  483. }: {
  484. label: string
  485. value: React.ReactNode
  486. hint?: string
  487. }) {
  488. return (
  489. <div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
  490. <Text type="secondary" style={{ minWidth: 56, fontSize: 12 }}>
  491. {label}
  492. </Text>
  493. <div style={{ flex: 1, minWidth: 0, fontSize: 13 }}>
  494. {value}
  495. {hint && (
  496. <Text type="secondary" style={{ marginLeft: 6, fontSize: 11 }}>
  497. ({hint})
  498. </Text>
  499. )}
  500. </div>
  501. </div>
  502. )
  503. }
  504. /** 召回结果卡片标题: "召回维度:[中文标签]" + 描述 */
  505. function RecallTitle({
  506. meta,
  507. }: {
  508. meta: { dimensionLabel: string; dimensionCode: string; description: string } | null
  509. }) {
  510. if (!meta) {
  511. return (
  512. <Space size={6}>
  513. <SearchOutlined style={{ color: '#722ed1' }} />
  514. <span>召回结果</span>
  515. </Space>
  516. )
  517. }
  518. // 按 configCode 前缀走两套配色
  519. const palette = meta.dimensionCode.startsWith('RESULT_LOG_')
  520. ? { bg: '#fff7e6', border: '#ffd591', text: '#d46b08' }
  521. : meta.dimensionCode === 'VIDEO_TOPIC'
  522. ? { bg: '#f9f0ff', border: '#d3adf7', text: '#531dab' }
  523. : meta.dimensionCode === 'VIDEO_INSPIRATION'
  524. ? { bg: '#e6f4ff', border: '#91caff', text: '#0958d9' }
  525. : { bg: '#f6ffed', border: '#b7eb8f', text: '#389e0d' }
  526. return (
  527. <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
  528. <SearchOutlined style={{ color: '#722ed1', fontSize: 16 }} />
  529. <span style={{ fontSize: 14, color: 'rgba(0,0,0,0.65)' }}>召回维度:</span>
  530. <span
  531. style={{
  532. background: palette.bg,
  533. border: `1px solid ${palette.border}`,
  534. color: palette.text,
  535. fontSize: 16,
  536. fontWeight: 700,
  537. padding: '4px 12px',
  538. borderRadius: 6,
  539. letterSpacing: 0.5,
  540. }}
  541. >
  542. {meta.dimensionLabel}
  543. </span>
  544. <span style={{ color: 'rgba(0,0,0,0.65)', fontSize: 13 }}>· {meta.description}</span>
  545. </div>
  546. )
  547. }
  548. // ============================================================================
  549. // Tab2: 文本输入 → 文本召回
  550. // ============================================================================
  551. function TextRecallTab() {
  552. /** URL ?queryText=xxx&configCode=yyy 落地参数,缺省走原有空白态 */
  553. const urlQueryText = readUrlQueryText()
  554. const urlConfigCode = readUrlConfigCode()
  555. const [queryText, setQueryText] = useState(urlQueryText ?? '')
  556. /** 默认 "全部"; URL 上有 configCode 时优先走 URL 指定 */
  557. const [configCode, setConfigCode] = useState(urlConfigCode ?? ALL_CONFIG_CODE)
  558. const [topN, setTopN] = useState<number>(10)
  559. const [result, setResult] = useState<RecallResultVO | null>(null)
  560. const [loading, setLoading] = useState(false)
  561. const configCodes = useConfigCodes()
  562. const groupedOptions = buildGroupedConfigOptions(configCodes)
  563. /** 召回结果标题展示的维度 meta — 锁定提交瞬间的值, 避免提交后改下拉影响展示 */
  564. const [resultMeta, setResultMeta] = useState<{
  565. dimensionLabel: string
  566. dimensionCode: string
  567. description: string
  568. } | null>(null)
  569. /**
  570. * 召回请求代际计数 — 每次 onSubmit 自增, 异步返回时只有最新代的回包才更新 state
  571. * 防止"先点全部(并发 N 个), 再切单维度点召回, 全部那批晚到的回包覆盖单维度结果"
  572. */
  573. const submitGenRef = useRef(0)
  574. const onSubmit = useCallback(async () => {
  575. const trimmed = queryText.trim()
  576. if (!trimmed) {
  577. message.warning('请输入查询文本')
  578. return
  579. }
  580. const myGen = ++submitGenRef.current
  581. const isStale = () => myGen !== submitGenRef.current
  582. setLoading(true)
  583. const preview = trimmed.length > 24 ? trimmed.slice(0, 24) + '…' : trimmed
  584. if (configCode === ALL_CONFIG_CODE) {
  585. // 并发调所有维度, allSettled 等齐再展示
  586. const allCodes = listAllConfigCodes(configCodes)
  587. if (allCodes.length === 0) {
  588. message.warning('维度字典尚未加载完成, 请稍后再试')
  589. setLoading(false)
  590. return
  591. }
  592. setResultMeta({
  593. dimensionLabel: `全部(${allCodes.length} 个维度)`,
  594. dimensionCode: ALL_CONFIG_CODE,
  595. description: `基于文本 "${preview}"`,
  596. })
  597. try {
  598. const settled = await Promise.allSettled(
  599. allCodes.map((code) => matchByText({ queryText: trimmed, configCode: code, topN })),
  600. )
  601. if (isStale()) return // 已被新一轮 submit 抢占
  602. const failedDims: string[] = []
  603. const merged: RecallResultVO = {
  604. items: [],
  605. videoCount: 0,
  606. materialCount: 0,
  607. articleCount: 0,
  608. total: 0,
  609. }
  610. settled.forEach((s, i) => {
  611. if (s.status === 'fulfilled') {
  612. merged.items.push(...s.value.items)
  613. } else {
  614. failedDims.push(getConfigDisplayLabel(allCodes[i], configCodes))
  615. }
  616. })
  617. // 按 score 倒序; null/undefined 视为 -Infinity
  618. merged.items.sort((a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity))
  619. merged.videoCount = merged.items.filter((x) => x.modality === 'VIDEO').length
  620. merged.materialCount = merged.items.filter((x) => x.modality === 'MATERIAL').length
  621. merged.articleCount = merged.items.filter((x) => x.modality === 'ARTICLE').length
  622. merged.total = merged.items.length
  623. setResult(merged)
  624. if (failedDims.length > 0) {
  625. message.warning(`部分维度召回失败: ${failedDims.join(', ')} — 已展示其余维度结果`)
  626. } else {
  627. message.success(`全部维度召回完成, 共 ${merged.total} 条`)
  628. }
  629. } catch {
  630. if (!isStale()) message.error('召回失败')
  631. } finally {
  632. if (!isStale()) setLoading(false)
  633. }
  634. return
  635. }
  636. // 单维度, 原有逻辑
  637. setResultMeta({
  638. dimensionLabel: getConfigDisplayLabel(configCode, configCodes),
  639. dimensionCode: configCode,
  640. description: `基于文本 "${preview}"`,
  641. })
  642. try {
  643. const data = await matchByText({ queryText: trimmed, configCode, topN })
  644. if (isStale()) return
  645. setResult(data)
  646. message.success(`召回 ${data.total} 条`)
  647. } catch {
  648. if (!isStale()) message.error('召回失败')
  649. } finally {
  650. if (!isStale()) setLoading(false)
  651. }
  652. }, [queryText, configCode, topN, configCodes])
  653. /** URL 上有 queryText 时, 挂载后自动触发一次召回 (Grafana 跳转 0 点击) */
  654. const autoSearchedRef = useRef(false)
  655. useEffect(() => {
  656. if (autoSearchedRef.current) return
  657. if (!urlQueryText) return
  658. autoSearchedRef.current = true
  659. onSubmit()
  660. }, [urlQueryText, onSubmit])
  661. return (
  662. <Space direction="vertical" size={16} style={{ width: '100%' }}>
  663. <Card size="small" bodyStyle={{ padding: 16 }}>
  664. <Space direction="vertical" size={12} style={{ width: '100%' }}>
  665. <TextArea
  666. placeholder="请输入查询文本(选题、灵感点描述等)"
  667. value={queryText}
  668. onChange={(e) => setQueryText(e.target.value)}
  669. rows={4}
  670. allowClear
  671. />
  672. <Space wrap>
  673. <Text strong>召回维度</Text>
  674. <Select value={configCode} onChange={setConfigCode} options={groupedOptions} style={{ width: 240 }} />
  675. <Text strong>TopN</Text>
  676. <InputNumber min={1} max={20} value={topN} onChange={(v) => setTopN(v ?? 10)} style={{ width: 80 }} />
  677. <Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={onSubmit}>
  678. 召回
  679. </Button>
  680. </Space>
  681. </Space>
  682. </Card>
  683. <Card size="small" title={<RecallTitle meta={resultMeta} />}>
  684. <RecallResultList result={result} loading={loading} />
  685. </Card>
  686. </Space>
  687. )
  688. }