Skip to content

Commit 5894231

Browse files
committed
Merge branch 'main' of github.com:xerrors/Yuxi-Know
2 parents f8cee20 + 5871def commit 5894231

37 files changed

+706
-318
lines changed

.env.template

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,6 @@ NEO4J_PASSWORD=
4141
# Servies
4242
YUXI_SUPER_ADMIN_NAME=
4343
YUXI_SUPER_ADMIN_PASSWORD=
44+
45+
# MinerU
46+
MINERU_API_KEY=

docs/changelog/roadmap.md

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,31 @@
22

33
路线图可能会经常变更,如果有强烈的建议,可以在 [issue](https://github.com/xerrors/Yuxi-Know/issues) 中提。
44

5-
## Version 0.3.x
5+
- [2025/10/13] 0.3.0 进入 beta 测试环节,不会再封装新的特性,仅作 bug 层面的修复
66

77

8-
🐛**BUGs**
9-
- [x] 部分 doc 格式的文件支持有问题
10-
- [x] 当出现不支持的文件类型的时候,前端没有限制
11-
- [x] 当消息生成的时候有报错的时候,前端无显示
12-
- [x] 另外一个智能体的历史对话无法显示
8+
**Bugs**
139
- [ ] 调用统计的统计结果疑似有问题(Token 计算方法可能也不对)
1410

1511

16-
---
12+
**Next**
1713

18-
💭 **Features Todo**
19-
20-
- [ ] 添加对于上传文件的支持:这里的复杂的地方就在于如何和历史记录结合在一起(v0.2.3 版本实现,放在记忆管理后面)
21-
- [ ] 知识图谱的上传和可视化,支持属性,标签的展示
14+
- [ ] 添加对于上传文件的支持
15+
- [ ] 知识图谱的上传和可视化,支持属性,标签的展示 <Badge type="info" text="0.4" />
2216
- [ ] 集成智能体评估,首先使用命令行来实现,然后考虑放在 UI 里面展示
23-
- [ ] 开发与生产环境隔离
24-
- [x] 添加统计信息
25-
- [ ] 支持 MinerU 2.5 的解析方法
26-
- [x] 移除自定义模型,同时将模型的 env 修改为单环境变量的形式,移除 models.yaml 的私有共有双配置模式,除非配置了环境变量 `OVERRIDE_DEFAULT_MODELS_CONFIG_WITH` 然后指向一个变量。
17+
- [ ] 开发与生产环境隔离,构建生产镜像 <Badge type="info" text="0.4" />
18+
- [ ] 支持 MinerU 2.5 的解析方法(含API方法)<Badge type="info" text="0.3.5" />
19+
- [ ] 优化全局配置的管理模型,优化配置管理
2720

28-
📝 **Base**
21+
**Later**
2922

30-
- [ ] 优化全局配置的管理模型,以及配置文件的管理,子配置的管理
31-
- [x] 新建 tasker 模块,用来管理所有的后台任务,UI 上使用侧边栏管理。
32-
- [ ] 新增 files 模块,用来管理文件上传,下载等
23+
下面的功能**可能**会放在后续版本实现,暂时未定
3324

34-
## 未来可能会支持
25+
- [ ] 集成 LangFuse (观望) 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息
26+
27+
**Done**
3528

36-
下面的功能**可能**会放在后续版本实现,暂时未定
3729

3830
- [x] 添加测试脚本,覆盖最常见的功能(已覆盖API)
39-
- [x] 优化对文档信息的检索展示(检索结果页、详情页)
40-
- [ ] 集成 LangFuse (观望)添加用户日志与用户反馈模块,可以在 AgentView 中查看信息
31+
- [x] 新建 tasker 模块,用来管理所有的后台任务,UI 上使用侧边栏管理。
32+
- [x] 优化对文档信息的检索展示(检索结果页、详情页)

docs/intro/quick-start.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@
1414

1515
```bash
1616
# 克隆稳定版本
17-
git clone --branch 0.2.2 --depth 1 https://github.com/xerrors/Yuxi-Know.git
17+
git clone --branch v0.2.2 --depth 1 https://github.com/xerrors/Yuxi-Know.git
1818
cd Yuxi-Know
1919
```
2020

2121
::: warning 版本说明
22-
- `0.2.2`: 当前稳定版本(推荐)
22+
- `v0.2.2`: 当前稳定版本(推荐)
23+
- `v0.3.0-beta`:最新的 Beta 测试版
2324
- `main`: 最新开发版本(不稳定,新特性可能会导致新 bug)
2425
:::
2526

server/main.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import asyncio
2+
import time
3+
from collections import defaultdict, deque
4+
15
import uvicorn
2-
from fastapi import FastAPI, Request
6+
from fastapi import FastAPI, Request, status
37
from fastapi.middleware.cors import CORSMiddleware
8+
from fastapi.responses import JSONResponse
49
from starlette.middleware.base import BaseHTTPMiddleware
510

611
from server.routers import router
@@ -11,6 +16,14 @@
1116
# 设置日志配置
1217
setup_logging()
1318

19+
RATE_LIMIT_MAX_ATTEMPTS = 10
20+
RATE_LIMIT_WINDOW_SECONDS = 60
21+
RATE_LIMIT_ENDPOINTS = {("/api/auth/token", "POST")}
22+
23+
# In-memory login attempt tracker to reduce brute-force exposure per worker
24+
_login_attempts: defaultdict[str, deque[float]] = defaultdict(deque)
25+
_attempt_lock = asyncio.Lock()
26+
1427
app = FastAPI()
1528
app.include_router(router, prefix="/api")
1629

@@ -24,6 +37,51 @@
2437
)
2538

