TopicTableQueryPanel.jsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. import { useMemo, useState } from 'react'
  2. import { generateTopicTableQueries, getTopicTableQueryPrompt } from '../../api/workbench.js'
  3. const EXAMPLE = {
  4. topicBuildId: '1229',
  5. topicId: '1392',
  6. }
  7. const ROUTES = [
  8. { key: 'unique_hook', inputTitle: '灵感点和特殊组合' },
  9. { key: 'goal_execution', inputTitle: '目标和想达到的效果' },
  10. { key: 'key_execution', inputTitle: '关键镜头和动作要求' },
  11. { key: 'cross_dimension', inputTitle: '实质、形式、意图三类元素' },
  12. { key: 'account_fit', inputTitle: '账号原来的内容风格' },
  13. { key: 'evidence_gap', inputTitle: '参考案例和元素来源' },
  14. ]
  15. function shortText(value, max = 120) {
  16. const text = String(value || '').trim()
  17. return text.length > max ? `${text.slice(0, max)}…` : text
  18. }
  19. function firstItems(values, max = 4) {
  20. return [...new Set((values || []).filter(Boolean).map((value) => shortText(value)))].slice(0, max)
  21. }
  22. function joinNames(values, max = 8) {
  23. const names = values || []
  24. if (names.length <= max) return names.join('、')
  25. return `${names.slice(0, max).join('、')}等`
  26. }
  27. function sourceItems(result, route) {
  28. const topic = result.topic || {}
  29. const groups = topic.input_groups || {}
  30. const points = groups.points || {}
  31. const dimensions = topic.dimensions || {}
  32. const direction = topic.topic_direction || {}
  33. const preferences = direction.persona_dimensions || {}
  34. if (route === 'unique_hook') {
  35. return firstItems(points['灵感点'] || [topic.result])
  36. }
  37. if (route === 'goal_execution') {
  38. return firstItems([
  39. ...(points['目的点'] || []),
  40. dimensions['意图']?.length ? `想达到的效果:${joinNames(dimensions['意图'])}` : '',
  41. ])
  42. }
  43. if (route === 'key_execution') {
  44. return firstItems([
  45. ...(points['关键点'] || []),
  46. dimensions['形式']?.length ? `拍摄形式:${joinNames(dimensions['形式'])}` : '',
  47. ])
  48. }
  49. if (route === 'cross_dimension') {
  50. return firstItems([
  51. dimensions['实质']?.length ? `内容里有什么:${joinNames(dimensions['实质'])}` : '',
  52. dimensions['形式']?.length ? `怎么呈现:${joinNames(dimensions['形式'])}` : '',
  53. dimensions['意图']?.length ? `想达到什么效果:${joinNames(dimensions['意图'])}` : '',
  54. ])
  55. }
  56. if (route === 'account_fit') {
  57. return firstItems([
  58. preferences['实质偏好'] ? `常拍内容:${preferences['实质偏好']}` : '',
  59. preferences['形式偏好'] ? `常用拍法:${preferences['形式偏好']}` : '',
  60. preferences['意图偏好'] ? `希望观众感受到:${preferences['意图偏好']}` : '',
  61. ], 3)
  62. }
  63. return firstItems((groups.reference_notes || []).map((row) => `${row.element}:${row.note}`), 3)
  64. }
  65. function PromptModal({ payload, loading, error, onClose }) {
  66. return (
  67. <div className="manual-modal-mask" onClick={onClose}>
  68. <section className="manual-modal-panel topic-prompt-modal" onClick={(event) => event.stopPropagation()}>
  69. <header className="manual-modal-head">
  70. <div>
  71. <span className="topic-eyebrow">选题表信息 → 大模型判断 → 搜索词</span>
  72. <h2>生成 Prompt</h2>
  73. <p>{payload ? `${payload.name} · ${payload.version}` : '读取当前线上使用的完整 Prompt'}</p>
  74. </div>
  75. <button className="manual-close" type="button" onClick={onClose} aria-label="关闭">×</button>
  76. </header>
  77. {loading && <div className="topic-inline-state">正在读取 Prompt…</div>}
  78. {error && <div className="manual-error">{error}</div>}
  79. {payload && (
  80. <>
  81. <div className="topic-route-strip">
  82. {(payload.routes || []).map((route) => (
  83. <span key={route.key}>{route.label}</span>
  84. ))}
  85. </div>
  86. <pre className="topic-prompt-code">{payload.system_prompt}</pre>
  87. </>
  88. )}
  89. </section>
  90. </div>
  91. )
  92. }
  93. function GenerationRows({ result }) {
  94. const rows = useMemo(() => ROUTES.map((route, index) => ({
  95. ...route,
  96. number: index + 1,
  97. need: (result.knowledge_needs || []).find((need) => need.route === route.key),
  98. queries: (result.queries || []).filter((query) => query.route === route.key),
  99. inputs: sourceItems(result, route.key),
  100. })), [result])
  101. return (
  102. <section className="topic-route-map">
  103. <div className="topic-route-map-head">
  104. <strong>选题表里用到的信息</strong>
  105. <strong>大模型判断什么</strong>
  106. <strong>生成哪些具体搜索词</strong>
  107. </div>
  108. <div className="topic-route-map-body">
  109. {rows.map((row) => (
  110. <article className={`topic-route-row route-${row.key}`} key={row.key}>
  111. <div className="topic-route-input">
  112. <span>第 {row.number} 种</span>
  113. <h3>{row.inputTitle}</h3>
  114. <ul>
  115. {row.inputs.map((item) => <li key={item}>{item}</li>)}
  116. </ul>
  117. </div>
  118. <div className="topic-route-judgement">
  119. <p>{row.need?.decision_context || '这类信息没有形成需要判断的问题'}</p>
  120. </div>
  121. <div className="topic-route-queries">
  122. {row.queries.map((query, index) => (
  123. <div key={`${query.query}-${index}`}>
  124. <b>{index + 1}</b>
  125. <p>{query.query}</p>
  126. </div>
  127. ))}
  128. </div>
  129. </article>
  130. ))}
  131. </div>
  132. </section>
  133. )
  134. }
  135. export default function TopicTableQueryPanel() {
  136. const [topicBuildId, setTopicBuildId] = useState(EXAMPLE.topicBuildId)
  137. const [topicId, setTopicId] = useState(EXAMPLE.topicId)
  138. const [result, setResult] = useState(null)
  139. const [loading, setLoading] = useState(false)
  140. const [error, setError] = useState('')
  141. const [promptOpen, setPromptOpen] = useState(false)
  142. const [prompt, setPrompt] = useState(null)
  143. const [promptLoading, setPromptLoading] = useState(false)
  144. const [promptError, setPromptError] = useState('')
  145. const showPrompt = async () => {
  146. setPromptOpen(true)
  147. if (prompt || promptLoading) return
  148. setPromptLoading(true)
  149. setPromptError('')
  150. try {
  151. setPrompt(await getTopicTableQueryPrompt())
  152. } catch (e) {
  153. setPromptError(e.message || 'Prompt 读取失败')
  154. } finally {
  155. setPromptLoading(false)
  156. }
  157. }
  158. const generate = async (event) => {
  159. event.preventDefault()
  160. setLoading(true)
  161. setError('')
  162. try {
  163. const payload = {
  164. topic_build_id: Number(topicBuildId),
  165. topic_id: topicId ? Number(topicId) : null,
  166. max_queries: 18,
  167. }
  168. setResult(await generateTopicTableQueries(payload))
  169. } catch (e) {
  170. setError(e.message || '搜索词生成失败')
  171. } finally {
  172. setLoading(false)
  173. }
  174. }
  175. return (
  176. <div className="topic-table-panel">
  177. <header className="topic-table-intro">
  178. <div>
  179. <span className="topic-eyebrow">第四种生成方式</span>
  180. <h2>根据选题表生成搜索词</h2>
  181. <p>每一类选题信息,都对应一项大模型判断和一组具体搜索词。</p>
  182. </div>
  183. <span className="topic-preview-only">这里只生成搜索词,不会开始搜索</span>
  184. </header>
  185. <form className="topic-table-form" onSubmit={generate}>
  186. <label>
  187. <span>选题表编号</span>
  188. <input value={topicBuildId} onChange={(e) => setTopicBuildId(e.target.value)} type="number" min="1" required />
  189. </label>
  190. <label>
  191. <span>选题编号</span>
  192. <input value={topicId} onChange={(e) => setTopicId(e.target.value)} type="number" min="1" placeholder="不填则取 mature 选题" />
  193. </label>
  194. <button className="topic-example-button" type="button" onClick={() => {
  195. setTopicBuildId(EXAMPLE.topicBuildId)
  196. setTopicId(EXAMPLE.topicId)
  197. setResult(null)
  198. setError('')
  199. }}>载入真实示例 1229</button>
  200. <button className="topic-prompt-button" type="button" onClick={showPrompt}>查看生成 Prompt</button>
  201. <button className="topic-generate-button" type="submit" disabled={loading}>
  202. {loading ? '大模型正在生成…' : '生成搜索词'}
  203. </button>
  204. </form>
  205. {error && <div className="manual-error topic-generation-error">{error}</div>}
  206. {!result && !loading && (
  207. <section className="topic-example-map">
  208. <div className="topic-example-cell">
  209. <span>第 1 种 · 灵感点和特殊组合</span>
  210. <p>樱花树下低角度拍小羊,用 CCD 记录彩虹光晕</p>
  211. </div>
  212. <div className="topic-example-cell">
  213. <span>大模型判断什么</span>
  214. <p>这个画面里的彩虹光晕,怎样才能稳定拍出来?</p>
  215. </div>
  216. <div className="topic-example-cell">
  217. <span>生成的搜索词</span>
  218. <ul>
  219. <li>逆光人像怎么拍出自然彩虹光晕</li>
  220. <li>低角度拍摄时太阳和镜头怎么配合</li>
  221. </ul>
  222. </div>
  223. </section>
  224. )}
  225. {loading && <div className="topic-loading-card"><span />正在读取选题表,并逐类生成搜索词…</div>}
  226. {result && <GenerationRows result={result} />}
  227. {promptOpen && (
  228. <PromptModal
  229. payload={prompt}
  230. loading={promptLoading}
  231. error={promptError}
  232. onClose={() => setPromptOpen(false)}
  233. />
  234. )}
  235. </div>
  236. )
  237. }