fix: model arguments fixed
This commit is contained in:
@@ -2,9 +2,10 @@ from typing import Optional, Dict
|
|||||||
|
|
||||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
|
||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
|
|
||||||
|
from app.core.patched_openai_compat_provider import PatchedOpenAICompatProvider
|
||||||
|
|
||||||
|
|
||||||
def normalize_provider_name(provider: Optional[str]) -> Optional[str]:
|
def normalize_provider_name(provider: Optional[str]) -> Optional[str]:
|
||||||
if not provider:
|
if not provider:
|
||||||
@@ -51,7 +52,7 @@ def build_llm_provider(
|
|||||||
extra_headers=extra_headers,
|
extra_headers=extra_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
return OpenAICompatProvider(
|
return PatchedOpenAICompatProvider(
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
api_base=api_base,
|
api_base=api_base,
|
||||||
default_model=model,
|
default_model=model,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from nanobot.bus.events import OutboundMessage
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
|
||||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||||
from nanobot.providers.base import GenerationSettings
|
from nanobot.providers.base import GenerationSettings
|
||||||
@@ -32,6 +31,7 @@ from nanobot.config.schema import Config
|
|||||||
# or just import here if we are confident.
|
# or just import here if we are confident.
|
||||||
# Given the structure, importing here should be fine as long as skills.py doesn't import nanobot.py.
|
# Given the structure, importing here should be fine as long as skills.py doesn't import nanobot.py.
|
||||||
from app.api.skills import load_skills
|
from app.api.skills import load_skills
|
||||||
|
from app.core.patched_openai_compat_provider import PatchedOpenAICompatProvider
|
||||||
from app.services.llm_cache import get_llm_configs, get_active_llm_config
|
from app.services.llm_cache import get_llm_configs, get_active_llm_config
|
||||||
|
|
||||||
from app.core.data_root import get_workspace_root
|
from app.core.data_root import get_workspace_root
|
||||||
@@ -45,6 +45,7 @@ class NanobotIntegration:
|
|||||||
self._started = False
|
self._started = False
|
||||||
self._model_agent_cache: Dict[tuple[str | None, int | None], AgentLoop] = {}
|
self._model_agent_cache: Dict[tuple[str | None, int | None], AgentLoop] = {}
|
||||||
self._model_agent_lock = asyncio.Lock()
|
self._model_agent_lock = asyncio.Lock()
|
||||||
|
self._last_usage_by_session: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_config_value(value: Any) -> Any:
|
def _normalize_config_value(value: Any) -> Any:
|
||||||
@@ -80,6 +81,28 @@ class NanobotIntegration:
|
|||||||
return content
|
return content
|
||||||
return str(response)
|
return str(response)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_usage(usage: Any) -> Dict[str, int] | None:
|
||||||
|
if not isinstance(usage, dict):
|
||||||
|
return None
|
||||||
|
normalized: Dict[str, int] = {}
|
||||||
|
prompt = int(usage.get("prompt_tokens", 0) or 0)
|
||||||
|
completion = int(usage.get("completion_tokens", 0) or 0)
|
||||||
|
total = int(usage.get("total_tokens", 0) or 0)
|
||||||
|
|
||||||
|
# If total_tokens is missing or zero, calculate it
|
||||||
|
if total == 0:
|
||||||
|
total = prompt + completion
|
||||||
|
|
||||||
|
normalized["prompt_tokens"] = prompt
|
||||||
|
normalized["completion_tokens"] = completion
|
||||||
|
normalized["total_tokens"] = total
|
||||||
|
return normalized if (prompt > 0 or completion > 0) else None
|
||||||
|
|
||||||
|
def get_last_usage(self, session_id: str) -> Dict[str, int] | None:
|
||||||
|
usage = self._last_usage_by_session.get(session_id)
|
||||||
|
return dict(usage) if usage else None
|
||||||
|
|
||||||
def _need_custom_agent_for_target(self, target_config: Dict[str, Any]) -> bool:
|
def _need_custom_agent_for_target(self, target_config: Dict[str, Any]) -> bool:
|
||||||
if not self.agent:
|
if not self.agent:
|
||||||
return False
|
return False
|
||||||
@@ -220,7 +243,7 @@ class NanobotIntegration:
|
|||||||
extra_headers=extra_headers,
|
extra_headers=extra_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
return OpenAICompatProvider(
|
return PatchedOpenAICompatProvider(
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
api_base=api_base,
|
api_base=api_base,
|
||||||
default_model=model,
|
default_model=model,
|
||||||
@@ -407,6 +430,9 @@ class NanobotIntegration:
|
|||||||
on_progress=on_progress,
|
on_progress=on_progress,
|
||||||
on_stream=on_stream,
|
on_stream=on_stream,
|
||||||
)
|
)
|
||||||
|
usage = self._normalize_usage(getattr(agent_to_use, "_last_usage", None))
|
||||||
|
if usage:
|
||||||
|
self._last_usage_by_session[session_id] = usage
|
||||||
return self._extract_response_text(response)
|
return self._extract_response_text(response)
|
||||||
|
|
||||||
def _normalize_session_messages(self, messages: List[Any]) -> List[dict[str, Any]]:
|
def _normalize_session_messages(self, messages: List[Any]) -> List[dict[str, Any]]:
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
|
|
||||||
|
|
||||||
|
class PatchedOpenAICompatProvider(OpenAICompatProvider):
|
||||||
|
_MAX_COMPLETION_TOKEN_MODELS = ("gpt-5", "o1", "o3", "o4")
|
||||||
|
|
||||||
|
def _build_kwargs(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
tools: list[dict[str, Any]] | None,
|
||||||
|
model: str | None,
|
||||||
|
max_tokens: int,
|
||||||
|
temperature: float,
|
||||||
|
reasoning_effort: str | None,
|
||||||
|
tool_choice: str | dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
kwargs = super()._build_kwargs(
|
||||||
|
messages=messages,
|
||||||
|
tools=tools,
|
||||||
|
model=model,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
temperature=temperature,
|
||||||
|
reasoning_effort=reasoning_effort,
|
||||||
|
tool_choice=tool_choice,
|
||||||
|
)
|
||||||
|
|
||||||
|
model_name = (model or self.default_model or "").lower()
|
||||||
|
spec = self._spec
|
||||||
|
supports_max_completion_tokens = bool(
|
||||||
|
spec and getattr(spec, "supports_max_completion_tokens", False)
|
||||||
|
)
|
||||||
|
should_use_max_completion_tokens = supports_max_completion_tokens or any(
|
||||||
|
token in model_name for token in self._MAX_COMPLETION_TOKEN_MODELS
|
||||||
|
)
|
||||||
|
|
||||||
|
if should_use_max_completion_tokens and "max_tokens" in kwargs:
|
||||||
|
kwargs["max_completion_tokens"] = kwargs.pop("max_tokens")
|
||||||
|
|
||||||
|
return kwargs
|
||||||
+31
-2
@@ -268,6 +268,7 @@ def _persist_assistant_enrichment(
|
|||||||
session_id: str,
|
session_id: str,
|
||||||
viz_payload: Optional[Dict[str, Any]] = None,
|
viz_payload: Optional[Dict[str, Any]] = None,
|
||||||
artifacts: Optional[List[Dict[str, Any]]] = None,
|
artifacts: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
usage: Optional[Dict[str, Any]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not nanobot_service.agent:
|
if not nanobot_service.agent:
|
||||||
return
|
return
|
||||||
@@ -281,9 +282,25 @@ def _persist_assistant_enrichment(
|
|||||||
if artifacts:
|
if artifacts:
|
||||||
session.messages[-1]["artifacts"] = artifacts
|
session.messages[-1]["artifacts"] = artifacts
|
||||||
changed = True
|
changed = True
|
||||||
|
if usage:
|
||||||
|
session.messages[-1]["usage"] = usage
|
||||||
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
nanobot_service.agent.sessions.save(session)
|
nanobot_service.agent.sessions.save(session)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_reasoning_content(session_messages: List[Dict[str, Any]]) -> str:
|
||||||
|
for message in reversed(session_messages):
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
continue
|
||||||
|
if message.get("role") != "assistant":
|
||||||
|
continue
|
||||||
|
reasoning_content = message.get("reasoning_content")
|
||||||
|
if isinstance(reasoning_content, str) and reasoning_content.strip():
|
||||||
|
return reasoning_content
|
||||||
|
break
|
||||||
|
return ""
|
||||||
|
|
||||||
@app.post("/nanobot/chat")
|
@app.post("/nanobot/chat")
|
||||||
async def nanobot_chat(request: ChatRequest):
|
async def nanobot_chat(request: ChatRequest):
|
||||||
try:
|
try:
|
||||||
@@ -321,10 +338,12 @@ async def nanobot_chat(request: ChatRequest):
|
|||||||
artifacts = extract_artifacts(text, session_messages)
|
artifacts = extract_artifacts(text, session_messages)
|
||||||
|
|
||||||
viz_payload = current_viz_data.get()
|
viz_payload = current_viz_data.get()
|
||||||
|
usage = nanobot_service.get_last_usage(request.session_id)
|
||||||
_persist_assistant_enrichment(
|
_persist_assistant_enrichment(
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
viz_payload=viz_payload if isinstance(viz_payload, dict) else None,
|
viz_payload=viz_payload if isinstance(viz_payload, dict) else None,
|
||||||
artifacts=artifacts,
|
artifacts=artifacts,
|
||||||
|
usage=usage,
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@@ -334,6 +353,8 @@ async def nanobot_chat(request: ChatRequest):
|
|||||||
}
|
}
|
||||||
if artifacts:
|
if artifacts:
|
||||||
payload["artifacts"] = artifacts
|
payload["artifacts"] = artifacts
|
||||||
|
if usage:
|
||||||
|
payload["usage"] = usage
|
||||||
return payload
|
return payload
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -356,7 +377,9 @@ async def nanobot_chat_stream(request: ChatRequest):
|
|||||||
|
|
||||||
async def _on_progress(content: str, **kwargs: Any) -> None:
|
async def _on_progress(content: str, **kwargs: Any) -> None:
|
||||||
if content:
|
if content:
|
||||||
await progress_queue.put(content)
|
payload: Dict[str, Any] = {"type": "progress", "content": content}
|
||||||
|
payload.update(kwargs)
|
||||||
|
await progress_queue.put(payload)
|
||||||
|
|
||||||
async def _on_stream(delta: str) -> None:
|
async def _on_stream(delta: str) -> None:
|
||||||
if delta:
|
if delta:
|
||||||
@@ -427,9 +450,10 @@ async def nanobot_chat_stream(request: ChatRequest):
|
|||||||
session = nanobot_service.agent.sessions.get_or_create(request.session_id)
|
session = nanobot_service.agent.sessions.get_or_create(request.session_id)
|
||||||
session_messages = session.messages
|
session_messages = session.messages
|
||||||
artifacts = extract_artifacts(text, session_messages)
|
artifacts = extract_artifacts(text, session_messages)
|
||||||
|
reasoning_content = _extract_reasoning_content(session_messages)
|
||||||
|
|
||||||
# Check again for viz payload after task completes if not sent yet
|
|
||||||
viz_payload = current_viz_data.get()
|
viz_payload = current_viz_data.get()
|
||||||
|
usage = nanobot_service.get_last_usage(request.session_id)
|
||||||
if viz_payload:
|
if viz_payload:
|
||||||
try:
|
try:
|
||||||
current_hash = hash((
|
current_hash = hash((
|
||||||
@@ -447,11 +471,16 @@ async def nanobot_chat_stream(request: ChatRequest):
|
|||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
viz_payload=viz_payload if isinstance(viz_payload, dict) else None,
|
viz_payload=viz_payload if isinstance(viz_payload, dict) else None,
|
||||||
artifacts=artifacts,
|
artifacts=artifacts,
|
||||||
|
usage=usage,
|
||||||
)
|
)
|
||||||
|
|
||||||
final_payload = {"type": "final", "content": text}
|
final_payload = {"type": "final", "content": text}
|
||||||
|
if reasoning_content:
|
||||||
|
final_payload["reasoning_content"] = reasoning_content
|
||||||
if artifacts:
|
if artifacts:
|
||||||
final_payload["artifacts"] = artifacts
|
final_payload["artifacts"] = artifacts
|
||||||
|
if usage:
|
||||||
|
final_payload["usage"] = usage
|
||||||
yield f"data: {json.dumps(final_payload, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(final_payload, ensure_ascii=False)}\n\n"
|
||||||
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
|||||||
@@ -98,3 +98,48 @@ def test_nanobot_chat_stream_syncs_project_id(monkeypatch) -> None:
|
|||||||
assert "stream-complete" in content
|
assert "stream-complete" in content
|
||||||
assert calls == [{"session_key": "api:test-3", "project_id": 202}]
|
assert calls == [{"session_key": "api:test-3", "project_id": 202}]
|
||||||
assert process_kwargs and process_kwargs[0]["project_id"] == 202
|
assert process_kwargs and process_kwargs[0]["project_id"] == 202
|
||||||
|
|
||||||
|
|
||||||
|
def test_nanobot_chat_stream_emits_reasoning_flags_and_final_reasoning(monkeypatch) -> None:
|
||||||
|
async def fake_process_message(*args, **kwargs):
|
||||||
|
on_progress = kwargs.get("on_progress")
|
||||||
|
on_stream = kwargs.get("on_stream")
|
||||||
|
if on_progress:
|
||||||
|
await on_progress("模型正在拆解问题", is_reasoning=True)
|
||||||
|
await on_progress("开始执行工具", tool_hint=True)
|
||||||
|
if on_stream:
|
||||||
|
await on_stream("answer-token")
|
||||||
|
return "final-answer"
|
||||||
|
|
||||||
|
class _DummySession:
|
||||||
|
def __init__(self):
|
||||||
|
self.metadata = {}
|
||||||
|
self.messages = [
|
||||||
|
{"role": "assistant", "content": "final-answer", "reasoning_content": "完整思考过程"}
|
||||||
|
]
|
||||||
|
|
||||||
|
class _DummySessions:
|
||||||
|
def get_or_create(self, _key):
|
||||||
|
return _DummySession()
|
||||||
|
|
||||||
|
class _DummyAgent:
|
||||||
|
def __init__(self):
|
||||||
|
self.sessions = _DummySessions()
|
||||||
|
|
||||||
|
async def collect_stream_chunks(response) -> list[str]:
|
||||||
|
chunks: list[str] = []
|
||||||
|
async for chunk in response.body_iterator:
|
||||||
|
chunks.append(chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk)
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.nanobot_service, "process_message", fake_process_message)
|
||||||
|
monkeypatch.setattr(main.nanobot_service, "agent", _DummyAgent())
|
||||||
|
|
||||||
|
request = main.ChatRequest(message="hello", session_id="api:test-4", project_id=303)
|
||||||
|
response = asyncio.run(main.nanobot_chat_stream(request))
|
||||||
|
chunks = asyncio.run(collect_stream_chunks(response))
|
||||||
|
content = "".join(chunks)
|
||||||
|
|
||||||
|
assert '"type": "progress", "content": "模型正在拆解问题", "is_reasoning": true' in content
|
||||||
|
assert '"type": "progress", "content": "开始执行工具", "tool_hint": true' in content
|
||||||
|
assert '"type": "final", "content": "final-answer", "reasoning_content": "完整思考过程"' in content
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
REPO_ROOT = BACKEND_ROOT.parent
|
||||||
|
NANOBOT_ROOT = REPO_ROOT / "nanobot"
|
||||||
|
if str(BACKEND_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
if str(NANOBOT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(NANOBOT_ROOT))
|
||||||
|
|
||||||
|
from app.core.llm_provider import build_llm_provider
|
||||||
|
from app.core.nanobot import NanobotIntegration
|
||||||
|
from app.core.patched_openai_compat_provider import PatchedOpenAICompatProvider
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_llm_provider_uses_max_completion_tokens_for_gpt5() -> None:
|
||||||
|
provider = build_llm_provider(
|
||||||
|
model="gpt-5.4-nano",
|
||||||
|
provider="openai",
|
||||||
|
api_key="test-key",
|
||||||
|
api_base="https://example.com/v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(provider, PatchedOpenAICompatProvider)
|
||||||
|
kwargs = provider._build_kwargs(
|
||||||
|
messages=[{"role": "user", "content": "hello"}],
|
||||||
|
tools=None,
|
||||||
|
model="gpt-5.4-nano",
|
||||||
|
max_tokens=5,
|
||||||
|
temperature=0,
|
||||||
|
reasoning_effort=None,
|
||||||
|
tool_choice=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert kwargs["max_completion_tokens"] == 5
|
||||||
|
assert "max_tokens" not in kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def test_nanobot_provider_keeps_max_tokens_for_legacy_models() -> None:
|
||||||
|
integration = NanobotIntegration()
|
||||||
|
provider = integration._build_provider(
|
||||||
|
model="gpt-4o-mini",
|
||||||
|
provider_name="openai",
|
||||||
|
api_key="test-key",
|
||||||
|
api_base="https://example.com/v1",
|
||||||
|
extra_headers=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(provider, PatchedOpenAICompatProvider)
|
||||||
|
kwargs = provider._build_kwargs(
|
||||||
|
messages=[{"role": "user", "content": "hello"}],
|
||||||
|
tools=None,
|
||||||
|
model="gpt-4o-mini",
|
||||||
|
max_tokens=5,
|
||||||
|
temperature=0,
|
||||||
|
reasoning_effort=None,
|
||||||
|
tool_choice=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert kwargs["max_tokens"] == 5
|
||||||
|
assert "max_completion_tokens" not in kwargs
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { User, Loader2, ArrowUp, ChevronDown, Check, Square, Plus, Database, Wand2, Zap, CheckCircle2, Table, XCircle, Settings, ExternalLink, FileText, Download, Eye } from "lucide-react";
|
import { User, Loader2, ArrowUp, ChevronDown, Check, Square, Plus, Database, Wand2, Zap, CheckCircle2, Table, XCircle, Settings, ExternalLink, FileText, Download, Eye, Copy } from "lucide-react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { type ChartSpec } from "@/store/visualizationStore";
|
import { type ChartSpec } from "@/store/visualizationStore";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
@@ -25,6 +25,11 @@ interface Message {
|
|||||||
progressLogs?: string[];
|
progressLogs?: string[];
|
||||||
routeInfo?: string;
|
routeInfo?: string;
|
||||||
reasoningContent?: string;
|
reasoningContent?: string;
|
||||||
|
usage?: {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
};
|
||||||
artifacts?: MessageArtifact[];
|
artifacts?: MessageArtifact[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +188,8 @@ export function ChatInterface() {
|
|||||||
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
|
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const [artifactPreview, setArtifactPreview] = useState<ArtifactPreviewTarget | null>(null);
|
const [artifactPreview, setArtifactPreview] = useState<ArtifactPreviewTarget | null>(null);
|
||||||
|
const [collapsedThinkingByMessage, setCollapsedThinkingByMessage] = useState<Record<string, boolean>>({});
|
||||||
|
const [thinkingCopiedByMessage, setThinkingCopiedByMessage] = useState<Record<string, boolean>>({});
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { currentProject } = useProjectStore();
|
const { currentProject } = useProjectStore();
|
||||||
@@ -372,6 +379,8 @@ export function ChatInterface() {
|
|||||||
role: m.role as 'user' | 'assistant',
|
role: m.role as 'user' | 'assistant',
|
||||||
content: cleanContent,
|
content: cleanContent,
|
||||||
viz: m.viz ? buildMessageViz(m.viz) : undefined,
|
viz: m.viz ? buildMessageViz(m.viz) : undefined,
|
||||||
|
reasoningContent: typeof m.reasoning_content === "string" ? m.reasoning_content : undefined,
|
||||||
|
usage: m.usage,
|
||||||
artifacts: normalizeArtifacts(m.artifacts),
|
artifacts: normalizeArtifacts(m.artifacts),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -489,6 +498,22 @@ export function ChatInterface() {
|
|||||||
|
|
||||||
const selectedDataSourceName = availableDataSources.find(ds => ds.id === selectedDataSource)?.name || "";
|
const selectedDataSourceName = availableDataSources.find(ds => ds.id === selectedDataSource)?.name || "";
|
||||||
const selectedSkills = availableSkills.filter(skill => selectedSkillIds.includes(skill.id));
|
const selectedSkills = availableSkills.filter(skill => selectedSkillIds.includes(skill.id));
|
||||||
|
const isThinkingCollapsed = (messageId: string) => collapsedThinkingByMessage[messageId] ?? true;
|
||||||
|
const toggleThinkingCollapsed = (messageId: string) => {
|
||||||
|
setCollapsedThinkingByMessage((prev) => ({ ...prev, [messageId]: !(prev[messageId] ?? true) }));
|
||||||
|
};
|
||||||
|
const copyThinkingContent = async (messageId: string, content: string) => {
|
||||||
|
if (!content.trim()) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content);
|
||||||
|
setThinkingCopiedByMessage((prev) => ({ ...prev, [messageId]: true }));
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setThinkingCopiedByMessage((prev) => ({ ...prev, [messageId]: false }));
|
||||||
|
}, 1200);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to copy thinking content", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderActiveSelections = () => {
|
const renderActiveSelections = () => {
|
||||||
if (!selectedDataSource && selectedSkills.length === 0) return null;
|
if (!selectedDataSource && selectedSkills.length === 0) return null;
|
||||||
@@ -613,9 +638,7 @@ export function ChatInterface() {
|
|||||||
if (msg.id !== assistantId) return msg;
|
if (msg.id !== assistantId) return msg;
|
||||||
|
|
||||||
if (isReasoningToken) {
|
if (isReasoningToken) {
|
||||||
// 对于流式推理内容,拼接而不是创建新条目
|
return msg;
|
||||||
const currentReasoning = msg.reasoningContent || "";
|
|
||||||
return { ...msg, reasoningContent: currentReasoning + text };
|
|
||||||
} else {
|
} else {
|
||||||
// 对于普通的阶段性日志,取消 8 条限制,允许滚动查看所有历史
|
// 对于普通的阶段性日志,取消 8 条限制,允许滚动查看所有历史
|
||||||
const current = msg.progressLogs || [];
|
const current = msg.progressLogs || [];
|
||||||
@@ -673,6 +696,35 @@ export function ChatInterface() {
|
|||||||
let hasDonePayload = false;
|
let hasDonePayload = false;
|
||||||
let rafPending = false;
|
let rafPending = false;
|
||||||
let renderedText = "";
|
let renderedText = "";
|
||||||
|
let reasoningBuffer = "";
|
||||||
|
let reasoningRafPending = false;
|
||||||
|
|
||||||
|
const flushReasoning = (force = false) => {
|
||||||
|
if (!reasoningBuffer) return;
|
||||||
|
if (force) {
|
||||||
|
const content = reasoningBuffer;
|
||||||
|
reasoningBuffer = "";
|
||||||
|
setMessagesForSession(targetSessionKey, (prev) =>
|
||||||
|
prev.map((msg) =>
|
||||||
|
msg.id === assistantId ? { ...msg, reasoningContent: (msg.reasoningContent || "") + content } : msg
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reasoningRafPending) return;
|
||||||
|
reasoningRafPending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
reasoningRafPending = false;
|
||||||
|
if (!reasoningBuffer) return;
|
||||||
|
const content = reasoningBuffer;
|
||||||
|
reasoningBuffer = "";
|
||||||
|
setMessagesForSession(targetSessionKey, (prev) =>
|
||||||
|
prev.map((msg) =>
|
||||||
|
msg.id === assistantId ? { ...msg, reasoningContent: (msg.reasoningContent || "") + content } : msg
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const flushAssistant = (force = false) => {
|
const flushAssistant = (force = false) => {
|
||||||
if (streamedText === renderedText && !force) return;
|
if (streamedText === renderedText && !force) return;
|
||||||
@@ -717,6 +769,7 @@ export function ChatInterface() {
|
|||||||
type: string;
|
type: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
is_reasoning?: boolean;
|
is_reasoning?: boolean;
|
||||||
|
tool_hint?: boolean;
|
||||||
sql?: string;
|
sql?: string;
|
||||||
result?: unknown;
|
result?: unknown;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -724,6 +777,12 @@ export function ChatInterface() {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
chart?: { chart_spec?: ChartSpec | null; reasoning?: string; can_visualize?: boolean; chart_type?: string } | null;
|
chart?: { chart_spec?: ChartSpec | null; reasoning?: string; can_visualize?: boolean; chart_type?: string } | null;
|
||||||
artifacts?: unknown;
|
artifacts?: unknown;
|
||||||
|
reasoning_content?: string;
|
||||||
|
usage?: {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
if (payload.type === "delta" && payload.content) {
|
if (payload.type === "delta" && payload.content) {
|
||||||
@@ -743,9 +802,13 @@ export function ChatInterface() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type === "progress" && payload.content) {
|
if (payload.type === "progress" && payload.content) {
|
||||||
// 如果 progress 内容带有空格或者换行,并且不是典型的系统提示词,很可能这是 reasoning_content
|
if (payload.is_reasoning || payload.tool_hint) {
|
||||||
// 为了安全起见,我们在后端应该加上 is_reasoning 标记,这里我们通过启发式或者统一拼接
|
const nextLine = payload.content.endsWith("\n") ? payload.content : `${payload.content}\n`;
|
||||||
pushProgressLog(payload.content, payload.is_reasoning || false);
|
reasoningBuffer += nextLine;
|
||||||
|
flushReasoning(false);
|
||||||
|
} else {
|
||||||
|
pushProgressLog(payload.content, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type === "final") {
|
if (payload.type === "final") {
|
||||||
@@ -753,12 +816,22 @@ export function ChatInterface() {
|
|||||||
if (typeof payload.content === "string") {
|
if (typeof payload.content === "string") {
|
||||||
streamedText = payload.content;
|
streamedText = payload.content;
|
||||||
}
|
}
|
||||||
|
if (typeof payload.reasoning_content === "string") {
|
||||||
|
reasoningBuffer = "";
|
||||||
|
setMessagesForSession(targetSessionKey, (prev) =>
|
||||||
|
prev.map((msg) =>
|
||||||
|
msg.id === assistantId ? { ...msg, reasoningContent: payload.reasoning_content } : msg
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
flushReasoning(true);
|
||||||
|
}
|
||||||
flushAssistant(true);
|
flushAssistant(true);
|
||||||
pushProgressLog(t('answerGenerationCompleted'));
|
pushProgressLog(t('answerGenerationCompleted'));
|
||||||
const messageArtifacts = normalizeArtifacts(payload.artifacts);
|
const messageArtifacts = normalizeArtifacts(payload.artifacts);
|
||||||
setMessagesForSession(targetSessionKey, (prev) =>
|
setMessagesForSession(targetSessionKey, (prev) =>
|
||||||
prev.map((msg) =>
|
prev.map((msg) =>
|
||||||
msg.id === assistantId ? { ...msg, content: typeof payload.content === "string" ? payload.content : msg.content || "", awaitingFirstToken: false, viz: streamedViz ?? msg.viz, artifacts: messageArtifacts.length > 0 ? messageArtifacts : msg.artifacts } : msg
|
msg.id === assistantId ? { ...msg, content: typeof payload.content === "string" ? payload.content : msg.content || "", awaitingFirstToken: false, viz: streamedViz ?? msg.viz, usage: payload.usage, artifacts: messageArtifacts.length > 0 ? messageArtifacts : msg.artifacts } : msg
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -783,6 +856,7 @@ export function ChatInterface() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flushReasoning(true);
|
||||||
flushAssistant(true);
|
flushAssistant(true);
|
||||||
if (!streamedText && (hasFinalPayload || hasDonePayload)) {
|
if (!streamedText && (hasFinalPayload || hasDonePayload)) {
|
||||||
setMessagesForSession(targetSessionKey, (prev) =>
|
setMessagesForSession(targetSessionKey, (prev) =>
|
||||||
@@ -1046,6 +1120,14 @@ export function ChatInterface() {
|
|||||||
const isMessageGenerating = isLoading && msgIdx === messages.length - 1;
|
const isMessageGenerating = isLoading && msgIdx === messages.length - 1;
|
||||||
const { markdown, reportHtml } = splitReportHtml(msg.content);
|
const { markdown, reportHtml } = splitReportHtml(msg.content);
|
||||||
const externalReportUrl = extractExternalReport(msg.content);
|
const externalReportUrl = extractExternalReport(msg.content);
|
||||||
|
const fallbackThinkingLines = Array.from(new Set(
|
||||||
|
(msg.progressLogs || []).filter((log) =>
|
||||||
|
log &&
|
||||||
|
log !== t('requestSubmittedRouting') &&
|
||||||
|
log !== t('answerGenerationCompleted')
|
||||||
|
)
|
||||||
|
));
|
||||||
|
const displayedThinkingContent = (msg.reasoningContent || "").trim() || fallbackThinkingLines.join("\n");
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
@@ -1065,13 +1147,53 @@ export function ChatInterface() {
|
|||||||
>
|
>
|
||||||
{msg.role === "assistant" ? (
|
{msg.role === "assistant" ? (
|
||||||
<>
|
<>
|
||||||
{msg.reasoningContent && (
|
{displayedThinkingContent && (
|
||||||
<div className="mb-3 rounded-xl border border-zinc-200 bg-zinc-50/50 p-3 text-sm text-zinc-600 font-mono whitespace-pre-wrap leading-relaxed shadow-inner max-h-[300px] overflow-y-auto">
|
<div className="mb-3 rounded-xl border border-zinc-200 bg-zinc-50/50 p-3 text-sm text-zinc-600 font-mono whitespace-pre-wrap leading-relaxed shadow-inner">
|
||||||
<div className="flex items-center gap-2 mb-2 text-xs font-semibold text-zinc-500 uppercase tracking-wider">
|
<button
|
||||||
<Settings className={`h-3.5 w-3.5 ${msg.awaitingFirstToken ? 'animate-spin' : ''}`} />
|
type="button"
|
||||||
{t('thinkingProcess')}
|
onClick={() => toggleThinkingCollapsed(msg.id)}
|
||||||
</div>
|
className="w-full flex items-center justify-between gap-2 mb-2 text-xs font-semibold text-zinc-500 uppercase tracking-wider hover:text-zinc-700 transition-colors"
|
||||||
{msg.reasoningContent}
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Settings className={`h-3.5 w-3.5 ${msg.awaitingFirstToken ? 'animate-spin' : ''}`} />
|
||||||
|
{t('thinkingProcess')}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2 normal-case text-[11px]">
|
||||||
|
{msg.usage?.total_tokens ? (
|
||||||
|
<span>{t('thinkingTokens', { count: msg.usage.total_tokens })}</span>
|
||||||
|
) : msg.reasoningContent ? (
|
||||||
|
<span>{t('thinkingCharCount', { count: msg.reasoningContent.length })}</span>
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void copyThinkingContent(msg.id, displayedThinkingContent);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void copyThinkingContent(msg.id, displayedThinkingContent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 hover:bg-zinc-200/70 transition-colors"
|
||||||
|
>
|
||||||
|
{thinkingCopiedByMessage[msg.id] ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||||
|
<span>{thinkingCopiedByMessage[msg.id] ? t('copied') : t('copy')}</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{isThinkingCollapsed(msg.id) ? t('expandThinking') : t('collapseThinking')}
|
||||||
|
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", isThinkingCollapsed(msg.id) ? "-rotate-90" : "rotate-0")} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{!isThinkingCollapsed(msg.id) && (
|
||||||
|
<div className="max-h-[280px] overflow-y-auto pr-1">
|
||||||
|
{displayedThinkingContent}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{msg.progressLogs && msg.progressLogs.length > 0 ? (
|
{msg.progressLogs && msg.progressLogs.length > 0 ? (
|
||||||
|
|||||||
@@ -140,10 +140,8 @@ function Section({
|
|||||||
onRename,
|
onRename,
|
||||||
onTogglePinned,
|
onTogglePinned,
|
||||||
onToggleArchived,
|
onToggleArchived,
|
||||||
onBatchDelete,
|
|
||||||
activeKey,
|
activeKey,
|
||||||
isSelectionMode,
|
isSelectionMode,
|
||||||
setIsSelectionMode,
|
|
||||||
selectedKeys,
|
selectedKeys,
|
||||||
setSelectedKeys
|
setSelectedKeys
|
||||||
}: {
|
}: {
|
||||||
@@ -153,10 +151,8 @@ function Section({
|
|||||||
onRename: (key: string, currentTitle: string) => void;
|
onRename: (key: string, currentTitle: string) => void;
|
||||||
onTogglePinned: (key: string, pinned: boolean) => void;
|
onTogglePinned: (key: string, pinned: boolean) => void;
|
||||||
onToggleArchived: (key: string, archived: boolean) => void;
|
onToggleArchived: (key: string, archived: boolean) => void;
|
||||||
onBatchDelete: (keys: string[]) => void;
|
|
||||||
activeKey: string | null;
|
activeKey: string | null;
|
||||||
isSelectionMode: boolean;
|
isSelectionMode: boolean;
|
||||||
setIsSelectionMode: (val: boolean) => void;
|
|
||||||
selectedKeys: string[];
|
selectedKeys: string[];
|
||||||
setSelectedKeys: (val: string[] | ((prev: string[]) => string[])) => void;
|
setSelectedKeys: (val: string[] | ((prev: string[]) => string[])) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -684,13 +680,11 @@ function SidebarBody() {
|
|||||||
items={activeSessions}
|
items={activeSessions}
|
||||||
onSelect={handleSelectSession}
|
onSelect={handleSelectSession}
|
||||||
onDelete={handleDeleteSession}
|
onDelete={handleDeleteSession}
|
||||||
onBatchDelete={handleBatchDelete}
|
|
||||||
onRename={openRenameDialog}
|
onRename={openRenameDialog}
|
||||||
onTogglePinned={handleTogglePinned}
|
onTogglePinned={handleTogglePinned}
|
||||||
onToggleArchived={handleToggleArchived}
|
onToggleArchived={handleToggleArchived}
|
||||||
activeKey={activeSessionKey}
|
activeKey={activeSessionKey}
|
||||||
isSelectionMode={activeSelectionMode}
|
isSelectionMode={activeSelectionMode}
|
||||||
setIsSelectionMode={setActiveSelectionMode}
|
|
||||||
selectedKeys={activeSelectedKeys}
|
selectedKeys={activeSelectedKeys}
|
||||||
setSelectedKeys={setActiveSelectedKeys}
|
setSelectedKeys={setActiveSelectedKeys}
|
||||||
/>
|
/>
|
||||||
@@ -714,13 +708,11 @@ function SidebarBody() {
|
|||||||
items={archivedSessions}
|
items={archivedSessions}
|
||||||
onSelect={handleSelectSession}
|
onSelect={handleSelectSession}
|
||||||
onDelete={handleDeleteSession}
|
onDelete={handleDeleteSession}
|
||||||
onBatchDelete={handleBatchDelete}
|
|
||||||
onRename={openRenameDialog}
|
onRename={openRenameDialog}
|
||||||
onTogglePinned={handleTogglePinned}
|
onTogglePinned={handleTogglePinned}
|
||||||
onToggleArchived={handleToggleArchived}
|
onToggleArchived={handleToggleArchived}
|
||||||
activeKey={activeSessionKey}
|
activeKey={activeSessionKey}
|
||||||
isSelectionMode={archivedSelectionMode}
|
isSelectionMode={archivedSelectionMode}
|
||||||
setIsSelectionMode={setArchivedSelectionMode}
|
|
||||||
selectedKeys={archivedSelectedKeys}
|
selectedKeys={archivedSelectedKeys}
|
||||||
setSelectedKeys={setArchivedSelectedKeys}
|
setSelectedKeys={setArchivedSelectedKeys}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -36,6 +36,10 @@
|
|||||||
"processing": "Processing...",
|
"processing": "Processing...",
|
||||||
"processCompleted": "Completed",
|
"processCompleted": "Completed",
|
||||||
"thinkingProcess": "Thinking Process",
|
"thinkingProcess": "Thinking Process",
|
||||||
|
"thinkingTokens": "{{count}} tokens",
|
||||||
|
"thinkingCharCount": "{{count}} chars",
|
||||||
|
"expandThinking": "Expand",
|
||||||
|
"collapseThinking": "Collapse",
|
||||||
"modelThinking": "Model is thinking, please wait...",
|
"modelThinking": "Model is thinking, please wait...",
|
||||||
"openReportInNewTab": "Open report in new tab",
|
"openReportInNewTab": "Open report in new tab",
|
||||||
"artifactPreview": "File Preview",
|
"artifactPreview": "File Preview",
|
||||||
|
|||||||
@@ -49,6 +49,10 @@
|
|||||||
"processing": "正在处理中",
|
"processing": "正在处理中",
|
||||||
"processCompleted": "处理完成",
|
"processCompleted": "处理完成",
|
||||||
"thinkingProcess": "思考过程",
|
"thinkingProcess": "思考过程",
|
||||||
|
"thinkingTokens": "{{count}} tokens",
|
||||||
|
"thinkingCharCount": "{{count}} 字",
|
||||||
|
"expandThinking": "展开",
|
||||||
|
"collapseThinking": "收起",
|
||||||
"modelThinking": "模型思考中,请稍候...",
|
"modelThinking": "模型思考中,请稍候...",
|
||||||
"openReportInNewTab": "在新标签页中打开分析报告",
|
"openReportInNewTab": "在新标签页中打开分析报告",
|
||||||
"artifactPreview": "文件预览",
|
"artifactPreview": "文件预览",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { X, Type, AlignLeft, AlignCenter, AlignRight, Bold, Italic, Underline } from "lucide-react";
|
import { X, Type, Bold, Italic, Underline } from "lucide-react";
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line } from 'recharts';
|
||||||
import { VegaChart } from "@/components/VegaChart";
|
import { VegaChart } from "@/components/VegaChart";
|
||||||
import 'react-grid-layout/css/styles.css';
|
import 'react-grid-layout/css/styles.css';
|
||||||
|
|||||||
@@ -87,10 +87,8 @@ export function Login() {
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="absolute right-0 bottom-0 translate-y-4">
|
<div className="absolute right-0 bottom-0 translate-y-4">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger className="h-9 w-9 rounded-full bg-white/50 backdrop-blur-sm shadow-sm border border-zinc-200/50 text-zinc-500 hover:text-zinc-900 hover:bg-white transition-all inline-flex items-center justify-center">
|
||||||
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-full bg-white/50 backdrop-blur-sm shadow-sm border border-zinc-200/50 text-zinc-500 hover:text-zinc-900 hover:bg-white transition-all">
|
<Languages className="h-4 w-4" />
|
||||||
<Languages className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-32">
|
<DropdownMenuContent align="end" className="w-32">
|
||||||
<DropdownMenuItem onClick={() => i18n.changeLanguage('zh')} className={i18n.language === 'zh' ? 'bg-zinc-100' : ''}>
|
<DropdownMenuItem onClick={() => i18n.changeLanguage('zh')} className={i18n.language === 'zh' ? 'bg-zinc-100' : ''}>
|
||||||
|
|||||||
Reference in New Issue
Block a user