1906 lines
103 KiB
Python
1906 lines
103 KiB
Python
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)
|