完善模板编辑与模型管理体验
This commit is contained in:
@@ -45,3 +45,11 @@ async def init_db():
|
||||
)
|
||||
if "request_payload_json" not in document_columns:
|
||||
await conn.execute(text("ALTER TABLE documents ADD COLUMN request_payload_json TEXT"))
|
||||
paragraph_columns = await conn.run_sync(
|
||||
lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("paragraphs")]
|
||||
)
|
||||
if "anchor_title" not in paragraph_columns:
|
||||
await conn.execute(text("ALTER TABLE paragraphs ADD COLUMN anchor_title VARCHAR(500) DEFAULT ''"))
|
||||
await conn.execute(text("UPDATE paragraphs SET anchor_title = title WHERE anchor_title = '' OR anchor_title IS NULL"))
|
||||
if "write_mode" not in paragraph_columns:
|
||||
await conn.execute(text("ALTER TABLE paragraphs ADD COLUMN write_mode VARCHAR(30) DEFAULT 'replace_section'"))
|
||||
|
||||
@@ -6,12 +6,14 @@ class Paragraph(Base):
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
template_id = Column(Integer, ForeignKey("templates.id"), nullable=False)
|
||||
sort_index = Column(Integer, default=0, comment="排序")
|
||||
anchor_title = Column(String(500), default="", comment="原始标题锚点")
|
||||
title = Column(String(500), default="", comment="段落标题")
|
||||
content = Column(Text, default="", comment="正文内容/上下文")
|
||||
style_json = Column(Text, default="{}", comment="段落样式定义JSON")
|
||||
is_table = Column(Boolean, default=False, comment="是否为表格")
|
||||
table_json = Column(Text, default="{}", comment="表格结构JSON")
|
||||
edit_mode = Column(String(20), default="ai", comment="manual/ai")
|
||||
edit_mode = Column(String(20), default="manual", comment="manual/ai")
|
||||
write_mode = Column(String(30), default="replace_section", comment="replace_section/append_after_heading/replace_heading_only")
|
||||
model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True, comment="指定模型")
|
||||
need_prompt = Column(Boolean, default=True, comment="是否需要提示词")
|
||||
prompt_text = Column(Text, default="", comment="预设提示词")
|
||||
|
||||
@@ -49,7 +49,9 @@ async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
|
||||
for log, paragraph in result.all():
|
||||
logs.append(
|
||||
{
|
||||
"anchor_title": paragraph.anchor_title or paragraph.title,
|
||||
"title": paragraph.title,
|
||||
"write_mode": paragraph.write_mode,
|
||||
"content": json.loads(log.content) if log.content else {"content": []},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import httpx
|
||||
|
||||
from database import get_db
|
||||
from models.ai_model import AiModel
|
||||
@@ -11,6 +12,11 @@ from services.security import decrypt_text, encrypt_text, mask_secret
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _is_deepseek_model(model: AiModel) -> bool:
|
||||
provider = (model.provider or "").strip().lower()
|
||||
return provider == "deepseek"
|
||||
|
||||
|
||||
def _serialize_model(model: AiModel) -> dict:
|
||||
api_key = decrypt_text(model.api_key_encrypted)
|
||||
return {
|
||||
@@ -121,3 +127,36 @@ async def test_model(model_id: int, db: AsyncSession = Depends(get_db)):
|
||||
message=str(error),
|
||||
data={"id": model.id, "success": False},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{model_id}/balance")
|
||||
async def get_model_balance(model_id: int, db: AsyncSession = Depends(get_db)):
|
||||
model = await db.get(AiModel, model_id)
|
||||
if model is None:
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
if not _is_deepseek_model(model):
|
||||
raise HTTPException(status_code=400, detail="仅 DeepSeek 模型支持余额查询")
|
||||
|
||||
api_key = decrypt_text(model.api_key_encrypted)
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="模型 API Key 不可用")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20, trust_env=False) as client:
|
||||
response = await client.get(
|
||||
"https://api.deepseek.com/user/balance",
|
||||
headers={"Authorization": f"Bearer {api_key}", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except Exception as error:
|
||||
raise HTTPException(status_code=400, detail=f"查询余额失败:{error}")
|
||||
|
||||
return Response(
|
||||
data={
|
||||
"id": model.id,
|
||||
"provider": model.provider,
|
||||
"is_available": payload.get("is_available", False),
|
||||
"balance_infos": payload.get("balance_infos", []),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -32,12 +32,14 @@ def _serialize_paragraph(paragraph: Paragraph) -> dict:
|
||||
"id": paragraph.id,
|
||||
"template_id": paragraph.template_id,
|
||||
"sort_index": paragraph.sort_index,
|
||||
"anchor_title": paragraph.anchor_title,
|
||||
"title": paragraph.title,
|
||||
"content": paragraph.content,
|
||||
"style_json": paragraph.style_json,
|
||||
"is_table": paragraph.is_table,
|
||||
"table_json": paragraph.table_json,
|
||||
"edit_mode": paragraph.edit_mode,
|
||||
"write_mode": paragraph.write_mode,
|
||||
"model_id": paragraph.model_id,
|
||||
"need_prompt": paragraph.need_prompt,
|
||||
"prompt_text": paragraph.prompt_text,
|
||||
@@ -151,11 +153,14 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen
|
||||
paragraph = Paragraph(
|
||||
template_id=template.id,
|
||||
sort_index=item.sort_index,
|
||||
anchor_title=item.anchor_title,
|
||||
title=item.title,
|
||||
content=item.content,
|
||||
style_json=item.style_json,
|
||||
is_table=item.is_table,
|
||||
table_json=item.table_json,
|
||||
edit_mode="manual",
|
||||
write_mode=item.write_mode,
|
||||
)
|
||||
db.add(paragraph)
|
||||
paragraph_rows.append(paragraph)
|
||||
@@ -180,16 +185,30 @@ async def save_template_paragraphs(
|
||||
if template is None:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id))
|
||||
paragraph_map = {item.id: item for item in result.scalars().all()}
|
||||
result = await db.execute(
|
||||
select(Paragraph)
|
||||
.where(Paragraph.template_id == template_id)
|
||||
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||||
)
|
||||
existing_paragraphs = result.scalars().all()
|
||||
paragraph_map = {item.id: item for item in existing_paragraphs}
|
||||
incoming_ids = {config.id for config in body.paragraphs if config.id}
|
||||
|
||||
for config in body.paragraphs:
|
||||
paragraph = paragraph_map.get(config.id)
|
||||
for paragraph in existing_paragraphs:
|
||||
if paragraph.id not in incoming_ids:
|
||||
await db.delete(paragraph)
|
||||
|
||||
for index, config in enumerate(body.paragraphs, start=1):
|
||||
paragraph = paragraph_map.get(config.id) if config.id else None
|
||||
if paragraph is None:
|
||||
continue
|
||||
paragraph.sort_index = config.sort_index
|
||||
paragraph = Paragraph(template_id=template_id)
|
||||
db.add(paragraph)
|
||||
paragraph.sort_index = index
|
||||
paragraph.anchor_title = config.anchor_title or config.title or paragraph.anchor_title
|
||||
paragraph.title = config.title
|
||||
paragraph.content = config.content
|
||||
paragraph.edit_mode = config.edit_mode
|
||||
paragraph.write_mode = config.write_mode
|
||||
paragraph.model_id = config.model_id
|
||||
paragraph.need_prompt = config.need_prompt
|
||||
paragraph.prompt_text = config.prompt_text
|
||||
@@ -197,6 +216,8 @@ async def save_template_paragraphs(
|
||||
paragraph.file_note = config.file_note
|
||||
paragraph.output_format = config.output_format
|
||||
|
||||
await db.commit()
|
||||
template.paragraph_count = len(body.paragraphs)
|
||||
await db.commit()
|
||||
return Response(data={"template_id": template_id, "saved": len(body.paragraphs)})
|
||||
|
||||
|
||||
@@ -31,8 +31,11 @@ class TemplateOut(BaseModel):
|
||||
class ParagraphConfig(BaseModel):
|
||||
id: int = 0
|
||||
sort_index: int = 0
|
||||
anchor_title: str = ""
|
||||
title: str = ""
|
||||
edit_mode: str = "ai"
|
||||
content: str = ""
|
||||
edit_mode: str = "manual"
|
||||
write_mode: str = "replace_section"
|
||||
model_id: Optional[int] = None
|
||||
need_prompt: bool = True
|
||||
prompt_text: str = ""
|
||||
|
||||
@@ -56,6 +56,34 @@ def _copy_run_format(target_run, source_paragraph: Paragraph | None):
|
||||
break
|
||||
|
||||
|
||||
def _extract_first_run_format(source_paragraph: Paragraph | None):
|
||||
if source_paragraph is None:
|
||||
return None
|
||||
for source_run in source_paragraph.runs:
|
||||
if source_run._element.rPr is not None:
|
||||
return deepcopy(source_run._element.rPr)
|
||||
return None
|
||||
|
||||
|
||||
def _set_paragraph_text(
|
||||
paragraph: Paragraph,
|
||||
text: str,
|
||||
style_name: str | None = None,
|
||||
template_paragraph: Paragraph | None = None,
|
||||
):
|
||||
run_format = _extract_first_run_format(template_paragraph)
|
||||
_clear_paragraph(paragraph)
|
||||
if style_name:
|
||||
try:
|
||||
paragraph.style = style_name
|
||||
except Exception:
|
||||
pass
|
||||
if text:
|
||||
run = paragraph.add_run(text)
|
||||
if run_format is not None:
|
||||
run._element.insert(0, run_format)
|
||||
|
||||
|
||||
def _append_paragraph_after(
|
||||
paragraph: Paragraph,
|
||||
text: str,
|
||||
@@ -166,16 +194,12 @@ def _find_heading_paragraph(document: DocumentObject, heading_text: str, after_e
|
||||
return None
|
||||
|
||||
|
||||
def _replace_section_content(document: DocumentObject, heading_title: str, content: dict, after_element=None):
|
||||
heading = _find_heading_paragraph(document, heading_title, after_element)
|
||||
if heading is None:
|
||||
return after_element
|
||||
|
||||
def _collect_section_templates(heading: Paragraph):
|
||||
first_body_style = None
|
||||
paragraph_template = None
|
||||
table_template = None
|
||||
blocks = []
|
||||
current = heading._element.getnext()
|
||||
blocks_to_remove = []
|
||||
while current is not None:
|
||||
if isinstance(current, CT_P):
|
||||
current_paragraph = Paragraph(current, heading._parent)
|
||||
@@ -185,49 +209,132 @@ def _replace_section_content(document: DocumentObject, heading_title: str, conte
|
||||
first_body_style = current_paragraph.style.name
|
||||
if paragraph_template is None:
|
||||
paragraph_template = current_paragraph
|
||||
blocks_to_remove.append(current_paragraph)
|
||||
blocks.append(current_paragraph)
|
||||
elif isinstance(current, CT_Tbl):
|
||||
current_table = Table(current, heading._parent)
|
||||
if table_template is None:
|
||||
table_template = current_table
|
||||
blocks_to_remove.append(current_table)
|
||||
blocks.append(current_table)
|
||||
current = current.getnext()
|
||||
return first_body_style, paragraph_template, table_template, blocks
|
||||
|
||||
for block in blocks_to_remove:
|
||||
_delete_block(block)
|
||||
|
||||
insert_after = heading
|
||||
def _insert_content_after(
|
||||
insert_after: Paragraph,
|
||||
content: dict,
|
||||
first_body_style: str | None,
|
||||
paragraph_template: Paragraph | None,
|
||||
table_template: Table | None,
|
||||
):
|
||||
current_anchor: Paragraph = insert_after
|
||||
content_blocks = content.get("content", [])
|
||||
for block in content_blocks:
|
||||
block_type = block.get("type")
|
||||
if block_type == "table":
|
||||
rows = [list(row) for row in block.get("rows", [])]
|
||||
headers = block.get("headers") or []
|
||||
table = _append_table_after(insert_after, rows, headers, table_template)
|
||||
insert_after = _append_empty_paragraph_after_table(table, first_body_style)
|
||||
table = _append_table_after(current_anchor, rows, headers, table_template)
|
||||
current_anchor = _append_empty_paragraph_after_table(table, first_body_style)
|
||||
else:
|
||||
text = block.get("text", "")
|
||||
text_parts = [item for item in text.split("\n") if item] or [text]
|
||||
for text_part in text_parts:
|
||||
insert_after = _append_paragraph_after(
|
||||
insert_after,
|
||||
current_anchor = _append_paragraph_after(
|
||||
current_anchor,
|
||||
text_part,
|
||||
first_body_style,
|
||||
paragraph_template,
|
||||
)
|
||||
return current_anchor
|
||||
|
||||
|
||||
def _replace_section_content(
|
||||
document: DocumentObject,
|
||||
anchor_title: str,
|
||||
target_title: str,
|
||||
content: dict,
|
||||
write_mode: str,
|
||||
after_element=None,
|
||||
):
|
||||
heading = _find_heading_paragraph(document, anchor_title, after_element)
|
||||
if heading is None:
|
||||
return after_element
|
||||
_set_paragraph_text(heading, target_title, heading.style.name if heading.style is not None else None, heading)
|
||||
first_body_style, paragraph_template, table_template, blocks_to_remove = _collect_section_templates(heading)
|
||||
|
||||
if write_mode == "replace_heading_only":
|
||||
return heading._element
|
||||
|
||||
if write_mode == "replace_section":
|
||||
for block in blocks_to_remove:
|
||||
_delete_block(block)
|
||||
|
||||
_insert_content_after(
|
||||
heading,
|
||||
content,
|
||||
first_body_style,
|
||||
paragraph_template,
|
||||
table_template,
|
||||
)
|
||||
return heading._element
|
||||
|
||||
|
||||
def _group_logs(logs: list[dict]) -> list[list[dict]]:
|
||||
groups: list[list[dict]] = []
|
||||
for item in logs:
|
||||
anchor_title = item.get("anchor_title") or item.get("title") or ""
|
||||
if not groups:
|
||||
groups.append([item])
|
||||
continue
|
||||
last_group = groups[-1]
|
||||
last_anchor = last_group[0].get("anchor_title") or last_group[0].get("title") or ""
|
||||
if anchor_title == last_anchor:
|
||||
last_group.append(item)
|
||||
else:
|
||||
groups.append([item])
|
||||
return groups
|
||||
|
||||
|
||||
def _replace_section_group(
|
||||
document: DocumentObject,
|
||||
items: list[dict],
|
||||
after_element=None,
|
||||
):
|
||||
first_item = items[0]
|
||||
anchor_title = first_item.get("anchor_title") or first_item.get("title") or ""
|
||||
target_title = first_item.get("title") or anchor_title
|
||||
heading = _find_heading_paragraph(document, anchor_title, after_element)
|
||||
if heading is None:
|
||||
return after_element
|
||||
|
||||
_set_paragraph_text(heading, target_title, heading.style.name if heading.style is not None else None, heading)
|
||||
first_body_style, paragraph_template, table_template, blocks_to_remove = _collect_section_templates(heading)
|
||||
|
||||
if len(items) == 1 and first_item.get("write_mode") == "replace_heading_only":
|
||||
return heading._element
|
||||
|
||||
preserve_existing = len(items) == 1 and first_item.get("write_mode") == "append_after_heading"
|
||||
if not preserve_existing:
|
||||
for block in blocks_to_remove:
|
||||
_delete_block(block)
|
||||
|
||||
current_anchor = heading
|
||||
for item in items:
|
||||
current_anchor = _insert_content_after(
|
||||
current_anchor,
|
||||
item.get("content") or {"content": []},
|
||||
first_body_style,
|
||||
paragraph_template,
|
||||
table_template,
|
||||
)
|
||||
return heading._element
|
||||
|
||||
|
||||
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
|
||||
document = Document(BytesIO(template_bytes))
|
||||
last_heading_element = None
|
||||
for item in logs:
|
||||
last_heading_element = _replace_section_content(
|
||||
document,
|
||||
item["title"],
|
||||
item["content"],
|
||||
last_heading_element,
|
||||
)
|
||||
for group in _group_logs(logs):
|
||||
last_heading_element = _replace_section_group(document, group, last_heading_element)
|
||||
|
||||
output = BytesIO()
|
||||
document.save(output)
|
||||
|
||||
@@ -15,11 +15,13 @@ from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
@dataclass
|
||||
class ParsedParagraph:
|
||||
sort_index: int
|
||||
anchor_title: str
|
||||
title: str
|
||||
content: str
|
||||
style_json: str
|
||||
is_table: bool
|
||||
table_json: str
|
||||
write_mode: str
|
||||
|
||||
|
||||
def _iter_block_items(document: DocumentObject) -> Iterator[Paragraph | Table]:
|
||||
@@ -173,11 +175,13 @@ def parse_template(file_path: str) -> list[ParsedParagraph]:
|
||||
if level is not None:
|
||||
current_item = ParsedParagraph(
|
||||
sort_index=len(parsed) + 1,
|
||||
anchor_title=text,
|
||||
title=text,
|
||||
content="",
|
||||
style_json=json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False),
|
||||
is_table=False,
|
||||
table_json="{}",
|
||||
write_mode="replace_section",
|
||||
)
|
||||
parsed.append(current_item)
|
||||
continue
|
||||
@@ -185,11 +189,13 @@ def parse_template(file_path: str) -> list[ParsedParagraph]:
|
||||
if current_item is None:
|
||||
current_item = ParsedParagraph(
|
||||
sort_index=len(parsed) + 1,
|
||||
anchor_title="未命名段落",
|
||||
title="未命名段落",
|
||||
content=text,
|
||||
style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False),
|
||||
is_table=False,
|
||||
table_json="{}",
|
||||
write_mode="replace_section",
|
||||
)
|
||||
parsed.append(current_item)
|
||||
else:
|
||||
@@ -201,11 +207,13 @@ def parse_template(file_path: str) -> list[ParsedParagraph]:
|
||||
loose_table_count += 1
|
||||
current_item = ParsedParagraph(
|
||||
sort_index=len(parsed) + 1,
|
||||
anchor_title=f"表格_{loose_table_count}",
|
||||
title=f"表格_{loose_table_count}",
|
||||
content=table_text,
|
||||
style_json="{}",
|
||||
is_table=True,
|
||||
table_json=json.dumps(table_data, ensure_ascii=False),
|
||||
write_mode="replace_section",
|
||||
)
|
||||
parsed.append(current_item)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user