CareerBot/DESIGN.md
ln0422 5472649e89 Update Gitea URL to https://git.ityb.me
- DESIGN.md: update Gitea URL in online addresses table
- OPS_MANUAL.md: update all Gitea references, add gitea.conf section,
  update service architecture diagram

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 15:06:37 +08:00

746 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CareerBot 项目设计文档
## 1. 项目概述
CareerBot 是一个个人职业展示网站,集成了 AI 智能对话助手。主要面向两类用户:
- **访问者(招聘方)**:浏览候选人的职业背景,通过 AI 对话深入了解候选人,上传 JD 进行匹配分析
- **管理员(候选人)**:管理个人资料、教育/工作经历、技能等内容,配置 LLM管理访问令牌查看招聘意向消息生成定制简历
### 线上地址
| 用途 | 地址 |
|------|------|
| 主页 | `http://www.ityb.me/careerbot/` |
| 管理后台 | `http://www.ityb.me/careerbot/admin/login` |
| Gitea 代码仓库 | `https://git.ityb.me` |
### 技术栈
| 层级 | 技术 |
|------|------|
| 后端框架 | Python / FastAPI |
| 模板引擎 | Jinja2 |
| 数据库 | SQLite + SQLAlchemy ORM |
| LLM 集成 | OpenAI 兼容 API (httpx) |
| 实时通信 | Server-Sent Events (SSE) |
| 认证 | JWT (管理员) / Cookie (访问者) |
| 密码加密 | bcrypt |
| 文件处理 | python-docx, PyPDF2, Pillow |
| 反向代理 | Nginx (子路径部署) |
---
## 2. 项目结构
```
CareerBot/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI 应用入口,路由挂载,启动初始化
│ ├── config.py # Pydantic Settings 配置(含 BASE_PATH
│ ├── database.py # SQLAlchemy 引擎、Session、init_db
│ ├── models.py # 全部 ORM 模型定义
│ ├── routers/
│ │ ├── auth.py # 认证:管理员登录、访问令牌验证、匿名访问
│ │ ├── public.py # 公开页面路由和数据 API
│ │ ├── admin.py # 管理后台 CRUD、简历生成、消息管理
│ │ └── chat.py # 聊天 API对话、历史、招聘意图检测
│ ├── services/
│ │ ├── chat_service.py # 聊天业务逻辑:构建 prompt、处理消息
│ │ ├── llm_service.py # LLM 调用封装:流式/非流式、视觉降级
│ │ └── file_parser.py # 文件解析txt/docx/pdf/image
│ └── static/
│ ├── css/style.css # 全局样式
│ └── js/
│ ├── main.js # 主页数据加载(个人信息、技能、经历)
│ ├── chat.js # 聊天组件SSE 流式、提问限制、匿名模式)
│ └── admin.js # 管理后台 CRUD 操作、消息管理
├── templates/
│ ├── index.html # 主页(职业展示 + 聊天组件)
│ ├── login.html # 访问者登录/匿名入口
│ └── admin/
│ ├── login.html # 管理员登录
│ ├── dashboard.html # 仪表盘
│ ├── profile.html # 个人资料管理
│ ├── education.html # 教育经历管理
│ ├── experience.html # 工作经历管理
│ ├── skills.html # 技能管理
│ ├── tokens.html # 访问令牌管理
│ ├── messages.html # 招聘消息查看
│ ├── llm-config.html # LLM 配置
│ └── resume.html # 简历生成
├── init_data.py # 数据库初始化种子数据
├── requirements.txt # Python 依赖
├── start.bat # 启动脚本
└── stop.bat # 停止脚本
```
---
## 3. 系统架构图
```
┌─────────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ index.html │ │ login.html │ │ admin/*.html │ │
│ │ main.js │ │ (Token/匿名入口) │ │ admin.js │ │
│ │ chat.js │ │ │ │ │ │
│ └──────┬───────┘ └────────┬─────────┘ └────────┬─────────┘ │
└─────────┼──────────────────┼──────────────────────┼─────────────┘
│ HTTP/SSE │ HTTP │ HTTP
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ FastAPI Application │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌──────────┐ │
│ │ public.py │ │ auth.py │ │ chat.py │ │ admin.py │ │
│ │ 页面路由 │ │ 认证鉴权 │ │ 聊天API │ │ 管理CRUD │ │
│ │ 数据API │ │ JWT/Cookie │ │ SSE流式 │ │ 简历生成 │ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └────┬─────┘ │
│ │ │ │ │ │
│ │ ┌──────┴──────────┐ │ │ │
│ │ │ get_current_ │ │ │ │
│ │ │ admin/visitor │◄───┤ │ │
│ │ └─────────────────┘ │ │ │
│ │ │ │ │
│ ┌─────┴───────────────────────────────┴──────────────┴─────┐ │
│ │ Services Layer │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │chat_service │ │ llm_service │ │ file_parser │ │ │
│ │ │System Prompt │ │ OpenAI API │ │ txt/docx/pdf │ │ │
│ │ │消息处理/历史 │─▶│ 流式/非流式 │ │ image/base64 │ │ │
│ │ └──────────────┘ └──────┬───────┘ └──────────────┘ │ │
│ └───────────────────────────┼──────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┼──────────────────────────────┐ │
│ │ SQLAlchemy ORM │ │
│ │ Profile │ Skill │ Education │ WorkExperience │ AdminUser │ │
│ │ AccessToken │ LLMConfig │ ChatHistory │ RecruiterMessage │ │
│ └───────────────────────────┼──────────────────────────────┘ │
└──────────────────────────────┼──────────────────────────────────┘
┌──────────▼──────────┐
│ SQLite (WAL模式) │
│ careerbot.db │
└─────────────────────┘
│ httpx (异步)
┌──────────▼──────────┐
│ LLM API Provider │
│ (DeepSeek/Qwen/ │
│ OpenAI/Zhipu) │
└─────────────────────┘
```
### 3.1 子路径部署架构BASE_PATH
CareerBot 部署在域名的 `/careerbot` 子路径下,通过 `BASE_PATH` 配置实现全局路径前缀管理:
```
浏览器请求: http://www.ityb.me/careerbot/api/profile
Nginx (:80)
location /careerbot/ → proxy_pass http://127.0.0.1:8000/careerbot/
FastAPI (uvicorn :8000)
app.include_router(public.router, prefix="/careerbot")
→ 匹配路由: @router.get("/api/profile")
→ 实际路径: /careerbot/api/profile
```
**前缀注入机制:**
| 层级 | 方式 | 说明 |
|------|------|------|
| Python 路由 | `app.include_router(prefix=BASE)` | 所有路由自动加前缀,路由装饰器不变 |
| 静态文件 | `app.mount(f"{BASE}/static", ...)` | CSS/JS/图片通过 `/careerbot/static/` 访问 |
| Jinja2 模板 | `templates.env.globals["base"]` | 模板中用 `{{ base }}` 引用前缀 |
| 前端 JS | `window.BASE_PATH` | 在模板中注入JS 文件通过全局变量使用 |
| RedirectResponse | `url=f"{BASE}/login"` | Python 重定向使用完整路径 |
| DB 存储路径 | `photo_url = f"{BASE}/uploads/..."` | 持久化数据中的 URL 包含前缀 |
**配置位置:**
- `app/config.py``BASE_PATH: str = "/careerbot"`
- 修改此值即可将应用迁移到其他子路径
---
## 4. 数据模型(类图)
```
┌─────────────────────┐ ┌─────────────────────────┐
│ Profile │ │ AdminUser │
├─────────────────────┤ ├─────────────────────────┤
│ id: int (PK) │ │ id: int (PK) │
│ name: str │ │ email: str (unique) │
│ phone: str │ │ password_hash: str │
│ location: str │ └─────────────────────────┘
│ birthday: str │
│ party: str │ ┌─────────────────────────┐
│ education_level: str│ │ AccessToken │
│ email: str │ ├─────────────────────────┤
│ photo_url: str? │ │ id: int (PK) │
│ self_summary: text │ │ token: str (unique) │
└─────────────────────┘ │ created_at: datetime │
│ is_active: bool │
┌─────────────────────┐ │ note: str? │
│ Skill │ │ max_questions: int [=9] │
├─────────────────────┤ │ used_questions: int [=0] │
│ id: int (PK) │ └─────────────────────────┘
│ category: str │
│ content: text │ ┌─────────────────────────┐
│ sort_order: int │ │ LLMConfig │
└─────────────────────┘ ├─────────────────────────┤
│ id: int (PK) │
┌─────────────────────┐ │ api_url: str │
│ Education │ │ api_key: str │
├─────────────────────┤ │ model_name: str │
│ id: int (PK) │ │ max_tokens: int [=2048] │
│ start_date: str │ │ temperature: float [=0.7]│
│ end_date: str │ │ system_prompt: text? │
│ school: str │ │ is_active: bool │
│ major: str │ │ max_questions_per_session│
│ degree: str │ └─────────────────────────┘
│ details: text │
│ sort_order: int │ ┌─────────────────────────┐
└─────────────────────┘ │ ChatHistory │
├─────────────────────────┤
┌─────────────────────┐ │ id: int (PK) │
│ WorkExperience │ │ session_id: str (索引) │
├─────────────────────┤ │ role: str │
│ id: int (PK) │ │ content: text │
│ start_date: str │ │ created_at: datetime │
│ end_date: str │ └─────────────────────────┘
│ company: str │
│ position: str │ ┌─────────────────────────┐
│ company_intro: text │ │ RecruiterMessage │
│ responsibilities: t │ ├─────────────────────────┤
│ achievements: text │ │ id: int (PK) │
│ sort_order: int │ │ session_id: str (索引) │
└─────────────────────┘ │ visitor_label: str │
│ company: str │
│ contact: str │
│ intent: str │
│ summary: text │
│ is_read: bool │
│ created_at: datetime │
└─────────────────────────┘
```
**模型分组关系:**
```
候选人内容数据: Profile ──┬── Skill (1:N)
├── Education (1:N)
└── WorkExperience (1:N)
访问控制: AdminUser (管理员)
AccessToken (访问者令牌) ── 关联 max_questions / used_questions
AI 配置: LLMConfig (LLM连接配置 + 默认提问次数)
对话数据: ChatHistory (按 session_id 分组)
RecruiterMessage (招聘意图检测结果)
```
---
## 5. 认证与访问控制
### 5.1 三种访问身份
```
┌───────────────┬───────────────────┬──────────────┬──────────────┐
│ 身份 │ 认证方式 │ 浏览内容 │ 对话限制 │
├───────────────┼───────────────────┼──────────────┼──────────────┤
│ 管理员 │ JWT (Bearer Token)│ 管理后台 │ 无限制 │
│ Token 访问者 │ Cookie (visitor_ │ 主页+对话 │ 按token配额 │
│ │ token = 实际token)│ │ (默认9次) │
│ 匿名访问者 │ Cookie (visitor_ │ 主页+对话 │ 3次 │
│ │ token=__anonymous__)│ │ │
└───────────────┴───────────────────┴──────────────┴──────────────┘
```
### 5.2 访问者认证流程
```
访问 / (主页)
有 visitor_token Cookie?
┌────┴────┐
│ No │ Yes
▼ ▼
重定向 token == "__anonymous__"?
/login │
│ ┌────┴────┐
▼ │ Yes │ No
登录页 │ ▼
│ │ DB查询token有效?
│ │ │
│ │ ┌────┴────┐
│ │ │ Yes │ No
│ │ │ ▼
│ │ │ 重定向 /login
│ ▼ ▼
│ 渲染 index.html
├─ 输入Token → POST /api/verify-token → set cookie → 跳转 /
└─ 匿名访问 → POST /api/anonymous-entry → set cookie(__anonymous__) → 跳转 /
```
### 5.3 管理员认证
- **登录**: `POST /api/admin/login` → 验证 email + bcrypt 密码 → 返回 JWT
- **鉴权**: 所有 `/api/admin/*` 接口通过 `get_current_admin` 依赖校验 JWT
- **JWT 存储**: 前端 `localStorage.admin_token`,请求时放入 `Authorization: Bearer` Header
- **有效期**: 480 分钟8 小时)
---
## 6. 核心流程
### 6.1 智能对话流程
```
用户发送消息 (chat.js)
│ POST /api/chat (FormData: session_id, message, file?)
┌─── chat.py ─────────────────────────────────────────┐
│ │
│ 1. 身份识别: get_current_visitor │
│ ├─ AccessToken记录 → 检查 used_questions < max │
│ └─ None (匿名) → 查 ChatHistory 计数 < 3 │
│ │
│ 2. 超限 → 返回限制提示的 SSE │
│ 未超限 → 继续 │
│ │
│ 3. Token用户: used_questions += 1, db.commit() │
│ │
│ 4. 调用 chat_service.process_message() │
│ │ │
│ ┌──▼── chat_service.py ──────────────────────┐ │
│ │ │ │
│ │ a. 解析上传文件 (file_parser) │ │
│ │ ├─ txt/docx/pdf → 提取文本 │ │
│ │ └─ 图片 → base64编码 │ │
│ │ │ │
│ │ b. 查询最近20条 ChatHistory │ │
│ │ │ │
│ │ c. 构建 messages: │ │
│ │ [system_prompt, ...history, user_msg] │ │
│ │ │ │
│ │ d. 调用 llm_service.chat_completion_stream │ │
│ │ │ │
│ │ e. 保存 user/assistant 消息到 ChatHistory │ │
│ │ (错误消息不保存) │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ 5. SSE 流式返回 → chat.js 逐块渲染 │
│ │
│ 6. BackgroundTask: 招聘意图检测 │
│ (独立DB Session, 不影响主流程) │
└──────────────────────────────────────────────────────┘
```
### 6.2 LLM 调用流程(含视觉降级)
```
chat_completion_stream(messages, db)
读取 LLMConfig (is_active=True)
├─ 无配置 → yield "AI助手尚未配置"
构建请求:
URL: {api_url}/v1/chat/completions
Headers: Authorization: Bearer {api_key}
Body: { model, messages, max_tokens, temperature, stream: true }
发送流式请求 (httpx.AsyncClient.stream)
┌────┴────────────────┐
│ 成功 │ 400 错误 且 消息含图片
│ │
▼ ▼
逐块 yield 视觉降级处理:
content _strip_images(messages)
├─ 移除 image_url 内容
├─ 保留文本部分
└─ 追加提示: "模型不支持图片, 请改用txt/docx/pdf"
重试 (不含图片的messages)
┌────┴────┐
│ 成功 │ 失败
▼ ▼
逐块 yield 错误信息
yield
```
### 6.3 招聘意图检测流程
```
聊天 SSE 流结束
▼ BackgroundTask (独立DB Session)
_check_recruiter_intent()
构建检测 prompt:
System: "你是一个精确的信息提取助手只输出JSON格式。"
User: "分析以下对话,判断访问者是否表达了:
1.招聘意愿 2.面试意愿 3.公司/联系方式"
+ 用户消息(截取500字) + AI回复(截取500字)
调用 chat_completion (非流式) → LLM 返回 JSON
解析 JSON:
{"detected": true/false, "intent": "...",
"company": "...", "contact": "...", "summary": "..."}
┌────┴────┐
│detected │ not detected
│= true │
▼ ▼
创建 忽略
RecruiterMessage
记录到DB
管理后台 Messages 页面显示
侧边栏红色未读数徽章
```
### 6.4 简历生成流程
```
管理员上传 JD (文件或文本)
▼ POST /api/admin/resume/generate
解析 JD 内容 (file_parser)
从 DB 读取候选人全部资料:
Profile + Skills + Education + WorkExperience
构建 messages:
System: "你是专业简历撰写顾问..." (详见 §7.3)
User: "=== JD === ... === 候选人背景 === ..."
SSE 流式生成 Markdown 格式简历
前端渲染 Markdown 预览
▼ POST /api/admin/resume/download
Markdown → Word 文档 (python-docx)
- 解析 #/##/### 标题
- 解析 -/* 列表
- 解析 **粗体**
- 设置页边距、对齐
下载 resume.docx
```
---
## 7. LLM 集成详细设计
### 7.1 架构设计
CareerBot 采用 **OpenAI 兼容 API 格式** 调用 LLM通过 `httpx` 发送 HTTP 请求,**不使用官方 SDK**。这样可以灵活切换国内外各种兼容 OpenAI 格式的 LLM 提供商。
```
┌──────────────────────────────────────────────────┐
│ llm_service.py │
│ │
│ get_active_config(db) ← 从DB读取LLM配置 │
│ │ │
│ ▼ │
│ _build_url_and_headers() │
│ URL自动补全: {base_url}/v1/chat/completions │
│ Headers: Authorization: Bearer {api_key} │
│ │ │
│ _build_body() │
│ { model, messages, max_tokens, temperature } │
│ │ │
│ ┌────┴────────────────┐ │
│ │ │ │
│ ▼ ▼ │
│ chat_completion chat_completion_stream │
│ (非流式,同步返回) (流式,AsyncGenerator) │
│ │ │ │
│ │ ┌────────────────┤ │
│ │ │ 视觉降级逻辑 │ │
│ │ │ _has_image_content() 检测 │
│ │ │ _strip_images() 移除图片 │
│ │ │ 自动重试 │
│ │ └────────────────┘ │
└──────┴──────────────────────────────────────────┘
```
**支持的 LLM 提供商(管理后台预设按钮):**
| 提供商 | Base URL | 默认模型 |
|--------|----------|----------|
| 通义千问 | `https://dashscope.aliyuncs.com/compatible-mode` | qwen-plus |
| DeepSeek | `https://api.deepseek.com` | deepseek-chat |
| 智谱 AI | `https://open.bigmodel.cn/api/paas` | glm-4-flash |
| OpenAI | `https://api.openai.com` | gpt-4o |
### 7.2 LLM 配置参数
在管理后台 **LLM Config** 页面配置,存储在 `llm_config` 表:
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `api_url` | LLM API 基础地址 | - |
| `api_key` | API 密钥 | - |
| `model_name` | 模型名称 | - |
| `max_tokens` | 单次最大生成 token 数 | 2048 |
| `temperature` | 生成随机性 (0~1) | 0.7 |
| `system_prompt` | 自定义额外系统指令 | 空 |
| `max_questions_per_session` | 新Token默认提问次数 | 9 |
### 7.3 Prompt 设计
#### 7.3.1 智能对话 System Prompt
`chat_service.build_system_prompt()` 动态构建,**每次对话请求实时从数据库读取**,确保内容始终最新。
**结构:**
```
你是{候选人姓名}的个人AI职业助手专门帮助招聘者了解{姓名}的职业背景、技能和工作经验。
=== 个人基本信息 ===
姓名: ... 学历: ... 所在地: ... 邮箱: ...
个人总结: ...
=== 技能特长 ===
- 类别1: 内容...
- 类别2: 内容...
=== 教育经历 ===
时间 | 学校 | 专业 | 学位
详情: ...
=== 工作经历 ===
时间 | 公司 | 职位
公司简介: ...
工作职责: ...
工作成就: ...
=== 回答规则 ===
1. 只回答与招聘、职业、工作能力、技术背景相关的问题
2. 对于无关问题,礼貌拒绝并引导回职业话题
3. 严格基于以上信息回答,不编造经历
4. 使用专业、友好的语气
5. 如果招聘者上传了JD详细分析岗位匹配度突出亮点/优势,客观指出不足
6. 根据招聘者使用的语言选择中文或英文回答
=== 额外指令 === ← 仅当管理员配置了自定义 system_prompt 时追加
{自定义内容}
```
#### 7.3.2 招聘意图检测 Prompt
`chat.py._check_recruiter_intent()` 使用,**非流式调用**,后台静默执行:
```
System: "你是一个精确的信息提取助手只输出JSON格式。"
User:
你是一个信息提取助手。分析以下对话,判断访问者是否表达了以下任何意愿:
1. 招聘意愿(想要招聘候选人)
2. 面试意愿(想要邀请候选人面试)
3. 留下了公司信息或联系方式
如果检测到以上任何一项请用以下JSON格式回复
{"detected": true, "intent": "...", "company": "...", "contact": "...", "summary": "..."}
如果没有检测到,回复:
{"detected": false}
访问者消息: {截取前500字}
AI助手回复: {截取前500字}
```
#### 7.3.3 简历生成 Prompt
`admin.py.generate_resume()` 使用,**流式调用**
```
System:
你是一位专业的简历撰写顾问。请根据提供的JD和候选人的完整背景信息
生成一份针对该职位量身定制的中文简历。
要求:
1. 必须包含候选人的全部教育经历和工作经历,不得遗漏
2. "个人优势"和"自我评价"部分要针对JD定制
3. 每段工作经历重点突出与JD相关的职责和成就
4. 技能部分优先展示JD要求的技能
5. 使用专业、简洁的语言
6. 输出格式使用Markdown
简历结构:
# 个人信息
# 求职意向根据JD推断
# 个人优势3-5条针对JD定制
# 核心技能
# 工作经历(全部,按时间倒序)
# 教育背景(全部,按时间倒序)
User:
=== 目标职位描述(JD) ===
{JD内容}
=== 候选人完整背景 ===
{从DB读取的全部资料}
```
### 7.4 LLM 能力说明
> **当前版本没有为 LLM 配置 Function Calling / Tool Use 能力。**
LLM 在本项目中扮演的角色是**纯文本生成**,有三个使用场景:
| 场景 | 调用方式 | 输入 | 输出 |
|------|---------|------|------|
| 智能对话 | 流式 (SSE) | system prompt + 对话历史 + 用户消息(可含文件文本) | 自然语言回复 |
| 招聘意图检测 | 非流式 | 当轮对话(用户+AI各500字) | JSON 结构化数据 |
| 简历生成 | 流式 (SSE) | JD + 候选人全部资料 | Markdown 格式简历 |
**没有使用的 LLM 高级能力:**
- Function Calling / Tool Use
- RAG (检索增强生成)
- Agent 多步推理
- 图片生成
**已实现的降级策略:**
- 视觉模型不可用时自动降级:检测到 400 错误 + 消息含图片 → 移除图片内容 → 用文本重试 → 提示用户改用 txt/docx/pdf 格式
---
## 8. 提问次数限制机制
```
┌─────────────────────────────────────────────────┐
│ 提问次数限制 │
├─────────────────────────────────────────────────┤
│ │
│ Token 用户: │
│ 限制存储: AccessToken.max_questions (默认9) │
│ 已用计数: AccessToken.used_questions │
│ 检查方式: 服务端每次请求 check + increment │
│ 持久性: 跨会话、跨页面刷新持久有效 │
│ 管理: 管理后台可修改 max/used可重置 │
│ │
│ 匿名用户: │
│ 限制: 固定 3 次 (ANONYMOUS_MAX_QUESTIONS) │
│ 计数: 服务端查 ChatHistory 中 session_id 的 │
│ user 消息数量 │
│ 持久性: 绑定 session_id (sessionStorage) │
│ │
│ 默认值来源: │
│ 新Token的 max_questions │
│ ← LLMConfig.max_questions_per_session │
│ ← 若未配置则 fallback 到 9 │
│ │
└─────────────────────────────────────────────────┘
```
---
## 9. SSE 流式通信协议
前后端通过 **Server-Sent Events** 实现流式对话,使用 `POST` + `fetch ReadableStream`(非 EventSource因为需要 POST 发送 FormData
### 数据格式
```
data: {"content": "回复文本块..."} ← 正常内容块
data: {"content": "...", "limit_reached": true} ← 超限提示
data: {"content": "", "done": true} ← 结束标记
```
### 前端处理逻辑 (chat.js)
```javascript
// 0. BASE_PATH 由模板注入: window.BASE_PATH = "{{ base }}"
const BASE_PATH = window.BASE_PATH || '';
// 1. POST FormData (session_id + message + file?)
const response = await fetch(BASE_PATH + '/api/chat', { method: 'POST', body: formData });
// 2. 读取 ReadableStream
const reader = response.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
// 3. 解析 SSE 行,提取 data: JSON
// 4. 逐块拼接到气泡元素的 textContent
}
```
---
## 10. 文件处理能力
`file_parser.py` 支持以下格式:
| 格式 | 处理方式 | 返回 | 大小限制 |
|------|---------|------|---------|
| `.txt` | 多编码尝试 (utf-8 → gbk → gb2312 → utf-16) | `{"text": "..."}` | 10MB |
| `.docx` | python-docx 提取段落文本 | `{"text": "..."}` | 10MB |
| `.pdf` | PyPDF2 逐页提取文本 | `{"text": "..."}` | 10MB |
| `.png/.jpg/.gif/.webp` | base64 编码 | `{"base64_image": "...", "mime_type": "..."}` | 10MB |
| 其他 | - | `{"error": "不支持的文件格式"}` | - |
---
## 11. 运维脚本
### start.bat
1. 检查端口 8000 是否被占用
2. 首次运行自动执行 `init_data.py` 初始化数据库
3. 启动 `uvicorn --host 0.0.0.0 --port 8000 --reload`
### stop.bat
1. 查找占用端口 8000 的所有进程
2. 逐个终止进程
---
## 12. 关键设计决策
| 决策 | 原因 |
|------|------|
| 直接使用 bcrypt 而非 passlib | passlib 与 bcrypt 5.0 存在不兼容问题 |
| httpx 而非 openai SDK | 灵活支持国内 LLM 厂商,无需依赖特定 SDK |
| SQLite + WAL | 轻量部署WAL 模式支持并发读取 |
| 无 Alembic 迁移 | 项目初期用 `create_all` 自动建表,新增列手动 `ALTER TABLE` |
| Jinja2 < 3.1.6 | 3.1.6 存在模板缓存 `unhashable dict` bug |
| POST + ReadableStream 而非 EventSource | EventSource 仅支持 GET无法发送 FormData 附件 |
| BackgroundTask 做意图检测 | 避免阻塞 SSE 流或导致连接异常 |
| 意图检测用独立 DB Session | BackgroundTask 执行时请求级 Session 已关闭 |
| FAB 按钮用内层 span flex | Chrome button 元素的 flex 布局有渲染 bug |
| 子路径部署 + BASE_PATH | 支持同域名多项目部署一处配置全局生效 |
| Jinja2 globals + window.BASE_PATH | 模板和静态 JS 统一获取路径前缀避免硬编码 |