feat(agent): control reasoning visibility

This commit is contained in:
2026-07-17 14:05:54 +08:00
parent bfaf2ebf67
commit a879dc2ff1
18 changed files with 368 additions and 51 deletions

View File

@@ -30,7 +30,13 @@ const historyDetailOpen = ref(false);
const selectedHistory = ref<PromptDetail | null>(null); const selectedHistory = ref<PromptDetail | null>(null);
const historyDetailLoading = ref(false); const historyDetailLoading = ref(false);
const agentDebugTrace = ref<Record<string, any>[]>([]); const agentDebugTrace = ref<Record<string, any>[]>([]);
const agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; content: string; streaming?: boolean }[]>([ const agentPreviewMessages = ref<{
role: "user" | "assistant" | "system";
content: string;
reasoning?: string;
showReasoning?: boolean;
streaming?: boolean;
}[]>([
{ role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" }, { role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" },
]); ]);
@@ -54,6 +60,7 @@ const runtimeForm = reactive({
frequencyPenalty: null as number | null, frequencyPenalty: null as number | null,
maxToken: 8192 as number | null, maxToken: 8192 as number | null,
streamEnabled: 1, streamEnabled: 1,
reasoningVisible: 0,
}); });
const promptDirty = computed(() => promptContent.value !== savedPromptContent.value); const promptDirty = computed(() => promptContent.value !== savedPromptContent.value);
@@ -109,6 +116,7 @@ function applyRuntimeConfig(value: AgentRuntimeConfig) {
frequencyPenalty: value.frequencyPenalty, frequencyPenalty: value.frequencyPenalty,
maxToken: value.maxToken, maxToken: value.maxToken,
streamEnabled: value.streamEnabled, streamEnabled: value.streamEnabled,
reasoningVisible: value.reasoningVisible,
}); });
} }
@@ -138,6 +146,7 @@ async function saveRuntimeConfig() {
frequencyPenalty: runtimeForm.frequencyPenalty, frequencyPenalty: runtimeForm.frequencyPenalty,
maxToken: runtimeForm.maxToken, maxToken: runtimeForm.maxToken,
streamEnabled: runtimeForm.streamEnabled, streamEnabled: runtimeForm.streamEnabled,
reasoningVisible: runtimeForm.reasoningVisible,
}); });
applyRuntimeConfig(saved); applyRuntimeConfig(saved);
const model = models.value.find((item) => item.id === saved.modelId); const model = models.value.find((item) => item.id === saved.modelId);
@@ -289,6 +298,14 @@ async function debugAgent() {
currentAssistant().content += chunk; currentAssistant().content += chunk;
scrollAgentPreview(); scrollAgentPreview();
}, },
(chunk) => {
currentAssistant().reasoning = (currentAssistant().reasoning || "") + chunk;
scrollAgentPreview();
},
(status) => {
currentAssistant().showReasoning = status.reasoningVisible;
scrollAgentPreview();
},
(result) => { (result) => {
agentDebugTrace.value = result.retrievalTrace || []; agentDebugTrace.value = result.retrievalTrace || [];
ElMessage.success(result.message || "Agent 调试完成"); ElMessage.success(result.message || "Agent 调试完成");
@@ -410,6 +427,21 @@ function errorMessage(error: unknown, fallback: string) {
:disabled="!runtimeConfig?.modelId" :disabled="!runtimeConfig?.modelId"
/> />
</el-form-item> </el-form-item>
<el-form-item class="agent-stream-setting">
<div class="agent-stream-setting-copy">
<strong>展示思考过程</strong>
<span>开启后用户端和后台调试预览可展开查看模型返回的思考内容关闭时后端不会下发该内容</span>
</div>
<el-switch
v-model="runtimeForm.reasoningVisible"
:active-value="1"
:inactive-value="0"
active-text="开启"
inactive-text="关闭"
inline-prompt
:disabled="!runtimeConfig?.modelId"
/>
</el-form-item>
</section> </section>
</el-form> </el-form>
</el-tab-pane> </el-tab-pane>
@@ -475,7 +507,12 @@ function errorMessage(error: unknown, fallback: string) {
<div class="agent-preview-bubble"> <div class="agent-preview-bubble">
<strong>{{ message.role === "user" ? "你" : "Agent" }}</strong> <strong>{{ message.role === "user" ? "你" : "Agent" }}</strong>
<template v-if="message.role === 'assistant'"> <template v-if="message.role === 'assistant'">
<StreamingMarkdownMessage :content="message.content" :streaming="message.streaming" /> <StreamingMarkdownMessage
:content="message.content"
:reasoning="message.reasoning"
:show-reasoning="message.showReasoning"
:streaming="message.streaming"
/>
</template> </template>
<pre v-else>{{ message.content }}</pre> <pre v-else>{{ message.content }}</pre>
</div> </div>

View File

@@ -4,47 +4,60 @@ import { computed } from "vue";
const props = defineProps<{ const props = defineProps<{
content: string; content: string;
reasoning?: string;
showReasoning?: boolean;
streaming?: boolean; streaming?: boolean;
}>(); }>();
const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true }); const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true });
const answer = computed(() => stripStreamingReasoning(props.content).replace(/^\s+/, "")); const parsed = computed(() => splitReasoning(props.content));
const answer = computed(() => parsed.value.answer.replace(/^\s+/, ""));
const reasoning = computed(() => props.reasoning || parsed.value.reasoning);
const renderedContent = computed(() => answer.value.trim() ? markdown.render(answer.value) : ""); const renderedContent = computed(() => answer.value.trim() ? markdown.render(answer.value) : "");
function stripStreamingReasoning(content: string) { function splitReasoning(content: string) {
const lower = content.toLowerCase(); const lower = content.toLowerCase();
let visible = ""; let answer = "";
let reasoning = "";
let cursor = 0; let cursor = 0;
let reasoningDepth = 0; let depth = 0;
while (cursor < content.length) { while (cursor < content.length) {
const tagStart = content.indexOf("<", cursor); const tagStart = content.indexOf("<", cursor);
if (tagStart === -1) { if (tagStart === -1) {
if (reasoningDepth === 0) visible += content.slice(cursor); if (depth) reasoning += content.slice(cursor);
else answer += content.slice(cursor);
break; break;
} }
if (reasoningDepth === 0) visible += content.slice(cursor, tagStart); const text = content.slice(cursor, tagStart);
if (depth) reasoning += text;
else answer += text;
const tail = lower.slice(tagStart); const tail = lower.slice(tagStart);
const openTag = tail.match(/^<think(?:\s[^>]*)?>/); const openTag = tail.match(/^<think(?:\s[^>]*)?>/);
if (openTag) { if (openTag) {
reasoningDepth += 1; depth += 1;
cursor = tagStart + openTag[0].length; cursor = tagStart + openTag[0].length;
continue; continue;
} }
const closeTag = tail.match(/^<\/think\s*>/); const closeTag = tail.match(/^<\/think\s*>/);
if (closeTag) { if (closeTag) {
reasoningDepth = Math.max(0, reasoningDepth - 1); depth = Math.max(0, depth - 1);
cursor = tagStart + closeTag[0].length; cursor = tagStart + closeTag[0].length;
continue; continue;
} }
if ("<think".startsWith(tail) || "</think>".startsWith(tail)) break; if ("<think".startsWith(tail) || "</think>".startsWith(tail)) break;
if (reasoningDepth === 0) visible += "<"; if (depth) reasoning += "<";
else answer += "<";
cursor = tagStart + 1; cursor = tagStart + 1;
} }
return visible; return { answer, reasoning };
} }
</script> </script>
<template> <template>
<details v-if="showReasoning && reasoning.trim()" class="agent-reasoning-panel" :open="streaming">
<summary>思考过程</summary>
<div class="agent-reasoning-content">{{ reasoning }}</div>
</details>
<div v-if="renderedContent" class="agent-preview-markdown" v-html="renderedContent"></div> <div v-if="renderedContent" class="agent-preview-markdown" v-html="renderedContent"></div>
<div v-else-if="streaming" class="agent-preview-generation-state"> <div v-else-if="streaming" class="agent-preview-generation-state">
<span></span><span></span><span></span> <span></span><span></span><span></span>

