接入真实模型调用与参考文件上传
This commit is contained in:
@@ -15,7 +15,7 @@
|
|||||||
- 上传 `.docx` 模板并按 Heading 1~6 解析段落
|
- 上传 `.docx` 模板并按 Heading 1~6 解析段落
|
||||||
- 在模板编辑页配置段落的编辑方式、模型、提示词、文件要求、输出格式
|
- 在模板编辑页配置段落的编辑方式、模型、提示词、文件要求、输出格式
|
||||||
- 管理模型配置,API Key 以加密形式存储,前端仅显示脱敏内容
|
- 管理模型配置,API Key 以加密形式存储,前端仅显示脱敏内容
|
||||||
- 执行整份文档的模拟生成
|
- 执行整份文档生成:已支持按模型配置发起真实调用,异常时自动回退为模拟结果
|
||||||
- 查看生成记录与预览页真实结果
|
- 查看生成记录与预览页真实结果
|
||||||
- 导出 Word:基于原模板替换标题下内容并生成可下载文件
|
- 导出 Word:基于原模板替换标题下内容并生成可下载文件
|
||||||
|
|
||||||
|
|||||||
+84
-18
@@ -1,17 +1,23 @@
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import settings
|
||||||
from database import get_db
|
from database import get_db
|
||||||
|
from models.ai_model import AiModel
|
||||||
from models.document import Document
|
from models.document import Document
|
||||||
from models.generation_log import GenerationLog
|
from models.generation_log import GenerationLog
|
||||||
from models.paragraph import Paragraph
|
from models.paragraph import Paragraph
|
||||||
from models.template import Template
|
from models.template import Template
|
||||||
from schemas.schemas import GenerateFullRequest, GenerateTestRequest, Response
|
from schemas.schemas import GenerateFullRequest, GenerateTestRequest, Response
|
||||||
|
from services.ai_service import call_ai
|
||||||
|
from services.minio_client import upload_bytes
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -60,18 +66,72 @@ def _build_mock_content(paragraph: Paragraph) -> dict:
|
|||||||
return {"content": blocks}
|
return {"content": blocks}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_effective_model(db: AsyncSession, paragraph: Paragraph) -> AiModel | None:
|
||||||
|
if paragraph.model_id:
|
||||||
|
model = await db.get(AiModel, paragraph.model_id)
|
||||||
|
if model is not None and model.status == "enabled":
|
||||||
|
return model
|
||||||
|
result = await db.execute(
|
||||||
|
select(AiModel).where(AiModel.status == "enabled").order_by(AiModel.id.asc()).limit(1)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test")
|
@router.post("/test")
|
||||||
async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)):
|
async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)):
|
||||||
paragraph = await db.get(Paragraph, body.paragraph_id)
|
paragraph = await db.get(Paragraph, body.paragraph_id)
|
||||||
if paragraph is None or paragraph.template_id != body.template_id:
|
if paragraph is None or paragraph.template_id != body.template_id:
|
||||||
raise HTTPException(status_code=404, detail="段落不存在")
|
raise HTTPException(status_code=404, detail="段落不存在")
|
||||||
|
|
||||||
|
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":
|
||||||
content = _build_mock_content(paragraph)
|
content = _build_mock_content(paragraph)
|
||||||
|
message = "当前未找到可用模型,返回本地模拟生成结果。"
|
||||||
|
else:
|
||||||
|
result = await call_ai(paragraph, model)
|
||||||
|
content = result.content
|
||||||
|
message = f"已通过模型 {result.used_model} 生成。"
|
||||||
return Response(
|
return Response(
|
||||||
data={
|
data={
|
||||||
"paragraph_id": paragraph.id,
|
"paragraph_id": paragraph.id,
|
||||||
"content": content,
|
"content": content,
|
||||||
"message": "当前返回本地模拟生成结果,便于前端联调。",
|
"message": message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload_reference_file(file: UploadFile = File(...)):
|
||||||
|
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="文件类型不支持")
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=400, detail="上传文件不能为空")
|
||||||
|
if len(content) > settings.MAX_UPLOAD_SIZE:
|
||||||
|
raise HTTPException(status_code=400, detail="文件大小超过限制")
|
||||||
|
|
||||||
|
object_name = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}{ext}"
|
||||||
|
await asyncio.to_thread(
|
||||||
|
upload_bytes,
|
||||||
|
settings.MINIO_BUCKET_UPLOADS,
|
||||||
|
object_name,
|
||||||
|
content,
|
||||||
|
file.content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
data={
|
||||||
|
"file_name": file.filename,
|
||||||
|
"file_path": f"{settings.MINIO_BUCKET_UPLOADS}/{object_name}",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,40 +164,46 @@ async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(ge
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
done_count = 0
|
done_count = 0
|
||||||
|
failed_count = 0
|
||||||
for paragraph in paragraphs:
|
for paragraph in paragraphs:
|
||||||
if paragraph.edit_mode == "manual":
|
if paragraph.edit_mode == "manual":
|
||||||
content = {"content": [{"type": "text", "text": paragraph.content or "该段落为人工编辑模式。"}]}
|
content = {"content": [{"type": "text", "text": paragraph.content or "该段落为人工编辑模式。"}]}
|
||||||
|
status = "success"
|
||||||
|
duration = 0
|
||||||
|
error_message = ""
|
||||||
else:
|
else:
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
|
model = await _get_effective_model(db, paragraph)
|
||||||
|
try:
|
||||||
|
if model is None:
|
||||||
content = _build_mock_content(paragraph)
|
content = _build_mock_content(paragraph)
|
||||||
|
else:
|
||||||
|
result = await call_ai(paragraph, model)
|
||||||
|
content = result.content
|
||||||
|
status = "success"
|
||||||
|
error_message = ""
|
||||||
|
except Exception as error:
|
||||||
|
content = _build_mock_content(paragraph)
|
||||||
|
status = "failed"
|
||||||
|
error_message = str(error)
|
||||||
|
failed_count += 1
|
||||||
duration = round(time.perf_counter() - start, 4)
|
duration = round(time.perf_counter() - start, 4)
|
||||||
log = GenerationLog(
|
|
||||||
document_id=document.id,
|
|
||||||
paragraph_id=paragraph.id,
|
|
||||||
model_id=paragraph.model_id,
|
|
||||||
status="success",
|
|
||||||
content=json.dumps(content, ensure_ascii=False),
|
|
||||||
duration=duration,
|
|
||||||
error_msg="",
|
|
||||||
)
|
|
||||||
db.add(log)
|
|
||||||
done_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
log = GenerationLog(
|
log = GenerationLog(
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
paragraph_id=paragraph.id,
|
paragraph_id=paragraph.id,
|
||||||
model_id=paragraph.model_id,
|
model_id=paragraph.model_id,
|
||||||
status="success",
|
status=status,
|
||||||
content=json.dumps(content, ensure_ascii=False),
|
content=json.dumps(content, ensure_ascii=False),
|
||||||
duration=0,
|
duration=duration,
|
||||||
error_msg="",
|
error_msg=error_message,
|
||||||
)
|
)
|
||||||
db.add(log)
|
db.add(log)
|
||||||
done_count += 1
|
done_count += 1
|
||||||
|
|
||||||
document.para_count_done = done_count
|
document.para_count_done = done_count
|
||||||
document.status = "completed"
|
document.status = "completed" if failed_count == 0 else "failed"
|
||||||
|
document.error = "" if failed_count == 0 else f"{failed_count} 个段落生成失败,已回退为模拟结果。"
|
||||||
document.file_path = f"mock://document/{document.id}"
|
document.file_path = f"mock://document/{document.id}"
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(document)
|
await db.refresh(document)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from database import get_db
|
from database import get_db
|
||||||
from models.ai_model import AiModel
|
from models.ai_model import AiModel
|
||||||
from schemas.schemas import AiModelCreate, AiModelUpdate, Response
|
from schemas.schemas import AiModelCreate, AiModelUpdate, Response
|
||||||
|
from services.ai_service import call_ai
|
||||||
from services.security import decrypt_text, encrypt_text, mask_secret
|
from services.security import decrypt_text, encrypt_text, mask_secret
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -88,10 +89,26 @@ async def test_model(model_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
if model is None:
|
if model is None:
|
||||||
raise HTTPException(status_code=404, detail="模型不存在")
|
raise HTTPException(status_code=404, detail="模型不存在")
|
||||||
|
|
||||||
|
class FakeParagraph:
|
||||||
|
title = "连接测试"
|
||||||
|
content = "请返回一段非常简短的测试文本。"
|
||||||
|
need_prompt = False
|
||||||
|
prompt_text = ""
|
||||||
|
output_format = "text"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await call_ai(FakeParagraph(), model)
|
||||||
return Response(
|
return Response(
|
||||||
data={
|
data={
|
||||||
"id": model.id,
|
"id": model.id,
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"模型 {model.name} 配置校验通过(当前为本地模拟测试)",
|
"message": f"模型 {model.name} 连接测试成功",
|
||||||
|
"preview": result.content,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
except Exception as error:
|
||||||
|
return Response(
|
||||||
|
code=-1,
|
||||||
|
message=str(error),
|
||||||
|
data={"id": model.id, "success": False},
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from models.ai_model import AiModel
|
||||||
|
from models.paragraph import Paragraph
|
||||||
|
from services.security import decrypt_text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AiCallResult:
|
||||||
|
content: dict
|
||||||
|
raw_text: str
|
||||||
|
used_model: str
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_json_content(text: str) -> dict:
|
||||||
|
stripped = text.strip()
|
||||||
|
if not stripped:
|
||||||
|
return {"content": [{"type": "text", "text": ""}]}
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(stripped)
|
||||||
|
if isinstance(parsed, dict) and "content" in parsed:
|
||||||
|
return parsed
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
code_block_match = re.search(r"```json\s*(.*?)\s*```", stripped, re.S)
|
||||||
|
if code_block_match:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(code_block_match.group(1))
|
||||||
|
if isinstance(parsed, dict) and "content" in parsed:
|
||||||
|
return parsed
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"content": [{"type": "text", "text": stripped}]}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_prompt(paragraph: Paragraph) -> tuple[str, str]:
|
||||||
|
system_prompt = (
|
||||||
|
"你是一个企业文档撰写助手。"
|
||||||
|
"请严格输出 JSON,不要输出 JSON 之外的说明。"
|
||||||
|
'格式为:{"content":[{"type":"text","text":"..."},{"type":"table","title":"...","headers":["..."],"rows":[["..."]]}]}。'
|
||||||
|
)
|
||||||
|
|
||||||
|
user_parts = [f"段落标题:{paragraph.title}"]
|
||||||
|
if paragraph.content:
|
||||||
|
user_parts.append(f"模板上下文:{paragraph.content}")
|
||||||
|
if paragraph.need_prompt and paragraph.prompt_text:
|
||||||
|
user_parts.append(f"附加要求:{paragraph.prompt_text}")
|
||||||
|
user_parts.append(f"输出格式:{paragraph.output_format}")
|
||||||
|
return system_prompt, "\n\n".join(user_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_openai_endpoint(api_endpoint: str) -> str:
|
||||||
|
endpoint = api_endpoint.rstrip("/")
|
||||||
|
if endpoint.endswith("/chat/completions"):
|
||||||
|
return endpoint
|
||||||
|
if endpoint.endswith("/v1"):
|
||||||
|
return f"{endpoint}/chat/completions"
|
||||||
|
return f"{endpoint}/v1/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_anthropic_endpoint(api_endpoint: str) -> str:
|
||||||
|
endpoint = api_endpoint.rstrip("/")
|
||||||
|
if endpoint.endswith("/messages"):
|
||||||
|
return endpoint
|
||||||
|
if endpoint.endswith("/v1"):
|
||||||
|
return f"{endpoint}/messages"
|
||||||
|
return f"{endpoint}/v1/messages"
|
||||||
|
|
||||||
|
|
||||||
|
async def _post_with_retry(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
url: str,
|
||||||
|
headers: dict,
|
||||||
|
payload: dict,
|
||||||
|
) -> httpx.Response:
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for attempt in range(settings.AI_MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
response = await client.post(url, headers=headers, json=payload)
|
||||||
|
if response.status_code in (429, 500, 502, 503, 504):
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
f"上游模型响应异常: {response.status_code}",
|
||||||
|
request=response.request,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
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_openai_compatible(model: AiModel, system_prompt: str, user_prompt: str) -> AiCallResult:
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||||
|
async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT) as client:
|
||||||
|
response = await _post_with_retry(client, _normalize_openai_endpoint(model.api_endpoint), headers, payload)
|
||||||
|
body = response.json()
|
||||||
|
text = body["choices"][0]["message"]["content"]
|
||||||
|
return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
raise RuntimeError("模型 API Key 不可用")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model.name,
|
||||||
|
"max_tokens": 2048,
|
||||||
|
"system": system_prompt,
|
||||||
|
"messages": [{"role": "user", "content": user_prompt}],
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"x-api-key": api_key,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=settings.AI_REQUEST_TIMEOUT) as client:
|
||||||
|
response = await _post_with_retry(client, _normalize_anthropic_endpoint(model.api_endpoint), headers, payload)
|
||||||
|
body = response.json()
|
||||||
|
text = ""
|
||||||
|
for item in body.get("content", []):
|
||||||
|
if item.get("type") == "text":
|
||||||
|
text += item.get("text", "")
|
||||||
|
return AiCallResult(content=_ensure_json_content(text), raw_text=text, used_model=model.name)
|
||||||
|
|
||||||
|
|
||||||
|
async def call_ai(paragraph: Paragraph, model: AiModel) -> AiCallResult:
|
||||||
|
system_prompt, user_prompt = _build_prompt(paragraph)
|
||||||
|
if model.api_format == "anthropic":
|
||||||
|
return await _call_anthropic(model, system_prompt, user_prompt)
|
||||||
|
return await _call_openai_compatible(model, system_prompt, user_prompt)
|
||||||
@@ -37,3 +37,16 @@
|
|||||||
5. 再次执行后端语法检查与前端类型检查。
|
5. 再次执行后端语法检查与前端类型检查。
|
||||||
6. 同步更新任务拆解清单,标记当前前端页面中已实际可用的子项。
|
6. 同步更新任务拆解清单,标记当前前端页面中已实际可用的子项。
|
||||||
- **执行结果**: README 已补全运行说明,系统新增基础 Word 导出能力,当前可以从预览页直接导出可下载的 Word 文件。
|
- **执行结果**: README 已补全运行说明,系统新增基础 Word 导出能力,当前可以从预览页直接导出可下载的 Word 文件。
|
||||||
|
|
||||||
|
## 会话 ID: local-20260702151621
|
||||||
|
- [2026-07-02 15:16:21]
|
||||||
|
- **执行原因**: 用户要求继续完善系统能力,并提交当前进展。
|
||||||
|
- **执行过程**:
|
||||||
|
1. 新增统一 AI 调用服务,支持 OpenAI 兼容接口与 Anthropic 接口两类模型调用。
|
||||||
|
2. 为 AI 调用补充 JSON 解析兜底、超时控制、重试机制与异常回退逻辑。
|
||||||
|
3. 将模型管理中的“连接测试”接入真实后端调用,并在前端增加测试与删除操作入口。
|
||||||
|
4. 将整份文档生成流程接入真实模型调用;当模型不可用或调用失败时,自动回退为模拟结果并记录失败状态。
|
||||||
|
5. 新增参考文件上传接口,将执行生成页的附件上传接入 MinIO,并把文件路径带入生成请求。
|
||||||
|
6. 更新 README 与任务拆解清单,标记真实模型调用与文件上传相关能力的完成状态。
|
||||||
|
7. 再次执行后端语法检查与前端类型检查,确认本轮改动稳定。
|
||||||
|
- **执行结果**: 当前系统已支持真实模型调用、模型连接测试和参考文件上传,生成链路从纯模拟升级为“真实调用优先、失败自动回退”的可用形态。
|
||||||
|
|||||||
@@ -24,11 +24,11 @@
|
|||||||
- [x] 输出结构化 JSON
|
- [x] 输出结构化 JSON
|
||||||
|
|
||||||
### AI 服务层(5-7 天)
|
### AI 服务层(5-7 天)
|
||||||
- [ ] OpenAI 格式适配(GPT-4o、DeepSeek-V3、通义千问)
|
- [x] OpenAI 格式适配(GPT-4o、DeepSeek-V3、通义千问)
|
||||||
- [ ] Anthropic 格式适配(Claude 3.5 Sonnet)
|
- [x] Anthropic 格式适配(Claude 3.5 Sonnet)
|
||||||
- [ ] 统一接口:call_ai(paragraph, files, callback) → content
|
- [x] 统一接口:call_ai(paragraph, files, callback) → content
|
||||||
- [ ] 提示词拼接:系统提示词 + 段落预设提示词 + 文件摘要
|
- [ ] 提示词拼接:系统提示词 + 段落预设提示词 + 文件摘要
|
||||||
- [ ] 超时/重试/错误处理
|
- [x] 超时/重试/错误处理
|
||||||
- [ ] 并发控制(asyncio.Semaphore)
|
- [ ] 并发控制(asyncio.Semaphore)
|
||||||
- [ ] 文件摘要生成(Excel 解析 + 数据统计)
|
- [ ] 文件摘要生成(Excel 解析 + 数据统计)
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
- [x] 模型 CRUD 路由
|
- [x] 模型 CRUD 路由
|
||||||
- [ ] 生成相关路由(测试/全量/进度SSE/取消)
|
- [ ] 生成相关路由(测试/全量/进度SSE/取消)
|
||||||
- [ ] 导出路由(Word/PDF)
|
- [ ] 导出路由(Word/PDF)
|
||||||
- [ ] 文件上传/管理
|
- [x] 文件上传/管理
|
||||||
|
|
||||||
## 第三阶段:前端核心开发(第 2-4 周)
|
## 第三阶段:前端核心开发(第 2-4 周)
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
|
|
||||||
### 执行生成页(3 天)
|
### 执行生成页(3 天)
|
||||||
- [x] 双栏布局:左模板选择 + 右段落列表
|
- [x] 双栏布局:左模板选择 + 右段落列表
|
||||||
- [ ] 文件上传区(按段落分列)
|
- [x] 文件上传区(按段落分列)
|
||||||
- [x] 生成按钮 + 进度展示
|
- [x] 生成按钮 + 进度展示
|
||||||
- [x] 完成跳转
|
- [x] 完成跳转
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import http from './index'
|
|||||||
|
|
||||||
export const generateApi = {
|
export const generateApi = {
|
||||||
test: (data: any) => http.post('/generate/test', data),
|
test: (data: any) => http.post('/generate/test', data),
|
||||||
|
upload: (formData: FormData) => http.post('/generate/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }),
|
||||||
full: (data: any) => http.post('/generate/full', data),
|
full: (data: any) => http.post('/generate/full', data),
|
||||||
progress: (id: number) => `/api/v1/generate/progress/${id}`,
|
progress: (id: number) => `/api/v1/generate/progress/${id}`,
|
||||||
cancel: (id: number) => http.post(`/generate/cancel/${id}`),
|
cancel: (id: number) => http.post(`/generate/cancel/${id}`),
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<template><div style="padding:24px;display:flex;gap:24px;height:calc(100vh - 112px)"><div style="width:340px;flex-shrink:0"><a-card title="选择模板"><a-select style="width:100%" v-model:value="selectedTplId" placeholder="请选择已编辑好的模板" @change="onTplChange"><a-select-option v-for="t in templates" :key="t.id" :value="t.id">{{t.name}}</a-select-option></a-select><a-divider /><a-statistic title="总段落" :value="tplInfo.paragraph_count||0" /><a-statistic title="需上传文件" :value="tplInfo.fileCount||0" suffix="/"+String(tplInfo.paragraph_count||0) /></a-card><div v-if="generating" style="margin-top:16px"><a-card title="生成进度"><a-progress :percent="progress" /><p>{{progressText}}</p></a-card></div></div><div style="flex:1;display:flex;flex-direction:column"><a-card title="段落文件配置" style="flex:1"><div v-for="p in paragraphs" :key="p.id" :class="['para-row',{needFile:p.need_file,noFile:!p.need_file}]"><div class="para-info"><span class="idx">{{p.sort_index}}</span><span>{{p.title}}</span><a-tag color="blue">{{p.modelName||"默认"}}</a-tag></div><div v-if="p.need_file" class="file-info"><a-upload :beforeUpload="(f: File)=>{return handleFileUpload(p.id,f)}" :showUploadList="false"><a-button size="small">{{uploadedFiles[p.id]?"已上传":"上传文件"}}</a-button></a-upload><span v-if="uploadedFiles[p.id]" style="color:green;margin-left:8px">{{uploadedFiles[p.id]}}</span></div><span v-else class="no-file-tag">无需上传</span></div></a-card><div style="margin-top:16px;display:flex;justify-content:space-between;align-items:center"><span>{{fileCount}}/{{needFileCount}} 个文件已上传</span><a-button type="primary" size="large" :loading="generating" @click="startGen">立即生成</a-button></div></div></div></template>
|
<template><div style="padding:24px;display:flex;gap:24px;height:calc(100vh - 112px)"><div style="width:340px;flex-shrink:0"><a-card title="选择模板"><a-select style="width:100%" v-model:value="selectedTplId" placeholder="请选择已编辑好的模板" @change="onTplChange"><a-select-option v-for="t in templates" :key="t.id" :value="t.id">{{t.name}}</a-select-option></a-select><a-divider /><a-statistic title="总段落" :value="tplInfo.paragraph_count||0" /><a-statistic title="需上传文件" :value="tplInfo.fileCount||0" suffix="/"+String(tplInfo.paragraph_count||0) /></a-card><div v-if="generating" style="margin-top:16px"><a-card title="生成进度"><a-progress :percent="progress" /><p>{{progressText}}</p></a-card></div></div><div style="flex:1;display:flex;flex-direction:column"><a-card title="段落文件配置" style="flex:1"><div v-for="p in paragraphs" :key="p.id" :class="['para-row',{needFile:p.need_file,noFile:!p.need_file}]"><div class="para-info"><span class="idx">{{p.sort_index}}</span><span>{{p.title}}</span><a-tag color="blue">{{p.modelName||"默认"}}</a-tag></div><div v-if="p.need_file" class="file-info"><a-upload :beforeUpload="(f: File)=>{return handleFileUpload(p.id,f)}" :showUploadList="false"><a-button size="small" :loading="uploadingMap[p.id]">{{uploadedFiles[p.id]?"已上传":"上传文件"}}</a-button></a-upload><span v-if="uploadedFiles[p.id]" style="color:green;margin-left:8px">{{uploadedFiles[p.id]}}</span></div><span v-else class="no-file-tag">无需上传</span></div></a-card><div style="margin-top:16px;display:flex;justify-content:space-between;align-items:center"><span>{{fileCount}}/{{needFileCount}} 个文件已上传</span><a-button type="primary" size="large" :loading="generating" @click="startGen">立即生成</a-button></div></div></div></template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { useTemplateStore } from "@/stores/template";
|
import { useTemplateStore } from "@/stores/template";
|
||||||
import { useDocumentStore } from "@/stores/document";
|
import { useDocumentStore } from "@/stores/document";
|
||||||
|
import { generateApi } from "@/api/generate";
|
||||||
import { message } from "ant-design-vue";
|
import { message } from "ant-design-vue";
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const tplStore = useTemplateStore();
|
const tplStore = useTemplateStore();
|
||||||
@@ -12,6 +13,8 @@ const templates = ref<any[]>([]);
|
|||||||
const paragraphs = ref<any[]>([]);
|
const paragraphs = ref<any[]>([]);
|
||||||
const selectedTplId = ref(undefined);
|
const selectedTplId = ref(undefined);
|
||||||
const uploadedFiles = ref<Record<number,string>>({});
|
const uploadedFiles = ref<Record<number,string>>({});
|
||||||
|
const uploadedFilePaths = ref<Record<number,string>>({});
|
||||||
|
const uploadingMap = ref<Record<number,boolean>>({});
|
||||||
const generating = ref(false);
|
const generating = ref(false);
|
||||||
const progress = ref(0);
|
const progress = ref(0);
|
||||||
const progressText = ref("");
|
const progressText = ref("");
|
||||||
@@ -19,8 +22,8 @@ const tplInfo = ref<any>({});
|
|||||||
const needFileCount = computed(()=>paragraphs.value.filter(p=>p.need_file).length);
|
const needFileCount = computed(()=>paragraphs.value.filter(p=>p.need_file).length);
|
||||||
const fileCount = computed(()=>Object.keys(uploadedFiles.value).length);
|
const fileCount = computed(()=>Object.keys(uploadedFiles.value).length);
|
||||||
onMounted(async()=>{await tplStore.fetchList();templates.value=tplStore.templates as any});
|
onMounted(async()=>{await tplStore.fetchList();templates.value=tplStore.templates as any});
|
||||||
async function onTplChange(id:number){const tpl=await tplStore.fetchOne(id);paragraphs.value=tplStore.paragraphs as any;tplInfo.value={paragraph_count:tpl.paragraph_count,fileCount:paragraphs.value.filter((p:any)=>p.need_file).length}}
|
async function onTplChange(id:number){const tpl=await tplStore.fetchOne(id);paragraphs.value=tplStore.paragraphs as any;tplInfo.value={paragraph_count:tpl.paragraph_count,fileCount:paragraphs.value.filter((p:any)=>p.need_file).length};uploadedFiles.value={};uploadedFilePaths.value={};}
|
||||||
function handleFileUpload(paraId:number,file:File){uploadedFiles.value[paraId]=file.name;return false}
|
async function handleFileUpload(paraId:number,file:File){try{uploadingMap.value[paraId]=true;const fd=new FormData();fd.append("file",file);const res:any=await generateApi.upload(fd);uploadedFiles.value[paraId]=res.data.file_name;uploadedFilePaths.value[paraId]=res.data.file_path;message.success("文件上传成功")}catch(e:any){message.error(e.message||"文件上传失败")}finally{uploadingMap.value[paraId]=false}return false}
|
||||||
async function startGen(){if(!selectedTplId.value){message.warning("请先选择模板");return}generating.value=true;progress.value=0;progressText.value="正在生成...";const data={template_id:selectedTplId.value,file_map:{}};try{const doc=await docStore.generateFull(data);message.success("生成完成");router.push(`/preview/${doc.id}`)}catch(e:any){message.error(e.message||"生成失败")}finally{generating.value=false}}
|
async function startGen(){if(!selectedTplId.value){message.warning("请先选择模板");return}const missing=paragraphs.value.filter((p:any)=>p.need_file&&!uploadedFilePaths.value[p.id]);if(missing.length){message.warning("还有必传文件未上传");return}generating.value=true;progress.value=30;progressText.value="正在生成...";const fileMap=Object.fromEntries(Object.entries(uploadedFilePaths.value).map(([k,v])=>[String(k),v]));const data={template_id:selectedTplId.value,file_map:fileMap};try{const doc=await docStore.generateFull(data);progress.value=100;progressText.value="生成完成";message.success("生成完成");router.push(`/preview/${doc.id}`)}catch(e:any){message.error(e.message||"生成失败")}finally{generating.value=false}}
|
||||||
</script>
|
</script>
|
||||||
<style scoped>.para-row{display:flex;align-items:center;justify-content:space-between;padding:12px;border:1px solid #f0f0f0;border-radius:8px;margin-bottom:8px}.para-row.needFile{background:#fff;border-color:#d9d9d9}.para-row.noFile{background:#fafafa;border-style:dashed;opacity:.7}.para-info{display:flex;align-items:center;gap:8px}.para-info .idx{width:22px;height:22px;border-radius:50%;background:#f0f0ff;color:#5b5bd6;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700}.no-file-tag{font-size:12px;color:#999}</style>
|
<style scoped>.para-row{display:flex;align-items:center;justify-content:space-between;padding:12px;border:1px solid #f0f0f0;border-radius:8px;margin-bottom:8px}.para-row.needFile{background:#fff;border-color:#d9d9d9}.para-row.noFile{background:#fafafa;border-style:dashed;opacity:.7}.para-info{display:flex;align-items:center;gap:8px}.para-info .idx{width:22px;height:22px;border-radius:50%;background:#f0f0ff;color:#5b5bd6;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700}.no-file-tag{font-size:12px;color:#999}</style>
|
||||||
|
|||||||
+126
-15
@@ -1,17 +1,128 @@
|
|||||||
<template><div style="padding:24px"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"><h2>模型管理</h2><a-button type="primary" @click="openAdd">添加模型</a-button></div><a-row :gutter="[16,16]"><a-col :span="8" v-for="m in models" :key="m.id"><a-card :title="m.name"><template #extra><a-button type="link" size="small" @click="openEdit(m)">编辑</a-button></template><p>厂商:{{m.provider}}</p><p>格式:{{m.api_format}}</p><p>地址:{{m.api_endpoint}}</p><p>状态:<a-switch :checked="m.status==='enabled'" @change="toggleStatus(m)" /></p></a-card></a-col></a-row><a-modal v-model:open="modalOpen" :title="isEdit?'编辑模型':'添加模型'" @ok="saveModel"><a-form layout="vertical"><a-form-item label="模型名称"><a-input v-model:value="form.name" /></a-form-item><a-form-item label="供应厂商"><a-input v-model:value="form.provider" /></a-form-item><a-form-item label="API 格式"><a-select v-model:value="form.api_format"><a-select-option value="openai">OpenAI 格式</a-select-option><a-select-option value="anthropic">Anthropic 格式</a-select-option></a-select></a-form-item><a-form-item label="API 地址"><a-input v-model:value="form.api_endpoint" placeholder="https://api.xxx.com" /></a-form-item><a-form-item label="API Key"><a-input-password v-model:value="form.api_key" /></a-form-item></a-form></a-modal></div></template>
|
<template>
|
||||||
|
<div style="padding:24px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||||
|
<h2>模型管理</h2>
|
||||||
|
<a-button type="primary" @click="openAdd">添加模型</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-row :gutter="[16, 16]">
|
||||||
|
<a-col :span="8" v-for="item in models" :key="item.id">
|
||||||
|
<a-card :title="item.name">
|
||||||
|
<template #extra>
|
||||||
|
<a-button type="link" size="small" @click="openEdit(item)">编辑</a-button>
|
||||||
|
</template>
|
||||||
|
<p>厂商:{{ item.provider }}</p>
|
||||||
|
<p>格式:{{ item.api_format }}</p>
|
||||||
|
<p>地址:{{ item.api_endpoint }}</p>
|
||||||
|
<p>密钥:{{ item.api_key_preview || '未设置' }}</p>
|
||||||
|
<p>状态:<a-switch :checked="item.status === 'enabled'" @change="toggleStatus(item)" /></p>
|
||||||
|
<div style="margin-top:12px;display:flex;gap:8px">
|
||||||
|
<a-button size="small" @click="runTest(item)">连接测试</a-button>
|
||||||
|
<a-button danger size="small" @click="removeModel(item.id)">删除</a-button>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-modal v-model:open="modalOpen" :title="isEdit ? '编辑模型' : '添加模型'" @ok="saveModel">
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label="模型名称">
|
||||||
|
<a-input v-model:value="form.name" placeholder="如 gpt-4o-mini / deepseek-chat / claude-3-5-sonnet-latest" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="供应厂商">
|
||||||
|
<a-input v-model:value="form.provider" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="API 格式">
|
||||||
|
<a-select v-model:value="form.api_format">
|
||||||
|
<a-select-option value="openai">OpenAI 格式</a-select-option>
|
||||||
|
<a-select-option value="anthropic">Anthropic 格式</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="API 地址">
|
||||||
|
<a-input v-model:value="form.api_endpoint" placeholder="https://api.openai.com 或兼容网关地址" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="API Key">
|
||||||
|
<a-input-password v-model:value="form.api_key" placeholder="编辑时留空表示保持原密钥不变" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from 'vue'
|
||||||
import { useModelStore } from "@/stores/model";
|
import { Modal, message } from 'ant-design-vue'
|
||||||
import { message } from "ant-design-vue";
|
import { useModelStore } from '@/stores/model'
|
||||||
const store = useModelStore();
|
|
||||||
const models = ref<any[]>([]);
|
const store = useModelStore()
|
||||||
const modalOpen = ref(false);
|
const models = ref<any[]>([])
|
||||||
const isEdit = ref(false);
|
const modalOpen = ref(false)
|
||||||
const editId = ref(0);
|
const isEdit = ref(false)
|
||||||
const form = ref({name:"",provider:"",api_format:"openai",api_endpoint:"",api_key:""});
|
const editId = ref(0)
|
||||||
onMounted(async()=>{await store.fetchList();models.value=store.models as any});
|
const form = ref({ name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '' })
|
||||||
function openAdd(){isEdit.value=false;form.value={name:"",provider:"",api_format:"openai",api_endpoint:"",api_key:""};modalOpen.value=true}
|
|
||||||
function openEdit(m:any){isEdit.value=true;editId.value=m.id;form.value={name:m.name,provider:m.provider,api_format:m.api_format,api_endpoint:m.api_endpoint,api_key:""};modalOpen.value=true}
|
async function refreshList() {
|
||||||
async function saveModel(){if(isEdit.value){await store.update(editId.value,form.value)}else{await store.create(form.value)}modalOpen.value=false;models.value=store.models as any;message.success("保存成功")}
|
await store.fetchList()
|
||||||
async function toggleStatus(m:any){m.status=m.status==="enabled"?"disabled":"enabled";await store.update(m.id,{status:m.status});message.success("状态已更新")}
|
models.value = store.models as any
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refreshList()
|
||||||
|
})
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
isEdit.value = false
|
||||||
|
form.value = { name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '' }
|
||||||
|
modalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: any) {
|
||||||
|
isEdit.value = true
|
||||||
|
editId.value = item.id
|
||||||
|
form.value = {
|
||||||
|
name: item.name,
|
||||||
|
provider: item.provider,
|
||||||
|
api_format: item.api_format,
|
||||||
|
api_endpoint: item.api_endpoint,
|
||||||
|
api_key: '',
|
||||||
|
}
|
||||||
|
modalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveModel() {
|
||||||
|
if (isEdit.value) {
|
||||||
|
await store.update(editId.value, form.value)
|
||||||
|
} else {
|
||||||
|
await store.create(form.value)
|
||||||
|
}
|
||||||
|
modalOpen.value = false
|
||||||
|
await refreshList()
|
||||||
|
message.success('保存成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(item: any) {
|
||||||
|
const nextStatus = item.status === 'enabled' ? 'disabled' : 'enabled'
|
||||||
|
await store.update(item.id, { status: nextStatus })
|
||||||
|
await refreshList()
|
||||||
|
message.success('状态已更新')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTest(item: any) {
|
||||||
|
try {
|
||||||
|
const result: any = await store.test(item.id)
|
||||||
|
Modal.info({
|
||||||
|
title: '连接测试结果',
|
||||||
|
width: 640,
|
||||||
|
content: JSON.stringify(result.data, null, 2),
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '连接测试失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeModel(id: number) {
|
||||||
|
await store.remove(id)
|
||||||
|
await refreshList()
|
||||||
|
message.success('模型已删除')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user