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//config.yaml\ndata/backend/agents//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:@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//,通常包含 config.yaml 与 SOUL.md;名称、所有者、发布状态、广场等信息在业务数据库中。因此备份或迁移智能体必须同时覆盖 data/backend 和数据库。") add_h2(doc, "9.1 标准目录与职责") add_code(doc, """ data/backend/agents// ├── 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= --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= 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://:@:3306/?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: api_key: $INTRANET_MODEL_API_KEY base_url: http://:/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/;还依赖数据库元数据"], ["发布广场", "资产所有者或 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)