From 303dd78c54c250ee911dc050ef1264f29df9b9f5 Mon Sep 17 00:00:00 2001 From: HaHafeng Date: Sun, 25 Jan 2026 19:16:36 +0800 Subject: [PATCH] feat(aia): Protocol Agent MVP complete with one-click generation and Word export - Add one-click research protocol generation with streaming output - Implement Word document export via Pandoc integration - Add dynamic dual-panel layout with resizable split pane - Implement collapsible content for StatePanel stages - Add conversation history management with title auto-update - Fix scroll behavior, markdown rendering, and UI layout issues - Simplify conversation creation logic for reliability --- COMMIT_DAY1.txt | 1 + DC模块代码恢复指南.md | 1 + Dockerfile.postgres-with-extensions | 1 + SAE_WECHAT_MP_DEPLOY_STEPS.md | 1 + backend/DEPLOY_TO_SAE_FOR_WECHAT_MP.md | 1 + backend/RESTART_SERVER_NOW.md | 1 + backend/WECHAT_MP_CONFIG_READY.md | 1 + backend/WECHAT_MP_QUICK_FIX.md | 1 + backend/check_db.ts | 1 + backend/check_db_data.ts | 1 + backend/check_iit.ts | 1 + backend/check_iit_asl_data.ts | 1 + backend/check_queue_table.ts | 1 + backend/check_rvw_issue.ts | 1 + backend/check_tables.ts | 1 + backend/compare_db.ts | 1 + backend/compare_dc_asl.ts | 1 + backend/compare_pkb_aia_rvw.ts | 1 + backend/compare_schema_db.ts | 1 + backend/create_mock_user.sql | 1 + backend/create_mock_user_platform.sql | 1 + backend/env.example.md | 1 + .../add_data_stats_to_tool_c_session.sql | 1 + .../001_add_postgres_cache_and_checkpoint.sql | 1 + .../manual-migrations/run-migration-002.ts | 1 + .../20251208_add_column_mapping/migration.sql | 1 + .../migrations/create_tool_c_session.sql | 1 + .../migrations/manual/ekb_create_indexes.sql | 1 + .../manual/ekb_create_indexes_mvp.sql | 1 + backend/prisma/seed-protocol-agent.ts | 1 + backend/prisma/seeds/protocol-agent-seed.ts | 1 + backend/rebuild-and-push.ps1 | 1 + backend/recover-code-from-cursor-db.js | 1 + backend/restore_job_common.sql | 1 + backend/restore_pgboss_functions.sql | 1 + backend/scripts/check-dc-tables.mjs | 1 + backend/scripts/create-capability-schema.sql | 1 + .../create-tool-c-ai-history-table.mjs | 1 + backend/scripts/create-tool-c-table.js | 1 + backend/scripts/create-tool-c-table.mjs | 1 + backend/scripts/migrate-aia-prompts.ts | 1 + backend/scripts/setup-prompt-system.ts | 1 + backend/scripts/test-pkb-apis-simple.ts | 1 + backend/scripts/test-prompt-api.ts | 1 + backend/scripts/test-unifuncs-deepsearch.ts | 1 + backend/scripts/verify-pkb-rvw-schema.ts | 1 + backend/src/common/auth/jwt.service.ts | 1 + backend/src/common/jobs/utils.ts | 1 + backend/src/common/prompt/prompt.types.ts | 1 + backend/src/common/rag/ChunkService.ts | 1 + backend/src/common/rag/DifyClient.ts | 1 + .../common/streaming/OpenAIStreamAdapter.ts | 1 + .../src/common/streaming/StreamingService.ts | 1 + backend/src/common/streaming/index.ts | 1 + backend/src/common/streaming/types.ts | 1 + .../src/modules/admin/routes/tenantRoutes.ts | 1 + .../src/modules/admin/types/tenant.types.ts | 1 + backend/src/modules/admin/types/user.types.ts | 1 + .../controllers/ProtocolAgentController.ts | 377 ++++++- .../prompts/protocolGenerationPrompts.ts | 246 +++++ .../modules/agent/protocol/routes/index.ts | 40 +- .../protocol/services/LLMServiceAdapter.ts | 1 + .../agent/protocol/services/PromptBuilder.ts | 1 + .../services/ProtocolContextService.ts | 30 + .../services/ProtocolExportService.ts | 297 ++++++ .../protocol/services/ProtocolOrchestrator.ts | 63 +- backend/src/modules/agent/types/index.ts | 1 + .../aia/controllers/agentController.ts | 1 + .../aia/controllers/attachmentController.ts | 1 + backend/src/modules/aia/index.ts | 1 + .../__tests__/api-integration-test.ts | 1 + .../__tests__/e2e-real-test-v2.ts | 1 + .../__tests__/fulltext-screening-api.http | 1 + .../services/ConflictDetectionService.ts | 1 + backend/src/modules/dc/tool-c/README.md | 1 + .../tool-c/controllers/StreamAIController.ts | 1 + .../iit-manager/agents/SessionMemory.ts | 1 + .../iit-manager/check-iit-table-structure.ts | 1 + .../iit-manager/check-project-config.ts | 1 + .../iit-manager/check-test-project-in-db.ts | 1 + .../iit-manager/docs/微信服务号接入指南.md | 1 + .../iit-manager/generate-wechat-tokens.ts | 1 + .../services/PatientWechatService.ts | 1 + .../iit-manager/test-chatservice-dify.ts | 1 + .../modules/iit-manager/test-iit-database.ts | 1 + .../iit-manager/test-patient-wechat-config.ts | 1 + .../test-patient-wechat-url-verify.ts | 1 + .../iit-manager/test-redcap-query-from-db.ts | 1 + .../iit-manager/test-wechat-mp-local.ps1 | 1 + .../src/modules/iit-manager/types/index.ts | 1 + backend/src/modules/pkb/routes/health.ts | 1 + backend/src/modules/rvw/__tests__/api.http | 1 + .../src/modules/rvw/__tests__/test-api.ps1 | 1 + backend/src/modules/rvw/index.ts | 1 + backend/src/modules/rvw/services/utils.ts | 1 + backend/src/tests/README.md | 1 + .../src/tests/test-cross-language-search.ts | 1 + backend/src/tests/test-query-rewrite.ts | 1 + backend/src/tests/test-rerank.ts | 1 + backend/src/tests/verify-test1-database.sql | 1 + backend/src/tests/verify-test1-database.ts | 1 + backend/src/types/global.d.ts | 1 + backend/sync-dc-database.ps1 | 1 + backend/temp_check.sql | 1 + backend/test-pkb-migration.http | 1 + backend/test-tool-c-advanced-scenarios.mjs | 1 + backend/test-tool-c-day2.mjs | 1 + backend/test-tool-c-day3.mjs | 1 + backend/verify_all_users.ts | 1 + backend/verify_functions.ts | 1 + backend/verify_job_common.ts | 1 + backend/verify_mock_user.ts | 1 + backend/verify_system.ts | 1 + deploy-to-sae.ps1 | 1 + .../00-系统当前状态与开发指南.md | 55 +- .../02-存储服务/OSS账号与配置信息.md | 1 + .../02-存储服务/OSS集成开发记录-2026-01-22.md | 1 + docs/02-通用能力层/快速引用卡片.md | 1 + docs/02-通用能力层/通用能力层技术债务清单.md | 1 + .../ADMIN-运营管理端/00-Phase3.5完成总结.md | 1 + .../00-系统设计/运营体系设计方案-MVP-V3.0.md | 293 ++++++ .../2026-01-16_用户管理功能与模块权限系统完成.md | 1 + docs/03-业务模块/ADMIN-运营管理端/README.md | 1 + .../ADMIN运营与INST机构管理端-文档体系建立完成.md | 1 + .../AIA-AI智能问答/00-模块当前状态与开发指南.md | 47 +- .../00-系统设计/UI_Layout_Ratio_Analysis.md | 50 + .../00-系统设计/研究方案一键生成.html | 277 +++++ .../AIA-AI智能问答/04-开发计划/03-前端组件设计.md | 1 + .../04-Protocol_Agent开发计划/01-架构设计.md | 1 + .../04-Protocol_Agent开发计划/03-代码结构设计.md | 1 + .../04-开发计划/05-一键生成研究方案开发计划.md | 608 +++++++---- .../04-开发计划/06-一键生成研究方案开发计划V2.md | 349 +++++++ .../04-开发计划/Novel_CRF_Extension_Guide.md | 110 ++ .../04-开发计划/基于对话流的文档生成与导出技术方案.md | 105 ++ .../06-开发记录/2026-01-18-Prompt管理系统集成.md | 1 + .../2026-01-24-Protocol_Agent_MVP开发完成.md | 1 + .../2026-01-25-Protocol_Agent_MVP完整交付.md | 352 +++++++ .../04-开发计划/05-全文复筛前端开发计划.md | 1 + .../05-开发记录/2025-01-23_全文复筛前端开发完成.md | 1 + .../05-开发记录/2025-01-23_全文复筛前端逻辑调整.md | 1 + .../05-开发记录/2025-11-23_Day5_全文复筛API开发.md | 1 + .../2026-01-18_智能文献检索DeepSearch集成.md | 1 + .../04-开发计划/工具C_AI_Few-shot示例库.md | 1 + .../04-开发计划/工具C_Bug修复总结_2025-12-08.md | 1 + .../04-开发计划/工具C_Day3开发计划.md | 1 + .../04-开发计划/工具C_Day4-5前端开发计划.md | 1 + .../04-开发计划/工具C_Pivot列顺序优化总结.md | 1 + .../04-开发计划/工具C_方案B实施总结_2025-12-09.md | 1 + .../04-开发计划/工具C_缺失值处理_开发进度_2025-12-10.md | 1 + .../04-开发计划/工具C_缺失值处理功能_更新说明.md | 1 + .../06-开发记录/2025-12-02_工作总结.md | 1 + .../06-开发记录/2025-12-06_工具C_Day1开发完成总结.md | 1 + .../06-开发记录/2025-12-06_工具C_Day2开发完成总结.md | 1 + .../06-开发记录/2025-12-07_AI对话核心功能增强总结.md | 1 + .../2025-12-07_Bug修复_DataGrid空数据防御.md | 1 + .../06-开发记录/2025-12-07_Day5_Ant-Design-X重构完成.md | 1 + .../06-开发记录/2025-12-07_Day5最终总结.md | 1 + .../06-开发记录/2025-12-07_UI优化与Bug修复.md | 1 + .../06-开发记录/2025-12-07_后端API完整对接完成.md | 1 + .../06-开发记录/2025-12-07_完整UI优化与功能增强.md | 1 + .../06-开发记录/2025-12-07_工具C_Day4前端基础完成.md | 1 + .../06-开发记录/DC模块重建完成总结-Day1.md | 1 + .../06-开发记录/Phase1-Portal页面开发完成-2025-12-02.md | 1 + .../Phase2-ToolB-Step1-2开发完成-2025-12-03.md | 1 + .../06-开发记录/Portal页面UI优化-2025-12-02.md | 1 + .../06-开发记录/Tool-B-MVP完成总结-2025-12-03.md | 1 + .../06-开发记录/ToolB-UI优化-2025-12-03.md | 1 + .../06-开发记录/ToolB-UI优化-Round2-2025-12-03.md | 1 + .../06-开发记录/ToolB浏览器测试计划-2025-12-03.md | 1 + .../06-开发记录/后端API测试报告-2025-12-02.md | 1 + .../06-开发记录/待办事项-下一步工作.md | 1 + .../06-开发记录/数据库验证报告-2025-12-02.md | 1 + .../07-技术债务/Tool-B技术债务清单.md | 1 + .../IIT Manager Agent 技术路径与架构设计.md | 1 + .../04-开发计划/REDCap对接技术方案与实施指南.md | 1 + .../04-开发计划/企业微信注册指南.md | 1 + .../2026-01-04-Dify知识库集成开发记录.md | 1 + .../Day2-REDCap实时集成开发完成记录.md | 1 + .../Day3-企业微信集成与端到端测试完成记录.md | 1 + .../06-开发记录/Day3-企业微信集成开发完成记录.md | 1 + .../Phase1.5-AI对话集成REDCap完成记录.md | 1 + .../06-开发记录/V1.1更新完成报告.md | 1 + .../07-技术债务/IIT Manager Agent 技术债务清单.md | 1 + .../INST-机构管理端/00-模块当前状态与开发指南.md | 1 + docs/03-业务模块/INST-机构管理端/README.md | 1 + .../06-开发记录/2026-01-07-前端迁移与批处理功能完善.md | 1 + .../06-开发记录/2026-01-07_PKB模块前端V3设计实现.md | 1 + .../01-部署与配置/10-REDCap_Docker部署操作手册.md | 1 + docs/03-业务模块/Redcap/README.md | 1 + docs/04-开发规范/09-数据库开发规范.md | 1 + docs/04-开发规范/10-模块认证规范.md | 1 + .../02-SAE部署完全指南(产品经理版).md | 1 + .../07-前端Nginx-SAE部署操作手册.md | 1 + .../08-PostgreSQL数据库部署操作手册.md | 1 + .../10-Node.js后端-Docker镜像构建手册.md | 1 + .../11-Node.js后端-SAE部署配置清单.md | 1 + .../12-Node.js后端-SAE部署操作手册.md | 1 + .../13-Node.js后端-镜像修复记录.md | 1 + .../14-Node.js后端-pino-pretty问题修复.md | 1 + docs/05-部署文档/16-前端Nginx-部署成功总结.md | 1 + .../05-部署文档/17-完整部署实战手册-2025版.md | 1 + docs/05-部署文档/18-部署文档使用指南.md | 1 + docs/05-部署文档/19-日常更新快速操作手册.md | 1 + docs/05-部署文档/文档修正报告-20251214.md | 1 + docs/07-运维文档/03-SAE环境变量配置指南.md | 1 + .../05-Redis缓存与队列的区别说明.md | 1 + docs/07-运维文档/06-长时间任务可靠性分析.md | 1 + .../07-Redis使用需求分析(按模块).md | 1 + .../2025-12-13-Postgres-Only架构改造完成.md | 1 + .../05-技术债务/通用对话服务抽取计划.md | 1 + docs/08-项目管理/2026-01-11-数据库事故总结.md | 1 + docs/08-项目管理/PKB前端问题修复报告.md | 1 + docs/08-项目管理/PKB前端验证指南.md | 1 + docs/08-项目管理/PKB功能审查报告-阶段0.md | 1 + docs/08-项目管理/PKB和RVW功能迁移计划.md | 1 + docs/08-项目管理/PKB精细化优化报告.md | 1 + docs/08-项目管理/PKB迁移-超级安全执行计划.md | 1 + docs/08-项目管理/PKB迁移-阶段1完成报告.md | 1 + docs/08-项目管理/PKB迁移-阶段2完成报告.md | 1 + docs/08-项目管理/PKB迁移-阶段2进行中.md | 1 + docs/08-项目管理/PKB迁移-阶段3完成报告.md | 1 + docs/08-项目管理/PKB迁移-阶段4完成报告.md | 1 + extraction_service/.dockerignore | 1 + extraction_service/main.py | 171 +++- extraction_service/operations/__init__.py | 1 + extraction_service/operations/dropna.py | 1 + extraction_service/operations/filter.py | 1 + extraction_service/operations/unpivot.py | 1 + extraction_service/requirements.txt | 1 + .../services/doc_export_service.py | 218 ++++ .../services/pdf_markdown_processor.py | 1 + extraction_service/test_dc_api.py | 1 + extraction_service/test_execute_simple.py | 1 + extraction_service/test_module.py | 1 + frontend-v2/.dockerignore | 1 + frontend-v2/docker-entrypoint.sh | 1 + frontend-v2/nginx.conf | 1 + frontend-v2/src/common/api/axios.ts | 1 + frontend-v2/src/framework/auth/api.ts | 1 + frontend-v2/src/framework/auth/index.ts | 1 + frontend-v2/src/framework/auth/moduleApi.ts | 1 + .../components/ModulePermissionModal.tsx | 1 + frontend-v2/src/modules/admin/index.tsx | 1 + frontend-v2/src/modules/admin/types/user.ts | 1 + .../src/modules/aia/components/index.ts | 1 + .../aia/protocol-agent/ProtocolAgentPage.tsx | 161 ++- .../protocol-agent/components/ActionCard.tsx | 1 + .../protocol-agent/components/ChatArea.tsx | 133 ++- .../components/DocumentPanel.tsx | 334 ++++++ .../components/MarkdownContent.tsx | 11 +- .../components/ReflexionMessage.tsx | 1 + .../components/ResizableSplitPane.tsx | 206 ++++ .../protocol-agent/components/StageCard.tsx | 388 +++++-- .../components/StageEditModal.tsx | 7 +- .../protocol-agent/components/StatePanel.tsx | 118 ++- .../protocol-agent/components/SyncButton.tsx | 1 + .../components/ViewSwitcher.tsx | 52 + .../aia/protocol-agent/components/index.ts | 1 + .../modules/aia/protocol-agent/hooks/index.ts | 1 + .../hooks/useProtocolConversations.ts | 65 +- .../hooks/useProtocolGeneration.ts | 260 +++++ .../src/modules/aia/protocol-agent/index.ts | 1 + .../protocol-agent/styles/protocol-agent.css | 954 +++++++++++++++++- .../src/modules/aia/protocol-agent/types.ts | 23 +- .../asl/components/FulltextDetailDrawer.tsx | 1 + frontend-v2/src/modules/dc/hooks/useAssets.ts | 1 + .../src/modules/dc/hooks/useRecentTasks.ts | 1 + .../dc/pages/tool-c/hooks/useSessionStatus.ts | 1 + .../modules/dc/pages/tool-c/types/index.ts | 1 + frontend-v2/src/modules/dc/types/portal.ts | 1 + .../src/modules/pkb/pages/KnowledgePage.tsx | 1 + .../src/modules/pkb/types/workspace.ts | 1 + .../src/modules/rvw/components/AgentModal.tsx | 1 + .../modules/rvw/components/BatchToolbar.tsx | 1 + .../modules/rvw/components/FilterChips.tsx | 1 + .../src/modules/rvw/components/Header.tsx | 1 + .../modules/rvw/components/ReportDetail.tsx | 1 + .../src/modules/rvw/components/ScoreRing.tsx | 1 + .../src/modules/rvw/components/Sidebar.tsx | 1 + .../src/modules/rvw/components/TaskDetail.tsx | 11 +- .../src/modules/rvw/components/TaskTable.tsx | 111 +- .../src/modules/rvw/components/index.ts | 1 + .../src/modules/rvw/pages/Dashboard.tsx | 1 + frontend-v2/src/modules/rvw/styles/index.css | 1 + .../pages/admin/tenants/TenantListPage.tsx | 1 + .../src/pages/admin/tenants/api/tenantApi.ts | 1 + .../shared/components/Chat/AIStreamChat.tsx | 1 + .../components/Chat/ConversationList.tsx | 1 + .../src/shared/components/Chat/hooks/index.ts | 1 + .../components/Chat/hooks/useAIStream.ts | 1 + .../components/Chat/hooks/useConversations.ts | 1 + .../components/Chat/styles/ai-stream-chat.css | 1 + .../Chat/styles/conversation-list.css | 1 + .../components/Chat/styles/thinking.css | 1 + frontend-v2/src/shared/components/index.ts | 1 + frontend-v2/src/vite-env.d.ts | 1 + .../src/pages/rvw/components/BatchToolbar.tsx | 1 + .../pages/rvw/components/EditorialReport.tsx | 1 + .../src/pages/rvw/components/FilterChips.tsx | 1 + frontend/src/pages/rvw/components/Header.tsx | 1 + .../src/pages/rvw/components/ReportDetail.tsx | 1 + .../src/pages/rvw/components/ScoreRing.tsx | 1 + frontend/src/pages/rvw/components/Sidebar.tsx | 1 + frontend/src/pages/rvw/index.ts | 1 + frontend/src/pages/rvw/styles.css | 1 + git-cleanup-redcap.ps1 | 1 + git-commit-day1.ps1 | 1 + git-fix-lock.ps1 | 1 + python-microservice/operations/__init__.py | 1 + python-microservice/operations/binning.py | 1 + python-microservice/operations/filter.py | 1 + python-microservice/operations/recode.py | 1 + recover_dc_code.py | 1 + redcap-docker-dev/.gitattributes | 1 + redcap-docker-dev/.gitignore | 1 + redcap-docker-dev/README.md | 1 + redcap-docker-dev/docker-compose.prod.yml | 1 + redcap-docker-dev/docker-compose.yml | 1 + redcap-docker-dev/env.template | 1 + redcap-docker-dev/scripts/clean-redcap.ps1 | 1 + .../scripts/create-redcap-password.php | 1 + redcap-docker-dev/scripts/logs-redcap.ps1 | 1 + .../scripts/reset-admin-password.php | 1 + redcap-docker-dev/scripts/start-redcap.ps1 | 1 + redcap-docker-dev/scripts/stop-redcap.ps1 | 1 + run_recovery.ps1 | 1 + tests/QUICKSTART_快速开始.md | 1 + tests/README_测试说明.md | 1 + tests/run_tests.bat | 1 + tests/run_tests.sh | 1 + 快速部署到SAE.md | 1 + 部署检查清单.md | 1 + 332 files changed, 6204 insertions(+), 617 deletions(-) create mode 100644 backend/src/modules/agent/protocol/prompts/protocolGenerationPrompts.ts create mode 100644 backend/src/modules/agent/protocol/services/ProtocolExportService.ts create mode 100644 docs/03-业务模块/ADMIN-运营管理端/00-系统设计/运营体系设计方案-MVP-V3.0.md create mode 100644 docs/03-业务模块/AIA-AI智能问答/00-系统设计/UI_Layout_Ratio_Analysis.md create mode 100644 docs/03-业务模块/AIA-AI智能问答/00-系统设计/研究方案一键生成.html create mode 100644 docs/03-业务模块/AIA-AI智能问答/04-开发计划/06-一键生成研究方案开发计划V2.md create mode 100644 docs/03-业务模块/AIA-AI智能问答/04-开发计划/Novel_CRF_Extension_Guide.md create mode 100644 docs/03-业务模块/AIA-AI智能问答/04-开发计划/基于对话流的文档生成与导出技术方案.md create mode 100644 docs/03-业务模块/AIA-AI智能问答/06-开发记录/2026-01-25-Protocol_Agent_MVP完整交付.md create mode 100644 extraction_service/services/doc_export_service.py create mode 100644 frontend-v2/src/modules/aia/protocol-agent/components/DocumentPanel.tsx create mode 100644 frontend-v2/src/modules/aia/protocol-agent/components/ResizableSplitPane.tsx create mode 100644 frontend-v2/src/modules/aia/protocol-agent/components/ViewSwitcher.tsx create mode 100644 frontend-v2/src/modules/aia/protocol-agent/hooks/useProtocolGeneration.ts diff --git a/COMMIT_DAY1.txt b/COMMIT_DAY1.txt index 1d4e84ae..b8cdd667 100644 --- a/COMMIT_DAY1.txt +++ b/COMMIT_DAY1.txt @@ -61,5 +61,6 @@ Status: Day 1 complete (11/11 tasks), ready for Day 2 + diff --git a/DC模块代码恢复指南.md b/DC模块代码恢复指南.md index 2d38e4db..c72ebfd6 100644 --- a/DC模块代码恢复指南.md +++ b/DC模块代码恢复指南.md @@ -291,5 +291,6 @@ + diff --git a/Dockerfile.postgres-with-extensions b/Dockerfile.postgres-with-extensions index 17e47f91..4963c0ed 100644 --- a/Dockerfile.postgres-with-extensions +++ b/Dockerfile.postgres-with-extensions @@ -54,3 +54,4 @@ EXPOSE 5432 + diff --git a/SAE_WECHAT_MP_DEPLOY_STEPS.md b/SAE_WECHAT_MP_DEPLOY_STEPS.md index f71565e8..e6474ee9 100644 --- a/SAE_WECHAT_MP_DEPLOY_STEPS.md +++ b/SAE_WECHAT_MP_DEPLOY_STEPS.md @@ -237,5 +237,6 @@ https://iit.xunzhengyixue.com/api/v1/iit/health + diff --git a/backend/DEPLOY_TO_SAE_FOR_WECHAT_MP.md b/backend/DEPLOY_TO_SAE_FOR_WECHAT_MP.md index 1d1f3c33..32eee2ab 100644 --- a/backend/DEPLOY_TO_SAE_FOR_WECHAT_MP.md +++ b/backend/DEPLOY_TO_SAE_FOR_WECHAT_MP.md @@ -166,5 +166,6 @@ https://iit.xunzhengyixue.com/api/v1/iit/health + diff --git a/backend/RESTART_SERVER_NOW.md b/backend/RESTART_SERVER_NOW.md index a51bf44a..55f47f1f 100644 --- a/backend/RESTART_SERVER_NOW.md +++ b/backend/RESTART_SERVER_NOW.md @@ -67,5 +67,6 @@ + diff --git a/backend/WECHAT_MP_CONFIG_READY.md b/backend/WECHAT_MP_CONFIG_READY.md index 1bd1c363..79f6bd58 100644 --- a/backend/WECHAT_MP_CONFIG_READY.md +++ b/backend/WECHAT_MP_CONFIG_READY.md @@ -327,5 +327,6 @@ npx tsx src/modules/iit-manager/test-patient-wechat-url-verify.ts + diff --git a/backend/WECHAT_MP_QUICK_FIX.md b/backend/WECHAT_MP_QUICK_FIX.md index 5df7dd82..15cf9c66 100644 --- a/backend/WECHAT_MP_QUICK_FIX.md +++ b/backend/WECHAT_MP_QUICK_FIX.md @@ -189,5 +189,6 @@ npm run dev + diff --git a/backend/check_db.ts b/backend/check_db.ts index bc8aa607..8a8cdee0 100644 --- a/backend/check_db.ts +++ b/backend/check_db.ts @@ -66,5 +66,6 @@ main() + diff --git a/backend/check_db_data.ts b/backend/check_db_data.ts index 5f4833ba..fa11f763 100644 --- a/backend/check_db_data.ts +++ b/backend/check_db_data.ts @@ -60,5 +60,6 @@ main() + diff --git a/backend/check_iit.ts b/backend/check_iit.ts index c98f5109..7adab3ea 100644 --- a/backend/check_iit.ts +++ b/backend/check_iit.ts @@ -55,5 +55,6 @@ main() + diff --git a/backend/check_iit_asl_data.ts b/backend/check_iit_asl_data.ts index 6b6dd5be..a4927d9b 100644 --- a/backend/check_iit_asl_data.ts +++ b/backend/check_iit_asl_data.ts @@ -87,5 +87,6 @@ main() + diff --git a/backend/check_queue_table.ts b/backend/check_queue_table.ts index 37be8453..ab319135 100644 --- a/backend/check_queue_table.ts +++ b/backend/check_queue_table.ts @@ -50,5 +50,6 @@ main() + diff --git a/backend/check_rvw_issue.ts b/backend/check_rvw_issue.ts index e0b62e05..679150ce 100644 --- a/backend/check_rvw_issue.ts +++ b/backend/check_rvw_issue.ts @@ -91,5 +91,6 @@ main() + diff --git a/backend/check_tables.ts b/backend/check_tables.ts index 32bbd63b..6441f45f 100644 --- a/backend/check_tables.ts +++ b/backend/check_tables.ts @@ -38,5 +38,6 @@ main() + diff --git a/backend/compare_db.ts b/backend/compare_db.ts index d2377b46..be897b3b 100644 --- a/backend/compare_db.ts +++ b/backend/compare_db.ts @@ -126,5 +126,6 @@ main() + diff --git a/backend/compare_dc_asl.ts b/backend/compare_dc_asl.ts index 04be1381..bd54d8ec 100644 --- a/backend/compare_dc_asl.ts +++ b/backend/compare_dc_asl.ts @@ -97,5 +97,6 @@ main() + diff --git a/backend/compare_pkb_aia_rvw.ts b/backend/compare_pkb_aia_rvw.ts index e11b1950..0dadcccd 100644 --- a/backend/compare_pkb_aia_rvw.ts +++ b/backend/compare_pkb_aia_rvw.ts @@ -83,5 +83,6 @@ main() + diff --git a/backend/compare_schema_db.ts b/backend/compare_schema_db.ts index 466c1a08..13a568c0 100644 --- a/backend/compare_schema_db.ts +++ b/backend/compare_schema_db.ts @@ -125,5 +125,6 @@ main() + diff --git a/backend/create_mock_user.sql b/backend/create_mock_user.sql index b15741f3..ac028062 100644 --- a/backend/create_mock_user.sql +++ b/backend/create_mock_user.sql @@ -36,5 +36,6 @@ ON CONFLICT (id) DO NOTHING; + diff --git a/backend/create_mock_user_platform.sql b/backend/create_mock_user_platform.sql index e649278c..185154f9 100644 --- a/backend/create_mock_user_platform.sql +++ b/backend/create_mock_user_platform.sql @@ -68,5 +68,6 @@ ON CONFLICT (id) DO NOTHING; + diff --git a/backend/env.example.md b/backend/env.example.md index 7e2c007a..45d0e88c 100644 --- a/backend/env.example.md +++ b/backend/env.example.md @@ -80,3 +80,4 @@ OSS_SIGNED_URL_EXPIRES=3600 + diff --git a/backend/migrations/add_data_stats_to_tool_c_session.sql b/backend/migrations/add_data_stats_to_tool_c_session.sql index 438e3b14..1747d095 100644 --- a/backend/migrations/add_data_stats_to_tool_c_session.sql +++ b/backend/migrations/add_data_stats_to_tool_c_session.sql @@ -86,5 +86,6 @@ WHERE table_schema = 'dc_schema' + diff --git a/backend/prisma/manual-migrations/001_add_postgres_cache_and_checkpoint.sql b/backend/prisma/manual-migrations/001_add_postgres_cache_and_checkpoint.sql index 9f0c7907..1de03fa2 100644 --- a/backend/prisma/manual-migrations/001_add_postgres_cache_and_checkpoint.sql +++ b/backend/prisma/manual-migrations/001_add_postgres_cache_and_checkpoint.sql @@ -124,5 +124,6 @@ ORDER BY ordinal_position; + diff --git a/backend/prisma/manual-migrations/run-migration-002.ts b/backend/prisma/manual-migrations/run-migration-002.ts index b8af7b84..1bb19333 100644 --- a/backend/prisma/manual-migrations/run-migration-002.ts +++ b/backend/prisma/manual-migrations/run-migration-002.ts @@ -137,5 +137,6 @@ runMigration() + diff --git a/backend/prisma/migrations/20251208_add_column_mapping/migration.sql b/backend/prisma/migrations/20251208_add_column_mapping/migration.sql index 2c5aab38..29431b93 100644 --- a/backend/prisma/migrations/20251208_add_column_mapping/migration.sql +++ b/backend/prisma/migrations/20251208_add_column_mapping/migration.sql @@ -71,5 +71,6 @@ COMMENT ON COLUMN "dc_schema"."dc_tool_c_sessions"."column_mapping" IS '列名 + diff --git a/backend/prisma/migrations/create_tool_c_session.sql b/backend/prisma/migrations/create_tool_c_session.sql index 11e77de7..7952a76d 100644 --- a/backend/prisma/migrations/create_tool_c_session.sql +++ b/backend/prisma/migrations/create_tool_c_session.sql @@ -98,5 +98,6 @@ COMMENT ON COLUMN dc_schema.dc_tool_c_sessions.expires_at IS '过期时间(创 + diff --git a/backend/prisma/migrations/manual/ekb_create_indexes.sql b/backend/prisma/migrations/manual/ekb_create_indexes.sql index 3921c287..9cdbb17c 100644 --- a/backend/prisma/migrations/manual/ekb_create_indexes.sql +++ b/backend/prisma/migrations/manual/ekb_create_indexes.sql @@ -68,3 +68,4 @@ USING gin (metadata jsonb_path_ops); + diff --git a/backend/prisma/migrations/manual/ekb_create_indexes_mvp.sql b/backend/prisma/migrations/manual/ekb_create_indexes_mvp.sql index 9f8df0ea..5aa343ca 100644 --- a/backend/prisma/migrations/manual/ekb_create_indexes_mvp.sql +++ b/backend/prisma/migrations/manual/ekb_create_indexes_mvp.sql @@ -35,3 +35,4 @@ USING gin (tags); + diff --git a/backend/prisma/seed-protocol-agent.ts b/backend/prisma/seed-protocol-agent.ts index 70855050..e226345e 100644 --- a/backend/prisma/seed-protocol-agent.ts +++ b/backend/prisma/seed-protocol-agent.ts @@ -388,3 +388,4 @@ seedProtocolAgent() process.exit(1); }); + diff --git a/backend/prisma/seeds/protocol-agent-seed.ts b/backend/prisma/seeds/protocol-agent-seed.ts index 0f10fa68..7e31696d 100644 --- a/backend/prisma/seeds/protocol-agent-seed.ts +++ b/backend/prisma/seeds/protocol-agent-seed.ts @@ -558,3 +558,4 @@ main() }); + diff --git a/backend/rebuild-and-push.ps1 b/backend/rebuild-and-push.ps1 index a1638313..a458e902 100644 --- a/backend/rebuild-and-push.ps1 +++ b/backend/rebuild-and-push.ps1 @@ -138,5 +138,6 @@ Write-Host "" + diff --git a/backend/recover-code-from-cursor-db.js b/backend/recover-code-from-cursor-db.js index 09dd991b..df0a5935 100644 --- a/backend/recover-code-from-cursor-db.js +++ b/backend/recover-code-from-cursor-db.js @@ -248,5 +248,6 @@ function extractCodeBlocks(obj, blocks = []) { + diff --git a/backend/restore_job_common.sql b/backend/restore_job_common.sql index 8a9a2470..a6614ea8 100644 --- a/backend/restore_job_common.sql +++ b/backend/restore_job_common.sql @@ -45,5 +45,6 @@ CREATE TABLE IF NOT EXISTS platform_schema.job_common ( + diff --git a/backend/restore_pgboss_functions.sql b/backend/restore_pgboss_functions.sql index 07cdc2f3..b20039a5 100644 --- a/backend/restore_pgboss_functions.sql +++ b/backend/restore_pgboss_functions.sql @@ -119,5 +119,6 @@ CREATE OR REPLACE FUNCTION platform_schema.delete_queue(queue_name text) RETURNS + diff --git a/backend/scripts/check-dc-tables.mjs b/backend/scripts/check-dc-tables.mjs index c01eb871..7f6d7259 100644 --- a/backend/scripts/check-dc-tables.mjs +++ b/backend/scripts/check-dc-tables.mjs @@ -267,5 +267,6 @@ checkDCTables(); + diff --git a/backend/scripts/create-capability-schema.sql b/backend/scripts/create-capability-schema.sql index b59ad44b..da7be47d 100644 --- a/backend/scripts/create-capability-schema.sql +++ b/backend/scripts/create-capability-schema.sql @@ -20,5 +20,6 @@ CREATE SCHEMA IF NOT EXISTS capability_schema; + diff --git a/backend/scripts/create-tool-c-ai-history-table.mjs b/backend/scripts/create-tool-c-ai-history-table.mjs index e50c6cfc..9476daa9 100644 --- a/backend/scripts/create-tool-c-ai-history-table.mjs +++ b/backend/scripts/create-tool-c-ai-history-table.mjs @@ -219,5 +219,6 @@ createAiHistoryTable() + diff --git a/backend/scripts/create-tool-c-table.js b/backend/scripts/create-tool-c-table.js index 5bcd43b0..9ce488c3 100644 --- a/backend/scripts/create-tool-c-table.js +++ b/backend/scripts/create-tool-c-table.js @@ -206,5 +206,6 @@ createToolCTable() + diff --git a/backend/scripts/create-tool-c-table.mjs b/backend/scripts/create-tool-c-table.mjs index 5b07a6e5..0316cd0f 100644 --- a/backend/scripts/create-tool-c-table.mjs +++ b/backend/scripts/create-tool-c-table.mjs @@ -203,5 +203,6 @@ createToolCTable() + diff --git a/backend/scripts/migrate-aia-prompts.ts b/backend/scripts/migrate-aia-prompts.ts index 01d3cecf..3aaacefa 100644 --- a/backend/scripts/migrate-aia-prompts.ts +++ b/backend/scripts/migrate-aia-prompts.ts @@ -325,3 +325,4 @@ main() + diff --git a/backend/scripts/setup-prompt-system.ts b/backend/scripts/setup-prompt-system.ts index 62ec8599..fc8724ac 100644 --- a/backend/scripts/setup-prompt-system.ts +++ b/backend/scripts/setup-prompt-system.ts @@ -130,5 +130,6 @@ main() + diff --git a/backend/scripts/test-pkb-apis-simple.ts b/backend/scripts/test-pkb-apis-simple.ts index 52d34955..cda8b82e 100644 --- a/backend/scripts/test-pkb-apis-simple.ts +++ b/backend/scripts/test-pkb-apis-simple.ts @@ -350,5 +350,6 @@ runTests().catch(error => { + diff --git a/backend/scripts/test-prompt-api.ts b/backend/scripts/test-prompt-api.ts index 02852e44..727388f5 100644 --- a/backend/scripts/test-prompt-api.ts +++ b/backend/scripts/test-prompt-api.ts @@ -96,5 +96,6 @@ testAPI().catch(console.error); + diff --git a/backend/scripts/test-unifuncs-deepsearch.ts b/backend/scripts/test-unifuncs-deepsearch.ts index a162e10b..2ae893da 100644 --- a/backend/scripts/test-unifuncs-deepsearch.ts +++ b/backend/scripts/test-unifuncs-deepsearch.ts @@ -128,3 +128,4 @@ testDeepSearch().catch(console.error); + diff --git a/backend/scripts/verify-pkb-rvw-schema.ts b/backend/scripts/verify-pkb-rvw-schema.ts index c52c6124..4b4996a1 100644 --- a/backend/scripts/verify-pkb-rvw-schema.ts +++ b/backend/scripts/verify-pkb-rvw-schema.ts @@ -315,5 +315,6 @@ verifySchemas() + diff --git a/backend/src/common/auth/jwt.service.ts b/backend/src/common/auth/jwt.service.ts index a7b5a92f..0104c856 100644 --- a/backend/src/common/auth/jwt.service.ts +++ b/backend/src/common/auth/jwt.service.ts @@ -203,5 +203,6 @@ export const jwtService = new JWTService(); + diff --git a/backend/src/common/jobs/utils.ts b/backend/src/common/jobs/utils.ts index 1c7259b8..d6bfb15c 100644 --- a/backend/src/common/jobs/utils.ts +++ b/backend/src/common/jobs/utils.ts @@ -335,5 +335,6 @@ export function getBatchItems( + diff --git a/backend/src/common/prompt/prompt.types.ts b/backend/src/common/prompt/prompt.types.ts index 81ae775b..0050755b 100644 --- a/backend/src/common/prompt/prompt.types.ts +++ b/backend/src/common/prompt/prompt.types.ts @@ -86,5 +86,6 @@ export interface VariableValidation { + diff --git a/backend/src/common/rag/ChunkService.ts b/backend/src/common/rag/ChunkService.ts index e36eeca4..8a2efce8 100644 --- a/backend/src/common/rag/ChunkService.ts +++ b/backend/src/common/rag/ChunkService.ts @@ -358,3 +358,4 @@ export default ChunkService; + diff --git a/backend/src/common/rag/DifyClient.ts b/backend/src/common/rag/DifyClient.ts index f6aac079..9f843715 100644 --- a/backend/src/common/rag/DifyClient.ts +++ b/backend/src/common/rag/DifyClient.ts @@ -54,3 +54,4 @@ export const DifyClient = DeprecatedDifyClient; + diff --git a/backend/src/common/streaming/OpenAIStreamAdapter.ts b/backend/src/common/streaming/OpenAIStreamAdapter.ts index ade63c34..aa7fc4cb 100644 --- a/backend/src/common/streaming/OpenAIStreamAdapter.ts +++ b/backend/src/common/streaming/OpenAIStreamAdapter.ts @@ -209,3 +209,4 @@ export function createOpenAIStreamAdapter( + diff --git a/backend/src/common/streaming/StreamingService.ts b/backend/src/common/streaming/StreamingService.ts index 6470bc64..9b9d291f 100644 --- a/backend/src/common/streaming/StreamingService.ts +++ b/backend/src/common/streaming/StreamingService.ts @@ -215,3 +215,4 @@ export async function streamChat( + diff --git a/backend/src/common/streaming/index.ts b/backend/src/common/streaming/index.ts index eb29636d..be429e84 100644 --- a/backend/src/common/streaming/index.ts +++ b/backend/src/common/streaming/index.ts @@ -33,3 +33,4 @@ export { THINKING_TAGS } from './types'; + diff --git a/backend/src/common/streaming/types.ts b/backend/src/common/streaming/types.ts index dbbe9011..fcc4dd9f 100644 --- a/backend/src/common/streaming/types.ts +++ b/backend/src/common/streaming/types.ts @@ -108,3 +108,4 @@ export type SSEEventType = + diff --git a/backend/src/modules/admin/routes/tenantRoutes.ts b/backend/src/modules/admin/routes/tenantRoutes.ts index 59795e29..b2007ae7 100644 --- a/backend/src/modules/admin/routes/tenantRoutes.ts +++ b/backend/src/modules/admin/routes/tenantRoutes.ts @@ -93,4 +93,5 @@ export async function moduleRoutes(fastify: FastifyInstance) { + diff --git a/backend/src/modules/admin/types/tenant.types.ts b/backend/src/modules/admin/types/tenant.types.ts index ce24e30c..95432dcb 100644 --- a/backend/src/modules/admin/types/tenant.types.ts +++ b/backend/src/modules/admin/types/tenant.types.ts @@ -123,4 +123,5 @@ export interface PaginatedResponse { + diff --git a/backend/src/modules/admin/types/user.types.ts b/backend/src/modules/admin/types/user.types.ts index 6dcb1a57..54730c1e 100644 --- a/backend/src/modules/admin/types/user.types.ts +++ b/backend/src/modules/admin/types/user.types.ts @@ -171,3 +171,4 @@ export const ROLE_DISPLAY_NAMES: Record = { + diff --git a/backend/src/modules/agent/protocol/controllers/ProtocolAgentController.ts b/backend/src/modules/agent/protocol/controllers/ProtocolAgentController.ts index 05408e7e..bef8d571 100644 --- a/backend/src/modules/agent/protocol/controllers/ProtocolAgentController.ts +++ b/backend/src/modules/agent/protocol/controllers/ProtocolAgentController.ts @@ -8,11 +8,27 @@ import { FastifyRequest, FastifyReply } from 'fastify'; import { PrismaClient } from '@prisma/client'; import { ProtocolOrchestrator } from '../services/ProtocolOrchestrator.js'; +import { ProtocolExportService } from '../services/ProtocolExportService.js'; import { LLMServiceInterface } from '../../services/BaseAgentOrchestrator.js'; import { ProtocolStageCode } from '../../types/index.js'; import { createStreamingService } from '../../../../common/streaming/index.js'; import type { OpenAIMessage } from '../../../../common/streaming/index.js'; import { logger } from '../../../../common/logging/index.js'; +import { + FULL_PROTOCOL_GENERATION_PROMPT, + TITLE_GENERATION_PROMPT, + fillPromptTemplate, + buildPromptVariables, +} from '../prompts/protocolGenerationPrompts.js'; + +// 阶段顺序 +const STAGE_ORDER: ProtocolStageCode[] = [ + 'scientific_question', + 'pico', + 'study_design', + 'sample_size', + 'endpoints', +]; // 请求类型定义 interface SendMessageBody { @@ -41,11 +57,13 @@ interface GetContextParams { export class ProtocolAgentController { private orchestrator: ProtocolOrchestrator; + private exportService: ProtocolExportService; private prisma: PrismaClient; constructor(prisma: PrismaClient, llmService: LLMServiceInterface) { this.prisma = prisma; this.orchestrator = new ProtocolOrchestrator({ prisma, llmService }); + this.exportService = new ProtocolExportService(prisma); } /** @@ -213,30 +231,42 @@ export class ProtocolAgentController { // 获取当前阶段需要输出的字段格式 const stageOutputFormat = this.getStageOutputFormat(context.currentStage); + // 获取阶段任务描述 + const stageInstructions = this.getStageInstructions(context.currentStage); + return `你是一位资深的临床研究方法学专家,正在帮助医生设计临床研究方案。 +## ⚠️ 重要:当前阶段 + +**🎯 你现在正在处理「${currentStageName}」阶段(第 ${STAGE_ORDER.indexOf(context.currentStage as ProtocolStageCode) + 1}/5 阶段)** + +${stageInstructions} + ## 当前状态 -- **当前阶段**: ${currentStageName} - **已完成阶段**: ${completedStages.map((s: string) => stageNames[s]).join(', ') || '无'} - **进度**: ${progress}% -## 已收集的数据${completedDataSummary || '\n暂无已确认的数据'} +## 已收集的数据(仅供参考,不要重复讨论)${completedDataSummary || '\n暂无已确认的数据'} -## 你的任务 -1. **只围绕「${currentStageName}」阶段与用户对话**,不要跨阶段讨论 -2. 引导用户提供当前阶段所需的完整信息 -3. 当信息收集完整时,先用文字总结,然后**必须**在回复末尾输出结构化数据 +## ❌ 禁止事项 +1. **不要讨论已完成的阶段**(如${completedStages.map((s: string) => stageNames[s]).join('、') || '无'}) +2. **不要提前讨论未来的阶段** +3. **不要重复已经确认的数据** -## 当前阶段「${currentStageName}」的输出格式 -当信息完整时,**必须**在回复末尾添加以下格式的数据提取标签: +## ✅ 你的任务 +1. **只围绕「${currentStageName}」阶段与用户对话** +2. 引导用户提供当前阶段所需的信息 +3. 当信息收集完整时,先用文字总结,然后在回复末尾输出结构化数据 + +## 当前阶段「${currentStageName}」的数据格式 +当信息完整时,**必须**在回复末尾添加: ${stageOutputFormat} -## 重要提示 -- 只有当用户提供了足够的信息后才输出 标签 -- 输出的 JSON 必须是有效格式 -- 每次对话只关注当前阶段「${currentStageName}」 -- 回复使用 Markdown 格式,简洁专业`; +## 输出要求 +- 只有当用户提供了足够的「${currentStageName}」信息后才输出 标签 +- 回复使用 Markdown 格式,简洁专业 +- **再次强调:你只处理「${currentStageName}」**`; } /** @@ -296,6 +326,50 @@ ${stageOutputFormat} `; } + /** + * 获取阶段任务说明(帮助模型理解当前阶段应该做什么) + */ + private getStageInstructions(stageCode: string): string { + const instructions: Record = { + scientific_question: `**当前任务:科学问题梳理** +你需要帮助用户明确研究的核心科学问题。 +- 询问用户想研究什么问题 +- 了解研究背景和临床意义 +- 引导用户将问题凝练成一句话`, + + pico: `**当前任务:PICO要素确定** +你需要帮助用户确定PICO四要素: +- P (Population): 研究人群是谁? +- I (Intervention): 干预/暴露因素是什么? +- C (Comparison): 对照组是什么? +- O (Outcome): 主要结局指标是什么?`, + + study_design: `**当前任务:研究设计选择** +你需要帮助用户选择合适的研究设计类型: +- 根据研究问题推荐研究类型(RCT/队列/病例对照等) +- 讨论设计特征(随机/盲法/多中心等) +- 确定分组方法`, + + sample_size: `**当前任务:样本量计算** +你需要帮助用户估算所需样本量: +- 讨论主要结局指标的预期效应量 +- 确定统计学参数(α, β/Power) +- 考虑脱落率 +- 计算最终样本量`, + + endpoints: `**当前任务:观察指标设计** +你需要帮助用户定义研究的观察指标体系: +- 主要结局指标(Primary Outcome) +- 次要结局指标(Secondary Outcomes) +- 安全性指标 +- 潜在混杂因素 + +⚠️ 注意:样本量计算已在上一阶段完成,本阶段只讨论观察指标的定义和测量方法。`, + }; + + return instructions[stageCode] || ''; + } + /** * 同步阶段数据 * POST /api/aia/protocol-agent/sync @@ -375,28 +449,91 @@ ${stageOutputFormat} } /** - * 一键生成研究方案 - * POST /api/aia/protocol-agent/generate + * 获取对话的历史消息 + * GET /api/aia/protocol-agent/messages/:conversationId */ - async generateProtocol( - request: FastifyRequest<{ Body: GenerateProtocolBody }>, + async getMessages( + request: FastifyRequest<{ Params: GetContextParams }>, reply: FastifyReply ): Promise { try { - const { conversationId, options } = request.body; - const userId = (request as any).user?.userId; - - if (!userId) { - reply.code(401).send({ error: 'Unauthorized' }); - return; - } + const { conversationId } = request.params; if (!conversationId) { reply.code(400).send({ error: 'Missing conversationId' }); return; } - // 获取上下文 + // 验证对话是否存在 + const conversation = await this.prisma.conversation.findUnique({ + where: { id: conversationId }, + }); + + if (!conversation) { + reply.code(404).send({ error: 'Conversation not found' }); + return; + } + + // 获取历史消息 + const messages = await this.prisma.message.findMany({ + where: { conversationId }, + orderBy: { createdAt: 'asc' }, + select: { + id: true, + role: true, + content: true, + thinkingContent: true, + createdAt: true, + }, + }); + + reply.send({ + success: true, + data: { + messages: messages.map(m => ({ + id: m.id, + role: m.role, + content: m.content, + thinkingContent: m.thinkingContent, + createdAt: m.createdAt, + })), + }, + }); + } catch (error) { + console.error('[ProtocolAgentController] getMessages error:', error); + reply.code(500).send({ + success: false, + error: error instanceof Error ? error.message : 'Internal server error', + }); + } + } + + /** + * 一键生成研究方案(流式输出) + * POST /api/aia/protocol-agent/generate + * + * 必填要素:科学问题、PICO、研究设计、观察指标(4/5) + * 可选要素:样本量 + */ + async generateProtocol( + request: FastifyRequest<{ Body: GenerateProtocolBody }>, + reply: FastifyReply + ): Promise { + const { conversationId, options } = request.body; + const userId = (request as any).user?.userId; + + if (!userId) { + reply.code(401).send({ error: 'Unauthorized' }); + return; + } + + if (!conversationId) { + reply.code(400).send({ error: 'Missing conversationId' }); + return; + } + + try { + // 1. 获取上下文 const contextService = this.orchestrator.getContextService(); const context = await contextService.getContext(conversationId); @@ -405,27 +542,98 @@ ${stageOutputFormat} return; } - // 检查是否所有阶段都已完成 - if (!contextService.isAllStagesCompleted(context)) { + // 2. 校验必填要素(4/5:科学问题、PICO、研究设计、观察指标) + const requiredStages = ['scientific_question', 'pico', 'study_design', 'endpoints']; + const completedStages = context.completedStages || []; + const missingStages = requiredStages.filter(s => !completedStages.includes(s)); + + if (missingStages.length > 0) { + const stageNames: Record = { + scientific_question: '科学问题', + pico: 'PICO要素', + study_design: '研究设计', + endpoints: '观察指标', + }; + const missingNames = missingStages.map(s => stageNames[s]).join('、'); reply.code(400).send({ - error: '请先完成所有5个阶段(科学问题、PICO、研究设计、样本量、观察指标)' + error: `请先完成必填要素:${missingNames}`, + missingStages, }); return; } - // TODO: 实现方案生成逻辑 - // 这里先返回占位响应,实际应该调用LLM生成完整方案 - reply.send({ - success: true, - data: { - generationId: 'placeholder', - status: 'generating', - message: '研究方案生成中...', - estimatedTime: 30, + logger.info('[ProtocolAgentController] 开始生成研究方案', { + conversationId, + completedStages, + style: options?.style || 'academic', + }); + + // 3. 构建 Prompt 变量 + const promptVariables = buildPromptVariables({ + scientificQuestion: context.scientificQuestion as any, + pico: context.pico as any, + studyDesign: context.studyDesign as any, + sampleSize: context.sampleSize as any, + endpoints: context.endpoints as any, + }); + + // 4. 先生成标题 + const titlePrompt = fillPromptTemplate(TITLE_GENERATION_PROMPT, promptVariables); + + // 5. 生成完整方案(流式) + const fullPromptWithTitle = fillPromptTemplate(FULL_PROTOCOL_GENERATION_PROMPT, { + ...promptVariables, + title: '(标题将根据内容自动生成)', // 占位,实际会在内容中生成 + }); + + // 6. 构建消息 + const messages: OpenAIMessage[] = [ + { role: 'system', content: fullPromptWithTitle }, + { role: 'user', content: '请根据以上关键要素,生成完整的临床研究方案。使用 Markdown 格式输出,按照12个章节结构撰写。' }, + ]; + + // 7. 使用 StreamingService 流式输出 + const streamingService = createStreamingService(reply, { + model: 'deepseek-v3', + temperature: 0.7, + maxTokens: 8192, // 长文档需要更多 tokens + enableDeepThinking: false, + userId, + conversationId, + }); + + await streamingService.streamGenerate(messages, { + onComplete: async (fullContent, thinkingContent) => { + // 保存生成的方案到数据库 + await this.prisma.message.create({ + data: { + conversationId, + role: 'assistant', + content: fullContent, + thinkingContent: thinkingContent || null, + model: 'deepseek-v3', + metadata: { + type: 'protocol_generation', + style: options?.style || 'academic', + }, + }, + }); + + logger.info('[ProtocolAgentController] 研究方案生成完成', { + conversationId, + contentLength: fullContent.length, + }); + }, + onError: (error) => { + logger.error('[ProtocolAgentController] 研究方案生成失败', { + error, + conversationId + }); }, }); + } catch (error) { - console.error('[ProtocolAgentController] generateProtocol error:', error); + logger.error('[ProtocolAgentController] generateProtocol error:', error); reply.code(500).send({ success: false, error: error instanceof Error ? error.message : 'Internal server error', @@ -465,29 +673,100 @@ ${stageOutputFormat} /** * 导出Word文档 - * POST /api/aia/protocol-agent/generation/:generationId/export + * POST /api/aia/protocol-agent/export/docx + * + * 支持两种模式: + * 1. 提供 content: 直接将前端生成的内容转换为 Word + * 2. 不提供 content: 从上下文数据生成模板(旧模式) */ async exportWord( request: FastifyRequest<{ - Params: { generationId: string }; - Body: { format: 'docx' | 'pdf' }; + Body: { conversationId?: string; content?: string }; }>, reply: FastifyReply ): Promise { try { - const { generationId } = request.params; - const { format } = request.body; + const { conversationId, content } = request.body; + const userId = (request as any).user?.userId; - // TODO: 实现导出逻辑 + if (!userId) { + reply.code(401).send({ error: 'Unauthorized' }); + return; + } + + let markdown: string; + + // 模式1:使用前端提供的内容(推荐) + if (content && content.trim()) { + logger.info('[ProtocolAgentController] 导出 Word: 使用前端生成的内容', { + contentLength: content.length, + }); + markdown = content; + } + // 模式2:从上下文生成模板(兼容旧逻辑) + else if (conversationId) { + logger.info('[ProtocolAgentController] 导出 Word: 从上下文生成模板', { + conversationId, + }); + + const contextService = this.orchestrator.getContextService(); + const context = await contextService.getContext(conversationId); + + if (!context) { + reply.code(404).send({ error: 'Context not found' }); + return; + } + + markdown = this.exportService.generateProtocolMarkdown({ + scientificQuestion: context.scientificQuestion as any, + pico: context.pico as any, + studyDesign: context.studyDesign as any, + sampleSize: context.sampleSize as any, + endpoints: context.endpoints as any, + }); + } else { + reply.code(400).send({ error: 'Missing content or conversationId' }); + return; + } + + // 调用导出服务转换为 Word + const buffer = await this.exportService.convertToDocx(markdown); + + // 生成文件名 + const timestamp = new Date().toISOString().slice(0, 10); + const filename = `研究方案_${timestamp}.docx`; + + // 返回文件 + reply + .header('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') + .header('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`) + .send(buffer); + + } catch (error) { + logger.error('[ProtocolAgentController] exportWord error:', error); + reply.code(500).send({ + success: false, + error: error instanceof Error ? error.message : 'Internal server error', + }); + } + } + + /** + * 检查 Pandoc 服务状态 + * GET /api/aia/protocol-agent/export/status + */ + async checkExportStatus( + request: FastifyRequest, + reply: FastifyReply + ): Promise { + try { + const status = await this.exportService.checkPandocStatus(); reply.send({ success: true, - data: { - downloadUrl: `/api/aia/protocol-agent/download/${generationId}.${format}`, - expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), - }, + data: status, }); } catch (error) { - console.error('[ProtocolAgentController] exportWord error:', error); + logger.error('[ProtocolAgentController] checkExportStatus error:', error); reply.code(500).send({ success: false, error: error instanceof Error ? error.message : 'Internal server error', diff --git a/backend/src/modules/agent/protocol/prompts/protocolGenerationPrompts.ts b/backend/src/modules/agent/protocol/prompts/protocolGenerationPrompts.ts new file mode 100644 index 00000000..b256d91e --- /dev/null +++ b/backend/src/modules/agent/protocol/prompts/protocolGenerationPrompts.ts @@ -0,0 +1,246 @@ +/** + * Protocol Generation Prompts + * 研究方案生成的 Prompt 模板 + * + * @module agent/protocol/prompts + */ + +/** + * 方案标题生成 Prompt + */ +export const TITLE_GENERATION_PROMPT = `你是一位资深的临床研究方案撰写专家。请根据以下关键要素,生成一个专业、准确、符合学术规范的研究方案标题。 + +## 关键要素 + +**科学问题**: {{scientificQuestion}} + +**PICO 框架**: +- P (研究人群): {{population}} +- I (干预措施): {{intervention}} +- C (对照措施): {{comparison}} +- O (结局指标): {{outcome}} + +**研究设计**: {{studyType}} + +## 标题要求 + +1. 标题应包含:研究人群、干预措施、对照措施、主要结局、研究类型 +2. 格式参考:「[干预措施]对[研究人群][主要结局]的[研究类型]」 +3. 标题长度:20-50字 +4. 使用专业医学术语 +5. 不要包含标点符号(除必要的顿号) + +## 输出格式 + +只输出标题文本,不要包含任何解释或前缀。`; + +/** + * 完整方案生成 Prompt(流式输出) + */ +export const FULL_PROTOCOL_GENERATION_PROMPT = `你是一位资深的临床研究方案撰写专家,拥有丰富的 RCT、队列研究、病例对照研究方案撰写经验。请根据以下关键要素,生成一份完整的临床研究方案。 + +## 关键要素 + +**研究标题**: {{title}} + +**科学问题**: {{scientificQuestion}} + +**PICO 框架**: +- P (研究人群): {{population}} +- I (干预措施): {{intervention}} +- C (对照措施): {{comparison}} +- O (结局指标): {{outcome}} + +**研究设计**: +- 研究类型: {{studyType}} +- 设计特征: {{designFeatures}} + +**样本量**: {{sampleSize}} +- 计算依据: α={{alpha}}, Power={{power}}, 效应量={{effectSize}} + +**观察指标**: +- 主要结局: {{primaryOutcomes}} +- 次要结局: {{secondaryOutcomes}} +- 安全性指标: {{safetyOutcomes}} + +## 输出要求 + +请按以下 12 个章节结构输出,使用 Markdown 格式: + +# {{title}} + +## 1. 研究背景 (Background) +- 疾病/问题的流行病学数据 +- 当前治疗现状与挑战 +- 研究的科学依据和立题依据 +- 为什么需要本研究(知识缺口) + +## 2. 研究目的 (Objectives) +- **主要目的**: 基于科学问题,明确主要研究假设 +- **次要目的**: 列出 2-3 个次要目的 + +## 3. 研究设计 (Study Design) +- 研究类型(RCT/队列/病例对照等) +- 设计特征(随机/盲法/多中心等) +- 分组方法和随机化方案 + +## 4. 研究对象 (Subjects) +### 4.1 纳入标准 +- 基于 P(研究人群)制定 5-8 条纳入标准 +### 4.2 排除标准 +- 制定 5-8 条排除标准(安全性考虑、混杂控制) + +## 5. 样本量估算 (Sample Size) +- 主要结局指标 +- 预期效应量 +- 统计学参数(α, β, Power) +- 计算公式和结果 +- 考虑脱落率后的最终样本量 + +## 6. 研究实施步骤 (Implementation) +- 研究流程图 +- 各阶段时间节点 +- 干预措施详细说明 +- 对照措施详细说明 +- 访视计划 + +## 7. 观察指标 (Endpoints) +### 7.1 主要结局指标 +- 指标定义、测量方法、测量时点 +### 7.2 次要结局指标 +- 各指标的定义和测量方法 +### 7.3 安全性指标 +- 不良事件定义和报告流程 + +## 8. 数据管理与质量控制 (Data Management) +- 数据收集方法 +- 数据录入和核查 +- 质量控制措施 +- 数据安全和保密 + +## 9. 统计分析计划 (Statistical Analysis) +- 分析人群定义(ITT/PP) +- 描述性统计方法 +- 主要结局分析方法 +- 次要结局分析方法 +- 缺失数据处理 +- 亚组分析计划 + +## 10. 伦理与知情同意 (Ethics) +- 伦理审批要求 +- 知情同意流程 +- 受试者权益保护 +- 数据隐私保护 + +## 11. 研究时间表 (Timeline) +- 研究各阶段时间安排 +- 里程碑节点 + +## 12. 参考文献 (References) +- 列出 3-5 篇支持本研究的核心文献 + +## 撰写规范 + +1. **语言**: 使用专业、严谨的学术语言 +2. **格式**: 严格按照上述 Markdown 格式输出 +3. **内容**: 每个章节都要有实质性内容,不要使用占位符 +4. **逻辑**: 各章节内容要前后呼应,逻辑一致 +5. **篇幅**: 每个章节 200-500 字,总计约 4000-6000 字 + +## 重要提示 + +- **直接输出方案内容**,不要包含任何开场白、问候语或解释性文字 +- **禁止**输出类似"好的,作为..."、"我将为您..."等引导语 +- **第一行必须是**:# {{title}}(方案标题) +- 立即开始输出方案正文内容`; + +/** + * 单章节生成 Prompt(用于"讨论与优化"后重新生成) + */ +export const SECTION_REGENERATION_PROMPT = `你是一位资深的临床研究方案撰写专家。请根据用户的反馈,重新撰写研究方案的「{{sectionTitle}}」章节。 + +## 研究基本信息 + +**研究标题**: {{title}} +**科学问题**: {{scientificQuestion}} +**PICO**: P={{population}}, I={{intervention}}, C={{comparison}}, O={{outcome}} +**研究设计**: {{studyType}} + +## 当前章节内容 + +{{currentContent}} + +## 用户反馈 + +{{userFeedback}} + +## 输出要求 + +1. 根据用户反馈修改和优化当前章节 +2. 保持与其他章节的一致性和连贯性 +3. 使用 Markdown 格式 +4. 只输出该章节内容,以「## {{sectionNumber}}. {{sectionTitle}}」开头 + +请直接输出优化后的章节内容:`; + +/** + * 章节配置 + */ +export const PROTOCOL_SECTIONS = [ + { id: 'background', number: 1, title: '研究背景 (Background)' }, + { id: 'objectives', number: 2, title: '研究目的 (Objectives)' }, + { id: 'design', number: 3, title: '研究设计 (Study Design)' }, + { id: 'subjects', number: 4, title: '研究对象 (Subjects)' }, + { id: 'sample_size', number: 5, title: '样本量估算 (Sample Size)' }, + { id: 'implementation', number: 6, title: '研究实施步骤 (Implementation)' }, + { id: 'endpoints', number: 7, title: '观察指标 (Endpoints)' }, + { id: 'data_management', number: 8, title: '数据管理与质量控制 (Data Management)' }, + { id: 'statistics', number: 9, title: '统计分析计划 (Statistical Analysis)' }, + { id: 'ethics', number: 10, title: '伦理与知情同意 (Ethics)' }, + { id: 'timeline', number: 11, title: '研究时间表 (Timeline)' }, + { id: 'references', number: 12, title: '参考文献 (References)' }, +] as const; + +/** + * 填充 Prompt 模板 + */ +export function fillPromptTemplate( + template: string, + variables: Record +): string { + let result = template; + for (const [key, value] of Object.entries(variables)) { + const placeholder = `{{${key}}}`; + result = result.replace(new RegExp(placeholder, 'g'), String(value ?? '未提供')); + } + return result; +} + +/** + * 从 Context 数据构建 Prompt 变量 + */ +export function buildPromptVariables(context: { + scientificQuestion?: { content?: string }; + pico?: { population?: string; intervention?: string; comparison?: string; outcome?: string }; + studyDesign?: { studyType?: string; design?: string[] }; + sampleSize?: { sampleSize?: number; calculation?: { alpha?: number; power?: number; effectSize?: string } }; + endpoints?: { outcomes?: { primary?: string[]; secondary?: string[]; safety?: string[] } }; +}): Record { + return { + scientificQuestion: context.scientificQuestion?.content || '', + population: context.pico?.population || '', + intervention: context.pico?.intervention || '', + comparison: context.pico?.comparison || '', + outcome: context.pico?.outcome || '', + studyType: context.studyDesign?.studyType || '', + designFeatures: context.studyDesign?.design?.join('、') || '', + sampleSize: context.sampleSize?.sampleSize?.toString() || '待计算', + alpha: context.sampleSize?.calculation?.alpha?.toString() || '0.05', + power: context.sampleSize?.calculation?.power?.toString() || '0.8', + effectSize: context.sampleSize?.calculation?.effectSize || '', + primaryOutcomes: context.endpoints?.outcomes?.primary?.join('、') || '', + secondaryOutcomes: context.endpoints?.outcomes?.secondary?.join('、') || '', + safetyOutcomes: context.endpoints?.outcomes?.safety?.join('、') || '', + }; +} + diff --git a/backend/src/modules/agent/protocol/routes/index.ts b/backend/src/modules/agent/protocol/routes/index.ts index aabc771a..95938e05 100644 --- a/backend/src/modules/agent/protocol/routes/index.ts +++ b/backend/src/modules/agent/protocol/routes/index.ts @@ -81,6 +81,22 @@ export async function protocolAgentRoutes( }, }, (request, reply) => controller.getContext(request, reply)); + // 获取历史消息 + fastify.get<{ + Params: { conversationId: string }; + }>('/messages/:conversationId', { + preHandler: [authenticate], + schema: { + params: { + type: 'object', + required: ['conversationId'], + properties: { + conversationId: { type: 'string' }, + }, + }, + }, + }, (request, reply) => controller.getMessages(request, reply)); + // 一键生成研究方案 fastify.post<{ Body: { @@ -127,27 +143,27 @@ export async function protocolAgentRoutes( }, (request, reply) => controller.getGeneration(request, reply)); // 导出Word文档 + // 支持两种方式: + // 1. 提供 content: 直接转换前端生成的内容(推荐) + // 2. 提供 conversationId: 从上下文数据生成模板 fastify.post<{ - Params: { generationId: string }; - Body: { format: 'docx' | 'pdf' }; - }>('/generation/:generationId/export', { + Body: { conversationId?: string; content?: string }; + }>('/export/docx', { preHandler: [authenticate], schema: { - params: { - type: 'object', - required: ['generationId'], - properties: { - generationId: { type: 'string' }, - }, - }, body: { type: 'object', - required: ['format'], properties: { - format: { type: 'string', enum: ['docx', 'pdf'] }, + conversationId: { type: 'string' }, + content: { type: 'string', description: '前端生成的 Markdown 内容' }, }, }, }, }, (request, reply) => controller.exportWord(request, reply)); + + // 检查导出服务状态 + fastify.get('/export/status', { + preHandler: [authenticate], + }, (request, reply) => controller.checkExportStatus(request, reply)); } diff --git a/backend/src/modules/agent/protocol/services/LLMServiceAdapter.ts b/backend/src/modules/agent/protocol/services/LLMServiceAdapter.ts index 8bab176a..513aec34 100644 --- a/backend/src/modules/agent/protocol/services/LLMServiceAdapter.ts +++ b/backend/src/modules/agent/protocol/services/LLMServiceAdapter.ts @@ -182,3 +182,4 @@ export function createLLMServiceAdapter(): LLMServiceInterface { } + diff --git a/backend/src/modules/agent/protocol/services/PromptBuilder.ts b/backend/src/modules/agent/protocol/services/PromptBuilder.ts index 7070ab4e..f186380d 100644 --- a/backend/src/modules/agent/protocol/services/PromptBuilder.ts +++ b/backend/src/modules/agent/protocol/services/PromptBuilder.ts @@ -284,3 +284,4 @@ export class PromptBuilder { } + diff --git a/backend/src/modules/agent/protocol/services/ProtocolContextService.ts b/backend/src/modules/agent/protocol/services/ProtocolContextService.ts index d0d20284..8419b974 100644 --- a/backend/src/modules/agent/protocol/services/ProtocolContextService.ts +++ b/backend/src/modules/agent/protocol/services/ProtocolContextService.ts @@ -189,6 +189,36 @@ export class ProtocolContextService { return requiredStages.every(stage => context.completedStages.includes(stage)); } + /** + * 检查是否可以生成研究方案(4/5 必填项) + * 必填:科学问题、PICO、研究设计、观察指标 + * 可选:样本量 + */ + canGenerateProtocol(context: ProtocolContextData): boolean { + const requiredStages: ProtocolStageCode[] = [ + 'scientific_question', + 'pico', + 'study_design', + 'endpoints', + ]; + + return requiredStages.every(stage => context.completedStages.includes(stage)); + } + + /** + * 获取缺失的必填阶段 + */ + getMissingRequiredStages(context: ProtocolContextData): ProtocolStageCode[] { + const requiredStages: ProtocolStageCode[] = [ + 'scientific_question', + 'pico', + 'study_design', + 'endpoints', + ]; + + return requiredStages.filter(stage => !context.completedStages.includes(stage)); + } + /** * 获取进度百分比 */ diff --git a/backend/src/modules/agent/protocol/services/ProtocolExportService.ts b/backend/src/modules/agent/protocol/services/ProtocolExportService.ts new file mode 100644 index 00000000..76a9ec60 --- /dev/null +++ b/backend/src/modules/agent/protocol/services/ProtocolExportService.ts @@ -0,0 +1,297 @@ +/** + * Protocol Export Service + * 处理研究方案的生成和导出 + * + * @module agent/protocol/services/ProtocolExportService + */ + +import axios from 'axios'; +import { PrismaClient } from '@prisma/client'; +import { logger } from '../../../../common/logging/index.js'; + +// Python 微服务地址 +const EXTRACTION_SERVICE_URL = process.env.EXTRACTION_SERVICE_URL || 'http://localhost:8000'; + +// 方案章节配置 +const PROTOCOL_SECTIONS = [ + { key: 'title', name: '研究题目' }, + { key: 'background', name: '研究背景与立题依据' }, + { key: 'objectives', name: '研究目的' }, + { key: 'design', name: '研究设计' }, + { key: 'subjects', name: '研究对象(纳入/排除标准)' }, + { key: 'sample_size', name: '样本量估算' }, + { key: 'implementation', name: '研究实施步骤与技术路线' }, + { key: 'endpoints', name: '观察指标' }, + { key: 'data_management', name: '数据管理与质量控制' }, + { key: 'safety', name: '安全性评价' }, + { key: 'statistics', name: '统计分析计划' }, + { key: 'ethics', name: '伦理与知情同意' }, + { key: 'timeline', name: '研究时间表' }, + { key: 'references', name: '参考文献' }, +]; + +interface ContextData { + scientificQuestion?: { + content?: string; + summary?: string; + original?: string; + }; + pico?: { + population?: string; + intervention?: string; + comparison?: string; + outcome?: string; + }; + studyDesign?: { + studyType?: string; + design?: string[]; + }; + sampleSize?: { + sampleSize?: number; + calculation?: { + alpha?: number; + power?: number; + effectSize?: string; + }; + }; + endpoints?: { + outcomes?: { + primary?: string[]; + secondary?: string[]; + safety?: string[]; + }; + confounders?: string[]; + }; +} + +export class ProtocolExportService { + private prisma: PrismaClient; + + constructor(prisma: PrismaClient) { + this.prisma = prisma; + } + + /** + * 检查 Pandoc 服务可用性 + */ + async checkPandocStatus(): Promise<{ + available: boolean; + version: string | null; + message: string; + }> { + try { + const response = await axios.get(`${EXTRACTION_SERVICE_URL}/api/pandoc/status`, { + timeout: 5000, + }); + return response.data; + } catch (error) { + logger.error('[ProtocolExportService] Pandoc 状态检查失败:', error); + return { + available: false, + version: null, + message: `无法连接到文档服务: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } + + /** + * 根据上下文数据生成 Markdown 格式的研究方案 + */ + generateProtocolMarkdown(context: ContextData, title?: string): string { + const parts: string[] = []; + + // 标题 + const protocolTitle = title || this.generateTitle(context); + parts.push(`# 临床研究方案\n\n`); + + // 1. 研究题目 + parts.push(`## 1. 研究题目\n\n${protocolTitle}\n\n`); + + // 2. 研究背景(占位,可由 LLM 生成) + parts.push(`## 2. 研究背景与立题依据\n\n`); + parts.push(`(待 LLM 根据科学问题生成)\n\n`); + + // 3. 研究目的 + parts.push(`## 3. 研究目的\n\n`); + if (context.scientificQuestion?.content) { + parts.push(`**主要目的**:${context.scientificQuestion.content}\n\n`); + } + + // 4. 研究设计 + parts.push(`## 4. 研究设计\n\n`); + if (context.studyDesign) { + if (context.studyDesign.studyType) { + parts.push(`**研究类型**:${context.studyDesign.studyType}\n\n`); + } + if (context.studyDesign.design && context.studyDesign.design.length > 0) { + parts.push(`**设计特征**:\n`); + context.studyDesign.design.forEach(d => { + parts.push(`- ${d}\n`); + }); + parts.push('\n'); + } + } + + // 5. 研究对象 + parts.push(`## 5. 研究对象(纳入/排除标准)\n\n`); + if (context.pico?.population) { + parts.push(`**目标人群**:${context.pico.population}\n\n`); + } + parts.push(`### 纳入标准\n\n(待补充)\n\n`); + parts.push(`### 排除标准\n\n(待补充)\n\n`); + + // 6. 样本量估算 + parts.push(`## 6. 样本量估算\n\n`); + if (context.sampleSize) { + if (context.sampleSize.sampleSize) { + parts.push(`**计划样本量**:${context.sampleSize.sampleSize} 例\n\n`); + } + if (context.sampleSize.calculation) { + const calc = context.sampleSize.calculation; + parts.push(`**计算依据**:\n`); + if (calc.alpha) parts.push(`- α = ${calc.alpha}\n`); + if (calc.power) parts.push(`- 1-β = ${calc.power}\n`); + if (calc.effectSize) parts.push(`- 效应量 = ${calc.effectSize}\n`); + parts.push('\n'); + } + } + + // 7. 研究实施步骤 + parts.push(`## 7. 研究实施步骤与技术路线\n\n`); + if (context.pico) { + if (context.pico.intervention) { + parts.push(`**干预措施**:${context.pico.intervention}\n\n`); + } + if (context.pico.comparison) { + parts.push(`**对照措施**:${context.pico.comparison}\n\n`); + } + } + parts.push(`(技术路线图待补充)\n\n`); + + // 8. 观察指标 + parts.push(`## 8. 观察指标\n\n`); + if (context.endpoints?.outcomes) { + const outcomes = context.endpoints.outcomes; + if (outcomes.primary && outcomes.primary.length > 0) { + parts.push(`### 主要结局指标\n\n`); + outcomes.primary.forEach(o => parts.push(`- ${o}\n`)); + parts.push('\n'); + } + if (outcomes.secondary && outcomes.secondary.length > 0) { + parts.push(`### 次要结局指标\n\n`); + outcomes.secondary.forEach(o => parts.push(`- ${o}\n`)); + parts.push('\n'); + } + if (outcomes.safety && outcomes.safety.length > 0) { + parts.push(`### 安全性指标\n\n`); + outcomes.safety.forEach(o => parts.push(`- ${o}\n`)); + parts.push('\n'); + } + } + if (context.endpoints?.confounders && context.endpoints.confounders.length > 0) { + parts.push(`### 潜在混杂因素\n\n`); + context.endpoints.confounders.forEach(c => parts.push(`- ${c}\n`)); + parts.push('\n'); + } + + // 9-14. 其他章节(占位) + parts.push(`## 9. 数据管理与质量控制\n\n(待补充)\n\n`); + parts.push(`## 10. 安全性评价\n\n(待补充)\n\n`); + parts.push(`## 11. 统计分析计划\n\n(待补充)\n\n`); + parts.push(`## 12. 伦理与知情同意\n\n本研究将遵循赫尔辛基宣言的伦理原则,并提交机构伦理委员会审批。\n\n`); + parts.push(`## 13. 研究时间表\n\n(待补充)\n\n`); + parts.push(`## 14. 参考文献\n\n(待补充)\n\n`); + + return parts.join(''); + } + + /** + * 根据上下文生成研究题目 + */ + private generateTitle(context: ContextData): string { + const parts: string[] = []; + + // PICO 要素 + if (context.pico) { + if (context.pico.population) parts.push(context.pico.population); + if (context.pico.intervention) parts.push(`使用${context.pico.intervention}`); + if (context.pico.comparison) parts.push(`与${context.pico.comparison}对比`); + if (context.pico.outcome) parts.push(`对${context.pico.outcome}影响`); + } + + if (parts.length > 0) { + // 研究类型后缀 + if (context.studyDesign?.studyType) { + parts.push(`的${context.studyDesign.studyType}`); + } else { + parts.push('的临床研究'); + } + return parts.join(''); + } + + // 使用科学问题作为标题 + if (context.scientificQuestion?.content) { + return context.scientificQuestion.content; + } + + return '临床研究方案'; + } + + /** + * 将 Markdown 转换为 Word 文档 + */ + async convertToDocx(markdown: string): Promise { + try { + logger.info('[ProtocolExportService] 开始转换 Markdown → Word'); + + const response = await axios.post( + `${EXTRACTION_SERVICE_URL}/api/convert/docx`, + { + content: markdown, + use_template: true, + title: '临床研究方案', + }, + { + responseType: 'arraybuffer', + timeout: 30000, // 30秒超时 + } + ); + + logger.info(`[ProtocolExportService] Word 转换成功, 大小: ${response.data.length} bytes`); + return Buffer.from(response.data); + } catch (error) { + logger.error('[ProtocolExportService] Word 转换失败:', error); + throw new Error(`Word 转换失败: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * 导出研究方案为 Word + */ + async exportProtocol( + conversationId: string, + context: ContextData, + title?: string + ): Promise<{ + buffer: Buffer; + filename: string; + contentType: string; + }> { + // 1. 生成 Markdown + const markdown = this.generateProtocolMarkdown(context, title); + + // 2. 转换为 Word + const buffer = await this.convertToDocx(markdown); + + // 3. 生成文件名 + const timestamp = new Date().toISOString().slice(0, 10); + const filename = `研究方案_${timestamp}.docx`; + + return { + buffer, + filename, + contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }; + } +} + diff --git a/backend/src/modules/agent/protocol/services/ProtocolOrchestrator.ts b/backend/src/modules/agent/protocol/services/ProtocolOrchestrator.ts index c8ec0841..58ba5de2 100644 --- a/backend/src/modules/agent/protocol/services/ProtocolOrchestrator.ts +++ b/backend/src/modules/agent/protocol/services/ProtocolOrchestrator.ts @@ -212,51 +212,90 @@ export class ProtocolOrchestrator extends BaseAgentOrchestrator { ): Promise> { // 构建凝练 Prompt const condensePrompts: Record = { - scientific_question: `请将以下科学问题内容凝练成一句话(不超过50字),保留核心要点: + scientific_question: `请提取并整理以下科学问题的完整内容: 原始内容: ${JSON.stringify(data, null, 2)} 要求: -- 输出格式:{ "content": "一句话科学问题" } +- 保留完整的科学问题描述(100-200字) +- 包含研究背景、研究对象、研究目的 +- 输出格式:{ "content": "完整科学问题描述" } - 只输出 JSON,不要其他内容`, - pico: `请将以下 PICO 要素凝练成简短描述: + pico: `请提取并整理以下 PICO 要素的详细内容: 原始内容: ${JSON.stringify(data, null, 2)} 要求: -- 每个要素不超过20字 +- Population(研究人群):50-100字,包含纳入排除标准要点 +- Intervention(干预措施):50-100字,包含具体干预方法 +- Comparison(对照):50-100字,包含对照组设置 +- Outcome(结局指标):50-100字,包含主要和次要结局 - 输出格式:{ "population": "...", "intervention": "...", "comparison": "...", "outcome": "..." } - 只输出 JSON,不要其他内容`, - study_design: `请将以下研究设计凝练成关键标签: + study_design: `请提取并整理以下研究设计的详细内容: 原始内容: ${JSON.stringify(data, null, 2)} 要求: -- 输出格式:{ "studyType": "研究类型", "design": ["特征1", "特征2"] } +- 包含研究类型、设计特征、分组方法、盲法等 +- 输出格式:{ "studyType": "研究类型(如:前瞻性队列研究)", "design": ["设计特征1", "设计特征2", "..."], "details": "其他设计细节说明" } - 只输出 JSON,不要其他内容`, - sample_size: `请提取样本量关键数据: + sample_size: `请提取并整理以下样本量计算的完整内容: 原始内容: ${JSON.stringify(data, null, 2)} 要求: -- 输出格式:{ "sampleSize": 数字, "calculation": { "alpha": 数字, "power": 数字 } } +- 保留计算过程和参数依据 +- 输出格式: +{ + "sampleSize": 总样本量数字, + "calculation": { + "alpha": α值, + "power": 检验效能, + "effectSize": "效应值及来源说明" + }, + "rationale": "参数设定依据(如文献来源、预实验数据等)", + "groups": [ + { "name": "组名", "size": 人数 } + ], + "dropoutRate": "预计失访率及调整说明" +} - 只输出 JSON,不要其他内容`, - endpoints: `请将以下观察指标凝练成简短列表: + endpoints: `请提取并整理以下观察指标的详细内容: 原始内容: ${JSON.stringify(data, null, 2)} 要求: -- 每个指标不超过10字 -- 输出格式:{ "baseline": {...}, "exposure": {...}, "outcomes": {...}, "confounders": [...] } +- 保留完整的指标定义、测量方法和时间点 +- 输出格式: +{ + "baseline": { + "demographics": ["人口学特征1", "人口学特征2", "..."], + "clinicalHistory": ["临床病史1", "临床病史2", "..."] + }, + "exposure": { + "name": "暴露因素名称", + "definition": "暴露的具体定义", + "measurement": "测量方法", + "timing": "测量时间点" + }, + "outcomes": { + "primary": ["主要结局指标1(含定义)", "主要结局指标2(含定义)"], + "secondary": ["次要结局指标1(含定义)", "次要结局指标2(含定义)"], + "safety": ["安全性指标1", "安全性指标2"] + }, + "confounders": ["混杂因素1", "混杂因素2", "..."], + "followUp": "随访方案和时间节点" +} - 只输出 JSON,不要其他内容`, }; @@ -428,7 +467,7 @@ ${JSON.stringify(data, null, 2)} stageName: STAGE_NAMES[context.currentStage] || context.currentStage, progress: this.contextService.getProgress(context), stages: this.contextService.getStagesStatus(context), - canGenerate: this.contextService.isAllStagesCompleted(context), + canGenerate: this.contextService.canGenerateProtocol(context), }; } } diff --git a/backend/src/modules/agent/types/index.ts b/backend/src/modules/agent/types/index.ts index 0f26b2a7..a8dd55fb 100644 --- a/backend/src/modules/agent/types/index.ts +++ b/backend/src/modules/agent/types/index.ts @@ -380,3 +380,4 @@ export interface PromptRenderContext { } + diff --git a/backend/src/modules/aia/controllers/agentController.ts b/backend/src/modules/aia/controllers/agentController.ts index 1a16c8a8..cd7f8fc3 100644 --- a/backend/src/modules/aia/controllers/agentController.ts +++ b/backend/src/modules/aia/controllers/agentController.ts @@ -246,3 +246,4 @@ async function matchIntent(query: string): Promise<{ + diff --git a/backend/src/modules/aia/controllers/attachmentController.ts b/backend/src/modules/aia/controllers/attachmentController.ts index ae315f3d..cc782ddc 100644 --- a/backend/src/modules/aia/controllers/attachmentController.ts +++ b/backend/src/modules/aia/controllers/attachmentController.ts @@ -100,3 +100,4 @@ export async function uploadAttachment( + diff --git a/backend/src/modules/aia/index.ts b/backend/src/modules/aia/index.ts index 93cf32f6..e078bbf9 100644 --- a/backend/src/modules/aia/index.ts +++ b/backend/src/modules/aia/index.ts @@ -29,3 +29,4 @@ export { aiaRoutes }; + diff --git a/backend/src/modules/asl/fulltext-screening/__tests__/api-integration-test.ts b/backend/src/modules/asl/fulltext-screening/__tests__/api-integration-test.ts index 52ff3813..5bd23e68 100644 --- a/backend/src/modules/asl/fulltext-screening/__tests__/api-integration-test.ts +++ b/backend/src/modules/asl/fulltext-screening/__tests__/api-integration-test.ts @@ -371,5 +371,6 @@ runTests().catch((error) => { + diff --git a/backend/src/modules/asl/fulltext-screening/__tests__/e2e-real-test-v2.ts b/backend/src/modules/asl/fulltext-screening/__tests__/e2e-real-test-v2.ts index 9afe67f2..88c02b03 100644 --- a/backend/src/modules/asl/fulltext-screening/__tests__/e2e-real-test-v2.ts +++ b/backend/src/modules/asl/fulltext-screening/__tests__/e2e-real-test-v2.ts @@ -312,5 +312,6 @@ runTest() + diff --git a/backend/src/modules/asl/fulltext-screening/__tests__/fulltext-screening-api.http b/backend/src/modules/asl/fulltext-screening/__tests__/fulltext-screening-api.http index 35f5892b..684c35a9 100644 --- a/backend/src/modules/asl/fulltext-screening/__tests__/fulltext-screening-api.http +++ b/backend/src/modules/asl/fulltext-screening/__tests__/fulltext-screening-api.http @@ -350,5 +350,6 @@ Content-Type: application/json + diff --git a/backend/src/modules/dc/tool-b/services/ConflictDetectionService.ts b/backend/src/modules/dc/tool-b/services/ConflictDetectionService.ts index a037cb86..3f6a97df 100644 --- a/backend/src/modules/dc/tool-b/services/ConflictDetectionService.ts +++ b/backend/src/modules/dc/tool-b/services/ConflictDetectionService.ts @@ -286,5 +286,6 @@ export const conflictDetectionService = new ConflictDetectionService(); + diff --git a/backend/src/modules/dc/tool-c/README.md b/backend/src/modules/dc/tool-c/README.md index f60582f6..6ce73394 100644 --- a/backend/src/modules/dc/tool-c/README.md +++ b/backend/src/modules/dc/tool-c/README.md @@ -236,5 +236,6 @@ curl -X POST http://localhost:3000/api/v1/dc/tool-c/test/execute \ + diff --git a/backend/src/modules/dc/tool-c/controllers/StreamAIController.ts b/backend/src/modules/dc/tool-c/controllers/StreamAIController.ts index d6313279..abfe4dbb 100644 --- a/backend/src/modules/dc/tool-c/controllers/StreamAIController.ts +++ b/backend/src/modules/dc/tool-c/controllers/StreamAIController.ts @@ -290,5 +290,6 @@ export const streamAIController = new StreamAIController(); + diff --git a/backend/src/modules/iit-manager/agents/SessionMemory.ts b/backend/src/modules/iit-manager/agents/SessionMemory.ts index e97d5d1a..91171d98 100644 --- a/backend/src/modules/iit-manager/agents/SessionMemory.ts +++ b/backend/src/modules/iit-manager/agents/SessionMemory.ts @@ -199,5 +199,6 @@ logger.info('[SessionMemory] 会话记忆管理器已启动', { + diff --git a/backend/src/modules/iit-manager/check-iit-table-structure.ts b/backend/src/modules/iit-manager/check-iit-table-structure.ts index 9bfd11d1..09b6e865 100644 --- a/backend/src/modules/iit-manager/check-iit-table-structure.ts +++ b/backend/src/modules/iit-manager/check-iit-table-structure.ts @@ -133,5 +133,6 @@ checkTableStructure(); + diff --git a/backend/src/modules/iit-manager/check-project-config.ts b/backend/src/modules/iit-manager/check-project-config.ts index 566de5e8..7545039f 100644 --- a/backend/src/modules/iit-manager/check-project-config.ts +++ b/backend/src/modules/iit-manager/check-project-config.ts @@ -120,5 +120,6 @@ checkProjectConfig().catch(console.error); + diff --git a/backend/src/modules/iit-manager/check-test-project-in-db.ts b/backend/src/modules/iit-manager/check-test-project-in-db.ts index 553bf3b2..5836b3ae 100644 --- a/backend/src/modules/iit-manager/check-test-project-in-db.ts +++ b/backend/src/modules/iit-manager/check-test-project-in-db.ts @@ -102,5 +102,6 @@ main(); + diff --git a/backend/src/modules/iit-manager/docs/微信服务号接入指南.md b/backend/src/modules/iit-manager/docs/微信服务号接入指南.md index 48615f43..c36a6a1d 100644 --- a/backend/src/modules/iit-manager/docs/微信服务号接入指南.md +++ b/backend/src/modules/iit-manager/docs/微信服务号接入指南.md @@ -559,5 +559,6 @@ URL: https://iit.xunzhengyixue.com/api/v1/iit/patient-wechat/callback + diff --git a/backend/src/modules/iit-manager/generate-wechat-tokens.ts b/backend/src/modules/iit-manager/generate-wechat-tokens.ts index 319e5640..2e689485 100644 --- a/backend/src/modules/iit-manager/generate-wechat-tokens.ts +++ b/backend/src/modules/iit-manager/generate-wechat-tokens.ts @@ -194,5 +194,6 @@ console.log(''); + diff --git a/backend/src/modules/iit-manager/services/PatientWechatService.ts b/backend/src/modules/iit-manager/services/PatientWechatService.ts index a4d259ed..6afcfbdc 100644 --- a/backend/src/modules/iit-manager/services/PatientWechatService.ts +++ b/backend/src/modules/iit-manager/services/PatientWechatService.ts @@ -511,5 +511,6 @@ export const patientWechatService = new PatientWechatService(); + diff --git a/backend/src/modules/iit-manager/test-chatservice-dify.ts b/backend/src/modules/iit-manager/test-chatservice-dify.ts index f27b7a91..8ffffe5c 100644 --- a/backend/src/modules/iit-manager/test-chatservice-dify.ts +++ b/backend/src/modules/iit-manager/test-chatservice-dify.ts @@ -156,5 +156,6 @@ testDifyIntegration().catch(error => { + diff --git a/backend/src/modules/iit-manager/test-iit-database.ts b/backend/src/modules/iit-manager/test-iit-database.ts index 145cb277..e385f022 100644 --- a/backend/src/modules/iit-manager/test-iit-database.ts +++ b/backend/src/modules/iit-manager/test-iit-database.ts @@ -185,5 +185,6 @@ testIitDatabase() + diff --git a/backend/src/modules/iit-manager/test-patient-wechat-config.ts b/backend/src/modules/iit-manager/test-patient-wechat-config.ts index 54925543..4ebe6984 100644 --- a/backend/src/modules/iit-manager/test-patient-wechat-config.ts +++ b/backend/src/modules/iit-manager/test-patient-wechat-config.ts @@ -171,5 +171,6 @@ if (hasError) { + diff --git a/backend/src/modules/iit-manager/test-patient-wechat-url-verify.ts b/backend/src/modules/iit-manager/test-patient-wechat-url-verify.ts index 71b7a7f8..2e2b4749 100644 --- a/backend/src/modules/iit-manager/test-patient-wechat-url-verify.ts +++ b/backend/src/modules/iit-manager/test-patient-wechat-url-verify.ts @@ -197,5 +197,6 @@ async function testUrlVerification() { + diff --git a/backend/src/modules/iit-manager/test-redcap-query-from-db.ts b/backend/src/modules/iit-manager/test-redcap-query-from-db.ts index 02170602..f4e8931d 100644 --- a/backend/src/modules/iit-manager/test-redcap-query-from-db.ts +++ b/backend/src/modules/iit-manager/test-redcap-query-from-db.ts @@ -278,5 +278,6 @@ main().catch((error) => { + diff --git a/backend/src/modules/iit-manager/test-wechat-mp-local.ps1 b/backend/src/modules/iit-manager/test-wechat-mp-local.ps1 index 8341b983..e55a077a 100644 --- a/backend/src/modules/iit-manager/test-wechat-mp-local.ps1 +++ b/backend/src/modules/iit-manager/test-wechat-mp-local.ps1 @@ -162,5 +162,6 @@ Write-Host "" + diff --git a/backend/src/modules/iit-manager/types/index.ts b/backend/src/modules/iit-manager/types/index.ts index 8946d660..d338c737 100644 --- a/backend/src/modules/iit-manager/types/index.ts +++ b/backend/src/modules/iit-manager/types/index.ts @@ -255,5 +255,6 @@ export interface CachedProtocolRules { + diff --git a/backend/src/modules/pkb/routes/health.ts b/backend/src/modules/pkb/routes/health.ts index 4ddc33a1..a32398f1 100644 --- a/backend/src/modules/pkb/routes/health.ts +++ b/backend/src/modules/pkb/routes/health.ts @@ -68,5 +68,6 @@ export default async function healthRoutes(fastify: FastifyInstance) { + diff --git a/backend/src/modules/rvw/__tests__/api.http b/backend/src/modules/rvw/__tests__/api.http index 6d26e9a2..652c6e6d 100644 --- a/backend/src/modules/rvw/__tests__/api.http +++ b/backend/src/modules/rvw/__tests__/api.http @@ -146,5 +146,6 @@ Content-Type: application/json + diff --git a/backend/src/modules/rvw/__tests__/test-api.ps1 b/backend/src/modules/rvw/__tests__/test-api.ps1 index 6839a556..4f1a8532 100644 --- a/backend/src/modules/rvw/__tests__/test-api.ps1 +++ b/backend/src/modules/rvw/__tests__/test-api.ps1 @@ -131,5 +131,6 @@ Write-Host " - 删除任务: DELETE $BaseUrl/api/v1/rvw/tasks/{taskId}" -Foregr + diff --git a/backend/src/modules/rvw/index.ts b/backend/src/modules/rvw/index.ts index 03b894b6..14a57b1b 100644 --- a/backend/src/modules/rvw/index.ts +++ b/backend/src/modules/rvw/index.ts @@ -45,5 +45,6 @@ export * from './services/utils.js'; + diff --git a/backend/src/modules/rvw/services/utils.ts b/backend/src/modules/rvw/services/utils.ts index 6b76d956..3767e6a1 100644 --- a/backend/src/modules/rvw/services/utils.ts +++ b/backend/src/modules/rvw/services/utils.ts @@ -136,5 +136,6 @@ export function validateAgentSelection(agents: string[]): void { + diff --git a/backend/src/tests/README.md b/backend/src/tests/README.md index 65d79af9..b65a63b7 100644 --- a/backend/src/tests/README.md +++ b/backend/src/tests/README.md @@ -436,5 +436,6 @@ SET session_replication_role = 'origin'; + diff --git a/backend/src/tests/test-cross-language-search.ts b/backend/src/tests/test-cross-language-search.ts index f3b081c7..94ca6110 100644 --- a/backend/src/tests/test-cross-language-search.ts +++ b/backend/src/tests/test-cross-language-search.ts @@ -116,3 +116,4 @@ testCrossLanguageSearch(); + diff --git a/backend/src/tests/test-query-rewrite.ts b/backend/src/tests/test-query-rewrite.ts index 7e5a57ff..f32763bd 100644 --- a/backend/src/tests/test-query-rewrite.ts +++ b/backend/src/tests/test-query-rewrite.ts @@ -178,3 +178,4 @@ testQueryRewrite(); + diff --git a/backend/src/tests/test-rerank.ts b/backend/src/tests/test-rerank.ts index ba880026..aa56666d 100644 --- a/backend/src/tests/test-rerank.ts +++ b/backend/src/tests/test-rerank.ts @@ -124,3 +124,4 @@ testRerank(); + diff --git a/backend/src/tests/verify-test1-database.sql b/backend/src/tests/verify-test1-database.sql index f5229e6e..ded07525 100644 --- a/backend/src/tests/verify-test1-database.sql +++ b/backend/src/tests/verify-test1-database.sql @@ -138,5 +138,6 @@ WHERE key = 'verify_test'; + diff --git a/backend/src/tests/verify-test1-database.ts b/backend/src/tests/verify-test1-database.ts index b9206e9a..9c50ed5b 100644 --- a/backend/src/tests/verify-test1-database.ts +++ b/backend/src/tests/verify-test1-database.ts @@ -281,5 +281,6 @@ verifyDatabase() + diff --git a/backend/src/types/global.d.ts b/backend/src/types/global.d.ts index 69508978..ab6e697e 100644 --- a/backend/src/types/global.d.ts +++ b/backend/src/types/global.d.ts @@ -71,5 +71,6 @@ export {} + diff --git a/backend/sync-dc-database.ps1 b/backend/sync-dc-database.ps1 index e3c1b96a..e81a513b 100644 --- a/backend/sync-dc-database.ps1 +++ b/backend/sync-dc-database.ps1 @@ -94,5 +94,6 @@ Write-Host "✅ 完成!" -ForegroundColor Green + diff --git a/backend/temp_check.sql b/backend/temp_check.sql index 19602e65..0ca7f7cc 100644 --- a/backend/temp_check.sql +++ b/backend/temp_check.sql @@ -19,5 +19,6 @@ SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('p + diff --git a/backend/test-pkb-migration.http b/backend/test-pkb-migration.http index 8c43c490..f831f3a4 100644 --- a/backend/test-pkb-migration.http +++ b/backend/test-pkb-migration.http @@ -182,5 +182,6 @@ DELETE {{baseUrl}}/api/v1/pkb/knowledge/knowledge-bases/{{testKbId}} + diff --git a/backend/test-tool-c-advanced-scenarios.mjs b/backend/test-tool-c-advanced-scenarios.mjs index 72eb2f99..9b97ab26 100644 --- a/backend/test-tool-c-advanced-scenarios.mjs +++ b/backend/test-tool-c-advanced-scenarios.mjs @@ -381,5 +381,6 @@ runAdvancedTests().catch(error => { + diff --git a/backend/test-tool-c-day2.mjs b/backend/test-tool-c-day2.mjs index fb6c0fd0..045c5426 100644 --- a/backend/test-tool-c-day2.mjs +++ b/backend/test-tool-c-day2.mjs @@ -447,5 +447,6 @@ runAllTests() + diff --git a/backend/test-tool-c-day3.mjs b/backend/test-tool-c-day3.mjs index 5582c1fd..a326a3c1 100644 --- a/backend/test-tool-c-day3.mjs +++ b/backend/test-tool-c-day3.mjs @@ -405,5 +405,6 @@ runAllTests() + diff --git a/backend/verify_all_users.ts b/backend/verify_all_users.ts index 36fb56e6..756cac7a 100644 --- a/backend/verify_all_users.ts +++ b/backend/verify_all_users.ts @@ -39,5 +39,6 @@ main() + diff --git a/backend/verify_functions.ts b/backend/verify_functions.ts index d0cacaee..30810383 100644 --- a/backend/verify_functions.ts +++ b/backend/verify_functions.ts @@ -37,5 +37,6 @@ main() + diff --git a/backend/verify_job_common.ts b/backend/verify_job_common.ts index 88d4f93f..81dbe7ae 100644 --- a/backend/verify_job_common.ts +++ b/backend/verify_job_common.ts @@ -49,5 +49,6 @@ main() + diff --git a/backend/verify_mock_user.ts b/backend/verify_mock_user.ts index a4be222c..261d04a9 100644 --- a/backend/verify_mock_user.ts +++ b/backend/verify_mock_user.ts @@ -38,5 +38,6 @@ main() + diff --git a/backend/verify_system.ts b/backend/verify_system.ts index c0815dca..0170f76b 100644 --- a/backend/verify_system.ts +++ b/backend/verify_system.ts @@ -178,5 +178,6 @@ main() + diff --git a/deploy-to-sae.ps1 b/deploy-to-sae.ps1 index cdbd7412..ecedca62 100644 --- a/deploy-to-sae.ps1 +++ b/deploy-to-sae.ps1 @@ -189,5 +189,6 @@ Set-Location .. + diff --git a/docs/00-系统总体设计/00-系统当前状态与开发指南.md b/docs/00-系统总体设计/00-系统当前状态与开发指南.md index 09e08cd8..13186543 100644 --- a/docs/00-系统总体设计/00-系统当前状态与开发指南.md +++ b/docs/00-系统总体设计/00-系统当前状态与开发指南.md @@ -1,20 +1,21 @@ # AIclinicalresearch 系统当前状态与开发指南 -> **文档版本:** v4.2 +> **文档版本:** v4.3 > **创建日期:** 2025-11-28 > **维护者:** 开发团队 -> **最后更新:** 2026-01-24 +> **最后更新:** 2026-01-25 > **🎉 重大里程碑:** -> - **2026-01-24:Protocol Agent MVP完成!** 可复用Agent框架+5阶段对话流程 +> - **2026-01-25:Protocol Agent MVP完整交付!** 一键生成研究方案+Word导出 +> - **2026-01-24:Protocol Agent 框架完成!** 可复用Agent框架+5阶段对话流程 > - **2026-01-22:OSS 存储集成完成!** 阿里云 OSS 正式接入平台基础层 > - **2026-01-21:成功替换 Dify!** PKB 模块完全使用自研 pgvector RAG 引擎 > -> **最新进展(Protocol Agent):** -> - ✅ **通用Agent框架**:Query→Planner→Executor→Tools→Reflection 五层架构 -> - ✅ **8张数据库表**:agent_schema(6) + protocol_schema(2) -> - ✅ **5阶段对话流程**:科学问题→PICO→研究设计→样本量→观察指标 -> - ✅ **前端完整实现**:Gemini折叠侧边栏+状态面板+100%原型图还原 -> - ⏳ **待联调测试**:前后端集成调试 +> **最新进展(Protocol Agent MVP 完整交付 2026-01-25):** +> - ✅ **一键生成研究方案**:流式输出+A4预览+12章节结构 +> - ✅ **Word文档导出**:Pandoc转换,格式完美 +> - ✅ **动态双面板布局**:可拖拽调整,收集65:35/生成35:65 +> - ✅ **用户体验优化**:折叠展开、延迟创建、滚动跟随 +> - ✅ **代码总量**:~8,500行(前端3,300+后端4,700+Python500) > > **部署状态:** ✅ 生产环境运行中 | 公网地址:http://8.140.53.236/ > **文档目的:** 快速了解系统当前状态,为新AI助手提供上下文 @@ -49,7 +50,7 @@ | 模块代号 | 模块名称 | 核心功能 | 商业价值 | 当前状态 | 优先级 | |---------|---------|---------|---------|---------|--------| -| **AIA** | AI智能问答 | 12个智能体 + Protocol Agent(全流程方案) | ⭐⭐⭐⭐⭐ | 🎉 **V3.0 MVP完成(75%)** - Agent框架+5阶段流程 | **P0** | +| **AIA** | AI智能问答 | 12个智能体 + Protocol Agent(全流程方案) | ⭐⭐⭐⭐⭐ | 🎉 **V3.1 MVP完整交付(90%)** - 一键生成+Word导出 | **P0** | | **PKB** | 个人知识库 | RAG问答、私人文献库 | ⭐⭐⭐ | 🎉 **Dify已替换!自研RAG上线(95%)** | P1 | | **ASL** | AI智能文献 | 文献筛选、Meta分析、证据图谱 | ⭐⭐⭐⭐⭐ | 🎉 **智能检索MVP完成(60%)** - DeepSearch集成 | **P0** | | **DC** | 数据清洗整理 | ETL + 医学NER(百万行级数据) | ⭐⭐⭐⭐⭐ | ✅ **Tool B完成 + Tool C 99%(异步架构+性能优化-99%+多指标转换+7大功能)** | **P0** | @@ -137,9 +138,39 @@ --- -## 🚀 当前开发状态(2026-01-22) +## 🚀 当前开发状态(2026-01-25) -### 🆕 最新进展:OSS 存储集成完成(2026-01-22) +### 🎉 最新进展:Protocol Agent MVP 完整交付(2026-01-25) + +#### ✅ 一键生成研究方案 + Word 导出 + +**重大里程碑**: +- 🎉 **MVP 完整可用**:从关键要素收集到完整研究方案输出的全流程 +- 🎉 **Word 导出**:Pandoc 转换,格式完美,支持自定义模板 +- 🎉 **代码量**:累计 ~8,500 行(前端3,300+后端4,700+Python500) + +**核心功能**: +| 功能 | 实现 | 状态 | +|------|------|------| +| 一键生成研究方案 | 流式输出+A4预览+12章节 | ✅ | +| Word文档导出 | pypandoc + Pandoc | ✅ | +| 动态双面板布局 | ResizableSplitPane | ✅ | +| 研究摘要展示 | CollapsibleContent 折叠/展开 | ✅ | +| 延迟创建对话 | 类ChatGPT体验 | ✅ | +| 对话历史管理 | 保存+加载+标题更新 | ✅ | + +**技术亮点**: +- **无编辑器方案**:对话生成→Markdown预览→Pandoc导出,开发快速 +- **Prompt工程**:阶段约束+数据凝练放宽,避免模型混乱 +- **流式渲染**:SSE + 自定义Markdown组件,打字机效果 + +**相关文档**: +- 开发记录:`docs/03-业务模块/AIA-AI智能问答/06-开发记录/2026-01-25-Protocol_Agent_MVP完整交付.md` +- 开发计划V2:`docs/03-业务模块/AIA-AI智能问答/04-开发计划/06-一键生成研究方案开发计划V2.md` + +--- + +### 🆕 OSS 存储集成完成(2026-01-22) #### ✅ 阿里云 OSS 正式接入平台基础层 diff --git a/docs/01-平台基础层/02-存储服务/OSS账号与配置信息.md b/docs/01-平台基础层/02-存储服务/OSS账号与配置信息.md index ce485c59..48574264 100644 --- a/docs/01-平台基础层/02-存储服务/OSS账号与配置信息.md +++ b/docs/01-平台基础层/02-存储服务/OSS账号与配置信息.md @@ -166,3 +166,4 @@ npm run dev + diff --git a/docs/01-平台基础层/02-存储服务/OSS集成开发记录-2026-01-22.md b/docs/01-平台基础层/02-存储服务/OSS集成开发记录-2026-01-22.md index 0c0b593e..e7cbe271 100644 --- a/docs/01-平台基础层/02-存储服务/OSS集成开发记录-2026-01-22.md +++ b/docs/01-平台基础层/02-存储服务/OSS集成开发记录-2026-01-22.md @@ -193,3 +193,4 @@ OSS_INTERNAL=false # 本地开发用公网,生产用内网 + diff --git a/docs/02-通用能力层/快速引用卡片.md b/docs/02-通用能力层/快速引用卡片.md index 3ad1a47d..113c46be 100644 --- a/docs/02-通用能力层/快速引用卡片.md +++ b/docs/02-通用能力层/快速引用卡片.md @@ -241,3 +241,4 @@ const userId = 'test'; // ❌ 应该用 getUserId(request) + diff --git a/docs/02-通用能力层/通用能力层技术债务清单.md b/docs/02-通用能力层/通用能力层技术债务清单.md index 910c1b7b..45d22086 100644 --- a/docs/02-通用能力层/通用能力层技术债务清单.md +++ b/docs/02-通用能力层/通用能力层技术债务清单.md @@ -815,5 +815,6 @@ export const AsyncProgressBar: React.FC = ({ + diff --git a/docs/03-业务模块/ADMIN-运营管理端/00-Phase3.5完成总结.md b/docs/03-业务模块/ADMIN-运营管理端/00-Phase3.5完成总结.md index bd0be381..0c974bac 100644 --- a/docs/03-业务模块/ADMIN-运营管理端/00-Phase3.5完成总结.md +++ b/docs/03-业务模块/ADMIN-运营管理端/00-Phase3.5完成总结.md @@ -310,3 +310,4 @@ Level 3: 兜底Prompt(缓存也失效) + diff --git a/docs/03-业务模块/ADMIN-运营管理端/00-系统设计/运营体系设计方案-MVP-V3.0.md b/docs/03-业务模块/ADMIN-运营管理端/00-系统设计/运营体系设计方案-MVP-V3.0.md new file mode 100644 index 00000000..b159804e --- /dev/null +++ b/docs/03-业务模块/ADMIN-运营管理端/00-系统设计/运营体系设计方案-MVP-V3.0.md @@ -0,0 +1,293 @@ +# **AI 临床科研平台 \- MVP 运营监控体系实施方案 V3.0** + +**文档版本**:V3.0 (全景洞察版) + +**面向对象**:核心开发团队 (2人) + +**更新重点**: + +1. **宏观**:确立 DAU/DAT 双核心指标,防止“假活跃”。 +2. **微观**:新增 **“用户 360 全景画像”**,支持查看单用户的资产存量与行为流水。 +3. **技术**:维持 Postgres-Only 极简架构,不引入新组件。 + +## **1\. 核心理念:关注“生存”与“真实价值”** + +在 MVP 阶段,我们必须警惕“只有管理员登录”的假象。**医生用起来,才是真的活了。** + +| 优先级 | 指标名称 | 定义 | 为什么关注? | +| :---- | :---- | :---- | :---- | +| **P0+** | **活跃医生数 (DAU)** | 今日有登录/使用行为的去重 user\_id 数。 | **真实价值线**。只有医生个人觉得好用,产品才有生命力。这是防止客户流失的前哨指标。 | +| **P0** | **活跃租户数 (DAT)** | 今日有登录行为的去重 tenant\_id 数。 | **商务生死线**。代表医院客户的存活情况。 | +| **P1** | **具体功能渗透率** | 例如:“选题助手”的使用次数 vs “论文润色”的使用次数。 | **产品迭代指引**。如果没人用“润色”,我们就别在这个功能上浪费时间开发了。 | +| **P2** | **价值交付次数** | 用户点击 **“导出/下载”** 的次数。 | **北极星指标**。用户只有认可了 AI 的结果才会导出。这是交付价值的终点。 | + +## **2\. 技术架构:Postgres-Only 极简方案** + +### **2.1 数据库设计 (Schema)** + +在 admin\_schema 下新增一张通用日志表。**不需要**做聚合表,**不需要**做定时统计任务,直接查表即可。 + +**文件路径**:backend/prisma/schema.prisma + +// ... inside admin\_schema ... + +/// 极简运营日志表 (MVP) +model SimpleLog { + id String @id @default(dbgenerated("gen\_random\_uuid()")) @db.Uuid + createdAt DateTime @default(now()) @map("created\_at") + + tenantId String @map("tenant\_id") @db.VarChar(50) + userId String @map("user\_id") @db.Uuid + userName String? @map("user\_name") @db.VarChar(50) + + // V3.0 核心字段设计 + module String @db.VarChar(20) // 大模块: 'AIA', 'ASL', 'DC', 'PKB' + feature String @db.VarChar(50) // 细分功能: 'Topic Agent', 'Tool C', 'RAG Chat' + action String @db.VarChar(20) // 动作: 'USE', 'EXPORT', 'ERROR', 'LOGIN' + + info String? @db.Text // 详情: "生成了3个选题" / "导出Excel" + + @@index(\[createdAt\]) + @@index(\[tenantId\]) + @@index(\[userId\]) // V3.0 新增:支持查询单用户轨迹 + @@index(\[module, feature\]) // 支持按功能统计热度 + @@map("simple\_logs") + @@schema("admin\_schema") +} + +### **2.2 后端服务实现 (ActivityService)** + +负责写入日志和获取大盘数据。 + +**文件路径**:backend/src/common/services/activity.service.ts + +import { PrismaClient } from '@prisma/client'; +// 假设这是全局 Prisma 实例 +import { prisma } from '@/common/database/prisma'; + +export const activityService \= { + /\*\* + \* 核心埋点方法 (Fire-and-Forget 模式) + \* @param feature 具体功能名称,如 'Topic Agent' + \*/ + async log( + tenantId: string, + userId: string, + userName: string, + action: 'LOGIN' | 'USE' | 'EXPORT' | 'ERROR', + module: 'AIA' | 'ASL' | 'DC' | 'PKB' | 'SYSTEM', + feature: string, // 必填:细分功能 + info: any + ) { + // 异步执行,不要 await,避免影响主接口性能 + prisma.simpleLog.create({ + data: { + tenantId, userId, userName, action, module, feature, + info: typeof info \=== 'object' ? JSON.stringify(info) : String(info), + } + }).catch(e \=\> console.error('埋点写入失败(可忽略):', e)); + }, + + /\*\* + \* 运营看板:获取实时流水账 + \*/ + async getLiveFeed(limit \= 100\) { + return prisma.simpleLog.findMany({ + orderBy: { createdAt: 'desc' }, + take: limit + }); + }, + + /\*\* + \* 🟢 获取今日核心大盘数据 (DAU \+ DAT) + \* 用于 Admin 首页顶部展示 + \*/ + async getTodayOverview() { + const todayStart \= new Date(); + todayStart.setHours(0,0,0,0); + + const stats \= await prisma.$queryRaw\` + SELECT + COUNT(DISTINCT user\_id) as dau, + COUNT(DISTINCT tenant\_id) as dat, + COUNT(CASE WHEN action \= 'EXPORT' THEN 1 END) as export\_count + FROM admin\_schema.simple\_logs + WHERE created\_at \>= ${todayStart} + \`; + + return { + dau: Number(stats\[0\].dau), + dat: Number(stats\[0\].dat), + exportCount: Number(stats\[0\].export\_count) + }; + } +}; + +## **3\. 细粒度埋点实施清单 (直接复制给开发)** + +为了满足运营需求,请在以下位置埋点,并务必带上 feature 参数。 + +### **🤖 3.1 AIA 模块 (12个智能体细分)** + +**位置**:aia/services/chat.service.ts \-\> complete 方法 (AI回复完成时) + +**逻辑**:根据当前对话的 agentId 记录不同的 feature。 + +// 示例映射表 +const agentNameMap \= { + 'topic-scoping': '科学问题梳理', + 'pico-analysis': 'PICO梳理', + 'topic-eval': '选题评价', + 'outcome-design': '观察指标设计', + 'crf-design': 'CRF设计', + 'sample-size': '样本量计算', + 'protocol-writing': '方案撰写', + 'methodology-review': '方法学评审', + 'paper-polish': '论文润色', + 'paper-translate': '论文翻译', + // ... 其他智能体 +}; +const feature \= agentNameMap\[agentId\] || 'Unknown Agent'; +activityService.log(..., 'USE', 'AIA', feature, '生成完成'); + +### **🧹 3.2 DC 模块 (清洗工具细分)** + +**位置**:dc/controllers/extraction.controller.ts + +* **Tool B (自动提取)**: log(..., 'USE', 'DC', 'Tool B', '提取任务: 50条') +* **Tool C (数据清洗)**: log(..., 'USE', 'DC', 'Tool C', '执行清洗操作') +* **导出结果**: log(..., 'EXPORT', 'DC', 'Tool C', '导出 Excel') + +### **📚 3.3 ASL 模块 (文献功能细分)** + +**位置**:asl/controllers/research.controller.ts + +* **智能检索**: log(..., 'USE', 'ASL', 'DeepSearch', '关键词: 肺癌') +* **标题摘要筛选**: log(..., 'USE', 'ASL', 'Title/Abstract Screening', '筛选进度: 100/500') +* **全文复筛**: log(..., 'USE', 'ASL', 'Fulltext Review', '复筛完成') + +### **🧠 3.4 PKB 模块 (知识库行为)** + +**位置**:pkb/controllers/... + +* **上传文档**: log(..., 'USE', 'PKB', 'Document Upload', '上传: 3篇') +* **RAG 问答**: log(..., 'USE', 'PKB', 'RAG Chat', '提问: 入排标准是什么?') +* **批处理**: log(..., 'USE', 'PKB', 'Batch Process', '批量提取: 10篇') + +## **4\. 运营看板设计 (Admin Portal) \- 宏观** + +### **界面参考** + +在 Admin 首页展示。 + +#### **\[ 顶部卡片区域 \]** + +| 今日活跃医生 (DAU) | 今日活跃医院 (DAT) | 今日价值交付 (导出) | +| :---- | :---- | :---- | +| **12** 👨‍⚕️ | **3** 🏥 | **5** 🟢 | + +#### **\[ 实时流水账区域 \]** + +| 时间 | 医院 | 医生 | 模块 | 具体功能 | 动作 | 详情 | +| :---- | :---- | :---- | :---- | :---- | :---- | :---- | +| 10:05 | 协和 | 张主任 | **AIA** | **选题评价** | 🔵 USE | 评价得分: 85分 | +| 10:03 | 协和 | 张主任 | **AIA** | **PICO梳理** | 🔵 USE | 梳理完成 | +| 09:55 | 华西 | 李医生 | **DC** | **Tool C** | 🟢 **EXPORT** | 导出 Excel | + +## **5\. 用户 360 全景画像 (User 360 View) \- 微观 🆕** + +**新增模块**:当运营人员点击用户列表中的“详情”时,展示该用户的全生命周期数据。 + +### **5.1 后端聚合服务 (UserOverviewService)** + +不建立宽表,利用 Prisma 并行查询实现\*\*“资产快照 \+ 行为流水”\*\*聚合。 + +**文件路径**:backend/src/modules/admin/services/user-overview.service.ts + +import { prisma } from '@/common/database/prisma'; + +export const userOverviewService \= { + async getUserProfile(userId: string) { + // 并行查询,耗时取决于最慢的那个查询,通常 \< 200ms + const \[user, aiaStats, kbs, dcStats, logs\] \= await Promise.all(\[ + // 1\. 基础信息 + prisma.user.findUnique({ where: { id: userId } }), + + // 2\. AIA 资产 (会话数) + prisma.conversation.count({ where: { userId, isDeleted: false } }), + + // 3\. PKB 资产 (知识库数 \+ 文档数) + prisma.knowledgeBase.findMany({ + where: { userId }, + include: { \_count: { select: { documents: true } } } + }), + + // 4\. DC 资产 (任务数) + prisma.extractionTask.count({ where: { userId } }), + + // 5\. 最近行为 (从 SimpleLog 查最近 20 条) + prisma.simpleLog.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: 20 + }) + \]); + + // 计算文档总数 + const totalDocs \= kbs.reduce((sum, kb) \=\> sum \+ kb.\_count.documents, 0); + + return { + profile: user, + assets: { + aia: { conversationCount: aiaStats }, + pkb: { kbCount: kbs.length, docCount: totalDocs, kbs }, + dc: { taskCount: dcStats } + }, + activities: logs + }; + } +}; + +### **5.2 前端布局设计 (UserDetailPage)** + +使用 Ant Design 组件库实现三段式布局。 + +#### **区域一:资产数字 (Asset Stats)** + +| 💬 AIA 对话 | 📚 PKB 知识库 | 📄 上传文献 | 🧹 DC 清洗任务 | +| :---- | :---- | :---- | :---- | +| **158** 次 | **3** 个 | **450** 篇 | **12** 次 | + +*(点击数字可展开查看具体列表,如查看 3 个知识库的名称)* + +#### **区域二:行为时间轴 (Timeline)** + +展示用户最近的操作轨迹,快速判断用户是否遇到困难。 + +* **10:30** \[AIA\] 使用了 **“选题评价”** (生成结果: 85分) +* **10:15** \[PKB\] 上传了文档 lung\_cancer\_study.pdf 到 **“肺癌课题库”** +* **09:50** \[DC\] 导出了 **Tool C** 清洗结果 (Excel) +* **09:48** \[系统\] 登录系统 + +## **6\. 每日自动化巡检 (SAE Job)** + +复用后端镜像,使用 SAE Job 功能,每天 06:00 执行。 + +* **检查项**:数据库连接、Python 微服务心跳、公网连通性 (NAT)。 +* **通知**:检查失败时发送企业微信报警。 +* **目的**:确保早上醒来时系统是健康的。 + +## **7\. 开发执行计划** + +**总耗时预计:3-4 小时** + +1. **\[DB\] (15min)**: + * 更新 Prisma Schema (新增 feature 字段和索引)。 + * 执行 prisma db push。 +2. **\[Backend\] (60min)**: + * 实现 activityService (埋点 \+ 大盘)。 + * 实现 userOverviewService (360 聚合)。 + * 在 AIA/DC/ASL/PKB 核心 Controller 插入 log() 代码。 +3. **\[Frontend\] (90min)**: + * Admin 首页:实现大盘卡片 \+ 实时流水表格。 + * User 详情页:实现资产统计卡片 \+ 行为 Timeline。 \ No newline at end of file diff --git a/docs/03-业务模块/ADMIN-运营管理端/06-开发记录/2026-01-16_用户管理功能与模块权限系统完成.md b/docs/03-业务模块/ADMIN-运营管理端/06-开发记录/2026-01-16_用户管理功能与模块权限系统完成.md index 7e2fb990..ac35ca24 100644 --- a/docs/03-业务模块/ADMIN-运营管理端/06-开发记录/2026-01-16_用户管理功能与模块权限系统完成.md +++ b/docs/03-业务模块/ADMIN-运营管理端/06-开发记录/2026-01-16_用户管理功能与模块权限系统完成.md @@ -496,3 +496,4 @@ const pageSize = Number(query.pageSize) || 20; + diff --git a/docs/03-业务模块/ADMIN-运营管理端/README.md b/docs/03-业务模块/ADMIN-运营管理端/README.md index 6ec767e8..f7119d89 100644 --- a/docs/03-业务模块/ADMIN-运营管理端/README.md +++ b/docs/03-业务模块/ADMIN-运营管理端/README.md @@ -230,3 +230,4 @@ ADMIN-运营管理端/ + diff --git a/docs/03-业务模块/ADMIN运营与INST机构管理端-文档体系建立完成.md b/docs/03-业务模块/ADMIN运营与INST机构管理端-文档体系建立完成.md index c84df197..4220ab32 100644 --- a/docs/03-业务模块/ADMIN运营与INST机构管理端-文档体系建立完成.md +++ b/docs/03-业务模块/ADMIN运营与INST机构管理端-文档体系建立完成.md @@ -329,3 +329,4 @@ INST-机构管理端/ + diff --git a/docs/03-业务模块/AIA-AI智能问答/00-模块当前状态与开发指南.md b/docs/03-业务模块/AIA-AI智能问答/00-模块当前状态与开发指南.md index 73f11582..e1fef71b 100644 --- a/docs/03-业务模块/AIA-AI智能问答/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/AIA-AI智能问答/00-模块当前状态与开发指南.md @@ -1,16 +1,16 @@ # AIA AI智能问答模块 - 当前状态与开发指南 -> **文档版本:** v3.0 +> **文档版本:** v3.1 > **创建日期:** 2026-01-14 > **维护者:** AIA模块开发团队 -> **最后更新:** 2026-01-24 🎉 **V3.0版本发布 - Protocol Agent MVP完成** +> **最后更新:** 2026-01-25 🎉 **V3.1版本发布 - Protocol Agent MVP完整交付** > **重大里程碑:** > - 🏆 通用流式响应服务(OpenAI Compatible) > - 🎨 现代感UI(100%还原原型图V11) > - 🚀 Ant Design X 深度集成 > - ✨ 12个智能体配置完成 > - 🆕 Prompt管理系统集成(灰度预览、版本管理) -> - 🎉 **Protocol Agent MVP完成(可复用Agent框架+5阶段对话流程)** +> - 🎉 **Protocol Agent MVP完整交付(一键生成研究方案+Word导出)** --- @@ -42,9 +42,9 @@ AIA(AI Intelligent Assistant)模块提供覆盖临床研究全生命周期 ### 当前状态 -- **开发阶段:** ✅ **V3.0 Protocol Agent MVP完成** -- **架构版本:** V3.0(通用Agent框架 + Protocol Agent) -- **完成度:** 75%(MVP核心流程完成,待前后端联调) +- **开发阶段:** 🎉 **V3.1 Protocol Agent MVP完整交付** +- **架构版本:** V3.1(通用Agent框架 + Protocol Agent + 一键生成) +- **完成度:** 90%(MVP完整可用,待生产测试) ### ✅ V2.1 新增功能(2026-01-18) @@ -85,7 +85,40 @@ AIA(AI Intelligent Assistant)模块提供覆盖临床研究全生命周期 - [x] 前端:三栏布局,5阶段状态面板,100%还原原型图 - [x] 5阶段流程:科学问题→PICO→研究设计→样本量→观察指标 -**待完成:** 前后端联调、一键生成、Word导出 +--- + +### 🎉 V3.1 Protocol Agent 完整交付(2026-01-25) + +**一键生成研究方案:** +- [x] 动态双面板布局(ResizableSplitPane,可拖拽调整) +- [x] 视图切换(研究摘要 / 完整方案) +- [x] A4 纸张预览效果(DocumentPanel) +- [x] 流式生成 + Markdown 渲染 + 滚动跟随 +- [x] 12章节完整临床研究方案结构 + +**Word 文档导出:** +- [x] Python 微服务(pypandoc + Pandoc) +- [x] Node.js API 端点(/export/docx) +- [x] 前端一键下载 + +**用户体验优化:** +- [x] StatePanel 折叠/展开(CollapsibleContent) +- [x] 科学问题/PICO/样本量/观察指标完整显示 +- [x] 延迟创建对话(避免空记录) +- [x] 对话标题自动更新 +- [x] Prompt 工程优化(阶段约束、数据凝练放宽) + +**Bug 修复:** +- [x] 滚动条显示问题(flex min-height: 0) +- [x] 模型阶段混乱问题(Prompt 增强) +- [x] 数据类型错误(toArray 辅助函数) +- [x] 顶部标题两行、欢迎语过大、列表编号错误 + +**代码统计:** +- 前端新增:~1,200行(累计~3,300行) +- 后端新增:~400行(累计~4,700行) +- Python新增:~110行 +- **总计:~8,500行** --- diff --git a/docs/03-业务模块/AIA-AI智能问答/00-系统设计/UI_Layout_Ratio_Analysis.md b/docs/03-业务模块/AIA-AI智能问答/00-系统设计/UI_Layout_Ratio_Analysis.md new file mode 100644 index 00000000..0af7b453 --- /dev/null +++ b/docs/03-业务模块/AIA-AI智能问答/00-系统设计/UI_Layout_Ratio_Analysis.md @@ -0,0 +1,50 @@ +# **UI 布局深度分析:Chat vs. Document 比例问题** + +**核心冲突:** + +* **Chat**: 主要是控制台,指令短,但历史记录长。 +* **Document**: 是交付物,内容宽,需要沉浸式阅读。 +* **Context Panel**: 是辅助信息,卡片式,不需要太宽。 + +## **1\. 为什么“固定比例”不是最优解?** + +如果强行统一比例,两头都不讨好: + +* **如果统一为 70% (Chat) : 30% (Right)** + * *问题*: 右侧只能放 PICO 卡片。一旦开始生成文档,A4 纸被挤成“长条”,用户必须横向滚动或者字变得极小,根本没法阅读。 +* **如果统一为 40% (Chat) : 60% (Right)** + * *问题*: 在前期的 PICO 收集阶段,右侧面板(只有几个卡片)会留出大片空白,显得界面空旷、重心失衡。 + +## **2\. 推荐方案:动态自适应布局 (Dynamic Split View)** + +我们要根据 **“用户当前的注意力焦点”** 自动调整比例。 + +### **阶段 A:要素收集期 (Focus on Chat)** + +* **状态**:AI 在问,用户在答。右侧只是辅助展示“已提取的 PICO”。 +* **比例**:**Chat 65% : Context 35%** +* **理由**:此时用户的视线主要在聊天流上,右侧只是个“仪表盘”。 + +### **阶段 B:方案生成期 (Focus on Document)** + +* **状态**:AI 在写长文,用户在审阅。聊天框只用来发简单的修改指令。 +* **比例**:**Chat 35% : Document 65%** +* **理由**:此时右侧的 A4 纸是主角。A4 纸的最佳阅读宽度通常需要 800px+,否则排版会乱(尤其是表格)。 + +### **阶段 C:终极自由 (User Control)** + +* **功能**:在两栏中间加一个 **Drag Handle (拖拽手柄)**,允许用户自己拖动宽度。 +* **记忆**:记住用户的最后设置。 + +## **3\. 视觉优化细节** + +1. **平滑过渡**:当从阶段 A 切换到阶段 B 时,使用 CSS transition 让分界线平滑移动,而不是突变。 +2. **折叠按钮**:允许用户完全折叠左侧 Chat,进入 **“全屏沉浸阅读模式”** (100% Doc)。 + +## **4\. 结论** + +**不要统一。** + +请采用 **“模式驱动的默认比例 \+ 手动拖拽”** 的策略。 + +* 默认:**PICO 模式 (60/40)** \-\> **生成模式 (35/65)**。 \ No newline at end of file diff --git a/docs/03-业务模块/AIA-AI智能问答/00-系统设计/研究方案一键生成.html b/docs/03-业务模块/AIA-AI智能问答/00-系统设计/研究方案一键生成.html new file mode 100644 index 00000000..3596afcf --- /dev/null +++ b/docs/03-业务模块/AIA-AI智能问答/00-系统设计/研究方案一键生成.html @@ -0,0 +1,277 @@ + + + + + + Protocol Agent V3.0 - 生成全流程 + + + + + + + +
+
+
+ +
+
+

