From 13fed0467a9fee2d568b878873b39cbe6d9be865 Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Fri, 17 Jul 2026 13:34:27 +0800 Subject: [PATCH] feat(chat): add configurable streaming output --- .../src/components/AgentManagementView.vue | 18 +++++++ .../apps/admin-web/src/styles.css | 40 ++++++++++++++++ .../apps/admin-web/src/types/api.ts | 1 + .../apps/backend/app/api/admin_settings.py | 2 + .../apps/backend/app/api/chat.py | 10 +++- .../apps/backend/app/schemas/admin.py | 1 + .../app/services/model_stream_service.py | 31 ++++++++---- .../tests/test_agent_runtime_config.py | 29 +++++++++++- .../tests/test_production_readiness.py | 9 ++++ .../src/components/ChatMessage.vue | 47 +++++++++++++++---- .../apps/user-client/src/services/api.ts | 8 ++-- 11 files changed, 172 insertions(+), 24 deletions(-) diff --git a/ai_knowledge_base_v2/apps/admin-web/src/components/AgentManagementView.vue b/ai_knowledge_base_v2/apps/admin-web/src/components/AgentManagementView.vue index 5b0276d..1a092ab 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/components/AgentManagementView.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/components/AgentManagementView.vue @@ -50,6 +50,7 @@ const runtimeForm = reactive({ presencePenalty: null as number | null, frequencyPenalty: null as number | null, maxToken: 8192 as number | null, + streamEnabled: 1, }); const promptDirty = computed(() => promptContent.value !== savedPromptContent.value); @@ -104,6 +105,7 @@ function applyRuntimeConfig(value: AgentRuntimeConfig) { presencePenalty: value.presencePenalty, frequencyPenalty: value.frequencyPenalty, maxToken: value.maxToken, + streamEnabled: value.streamEnabled, }); } @@ -132,6 +134,7 @@ async function saveRuntimeConfig() { presencePenalty: runtimeForm.presencePenalty, frequencyPenalty: runtimeForm.frequencyPenalty, maxToken: runtimeForm.maxToken, + streamEnabled: runtimeForm.streamEnabled, }); applyRuntimeConfig(saved); const model = models.value.find((item) => item.id === saved.modelId); @@ -368,6 +371,21 @@ function errorMessage(error: unknown, fallback: string) { v-model:max-token="runtimeForm.maxToken" :disabled="!runtimeConfig?.modelId" /> + +
+ 用户端流式输出 + 开启后回答正文会边生成边展示;关闭后等待模型完成,再一次性显示完整回答。 +
+ +
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/styles.css b/ai_knowledge_base_v2/apps/admin-web/src/styles.css index 41aa7cb..c84aa33 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/styles.css +++ b/ai_knowledge_base_v2/apps/admin-web/src/styles.css @@ -1017,6 +1017,46 @@ textarea { font-size: 12px; } +.agent-stream-setting { + margin: 16px 0 0; + padding: 14px 16px; + border: 1px solid #dce7e3; + border-radius: 8px; + background: #ffffff; +} + +.agent-stream-setting > .el-form-item__content { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +.agent-stream-setting-copy { + display: grid; + gap: 4px; + min-width: 0; +} + +.agent-stream-setting-copy strong { + color: #263832; + font-size: 14px; +} + +.agent-stream-setting-copy span { + color: #667a73; + font-size: 12px; + line-height: 1.6; +} + +@media (max-width: 680px) { + .agent-stream-setting > .el-form-item__content { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } +} + .agent-parameter-label { display: inline-flex; align-items: center; diff --git a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts index 1c76280..8fa4bbf 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts @@ -137,6 +137,7 @@ export interface AgentGenerationConfig { presencePenalty: number | null; frequencyPenalty: number | null; maxToken: number; + streamEnabled: number; } export interface AgentRuntimeConfig extends AgentGenerationConfig { diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py index d024a1d..695b4de 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py @@ -212,6 +212,7 @@ def save_agent_runtime_config( model.presence_penalty = payload.presencePenalty model.frequency_penalty = payload.frequencyPenalty model.max_token = payload.maxToken + model.stream_enabled = payload.streamEnabled db.add(model) OperationLogService.write( db, @@ -436,6 +437,7 @@ def _agent_runtime_config_dict(model: ModelConfig | None) -> dict: "presencePenalty": float(model.presence_penalty) if model is not None and model.presence_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, + "streamEnabled": model.stream_enabled if model is not None else 1, } diff --git a/ai_knowledge_base_v2/apps/backend/app/api/chat.py b/ai_knowledge_base_v2/apps/backend/app/api/chat.py index 4fff921..8a9b40d 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/chat.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/chat.py @@ -83,7 +83,15 @@ def completions( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> StreamingResponse: - return StreamingResponse(_chat_stream(payload, db, current_user), media_type="text/event-stream") + return StreamingResponse( + _chat_stream(payload, db, current_user), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) @router.post("/stop") diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py b/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py index c3ec396..6d2f858 100644 --- a/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py +++ b/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py @@ -98,6 +98,7 @@ class AgentRuntimeConfigSaveRequest(BaseModel): presencePenalty: 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) + streamEnabled: int = Field(default=1, ge=0, le=1) class ModelSaveRequest(BaseModel): diff --git a/ai_knowledge_base_v2/apps/backend/app/services/model_stream_service.py b/ai_knowledge_base_v2/apps/backend/app/services/model_stream_service.py index fec4f1f..ea31419 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/model_stream_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/model_stream_service.py @@ -60,7 +60,7 @@ class ModelStreamService: model_id=model.id if model is not None else None, model_name=model_name, input_token=_rough_token_count(rag_result.prompt), - chunks=_chunk_text(_mock_answer(rag_result)), + chunks=_display_chunks(model, _mock_answer(rag_result)), ) if not rag_result.is_hit and not rag_result.allow_general_knowledge: @@ -68,7 +68,7 @@ class ModelStreamService: model_id=model.id if model is not None else None, model_name=model.model_name if model is not None else "no-hit", input_token=_rough_token_count(rag_result.prompt), - chunks=_chunk_text(NO_HIT_ANSWER), + chunks=_display_chunks(model, NO_HIT_ANSWER), ) if model is None: @@ -94,7 +94,7 @@ class ModelStreamService: model_id=model.id if model is not None else None, model_name=model_name, input_token=_rough_token_count(rag_result.prompt), - chunks=_async_chunk_text(_mock_answer(rag_result)), + chunks=_async_display_chunks(model, _mock_answer(rag_result)), ) if not rag_result.is_hit and not rag_result.allow_general_knowledge: @@ -102,7 +102,7 @@ class ModelStreamService: model_id=model.id if model is not None else None, model_name=model.model_name if model is not None else "no-hit", input_token=_rough_token_count(rag_result.prompt), - chunks=_async_chunk_text(NO_HIT_ANSWER), + chunks=_async_display_chunks(model, NO_HIT_ANSWER), ) if model is None: @@ -130,12 +130,12 @@ def _get_enabled_model(db: Session) -> ModelConfig | None: def _stream_configured_model(model: ModelConfig, rag_result: RagResult) -> Iterator[str]: api_type = model.api_type or "openai_compatible" if model.stream_enabled != 1: - return _chunk_text(_call_configured_model(model, rag_result, allow_no_hit=rag_result.allow_general_knowledge)) + return iter((_call_configured_model(model, rag_result, allow_no_hit=rag_result.allow_general_knowledge),)) if api_type == "anthropic_messages": return _stream_anthropic_messages(model, rag_result) if api_type == "openai_compatible": return _stream_openai_compatible_model(model, rag_result) - return _chunk_text(_call_configured_model(model, rag_result, allow_no_hit=rag_result.allow_general_knowledge)) + return iter((_call_configured_model(model, rag_result, allow_no_hit=rag_result.allow_general_knowledge),)) async def _stream_configured_model_async(model: ModelConfig, rag_result: RagResult) -> AsyncIterator[str]: @@ -144,8 +144,7 @@ async def _stream_configured_model_async(model: ModelConfig, rag_result: RagResu answer = await asyncio.to_thread( _call_configured_model, model, rag_result, allow_no_hit=rag_result.allow_general_knowledge ) - async for chunk in _async_chunk_text(answer): - yield chunk + yield answer return if api_type == "anthropic_messages": @@ -161,8 +160,7 @@ async def _stream_configured_model_async(model: ModelConfig, rag_result: RagResu answer = await asyncio.to_thread( _call_configured_model, model, rag_result, allow_no_hit=rag_result.allow_general_knowledge ) - async for chunk in _async_chunk_text(answer): - yield chunk + yield answer def _stream_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -> Iterator[str]: @@ -479,6 +477,19 @@ def _chunk_text(text: str, *, chunk_size: int = 12) -> Iterator[str]: async def _async_chunk_text(text: str, *, chunk_size: int = 12) -> AsyncIterator[str]: for index in range(0, len(text), chunk_size): yield text[index : index + chunk_size] + await asyncio.sleep(0) + + +def _display_chunks(model: ModelConfig | None, text: str) -> Iterator[str]: + return _chunk_text(text) if model is None or model.stream_enabled == 1 else iter((text,)) + + +async def _async_display_chunks(model: ModelConfig | None, text: str) -> AsyncIterator[str]: + if model is not None and model.stream_enabled != 1: + yield text + return + async for chunk in _async_chunk_text(text): + yield chunk def _string_or_empty(value: Any) -> str: diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_agent_runtime_config.py b/ai_knowledge_base_v2/apps/backend/tests/test_agent_runtime_config.py index 9eff4d7..628da88 100644 --- a/ai_knowledge_base_v2/apps/backend/tests/test_agent_runtime_config.py +++ b/ai_knowledge_base_v2/apps/backend/tests/test_agent_runtime_config.py @@ -1,4 +1,6 @@ +import asyncio from decimal import Decimal +from unittest.mock import patch from sqlalchemy import create_engine from sqlalchemy.orm import Session @@ -9,7 +11,7 @@ from app.models import Base from app.models.admin import Admin from app.models.ai_config import ModelConfig from app.schemas.admin import AgentRuntimeConfigSaveRequest -from app.services.model_stream_service import _openai_stream_payload +from app.services.model_stream_service import _openai_stream_payload, _stream_configured_model_async from app.services.model_service import ModelClientService, _max_output_tokens from app.services.rag_service import RagResult @@ -58,6 +60,7 @@ def test_runtime_config_defaults_to_long_answer_safe_max_tokens(): assert result["modelId"] == model.id assert result["modelName"] == "正式模型" assert result["maxToken"] == 8192 + assert result["streamEnabled"] == 1 assert _max_output_tokens(model) == 8192 @@ -76,6 +79,7 @@ def test_saved_runtime_config_is_persisted_on_enabled_model(): presencePenalty=0.2, frequencyPenalty=0.4, maxToken=12000, + streamEnabled=0, ), db=db, current_admin=admin, @@ -89,6 +93,8 @@ def test_saved_runtime_config_is_persisted_on_enabled_model(): assert model.top_k == 40 assert model.presence_penalty == Decimal("0.20") assert model.frequency_penalty == Decimal("0.40") + assert model.stream_enabled == 0 + assert result["streamEnabled"] == 0 payload = _openai_stream_payload( model, @@ -101,6 +107,27 @@ def test_saved_runtime_config_is_persisted_on_enabled_model(): assert payload["frequency_penalty"] == 0.4 +def test_disabled_stream_returns_one_complete_chunk(): + model = _model() + model.stream_enabled = 0 + answer = "完整回答" * 20 + rag_result = RagResult( + question="测试", + knowledge_scopes=[], + chunks=[], + prompt="测试", + allow_general_knowledge=True, + ) + + async def collect(): + return [item async for item in _stream_configured_model_async(model, rag_result)] + + with patch("app.services.model_stream_service._call_configured_model", return_value=answer): + chunks = asyncio.run(collect()) + + assert chunks == [answer] + + def test_agent_debug_does_not_truncate_long_answer(monkeypatch): monkeypatch.setattr( "app.services.model_service._call_configured_model", diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py b/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py index 12ed9f4..5c4da36 100644 --- a/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py +++ b/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py @@ -113,6 +113,15 @@ def test_queued_chat_reports_position_and_completes(monkeypatch): assert released is True +def test_chat_streaming_response_disables_proxy_buffering(): + payload = type("Payload", (), {"sessionId": 1, "message": "问题"})() + response = chat.completions(payload, object(), object()) + + assert response.headers["cache-control"] == "no-cache, no-transform" + assert response.headers["x-accel-buffering"] == "no" + assert response.media_type == "text/event-stream" + + def test_sensitive_system_config_never_returns_plaintext(): from app.api.admin_settings import _config_dict diff --git a/ai_knowledge_base_v2/apps/user-client/src/components/ChatMessage.vue b/ai_knowledge_base_v2/apps/user-client/src/components/ChatMessage.vue index 75f11fe..67c2999 100644 --- a/ai_knowledge_base_v2/apps/user-client/src/components/ChatMessage.vue +++ b/ai_knowledge_base_v2/apps/user-client/src/components/ChatMessage.vue @@ -15,16 +15,47 @@ const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true }); const answer = computed(() => { if (props.role === "user") return props.content; - // 部分模型会产生嵌套 think 标签,使用贪婪匹配移除完整思考区,再清理流式残留标签。 - let content = props.content.replace(/[\s\S]*<\/think>/gi, ""); - content = content.replace(/[\s\S]*$/i, ""); - content = content.replace(/<\/?think>/gi, ""); - return content.trim(); + return stripStreamingReasoning(props.content).replace(/^\s+/, ""); }); -const renderedContent = computed(() => - props.role === "assistant" ? markdown.render(answer.value) : answer.value, -); +const hasAnswer = computed(() => answer.value.trim().length > 0); + +const renderedContent = computed(() => { + if (props.role !== "assistant") return answer.value; + return hasAnswer.value ? markdown.render(answer.value) : ""; +}); + +function stripStreamingReasoning(content: string) { + const lower = content.toLowerCase(); + let visible = ""; + let cursor = 0; + let reasoningDepth = 0; + while (cursor < content.length) { + const tagStart = content.indexOf("<", cursor); + if (tagStart === -1) { + if (reasoningDepth === 0) visible += content.slice(cursor); + break; + } + if (reasoningDepth === 0) visible += content.slice(cursor, tagStart); + const tail = lower.slice(tagStart); + const openTag = tail.match(/^]*)?>/); + if (openTag) { + reasoningDepth += 1; + cursor = tagStart + openTag[0].length; + continue; + } + const closeTag = tail.match(/^<\/think\s*>/); + if (closeTag) { + reasoningDepth = Math.max(0, reasoningDepth - 1); + cursor = tagStart + closeTag[0].length; + continue; + } + if ("".startsWith(tail)) break; + if (reasoningDepth === 0) visible += "<"; + cursor = tagStart + 1; + } + return visible; +} const displayTime = computed(() => { // 消息服务返回的是 UTC 无时区字符串,补齐时区后再按用户本地时间展示。 diff --git a/ai_knowledge_base_v2/apps/user-client/src/services/api.ts b/ai_knowledge_base_v2/apps/user-client/src/services/api.ts index 73029c3..6bf3221 100644 --- a/ai_knowledge_base_v2/apps/user-client/src/services/api.ts +++ b/ai_knowledge_base_v2/apps/user-client/src/services/api.ts @@ -107,12 +107,12 @@ export async function streamChat( const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); - const parts = buffer.split("\n\n"); + const parts = buffer.split(/\r?\n\r?\n/); buffer = parts.pop() ?? ""; for (const part of parts) { - const line = part.trim(); - if (!line.startsWith("data:")) continue; - const payload = line.slice(5).trim(); + const dataLines = part.split(/\r?\n/).filter((line) => line.startsWith("data:")); + if (!dataLines.length) continue; + const payload = dataLines.map((line) => line.slice(5).trimStart()).join("\n").trim(); if (payload === "[DONE]") return; const parsed = JSON.parse(payload) as { type?: string; content?: string; message?: string }; if (parsed.type === "queued" || parsed.type === "generating") {