diff --git a/backend/python/app/api/chat_service.py b/backend/python/app/api/chat_service.py
index 4b10c03..46e489c 100644
--- a/backend/python/app/api/chat_service.py
+++ b/backend/python/app/api/chat_service.py
@@ -1999,6 +1999,15 @@ async def _send_message_core(
process_steps = [] # 过程步骤(思考/技能/MCP执行),用于前端收拢式展示
+ async def _push_step(step: dict):
+ """追加过程步骤,并在流式场景实时推送给前端(step 事件)。"""
+ await _push_step(step)
+ if on_event is not None:
+ try:
+ await on_event({"type": "step", "step": step, "index": len(process_steps) - 1})
+ except Exception:
+ pass
+
@@ -4489,7 +4498,7 @@ async def _send_message_core(
})
- process_steps.append({
+ await _push_step({
"type": "skill",
@@ -4791,10 +4800,11 @@ async def _send_message_core(
try:
- if stream and on_event is not None and round_num == 0 and not (effective_mcp_ids or effective_skill_ids or effective_agent_ids):
+ if stream and on_event is not None:
_sc = ""
_sr = ""
+ _final_msg = None
async for _kind, _text in await achat_complete(
api_url, api_key, request_body,
proxy=_proxy_for_url(api_url),
@@ -4807,7 +4817,15 @@ async def _send_message_core(
elif _kind == "reasoning":
_sr += _text
await on_event({"type": "reasoning", "content": _text})
- message = {"content": _sc, "tool_calls": [], "reasoning": _sr}
+ elif _kind == "message":
+ _final_msg = _text
+ if _final_msg is not None:
+ _final_msg = dict(_final_msg)
+ if "reasoning_content" in _final_msg:
+ _final_msg["reasoning"] = _final_msg.pop("reasoning_content")
+ message = _final_msg
+ else:
+ message = {"content": _sc, "tool_calls": [], "reasoning": _sr}
choice = {"message": message}
data = {"choices": [choice], "usage": {}}
else:
@@ -4983,7 +5001,7 @@ async def _send_message_core(
if _think and tool_calls:
- process_steps.append({
+ await _push_step({
"type": "thinking",
@@ -5469,7 +5487,7 @@ async def _send_message_core(
called_mcp_names.append(_srv.get("name", ""))
- process_steps.append({
+ await _push_step({
"type": "mcp",
@@ -5545,7 +5563,7 @@ async def _send_message_core(
called_skill_names.append(_sk.get("name", ""))
- process_steps.append({
+ await _push_step({
"type": "skill",
diff --git a/backend/python/app/core/llm_client.py b/backend/python/app/core/llm_client.py
index c871466..ff2cfaf 100644
--- a/backend/python/app/core/llm_client.py
+++ b/backend/python/app/core/llm_client.py
@@ -1,182 +1,222 @@
-"""Shared, standards-based LLM client for web chat + IM bot.
-
-平台红线(07-24):模型层以成熟/开源标准为设计基准。本模块使用官方 OpenAI SDK
-(`openai.AsyncOpenAI`)——它是 OpenAI 兼容端点的事实标准客户端。平台本身已在讲
-OpenAI 协议(`/v1/chat/completions`),因此零协议改造成本,并天然获得:
- * 原生 SSE 流式(`stream=True`);
- * 结构化的 tool_calls / usage 解析;
- * 统一的代理(trust_env=False + 显式 proxy)与超时处理。
-
-本模块取代 chat_service.py 与 im_llm.py 中两套手写 httpx 循环,使 web chat 与
-IM bot 共用**同一模型层**、同一套 failover/代理/超时语义——这是「各端能力统一对齐」
-红线在模型层的落地。
-
-API:
- achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False)
- - stream=False -> dict,形状对齐上游 `data` 响应:
- {"choices":[{"message":{"content","tool_calls","reasoning_content"}}], "usage":{...}}
- - stream=True -> async generator,逐块产出 ("content"|"reasoning", text) 增量
-"""
-from __future__ import annotations
-
-import httpx
-from openai import AsyncOpenAI, OpenAI
-
-
-def _base_url(api_url: str) -> str:
- """从完整 /v1/chat/completions 或 /v1/embeddings 地址推导 OpenAI SDK 所需的 base_url。"""
- u = (api_url or "").rstrip()
- for suf in ("/v1/chat/completions", "/chat/completions", "/v1/embeddings", "/embeddings"):
- if u.endswith(suf):
- return u[: -len(suf)]
- return u
-
-
-def build_openai_client(api_url, api_key, *, proxy=None, timeout=120) -> AsyncOpenAI:
- base = _base_url(api_url)
- # 始终 trust_env=False:避免容器 HTTP_PROXY 对国内端点(siliconflow 等)造成 502;
- # 代理经 proxy 显式传入(AGNES 等),未传则直连。
- http_client = httpx.AsyncClient(proxy=proxy, trust_env=False)
- return AsyncOpenAI(
- base_url=base,
- api_key=api_key or "EMPTY",
- timeout=timeout,
- http_client=http_client,
- )
-
-
-def _create_kwargs(request_body: dict, stream: bool = False) -> dict:
- kw = {
- "model": request_body.get("model"),
- "messages": request_body.get("messages", []),
- "stream": stream,
- }
- if request_body.get("temperature") is not None:
- kw["temperature"] = request_body["temperature"]
- if request_body.get("top_p") is not None:
- kw["top_p"] = request_body["top_p"]
- if request_body.get("max_tokens") is not None:
- kw["max_tokens"] = request_body["max_tokens"]
- if request_body.get("stop") is not None:
- kw["stop"] = request_body["stop"]
- tools = request_body.get("tools")
- if tools:
- kw["tools"] = tools
- kw["tool_choice"] = request_body.get("tool_choice", "auto")
- return kw
-
-
-def _normalize_tool_calls(tcs):
- if not tcs:
- return None
- out = []
- for tc in tcs:
- out.append(
- {
- "id": getattr(tc, "id", None),
- "type": "function",
- "function": {
- "name": getattr(tc.function, "name", None),
- "arguments": getattr(tc.function, "arguments", "") or "",
- },
- }
- )
- return out
-
-
-async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False):
- """统一的 OpenAI 兼容聊天补全。详见模块 docstring。"""
- if stream:
-
- async def _gen():
- client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
- try:
- gen = await client.chat.completions.create(
- **_create_kwargs(request_body, stream=True)
- )
- async for chunk in gen:
- if not chunk.choices:
- continue
- delta = chunk.choices[0].delta
- rc = getattr(delta, "reasoning_content", None)
- if rc:
- yield ("reasoning", rc)
- ct = getattr(delta, "content", None)
- if ct:
- yield ("content", ct)
- finally:
- await client.close()
-
- return _gen()
-
- client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
- async with client:
- resp = await client.chat.completions.create(**_create_kwargs(request_body))
- msg = resp.choices[0].message
- return {
- "choices": [
- {
- "message": {
- "content": msg.content or "",
- "tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)),
- "reasoning_content": getattr(msg, "reasoning_content", None),
- }
- }
- ],
- "usage": (resp.usage.model_dump() if resp.usage else {}),
- }
-
-
-def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120) -> OpenAI:
- """同步客户端工厂(供 IM bot 等同步上下文复用同一标准模型层)。"""
- base = _base_url(api_url)
- http_client = httpx.Client(proxy=proxy, trust_env=False)
- return OpenAI(base_url=base, api_key=api_key or "EMPTY", timeout=timeout, http_client=http_client)
-
-
-def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120):
- """同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。"""
- client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout)
- with client:
- resp = client.chat.completions.create(**_create_kwargs(request_body))
- msg = resp.choices[0].message
- return {
- "choices": [
- {
- "message": {
- "content": msg.content or "",
- "tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)),
- "reasoning_content": getattr(msg, "reasoning_content", None),
- }
- }
- ],
- "usage": (resp.usage.model_dump() if resp.usage else {}),
- }
-
-
-# ---------------------------------------------------------------------------
-# 嵌入(Embeddings):OpenAI 兼容 /v1/embeddings,收敛散落的裸 httpx 嵌入调用
-# ---------------------------------------------------------------------------
-def _embed_kwargs(input, model):
- return {"model": model, "input": input if isinstance(input, list) else [input]}
-
-
-async def aembed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
- """统一的 OpenAI 兼容文本嵌入(异步)。返回 list[list[float]]。
-
- 供 pgvector_search.get_embedding 等 embeddings 路径复用,把散落的裸 httpx
- 嵌入调用收敛到同一标准模型层(与聊天共用代理/超时/failover 语义)。
- """
- client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
- async with client:
- resp = await client.embeddings.create(**_embed_kwargs(input, model))
- return [d.embedding for d in resp.data]
-
-
-def embed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
- """同步版嵌入(与非 async 上下文复用同一标准模型层)。"""
- client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout)
- with client:
- resp = client.embeddings.create(**_embed_kwargs(input, model))
- return [d.embedding for d in resp.data]
-
+"""Shared, standards-based LLM client for web chat + IM bot.
+
+平台红线(07-24):模型层以成熟/开源标准为设计基准。本模块使用官方 OpenAI SDK
+(`openai.AsyncOpenAI`)——它是 OpenAI 兼容端点的事实标准客户端。平台本身已在讲
+OpenAI 协议(`/v1/chat/completions`),因此零协议改造成本,并天然获得:
+ * 原生 SSE 流式(`stream=True`);
+ * 结构化的 tool_calls / usage 解析;
+ * 统一的代理(trust_env=False + 显式 proxy)与超时处理。
+
+本模块取代 chat_service.py 与 im_llm.py 中两套手写 httpx 循环,使 web chat 与
+IM bot 共用**同一模型层**、同一套 failover/代理/超时语义——这是「各端能力统一对齐」
+红线在模型层的落地。
+
+API:
+ achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False)
+ - stream=False -> dict,形状对齐上游 `data` 响应:
+ {"choices":[{"message":{"content","tool_calls","reasoning_content"}}], "usage":{...}}
+ - stream=True -> async generator,逐块产出 ("content"|"reasoning", text) 增量
+"""
+from __future__ import annotations
+
+import httpx
+from openai import AsyncOpenAI, OpenAI
+
+
+def _base_url(api_url: str) -> str:
+ """从完整 /v1/chat/completions 或 /v1/embeddings 地址推导 OpenAI SDK 所需的 base_url。"""
+ u = (api_url or "").rstrip()
+ for suf in ("/v1/chat/completions", "/chat/completions", "/v1/embeddings", "/embeddings"):
+ if u.endswith(suf):
+ return u[: -len(suf)]
+ return u
+
+
+def build_openai_client(api_url, api_key, *, proxy=None, timeout=120) -> AsyncOpenAI:
+ base = _base_url(api_url)
+ # 始终 trust_env=False:避免容器 HTTP_PROXY 对国内端点(siliconflow 等)造成 502;
+ # 代理经 proxy 显式传入(AGNES 等),未传则直连。
+ http_client = httpx.AsyncClient(proxy=proxy, trust_env=False)
+ return AsyncOpenAI(
+ base_url=base,
+ api_key=api_key or "EMPTY",
+ timeout=timeout,
+ http_client=http_client,
+ )
+
+
+def _create_kwargs(request_body: dict, stream: bool = False) -> dict:
+ kw = {
+ "model": request_body.get("model"),
+ "messages": request_body.get("messages", []),
+ "stream": stream,
+ }
+ if request_body.get("temperature") is not None:
+ kw["temperature"] = request_body["temperature"]
+ if request_body.get("top_p") is not None:
+ kw["top_p"] = request_body["top_p"]
+ if request_body.get("max_tokens") is not None:
+ kw["max_tokens"] = request_body["max_tokens"]
+ if request_body.get("stop") is not None:
+ kw["stop"] = request_body["stop"]
+ tools = request_body.get("tools")
+ if tools:
+ kw["tools"] = tools
+ kw["tool_choice"] = request_body.get("tool_choice", "auto")
+ return kw
+
+
+def _normalize_tool_calls(tcs):
+ if not tcs:
+ return None
+ out = []
+ for tc in tcs:
+ out.append(
+ {
+ "id": getattr(tc, "id", None),
+ "type": "function",
+ "function": {
+ "name": getattr(tc.function, "name", None),
+ "arguments": getattr(tc.function, "arguments", "") or "",
+ },
+ }
+ )
+ return out
+
+
+async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False):
+ """统一的 OpenAI 兼容聊天补全。详见模块 docstring。"""
+ if stream:
+
+ async def _gen():
+ client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
+ _content_parts = []
+ _reasoning_parts = []
+ _tool_acc = {} # index -> {id, type, name, arguments}
+ try:
+ gen = await client.chat.completions.create(
+ **_create_kwargs(request_body, stream=True)
+ )
+ async for chunk in gen:
+ if not chunk.choices:
+ continue
+ delta = chunk.choices[0].delta
+ rc = getattr(delta, "reasoning_content", None)
+ if rc:
+ _reasoning_parts.append(rc)
+ yield ("reasoning", rc)
+ ct = getattr(delta, "content", None)
+ if ct:
+ _content_parts.append(ct)
+ yield ("content", ct)
+ # 累积 OpenAI 流式 tool_calls(供后续工具循环执行)
+ tcs = getattr(delta, "tool_calls", None)
+ if tcs:
+ for tc in tcs:
+ _idx = getattr(tc, "index", 0)
+ _slot = _tool_acc.setdefault(
+ _idx, {"id": None, "type": "function", "name": "", "arguments": ""}
+ )
+ _tcid = getattr(tc, "id", None)
+ if _tcid:
+ _slot["id"] = _tcid
+ _fn = getattr(tc, "function", None)
+ if _fn is not None:
+ _fn_name = getattr(_fn, "name", None)
+ if _fn_name:
+ _slot["name"] += _fn_name
+ _fn_args = getattr(_fn, "arguments", None)
+ if _fn_args:
+ _slot["arguments"] += _fn_args
+ finally:
+ await client.close()
+ # 流式结束时吐出已组装的完整 message(含 tool_calls),供调用方无缝衔接工具循环
+ _tool_calls = None
+ if _tool_acc:
+ _tool_calls = []
+ for _i in sorted(_tool_acc.keys()):
+ _s = _tool_acc[_i]
+ _tool_calls.append({
+ "id": _s["id"],
+ "type": "function",
+ "function": {"name": _s["name"], "arguments": _s["arguments"]},
+ })
+ yield ("message", {
+ "content": "".join(_content_parts),
+ "tool_calls": _tool_calls,
+ "reasoning_content": "".join(_reasoning_parts) or None,
+ })
+
+ return _gen()
+
+ client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
+ async with client:
+ resp = await client.chat.completions.create(**_create_kwargs(request_body))
+ msg = resp.choices[0].message
+ return {
+ "choices": [
+ {
+ "message": {
+ "content": msg.content or "",
+ "tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)),
+ "reasoning_content": getattr(msg, "reasoning_content", None),
+ }
+ }
+ ],
+ "usage": (resp.usage.model_dump() if resp.usage else {}),
+ }
+
+
+def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120) -> OpenAI:
+ """同步客户端工厂(供 IM bot 等同步上下文复用同一标准模型层)。"""
+ base = _base_url(api_url)
+ http_client = httpx.Client(proxy=proxy, trust_env=False)
+ return OpenAI(base_url=base, api_key=api_key or "EMPTY", timeout=timeout, http_client=http_client)
+
+
+def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120):
+ """同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。"""
+ client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout)
+ with client:
+ resp = client.chat.completions.create(**_create_kwargs(request_body))
+ msg = resp.choices[0].message
+ return {
+ "choices": [
+ {
+ "message": {
+ "content": msg.content or "",
+ "tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)),
+ "reasoning_content": getattr(msg, "reasoning_content", None),
+ }
+ }
+ ],
+ "usage": (resp.usage.model_dump() if resp.usage else {}),
+ }
+
+
+# ---------------------------------------------------------------------------
+# 嵌入(Embeddings):OpenAI 兼容 /v1/embeddings,收敛散落的裸 httpx 嵌入调用
+# ---------------------------------------------------------------------------
+def _embed_kwargs(input, model):
+ return {"model": model, "input": input if isinstance(input, list) else [input]}
+
+
+async def aembed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
+ """统一的 OpenAI 兼容文本嵌入(异步)。返回 list[list[float]]。
+
+ 供 pgvector_search.get_embedding 等 embeddings 路径复用,把散落的裸 httpx
+ 嵌入调用收敛到同一标准模型层(与聊天共用代理/超时/failover 语义)。
+ """
+ client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
+ async with client:
+ resp = await client.embeddings.create(**_embed_kwargs(input, model))
+ return [d.embedding for d in resp.data]
+
+
+def embed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
+ """同步版嵌入(与非 async 上下文复用同一标准模型层)。"""
+ client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout)
+ with client:
+ resp = client.embeddings.create(**_embed_kwargs(input, model))
+ return [d.embedding for d in resp.data]
+
diff --git a/frontend/src/pages/front/ChatPage.tsx b/frontend/src/pages/front/ChatPage.tsx
index 2cfa216..61bd85b 100644
--- a/frontend/src/pages/front/ChatPage.tsx
+++ b/frontend/src/pages/front/ChatPage.tsx
@@ -1,1843 +1,1936 @@
-import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
-import { useParams, useNavigate } from 'react-router-dom';
-import { chatAPI, knowledgeAPI, marketAPI, evolutionAPI } from '@/api';
-import { formatChatTime } from '@/utils/datetime';
-import type { Conversation, Message } from '@/types';
-import './ChatPage.css';
-
-// ===== 增强渲染依赖 =====
-import 'highlight.js/styles/github.css';
-import 'katex/dist/katex.min.css';
-import mermaid from 'mermaid';
-
-mermaid.initialize({ startOnLoad: false, theme: 'default' });
-
-// ===== AI 回复异常检测 =====
-const AI_ERROR_PATTERNS = [
- 'AI模型调用失败',
- 'AI服务暂时不可用',
- 'AI模型返回了空响应',
- 'AI模型已处理工具调用结果',
- '该模型缺少API密钥或端点配置',
- '请选择一个已配置的AI模型',
- '请先在后台管理-大模型库配置默认对话模型',
- '⚠️ 多 Agent 编排执行失败',
- '⚠️ 外部 Agent 调用失败',
- '编排未产生内容',
- '外部 Agent 未返回内容',
-];
-function isAiError(content: string): boolean {
- if (!content) return false;
- return AI_ERROR_PATTERNS.some(p => content.includes(p));
-}
-
-// ===== Enhanced Markdown Renderer =====
-// 媒体(图片/视频)以受限缩略图呈现,并提供下载(格式+尺寸)选项
-import { renderMarkdownToHtml, MediaInfo } from '@/utils/markdown';
-
-// 全屏灯箱预览
-const ChatLightbox: React.FC<{ src: string; type: 'img' | 'video'; onClose: () => void }> = ({ src, type, onClose }) => {
- useEffect(() => {
- const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
- document.addEventListener('keydown', h);
- return () => document.removeEventListener('keydown', h);
- }, [onClose]);
- return (
-
-
-
e.stopPropagation()}>
- {type === 'video'
- ?
- :

}
-
-
- );
-};
-
-// 根据原始尺寸推算 3 个建议下载尺寸(保持宽高比)
-function computeSizes(natural: { w: number; h: number }) {
- if (natural.w && natural.h) {
- const ratio = natural.w / natural.h;
- const fit = (maxDim: number) => ratio >= 1
- ? { w: maxDim, h: Math.round(maxDim / ratio) }
- : { w: Math.round(maxDim * ratio), h: maxDim };
- return [
- { label: '小图', ...fit(512) },
- { label: '中图', ...fit(1024) },
- { label: '原始', w: natural.w, h: natural.h },
- ];
- }
- // 未知原始尺寸时按生成默认 4:3 给出三档
- return [
- { label: '小图', w: 512, h: 384 },
- { label: '中图', w: 1024, h: 768 },
- { label: '大图', w: 2048, h: 1536 },
- ];
-}
-
-// 客户端画布转换格式+尺寸后下载;跨域受限时回退到新标签页打开原图
-function downloadImage(src: string, format: 'png' | 'jpg' | 'webp', w: number, h: number) {
- const fallback = () => window.open(src, '_blank');
- const img = new Image();
- img.crossOrigin = 'anonymous';
- img.onload = () => {
- try {
- const canvas = document.createElement('canvas');
- canvas.width = w; canvas.height = h;
- const ctx = canvas.getContext('2d');
- if (!ctx) { fallback(); return; }
- if (format === 'jpg') { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); }
- ctx.drawImage(img, 0, 0, w, h);
- const mime = format === 'jpg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png';
- canvas.toBlob((blob) => {
- if (!blob) { fallback(); return; }
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url; a.download = `aiflow-image-${w}x${h}.${format}`;
- document.body.appendChild(a); a.click(); a.remove();
- setTimeout(() => URL.revokeObjectURL(url), 1000);
- }, mime, 0.92);
- } catch (e) {
- fallback();
- }
- };
- img.onerror = fallback;
- img.src = src;
-}
-
-// 媒体渲染组件:固定尺寸缩略图 + 点击放大 + 下载(格式/尺寸)
-const ChatMedia: React.FC<{ info: MediaInfo }> = ({ info }) => {
- const [open, setOpen] = useState(false);
- const [dlOpen, setDlOpen] = useState(false);
- const [fmt, setFmt] = useState<'png' | 'jpg' | 'webp'>('png');
- const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 0, h: 0 });
-
- if (info.type === 'video') {
- return (
-
-
- );
- }
-
- const sizes = computeSizes(natural);
- return (
-
-

setOpen(true)}
- onLoad={(e) => {
- const im = e.currentTarget;
- if (im.naturalWidth && im.naturalHeight) setNatural({ w: im.naturalWidth, h: im.naturalHeight });
- }}
- />
-
- {dlOpen && (
-
e.stopPropagation()}>
-
下载图片
-
-
格式
-
- {(['png', 'jpg', 'webp'] as const).map((f) => (
-
- ))}
-
-
-
尺寸
-
- {sizes.map((s, i) => (
-
- ))}
-
-
- )}
- {open &&
setOpen(false)} />}
-
- );
-};
-
-const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
- // 渲染管线(代码块/加粗/表格/换行/媒体占位符等)已抽到 src/utils/markdown.ts,
- // 组件只负责把返回的 html 切分为「文本片段 + 媒体组件」。
- const { html, media } = useMemo(() => renderMarkdownToHtml(content), [content]);
-
- const nodes = useMemo(() => {
- // 12. 拆分为文本片段与媒体组件(媒体以 \x00MEDIA\x00 占位符保留在 html 中)
- const parts = html.split(/(\x00MEDIA\d+\x00)/g);
- return parts.map((part: string, i: number) => {
- const m = part.match(/^\x00MEDIA(\d+)\x00$/);
- if (m) {
- const info = media[parseInt(m[1])];
- if (info && info.src) return ;
- return null;
- }
- if (!part) return null;
- // 仅清除被媒体切分产生的悬空 标签,保留段落内部的成对
- let s = part.replace(/^<\/p>/, '').replace(/
$/, '');
- if (!s.trim()) return null;
- return
;
- });
- }, [html, media]);
-
- const containerRef = useRef(null);
- useEffect(() => {
- if (!containerRef.current) return;
- const diagrams = containerRef.current.querySelectorAll('.md-mermaid:not(.md-mermaid-rendered)');
- if (diagrams.length === 0) return;
- const timer = setTimeout(async () => {
- for (const el of diagrams) {
- try {
- const code = el.getAttribute('data-code') || '';
- if (!code) continue;
- const id = 'mermaid-' + Math.random().toString(36).slice(2, 10);
- const { svg } = await mermaid.render(id, code);
- el.innerHTML = svg;
- el.classList.add('md-mermaid-rendered');
- } catch (e) {
- el.innerHTML = 'Mermaid: ' + (e as Error).message + '
';
- }
- }
- }, 50);
- return () => clearTimeout(timer);
- }, [content]);
-
- return {nodes}
;
-};
-
-// 解析后端返回的「模糊创作意图」确认指令:<>{json}<>
-function parseClarify(content: string): { prompt: string; options: any[] } | null {
- if (!content || typeof content !== 'string') return null;
- const m = content.match(/^<>([\s\S]+)<>$/);
- if (!m) return null;
- try {
- const obj = JSON.parse(m[1]);
- if (obj && Array.isArray(obj.options)) return obj;
- } catch { /* ignore */ }
- return null;
-}
-
-// 模糊创作意图确认卡片:让用户选择产出方向(文案/图片/视频/其他)
-const ClarifyCard: React.FC<{ data: { prompt: string; options: any[] }; onChoose: (opt: any) => void }> = ({ data, onChoose }) => {
- return (
-
-
🤔 这个请求的方向还不太明确,您希望以哪种形式呈现?
-
「{data.prompt}」
-
- {data.options.map((opt: any) => (
-
- ))}
-
-
- );
-};
-
-
-// ===== Time Formatting =====
-function formatTime(iso: string): string {
- return formatChatTime(iso);
-}
-
-// ===== Helpers: normalize API responses =====
-function unwrapList(res: any): any[] {
- const data = res?.data !== undefined ? res.data : res;
- if (Array.isArray(data)) return data;
- if (data?.items) return data.items;
- return [];
-}
-
-function unwrapData(res: any): any {
- return res?.data !== undefined ? res.data : res;
-}
-
-// ===== Icons =====
-const IconSearch = () => (
-
-);
-
-const IconPlus = () => (
-
-);
-
-const IconMore = () => (
-
-);
-
-const IconSend = () => (
-
-);
-
-const IconChevronDown = () => (
-
-);
-
-const IconEdit = () => (
-
-);
-
-const IconArchive = () => (
-
-);
-
-const IconTrash = () => (
-
-);
-
-const IconPin = () => (
-
-);
-
-const IconGraph = () => (
-
-);
-
-const IconKnowledge = () => (
-
-);
-
-const IconAgent = () => (
-
-);
-
-const IconSkill = () => (
-
-);
-
-const IconMcp = () => (
-
-);
-
-const IconPaperclip = () => (
-
-);
-
-const IconMic = () => (
-
-);
-
-const IconStop = () => (
-
-);
-
-const IconVolume = () => (
-
-);
-
-const IconModel = () => (
-
-);
-
-const IconEmpty = () => (
-
-);
-
-const IconSpinner = () => (
-
-);
-
-// ===== Dropdown Component =====
-interface DropdownProps {
- trigger: React.ReactNode;
- children: React.ReactNode;
- className?: string;
- align?: 'left' | 'right';
- dropup?: boolean;
- closeOnClick?: boolean;
-}
-
-const Dropdown: React.FC = ({ trigger, children, className = '', align = 'left', dropup = false, closeOnClick = true }) => {
- const [open, setOpen] = useState(false);
- const ref = useRef(null);
- const menuRef = useRef(null);
- const openRef = useRef(open);
- openRef.current = open;
-
- useEffect(() => {
- const handleClickOutside = (e: MouseEvent) => {
- const target = e.target as Node;
- // 如果点击在wrapper内部,不关闭
- if (ref.current?.contains(target)) return;
- setOpen(false);
- };
- document.addEventListener('mousedown', handleClickOutside, true);
- return () => document.removeEventListener('mousedown', handleClickOutside, true);
- }, []);
-
- // 打开时处理z-index
- useEffect(() => {
- if (open && dropup) {
- const menu = menuRef.current;
- if (menu) {
- menu.style.zIndex = '9999';
- }
- }
- }, [open, dropup]);
-
- const alignClass = align === 'right' ? 'align-right' : 'align-left';
- const dropupClass = dropup ? 'dropup' : '';
-
- const handleTriggerMouseDown = (e: React.MouseEvent) => {
- // 阻止冒泡到document,防止被handleClickOutside误关
- e.stopPropagation();
- // 基于当前状态切换(在mousedown阶段决定,避免与click冲突)
- setOpen(prev => !prev);
- };
-
- const handleTriggerClick = (e: React.MouseEvent) => {
- // 阻止click冒泡到父级的导航onClick
- e.stopPropagation();
- e.preventDefault();
- };
-
- return (
-
-
- {trigger}
-
- {open && (
-
e.stopPropagation()}
- onClick={(e) => { e.stopPropagation(); if (closeOnClick) setOpen(false); }}
- >
- {children}
-
- )}
-
- );
-};
-
-// ===== AI 思考 / 执行过程面板(顶部展开 + 自动收拢) =====
-// autoExpand=true 时(本轮刚产生的助手消息)首屏展开呈现思考链路,
-// 6s 后若用户未手动干预则自动收拢为一行摘要,避免与回复内容冲突。
-// 展开时自动滚动到视图内,带脉冲动画提示用户注意。
-const AiProcessBlock: React.FC<{ steps: any[]; autoExpand: boolean }> = ({ steps, autoExpand }) => {
- const [open, setOpen] = useState(autoExpand);
- const [fresh, setFresh] = useState(autoExpand);
- const timerRef = useRef(null);
- const touchedRef = useRef(false);
- const blockRef = useRef(null);
-
- useEffect(() => {
- if (!autoExpand) {
- setOpen(false);
- setFresh(false);
- return;
- }
- setOpen(true);
- setFresh(true);
- touchedRef.current = false;
- // 自动滚动到视图内(上方留 60px 避免被顶部遮挡)
- if (blockRef.current) {
- blockRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
- }
- timerRef.current = window.setTimeout(() => {
- if (!touchedRef.current) {
- setOpen(false);
- // 收拢后短暂保留 fresh 样式再移除
- setTimeout(() => setFresh(false), 500);
- }
- }, 6000);
- return () => {
- if (timerRef.current) window.clearTimeout(timerRef.current);
- };
- }, [autoExpand]);
-
- if (!steps || steps.length === 0) return null;
-
- const toggle = () => {
- touchedRef.current = true;
- setFresh(false);
- if (timerRef.current) {
- window.clearTimeout(timerRef.current);
- timerRef.current = null;
- }
- setOpen(o => !o);
- };
-
- return (
-
- { e.preventDefault(); toggle(); }}>
- 🧠
- AI 思考 / 执行过程({steps.length} 步 — {open ? '自动收拢中... 点击手动收起' : '点击展开查看'})
-
-
- {steps.map((s: any, i: number) => (
-
-
- {s.type === 'thinking' ? '💡' : s.type === 'skill' ? '🛠' : '🔌'}
-
-
- {s.type === 'thinking' && (<>
思考{s.text}
>)}
- {s.type === 'skill' && (<>
调用技能:{s.name}{(s.result || '').slice(0, 400)}
>)}
- {s.type === 'mcp' && (<>
MCP · {s.server}:{s.name}{(s.result || '').slice(0, 400)}
>)}
-
-
- ))}
-
-
- );
-};
-
-// ===== Main Component =====
-const ChatPage: React.FC = () => {
- const { id } = useParams<{ id?: string }>();
- const navigate = useNavigate();
- const [searchQuery, setSearchQuery] = useState('');
- const [selectedModelId, setSelectedModelId] = useState('auto');
- const [chatModels, setChatModels] = useState([]);
- const [inputValue, setInputValue] = useState('');
- const [sending, setSending] = useState(false);
- const [sendingTimeout, setSendingTimeout] = useState(false);
- const sendingTimerRef = useRef | null>(null);
- // 正在 SSE 流式渲染的助手消息 id;非空时隐藏"思考中"占位气泡
- const [streamingId, setStreamingId] = useState(null);
- const [conversations, setConversations] = useState([]);
- const [messages, setMessages] = useState([]);
- const [loadingConversations, setLoadingConversations] = useState(true);
- const [loadingMessages, setLoadingMessages] = useState(false);
- // 本轮对话新产生的助手消息 id 集合,用于触发"思考过程"首屏展开
- const [freshMsgIds, setFreshMsgIds] = useState>(new Set());
- const [knowledgeBases, setKnowledgeBases] = useState([]);
- const [agents, setAgents] = useState([]);
- const [skills, setSkills] = useState([]);
- const [mcps, setMcps] = useState([]);
- // 多选组合模式
- const [selectedAgentIds, setSelectedAgentIds] = useState([]);
- const [selectedSkillIds, setSelectedSkillIds] = useState([]);
- const [selectedKbIds, setSelectedKbIds] = useState([]);
- const [selectedMcpIds, setSelectedMcpIds] = useState([]);
- // 文件附件
- const [attachments, setAttachments] = useState<{ name: string; file_type: string; content?: string }[]>([]);
- const fileInputRef = useRef(null);
- const recognitionRef = useRef(null);
- const [recording, setRecording] = useState(false);
- const messagesEndRef = useRef(null);
- const inputRef = useRef(null);
-
- // Knowledge graph conversion state
- const [kgModalOpen, setKgModalOpen] = useState(false);
- const [kgStep, setKgStep] = useState<1 | 2 | 3>(1);
- const [kgResult, setKgResult] = useState<{ nodes: number; edges: number; kbName: string; kbId: string } | null>(null);
- const [kgError, setKgError] = useState(null);
- const [kgConvId, setKgConvId] = useState(null);
-
- // Load models from backend API
- useEffect(() => {
- chatAPI.getModels().then((res: any) => {
- const data = res.data || res;
- const models = Array.isArray(data) ? data : (data?.items || []);
- setChatModels(models);
- if (models.length > 0 && !selectedModelId) {
- setSelectedModelId('auto');
- }
- }).catch(() => {
- setChatModels([]);
- });
- }, []);
-
- // Load conversations from API
- const loadConversations = useCallback(async () => {
- try {
- setLoadingConversations(true);
- const res = await chatAPI.getConversations();
- const convs = unwrapList(res);
- // Sort: pinned first, then by updatedAt desc
- const sorted = [...convs].sort((a: any, b: any) => {
- if (a.pinned && !b.pinned) return -1;
- if (!a.pinned && b.pinned) return 1;
- return new Date(b.updatedAt || b.updated_at || 0).getTime() - new Date(a.updatedAt || a.updated_at || 0).getTime();
- });
- // Normalize field names
- const mapped: Conversation[] = sorted.map((c: any) => ({
- id: c.id,
- userId: c.userId || c.user_id || '',
- title: c.title || '未命名对话',
- modelId: c.modelId || c.model_id || '',
- status: c.status || 'active',
- pinned: c.pinned || false,
- knowledgeGraphCount: c.knowledgeGraphCount ?? c.knowledge_graph_count ?? 0,
- lastMessage: c.lastMessage || c.last_message || '',
- createdAt: c.createdAt || c.created_at || new Date().toISOString(),
- updatedAt: c.updatedAt || c.updated_at || new Date().toISOString(),
- }));
- setConversations(mapped);
- } catch (err) {
- console.error('加载对话列表失败', err);
- } finally {
- setLoadingConversations(false);
- }
- }, []);
-
- // Load conversations on mount
- useEffect(() => {
- loadConversations();
- }, [loadConversations]);
-
- // Load messages for selected conversation
- useEffect(() => {
- if (!id) {
- setMessages([]);
- return;
- }
- const loadMessages = async () => {
- try {
- setLoadingMessages(true);
- const res = await chatAPI.getMessages(id);
- const msgs = unwrapList(res);
- const mapped: Message[] = msgs.map((m: any) => ({
- id: m.id,
- conversationId: m.conversationId || m.conversation_id || id,
- role: m.role,
- content: m.content,
- tokenUsed: m.tokenUsed ?? m.token_used ?? 0,
- createdAt: m.createdAt || m.created_at || new Date().toISOString(),
- }));
- setMessages(mapped);
- // 页面刷新时若仍有「⏳」占位的媒体消息,继续轮询后端回写结果
- mapped.forEach((m) => {
- if (m.role === 'assistant' && typeof m.content === 'string' && m.content.startsWith('⏳')) {
- startMediaPolling(id, m.id);
- }
- });
- } catch (err) {
- console.error('加载消息失败', err);
- setMessages([]);
- } finally {
- setLoadingMessages(false);
- }
- };
- loadMessages();
- }, [id]);
-
- // 媒体(图像/视频)异步生成轮询:后端先返回「⏳」占位,前端定时拉取该消息最新内容并回写
- const pollMediaRef = useRef | null>>({});
-
- const stopMediaPolling = (msgId: string) => {
- const t = pollMediaRef.current[msgId];
- if (t) {
- clearInterval(t);
- pollMediaRef.current[msgId] = null;
- }
- };
-
- const startMediaPolling = (convId: string, msgId: string) => {
- if (pollMediaRef.current[msgId]) return; // 已在轮询
- let ticks = 0;
- const timer = setInterval(async () => {
- ticks += 1;
- try {
- const res = await chatAPI.getMessages(convId);
- const msgs: any[] = unwrapList(res);
- const updated = msgs.find((m: any) => m.id === msgId);
- if (!updated) {
- stopMediaPolling(msgId);
- return;
- }
- const content: string = updated.content || '';
- if (content.startsWith('⏳')) {
- if (ticks > 120) stopMediaPolling(msgId); // ~6 分钟兜底
- return;
- }
- // 生成完成:回写消息内容
- setMessages(prev => prev.map(m => m.id === msgId ? {
- ...m,
- content,
- tokenUsed: updated.tokenUsed ?? updated.token_used ?? m.tokenUsed,
- createdAt: updated.createdAt || updated.created_at || m.createdAt,
- used_skills: updated.usedSkills ?? updated.used_skills ?? m.used_skills,
- used_mcp: updated.usedMcp ?? updated.used_mcp ?? m.used_mcp,
- } : m));
- stopMediaPolling(msgId);
- } catch (e) {
- if (ticks > 120) stopMediaPolling(msgId); // 网络异常兜底
- }
- }, 3000);
- pollMediaRef.current[msgId] = timer;
- };
-
- // 卸载时清理所有轮询定时器
- useEffect(() => {
- return () => {
- Object.values(pollMediaRef.current).forEach(t => t && clearInterval(t));
- };
- }, []);
-
- // Load knowledge bases, agents, skills for attachment dropdowns
- useEffect(() => {
- knowledgeAPI.getBases().then((res: any) => {
- const data = res?.data !== undefined ? res.data : res;
- if (data?.bases && Array.isArray(data.bases)) {
- setKnowledgeBases(data.bases);
- } else if (Array.isArray(data)) {
- setKnowledgeBases(data);
- } else if (data?.items) {
- setKnowledgeBases(data.items);
- } else {
- setKnowledgeBases([]);
- }
- }).catch(() => {});
- marketAPI.getApps({ type: 'agent' }).then((res: any) => {
- const data = res?.data !== undefined ? res.data : res;
- let apps: any[] = [];
- if (Array.isArray(data)) apps = data;
- else if (data?.items) apps = data.items;
- // 显示所有已发布的Agent(不需要安装即可在聊天中使用)
- setAgents(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
- }).catch(() => {});
- marketAPI.getApps({ type: 'skill' }).then((res: any) => {
- const data = res?.data !== undefined ? res.data : res;
- let apps: any[] = [];
- if (Array.isArray(data)) apps = data;
- else if (data?.items) apps = data.items;
- // 显示所有已发布的技能(不需要安装即可在聊天中使用)
- setSkills(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
- }).catch(() => {});
- marketAPI.getApps({ type: 'mcp' }).then((res: any) => {
- const data = res?.data !== undefined ? res.data : res;
- let apps: any[] = [];
- if (Array.isArray(data)) apps = data;
- else if (data?.items) apps = data.items;
- setMcps(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
- }).catch(() => {});
- }, []);
-
- // Determine selected conversation
- const selectedConversation = useMemo(() => {
- if (id) {
- return conversations.find(c => c.id === id) || null;
- }
- return conversations[0] || null;
- }, [id, conversations]);
-
- // Filter conversations by search
- const filteredConversations = useMemo(() => {
- const pinned = conversations.filter(c => c.pinned);
- const unpinned = conversations.filter(c => !c.pinned);
- if (!searchQuery.trim()) return [...pinned, ...unpinned];
- const q = searchQuery.toLowerCase();
- return [...pinned.filter(c => c.title.toLowerCase().includes(q)), ...unpinned.filter(c => c.title.toLowerCase().includes(q))];
- }, [conversations, searchQuery]);
-
- // Selected model info(Auto 自动:按对话意图自动匹配后台各能力默认模型)
- const isAutoModel = selectedModelId === 'auto';
- const AUTO_MODEL = { id: 'auto', name: 'Auto 自动', status: 'free', provider_name: '按能力智能匹配', capabilities: [], price_per_million_tokens: 0 };
- const selectedModel = isAutoModel
- ? AUTO_MODEL
- : (chatModels.find(m => m.id === selectedModelId) || chatModels[0] || AUTO_MODEL);
-
- // Scroll to bottom when messages change
- useEffect(() => {
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
- }, [messages]);
-
- // Select first conversation if no id param
- useEffect(() => {
- if (!id && conversations.length > 0) {
- navigate(`/chat/${conversations[0].id}`, { replace: true });
- }
- }, [id, conversations, navigate]);
-
- // Handle new conversation
- const handleNewConversation = async () => {
- try {
- const res = await chatAPI.createConversation({ title: '新对话', model_id: selectedModelId === 'auto' ? (chatModels[0]?.id || '') : selectedModelId });
- const newConv = unwrapData(res);
- const conv: Conversation = {
- id: newConv.id,
- userId: newConv.userId || newConv.user_id || '',
- title: newConv.title || '新对话',
- modelId: newConv.modelId || newConv.model_id || selectedModelId,
- status: 'active',
- pinned: false,
- knowledgeGraphCount: 0,
- createdAt: newConv.createdAt || newConv.created_at || new Date().toISOString(),
- updatedAt: newConv.updatedAt || newConv.updated_at || new Date().toISOString(),
- };
- setConversations(prev => [conv, ...prev]);
- navigate(`/chat/${conv.id}`);
- } catch (err: any) {
- alert('创建对话失败: ' + (err.message || '未知错误'));
- }
- };
-
- // Handle rename
- const handleRename = async (convId: string, e: React.MouseEvent) => {
- e.stopPropagation();
- const newTitle = prompt('请输入新名称:');
- if (newTitle && newTitle.trim()) {
- try {
- await chatAPI.updateConversation(convId, { title: newTitle.trim() });
- setConversations(prev => prev.map(c => c.id === convId ? { ...c, title: newTitle.trim() } : c));
- } catch (err: any) {
- alert('重命名失败: ' + (err.message || '未知错误'));
- }
- }
- };
-
- // Handle archive
- const handleArchive = async (convId: string, e: React.MouseEvent) => {
- e.stopPropagation();
- const conv = conversations.find(c => c.id === convId);
- if (!conv) return;
- const newStatus = conv.status === 'archived' ? 'active' : 'archived';
- try {
- await chatAPI.updateConversation(convId, { status: newStatus });
- setConversations(prev => prev.map(c => c.id === convId ? { ...c, status: newStatus } as Conversation : c));
- } catch (err: any) {
- alert('操作失败: ' + (err.message || '未知错误'));
- }
- };
-
- // Handle delete
- const handleDelete = async (convId: string, e: React.MouseEvent) => {
- e.stopPropagation();
- if (!confirm('确定要删除这个对话吗?')) return;
- try {
- await chatAPI.deleteConversation(convId);
- const next = conversations.filter(c => c.id !== convId);
- setConversations(next);
- if (selectedConversation?.id === convId) {
- if (next.length > 0) {
- navigate(`/chat/${next[0].id}`);
- } else {
- navigate('/chat');
- }
- }
- } catch (err: any) {
- alert('删除失败: ' + (err.message || '未知错误'));
- }
- };
-
- // Handle pin / unpin
- const handlePin = async (convId: string, e: React.MouseEvent) => {
- e.stopPropagation();
- const conv = conversations.find(c => c.id === convId);
- if (!conv) return;
- const newPinned = !conv.pinned;
- try {
- await chatAPI.updateConversation(convId, { pinned: newPinned });
- setConversations(prev => {
- const updated = prev.map(c => c.id === convId ? { ...c, pinned: newPinned } : c);
- const pinned = updated.filter(c => c.pinned);
- const unpinned = updated.filter(c => !c.pinned).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
- return [...pinned, ...unpinned];
- });
- } catch (err: any) {
- alert('操作失败: ' + (err.message || '未知错误'));
- }
- };
-
- // Handle convert to knowledge graph
- const handleKnowledgeGraph = async (convId: string, e: React.MouseEvent) => {
- e.stopPropagation();
- setKgModalOpen(true);
- setKgStep(1);
- setKgResult(null);
- setKgError(null);
- setKgConvId(convId);
-
- try {
- await new Promise(r => setTimeout(r, 600));
- setKgStep(2);
-
- const res = await chatAPI.convertToKnowledgeGraph(convId);
- await new Promise(r => setTimeout(r, 400));
- setKgStep(3);
-
- if (res.code === 0) {
- const graph = res.data.graph;
- setKgResult({
- nodes: graph.stats.total_nodes,
- edges: graph.stats.total_edges,
- kbName: res.data.knowledge_base_name,
- kbId: res.data.knowledge_base_id,
- });
- // 使用后端返回的真实计数(幂等,始终为 1),避免前端重复累加导致计数虚高
- const newCount = res.data.knowledge_graph_count ?? 1;
- setConversations(prev =>
- prev.map(c => c.id === convId ? { ...c, knowledgeGraphCount: newCount } : c)
- );
- } else {
- setKgError(res.message || '转换失败');
- }
- } catch (err) {
- setKgError((err as Error).message || '转换失败,请稍后重试');
- }
- };
-
- // 非流式兜底发送(与历史行为一致)——供 SSE 连接/流式失败时回退
- const sendLegacy = async (
- payload: any,
- tempUserMsg: Message,
- ) => {
- const res = await chatAPI.sendMessage(selectedConversation!.id, payload);
- const data = unwrapData(res);
-
- // Replace temporary user message and add assistant response
- setMessages(prev => {
- const filtered = prev.filter(m => m.id !== tempUserMsg.id);
- const newMsgs: Message[] = [];
- if (data.user_message) {
- newMsgs.push({
- id: data.user_message.id,
- conversationId: selectedConversation?.id || '',
- role: 'user',
- content: data.user_message.content,
- tokenUsed: data.user_message.token_used || 0,
- createdAt: data.user_message.created_at || new Date().toISOString(),
- });
- }
- if (data.assistant_message) {
- newMsgs.push({
- id: data.assistant_message.id,
- conversationId: selectedConversation?.id || '',
- role: 'assistant',
- content: data.assistant_message.content,
- tokenUsed: data.assistant_message.token_used || 0,
- createdAt: data.assistant_message.created_at || new Date().toISOString(),
- used_skills: data.assistant_message.used_skills || [],
- used_mcp: data.assistant_message.used_mcp || [],
- process_steps: data.assistant_message.process_steps || [],
- });
- }
- return [...filtered, ...newMsgs];
- });
-
- // 标记本轮新产生的助手消息,触发"思考过程"首屏展开
- if (data.assistant_message) {
- setFreshMsgIds(prev => new Set(prev).add(data.assistant_message.id));
- }
-
- // 媒体异步生成:启动轮询,待后端回写后自动刷新
- if (data.assistant_message?.generating) {
- startMediaPolling(selectedConversation!.id, data.assistant_message.id);
- }
-
- // Refresh conversations to update lastMessage
- loadConversations();
- // 发送后清空本次附件(选择状态仍保留)
- setAttachments([]);
-
- // GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI)
- if (selectedConversation?.id && data.assistant_message) {
- const recentMsgs = [
- ...(data.user_message ? [{ role: 'user', content: data.user_message.content, tokens: data.user_message.token_used || 0 }] : []),
- { role: 'assistant', content: data.assistant_message.content, tokens: data.assistant_message.token_used || 0 },
- ];
- evolutionAPI.submitTrajectory({
- conversation_id: selectedConversation.id,
- messages: recentMsgs,
- }).catch(() => {});
- }
- };
-
- // SSE 流式发送(Phase 0)——实时增量渲染 AI 回复文本与思考链(reasoning)
- const streamSend = async (
- convId: string,
- payload: any,
- tempUserMsg: Message,
- assistantId: string,
- ): Promise => {
- const token = localStorage.getItem('aiflow_token');
- const resp = await fetch(`/app/api/chat/conversations/${convId}/messages/stream`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
- },
- body: JSON.stringify(payload),
- });
- if (!resp.ok || !resp.body) {
- throw new Error(`SSE 连接失败 (${resp.status})`);
- }
-
- const reader = resp.body.getReader();
- const decoder = new TextDecoder();
- let buffer = '';
- let accReasoning = '';
- let gotDone = false;
-
- const patchAssistant = (patch: Partial) => {
- setMessages(prev => prev.map(m => (m.id === assistantId ? { ...m, ...patch } : m)));
- };
-
- while (true) {
- const { value, done } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
- let sep: number;
- while ((sep = buffer.indexOf('\n\n')) >= 0) {
- const raw = buffer.slice(0, sep);
- buffer = buffer.slice(sep + 2);
- const dataLine = raw.split('\n').find(l => l.startsWith('data: '));
- if (!dataLine) continue;
- let evt: any;
- try { evt = JSON.parse(dataLine.slice(6)); } catch { continue; }
- if (evt.type === 'delta') {
- const piece = evt.content || '';
- setMessages(prev => prev.map(m => (m.id === assistantId ? { ...m, content: (m.content || '') + piece } : m)));
- } else if (evt.type === 'reasoning') {
- accReasoning += evt.content || '';
- patchAssistant({ reasoning: accReasoning } as any);
- } else if (evt.type === 'done') {
- gotDone = true;
- const finalId = evt.message_id || assistantId;
- setMessages(prev => prev.map(m => {
- if (m.id !== assistantId) return m;
- return {
- ...m,
- id: finalId,
- content: evt.content ?? m.content,
- process_steps: evt.process_steps || [],
- used_skills: evt.used_skills || [],
- used_mcp: evt.used_mcp || [],
- generating: !!evt.generating,
- };
- }));
- setStreamingId(null);
- if (evt.generating) startMediaPolling(convId, finalId);
- setFreshMsgIds(prev => new Set(prev).add(finalId));
- loadConversations();
- setAttachments([]);
- if (convId && evt.content) {
- evolutionAPI.submitTrajectory({
- conversation_id: convId,
- messages: [
- { role: 'user', content: tempUserMsg.content, tokens: 0 },
- { role: 'assistant', content: evt.content, tokens: 0 },
- ],
- }).catch(() => {});
- }
- } else if (evt.type === 'error') {
- throw new Error(evt.message || '生成失败');
- }
- }
- }
- if (!gotDone) {
- // 流结束但未收到 done:已累积的 delta 仍可展示,但视为异常兜底
- setStreamingId(null);
- throw new Error('流式响应未正常结束');
- }
- };
-
- // Handle send message - CALLS REAL API(Phase 0:默认 SSE 流式,失败回退非流式)
- // 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用
- const sendText = async (content: string) => {
- if (!content.trim() || sending || !selectedConversation) return;
- setSending(true);
- setSendingTimeout(false);
- // 60s 超时警告
- if (sendingTimerRef.current) clearTimeout(sendingTimerRef.current);
- sendingTimerRef.current = setTimeout(() => {
- setSendingTimeout(true);
- }, 60000);
-
- // Add user message optimistically
- const tempUserMsg: Message = {
- id: `temp_${Date.now()}`,
- conversationId: selectedConversation?.id || '',
- role: 'user',
- content,
- tokenUsed: 0,
- createdAt: new Date().toISOString(),
- };
- setMessages(prev => [...prev, tempUserMsg]);
-
- const payload = {
- content,
- model_id: selectedModelId || undefined,
- agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
- skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
- knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
- mcp_ids: selectedMcpIds.length > 0 ? selectedMcpIds : undefined,
- attachments: attachments.length > 0 ? attachments.map(a => ({
- name: a.name, file_type: a.file_type, content: a.content || '',
- })) : undefined,
- use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0 || selectedMcpIds.length > 0,
- };
-
- // 先放一个空的助手消息占位,用于 SSE 流式增量渲染
- const assistantId = `temp_assistant_${Date.now()}`;
- setMessages(prev => [...prev, {
- id: assistantId,
- conversationId: selectedConversation!.id,
- role: 'assistant',
- content: '',
- tokenUsed: 0,
- createdAt: new Date().toISOString(),
- reasoning: '',
- } as Message]);
- setStreamingId(assistantId);
-
- try {
- await streamSend(selectedConversation!.id, payload, tempUserMsg, assistantId);
- } catch (err: any) {
- // 移除流式占位,回退非流式重新发送
- setMessages(prev => prev.filter(m => m.id !== assistantId && m.id !== tempUserMsg.id));
- setStreamingId(null);
- try {
- await sendLegacy(payload, tempUserMsg);
- } catch (e2: any) {
- setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id));
- alert('发送失败: ' + (e2?.message || e2?.detail || '未知错误'));
- }
- } finally {
- if (sendingTimerRef.current) { clearTimeout(sendingTimerRef.current); sendingTimerRef.current = null; }
- setSending(false);
- setSendingTimeout(false);
- inputRef.current?.focus();
- }
- };
-
- // 文件上传:读取文本类文件内容作为附件上下文
- const handleFileChange = (e: React.ChangeEvent) => {
- const files = Array.from(e.target.files || []);
- if (!files.length) return;
- const textExts = ['txt', 'md', 'markdown', 'csv', 'json', 'log', 'py', 'js', 'ts', 'tsx', 'jsx', 'html', 'css', 'xml', 'yaml', 'yml', 'sql', 'text'];
- files.forEach(file => {
- const ext = (file.name.split('.').pop() || '').toLowerCase();
- const isText = textExts.includes(ext) || file.type.startsWith('text/');
- if (isText && file.size <= 200 * 1024) {
- const reader = new FileReader();
- reader.onload = () => {
- const content = String(reader.result || '');
- setAttachments(prev => [...prev, { name: file.name, file_type: ext || 'txt', content }]);
- };
- reader.readAsText(file);
- } else {
- setAttachments(prev => [...prev, { name: file.name, file_type: ext || file.type || 'bin' }]);
- }
- });
- e.target.value = '';
- };
-
- // 语音输入(浏览器 Web Speech API,支持中文)
- const handleMicClick = () => {
- const SR: any = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
- if (!SR) {
- alert('当前浏览器不支持语音输入,请使用 Chrome / Edge 等浏览器。');
- return;
- }
- if (recording) {
- recognitionRef.current?.stop();
- return;
- }
- const rec = new SR();
- recognitionRef.current = rec;
- rec.lang = 'zh-CN';
- rec.interimResults = true;
- rec.continuous = false;
- rec.onresult = (ev: any) => {
- let text = '';
- for (let i = 0; i < ev.results.length; i++) text += ev.results[i][0].transcript;
- setInputValue(prev => (prev ? prev + text : text));
- };
- rec.onend = () => { setRecording(false); recognitionRef.current = null; };
- rec.onerror = () => { setRecording(false); recognitionRef.current = null; };
- setRecording(true);
- rec.start();
- };
-
- // 语音合成(朗读回复,浏览器 TTS)
- const speakText = (text: string) => {
- if (typeof window === 'undefined' || !('speechSynthesis' in window)) {
- alert('当前浏览器不支持朗读。');
- return;
- }
- const synth = window.speechSynthesis;
- synth.cancel();
- const plain = text
- .replace(/```[\s\S]*?```/g, ' ')
- .replace(/[#>*`_~\-]+/g, ' ')
- .replace(/\!\[[^\]]*\]\([^)]*\)/g, ' ')
- .replace(/\[[^\]]*\]\([^)]*\)/g, ' ')
- .replace(/\s+/g, ' ')
- .trim();
- const u = new SpeechSynthesisUtterance(plain);
- u.lang = 'zh-CN';
- u.rate = 1;
- synth.speak(u);
- };
-
- const handleSend = () => {
- const content = inputValue.trim();
- if (!content || sending) return;
- setInputValue('');
- sendText(content);
- };
-
- // Handle key down
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleSend();
- }
- };
-
- return (
-
- {/* Left Sidebar - Conversation List */}
-
-
- {/* Right Main Chat Area */}
-
- {selectedConversation ? (
- <>
- {/* Chat Header */}
-
-
-
-
- {selectedModel.name}
- {(selectedModel.status === 'free' || selectedModel.price_per_million_tokens === 0) ? '免费' : '付费'}
-
-
- }
- >
-
- {chatModels.filter(m => m.is_enabled !== false).map(model => (
-
- ))}
-
-
-
{selectedConversation.title}
-
-
-
- {/* Messages Area */}
-
- {loadingMessages ? (
-
- ) : messages.length === 0 ? (
-
-
-
开始一段新对话
-
- 向 {selectedModel.name} 发送消息,开始对话
-
-
- ) : (
- messages.map(msg => {
- // Real used_skills from API response
- const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
- const usedMcp: string[] = (msg.role === 'assistant' && msg.used_mcp) ? msg.used_mcp : [];
- const steps: any[] = (msg as any).process_steps || [];
- // 模糊创作意图确认卡片
- const clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null;
- return (
-
- {msg.role === 'assistant' && steps.length > 0 && (
-
- )}
- {msg.role === 'assistant' && (msg as any).reasoning && (
-
-
- 🤔
- AI 思考过程(实时)
-
-
-
-
💡
-
-
{(msg as any).reasoning}
-
-
-
-
- )}
-
- {clarify ? (
-
sendText(`${opt.medium}:${clarify.prompt}`)}
- />
- ) : msg.role === 'assistant' && isAiError(msg.content) ? (
-
-
⚠️
-
-
-
-
-
- ) : msg.role === 'assistant' ? (
- msg.content ? : (streamingId === msg.id ? 生成中… : null)
- ) : (
- {msg.content}
- )}
-
- {!clarify && msg.role === 'assistant' && msg.content && (
-
- )}
- {/* Skill usage tags */}
- {msg.role === 'assistant' && usedSkills.length > 0 && (
-
- {usedSkills.slice(0, 3).map(skillName => (
-
{ window.location.href = '/evolution?skill=' + encodeURIComponent(skillName); }}
- title={`使用了专属技能:${skillName}`}
- >
-
- {skillName}
-
- ))}
- {usedSkills.length > 3 && (
-
+{usedSkills.length - 3}
- )}
-
- )}
- {msg.role === 'assistant' && usedMcp.length > 0 && (
-
- {usedMcp.slice(0, 3).map(mcpName => (
-
-
- {mcpName}
-
- ))}
- {usedMcp.length > 3 && (
- +{usedMcp.length - 3}
- )}
-
- )}
-
- {formatTime(msg.createdAt)}
- {msg.tokenUsed} tokens
-
-
- );
- })
- )}
- {sending && !streamingId && (
-
-
-
- {sendingTimeout ? '⏳ 响应时间较长,AI 仍在处理中...' : '思考中...'}
- {sendingTimeout && (
-
- )}
-
-
- )}
-
-
-
- {/* Input Area */}
-
-
-
-
-
- {selectedKbIds.length > 0 ? `知识库 (${selectedKbIds.length})` : '知识库'}
-
- }
- >
- 选择知识库(多选)
- {knowledgeBases.map((kb: any) => (
-
- ))}
- {knowledgeBases.length > 0 && selectedKbIds.length > 0 && (
-
- )}
- {selectedKbIds.length > 0 && (
-
- )}
- {knowledgeBases.length === 0 && (
- 暂无可用知识库
- )}
-
-
-
-
- {selectedAgentIds.length > 0 ? `Agent (${selectedAgentIds.length})` : 'Agent'}
-
- }
- >
- 选择 Agent(多选)
- {agents.map((agent: any) => (
-
- ))}
- {agents.length > 0 && selectedAgentIds.length > 0 && (
-
- )}
- {selectedAgentIds.length > 0 && (
-
- )}
- {agents.length === 0 && (
- 暂无可用 Agent
- )}
-
-
-
-
- {selectedSkillIds.length > 0 ? `技能 (${selectedSkillIds.length})` : '技能'}
-
- }
- >
- 选择技能(多选)
- {skills.map((skill: any) => (
-
- ))}
- {skills.length > 0 && selectedSkillIds.length > 0 && (
-
- )}
- {selectedSkillIds.length > 0 && (
-
- )}
- {skills.length === 0 && (
- 暂无可用技能
- )}
-
-
-
-
- {selectedMcpIds.length > 0 ? `MCP (${selectedMcpIds.length})` : 'MCP'}
-
- }
- >
- 选择 MCP 应用(多选)
- {mcps.map((mcp: any) => (
-
- ))}
- {mcps.length > 0 && selectedMcpIds.length > 0 && (
-
- )}
- {selectedMcpIds.length > 0 && (
-
- )}
- {mcps.length === 0 && (
- 暂无可用 MCP 应用
- )}
-
-
-
-
-
-
- {attachments.length > 0 && (
-
- {attachments.map((att, idx) => (
-
-
- {att.name}
-
-
- ))}
-
- )}
-
-
-
-
-
- >
- ) : (
-
-
-
选择或创建对话
-
从左侧列表选择一个对话,或点击上方按钮创建新对话
-
- )}
-
-
- {/* Knowledge Graph Conversion Progress Modal */}
- {kgModalOpen && (
- !kgError && kgStep !== 2 && setKgModalOpen(false)}>
-
e.stopPropagation()}>
-
-
转换知识图谱
-
-
-
- {kgError ? (
-
-
-
-
-
{kgError}
-
-
- ) : kgStep === 3 && kgResult ? (
-
-
-
知识图谱生成完成
-
-
- {kgResult.nodes}
- 节点
-
-
-
- {kgResult.edges}
- 条边
-
-
-
已存入知识库:{kgResult.kbName}
-
-
-
-
-
- ) : (
-
-
-
= 1 ? 'active' : ''} ${kgStep > 1 ? 'done' : ''}`}>
-
- {kgStep > 1 ? (
-
- ) : kgStep === 1 ? (
-
- ) : (
-
1
- )}
-
-
提取对话内容
-
-
-
1 ? 'done' : ''}`}>
- {kgStep === 2 &&
}
-
-
-
= 2 ? 'active' : ''} ${kgStep > 2 ? 'done' : ''}`}>
-
- {kgStep > 2 ? (
-
- ) : kgStep === 2 ? (
-
- ) : (
-
2
- )}
-
-
构建知识图谱
-
-
-
2 ? 'done' : ''}`} />
-
-
= 3 ? 'active' : ''} ${kgStep > 3 ? 'done' : ''}`}>
-
- 3
-
-
完成
-
-
-
- {kgStep === 1 ? '正在提取对话内容...' : kgStep === 2 ? '正在构建知识图谱...' : '处理完成'}
-
-
- )}
-
-
-
- )}
-
- );
-};
-
-export default ChatPage;
+import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { chatAPI, knowledgeAPI, marketAPI, evolutionAPI } from '@/api';
+import { formatChatTime } from '@/utils/datetime';
+import type { Conversation, Message } from '@/types';
+import './ChatPage.css';
+
+// ===== 增强渲染依赖 =====
+import 'highlight.js/styles/github.css';
+import 'katex/dist/katex.min.css';
+import mermaid from 'mermaid';
+
+mermaid.initialize({ startOnLoad: false, theme: 'default' });
+
+// ===== AI 回复异常检测 =====
+const AI_ERROR_PATTERNS = [
+ 'AI模型调用失败',
+ 'AI服务暂时不可用',
+ 'AI模型返回了空响应',
+ 'AI模型已处理工具调用结果',
+ '该模型缺少API密钥或端点配置',
+ '请选择一个已配置的AI模型',
+ '请先在后台管理-大模型库配置默认对话模型',
+ '⚠️ 多 Agent 编排执行失败',
+ '⚠️ 外部 Agent 调用失败',
+ '编排未产生内容',
+ '外部 Agent 未返回内容',
+];
+function isAiError(content: string): boolean {
+ if (!content) return false;
+ return AI_ERROR_PATTERNS.some(p => content.includes(p));
+}
+
+// ===== Enhanced Markdown Renderer =====
+// 媒体(图片/视频)以受限缩略图呈现,并提供下载(格式+尺寸)选项
+import { renderMarkdownToHtml, MediaInfo } from '@/utils/markdown';
+
+// 全屏灯箱预览
+const ChatLightbox: React.FC<{ src: string; type: 'img' | 'video'; onClose: () => void }> = ({ src, type, onClose }) => {
+ useEffect(() => {
+ const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
+ document.addEventListener('keydown', h);
+ return () => document.removeEventListener('keydown', h);
+ }, [onClose]);
+ return (
+
+
+
e.stopPropagation()}>
+ {type === 'video'
+ ?
+ :

}
+
+
+ );
+};
+
+// 根据原始尺寸推算 3 个建议下载尺寸(保持宽高比)
+function computeSizes(natural: { w: number; h: number }) {
+ if (natural.w && natural.h) {
+ const ratio = natural.w / natural.h;
+ const fit = (maxDim: number) => ratio >= 1
+ ? { w: maxDim, h: Math.round(maxDim / ratio) }
+ : { w: Math.round(maxDim * ratio), h: maxDim };
+ return [
+ { label: '小图', ...fit(512) },
+ { label: '中图', ...fit(1024) },
+ { label: '原始', w: natural.w, h: natural.h },
+ ];
+ }
+ // 未知原始尺寸时按生成默认 4:3 给出三档
+ return [
+ { label: '小图', w: 512, h: 384 },
+ { label: '中图', w: 1024, h: 768 },
+ { label: '大图', w: 2048, h: 1536 },
+ ];
+}
+
+// 客户端画布转换格式+尺寸后下载;跨域受限时回退到新标签页打开原图
+function downloadImage(src: string, format: 'png' | 'jpg' | 'webp', w: number, h: number) {
+ const fallback = () => window.open(src, '_blank');
+ const img = new Image();
+ img.crossOrigin = 'anonymous';
+ img.onload = () => {
+ try {
+ const canvas = document.createElement('canvas');
+ canvas.width = w; canvas.height = h;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) { fallback(); return; }
+ if (format === 'jpg') { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); }
+ ctx.drawImage(img, 0, 0, w, h);
+ const mime = format === 'jpg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png';
+ canvas.toBlob((blob) => {
+ if (!blob) { fallback(); return; }
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url; a.download = `aiflow-image-${w}x${h}.${format}`;
+ document.body.appendChild(a); a.click(); a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }, mime, 0.92);
+ } catch (e) {
+ fallback();
+ }
+ };
+ img.onerror = fallback;
+ img.src = src;
+}
+
+// 媒体渲染组件:固定尺寸缩略图 + 点击放大 + 下载(格式/尺寸)
+const ChatMedia: React.FC<{ info: MediaInfo }> = ({ info }) => {
+ const [open, setOpen] = useState(false);
+ const [dlOpen, setDlOpen] = useState(false);
+ const [fmt, setFmt] = useState<'png' | 'jpg' | 'webp'>('png');
+ const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 0, h: 0 });
+
+ if (info.type === 'video') {
+ return (
+
+
+ );
+ }
+
+ const sizes = computeSizes(natural);
+ return (
+
+

setOpen(true)}
+ onLoad={(e) => {
+ const im = e.currentTarget;
+ if (im.naturalWidth && im.naturalHeight) setNatural({ w: im.naturalWidth, h: im.naturalHeight });
+ }}
+ />
+
+ {dlOpen && (
+
e.stopPropagation()}>
+
下载图片
+
+
格式
+
+ {(['png', 'jpg', 'webp'] as const).map((f) => (
+
+ ))}
+
+
+
尺寸
+
+ {sizes.map((s, i) => (
+
+ ))}
+
+
+ )}
+ {open &&
setOpen(false)} />}
+
+ );
+};
+
+const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
+ // 渲染管线(代码块/加粗/表格/换行/媒体占位符等)已抽到 src/utils/markdown.ts,
+ // 组件只负责把返回的 html 切分为「文本片段 + 媒体组件」。
+ const { html, media } = useMemo(() => renderMarkdownToHtml(content), [content]);
+
+ const nodes = useMemo(() => {
+ // 12. 拆分为文本片段与媒体组件(媒体以 \x00MEDIA\x00 占位符保留在 html 中)
+ const parts = html.split(/(\x00MEDIA\d+\x00)/g);
+ return parts.map((part: string, i: number) => {
+ const m = part.match(/^\x00MEDIA(\d+)\x00$/);
+ if (m) {
+ const info = media[parseInt(m[1])];
+ if (info && info.src) return ;
+ return null;
+ }
+ if (!part) return null;
+ // 仅清除被媒体切分产生的悬空 标签,保留段落内部的成对
+ let s = part.replace(/^<\/p>/, '').replace(/
$/, '');
+ if (!s.trim()) return null;
+ return
;
+ });
+ }, [html, media]);
+
+ const containerRef = useRef(null);
+ useEffect(() => {
+ if (!containerRef.current) return;
+ const diagrams = containerRef.current.querySelectorAll('.md-mermaid:not(.md-mermaid-rendered)');
+ if (diagrams.length === 0) return;
+ const timer = setTimeout(async () => {
+ for (const el of diagrams) {
+ try {
+ const code = el.getAttribute('data-code') || '';
+ if (!code) continue;
+ const id = 'mermaid-' + Math.random().toString(36).slice(2, 10);
+ const { svg } = await mermaid.render(id, code);
+ el.innerHTML = svg;
+ el.classList.add('md-mermaid-rendered');
+ } catch (e) {
+ el.innerHTML = 'Mermaid: ' + (e as Error).message + '
';
+ }
+ }
+ }, 50);
+ return () => clearTimeout(timer);
+ }, [content]);
+
+ return {nodes}
;
+};
+
+// 解析后端返回的「模糊创作意图」确认指令:<>{json}<>
+function parseClarify(content: string): { prompt: string; options: any[] } | null {
+ if (!content || typeof content !== 'string') return null;
+ const m = content.match(/^<>([\s\S]+)<>$/);
+ if (!m) return null;
+ try {
+ const obj = JSON.parse(m[1]);
+ if (obj && Array.isArray(obj.options)) return obj;
+ } catch { /* ignore */ }
+ return null;
+}
+
+// 模糊创作意图确认卡片:让用户选择产出方向(文案/图片/视频/其他)
+const ClarifyCard: React.FC<{ data: { prompt: string; options: any[] }; onChoose: (opt: any) => void }> = ({ data, onChoose }) => {
+ return (
+
+
🤔 这个请求的方向还不太明确,您希望以哪种形式呈现?
+
「{data.prompt}」
+
+ {data.options.map((opt: any) => (
+
+ ))}
+
+
+ );
+};
+
+
+// ===== Time Formatting =====
+function formatTime(iso: string): string {
+ return formatChatTime(iso);
+}
+
+// ===== Helpers: normalize API responses =====
+function unwrapList(res: any): any[] {
+ const data = res?.data !== undefined ? res.data : res;
+ if (Array.isArray(data)) return data;
+ if (data?.items) return data.items;
+ return [];
+}
+
+function unwrapData(res: any): any {
+ return res?.data !== undefined ? res.data : res;
+}
+
+// ===== Icons =====
+const IconSearch = () => (
+
+);
+
+const IconPlus = () => (
+
+);
+
+const IconMore = () => (
+
+);
+
+const IconSend = () => (
+
+);
+
+const IconChevronDown = () => (
+
+);
+
+const IconEdit = () => (
+
+);
+
+const IconArchive = () => (
+
+);
+
+const IconTrash = () => (
+
+);
+
+const IconPin = () => (
+
+);
+
+const IconGraph = () => (
+
+);
+
+const IconKnowledge = () => (
+
+);
+
+const IconAgent = () => (
+
+);
+
+const IconSkill = () => (
+
+);
+
+const IconMcp = () => (
+
+);
+
+const IconPaperclip = () => (
+
+);
+
+const IconMic = () => (
+
+);
+
+const IconStop = () => (
+
+);
+
+const IconVolume = () => (
+
+);
+
+const IconModel = () => (
+
+);
+
+const IconEmpty = () => (
+
+);
+
+const IconSpinner = () => (
+
+);
+
+// ===== Dropdown Component =====
+interface DropdownProps {
+ trigger: React.ReactNode;
+ children: React.ReactNode;
+ className?: string;
+ align?: 'left' | 'right';
+ dropup?: boolean;
+ closeOnClick?: boolean;
+}
+
+const Dropdown: React.FC = ({ trigger, children, className = '', align = 'left', dropup = false, closeOnClick = true }) => {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+ const menuRef = useRef(null);
+ const openRef = useRef(open);
+ openRef.current = open;
+
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ const target = e.target as Node;
+ // 如果点击在wrapper内部,不关闭
+ if (ref.current?.contains(target)) return;
+ setOpen(false);
+ };
+ document.addEventListener('mousedown', handleClickOutside, true);
+ return () => document.removeEventListener('mousedown', handleClickOutside, true);
+ }, []);
+
+ // 打开时处理z-index
+ useEffect(() => {
+ if (open && dropup) {
+ const menu = menuRef.current;
+ if (menu) {
+ menu.style.zIndex = '9999';
+ }
+ }
+ }, [open, dropup]);
+
+ const alignClass = align === 'right' ? 'align-right' : 'align-left';
+ const dropupClass = dropup ? 'dropup' : '';
+
+ const handleTriggerMouseDown = (e: React.MouseEvent) => {
+ // 阻止冒泡到document,防止被handleClickOutside误关
+ e.stopPropagation();
+ // 基于当前状态切换(在mousedown阶段决定,避免与click冲突)
+ setOpen(prev => !prev);
+ };
+
+ const handleTriggerClick = (e: React.MouseEvent) => {
+ // 阻止click冒泡到父级的导航onClick
+ e.stopPropagation();
+ e.preventDefault();
+ };
+
+ return (
+
+
+ {trigger}
+
+ {open && (
+
e.stopPropagation()}
+ onClick={(e) => { e.stopPropagation(); if (closeOnClick) setOpen(false); }}
+ >
+ {children}
+
+ )}
+
+ );
+};
+
+// ===== AI 思考 / 执行过程面板(顶部展开 + 自动收拢) =====
+// autoExpand=true 时(本轮刚产生的助手消息)首屏展开呈现思考链路,
+// 6s 后若用户未手动干预则自动收拢为一行摘要,避免与回复内容冲突。
+// 展开时自动滚动到视图内,带脉冲动画提示用户注意。
+const AiProcessBlock: React.FC<{ steps: any[]; autoExpand: boolean }> = ({ steps, autoExpand }) => {
+ const [open, setOpen] = useState(autoExpand);
+ const [fresh, setFresh] = useState(autoExpand);
+ const timerRef = useRef(null);
+ const touchedRef = useRef(false);
+ const blockRef = useRef(null);
+
+ useEffect(() => {
+ if (!autoExpand) {
+ setOpen(false);
+ setFresh(false);
+ return;
+ }
+ setOpen(true);
+ setFresh(true);
+ touchedRef.current = false;
+ // 自动滚动到视图内(上方留 60px 避免被顶部遮挡)
+ if (blockRef.current) {
+ blockRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ timerRef.current = window.setTimeout(() => {
+ if (!touchedRef.current) {
+ setOpen(false);
+ // 收拢后短暂保留 fresh 样式再移除
+ setTimeout(() => setFresh(false), 500);
+ }
+ }, 6000);
+ return () => {
+ if (timerRef.current) window.clearTimeout(timerRef.current);
+ };
+ }, [autoExpand]);
+
+ if (!steps || steps.length === 0) return null;
+
+ const toggle = () => {
+ touchedRef.current = true;
+ setFresh(false);
+ if (timerRef.current) {
+ window.clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ setOpen(o => !o);
+ };
+
+ return (
+
+ { e.preventDefault(); toggle(); }}>
+ 🧠
+ AI 思考 / 执行过程({steps.length} 步 — {open ? '自动收拢中... 点击手动收起' : '点击展开查看'})
+
+
+ {steps.map((s: any, i: number) => (
+
+
+ {s.type === 'thinking' ? '💡' : s.type === 'skill' ? '🛠' : '🔌'}
+
+
+ {s.type === 'thinking' && (<>
思考{s.text}
>)}
+ {s.type === 'skill' && (<>
调用技能:{s.name}{(s.result || '').slice(0, 400)}
>)}
+ {s.type === 'mcp' && (<>
MCP · {s.server}:{s.name}{(s.result || '').slice(0, 400)}
>)}
+
+
+ ))}
+
+
+ );
+};
+
+// 思考过程收拢式展示(缺陷5:默认折叠;fresh 流式消息自动展开,数秒后自动收拢)
+const ReasoningBlock: React.FC<{ text: string; fresh: boolean }> = ({ text, fresh }) => {
+ const [open, setOpen] = useState(fresh);
+ const [isFresh, setFreshState] = useState(fresh);
+ const touchedRef = useRef(false);
+ const timerRef = useRef(null);
+ useEffect(() => {
+ if (!fresh) {
+ setOpen(false);
+ setFreshState(false);
+ return;
+ }
+ setOpen(true);
+ setFreshState(true);
+ touchedRef.current = false;
+ timerRef.current = window.setTimeout(() => {
+ if (!touchedRef.current) {
+ setOpen(false);
+ setTimeout(() => setFreshState(false), 500);
+ }
+ }, 5000);
+ return () => { if (timerRef.current) window.clearTimeout(timerRef.current); };
+ }, [fresh]);
+ if (!text) return null;
+ const toggle = () => {
+ touchedRef.current = true;
+ setFreshState(false);
+ if (timerRef.current) window.clearTimeout(timerRef.current);
+ setOpen(o => !o);
+ };
+ return (
+
+ { e.preventDefault(); toggle(); }}>
+ 🤔
+ AI 思考过程{open ? '(收拢中... 点击收起)' : '(点击展开)'}
+
+
+
+ );
+};
+
+// ===== Main Component =====
+const ChatPage: React.FC = () => {
+ const { id } = useParams<{ id?: string }>();
+ const navigate = useNavigate();
+ const [searchQuery, setSearchQuery] = useState('');
+ const [selectedModelId, setSelectedModelId] = useState('auto');
+ const [chatModels, setChatModels] = useState([]);
+ const [inputValue, setInputValue] = useState('');
+ const [sending, setSending] = useState(false);
+ const [sendingTimeout, setSendingTimeout] = useState(false);
+ const sendingTimerRef = useRef | null>(null);
+ // 正在 SSE 流式渲染的助手消息 id;非空时隐藏"思考中"占位气泡
+ const [streamingId, setStreamingId] = useState(null);
+ // 流式中断控制器(缺陷4:支持「停止生成」)
+ const abortRef = useRef(null);
+ const stopStreaming = () => { try { abortRef.current?.abort(); } catch { /* noop */ } };
+ const [conversations, setConversations] = useState([]);
+ const [messages, setMessages] = useState([]);
+ const [loadingConversations, setLoadingConversations] = useState(true);
+ const [loadingMessages, setLoadingMessages] = useState(false);
+ // 本轮对话新产生的助手消息 id 集合,用于触发"思考过程"首屏展开
+ const [freshMsgIds, setFreshMsgIds] = useState>(new Set());
+ const [knowledgeBases, setKnowledgeBases] = useState([]);
+ const [agents, setAgents] = useState([]);
+ const [skills, setSkills] = useState([]);
+ const [mcps, setMcps] = useState([]);
+ // 多选组合模式
+ const [selectedAgentIds, setSelectedAgentIds] = useState([]);
+ const [selectedSkillIds, setSelectedSkillIds] = useState([]);
+ const [selectedKbIds, setSelectedKbIds] = useState([]);
+ const [selectedMcpIds, setSelectedMcpIds] = useState([]);
+ // 文件附件
+ const [attachments, setAttachments] = useState<{ name: string; file_type: string; content?: string }[]>([]);
+ const fileInputRef = useRef(null);
+ const recognitionRef = useRef(null);
+ const [recording, setRecording] = useState(false);
+ const messagesEndRef = useRef(null);
+ const inputRef = useRef(null);
+
+ // Knowledge graph conversion state
+ const [kgModalOpen, setKgModalOpen] = useState(false);
+ const [kgStep, setKgStep] = useState<1 | 2 | 3>(1);
+ const [kgResult, setKgResult] = useState<{ nodes: number; edges: number; kbName: string; kbId: string } | null>(null);
+ const [kgError, setKgError] = useState(null);
+ const [kgConvId, setKgConvId] = useState(null);
+
+ // Load models from backend API
+ useEffect(() => {
+ chatAPI.getModels().then((res: any) => {
+ const data = res.data || res;
+ const models = Array.isArray(data) ? data : (data?.items || []);
+ setChatModels(models);
+ if (models.length > 0 && !selectedModelId) {
+ setSelectedModelId('auto');
+ }
+ }).catch(() => {
+ setChatModels([]);
+ });
+ }, []);
+
+ // Load conversations from API
+ const loadConversations = useCallback(async () => {
+ try {
+ setLoadingConversations(true);
+ const res = await chatAPI.getConversations();
+ const convs = unwrapList(res);
+ // Sort: pinned first, then by updatedAt desc
+ const sorted = [...convs].sort((a: any, b: any) => {
+ if (a.pinned && !b.pinned) return -1;
+ if (!a.pinned && b.pinned) return 1;
+ return new Date(b.updatedAt || b.updated_at || 0).getTime() - new Date(a.updatedAt || a.updated_at || 0).getTime();
+ });
+ // Normalize field names
+ const mapped: Conversation[] = sorted.map((c: any) => ({
+ id: c.id,
+ userId: c.userId || c.user_id || '',
+ title: c.title || '未命名对话',
+ modelId: c.modelId || c.model_id || '',
+ status: c.status || 'active',
+ pinned: c.pinned || false,
+ knowledgeGraphCount: c.knowledgeGraphCount ?? c.knowledge_graph_count ?? 0,
+ lastMessage: c.lastMessage || c.last_message || '',
+ createdAt: c.createdAt || c.created_at || new Date().toISOString(),
+ updatedAt: c.updatedAt || c.updated_at || new Date().toISOString(),
+ }));
+ setConversations(mapped);
+ } catch (err) {
+ console.error('加载对话列表失败', err);
+ } finally {
+ setLoadingConversations(false);
+ }
+ }, []);
+
+ // Load conversations on mount
+ useEffect(() => {
+ loadConversations();
+ }, [loadConversations]);
+
+ // Load messages for selected conversation
+ useEffect(() => {
+ if (!id) {
+ setMessages([]);
+ return;
+ }
+ const loadMessages = async () => {
+ try {
+ setLoadingMessages(true);
+ const res = await chatAPI.getMessages(id);
+ const msgs = unwrapList(res);
+ const mapped: Message[] = msgs.map((m: any) => ({
+ id: m.id,
+ conversationId: m.conversationId || m.conversation_id || id,
+ role: m.role,
+ content: m.content,
+ tokenUsed: m.tokenUsed ?? m.token_used ?? 0,
+ createdAt: m.createdAt || m.created_at || new Date().toISOString(),
+ }));
+ setMessages(mapped);
+ // 页面刷新时若仍有「⏳」占位的媒体消息,继续轮询后端回写结果
+ mapped.forEach((m) => {
+ if (m.role === 'assistant' && typeof m.content === 'string' && m.content.startsWith('⏳')) {
+ startMediaPolling(id, m.id);
+ }
+ });
+ } catch (err) {
+ console.error('加载消息失败', err);
+ setMessages([]);
+ } finally {
+ setLoadingMessages(false);
+ }
+ };
+ loadMessages();
+ }, [id]);
+
+ // 媒体(图像/视频)异步生成轮询:后端先返回「⏳」占位,前端定时拉取该消息最新内容并回写
+ const pollMediaRef = useRef | null>>({});
+
+ const stopMediaPolling = (msgId: string) => {
+ const t = pollMediaRef.current[msgId];
+ if (t) {
+ clearInterval(t);
+ pollMediaRef.current[msgId] = null;
+ }
+ };
+
+ const startMediaPolling = (convId: string, msgId: string) => {
+ if (pollMediaRef.current[msgId]) return; // 已在轮询
+ let ticks = 0;
+ const timer = setInterval(async () => {
+ ticks += 1;
+ try {
+ const res = await chatAPI.getMessages(convId);
+ const msgs: any[] = unwrapList(res);
+ const updated = msgs.find((m: any) => m.id === msgId);
+ if (!updated) {
+ stopMediaPolling(msgId);
+ return;
+ }
+ const content: string = updated.content || '';
+ if (content.startsWith('⏳')) {
+ if (ticks > 120) stopMediaPolling(msgId); // ~6 分钟兜底
+ return;
+ }
+ // 生成完成:回写消息内容
+ setMessages(prev => prev.map(m => m.id === msgId ? {
+ ...m,
+ content,
+ tokenUsed: updated.tokenUsed ?? updated.token_used ?? m.tokenUsed,
+ createdAt: updated.createdAt || updated.created_at || m.createdAt,
+ used_skills: updated.usedSkills ?? updated.used_skills ?? m.used_skills,
+ used_mcp: updated.usedMcp ?? updated.used_mcp ?? m.used_mcp,
+ } : m));
+ stopMediaPolling(msgId);
+ } catch (e) {
+ if (ticks > 120) stopMediaPolling(msgId); // 网络异常兜底
+ }
+ }, 3000);
+ pollMediaRef.current[msgId] = timer;
+ };
+
+ // 卸载时清理所有轮询定时器
+ useEffect(() => {
+ return () => {
+ Object.values(pollMediaRef.current).forEach(t => t && clearInterval(t));
+ };
+ }, []);
+
+ // Load knowledge bases, agents, skills for attachment dropdowns
+ useEffect(() => {
+ knowledgeAPI.getBases().then((res: any) => {
+ const data = res?.data !== undefined ? res.data : res;
+ if (data?.bases && Array.isArray(data.bases)) {
+ setKnowledgeBases(data.bases);
+ } else if (Array.isArray(data)) {
+ setKnowledgeBases(data);
+ } else if (data?.items) {
+ setKnowledgeBases(data.items);
+ } else {
+ setKnowledgeBases([]);
+ }
+ }).catch(() => {});
+ marketAPI.getApps({ type: 'agent' }).then((res: any) => {
+ const data = res?.data !== undefined ? res.data : res;
+ let apps: any[] = [];
+ if (Array.isArray(data)) apps = data;
+ else if (data?.items) apps = data.items;
+ // 显示所有已发布的Agent(不需要安装即可在聊天中使用)
+ setAgents(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
+ }).catch(() => {});
+ marketAPI.getApps({ type: 'skill' }).then((res: any) => {
+ const data = res?.data !== undefined ? res.data : res;
+ let apps: any[] = [];
+ if (Array.isArray(data)) apps = data;
+ else if (data?.items) apps = data.items;
+ // 显示所有已发布的技能(不需要安装即可在聊天中使用)
+ setSkills(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
+ }).catch(() => {});
+ marketAPI.getApps({ type: 'mcp' }).then((res: any) => {
+ const data = res?.data !== undefined ? res.data : res;
+ let apps: any[] = [];
+ if (Array.isArray(data)) apps = data;
+ else if (data?.items) apps = data.items;
+ setMcps(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
+ }).catch(() => {});
+ }, []);
+
+ // Determine selected conversation
+ const selectedConversation = useMemo(() => {
+ if (id) {
+ return conversations.find(c => c.id === id) || null;
+ }
+ return conversations[0] || null;
+ }, [id, conversations]);
+
+ // Filter conversations by search
+ const filteredConversations = useMemo(() => {
+ const pinned = conversations.filter(c => c.pinned);
+ const unpinned = conversations.filter(c => !c.pinned);
+ if (!searchQuery.trim()) return [...pinned, ...unpinned];
+ const q = searchQuery.toLowerCase();
+ return [...pinned.filter(c => c.title.toLowerCase().includes(q)), ...unpinned.filter(c => c.title.toLowerCase().includes(q))];
+ }, [conversations, searchQuery]);
+
+ // Selected model info(Auto 自动:按对话意图自动匹配后台各能力默认模型)
+ const isAutoModel = selectedModelId === 'auto';
+ const AUTO_MODEL = { id: 'auto', name: 'Auto 自动', status: 'free', provider_name: '按能力智能匹配', capabilities: [], price_per_million_tokens: 0 };
+ const selectedModel = isAutoModel
+ ? AUTO_MODEL
+ : (chatModels.find(m => m.id === selectedModelId) || chatModels[0] || AUTO_MODEL);
+
+ // Scroll to bottom when messages change
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [messages]);
+
+ // Select first conversation if no id param
+ useEffect(() => {
+ if (!id && conversations.length > 0) {
+ navigate(`/chat/${conversations[0].id}`, { replace: true });
+ }
+ }, [id, conversations, navigate]);
+
+ // Handle new conversation
+ const handleNewConversation = async () => {
+ try {
+ const res = await chatAPI.createConversation({ title: '新对话', model_id: selectedModelId === 'auto' ? (chatModels[0]?.id || '') : selectedModelId });
+ const newConv = unwrapData(res);
+ const conv: Conversation = {
+ id: newConv.id,
+ userId: newConv.userId || newConv.user_id || '',
+ title: newConv.title || '新对话',
+ modelId: newConv.modelId || newConv.model_id || selectedModelId,
+ status: 'active',
+ pinned: false,
+ knowledgeGraphCount: 0,
+ createdAt: newConv.createdAt || newConv.created_at || new Date().toISOString(),
+ updatedAt: newConv.updatedAt || newConv.updated_at || new Date().toISOString(),
+ };
+ setConversations(prev => [conv, ...prev]);
+ navigate(`/chat/${conv.id}`);
+ } catch (err: any) {
+ alert('创建对话失败: ' + (err.message || '未知错误'));
+ }
+ };
+
+ // Handle rename
+ const handleRename = async (convId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ const newTitle = prompt('请输入新名称:');
+ if (newTitle && newTitle.trim()) {
+ try {
+ await chatAPI.updateConversation(convId, { title: newTitle.trim() });
+ setConversations(prev => prev.map(c => c.id === convId ? { ...c, title: newTitle.trim() } : c));
+ } catch (err: any) {
+ alert('重命名失败: ' + (err.message || '未知错误'));
+ }
+ }
+ };
+
+ // Handle archive
+ const handleArchive = async (convId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ const conv = conversations.find(c => c.id === convId);
+ if (!conv) return;
+ const newStatus = conv.status === 'archived' ? 'active' : 'archived';
+ try {
+ await chatAPI.updateConversation(convId, { status: newStatus });
+ setConversations(prev => prev.map(c => c.id === convId ? { ...c, status: newStatus } as Conversation : c));
+ } catch (err: any) {
+ alert('操作失败: ' + (err.message || '未知错误'));
+ }
+ };
+
+ // Handle delete
+ const handleDelete = async (convId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (!confirm('确定要删除这个对话吗?')) return;
+ try {
+ await chatAPI.deleteConversation(convId);
+ const next = conversations.filter(c => c.id !== convId);
+ setConversations(next);
+ if (selectedConversation?.id === convId) {
+ if (next.length > 0) {
+ navigate(`/chat/${next[0].id}`);
+ } else {
+ navigate('/chat');
+ }
+ }
+ } catch (err: any) {
+ alert('删除失败: ' + (err.message || '未知错误'));
+ }
+ };
+
+ // Handle pin / unpin
+ const handlePin = async (convId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ const conv = conversations.find(c => c.id === convId);
+ if (!conv) return;
+ const newPinned = !conv.pinned;
+ try {
+ await chatAPI.updateConversation(convId, { pinned: newPinned });
+ setConversations(prev => {
+ const updated = prev.map(c => c.id === convId ? { ...c, pinned: newPinned } : c);
+ const pinned = updated.filter(c => c.pinned);
+ const unpinned = updated.filter(c => !c.pinned).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
+ return [...pinned, ...unpinned];
+ });
+ } catch (err: any) {
+ alert('操作失败: ' + (err.message || '未知错误'));
+ }
+ };
+
+ // Handle convert to knowledge graph
+ const handleKnowledgeGraph = async (convId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ setKgModalOpen(true);
+ setKgStep(1);
+ setKgResult(null);
+ setKgError(null);
+ setKgConvId(convId);
+
+ try {
+ await new Promise(r => setTimeout(r, 600));
+ setKgStep(2);
+
+ const res = await chatAPI.convertToKnowledgeGraph(convId);
+ await new Promise(r => setTimeout(r, 400));
+ setKgStep(3);
+
+ if (res.code === 0) {
+ const graph = res.data.graph;
+ setKgResult({
+ nodes: graph.stats.total_nodes,
+ edges: graph.stats.total_edges,
+ kbName: res.data.knowledge_base_name,
+ kbId: res.data.knowledge_base_id,
+ });
+ // 使用后端返回的真实计数(幂等,始终为 1),避免前端重复累加导致计数虚高
+ const newCount = res.data.knowledge_graph_count ?? 1;
+ setConversations(prev =>
+ prev.map(c => c.id === convId ? { ...c, knowledgeGraphCount: newCount } : c)
+ );
+ } else {
+ setKgError(res.message || '转换失败');
+ }
+ } catch (err) {
+ setKgError((err as Error).message || '转换失败,请稍后重试');
+ }
+ };
+
+ // 非流式兜底发送(与历史行为一致)——供 SSE 连接/流式失败时回退
+ const sendLegacy = async (
+ payload: any,
+ tempUserMsg: Message,
+ ) => {
+ const res = await chatAPI.sendMessage(selectedConversation!.id, payload);
+ const data = unwrapData(res);
+
+ // Replace temporary user message and add assistant response
+ setMessages(prev => {
+ const filtered = prev.filter(m => m.id !== tempUserMsg.id);
+ const newMsgs: Message[] = [];
+ if (data.user_message) {
+ newMsgs.push({
+ id: data.user_message.id,
+ conversationId: selectedConversation?.id || '',
+ role: 'user',
+ content: data.user_message.content,
+ tokenUsed: data.user_message.token_used || 0,
+ createdAt: data.user_message.created_at || new Date().toISOString(),
+ });
+ }
+ if (data.assistant_message) {
+ newMsgs.push({
+ id: data.assistant_message.id,
+ conversationId: selectedConversation?.id || '',
+ role: 'assistant',
+ content: data.assistant_message.content,
+ tokenUsed: data.assistant_message.token_used || 0,
+ createdAt: data.assistant_message.created_at || new Date().toISOString(),
+ used_skills: data.assistant_message.used_skills || [],
+ used_mcp: data.assistant_message.used_mcp || [],
+ process_steps: data.assistant_message.process_steps || [],
+ });
+ }
+ return [...filtered, ...newMsgs];
+ });
+
+ // 标记本轮新产生的助手消息,触发"思考过程"首屏展开
+ if (data.assistant_message) {
+ setFreshMsgIds(prev => new Set(prev).add(data.assistant_message.id));
+ }
+
+ // 媒体异步生成:启动轮询,待后端回写后自动刷新
+ if (data.assistant_message?.generating) {
+ startMediaPolling(selectedConversation!.id, data.assistant_message.id);
+ }
+
+ // Refresh conversations to update lastMessage
+ loadConversations();
+ // 发送后清空本次附件(选择状态仍保留)
+ setAttachments([]);
+
+ // GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI)
+ if (selectedConversation?.id && data.assistant_message) {
+ const recentMsgs = [
+ ...(data.user_message ? [{ role: 'user', content: data.user_message.content, tokens: data.user_message.token_used || 0 }] : []),
+ { role: 'assistant', content: data.assistant_message.content, tokens: data.assistant_message.token_used || 0 },
+ ];
+ evolutionAPI.submitTrajectory({
+ conversation_id: selectedConversation.id,
+ messages: recentMsgs,
+ }).catch(() => {});
+ }
+ };
+
+ // SSE 流式发送(Phase 0)——实时增量渲染 AI 回复文本与思考链(reasoning)
+ const streamSend = async (
+ convId: string,
+ payload: any,
+ tempUserMsg: Message,
+ assistantId: string,
+ ): Promise => {
+ const token = localStorage.getItem('aiflow_token');
+ const controller = new AbortController();
+ abortRef.current = controller;
+ const resp = await fetch(`/app/api/chat/conversations/${convId}/messages/stream`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ body: JSON.stringify(payload),
+ signal: controller.signal,
+ });
+ if (!resp.ok || !resp.body) {
+ throw new Error(`SSE 连接失败 (${resp.status})`);
+ }
+
+ const reader = resp.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+ let accReasoning = '';
+ let gotDone = false;
+ let pendingDelta = '';
+ let reasoningDirty = false;
+ let rafId: number | null = null;
+
+ // 缺陷4:用 requestAnimationFrame 合并同一帧内的多个 delta/reasoning,避免逐 token 重渲染抖动
+ const flushBuffered = () => {
+ rafId = null;
+ if (!pendingDelta && !reasoningDirty) return;
+ const piece = pendingDelta;
+ pendingDelta = '';
+ const r = reasoningDirty ? accReasoning : null;
+ reasoningDirty = false;
+ setMessages(prev => prev.map(m => {
+ if (m.id !== assistantId) return m;
+ const patch: any = {};
+ if (piece) patch.content = (m.content || '') + piece;
+ if (r !== null) patch.reasoning = r;
+ return { ...m, ...patch };
+ }));
+ };
+ const scheduleFlush = () => {
+ if (rafId == null) rafId = requestAnimationFrame(flushBuffered);
+ };
+ const finalizeFlush = () => {
+ if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; }
+ flushBuffered();
+ };
+
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ let sep: number;
+ while ((sep = buffer.indexOf('\n\n')) >= 0) {
+ const raw = buffer.slice(0, sep);
+ buffer = buffer.slice(sep + 2);
+ const dataLine = raw.split('\n').find(l => l.startsWith('data: '));
+ if (!dataLine) continue;
+ let evt: any;
+ try { evt = JSON.parse(dataLine.slice(6)); } catch { continue; }
+ if (evt.type === 'delta') {
+ pendingDelta += evt.content || '';
+ scheduleFlush();
+ } else if (evt.type === 'reasoning') {
+ accReasoning += evt.content || '';
+ reasoningDirty = true;
+ scheduleFlush();
+ } else if (evt.type === 'step') {
+ // 缺陷2:实时增量过程步骤,前端随事件收拢式渲染
+ const step = evt.step;
+ const idx = typeof evt.index === 'number' ? evt.index : -1;
+ setMessages(prev => prev.map(m => {
+ if (m.id !== assistantId) return m;
+ const steps: any[] = (m as any).process_steps || [];
+ const next = steps.slice();
+ if (idx >= 0 && idx < next.length) next[idx] = step;
+ else next.push(step);
+ return { ...m, process_steps: next };
+ }));
+ } else if (evt.type === 'done') {
+ gotDone = true;
+ finalizeFlush();
+ const finalId = evt.message_id || assistantId;
+ setMessages(prev => prev.map(m => {
+ if (m.id !== assistantId) return m;
+ return {
+ ...m,
+ id: finalId,
+ content: evt.content ?? m.content,
+ process_steps: evt.process_steps || [],
+ used_skills: evt.used_skills || [],
+ used_mcp: evt.used_mcp || [],
+ generating: !!evt.generating,
+ };
+ }));
+ setStreamingId(null);
+ abortRef.current = null;
+ if (evt.generating) startMediaPolling(convId, finalId);
+ setFreshMsgIds(prev => new Set(prev).add(finalId));
+ loadConversations();
+ setAttachments([]);
+ if (convId && evt.content) {
+ evolutionAPI.submitTrajectory({
+ conversation_id: convId,
+ messages: [
+ { role: 'user', content: tempUserMsg.content, tokens: 0 },
+ { role: 'assistant', content: evt.content, tokens: 0 },
+ ],
+ }).catch(() => {});
+ }
+ } else if (evt.type === 'error') {
+ // 缺陷4:容错——保留已生成内容,不强删,标记中断后优雅收尾
+ finalizeFlush();
+ setMessages(prev => prev.map(m => {
+ if (m.id !== assistantId) return m;
+ const note = '\n\n_(生成中断:' + (evt.message || '未知错误') + ')_';
+ return { ...m, content: (m.content || '') + note };
+ }));
+ setStreamingId(null);
+ abortRef.current = null;
+ setFreshMsgIds(prev => new Set(prev).add(assistantId));
+ loadConversations();
+ gotDone = true;
+ return;
+ }
+ }
+ }
+ if (!gotDone) {
+ // 流结束但未收到 done:已累积的 delta 仍可展示,但视为异常兜底
+ finalizeFlush();
+ abortRef.current = null;
+ setStreamingId(null);
+ throw new Error('流式响应未正常结束');
+ }
+ };
+
+ // Handle send message - CALLS REAL API(Phase 0:默认 SSE 流式,失败回退非流式)
+ // 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用
+ const sendText = async (content: string) => {
+ if (!content.trim() || sending || !selectedConversation) return;
+ setSending(true);
+ setSendingTimeout(false);
+ // 60s 超时警告
+ if (sendingTimerRef.current) clearTimeout(sendingTimerRef.current);
+ sendingTimerRef.current = setTimeout(() => {
+ setSendingTimeout(true);
+ }, 60000);
+
+ // Add user message optimistically
+ const tempUserMsg: Message = {
+ id: `temp_${Date.now()}`,
+ conversationId: selectedConversation?.id || '',
+ role: 'user',
+ content,
+ tokenUsed: 0,
+ createdAt: new Date().toISOString(),
+ };
+ setMessages(prev => [...prev, tempUserMsg]);
+
+ const payload = {
+ content,
+ model_id: selectedModelId || undefined,
+ agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
+ skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
+ knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
+ mcp_ids: selectedMcpIds.length > 0 ? selectedMcpIds : undefined,
+ attachments: attachments.length > 0 ? attachments.map(a => ({
+ name: a.name, file_type: a.file_type, content: a.content || '',
+ })) : undefined,
+ use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0 || selectedMcpIds.length > 0,
+ };
+
+ // 先放一个空的助手消息占位,用于 SSE 流式增量渲染
+ const assistantId = `temp_assistant_${Date.now()}`;
+ setMessages(prev => [...prev, {
+ id: assistantId,
+ conversationId: selectedConversation!.id,
+ role: 'assistant',
+ content: '',
+ tokenUsed: 0,
+ createdAt: new Date().toISOString(),
+ reasoning: '',
+ } as Message]);
+ setStreamingId(assistantId);
+
+ try {
+ await streamSend(selectedConversation!.id, payload, tempUserMsg, assistantId);
+ } catch (err: any) {
+ // 移除流式占位,回退非流式重新发送
+ setMessages(prev => prev.filter(m => m.id !== assistantId && m.id !== tempUserMsg.id));
+ setStreamingId(null);
+ try {
+ await sendLegacy(payload, tempUserMsg);
+ } catch (e2: any) {
+ setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id));
+ alert('发送失败: ' + (e2?.message || e2?.detail || '未知错误'));
+ }
+ } finally {
+ if (sendingTimerRef.current) { clearTimeout(sendingTimerRef.current); sendingTimerRef.current = null; }
+ setSending(false);
+ setSendingTimeout(false);
+ inputRef.current?.focus();
+ }
+ };
+
+ // 文件上传:读取文本类文件内容作为附件上下文
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const files = Array.from(e.target.files || []);
+ if (!files.length) return;
+ const textExts = ['txt', 'md', 'markdown', 'csv', 'json', 'log', 'py', 'js', 'ts', 'tsx', 'jsx', 'html', 'css', 'xml', 'yaml', 'yml', 'sql', 'text'];
+ files.forEach(file => {
+ const ext = (file.name.split('.').pop() || '').toLowerCase();
+ const isText = textExts.includes(ext) || file.type.startsWith('text/');
+ if (isText && file.size <= 200 * 1024) {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const content = String(reader.result || '');
+ setAttachments(prev => [...prev, { name: file.name, file_type: ext || 'txt', content }]);
+ };
+ reader.readAsText(file);
+ } else {
+ setAttachments(prev => [...prev, { name: file.name, file_type: ext || file.type || 'bin' }]);
+ }
+ });
+ e.target.value = '';
+ };
+
+ // 语音输入(浏览器 Web Speech API,支持中文)
+ const handleMicClick = () => {
+ const SR: any = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
+ if (!SR) {
+ alert('当前浏览器不支持语音输入,请使用 Chrome / Edge 等浏览器。');
+ return;
+ }
+ if (recording) {
+ recognitionRef.current?.stop();
+ return;
+ }
+ const rec = new SR();
+ recognitionRef.current = rec;
+ rec.lang = 'zh-CN';
+ rec.interimResults = true;
+ rec.continuous = false;
+ rec.onresult = (ev: any) => {
+ let text = '';
+ for (let i = 0; i < ev.results.length; i++) text += ev.results[i][0].transcript;
+ setInputValue(prev => (prev ? prev + text : text));
+ };
+ rec.onend = () => { setRecording(false); recognitionRef.current = null; };
+ rec.onerror = () => { setRecording(false); recognitionRef.current = null; };
+ setRecording(true);
+ rec.start();
+ };
+
+ // 语音合成(朗读回复,浏览器 TTS)
+ const speakText = (text: string) => {
+ if (typeof window === 'undefined' || !('speechSynthesis' in window)) {
+ alert('当前浏览器不支持朗读。');
+ return;
+ }
+ const synth = window.speechSynthesis;
+ synth.cancel();
+ const plain = text
+ .replace(/```[\s\S]*?```/g, ' ')
+ .replace(/[#>*`_~\-]+/g, ' ')
+ .replace(/\!\[[^\]]*\]\([^)]*\)/g, ' ')
+ .replace(/\[[^\]]*\]\([^)]*\)/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ const u = new SpeechSynthesisUtterance(plain);
+ u.lang = 'zh-CN';
+ u.rate = 1;
+ synth.speak(u);
+ };
+
+ const handleSend = () => {
+ const content = inputValue.trim();
+ if (!content || sending) return;
+ setInputValue('');
+ sendText(content);
+ };
+
+ // Handle key down
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ };
+
+ return (
+
+ {/* Left Sidebar - Conversation List */}
+
+
+ {/* Right Main Chat Area */}
+
+ {selectedConversation ? (
+ <>
+ {/* Chat Header */}
+
+
+
+
+ {selectedModel.name}
+ {(selectedModel.status === 'free' || selectedModel.price_per_million_tokens === 0) ? '免费' : '付费'}
+
+
+ }
+ >
+
+ {chatModels.filter(m => m.is_enabled !== false).map(model => (
+
+ ))}
+
+
+
{selectedConversation.title}
+
+
+
+ {/* Messages Area */}
+
+ {loadingMessages ? (
+
+ ) : messages.length === 0 ? (
+
+
+
开始一段新对话
+
+ 向 {selectedModel.name} 发送消息,开始对话
+
+
+ ) : (
+ messages.map(msg => {
+ // Real used_skills from API response
+ const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
+ const usedMcp: string[] = (msg.role === 'assistant' && msg.used_mcp) ? msg.used_mcp : [];
+ const steps: any[] = (msg as any).process_steps || [];
+ // 模糊创作意图确认卡片
+ const clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null;
+ return (
+
+ {msg.role === 'assistant' && steps.length > 0 && (
+
+ )}
+ {msg.role === 'assistant' && (msg as any).reasoning && (
+
+ )}
+
+ {clarify ? (
+
sendText(`${opt.medium}:${clarify.prompt}`)}
+ />
+ ) : msg.role === 'assistant' && isAiError(msg.content) ? (
+
+
⚠️
+
+
+
+
+
+ ) : msg.role === 'assistant' ? (
+ msg.content ? : (streamingId === msg.id ? 生成中… : null)
+ ) : (
+ {msg.content}
+ )}
+
+ {!clarify && msg.role === 'assistant' && msg.content && (
+
+ )}
+ {/* Skill usage tags */}
+ {msg.role === 'assistant' && usedSkills.length > 0 && (
+
+ {usedSkills.slice(0, 3).map(skillName => (
+
{ window.location.href = '/evolution?skill=' + encodeURIComponent(skillName); }}
+ title={`使用了专属技能:${skillName}`}
+ >
+
+ {skillName}
+
+ ))}
+ {usedSkills.length > 3 && (
+
+{usedSkills.length - 3}
+ )}
+
+ )}
+ {msg.role === 'assistant' && usedMcp.length > 0 && (
+
+ {usedMcp.slice(0, 3).map(mcpName => (
+
+
+ {mcpName}
+
+ ))}
+ {usedMcp.length > 3 && (
+ +{usedMcp.length - 3}
+ )}
+
+ )}
+
+ {formatTime(msg.createdAt)}
+ {msg.tokenUsed} tokens
+
+
+ );
+ })
+ )}
+ {sending && !streamingId && (
+
+
+
+ {sendingTimeout ? '⏳ 响应时间较长,AI 仍在处理中...' : '思考中...'}
+ {sendingTimeout && (
+
+ )}
+
+
+ )}
+
+
+
+ {/* Input Area */}
+
+
+
+
+
+ {selectedKbIds.length > 0 ? `知识库 (${selectedKbIds.length})` : '知识库'}
+
+ }
+ >
+ 选择知识库(多选)
+ {knowledgeBases.map((kb: any) => (
+
+ ))}
+ {knowledgeBases.length > 0 && selectedKbIds.length > 0 && (
+
+ )}
+ {selectedKbIds.length > 0 && (
+
+ )}
+ {knowledgeBases.length === 0 && (
+ 暂无可用知识库
+ )}
+
+
+
+
+ {selectedAgentIds.length > 0 ? `Agent (${selectedAgentIds.length})` : 'Agent'}
+
+ }
+ >
+ 选择 Agent(多选)
+ {agents.map((agent: any) => (
+
+ ))}
+ {agents.length > 0 && selectedAgentIds.length > 0 && (
+
+ )}
+ {selectedAgentIds.length > 0 && (
+
+ )}
+ {agents.length === 0 && (
+ 暂无可用 Agent
+ )}
+
+
+
+
+ {selectedSkillIds.length > 0 ? `技能 (${selectedSkillIds.length})` : '技能'}
+
+ }
+ >
+ 选择技能(多选)
+ {skills.map((skill: any) => (
+
+ ))}
+ {skills.length > 0 && selectedSkillIds.length > 0 && (
+
+ )}
+ {selectedSkillIds.length > 0 && (
+
+ )}
+ {skills.length === 0 && (
+ 暂无可用技能
+ )}
+
+
+
+
+ {selectedMcpIds.length > 0 ? `MCP (${selectedMcpIds.length})` : 'MCP'}
+
+ }
+ >
+ 选择 MCP 应用(多选)
+ {mcps.map((mcp: any) => (
+
+ ))}
+ {mcps.length > 0 && selectedMcpIds.length > 0 && (
+
+ )}
+ {selectedMcpIds.length > 0 && (
+
+ )}
+ {mcps.length === 0 && (
+ 暂无可用 MCP 应用
+ )}
+
+
+
+
+
+
+ {attachments.length > 0 && (
+
+ {attachments.map((att, idx) => (
+
+
+ {att.name}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ >
+ ) : (
+
+
+
选择或创建对话
+
从左侧列表选择一个对话,或点击上方按钮创建新对话
+
+ )}
+
+
+ {/* Knowledge Graph Conversion Progress Modal */}
+ {kgModalOpen && (
+ !kgError && kgStep !== 2 && setKgModalOpen(false)}>
+
e.stopPropagation()}>
+
+
转换知识图谱
+
+
+
+ {kgError ? (
+
+
+
+
+
{kgError}
+
+
+ ) : kgStep === 3 && kgResult ? (
+
+
+
知识图谱生成完成
+
+
+ {kgResult.nodes}
+ 节点
+
+
+
+ {kgResult.edges}
+ 条边
+
+
+
已存入知识库:{kgResult.kbName}
+
+
+
+
+
+ ) : (
+
+
+
= 1 ? 'active' : ''} ${kgStep > 1 ? 'done' : ''}`}>
+
+ {kgStep > 1 ? (
+
+ ) : kgStep === 1 ? (
+
+ ) : (
+
1
+ )}
+
+
提取对话内容
+
+
+
1 ? 'done' : ''}`}>
+ {kgStep === 2 &&
}
+
+
+
= 2 ? 'active' : ''} ${kgStep > 2 ? 'done' : ''}`}>
+
+ {kgStep > 2 ? (
+
+ ) : kgStep === 2 ? (
+
+ ) : (
+
2
+ )}
+
+
构建知识图谱
+
+
+
2 ? 'done' : ''}`} />
+
+
= 3 ? 'active' : ''} ${kgStep > 3 ? 'done' : ''}`}>
+
+ 3
+
+
完成
+
+
+
+ {kgStep === 1 ? '正在提取对话内容...' : kgStep === 2 ? '正在构建知识图谱...' : '处理完成'}
+
+
+ )}
+
+
+
+ )}
+
+ );
+};
+
+export default ChatPage;