Protocol Agent

+
+ + PICO Ready +
+
+
+ + +
+ + +
+
+ +
+ + +
+
+ + +
+
+
+ 样本量计算已完成 (N=386)。至此,您的研究方案 5 个关键要素 已全部收集完毕。 +
+
+ + +
+
+
+
+
+

要素已就绪

+

基于已确认的 PICO 与样本量

+
+
+ +
+
+ + + + +
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+
+

+ + 关键要素 (Context Data) +

+ 数据源: Postgres JSONB +
+ +
+ +
+
PICO 模型
+
+
P≥65岁原发性高血压患者
+
I阿司匹林肠溶片 100mg/d
+
C安慰剂
+
O5年内缺血性脑卒中发生率
+
+
+ + +
+
+
样本量估算
+
Alpha=0.05, Power=0.8
+
+
N=386
+
+
+ +
+ + 点击左侧 "开始撰写" 将基于以上数据生成文档 +
+
+
+ + +
+ +
+ + +
+ + +
+

阿司匹林预防老年高血压患者缺血性脑卒中的
前瞻性、随机、双盲对照研究方案

+ +
+
+

1. 研究背景 (Background)

+

+ 脑卒中是全球范围内导致死亡和长期残疾的主要原因之一。流行病学数据显示,在65岁以上的老年人群中,高血压是缺血性脑卒中最重要的独立危险因素... +

