Files
deerflow-code/tmp/maintenance-manual/build_manual.py
T
2026-08-10 14:26:49 +08:00

1906 lines
103 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import os
from pathlib import Path
from textwrap import dedent
from PIL import Image, ImageDraw, ImageFont
from docx import Document
from docx.enum.section import WD_SECTION
from docx.enum.style import WD_STYLE_TYPE
from docx.enum.table import WD_ALIGN_VERTICAL, WD_CELL_VERTICAL_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_LINE_SPACING
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Inches, Pt, RGBColor
ROOT = Path(__file__).resolve().parent
ASSETS = ROOT / "assets"
OUTPUT = ROOT / "DeerFlow系统运维手册-内网离线部署版.docx"
BLUE = "2E74B5"
DARK_BLUE = "1F4D78"
LIGHT_BLUE = "E8F2FA"
PALE_BLUE = "F3F8FC"
PALE_GREEN = "EAF5EE"
PALE_YELLOW = "FFF5D9"
PALE_RED = "FCE8E6"
GRAY = "606A73"
LIGHT_GRAY = "F3F5F7"
BORDER = "B9C6D2"
TEXT = "24313A"
WHITE = "FFFFFF"
USABLE_DXA = 9360
def pil_color(value):
if isinstance(value, str) and len(value) in (6, 8) and not value.startswith("#"):
return f"#{value}"
return value
class ColorDraw:
"""Normalize bare six-digit hex colors before forwarding to Pillow."""
def __init__(self, raw):
self.raw = raw
def __getattr__(self, name):
attr = getattr(self.raw, name)
if not callable(attr):
return attr
def wrapped(*args, **kwargs):
for key in ("fill", "outline"):
if key in kwargs:
kwargs[key] = pil_color(kwargs[key])
return attr(*args, **kwargs)
return wrapped
def ensure_dirs() -> None:
ASSETS.mkdir(parents=True, exist_ok=True)
def font_path(bold: bool = False) -> str:
candidates = [
r"C:\Windows\Fonts\msyhbd.ttc" if bold else r"C:\Windows\Fonts\msyh.ttc",
r"C:\Windows\Fonts\simhei.ttf" if bold else r"C:\Windows\Fonts\simsun.ttc",
r"C:\Windows\Fonts\arialbd.ttf" if bold else r"C:\Windows\Fonts\arial.ttf",
]
for candidate in candidates:
if Path(candidate).exists():
return candidate
raise FileNotFoundError("No usable Windows font found")
def pil_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(font_path(bold), size=size)
def rounded_box(draw: ImageDraw.ImageDraw, xy, fill, outline=BLUE, width=3, radius=24):
draw.rounded_rectangle(xy, radius=radius, fill=fill, outline=outline, width=width)
def center_text(draw: ImageDraw.ImageDraw, xy, text, font, fill=TEXT, spacing=8):
x1, y1, x2, y2 = xy
bbox = draw.multiline_textbbox((0, 0), text, font=font, align="center", spacing=spacing)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
draw.multiline_text(((x1 + x2 - tw) / 2, (y1 + y2 - th) / 2), text, font=font, fill=fill, align="center", spacing=spacing)
def arrow(draw: ImageDraw.ImageDraw, start, end, fill=BLUE, width=6):
draw.line([start, end], fill=fill, width=width)
x1, y1 = start
x2, y2 = end
dx, dy = x2 - x1, y2 - y1
length = max((dx * dx + dy * dy) ** 0.5, 1)
ux, uy = dx / length, dy / length
px, py = -uy, ux
tip = (x2, y2)
a = (x2 - ux * 24 + px * 12, y2 - uy * 24 + py * 12)
b = (x2 - ux * 24 - px * 12, y2 - uy * 24 - py * 12)
draw.polygon([tip, a, b], fill=fill)
def base_canvas(title: str, subtitle: str = ""):
img = Image.new("RGB", (1800, 1020), pil_color(WHITE))
draw = ColorDraw(ImageDraw.Draw(img))
draw.rounded_rectangle((30, 30, 1770, 990), radius=32, fill="F8FBFD", outline="CFDCE7", width=3)
draw.text((90, 72), title, font=pil_font(48, True), fill=DARK_BLUE)
if subtitle:
draw.text((92, 138), subtitle, font=pil_font(24), fill=GRAY)
draw.line((90, 190, 1710, 190), fill="D9E5EE", width=3)
return img, draw
def make_diagrams() -> dict[str, Path]:
paths: dict[str, Path] = {}
img, draw = base_canvas("DeerFlow 生产访问链路", "推荐:浏览器只访问一个前端端口,Nginx 同源反向代理后端")
boxes = {
"browser": (90, 340, 390, 560),
"nginx": (520, 300, 870, 600),
"backend": (1010, 260, 1390, 490),
"data": (1010, 610, 1390, 840),
"external": (1480, 260, 1710, 840),
}
rounded_box(draw, boxes["browser"], "E8F2FA")
rounded_box(draw, boxes["nginx"], "DCECF8")
rounded_box(draw, boxes["backend"], "EAF5EE", outline="4B8C63")
rounded_box(draw, boxes["data"], "FFF5D9", outline="AA7D10")
rounded_box(draw, boxes["external"], "F1ECF8", outline="76519A")
center_text(draw, boxes["browser"], "用户浏览器\nhttp://服务器:APP_PORT", pil_font(31, True))
center_text(draw, boxes["nginx"], "Web 容器 / Nginx\n静态前端 + /deerflow 代理\n容器内 80", pil_font(30, True))
center_text(draw, boxes["backend"], "Backend 容器\nFastAPI + Agent Runtime\n容器内 8001", pil_font(30, True))
center_text(draw, boxes["data"], "持久化目录\ndata/backend · data/skills\nconfig · 日志 · 知识库", pil_font(28, True))
center_text(draw, boxes["external"], "外部依赖\nMySQL\n模型 API\nHindsight\n业务接口", pil_font(26, True))
arrow(draw, (390, 450), (520, 450))
arrow(draw, (870, 400), (1010, 375))
arrow(draw, (1200, 490), (1200, 610), fill="AA7D10")
arrow(draw, (1390, 375), (1480, 375), fill="76519A")
path = ASSETS / "architecture.png"
img.save(path)
paths["architecture"] = path
img, draw = base_canvas("配置分层:先判断改哪一层", "最常见的误区是改了源码 .env,却没有改运行中的部署配置")
levels = [
((110, 245, 1690, 390), "① 部署层 .env", "APP_PORT、镜像标签、Compose 变量\n改后重建 Web 或受影响服务", "E8F2FA"),
((110, 420, 1690, 565), "② 后端配置\nbackend.env + config.yaml", "登录 JWT、模型 Key、数据库 URL\n环境变量变更需重建 Backend", "EAF5EE"),
((110, 595, 1690, 740), "③ 前端运行时配置\nruntime-config.js", "后端/API、业务接口、Logo\n通常重启 Web + 浏览器强刷", "FFF5D9"),
((110, 770, 1690, 915), "④ 管理页面配置", "菜单、主题、发布广场、敏感词\n写入数据库,保存后全局生效", "F1ECF8"),
]
for xy, title, desc, fill in levels:
rounded_box(draw, xy, fill)
center_text(draw, (145, xy[1] + 10, 665, xy[3] - 10), title, pil_font(26, True), DARK_BLUE)
center_text(draw, (700, xy[1] + 10, 1650, xy[3] - 10), desc, pil_font(23), TEXT)
path = ASSETS / "config_layers.png"
img.save(path)
paths["config_layers"] = path
img, draw = base_canvas("账号与权限边界", "四种身份不要混用:系统层权限不能替代应用层权限")
roles = [
((90, 265, 455, 800), "Linux / 1Panel 管理员", "安装镜像、改文件\n看日志、备份与恢复\n\n不是 DeerFlow\n应用管理员", "E8F2FA"),
((505, 265, 870, 800), "DeerFlow admin", "菜单、主题、广场\n白名单与全局资产\n\nsystem_role=admin", "EAF5EE"),
((920, 265, 1285, 800), "lqq 账号", "前端敏感词管理页\n专用入口\n\n用户名或邮箱 @ 前缀\n必须为 lqq", "FFF5D9"),
((1335, 265, 1700, 800), "普通业务用户", "聊天、维护自己的\n技能和智能体、发布/下架\n\nsystem_role=user", "F1ECF8"),
]
for xy, title, desc, fill in roles:
rounded_box(draw, xy, fill)
center_text(draw, (xy[0] + 18, xy[1] + 28, xy[2] - 18, xy[1] + 145), title, pil_font(29, True), fill=DARK_BLUE)
center_text(draw, (xy[0] + 24, xy[1] + 165, xy[2] - 24, xy[3] - 28), desc, pil_font(23), TEXT, spacing=12)
path = ASSETS / "roles.png"
img.save(path)
paths["roles"] = path
img, draw = base_canvas("模型配置与验证闭环", "修改模型后必须同时验证配置解析、接口连通和实际对话")
steps = [
((80, 340, 360, 610), "1", "确定模型参数\nmodel / base_url\n上下文与输出限制"),
((430, 340, 710, 610), "2", "backend.env\n写入 API Key\n只写变量值"),
((780, 340, 1060, 610), "3", "config.yaml\n新增 models 条目\n添加 max_tokens"),
((1130, 340, 1410, 610), "4", "重建/重启 Backend\n查看启动日志\n请求 /health"),
((1480, 340, 1720, 610), "5", "UI 选模型\n最短问答验证\n再做长文本测试"),
]
for i, (xy, num, label) in enumerate(steps):
rounded_box(draw, xy, ["E8F2FA", "EAF5EE", "FFF5D9", "F1ECF8", "E8F2FA"][i])
draw.ellipse((xy[0] + 18, xy[1] + 18, xy[0] + 78, xy[1] + 78), fill=BLUE)
center_text(draw, (xy[0] + 18, xy[1] + 18, xy[0] + 78, xy[1] + 78), num, pil_font(29, True), WHITE)
center_text(draw, (xy[0] + 20, xy[1] + 90, xy[2] - 20, xy[3] - 20), label, pil_font(25, True))
if i < len(steps) - 1:
arrow(draw, (xy[2], 475), (steps[i + 1][0][0], 475))
draw.text((155, 725), "max_tokens 是单次生成输出上限,不是模型上下文窗口;必须小于或等于模型服务允许值。", font=pil_font(30, True), fill="A34B00")
path = ASSETS / "model_flow.png"
img.save(path)
paths["model_flow"] = path
img, draw = base_canvas("技能与智能体的数据归属", "文件、数据库元数据、账号权限缺一不可;备份必须覆盖两边")
blocks = [
((100, 270, 690, 500), "技能文件", "data/skills/public/<技能>/SKILL.md\ndata/skills/custom/<技能>/SKILL.md\n可附带 .py、references、assets", "E8F2FA"),
((100, 590, 690, 820), "智能体文件", "data/backend/agents/<agent-id>/config.yaml\ndata/backend/agents/<agent-id>/SOUL.md", "EAF5EE"),
((940, 270, 1690, 500), "业务数据库", "技能/智能体所有者、名称、可见性、广场、发布状态\nSQLite 或外部 MySQL", "FFF5D9"),
((940, 590, 1690, 820), "运行时与缓存", "启动扫描、技能系统提示缓存、容器权限、当前登录用户\n异常时可通过重启和日志核对", "F1ECF8"),
]
for xy, title, desc, fill in blocks:
rounded_box(draw, xy, fill)
draw.text((xy[0] + 32, xy[1] + 28), title, font=pil_font(31, True), fill=DARK_BLUE)
draw.multiline_text((xy[0] + 32, xy[1] + 85), desc, font=pil_font(24), fill=TEXT, spacing=10)
arrow(draw, (690, 385), (940, 385))
arrow(draw, (690, 705), (940, 705))
draw.text((720, 510), "共同决定 UI 中能否看到、能否使用、能否发布", font=pil_font(25, True), fill=BLUE)
path = ASSETS / "persistence_map.png"
img.save(path)
paths["persistence_map"] = path
img, draw = base_canvas("从创建到发布广场", "UI 发布会同步维护文件与数据库元数据,优先于手工拷贝")
steps = [
((90, 320, 380, 620), "创建 / 导入", "普通用户创建 custom 技能\n或创建自己的智能体"),
((460, 320, 750, 620), "本地验证", "补齐说明与依赖\n完成一次真实调用\n检查无敏感信息"),
((830, 320, 1120, 620), "点击发布", "资产详情或卡片 → 发布\n选择目标广场"),
((1200, 320, 1490, 620), "权限校验", "所有者或 admin\n每个资产只能属于一个广场"),
((1550, 320, 1730, 620), "生效", "广场可见\n可下架\n可改投其他广场"),
]
for i, (xy, title, desc) in enumerate(steps):
rounded_box(draw, xy, ["E8F2FA", "EAF5EE", "FFF5D9", "F1ECF8", "E8F2FA"][i])
center_text(draw, (xy[0] + 15, xy[1] + 25, xy[2] - 15, xy[1] + 110), title, pil_font(27, True), DARK_BLUE)
draw.multiline_text((xy[0] + 22, xy[1] + 135), desc, font=pil_font(22), fill=TEXT, spacing=8, align="center")
if i < len(steps) - 1:
arrow(draw, (xy[2], 470), (steps[i + 1][0][0], 470))
draw.rounded_rectangle((240, 735, 1560, 860), radius=20, fill=PALE_RED, outline="C5645A", width=3)
center_text(draw, (240, 735, 1560, 860), "不要把“复制到 public 目录”和“发布到广场”混为一谈:前者是文件类别,后者是业务可见性与发布元数据。", pil_font(27, True), "8E3028")
path = ASSETS / "publish_flow.png"
img.save(path)
paths["publish_flow"] = path
img, draw = base_canvas("管理入口地图", "所有入口均以当前前端版本为准;看不到入口时先检查账号身份")
items = [
((120, 250, 760, 410), "设置和更多", "菜单管理 · 敏感词管理 · 按钮管理"),
((1040, 250, 1680, 410), "外观设置", "个人主题 · 系统默认主题 · 自定义主题库 · 发布广场"),
((120, 500, 760, 660), "技能管理 / 技能设置", "上传、编辑、地址扫描、发布/下架、权限"),
((1040, 500, 1680, 660), "智能体管理 / 智能体详情", "创建、编辑配置、发布/下架、私有广场"),
((490, 750, 1310, 900), "用户与白名单管理", "账号启停 · system_role · 登录密码 · 管理员审批"),
]
for i, (xy, title, desc) in enumerate(items):
rounded_box(draw, xy, ["E8F2FA", "EAF5EE", "FFF5D9", "F1ECF8", "FCE8E6"][i])
draw.text((xy[0] + 30, xy[1] + 24), title, font=pil_font(30, True), fill=DARK_BLUE)
draw.text((xy[0] + 30, xy[1] + 84), desc, font=pil_font(23), fill=TEXT)
arrow(draw, (760, 330), (1040, 330))
arrow(draw, (760, 580), (1040, 580))
path = ASSETS / "ui_map.png"
img.save(path)
paths["ui_map"] = path
img, draw = base_canvas("技能或智能体“丢失”的排查顺序", "先确定是显示问题、身份问题、数据库问题,还是文件/挂载真的丢失")
nodes = [
((80, 290, 380, 470), "1. 服务与项目", "确认容器、Compose 目录、端口"),
((500, 290, 800, 470), "2. 登录身份", "账号是否变化、角色/所有者是否匹配"),
((920, 290, 1220, 470), "3. 文件挂载", "宿主机 data 目录与容器内目录"),
((1340, 290, 1720, 470), "4. 数据库", "SQLite/MySQL 是否切换、记录是否存在"),
((1340, 650, 1720, 830), "5. 发布与过滤", "广场、私有状态、搜索筛选、权限"),
((920, 650, 1220, 830), "6. 日志与权限", "permission、invalid config、扫描失败"),
((500, 650, 800, 830), "7. 备份恢复", "先停止写入,再按文件+数据库恢复"),
((80, 650, 380, 830), "8. 验证", "重启后端、重新登录、实际调用"),
]
for i, (xy, title, desc) in enumerate(nodes):
rounded_box(draw, xy, ["E8F2FA", "EAF5EE", "FFF5D9", "F1ECF8"][i % 4])
center_text(draw, (xy[0] + 12, xy[1] + 12, xy[2] - 12, xy[1] + 75), title, pil_font(25, True), DARK_BLUE)
center_text(draw, (xy[0] + 20, xy[1] + 78, xy[2] - 20, xy[3] - 15), desc, pil_font(21), TEXT)
for i in range(3):
arrow(draw, (nodes[i][0][2], 380), (nodes[i + 1][0][0], 380))
arrow(draw, (1530, 470), (1530, 650))
for i in range(4, 7):
arrow(draw, (nodes[i][0][0], 740), (nodes[i + 1][0][2], 740))
path = ASSETS / "lost_asset_flow.png"
img.save(path)
paths["lost_asset_flow"] = path
img, draw = base_canvas("备份、升级与回滚闭环", "离线环境的核心原则:先验证备份可恢复,再导入新镜像")
steps = [
((100, 310, 400, 620), "盘点", "记录版本、镜像摘要\n端口、数据库模式\n外部依赖地址"),
((470, 310, 770, 620), "一致性备份", "停止写入窗口\n备份 data + config\n导出外部 MySQL"),
((840, 310, 1140, 620), "新目录演练", "导入 ARM64 镜像\n复制配置并合并\n换测试端口启动"),
((1210, 310, 1510, 620), "验收", "健康检查、登录\n模型、技能、智能体\n广场与关键业务"),
((1580, 310, 1730, 620), "切换", "停旧启新\n保留旧镜像\n异常即回滚"),
]
for i, (xy, title, desc) in enumerate(steps):
rounded_box(draw, xy, ["E8F2FA", "EAF5EE", "FFF5D9", "F1ECF8", "E8F2FA"][i])
center_text(draw, (xy[0] + 12, xy[1] + 20, xy[2] - 12, xy[1] + 105), title, pil_font(28, True), DARK_BLUE)
center_text(draw, (xy[0] + 20, xy[1] + 110, xy[2] - 20, xy[3] - 20), desc, pil_font(23), TEXT)
if i < len(steps) - 1:
arrow(draw, (xy[2], 465), (steps[i + 1][0][0], 465))
draw.text((210, 760), "切忌:在没有确认数据路径和备份的情况下执行 docker system prune -a 或删除部署目录。", font=pil_font(31, True), fill="9A342D")
path = ASSETS / "backup_upgrade.png"
img.save(path)
paths["backup_upgrade"] = path
return paths
def set_cell_shading(cell, fill: str):
tc_pr = cell._tc.get_or_add_tcPr()
shd = tc_pr.find(qn("w:shd"))
if shd is None:
shd = OxmlElement("w:shd")
tc_pr.append(shd)
shd.set(qn("w:fill"), fill)
def set_cell_margins(cell, top=80, start=120, bottom=80, end=120):
tc = cell._tc
tc_pr = tc.get_or_add_tcPr()
tc_mar = tc_pr.first_child_found_in("w:tcMar")
if tc_mar is None:
tc_mar = OxmlElement("w:tcMar")
tc_pr.append(tc_mar)
for m, v in (("top", top), ("start", start), ("bottom", bottom), ("end", end)):
node = tc_mar.find(qn(f"w:{m}"))
if node is None:
node = OxmlElement(f"w:{m}")
tc_mar.append(node)
node.set(qn("w:w"), str(v))
node.set(qn("w:type"), "dxa")
def set_repeat_table_header(row):
tr_pr = row._tr.get_or_add_trPr()
tbl_header = OxmlElement("w:tblHeader")
tbl_header.set(qn("w:val"), "true")
tr_pr.append(tbl_header)
def prevent_row_split(row):
tr_pr = row._tr.get_or_add_trPr()
cant_split = OxmlElement("w:cantSplit")
tr_pr.append(cant_split)
def set_table_fixed(table, widths: list[int]):
table.autofit = False
tbl_pr = table._tbl.tblPr
layout = tbl_pr.find(qn("w:tblLayout"))
if layout is None:
layout = OxmlElement("w:tblLayout")
tbl_pr.append(layout)
layout.set(qn("w:type"), "fixed")
tbl_w = tbl_pr.find(qn("w:tblW"))
if tbl_w is None:
tbl_w = OxmlElement("w:tblW")
tbl_pr.append(tbl_w)
tbl_w.set(qn("w:w"), str(sum(widths)))
tbl_w.set(qn("w:type"), "dxa")
tbl_ind = tbl_pr.find(qn("w:tblInd"))
if tbl_ind is None:
tbl_ind = OxmlElement("w:tblInd")
tbl_pr.append(tbl_ind)
tbl_ind.set(qn("w:w"), "120")
tbl_ind.set(qn("w:type"), "dxa")
grid = table._tbl.tblGrid
for child in list(grid):
grid.remove(child)
for width in widths:
col = OxmlElement("w:gridCol")
col.set(qn("w:w"), str(width))
grid.append(col)
for row in table.rows:
for cell, width in zip(row.cells, widths):
tc_pr = cell._tc.get_or_add_tcPr()
tc_w = tc_pr.find(qn("w:tcW"))
if tc_w is None:
tc_w = OxmlElement("w:tcW")
tc_pr.append(tc_w)
tc_w.set(qn("w:w"), str(width))
tc_w.set(qn("w:type"), "dxa")
set_cell_margins(cell)
def set_run_font(run, name="Microsoft YaHei", size=None, bold=None, color=None):
run.font.name = name
run._element.rPr.rFonts.set(qn("w:eastAsia"), name)
if size is not None:
run.font.size = Pt(size)
if bold is not None:
run.bold = bold
if color:
run.font.color.rgb = RGBColor.from_string(color)
def style_paragraph(p, before=0, after=6, line=1.25, keep=False):
fmt = p.paragraph_format
fmt.space_before = Pt(before)
fmt.space_after = Pt(after)
fmt.line_spacing = line
if keep:
fmt.keep_together = True
def add_field(paragraph, instr: str, display: str = ""):
run = paragraph.add_run()
fld_char1 = OxmlElement("w:fldChar")
fld_char1.set(qn("w:fldCharType"), "begin")
instr_text = OxmlElement("w:instrText")
instr_text.set(qn("xml:space"), "preserve")
instr_text.text = instr
fld_char2 = OxmlElement("w:fldChar")
fld_char2.set(qn("w:fldCharType"), "separate")
text_node = OxmlElement("w:t")
text_node.text = display
fld_char3 = OxmlElement("w:fldChar")
fld_char3.set(qn("w:fldCharType"), "end")
run._r.extend([fld_char1, instr_text, fld_char2, text_node, fld_char3])
def configure_document(doc: Document):
section = doc.sections[0]
section.page_width = Inches(8.5)
section.page_height = Inches(11)
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1)
section.right_margin = Inches(1)
section.header_distance = Inches(0.492)
section.footer_distance = Inches(0.492)
section.different_first_page_header_footer = True
styles = doc.styles
normal = styles["Normal"]
normal.font.name = "Calibri"
normal.font.size = Pt(11)
normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
normal.font.color.rgb = RGBColor.from_string(TEXT)
normal.paragraph_format.space_after = Pt(6)
normal.paragraph_format.line_spacing = 1.25
for style_name, size, color, before, after, level in (
("Title", 28, DARK_BLUE, 0, 14, 0),
("Subtitle", 15, GRAY, 0, 8, 0),
("Heading 1", 16, BLUE, 18, 10, 1),
("Heading 2", 13, BLUE, 14, 7, 2),
("Heading 3", 12, DARK_BLUE, 10, 5, 3),
):
st = styles[style_name]
st.font.name = "Calibri"
st._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
st.font.size = Pt(size)
st.font.color.rgb = RGBColor.from_string(color)
st.font.bold = True
st.paragraph_format.space_before = Pt(before)
st.paragraph_format.space_after = Pt(after)
st.paragraph_format.keep_with_next = True
if level == 1:
st.paragraph_format.page_break_before = True
for style_name in ("List Bullet", "List Number"):
st = styles[style_name]
st.font.name = "Calibri"
st._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
st.font.size = Pt(11)
st.paragraph_format.left_indent = Inches(0.375)
st.paragraph_format.first_line_indent = Inches(-0.188)
st.paragraph_format.space_after = Pt(4)
st.paragraph_format.line_spacing = 1.25
if "Code Block" not in styles:
code = styles.add_style("Code Block", WD_STYLE_TYPE.PARAGRAPH)
else:
code = styles["Code Block"]
code.font.name = "Consolas"
code._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei")
code.font.size = Pt(8.8)
code.font.color.rgb = RGBColor.from_string("1F2933")
code.paragraph_format.left_indent = Inches(0.18)
code.paragraph_format.right_indent = Inches(0.12)
code.paragraph_format.space_before = Pt(3)
code.paragraph_format.space_after = Pt(6)
code.paragraph_format.line_spacing = 1.05
code.paragraph_format.keep_together = True
# Running header and page footer.
header = section.header
hp = header.paragraphs[0]
hp.alignment = WD_ALIGN_PARAGRAPH.RIGHT
hr = hp.add_run("DeerFlow 系统运维手册 | 内网离线部署版")
set_run_font(hr, size=8.5, color=GRAY)
footer = section.footer
fp = footer.paragraphs[0]
fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
fr = fp.add_run("内部运维资料 · 第 ")
set_run_font(fr, size=8.5, color=GRAY)
add_field(fp, "PAGE", "1")
fr2 = fp.add_run(" 页")
set_run_font(fr2, size=8.5, color=GRAY)
update = OxmlElement("w:updateFields")
update.set(qn("w:val"), "true")
doc.settings._element.append(update)
def add_para(doc, text="", *, bold_prefix=None, italic=False, align=None, after=6, keep=False):
p = doc.add_paragraph()
if bold_prefix and text.startswith(bold_prefix):
r1 = p.add_run(bold_prefix)
set_run_font(r1, bold=True)
r2 = p.add_run(text[len(bold_prefix):])
set_run_font(r2)
else:
r = p.add_run(text)
set_run_font(r)
r.italic = italic
style_paragraph(p, after=after, keep=keep)
if align is not None:
p.alignment = align
return p
def add_bullet(doc, text, level=0):
p = doc.add_paragraph(style="List Bullet")
if level:
p.paragraph_format.left_indent = Inches(0.375 + 0.25 * level)
r = p.add_run(text)
set_run_font(r)
return p
def new_numbering_instance(doc) -> int:
"""Create a real numbered-list instance that restarts at 1."""
numbering = doc.part.numbering_part.element
style = doc.styles["List Number"]
style_num_id = int(style._element.pPr.numPr.numId.val)
abstract_id = None
existing_ids = []
for num in numbering.findall(qn("w:num")):
num_id = int(num.get(qn("w:numId")))
existing_ids.append(num_id)
if num_id == style_num_id:
abstract_id = int(num.find(qn("w:abstractNumId")).get(qn("w:val")))
if abstract_id is None:
raise RuntimeError("Unable to resolve List Number abstract numbering")
new_id = max(existing_ids or [0]) + 1
num = OxmlElement("w:num")
num.set(qn("w:numId"), str(new_id))
abstract = OxmlElement("w:abstractNumId")
abstract.set(qn("w:val"), str(abstract_id))
num.append(abstract)
override = OxmlElement("w:lvlOverride")
override.set(qn("w:ilvl"), "0")
start = OxmlElement("w:startOverride")
start.set(qn("w:val"), "1")
override.append(start)
num.append(override)
numbering.append(num)
return new_id
def add_number(doc, text, level=0):
if getattr(doc, "_restart_numbering", True) or not hasattr(doc, "_active_num_id"):
doc._active_num_id = new_numbering_instance(doc)
doc._restart_numbering = False
p = doc.add_paragraph(style="List Number")
p_pr = p._p.get_or_add_pPr()
num_pr = p_pr.find(qn("w:numPr"))
if num_pr is None:
num_pr = OxmlElement("w:numPr")
p_pr.append(num_pr)
ilvl = OxmlElement("w:ilvl")
ilvl.set(qn("w:val"), str(level))
num_id = OxmlElement("w:numId")
num_id.set(qn("w:val"), str(doc._active_num_id))
num_pr.extend([ilvl, num_id])
if level:
p.paragraph_format.left_indent = Inches(0.375 + 0.25 * level)
r = p.add_run(text)
set_run_font(r)
return p
def add_code(doc, code_text: str, language: str = ""):
if language:
label = doc.add_paragraph()
label.paragraph_format.space_before = Pt(3)
label.paragraph_format.space_after = Pt(2)
label.paragraph_format.keep_with_next = True
rr = label.add_run(language)
set_run_font(rr, size=8, bold=True, color=GRAY)
lines = dedent(code_text).strip("\n").splitlines()
for idx, line in enumerate(lines):
p = doc.add_paragraph(style="Code Block")
p.paragraph_format.space_before = Pt(0)
p.paragraph_format.space_after = Pt(0 if idx < len(lines) - 1 else 6)
p.paragraph_format.keep_with_next = idx < len(lines) - 1
p_pr = p._p.get_or_add_pPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:fill"), "F2F4F7")
p_pr.append(shd)
r = p.add_run(line if line else " ")
set_run_font(r, name="Consolas", size=8.8, color="1F2933")
return p
def add_callout(doc, title: str, text: str, kind="note"):
colors = {
"note": (LIGHT_BLUE, BLUE),
"ok": (PALE_GREEN, "4B8C63"),
"warn": (PALE_YELLOW, "A06F00"),
"danger": (PALE_RED, "A33A32"),
}
fill, color = colors[kind]
p = doc.add_paragraph()
p.paragraph_format.left_indent = Inches(0.12)
p.paragraph_format.right_indent = Inches(0.05)
p.paragraph_format.space_before = Pt(4)
p.paragraph_format.space_after = Pt(8)
p.paragraph_format.keep_together = True
p_pr = p._p.get_or_add_pPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:fill"), fill)
p_pr.append(shd)
border = OxmlElement("w:pBdr")
left = OxmlElement("w:left")
left.set(qn("w:val"), "single")
left.set(qn("w:sz"), "18")
left.set(qn("w:space"), "6")
left.set(qn("w:color"), color)
border.append(left)
p_pr.append(border)
r1 = p.add_run(f"{title} ")
set_run_font(r1, bold=True, color=color)
r2 = p.add_run(text)
set_run_font(r2)
return p
def add_table(doc, headers: list[str], rows: list[list[str]], widths: list[int], font_size=9.2):
assert sum(widths) == USABLE_DXA, (sum(widths), widths)
table = doc.add_table(rows=1, cols=len(headers))
table.style = "Table Grid"
table.alignment = WD_ALIGN_PARAGRAPH.CENTER
for i, h in enumerate(headers):
cell = table.rows[0].cells[i]
set_cell_shading(cell, "E8EEF5")
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
p = cell.paragraphs[0]
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
p.paragraph_format.space_after = Pt(0)
r = p.add_run(h)
set_run_font(r, size=font_size, bold=True, color=DARK_BLUE)
set_repeat_table_header(table.rows[0])
for row_idx, data in enumerate(rows):
row = table.add_row()
prevent_row_split(row)
for i, value in enumerate(data):
cell = row.cells[i]
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
if row_idx % 2 == 1:
set_cell_shading(cell, "FAFBFC")
p = cell.paragraphs[0]
p.paragraph_format.space_after = Pt(0)
p.paragraph_format.line_spacing = 1.12
r = p.add_run(str(value))
set_run_font(r, size=font_size)
set_table_fixed(table, widths)
doc.add_paragraph().paragraph_format.space_after = Pt(1)
return table
def add_figure(doc, path: Path, caption: str, alt_text: str, width=6.35):
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.space_before = Pt(4)
p.paragraph_format.space_after = Pt(2)
run = p.add_run()
shape = run.add_picture(str(path), width=Inches(width))
try:
doc_pr = shape._inline.docPr
doc_pr.set("descr", alt_text)
doc_pr.set("title", caption)
except Exception:
pass
cp = doc.add_paragraph()
cp.alignment = WD_ALIGN_PARAGRAPH.CENTER
cp.paragraph_format.space_after = Pt(8)
cr = cp.add_run(caption)
set_run_font(cr, size=9, color=GRAY)
cr.italic = True
def add_h1(doc, text):
doc._restart_numbering = True
return doc.add_heading(text, level=1)
def add_h2(doc, text):
doc._restart_numbering = True
return doc.add_heading(text, level=2)
def add_h3(doc, text):
doc._restart_numbering = True
return doc.add_heading(text, level=3)
def add_page_break(doc):
doc.add_page_break()
def build_document(diagrams: dict[str, Path]):
doc = Document()
configure_document(doc)
doc.core_properties.title = "DeerFlow 系统运维手册(内网离线部署版)"
doc.core_properties.subject = "DeerFlow 前后端、模型、账号、技能、智能体、广场、菜单、主题、敏感词及故障排查"
doc.core_properties.author = "DeerFlow 运维团队"
doc.core_properties.keywords = "DeerFlow, 运维, Docker, 1Panel, ARM64, 离线部署"
# Cover
p = doc.add_paragraph()
p.paragraph_format.space_before = Inches(0.75)
p.paragraph_format.space_after = Pt(10)
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
r = p.add_run("DeerFlow")
set_run_font(r, size=16, bold=True, color=BLUE)
p = doc.add_paragraph(style="Title")
p.paragraph_format.space_before = Pt(16)
p.paragraph_format.space_after = Pt(14)
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
r = p.add_run("系统运维手册")
set_run_font(r, size=30, bold=True, color=DARK_BLUE)
p = doc.add_paragraph(style="Subtitle")
r = p.add_run("内网离线部署版 · Docker / 1Panel / ARM64")
set_run_font(r, size=15, color=GRAY)
add_figure(doc, diagrams["architecture"], "系统生产访问链路总览", "浏览器通过 Nginx 访问 DeerFlow 前端和后端的架构示意图", width=6.1)
add_callout(doc, "适用对象", "首次接手本系统的 Linux/1Panel 运维人员、DeerFlow 应用管理员、模型与数据管理员。", "note")
meta = add_table(
doc,
["文档版本", "适用版本", "更新日期"],
[["V1.0", "当前离线部署包与本项目定制版", "2026-08-08"]],
[2200, 4200, 2960],
font_size=9.2,
)
add_para(doc, "密级:内部运维资料。本文所有密钥、密码、IP 和数据库账号均使用占位符。", italic=True, align=WD_ALIGN_PARAGRAPH.CENTER, after=0)
p = doc.add_paragraph(style="Title")
p.paragraph_format.page_break_before = True
r = p.add_run("文档导航")
set_run_font(r, size=24, bold=True, color=DARK_BLUE)
add_para(doc, "本文按“先理解、再配置、后维护、能恢复”的顺序编排。Word 左侧导航窗格可按标题跳转;如目录页码未自动刷新,请在 Word 中按 Ctrl+A 后按 F9。")
toc = doc.add_paragraph()
toc.paragraph_format.space_after = Pt(10)
add_field(toc, r'TOC \o "1-3" \h \z \u', "目录将在打开 Word 时自动更新")
add_callout(doc, "先看这四章", "首次接手请先读第 1、2、3、4 章;发生资产丢失或生产故障时直接跳到第 16、17 章。", "ok")
add_h2(doc, "版本与变更记录")
add_table(
doc,
["版本", "日期", "变更说明", "维护人"],
[["V1.0", "2026-08-08", "首次发布,覆盖离线部署、配置、资产发布、备份恢复与排错", "DeerFlow 运维团队"]],
[1000, 1500, 5260, 1600],
)
add_h2(doc, "阅读约定")
add_bullet(doc, "“部署目录”指解压离线包后,包含 docker-compose.yml、config、data、restart.sh 等内容的目录。")
add_bullet(doc, "命令默认以 Linux shell 执行;1Panel 的容器终端或 Xshell 均可。")
add_bullet(doc, "涉及生产数据的命令,先确认当前路径,再做备份;不要机械复制带删除含义的命令。")
add_bullet(doc, "本文以 APP_PORT=8090 为例,实际端口以部署目录 .env 为准。")
add_h1(doc, "1. 系统全貌与运维边界")
add_para(doc, "DeerFlow 是由 React 前端、Nginx、FastAPI/LangGraph 后端、持久化文件目录和业务数据库共同组成的 AI 智能体平台。前端和后端在容器内使用不同端口,但生产访问通常只暴露一个 Web 端口:浏览器访问 Nginx,Nginx 再把 /deerflow 请求转发到后端。")
add_figure(doc, diagrams["architecture"], "图 1-1 生产访问与数据流", "DeerFlow Web、后端、持久化目录和外部依赖之间的数据流")
add_h2(doc, "1.1 服务与端口")
add_table(
doc,
["组件", "容器内端口", "宿主机端口", "主要职责", "运维结论"],
[
["Web / Nginx", "80", "APP_PORT(默认 8090)", "前端静态文件、SPA 路由、反向代理", "公网或内网只需开放这一端口"],
["Backend", "8001", "默认不直接映射", "登录、智能体、技能、模型调用、业务 API", "通过 /deerflow 由 Nginx 访问"],
["MySQL(可选)", "由数据库决定", "通常仅内网", "业务表、资产元数据、用户与全局配置", "容器要能访问数据库地址"],
],
[1300, 1200, 1650, 3100, 2110],
font_size=8.7,
)
add_callout(doc, "关键结论", "前端与后端不是同一个容器端口。对用户而言可以是同一个访问端口,因为 Nginx 做了同源代理。", "note")
add_h2(doc, "1.2 需要纳入监控和备份的对象")
for item in [
"部署配置:.env、config/backend.env、config/config.yaml、config/extensions_config.json、config/runtime-config.js。",
"持久化文件:data/backend、data/skills、data/knowledge、data/logs。",
"数据库:默认 SQLite 文件;切换外部 MySQL 后还要做 MySQL 备份。",
"离线镜像与版本:保留镜像 tar 包、SHA256、部署日期和变更记录。",
"外部依赖:模型 API、MySQL、Hindsight、MCP、企业业务接口的地址和账号。",
]:
add_bullet(doc, item)
add_h2(doc, "1.3 本手册不替代的制度")
add_para(doc, "生产变更审批、账号实名、密码轮换、数据库备份保留期和等保要求仍以本单位制度为准。本文提供技术步骤,不构成放宽安全控制的依据。")
add_h1(doc, "2. 账号、角色与登录密钥")
add_para(doc, "运维中最容易混淆的是“服务器 root”和“DeerFlow admin”。前者能管理容器和文件,后者才能在应用页面中配置菜单、主题、用户和发布广场。敏感词页面又有独立的 lqq 账号入口条件。")
add_figure(doc, diagrams["roles"], "图 2-1 四类身份与职责", "Linux 管理员、DeerFlow 管理员、lqq 账号、普通业务用户的权限边界")
heading = add_h2(doc, "2.1 建议准备的账号")
heading.paragraph_format.page_break_before = True
add_table(
doc,
["账号类型", "至少数量", "用途", "注意事项"],
[
["Linux / 1Panel 管理员", "2", "部署、日志、备份、恢复", "不共用个人账号;禁止把 root 密码写进文档"],
["DeerFlow admin", "2", "全局设置、白名单、菜单、主题、广场", "system_role 必须是 admin;保留一名应急管理员"],
["lqq", "1", "前端敏感词管理", "显示用户名或邮箱 @ 前缀必须严格为 lqq"],
["普通测试用户", "1", "验证普通权限、资产发布和页面可见性", "不能只用管理员做验收"],
["模型/数据库服务账号", "按环境", "调用外部服务", "最小权限、单独轮换、不得与人员账号共用"],
],
[1550, 900, 2850, 4060],
font_size=8.8,
)
add_h2(doc, "2.2 AUTH_JWT_SECRET 的作用")
add_para(doc, "AUTH_JWT_SECRET 是后端签发和校验登录令牌的服务端密钥,不是用户登录密码。它必须在多进程、多容器和每次重启之间保持一致。若更换该值,所有现有登录会话会失效,需要重新登录。")
add_code(doc, """
# config/backend.env
AUTH_JWT_SECRET=<至少32字节的高强度随机值>
""", "ENV")
add_para(doc, "在可联网或可信管理机生成随机值,再通过受控介质导入内网。Linux 可使用:")
add_code(doc, "openssl rand -hex 32", "Shell")
add_callout(doc, "禁止事项", "不要把密钥提交到 Git,不要写入 runtime-config.js,不要保留在 config.yaml 的注释中。浏览器可下载的前端配置中永远不能放服务端密钥。", "danger")
add_h2(doc, "2.3 用户名登录密码与 JWT 密钥的区别")
add_table(
doc,
["项目", "保存位置", "谁使用", "变更影响"],
[
["AUTH_JWT_SECRET", "config/backend.env", "后端服务", "全部登录会话失效"],
["用户名登录共享密码", "config/config.yaml 的 auth_login.username_login", "登录入口校验", "影响使用该入口的新登录"],
["账号 login_password", "应用用户/白名单管理", "指定账号", "只影响该账号"],
["VITE_ROUTE_LOGIN_PASSWORD", "runtime-config.js 或构建环境", "前端特定路由辅助", "不是后端安全边界"],
],
[1700, 2600, 2100, 2960],
)
add_h2(doc, "2.4 管理员与 lqq 的判定")
add_bullet(doc, "应用管理员由 system_role=admin 判定;内置 admin 用户通常会被提升为管理员,但仍应在用户管理页核验。")
add_bullet(doc, "敏感词写接口允许管理员或 lqq,但当前前端敏感词页面的入口和路由会检查显示用户名是否为 lqq。因此正常页面维护建议直接使用 lqq 登录。")
add_bullet(doc, "邮箱账号以 @ 前缀作为显示用户名时,应确保前缀为 lqq;不要使用类似 lqq01 代替。")
add_h1(doc, "3. 部署目录、配置分层与变更生效")
add_para(doc, "同一项配置可能存在源码默认值、镜像内默认值和运行时覆盖值。生产运维只改部署目录中的运行时文件,并记录变更;不要进入容器直接改文件,因为重建容器后会丢失。")
add_figure(doc, diagrams["config_layers"], "图 3-1 配置分层和生效方式", "部署层、后端层、前端运行时层和管理页面配置的关系")
add_h2(doc, "3.1 标准部署目录")
add_code(doc, """
deerflow-deploy/
├── docker-compose.yml
├── .env
├── config/
│ ├── backend.env
│ ├── config.yaml
│ ├── extensions_config.json
│ └── runtime-config.js
├── data/
│ ├── backend/
│ ├── skills/
│ ├── knowledge/
│ └── logs/
├── install.sh
├── restart.sh
├── stop.sh
├── logs.sh
└── diagnose.sh
""", "目录结构")
add_h2(doc, "3.2 变更生效速查")
add_table(
doc,
["修改对象", "典型内容", "最小生效动作", "验证"],
[
[".env", "APP_PORT、镜像标签", "重建受影响容器", "端口监听、浏览器访问"],
["backend.env", "JWT、模型 Key、MySQL URL", "强制重建 Backend", "后端日志、/health、实际功能"],
["config.yaml", "模型、数据库模式、登录配置", "重启 Backend", "启动日志、模型列表、登录"],
["runtime-config.js", "前端 API/业务接口、Logo", "建议重启 Web;浏览器 Ctrl+F5", "浏览器 Network 与页面"],
["管理页面", "菜单、主题、敏感词、广场", "保存后通常即时生效;必要时刷新", "管理员和普通用户分别验证"],
],
[1700, 2500, 2500, 2660],
)
add_h2(doc, "3.3 修改前的通用流程")
for step in [
"确认当前部署目录和 Compose 项目,不要在旧目录操作。",
"复制要修改的配置文件并加时间戳备份。",
"记录修改人、工单、原值、目标值和回退方法。",
"只重启受影响的服务;变更后执行健康检查和实际业务验证。",
"观察日志至少 5 分钟,再结束变更窗口。",
]:
add_number(doc, step)
add_h1(doc, "4. Docker / 1Panel 日常启停与日志")
add_h2(doc, "4.1 一键安装、重启、停止")
add_para(doc, "进入部署目录后执行以下脚本。生产环境建议保留 sudo,避免数据目录因不同用户写入而出现权限不一致。")
add_code(doc, """
cd /opt/deerflow/deerflow-deploy
# 首次安装 / 加载镜像并启动
sudo bash install.sh
# 一键重启全部服务
sudo bash restart.sh
# 一键停止全部服务
sudo bash stop.sh
""", "Shell")
add_h2(doc, "4.2 只重启后端或前端")
add_code(doc, """
# 日常重启后端,或仅 config.yaml / 挂载文件变更
sudo docker compose restart backend
# backend.env 发生变化时,必须重建容器以重新加载环境变量
sudo docker compose up -d --force-recreate backend
# 只重启 Web(前端运行时配置或 Nginx 变更)
sudo docker compose restart web
# 查看容器状态
sudo docker compose ps
""", "Shell")
add_callout(doc, "环境变量不会被普通 restart 重新读取", "restart.sh 和 docker compose restart 适合日常重启;修改 backend.env、APP_PORT 或其他 Compose 环境变量后,应使用 docker compose up -d --force-recreate <服务>。", "warn")
add_callout(doc, "1Panel 操作", "在 1Panel 的容器/编排页面执行“重启”也可以,但要确认选中的是当前部署目录对应的 Compose 项目。脚本方式更便于保留统一操作记录。", "note")
add_h2(doc, "4.3 打印实时日志")
add_code(doc, """
# 全部服务实时日志(Ctrl+C 退出,不会停止服务)
sudo bash logs.sh
# 只看后端
sudo bash logs.sh backend
# 只看前端 / Nginx
sudo bash logs.sh web
# Compose 原生命令:最近 500 行并持续跟踪
sudo docker compose logs --tail=500 -f backend
sudo docker compose logs --tail=500 -f web
""", "Shell")
add_h2(doc, "4.4 一键诊断")
add_code(doc, "sudo env TAIL=1000 bash diagnose.sh", "Shell")
add_para(doc, "诊断结果应至少包含架构、磁盘、内存、容器状态、端口监听、健康检查和最近错误日志。发送给研发前先检查输出中是否包含账号、Token、数据库 URL 或 API Key。")
add_h2(doc, "4.5 健康检查")
add_code(doc, """
# 从服务器本机检查,端口以 .env 的 APP_PORT 为准
curl -fsS http://127.0.0.1:8090/deerflow/health
# 检查前端首页响应头
curl -I http://127.0.0.1:8090/
""", "Shell")
add_callout(doc, "不要只看容器 Up", "容器处于 Up 只代表进程存在,不代表模型、数据库和业务功能可用。每次重启后至少完成健康接口、登录和一次对话验证。", "warn")
add_h1(doc, "5. 后端模型配置与 max_tokens")
add_para(doc, "模型列表由后端 config/config.yaml 管理,密钥放在 config/backend.env。推荐使用 OpenAI 兼容接口时配置 ChatOpenAI 适配器,并逐项核对模型 ID、base_url、超时、重试、输出上限和能力标志。")
add_figure(doc, diagrams["model_flow"], "图 5-1 模型配置与验证流程", "模型参数、密钥、后端配置、重启和界面验证的闭环")
add_h2(doc, "5.1 新增一个 OpenAI 兼容模型")
add_para(doc, "先在 backend.env 中保存密钥。变量名可自定义,但必须与 config.yaml 中 $ 后的名称完全一致。")
add_code(doc, """
# config/backend.env
INTRANET_MODEL_API_KEY=<模型服务分配的密钥>
""", "ENV")
add_para(doc, "再在 config/config.yaml 的 models 列表中新增条目:")
add_code(doc, """
models:
- name: intranet-model
display_name: 内网模型
use: langchain_openai:ChatOpenAI
model: model-id-from-provider
api_key: $INTRANET_MODEL_API_KEY
base_url: http://10.0.0.20:8000/v1
request_timeout: 600.0
max_retries: 2
max_tokens: 8192
temperature: 0.7
supports_vision: false
supports_thinking: false
""", "YAML")
add_h2(doc, "5.2 字段说明")
add_table(
doc,
["字段", "必需", "说明", "常见错误"],
[
["name", "是", "系统内部唯一名称,智能体配置引用它", "重名或改名后旧智能体引用失效"],
["display_name", "建议", "前端显示名称", "写成真实密钥或内部地址"],
["use", "是", "模型适配器,例如 langchain_openai:ChatOpenAI", "适配器与接口协议不匹配"],
["model", "是", "模型服务要求的精确 model id", "使用市场名称而非接口 ID,导致 404"],
["api_key", "通常", "使用 $ENV_NAME 从 backend.env 解析", "写成明文、变量名拼错"],
["base_url", "通常", "OpenAI 兼容接口通常以 /v1 结尾", "容器中 127.0.0.1 指向自身"],
["request_timeout", "建议", "单次请求秒数", "长推理模型超时过短"],
["max_retries", "建议", "网络或限流重试次数", "重试过多造成雪崩"],
["max_tokens", "建议", "单次生成的最大输出 token", "超过提供方限制或误认为上下文窗口"],
["supports_vision", "按能力", "是否允许图像输入", "服务不支持却标 true"],
["supports_thinking", "按能力", "是否支持思考模式", "适配器/服务参数不兼容"],
],
[1300, 850, 3900, 3310],
font_size=8.4,
)
add_h2(doc, "5.3 如何添加或调整 max_tokens")
add_para(doc, "在目标模型条目中增加 max_tokens: <整数>。它控制模型本次生成的最大输出长度,不等于“输入 + 输出”的总上下文窗口。提供方若只允许 8192 输出,就不能配置 65536。")
add_table(
doc,
["现象", "可能原因", "处理"],
[
["回答被截断", "max_tokens 太小、模型自行结束或上游网关限制", "逐步增大并检查 finish_reason"],
["400 invalid max_tokens", "超过模型上限或字段名不兼容", "查模型服务文档/网关日志,改为允许值"],
["长文本仍失败", "上下文窗口不足,不是输出上限问题", "缩短输入、启用摘要、换长上下文模型"],
["成本或耗时突然上升", "上限过大且提示词未约束", "降低 max_tokens,并在提示词限定结构和篇幅"],
],
[1900, 3500, 3960],
)
add_h2(doc, "5.4 模型变更后的验证")
for step in [
"备份 config.yaml 和 backend.env;检查 YAML 缩进,不使用 Tab。",
"若 backend.env 有变化,执行 sudo docker compose up -d --force-recreate backend;只改 config.yaml 时可 restart backend。",
"立即跟踪 backend 日志,确认没有 YAML 解析、环境变量缺失或模型初始化错误。",
"访问 /deerflow/health,登录页面确认新模型出现在可选列表。",
"完成短问答、长文本、工具调用;视觉模型再验证图片上传。",
"确认日志中没有 API Key;记录模型版本、base_url、限流和最大上下文。",
]:
add_number(doc, step)
add_h2(doc, "5.5 模型故障快速定位")
add_table(
doc,
["HTTP/日志", "含义", "优先检查"],
[
["401 / invalid_api_key", "模型密钥无效", "backend.env、变量名、密钥状态、是否有前后空格"],
["403", "密钥无该模型权限或 IP 白名单", "模型服务授权与来源 IP"],
["404 model_not_found", "model ID 或 base_url 不对", "接口真实 ID、/v1 路径、网关路由"],
["429", "限流或余额不足", "并发、速率配额、余额、重试策略"],
["timeout", "模型慢或网络不通", "容器到服务连通性、request_timeout、代理"],
["connection refused", "地址/端口错误或服务未启动", "不要在容器内使用错误的 127.0.0.1"],
],
[1950, 2910, 4500],
)
add_h1(doc, "6. 数据库:SQLite 与外部 MySQL")
add_para(doc, "默认部署可使用 SQLite,便于离线单机运行;已有外部 MySQL 时,可将业务数据库切换到 MySQL。切换数据库不会自动迁移旧 SQLite 中的业务数据,必须先做迁移和验收计划。LangGraph checkpointer 可继续保留 SQLite,两者不是同一配置项。")
add_h2(doc, "6.1 默认 SQLite")
add_code(doc, """
database:
backend: sqlite
sqlite_dir: .deer-flow/data
checkpointer:
type: sqlite
connection_string: .deer-flow/data/deerflow.db
""", "YAML")
add_para(doc, "在容器挂载下,关键数据位于宿主机 data/backend 对应目录。备份时应在写入低峰或停止后端,避免复制过程中数据库仍在变化。")
add_h2(doc, "6.2 切换到外部 MySQL")
add_code(doc, """
# config/backend.env
MYSQL_DATABASE_URL=mysql+asyncmy://deerflow_user:<URL编码后的密码>@10.0.0.30:3306/deerflow?charset=utf8mb4
""", "ENV")
add_code(doc, """
# config/config.yaml
database:
backend: mysql
mysql_url: $MYSQL_DATABASE_URL
# 可按现网设计继续使用 SQLite checkpointer
checkpointer:
type: sqlite
connection_string: .deer-flow/data/deerflow.db
""", "YAML")
add_callout(doc, "容器网络", "MySQL 在宿主机或另一台服务器时,地址必须是容器可访问的 IP/域名。容器内的 127.0.0.1 指后端容器自身,不指宿主机。", "warn")
add_h2(doc, "6.3 MySQL 前置条件")
for item in [
"数据库已创建,字符集建议 utf8mb4;时区和排序规则按单位标准统一。",
"服务账号至少拥有本应用所需的建表、改表、查询、增删改权限;禁止使用 root 作为长期业务账号。",
"防火墙允许后端容器所在主机访问 3306;MySQL bind-address 和用户 host 范围正确。",
"密码中的 @、:、/、#、% 等字符进行 URL 编码,避免连接串解析错误。",
"首次启动预留数据库迁移时间;观察 Alembic/建表日志,不要在未评估的情况下跳过迁移。",
]:
add_bullet(doc, item)
add_h2(doc, "6.4 切换与回退步骤")
for step in [
"冻结资产编辑和用户写入,完整备份 SQLite、data 目录与配置。",
"创建 MySQL 数据库和最小权限账号;从后端容器验证网络。",
"制定并执行数据迁移。只改 backend=mysql 不会把旧数据自动搬过去。",
"修改 backend.env 和 config.yaml,执行 docker compose up -d --force-recreate backend,持续看迁移日志。",
"用 admin、lqq、普通用户分别验收登录、技能、智能体、广场、菜单、敏感词和对话。",
"确认稳定前保留旧 SQLite 与旧配置;回退时停止写入,避免两边各自产生新数据。",
]:
add_number(doc, step)
heading = add_h2(doc, "6.5 数据库常见报错")
heading.paragraph_format.page_break_before = True
add_table(
doc,
["报错", "原因", "处理"],
[
["Access denied", "账号/密码/host 授权错误", "核对 URL 编码、MySQL 用户 host、权限"],
["Unknown database", "库未创建或名称错误", "创建库并核对大小写"],
["Connection refused / timeout", "端口、防火墙、bind-address、路由", "从容器执行 TCP 连通测试"],
["Too many connections", "连接池或并发超限", "先止损降并发,再调连接池和 MySQL 上限"],
["SQLite database is locked", "多写者或磁盘/挂载问题", "停止并发写,检查权限和磁盘,完整备份后处理"],
["Permission denied", "宿主机挂载所有者不正确", "核对 data/backend 的 UID/GID 与容器运行用户"],
],
[2600, 3200, 3560],
)
add_h1(doc, "7. 前端端口、接口地址与运行时配置")
add_para(doc, "生产前端使用 Nginx,不使用开发端口 5174。对外端口由部署目录 .env 的 APP_PORT 控制;前端访问后端和外部业务接口的地址由 config/runtime-config.js 控制。该文件由浏览器直接读取,不能放任何服务端秘密。")
add_h2(doc, "7.1 修改前端访问端口")
add_code(doc, """
# 部署目录 .env
APP_PORT=18090
""", "ENV")
add_code(doc, """
sudo docker compose up -d --force-recreate web
sudo docker compose ps
curl -I http://127.0.0.1:18090/
""", "Shell")
add_para(doc, "1Panel 中若有端口安全组或主机防火墙,还要放行新的 TCP 端口。旧端口如不再使用应关闭。")
add_h2(doc, "7.2 推荐的同源后端配置")
add_code(doc, """
// config/runtime-config.js
window.__DEERFLOW_RUNTIME_CONFIG__ = {
VITE_BACKEND_BASE_URL: "/deerflow",
VITE_LANGGRAPH_BASE_URL: "/deerflow/api"
};
""", "JavaScript")
add_para(doc, "使用相对路径时,浏览器始终访问当前域名和 APP_PORT,再由 Nginx 转发到 backend:8001。这样最少遇到 CORS、证书和混合内容问题。")
add_h2(doc, "7.3 后端独立地址的配置")
add_code(doc, """
window.__DEERFLOW_RUNTIME_CONFIG__ = {
VITE_BACKEND_BASE_URL: "https://api.example.internal",
VITE_LANGGRAPH_BASE_URL: "https://api.example.internal/api"
};
""", "JavaScript")
add_callout(doc, "使用绝对地址时", "后端必须配置允许前端 Origin,HTTPS 页面不能调用 HTTP 接口;证书需被客户端信任。除非确有分离网关需求,优先使用 /deerflow 相对地址。", "warn")
add_h2(doc, "7.4 常用运行时变量")
add_table(
doc,
["变量", "用途", "是否可含秘密", "变更验证"],
[
["VITE_BACKEND_BASE_URL", "Gateway 基础地址", "否", "登录、/health、普通 API"],
["VITE_LANGGRAPH_BASE_URL", "LangGraph/流式对话地址", "否", "新建对话和流式输出"],
["VITE_API_BASE_URL", "通用业务 API 根地址", "否", "对应业务页面"],
["VITE_AIPI_CONSUMER_URL", "任务深链/业务查询", "否", "任务详情与深链"],
["VITE_AIPI_CHAT_URL", "AIPI 对话类接口", "否", "对应智能体/业务"],
["VITE_FORCE_COLLAPSED_SIDEBAR", "强制紧凑侧边栏", "否", "刷新后导航形态"],
["Logo/欢迎页相关变量", "品牌素材 URL", "否", "明暗主题、登录页"],
],
[3000, 2900, 1200, 2260],
font_size=8.6,
)
add_h2(doc, "7.5 修改后的生效")
for step in [
"备份 runtime-config.js,确认 JavaScript 逗号和引号语法正确。",
"重启 Web 容器,或至少确认挂载文件已经更新。",
"浏览器使用 Ctrl+F5 强制刷新;必要时清空站点缓存。",
"在开发者工具 Network 中检查 runtime-config.js、/deerflow/health、/deerflow/api 请求。",
"验证普通登录、对话流和至少一个外部业务接口。",
]:
add_number(doc, step)
heading = add_h2(doc, "7.6 前端常见现象")
heading.paragraph_format.page_break_before = True
add_table(
doc,
["现象", "检查", "处理"],
[
["页面白屏", "Console 是否有语法/资源错误", "修复 runtime-config.js;恢复备份;强刷"],
["页面能开但所有 API 失败", "Network 请求地址", "修正 BACKEND/LANGGRAPH_BASE_URL"],
["刷新二级路由 404", "Nginx 是否 SPA try_files", "恢复随包 Nginx 配置并重建 Web"],
["502 Bad Gateway", "backend 状态与日志", "先恢复后端,不要反复刷新前端"],
["CORS blocked", "前后端 Origin 是否不同", "优先改同源;或配置后端 CORS"],
["Mixed Content", "HTTPS 页面调用 HTTP", "接口改 HTTPS 或统一由 Nginx 代理"],
],
[2400, 3000, 3960],
)
add_h1(doc, "8. 技能:目录、上传、发布与维护")
add_para(doc, "技能由宿主机 data/skills 持久化到容器 /app/backend/skills。系统使用 public 和 custom 两类目录,每个技能至少应包含 SKILL.md;还可包含 Python 脚本、references 和 assets。启动脚本只会在技能挂载目录为空时写入默认技能,不会覆盖已有文件。")
add_figure(doc, diagrams["persistence_map"], "图 8-1 技能与智能体的持久化关系", "技能和智能体由文件、数据库元数据、权限和运行时共同决定")
add_h2(doc, "8.1 标准技能目录")
add_code(doc, """
data/skills/
├── public/
│ └── example-public-skill/
│ ├── SKILL.md
│ ├── scripts/
│ ├── references/
│ └── assets/
└── custom/
└── example-custom-skill/
├── SKILL.md
└── tool.py
""", "目录结构")
add_h2(doc, "8.2 推荐:通过前端上传或创建")
for step in [
"使用普通业务用户或管理员登录,进入技能设置/技能管理。",
"创建或上传技能,填写名称、描述、使用说明和依赖。",
"在技能详情页完成测试,确认工具地址、Python 依赖和文件权限。",
"需要共享时点击“发布”,选择目标广场;不共享则保留为自定义技能。",
]:
add_number(doc, step)
add_callout(doc, "为什么推荐 UI", "UI 会同时维护技能文件、所有者、可见性、发布状态等数据库元数据。手工复制文件只解决文件存在,不一定能让正确用户在界面看到。", "note")
add_h2(doc, "8.3 手工离线导入技能")
add_para(doc, "仅在批量迁移或 UI 不可用时使用。先在测试环境核对技能内容,再复制整个技能目录,保持目录名稳定。")
add_code(doc, """
# 例:导入为自定义技能
sudo mkdir -p data/skills/custom/example-skill
sudo cp -a /mnt/offline/example-skill/. data/skills/custom/example-skill/
# 核对宿主机文件
find data/skills/custom/example-skill -maxdepth 3 -type f -print
# 核对容器内挂载
sudo docker compose exec backend sh -lc \
'find /app/backend/skills/custom/example-skill -maxdepth 3 -type f -print'
""", "Shell")
add_para(doc, "若容器不是 root 运行,需确保运行用户对文件可读、对需要修改的目录可写。完成后重启 backend 以刷新技能扫描与系统提示缓存。")
add_h2(doc, "8.4 public 与广场发布的区别")
add_table(
doc,
["概念", "含义", "建议权限", "是否等于广场可见"],
[
["public 目录", "技能文件类别/系统公共技能", "仅管理员直接写入", "不一定,仍受业务元数据和页面逻辑影响"],
["custom 目录", "用户自定义技能", "普通用户维护自己的", "可发布到广场"],
["发布广场", "业务可见性、目标广场和发布状态", "所有者或管理员", "是"],
],
[1750, 3600, 1900, 2110],
)
add_h2(doc, "8.5 技能地址管理")
add_para(doc, "管理员可在技能管理页的“技能地址管理”标签扫描运行中 skills/public 与 skills/custom 下 .md、.py 中出现的 HTTP(S) URL 和 IP:端口,预览后批量替换,并按修改记录回退。适用于 UAT 地址切生产地址。")
for item in [
"替换前使用预览,核对每个 file:line 片段;不要直接全目录文本替换。",
"应用会生成磁盘备份和 manifest;回退前仍建议做整目录备份。",
"扫描范围只包含运行中技能目录,不会修改 deploy/minimal/skills。",
"接口为管理员权限;大量技能扫描时等待进度完成,不要重复点击。",
]:
add_bullet(doc, item)
add_h2(doc, "8.6 技能发布到广场")
for step in [
"确认当前登录用户是技能所有者,或使用 admin。",
"在技能列表/详情点击“发布”。",
"选择一个发布广场;未选择时可能进入系统默认广场。",
"确认发布后,使用普通用户进入广场检索并安装/使用。",
"需要改投时重新发布到另一个广场;一个技能同一时间只属于一个广场。",
]:
add_number(doc, step)
add_h2(doc, "8.7 技能上传安全检查")
add_bullet(doc, "检查 SKILL.md 和脚本中是否包含真实 API Key、数据库密码、内网敏感地址或个人信息。")
add_bullet(doc, "检查 Python/shell 脚本是否包含删除、提权、任意命令执行或越权网络访问。")
add_bullet(doc, "需要外部依赖时,离线镜像必须已包含包,或提供受控离线 wheel/npm 包。")
add_bullet(doc, "仅允许可信管理员把技能直接放入 public;普通用户走 custom + 广场审批。")
add_h1(doc, "9. 智能体:目录、模型、发布与恢复")
add_para(doc, "智能体的主要文件位于 data/backend/agents/<agent-id>/,通常包含 config.yaml 与 SOUL.md;名称、所有者、发布状态、广场等信息在业务数据库中。因此备份或迁移智能体必须同时覆盖 data/backend 和数据库。")
add_h2(doc, "9.1 标准目录与职责")
add_code(doc, """
data/backend/agents/<agent-id>/
├── config.yaml # 模型、工具、参数等结构化配置
└── SOUL.md # 角色、任务、边界、表达风格等提示词
""", "目录结构")
add_table(
doc,
["对象", "主要内容", "缺失影响"],
[
["config.yaml", "模型引用、工具/技能和运行参数", "智能体可能无法加载或退化为最小配置"],
["SOUL.md", "角色提示词和业务规则", "角色行为改变、回答质量下降"],
["数据库记录", "所有者、名称、状态、发布信息", "界面看不到、权限异常、广场丢失"],
],
[2200, 3800, 3360],
)
add_h2(doc, "9.2 创建与维护建议")
for step in [
"优先在智能体管理页面创建,系统会生成 ID 和数据库记录。",
"选择已在后端 models 中存在的模型 name;模型改名时先盘点引用。",
"配置工具/技能后完成一轮真实问答和错误场景测试。",
"SOUL.md 中不要写真实密钥;外部账号通过环境变量或受控凭据服务注入。",
"修改生产智能体前导出目录和数据库备份,保留变更说明。",
]:
add_number(doc, step)
add_h2(doc, "9.3 发布到广场")
add_para(doc, "智能体列表卡片或详情页提供发布入口。所有者或管理员选择目标广场并确认;发布后普通用户可在对应广场发现。重新发布到另一广场表示移动,不会在多个广场重复复制。私有广场状态与公开发布状态应按页面规则保持互斥。")
add_figure(doc, diagrams["publish_flow"], "图 9-1 技能/智能体发布流程", "创建、验证、发布、权限校验和广场生效流程")
add_h2(doc, "9.4 直接发布与私有广场")
add_bullet(doc, "“直接发布到广场”仍建议从资产卡片/详情点击发布,这样所有者和发布元数据完整。")
add_bullet(doc, "管理员在外观/系统设置中维护广场列表、默认广场和私有广场密码。")
add_bullet(doc, "若资产已在私有广场,转为公开发布前按页面提示解除私有状态;反向操作同理。")
add_bullet(doc, "发布后要用普通用户验证,不要只看管理员列表。")
add_h2(doc, "9.5 智能体模型失效")
add_table(
doc,
["现象", "原因", "处理"],
[
["智能体加载但对话报模型不存在", "config.yaml 引用了已删除/改名的模型 name", "恢复原 name 或批量更新智能体配置"],
["模型列表有但智能体仍用旧模型", "智能体配置固定引用旧值或缓存未刷新", "修改智能体并重启 backend"],
["同名智能体权限不同", "数据库所有者/作用域不同", "用 admin 检查记录,不要仅比较目录名"],
],
[2800, 3300, 3260],
)
add_h1(doc, "10. 发布广场与私有广场管理")
add_para(doc, "发布广场是技能和智能体的分发入口。管理员维护广场列表、默认广场、启用状态与私有广场密码;资产所有者或管理员负责发布和下架。")
add_h2(doc, "10.1 管理员配置广场")
for step in [
"使用 DeerFlow admin 登录。",
"进入设置中的外观/发布广场配置区域。",
"新增广场名称和说明,设置启用状态、排序和默认广场。",
"如启用私有广场,配置独立密码并通过安全渠道分发。",
"保存后用普通用户检查广场入口、默认选择和访问控制。",
]:
add_number(doc, step)
add_h2(doc, "10.2 发布规则")
add_table(
doc,
["规则", "解释", "运维影响"],
[
["一项资产只属于一个广场", "重新发布到新广场会移动", "迁移前通知原广场使用者"],
["默认广场", "未明确选择时作为默认目标", "删除/停用前先指定新默认"],
["所有者或管理员可发布", "防止普通用户操作他人资产", "账号变更时先转移所有权或由 admin 处理"],
["私有与公开互斥", "防止同一资产同时处于两类可见性", "变更后用两类用户复核"],
],
[2300, 3400, 3660],
)
add_h2(doc, "10.3 看不到发布按钮")
add_bullet(doc, "当前用户不是资产所有者,也不是 admin。")
add_bullet(doc, "资产来自系统公共目录,页面只允许管理员操作。")
add_bullet(doc, "发布广场全部停用或没有默认广场。")
add_bullet(doc, "前端缓存、权限令牌过期,或后端数据库切换后记录不完整。")
add_bullet(doc, "打开浏览器 Network,检查 publish API 的 401/403/409 响应。")
add_h1(doc, "11. 菜单名称、排序、隐藏与路由安全")
add_para(doc, "管理员可通过“设置和更多 → 菜单管理”修改侧边栏节点的显示名称、启停、顺序和父级。系统以静态路由树为基础,仅把数据库中的覆盖项叠加上去;图标和实际 path 不会因为改名称而改变。")
add_figure(doc, diagrams["ui_map"], "图 11-1 常用管理入口地图", "菜单、主题、技能、智能体和用户管理的页面入口")
add_h2(doc, "11.1 修改菜单名称")
for step in [
"用 admin 登录,打开左下角“设置和更多”。",
"进入“菜单管理”,找到目标一级/二级/三级菜单。",
"编辑 label,保存;不要修改真实 path 或源代码 ID。",
"刷新页面,用普通用户核对侧边栏和顶部导航。",
"记录原名称,避免业务培训资料与新名称不一致。",
]:
add_number(doc, step)
add_h2(doc, "11.2 隐藏菜单")
add_para(doc, "在菜单管理中将节点停用。停用父节点会隐藏整个子树;支持上下移动或拖拽调整顺序和父级,但系统严格限制最多三级。动态注入的 iframe 轻应用节点也可管理。")
add_callout(doc, "重要安全边界", "隐藏菜单只移除导航入口,不保证用户不能通过已知 URL 直接访问,也不替代后端 API 权限。敏感功能必须同时有前端路由守卫和后端角色校验。", "danger")
add_h2(doc, "11.3 需要真正禁用功能时")
for item in [
"先停用菜单,避免用户继续进入。",
"检查对应页面是否有 admin/lqq/业务角色路由守卫。",
"检查后端 API 是否校验角色、所有者或账号。",
"必要时通过功能开关或后端配置关闭 API;不要只依赖 CSS 隐藏。",
"用普通账号直接输入 URL 和直接调用 API 进行验收。",
]:
add_number(doc, item)
add_h2(doc, "11.4 菜单异常恢复")
add_table(
doc,
["问题", "原因", "处理"],
[
["全部子菜单消失", "父节点被停用或移到错误父级", "admin 在菜单管理恢复父节点;必要时查 menu-overrides 数据"],
["菜单名改了但路由未变", "这是设计行为", "继续使用原 path;不要以显示名推导 URL"],
["轻应用菜单消失", "轻应用停用、父级停用或 override 隐藏", "同时检查轻应用和菜单覆盖"],
["普通用户看不到,admin 能看到", "角色过滤或功能权限", "确认预期角色,不要直接改静态路由"],
],
[2300, 3400, 3660],
)
add_h1(doc, "12. 主题色、布局和品牌配置")
add_para(doc, "所有用户都可在“设置 → 外观”选择个人主题和布局。管理员还可配置系统默认主题、默认布局和自定义主题库。用户已有个人选择时,个人设置通常优先于系统默认。")
add_h2(doc, "12.1 内置主题")
add_table(
doc,
["主题标识", "界面名称", "使用建议"],
[
["light", "标准浅色", "默认办公环境"],
["dark", "深色", "暗光环境和大屏"],
["lightblue", "浅蓝", "政企蓝色体系"],
["lightgreen", "浅绿", "运营/生态类场景"],
["iceblue", "冰蓝", "冷色、低饱和界面"],
["lightyellow", "浅黄", "暖色、阅读型界面"],
],
[2100, 2400, 4860],
)
add_h2(doc, "12.2 管理员设置系统默认")
for step in [
"使用 admin 登录,进入设置 → 外观。",
"设置系统默认主题和默认布局;这些主要影响没有个人覆盖的新用户。",
"在自定义主题库中配置主题名称、主色、启用状态和排序。",
"保存后使用新普通用户或清除个人覆盖的测试用户验证。",
"分别检查明暗主题的 Logo、文字对比度、按钮和焦点状态。",
]:
add_number(doc, step)
add_h2(doc, "12.3 自定义主题色的验收")
add_bullet(doc, "正文文字和背景必须有足够对比度;不要只看主按钮。")
add_bullet(doc, "检查 hover、selected、disabled、focus、error、success 等状态。")
add_bullet(doc, "检查表格、弹窗、下拉菜单、图表、侧边栏和移动窗口宽度。")
add_bullet(doc, "品牌主色不要直接当大面积背景,浅色主题应提供足够浅的层级色。")
add_bullet(doc, "主题调整后至少用 Chrome/Edge 验证,并保留一键恢复内置主题的方法。")
add_h2(doc, "12.4 强制紧凑侧边栏")
add_code(doc, """
// config/runtime-config.js
window.__DEERFLOW_RUNTIME_CONFIG__ = {
VITE_FORCE_COLLAPSED_SIDEBAR: "true"
};
""", "JavaScript")
add_para(doc, "这是部署级开关,适用于外部嵌入或小屏展示。若用户报告无法展开侧边栏,先检查该值。")
add_h2(doc, "12.5 iframe 嵌入主题")
add_para(doc, "外部系统可在深链加入 ?embed=1&theme=dark 等参数隐藏全局导航并强制当前标签页主题。该设置使用会话级隔离,不应覆盖普通标签页的个人主题。另有 ?hideJump=1 隐藏快捷跳转栏,?isolate=1 启用本标签页独立登录会话。")
add_h1(doc, "13. 敏感词管理与显示层脱敏")
add_para(doc, "敏感词功能维护“原始词 → 替换词”的展示层映射。它会改变菜单/导航等页面显示,但不会修改原始业务数据、路由 path、节点 ID 或数据库中的源文本。因此它不是数据清洗,也不能代替数据库脱敏。")
add_h2(doc, "13.1 登录哪个用户")
add_callout(doc, "使用 lqq", "当前前端入口、路由守卫和页面守卫要求显示用户名转小写后等于 lqq。即使 admin 后端有写权限,非 lqq 管理员通常也看不到或进不了该页面。", "warn")
add_h2(doc, "13.2 操作步骤")
for step in [
"以 lqq 登录,进入左下角“设置和更多 → 敏感词管理”。",
"新增原始词和替换词,设置启用状态;避免把过短的常用单字作为原始词。",
"如需恢复系统初始映射,使用“导入种子词”合并缺失项。",
"保存会整体替换当前配置,提交前导出或截图留档。",
"刷新页面,检查菜单、导航、标题;再用普通用户验证。",
]:
add_number(doc, step)
add_h2(doc, "13.3 设计与风险")
add_table(
doc,
["问题", "说明", "建议"],
[
["是否改数据库原文", "不会,只影响展示", "需要源数据脱敏时另走数据治理流程"],
["是否影响 URL", "不会,path/id 保持不变", "文档仍使用真实路由"],
["空配置怎么办", "加载失败或空时保留前端种子映射", "检查 /api/sensitive-words 与日志"],
["替换冲突", "多个词重叠可能产生意外显示", "从长词到短词测试;避免循环式替换"],
["保存影响范围", "全局共享", "必须有审批和变更记录"],
],
[2200, 3400, 3760],
)
add_h2(doc, "13.4 页面看不到时")
add_bullet(doc, "确认显示用户名是 lqq,而不是仅系统角色为 admin。")
add_bullet(doc, "邮箱登录时确认 @ 前缀;清理旧 Token 后重新登录。")
add_bullet(doc, "确认菜单覆盖没有停用“敏感词管理”节点。")
add_bullet(doc, "检查后端 /api/sensitive-words 的 401/403 和数据库连接。")
add_h1(doc, "14. 备份、恢复、升级与回滚")
add_para(doc, "一次可恢复的备份至少包含配置、持久化文件和当前业务数据库。只保存 Docker 镜像不能恢复用户、技能、智能体和全局设置。外部 MySQL 必须单独导出。")
add_figure(doc, diagrams["backup_upgrade"], "图 14-1 离线升级与回滚闭环", "从盘点、备份、演练、验收到切换和回滚的过程")
add_h2(doc, "14.1 备份范围")
add_table(
doc,
["对象", "必须", "建议频率", "恢复用途"],
[
["config 目录 + .env", "是", "每次变更后", "恢复模型、登录、接口、数据库配置"],
["data/backend", "是", "每日 + 变更前", "SQLite、智能体文件和运行数据"],
["data/skills", "是", "每日 + 技能变更后", "public/custom 技能"],
["data/knowledge", "按使用", "每日/每周", "知识库文件"],
["外部 MySQL dump", "使用 MySQL 时", "每日 + 变更前", "资产元数据、用户、菜单、主题、广场等"],
["镜像 tar + SHA256", "是", "每个版本", "离线重装和回滚"],
],
[2600, 1100, 2100, 3560],
)
add_h2(doc, "14.2 文件备份示例")
add_code(doc, """
cd /opt/deerflow/deerflow-deploy
# 推荐先安排短暂停写窗口,再执行归档
sudo tar -czf /backup/deerflow-files-$(date +%F-%H%M).tar.gz \
.env config data
sha256sum /backup/deerflow-files-*.tar.gz
""", "Shell")
add_h2(doc, "14.3 MySQL 备份示例")
add_code(doc, """
# 不要把密码直接写在命令行历史中;优先使用受控 defaults-extra-file
mysqldump --single-transaction --routines --triggers \
--host=<MYSQL_HOST> --user=<BACKUP_USER> deerflow \
> /backup/deerflow-mysql-$(date +%F-%H%M).sql
""", "Shell")
add_h2(doc, "14.4 恢复原则")
for step in [
"确认备份版本、摘要、数据库模式和目标目录;禁止直接覆盖未知现网。",
"停止写入或停止 backend,先把现网再做一次应急备份。",
"在新目录恢复 config 和 data,检查文件所有者与权限。",
"外部 MySQL 恢复到新库或确认的空库,避免覆盖错误库。",
"用临时 APP_PORT 启动,完成登录、模型、技能、智能体、广场和对话验收。",
"切换正式端口;保留旧目录、旧库和旧镜像直到观察期结束。",
]:
add_number(doc, step)
add_h2(doc, "14.5 离线升级")
add_bullet(doc, "核对离线包架构必须为 linux/arm64;SHA256 与交付清单一致。")
add_bullet(doc, "不要直接覆盖旧目录。解压到带版本号的新目录,复制 data 和配置后逐项合并。")
add_bullet(doc, "先用临时端口启动新版本,数据库升级必须有回退方案。")
add_bullet(doc, "升级后测试管理员、lqq、普通用户三类账号。")
add_bullet(doc, "观察期内保留旧镜像,禁止执行会清理回滚镜像的 prune。")
add_callout(doc, "高风险命令", "docker system prune -a、删除部署目录、覆盖数据库和递归修改所有者均可能造成不可逆损失。执行前必须明确目标、备份已验证并经过审批。", "danger")
add_h2(doc, "14.6 备份校验与恢复演练")
add_para(doc, "备份任务显示“成功”不代表一定可恢复。至少每季度在隔离目录或隔离服务器做一次完整恢复演练,并记录恢复用时和缺失项。")
add_table(
doc,
["检查项", "检查方法", "合格标准"],
[
["归档完整性", "tar -tzf 列出 config、data 关键目录", "无读取错误,路径与部署台账一致"],
["文件摘要", "重新计算 SHA256 并与备份台账比对", "摘要完全一致"],
["SQLite", "在副本上执行完整性检查并启动测试环境", "检查通过,用户与资产数量正确"],
["MySQL", "恢复到隔离库,核对表、行数和关键业务", "无迁移错误,三类账号验收通过"],
["应用验证", "临时 APP_PORT 启动并执行上线验收清单", "健康、登录、模型、资产、管理功能通过"],
["回退时长", "记录从故障确认到旧版本恢复的总耗时", "满足本单位 RTO/RPO 要求"],
],
[2100, 4100, 3160],
font_size=8.7,
)
add_h1(doc, "15. 监控、巡检与日志留存")
add_h2(doc, "15.1 每日巡检")
for item in [
"docker compose ps:backend、web 均为正常状态,无反复重启。",
"/deerflow/health 返回成功;前端首页可打开。",
"磁盘剩余空间、inode、内存和负载无异常。",
"backend/web 日志无持续 401、429、500、数据库断连或权限错误。",
"模型最短问答可用;关键业务接口按单位要求抽测。",
"前一日文件备份和数据库备份成功,摘要记录完整。",
]:
add_bullet(doc, item)
add_h2(doc, "15.2 每周巡检")
for item in [
"检查离线镜像、部署目录和文档版本是否对应。",
"统计模型调用错误率、超时和 429,评估并发与额度。",
"检查技能地址是否指向过期 UAT/测试环境。",
"抽查发布广场、私有广场、菜单、主题和敏感词是否符合审批。",
"在隔离环境随机做一次备份恢复演练。",
"清理日志前先确认保留策略;不要删除仍在被容器写入的文件。",
]:
add_bullet(doc, item)
add_h2(doc, "15.3 建议监控指标")
add_table(
doc,
["层级", "指标", "建议告警"],
[
["主机", "磁盘、inode、内存、负载、时间同步", "磁盘 <20% 预警,<10% 紧急"],
["容器", "Up、重启次数、CPU/内存", "反复重启或持续高占用"],
["HTTP", "首页、/health、5xx、延迟", "健康失败、连续 5xx"],
["模型", "401/403/404/429、超时、首 token 延迟", "权限错误立即,429 持续告警"],
["数据库", "连接数、慢查询、空间、备份", "连接耗尽、备份失败"],
["业务资产", "技能/智能体数量、发布变更、管理员操作", "数量突降、未经审批的全局变更"],
],
[1800, 4100, 3460],
)
add_h2(doc, "15.4 日志采集包")
add_para(doc, "提交问题时应包含:发生时间(含时区)、用户和角色、访问 URL、操作步骤、浏览器 Network/Console、backend/web 最近日志、compose ps、健康检查、版本号与配置变更。任何日志对外发送前都要脱敏。")
add_h1(doc, "16. 常见报错与解决办法")
add_h2(doc, "16.1 容器和主机")
add_table(
doc,
["报错/现象", "根因", "解决"],
[
["exec format error", "镜像架构不匹配", "确认 uname -m;ARM 服务器使用 linux/arm64 镜像"],
["address already in use", "APP_PORT 被占用", "ss -lntp 检查;改 .env 或停冲突服务"],
["no space left on device", "磁盘或 inode 满", "先定位大目录和日志;扩容或受控清理;不要盲目 prune"],
["容器不断 Restarting", "配置解析、数据库、权限或启动命令失败", "docker compose logs --tail=500 backend"],
["permission denied", "挂载目录所有者/模式不匹配", "对照容器 UID/GID 修复目标目录,不要全盘 chmod 777"],
["镜像导入失败", "tar 损坏或磁盘不足", "核对 SHA256、剩余空间、docker load 日志"],
],
[2600, 2900, 3860],
font_size=8.7,
)
add_h2(doc, "16.2 前端和 Nginx")
add_table(
doc,
["报错/现象", "根因", "解决"],
[
["502 Bad Gateway", "backend 未启动或 Nginx 无法访问 backend:8001", "查 backend 日志和 compose 网络;先恢复后端"],
["404 刷新二级路由", "SPA fallback 丢失", "Nginx location / 使用 try_files ... /index.html"],
["静态资源 404", "错误 base 路径、缓存或镜像不完整", "查 Network 路径、runtime-config 和 Web 镜像"],
["页面白屏", "JavaScript 语法、旧缓存、运行时配置异常", "看 Console;恢复 runtime-config;Ctrl+F5"],
["请求发到旧地址", "浏览器缓存或改错文件", "确认 Network 中 runtime-config.js;重启 Web 并强刷"],
],
[2600, 3150, 3610],
)
add_h2(doc, "16.3 登录和权限")
add_table(
doc,
["报错/现象", "根因", "解决"],
[
["401 Unauthorized", "Token 过期、JWT 密钥变化或存储隔离", "重新登录;核对 AUTH_JWT_SECRET 是否稳定"],
["403 Forbidden", "角色/所有者不足", "核对 system_role、资产所有者、lqq 条件"],
["重启后全员掉线", "JWT 密钥被改或未持久化", "恢复固定密钥;全员重新登录"],
["admin 看不到敏感词入口", "前端要求用户名 lqq", "使用 lqq;不要仅提升 admin"],
["iframe 与正常页账号混淆", "未使用 embed/isolate 会话隔离", "核对 URL 参数与 sessionStorage 策略"],
],
[2600, 3300, 3460],
)
add_h2(doc, "16.4 模型和流式对话")
add_table(
doc,
["报错/现象", "根因", "解决"],
[
["模型 401/403", "Key 或权限", "核对 backend.env、模型授权、IP 白名单"],
["模型 404", "base_url/model ID", "用提供方接口清单核对,注意 /v1"],
["模型 429", "并发/限流/余额", "降并发、等待、申请额度;避免重试风暴"],
["流式输出中断", "代理缓冲、超时、客户端断网", "查 Nginx SSE 配置、后端日志和 Network"],
["回答截断", "max_tokens 或上游输出上限", "逐步调大,查 finish_reason;不要超过提供方限制"],
["工具调用循环", "模型能力、提示词或工具返回异常", "查看线程日志,暂时禁用问题工具并修复输入输出"],
],
[2600, 3000, 3760],
)
add_h2(doc, "16.5 数据库和资产")
add_table(
doc,
["报错/现象", "根因", "解决"],
[
["MySQL Access denied", "账号、密码或 host", "核对 URL 编码和授权"],
["启动迁移报错", "版本跨度、权限或脏迁移", "停止反复启动,备份后按迁移日志处理"],
["技能存在但 UI 不显示", "数据库所有者/作用域或扫描失败", "查文件、记录、权限和 backend 日志"],
["智能体目录存在但列表为空", "数据库切换或元数据缺失", "恢复同版本数据库或按管理流程重建记录"],
["发布后广场无资产", "发布目标、过滤、私有状态或缓存", "用普通用户查目标广场和 publish API"],
],
[2600, 3300, 3460],
)
add_h1(doc, "17. 技能或智能体丢失专项排查")
add_para(doc, "“丢失”不一定是磁盘文件被删。账号变化、数据库切换、发布过滤、错误 Compose 目录、空挂载或权限失败都可能表现为界面看不到。按固定顺序排查,避免先复制文件造成数据进一步分叉。")
add_figure(doc, diagrams["lost_asset_flow"], "图 17-1 资产丢失排查流程", "从服务、账号、文件、数据库到恢复和验证的排查顺序")
add_h2(doc, "17.1 第一步:确认当前服务和部署目录")
add_code(doc, """
pwd
sudo docker compose ps
sudo docker compose config --services
sudo docker compose images
""", "Shell")
add_para(doc, "重点排除在旧部署目录执行命令,或 1Panel 启动了另一个同名 Compose 项目。记录容器名称、镜像 ID 和端口。")
add_h2(doc, "17.2 第二步:确认登录身份")
add_bullet(doc, "退出重新登录,确认用户名、system_role 和是否使用 iframe/isolate 独立会话。")
add_bullet(doc, "确认资产原所有者是谁;普通用户不能看到他人私有资产。")
add_bullet(doc, "用 admin 查看全局资产;再用原所有者确认。")
add_h2(doc, "17.3 第三步:核对宿主机与容器内文件")
add_code(doc, """
# 宿主机
find data/skills -maxdepth 3 -type f -print | sort
find data/backend/agents -maxdepth 3 -type f -print | sort
# 容器内
sudo docker compose exec backend sh -lc \
'find /app/backend/skills -maxdepth 3 -type f -print | sort'
sudo docker compose exec backend sh -lc \
'find /app/backend/.deer-flow/agents -maxdepth 3 -type f -print | sort'
""", "Shell")
add_para(doc, "如果宿主机有、容器内没有,检查 docker-compose.yml 的 volume 映射和当前工作目录;如果两边都没有,先停止写入并查备份,不要创建同名空资产覆盖恢复线索。")
add_h2(doc, "17.4 第四步:确认数据库没有切换")
add_bullet(doc, "检查 config.yaml 的 database.backend 是 sqlite 还是 mysql。")
add_bullet(doc, "检查 backend.env 的 MYSQL_DATABASE_URL 指向哪个主机和库。")
add_bullet(doc, "核对变更前后资产数量;SQLite 与 MySQL 不会自动保持同步。")
add_bullet(doc, "如果切回旧数据库后资产恢复,先冻结写入,制定合并方案,避免两套库继续分叉。")
add_h2(doc, "17.5 第五步:检查过滤、广场和权限")
add_bullet(doc, "清除搜索词和分类筛选;查看“我的”“公共”“已发布”“私有”等不同视图。")
add_bullet(doc, "确认资产是否改投另一广场、被下架、进入私有广场或所有权变化。")
add_bullet(doc, "检查 menu-overrides 是否隐藏了技能/智能体入口。")
add_h2(doc, "17.6 第六步:日志和权限")
add_code(doc, """
sudo docker compose logs --tail=1000 backend \
| grep -Ei 'skill|agent|permission|database|mysql|sqlite|invalid|error|traceback'
""", "Shell")
add_para(doc, "重点关注:SKILL.md 格式不合法、config.yaml 解析失败、目录 permission denied、数据库连接失败、扫描缓存刷新失败和 401/403。")
add_h2(doc, "17.7 恢复决策")
add_table(
doc,
["文件", "数据库", "建议"],
[
["存在", "存在", "修复权限、挂载、账号或过滤;重启 backend 验证"],
["存在", "缺失", "从同版本数据库备份恢复元数据;不要直接伪造所有者"],
["缺失", "存在", "从文件备份恢复;数据库可能只能提供最小信息"],
["缺失", "缺失", "停止写入,恢复完整备份;保全日志和磁盘证据"],
["两套库各有新数据", "分叉", "冻结写入,导出比对,编写一次性合并迁移并审计"],
],
[2000, 2000, 5360],
)
add_callout(doc, "不要做什么", "不要执行 docker system prune -a,不要删除 data 重新启动,不要把空目录覆盖到挂载点,不要用 chmod -R 777 掩盖所有权问题。", "danger")
add_h1(doc, "18. 安全加固与密钥管理")
add_h2(doc, "18.1 密钥清单")
add_table(
doc,
["密钥/凭据", "保存位置", "前端可见", "轮换建议"],
[
["AUTH_JWT_SECRET", "config/backend.env", "绝对不可", "事件触发或年度;轮换会使会话失效"],
["模型 API Key", "config/backend.env", "绝对不可", "按提供方策略,泄露立即轮换"],
["MySQL URL/密码", "config/backend.env 或安全挂载", "绝对不可", "90~180 天或按制度"],
["私有广场密码", "后台全局设置/数据库", "受登录用户可见范围", "人员变化或定期"],
["runtime-config 地址", "config/runtime-config.js", "是", "只能放非秘密地址和开关"],
],
[2300, 2800, 1600, 2660],
font_size=8.8,
)
add_h2(doc, "18.2 最低安全要求")
for item in [
"服务器 SSH 禁止长期使用共享 root 密码,启用个人密钥、最小 sudo 和登录审计。",
"1Panel 使用独立安全入口、强密码、双因素认证和访问白名单;及时安装受控补丁。",
"只开放 APP_PORT、SSH 管理端口和必要数据库访问;后端 8001 默认不对外映射。",
"config/backend.env 权限建议 600,部署目录限制给运维组;备份同样加密和访问控制。",
"审计 config.yaml,包括注释中的历史 API Key;发现真实 Key 即轮换并删除,不因注释状态降低风险。",
"技能和智能体提示词中禁止嵌入密钥;上传脚本按代码审查流程执行。",
"内网不等于可信。模型、数据库和业务接口仍使用 TLS、账号权限和日志审计。",
]:
add_bullet(doc, item)
add_h2(doc, "18.3 日志脱敏")
add_bullet(doc, "对外发送日志前删除 Authorization、Cookie、Token、API Key、数据库 URL、手机号和身份证号。")
add_bullet(doc, "保留时间戳、请求路径、状态码、trace ID 和错误栈,避免过度脱敏导致无法定位。")
add_bullet(doc, "不要在群聊粘贴完整 backend.env;必要时只报告变量是否已设置和哈希/末四位。")
add_h2(doc, "18.4 离线介质")
add_bullet(doc, "导入前扫描移动介质;镜像和文档核对 SHA256。")
add_bullet(doc, "记录介质编号、导入人、时间和文件清单。")
add_bullet(doc, "导入完成后按单位制度清除临时介质和服务器临时目录。")
add_h1(doc, "19. 标准运维流程与交接清单")
add_h2(doc, "19.1 上线验收")
checks = [
["基础", "CPU 架构为 ARM64;镜像 SHA256 与交付清单一致", "□"],
["服务", "web/backend 正常;健康接口成功;无重启循环", "□"],
["网络", "APP_PORT 可访问;8001 未无意暴露;外部依赖可达", "□"],
["登录", "admin、lqq、普通用户均可登录且权限符合预期", "□"],
["模型", "短问答、长文本、工具调用和视觉能力按需通过", "□"],
["资产", "技能、智能体数量一致;所有者、广场和私有状态正确", "□"],
["管理", "菜单、主题、敏感词、发布广场保存并对普通用户生效", "□"],
["备份", "文件和数据库备份成功,并完成至少一次恢复抽检", "□"],
["文档", "端口、地址、版本、账号责任人和回退步骤已登记", "□"],
]
add_table(doc, ["类别", "验收项", "结果"], checks, [1400, 7160, 800], font_size=8.8)
add_h2(doc, "19.2 变更单最少信息")
add_bullet(doc, "变更目标、影响范围、窗口、负责人和审批单号。")
add_bullet(doc, "修改前后值(密钥只记录变量名,不记录明文)。")
add_bullet(doc, "备份位置、摘要、恢复验证结果。")
add_bullet(doc, "执行命令、服务重启范围、健康和业务验收结果。")
add_bullet(doc, "回退触发条件、回退命令、回退负责人。")
add_h2(doc, "19.3 离职/交接")
add_bullet(doc, "回收 Linux/1Panel、DeerFlow、MySQL、模型平台和外部系统账号。")
add_bullet(doc, "轮换共享密钥、私有广场密码和应急账号密码。")
add_bullet(doc, "核对定时备份、监控、证书和离线介质责任人。")
add_bullet(doc, "移交最近一次升级记录、已知问题和未完成变更。")
add_h2(doc, "19.4 运维责任矩阵")
add_table(
doc,
["事项", "Linux/1Panel 运维", "DeerFlow admin", "模型/数据库管理员"],
[
["容器、端口、磁盘", "主责", "知会", "知会"],
["菜单、主题、广场、用户", "支持", "主责", "知会"],
["模型参数与密钥", "执行部署", "业务验收", "主责并授权"],
["数据库迁移与恢复", "容器与文件支持", "业务验收", "主责"],
["技能/智能体丢失", "核对挂载和备份", "核对所有者和元数据", "核对数据库"],
["安全事件", "隔离主机与日志保全", "冻结应用账号/资产", "轮换凭据并保全审计"],
],
[2100, 2450, 2350, 2460],
font_size=8.5,
)
add_h2(doc, "19.5 交接会必须确认")
add_bullet(doc, "现场演示一次后端环境变量变更后的强制重建、实时日志和一键诊断。")
add_bullet(doc, "现场演示 admin、lqq、普通用户三类账号的边界。")
add_bullet(doc, "现场定位技能、智能体文件目录和当前数据库,并抽查最近备份。")
add_bullet(doc, "明确故障升级联系人、响应时间、离线介质和回退决策人。")
add_h1(doc, "附录 A:命令速查")
add_h2(doc, "A.1 服务")
add_code(doc, """
sudo bash install.sh # 首次安装/加载镜像并启动
sudo bash restart.sh # 重启全部
sudo bash stop.sh # 停止全部
sudo docker compose restart backend # 日常重启后端/挂载配置变更
sudo docker compose up -d --force-recreate backend # backend.env 变更
sudo docker compose restart web # 只重启前端
sudo docker compose ps # 状态
""", "Shell")
add_h2(doc, "A.2 日志与健康")
add_code(doc, """
sudo bash logs.sh # 全部实时日志
sudo bash logs.sh backend # 后端实时日志
sudo bash logs.sh web # Web/Nginx 实时日志
sudo env TAIL=1000 bash diagnose.sh # 一键诊断
curl -fsS http://127.0.0.1:8090/deerflow/health
""", "Shell")
add_h2(doc, "A.3 主机资源")
add_code(doc, """
uname -m # 架构,应为 aarch64/arm64
df -h # 磁盘空间
df -i # inode
free -h # 内存
ss -lntp # 端口监听
sudo docker stats --no-stream # 容器资源
""", "Shell")
add_h2(doc, "A.4 文件与挂载")
add_code(doc, """
sudo docker compose config # 展开后的 Compose 配置
sudo docker compose exec backend sh -lc 'id; pwd; mount'
sudo docker compose exec backend sh -lc 'ls -la /app/backend/skills'
sudo docker compose exec backend sh -lc 'ls -la /app/backend/.deer-flow/agents'
""", "Shell")
add_h1(doc, "附录 B:安全配置模板")
add_h2(doc, "B.1 部署 .env")
add_code(doc, """
COMPOSE_PROJECT_NAME=deerflow-1panel
# 原样复制离线包内 .env 的标签值
DEERFLOW_TAG=<PACKAGE_TAG>
APP_PORT=8090
TZ=Asia/Shanghai
# 不要只凭 tag 名称判断 CPU 架构;用 docker image inspect 核对 Architecture
""", "ENV")
add_h2(doc, "B.2 backend.env")
add_code(doc, """
AUTH_JWT_SECRET=<强随机值>
INTRANET_MODEL_API_KEY=<模型密钥>
MYSQL_DATABASE_URL=mysql+asyncmy://<USER>:<URL_ENCODED_PASSWORD>@<HOST>:3306/<DB>?charset=utf8mb4
GATEWAY_ENABLE_DOCS=false
GATEWAY_WORKERS=1
""", "ENV")
add_h2(doc, "B.3 runtime-config.js")
add_code(doc, """
window.__DEERFLOW_RUNTIME_CONFIG__ = {
VITE_BACKEND_BASE_URL: "/deerflow",
VITE_LANGGRAPH_BASE_URL: "/deerflow/api",
VITE_API_BASE_URL: "<可选业务API地址>",
VITE_AIPI_CONSUMER_URL: "<可选任务业务地址>",
VITE_FORCE_COLLAPSED_SIDEBAR: "false"
};
""", "JavaScript")
add_h2(doc, "B.4 模型条目")
add_code(doc, """
models:
- name: intranet-model
display_name: 内网模型
use: langchain_openai:ChatOpenAI
model: <provider-model-id>
api_key: $INTRANET_MODEL_API_KEY
base_url: http://<MODEL_HOST>:<PORT>/v1
request_timeout: 600.0
max_retries: 2
max_tokens: 8192
temperature: 0.7
supports_vision: false
supports_thinking: false
""", "YAML")
add_h1(doc, "附录 C:问题升级模板")
add_para(doc, "向研发或供应商提交问题时,复制以下模板填写。密钥和个人信息必须脱敏。")
add_code(doc, """
【问题标题】
【发生时间】YYYY-MM-DD HH:mm:ss(Asia/Shanghai)
【环境/版本】生产/测试;离线包版本;镜像 ID;主机架构
【影响范围】全部用户/指定用户;登录/对话/技能/智能体/广场等
【用户身份】admin / lqq / 普通用户(不要提供密码)
【复现步骤】1... 2... 3...
【期望结果】
【实际结果】
【HTTP 状态码 / trace ID】
【最近变更】配置、模型、数据库、镜像、菜单、主题等
【容器状态】docker compose ps
【健康检查】/deerflow/health 结果
【日志附件】backend/web 最近 1000 行(已脱敏)
【浏览器附件】Console + Network(已脱敏)
【已尝试操作】
【是否可回退】是/否;回退点
""", "故障单")
add_h2(doc, "C.1 现场信息保全")
add_bullet(doc, "故障期间先记录时间、容器状态和日志,再重启;重启会丢失部分现场。")
add_bullet(doc, "涉及资产丢失时不要新建同名资产,不要修改数据库,不要清理 Docker。")
add_bullet(doc, "涉及安全事件时立即隔离、轮换凭据并按本单位安全事件流程上报。")
add_h1(doc, "附录 D:核心事实速记")
facts = [
["前端和后端端口", "容器内不同:Web 80、Backend 8001;对用户可由 Nginx 统一为 APP_PORT"],
["前端改端口", "部署 .env 的 APP_PORT,重建/重启 Web,放行防火墙"],
["前端改接口", "config/runtime-config.js;推荐 /deerflow 和 /deerflow/api"],
["后端模型", "config/config.yaml;密钥在 config/backend.env"],
["max_tokens", "模型单次输出上限,不是上下文窗口"],
["技能位置", "data/skills/public 与 data/skills/custom"],
["智能体位置", "data/backend/agents/<agent-id>;还依赖数据库元数据"],
["发布广场", "资产所有者或 admin 在 UI 发布;每项资产同一时间一个广场"],
["敏感词用户", "正常前端管理使用显示用户名为 lqq 的账号"],
["菜单隐藏", "只隐藏导航,不等于禁止直接路由和 API"],
["JWT 密钥", "AUTH_JWT_SECRET;必须持久化;改变会导致全员重新登录"],
["数据库切换", "SQLite ↔ MySQL 不自动迁移;必须备份、迁移、验收"],
["完整备份", "config + data + 外部 MySQL + 镜像及 SHA256"],
["资产丢失", "先查账号、挂载、数据库和过滤,再决定恢复;不要先 prune/删除"],
]
add_table(doc, ["主题", "结论"], facts, [2500, 6860], font_size=8.8)
add_callout(doc, "手册维护", "每次镜像升级、路由调整、权限变化或数据库迁移后,由应用管理员和 Linux 运维共同复核本手册并更新版本记录。", "ok")
# Final metadata page without forced H1 to avoid a mostly blank page.
add_page_break(doc)
p = doc.add_paragraph(style="Title")
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.space_before = Inches(1.45)
r = p.add_run("运维交付确认")
set_run_font(r, size=24, bold=True, color=DARK_BLUE)
add_para(doc, "以下信息应由接收方在正式上线前补充,并存放在受控运维台账中。不要在本 Word 文档中写明文密码。", align=WD_ALIGN_PARAGRAPH.CENTER)
add_table(
doc,
["项目", "填写内容"],
[
["生产访问地址", ""],
["部署目录", ""],
["APP_PORT", ""],
["镜像版本 / SHA256", ""],
["数据库模式与库名", ""],
["Linux / 1Panel 责任人", ""],
["DeerFlow admin 责任人", ""],
["lqq 账号责任人", ""],
["备份位置与保留期", ""],
["监控告警联系人", ""],
["最近一次恢复演练日期", ""],
],
[3000, 6360],
font_size=9,
)
add_para(doc, "交付人签字:____________________ 接收人签字:____________________", align=WD_ALIGN_PARAGRAPH.CENTER, after=10)
add_para(doc, "日期:________年____月____日", align=WD_ALIGN_PARAGRAPH.CENTER)
doc.save(OUTPUT)
return OUTPUT
if __name__ == "__main__":
ensure_dirs()
diagrams = make_diagrams()
output = build_document(diagrams)
print(output)