View File

@@ -222,6 +222,8 @@ export const api = {
export async function streamDebugAgent( export async function streamDebugAgent(
payload: Record<string, unknown>, payload: Record<string, unknown>,
onChunk: (chunk: string) => void, onChunk: (chunk: string) => void,
onReasoning: (chunk: string) => void,
onStatus: (status: { message: string; reasoningVisible: boolean }) => void,
onComplete: (result: AgentDebugStreamComplete) => void, onComplete: (result: AgentDebugStreamComplete) => void,
signal?: AbortSignal, signal?: AbortSignal,
) { ) {
@@ -256,9 +258,14 @@ export async function streamDebugAgent(
const parsed = JSON.parse(data) as AgentDebugStreamComplete & { const parsed = JSON.parse(data) as AgentDebugStreamComplete & {
type?: string; type?: string;
content?: string; content?: string;
reasoningVisible?: boolean;
}; };
if (parsed.type === "error") throw new Error(parsed.message || "Agent 调试失败"); if (parsed.type === "error") throw new Error(parsed.message || "Agent 调试失败");
if (parsed.type === "content" && parsed.content) onChunk(parsed.content); if (parsed.type === "content" && parsed.content) onChunk(parsed.content);
if (parsed.type === "reasoning" && parsed.content) onReasoning(parsed.content);
if (parsed.type === "status") {
onStatus({ message: parsed.message || "思考中", reasoningVisible: Boolean(parsed.reasoningVisible) });
}
if (parsed.type === "complete") onComplete(parsed); if (parsed.type === "complete") onComplete(parsed);
} }
} }