2639

40+
def _extract_client_ip(request: Request) -> str:
41+
forwarded_for = request.headers.get("x-forwarded-for")
42+
if forwarded_for:
43+
return forwarded_for.split(",")[0].strip()
44+
if request.client:
45+
return request.client.host
46+
return "unknown"
47+
48+
49+
class LoginRateLimitMiddleware(BaseHTTPMiddleware):
50+
async def dispatch(self, request: Request, call_next):
51+
normalized_path = request.url.path.rstrip("/") or "/"
52+
request_signature = (normalized_path, request.method.upper())
53+
54+
if request_signature in RATE_LIMIT_ENDPOINTS:
55+
client_ip = _extract_client_ip(request)
56+
now = time.monotonic()
57+
58+
async with _attempt_lock:
59+
attempt_history = _login_attempts[client_ip]
60+
61+
while attempt_history and now - attempt_history[0] > RATE_LIMIT_WINDOW_SECONDS:
62+
attempt_history.popleft()
63+
64+
if len(attempt_history) >= RATE_LIMIT_MAX_ATTEMPTS:
65+
retry_after = int(max(1, RATE_LIMIT_WINDOW_SECONDS - (now - attempt_history[0])))
66+
return JSONResponse(
67+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
68+
content={"detail": "登录尝试过于频繁,请稍后再试"},
69+
headers={"Retry-After": str(retry_after)},
70+
)
71+
72+
attempt_history.append(now)
73+
74+
response = await call_next(request)
75+
76+
if response.status_code < 400:
77+
async with _attempt_lock:
78+
_login_attempts.pop(client_ip, None)
79+
80+
return response
81+
82+
return await call_next(request)
83+
84+
2785
# 鉴权中间件
2886
class AuthMiddleware(BaseHTTPMiddleware):
2987
async def dispatch(self, request: Request, call_next):
@@ -58,6 +116,7 @@ async def dispatch(self, request: Request, call_next):
58116

59117

60118
# 添加鉴权中间件
119+
app.add_middleware(LoginRateLimitMiddleware)
61120
app.add_middleware(AuthMiddleware)
62121

63122

server/routers/auth_router.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from datetime import datetime
21
import re
32

43
from fastapi import APIRouter, Depends, HTTPException, Request, status, UploadFile, File
@@ -13,6 +12,7 @@
1312
from server.utils.user_utils import generate_unique_user_id, validate_username, is_valid_phone_number
1413
from server.utils.common_utils import log_operation
1514
from src.storage.minio import upload_image_to_minio
15+
from src.utils.datetime_utils import utc_now
1616

