From ceab7fac14c3a8067670b85623697d4a42bd5fc1 Mon Sep 17 00:00:00 2001 From: Simon Su Date: Fri, 20 Mar 2026 17:18:59 +0800 Subject: [PATCH] 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 .... 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 --- backend/app/gateway/routers/models.py | 3 + backend/packages/harness/deerflow/client.py | 2 + .../deerflow/models/patched_minimax.py | 226 ++++++++++++++++++ backend/tests/test_client.py | 9 + backend/tests/test_patched_minimax.py | 149 ++++++++++++ frontend/src/app/layout.tsx | 13 +- frontend/src/app/mock/api/models/route.ts | 4 + .../[agent_name]/chats/[thread_id]/page.tsx | 8 +- .../app/workspace/chats/[thread_id]/page.tsx | 8 +- .../src/components/workspace/input-box.tsx | 20 +- frontend/src/core/messages/utils.ts | 41 +++- frontend/src/core/models/types.ts | 1 + frontend/src/core/threads/hooks.ts | 27 +++ frontend/src/styles/globals.css | 2 +- 14 files changed, 491 insertions(+), 22 deletions(-) create mode 100644 backend/packages/harness/deerflow/models/patched_minimax.py create mode 100644 backend/tests/test_patched_minimax.py 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;