完善模型测试与参考文件历史能力

This commit is contained in:
zwt13703
2026-07-02 17:37:35 +08:00
parent 8743a02110
commit d3530ab0b7
16 changed files with 697 additions and 32 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ class Settings(BaseSettings):
# 文件上传限制
MAX_UPLOAD_SIZE: int = 50 * 1024 * 1024 # 50MB
ALLOWED_EXTENSIONS: list = [".docx", ".xlsx", ".xls", ".csv", ".pdf", ".txt", ".md"]
ALLOWED_EXTENSIONS: list = [".docx", ".doc", ".xlsx", ".xls", ".xlsm", ".csv", ".pdf", ".txt", ".md", ".json"]
# 加密(用于 API Key 加密)
ENCRYPTION_KEY: str = "change-this-to-a-32-byte-key-in-production!!"
+14
View File
@@ -1,4 +1,5 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy import inspect, text
from sqlalchemy.orm import DeclarativeBase
from config import settings
@@ -24,5 +25,18 @@ async def init_db():
from models.ai_model import AiModel
from models.document import Document
from models.generation_log import GenerationLog
from models.reference_file import ReferenceFile
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
dialect_name = conn.dialect.name
columns = await conn.run_sync(lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("ai_models")])
if "supports_streaming" not in columns:
if dialect_name == "sqlite":
await conn.execute(text("ALTER TABLE ai_models ADD COLUMN supports_streaming BOOLEAN DEFAULT 0"))
else:
await conn.execute(text("ALTER TABLE ai_models ADD COLUMN supports_streaming TINYINT(1) DEFAULT 0"))
if "enable_reasoning" not in columns:
if dialect_name == "sqlite":
await conn.execute(text("ALTER TABLE ai_models ADD COLUMN enable_reasoning BOOLEAN DEFAULT 0"))
else:
await conn.execute(text("ALTER TABLE ai_models ADD COLUMN enable_reasoning TINYINT(1) DEFAULT 0"))
+3 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, func
from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime, func
from database import Base
class AiModel(Base):
@@ -9,6 +9,8 @@ class AiModel(Base):
api_format = Column(String(20), default="openai", comment="anthropic/openai")
api_endpoint = Column(String(500), default="", comment="API接口地址")
api_key_encrypted = Column(Text, default="", comment="加密后的API Key")
supports_streaming = Column(Boolean, default=False, comment="是否支持流式传输")
enable_reasoning = Column(Boolean, default=False, comment="是否开启思考模式")
status = Column(String(20), default="enabled", comment="enabled/disabled")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+17
View File
@@ -0,0 +1,17 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from database import Base
class ReferenceFile(Base):
__tablename__ = "reference_files"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
file_name: Mapped[str] = mapped_column(String(255), default="", comment="原始文件名")
file_path: Mapped[str] = mapped_column(String(500), default="", comment="MinIO 对象路径")
file_size: Mapped[int] = mapped_column(Integer, default=0, comment="文件大小")
content_type: Mapped[str] = mapped_column(String(120), default="", comment="文件类型")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+136 -8
View File
@@ -15,9 +15,10 @@ from models.ai_model import AiModel
from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.reference_file import ReferenceFile
from models.template import Template
from schemas.schemas import GenerateFullRequest, GenerateTestRequest, Response
from services.ai_service import call_ai
from services.ai_service import call_ai, stream_ai_preview
from services.file_summary import summarize_minio_files
from services.generation_runtime import (
build_mock_content,
@@ -45,6 +46,17 @@ def _serialize_document(document: Document) -> dict:
"updated_at": document.updated_at,
}
def _serialize_reference_file(file: ReferenceFile) -> dict:
return {
"id": file.id,
"file_name": file.file_name,
"file_path": file.file_path,
"file_size": file.file_size,
"content_type": file.content_type,
"created_at": file.created_at,
}
@router.post("/test")
async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)):
paragraph = await db.get(Paragraph, body.paragraph_id)
@@ -64,6 +76,7 @@ async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(ge
content = build_mock_content(paragraph)
message = "当前未找到可用模型,返回本地模拟生成结果。"
else:
setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning))
result = await call_ai(paragraph, model, file_summaries)
content = result.content
message = f"已通过模型 {result.used_model} 生成。"
@@ -77,14 +90,101 @@ async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(ge
)
@router.post("/test-stream")
async def generate_test_stream(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)):
paragraph = await db.get(Paragraph, body.paragraph_id)
if paragraph is None or paragraph.template_id != body.template_id:
raise HTTPException(status_code=404, detail="段落不存在")
if body.prompt_text:
paragraph.prompt_text = body.prompt_text
model = None
if body.model_id:
model = await db.get(AiModel, body.model_id)
elif paragraph.model_id:
model = await db.get(AiModel, paragraph.model_id)
if model is None or model.status != "enabled":
raise HTTPException(status_code=400, detail="当前段落未配置可用的流式模型")
if not model.supports_streaming:
raise HTTPException(status_code=400, detail="当前模型未开启流式传输")
file_summaries = await asyncio.to_thread(summarize_minio_files, body.file_paths or []) if body.file_paths else []
setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning))
async def event_stream():
yield {
"event": "message",
"data": json.dumps(
{
"type": "meta",
"message": f"正在通过模型 {model.name} 流式生成...",
"file_summaries": file_summaries,
},
ensure_ascii=False,
),
}
try:
async for chunk in stream_ai_preview(paragraph, model, file_summaries):
yield {
"event": "message",
"data": json.dumps({"type": "delta", "content": chunk}, ensure_ascii=False),
}
yield {
"event": "message",
"data": json.dumps({"type": "done"}, ensure_ascii=False),
}
except Exception as error:
fallback_message = str(error)
if "503" in fallback_message or "temporarily unavailable" in fallback_message.lower():
try:
result = await call_ai(paragraph, model, file_summaries)
yield {
"event": "message",
"data": json.dumps(
{
"type": "meta",
"message": "流式通道暂时不可用,已自动回退为普通返回。",
"file_summaries": file_summaries,
},
ensure_ascii=False,
),
}
yield {
"event": "message",
"data": json.dumps({"type": "delta", "content": result.raw_text}, ensure_ascii=False),
}
yield {
"event": "message",
"data": json.dumps({"type": "done"}, ensure_ascii=False),
}
return
except Exception as fallback_error:
fallback_message = f"{fallback_message};普通调用回退也失败:{fallback_error}"
yield {
"event": "message",
"data": json.dumps({"type": "error", "message": fallback_message}, ensure_ascii=False),
}
return EventSourceResponse(
event_stream(),
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/upload")
async def upload_reference_file(file: UploadFile = File(...)):
async def upload_reference_file(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
if not file.filename:
raise HTTPException(status_code=400, detail="文件名不能为空")
ext = os.path.splitext(file.filename)[1].lower()
if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail="文件类型不支持")
allowed = " / ".join(settings.ALLOWED_EXTENSIONS)
raise HTTPException(status_code=400, detail=f"文件类型不支持:{ext or '无扩展名'}。当前支持:{allowed}")
content = await file.read()
if not content:
@@ -100,12 +200,40 @@ async def upload_reference_file(file: UploadFile = File(...)):
content,
file.content_type or "application/octet-stream",
)
return Response(
data={
"file_name": file.filename,
"file_path": f"{settings.MINIO_BUCKET_UPLOADS}/{object_name}",
}
record = ReferenceFile(
file_name=file.filename,
file_path=f"{settings.MINIO_BUCKET_UPLOADS}/{object_name}",
file_size=len(content),
content_type=file.content_type or "application/octet-stream",
)
db.add(record)
await db.commit()
await db.refresh(record)
return Response(
data=_serialize_reference_file(record)
)
@router.get("/reference-files")
async def list_reference_files(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
keyword: str = Query("", description="按文件名搜索"),
db: AsyncSession = Depends(get_db),
):
stmt = select(ReferenceFile)
count_stmt = select(func.count(ReferenceFile.id))
if keyword:
like_keyword = f"%{keyword.strip()}%"
stmt = stmt.where(ReferenceFile.file_name.like(like_keyword))
count_stmt = count_stmt.where(ReferenceFile.file_name.like(like_keyword))
total = (await db.execute(count_stmt)).scalar_one()
result = await db.execute(
stmt.order_by(ReferenceFile.id.desc()).offset((page - 1) * page_size).limit(page_size)
)
items = [_serialize_reference_file(item) for item in result.scalars().all()]
return Response(data={"items": items, "total": total, "page": page, "page_size": page_size})
@router.post("/full")
+9
View File
@@ -20,6 +20,8 @@ def _serialize_model(model: AiModel) -> dict:
"api_format": model.api_format,
"api_endpoint": model.api_endpoint,
"api_key_preview": mask_secret(api_key),
"supports_streaming": bool(model.supports_streaming),
"enable_reasoning": bool(model.enable_reasoning),
"status": model.status,
"created_at": model.created_at,
}
@@ -40,6 +42,8 @@ async def create_model(body: AiModelCreate, db: AsyncSession = Depends(get_db)):
api_format=body.api_format,
api_endpoint=body.api_endpoint,
api_key_encrypted=encrypt_text(body.api_key),
supports_streaming=body.supports_streaming,
enable_reasoning=body.enable_reasoning,
status=body.status,
)
db.add(model)
@@ -62,6 +66,10 @@ async def update_model(model_id: int, body: AiModelUpdate, db: AsyncSession = De
model.api_format = body.api_format
if body.api_endpoint is not None:
model.api_endpoint = body.api_endpoint
if body.supports_streaming is not None:
model.supports_streaming = body.supports_streaming
if body.enable_reasoning is not None:
model.enable_reasoning = body.enable_reasoning
if body.status is not None:
model.status = body.status
if body.api_key:
@@ -95,6 +103,7 @@ async def test_model(model_id: int, db: AsyncSession = Depends(get_db)):
need_prompt = False
prompt_text = ""
output_format = "text"
enable_reasoning = bool(model.enable_reasoning)
try:
result = await call_ai(FakeParagraph(), model)
+6
View File
@@ -50,6 +50,8 @@ class AiModelCreate(BaseModel):
api_format: str = "openai"
api_endpoint: str = ""
api_key: str = ""
supports_streaming: bool = False
enable_reasoning: bool = False
status: str = "enabled"
@@ -59,6 +61,8 @@ class AiModelUpdate(BaseModel):
api_format: Optional[str] = None
api_endpoint: Optional[str] = None
api_key: str = ""
supports_streaming: Optional[bool] = None
enable_reasoning: Optional[bool] = None
status: Optional[str] = None
class AiModelOut(BaseModel):
@@ -68,6 +72,8 @@ class AiModelOut(BaseModel):
api_format: str = "openai"
api_endpoint: str = ""
api_key_preview: str = ""
supports_streaming: bool = False
enable_reasoning: bool = False
status: str = "enabled"
created_at: Optional[datetime] = None
+142 -4
View File
@@ -43,12 +43,23 @@ def _ensure_json_content(text: str) -> dict:
return {"content": [{"type": "text", "text": stripped}]}
def _format_file_context(file_summaries: list[dict]) -> str:
file_blocks: list[str] = []
for item in file_summaries:
file_name = item.get("file_name") or "未命名文件"
summary = item.get("summary") or "文件内容为空。"
file_blocks.append(f"文件:{file_name}\n内容:\n{summary}")
return "\n\n".join(file_blocks)
def _build_prompt(paragraph: Paragraph, file_summaries: list[dict] | None = None) -> tuple[str, str]:
system_prompt = (
"你是一个企业文档撰写助手。"
"请严格输出 JSON,不要输出 JSON 之外的说明。"
'格式为:{"content":[{"type":"text","text":"..."},{"type":"table","title":"...","headers":["..."],"rows":[["..."]]}]}。'
)
if getattr(paragraph, "enable_reasoning", False):
system_prompt += "你可以先进行充分思考,再给出最终答案,但最终只输出要求的结果内容。"
user_parts = [f"段落标题:{paragraph.title}"]
if paragraph.content:
@@ -56,10 +67,7 @@ def _build_prompt(paragraph: Paragraph, file_summaries: list[dict] | None = None
if paragraph.need_prompt and paragraph.prompt_text:
user_parts.append(f"附加要求:{paragraph.prompt_text}")
if file_summaries:
file_blocks = []
for item in file_summaries:
file_blocks.append(f"文件名:{item['file_name']}\n文件摘要:{item['summary']}")
user_parts.append("参考文件内容:\n" + "\n\n".join(file_blocks))
user_parts.append("参考文件内容:\n" + _format_file_context(file_summaries))
user_parts.append(f"输出格式:{paragraph.output_format}")
return system_prompt, "\n\n".join(user_parts)
@@ -136,6 +144,65 @@ async def _call_openai_compatible(model: AiModel, system_prompt: str, user_promp
return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name)
async def _stream_openai_compatible(model: AiModel, system_prompt: str, user_prompt: str):
api_key = decrypt_text(model.api_key_encrypted)
if not api_key:
raise RuntimeError("模型 API Key 不可用")
payload = {
"model": model.name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": 0.3,
"stream": True,
}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
last_error: Exception | None = None
for attempt in range(settings.AI_MAX_RETRIES):
try:
async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client:
async with client.stream("POST", _normalize_openai_endpoint(model.api_endpoint), headers=headers, json=payload) as response:
if response.status_code in (429, 500, 502, 503, 504):
body = await response.aread()
raise httpx.HTTPStatusError(
f"上游模型流式响应异常: {response.status_code} - {body.decode('utf-8', errors='ignore')[:500]}",
request=response.request,
response=response,
)
response.raise_for_status()
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue
payload_line = line[5:].strip()
if payload_line == "[DONE]":
break
try:
chunk = json.loads(payload_line)
except json.JSONDecodeError:
continue
delta_payload = chunk.get("choices", [{}])[0].get("delta", {})
delta = delta_payload.get("content", "")
reasoning = delta_payload.get("reasoning_content", "")
if isinstance(delta, list):
delta = "".join(
item.get("text", "") if isinstance(item, dict) else str(item)
for item in delta
)
if delta:
yield delta
if reasoning:
yield reasoning
return
except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.NetworkError) as error:
last_error = error
if attempt == settings.AI_MAX_RETRIES - 1:
break
await asyncio.sleep(2 ** attempt)
raise RuntimeError(f"模型流式调用失败:{last_error}")
async def _call_anthropic(model: AiModel, system_prompt: str, user_prompt: str) -> AiCallResult:
api_key = decrypt_text(model.api_key_encrypted)
if not api_key:
@@ -162,8 +229,79 @@ async def _call_anthropic(model: AiModel, system_prompt: str, user_prompt: str)
return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name)
async def _stream_anthropic(model: AiModel, system_prompt: str, user_prompt: str):
api_key = decrypt_text(model.api_key_encrypted)
if not api_key:
raise RuntimeError("模型 API Key 不可用")
payload = {
"model": model.name,
"max_tokens": 2048,
"system": system_prompt,
"messages": [{"role": "user", "content": user_prompt}],
"stream": True,
}
headers = {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
last_error: Exception | None = None
for attempt in range(settings.AI_MAX_RETRIES):
try:
async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT, trust_env=False) as client:
async with client.stream("POST", _normalize_anthropic_endpoint(model.api_endpoint), headers=headers, json=payload) as response:
if response.status_code in (429, 500, 502, 503, 504):
body = await response.aread()
raise httpx.HTTPStatusError(
f"上游模型流式响应异常: {response.status_code} - {body.decode('utf-8', errors='ignore')[:500]}",
request=response.request,
response=response,
)
response.raise_for_status()
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue
payload_line = line[5:].strip()
if payload_line == "[DONE]":
break
try:
chunk = json.loads(payload_line)
except json.JSONDecodeError:
continue
if chunk.get("type") == "content_block_delta":
delta = chunk.get("delta", {}).get("text", "")
if delta:
yield delta
return
except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.NetworkError) as error:
last_error = error
if attempt == settings.AI_MAX_RETRIES - 1:
break
await asyncio.sleep(2 ** attempt)
raise RuntimeError(f"模型流式调用失败:{last_error}")
async def call_ai(paragraph: Paragraph, model: AiModel, file_summaries: list[dict] | None = None) -> AiCallResult:
system_prompt, user_prompt = _build_prompt(paragraph, file_summaries)
if model.api_format == "anthropic":
return await _call_anthropic(model, system_prompt, user_prompt)
return await _call_openai_compatible(model, system_prompt, user_prompt)
def build_test_stream_prompt(paragraph: Paragraph, file_summaries: list[dict] | None = None) -> tuple[str, str]:
system_prompt = "你是一个企业文档撰写助手。请直接输出适合预览的正文内容或 Markdown 表格,不要输出 JSON。"
if getattr(paragraph, "enable_reasoning", False):
system_prompt += "你可以先进行充分思考,再持续输出最终可展示的内容。"
_, user_prompt = _build_prompt(paragraph, file_summaries or [])
return system_prompt, user_prompt
async def stream_ai_preview(paragraph: Paragraph, model: AiModel, file_summaries: list[dict] | None = None):
system_prompt, user_prompt = build_test_stream_prompt(paragraph, file_summaries)
if model.api_format == "anthropic":
async for chunk in _stream_anthropic(model, system_prompt, user_prompt):
yield chunk
return
async for chunk in _stream_openai_compatible(model, system_prompt, user_prompt):
yield chunk
+3 -1
View File
@@ -63,10 +63,12 @@ def summarize_file_bytes(file_name: str, content: bytes) -> str:
return _decode_text(content)[:4000]
if suffix == ".csv":
return _summarize_csv(content)
if suffix in {".xlsx", ".xls"}:
if suffix in {".xlsx", ".xls", ".xlsm"}:
return _summarize_excel(content, suffix)
if suffix == ".docx":
return _summarize_docx(content)
if suffix == ".doc":
return "当前暂不支持直接解析 .doc 旧版 Word 文件正文,建议先另存为 .docx 后再上传。"
if suffix == ".pdf":
return _summarize_pdf(content)
return f"暂不支持解析该文件内容:{file_name}"
+1
View File
@@ -117,6 +117,7 @@ async def run_generation(document_id: int, template_id: int):
if model is None:
content = build_mock_content(paragraph)
else:
setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning))
result_data = await call_ai(paragraph, model)
content = result_data.content
status = "success"
+50
View File
@@ -106,3 +106,53 @@
5. 重写模板编辑页,使其更接近原型的三栏编辑结构,并实现“立即测试”三步弹窗、多文件上传、文件摘要展示和测试结果预览。
6. 增补 PDF 与老式 Excel 解析依赖,并执行后端语法检查与前端类型检查。
- **执行结果**: 模板编辑页已更接近原型编辑态,“立即测试”现支持多文件上传、文件内容解析、带提示词调用 AI 模型并返回结果。
## 会话 ID: local-20260702163801
- [2026-07-02 16:38:01]
- **执行原因**: 用户要求先提交代码后继续完善,并新增模型“是否支持流式传输”配置及模板测试流式返回能力。
- **执行过程**:
1. 先提交“模板测试弹窗与多文件解析链路”改动,保持工作区清晰。
2. 为模型表新增 `supports_streaming` 字段,并在数据库初始化时兼容旧表自动补列。
3. 更新模型创建、编辑、列表返回结构,以及模型管理页表单和展示,支持配置是否开启流式传输。
4. 扩展 AI 服务,新增 OpenAI / Anthropic 的流式输出能力。
5. 为段落测试新增流式接口;当模型开启流式能力时,模板编辑页测试弹窗在处理中阶段实时显示模型返回内容。
6. 执行后端语法检查与前端类型检查,确认本轮流式能力改动稳定。
- **执行结果**: 当前系统已支持在模型配置中开启流式传输,并在模板编辑页对开启流式的模型进行实时测试返回。
## 会话 ID: local-20260702165813
- [2026-07-02 16:58:13]
- **执行原因**: 用户希望在模型设置中增加“是否开启思考”能力,并确认是否能接入现有测试与生成流程。
- **执行过程**:
1. 为模型表、初始化 SQL、后端 Schema 和模型管理页补齐 `enable_reasoning` 字段,支持创建、编辑和展示思考模式开关。
2. 修正段落测试、流式测试、模型连接测试和整份文档生成链路,将模型上的思考模式显式传递给 AI 提示词构建逻辑。
3. 执行后端语法检查与前端类型检查,确认本轮改动稳定可用。
- **执行结果**: 当前系统已支持在模型配置中开启或关闭思考模式,且会同时作用于模型连接测试、模板编辑页测试和正式生成流程。
## 会话 ID: local-20260702170209
- [2026-07-02 17:02:09]
- **执行原因**: 用户希望在提交给 AI 的参考文件内容中显式带上文件名,便于模型理解每份内容对应的来源文件。
- **执行过程**:
1. 调整 AI 提示词中的参考文件拼接格式,将多文件上下文改为“文件:xxx”加“内容:...”的结构化文本。
2. 修正流式预览调用,确保测试流式场景也复用同一套系统提示词与文件上下文格式。
3. 执行后端语法检查与前端类型检查,确认本轮改动稳定。
- **执行结果**: 当前无论普通测试还是流式测试,AI 在接收参考文件时都能明确看到每个文件的文件名与对应内容摘要。
## 会话 ID: local-20260702170627
- [2026-07-02 17:06:27]
- **执行原因**: 用户希望参考文件支持一次选择多个,并将已上传文件保存为系统历史,后续可直接复用或重新上传。
- **执行过程**:
1. 新增 `reference_files` 数据表与后端模型,在参考文件上传成功后持久化保存文件名、对象路径、大小、类型和创建时间。
2. 新增历史文件列表接口,支持按文件名搜索和分页读取已上传的参考文件记录。
3. 重写模板编辑页测试弹窗的文件区,保留多文件本地上传,同时新增历史文件库选择区,允许“历史文件 + 新上传文件”混合提交给 AI。
4. 执行后端语法检查与前端类型检查,确认本轮改动稳定。
- **执行结果**: 当前段落测试已支持多文件一起提交,且上传过的参考文件会进入系统历史库,后续可以直接勾选历史文件或重新上传新文件。
## 会话 ID: local-20260702171007
- [2026-07-02 17:10:07]
- **执行原因**: 用户反馈上传参考文件时报“文件类型不支持”,需要补齐支持范围并提升报错可读性。
- **执行过程**:
1. 扩展后端参考文件白名单,补充 `.doc``.xlsm``.json` 等常见文件后缀。
2. 调整文件摘要服务,支持解析 `.xlsm`,并对 `.doc` 返回明确的转换建议说明。
3. 优化上传接口错误提示,返回实际不支持的扩展名和当前支持列表。
4. 同步更新模板测试弹窗中的上传提示文案,并执行后端语法检查与前端类型检查。
- **执行结果**: 当前参考文件上传支持范围更完整,遇到不支持的文件类型时也会直接显示具体后缀和支持列表,便于快速判断问题。
+11
View File
@@ -19,6 +19,8 @@ CREATE TABLE IF NOT EXISTS ai_models (
api_format VARCHAR(20) DEFAULT 'openai' COMMENT 'anthropic/openai',
api_endpoint VARCHAR(500) DEFAULT '' COMMENT 'API 地址',
api_key_encrypted TEXT DEFAULT '' COMMENT '加密后的 API Key',
supports_streaming TINYINT(1) DEFAULT 0 COMMENT '是否支持流式传输',
enable_reasoning TINYINT(1) DEFAULT 0 COMMENT '是否开启思考模式',
status VARCHAR(20) DEFAULT 'enabled' COMMENT 'enabled/disabled',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
@@ -74,3 +76,12 @@ CREATE TABLE IF NOT EXISTS generation_logs (
FOREIGN KEY (paragraph_id) REFERENCES paragraphs(id) ON DELETE CASCADE,
FOREIGN KEY (model_id) REFERENCES ai_models(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS reference_files (
id INT AUTO_INCREMENT PRIMARY KEY,
file_name VARCHAR(255) DEFAULT '' COMMENT '原始文件名',
file_path VARCHAR(500) DEFAULT '' COMMENT 'MinIO 对象路径',
file_size INT DEFAULT 0 COMMENT '文件大小',
content_type VARCHAR(120) DEFAULT '' COMMENT '文件类型',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+2
View File
@@ -2,7 +2,9 @@ import http from './index'
export const generateApi = {
test: (data: any) => http.post('/generate/test', data),
testStream: () => '/api/v1/generate/test-stream',
upload: (formData: FormData) => http.post('/generate/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }),
referenceFiles: (params?: any) => http.get('/generate/reference-files', { params }),
full: (data: any) => http.post('/generate/full', data),
progress: (id: number) => `/api/v1/generate/progress/${id}`,
cancel: (id: number) => http.post(`/generate/cancel/${id}`),
+5 -1
View File
@@ -13,7 +13,7 @@ export interface Paragraph {
export interface AiModel {
id: number; name: string; provider: string; api_format: 'anthropic' | 'openai'
api_endpoint: string; api_key_preview: string; status: 'enabled' | 'disabled'
api_endpoint: string; api_key_preview: string; supports_streaming: boolean; enable_reasoning: boolean; status: 'enabled' | 'disabled'
}
export interface Document {
@@ -23,5 +23,9 @@ export interface Document {
file_path: string; error: string
}
export interface ReferenceFile {
id: number; file_name: string; file_path: string; file_size: number; content_type: string; created_at: string
}
export interface ApiResponse<T = any> { code: number; data: T; message: string }
export interface PageData<T = any> { items: T[]; total: number; page: number; page_size: number }
+12 -2
View File
@@ -18,6 +18,8 @@
<div class="mc-name">{{ item.name }}</div>
<div class="mc-provider">{{ item.provider }} · {{ item.api_endpoint }}</div>
<div class="mc-provider">密钥{{ item.api_key_preview || '未设置' }}</div>
<div class="mc-provider">流式传输{{ item.supports_streaming ? '支持' : '关闭' }}</div>
<div class="mc-provider">思考模式{{ item.enable_reasoning ? '开启' : '关闭' }}</div>
</div>
<div :class="['mc-status', item.status === 'enabled' ? 'on' : 'off']">
{{ item.status === 'enabled' ? '已启用' : '已禁用' }}
@@ -48,6 +50,12 @@
<a-form-item label="API 地址">
<a-input v-model:value="form.api_endpoint" />
</a-form-item>
<a-form-item label="支持流式传输">
<a-switch v-model:checked="form.supports_streaming" />
</a-form-item>
<a-form-item label="开启思考模式">
<a-switch v-model:checked="form.enable_reasoning" />
</a-form-item>
<a-form-item label="API Key">
<a-input-password v-model:value="form.api_key" />
</a-form-item>
@@ -66,7 +74,7 @@ const models = ref<any[]>([])
const modalOpen = ref(false)
const isEdit = ref(false)
const editId = ref(0)
const form = ref({ name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '' })
const form = ref({ name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false })
async function refreshList() {
await store.fetchList()
@@ -79,7 +87,7 @@ onMounted(async () => {
function openAdd() {
isEdit.value = false
form.value = { name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '' }
form.value = { name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false }
modalOpen.value = true
}
@@ -92,6 +100,8 @@ function openEdit(item: any) {
api_format: item.api_format,
api_endpoint: item.api_endpoint,
api_key: '',
supports_streaming: !!item.supports_streaming,
enable_reasoning: !!item.enable_reasoning,
}
modalOpen.value = true
}
+285 -14
View File
@@ -140,7 +140,7 @@
<a-upload-dragger :multiple="true" :beforeUpload="beforeTestUpload" :showUploadList="false">
<p class="ant-upload-drag-icon"><upload-outlined /></p>
<p class="ant-upload-text">点击或拖拽上传参考文件</p>
<p class="ant-upload-hint">支持多文件docx / xlsx / xls / csv / pdf / txt / md</p>
<p class="ant-upload-hint">支持多文件docx / doc / xlsx / xls / xlsm / csv / pdf / txt / md / json</p>
</a-upload-dragger>
</div>
@@ -151,12 +151,62 @@
</div>
</div>
<a-button type="primary" block :loading="testing" @click="startTest">开始测试</a-button>
<div class="history-card">
<div class="history-head">
<div>
<div class="history-title">历史文件</div>
<div class="history-desc">已上传过的文件会保存在系统里下次可直接选择复用</div>
</div>
<a-button size="small" @click="fetchReferenceHistory">刷新</a-button>
</div>
<div class="history-search">
<a-input-search
v-model:value="historyKeyword"
placeholder="按文件名搜索历史文件"
allow-clear
@search="fetchReferenceHistory"
/>
</div>
<a-spin :spinning="historyLoading">
<div v-if="referenceHistory.length" class="history-list">
<label v-for="item in referenceHistory" :key="item.id" class="history-item">
<input
type="checkbox"
:checked="selectedHistoryPaths.includes(item.file_path)"
@change="toggleHistoryFile(item.file_path)"
/>
<div class="history-item-main">
<div class="history-item-name">{{ item.file_name }}</div>
<div class="history-item-meta">
<span>{{ formatFileSize(item.file_size) }}</span>
<span>{{ formatDateTime(item.created_at) }}</span>
</div>
</div>
</label>
</div>
<a-empty v-else description="暂无历史文件" />
</a-spin>
</div>
<div v-if="selectedHistoryItems.length" class="uploaded-list">
<div class="uploaded-item" v-for="item in selectedHistoryItems" :key="item.file_path">
<span class="uploaded-name">{{ item.file_name }}</span>
<a-button type="link" size="small" danger @click="toggleHistoryFile(item.file_path)">取消选择</a-button>
</div>
</div>
<a-button type="primary" block :loading="testing" @click="startTest">开始测试支持多文件</a-button>
</div>
<div v-else-if="testStep === 1" class="test-processing">
<a-spin size="large" />
<p class="processing-text">{{ testStatusText }}</p>
<div v-if="streamedText" class="streaming-card">
<div class="streaming-title">模型实时返回内容</div>
<pre class="streaming-pre">{{ streamedText }}</pre>
</div>
</div>
<div v-else>
@@ -194,6 +244,7 @@ import {
import { useTemplateStore } from '@/stores/template'
import { useModelStore } from '@/stores/model'
import { generateApi } from '@/api/generate'
import type { ReferenceFile } from '@/types'
interface LocalUploadFile {
uid: string
@@ -219,8 +270,16 @@ const testFiles = ref<LocalUploadFile[]>([])
const testResultHtml = ref('')
const testResultMessage = ref('')
const testFileSummaries = ref<any[]>([])
const streamedText = ref('')
const referenceHistory = ref<ReferenceFile[]>([])
const selectedHistoryPaths = ref<string[]>([])
const historyKeyword = ref('')
const historyLoading = ref(false)
const selectedPara = computed(() => paragraphs.value.find((item) => item.id === selectedId.value))
const selectedHistoryItems = computed(() =>
referenceHistory.value.filter((item) => selectedHistoryPaths.value.includes(item.file_path))
)
function selectPara(id: number) {
selectedId.value = id
@@ -240,6 +299,8 @@ function openTestModal() {
testResultHtml.value = ''
testResultMessage.value = ''
testFileSummaries.value = []
selectedHistoryPaths.value = []
fetchReferenceHistory()
}
function resetTestModal() {
@@ -250,6 +311,8 @@ function resetTestModal() {
testResultHtml.value = ''
testResultMessage.value = ''
testFileSummaries.value = []
streamedText.value = ''
selectedHistoryPaths.value = []
}
function beforeTestUpload(file: File) {
@@ -265,6 +328,40 @@ function removeTestFile(uid: string) {
testFiles.value = testFiles.value.filter((item) => item.uid !== uid)
}
function toggleHistoryFile(filePath: string) {
if (selectedHistoryPaths.value.includes(filePath)) {
selectedHistoryPaths.value = selectedHistoryPaths.value.filter((item) => item !== filePath)
return
}
selectedHistoryPaths.value = [...selectedHistoryPaths.value, filePath]
}
function formatFileSize(fileSize: number) {
if (fileSize < 1024) return `${fileSize} B`
if (fileSize < 1024 * 1024) return `${(fileSize / 1024).toFixed(1)} KB`
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
}
function formatDateTime(value: string) {
return value ? value.replace('T', ' ').slice(0, 19) : ''
}
async function fetchReferenceHistory() {
historyLoading.value = true
try {
const response: any = await generateApi.referenceFiles({
page: 1,
page_size: 30,
keyword: historyKeyword.value.trim(),
})
referenceHistory.value = response.data?.items || []
} catch (error: any) {
message.error(error.message || '加载历史文件失败')
} finally {
historyLoading.value = false
}
}
function renderTestResult(content: any) {
const blocks = content?.content || []
return blocks
@@ -291,7 +388,7 @@ function renderTestResult(content: any) {
async function startTest() {
if (!selectedPara.value) return
if (selectedPara.value.need_file && !testFiles.value.length) {
if (selectedPara.value.need_file && !testFiles.value.length && !selectedHistoryPaths.value.length) {
message.warning('请先上传至少一个参考文件')
return
}
@@ -308,21 +405,34 @@ async function startTest() {
const uploadRes: any = await generateApi.upload(formData)
filePaths.push(uploadRes.data.file_path)
}
for (const filePath of selectedHistoryPaths.value) {
if (!filePaths.includes(filePath)) {
filePaths.push(filePath)
}
}
await fetchReferenceHistory()
testStatusText.value = '正在解析文件内容并请求 AI 模型...'
const templateId = Number(route.params.id)
const response: any = await generateApi.test({
paragraph_id: selectedPara.value.id,
template_id: templateId,
prompt_text: selectedPara.value.prompt_text || '',
model_id: selectedPara.value.model_id || 0,
file_paths: filePaths,
})
const currentModel = models.value.find((item) => item.id === selectedPara.value.model_id)
if (currentModel?.supports_streaming) {
testStatusText.value = '正在流式接收模型返回内容...'
streamedText.value = ''
await runStreamingTest(templateId, filePaths)
} else {
const response: any = await generateApi.test({
paragraph_id: selectedPara.value.id,
template_id: templateId,
prompt_text: selectedPara.value.prompt_text || '',
model_id: selectedPara.value.model_id || 0,
file_paths: filePaths,
})
testResultMessage.value = response.data?.message || '测试完成'
testFileSummaries.value = response.data?.file_summaries || []
testResultHtml.value = renderTestResult(response.data?.content)
testStep.value = 2
testResultMessage.value = response.data?.message || '测试完成'
testFileSummaries.value = response.data?.file_summaries || []
testResultHtml.value = renderTestResult(response.data?.content)
testStep.value = 2
}
} catch (error: any) {
message.error(error.message || '段落测试失败')
resetTestModal()
@@ -331,6 +441,68 @@ async function startTest() {
}
}
async function runStreamingTest(templateId: number, filePaths: string[]) {
const response = await fetch(generateApi.testStream(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paragraph_id: selectedPara.value.id,
template_id: templateId,
prompt_text: selectedPara.value.prompt_text || '',
model_id: selectedPara.value.model_id || 0,
file_paths: filePaths,
}),
})
if (!response.ok || !response.body) {
throw new Error('流式测试启动失败')
}
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
const processEventBlock = (block: string) => {
const normalizedBlock = block.replace(/\r\n/g, '\n').trim()
if (!normalizedBlock) return
const dataLines = normalizedBlock
.split('\n')
.filter((line) => line.startsWith('data: '))
.map((line) => line.slice(6))
if (!dataLines.length) return
const payload = JSON.parse(dataLines.join('\n'))
if (payload.type === 'meta') {
testFileSummaries.value = payload.file_summaries || []
testResultMessage.value = payload.message || '流式生成中'
} else if (payload.type === 'delta') {
streamedText.value += payload.content || ''
} else if (payload.type === 'error') {
throw new Error(payload.message || '流式测试失败')
} else if (payload.type === 'done') {
testResultHtml.value = `<pre class="streamed-pre">${streamedText.value}</pre>`
testStep.value = 2
}
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const segments = buffer.split(/\r?\n\r?\n/)
buffer = segments.pop() || ''
for (const segment of segments) {
processEventBlock(segment)
}
}
if (buffer.trim()) {
processEventBlock(buffer)
}
if (!testResultHtml.value) {
testResultHtml.value = `<pre class="streamed-pre">${streamedText.value}</pre>`
testStep.value = 2
}
}
onMounted(async () => {
const id = Number(route.params.id)
const template = await store.fetchOne(id)
@@ -654,6 +826,75 @@ onMounted(async () => {
margin-bottom: 16px;
}
.history-card {
margin-bottom: 16px;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fafbfc;
}
.history-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.history-title {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.history-desc {
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.history-search {
margin: 12px 0;
}
.history-list {
display: grid;
gap: 8px;
max-height: 240px;
overflow-y: auto;
}
.history-item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fff;
cursor: pointer;
}
.history-item-main {
min-width: 0;
flex: 1;
}
.history-item-name {
font-size: 13px;
font-weight: 500;
color: #111827;
word-break: break-all;
}
.history-item-meta {
display: flex;
gap: 12px;
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.uploaded-item {
display: flex;
justify-content: space-between;
@@ -678,6 +919,29 @@ onMounted(async () => {
color: #5b626e;
}
.streaming-card {
margin-top: 20px;
background: #f0f0ff;
border-left: 3px solid #5b5bd6;
border-radius: 4px;
padding: 16px;
text-align: left;
}
.streaming-title {
font-size: 12px;
font-weight: 600;
color: #5b5bd6;
margin-bottom: 8px;
}
.streaming-pre {
white-space: pre-wrap;
margin: 0;
font-family: inherit;
line-height: 1.8;
}
.result-meta {
display: flex;
align-items: center;
@@ -739,6 +1003,13 @@ onMounted(async () => {
border-radius: 4px;
}
:deep(.streamed-pre) {
white-space: pre-wrap;
margin: 0;
font-family: inherit;
line-height: 1.8;
}
:deep(.result-text) {
margin: 0 0 12px;
line-height: 1.8;