mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-05-01 01:30:44 +08:00
fix: improve MiniMax code plan integration (#1169)
This PR improves MiniMax Code Plan integration in DeerFlow by fixing three issues in the current flow: stream errors were not clearly surfaced in the UI, the frontend could not display the actual provider model ID, and MiniMax reasoning output could leak into final assistant content as inline <think>...</think>. The change adds a MiniMax-specific adapter, exposes real model IDs end-to-end, and adds a frontend fallback for historical messages. Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
226
backend/packages/harness/deerflow/models/patched_minimax.py
Normal file
226
backend/packages/harness/deerflow/models/patched_minimax.py
Normal file
@@ -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"<think>\s*(.*?)\s*</think>", 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)
|
||||
@@ -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()
|
||||
|
||||
149
backend/tests/test_patched_minimax.py
Normal file
149
backend/tests/test_patched_minimax.py
Normal file
@@ -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": "<think>\n这是思考过程。\n</think>\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 == "最终答案"
|
||||
@@ -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 (
|
||||
<html
|
||||
lang={locale}
|
||||
className={geist.variable}
|
||||
suppressContentEditableWarning
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<html lang={locale} suppressContentEditableWarning suppressHydrationWarning>
|
||||
<body>
|
||||
<ThemeProvider attribute="class" enableSystem disableTransitionOnChange>
|
||||
<I18nProvider initialLocale={locale}>{children}</I18nProvider>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 && <Welcome mode={settings.context.mode} />
|
||||
|
||||
@@ -702,9 +702,16 @@ export function InputBox({
|
||||
>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<PromptInputButton>
|
||||
<ModelSelectorName className="text-xs font-normal">
|
||||
{selectedModel?.display_name}
|
||||
</ModelSelectorName>
|
||||
<div className="flex min-w-0 flex-col items-start text-left">
|
||||
<ModelSelectorName className="text-xs font-normal">
|
||||
{selectedModel?.display_name}
|
||||
</ModelSelectorName>
|
||||
{selectedModel?.model && (
|
||||
<span className="text-muted-foreground w-full truncate text-[10px] leading-none">
|
||||
{selectedModel.model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</PromptInputButton>
|
||||
</ModelSelectorTrigger>
|
||||
<ModelSelectorContent>
|
||||
@@ -716,7 +723,12 @@ export function InputBox({
|
||||
value={m.name}
|
||||
onSelect={() => handleModelSelect(m.name)}
|
||||
>
|
||||
<ModelSelectorName>{m.display_name}</ModelSelectorName>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<ModelSelectorName>{m.display_name}</ModelSelectorName>
|
||||
<span className="text-muted-foreground truncate text-[10px]">
|
||||
{m.model}
|
||||
</span>
|
||||
</div>
|
||||
{m.name === context.model_name ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
|
||||
@@ -127,7 +127,7 @@ export function groupMessages<T>(
|
||||
|
||||
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 = /<think>\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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface Model {
|
||||
id: string;
|
||||
name: string;
|
||||
model: string;
|
||||
display_name: string;
|
||||
description?: string | null;
|
||||
supports_thinking?: boolean;
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user