+
+ +
+

2. 研究目的 (Objectives)

+

+ 主要目的: 评价每日口服 100mg 阿司匹林肠溶片对比安慰剂,在降低 65 岁及以上原发性高血压患者 5 年内缺血性脑卒中发生率方面的有效性。 +

+

+ 次要目的: 评估阿司匹林组与安慰剂组在全因死亡率、出血性脑卒中发生率及主要出血事件(如消化道出血)方面的差异。 +

+
+ +
+

3. 研究设计 (Study Design)

+

+ 本研究采用多中心、随机、双盲、安慰剂对照设计 (RCT)。入组后的受试者将通过中央随机系统以 1:1 的比例分配至试验组(阿司匹林)或对照组(安慰剂)。 +

+
+
+
+ +
+
+ +
+ +
+ + + + \ No newline at end of file diff --git a/docs/03-业务模块/AIA-AI智能问答/04-开发计划/03-前端组件设计.md b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/03-前端组件设计.md index dfeeb880..8a07ecaa 100644 --- a/docs/03-业务模块/AIA-AI智能问答/04-开发计划/03-前端组件设计.md +++ b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/03-前端组件设计.md @@ -896,3 +896,4 @@ export interface SlashCommand { + diff --git a/docs/03-业务模块/AIA-AI智能问答/04-开发计划/04-Protocol_Agent开发计划/01-架构设计.md b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/04-Protocol_Agent开发计划/01-架构设计.md index 97a192af..f632c7e6 100644 --- a/docs/03-业务模块/AIA-AI智能问答/04-开发计划/04-Protocol_Agent开发计划/01-架构设计.md +++ b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/04-Protocol_Agent开发计划/01-架构设计.md @@ -496,3 +496,4 @@ class StatisticsAgentOrchestrator extends BaseAgentOrchestrator **版本**: v1.1 +> **版本**: v2.0 > **创建日期**: 2026-01-24 -> **最后更新**: 2026-01-24 +> **最后更新**: 2026-01-25 > **负责人**: AI Assistant -> **状态**: 待开发 +> **状态**: 开发中 --- ## 一、功能概述 ### 目标 -基于 Protocol Agent 收集的 5 个核心要素,一键生成完整的临床研究方案文档,支持在线编辑和 Word 导出。 +基于 Protocol Agent 收集的 5 个核心要素,一键生成完整的临床研究方案文档,支持对话式修改和 Word 导出。 ### 核心价值 - 将 5-10 小时的方案撰写工作缩短至 30 分钟 -- AI 生成 + 人工编辑,保证专业性和个性化 +- AI 生成 + 对话式修改 + Word 精修,保证专业性和个性化 - 输出符合伦理委员会审查要求的标准文档 +### 技术策略:No-Editor First(无编辑器优先) + +**核心洞察**: +> 用户最终会在 Word 中精修,我们的价值是 **"AI 生成高质量初稿"**,而不是 **"提供一个编辑器"**。 + --- -## 二、交互设计:两阶段渐进式生成 +## 二、交互设计:对话流生成 -### 第一阶段:对话框生成摘要 +### 用户流程 ``` -用户点击"一键生成研究方案" +用户完成 5 阶段要素收集 ↓ -AI 在对话框中流式输出研究方案摘要(约500字) +点击"一键生成研究方案" ↓ -用户确认摘要 → 进入第二阶段 -用户不满意 → 在对话中继续调整要素 +AI 在对话框中流式输出完整方案(Markdown) + ↓ +用户对话修改:"把样本量部分改一下..." + ↓ +AI 流式输出修改后的章节 + ↓ +用户满意 → 点击"导出 Word" + ↓ +下载符合伦理委员会格式的 Word 文档 ``` -**摘要内容**: -- 研究题目 -- 研究目的(主要/次要) -- 研究设计概述 -- 样本量结论 -- 主要结局指标 - -### 第二阶段:方案编辑器生成完整方案 +### 交互示意 ``` -用户点击"生成完整方案" - ↓ -跳转到方案编辑器页面 - ↓ -流式生成完整研究方案(5000-8000字) - ↓ -用户在线编辑 / AI协作润色 - ↓ -导出 Word 文档 +┌─────────────────────────────────────────────────────────────┐ +│ ← 返回 全流程研究方案制定 │ +├────────────────────────────────────────┬────────────────────┤ +│ │ 📋 研究方案状态 │ +│ [AI] 根据您确认的信息,我已生成研究方案: │ │ +│ │ ✅ 科学问题 │ +│ # 1. 研究题目 │ ✅ PICO要素 │ +│ 糖尿病患者使用二甲双胍与格列美脲... │ ✅ 研究设计 │ +│ │ ✅ 样本量 │ +│ # 2. 研究背景 │ ✅ 观察指标 │ +│ 2型糖尿病是全球性公共卫生问题... │ │ +│ │ ──────────────── │ +│ ... │ │ +│ │ [📥 导出 Word] │ +│ ┌────────────────────────────────┐ │ [🔄 重新生成] │ +│ │ 📥 导出Word │ 🔄 修改本章节 │ │ │ +│ └────────────────────────────────┘ │ │ +│ │ │ +│ [用户] 把样本量从200改成300,并说明原因 │ │ +│ │ │ +│ [AI] 好的,我已修改样本量部分: │ │ +│ ## 6. 样本量估算 │ │ +│ 根据前期预实验数据...样本量调整为300例 │ │ +│ │ │ +├────────────────────────────────────────┴────────────────────┤ +│ [输入消息...] [发送] │ +└─────────────────────────────────────────────────────────────┘ ``` --- @@ -79,229 +102,344 @@ AI 在对话框中流式输出研究方案摘要(约500字) --- -## 四、方案编辑器设计 +## 四、技术方案 -### 布局结构 +### 架构概览 ``` -┌────────────────────────────────────────────────────────────────┐ -│ ← 返回 📄 研究方案编辑器 [自动保存✓] [导出Word] [发布] │ -├──────────┬─────────────────────────────────────┬───────────────┤ -│ 📑 大纲 │ 📝 编辑区 │ 🤖 AI助手 │ -│ │ │ │ -│ 可点击 │ Notion 风格分块编辑 │ 选中文本后: │ -│ 快速跳转 │ 支持 Markdown + 富文本 │ - /ai 润色 │ -│ │ Slash 命令 (/) │ - /ai 扩写 │ -│ │ 拖拽排序章节 │ - /ai 精简 │ -└──────────┴─────────────────────────────────────┴───────────────┘ +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ 前端 │ │ Node.js │ │ Python │ +│ ChatArea │ ──▶ │ Backend │ ──▶ │ Service │ +│ │ │ │ │ (Pandoc) │ +└─────────────┘ └─────────────┘ └─────────────┘ + │ │ │ + │ 流式输出 │ 调用 LLM │ Markdown→Word + │ Markdown │ 生成方案 │ 格式转换 + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────┐ +│ 用户下载 Word │ +└─────────────────────────────────────────────────────┘ ``` -### 核心功能 - -| 功能 | 说明 | 优先级 | -|------|------|--------| -| **Slash 命令** | 输入 / 唤起菜单,支持 /ai 调用生成 | P0 | -| **分块编辑** | 每个章节独立编辑,支持拖拽排序 | P0 | -| **大纲导航** | 左侧目录,点击跳转 | P0 | -| **自动保存** | 每30秒 + 失焦时保存 | P0 | -| **导出Word** | Tiptap JSON → docx | P0 | -| **AI润色** | 选中文本,/ai polish 优化 | P1 | -| **AI扩写** | 选中章节,/ai expand 补充 | P1 | -| **Ghost Text** | AI 生成时显示幽灵文字预览 | P1 | -| **版本历史** | 查看修改记录,回滚 | P2 | - ---- - -## 五、技术方案 - -### 技术选型:Novel (Fork) - -**选型结论**:Fork [Novel](https://github.com/steven-tey/novel) 源码,而非 npm 包引入。 - -**选择 Novel 的理由**: - -| 因素 | Novel 优势 | -|------|-----------| -| **AI 原生** | 专为 AI 写作设计,已处理流式生成的 UX 细节(Ghost Text、光标锁定) | -| **标准 Tiptap** | 直接暴露 Tiptap 配置,可插入交互组件(样本量计算器、引用卡片) | -| **可控性** | 源码在手,100% 可定制 UI 和逻辑 | -| **对接成本** | 替换 useCompletion → useAIStream 即可对接现有后端 | - -**Fork 策略**: +### 技术栈 ``` -不要 npm install novel - -将 novel/packages/core/src 复制到: -frontend-v2/src/shared/components/ProtocolEditor/ - -目录结构: -├── index.tsx # 主组件 -├── extensions/ # Tiptap 扩展 -│ ├── ai-autocomplete.ts # 替换为 useAIStream -│ ├── slash-command.tsx # 保留 Slash 菜单 -│ ├── citation.ts # Phase 2: 文献引用 -│ └── medical-table.ts # Phase 2: 复杂表格 -├── components/ # UI 组件 -│ ├── EditorContent.tsx -│ ├── SlashMenu.tsx -│ └── BubbleMenu.tsx -└── styles/ # 样式(处理 Tailwind 冲突) - └── editor.css -``` - -**Tailwind CSS 冲突处理**: - -```css -/* 方案:CSS 命名空间隔离 */ -.novel-editor-scope { - /* Novel 的 Tailwind 样式限制在此作用域 */ -} -``` - -### 技术栈总览 - -``` -前端编辑器:Novel (Fork) - 基于 Tiptap/ProseMirror -├── 优点:AI 原生、Notion 风格、源码可控 -├── 核心:Slash 命令、Ghost Text、拖拽排序 +前端:复用现有 ChatArea + useAIStream +├── 增加"导出 Word"按钮 +├── 增加"修改本章节"交互 │ -AI 调用:复用现有 useAIStream Hook -├── 替换 Novel 的 useCompletion (Vercel AI SDK) -├── 对接 /api/v1/aia/protocol-agent/generate +后端 (Node.js): +├── POST /api/v1/aia/protocol-agent/generate/full # 生成完整方案 +├── POST /api/v1/aia/protocol-agent/regenerate # 重新生成指定章节 +├── POST /api/v1/aia/protocol-agent/export/docx # 导出 Word │ -文档导出:docx.js 或 @tiptap-pro/extension-export-docx -│ -数据存储:PostgreSQL (protocol_generations 表) +后端 (Python): +├── Pandoc 集成 +├── Reference Doc 模板控制样式 ``` ### 数据模型 ```sql --- 方案生成记录表 -CREATE TABLE protocol_generations ( - id UUID PRIMARY KEY, - conversation_id UUID REFERENCES conversations(id), - user_id UUID REFERENCES users(id), - - -- 内容 - summary TEXT, -- 摘要(第一阶段) - full_content JSONB, -- 完整方案(Tiptap JSON,标准格式) - - -- 状态 - status VARCHAR(20), -- draft | generating | completed - version INT DEFAULT 1, - - -- 元数据 - word_file_url TEXT, -- 导出的Word文件URL - created_at TIMESTAMP, - updated_at TIMESTAMP -); +-- 方案生成记录表(复用现有 protocol_generations) +ALTER TABLE protocol_schema.protocol_generations ADD COLUMN IF NOT EXISTS + sections JSONB; -- 分章节存储,支持局部修改 + +-- sections 结构示例 +{ + "title": "糖尿病患者使用二甲双胍...", + "background": "2型糖尿病是全球性...", + "objectives": "...", + "design": "...", + "subjects": "...", + "sample_size": "...", + "implementation": "...", + "endpoints": "...", + "data_management": "...", + "safety": "...", + "statistics": "...", + "ethics": "...", + "timeline": "...", + "references": "..." +} ``` ### API 设计 ```typescript -// 第一阶段:生成摘要(流式) -POST /api/v1/aia/protocol-agent/generate/summary -Request: { conversationId: string } -Response: SSE 流式输出摘要 - -// 第二阶段:生成完整方案(流式) +// 生成完整方案(流式) POST /api/v1/aia/protocol-agent/generate/full Request: { conversationId: string } -Response: SSE 流式输出完整方案 +Response: SSE 流式输出完整 Markdown -// 保存编辑 -PUT /api/v1/aia/protocol-agent/generation/:id -Request: { content: TiptapJSON } - -// 导出Word -POST /api/v1/aia/protocol-agent/generation/:id/export -Response: { downloadUrl: string } - -// AI编辑(润色/扩写) -POST /api/v1/aia/protocol-agent/generation/:id/ai-edit +// 重新生成指定章节(流式) +POST /api/v1/aia/protocol-agent/regenerate Request: { - action: 'polish' | 'expand' | 'simplify', - selectedText: string, - context: string + conversationId: string, + section: 'sample_size' | 'background' | ..., + instruction: "把样本量从200改成300" } -Response: SSE 流式输出 +Response: SSE 流式输出该章节 + +// 导出 Word +POST /api/v1/aia/protocol-agent/export/docx +Request: { conversationId: string } +Response: Binary (application/vnd.openxmlformats-officedocument.wordprocessingml.document) + +// 获取当前方案内容 +GET /api/v1/aia/protocol-agent/generation/:conversationId +Response: { sections: {...}, fullMarkdown: "...", version: 3 } +``` + +--- + +## 五、核心实现 + +### 5.1 Python 微服务:Pandoc 转换器 + +**Dockerfile 增加依赖:** +```dockerfile +RUN apt-get update && apt-get install -y pandoc +RUN pip install pypandoc +``` + +**Service 代码:** +```python +# python-service/app/services/doc_service.py +import pypandoc +import os + +def convert_md_to_docx(markdown_text: str, output_path: str): + # 使用参考文档控制样式(字体、字号、页眉) + reference_doc = os.path.join( + os.path.dirname(__file__), + 'assets/protocol_template.docx' + ) + + pypandoc.convert_text( + markdown_text, + 'docx', + format='markdown', + outputfile=output_path, + extra_args=[f'--reference-doc={reference_doc}'] + ) + return output_path +``` + +**API 端点:** +```python +# python-service/app/routers/doc_router.py +from fastapi import APIRouter, Response +from ..services.doc_service import convert_md_to_docx +import tempfile + +router = APIRouter() + +@router.post("/convert/docx") +async def convert_to_docx(request: dict): + markdown = request.get("content", "") + + with tempfile.NamedTemporaryFile(suffix='.docx', delete=False) as f: + output_path = f.name + + convert_md_to_docx(markdown, output_path) + + with open(output_path, 'rb') as f: + content = f.read() + + return Response( + content=content, + media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) +``` + +### 5.2 Node.js 后端:导出 API + +```typescript +// backend/src/modules/agent/protocol/controllers/ProtocolGenerateController.ts + +export class ProtocolGenerateController { + + // 生成完整方案 + async generateFull(request: FastifyRequest, reply: FastifyReply) { + const { conversationId } = request.body as { conversationId: string }; + + // 获取 Protocol Context(5 阶段数据) + const context = await this.contextService.getContext(conversationId); + + // 构建生成 Prompt + const prompt = this.buildGeneratePrompt(context); + + // 流式生成 + const streamingService = createStreamingService(reply, { + onChunk: async (chunk) => { + // 保存到数据库 + await this.saveChunk(conversationId, chunk); + } + }); + + await streamingService.streamGenerate([ + { role: 'system', content: this.getSystemPrompt() }, + { role: 'user', content: prompt } + ]); + } + + // 重新生成指定章节 + async regenerateSection(request: FastifyRequest, reply: FastifyReply) { + const { conversationId, section, instruction } = request.body; + + const context = await this.contextService.getContext(conversationId); + const currentGeneration = await this.getGeneration(conversationId); + + const prompt = ` +请根据以下指令修改"${section}"章节: + +用户指令:${instruction} + +当前章节内容: +${currentGeneration.sections[section]} + +研究背景信息: +${JSON.stringify(context)} + +请输出修改后的完整章节内容(Markdown 格式): +`; + + // 流式输出修改后的章节 + const streamingService = createStreamingService(reply); + await streamingService.streamGenerate([ + { role: 'system', content: '你是临床研究方案撰写专家...' }, + { role: 'user', content: prompt } + ]); + } + + // 导出 Word + async exportDocx(request: FastifyRequest, reply: FastifyReply) { + const { conversationId } = request.body as { conversationId: string }; + + // 获取完整 Markdown + const generation = await this.getGeneration(conversationId); + const markdown = this.sectionsToMarkdown(generation.sections); + + // 调用 Python 微服务转换 + const response = await axios.post( + `${PYTHON_SERVICE_URL}/convert/docx`, + { content: markdown }, + { responseType: 'arraybuffer' } + ); + + reply.header('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + reply.header('Content-Disposition', 'attachment; filename="research_protocol.docx"'); + return reply.send(response.data); + } +} +``` + +### 5.3 前端:ChatArea 增强 + +```tsx +// frontend-v2/src/modules/aia/protocol-agent/components/ChatArea.tsx + +// 在消息气泡中增加导出按钮 +const ProtocolMessageActions = ({ content, conversationId }) => { + const [exporting, setExporting] = useState(false); + + const handleExport = async () => { + setExporting(true); + try { + const response = await fetch('/api/v1/aia/protocol-agent/export/docx', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ conversationId }) + }); + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = '研究方案.docx'; + a.click(); + } finally { + setExporting(false); + } + }; + + return ( +
+ + +
+ ); +}; ``` --- ## 六、开发计划 -### Phase 1:Fork Novel + 对接后端(3天) - -**目标**:3天内跑通"编辑器 + AI 流式生成" +### Phase 1:MVP 核心功能(2 天) | 天数 | 任务 | 交付物 | |------|------|--------| -| Day 1 | Fork Novel 源码 + 项目集成 | 编辑器基础渲染、Slash 菜单可用 | -| Day 2 | 替换 AI 调用 → useAIStream | /ai 命令可调用后端生成 | -| Day 3 | 摘要生成 API + 编辑器页面路由 | 完整的"对话→摘要→编辑器"流程 | +| Day 1 | Python 微服务集成 Pandoc | `/convert/docx` API 可用 | +| Day 1 | Node.js 导出 API | `/export/docx` 端点完成 | +| Day 2 | 前端导出按钮 | ChatArea 增加导出功能 | +| Day 2 | 完整方案生成 Prompt | 生成质量优化 | **Phase 1 交付**: -- ✅ Notion 风格编辑器可用 -- ✅ /ai 命令可调用 Protocol Agent -- ✅ 支持 Markdown 导出 +- ✅ 一键生成完整研究方案(Markdown) +- ✅ 导出符合格式的 Word 文档 +- ✅ 基础对话式修改 -### Phase 2:完整方案生成 + Word 导出(3天) +### Phase 2:分章节修改(2 天) | 天数 | 任务 | 交付物 | |------|------|--------| -| Day 4 | 完整方案生成 API(流式) | 编辑器中流式显示完整方案 | -| Day 5 | 自动保存 + 版本管理 | 数据库存储、草稿恢复 | -| Day 6 | Word 导出功能 | docx 文件下载 | +| Day 3 | 分章节存储模型 | sections JSONB 字段 | +| Day 3 | 章节重新生成 API | `/regenerate` 端点 | +| Day 4 | 前端章节选择器 | 点击章节触发修改对话 | +| Day 4 | 版本历史 | 修改记录存储 | **Phase 2 交付**: -- ✅ 完整方案流式生成 -- ✅ 自动保存 -- ✅ Word 导出 +- ✅ 分章节存储和修改 +- ✅ 智能识别修改章节 +- ✅ 版本历史追踪 -### Phase 3:医疗特性增强(4天) +### Phase 3:体验优化(1 天) | 天数 | 任务 | 交付物 | |------|------|--------| -| Day 7 | 集成 Tiptap Table 扩展 | 复杂表格支持(访视排期表) | -| Day 8 | 开发 CitationBlock | 文献引用组件(对接 PKB) | -| Day 9 | AI 润色/扩写优化 | 选中文本 AI 编辑体验 | -| Day 10 | 测试 + UI 美化 | 完整功能测试 | - -**Phase 3 交付**: -- ✅ 复杂表格支持 -- ✅ 文献引用功能 -- ✅ AI 协作编辑完善 +| Day 5 | Word 模板优化 | 符合伦理委员会格式 | +| Day 5 | UI 美化 | 导出进度、预览 | +| Day 5 | 测试与修复 | 完整功能测试 | --- -## 七、风险与依赖 +## 七、与编辑器方案对比 -| 风险 | 影响 | 缓解措施 | -|------|------|----------| -| Tailwind CSS 冲突 | 样式混乱 | CSS 命名空间隔离 | -| Novel 源码维护成本 | 后续升级困难 | 代码量小(~2000行),可独立维护 | -| LLM 生成质量不稳定 | 方案内容不专业 | 优化 Prompt + 人工模板 | -| 长文本生成超时 | 用户等待过久 | 分章节流式生成 | -| Word 导出格式问题 | 格式错乱 | 预设 Word 模板 | +| 维度 | 无编辑器方案(当前) | 编辑器方案(备选) | +|------|---------------------|-------------------| +| **开发周期** | 5 天 | 10 天 | +| **维护成本** | 低(复用现有组件) | 高(Fork Novel) | +| **格式控制** | ⭐⭐⭐⭐⭐ Pandoc 精确控制 | ⭐⭐⭐ 需自己处理 | +| **修改体验** | 对话式(稍慢) | 直接编辑(更快) | +| **用户习惯** | 符合(Word 精修) | 需适应新界面 | +| **适用场景** | MVP / 快速验证 | 高频编辑场景 | -### 依赖项 +### 何时考虑上编辑器? -- [x] Protocol Agent 5阶段数据收集(已完成) -- [x] StreamingService 流式输出(已完成) -- [x] useAIStream Hook(已完成) -- [ ] Novel 源码 Fork(待执行) -- [ ] docx.js 导出功能(待开发) - -### 兜底方案 - -如果 Fork 的 Novel 代码难以维护: -- 可回退到 **Tiptap Headless** -- 用 Ant Design 重写 UI -- **数据模型 (Tiptap JSON) 完全兼容**,用户数据不丢失 +如果用户反馈以下问题,再考虑引入编辑器: +1. "对话修改太慢,我想直接改某个字" +2. "我需要频繁调整段落顺序" +3. "我想同时看到多个章节并对比" --- @@ -309,43 +447,69 @@ Response: SSE 流式输出 ### 功能验收 -- [ ] 点击"一键生成",对话框流式输出摘要 -- [ ] 点击"生成完整方案",跳转编辑器并流式生成 -- [ ] 编辑器支持 Slash 命令 (/) -- [ ] 编辑器支持章节拖拽排序 -- [ ] 选中文本可调用 AI 润色/扩写 -- [ ] 可导出标准格式的 Word 文档 -- [ ] 支持复杂表格编辑 +- [ ] 点击"一键生成",对话框流式输出完整方案 +- [ ] 点击"导出 Word",下载格式正确的 docx 文件 +- [ ] 输入"修改样本量",AI 只重新生成该章节 +- [ ] Word 文档符合伦理委员会格式要求 ### 性能指标 | 指标 | 目标 | |------|------| -| 摘要生成时间 | < 30秒 | -| 完整方案生成时间 | < 3分钟 | -| 自动保存延迟 | < 1秒 | -| Word导出时间 | < 5秒 | +| 完整方案生成时间 | < 2 分钟 | +| Word 导出时间 | < 5 秒 | +| 章节修改响应时间 | < 30 秒 | --- -## 九、后续迭代 +## 九、Word 模板设计 -- **v1.1**: 方案模板库(不同研究类型) -- **v1.2**: 多人协作编辑 -- **v1.3**: 方案审核流程 -- **v1.4**: 与伦理系统对接 +### 格式要求(伦理委员会标准) + +``` +字体:正文宋体小四,标题黑体三号 +行距:1.5 倍 +页边距:上下 2.54cm,左右 3.17cm +页眉:研究方案 + 版本号 +页脚:页码 +``` + +### Reference Doc 制作 + +1. 在 Word 中创建符合格式的模板文档 +2. 定义样式:标题1、标题2、正文、表格等 +3. 保存为 `protocol_template.docx` +4. 放置到 `python-service/app/services/assets/` --- -## 十、参考文档 +## 十、后续迭代 -- [Novel GitHub](https://github.com/steven-tey/novel) -- [Tiptap 官方文档](https://tiptap.dev/) -- [编辑器选型深度评估](./编辑器选型深度评估与落地建议.md) -- [Novel vs BlockNote 对比分析](./Novel_vs_BlockNote_深度对比分析.md) +### v1.1:编辑器增强(可选) +如果用户反馈需要更直接的编辑体验: +- 引入 Novel/Tiptap 编辑器 +- 支持实时编辑 + AI 辅助 + +### v1.2:模板库 +- 不同研究类型的方案模板 +- RCT、队列研究、病例对照等 + +### v1.3:协作与审核 +- 多人协作编辑 +- 方案审核流程 +- 与伦理系统对接 + +--- + +## 十一、参考文档 + +- [基于对话流的文档生成技术方案](./基于对话流的文档生成与导出技术方案.md) +- [编辑器选型深度评估](./编辑器选型深度评估与落地建议.md)(备选方案) +- [Novel vs BlockNote 对比分析](./Novel_vs_BlockNote_深度对比分析.md)(备选方案) --- **文档更新记录**: - 2026-01-24 v1.0: 初始版本(技术选型:Tiptap/BlockNote) -- 2026-01-24 v1.1: 技术选型改为 **Novel (Fork)**,更新开发计划 +- 2026-01-24 v1.1: 技术选型改为 Novel (Fork) +- 2026-01-25 v2.0: **重大调整:采用无编辑器方案**,优先实现对话流生成 + Word 导出 diff --git a/docs/03-业务模块/AIA-AI智能问答/04-开发计划/06-一键生成研究方案开发计划V2.md b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/06-一键生成研究方案开发计划V2.md new file mode 100644 index 00000000..c022b746 --- /dev/null +++ b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/06-一键生成研究方案开发计划V2.md @@ -0,0 +1,349 @@ +# 一键生成研究方案 - 开发计划 V2 + +> **版本**: 2.0 +> **日期**: 2026-01-25 +> **状态**: 规划中 +> **前置依赖**: Pandoc Word 导出已完成 ✅ + +--- + +## 一、设计目标 + +### 1.1 核心理念 + +``` +"关键要素就绪 → 一键生成方案" +``` + +**解耦设计**:方案生成功能**不依赖** AI 对话流程。无论用户通过 AI 对话收集要素,还是手动填写要素,只要必填项完成即可生成。 + +### 1.2 用户场景 + +| 场景 | 用户行为 | 系统响应 | +|------|---------|---------| +| **场景 A** | 用户通过 AI 对话完成 5 阶段 | 自动提示可生成,点击生成 | +| **场景 B** | 用户手动填写关键要素 | 校验通过后,直接点击生成 | +| **场景 C** | 用户对生成内容不满意 | 针对某章节"讨论与优化" | + +### 1.3 必填要素校验 + +| 要素 | 是否必填 | 说明 | +|------|---------|------| +| 科学问题 | ✅ 必填 | 方案的核心目的 | +| PICO | ✅ 必填 | 研究框架基础 | +| 研究设计 | ✅ 必填 | 决定方案结构 | +| 观察指标 | ✅ 必填 | 结局评价依据 | +| 样本量 | ⚪ 可选 | 可后续补充 | + +**校验规则**:4/5 必填项完成 → 可生成基础方案 + +--- + +## 二、UI 架构设计 + +### 2.1 双阶段动态布局 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Protocol Agent Page │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 阶段 A: 要素收集期 │ +│ ┌─────────────────────────┬───────────────────────────┐ │ +│ │ ChatPanel │ ContextPanel │ │ +│ │ 65% │ 35% │ │ +│ │ │ ┌───────────────────┐ │ │ +│ │ [AI 对话区域] │ │ 科学问题 [✓][✏️] │ │ │ +│ │ │ │ PICO [✓][✏️] │ │ │ +│ │ │ │ 研究设计 [✓][✏️] │ │ │ +│ │ │ │ 样本量 [○] │ │ │ +│ │ │ │ 观察指标 [✓][✏️] │ │ │ +│ │ │ ├───────────────────┤ │ │ +│ │ │ │ 进度: 80% (4/5) │ │ │ +│ │ │ │ [✨ 一键生成方案] │ │ │ +│ │ │ │ [📥 导出 Word] │ │ │ +│ │ │ └───────────────────┘ │ │ +│ └─────────────────────────┴───────────────────────────┘ │ +│ ↕ 拖拽手柄 │ +│ │ +│ 阶段 B: 方案生成期 │ +│ ┌───────────────────┬─────────────────────────────────┐ │ +│ │ ChatPanel │ DocumentPanel │ │ +│ │ 35% │ 65% │ │ +│ │ │ ┌─────────────────────────┐ │ │ +│ │ [正在撰写...] │ │ [复制] [导出 Word] │ │ │ +│ │ 1. 研究背景 ✓ │ ├─────────────────────────┤ │ │ +│ │ 2. 研究目的 ✓ │ │ │ │ │ +│ │ 3. 研究设计 ⟳ │ │ ══ A4 文档预览 ══ │ │ │ +│ │ │ │ │ │ │ +│ │ [修改指令输入] │ │ 1. 研究背景 │ │ │ +│ │ │ │ [讨论与优化] │ │ │ +│ │ │ │ 内容... │ │ │ +│ │ │ │ │ │ │ +│ │ │ │ 2. 研究目的 │ │ │ +│ │ │ │ [讨论与优化] │ │ │ +│ │ │ │ 内容... │ │ │ +│ │ │ │ │ │ │ +│ └───────────────────┴─────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 视图切换机制 + +```typescript +type ViewMode = 'context' | 'document'; + +// 切换条件 +const shouldSwitchToDocument = (event: string) => { + return event === 'GENERATION_STARTED'; +}; + +// 布局比例 +const LAYOUT_RATIO = { + context: { chat: 65, right: 35 }, + document: { chat: 35, right: 65 }, +}; +``` + +### 2.3 视图切换按钮 + +顶部 Header 区域添加视图切换: +``` +[视图: 关键要素] [视图: 完整方案] +``` + +--- + +## 三、功能模块详细设计 + +### 3.1 ContextPanel (要素面板) - 已有,需增强 + +**现有功能**: +- ✅ 五阶段卡片展示 +- ✅ 完成状态编辑按钮 +- ✅ 导出 Word 按钮 + +**需新增**: +1. **一键生成按钮位置调整**:移到面板顶部醒目位置 +2. **生成条件提示**:显示 `已完成 4/5 必填项,可生成方案` +3. **手动添加要素入口**:未完成阶段也显示 [➕ 添加] 按钮 + +### 3.2 DocumentPanel (文档面板) - 新增 + +**核心功能**: +- A4 纸张预览效果(宋体、首行缩进、分节标题) +- 流式输出 + 打字机光标 +- 分章节"讨论与优化"按钮 +- 顶部工具栏:复制、导出 Word + +**章节结构**: +```markdown +# 研究方案标题 + +## 1. 研究背景 (Background) +## 2. 研究目的 (Objectives) +## 3. 研究设计 (Study Design) +## 4. 研究对象 (Subjects) +## 5. 样本量估算 (Sample Size) +## 6. 研究实施步骤 (Implementation) +## 7. 观察指标 (Endpoints) +## 8. 数据管理与质量控制 +## 9. 统计分析计划 +## 10. 伦理与知情同意 +## 11. 研究时间表 +## 12. 参考文献 +``` + +### 3.3 "讨论与优化" 功能 + +**交互流程**: +1. 用户点击某章节的 [讨论与优化] 按钮 +2. 左侧 Chat 自动切换到"章节讨论模式" +3. AI 理解上下文,针对该章节进行对话 +4. 用户可发送修改建议 +5. AI 重新生成该章节内容 +6. 右侧文档实时更新 + +**技术实现**: +```typescript +interface SectionContext { + sectionId: string; // 如 'background', 'objectives' + sectionTitle: string; // 如 '研究背景' + currentContent: string; // 当前内容 + conversationMode: 'section'; // 章节讨论模式 +} +``` + +### 3.4 动态布局组件 + +```typescript +// ResizableSplitPane.tsx +interface ResizableSplitPaneProps { + defaultRatio: number; // 默认比例 (0-100) + minRatio: number; // 最小比例 + maxRatio: number; // 最大比例 + onRatioChange: (ratio: number) => void; + leftPanel: React.ReactNode; + rightPanel: React.ReactNode; +} +``` + +--- + +## 四、开发计划 (分阶段) + +### Phase 1: 核心功能 MVP (5天) + +| 天数 | 任务 | 交付物 | +|------|------|--------| +| Day 1 | 动态布局组件 | `ResizableSplitPane` + 视图切换按钮 | +| Day 2 | DocumentPanel 基础 | A4 预览 + 流式渲染 + 打字机效果 | +| Day 3 | 生成 API 对接 | 分章节流式生成 + 前后端联调 | +| Day 4 | 生成按钮 & 校验 | 必填项校验 + 双位置生成按钮 | +| Day 5 | 集成测试 & Bug Fix | 端到端测试 | + +**Phase 1 交付标准**: +- [x] 用户可从 ContextPanel 点击生成 +- [x] 方案在 DocumentPanel 流式展示 +- [x] 布局可根据阶段自动切换 +- [x] Word 导出功能正常 + +### Phase 2: "讨论与优化" (3天) + +| 天数 | 任务 | 交付物 | +|------|------|--------| +| Day 6 | 章节按钮 UI | 每个章节显示 [讨论与优化] 按钮 | +| Day 7 | 章节对话模式 | Chat 切换到章节讨论模式 + API | +| Day 8 | 章节重新生成 | 单章节流式更新 + 文档同步 | + +**Phase 2 交付标准**: +- [x] 用户点击章节按钮,左侧进入讨论模式 +- [x] AI 理解当前章节上下文 +- [x] 重新生成的内容替换原章节 + +### Phase 3: 体验优化 (2天) + +| 天数 | 任务 | 交付物 | +|------|------|--------| +| Day 9 | 手动添加要素 | 未完成阶段的 [➕ 添加] 入口 | +| Day 10 | 拖拽 & 记忆 | 拖拽手柄 + localStorage 记忆比例 | + +--- + +## 五、技术要点 + +### 5.1 流式渲染方案 + +```typescript +// 使用现有 useAIStream hook +const { streamingContent, isStreaming } = useAIStream({ + onChunk: (chunk) => { + // 实时更新 DocumentPanel + updateDocumentContent(chunk); + }, + onComplete: (fullContent) => { + // 保存完整方案 + saveProtocol(fullContent); + }, +}); +``` + +### 5.2 章节解析 + +```typescript +// 将流式 Markdown 解析为章节 +const parseMarkdownSections = (markdown: string): Section[] => { + const sections: Section[] = []; + const regex = /^## (\d+)\. (.+)$/gm; + // ... 解析逻辑 + return sections; +}; +``` + +### 5.3 状态管理 + +```typescript +interface ProtocolGenerationState { + viewMode: 'context' | 'document'; + layoutRatio: number; + generationStatus: 'idle' | 'generating' | 'completed'; + currentSection: string | null; // 当前讨论的章节 + sections: Section[]; + canGenerate: boolean; // 基于必填项校验 +} +``` + +--- + +## 六、文件结构 + +``` +frontend-v2/src/modules/aia/protocol-agent/ +├── components/ +│ ├── ProtocolAgentPage.tsx # 主页面 (修改) +│ ├── ChatPanel.tsx # 聊天面板 (已有) +│ ├── StatePanel.tsx # 要素面板 (修改) +│ ├── DocumentPanel.tsx # 文档面板 (新增) +│ ├── ResizableSplitPane.tsx # 可拖拽分栏 (新增) +│ ├── SectionRenderer.tsx # 章节渲染器 (新增) +│ └── ViewSwitcher.tsx # 视图切换器 (新增) +├── hooks/ +│ ├── useProtocolGeneration.ts # 方案生成 hook (新增) +│ └── useLayoutRatio.ts # 布局比例 hook (新增) +├── styles/ +│ ├── protocol-agent.css # 主样式 (修改) +│ └── document-panel.css # 文档面板样式 (新增) +└── types/ + └── index.ts # 类型定义 (修改) +``` + +--- + +## 七、风险与应对 + +| 风险 | 影响 | 应对措施 | +|------|------|---------| +| 长文档流式渲染性能 | 卡顿 | 虚拟滚动 + 分片渲染 | +| 章节解析复杂性 | 解析错误 | 规范化 Markdown 模板 | +| 布局切换体验 | 突兀 | CSS transition 平滑过渡 | + +--- + +## 八、验收标准 + +### MVP (Phase 1) + +- [ ] 4/5 必填项完成后,生成按钮可点击 +- [ ] 点击生成后,自动切换到文档视图 (35:65) +- [ ] 文档以 A4 纸样式流式展示 +- [ ] 生成完成后可导出 Word + +### 完整版 (Phase 1+2+3) + +- [ ] 每个章节有"讨论与优化"按钮 +- [ ] 章节可单独重新生成 +- [ ] 未完成阶段可手动添加要素 +- [ ] 布局比例可拖拽调整并记忆 + +--- + +## 九、附录 + +### A. 参考原型 + +- `AIclinicalresearch/docs/03-业务模块/AIA-AI智能问答/00-系统设计/研究方案一键生成.html` + +### B. 相关文档 + +- `UI_Layout_Ratio_Analysis.md` - 布局比例分析 +- `基于对话流的文档生成与导出技术方案.md` - 技术方案 + +### C. 已完成依赖 + +- ✅ Pandoc 系统安装 (3.8.3) +- ✅ pypandoc Python 包 +- ✅ Python `/api/convert/docx` 端点 +- ✅ Node.js `ProtocolExportService` +- ✅ 前端导出按钮 + diff --git a/docs/03-业务模块/AIA-AI智能问答/04-开发计划/Novel_CRF_Extension_Guide.md b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/Novel_CRF_Extension_Guide.md new file mode 100644 index 00000000..2b568679 --- /dev/null +++ b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/Novel_CRF_Extension_Guide.md @@ -0,0 +1,110 @@ +# **基于 Novel (Tiptap) 的 CRF 表单扩展开发指南** + +**目标**: 在编辑器中实现 "填空"、"单选"、"日期选择" 等 CRF 控件,并支持导出 Word。 + +## **1\. 自定义 CRF 节点开发 (Tiptap Extensions)** + +我们需要开发一组 **Node Extensions**,让编辑器理解表单元素。 + +### **1.1 填空输入框 (UnderlineInput)** + +// extensions/underline-input.tsx +import { Node, mergeAttributes } from '@tiptap/core'; +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'; + +export const UnderlineInput \= Node.create({ + name: 'underlineInput', + group: 'inline', + inline: true, + atom: true, // 作为一个整体,不可分割 + + addAttributes() { + return { + placeholder: { default: '请输入...' }, + value: { default: '' }, + } + }, + + parseHTML() { + return \[{ tag: 'span\[data-type="input"\]' }\] + }, + + renderHTML({ HTMLAttributes }) { + return \['span', mergeAttributes(HTMLAttributes, { 'data-type': 'input' })\] + }, + + // React 组件渲染 + addNodeView() { + return ReactNodeViewRenderer(({ node, updateAttributes }) \=\> { + return ( + \ + \ updateAttributes({ value: e.target.value })} + /\> + \ + ) + }) + }, +}); + +### **1.2 单选组 (RadioGroup)** + +// extensions/radio-group.tsx +// 类似逻辑,渲染一组 radio buttons + +## **2\. AI 生成与解析策略** + +如何让 AI 生成这些控件?我们约定一套 **"占位符语法"**。 + +* **Prompt**: "生成一个性别选择项,包含男、女。" +* **AI Output**: 性别: {{radio:男,女}} +* **前端解析 (Replacement Logic)**: + +// 在 useAIStream 的 onStream 更新中 +const parseContent \= (text) \=\> { + // 正则替换 + if (text.includes('{{input}}')) { + editor.chain().focus().insertContent({ type: 'underlineInput' }).run(); + } + // ... +}; + +## **3\. Word 导出逻辑 (docx.js)** + +针对自定义节点的导出映射。 + +// utils/export-docx.ts +import { TextRun, UnderlineType } from "docx"; + +export const transformNode \= (node) \=\> { + switch (node.type) { + + // 导出填空框 \-\> 带下划线的空格 + case 'underlineInput': + return new TextRun({ + text: node.attrs.value || " ", // 有值填值,无值填空格 + underline: { + type: UnderlineType.SINGLE, + }, + }); + + // 导出复选框 \-\> 特殊字符 + case 'taskItem': + const isChecked \= node.attrs.checked; + return new TextRun({ + text: isChecked ? "☑ " : "☐ ", // Unicode 字符 + font: "Arial Unicode MS", // 确保字体支持 + }); + + // ... 其他节点 + } +}; + +## **4\. 总结** + +Novel (Tiptap) 完全有能力承载 CRF 的需求。 + +虽然这需要一些 **"Extension 开发"** 的工作量,但相比自己从头写一个 Form Builder,这是性价比最高的方案,而且还能保持文档的流式阅读体验。 \ No newline at end of file diff --git a/docs/03-业务模块/AIA-AI智能问答/04-开发计划/基于对话流的文档生成与导出技术方案.md b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/基于对话流的文档生成与导出技术方案.md new file mode 100644 index 00000000..d391bb6f --- /dev/null +++ b/docs/03-业务模块/AIA-AI智能问答/04-开发计划/基于对话流的文档生成与导出技术方案.md @@ -0,0 +1,105 @@ +# **基于对话流的文档生成与导出技术方案** + +**核心策略**: No-Editor (无编辑器模式) + +**目标**: 在 Chat 界面完成方案生成与修改,后端直接合成 Word 下载。 + +## **1\. 业务流程 (User Flow)** + +sequenceDiagram + participant User + participant ChatUI + participant Agent (Node.js) + participant PandocSvc (Python) + + User-\>\>ChatUI: "生成完整方案" + ChatUI-\>\>Agent: POST /generate + Agent--\>\>ChatUI: Stream Markdown ("\# 1\. 研究背景...") + + User-\>\>ChatUI: "把样本量部分改一下..." + ChatUI-\>\>Agent: POST /regenerate + Agent--\>\>ChatUI: Stream Updated Markdown + + User-\>\>ChatUI: 点击 \[📥 导出 Word\] + ChatUI-\>\>Agent: POST /export/docx { markdown } + Agent-\>\>PandocSvc: Convert(markdown, reference.docx) + PandocSvc--\>\>Agent: Buffer (Binary) + Agent--\>\>ChatUI: Blob (Download) + +## **2\. 核心技术实现** + +### **2.1 Python 微服务:Pandoc 转换器** + +利用你们现有的 Python 微服务,集成 pypandoc。 + +**Dockerfile 增加依赖:** + +RUN apt-get update && apt-get install \-y pandoc + +**Service 代码 (python-service/app/services/doc\_service.py):** + +import pypandoc +import os + +def convert\_md\_to\_docx(markdown\_text: str, output\_path: str): + \# 使用参考文档 (Reference Doc) 来控制样式(字体、字号、页眉) + reference\_doc \= os.path.join(os.path.dirname(\_\_file\_\_), 'assets/style\_template.docx') + + pypandoc.convert\_text( + markdown\_text, + 'docx', + format='markdown', + outputfile=output\_path, + extra\_args=\[f'--reference-doc={reference\_doc}'\] + ) + +### **2.2 Node.js 后端:导出 API** + +// backend/src/modules/aia/controllers/exportController.ts + +export const exportToWord \= async (req, reply) \=\> { + const { markdown } \= req.body; + + // 1\. 调用 Python 微服务 + const response \= await pythonService.post('/convert/docx', { content: markdown }, { responseType: 'arraybuffer' }); + + // 2\. 返回文件流 + reply.header('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + reply.header('Content-Disposition', 'attachment; filename="protocol.docx"'); + return reply.send(response.data); +}; + +### **2.3 前端:Chat 组件增强** + +在 AIStreamChat 的消息组件中,增加导出按钮。 + +// frontend-v2/src/shared/components/Chat/MessageBubble.tsx + +const MessageBubble \= ({ content, role }) \=\> { + const handleDownload \= async () \=\> { + const blob \= await api.post('/aia/export/docx', { markdown: content }, { responseType: 'blob' }); + saveAs(blob, '研究方案.docx'); + }; + + return ( + \
+ \{content}\ + + {role \=== 'assistant' && ( + \
+ \ - 研究方案
-
@@ -241,6 +295,13 @@ export const ProtocolAgentPage: React.FC = () => {
+ {/* 视图切换器 */} +
当前阶段: Step {currentStageIndex + 1}: {currentStageName} @@ -251,17 +312,43 @@ export const ProtocolAgentPage: React.FC = () => {
- {/* 聊天 + 状态面板 */} + {/* 聊天 + 右侧面板(动态切换) */}
- {/* 聊天区域 */} - + } + rightPanel={ + viewMode === 'context' ? ( + + ) : ( + + ) + } /> - - {/* 状态面板 */} -
diff --git a/frontend-v2/src/modules/aia/protocol-agent/components/ActionCard.tsx b/frontend-v2/src/modules/aia/protocol-agent/components/ActionCard.tsx index 76bdfa94..ffbb99ce 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/components/ActionCard.tsx +++ b/frontend-v2/src/modules/aia/protocol-agent/components/ActionCard.tsx @@ -64,3 +64,4 @@ export const ActionCardComponent: React.FC = ({ card }) => { }; + diff --git a/frontend-v2/src/modules/aia/protocol-agent/components/ChatArea.tsx b/frontend-v2/src/modules/aia/protocol-agent/components/ChatArea.tsx index 7dde2cc8..115f09f0 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/components/ChatArea.tsx +++ b/frontend-v2/src/modules/aia/protocol-agent/components/ChatArea.tsx @@ -50,6 +50,8 @@ interface ChatAreaProps { conversationId?: string; context: ProtocolContext | null; onContextUpdate: () => void; + /** 更新对话标题(首次发送消息时调用) */ + onTitleUpdate?: (title: string) => void; } // ============================================ @@ -96,11 +98,15 @@ function parseExtractedData(content: string): { export const ChatArea: React.FC = ({ conversationId, context, - onContextUpdate + onContextUpdate, + onTitleUpdate, }) => { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); const chatContainerRef = useRef(null); + const prevConversationIdRef = useRef(undefined); + const isFirstMount = useRef(true); // 使用通用 useAIStream hook 实现流式输出(打字机效果) const { @@ -130,19 +136,15 @@ export const ChatArea: React.FC = ({ scrollToBottom(); }, [messages, streamContent, scrollToBottom]); - // 初始化欢迎消息 - useEffect(() => { - if (conversationId && messages.length === 0) { - const currentStage = context?.currentStage || 'scientific_question'; - const stageName = STAGE_NAMES[currentStage] || '科学问题梳理'; - - setMessages([{ - id: 'welcome', - role: 'assistant', - content: `您好!我是研究方案制定助手,将帮助您系统地完成临床研究方案的核心要素设计。 - -我们将一起完成以下5个关键步骤: - + // 生成欢迎消息(紧凑版本) + const createWelcomeMessage = useCallback((currentStage?: string): Message => { + const stage = currentStage || 'scientific_question'; + const stageName = STAGE_NAMES[stage] || '科学问题梳理'; + + return { + id: 'welcome', + role: 'assistant', + content: `您好!我是研究方案制定助手,将帮助您系统地完成临床研究方案的核心要素设计。我们将一起完成以下5个关键步骤: 1️⃣ **科学问题梳理** - 明确研究要解决的核心问题 2️⃣ **PICO要素** - 确定研究人群、干预、对照和结局 3️⃣ **研究设计** - 选择合适的研究类型和方法 @@ -151,17 +153,77 @@ export const ChatArea: React.FC = ({ 完成这5个要素后,您可以**一键生成完整的研究方案**并下载为Word文档。 ---- - 📍 **当前阶段**: ${stageName} - 让我们开始吧!请先告诉我,您想研究什么问题?或者描述一下您的研究背景和想法。`, - stage: currentStage, - stageName, - timestamp: new Date(), - }]); + stage, + stageName, + timestamp: new Date(), + }; + }, []); + + // 加载历史消息(当 conversationId 变化时) + useEffect(() => { + // 首次挂载或 conversationId 变化时执行 + const shouldUpdate = isFirstMount.current || conversationId !== prevConversationIdRef.current; + + if (!shouldUpdate) { + return; } - }, [conversationId, messages.length, context]); + + isFirstMount.current = false; + prevConversationIdRef.current = conversationId; + + // 如果没有 conversationId,显示欢迎消息(等待自动创建会话) + if (!conversationId) { + setMessages([createWelcomeMessage(context?.currentStage)]); + return; + } + + const loadHistoryMessages = async () => { + setIsLoadingHistory(true); + try { + const token = getAccessToken(); + const response = await fetch(`/api/v1/aia/protocol-agent/messages/${conversationId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + const result = await response.json(); + const historyMessages = result.data?.messages || []; + + if (historyMessages.length > 0) { + // 有历史消息:加载历史 + 欢迎消息在最前 + const loadedMessages: Message[] = historyMessages.map((m: any) => ({ + id: m.id, + role: m.role, + content: m.content, + thinkingContent: m.thinkingContent, + timestamp: new Date(m.createdAt), + })); + + // 在历史消息前添加欢迎消息 + setMessages([createWelcomeMessage(context?.currentStage), ...loadedMessages]); + } else { + // 没有历史消息:只显示欢迎消息 + setMessages([createWelcomeMessage(context?.currentStage)]); + } + } else { + // API 失败:显示欢迎消息 + setMessages([createWelcomeMessage(context?.currentStage)]); + } + } catch (error) { + console.error('[ChatArea] 加载历史消息失败:', error); + // 出错也显示欢迎消息 + setMessages([createWelcomeMessage(context?.currentStage)]); + } finally { + setIsLoadingHistory(false); + } + }; + + loadHistoryMessages(); + }, [conversationId, context?.currentStage, createWelcomeMessage]); // 处理流式响应完成 useEffect(() => { @@ -222,6 +284,9 @@ export const ChatArea: React.FC = ({ const userContent = input.trim(); + // 检查是否是首次用户消息(只有欢迎消息时) + const isFirstUserMessage = messages.filter(m => m.role === 'user').length === 0; + // 添加用户消息 const userMessage: Message = { id: Date.now().toString(), @@ -232,11 +297,23 @@ export const ChatArea: React.FC = ({ setMessages(prev => [...prev, userMessage]); setInput(''); - // 使用 useAIStream 发送消息(流式输出) + // 首次消息时更新对话标题(锦上添花,不影响核心功能) + if (isFirstUserMessage && onTitleUpdate) { + try { + const newTitle = userContent.length > 20 + ? userContent.slice(0, 20) + '...' + : userContent; + onTitleUpdate(newTitle); + } catch (e) { + console.warn('[ChatArea] 更新标题失败,不影响对话:', e); + } + } + + // 发送消息 await sendStreamMessage(userContent, { conversationId, }); - }, [input, conversationId, isStreaming, sendStreamMessage]); + }, [input, conversationId, isStreaming, sendStreamMessage, messages, onTitleUpdate]); /** * 处理同步到方案 @@ -321,6 +398,14 @@ export const ChatArea: React.FC = ({
{/* 聊天历史 */}
+ {/* 加载历史消息时显示加载状态 */} + {isLoadingHistory && ( +
+
+ 加载历史消息... +
+ )} + {messages.map(msg => (
{msg.role === 'user' && ( diff --git a/frontend-v2/src/modules/aia/protocol-agent/components/DocumentPanel.tsx b/frontend-v2/src/modules/aia/protocol-agent/components/DocumentPanel.tsx new file mode 100644 index 00000000..4467576d --- /dev/null +++ b/frontend-v2/src/modules/aia/protocol-agent/components/DocumentPanel.tsx @@ -0,0 +1,334 @@ +/** + * DocumentPanel - 文档预览面板 + * + * A4 纸张效果预览,显示生成的研究方案 + * 支持: + * - 流式渲染 + 打字机效果 + * - 分章节显示 + * - "讨论与优化" 按钮(Phase 2) + * - 复制、导出 Word + */ + +import React, { useRef, useEffect, useMemo, useState, useCallback } from 'react'; +import { Copy, Download, Loader2, MessageSquareText, ChevronDown } from 'lucide-react'; + +interface DocumentPanelProps { + /** Markdown 内容 */ + content: string; + /** 是否正在生成中 */ + isGenerating?: boolean; + /** 当前生成的章节名称 */ + currentSection?: string; + /** 导出 Word 回调 */ + onExportWord?: () => void; + /** 导出中状态 */ + isExporting?: boolean; + /** 章节讨论回调(Phase 2)*/ + onSectionDiscuss?: (sectionId: string, sectionTitle: string) => void; +} + +export const DocumentPanel: React.FC = ({ + content, + isGenerating = false, + currentSection, + onExportWord, + isExporting = false, + onSectionDiscuss, +}) => { + const contentRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const prevGeneratingRef = useRef(false); + + // 监测用户是否手动滚动 + const handleScroll = useCallback(() => { + if (!contentRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = contentRef.current; + // 如果用户离底部超过 100px,认为是手动滚动 + const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; + setAutoScroll(isNearBottom); + setShowScrollToBottom(!isNearBottom); + }, []); + + // 生成开始时重置到顶部 + useEffect(() => { + if (isGenerating && !prevGeneratingRef.current && contentRef.current) { + // 刚开始生成时滚动到顶部 + contentRef.current.scrollTop = 0; + setAutoScroll(true); + } + prevGeneratingRef.current = isGenerating; + }, [isGenerating]); + + // 自动滚动到最新内容(只在 autoScroll 开启时) + useEffect(() => { + if (isGenerating && autoScroll && contentRef.current) { + // 使用平滑滚动跟随内容 + contentRef.current.scrollTo({ + top: contentRef.current.scrollHeight, + behavior: 'smooth', + }); + } + }, [content, isGenerating, autoScroll]); + + // 滚动到底部按钮点击 + const scrollToBottom = useCallback(() => { + if (contentRef.current) { + contentRef.current.scrollTo({ + top: contentRef.current.scrollHeight, + behavior: 'smooth', + }); + setAutoScroll(true); + setShowScrollToBottom(false); + } + }, []); + + // 复制到剪贴板 + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(content); + // TODO: 显示 toast 提示 + alert('已复制到剪贴板'); + } catch (err) { + console.error('复制失败:', err); + } + }; + + // 空状态 + if (!content && !isGenerating) { + return ( +
+
+
📄
+

尚未生成研究方案

+

完成研究摘要后,点击"一键生成研究方案"开始撰写

+
+
+ ); + } + + return ( +
+ {/* 工具栏 */} +
+
+ {isGenerating && ( +
+ + 正在撰写{currentSection ? `「${currentSection}」` : '...'}... +
+ )} +
+
+ + +
+
+ + {/* A4 纸张预览 */} +
+
+
+ + + {/* 打字机光标 */} + {isGenerating && |} +
+
+ + {/* 底部留白 */} +
+
+ + {/* 滚动到底部按钮(用户手动滚动后显示) */} + {showScrollToBottom && isGenerating && ( + + )} +
+ ); +}; + +/** + * 研究方案 Markdown 渲染器 + * 支持章节级别的"讨论与优化"按钮 + */ +interface ProtocolMarkdownRendererProps { + content: string; + onSectionDiscuss?: (sectionId: string, sectionTitle: string) => void; +} + +const ProtocolMarkdownRenderer: React.FC = ({ + content, + onSectionDiscuss, +}) => { + const elements = useMemo(() => { + const lines = content.split('\n'); + const result: React.ReactNode[] = []; + let key = 0; + let currentParagraph: string[] = []; + + const flushParagraph = () => { + if (currentParagraph.length > 0) { + const text = currentParagraph.join(' ').trim(); + if (text) { + result.push( +

+ {formatInlineText(text)} +

+ ); + } + currentParagraph = []; + } + }; + + for (const line of lines) { + const trimmed = line.trim(); + + // 主标题 # + if (trimmed.startsWith('# ') && !trimmed.startsWith('## ')) { + flushParagraph(); + result.push( +

{formatInlineText(trimmed.slice(2))}

+ ); + continue; + } + + // 二级标题 ## (章节标题,添加讨论按钮) + if (trimmed.startsWith('## ')) { + flushParagraph(); + const titleText = trimmed.slice(3); + const match = titleText.match(/^(\d+)\.\s*(.+)$/); + const sectionNumber = match?.[1]; + const sectionTitle = match?.[2] || titleText; + + result.push( +
+

{formatInlineText(titleText)}

+ {onSectionDiscuss && sectionNumber && ( + + )} +
+ ); + continue; + } + + // 三级标题 ### + if (trimmed.startsWith('### ')) { + flushParagraph(); + result.push( +

{formatInlineText(trimmed.slice(4))}

+ ); + continue; + } + + // 无序列表 + if (trimmed.match(/^[\-\*]\s+/)) { + flushParagraph(); + result.push( +
  • + {formatInlineText(trimmed.replace(/^[\-\*]\s+/, ''))} +
  • + ); + continue; + } + + // 有序列表 + if (trimmed.match(/^\d+\.\s+/)) { + flushParagraph(); + result.push( +
  • + {formatInlineText(trimmed.replace(/^\d+\.\s+/, ''))} +
  • + ); + continue; + } + + // 空行 + if (trimmed === '') { + flushParagraph(); + continue; + } + + // 普通文本,累积到段落 + currentParagraph.push(trimmed); + } + + flushParagraph(); + return result; + }, [content, onSectionDiscuss]); + + return <>{elements}; +}; + +/** + * 处理行内格式(粗体、斜体) + */ +function formatInlineText(text: string): React.ReactNode { + // 简单的粗体处理 + const parts: React.ReactNode[] = []; + const regex = /\*\*([^*]+)\*\*/g; + let lastIndex = 0; + let match; + let key = 0; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push({text.slice(lastIndex, match.index)}); + } + parts.push({match[1]}); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + parts.push({text.slice(lastIndex)}); + } + + return parts.length === 0 ? text : parts.length === 1 ? parts[0] : <>{parts}; +} + +export default DocumentPanel; + diff --git a/frontend-v2/src/modules/aia/protocol-agent/components/MarkdownContent.tsx b/frontend-v2/src/modules/aia/protocol-agent/components/MarkdownContent.tsx index 4be0331c..c766461c 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/components/MarkdownContent.tsx +++ b/frontend-v2/src/modules/aia/protocol-agent/components/MarkdownContent.tsx @@ -83,9 +83,15 @@ function parseMarkdown(text: string): React.ReactNode[] { // 非列表内容,先清空列表 flushList(); - // 空行 + // 空行 - 只添加小间距,不添加 br if (line.trim() === '') { - elements.push(
    ); + // 跳过空行,段落之间的间距由 CSS margin 控制 + continue; + } + + // 分隔线 --- + if (line.trim() === '---' || line.trim() === '***') { + elements.push(
    ); continue; } @@ -220,3 +226,4 @@ export const MarkdownContent: React.FC = ({ export default MarkdownContent; + diff --git a/frontend-v2/src/modules/aia/protocol-agent/components/ReflexionMessage.tsx b/frontend-v2/src/modules/aia/protocol-agent/components/ReflexionMessage.tsx index dfb8f982..916dca44 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/components/ReflexionMessage.tsx +++ b/frontend-v2/src/modules/aia/protocol-agent/components/ReflexionMessage.tsx @@ -50,3 +50,4 @@ function formatTime(date: Date): string { } + diff --git a/frontend-v2/src/modules/aia/protocol-agent/components/ResizableSplitPane.tsx b/frontend-v2/src/modules/aia/protocol-agent/components/ResizableSplitPane.tsx new file mode 100644 index 00000000..7d748d90 --- /dev/null +++ b/frontend-v2/src/modules/aia/protocol-agent/components/ResizableSplitPane.tsx @@ -0,0 +1,206 @@ +/** + * ResizableSplitPane - 可拖拽分栏组件 + * + * 支持: + * - 动态调整左右面板比例 + * - 拖拽手柄 + * - 比例记忆 (localStorage) + * - 平滑过渡动画 + */ + +import React, { useState, useCallback, useEffect, useRef } from 'react'; + +interface ResizableSplitPaneProps { + /** 左侧面板 */ + leftPanel: React.ReactNode; + /** 右侧面板 */ + rightPanel: React.ReactNode; + /** 默认左侧宽度百分比 (0-100) */ + defaultLeftRatio?: number; + /** 最小左侧宽度百分比 */ + minLeftRatio?: number; + /** 最大左侧宽度百分比 */ + maxLeftRatio?: number; + /** 是否启用拖拽 */ + enableDrag?: boolean; + /** 比例变化回调 */ + onRatioChange?: (leftRatio: number) => void; + /** localStorage 存储 key */ + storageKey?: string; + /** CSS 类名 */ + className?: string; +} + +export const ResizableSplitPane: React.FC = ({ + leftPanel, + rightPanel, + defaultLeftRatio = 65, + minLeftRatio = 25, + maxLeftRatio = 80, + enableDrag = true, + onRatioChange, + storageKey = 'protocol-agent-split-ratio', + className = '', +}) => { + // 从 localStorage 读取保存的比例,否则使用默认值 + const [leftRatio, setLeftRatio] = useState(() => { + if (storageKey) { + const saved = localStorage.getItem(storageKey); + if (saved) { + const parsed = parseFloat(saved); + if (!isNaN(parsed) && parsed >= minLeftRatio && parsed <= maxLeftRatio) { + return parsed; + } + } + } + return defaultLeftRatio; + }); + + const [isDragging, setIsDragging] = useState(false); + const containerRef = useRef(null); + + // 当 defaultLeftRatio 变化时更新(用于视图切换时的自动调整) + useEffect(() => { + setLeftRatio(defaultLeftRatio); + }, [defaultLeftRatio]); + + // 保存到 localStorage + useEffect(() => { + if (storageKey && !isDragging) { + localStorage.setItem(storageKey, leftRatio.toString()); + } + }, [leftRatio, storageKey, isDragging]); + + // 拖拽处理 + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (!enableDrag) return; + e.preventDefault(); + setIsDragging(true); + }, [enableDrag]); + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!isDragging || !containerRef.current) return; + + const container = containerRef.current; + const rect = container.getBoundingClientRect(); + const newRatio = ((e.clientX - rect.left) / rect.width) * 100; + + // 限制范围 + const clampedRatio = Math.max(minLeftRatio, Math.min(maxLeftRatio, newRatio)); + setLeftRatio(clampedRatio); + onRatioChange?.(clampedRatio); + }, [isDragging, minLeftRatio, maxLeftRatio, onRatioChange]); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + // 绑定全局鼠标事件 + useEffect(() => { + if (isDragging) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + } + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [isDragging, handleMouseMove, handleMouseUp]); + + return ( +
    + {/* 左侧面板 */} +
    + {leftPanel} +
    + + {/* 拖拽手柄 */} + {enableDrag && ( +
    +
    + {/* 悬停区域扩大 */} +
    { + const handle = e.currentTarget.previousSibling as HTMLElement; + if (handle) handle.style.opacity = '1'; + }} + onMouseLeave={(e) => { + if (!isDragging) { + const handle = e.currentTarget.previousSibling as HTMLElement; + if (handle) handle.style.opacity = '0'; + } + }} + /> +
    + )} + + {/* 右侧面板 */} +
    + {rightPanel} +
    +
    + ); +}; + +export default ResizableSplitPane; + diff --git a/frontend-v2/src/modules/aia/protocol-agent/components/StageCard.tsx b/frontend-v2/src/modules/aia/protocol-agent/components/StageCard.tsx index 92cc76f9..c225855f 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/components/StageCard.tsx +++ b/frontend-v2/src/modules/aia/protocol-agent/components/StageCard.tsx @@ -1,11 +1,14 @@ /** * Stage Card - 阶段状态卡片 * - * 100%还原原型图阶段卡片设计 + * 支持: + * - 已完成阶段:显示数据 + 编辑按钮 + 折叠展开 + * - 进行中阶段:显示加载动画 + * - 待完成阶段:显示添加按钮(手动填写) */ -import React from 'react'; -import { Check, Loader2, Edit2 } from 'lucide-react'; +import React, { useState } from 'react'; +import { Check, Loader2, Edit2, Plus, Circle, ChevronDown, ChevronUp } from 'lucide-react'; import type { StageInfo, ScientificQuestionData, @@ -18,7 +21,10 @@ import type { interface StageCardProps { stage: StageInfo; index: number; + /** 编辑已完成的数据 */ onEdit?: () => void; + /** 手动添加数据(未完成阶段) */ + onAdd?: () => void; } const STAGE_TITLES: Record = { @@ -29,7 +35,7 @@ const STAGE_TITLES: Record = { endpoints: '观察指标', }; -export const StageCard: React.FC = ({ stage, index, onEdit }) => { +export const StageCard: React.FC = ({ stage, index, onEdit, onAdd }) => { const { stageCode, status, data } = stage; const title = STAGE_TITLES[stageCode] || stage.stageName; const number = (index + 1).toString().padStart(2, '0'); @@ -47,6 +53,7 @@ export const StageCard: React.FC = ({ stage, index, onEdit }) =>

    {number} {title}

    + {/* 已完成:显示编辑按钮 */} {status === 'completed' && onEdit && ( )} + {/* 待完成:显示添加按钮 */} + {status === 'pending' && onAdd && ( + + )} + {/* 状态图标 */} {status === 'completed' && } {status === 'current' && } + {status === 'pending' && }
    @@ -66,6 +85,75 @@ export const StageCard: React.FC = ({ stage, index, onEdit }) => ); }; +/** + * 可折叠内容包装器 + */ +interface CollapsibleContentProps { + children: React.ReactNode; + /** 预览高度(像素) */ + previewHeight?: number; + /** 默认是否展开 */ + defaultExpanded?: boolean; +} + +const CollapsibleContent: React.FC = ({ + children, + previewHeight = 80, + defaultExpanded = false +}) => { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const [needsExpand, setNeedsExpand] = useState(false); + const contentRef = React.useRef(null); + + // 检查内容是否需要折叠 + React.useEffect(() => { + if (contentRef.current) { + const contentHeight = contentRef.current.scrollHeight; + setNeedsExpand(contentHeight > previewHeight + 20); // 20px 缓冲 + } + }, [children, previewHeight]); + + return ( +
    +
    + {children} +
    + + {/* 渐变遮罩(折叠时显示) */} + {!isExpanded && needsExpand && ( +
    + )} + + {/* 展开/收起按钮 */} + {needsExpand && ( + + )} +
    + ); +}; + /** * 渲染不同阶段的数据 */ @@ -73,28 +161,47 @@ const StageDataRenderer: React.FC<{ stageCode: string; data: ScientificQuestionData | PICOData | StudyDesignData | SampleSizeData | EndpointsData; }> = ({ stageCode, data }) => { - switch (stageCode) { - case 'scientific_question': - return ; - case 'pico': - return ; - case 'study_design': - return ; - case 'sample_size': - return ; - case 'endpoints': - return ; - default: - return null; - } + // 根据阶段类型设置不同的预览高度 + const previewHeights: Record = { + scientific_question: 60, // 约3行 + pico: 120, // 约4个元素的预览 + study_design: 80, // 标签 + 部分特征 + sample_size: 100, // 结果 + 部分参数 + endpoints: 120, // 部分指标 + }; + + const previewHeight = previewHeights[stageCode] || 80; + + const renderContent = () => { + switch (stageCode) { + case 'scientific_question': + return ; + case 'pico': + return ; + case 'study_design': + return ; + case 'sample_size': + return ; + case 'endpoints': + return ; + default: + return null; + } + }; + + return ( + + {renderContent()} + + ); }; /** * 科学问题卡片 */ const ScientificQuestionCard: React.FC<{ data: ScientificQuestionData }> = ({ data }) => ( -
    -

    {data.content}

    +
    +

    {data.content || '暂无数据'}

    ); @@ -105,19 +212,19 @@ const PICOCard: React.FC<{ data: PICOData }> = ({ data }) => (
    P - {data.population} + {data.population || '暂无数据'}
    I - {data.intervention} + {data.intervention || '暂无数据'}
    C - {data.comparison} + {data.comparison || '暂无数据'}
    O - {data.outcome} + {data.outcome || '暂无数据'}
    ); @@ -126,13 +233,26 @@ const PICOCard: React.FC<{ data: PICOData }> = ({ data }) => ( * 研究设计卡片 */ const StudyDesignCard: React.FC<{ data: StudyDesignData }> = ({ data }) => ( -
    -
    - {data.studyType} - {data.design?.map((item, i) => ( - {item} - ))} +
    +
    + {data.studyType || '暂无数据'}
    + {data.design && data.design.length > 0 && ( +
    + 设计特征: +
    + {data.design.map((item, i) => ( + {item} + ))} +
    +
    + )} + {data.details && ( +
    + 详细说明: +

    {data.details}

    +
    + )}
    ); @@ -141,44 +261,115 @@ const StudyDesignCard: React.FC<{ data: StudyDesignData }> = ({ data }) => ( */ const SampleSizeCard: React.FC<{ data: SampleSizeData }> = ({ data }) => (
    -
    - 总样本量 (N) - N = {data.sampleSize} + {/* 结果高亮显示 */} +
    + 总样本量 + N = {data.sampleSize || '待计算'}
    + + {/* 计算参数 */} {data.calculation && ( -
    - {data.calculation.alpha && ( +
    +
    📊 计算参数
    +
    - α = {data.calculation.alpha} + 显著性水平 + α = {data.calculation.alpha || 0.05}
    - )} - {data.calculation.power && (
    - Power = {data.calculation.power} + 检验效能 + 1-β = {data.calculation.power || 0.8}
    - )} + {data.calculation.effectSize && ( +
    + 效应量 + {data.calculation.effectSize} +
    + )} +
    +
    + )} + + {/* 计算依据 */} + {data.rationale && ( +
    +
    📝 计算依据
    +

    {data.rationale}

    +
    + )} + + {/* 失访率 */} + {data.dropoutRate && ( +
    +
    📉 失访调整
    +

    {data.dropoutRate}

    +
    + )} + + {/* 分组信息 */} + {data.groups && data.groups.length > 0 && ( +
    +
    👥 分组安排
    +
    + {data.groups.map((group, i) => ( +
    + {group.name}: + {group.size}例 +
    + ))} +
    )}
    ); +/** + * 安全地将值转换为数组 + */ +const toArray = (value: unknown): string[] => { + if (Array.isArray(value)) return value; + if (typeof value === 'string' && value.trim()) return [value]; + return []; +}; + /** * 观察指标卡片 */ -const EndpointsCard: React.FC<{ data: EndpointsData }> = ({ data }) => ( -
    +const EndpointsCard: React.FC<{ data: EndpointsData }> = ({ data }) => { + // 安全处理数据,确保都是数组 + const primaryOutcomes = toArray(data.outcomes?.primary); + const secondaryOutcomes = toArray(data.outcomes?.secondary); + const safetyOutcomes = toArray(data.outcomes?.safety); + const confounders = toArray(data.confounders); + const demographics = toArray(data.baseline?.demographics); + const clinicalHistory = toArray(data.baseline?.clinicalHistory); + + return ( +
    {/* 基线指标 */} - {data.baseline && Object.values(data.baseline).some(arr => arr && arr.length > 0) && ( + {(demographics.length > 0 || clinicalHistory.length > 0) && (
    📊 基线指标
    -
    - {data.baseline.demographics?.map((item, i) => ( - {item} - ))} - {data.baseline.clinicalHistory?.map((item, i) => ( - {item} - ))} -
    + {demographics.length > 0 && ( +
    + 人口学特征: +
    + {demographics.map((item, i) => ( +
    {item}
    + ))} +
    +
    + )} + {clinicalHistory.length > 0 && ( +
    + 临床病史: +
    + {clinicalHistory.map((item, i) => ( +
    {item}
    + ))} +
    +
    + )}
    )} @@ -186,64 +377,101 @@ const EndpointsCard: React.FC<{ data: EndpointsData }> = ({ data }) => ( {data.exposure && (
    💊 暴露指标
    + {data.exposure.name && ( +
    + 暴露名称: + {data.exposure.name} +
    + )} + {data.exposure.definition && ( +
    + 定义: + {data.exposure.definition} +
    + )} + {data.exposure.measurement && ( +
    + 测量方法: + {data.exposure.measurement} +
    + )} + {data.exposure.timing && ( +
    + 测量时点: + {data.exposure.timing} +
    + )} {data.exposure.intervention && ( -
    - 干预: - {data.exposure.intervention} +
    + 干预组: + {data.exposure.intervention}
    )} {data.exposure.control && ( -
    - 对照: - {data.exposure.control} +
    + 对照组: + {data.exposure.control}
    )}
    )} {/* 结局指标 */} - {data.outcomes && ( + {(primaryOutcomes.length > 0 || secondaryOutcomes.length > 0 || safetyOutcomes.length > 0) && (
    🎯 结局指标
    - {data.outcomes.primary && data.outcomes.primary.length > 0 && ( + {primaryOutcomes.length > 0 && (
    - 主要: - {data.outcomes.primary.map((item, i) => ( - {item} - ))} + 主要结局: +
    + {primaryOutcomes.map((item, i) => ( +
    • {item}
    + ))} +
    )} - {data.outcomes.secondary && data.outcomes.secondary.length > 0 && ( + {secondaryOutcomes.length > 0 && (
    - 次要: - {data.outcomes.secondary.map((item, i) => ( - {item} - ))} + 次要结局: +
    + {secondaryOutcomes.map((item, i) => ( +
    • {item}
    + ))} +
    )} - {data.outcomes.safety && data.outcomes.safety.length > 0 && ( + {safetyOutcomes.length > 0 && (
    - 安全: - {data.outcomes.safety.map((item, i) => ( - {item} - ))} + 安全性指标: +
    + {safetyOutcomes.map((item, i) => ( +
    • {item}
    + ))} +
    )}
    )} {/* 混杂因素 */} - {data.confounders && data.confounders.length > 0 && ( + {confounders.length > 0 && (
    ⚠️ 混杂因素
    -
    - {data.confounders.map((item, i) => ( - {item} +
    + {confounders.map((item, i) => ( +
    • {item}
    ))}
    )} + + {/* 随访方案 */} + {data.followUp && ( +
    +
    📅 随访方案
    +

    {data.followUp}

    +
    + )}
    -); - - + ); +}; diff --git a/frontend-v2/src/modules/aia/protocol-agent/components/StageEditModal.tsx b/frontend-v2/src/modules/aia/protocol-agent/components/StageEditModal.tsx index 2ded1a70..9b7a3bf3 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/components/StageEditModal.tsx +++ b/frontend-v2/src/modules/aia/protocol-agent/components/StageEditModal.tsx @@ -12,6 +12,8 @@ interface StageEditModalProps { stage: StageInfo; onSave: (stageCode: string, data: Record) => Promise; onClose: () => void; + /** 是否为添加模式(而非编辑模式) */ + isAdding?: boolean; } const STAGE_TITLES: Record = { @@ -22,7 +24,7 @@ const STAGE_TITLES: Record = { endpoints: '观察指标', }; -export const StageEditModal: React.FC = ({ stage, onSave, onClose }) => { +export const StageEditModal: React.FC = ({ stage, onSave, onClose, isAdding = false }) => { const [formData, setFormData] = useState>({}); const [saving, setSaving] = useState(false); @@ -239,7 +241,7 @@ export const StageEditModal: React.FC = ({ stage, onSave, o
    e.stopPropagation()}>
    -

    编辑 {STAGE_TITLES[stage.stageCode] || stage.stageName}

    +

    {isAdding ? '添加' : '编辑'} {STAGE_TITLES[stage.stageCode] || stage.stageName}

    @@ -264,3 +266,4 @@ export const StageEditModal: React.FC = ({ stage, onSave, o ); }; + diff --git a/frontend-v2/src/modules/aia/protocol-agent/components/StatePanel.tsx b/frontend-v2/src/modules/aia/protocol-agent/components/StatePanel.tsx index 6a65a5c1..42ef5c13 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/components/StatePanel.tsx +++ b/frontend-v2/src/modules/aia/protocol-agent/components/StatePanel.tsx @@ -1,33 +1,97 @@ /** * State Panel - 方案状态面板 * - * 100%还原原型图右侧状态面板设计 - * 支持编辑已同步的数据 + * 研究摘要视图: + * - 显示 5 个阶段的数据卡片 + * - 已完成阶段:可编辑 + * - 未完成阶段:可手动添加 + * - 一键生成按钮 */ import React, { useState } from 'react'; -import { FileText, Check, Loader2 } from 'lucide-react'; -import type { ProtocolContext, StageInfo } from '../types'; +import { FileText, Loader2 } from 'lucide-react'; +import type { ProtocolContext, StageInfo, ProtocolStageCode } from '../types'; import { StageCard } from './StageCard'; import { StageEditModal } from './StageEditModal'; +// 阶段名称映射 +const STAGE_NAMES: Record = { + scientific_question: '科学问题', + pico: 'PICO', + study_design: '研究设计', + sample_size: '样本量', + endpoints: '观察指标', +}; + +// 获取空数据模板(用于手动添加) +function getEmptyDataForStage(stageCode: ProtocolStageCode): Record { + switch (stageCode) { + case 'scientific_question': + return { content: '' }; + case 'pico': + return { population: '', intervention: '', comparison: '', outcome: '' }; + case 'study_design': + return { studyType: '', design: [] }; + case 'sample_size': + return { sampleSize: 0, calculation: { alpha: 0.05, power: 0.8 } }; + case 'endpoints': + return { outcomes: { primary: [], secondary: [], safety: [] }, confounders: [] }; + default: + return {}; + } +} + interface StatePanelProps { context: ProtocolContext | null; onStageUpdate?: (stageCode: string, data: Record) => Promise; + /** 触发一键生成 */ + onStartGeneration?: () => void; + /** 是否可以生成(必填项校验通过) */ + canGenerate?: boolean; + /** 是否正在生成中 */ + isGenerating?: boolean; } -export const StatePanel: React.FC = ({ context, onStageUpdate }) => { +export const StatePanel: React.FC = ({ + context, + onStageUpdate, + onStartGeneration, + canGenerate: canGenerateProp, + isGenerating = false, +}) => { const [editingStage, setEditingStage] = useState(null); + // 用于手动添加数据时创建空的 stage + const [addingStageCode, setAddingStageCode] = useState(null); + // 编辑已完成的阶段 const handleEdit = (stage: StageInfo) => { setEditingStage(stage); }; + // 手动添加数据(未完成阶段) + const handleAdd = (stageCode: ProtocolStageCode) => { + // 创建一个空的 stage 对象用于编辑 + const emptyStage: StageInfo = { + stageCode, + stageName: STAGE_NAMES[stageCode] || stageCode, + status: 'pending', + data: getEmptyDataForStage(stageCode), + }; + setEditingStage(emptyStage); + setAddingStageCode(stageCode); + }; + const handleSaveEdit = async (stageCode: string, newData: Record) => { if (onStageUpdate) { await onStageUpdate(stageCode, newData); } setEditingStage(null); + setAddingStageCode(null); + }; + + const handleCloseModal = () => { + setEditingStage(null); + setAddingStageCode(null); }; if (!context) { return ( @@ -50,6 +114,9 @@ export const StatePanel: React.FC = ({ context, onStageUpdate } } const { stages, progress, canGenerate } = context; + + // 使用 prop 或 context 的 canGenerate + const effectiveCanGenerate = canGenerateProp ?? canGenerate; return (
    ); diff --git a/frontend-v2/src/modules/aia/protocol-agent/components/SyncButton.tsx b/frontend-v2/src/modules/aia/protocol-agent/components/SyncButton.tsx index c498a5aa..5e6ddc33 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/components/SyncButton.tsx +++ b/frontend-v2/src/modules/aia/protocol-agent/components/SyncButton.tsx @@ -48,3 +48,4 @@ export const SyncButton: React.FC = ({ syncData, onSync }) => { }; + diff --git a/frontend-v2/src/modules/aia/protocol-agent/components/ViewSwitcher.tsx b/frontend-v2/src/modules/aia/protocol-agent/components/ViewSwitcher.tsx new file mode 100644 index 00000000..b772437b --- /dev/null +++ b/frontend-v2/src/modules/aia/protocol-agent/components/ViewSwitcher.tsx @@ -0,0 +1,52 @@ +/** + * ViewSwitcher - 视图切换组件 + * + * 在 Header 区域显示,切换 "研究摘要" / "完整方案" 视图 + */ + +import React from 'react'; +import { Database, FileText } from 'lucide-react'; + +export type ViewMode = 'context' | 'document'; + +interface ViewSwitcherProps { + /** 当前视图模式 */ + currentView: ViewMode; + /** 视图切换回调 */ + onViewChange: (view: ViewMode) => void; + /** 是否禁用文档视图(未生成方案时) */ + documentDisabled?: boolean; + /** 禁用提示 */ + documentDisabledTooltip?: string; +} + +export const ViewSwitcher: React.FC = ({ + currentView, + onViewChange, + documentDisabled = false, + documentDisabledTooltip = '请先生成研究方案', +}) => { + return ( +
    + + +
    + ); +}; + +export default ViewSwitcher; + diff --git a/frontend-v2/src/modules/aia/protocol-agent/components/index.ts b/frontend-v2/src/modules/aia/protocol-agent/components/index.ts index e46d21cb..ac3edb00 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/components/index.ts +++ b/frontend-v2/src/modules/aia/protocol-agent/components/index.ts @@ -10,3 +10,4 @@ export { ActionCardComponent } from './ActionCard'; export { ReflexionMessage } from './ReflexionMessage'; + diff --git a/frontend-v2/src/modules/aia/protocol-agent/hooks/index.ts b/frontend-v2/src/modules/aia/protocol-agent/hooks/index.ts index f62d053c..760fd6a5 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/hooks/index.ts +++ b/frontend-v2/src/modules/aia/protocol-agent/hooks/index.ts @@ -6,3 +6,4 @@ export { useProtocolContext } from './useProtocolContext'; export { useProtocolConversations } from './useProtocolConversations'; + diff --git a/frontend-v2/src/modules/aia/protocol-agent/hooks/useProtocolConversations.ts b/frontend-v2/src/modules/aia/protocol-agent/hooks/useProtocolConversations.ts index 33397540..a21dad39 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/hooks/useProtocolConversations.ts +++ b/frontend-v2/src/modules/aia/protocol-agent/hooks/useProtocolConversations.ts @@ -15,13 +15,14 @@ export function useProtocolConversations(initialConversationId?: string) { const [loading, setLoading] = useState(false); /** - * 获取会话列表 + * 获取会话列表(带 agentId 过滤) */ const fetchConversations = useCallback(async () => { setLoading(true); try { const token = getAccessToken(); - const response = await fetch(`${API_BASE}/conversations`, { + // 使用 agentId 参数过滤,与 ChatWorkspace 保持一致 + const response = await fetch(`${API_BASE}/conversations?agentId=PROTOCOL_AGENT`, { headers: { 'Authorization': `Bearer ${token}`, }, @@ -32,13 +33,17 @@ export function useProtocolConversations(initialConversationId?: string) { } const result = await response.json(); - const allConversations = result.data || result; + // 后端返回格式:{ data: { conversations: [...] } } + const conversationsList = result.data?.conversations || result.data || []; - // 过滤出Protocol Agent的对话(agentId为PROTOCOL_AGENT) - const protocolConversations = Array.isArray(allConversations) - ? allConversations.filter((conv: any) => - conv.agentId === 'PROTOCOL_AGENT' || conv.agent_id === 'PROTOCOL_AGENT' - ) + const protocolConversations = Array.isArray(conversationsList) + ? conversationsList.map((conv: any) => ({ + id: conv.id, + title: conv.title, + agentId: conv.agentId, + createdAt: conv.createdAt, + updatedAt: conv.updatedAt, + })) : []; setConversations(protocolConversations); @@ -96,12 +101,24 @@ export function useProtocolConversations(initialConversationId?: string) { * 选择对话 */ const selectConversation = useCallback((id: string) => { + // 如果是 "new",表示新对话状态,清空当前对话 + if (id === 'new') { + setCurrentConversation(null); + return; + } const conv = conversations.find(c => c.id === id); if (conv) { setCurrentConversation(conv); } }, [conversations]); + /** + * 清空当前对话(用于新建对话) + */ + const clearCurrentConversation = useCallback(() => { + setCurrentConversation(null); + }, []); + /** * 删除对话 */ @@ -125,6 +142,35 @@ export function useProtocolConversations(initialConversationId?: string) { } }, [currentConversation]); + /** + * 更新对话标题 + */ + const updateConversationTitle = useCallback(async (id: string, title: string) => { + try { + const token = getAccessToken(); + const response = await fetch(`${API_BASE}/conversations/${id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ title }), + }); + + if (response.ok) { + // 更新本地状态 + setConversations(prev => + prev.map(c => c.id === id ? { ...c, title } : c) + ); + if (currentConversation?.id === id) { + setCurrentConversation(prev => prev ? { ...prev, title } : null); + } + } + } catch (err) { + console.error('[useProtocolConversations] updateConversationTitle error:', err); + } + }, [currentConversation]); + // 初次加载 useEffect(() => { fetchConversations(); @@ -137,8 +183,11 @@ export function useProtocolConversations(initialConversationId?: string) { createConversation, selectConversation, deleteConversation, + updateConversationTitle, + clearCurrentConversation, refreshConversations: fetchConversations, }; } + diff --git a/frontend-v2/src/modules/aia/protocol-agent/hooks/useProtocolGeneration.ts b/frontend-v2/src/modules/aia/protocol-agent/hooks/useProtocolGeneration.ts new file mode 100644 index 00000000..dc22282f --- /dev/null +++ b/frontend-v2/src/modules/aia/protocol-agent/hooks/useProtocolGeneration.ts @@ -0,0 +1,260 @@ +/** + * useProtocolGeneration - 研究方案生成状态管理 + * + * 管理: + * - 生成状态(idle/generating/completed) + * - 生成内容(流式 Markdown) + * - 当前章节 + * - 视图模式切换 + */ + +import { useState, useCallback } from 'react'; +import { getAccessToken } from '../../../../framework/auth/api'; +import type { ViewMode } from '../components/ViewSwitcher'; + +interface UseProtocolGenerationOptions { + conversationId?: string; + onViewModeChange?: (mode: ViewMode) => void; +} + +interface GenerationState { + status: 'idle' | 'generating' | 'completed' | 'error'; + content: string; + currentSection: string | null; + error: string | null; +} + +export const useProtocolGeneration = (options: UseProtocolGenerationOptions) => { + const { conversationId, onViewModeChange } = options; + + const [state, setState] = useState({ + status: 'idle', + content: '', + currentSection: null, + error: null, + }); + + const [viewMode, setViewMode] = useState('context'); + + // 切换视图模式 + const handleViewModeChange = useCallback((mode: ViewMode) => { + setViewMode(mode); + onViewModeChange?.(mode); + }, [onViewModeChange]); + + // 开始生成方案 + const startGeneration = useCallback(async () => { + if (!conversationId) { + console.error('No conversationId provided'); + return; + } + + setState({ + status: 'generating', + content: '', + currentSection: '研究标题', + error: null, + }); + + // 自动切换到文档视图 + handleViewModeChange('document'); + + try { + const token = getAccessToken(); + const response = await fetch('/api/v1/aia/protocol-agent/generate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + conversationId, + options: { + style: 'academic', + }, + }), + }); + + if (!response.ok) { + // 尝试解析错误信息 + const errorData = await response.json().catch(() => ({})); + const errorMessage = errorData.error || `生成失败: ${response.status}`; + throw new Error(errorMessage); + } + + // 流式读取 + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('无法获取响应流'); + } + + const decoder = new TextDecoder(); + let fullContent = ''; + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // 解析 SSE 格式 + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // 保留未完成的行 + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6).trim(); + + // SSE 结束标识 + if (data === '[DONE]') { + setState(prev => ({ + ...prev, + status: 'completed', + currentSection: null, + })); + continue; + } + + // 跳过空数据 + if (!data) continue; + + try { + const parsed = JSON.parse(data); + + // OpenAI Compatible 格式: choices[0].delta.content + const deltaContent = parsed.choices?.[0]?.delta?.content; + + if (deltaContent) { + fullContent += deltaContent; + + // 检测当前章节(## 1. 研究背景) + const sectionMatches = fullContent.match(/## (\d+)\. ([^\n]+)/g); + let currentSectionTitle = null; + if (sectionMatches && sectionMatches.length > 0) { + const lastMatch = sectionMatches[sectionMatches.length - 1]; + currentSectionTitle = lastMatch.replace(/## \d+\. /, '').trim(); + } + + setState(prev => ({ + ...prev, + content: fullContent, + currentSection: currentSectionTitle, + })); + } + + // 检查是否完成 + const finishReason = parsed.choices?.[0]?.finish_reason; + if (finishReason === 'stop') { + setState(prev => ({ + ...prev, + status: 'completed', + currentSection: null, + })); + } + } catch (e) { + // JSON 解析失败,记录日志但不中断 + console.warn('SSE 数据解析失败:', data, e); + } + } + } + } + + // 确保最终状态 + setState(prev => ({ + ...prev, + status: 'completed', + content: fullContent, + currentSection: null, + })); + + } catch (error) { + console.error('生成方案失败:', error); + setState(prev => ({ + ...prev, + status: 'error', + error: error instanceof Error ? error.message : '未知错误', + })); + } + }, [conversationId, handleViewModeChange]); + + // 重置状态 + const reset = useCallback(() => { + setState({ + status: 'idle', + content: '', + currentSection: null, + error: null, + }); + handleViewModeChange('context'); + }, [handleViewModeChange]); + + // 导出 Word(使用实际生成的内容) + const exportWord = useCallback(async () => { + if (!state.content) { + console.error('无法导出:尚未生成内容'); + alert('请先生成研究方案'); + return; + } + + try { + const token = getAccessToken(); + // 直接发送生成的 Markdown 内容到转换服务 + const response = await fetch('/api/v1/aia/protocol-agent/export/docx', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + conversationId, + // 关键:发送实际生成的内容 + content: state.content, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || '导出失败'); + } + + // 下载文件 + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `研究方案_${new Date().toISOString().slice(0, 10)}.docx`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + } catch (error) { + console.error('导出 Word 失败:', error); + alert(`导出失败: ${error instanceof Error ? error.message : '未知错误'}`); + throw error; + } + }, [conversationId, state.content]); + + return { + // 状态 + generationStatus: state.status, + generatedContent: state.content, + currentSection: state.currentSection, + generationError: state.error, + viewMode, + + // 计算属性 + isGenerating: state.status === 'generating', + hasGeneratedContent: state.content.length > 0, + + // 方法 + startGeneration, + reset, + exportWord, + setViewMode: handleViewModeChange, + }; +}; + +export default useProtocolGeneration; + diff --git a/frontend-v2/src/modules/aia/protocol-agent/index.ts b/frontend-v2/src/modules/aia/protocol-agent/index.ts index 14a9dd20..4efbb5ac 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/index.ts +++ b/frontend-v2/src/modules/aia/protocol-agent/index.ts @@ -8,3 +8,4 @@ export * from './components'; export * from './hooks'; + diff --git a/frontend-v2/src/modules/aia/protocol-agent/styles/protocol-agent.css b/frontend-v2/src/modules/aia/protocol-agent/styles/protocol-agent.css index 12e46e0c..22dff6a7 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/styles/protocol-agent.css +++ b/frontend-v2/src/modules/aia/protocol-agent/styles/protocol-agent.css @@ -145,20 +145,47 @@ color: #111827; } +/* 返回按钮 */ +.back-to-hub-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + background: none; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + color: #6B7280; + transition: all 0.2s; +} + +.back-to-hub-btn:hover { + background: #F3F4F6; + color: #111827; +} + +/* 新建对话按钮 */ .new-chat-btn { - margin: 12px; - padding: 10px 16px; + margin: 6px 10px; + padding: 6px 10px; background: #6366F1; color: white; border: none; - border-radius: 8px; + border-radius: 6px; display: flex; align-items: center; - gap: 8px; + justify-content: center; + gap: 4px; cursor: pointer; - font-size: 14px; + font-size: 12px; font-weight: 500; transition: all 0.2s; + width: auto; +} + +.new-chat-btn.compact { + padding: 5px 10px; } .new-chat-btn:hover { @@ -168,6 +195,7 @@ /* 会话列表 */ .conversations-list { flex: 1; + min-height: 0; /* 关键:允许 flex 子元素收缩 */ overflow-y: auto; padding: 8px; } @@ -225,6 +253,7 @@ /* ============================================ */ .workspace { flex: 1; + min-height: 0; /* 关键:允许 flex 子元素收缩 */ display: flex; flex-direction: column; background: #FFFFFF; @@ -266,27 +295,32 @@ } .agent-info { - display: flex; - align-items: center; - gap: 12px; + display: flex !important; + flex-direction: row !important; + align-items: center !important; + gap: 10px; + flex-shrink: 0; } .agent-icon { - width: 32px; - height: 32px; + width: 28px; + height: 28px; + min-width: 28px; background: #6366F1; - border-radius: 8px; + border-radius: 6px; display: flex; align-items: center; justify-content: center; color: white; - box-shadow: 0 2px 8px rgba(99, 102, 241, 0.2); + flex-shrink: 0; } .agent-meta { - display: flex; - flex-direction: column; - gap: 2px; + display: flex !important; + flex-direction: row !important; + align-items: center !important; + gap: 8px; + flex-shrink: 0; } .agent-name { @@ -294,14 +328,17 @@ font-weight: 700; color: #111827; margin: 0; + white-space: nowrap; + line-height: 1; } .agent-status { display: flex; align-items: center; - gap: 6px; - font-size: 12px; - color: #6B7280; + gap: 4px; + font-size: 11px; + color: #10B981; + white-space: nowrap; } .status-dot { @@ -356,6 +393,7 @@ /* 工作区主体 */ .workspace-body { flex: 1; + min-height: 0; /* 关键:允许 flex 子元素收缩 */ display: flex; overflow: hidden; } @@ -364,26 +402,49 @@ /* 聊天区域 */ /* ============================================ */ .chat-area { - flex: 1; + width: 100%; + height: 100%; display: flex; flex-direction: column; background: #FFFFFF; position: relative; + overflow: hidden; } /* 聊天容器 */ .chat-container { flex: 1; + min-height: 0; /* 关键:允许 flex 子元素收缩 */ overflow-y: auto; padding: 24px; padding-bottom: 120px; } +/* 加载历史消息 */ +.loading-history { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 20px; + color: #6B7280; + font-size: 13px; +} + +.loading-history .loading-spinner { + width: 20px; + height: 20px; + border: 2px solid #E5E7EB; + border-top-color: #6366F1; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + /* 消息行 */ .message-row { display: flex; - gap: 16px; - margin-bottom: 24px; + gap: 12px; + margin-bottom: 16px; } .message-row.user-row { @@ -447,10 +508,10 @@ /* 聊天气泡 */ .chat-bubble { - padding: 16px; + padding: 14px 16px; border-radius: 12px; font-size: 14px; - line-height: 1.8; + line-height: 1.6; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); white-space: pre-wrap; word-break: break-word; @@ -460,7 +521,7 @@ .chat-bubble h1, .chat-bubble h2, .chat-bubble h3 { - margin: 16px 0 8px 0; + margin: 12px 0 6px 0; font-weight: 700; color: #111827; } @@ -470,17 +531,26 @@ .chat-bubble h3 { font-size: 1.05em; } .chat-bubble p { - margin: 8px 0; + margin: 6px 0; } .chat-bubble ul, .chat-bubble ol { - margin: 8px 0; + margin: 6px 0; padding-left: 24px; } +.chat-bubble ul { + list-style-type: disc; +} + +.chat-bubble ol { + list-style-type: decimal; +} + .chat-bubble li { - margin: 4px 0; + margin: 3px 0; + display: list-item; } .chat-bubble strong, @@ -532,13 +602,19 @@ } .markdown-content p { - margin: 0 0 12px 0; + margin: 0 0 8px 0; } .markdown-content p:last-child { margin-bottom: 0; } +.markdown-content .md-divider { + border: none; + border-top: 1px solid #E5E7EB; + margin: 12px 0; +} + .markdown-content h1, .markdown-content h2, .markdown-content h3 { @@ -556,12 +632,22 @@ .markdown-content ul, .markdown-content ol { margin: 8px 0 12px 0; - padding-left: 20px; + padding-left: 24px; +} + +.markdown-content ul { + list-style-type: disc; +} + +.markdown-content ol { + list-style-type: decimal; + counter-reset: none; /* 确保使用浏览器默认计数 */ } .markdown-content li { margin: 6px 0; line-height: 1.6; + display: list-item; /* 确保显示列表项标记 */ } .markdown-content li::marker { @@ -1001,11 +1087,13 @@ /* 状态面板 */ /* ============================================ */ .state-panel { - width: 350px; + width: 100%; + height: 100%; background: #F9FAFB; border-left: 1px solid #E5E7EB; display: flex; flex-direction: column; + overflow: hidden; } .panel-header { @@ -1085,6 +1173,7 @@ /* 面板主体 */ .panel-body { flex: 1; + min-height: 0; /* 关键:允许 flex 子元素收缩 */ overflow-y: auto; padding: 16px; } @@ -1240,27 +1329,103 @@ .sample-size-data { display: flex; flex-direction: column; + gap: 12px; +} + +/* 结果高亮 */ +.sample-size-result { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + background: linear-gradient(135deg, #EEF2FF 0%, #E0E7FF 100%); + border-radius: 8px; +} + +.sample-size-result .result-label { + font-size: 12px; + color: #4F46E5; + font-weight: 500; +} + +.sample-size-result .result-value { + font-size: 20px; + font-weight: 700; + color: #4F46E5; + font-family: 'Monaco', 'Consolas', monospace; +} + +/* 计算区块 */ +.sample-size-data .calculation-section { + padding: 8px 0; + border-top: 1px solid #E5E7EB; +} + +.sample-size-data .section-title { + font-size: 11px; + color: #6B7280; + font-weight: 600; + margin-bottom: 8px; +} + +/* 参数网格 */ +.sample-size-data .params-grid { + display: grid; + grid-template-columns: 1fr 1fr; gap: 8px; } +.sample-size-data .param-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.sample-size-data .param-item.full-width { + grid-column: span 2; +} + +.sample-size-data .param-label { + font-size: 10px; + color: #9CA3AF; +} + +.sample-size-data .param-value { + font-size: 12px; + color: #374151; + font-family: 'Monaco', 'Consolas', monospace; +} + +/* 计算依据 */ +.sample-size-data .rationale-text { + font-size: 11px; + color: #6B7280; + line-height: 1.5; + margin: 0; +} + +/* 分组信息 */ +.sample-size-data .groups-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.sample-size-data .group-item { + font-size: 11px; + color: #374151; + padding: 4px 8px; + background: #F3F4F6; + border-radius: 4px; +} + +/* 旧样式兼容 */ .sample-size-row { display: flex; justify-content: space-between; align-items: center; } -.sample-size-row .label { - font-size: 12px; - color: #6B7280; -} - -.sample-size-row .value { - font-size: 18px; - font-weight: 700; - color: #6366F1; - font-family: 'Monaco', 'Consolas', monospace; -} - .calculation-params { display: flex; gap: 12px; @@ -1350,6 +1515,243 @@ min-width: 40px; } +/* ============================================ */ +/* 折叠展开组件 */ +/* ============================================ */ + +.collapsible-wrapper { + position: relative; +} + +.collapsible-content { + transition: max-height 0.3s ease-out; +} + +.collapsible-content.collapsed { + position: relative; +} + +.collapsible-content.expanded { + max-height: none !important; +} + +/* 渐变遮罩 - 折叠时显示 */ +.collapse-gradient { + position: absolute; + bottom: 24px; /* 按钮高度 */ + left: 0; + right: 0; + height: 40px; + background: linear-gradient( + to bottom, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.9) 60%, + rgba(255, 255, 255, 1) 100% + ); + pointer-events: none; +} + +/* 展开/收起按钮 */ +.expand-toggle-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + width: 100%; + padding: 6px 12px; + margin-top: 4px; + background: #F9FAFB; + border: 1px dashed #D1D5DB; + border-radius: 6px; + font-size: 11px; + color: #6B7280; + cursor: pointer; + transition: all 0.2s ease; +} + +.expand-toggle-btn:hover { + background: #F3F4F6; + border-color: #9CA3AF; + color: #4B5563; +} + +.expand-toggle-btn svg { + flex-shrink: 0; +} + +/* 已完成卡片的渐变遮罩颜色调整 */ +.stage-card.completed .collapse-gradient { + background: linear-gradient( + to bottom, + rgba(249, 250, 251, 0) 0%, + rgba(249, 250, 251, 0.9) 60%, + rgba(249, 250, 251, 1) 100% + ); +} + +/* ============================================ */ +/* 详细卡片样式 - 完整内容显示 */ +/* ============================================ */ + +/* 科学问题 - 完整显示 */ +.scientific-question-data .full-content { + font-size: 12px; + line-height: 1.6; + color: #374151; + margin: 0; + word-break: break-word; +} + +/* PICO - 完整显示 */ +.pico-data .full-value { + font-size: 12px; + line-height: 1.5; + word-break: break-word; +} + +/* 研究设计 - 完整显示 */ +.study-design-data .design-main { + margin-bottom: 8px; +} + +.study-design-data .design-type-tag { + display: inline-block; + padding: 4px 12px; + background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%); + color: white; + border-radius: 20px; + font-size: 12px; + font-weight: 600; +} + +.study-design-data .design-features { + margin-top: 8px; +} + +.study-design-data .features-label { + font-size: 10px; + color: #9CA3AF; + font-weight: 600; + margin-right: 8px; +} + +.study-design-data .design-details { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #E5E7EB; +} + +.study-design-data .details-label { + font-size: 10px; + color: #9CA3AF; + font-weight: 600; + display: block; + margin-bottom: 4px; +} + +.study-design-data .details-text { + font-size: 11px; + line-height: 1.5; + color: #374151; + margin: 0; +} + +/* 样本量 - 完整显示 */ +.sample-size-data .full-text { + font-size: 11px; + color: #374151; + line-height: 1.6; + margin: 0; + word-break: break-word; +} + +.sample-size-data .dropout-text { + font-size: 11px; + color: #6B7280; + line-height: 1.5; + margin: 0; +} + +.sample-size-data .group-name { + font-weight: 500; + color: #4B5563; + margin-right: 4px; +} + +.sample-size-data .group-size { + color: #4F46E5; + font-weight: 600; +} + +/* 观察指标 - 详细版本 */ +.endpoints-data.detailed { + gap: 14px; +} + +.endpoints-data .endpoint-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 4px; +} + +.endpoints-data .endpoint-item-full { + font-size: 11px; + line-height: 1.5; + color: #374151; + padding: 4px 0; +} + +.endpoints-data .endpoint-item-full.primary-item { + color: #4F46E5; + font-weight: 500; +} + +.endpoints-data .endpoint-item-full.safety-item { + color: #DC2626; +} + +.endpoints-data .endpoint-item-full.confounder-item { + color: #92400E; +} + +.endpoints-data .endpoint-detail-item { + display: flex; + gap: 8px; + font-size: 11px; + padding: 4px 0; +} + +.endpoints-data .detail-label { + color: #9CA3AF; + font-weight: 600; + min-width: 70px; + flex-shrink: 0; +} + +.endpoints-data .detail-value { + color: #374151; + line-height: 1.5; + flex: 1; +} + +.endpoints-data .primary-label { + color: #4F46E5; + font-weight: 700; +} + +.endpoints-data .safety-label { + color: #DC2626; + font-weight: 700; +} + +.endpoints-data .followup-text { + font-size: 11px; + line-height: 1.6; + color: #374151; + margin: 0; +} + /* ============================================ */ /* 一键生成按钮 */ /* ============================================ */ @@ -1380,11 +1782,33 @@ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); } -.generate-btn:hover { +.generate-btn:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(99, 102, 241, 0.4); } +.generate-btn.disabled, +.generate-btn:disabled { + background: linear-gradient(135deg, #9CA3AF 0%, #6B7280 100%); + cursor: not-allowed; + opacity: 0.7; + box-shadow: none; +} + +.generate-btn.disabled:hover, +.generate-btn:disabled:hover { + transform: none; +} + +.generate-btn.generating { + background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%); + cursor: wait; +} + +.generate-btn .spinner { + animation: spin 1s linear infinite; +} + .generate-icon { font-size: 18px; } @@ -1412,31 +1836,45 @@ } /* ============================================ */ -/* 滚动条美化 */ +/* 滚动条美化 - 始终可见 */ /* ============================================ */ +.chat-container, +.panel-body, +.conversations-list, +.document-scroll-area { + scrollbar-width: thin; + scrollbar-color: #94A3B8 #F1F5F9; +} + .chat-container::-webkit-scrollbar, .panel-body::-webkit-scrollbar, -.conversations-list::-webkit-scrollbar { - width: 6px; +.conversations-list::-webkit-scrollbar, +.document-scroll-area::-webkit-scrollbar { + width: 8px; } .chat-container::-webkit-scrollbar-track, .panel-body::-webkit-scrollbar-track, -.conversations-list::-webkit-scrollbar-track { - background: transparent; +.conversations-list::-webkit-scrollbar-track, +.document-scroll-area::-webkit-scrollbar-track { + background: #F1F5F9; + border-radius: 4px; } .chat-container::-webkit-scrollbar-thumb, .panel-body::-webkit-scrollbar-thumb, -.conversations-list::-webkit-scrollbar-thumb { - background: #CBD5E1; - border-radius: 3px; +.conversations-list::-webkit-scrollbar-thumb, +.document-scroll-area::-webkit-scrollbar-thumb { + background: #94A3B8; + border-radius: 4px; + border: 2px solid #F1F5F9; } .chat-container::-webkit-scrollbar-thumb:hover, .panel-body::-webkit-scrollbar-thumb:hover, -.conversations-list::-webkit-scrollbar-thumb:hover { - background: #94A3B8; +.conversations-list::-webkit-scrollbar-thumb:hover, +.document-scroll-area::-webkit-scrollbar-thumb:hover { + background: #64748B; } /* ============================================ */ @@ -1621,6 +2059,28 @@ color: #3B82F6; } +/* 添加按钮 */ +.stage-header .add-btn { + padding: 4px; + background: transparent; + border: 1px dashed #D1D5DB; + border-radius: 4px; + cursor: pointer; + color: #9CA3AF; + transition: all 0.2s; +} + +.stage-header .add-btn:hover { + background: #EEF2FF; + border-color: #6366F1; + color: #6366F1; +} + +/* 待完成图标 */ +.stage-header .pending-icon { + color: #D1D5DB; +} + /* 流式输出光标 */ .streaming-cursor { display: inline-block; @@ -1634,6 +2094,386 @@ 51%, 100% { opacity: 0; } } +/* ============================================ */ +/* 导出按钮样式 */ +/* ============================================ */ +.export-section { + padding: 16px; + margin: 12px 0; + background: linear-gradient(135deg, #EEF2FF 0%, #E0E7FF 100%); + border-radius: 12px; + border: 1px solid #C7D2FE; +} + +.export-btn { + width: 100%; + padding: 12px 16px; + background: #4F46E5; + border: none; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(79, 70, 229, 0.2); +} + +.export-btn:hover:not(:disabled) { + background: #4338CA; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(79, 70, 229, 0.3); +} + +.export-btn:active:not(:disabled) { + transform: translateY(0); +} + +.export-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.export-btn.loading { + background: #6366F1; +} + +.export-btn .spinner { + animation: spin 1s linear infinite; +} + +.export-hint { + margin-top: 8px; + font-size: 12px; + color: #4F46E5; + text-align: center; +} + +/* ============================================ */ +/* 视图切换器 */ +/* ============================================ */ +.view-switcher { + display: flex; + background: #F3F4F6; + padding: 4px; + border-radius: 8px; + gap: 2px; +} + +.view-switch-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: none; + background: transparent; + color: #6B7280; + font-size: 13px; + font-weight: 500; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +.view-switch-btn:hover:not(.disabled) { + color: #374151; +} + +.view-switch-btn.active { + background: #FFFFFF; + color: #4F46E5; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.view-switch-btn.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ============================================ */ +/* 文档面板 */ +/* ============================================ */ +.document-panel { + display: flex; + flex-direction: column; + height: 100%; + background: #E5E7EB; +} + +.document-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + text-align: center; +} + +.document-empty .empty-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.document-empty h3 { + font-size: 18px; + font-weight: 600; + color: #374151; + margin-bottom: 8px; +} + +.document-empty p { + font-size: 14px; + color: #6B7280; + max-width: 300px; +} + +/* 文档工具栏 */ +.document-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 24px; + background: #F9FAFB; + border-bottom: 1px solid #E5E7EB; +} + +.toolbar-left { + display: flex; + align-items: center; +} + +.generating-status { + display: flex; + align-items: center; + gap: 8px; + color: #6366F1; + font-size: 13px; + font-weight: 500; +} + +.generating-status .spinner { + animation: spin 1s linear infinite; +} + +.toolbar-right { + display: flex; + align-items: center; + gap: 8px; +} + +.toolbar-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: 1px solid #D1D5DB; + background: #FFFFFF; + color: #374151; + font-size: 13px; + font-weight: 500; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +.toolbar-btn:hover:not(:disabled) { + background: #F9FAFB; + border-color: #9CA3AF; +} + +.toolbar-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.toolbar-btn.primary { + background: #4F46E5; + border-color: #4F46E5; + color: #FFFFFF; +} + +.toolbar-btn.primary:hover:not(:disabled) { + background: #4338CA; + border-color: #4338CA; +} + +/* 文档滚动区域 */ +.document-scroll-area { + flex: 1; + min-height: 0; /* 关键:允许 flex 子元素收缩 */ + overflow-y: auto; + padding: 24px; + background: linear-gradient(to bottom, #D1D5DB 0%, #E5E7EB 100%); +} + +/* A4 纸张效果 */ +.a4-paper { + max-width: 210mm; + min-height: 297mm; + margin: 0 auto; + padding: 25mm 20mm; + background: #FFFFFF; + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06), + 0 20px 25px -5px rgba(0, 0, 0, 0.1); + font-family: 'Noto Serif SC', 'SimSun', serif; + color: #1F2937; + line-height: 1.8; + font-size: 15px; +} + +/* 纸张内容样式 */ +.paper-content h1 { + font-size: 24px; + font-weight: 700; + text-align: center; + margin-bottom: 32px; + padding-bottom: 16px; + line-height: 1.4; +} + +.paper-content h2 { + font-size: 18px; + font-weight: 700; + margin-top: 24px; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 2px solid #1F2937; +} + +.paper-content h3 { + font-size: 16px; + font-weight: 600; + margin-top: 16px; + margin-bottom: 8px; +} + +.paper-content p { + text-align: justify; + text-indent: 2em; + margin-bottom: 12px; +} + +.paper-content ul, .paper-content ol { + margin-left: 2em; + margin-bottom: 12px; +} + +.paper-content li { + margin-bottom: 4px; +} + +.paper-content strong { + font-weight: 700; +} + +/* 章节头部(带讨论按钮) */ +.section-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.section-header h2 { + flex: 1; +} + +.section-discuss-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border: 1px solid #D1D5DB; + background: #FFFFFF; + color: #6B7280; + font-size: 11px; + font-family: 'Inter', sans-serif; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + flex-shrink: 0; + margin-top: 4px; +} + +.section-discuss-btn:hover { + background: #F3F4F6; + color: #4F46E5; + border-color: #A5B4FC; +} + +/* 打字机光标 */ +.typing-cursor { + display: inline; + animation: blink 1s step-end infinite; + color: #4F46E5; + font-weight: 700; +} + +@keyframes blink { + 50% { opacity: 0; } +} + +/* 滚动到底部按钮 */ +.scroll-to-bottom-btn { + position: absolute; + bottom: 80px; + right: 40px; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: #4F46E5; + color: white; + border: none; + border-radius: 20px; + cursor: pointer; + box-shadow: 0 4px 12px rgba(79, 70, 229, 0.4); + font-size: 12px; + font-weight: 500; + transition: all 0.2s; + z-index: 10; + animation: fadeIn 0.2s ease; +} + +.scroll-to-bottom-btn:hover { + background: #4338CA; + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(79, 70, 229, 0.5); +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* 生成中的内容区域 */ +.paper-content.typing { + min-height: 200px; +} + +/* ============================================ */ +/* 可拖拽分栏 */ +/* ============================================ */ +.resizable-split-pane { + position: relative; +} + +.resizable-split-pane.dragging { + cursor: col-resize; +} + +.split-pane-handle:hover { + background: #E5E7EB; +} + /* ============================================ */ /* 响应式 */ /* ============================================ */ @@ -1648,5 +2488,13 @@ height: 100%; box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); } + + .document-panel { + display: none; + } + + .view-switcher { + display: none; + } } diff --git a/frontend-v2/src/modules/aia/protocol-agent/types.ts b/frontend-v2/src/modules/aia/protocol-agent/types.ts index 8a980323..36666c66 100644 --- a/frontend-v2/src/modules/aia/protocol-agent/types.ts +++ b/frontend-v2/src/modules/aia/protocol-agent/types.ts @@ -41,6 +41,8 @@ export interface StudyDesignData { studyType: string; design: string[]; features?: string[]; + /** 详细说明 */ + details?: string; confirmed?: boolean; confirmedAt?: string; } @@ -53,9 +55,18 @@ export interface SampleSizeData { calculation?: { alpha?: number; power?: number; - effectSize?: number; + effectSize?: string | number; dropoutRate?: number; }; + /** 计算依据/推导过程 */ + rationale?: string; + /** 失访率及调整说明 */ + dropoutRate?: string; + /** 分组信息 */ + groups?: Array<{ + name: string; + size: number; + }>; confirmed?: boolean; confirmedAt?: string; } @@ -70,6 +81,14 @@ export interface EndpointsData { laboratoryTests?: string[]; }; exposure?: { + /** 暴露名称 */ + name?: string; + /** 暴露定义 */ + definition?: string; + /** 测量方法 */ + measurement?: string; + /** 测量时点 */ + timing?: string; intervention?: string; control?: string; dosage?: string; @@ -81,6 +100,8 @@ export interface EndpointsData { safety?: string[]; }; confounders?: string[]; + /** 随访方案 */ + followUp?: string; confirmed?: boolean; confirmedAt?: string; } diff --git a/frontend-v2/src/modules/asl/components/FulltextDetailDrawer.tsx b/frontend-v2/src/modules/asl/components/FulltextDetailDrawer.tsx index 201a714d..9575ad05 100644 --- a/frontend-v2/src/modules/asl/components/FulltextDetailDrawer.tsx +++ b/frontend-v2/src/modules/asl/components/FulltextDetailDrawer.tsx @@ -580,5 +580,6 @@ export default FulltextDetailDrawer; + diff --git a/frontend-v2/src/modules/dc/hooks/useAssets.ts b/frontend-v2/src/modules/dc/hooks/useAssets.ts index 8d7a209b..d59d9c61 100644 --- a/frontend-v2/src/modules/dc/hooks/useAssets.ts +++ b/frontend-v2/src/modules/dc/hooks/useAssets.ts @@ -173,5 +173,6 @@ export const useAssets = (activeTab: AssetTabType) => { + diff --git a/frontend-v2/src/modules/dc/hooks/useRecentTasks.ts b/frontend-v2/src/modules/dc/hooks/useRecentTasks.ts index a82e860d..fd89d564 100644 --- a/frontend-v2/src/modules/dc/hooks/useRecentTasks.ts +++ b/frontend-v2/src/modules/dc/hooks/useRecentTasks.ts @@ -163,5 +163,6 @@ export const useRecentTasks = () => { + diff --git a/frontend-v2/src/modules/dc/pages/tool-c/hooks/useSessionStatus.ts b/frontend-v2/src/modules/dc/pages/tool-c/hooks/useSessionStatus.ts index 85cc9eb4..2bf6d7f7 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/hooks/useSessionStatus.ts +++ b/frontend-v2/src/modules/dc/pages/tool-c/hooks/useSessionStatus.ts @@ -133,5 +133,6 @@ export function useSessionStatus({ + diff --git a/frontend-v2/src/modules/dc/pages/tool-c/types/index.ts b/frontend-v2/src/modules/dc/pages/tool-c/types/index.ts index 4f64f5cf..e5e2a4ae 100644 --- a/frontend-v2/src/modules/dc/pages/tool-c/types/index.ts +++ b/frontend-v2/src/modules/dc/pages/tool-c/types/index.ts @@ -125,5 +125,6 @@ export interface DataStats { + diff --git a/frontend-v2/src/modules/dc/types/portal.ts b/frontend-v2/src/modules/dc/types/portal.ts index 8aa10286..3612d074 100644 --- a/frontend-v2/src/modules/dc/types/portal.ts +++ b/frontend-v2/src/modules/dc/types/portal.ts @@ -121,5 +121,6 @@ export type AssetTabType = 'all' | 'processed' | 'raw'; + diff --git a/frontend-v2/src/modules/pkb/pages/KnowledgePage.tsx b/frontend-v2/src/modules/pkb/pages/KnowledgePage.tsx index a70780fd..c71cb07f 100644 --- a/frontend-v2/src/modules/pkb/pages/KnowledgePage.tsx +++ b/frontend-v2/src/modules/pkb/pages/KnowledgePage.tsx @@ -308,5 +308,6 @@ export default KnowledgePage; + diff --git a/frontend-v2/src/modules/pkb/types/workspace.ts b/frontend-v2/src/modules/pkb/types/workspace.ts index f4f03cb6..e0d5625f 100644 --- a/frontend-v2/src/modules/pkb/types/workspace.ts +++ b/frontend-v2/src/modules/pkb/types/workspace.ts @@ -63,5 +63,6 @@ export interface BatchTemplate { + diff --git a/frontend-v2/src/modules/rvw/components/AgentModal.tsx b/frontend-v2/src/modules/rvw/components/AgentModal.tsx index 30390ecb..2bfc8157 100644 --- a/frontend-v2/src/modules/rvw/components/AgentModal.tsx +++ b/frontend-v2/src/modules/rvw/components/AgentModal.tsx @@ -141,5 +141,6 @@ export default function AgentModal({ visible, taskCount, onClose, onConfirm }: A + diff --git a/frontend-v2/src/modules/rvw/components/BatchToolbar.tsx b/frontend-v2/src/modules/rvw/components/BatchToolbar.tsx index 98a91d0e..aa14a2d2 100644 --- a/frontend-v2/src/modules/rvw/components/BatchToolbar.tsx +++ b/frontend-v2/src/modules/rvw/components/BatchToolbar.tsx @@ -61,5 +61,6 @@ export default function BatchToolbar({ selectedCount, onRunBatch, onClearSelecti + diff --git a/frontend-v2/src/modules/rvw/components/FilterChips.tsx b/frontend-v2/src/modules/rvw/components/FilterChips.tsx index f026733e..0e3858ea 100644 --- a/frontend-v2/src/modules/rvw/components/FilterChips.tsx +++ b/frontend-v2/src/modules/rvw/components/FilterChips.tsx @@ -84,5 +84,6 @@ export default function FilterChips({ filters, counts, onFilterChange }: FilterC + diff --git a/frontend-v2/src/modules/rvw/components/Header.tsx b/frontend-v2/src/modules/rvw/components/Header.tsx index 36607a81..1d13dd1a 100644 --- a/frontend-v2/src/modules/rvw/components/Header.tsx +++ b/frontend-v2/src/modules/rvw/components/Header.tsx @@ -74,5 +74,6 @@ export default function Header({ onUpload }: HeaderProps) { + diff --git a/frontend-v2/src/modules/rvw/components/ReportDetail.tsx b/frontend-v2/src/modules/rvw/components/ReportDetail.tsx index c8435e3c..7ffec538 100644 --- a/frontend-v2/src/modules/rvw/components/ReportDetail.tsx +++ b/frontend-v2/src/modules/rvw/components/ReportDetail.tsx @@ -128,5 +128,6 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) { + diff --git a/frontend-v2/src/modules/rvw/components/ScoreRing.tsx b/frontend-v2/src/modules/rvw/components/ScoreRing.tsx index 0cfafaa0..ef8b6f0e 100644 --- a/frontend-v2/src/modules/rvw/components/ScoreRing.tsx +++ b/frontend-v2/src/modules/rvw/components/ScoreRing.tsx @@ -56,5 +56,6 @@ export default function ScoreRing({ score, size = 'medium', showLabel = true }: + diff --git a/frontend-v2/src/modules/rvw/components/Sidebar.tsx b/frontend-v2/src/modules/rvw/components/Sidebar.tsx index cb678cb3..7d13b521 100644 --- a/frontend-v2/src/modules/rvw/components/Sidebar.tsx +++ b/frontend-v2/src/modules/rvw/components/Sidebar.tsx @@ -91,5 +91,6 @@ export default function Sidebar({ currentView, onViewChange, onSettingsClick }: + diff --git a/frontend-v2/src/modules/rvw/components/TaskDetail.tsx b/frontend-v2/src/modules/rvw/components/TaskDetail.tsx index 3ce6edd1..2814cdab 100644 --- a/frontend-v2/src/modules/rvw/components/TaskDetail.tsx +++ b/frontend-v2/src/modules/rvw/components/TaskDetail.tsx @@ -175,16 +175,17 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet children.push( new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, + columnWidths: [2000, 7000], // 固定列宽:第一列2000twips(约3.5cm),第二列7000twips(约12cm) rows: [ new TableRow({ children: [ new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: '文件名', bold: true })] })], - width: { size: 25, type: WidthType.PERCENTAGE }, + width: { size: 2000, type: WidthType.DXA }, }), new TableCell({ children: [new Paragraph(report.fileName)], - width: { size: 75, type: WidthType.PERCENTAGE }, + width: { size: 7000, type: WidthType.DXA }, }), ], }), @@ -192,9 +193,11 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet children: [ new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: '综合评分', bold: true })] })], + width: { size: 2000, type: WidthType.DXA }, }), new TableCell({ children: [new Paragraph(`${report.overallScore || '-'} 分`)], + width: { size: 7000, type: WidthType.DXA }, }), ], }), @@ -202,6 +205,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet children: [ new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: '审查用时', bold: true })] })], + width: { size: 2000, type: WidthType.DXA }, }), new TableCell({ children: [new Paragraph( @@ -209,6 +213,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet ? `${Math.floor(report.durationSeconds / 60)}分${report.durationSeconds % 60}秒` : '-' )], + width: { size: 7000, type: WidthType.DXA }, }), ], }), @@ -216,9 +221,11 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet children: [ new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: '审查时间', bold: true })] })], + width: { size: 2000, type: WidthType.DXA }, }), new TableCell({ children: [new Paragraph(report.completedAt ? new Date(report.completedAt).toLocaleString('zh-CN') : '-')], + width: { size: 7000, type: WidthType.DXA }, }), ], }), diff --git a/frontend-v2/src/modules/rvw/components/TaskTable.tsx b/frontend-v2/src/modules/rvw/components/TaskTable.tsx index 62153953..d19c5f99 100644 --- a/frontend-v2/src/modules/rvw/components/TaskTable.tsx +++ b/frontend-v2/src/modules/rvw/components/TaskTable.tsx @@ -74,59 +74,6 @@ export default function TaskTable({ ); }; - // 渲染结果摘要 - const renderResultSummary = (task: ReviewTask) => { - if (task.status === 'pending') { - return 等待发起...; - } - - if (task.status === 'extracting' || task.status === 'reviewing') { - return ( -
    - - {task.status === 'extracting' ? '提取文本中...' : '审查中...'} -
    - ); - } - - if (task.status === 'failed') { - return 失败; - } - - if (task.status === 'completed') { - return ( -
    - {task.editorialScore !== undefined && ( -
    -
    = 80 ? 'bg-green-500' : task.editorialScore >= 60 ? 'bg-amber-500' : 'bg-red-500'}`} /> - 规范性: - = 80 ? 'text-green-700' : task.editorialScore >= 60 ? 'text-amber-700' : 'text-red-700'}`}> - {task.editorialScore}分 - -
    - )} - {(task.methodologyScore !== undefined || task.methodologyStatus) && ( -
    -
    = 80 : task.methodologyStatus === '通过') ? 'bg-green-500' : - (task.methodologyScore !== undefined ? task.methodologyScore >= 60 : task.methodologyStatus === '存疑') ? 'bg-amber-500' : 'bg-red-500' - }`} /> - 方法学: - = 80 : task.methodologyStatus === '通过') ? 'text-green-700' : - (task.methodologyScore !== undefined ? task.methodologyScore >= 60 : task.methodologyStatus === '存疑') ? 'text-amber-700' : 'text-red-700' - }`}> - {task.methodologyScore !== undefined ? `${task.methodologyScore}分` : task.methodologyStatus} - -
    - )} -
    - ); - } - - return null; - }; - // 渲染操作按钮 const renderActions = (task: ReviewTask) => { // 待审稿:[开始审稿] [删除] @@ -229,10 +176,10 @@ export default function TaskTable({ return (
    - +
    - - - - - - + + + + @@ -253,7 +199,7 @@ export default function TaskTable({ key={task.id} className={`hover:bg-slate-50 group transition-colors ${selectedIds.includes(task.id) ? 'bg-blue-50' : ''}`} > - - - - - - diff --git a/frontend-v2/src/modules/rvw/components/index.ts b/frontend-v2/src/modules/rvw/components/index.ts index f7d82905..0a82bda6 100644 --- a/frontend-v2/src/modules/rvw/components/index.ts +++ b/frontend-v2/src/modules/rvw/components/index.ts @@ -33,5 +33,6 @@ export { default as TaskDetail } from './TaskDetail'; + diff --git a/frontend-v2/src/modules/rvw/pages/Dashboard.tsx b/frontend-v2/src/modules/rvw/pages/Dashboard.tsx index 3a200491..f237568c 100644 --- a/frontend-v2/src/modules/rvw/pages/Dashboard.tsx +++ b/frontend-v2/src/modules/rvw/pages/Dashboard.tsx @@ -302,5 +302,6 @@ export default function Dashboard() { + diff --git a/frontend-v2/src/modules/rvw/styles/index.css b/frontend-v2/src/modules/rvw/styles/index.css index f4c13dc5..1ca8aed4 100644 --- a/frontend-v2/src/modules/rvw/styles/index.css +++ b/frontend-v2/src/modules/rvw/styles/index.css @@ -251,5 +251,6 @@ + diff --git a/frontend-v2/src/pages/admin/tenants/TenantListPage.tsx b/frontend-v2/src/pages/admin/tenants/TenantListPage.tsx index 9cc03189..4c244887 100644 --- a/frontend-v2/src/pages/admin/tenants/TenantListPage.tsx +++ b/frontend-v2/src/pages/admin/tenants/TenantListPage.tsx @@ -353,4 +353,5 @@ export default TenantListPage; + diff --git a/frontend-v2/src/pages/admin/tenants/api/tenantApi.ts b/frontend-v2/src/pages/admin/tenants/api/tenantApi.ts index 3913a444..8145d124 100644 --- a/frontend-v2/src/pages/admin/tenants/api/tenantApi.ts +++ b/frontend-v2/src/pages/admin/tenants/api/tenantApi.ts @@ -262,4 +262,5 @@ export async function fetchModuleList(): Promise { + diff --git a/frontend-v2/src/shared/components/Chat/AIStreamChat.tsx b/frontend-v2/src/shared/components/Chat/AIStreamChat.tsx index e7d7cb08..14986664 100644 --- a/frontend-v2/src/shared/components/Chat/AIStreamChat.tsx +++ b/frontend-v2/src/shared/components/Chat/AIStreamChat.tsx @@ -482,3 +482,4 @@ export default AIStreamChat; + diff --git a/frontend-v2/src/shared/components/Chat/ConversationList.tsx b/frontend-v2/src/shared/components/Chat/ConversationList.tsx index e94dcace..f0ff12c8 100644 --- a/frontend-v2/src/shared/components/Chat/ConversationList.tsx +++ b/frontend-v2/src/shared/components/Chat/ConversationList.tsx @@ -182,3 +182,4 @@ export default ConversationList; + diff --git a/frontend-v2/src/shared/components/Chat/hooks/index.ts b/frontend-v2/src/shared/components/Chat/hooks/index.ts index 247fdc7c..cce49703 100644 --- a/frontend-v2/src/shared/components/Chat/hooks/index.ts +++ b/frontend-v2/src/shared/components/Chat/hooks/index.ts @@ -34,3 +34,4 @@ export type { + diff --git a/frontend-v2/src/shared/components/Chat/hooks/useAIStream.ts b/frontend-v2/src/shared/components/Chat/hooks/useAIStream.ts index 9e5a98e5..fd0c5d43 100644 --- a/frontend-v2/src/shared/components/Chat/hooks/useAIStream.ts +++ b/frontend-v2/src/shared/components/Chat/hooks/useAIStream.ts @@ -326,3 +326,4 @@ export default useAIStream; + diff --git a/frontend-v2/src/shared/components/Chat/hooks/useConversations.ts b/frontend-v2/src/shared/components/Chat/hooks/useConversations.ts index 1ddf595a..2f5052b7 100644 --- a/frontend-v2/src/shared/components/Chat/hooks/useConversations.ts +++ b/frontend-v2/src/shared/components/Chat/hooks/useConversations.ts @@ -255,3 +255,4 @@ export default useConversations; + diff --git a/frontend-v2/src/shared/components/Chat/styles/ai-stream-chat.css b/frontend-v2/src/shared/components/Chat/styles/ai-stream-chat.css index 7c666f39..3a3b0668 100644 --- a/frontend-v2/src/shared/components/Chat/styles/ai-stream-chat.css +++ b/frontend-v2/src/shared/components/Chat/styles/ai-stream-chat.css @@ -290,3 +290,4 @@ + diff --git a/frontend-v2/src/shared/components/Chat/styles/conversation-list.css b/frontend-v2/src/shared/components/Chat/styles/conversation-list.css index 47e2af51..4b79e553 100644 --- a/frontend-v2/src/shared/components/Chat/styles/conversation-list.css +++ b/frontend-v2/src/shared/components/Chat/styles/conversation-list.css @@ -226,3 +226,4 @@ + diff --git a/frontend-v2/src/shared/components/Chat/styles/thinking.css b/frontend-v2/src/shared/components/Chat/styles/thinking.css index 879472b1..6dc18fb2 100644 --- a/frontend-v2/src/shared/components/Chat/styles/thinking.css +++ b/frontend-v2/src/shared/components/Chat/styles/thinking.css @@ -163,3 +163,4 @@ + diff --git a/frontend-v2/src/shared/components/index.ts b/frontend-v2/src/shared/components/index.ts index 48f81180..aefed4f9 100644 --- a/frontend-v2/src/shared/components/index.ts +++ b/frontend-v2/src/shared/components/index.ts @@ -76,5 +76,6 @@ export { default as Placeholder } from './Placeholder'; + diff --git a/frontend-v2/src/vite-env.d.ts b/frontend-v2/src/vite-env.d.ts index 3ad6e236..d38cf847 100644 --- a/frontend-v2/src/vite-env.d.ts +++ b/frontend-v2/src/vite-env.d.ts @@ -56,5 +56,6 @@ interface ImportMeta { + diff --git a/frontend/src/pages/rvw/components/BatchToolbar.tsx b/frontend/src/pages/rvw/components/BatchToolbar.tsx index e8c8a644..e6fbc38f 100644 --- a/frontend/src/pages/rvw/components/BatchToolbar.tsx +++ b/frontend/src/pages/rvw/components/BatchToolbar.tsx @@ -63,5 +63,6 @@ export default function BatchToolbar({ selectedCount, onRunBatch, onClearSelecti + diff --git a/frontend/src/pages/rvw/components/EditorialReport.tsx b/frontend/src/pages/rvw/components/EditorialReport.tsx index 21320725..e7505660 100644 --- a/frontend/src/pages/rvw/components/EditorialReport.tsx +++ b/frontend/src/pages/rvw/components/EditorialReport.tsx @@ -128,5 +128,6 @@ export default function EditorialReport({ data }: EditorialReportProps) { + diff --git a/frontend/src/pages/rvw/components/FilterChips.tsx b/frontend/src/pages/rvw/components/FilterChips.tsx index 2728c969..8e549229 100644 --- a/frontend/src/pages/rvw/components/FilterChips.tsx +++ b/frontend/src/pages/rvw/components/FilterChips.tsx @@ -86,5 +86,6 @@ export default function FilterChips({ filters, counts, onFilterChange }: FilterC + diff --git a/frontend/src/pages/rvw/components/Header.tsx b/frontend/src/pages/rvw/components/Header.tsx index 671e0e0a..055c5321 100644 --- a/frontend/src/pages/rvw/components/Header.tsx +++ b/frontend/src/pages/rvw/components/Header.tsx @@ -76,5 +76,6 @@ export default function Header({ onUpload }: HeaderProps) { + diff --git a/frontend/src/pages/rvw/components/ReportDetail.tsx b/frontend/src/pages/rvw/components/ReportDetail.tsx index dc26f506..0c18313b 100644 --- a/frontend/src/pages/rvw/components/ReportDetail.tsx +++ b/frontend/src/pages/rvw/components/ReportDetail.tsx @@ -130,5 +130,6 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) { + diff --git a/frontend/src/pages/rvw/components/ScoreRing.tsx b/frontend/src/pages/rvw/components/ScoreRing.tsx index e9157b5d..79b2e0c3 100644 --- a/frontend/src/pages/rvw/components/ScoreRing.tsx +++ b/frontend/src/pages/rvw/components/ScoreRing.tsx @@ -58,5 +58,6 @@ export default function ScoreRing({ score, size = 'medium', showLabel = true }: + diff --git a/frontend/src/pages/rvw/components/Sidebar.tsx b/frontend/src/pages/rvw/components/Sidebar.tsx index 99284f55..5919e8a4 100644 --- a/frontend/src/pages/rvw/components/Sidebar.tsx +++ b/frontend/src/pages/rvw/components/Sidebar.tsx @@ -93,5 +93,6 @@ export default function Sidebar({ currentView, onViewChange, onSettingsClick }: + diff --git a/frontend/src/pages/rvw/index.ts b/frontend/src/pages/rvw/index.ts index 9f76f8fc..99c103cb 100644 --- a/frontend/src/pages/rvw/index.ts +++ b/frontend/src/pages/rvw/index.ts @@ -27,5 +27,6 @@ export * from './api'; + diff --git a/frontend/src/pages/rvw/styles.css b/frontend/src/pages/rvw/styles.css index 201e300b..54630731 100644 --- a/frontend/src/pages/rvw/styles.css +++ b/frontend/src/pages/rvw/styles.css @@ -253,5 +253,6 @@ + diff --git a/git-cleanup-redcap.ps1 b/git-cleanup-redcap.ps1 index 2589b58b..9c874441 100644 --- a/git-cleanup-redcap.ps1 +++ b/git-cleanup-redcap.ps1 @@ -49,5 +49,6 @@ Write-Host "Next step: Run the commit command" -ForegroundColor Cyan + diff --git a/git-commit-day1.ps1 b/git-commit-day1.ps1 index 29b6e9f6..0e61ac25 100644 --- a/git-commit-day1.ps1 +++ b/git-commit-day1.ps1 @@ -105,5 +105,6 @@ Write-Host "Git commit and push completed!" -ForegroundColor Green + diff --git a/git-fix-lock.ps1 b/git-fix-lock.ps1 index a4f4cbd6..71e8c866 100644 --- a/git-fix-lock.ps1 +++ b/git-fix-lock.ps1 @@ -53,5 +53,6 @@ Write-Host "Now you can run git commands again." -ForegroundColor Cyan + diff --git a/python-microservice/operations/__init__.py b/python-microservice/operations/__init__.py index ef847321..fa08c007 100644 --- a/python-microservice/operations/__init__.py +++ b/python-microservice/operations/__init__.py @@ -78,5 +78,6 @@ __version__ = '1.0.0' + diff --git a/python-microservice/operations/binning.py b/python-microservice/operations/binning.py index a03e1ce2..d5a6780f 100644 --- a/python-microservice/operations/binning.py +++ b/python-microservice/operations/binning.py @@ -185,5 +185,6 @@ def apply_binning( + diff --git a/python-microservice/operations/filter.py b/python-microservice/operations/filter.py index b437e2b8..4a63fd82 100644 --- a/python-microservice/operations/filter.py +++ b/python-microservice/operations/filter.py @@ -171,5 +171,6 @@ def apply_filter( + diff --git a/python-microservice/operations/recode.py b/python-microservice/operations/recode.py index b4bb9fcc..4d8b7966 100644 --- a/python-microservice/operations/recode.py +++ b/python-microservice/operations/recode.py @@ -141,5 +141,6 @@ def apply_recode( + diff --git a/recover_dc_code.py b/recover_dc_code.py index ba978f51..5c7b1cbc 100644 --- a/recover_dc_code.py +++ b/recover_dc_code.py @@ -285,5 +285,6 @@ if __name__ == "__main__": + diff --git a/redcap-docker-dev/.gitattributes b/redcap-docker-dev/.gitattributes index 55c4bd3f..3660975f 100644 --- a/redcap-docker-dev/.gitattributes +++ b/redcap-docker-dev/.gitattributes @@ -65,5 +65,6 @@ + diff --git a/redcap-docker-dev/.gitignore b/redcap-docker-dev/.gitignore index 07ddd46b..3de413fb 100644 --- a/redcap-docker-dev/.gitignore +++ b/redcap-docker-dev/.gitignore @@ -96,5 +96,6 @@ Desktop.ini + diff --git a/redcap-docker-dev/README.md b/redcap-docker-dev/README.md index 22290893..3e45bbee 100644 --- a/redcap-docker-dev/README.md +++ b/redcap-docker-dev/README.md @@ -397,5 +397,6 @@ docker-compose -f docker-compose.prod.yml up -d + diff --git a/redcap-docker-dev/docker-compose.prod.yml b/redcap-docker-dev/docker-compose.prod.yml index e9514771..1a66d0cf 100644 --- a/redcap-docker-dev/docker-compose.prod.yml +++ b/redcap-docker-dev/docker-compose.prod.yml @@ -158,5 +158,6 @@ volumes: + diff --git a/redcap-docker-dev/docker-compose.yml b/redcap-docker-dev/docker-compose.yml index cdf30f5e..e95c7cb4 100644 --- a/redcap-docker-dev/docker-compose.yml +++ b/redcap-docker-dev/docker-compose.yml @@ -156,5 +156,6 @@ volumes: + diff --git a/redcap-docker-dev/env.template b/redcap-docker-dev/env.template index 588605f8..256d056e 100644 --- a/redcap-docker-dev/env.template +++ b/redcap-docker-dev/env.template @@ -92,5 +92,6 @@ PMA_UPLOAD_LIMIT=50M + diff --git a/redcap-docker-dev/scripts/clean-redcap.ps1 b/redcap-docker-dev/scripts/clean-redcap.ps1 index de49db47..4a4adcde 100644 --- a/redcap-docker-dev/scripts/clean-redcap.ps1 +++ b/redcap-docker-dev/scripts/clean-redcap.ps1 @@ -100,5 +100,6 @@ Write-Host "" + diff --git a/redcap-docker-dev/scripts/create-redcap-password.php b/redcap-docker-dev/scripts/create-redcap-password.php index f6ddce0d..e04b483a 100644 --- a/redcap-docker-dev/scripts/create-redcap-password.php +++ b/redcap-docker-dev/scripts/create-redcap-password.php @@ -78,5 +78,6 @@ try { + diff --git a/redcap-docker-dev/scripts/logs-redcap.ps1 b/redcap-docker-dev/scripts/logs-redcap.ps1 index 33fcf5f2..e207a73d 100644 --- a/redcap-docker-dev/scripts/logs-redcap.ps1 +++ b/redcap-docker-dev/scripts/logs-redcap.ps1 @@ -91,5 +91,6 @@ Write-Host "" + diff --git a/redcap-docker-dev/scripts/reset-admin-password.php b/redcap-docker-dev/scripts/reset-admin-password.php index 7be0a246..cc023159 100644 --- a/redcap-docker-dev/scripts/reset-admin-password.php +++ b/redcap-docker-dev/scripts/reset-admin-password.php @@ -54,5 +54,6 @@ if ($result) { + diff --git a/redcap-docker-dev/scripts/start-redcap.ps1 b/redcap-docker-dev/scripts/start-redcap.ps1 index 51067d2d..22faeb48 100644 --- a/redcap-docker-dev/scripts/start-redcap.ps1 +++ b/redcap-docker-dev/scripts/start-redcap.ps1 @@ -76,5 +76,6 @@ if ($LASTEXITCODE -eq 0) { + diff --git a/redcap-docker-dev/scripts/stop-redcap.ps1 b/redcap-docker-dev/scripts/stop-redcap.ps1 index 4f5a11a7..e41a82f6 100644 --- a/redcap-docker-dev/scripts/stop-redcap.ps1 +++ b/redcap-docker-dev/scripts/stop-redcap.ps1 @@ -62,5 +62,6 @@ if ($LASTEXITCODE -eq 0) { + diff --git a/run_recovery.ps1 b/run_recovery.ps1 index 1547b5bd..00dd945b 100644 --- a/run_recovery.ps1 +++ b/run_recovery.ps1 @@ -109,5 +109,6 @@ Write-Host "==================================================================== + diff --git a/tests/QUICKSTART_快速开始.md b/tests/QUICKSTART_快速开始.md index d9aa506b..03c0c242 100644 --- a/tests/QUICKSTART_快速开始.md +++ b/tests/QUICKSTART_快速开始.md @@ -156,5 +156,6 @@ INFO: Uvicorn running on http://0.0.0.0:8001 + diff --git a/tests/README_测试说明.md b/tests/README_测试说明.md index a010c494..f4acc545 100644 --- a/tests/README_测试说明.md +++ b/tests/README_测试说明.md @@ -312,5 +312,6 @@ df_numeric.to_excel('test_data/numeric_test.xlsx', index=False) + diff --git a/tests/run_tests.bat b/tests/run_tests.bat index ff50d7c5..fa54a478 100644 --- a/tests/run_tests.bat +++ b/tests/run_tests.bat @@ -107,5 +107,6 @@ pause + diff --git a/tests/run_tests.sh b/tests/run_tests.sh index 66024d65..06ea322f 100644 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -103,5 +103,6 @@ echo "========================================" + diff --git a/快速部署到SAE.md b/快速部署到SAE.md index 53e211c0..d7c72c83 100644 --- a/快速部署到SAE.md +++ b/快速部署到SAE.md @@ -368,5 +368,6 @@ OSS AccessKeySecret:_______________ + diff --git a/部署检查清单.md b/部署检查清单.md index 3c7c225c..966b7f15 100644 --- a/部署检查清单.md +++ b/部署检查清单.md @@ -404,5 +404,6 @@ OSS配置: +
    + 文件名称 / 信息上传时间审稿维度结果摘要操作文件名称 / 信息上传
    时间
    审稿维度操作
    + -
    -
    +
    +
    +
    {getFileIcon(task.fileName)}
    -
    +
    task.status === 'completed' && onViewReport(task)} + title={task.fileName} > {task.fileName}
    -
    - {formatFileSize(task.fileSize)} +
    + {formatFileSize(task.fileSize)} {task.wordCount && ( <> - {task.wordCount.toLocaleString()} 字 + {task.wordCount.toLocaleString()} 字 + + )} + {/* 将结果摘要整合到这里 */} + {task.status === 'completed' && task.editorialScore !== undefined && ( + <> + + = 80 ? 'text-green-600' : task.editorialScore >= 60 ? 'text-amber-600' : 'text-red-600'}`}> + 规范{task.editorialScore}分 + + + )} + {task.status === 'completed' && task.methodologyScore !== undefined && ( + <> + + = 80 ? 'text-green-600' : task.methodologyScore >= 60 ? 'text-amber-600' : 'text-red-600'}`}> + 方法{task.methodologyScore}分 + )}
    + {formatTime(task.createdAt)} + {renderAgentTags(task)} - {renderResultSummary(task)} - + {renderActions(task)}