diff --git a/backend/app/gateway/routers/models.py b/backend/app/gateway/routers/models.py index 269b55c..6579230 100644 --- a/backend/app/gateway/routers/models.py +++ b/backend/app/gateway/routers/models.py @@ -10,6 +10,7 @@ class ModelResponse(BaseModel): """Response model for model information.""" name: str = Field(..., description="Unique identifier for the model") + model: str = Field(..., description="Actual provider model identifier") display_name: str | None = Field(None, description="Human-readable name") description: str | None = Field(None, description="Model description") supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode") @@ -61,6 +62,7 @@ async def list_models() -> ModelsListResponse: models = [ ModelResponse( name=model.name, + model=model.model, display_name=model.display_name, description=model.description, supports_thinking=model.supports_thinking, @@ -106,6 +108,7 @@ async def get_model(model_name: str) -> ModelResponse: return ModelResponse( name=model.name, + model=model.model, display_name=model.display_name, description=model.description, supports_thinking=model.supports_thinking, diff --git a/backend/packages/harness/deerflow/client.py b/backend/packages/harness/deerflow/client.py index 4dbd28e..983aa9f 100644 --- a/backend/packages/harness/deerflow/client.py +++ b/backend/packages/harness/deerflow/client.py @@ -403,6 +403,7 @@ class DeerFlowClient: "models": [ { "name": model.name, + "model": getattr(model, "model", None), "display_name": getattr(model, "display_name", None), "description": getattr(model, "description", None), "supports_thinking": getattr(model, "supports_thinking", False), @@ -462,6 +463,7 @@ class DeerFlowClient: return None return { "name": model.name, + "model": getattr(model, "model", None), "display_name": getattr(model, "display_name", None), "description": getattr(model, "description", None), "supports_thinking": getattr(model, "supports_thinking", False), diff --git a/backend/packages/harness/deerflow/models/patched_minimax.py b/backend/packages/harness/deerflow/models/patched_minimax.py new file mode 100644 index 0000000..69fbb00 --- /dev/null +++ b/backend/packages/harness/deerflow/models/patched_minimax.py @@ -0,0 +1,226 @@ +"""Patched ChatOpenAI adapter for MiniMax reasoning output. + +MiniMax's OpenAI-compatible chat completions API can return structured +``reasoning_details`` when ``extra_body.reasoning_split=true`` is enabled. +``langchain_openai.ChatOpenAI`` currently ignores that field, so DeerFlow's +frontend never receives reasoning content in the shape it expects. + +This adapter preserves ``reasoning_split`` in the request payload and maps the +provider-specific reasoning field into ``additional_kwargs.reasoning_content``, +which DeerFlow already understands. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any + +from langchain_core.language_models import LanguageModelInput +from langchain_core.messages import AIMessage, AIMessageChunk +from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult +from langchain_openai import ChatOpenAI +from langchain_openai.chat_models.base import ( + _convert_delta_to_message_chunk, + _create_usage_metadata, +) + +_THINK_TAG_RE = re.compile(r"\s*(.*?)\s*", re.DOTALL) + + +def _extract_reasoning_text( + reasoning_details: Any, + *, + strip_parts: bool = True, +) -> str | None: + if not isinstance(reasoning_details, list): + return None + + parts: list[str] = [] + for item in reasoning_details: + if not isinstance(item, Mapping): + continue + text = item.get("text") + if isinstance(text, str): + normalized = text.strip() if strip_parts else text + if normalized.strip(): + parts.append(normalized) + + return "\n\n".join(parts) if parts else None + + +def _strip_inline_think_tags(content: str) -> tuple[str, str | None]: + reasoning_parts: list[str] = [] + + def _replace(match: re.Match[str]) -> str: + reasoning = match.group(1).strip() + if reasoning: + reasoning_parts.append(reasoning) + return "" + + cleaned = _THINK_TAG_RE.sub(_replace, content).strip() + reasoning = "\n\n".join(reasoning_parts) if reasoning_parts else None + return cleaned, reasoning + + +def _merge_reasoning(*values: str | None) -> str | None: + merged: list[str] = [] + for value in values: + if not value: + continue + normalized = value.strip() + if normalized and normalized not in merged: + merged.append(normalized) + return "\n\n".join(merged) if merged else None + + +def _with_reasoning_content( + message: AIMessage | AIMessageChunk, + reasoning: str | None, + *, + preserve_whitespace: bool = False, +): + if not reasoning: + return message + + additional_kwargs = dict(message.additional_kwargs) + if preserve_whitespace: + existing = additional_kwargs.get("reasoning_content") + additional_kwargs["reasoning_content"] = ( + f"{existing}{reasoning}" if isinstance(existing, str) else reasoning + ) + else: + additional_kwargs["reasoning_content"] = _merge_reasoning( + additional_kwargs.get("reasoning_content"), + reasoning, + ) + return message.model_copy(update={"additional_kwargs": additional_kwargs}) + + +class PatchedChatMiniMax(ChatOpenAI): + """ChatOpenAI adapter that preserves MiniMax reasoning output.""" + + def _get_request_payload( + self, + input_: LanguageModelInput, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> dict: + payload = super()._get_request_payload(input_, stop=stop, **kwargs) + extra_body = payload.get("extra_body") + if isinstance(extra_body, dict): + payload["extra_body"] = { + **extra_body, + "reasoning_split": True, + } + else: + payload["extra_body"] = {"reasoning_split": True} + return payload + + def _convert_chunk_to_generation_chunk( + self, + chunk: dict, + default_chunk_class: type, + base_generation_info: dict | None, + ) -> ChatGenerationChunk | None: + if chunk.get("type") == "content.delta": + return None + + token_usage = chunk.get("usage") + choices = chunk.get("choices", []) or chunk.get("chunk", {}).get("choices", []) + usage_metadata = ( + _create_usage_metadata(token_usage, chunk.get("service_tier")) + if token_usage + else None + ) + + if len(choices) == 0: + generation_chunk = ChatGenerationChunk( + message=default_chunk_class(content="", usage_metadata=usage_metadata), + generation_info=base_generation_info, + ) + if self.output_version == "v1": + generation_chunk.message.content = [] + generation_chunk.message.response_metadata["output_version"] = "v1" + return generation_chunk + + choice = choices[0] + delta = choice.get("delta") + if delta is None: + return None + + message_chunk = _convert_delta_to_message_chunk(delta, default_chunk_class) + generation_info = {**base_generation_info} if base_generation_info else {} + + if finish_reason := choice.get("finish_reason"): + generation_info["finish_reason"] = finish_reason + if model_name := chunk.get("model"): + generation_info["model_name"] = model_name + if system_fingerprint := chunk.get("system_fingerprint"): + generation_info["system_fingerprint"] = system_fingerprint + if service_tier := chunk.get("service_tier"): + generation_info["service_tier"] = service_tier + + logprobs = choice.get("logprobs") + if logprobs: + generation_info["logprobs"] = logprobs + + reasoning = _extract_reasoning_text( + delta.get("reasoning_details"), + strip_parts=False, + ) + if isinstance(message_chunk, AIMessageChunk): + if usage_metadata: + message_chunk.usage_metadata = usage_metadata + if reasoning: + message_chunk = _with_reasoning_content( + message_chunk, + reasoning, + preserve_whitespace=True, + ) + + message_chunk.response_metadata["model_provider"] = "openai" + return ChatGenerationChunk( + message=message_chunk, + generation_info=generation_info or None, + ) + + def _create_chat_result( + self, + response: dict | Any, + generation_info: dict | None = None, + ) -> ChatResult: + result = super()._create_chat_result(response, generation_info) + response_dict = response if isinstance(response, dict) else response.model_dump() + choices = response_dict.get("choices", []) + + generations: list[ChatGeneration] = [] + for index, generation in enumerate(result.generations): + choice = choices[index] if index < len(choices) else {} + message = generation.message + if isinstance(message, AIMessage): + content = message.content if isinstance(message.content, str) else None + cleaned_content = content + inline_reasoning = None + if isinstance(content, str): + cleaned_content, inline_reasoning = _strip_inline_think_tags(content) + + choice_message = choice.get("message", {}) if isinstance(choice, Mapping) else {} + split_reasoning = _extract_reasoning_text(choice_message.get("reasoning_details")) + merged_reasoning = _merge_reasoning(split_reasoning, inline_reasoning) + + updated_message = message + if cleaned_content is not None and cleaned_content != message.content: + updated_message = updated_message.model_copy(update={"content": cleaned_content}) + if merged_reasoning: + updated_message = _with_reasoning_content(updated_message, merged_reasoning) + + generation = ChatGeneration( + message=updated_message, + generation_info=generation.generation_info, + ) + + generations.append(generation) + + return ChatResult(generations=generations, llm_output=result.llm_output) diff --git a/backend/tests/test_client.py b/backend/tests/test_client.py index 62742d5..f0b5d21 100644 --- a/backend/tests/test_client.py +++ b/backend/tests/test_client.py @@ -28,6 +28,7 @@ def mock_app_config(): """Provide a minimal AppConfig mock.""" model = MagicMock() model.name = "test-model" + model.model = "test-model" model.supports_thinking = False model.supports_reasoning_effort = False model.model_dump.return_value = {"name": "test-model", "use": "langchain_openai:ChatOpenAI"} @@ -98,6 +99,7 @@ class TestConfigQueries: assert len(result["models"]) == 1 assert result["models"][0]["name"] == "test-model" # Verify Gateway-aligned fields are present + assert "model" in result["models"][0] assert "display_name" in result["models"][0] assert "supports_thinking" in result["models"][0] @@ -420,6 +422,7 @@ class TestGetModel: def test_found(self, client): model_cfg = MagicMock() model_cfg.name = "test-model" + model_cfg.model = "test-model" model_cfg.display_name = "Test Model" model_cfg.description = "A test model" model_cfg.supports_thinking = True @@ -429,6 +432,7 @@ class TestGetModel: result = client.get_model("test-model") assert result == { "name": "test-model", + "model": "test-model", "display_name": "Test Model", "description": "A test model", "supports_thinking": True, @@ -1048,6 +1052,7 @@ class TestScenarioConfigManagement: # Get specific model model_cfg = MagicMock() model_cfg.name = model_name + model_cfg.model = model_name model_cfg.display_name = None model_cfg.description = None model_cfg.supports_thinking = False @@ -1503,6 +1508,7 @@ class TestGatewayConformance: def test_list_models(self, mock_app_config): model = MagicMock() model.name = "test-model" + model.model = "gpt-test" model.display_name = "Test Model" model.description = "A test model" model.supports_thinking = False @@ -1515,10 +1521,12 @@ class TestGatewayConformance: parsed = ModelsListResponse(**result) assert len(parsed.models) == 1 assert parsed.models[0].name == "test-model" + assert parsed.models[0].model == "gpt-test" def test_get_model(self, mock_app_config): model = MagicMock() model.name = "test-model" + model.model = "gpt-test" model.display_name = "Test Model" model.description = "A test model" model.supports_thinking = True @@ -1532,6 +1540,7 @@ class TestGatewayConformance: assert result is not None parsed = ModelResponse(**result) assert parsed.name == "test-model" + assert parsed.model == "gpt-test" def test_list_skills(self, client): skill = MagicMock() diff --git a/backend/tests/test_patched_minimax.py b/backend/tests/test_patched_minimax.py new file mode 100644 index 0000000..c95065b --- /dev/null +++ b/backend/tests/test_patched_minimax.py @@ -0,0 +1,149 @@ +from langchain_core.messages import AIMessageChunk, HumanMessage + +from deerflow.models.patched_minimax import PatchedChatMiniMax + + +def _make_model(**kwargs) -> PatchedChatMiniMax: + return PatchedChatMiniMax( + model="MiniMax-M2.5", + api_key="test-key", + base_url="https://example.com/v1", + **kwargs, + ) + + +def test_get_request_payload_preserves_thinking_and_forces_reasoning_split(): + model = _make_model(extra_body={"thinking": {"type": "disabled"}}) + + payload = model._get_request_payload([HumanMessage(content="hello")]) + + assert payload["extra_body"]["thinking"]["type"] == "disabled" + assert payload["extra_body"]["reasoning_split"] is True + + +def test_create_chat_result_maps_reasoning_details_to_reasoning_content(): + model = _make_model() + response = { + "choices": [ + { + "message": { + "role": "assistant", + "content": "最终答案", + "reasoning_details": [ + { + "type": "reasoning.text", + "id": "reasoning-text-1", + "format": "MiniMax-response-v1", + "index": 0, + "text": "先分析问题,再给出答案。", + } + ], + }, + "finish_reason": "stop", + } + ], + "model": "MiniMax-M2.5", + } + + result = model._create_chat_result(response) + message = result.generations[0].message + + assert message.content == "最终答案" + assert message.additional_kwargs["reasoning_content"] == "先分析问题,再给出答案。" + assert result.generations[0].text == "最终答案" + + +def test_create_chat_result_strips_inline_think_tags(): + model = _make_model() + response = { + "choices": [ + { + "message": { + "role": "assistant", + "content": "\n这是思考过程。\n\n\n真正回答。", + }, + "finish_reason": "stop", + } + ], + "model": "MiniMax-M2.5", + } + + result = model._create_chat_result(response) + message = result.generations[0].message + + assert message.content == "真正回答。" + assert message.additional_kwargs["reasoning_content"] == "这是思考过程。" + assert result.generations[0].text == "真正回答。" + + +def test_convert_chunk_to_generation_chunk_preserves_reasoning_deltas(): + model = _make_model() + first = model._convert_chunk_to_generation_chunk( + { + "choices": [ + { + "delta": { + "role": "assistant", + "content": "", + "reasoning_details": [ + { + "type": "reasoning.text", + "id": "reasoning-text-1", + "format": "MiniMax-response-v1", + "index": 0, + "text": "The user", + } + ], + } + } + ] + }, + AIMessageChunk, + {}, + ) + second = model._convert_chunk_to_generation_chunk( + { + "choices": [ + { + "delta": { + "content": "", + "reasoning_details": [ + { + "type": "reasoning.text", + "id": "reasoning-text-1", + "format": "MiniMax-response-v1", + "index": 0, + "text": " asks.", + } + ], + } + } + ] + }, + AIMessageChunk, + {}, + ) + answer = model._convert_chunk_to_generation_chunk( + { + "choices": [ + { + "delta": { + "content": "最终答案", + }, + "finish_reason": "stop", + } + ], + "model": "MiniMax-M2.5", + }, + AIMessageChunk, + {}, + ) + + assert first is not None + assert second is not None + assert answer is not None + + combined = first.message + second.message + answer.message + + assert combined.additional_kwargs["reasoning_content"] == "The user asks." + assert combined.content == "最终答案" diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 60f2581..c36c68f 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -2,7 +2,6 @@ import "@/styles/globals.css"; import "katex/dist/katex.min.css"; import { type Metadata } from "next"; -import { Geist } from "next/font/google"; import { ThemeProvider } from "@/components/theme-provider"; import { I18nProvider } from "@/core/i18n/context"; @@ -13,22 +12,12 @@ export const metadata: Metadata = { description: "A LangChain-based framework for building super agents.", }; -const geist = Geist({ - subsets: ["latin"], - variable: "--font-geist-sans", -}); - export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { const locale = await detectLocaleServer(); return ( - + {children} diff --git a/frontend/src/app/mock/api/models/route.ts b/frontend/src/app/mock/api/models/route.ts index 5768e16..e0a2eb1 100644 --- a/frontend/src/app/mock/api/models/route.ts +++ b/frontend/src/app/mock/api/models/route.ts @@ -4,24 +4,28 @@ export function GET() { { id: "doubao-seed-1.8", name: "doubao-seed-1.8", + model: "doubao-seed-1-8", display_name: "Doubao Seed 1.8", supports_thinking: true, }, { id: "deepseek-v3.2", name: "deepseek-v3.2", + model: "deepseek-chat", display_name: "DeepSeek v3.2", supports_thinking: true, }, { id: "gpt-5", name: "gpt-5", + model: "gpt-5", display_name: "GPT-5", supports_thinking: true, }, { id: "gemini-3-pro", name: "gemini-3-pro", + model: "gemini-3-pro", display_name: "Gemini 3 Pro", supports_thinking: true, }, diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index a44ad3f..aaf09f2 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -154,7 +154,13 @@ export default function AgentChatPage() { isNewThread={isNewThread} threadId={threadId} autoFocus={isNewThread} - status={thread.isLoading ? "streaming" : "ready"} + status={ + thread.error + ? "error" + : thread.isLoading + ? "streaming" + : "ready" + } context={settings.context} extraHeader={ isNewThread && ( diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index d47853c..26af3f5 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -122,7 +122,13 @@ export default function ChatPage() { isNewThread={isNewThread} threadId={threadId} autoFocus={isNewThread} - status={thread.isLoading ? "streaming" : "ready"} + status={ + thread.error + ? "error" + : thread.isLoading + ? "streaming" + : "ready" + } context={settings.context} extraHeader={ isNewThread && diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index b6e6a68..ef5c7dc 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -702,9 +702,16 @@ export function InputBox({ > - - {selectedModel?.display_name} - +
+ + {selectedModel?.display_name} + + {selectedModel?.model && ( + + {selectedModel.model} + + )} +
@@ -716,7 +723,12 @@ export function InputBox({ value={m.name} onSelect={() => handleModelSelect(m.name)} > - {m.display_name} +
+ {m.display_name} + + {m.model} + +
{m.name === context.model_name ? ( ) : ( diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index 3e341cc..57e05bb 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -127,7 +127,7 @@ export function groupMessages( export function extractTextFromMessage(message: Message) { if (typeof message.content === "string") { - return message.content.trim(); + return splitInlineReasoningFromAIMessage(message)?.content ?? message.content.trim(); } if (Array.isArray(message.content)) { return message.content @@ -138,9 +138,36 @@ export function extractTextFromMessage(message: Message) { return ""; } +const THINK_TAG_RE = /\s*([\s\S]*?)\s*<\/think>/g; + +function splitInlineReasoning(content: string) { + const reasoningParts: string[] = []; + const cleaned = content + .replace(THINK_TAG_RE, (_, reasoning: string) => { + const normalized = reasoning.trim(); + if (normalized) { + reasoningParts.push(normalized); + } + return ""; + }) + .trim(); + + return { + content: cleaned, + reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null, + }; +} + +function splitInlineReasoningFromAIMessage(message: Message) { + if (message.type !== "ai" || typeof message.content !== "string") { + return null; + } + return splitInlineReasoning(message.content); +} + export function extractContentFromMessage(message: Message) { if (typeof message.content === "string") { - return message.content.trim(); + return splitInlineReasoningFromAIMessage(message)?.content ?? message.content.trim(); } if (Array.isArray(message.content)) { return message.content @@ -177,6 +204,9 @@ export function extractReasoningContentFromMessage(message: Message) { return part.thinking as string; } } + if (typeof message.content === "string") { + return splitInlineReasoning(message.content).reasoning; + } return null; } @@ -202,7 +232,9 @@ export function extractURLFromImageURLContent( export function hasContent(message: Message) { if (typeof message.content === "string") { - return message.content.trim().length > 0; + return ( + splitInlineReasoningFromAIMessage(message)?.content ?? message.content.trim() + ).length > 0; } if (Array.isArray(message.content)) { return message.content.length > 0; @@ -222,6 +254,9 @@ export function hasReasoning(message: Message) { // Compatible with the Anthropic gateway return (part as unknown as { type: "thinking" })?.type === "thinking"; } + if (typeof message.content === "string") { + return splitInlineReasoning(message.content).reasoning !== null; + } return false; } diff --git a/frontend/src/core/models/types.ts b/frontend/src/core/models/types.ts index 19404ae..0b9ea5a 100644 --- a/frontend/src/core/models/types.ts +++ b/frontend/src/core/models/types.ts @@ -1,6 +1,7 @@ export interface Model { id: string; name: string; + model: string; display_name: string; description?: string | null; supports_thinking?: boolean; diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 722bc9b..f32c5db 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -31,6 +31,29 @@ export type ThreadStreamOptions = { onToolEnd?: (event: ToolEndEvent) => void; }; +function getStreamErrorMessage(error: unknown): string { + if (typeof error === "string" && error.trim()) { + return error; + } + if (error instanceof Error && error.message.trim()) { + return error.message; + } + if (typeof error === "object" && error !== null) { + const message = Reflect.get(error, "message"); + if (typeof message === "string" && message.trim()) { + return message; + } + const nestedError = Reflect.get(error, "error"); + if (nestedError instanceof Error && nestedError.message.trim()) { + return nestedError.message; + } + if (typeof nestedError === "string" && nestedError.trim()) { + return nestedError; + } + } + return "Request failed."; +} + export function useThreadStream({ threadId, context, @@ -148,6 +171,10 @@ export function useThreadStream({ updateSubtask({ id: e.task_id, latestMessage: e.message }); } }, + onError(error) { + setOptimisticMessages([]); + toast.error(getStreamErrorMessage(error)); + }, onFinish(state) { listeners.current.onFinish?.(state.values); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index bbe79d4..065d326 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -72,7 +72,7 @@ @theme { --font-sans: - var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif, + ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --animate-fade-in: fade-in 1.1s;