app.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. """StockInsightAgent — Gradio 前端"""
  2. import threading
  3. import queue
  4. import gradio as gr
  5. from framework_agent import FrameworkStockAgent
  6. from memory import (
  7. memory_get_watchlist, memory_add_watchlist, memory_remove_watchlist,
  8. memory_get_history, memory_get_preferences,
  9. )
  10. from rag import rag_import, rag_stats
  11. _agent = None
  12. def get_agent():
  13. global _agent
  14. if _agent is None:
  15. _agent = FrameworkStockAgent()
  16. return _agent
  17. def _run_with_capture(q: queue.Queue, agent, mode: str, msg: str):
  18. import io, sys
  19. buffer = io.StringIO()
  20. class QueueWriter:
  21. def write(self, s):
  22. buffer.write(s)
  23. q.put(s)
  24. def flush(self):
  25. pass
  26. old = sys.stdout
  27. sys.stdout = QueueWriter()
  28. try:
  29. if mode == "深度分析 (PlanSolve)":
  30. result = agent.plan_solve(msg)
  31. elif mode == "批判分析 (Reflection)":
  32. result = agent.reflect(msg)
  33. else:
  34. result = agent.react(msg)
  35. except Exception as e:
  36. result = f"分析出错: {e}"
  37. finally:
  38. sys.stdout = old
  39. q.put(None)
  40. q.result = result or ""
  41. def respond_stream(message: str, history: list, mode: str):
  42. if not message or not message.strip():
  43. yield history, ""
  44. return
  45. msg = message.strip()
  46. history = history or []
  47. # ── 快捷命令 ──
  48. quick = {
  49. ("帮助", "help", "?"): lambda: HELP_TEXT,
  50. ("列表", "关注列表"): memory_get_watchlist,
  51. ("历史",): memory_get_history,
  52. ("偏好",): memory_get_preferences,
  53. ("知识库",): rag_stats,
  54. }
  55. for keys, handler in quick.items():
  56. if msg in keys:
  57. history.append({"role": "user", "content": msg})
  58. history.append({"role": "assistant", "content": handler()})
  59. yield history, ""
  60. return
  61. if msg.startswith("关注 "):
  62. parts = msg[3:].strip().split()
  63. c, n = parts[0], (parts[1] if len(parts) > 1 else "")
  64. history.append({"role": "user", "content": msg})
  65. history.append({"role": "assistant", "content": memory_add_watchlist(f"{c}|{n}")})
  66. yield history, ""
  67. return
  68. if msg.startswith("移除 "):
  69. history.append({"role": "user", "content": msg})
  70. history.append({"role": "assistant", "content": memory_remove_watchlist(msg[3:].strip())})
  71. yield history, ""
  72. return
  73. if msg.startswith("历史 "):
  74. history.append({"role": "user", "content": msg})
  75. history.append({"role": "assistant", "content": memory_get_history(msg[3:].strip())})
  76. yield history, ""
  77. return
  78. if msg.startswith("导入 "):
  79. history.append({"role": "user", "content": msg})
  80. history.append({"role": "assistant", "content": rag_import(msg[3:].strip()) + "\n" + rag_stats()})
  81. yield history, ""
  82. return
  83. # ── 流式分析 ──
  84. history.append({"role": "user", "content": msg})
  85. history.append({"role": "assistant", "content": "..."})
  86. q = queue.Queue()
  87. t = threading.Thread(target=_run_with_capture, args=(q, get_agent(), mode, msg), daemon=True)
  88. t.start()
  89. collected = []
  90. while True:
  91. try:
  92. chunk = q.get(timeout=0.3)
  93. except queue.Empty:
  94. if collected:
  95. history[-1]["content"] = "".join(collected)
  96. yield history, ""
  97. continue
  98. if chunk is None:
  99. break
  100. collected.append(chunk)
  101. history[-1]["content"] = "".join(collected)
  102. yield history, ""
  103. final = getattr(q, 'result', '') or "".join(collected)
  104. history[-1]["content"] = str(final)
  105. yield history, ""
  106. HELP_TEXT = """## 使用指南
  107. ### 股票分析
  108. 直接输入:`分析贵州茅台600519的估值和风险`
  109. ### 关注管理
  110. | 命令 | 说明 |
  111. |------|------|
  112. | `列表` | 查看关注列表 |
  113. | `关注 600519 茅台` | 添加关注 |
  114. | `移除 600519` | 移除关注 |
  115. ### 数据查询
  116. | 命令 | 说明 |
  117. |------|------|
  118. | `历史` | 全部分析历史 |
  119. | `偏好` | 用户偏好设置 |
  120. | `知识库` | 知识库状态 |"""
  121. # ===== 自定义 CSS =====
  122. CUSTOM_CSS = """
  123. /* 全局 */
  124. .gradio-container {
  125. max-width: 100% !important;
  126. margin: 0 !important;
  127. padding: 0 !important;
  128. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif !important;
  129. }
  130. /* 隐藏默认 footer */
  131. footer { display: none !important; }
  132. /* Header */
  133. .header-wrap {
  134. background: linear-gradient(135deg, #0f1729 0%, #1a2744 50%, #0d2137 100%);
  135. border-bottom: 2px solid #2a5c8a;
  136. padding: 16px 32px;
  137. }
  138. .header-wrap h1 {
  139. font-size: 26px;
  140. font-weight: 700;
  141. color: #e8f0fe;
  142. margin: 0;
  143. letter-spacing: -0.5px;
  144. }
  145. .header-wrap .subtitle {
  146. font-size: 13px;
  147. color: #7b9bcb;
  148. margin-top: 4px;
  149. }
  150. .header-wrap .status-row {
  151. display: flex;
  152. gap: 16px;
  153. margin-top: 10px;
  154. flex-wrap: wrap;
  155. }
  156. .status-badge {
  157. display: inline-flex;
  158. align-items: center;
  159. gap: 6px;
  160. font-size: 11px;
  161. padding: 3px 10px;
  162. border-radius: 12px;
  163. font-weight: 500;
  164. }
  165. .status-badge.online {
  166. background: rgba(34, 197, 94, 0.15);
  167. color: #4ade80;
  168. }
  169. .status-badge.data {
  170. background: rgba(59, 130, 246, 0.15);
  171. color: #60a5fa;
  172. }
  173. /* 侧边栏卡片 */
  174. .sidebar-card {
  175. background: rgba(255,255,255,0.04);
  176. border: 1px solid rgba(255,255,255,0.08);
  177. border-radius: 10px;
  178. padding: 16px;
  179. margin-bottom: 12px;
  180. }
  181. .sidebar-card h4 {
  182. font-size: 12px;
  183. font-weight: 600;
  184. color: #7b9bcb;
  185. text-transform: uppercase;
  186. letter-spacing: 0.5px;
  187. margin: 0 0 10px 0;
  188. }
  189. /* 主对话区域 */
  190. .main-chat {
  191. border-radius: 12px !important;
  192. border: 1px solid rgba(255,255,255,0.1) !important;
  193. background: rgba(255,255,255,0.02) !important;
  194. }
  195. /* 输入框 */
  196. .input-box textarea {
  197. border-radius: 10px !important;
  198. border: 1px solid rgba(255,255,255,0.12) !important;
  199. background: rgba(255,255,255,0.04) !important;
  200. color: #e0e0e0 !important;
  201. font-size: 14px !important;
  202. padding: 12px 16px !important;
  203. }
  204. .input-box textarea::placeholder {
  205. color: rgba(255,255,255,0.3) !important;
  206. }
  207. /* 按钮 */
  208. button.primary {
  209. background: linear-gradient(135deg, #2563eb, #1d4ed8) !important;
  210. border: none !important;
  211. border-radius: 10px !important;
  212. color: white !important;
  213. font-weight: 600 !important;
  214. padding: 12px 24px !important;
  215. transition: all 0.2s !important;
  216. height: 100% !important;
  217. min-height: 44px !important;
  218. }
  219. button.primary:hover {
  220. background: linear-gradient(135deg, #3b82f6, #2563eb) !important;
  221. box-shadow: 0 4px 12px rgba(37,99,235,0.3) !important;
  222. }
  223. button.secondary {
  224. background: rgba(255,255,255,0.04) !important;
  225. border: 1px solid rgba(255,255,255,0.08) !important;
  226. border-radius: 8px !important;
  227. color: #6aaff7 !important;
  228. font-size: 12px !important;
  229. padding: 8px 14px !important;
  230. transition: all 0.2s !important;
  231. width: 100% !important;
  232. text-align: left !important;
  233. }
  234. button.secondary:hover {
  235. background: rgba(255,255,255,0.08) !important;
  236. color: #c8d6e5 !important;
  237. border-color: rgba(255,255,255,0.18) !important;
  238. }
  239. /* Radio 模式选择 */
  240. .mode-radio-wrap {
  241. background: rgba(255,255,255,0.04);
  242. border-radius: 10px;
  243. padding: 14px 16px;
  244. border: 1px solid rgba(255,255,255,0.08);
  245. }
  246. /* --- 高对比度修复 --- */
  247. /* Radio / Checkbox 标签 — 亮蓝色 */
  248. .radio-option label, .radio-option span,
  249. label:has(input[type="radio"]), .radio-label,
  250. fieldset label, .radio-wrap label {
  251. color: #6aaff7 !important;
  252. }
  253. /* Radio hover — 亮灰色 */
  254. .radio-option:hover label, .radio-option:hover span,
  255. fieldset label:hover, .radio-wrap:hover label {
  256. color: #c8d6e5 !important;
  257. }
  258. /* Radio 选中 — 加粗变白 */
  259. input[type="radio"]:checked + label,
  260. input[type="radio"]:checked ~ span {
  261. color: #ffffff !important;
  262. font-weight: 700 !important;
  263. }
  264. /* 输入框 */
  265. input[type="text"], textarea, .input-box textarea {
  266. background: #1a2236 !important;
  267. border: 1px solid #3a5078 !important;
  268. color: #e8edf5 !important;
  269. border-radius: 10px !important;
  270. padding: 12px 16px !important;
  271. font-size: 14px !important;
  272. }
  273. input[type="text"]:focus, textarea:focus {
  274. border-color: #4a8cf7 !important;
  275. box-shadow: 0 0 0 3px rgba(74, 140, 247, 0.15) !important;
  276. outline: none !important;
  277. }
  278. input[type="text"]::placeholder, textarea::placeholder {
  279. color: #5a7099 !important;
  280. }
  281. /* select 下拉 */
  282. select, .dropdown {
  283. background: #1a2236 !important;
  284. color: #d0d8e8 !important;
  285. border: 1px solid #3a5078 !important;
  286. }
  287. /* 链接 */
  288. a, .examples a {
  289. color: #7aabf7 !important;
  290. }
  291. a:hover {
  292. color: #a0c4ff !important;
  293. }
  294. /* 聊天气泡内容 */
  295. .message-row .message {
  296. color: #e4eaf5 !important;
  297. }
  298. /* 聊天气泡 */
  299. .bubble-wrap { border-radius: 12px !important; }
  300. /* 快捷图标 */
  301. .quick-icon {
  302. font-size: 16px;
  303. margin-right: 6px;
  304. }
  305. /* 滚动条 */
  306. ::-webkit-scrollbar { width: 5px; }
  307. ::-webkit-scrollbar-track { background: transparent; }
  308. ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
  309. ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
  310. """
  311. # ===== 界面 =====
  312. with gr.Blocks(title="StockInsightAgent") as app:
  313. # ── Header ──
  314. gr.HTML("""
  315. <div class="header-wrap">
  316. <h1>StockInsightAgent</h1>
  317. <div class="subtitle">智能股票分析助手</div>
  318. <div class="status-row">
  319. <span class="status-badge online">&#9679; 系统就绪</span>
  320. <span class="status-badge data">&#9679; 数据源: 东方财富 / Sina / 腾讯</span>
  321. </div>
  322. </div>
  323. """)
  324. with gr.Row(equal_height=True):
  325. # ── 左侧栏 ──
  326. with gr.Column(scale=1, min_width=200):
  327. gr.HTML('<div class="sidebar-card"><h4>分析模式</h4></div>')
  328. mode_radio = gr.Radio(
  329. choices=["快速分析 (ReAct)", "深度分析 (PlanSolve)", "批判分析 (Reflection)"],
  330. value="快速分析 (ReAct)",
  331. label="",
  332. interactive=True,
  333. )
  334. gr.HTML('<div class="sidebar-card"><h4>快捷操作</h4></div>')
  335. btn_watchlist = gr.Button("关注列表", elem_classes="secondary")
  336. btn_history = gr.Button("分析历史", elem_classes="secondary")
  337. btn_kb = gr.Button("知识库状态", elem_classes="secondary")
  338. btn_prefs = gr.Button("用户偏好", elem_classes="secondary")
  339. gr.HTML("""
  340. <div style="margin-top:16px; font-size:11px; color:#5a7a9a; line-height:1.6;">
  341. 输入 <b>帮助</b> 查看更多命令<br>
  342. </div>
  343. """)
  344. # ── 主区域 ──
  345. with gr.Column(scale=4):
  346. chatbot = gr.Chatbot(
  347. label="",
  348. height=520,
  349. elem_classes="main-chat",
  350. placeholder="<div style='text-align:center; color:#6a8aaa; padding-top:80px;'>"
  351. "<div style='font-size:48px; margin-bottom:16px;'>📊</div>"
  352. "<div style='font-size:16px; font-weight:600;'>开始分析你的投资组合</div>"
  353. "<div style='font-size:13px; margin-top:8px;'>输入股票代码或名称,获取全方位分析报告</div>"
  354. "</div>",
  355. )
  356. with gr.Row(equal_height=True):
  357. msg_input = gr.Textbox(
  358. placeholder="输入分析问题...",
  359. label="",
  360. scale=6,
  361. elem_classes="input-box",
  362. )
  363. submit_btn = gr.Button("开始分析", variant="primary", elem_classes="primary", scale=1)
  364. # ── 事件绑定 ──
  365. msg_input.submit(
  366. fn=respond_stream,
  367. inputs=[msg_input, chatbot, mode_radio],
  368. outputs=[chatbot, msg_input],
  369. )
  370. submit_btn.click(
  371. fn=respond_stream,
  372. inputs=[msg_input, chatbot, mode_radio],
  373. outputs=[chatbot, msg_input],
  374. )
  375. def quick_action(action, history):
  376. for result in respond_stream(action, history, "快速分析 (ReAct)"):
  377. pass
  378. return result[0]
  379. btn_watchlist.click(lambda h: quick_action("列表", h), [chatbot], [chatbot])
  380. btn_history.click(lambda h: quick_action("历史", h), [chatbot], [chatbot])
  381. btn_kb.click(lambda h: quick_action("知识库", h), [chatbot], [chatbot])
  382. btn_prefs.click(lambda h: quick_action("偏好", h), [chatbot], [chatbot])
  383. if __name__ == "__main__":
  384. app.launch(
  385. server_name="127.0.0.1", server_port=7861, share=False,
  386. css=CUSTOM_CSS,
  387. theme=gr.themes.Soft(primary_hue="blue", neutral_hue="slate"),
  388. )