1717
# 创建路由器
1818
auth = APIRouter(prefix="/auth", tags=["authentication"])
@@ -151,7 +151,7 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(
151151

152152
# 登录成功,重置失败计数器
153153
user.reset_failed_login()
154-
user.last_login = datetime.now()
154+
user.last_login = utc_now()
155155
db.commit()
156156

157157
# 生成访问令牌
@@ -220,7 +220,7 @@ async def initialize_admin(admin_data: InitializeAdmin, db: Session = Depends(ge
220220
avatar=None, # 初始化时头像为空
221221
password_hash=hashed_password,
222222
role="superadmin",
223-
last_login=datetime.now(),
223+
last_login=utc_now(),
224224
)
225225

226226
db.add(new_admin)
@@ -527,7 +527,7 @@ async def delete_user(
527527
hash_suffix = hashlib.md5(user.user_id.encode()).hexdigest()[:4]
528528

529529
user.is_deleted = 1
530-
user.deleted_at = datetime.now()
530+
user.deleted_at = utc_now()
531531
user.username = f"已注销用户-{hash_suffix}"
532532
user.phone_number = None # 清空手机号,释放该手机号供其他用户使用
533533
user.password_hash = "DELETED" # 禁止登录

server/routers/chat_router.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,11 @@ async def stream_messages():
306306
else:
307307
yield make_chunk(msg=msg.model_dump(), metadata=metadata, status="loading")
308308

309-
if conf.enable_content_guard and hasattr(full_msg, "content") and await content_guard.check(full_msg.content):
309+
if (
310+
conf.enable_content_guard
311+
and hasattr(full_msg, "content")
312+
and await content_guard.check(full_msg.content)
313+
):
310314
logger.warning("Sensitive content detected in final message")
311315
yield make_chunk(message="检测到敏感内容,已中断输出", status="error")
312316
return

server/routers/dashboard_router.py

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from server.utils.auth_middleware import get_db
1717
from src.storage.conversation import ConversationManager
1818
from src.storage.db.models import User
19+
from src.utils.datetime_utils import UTC, ensure_shanghai, shanghai_now, utc_now
1920
from src.utils.logging_config import logger
2021

2122

@@ -231,7 +232,7 @@ async def get_user_activity_stats(
231232
try:
232233
from src.storage.db.models import User, Conversation
233234

234-
now = datetime.utcnow()
235+
now = utc_now()
235236

236237
# Conversations may store either the numeric user primary key or the login user_id string.
237238
# Join condition accounts for both representations.
@@ -305,7 +306,7 @@ async def get_tool_call_stats(
305306
try:
306307
from src.storage.db.models import ToolCall
307308

308-
now = datetime.utcnow()
309+
now = utc_now()
309310

310311
# 基础工具调用统计
311312
total_calls = db.query(func.count(ToolCall.id)).scalar() or 0
@@ -746,26 +747,30 @@ async def get_call_timeseries_stats(
746747
from src.storage.db.models import Conversation, Message, ToolCall
747748

748749
# 计算时间范围(使用北京时间 UTC+8)
749-
now = datetime.utcnow()
750+
now = utc_now()
751+
local_now = shanghai_now()
750752

751753
if time_range == "7hours":
752754
intervals = 7
753755
# 包含当前小时:从6小时前开始
754756
start_time = now - timedelta(hours=intervals - 1)
755-
# SQLite compatible approach: 使用datetime函数转换UTC时间为北京时间
756757
group_format = func.strftime("%Y-%m-%d %H:00", func.datetime(Message.created_at, "+8 hours"))
758+
base_local_time = ensure_shanghai(start_time)
757759
elif time_range == "7weeks":
758760
intervals = 7
759-
# 包含当前周:从6周前开始
760-
start_time = now - timedelta(weeks=intervals - 1)
761-
# SQLite compatible approach: 使用datetime函数转换UTC时间为北京时间
761+
# 包含当前周:从6周前开始,并对齐到当周周一 00:00
762+
local_start = local_now - timedelta(weeks=intervals - 1)
763+
local_start = local_start - timedelta(days=local_start.weekday())
764+
local_start = local_start.replace(hour=0, minute=0, second=0, microsecond=0)
765+
start_time = local_start.astimezone(UTC)
762766
group_format = func.strftime("%Y-%W", func.datetime(Message.created_at, "+8 hours"))
767+
base_local_time = local_start
763768
else: # 7days (default)
764769
intervals = 7
765770
# 包含当前天:从6天前开始
766771
start_time = now - timedelta(days=intervals - 1)
767-
# SQLite compatible approach: 使用datetime函数转换UTC时间为北京时间
768772
group_format = func.strftime("%Y-%m-%d", func.datetime(Message.created_at, "+8 hours"))
773+
base_local_time = ensure_shanghai(start_time)
769774

770775
# 根据类型查询数据
771776
if type == "models":
@@ -783,22 +788,23 @@ async def get_call_timeseries_stats(
783788
.order_by(group_format)
784789
)
785790
elif type == "agents":
786-
# 智能体调用统计(基于对话数量,按智能体分组)
791+
# 智能体调用统计(基于对话更新时间,按智能体分组)
787792
# 为对话创建独立的时间格式化器
788793
if time_range == "7hours":
789-
conv_group_format = func.strftime("%Y-%m-%d %H:00", func.datetime(Conversation.created_at, "+8 hours"))
794+
conv_group_format = func.strftime("%Y-%m-%d %H:00", func.datetime(Conversation.updated_at, "+8 hours"))
790795
elif time_range == "7weeks":
791-
conv_group_format = func.strftime("%Y-%W", func.datetime(Conversation.created_at, "+8 hours"))
796+
conv_group_format = func.strftime("%Y-%W", func.datetime(Conversation.updated_at, "+8 hours"))
792797
else: # 7days
793-
conv_group_format = func.strftime("%Y-%m-%d", func.datetime(Conversation.created_at, "+8 hours"))
798+
conv_group_format = func.strftime("%Y-%m-%d", func.datetime(Conversation.updated_at, "+8 hours"))
794799

795800
query = (
796801
db.query(
797802
conv_group_format.label("date"),
798803
func.count(Conversation.id).label("count"),
799804
Conversation.agent_id.label("category"),
800805
)
801-
.filter(Conversation.created_at >= start_time)
806+
.filter(Conversation.updated_at.isnot(None))
807+
.filter(Conversation.updated_at >= start_time)
802808
.group_by(conv_group_format, Conversation.agent_id)
803809
.order_by(conv_group_format)
804810
)
@@ -894,8 +900,16 @@ async def get_call_timeseries_stats(
894900

895901
# 重新组织数据:按时间点分组每个类别的数据
896902
time_data = {}
903+
904+
def normalize_week_key(raw_key: str) -> str:
905+
base_date = datetime.strptime(f"{raw_key}-1", "%Y-%W-%w")
906+
iso_year, iso_week, _ = base_date.isocalendar()
907+
return f"{iso_year}-{iso_week:02d}"
908+
897909
for result in results:
898910
date_key = result.date
911+
if time_range == "7weeks":
912+
date_key = normalize_week_key(date_key)
899913
category = getattr(result, "category", "unknown")
900914
count = result.count
901915

@@ -906,8 +920,8 @@ async def get_call_timeseries_stats(
906920

907921
# 填充缺失的时间点(使用北京时间)
908922
data = []
909-
# 从start_time开始,转换为北京时间
910-
current_time = start_time + timedelta(hours=8)
923+
# 从起始点开始(北京时间)
924+
current_time = base_local_time
911925

912926
if time_range == "7hours":
913927
delta = timedelta(hours=1)
@@ -920,9 +934,8 @@ async def get_call_timeseries_stats(
920934
if time_range == "7hours":
921935
date_key = current_time.strftime("%Y-%m-%d %H:00")
922936
elif time_range == "7weeks":
923-
# 计算ISO周数
924-
week_num = current_time.isocalendar()[1]
925-
date_key = f"{current_time.year}-{week_num:02d}"
937+
iso_year, iso_week, _ = current_time.isocalendar()
938+
date_key = f"{iso_year}-{iso_week:02d}"
926939
else:
927940
date_key = current_time.strftime("%Y-%m-%d")
928941

server/services/tasker.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,20 @@
33
import os
44
import uuid
55
from dataclasses import asdict, dataclass, field
6-
from datetime import datetime
76
from pathlib import Path
87
from typing import Any
98
from collections.abc import Awaitable, Callable
109

1110
from src.config import config
1211
from src.utils.logging_config import logger
12+
from src.utils.datetime_utils import utc_isoformat
1313

1414
TaskCoroutine = Callable[["TaskContext"], Awaitable[Any]]
1515
TERMINAL_STATUSES = {"success", "failed", "cancelled"}
1616

1717

1818
def _utc_timestamp() -> str:
19-
return datetime.utcnow().isoformat() + "Z"
19+
return utc_isoformat()
2020

2121

2222
@dataclass
@@ -142,7 +142,7 @@ async def list_tasks(self, status: str | None = None) -> list[dict[str, Any]]:
142142
tasks = list(self._tasks.values())
143143
if status:
144144
tasks = [task for task in tasks if task.status == status]
145-
tasks.sort(key=lambda item: item.created_at, reverse=True)
145+
tasks.sort(key=lambda item: item.created_at or utc_isoformat(), reverse=True)
146146
return [task.to_dict() for task in tasks]
147147

148148
async def get_task(self, task_id: str) -> dict[str, Any] | None:

0 commit comments

Comments
 (0)