Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Linux 容器和 WSL 直接执行的脚本必须保持 LF,避免 Windows 检出后出现 ^M。
*.sh text eol=lf
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ cache

### IDE
.vscode
!/.vscode/
/.vscode/*
!/.vscode/tasks.json
.idea
.vibe
.qoder
Expand Down
35 changes: 35 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "语析:启动开发服务(WSL)",
"type": "process",
"command": "wsl.exe",
"args": ["-d", "Ubuntu", "--cd", "${workspaceFolder}", "--", "docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.wsl.yml", "up", "--build", "-d"],
"problemMatcher": [],
"presentation": { "reveal": "always", "panel": "dedicated" }
},
{
"label": "语析:查看服务状态(WSL)",
"type": "process",
"command": "wsl.exe",
"args": ["-d", "Ubuntu", "--cd", "${workspaceFolder}", "--", "docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.wsl.yml", "ps"],
"problemMatcher": []
},
{
"label": "语析:查看日志(WSL)",
"type": "process",
"command": "wsl.exe",
"args": ["-d", "Ubuntu", "--cd", "${workspaceFolder}", "--", "docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.wsl.yml", "logs", "--tail=100", "-f", "api", "worker", "web"],
"problemMatcher": [],
"presentation": { "reveal": "always", "panel": "dedicated" }
},
{
"label": "语析:停止服务并保留数据(WSL)",
"type": "process",
"command": "wsl.exe",
"args": ["-d", "Ubuntu", "--cd", "${workspaceFolder}", "--", "docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.wsl.yml", "stop"],
"problemMatcher": []
}
]
}
4 changes: 3 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ Yuxi 只交付完整知识能力路径。API 始终注册 `external_kb`、`knowl
- `composables` 封装请求排队、Run SSE、流式消息、审批、线程状态、提及和其他可组合逻辑。
- `utils` 放轻量转换和展示辅助;全局样式集中在 `assets/css`,颜色和基础规范优先复用 `base.css`。

`/` 是公开首页;登录后的核心工作区是 `/agent`。`/extensions` 对所有登录用户开放,其中 Skills 对普通用户可见,知识库、工具和 MCP 管理能力仅管理员可见;Dashboard 仅超级管理员可访问。后端权限检查始终是最终边界,前端守卫只负责页面体验。
`/` 是公开首页;登录后的核心工作区是 `/agent`。`/extensions` 对所有登录用户开放,其中 Skills 对普通用户可见,知识库对管理员及具备个人或团队知识业务能力的用户开放,工具和 MCP 管理能力仅管理员可见;Dashboard 仅超级管理员可访问。知识库页面按共享配置分为“团队/我的”:个人库复用创建者及空共享范围,仅所有者可访问;团队库按读取范围授权,辅导人员只读,业务管理员维护获授权范围内的库。后端权限检查始终是最终边界,前端守卫只负责页面体验。

`/students` 为辅导人员和业务管理员提供学生档案列表。业务管理员创建档案并指定初始负责人,负责人在详情中维护背景摘要与状态;部门和负责人可见性由后端档案接口执行。

## 智能体运行链路

Expand Down
1 change: 0 additions & 1 deletion CLAUDE.md

This file was deleted.

17 changes: 8 additions & 9 deletions backend/package/yuxi/config/static/info.template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,23 @@

# 组织信息
organization:
name: "语析" # 完整组织名称
name: "知伴" # 完整组织名称
logo: "/favicon.svg" # Logo文件路径(放在 web/public 目录下)
avatar: "/favicon.svg" # 头像文件路径(放在 web/public 目录下)
login_bg: "/login-bg.jpg" # 登录背景图片路径(放在 web/public 目录下)

# 项目信息
branding:
name: "Yuxi"
title: "让团队知识可连接,让智能体可行动" # 系统标题
subtitle: "知识库 × 知识图谱 × 智能体 Harness,开源且可私有部署" # 副标题(subtitles 为空时使用)
name: "知伴"
title: "知伴 · 心理辅导工作台" # 系统标题
subtitle: "从了解情况开始,让知识支持每一次辅导" # 副标题(subtitles 为空时使用)
subtitles:
- "知识与智能体真正协同,每个回答都可溯源"
- "从回答问题到交付任务,工具与沙盒一站完成"
- "多租户、权限与模型治理,面向团队而非 Demo"
- "Docker Compose 一键部署,数据不出你的内网"
- "从了解情况开始,让知识支持每一次辅导"
- "整理已有信息,逐步形成清晰的工作思路"
- "沉淀团队经验,为持续辅导提供参考"

# 页脚信息
footer:
copyright: "© 语析 2026 v{{YUXI_VERSION}}"
copyright: "© 知伴 2026 v{{YUXI_VERSION}}"
user_agreement_url: "/protocols/user-agreement.template.html"
privacy_policy_url: "/protocols/privacy-policy.template.html"
9 changes: 3 additions & 6 deletions backend/package/yuxi/knowledge/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,8 @@ def _normalize_share_config(
share_config,
strict=user_uid is not None or department_id is not None,
)
if normalized["read_scope"] is None and (user_uid is not None or department_id is not None):
raise ValueError("知识库必须设置读取范围")
if normalized["read_scope"] is None and normalized["manage_scope"] is not None:
raise ValueError("共享知识库必须设置读取范围")
read_scope = normalized["read_scope"]
if read_scope and read_scope["access_level"] == "department" and department_id is not None:
read_scope["department_ids"] = sorted({*read_scope["department_ids"], int(department_id)})
Expand Down Expand Up @@ -335,10 +335,6 @@ async def check_accessible(self, user: dict, kb_id: str) -> bool:
Returns:
bool: 是否有权限
"""
# 超级管理员有权访问所有
if user.get("role") == "superadmin":
return True

from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository

kb_repo = KnowledgeBaseRepository()
Expand Down Expand Up @@ -397,6 +393,7 @@ async def get_databases_by_user(self, user: User | dict) -> list[KnowledgeBaseSu
"uid": user.uid,
"role": user.role,
"department_id": user.department_id,
"business_roles": user.business_roles,
}

user_role = user_info.get("role")
Expand Down
23 changes: 23 additions & 0 deletions backend/package/yuxi/knowledge/utils/kb_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ async def prepare_item_metadata(item: str, content_type: str, kb_id: str, params
kb_id: 数据库ID
params: 处理参数,可选
"""
if content_type != "file":
raise ValueError(f"Unsupported content_type: {content_type}")
validate_uploaded_document_source(item, kb_id)

# 检查是否有预处理信息 (针对 URL 转 HTML 文件的情况)
if params and "_preprocessed_map" in params and item in params["_preprocessed_map"]:
pre_info = params["_preprocessed_map"][item]
Expand All @@ -83,6 +87,7 @@ async def prepare_item_metadata(item: str, content_type: str, kb_id: str, params

file_type = "html" # 强制转换为 html 类型,以便后续作为文件处理
item_path = pre_info["path"] # MinIO path
validate_uploaded_document_source(item_path, kb_id)
content_hash = pre_info["content_hash"]

# 使用 item(url) 生成 ID,保证同一 URL 即使多次添加 ID 也不同(配合 time)
Expand Down Expand Up @@ -168,6 +173,24 @@ async def prepare_item_metadata(item: str, content_type: str, kb_id: str, params
return metadata


def validate_uploaded_document_source(source: str, kb_id: str) -> None:
"""在文件元数据落库前限制来源为当前知识库的上传对象。"""
from yuxi.storage.minio.client import MinIOClient

if not isinstance(source, str) or not is_minio_url(source):
raise ValueError("File source must be a MinIO URL")
bucket_name, object_name = parse_minio_url(source)
prefix = f"{kb_id}/upload/"
if (
bucket_name != MinIOClient.KB_BUCKETS["documents"]
or not object_name.startswith(prefix)
or not object_name.removeprefix(prefix)
or "\\" in object_name
or any(part in {".", "..", ""} for part in object_name.split("/"))
):
raise ValueError("文件来源必须属于当前知识库的上传目录")


def _normalize_source_path(value: object) -> str | None:
"""归一化客户端传入的上传源路径,仅用于知识库文件树中的展示文件名。

Expand Down
18 changes: 18 additions & 0 deletions backend/package/yuxi/permissions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
"""跨资源权限能力。"""

from yuxi.permissions.business_roles import (
BUSINESS_ROLE_CAPABILITIES,
BusinessCapability,
BusinessRole,
default_business_roles_for_platform_role,
normalize_business_roles,
resolve_business_capabilities,
resolve_business_roles,
)
from yuxi.permissions.resource_permission import (
AGENT_PERMISSION_POLICY,
KNOWLEDGE_BASE_PERMISSION_POLICY,
SKILL_PERMISSION_POLICY,
ResourcePermission,
ResourcePermissionDenied,
is_personal_knowledge_base,
normalize_permission_config,
require_knowledge_base_permission,
require_resource_permission,
Expand All @@ -18,14 +28,22 @@

__all__ = [
"AGENT_PERMISSION_POLICY",
"BUSINESS_ROLE_CAPABILITIES",
"KNOWLEDGE_BASE_PERMISSION_POLICY",
"SKILL_PERMISSION_POLICY",
"BusinessCapability",
"BusinessRole",
"ResourcePermission",
"ResourcePermissionDenied",
"is_personal_knowledge_base",
"normalize_permission_config",
"default_business_roles_for_platform_role",
"normalize_business_roles",
"require_knowledge_base_permission",
"require_resource_permission",
"resolve_agent_permission",
"resolve_business_capabilities",
"resolve_business_roles",
"resolve_knowledge_base_permission",
"resolve_resource_permission",
"resolve_skill_permission",
Expand Down
93 changes: 93 additions & 0 deletions backend/package/yuxi/permissions/business_roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""知伴业务角色及其能力映射。"""

from __future__ import annotations

from collections.abc import Iterable, Mapping
from enum import StrEnum
from typing import Any


class BusinessRole(StrEnum):
"""第一版固定业务角色。"""

COUNSELOR = "counselor"
BUSINESS_ADMIN = "business_admin"
TECHNICAL_ADMIN = "technical_admin"


class BusinessCapability(StrEnum):
"""后续业务入口使用的最小授权能力。"""

MANAGE_ASSIGNED_STUDENTS = "students.manage_assigned"
ASSIGN_STUDENTS = "students.assign"
MANAGE_PERSONAL_KNOWLEDGE = "knowledge.personal.manage"
READ_AUTHORIZED_TEAM_KNOWLEDGE = "knowledge.team.read_authorized"
MANAGE_TEAM_KNOWLEDGE = "knowledge.team.manage"
MANAGE_SYSTEM = "system.manage"


BUSINESS_ROLE_ORDER = (
BusinessRole.COUNSELOR,
BusinessRole.BUSINESS_ADMIN,
BusinessRole.TECHNICAL_ADMIN,
)

BUSINESS_ROLE_CAPABILITIES = {
BusinessRole.COUNSELOR: frozenset(
{
BusinessCapability.MANAGE_ASSIGNED_STUDENTS,
BusinessCapability.MANAGE_PERSONAL_KNOWLEDGE,
BusinessCapability.READ_AUTHORIZED_TEAM_KNOWLEDGE,
}
),
BusinessRole.BUSINESS_ADMIN: frozenset(
{
BusinessCapability.ASSIGN_STUDENTS,
BusinessCapability.MANAGE_TEAM_KNOWLEDGE,
}
),
BusinessRole.TECHNICAL_ADMIN: frozenset({BusinessCapability.MANAGE_SYSTEM}),
}

LEGACY_PLATFORM_ROLE_DEFAULTS = {
"user": (BusinessRole.COUNSELOR,),
"admin": (BusinessRole.BUSINESS_ADMIN,),
"superadmin": (BusinessRole.TECHNICAL_ADMIN,),
}


def normalize_business_roles(values: Iterable[str | BusinessRole]) -> tuple[BusinessRole, ...]:
"""校验业务角色、去重并按固定顺序返回。"""

roles = {BusinessRole(value) for value in values}
return tuple(role for role in BUSINESS_ROLE_ORDER if role in roles)


def default_business_roles_for_platform_role(platform_role: str) -> tuple[BusinessRole, ...]:
"""为旧平台角色提供不扩大数据访问权的迁移默认值。"""

return LEGACY_PLATFORM_ROLE_DEFAULTS.get(platform_role, ())


def resolve_business_roles(user: Any) -> tuple[BusinessRole, ...]:
"""读取持久业务角色;旧对象缺少字段时按平台角色兼容。"""

if isinstance(user, Mapping):
stored_roles = user.get("business_roles")
platform_role = str(user.get("role", ""))
else:
stored_roles = getattr(user, "business_roles", None)
platform_role = str(getattr(user, "role", ""))

if stored_roles is None:
return default_business_roles_for_platform_role(platform_role)
return normalize_business_roles(stored_roles)


def resolve_business_capabilities(user: Any) -> frozenset[BusinessCapability]:
"""合并用户兼任角色的能力,不引入角色继承。"""

capabilities: set[BusinessCapability] = set()
for role in resolve_business_roles(user):
capabilities.update(BUSINESS_ROLE_CAPABILITIES[role])
return frozenset(capabilities)
40 changes: 39 additions & 1 deletion backend/package/yuxi/permissions/resource_permission.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from enum import StrEnum
from typing import Any, Protocol

from yuxi.permissions.business_roles import BusinessCapability, resolve_business_capabilities


class ResourcePermission(StrEnum):
"""资源权限等级,数值顺序用于判断权限是否足够。"""
Expand Down Expand Up @@ -207,7 +209,31 @@ def require_resource_permission(


def resolve_knowledge_base_permission(user: Any, resource: ShareableResource) -> ResourcePermission:
"""解析知识库权限,普通用户最多只能获得只读权限。"""
"""个人库仅所有者维护;团队库按共享范围和业务能力授权。"""

if is_personal_knowledge_base(resource):
owner = str(_value(resource, "created_by", "") or "")
if not owner or owner != str(_value(user, "uid", "") or ""):
return ResourcePermission.NONE
if _value(user, "role") in {"admin", "superadmin"} or (
BusinessCapability.MANAGE_PERSONAL_KNOWLEDGE in resolve_business_capabilities(user)
):
return ResourcePermission.MANAGE
return ResourcePermission.NONE

if _value(user, "role") == "user":
config = normalize_permission_config(_value(resource, "share_config"))
readable = scope_matches(user, config["read_scope"]) or (
config["read_scope"] is None and scope_matches(user, config["manage_scope"])
)
if not readable:
return ResourcePermission.NONE
capabilities = resolve_business_capabilities(user)
if BusinessCapability.MANAGE_TEAM_KNOWLEDGE in capabilities:
return ResourcePermission.MANAGE
if BusinessCapability.READ_AUTHORIZED_TEAM_KNOWLEDGE in capabilities:
return ResourcePermission.READ
return ResourcePermission.NONE

return resolve_resource_permission(
user,
Expand All @@ -216,6 +242,18 @@ def resolve_knowledge_base_permission(user: Any, resource: ShareableResource) ->
)


def is_personal_knowledge_base(resource: ShareableResource) -> bool:
"""用既有空共享范围识别仅所有者的个人知识库。"""

config = _value(resource, "share_config")
return (
isinstance(config, dict)
and config.get("version") == 2
and config.get("read_scope") is None
and config.get("manage_scope") is None
)


def require_knowledge_base_permission(
user: Any,
resource: ShareableResource,
Expand Down
Loading