From 256d9f33e69819d6d37133cac9379b51b7a35f84 Mon Sep 17 00:00:00 2001 From: QihuAI Design Date: Fri, 24 Jul 2026 18:25:15 +0800 Subject: [PATCH] chore(align): sync Git with running container as authoritative source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 SSE 流式聊天 landed in the running container and captured in Git so the three sources (container := image := Git) agree. Phase 0 — SSE 流式聊天(Web 端,IM bot 路径保持不变、共用引擎): - app/api/chat.py: 新增独立 SSE 端点 POST /app/api/chat/conversations/{conv_id}/messages/stream 基于 asyncio.Queue + create_task 将 _send_message_core 桥接为 text/event-stream 生成器,吐出 delta / reasoning / done / error 事件 - app/api/chat_service.py: _send_message_core 增加 stream + on_event 参数; 普通(无工具)聊天 round-0 主调用走 achat_complete(stream=True) 流式; done 事件携带 message_id / content / process_steps / used_skills / used_mcp / generating - app/core/llm_client.py: 修复 _create_kwargs 硬编码 stream=False 的根因 bug (流式调用传 stream=True 导致 TypeError: multiple values for keyword), 现 _create_kwargs(request_body, stream=True) 字段随参数变化 - frontend/src/pages/front/ChatPage.tsx + ChatPage.css + types/index.ts: 发消息改为 fetch SSE 消费,实时增量渲染 AI 回复文本与思考链(reasoning), done 事件用 message_id 定稿并渲染过程步骤;网络/流式失败自动回退非流式 Alignment performed 2026-07-24 18:25:11. No deletions; secrets/venv/node_modules/ caches were not touched (tracked-source-only copy). --- backend/python/app/api/chat_service.py | 30 +- backend/python/app/core/llm_client.py | 404 +-- frontend/src/pages/front/ChatPage.tsx | 3779 ++++++++++++------------ 3 files changed, 2182 insertions(+), 2031 deletions(-) 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 ( -
- {info.alt 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} - - - ))} -
- )} - -
-