From bfaf2ebf6742a5d44a19a02bd3a7801d3dff32c0 Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Fri, 17 Jul 2026 13:47:05 +0800 Subject: [PATCH] feat(agent): stream admin debug preview --- .../apps/admin-web/package-lock.json | 112 ++++++++++++++++++ .../apps/admin-web/package.json | 2 + .../src/components/AgentManagementView.vue | 106 ++++++++++------- .../components/StreamingMarkdownMessage.vue | 53 +++++++++ .../apps/admin-web/src/services/api.ts | 46 +++++++ .../apps/admin-web/src/styles.css | 109 +++++++++++++++++ .../apps/admin-web/src/types/api.ts | 9 ++ .../apps/backend/app/api/admin_settings.py | 74 +++++++----- .../app/services/agent_debug_service.py | 96 +++++++++++++++ .../app/services/model_stream_service.py | 17 +++ .../tests/test_agent_runtime_config.py | 71 ++++++++++- 11 files changed, 620 insertions(+), 75 deletions(-) create mode 100644 ai_knowledge_base_v2/apps/admin-web/src/components/StreamingMarkdownMessage.vue create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/agent_debug_service.py diff --git a/ai_knowledge_base_v2/apps/admin-web/package-lock.json b/ai_knowledge_base_v2/apps/admin-web/package-lock.json index 19b0348..d8c4093 100644 --- a/ai_knowledge_base_v2/apps/admin-web/package-lock.json +++ b/ai_knowledge_base_v2/apps/admin-web/package-lock.json @@ -10,10 +10,12 @@ "dependencies": { "@vitejs/plugin-vue": "^6.0.1", "element-plus": "^2.11.0", + "markdown-it": "^14.3.0", "vite": "^7.1.0", "vue": "^3.5.0" }, "devDependencies": { + "@types/markdown-it": "^14.1.2", "typescript": "^5.9.0", "vue-tsc": "^3.1.0" } @@ -877,6 +879,13 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/lodash": { "version": "4.17.24", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", @@ -892,6 +901,24 @@ "@types/lodash": "*" } }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", @@ -1104,6 +1131,12 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/async-validator": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", @@ -1238,6 +1271,25 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -1270,6 +1322,51 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", @@ -1360,6 +1457,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1443,6 +1549,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", diff --git a/ai_knowledge_base_v2/apps/admin-web/package.json b/ai_knowledge_base_v2/apps/admin-web/package.json index 99c6a59..e005f8b 100644 --- a/ai_knowledge_base_v2/apps/admin-web/package.json +++ b/ai_knowledge_base_v2/apps/admin-web/package.json @@ -11,10 +11,12 @@ "dependencies": { "@vitejs/plugin-vue": "^6.0.1", "element-plus": "^2.11.0", + "markdown-it": "^14.3.0", "vite": "^7.1.0", "vue": "^3.5.0" }, "devDependencies": { + "@types/markdown-it": "^14.1.2", "typescript": "^5.9.0", "vue-tsc": "^3.1.0" } 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 1a092ab..caa9d63 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 @@ -1,11 +1,12 @@ + + diff --git a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts index 79c7e13..8d2acb9 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts @@ -2,6 +2,7 @@ import type { AdminProfile, AdminUser, AgentDebugResult, + AgentDebugStreamComplete, AgentGenerationConfig, AgentRuntimeConfig, AiLogRecord, @@ -217,3 +218,48 @@ export const api = { clearFeishuCache: () => request<{ cleared: number; message: string }>("/admin/feishu/cache/clear", { method: "POST", body: "{}" }), }; + +export async function streamDebugAgent( + payload: Record, + onChunk: (chunk: string) => void, + onComplete: (result: AgentDebugStreamComplete) => void, + signal?: AbortSignal, +) { + const token = getToken(); + const response = await fetch(`${API_BASE}/admin/agent/debug/stream`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(payload), + signal, + }); + if (!response.ok || !response.body) { + const body = await readApiResponse(response); + throw new Error(body.detail || body.message || "Agent 调试连接失败"); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8"); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split(/\r?\n\r?\n/); + buffer = events.pop() ?? ""; + for (const event of events) { + const dataLines = event.split(/\r?\n/).filter((line) => line.startsWith("data:")); + if (!dataLines.length) continue; + const data = dataLines.map((line) => line.slice(5).trimStart()).join("\n").trim(); + if (data === "[DONE]") return; + const parsed = JSON.parse(data) as AgentDebugStreamComplete & { + type?: string; + content?: string; + }; + if (parsed.type === "error") throw new Error(parsed.message || "Agent 调试失败"); + if (parsed.type === "content" && parsed.content) onChunk(parsed.content); + if (parsed.type === "complete") onComplete(parsed); + } + } +} 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 c84aa33..6aca7e0 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/styles.css +++ b/ai_knowledge_base_v2/apps/admin-web/src/styles.css @@ -1380,6 +1380,115 @@ textarea { line-height: 1.75; } +.agent-preview-markdown { + overflow-wrap: anywhere; + color: #263832; + font-size: 14px; + line-height: 1.75; +} + +.agent-preview-markdown > :first-child { margin-top: 0; } +.agent-preview-markdown > :last-child { margin-bottom: 0; } +.agent-preview-markdown p { margin: 0 0 10px; } + +.agent-preview-markdown h1, +.agent-preview-markdown h2, +.agent-preview-markdown h3, +.agent-preview-markdown h4 { + margin: 16px 0 8px; + color: #1f352e; + line-height: 1.45; +} + +.agent-preview-markdown h1 { font-size: 19px; } +.agent-preview-markdown h2 { font-size: 17px; } +.agent-preview-markdown h3, +.agent-preview-markdown h4 { font-size: 15px; } + +.agent-preview-markdown ul, +.agent-preview-markdown ol { + margin: 8px 0 10px; + padding-left: 22px; +} + +.agent-preview-markdown li + li { margin-top: 5px; } +.agent-preview-markdown strong { color: #1f352e; font-weight: 700; } + +.agent-preview-markdown blockquote { + margin: 10px 0; + padding: 7px 10px; + border-left: 3px solid #8cc7b6; + background: #eef7f4; + color: #526b63; +} + +.agent-preview-markdown code { + padding: 2px 5px; + border-radius: 5px; + background: #edf5f2; + color: #0f735d; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 0.92em; +} + +.agent-preview-markdown pre { + max-width: 100%; + overflow-x: auto; + margin: 10px 0; + padding: 10px; + border-radius: 8px; + background: #13231e; + color: #f4fffb; +} + +.agent-preview-markdown pre code { + padding: 0; + background: transparent; + color: inherit; +} + +.agent-preview-markdown table { + display: block; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; + margin: 10px 0; +} + +.agent-preview-markdown th, +.agent-preview-markdown td { + padding: 6px 8px; + border: 1px solid #dbe7e3; + white-space: nowrap; +} + +.agent-preview-markdown a { color: #0f735d; font-weight: 700; } + +.agent-preview-generation-state { + display: flex; + align-items: center; + gap: 4px; + min-height: 28px; + color: #667a73; + font-size: 13px; +} + +.agent-preview-generation-state span { + width: 5px; + height: 5px; + border-radius: 50%; + background: #0f735d; + animation: agent-generation-pulse 1.2s ease-in-out infinite; +} + +.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; } + +@keyframes agent-generation-pulse { + 0%, 70%, 100% { opacity: 0.25; transform: translateY(0); } + 35% { opacity: 1; transform: translateY(-2px); } +} + .agent-preview-thinking { margin-bottom: 10px; padding: 9px 10px; 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 8fa4bbf..2c00ece 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 @@ -157,6 +157,15 @@ export interface AgentDebugResult { retrievalLogId?: number; } +export interface AgentDebugStreamComplete { + message: string; + modelName?: string; + retrieveCount?: number; + knowledgeIds?: string; + retrievalTrace?: Record[]; + retrievalLogId?: number; +} + export interface KnowledgeVersion { id: number; knowledgeId: number; 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 695b4de..53b41f0 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 @@ -1,6 +1,11 @@ from __future__ import annotations +import json +from collections.abc import AsyncIterator + from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.encoders import jsonable_encoder +from fastapi.responses import StreamingResponse from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -20,11 +25,10 @@ from app.schemas.admin import ( SystemConfigSaveRequest, ) from app.services.admin_service import OperationLogService +from app.services.agent_debug_service import AgentDebugService from app.services.feishu_service import FeishuKnowledgeService -from app.services.knowledge_agent_service import KnowledgeAgentService from app.services.knowledge_service import KnowledgeScope from app.services.model_service import ModelClientService -from app.services.rag_service import PromptService, RagResult from app.services.secret_service import MASKED_SECRET, SENSITIVE_CONFIG_KEYS, SecretService router = APIRouter() @@ -146,37 +150,11 @@ async def debug_agent( if model is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在") - rag_result = await KnowledgeAgentService.build_result( - db, - question=payload.question, - version_overrides=payload.knowledgeVersions, - preview_knowledge_ids=payload.knowledgeIds, - ) - debug_messages = [ - {"role": "system", "content": payload.promptContent.strip()}, - *(rag_result.messages or []), - ] - rag_result = RagResult( - question=rag_result.question, - knowledge_scopes=rag_result.knowledge_scopes, - chunks=rag_result.chunks, - prompt=PromptService.render_messages(debug_messages), - allow_general_knowledge=rag_result.allow_general_knowledge, - retrieval_log_id=rag_result.retrieval_log_id, - tool_trace=rag_result.tool_trace, - messages=debug_messages, - ) + rag_result = await AgentDebugService.build_result(db, payload) result = await ModelClientService.debug_model_async( model, rag_result, - { - "temperature": payload.temperature, - "top_p": payload.topP, - "top_k": payload.topK, - "presence_penalty": payload.presencePenalty, - "frequency_penalty": payload.frequencyPenalty, - "max_token": payload.maxToken, - }, + AgentDebugService.overrides(payload), ) result["retrievalTrace"] = rag_result.tool_trace or [] result["retrievalLogId"] = rag_result.retrieval_log_id @@ -185,6 +163,42 @@ async def debug_agent( return api_success(result) +@router.post("/agent/debug/stream") +def debug_agent_stream( + payload: AgentDebugRequest, + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> StreamingResponse: + return StreamingResponse( + _debug_agent_stream(payload, db, current_admin), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +async def _debug_agent_stream( + payload: AgentDebugRequest, + db: Session, + current_admin: Admin, +) -> AsyncIterator[str]: + async for event in AgentDebugService.stream(payload, db, current_admin): + yield _admin_sse_event(event) + yield _admin_sse_done() + + +def _admin_sse_event(event: dict) -> str: + event = jsonable_encoder(event) + return f"data: {json.dumps(event, ensure_ascii=False)}\n\n" + + +def _admin_sse_done() -> str: + return "data: [DONE]\n\n" + + @router.get("/agent/runtime-config") def get_agent_runtime_config( db: Session = Depends(get_db), diff --git a/ai_knowledge_base_v2/apps/backend/app/services/agent_debug_service.py b/ai_knowledge_base_v2/apps/backend/app/services/agent_debug_service.py new file mode 100644 index 0000000..eb1e71e --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/agent_debug_service.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator + +from sqlalchemy.orm import Session + +from app.models.admin import Admin +from app.models.ai_config import ModelConfig +from app.schemas.admin import AgentDebugRequest +from app.services.admin_service import OperationLogService +from app.services.knowledge_agent_service import KnowledgeAgentService +from app.services.model_stream_service import ModelStreamService +from app.services.rag_service import PromptService, RagResult + + +class AgentDebugService: + @staticmethod + async def build_result(db: Session, payload: AgentDebugRequest) -> RagResult: + rag_result = await KnowledgeAgentService.build_result( + db, + question=payload.question, + version_overrides=payload.knowledgeVersions, + preview_knowledge_ids=payload.knowledgeIds, + ) + debug_messages = [ + {"role": "system", "content": payload.promptContent.strip()}, + *(rag_result.messages or []), + ] + return RagResult( + question=rag_result.question, + knowledge_scopes=rag_result.knowledge_scopes, + chunks=rag_result.chunks, + prompt=PromptService.render_messages(debug_messages), + allow_general_knowledge=rag_result.allow_general_knowledge, + retrieval_log_id=rag_result.retrieval_log_id, + tool_trace=rag_result.tool_trace, + messages=debug_messages, + ) + + @staticmethod + def overrides(payload: AgentDebugRequest) -> dict: + return { + "temperature": payload.temperature, + "top_p": payload.topP, + "top_k": payload.topK, + "presence_penalty": payload.presencePenalty, + "frequency_penalty": payload.frequencyPenalty, + "max_token": payload.maxToken, + } + + @classmethod + async def stream( + cls, + payload: AgentDebugRequest, + db: Session, + current_admin: Admin, + ) -> AsyncIterator[dict]: + model = db.get(ModelConfig, payload.modelId) + if model is None: + yield {"type": "error", "message": "模型不存在"} + return + try: + yield {"type": "status", "stage": "retrieving", "message": "思考中"} + rag_result = await cls.build_result(db, payload) + model_response = ModelStreamService.debug_stream_async( + model, + rag_result, + cls.overrides(payload), + ) + async for chunk in model_response.chunks: + if chunk: + yield {"type": "content", "content": chunk} + OperationLogService.write( + db, + admin_id=current_admin.id, + module="agent", + action="debug_stream", + target_id=model.id, + ) + db.commit() + yield { + "type": "complete", + "message": "Agent 调试完成", + "modelName": model_response.model_name, + "retrieveCount": len(rag_result.chunks), + "knowledgeIds": rag_result.knowledge_ids, + "retrievalTrace": rag_result.tool_trace or [], + "retrievalLogId": rag_result.retrieval_log_id, + } + except asyncio.CancelledError: + db.rollback() + raise + except Exception as exc: + db.rollback() + yield {"type": "error", "message": str(exc) or "Agent 调试失败"} 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 ea31419..927b412 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 @@ -17,6 +17,7 @@ from app.services.model_service import ( _anthropic_headers, _auth_headers, _call_configured_model, + _copy_model_with_overrides, _decimal_to_float, _load_extra_params, _mock_answer, @@ -117,6 +118,22 @@ class ModelStreamService: chunks=_stream_configured_model_async(model, rag_result), ) + @staticmethod + def debug_stream_async( + model: ModelConfig, + rag_result: RagResult, + overrides: dict[str, Any], + ) -> AsyncStreamingModelResponse: + debug_model = _copy_model_with_overrides(model, overrides) + if not (debug_model.api_url or debug_model.base_url) or not debug_model.api_key: + raise ExternalServiceError("模型 Base URL/API URL 或 API Key 未配置", provider="model") + return AsyncStreamingModelResponse( + model_id=model.id, + model_name=model.model_name, + input_token=_rough_token_count(rag_result.prompt), + chunks=_stream_configured_model_async(debug_model, rag_result), + ) + def _get_enabled_model(db: Session) -> ModelConfig | None: return db.scalar( 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 628da88..cca591b 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,17 +1,22 @@ import asyncio from decimal import Decimal +import json from unittest.mock import patch from sqlalchemy import create_engine from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool -from app.api.admin_settings import 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.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, _stream_configured_model_async +from app.schemas.admin import AgentDebugRequest, AgentRuntimeConfigSaveRequest +from app.services.model_stream_service import ( + AsyncStreamingModelResponse, + _openai_stream_payload, + _stream_configured_model_async, +) from app.services.model_service import ModelClientService, _max_output_tokens from app.services.rag_service import RagResult @@ -128,6 +133,66 @@ def test_disabled_stream_returns_one_complete_chunk(): assert chunks == [answer] +def test_agent_debug_stream_emits_status_content_and_trace(): + async def chunks(): + yield "内部思考" + yield "- **第一步**:停一下" + + rag_result = RagResult( + question="怎么冷静", + knowledge_scopes=[], + chunks=[], + prompt="怎么冷静", + allow_general_knowledge=True, + retrieval_log_id=9, + tool_trace=[{"tool": "KnowledgeSearch", "status": "success"}], + messages=[{"role": "user", "content": "怎么冷静"}], + ) + model_response = AsyncStreamingModelResponse( + model_id=1, + model_name="production-model", + input_token=10, + chunks=chunks(), + ) + + async def collect(payload, db, admin): + return [event async for event in _debug_agent_stream(payload, db, admin)] + + with _database() as db: + admin = _admin() + model = _model() + db.add_all([admin, model]) + db.commit() + payload = AgentDebugRequest( + promptContent="你是测试助手", + modelId=model.id, + knowledgeIds=[], + question="怎么冷静", + maxToken=8192, + ) + with ( + patch( + "app.services.agent_debug_service.KnowledgeAgentService.build_result", + return_value=rag_result, + ), + patch( + "app.services.agent_debug_service.ModelStreamService.debug_stream_async", + return_value=model_response, + ), + ): + events = asyncio.run(collect(payload, db, admin)) + + decoded = [ + json.loads(event.removeprefix("data: ")) + for event in events + if event != "data: [DONE]\n\n" + ] + assert [event["type"] for event in decoded] == ["status", "content", "content", "complete"] + assert decoded[1]["content"].startswith("") + assert decoded[2]["content"] == "- **第一步**:停一下" + assert decoded[3]["retrievalTrace"][0]["tool"] == "KnowledgeSearch" + + def test_agent_debug_does_not_truncate_long_answer(monkeypatch): monkeypatch.setattr( "app.services.model_service._call_configured_model",