View File

@@ -1484,6 +1484,37 @@ textarea {
.agent-preview-generation-state span:nth-child(2) { animation-delay: 0.14s; } .agent-preview-generation-state span:nth-child(2) { animation-delay: 0.14s; }
.agent-preview-generation-state span:nth-child(3) { margin-right: 4px; animation-delay: 0.28s; } .agent-preview-generation-state span:nth-child(3) { margin-right: 4px; animation-delay: 0.28s; }
.agent-reasoning-panel {
margin: 6px 0 10px;
overflow: hidden;
border: 1px solid #dbe7e3;
border-radius: 10px;
background: #f2f7f5;
color: #496259;
}
.agent-reasoning-panel summary {
min-height: 34px;
display: flex;
align-items: center;
padding: 0 10px;
color: #315d52;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.agent-reasoning-content {
max-height: 220px;
overflow-y: auto;
padding: 0 10px 10px;
color: #5d7169;
font-size: 12px;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
}
@keyframes agent-generation-pulse { @keyframes agent-generation-pulse {
0%, 70%, 100% { opacity: 0.25; transform: translateY(0); } 0%, 70%, 100% { opacity: 0.25; transform: translateY(0); }
35% { opacity: 1; transform: translateY(-2px); } 35% { opacity: 1; transform: translateY(-2px); }

View File

@@ -138,6 +138,7 @@ export interface AgentGenerationConfig {
frequencyPenalty: number | null; frequencyPenalty: number | null;
maxToken: number; maxToken: number;
streamEnabled: number; streamEnabled: number;
reasoningVisible: number;
} }
export interface AgentRuntimeConfig extends AgentGenerationConfig { export interface AgentRuntimeConfig extends AgentGenerationConfig {

View File

@@ -29,6 +29,7 @@ from app.services.agent_debug_service import AgentDebugService
from app.services.feishu_service import FeishuKnowledgeService from app.services.feishu_service import FeishuKnowledgeService
from app.services.knowledge_service import KnowledgeScope from app.services.knowledge_service import KnowledgeScope
from app.services.model_service import ModelClientService from app.services.model_service import ModelClientService
from app.services.reasoning_policy_service import ReasoningPolicyService
from app.services.secret_service import MASKED_SECRET, SENSITIVE_CONFIG_KEYS, SecretService from app.services.secret_service import MASKED_SECRET, SENSITIVE_CONFIG_KEYS, SecretService
router = APIRouter() router = APIRouter()
@@ -205,7 +206,7 @@ def get_agent_runtime_config(
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
model = _enabled_model(db) model = _enabled_model(db)
return api_success(_agent_runtime_config_dict(model)) return api_success(_agent_runtime_config_dict(model, ReasoningPolicyService.is_visible(db)))
@router.put("/agent/runtime-config") @router.put("/agent/runtime-config")
@@ -228,6 +229,7 @@ def save_agent_runtime_config(
model.max_token = payload.maxToken model.max_token = payload.maxToken
model.stream_enabled = payload.streamEnabled model.stream_enabled = payload.streamEnabled
db.add(model) db.add(model)
ReasoningPolicyService.set_visible(db, payload.reasoningVisible == 1, current_admin.id)
OperationLogService.write( OperationLogService.write(
db, db,
admin_id=current_admin.id, admin_id=current_admin.id,
@@ -237,7 +239,7 @@ def save_agent_runtime_config(
) )
db.commit() db.commit()
db.refresh(model) db.refresh(model)
return api_success(_agent_runtime_config_dict(model)) return api_success(_agent_runtime_config_dict(model, ReasoningPolicyService.is_visible(db)))
@router.get("/model/list") @router.get("/model/list")
@@ -441,7 +443,7 @@ def _enabled_model(db: Session) -> ModelConfig | None:
) )
def _agent_runtime_config_dict(model: ModelConfig | None) -> dict: def _agent_runtime_config_dict(model: ModelConfig | None, reasoning_visible: bool = False) -> dict:
return { return {
"modelId": model.id if model is not None else None, "modelId": model.id if model is not None else None,
"modelName": (model.display_name or model.model_name) if model is not None else None, "modelName": (model.display_name or model.model_name) if model is not None else None,
@@ -452,6 +454,7 @@ def _agent_runtime_config_dict(model: ModelConfig | None) -> dict:
"frequencyPenalty": float(model.frequency_penalty) if model is not None and model.frequency_penalty is not None else None, "frequencyPenalty": float(model.frequency_penalty) if model is not None and model.frequency_penalty is not None else None,
"maxToken": model.max_token if model is not None and model.max_token is not None else 8192, "maxToken": model.max_token if model is not None and model.max_token is not None else 8192,
"streamEnabled": model.stream_enabled if model is not None else 1, "streamEnabled": model.stream_enabled if model is not None else 1,
"reasoningVisible": 1 if reasoning_visible else 0,
} }

View File

@@ -30,6 +30,7 @@ from app.services.chat_queue_runtime import (
) )
from app.services.chat_queue_service import load_chat_queue_config from app.services.chat_queue_service import load_chat_queue_config
from app.services.chat_stream_service import ChatStreamService from app.services.chat_stream_service import ChatStreamService
from app.services.reasoning_policy_service import ReasoningPolicyService
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -54,7 +55,14 @@ def history(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> dict: ) -> dict:
messages = ChatService.get_history(db, current_user, sessionId) messages = ChatService.get_history(db, current_user, sessionId)
return api_success([ChatMessageRead.model_validate(message).model_dump(mode="json") for message in messages]) reasoning_visible = ReasoningPolicyService.is_visible(db)
result = []
for message in messages:
item = ChatMessageRead.model_validate(message).model_dump(mode="json")
if message.role == "assistant" and not reasoning_visible:
item["content"] = ReasoningPolicyService.strip_reasoning(item["content"])
result.append(item)
return api_success(result)
@router.put("/session/title") @router.put("/session/title")
@@ -152,14 +160,20 @@ async def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user
return return
try: try:
reasoning_visible = ReasoningPolicyService.is_visible(db)
yield _sse_event( yield _sse_event(
"generating", "generating",
message="已进入生成队列,正在生成回答。", message="思考中",
activeCount=queue_request.active_count, activeCount=queue_request.active_count,
waitingCount=queue_request.waiting_count, waitingCount=queue_request.waiting_count,
reasoningVisible=reasoning_visible,
) )
async for chunk in ChatStreamService.stream_answer_async(db, current_user, payload.sessionId, payload.message): chunks = ChatStreamService.stream_answer_async(db, current_user, payload.sessionId, payload.message)
yield _sse_event("content", content=chunk) async for segment in ReasoningPolicyService.iter_segments(chunks):
if segment.kind == "content":
yield _sse_event("content", content=segment.content)
elif reasoning_visible:
yield _sse_event("reasoning", content=segment.content)
except HTTPException as exc: except HTTPException as exc:
yield _sse_event("error", message=str(exc.detail)) yield _sse_event("error", message=str(exc.detail))
except asyncio.CancelledError: except asyncio.CancelledError:

View File

@@ -99,6 +99,7 @@ class AgentRuntimeConfigSaveRequest(BaseModel):
frequencyPenalty: float | None = Field(default=None, ge=-2, le=2) frequencyPenalty: float | None = Field(default=None, ge=-2, le=2)
maxToken: int = Field(default=8192, ge=256, le=100000) maxToken: int = Field(default=8192, ge=256, le=100000)
streamEnabled: int = Field(default=1, ge=0, le=1) streamEnabled: int = Field(default=1, ge=0, le=1)
reasoningVisible: int = Field(default=0, ge=0, le=1)
class ModelSaveRequest(BaseModel): class ModelSaveRequest(BaseModel):

View File

@@ -11,6 +11,7 @@ from app.schemas.admin import AgentDebugRequest
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
from app.services.knowledge_agent_service import KnowledgeAgentService from app.services.knowledge_agent_service import KnowledgeAgentService
from app.services.model_stream_service import ModelStreamService from app.services.model_stream_service import ModelStreamService
from app.services.reasoning_policy_service import ReasoningPolicyService
from app.services.rag_service import PromptService, RagResult from app.services.rag_service import PromptService, RagResult
@@ -61,16 +62,24 @@ class AgentDebugService:
yield {"type": "error", "message": "模型不存在"} yield {"type": "error", "message": "模型不存在"}
return return
try: try:
yield {"type": "status", "stage": "retrieving", "message": "思考中"} reasoning_visible = ReasoningPolicyService.is_visible(db)
yield {
"type": "status",
"stage": "retrieving",
"message": "思考中",
"reasoningVisible": reasoning_visible,
}
rag_result = await cls.build_result(db, payload) rag_result = await cls.build_result(db, payload)
model_response = ModelStreamService.debug_stream_async( model_response = ModelStreamService.debug_stream_async(
model, model,
rag_result, rag_result,
cls.overrides(payload), cls.overrides(payload),
) )
async for chunk in model_response.chunks: async for segment in ReasoningPolicyService.iter_segments(model_response.chunks):
if chunk: if segment.kind == "content":
yield {"type": "content", "content": chunk} yield {"type": "content", "content": segment.content}
elif reasoning_visible:
yield {"type": "reasoning", "content": segment.content}
OperationLogService.write( OperationLogService.write(
db, db,
admin_id=current_admin.id, admin_id=current_admin.id,

View File

@@ -0,0 +1,124 @@
from __future__ import annotations
import re
from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass
from typing import Literal
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.ai_config import SystemConfig
REASONING_VISIBILITY_KEY = "show_model_reasoning"
_OPEN_TAG = re.compile(r"^<think(?:\s[^>]*)?>", re.IGNORECASE)
_CLOSE_TAG = re.compile(r"^</think\s*>", re.IGNORECASE)
@dataclass(frozen=True)
class ReasoningSegment:
kind: Literal["reasoning", "content"]
content: str
class ReasoningStreamParser:
"""Incrementally separates model reasoning tags from answer content."""
def __init__(self) -> None:
self._buffer = ""
self._depth = 0
def feed(self, chunk: str) -> list[ReasoningSegment]:
if not chunk:
return []
self._buffer += chunk
return list(self._drain(final=False))
def finish(self) -> list[ReasoningSegment]:
return list(self._drain(final=True))
def _drain(self, *, final: bool) -> Iterator[ReasoningSegment]:
while self._buffer:
tag_start = self._buffer.find("<")
if tag_start < 0:
yield self._segment(self._buffer)
self._buffer = ""
return
if tag_start > 0:
yield self._segment(self._buffer[:tag_start])
self._buffer = self._buffer[tag_start:]
continue
open_tag = _OPEN_TAG.match(self._buffer)
if open_tag:
self._depth += 1
self._buffer = self._buffer[open_tag.end():]
continue
close_tag = _CLOSE_TAG.match(self._buffer)
if close_tag:
self._depth = max(0, self._depth - 1)
self._buffer = self._buffer[close_tag.end():]
continue
lower = self._buffer.lower()
incomplete_tag = (
"<think".startswith(lower)
or "</think>".startswith(lower)
or (lower.startswith("<think") and ">" not in lower)
or (lower.startswith("</think") and ">" not in lower)
)
if incomplete_tag and not final:
return
if incomplete_tag and final:
self._buffer = ""
return
yield self._segment("<")
self._buffer = self._buffer[1:]
def _segment(self, content: str) -> ReasoningSegment:
return ReasoningSegment("reasoning" if self._depth else "content", content)
class ReasoningPolicyService:
@staticmethod
def is_visible(db: Session) -> bool:
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == REASONING_VISIBILITY_KEY))
if config is None:
return False
return config.config_value.strip().lower() in {"1", "true", "yes", "on"}
@staticmethod
def set_visible(db: Session, visible: bool, admin_id: int | None) -> SystemConfig:
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == REASONING_VISIBILITY_KEY))
if config is None:
config = SystemConfig(config_key=REASONING_VISIBILITY_KEY, config_value="false")
config.config_value = "true" if visible else "false"
config.description = "是否向用户端及后台预览展示模型思考过程"
config.updated_by = admin_id
db.add(config)
return config
@staticmethod
def split_text(text: str) -> tuple[str, str]:
parser = ReasoningStreamParser()
segments = [*parser.feed(text), *parser.finish()]
answer = "".join(item.content for item in segments if item.kind == "content")
reasoning = "".join(item.content for item in segments if item.kind == "reasoning")
return answer, reasoning
@classmethod
def strip_reasoning(cls, text: str) -> str:
return cls.split_text(text)[0]
@staticmethod
async def iter_segments(chunks: AsyncIterator[str]) -> AsyncIterator[ReasoningSegment]:
parser = ReasoningStreamParser()
async for chunk in chunks:
for segment in parser.feed(chunk):
if segment.content:
yield segment
for segment in parser.finish():
if segment.content:
yield segment

View File

@@ -3,6 +3,7 @@ from decimal import Decimal
import json import json
from unittest.mock import patch from unittest.mock import patch
import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
@@ -10,7 +11,7 @@ from sqlalchemy.pool import StaticPool
from app.api.admin_settings import _debug_agent_stream, get_agent_runtime_config, save_agent_runtime_config from app.api.admin_settings import _debug_agent_stream, get_agent_runtime_config, save_agent_runtime_config
from app.models import Base from app.models import Base
from app.models.admin import Admin from app.models.admin import Admin
from app.models.ai_config import ModelConfig from app.models.ai_config import ModelConfig, SystemConfig
from app.schemas.admin import AgentDebugRequest, AgentRuntimeConfigSaveRequest from app.schemas.admin import AgentDebugRequest, AgentRuntimeConfigSaveRequest
from app.services.model_stream_service import ( from app.services.model_stream_service import (
AsyncStreamingModelResponse, AsyncStreamingModelResponse,
@@ -57,7 +58,11 @@ def test_runtime_config_defaults_to_long_answer_safe_max_tokens():
with _database() as db: with _database() as db:
admin = _admin() admin = _admin()
model = _model() model = _model()
db.add_all([admin, model]) db.add_all([
admin,
model,
SystemConfig(id=1, config_key="show_model_reasoning", config_value="false"),
])
db.commit() db.commit()
result = get_agent_runtime_config(db=db, current_admin=admin)["data"] result = get_agent_runtime_config(db=db, current_admin=admin)["data"]
@@ -66,6 +71,7 @@ def test_runtime_config_defaults_to_long_answer_safe_max_tokens():
assert result["modelName"] == "正式模型" assert result["modelName"] == "正式模型"
assert result["maxToken"] == 8192 assert result["maxToken"] == 8192
assert result["streamEnabled"] == 1 assert result["streamEnabled"] == 1
assert result["reasoningVisible"] == 0
assert _max_output_tokens(model) == 8192 assert _max_output_tokens(model) == 8192
@@ -73,7 +79,11 @@ def test_saved_runtime_config_is_persisted_on_enabled_model():
with _database() as db: with _database() as db:
admin = _admin() admin = _admin()
model = _model() model = _model()
db.add_all([admin, model]) db.add_all([
admin,
model,
SystemConfig(id=1, config_key="show_model_reasoning", config_value="false"),
])
db.commit() db.commit()
result = save_agent_runtime_config( result = save_agent_runtime_config(
@@ -85,6 +95,7 @@ def test_saved_runtime_config_is_persisted_on_enabled_model():
frequencyPenalty=0.4, frequencyPenalty=0.4,
maxToken=12000, maxToken=12000,
streamEnabled=0, streamEnabled=0,
reasoningVisible=1,
), ),
db=db, db=db,
current_admin=admin, current_admin=admin,
@@ -100,6 +111,8 @@ def test_saved_runtime_config_is_persisted_on_enabled_model():
assert model.frequency_penalty == Decimal("0.40") assert model.frequency_penalty == Decimal("0.40")
assert model.stream_enabled == 0 assert model.stream_enabled == 0
assert result["streamEnabled"] == 0 assert result["streamEnabled"] == 0
assert result["reasoningVisible"] == 1
assert db.query(SystemConfig).filter_by(config_key="show_model_reasoning").one().config_value == "true"
payload = _openai_stream_payload( payload = _openai_stream_payload(
model, model,
@@ -133,7 +146,8 @@ def test_disabled_stream_returns_one_complete_chunk():
assert chunks == [answer] assert chunks == [answer]
def test_agent_debug_stream_emits_status_content_and_trace(): @pytest.mark.parametrize("reasoning_visible", [True, False])
def test_agent_debug_stream_respects_reasoning_visibility(reasoning_visible):
async def chunks(): async def chunks():
yield "<think>内部思考</think>" yield "<think>内部思考</think>"
yield "- **第一步**:停一下" yield "- **第一步**:停一下"
@@ -162,6 +176,11 @@ def test_agent_debug_stream_emits_status_content_and_trace():
admin = _admin() admin = _admin()
model = _model() model = _model()
db.add_all([admin, model]) db.add_all([admin, model])
db.add(SystemConfig(
id=1,
config_key="show_model_reasoning",
config_value="true" if reasoning_visible else "false",
))
db.commit() db.commit()
payload = AgentDebugRequest( payload = AgentDebugRequest(
promptContent="你是测试助手", promptContent="你是测试助手",
@@ -187,10 +206,18 @@ def test_agent_debug_stream_emits_status_content_and_trace():
for event in events for event in events
if event != "data: [DONE]\n\n" if event != "data: [DONE]\n\n"
] ]
assert [event["type"] for event in decoded] == ["status", "content", "content", "complete"] expected_types = (
assert decoded[1]["content"].startswith("<think>") ["status", "reasoning", "content", "complete"]
assert decoded[2]["content"] == "- **第一步**:停一下" if reasoning_visible
assert decoded[3]["retrievalTrace"][0]["tool"] == "KnowledgeSearch" else ["status", "content", "complete"]
)
assert [event["type"] for event in decoded] == expected_types
assert decoded[0]["reasoningVisible"] is reasoning_visible
if reasoning_visible:
assert decoded[1]["content"] == "内部思考"
content_event = next(event for event in decoded if event["type"] == "content")
assert content_event["content"] == "- **第一步**:停一下"
assert decoded[-1]["retrievalTrace"][0]["tool"] == "KnowledgeSearch"
def test_agent_debug_does_not_truncate_long_answer(monkeypatch): def test_agent_debug_does_not_truncate_long_answer(monkeypatch):

View File

@@ -101,6 +101,7 @@ def test_queued_chat_reports_position_and_completes(monkeypatch):
monkeypatch.setattr(chat, "wait_for_chat_slot", wait_slot) monkeypatch.setattr(chat, "wait_for_chat_slot", wait_slot)
monkeypatch.setattr(chat, "release_chat_slot", release_slot) monkeypatch.setattr(chat, "release_chat_slot", release_slot)
monkeypatch.setattr(chat.ChatStreamService, "stream_answer_async", stream_answer) monkeypatch.setattr(chat.ChatStreamService, "stream_answer_async", stream_answer)
monkeypatch.setattr(chat.ReasoningPolicyService, "is_visible", lambda _db: False)
payload = type("Payload", (), {"sessionId": 1, "message": "问题"})() payload = type("Payload", (), {"sessionId": 1, "message": "问题"})()
async def collect_events(): async def collect_events():

View File

@@ -0,0 +1,18 @@
from app.services.reasoning_policy_service import ReasoningPolicyService, ReasoningStreamParser
def test_reasoning_parser_handles_tags_split_across_chunks():
parser = ReasoningStreamParser()
segments = []
for chunk in ["<thi", "nk>内部", "思考</th", "ink>## 回答"]:
segments.extend(parser.feed(chunk))
segments.extend(parser.finish())
assert "".join(item.content for item in segments if item.kind == "reasoning") == "内部思考"
assert "".join(item.content for item in segments if item.kind == "content") == "## 回答"
def test_reasoning_is_removed_from_history_text_when_policy_is_off():
text = "<think>不应透出</think>\n正式回答"
assert ReasoningPolicyService.strip_reasoning(text) == "\n正式回答"

View File

@@ -150,9 +150,16 @@ async function send(message: string, complete: (success: boolean) => void) {
currentAssistant().content += chunk; currentAssistant().content += chunk;
if (followingOutput.value) await messageList.value?.scrollToBottom(); if (followingOutput.value) await messageList.value?.scrollToBottom();
}, },
async (statusMessage, statusType) => { async (chunk) => {
currentAssistant().reasoning = (currentAssistant().reasoning || "") + chunk;
if (followingOutput.value) await messageList.value?.scrollToBottom();
},
async (statusMessage, statusType, reasoningVisible) => {
if (statusType === "queued") currentAssistant().content = statusMessage || "当前请求较多,正在排队中。"; if (statusType === "queued") currentAssistant().content = statusMessage || "当前请求较多,正在排队中。";
if (statusType === "generating" && !hasContent) currentAssistant().content = ""; if (statusType === "generating") {
currentAssistant().showReasoning = reasoningVisible;
if (!hasContent) currentAssistant().content = "";
}
if (followingOutput.value) await messageList.value?.scrollToBottom(); if (followingOutput.value) await messageList.value?.scrollToBottom();
}, },
activeAbortController.value.signal, activeAbortController.value.signal,
@@ -256,6 +263,7 @@ function toUiMessage(message: ApiMessage): DisplayMessage {
id: String(message.id), id: String(message.id),
role: message.role, role: message.role,
content: message.content, content: message.content,
showReasoning: message.role === "assistant" && /<think(?:\s[^>]*)?>/i.test(message.content),
createdAt: message.created_at, createdAt: message.created_at,
streaming: message.message_status === "GENERATING", streaming: message.message_status === "GENERATING",
}; };

View File

@@ -7,58 +7,63 @@ const props = defineProps<{
messageId: string; messageId: string;
role: "user" | "assistant"; role: "user" | "assistant";
content: string; content: string;
reasoning?: string;
showReasoning?: boolean;
createdAt: string; createdAt: string;
streaming?: boolean; streaming?: boolean;
}>(); }>();
const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true }); const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true });
const parsed = computed(() => splitReasoning(props.content));
const answer = computed(() => { const answer = computed(() => {
if (props.role === "user") return props.content; if (props.role === "user") return props.content;
return stripStreamingReasoning(props.content).replace(/^\s+/, ""); return parsed.value.answer.replace(/^\s+/, "");
}); });
const reasoning = computed(() => props.reasoning || parsed.value.reasoning);
const hasAnswer = computed(() => answer.value.trim().length > 0); const hasAnswer = computed(() => answer.value.trim().length > 0);
const renderedContent = computed(() => { const renderedContent = computed(() => {
if (props.role !== "assistant") return answer.value; if (props.role !== "assistant") return answer.value;
return hasAnswer.value ? markdown.render(answer.value) : ""; return hasAnswer.value ? markdown.render(answer.value) : "";
}); });
function stripStreamingReasoning(content: string) { function splitReasoning(content: string) {
const lower = content.toLowerCase(); const lower = content.toLowerCase();
let visible = ""; let answer = "";
let reasoning = "";
let cursor = 0; let cursor = 0;
let reasoningDepth = 0; let depth = 0;
while (cursor < content.length) { while (cursor < content.length) {
const tagStart = content.indexOf("<", cursor); const tagStart = content.indexOf("<", cursor);
if (tagStart === -1) { if (tagStart === -1) {
if (reasoningDepth === 0) visible += content.slice(cursor); if (depth) reasoning += content.slice(cursor);
else answer += content.slice(cursor);
break; break;
} }
if (reasoningDepth === 0) visible += content.slice(cursor, tagStart); const text = content.slice(cursor, tagStart);
if (depth) reasoning += text;
else answer += text;
const tail = lower.slice(tagStart); const tail = lower.slice(tagStart);
const openTag = tail.match(/^<think(?:\s[^>]*)?>/); const openTag = tail.match(/^<think(?:\s[^>]*)?>/);
if (openTag) { if (openTag) {
reasoningDepth += 1; depth += 1;
cursor = tagStart + openTag[0].length; cursor = tagStart + openTag[0].length;
continue; continue;
} }
const closeTag = tail.match(/^<\/think\s*>/); const closeTag = tail.match(/^<\/think\s*>/);
if (closeTag) { if (closeTag) {
reasoningDepth = Math.max(0, reasoningDepth - 1); depth = Math.max(0, depth - 1);
cursor = tagStart + closeTag[0].length; cursor = tagStart + closeTag[0].length;
continue; continue;
} }
if ("<think".startsWith(tail) || "</think>".startsWith(tail)) break; if ("<think".startsWith(tail) || "</think>".startsWith(tail)) break;
if (reasoningDepth === 0) visible += "<"; if (depth) reasoning += "<";
else answer += "<";
cursor = tagStart + 1; cursor = tagStart + 1;
} }
return visible; return { answer, reasoning };
} }
const displayTime = computed(() => { const displayTime = computed(() => {
// 消息服务返回的是 UTC 无时区字符串,补齐时区后再按用户本地时间展示。
const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(props.createdAt) ? props.createdAt : `${props.createdAt}Z`; const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(props.createdAt) ? props.createdAt : `${props.createdAt}Z`;
const date = new Date(normalized); const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return ""; if (Number.isNaN(date.getTime())) return "";
@@ -73,6 +78,10 @@ const displayTime = computed(() => {
</div> </div>
<div class="message-bubble"> <div class="message-bubble">
<template v-if="role === 'assistant'"> <template v-if="role === 'assistant'">
<details v-if="showReasoning && reasoning.trim()" class="reasoning-panel" :open="streaming">
<summary>思考过程</summary>
<div class="reasoning-content">{{ reasoning }}</div>
</details>
<div v-if="renderedContent" class="message-content markdown-content" v-html="renderedContent"></div> <div v-if="renderedContent" class="message-content markdown-content" v-html="renderedContent"></div>
<div v-if="streaming && !renderedContent" class="generation-state"> <div v-if="streaming && !renderedContent" class="generation-state">
<span></span><span></span><span></span> <span></span><span></span><span></span>

View File

@@ -7,6 +7,8 @@ export interface DisplayMessage {
id: string; id: string;
role: "user" | "assistant"; role: "user" | "assistant";
content: string; content: string;
reasoning?: string;
showReasoning?: boolean;
createdAt: string; createdAt: string;
streaming?: boolean; streaming?: boolean;
} }
@@ -57,6 +59,8 @@ defineExpose({ scrollToBottom, scrollToMessage });
:message-id="message.id" :message-id="message.id"
:role="message.role" :role="message.role"
:content="message.content" :content="message.content"
:reasoning="message.reasoning"
:show-reasoning="message.showReasoning"
:created-at="message.createdAt" :created-at="message.createdAt"
:streaming="message.streaming" :streaming="message.streaming"
/> />

View File

@@ -83,7 +83,8 @@ export async function streamChat(
sessionId: number, sessionId: number,
message: string, message: string,
onChunk: (chunk: string) => void, onChunk: (chunk: string) => void,
onStatus?: (message: string, type: string) => void, onReasoning?: (chunk: string) => void,
onStatus?: (message: string, type: string, reasoningVisible: boolean) => void,
signal?: AbortSignal, signal?: AbortSignal,
) { ) {
const token = getToken(); const token = getToken();
@@ -114,15 +115,21 @@ export async function streamChat(
if (!dataLines.length) continue; if (!dataLines.length) continue;
const payload = dataLines.map((line) => line.slice(5).trimStart()).join("\n").trim(); const payload = dataLines.map((line) => line.slice(5).trimStart()).join("\n").trim();
if (payload === "[DONE]") return; if (payload === "[DONE]") return;
const parsed = JSON.parse(payload) as { type?: string; content?: string; message?: string }; const parsed = JSON.parse(payload) as {
type?: string;
content?: string;
message?: string;
reasoningVisible?: boolean;
};
if (parsed.type === "queued" || parsed.type === "generating") { if (parsed.type === "queued" || parsed.type === "generating") {
onStatus?.(parsed.message || "", parsed.type); onStatus?.(parsed.message || "", parsed.type, Boolean(parsed.reasoningVisible));
continue; continue;
} }
if (parsed.type === "error") { if (parsed.type === "error") {
throw new Error(parsed.message || "AI 回复失败"); throw new Error(parsed.message || "AI 回复失败");
} }
if (parsed.content) onChunk(parsed.content); if (parsed.type === "reasoning" && parsed.content) onReasoning?.(parsed.content);
if (parsed.type === "content" && parsed.content) onChunk(parsed.content);
} }
} }
} }

View File

@@ -818,6 +818,9 @@ textarea:focus-visible {
padding: 0 10px 10px; padding: 0 10px 10px;
color: #5d7169; color: #5d7169;
font-size: 13px; font-size: 13px;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
} }
.markdown-content > :first-child { .markdown-content > :first-child {