Merge branch 'feat/2.6.0-beta4' of github.com:dataelement/bisheng into feat/2.6.0-beta4

This commit is contained in:
GuoQing Zhang
2026-07-09 20:57:36 +08:00
71 changed files with 1892 additions and 4342 deletions
@@ -0,0 +1,94 @@
# 会话点赞点踩反馈 PRD(产品视角)
> 面向产品 / 业务的需求说明,大白话、金字塔结构:先结论,再问题,再要做的事、怎么存。
> 研发验收口径(EARS 句式)另出 `features/v2.6.0/{NNN}-message-feedback/spec.md`,与本文一一对应、不冲突。
> **本阶段只出文档,不写代码。**
---
## 一、结论先行
**目前只有「应用会话」能对 AI 回答点赞/点踩,另外四个 AI 问答界面(含「深度思考」答案)只能复制/朗读,用户无法表达"这条答案好不好",我们也拿不到质量反馈。本次给这四个界面统一补上点赞/点踩(点踩可填原因),并让反馈全部落库、能一张表取数分析。改动很小——因为四个界面里三个早就具备存储与接口,只有灵思需要补一列。**
| | 界面 | 现状 | 本次要做的事 | 用户 / 数据侧拿到的结果 |
|---|---|---|---|---|
| 1 | 工作台**日常模式** | 只有复制/朗读 | 工具栏加点赞/点踩,点踩填原因 | 能反馈、反馈进库可分析 |
| 2 | 工作台**任务模式 / 灵思**(深度思考) | 无任何反馈按钮 | 任务完成区加点赞/点踩,点踩填原因 | 同上(唯一需后端补一列的界面) |
| 3 | **知识空间 AI 问答**(知源) | 只有复制/朗读 | 工具栏加点赞/点踩,点踩填原因 | 同上 |
| 4 | **频道订阅 AI 问答**(知源) | 只有复制/朗读 | 工具栏加点赞/点踩,点踩填原因 | 同上 |
> 一句话:**不是从零做一套反馈系统,而是把「应用会话」已有的成熟机制铺到另外四个界面;三个界面直接复用,灵思补一列并汇总进同一张分析表。**
---
## 二、为什么要做(问题)
- **用户侧**:这四个界面用得很多(日常问答、深度任务、知识库问答、频道文章问答),但答案好坏用户"没处表达"。应用会话有赞踩、这四个没有,体验割裂。
- **产品/运营侧**:拿不到"哪些答案被点赞、哪些被点踩、点踩原因是什么"的数据,无法评估各界面/各模型的回答质量,也没法据此调优。
- **深度思考**:带"已深度思考"的答案恰恰是最该被评价的(推理长、易出错),现在同样无法反馈。
---
## 三、要做哪些事(功能范围)
1. **四个界面统一加点赞、点踩两个按钮**,位置与现有"复制/朗读"并列,风格一致;赞与踩互斥、可再次点击取消。
2. **点踩弹窗填原因**(沿用应用会话的"填写反馈"弹窗体验),原因文本也落库。
3. **深度思考按整条答案评价**:赞踩落在整条回答上(思考过程 + 正文一体),不对思考块单独设按钮——与应用会话行为一致。
4. **反馈全部落库、且方便取数分析**:数据结构合理,能从数据库直接统计各界面/各会话的赞踩情况(见第四节)。
5. **刷新后保持状态**:已点过的赞/踩,刷新页面后仍高亮显示。
**本次不做(Non-goals**
- 审计/后台页面不新增"按赞踩筛选"的界面能力(用户已明确暂不要求,仅保证数据可取)。
- 不改应用会话现有反馈逻辑(保持不变)。
- 只读分享页不提供赞踩(匿名访客不交互)。
---
## 四、数据怎么存、怎么取数(数据结构说明)
> 核心诉求是"结构合理 + 好分析"。做法是**逐条明细存在各自答案行、会话级汇总统一进一张表**。
| 界面 | 每条答案的赞踩存哪 | 点踩原因存哪 | 会话级汇总 |
|---|---|---|---|
| 日常 / 知源 / 频道 | `chatmessage.liked`0未评/1赞/2踩,**已有** | `chatmessage.remark`**已有** | `message_session.like/dislike`**已有** |
| 灵思任务 | `linsight_session_version.liked`**本次新增一列** | `linsight_session_version.execute_feedback`**已有** | `message_session.like/dislike`(本次让灵思也汇总进来) |
**为什么这样"好分析"**
- `message_session` 这张表已经按 `flow_type` 区分界面(日常=15 / 灵思=20 / 频道=25 / 知识空间=30)。
把灵思也汇总进它之后,**它就成了覆盖全部界面的单一分析表**——按 `flow_type` 分组即可拿到各界面赞踩量。
- 要看逐条明细,就查各自答案行的 `liked`,都能 join 回 `message_session` 带出界面类型、租户、用户。
- 既有分析函数可直接复用(`static_msg_liked``app_list_group_by_chat_id`、会话反馈过滤)。
**取数示例(说明"方便"):**
- 各界面赞踩总量:`SELECT flow_type, SUM(like), SUM(dislike) FROM message_session GROUP BY flow_type;`
- 逐条被踩明细 + 原因:查 `chatmessage`liked=2 取 remark)与 `linsight_session_version`liked=2 取 execute_feedback)。
---
## 五、交互与体验
- **按钮位置**:与"复制/朗读"同一行工具栏,悬停出现;灵思在"任务完成"结果区下方新增一行。
- **互斥与取消**:点赞后再点赞=取消;赞↔踩互斥切换。
- **点踩弹窗**:点踩后弹出"填写原因"输入框(复用应用会话的弹窗与文案),提交后记录原因。
- **状态保持**:刷新/重进会话后,已赞/已踩保持高亮(后端在历史里回传赞踩状态)。
- **一致性**:四个界面视觉、交互与应用会话统一,用户零学习成本。
---
## 六、范围、分期与风险
- **一次性全上**(不分期):三个界面复用成熟机制、几乎零后端改动;灵思单独补列。
- **主要工作量在灵思**:新增 `liked` 列需一条数据库迁移,**须兼容 MySQL 与达梦 DM8 双库**(按仓库既有迁移规范,幂等 + 手工核对 DM8)。
- **需确认的技术点(研发阶段处理)**:确保三个 chatmessage 界面的"历史接口"回传 `liked`、"实时答案"带上答案 id(否则新答案未刷新时点不了赞、或刷新后不高亮)。
- **风险低**:不触碰权限/多租户核心;灵思写入按 `session_id` 主键定位,无跨租户风险。
---
## 七、验收要点(对应研发 spec,供后续 EARS 化)
1. 四个界面均可点赞/点踩,赞踩互斥且可取消。
2. 点踩弹窗可填原因并成功保存(chatmessage→remark,灵思→execute_feedback)。
3. 刷新后赞踩状态正确回显。
4. 赞踩写库正确:chatmessage/linsight 各自 `liked` 落值,`message_session` 汇总计数增减正确(含赞→踩切换)。
5. `SELECT flow_type, SUM(like), SUM(dislike) FROM message_session GROUP BY flow_type` 能看到四个界面(含灵思 flow_type=20)的赞踩汇总。
6. 只读分享页不出现赞踩按钮;应用会话原有反馈行为不变。
+130 -6
View File
@@ -94,6 +94,40 @@ def _llm_api_key_hash(config: dict | None) -> str | None:
return hashlib.sha256(key.encode()).hexdigest()[:16]
def _coerce_model_id(value: Any) -> int | None:
if value is None or isinstance(value, bool):
return None
try:
model_id = int(value)
except (TypeError, ValueError):
return None
return model_id if model_id > 0 else None
def _workbench_model_ref_values(config: WorkbenchModelConfig) -> list[Any]:
return [
*(one.id for one in (config.models or [])),
config.linsight_default_model_id,
getattr(config.embedding_model, "id", None),
getattr(config.asr_model, "id", None),
getattr(config.tts_model, "id", None),
getattr(config.chat_title_llm, "id", None),
]
def _allowed_system_model_owner(
model_tenant_id: int | None,
target_tenant_id: int,
*,
inherited_from_root: bool,
) -> bool:
if inherited_from_root:
return model_tenant_id == ROOT_TENANT_ID
if model_tenant_id == target_tenant_id:
return True
return target_tenant_id != ROOT_TENANT_ID and model_tenant_id == ROOT_TENANT_ID
async def _write_llm_audit(
login_user: "UserPayload",
action: str,
@@ -261,6 +295,83 @@ class LLMService:
)
return model_cls(**(json.loads(value) if value else {}))
@classmethod
async def _sanitize_workbench_config_refs(
cls,
config: WorkbenchModelConfig,
target_tenant_id: int,
*,
inherited_from_root: bool,
) -> WorkbenchModelConfig:
model_ids = {
model_id
for model_id in (_coerce_model_id(value) for value in _workbench_model_ref_values(config))
if model_id is not None
}
if not model_ids:
return cls._filter_workbench_config(config, set())
with bypass_tenant_filter():
rows = await LLMDao.aget_model_by_ids(list(model_ids))
allowed_ids = {
row.id
for row in rows
if _allowed_system_model_owner(
row.tenant_id,
target_tenant_id,
inherited_from_root=inherited_from_root,
)
}
return cls._filter_workbench_config(config, allowed_ids)
@classmethod
def _sanitize_workbench_config_refs_sync(
cls,
config: WorkbenchModelConfig,
target_tenant_id: int,
*,
inherited_from_root: bool,
) -> WorkbenchModelConfig:
model_ids = {
model_id
for model_id in (_coerce_model_id(value) for value in _workbench_model_ref_values(config))
if model_id is not None
}
if not model_ids:
return cls._filter_workbench_config(config, set())
with bypass_tenant_filter():
rows = LLMDao.get_model_by_ids(list(model_ids))
allowed_ids = {
row.id
for row in rows
if _allowed_system_model_owner(
row.tenant_id,
target_tenant_id,
inherited_from_root=inherited_from_root,
)
}
return cls._filter_workbench_config(config, allowed_ids)
@staticmethod
def _filter_workbench_config(
config: WorkbenchModelConfig,
allowed_ids: set[int],
) -> WorkbenchModelConfig:
def is_allowed(value: Any) -> bool:
model_id = _coerce_model_id(value)
return model_id is not None and model_id in allowed_ids
if config.models is not None:
config.models = [one for one in config.models if is_allowed(one.id)]
if not is_allowed(config.linsight_default_model_id):
config.linsight_default_model_id = None
for field_name in ("embedding_model", "asr_model", "tts_model", "chat_title_llm"):
ws_model = getattr(config, field_name)
if ws_model is not None and not is_allowed(ws_model.id):
setattr(config, field_name, None)
return config
@classmethod
async def get_all_llm(
cls,
@@ -1308,18 +1419,31 @@ class LLMService:
cls,
tenant_id: int | None = None,
) -> tuple[WorkbenchModelConfig, bool, bool]:
return await cls._aget_typed_with_meta(
target = _resolve_tenant_id(tenant_id)
config, inherited, blocked = await cls._aget_typed_with_meta(
ConfigKeyEnum.LINSIGHT_LLM,
WorkbenchModelConfig,
tenant_id,
target,
)
config = await cls._sanitize_workbench_config_refs(
config,
target,
inherited_from_root=inherited,
)
return config, inherited, blocked
@classmethod
def get_workbench_llm_sync(cls, tenant_id: int | None = None) -> WorkbenchModelConfig:
return cls._get_typed_sync(
ConfigKeyEnum.LINSIGHT_LLM,
WorkbenchModelConfig,
tenant_id,
target = _resolve_tenant_id(tenant_id)
value, inherited, _ = TenantSystemModelConfigDao.resolve(
tenant_id=target,
key=ConfigKeyEnum.LINSIGHT_LLM.value,
)
config = WorkbenchModelConfig(**(json.loads(value) if value else {}))
return cls._sanitize_workbench_config_refs_sync(
config,
target,
inherited_from_root=inherited,
)
@classmethod
@@ -9,9 +9,9 @@ derivation → leaf status check → JWT signing.
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import datetime
from typing import AsyncIterator, List, Optional, Tuple
from loguru import logger
@@ -29,10 +29,10 @@ from bisheng.core.context.tenant import (
set_current_tenant_id,
)
from bisheng.database.constants import (
AdminRole,
DefaultRole,
USER_DISABLE_SOURCE_GATEWAY,
USER_DISABLE_SOURCE_ORG_SYNC,
AdminRole,
DefaultRole,
)
from bisheng.database.models.audit_log import AuditLogDao
from bisheng.database.models.department import DepartmentDao, UserDepartmentDao
@@ -73,14 +73,19 @@ from bisheng.user.domain.models.user_role import UserRoleDao
from bisheng.user.domain.services.auth import AuthJwt, LoginUser
from bisheng.user.domain.services.user import UserService
_USER_LOCK_KEY = 'user:sso_lock:{external_user_id}'
_USER_LOCK_KEY = "user:sso_lock:{external_user_id}"
class LoginSyncService:
SOURCE = DEFAULT_SSO_SYNC_SOURCE
#: Seeded guest department (临时访客), see ``init_data._init_default_root_department``.
#: Used as the department fallback for SSO users whose payload carries no HR
#: department, mirroring self-registration (``UserService.user_register``).
GUEST_DEPT_ID = "BS@guest"
@staticmethod
def _disable_source_for_row(row_source: str, want_delete: int) -> Optional[str]:
def _disable_source_for_row(row_source: str, want_delete: int) -> str | None:
if want_delete != 1:
return None
if row_source == WECOM_SOURCE:
@@ -91,12 +96,10 @@ class LoginSyncService:
async def execute(
cls,
payload: LoginSyncRequest,
request_ip: str = '',
request_ip: str = "",
row_source: str = DEFAULT_SSO_SYNC_SOURCE,
) -> LoginSyncResponse:
ttl = int(
getattr(settings.sso_sync, 'user_lock_ttl_seconds', 30) or 30
)
ttl = int(getattr(settings.sso_sync, "user_lock_ttl_seconds", 30) or 30)
lock_key = _USER_LOCK_KEY.format(
external_user_id=payload.external_user_id,
)
@@ -104,11 +107,12 @@ class LoginSyncService:
async with _acquire_user_lock(lock_key, ttl=ttl) as acquired:
if not acquired:
raise SsoUserLockBusyError.http_exception(
f'another SSO login for {payload.external_user_id} is '
f'in progress'
f"another SSO login for {payload.external_user_id} is in progress"
)
return await cls._execute_locked(
payload, request_ip, row_source,
payload,
request_ip,
row_source,
)
@classmethod
@@ -123,18 +127,14 @@ class LoginSyncService:
try:
# --- parent chain (enabled + disabled WeCom users share binding) ---
if payload.primary_dept_external_id:
all_exts = [payload.primary_dept_external_id] + list(
payload.secondary_dept_external_ids or []
)
all_exts = [payload.primary_dept_external_id] + list(payload.secondary_dept_external_ids or [])
ext_to_dept = await DeptUpsertService.assert_parent_chain_exists(
all_exts,
source=row_source,
)
primary_dept = ext_to_dept[payload.primary_dept_external_id]
secondary_depts = [
ext_to_dept[e]
for e in (payload.secondary_dept_external_ids or [])
if e in ext_to_dept
ext_to_dept[e] for e in (payload.secondary_dept_external_ids or []) if e in ext_to_dept
]
else:
primary_dept = None
@@ -147,7 +147,9 @@ class LoginSyncService:
)
user, full_department_override = await cls._upsert_user(
payload, request_ip=request_ip, row_source=row_source,
payload,
request_ip=request_ip,
row_source=row_source,
)
if full_department_override:
@@ -159,11 +161,11 @@ class LoginSyncService:
)
elif primary_dept is not None:
await cls._ensure_primary(
user.user_id, primary_dept.id, row_source=row_source,
)
reconcile_secondary = (
'secondary_dept_external_ids' in payload.model_fields_set
user.user_id,
primary_dept.id,
row_source=row_source,
)
reconcile_secondary = "secondary_dept_external_ids" in payload.model_fields_set
await cls._ensure_secondaries(
user.user_id,
[d.id for d in secondary_depts],
@@ -185,21 +187,32 @@ class LoginSyncService:
return LoginSyncResponse(
user_id=int(user.user_id or 0),
leaf_tenant_id=ROOT_TENANT_ID,
token='',
token="",
)
leaf_tenant = await UserTenantSyncService.sync_user(
user.user_id, trigger=UserTenantSyncTrigger.LOGIN,
# Guest fallback: an SSO user with no HR department (payload
# carried none and no membership survived above) joins the guest
# department, mirroring self-registration so the account is never
# left department-less. Runs after the disable short-circuit so
# disabled placeholder users are not given a guest membership.
await cls._reconcile_guest_membership(
user.user_id,
row_source=row_source,
)
if leaf_tenant.status != 'active':
leaf_tenant = await UserTenantSyncService.sync_user(
user.user_id,
trigger=UserTenantSyncTrigger.LOGIN,
)
if leaf_tenant.status != "active":
logger.warning(
'F014 login blocked: user %s leaf tenant %s status=%s',
user.user_id, leaf_tenant.id, leaf_tenant.status,
)
raise SsoTenantDisabledError.http_exception(
f'tenant {leaf_tenant.id} status={leaf_tenant.status}'
"F014 login blocked: user %s leaf tenant %s status=%s",
user.user_id,
leaf_tenant.id,
leaf_tenant.status,
)
raise SsoTenantDisabledError.http_exception(f"tenant {leaf_tenant.id} status={leaf_tenant.status}")
guard = await UserService._reject_login_if_user_has_no_usable_access(user)
if guard is not None:
@@ -207,6 +220,7 @@ class LoginSyncService:
UserNoRoleForLoginError,
UserNoWebMenuForLoginError,
)
if guard.status_code == UserNoRoleForLoginError.Code:
raise UserNoRoleForLoginError()
raise UserNoWebMenuForLoginError()
@@ -214,7 +228,8 @@ class LoginSyncService:
auth_jwt = AuthJwt()
token_version = await UserDao.aget_token_version(user.user_id)
access_token = LoginUser.create_access_token(
user, auth_jwt,
user,
auth_jwt,
tenant_id=leaf_tenant.id,
token_version=token_version,
)
@@ -237,7 +252,7 @@ class LoginSyncService:
payload: LoginSyncRequest,
request_ip: str,
row_source: str,
) -> Tuple[User, bool]:
) -> tuple[User, bool]:
ext = payload.external_user_id
attrs = payload.user_attrs
full_department_override = False
@@ -245,7 +260,7 @@ class LoginSyncService:
if user is None:
legacy = await UserDao.aget_by_external_id(ext)
if legacy is not None:
if int(getattr(legacy, 'delete', 0) or 0) == 1:
if int(getattr(legacy, "delete", 0) or 0) == 1:
# Do not re-adopt disabled rows unless the sync payload
# explicitly states account state (e.g. WeCom enable/disable).
if payload.account_disabled is None:
@@ -259,7 +274,7 @@ class LoginSyncService:
else:
legacy.source = row_source
write_migration_audit = True
full_department_override = old_source == 'local'
full_department_override = old_source == "local"
user = legacy
cls._apply_user_attrs(user, attrs)
cls._touch_user_sync_time(user)
@@ -270,13 +285,13 @@ class LoginSyncService:
operator_id=0,
operator_tenant_id=ROOT_TENANT_ID,
action=TenantAuditAction.USER_SOURCE_MIGRATED.value,
target_type='user',
target_type="user",
target_id=str(legacy.user_id),
metadata={
'old_source': old_source,
'new_source': row_source,
'external_id': ext,
'via': 'sso_realtime',
"old_source": old_source,
"new_source": row_source,
"external_id": ext,
"via": "sso_realtime",
},
ip_address=request_ip,
)
@@ -284,12 +299,12 @@ class LoginSyncService:
new_delete = 1 if payload.account_disabled is True else 0
ds = cls._disable_source_for_row(row_source, new_delete)
new_user = User(
user_name=(attrs.name.strip() if attrs.name else '') or ext,
user_name=(attrs.name.strip() if attrs.name else "") or ext,
email=cls._normalize_contact_field(attrs.email),
phone_number=cls._normalize_contact_field(attrs.phone),
external_id=ext,
source=row_source,
password='',
password="",
delete=new_delete,
disable_source=ds,
)
@@ -310,26 +325,31 @@ class LoginSyncService:
# Old (migrated) users avoid this because F011's backfill
# set is_active=1 for them.
from bisheng.database.models.tenant import UserTenantDao
activated = await UserTenantDao.aactivate_user_tenant(
user.user_id, ROOT_TENANT_ID,
user.user_id,
ROOT_TENANT_ID,
)
logger.info(
'SSO new user created with active user_tenant: '
'user_id=%s external_id=%s source=%s tenant_id=%s',
user.user_id, ext, row_source, activated.tenant_id,
"SSO new user created with active user_tenant: "
"user_id=%s external_id=%s source=%s tenant_id=%s",
user.user_id,
ext,
row_source,
activated.tenant_id,
)
except Exception as e: # pragma: no cover — rare integrity race
logger.error(
'F014 could not create SSO user %s: %s', ext, e,
)
raise SsoCrossSourceUserError.http_exception(
f'failed to create user for external_id={ext}: {e}'
"F014 could not create SSO user %s: %s",
ext,
e,
)
raise SsoCrossSourceUserError.http_exception(f"failed to create user for external_id={ext}: {e}")
else:
# WeCom (and Gateway) send explicit ``account_disabled``; when False,
# the row below must flip ``delete`` back to 0. Unconditional forbid
# here blocked re-enable after 企微禁用 → 再启用 (delete stayed 1).
if int(getattr(user, 'delete', 0) or 0) == 1:
if int(getattr(user, "delete", 0) or 0) == 1:
if payload.account_disabled is None:
raise UserForbiddenError.http_exception()
cls._apply_user_attrs(user, attrs)
@@ -339,17 +359,17 @@ class LoginSyncService:
# Gateway org sync: optional explicit account enable/disable
if payload.account_disabled is not None:
want = 1 if payload.account_disabled else 0
if int(getattr(user, 'delete', 0) or 0) != want:
if int(getattr(user, "delete", 0) or 0) != want:
user.delete = want
user.disable_source = cls._disable_source_for_row(row_source, want)
await UserDao.aupdate_user(user)
if int(getattr(user, 'delete', 0) or 0) == 1 and payload.account_disabled is not True:
if int(getattr(user, "delete", 0) or 0) == 1 and payload.account_disabled is not True:
raise UserForbiddenError.http_exception()
return user, full_department_override
@staticmethod
def _normalize_contact_field(val: Optional[str]) -> Optional[str]:
def _normalize_contact_field(val: str | None) -> str | None:
"""Strip; empty string → None. ``None`` means omit (do not overwrite in apply)."""
if val is None:
return None
@@ -381,9 +401,66 @@ class LoginSyncService:
# Helper: UserDepartment primary + secondary management.
# -----------------------------------------------------------------------
@classmethod
async def _reconcile_guest_membership(
cls,
user_id: int,
*,
row_source: str,
) -> None:
"""Keep guest-department membership consistent with the invariant
"a user belongs to the guest department iff they have no other
department".
Mirrors self-registration (``UserService.user_register``): an SSO user
whose payload carried no HR department — and who has no surviving
membership — is placed in the guest department (临时访客) as primary, so
the account is never left department-less. Conversely, once a real
department has been assigned (e.g. by a later org-sync that demotes the
guest placeholder to a secondary row), the guest membership is vacated.
Guest is a ``source='local'`` department mounted under the root tenant,
so making it primary keeps the leaf tenant at ``ROOT_TENANT_ID`` — the
same tenant a department-less SSO user already resolved to. Idempotent
and best-effort: a missing guest department is logged and skipped, never
fatal to login.
"""
guest = await DepartmentDao.aget_by_dept_id(cls.GUEST_DEPT_ID)
if guest is None or getattr(guest, "status", "") != "active":
logger.warning(
"guest department {} missing/inactive; skip SSO guest fallback for user {}",
cls.GUEST_DEPT_ID,
user_id,
)
return
guest_id = int(guest.id)
memberships = await UserDepartmentDao.aget_user_departments(user_id)
has_guest = any(int(m.department_id) == guest_id for m in memberships)
has_real = any(int(m.department_id) != guest_id for m in memberships)
if not memberships:
# Orphan → join the guest department as primary. Track the row as a
# bisheng-internal placeholder (source='local'), matching
# self-registration, so provider-scoped reconcile never touches it.
await UserDepartmentDao.aadd_member(
user_id,
guest_id,
is_primary=1,
source="local",
)
await cls._sync_department_member_tuples(user_id, [guest_id])
elif has_guest and has_real:
# A real department now exists → vacate the guest placeholder.
await cls._remove_department_membership(user_id, guest_id)
@classmethod
async def _ensure_primary(
cls, user_id: int, dept_id: int, *, row_source: str,
cls,
user_id: int,
dept_id: int,
*,
row_source: str,
) -> None:
"""Make (user_id, dept_id) the primary department, demoting any
previous primary to ``is_primary=0``. Idempotent."""
@@ -395,16 +472,23 @@ class LoginSyncService:
# Demote old primary in place instead of deleting to preserve
# membership history; F012 sync_user reads only the flag.
await UserDepartmentDao.aset_primary_flag(
user_id, current.department_id, is_primary=0,
user_id,
current.department_id,
is_primary=0,
)
existing = await UserDepartmentDao.aget_membership(user_id, dept_id)
if existing is not None:
await UserDepartmentDao.aset_primary_flag(
user_id, dept_id, is_primary=1,
user_id,
dept_id,
is_primary=1,
)
else:
await UserDepartmentDao.aadd_member(
user_id, dept_id, is_primary=1, source=row_source,
user_id,
dept_id,
is_primary=1,
source=row_source,
)
await cls._sync_department_member_tuples(user_id, [dept_id])
@@ -412,16 +496,14 @@ class LoginSyncService:
async def _replace_departments_full(
cls,
user_id: int,
primary_dept_id: Optional[int],
primary_dept_id: int | None,
secondary_dept_ids: list[int],
*,
row_source: str,
) -> None:
"""Replace all department memberships from the imported payload."""
desired_secondary_ids = [
int(did)
for did in secondary_dept_ids
if did is not None and int(did) != int(primary_dept_id or 0)
int(did) for did in secondary_dept_ids if did is not None and int(did) != int(primary_dept_id or 0)
]
desired_dept_ids: list[int] = []
if primary_dept_id is not None:
@@ -430,9 +512,7 @@ class LoginSyncService:
desired_dept_ids = list(dict.fromkeys(desired_dept_ids))
current_memberships = await UserDepartmentDao.aget_user_departments(user_id)
current_dept_ids = list(dict.fromkeys(
int(row.department_id) for row in current_memberships
))
current_dept_ids = list(dict.fromkeys(int(row.department_id) for row in current_memberships))
await cls._replace_department_scoped_roles(
user_id,
@@ -445,11 +525,17 @@ class LoginSyncService:
if primary_dept_id is not None:
await UserDepartmentDao.aadd_member(
user_id, int(primary_dept_id), is_primary=1, source=row_source,
user_id,
int(primary_dept_id),
is_primary=1,
source=row_source,
)
for department_id in desired_secondary_ids:
await UserDepartmentDao.aadd_member(
user_id, int(department_id), is_primary=0, source=row_source,
user_id,
int(department_id),
is_primary=0,
source=row_source,
)
await cls._sync_department_member_tuples(user_id, desired_dept_ids)
@@ -473,9 +559,9 @@ class LoginSyncService:
revoke_role_ids = {
int(role.id)
for role in role_rows
if getattr(role, 'id', None) is not None
and int(getattr(role, 'department_id', 0) or 0) in revoke_scope
and int(role.id) != AdminRole
if getattr(role, "id", None) is not None
and int(getattr(role, "department_id", 0) or 0) in revoke_scope
and int(role.id) != AdminRole
}
target_role_ids -= revoke_role_ids
@@ -484,7 +570,7 @@ class LoginSyncService:
default_role_ids = {
int(role_id)
for dept in dept_rows
for role_id in (getattr(dept, 'default_role_ids', None) or [])
for role_id in (getattr(dept, "default_role_ids", None) or [])
if role_id is not None and int(role_id) != AdminRole
}
target_role_ids.update(default_role_ids)
@@ -504,7 +590,9 @@ class LoginSyncService:
@classmethod
async def _sync_department_member_tuples(
cls, user_id: int, dept_ids: list[int],
cls,
user_id: int,
dept_ids: list[int],
) -> None:
"""Best-effort OpenFGA department membership repair for SSO login.
@@ -523,7 +611,7 @@ class LoginSyncService:
async def _sync_department_admin_tuples(
cls,
user_id: int,
admin_dept_external_ids: Optional[List[str]],
admin_dept_external_ids: list[str] | None,
*,
row_source: str,
) -> None:
@@ -544,32 +632,33 @@ class LoginSyncService:
depts = await DepartmentDao.aget_by_ids(dept_ids) if dept_ids else []
dept_by_id = {int(d.id): d for d in depts if d.id is not None}
reconcile_dept_ids: List[int] = []
reconcile_dept_ids: list[int] = []
for row in memberships:
dept = dept_by_id.get(int(row.department_id))
if dept is None or getattr(dept, 'source', '') != row_source:
if dept is None or getattr(dept, "source", "") != row_source:
continue
ext_raw = getattr(dept, 'external_id', None)
ext_raw = getattr(dept, "external_id", None)
if not ext_raw or not str(ext_raw).strip():
continue
reconcile_dept_ids.append(int(dept.id))
grants = await DepartmentAdminGrantDao.aget_by_user_and_departments(
user_id, reconcile_dept_ids,
user_id,
reconcile_dept_ids,
)
grant_by_dept = {int(g.department_id): g for g in grants}
ops = []
upsert_sso_dept_ids: List[int] = []
delete_grant_dept_ids: List[int] = []
upsert_sso_dept_ids: list[int] = []
delete_grant_dept_ids: list[int] = []
for row in memberships:
dept = dept_by_id.get(int(row.department_id))
if dept is None:
continue
if getattr(dept, 'source', '') != row_source:
if getattr(dept, "source", "") != row_source:
continue
ext_raw = getattr(dept, 'external_id', None)
ext_raw = getattr(dept, "external_id", None)
if not ext_raw:
continue
ext_key = str(ext_raw).strip()
@@ -579,27 +668,15 @@ class LoginSyncService:
marker = grant_by_dept.get(did)
if ext_key in want:
if getattr(dept, 'status', '') != 'active':
if getattr(dept, "status", "") != "active":
continue
if (
marker is not None
and getattr(marker, 'grant_source', '')
== DEPARTMENT_ADMIN_GRANT_SOURCE_MANUAL
):
if marker is not None and getattr(marker, "grant_source", "") == DEPARTMENT_ADMIN_GRANT_SOURCE_MANUAL:
continue
ops.extend(
DepartmentChangeHandler.on_admin_set(did, [user_id])
)
ops.extend(DepartmentChangeHandler.on_admin_set(did, [user_id]))
upsert_sso_dept_ids.append(did)
else:
if (
marker is not None
and getattr(marker, 'grant_source', '')
== DEPARTMENT_ADMIN_GRANT_SOURCE_SSO
):
ops.extend(
DepartmentChangeHandler.on_admin_removed(did, [user_id])
)
if marker is not None and getattr(marker, "grant_source", "") == DEPARTMENT_ADMIN_GRANT_SOURCE_SSO:
ops.extend(DepartmentChangeHandler.on_admin_removed(did, [user_id]))
delete_grant_dept_ids.append(did)
if ops:
@@ -607,7 +684,9 @@ class LoginSyncService:
for did in dict.fromkeys(upsert_sso_dept_ids):
await DepartmentAdminGrantDao.aupsert(
user_id, did, DEPARTMENT_ADMIN_GRANT_SOURCE_SSO,
user_id,
did,
DEPARTMENT_ADMIN_GRANT_SOURCE_SSO,
)
for did in dict.fromkeys(delete_grant_dept_ids):
await DepartmentAdminGrantDao.adelete(user_id, did)
@@ -619,7 +698,8 @@ class LoginSyncService:
)
await DepartmentKnowledgeSpaceService.cleanup_removed_department_admins(
department_id=did, user_ids=[user_id],
department_id=did,
user_ids=[user_id],
)
@classmethod
@@ -642,10 +722,9 @@ class LoginSyncService:
) -> None:
"""Remove a department membership and its FGA/admin markers."""
await UserDepartmentDao.aremove_member(user_id, department_id)
ops = (
DepartmentChangeHandler.on_member_removed(department_id, user_id)
+ DepartmentChangeHandler.on_admin_removed(department_id, [user_id])
)
ops = DepartmentChangeHandler.on_member_removed(
department_id, user_id
) + DepartmentChangeHandler.on_admin_removed(department_id, [user_id])
await DepartmentChangeHandler.execute_async(ops)
await DepartmentAdminGrantDao.adelete(user_id, department_id)
# Clear the derived knowledge-space binding (space_channel_member row +
@@ -655,7 +734,8 @@ class LoginSyncService:
)
await DepartmentKnowledgeSpaceService.cleanup_removed_department_admins(
department_id=department_id, user_ids=[user_id],
department_id=department_id,
user_ids=[user_id],
)
@classmethod
@@ -668,9 +748,9 @@ class LoginSyncService:
) -> None:
"""Drop secondary rows for ``source=row_source`` departments not in ``want``."""
memberships = await UserDepartmentDao.aget_user_departments(user_id)
to_drop: List[int] = []
to_drop: list[int] = []
for row in memberships:
if int(getattr(row, 'is_primary', 0) or 0) != 0:
if int(getattr(row, "is_primary", 0) or 0) != 0:
continue
did = int(row.department_id)
if did in want_secondary_ids:
@@ -685,7 +765,7 @@ class LoginSyncService:
dept = dept_by_id.get(did)
if dept is None:
continue
if getattr(dept, 'source', '') != row_source:
if getattr(dept, "source", "") != row_source:
continue
await cls._remove_sso_secondary_membership(user_id, did)
@@ -711,18 +791,24 @@ class LoginSyncService:
want_ids = {int(x) for x in dept_ids if x is not None}
if reconcile_remove:
await cls._reconcile_remove_sso_secondary_memberships(
user_id, want_ids, row_source=row_source,
user_id,
want_ids,
row_source=row_source,
)
if not dept_ids:
return
existing_rows = await UserDepartmentDao.aget_memberships_in_depts(
user_id, dept_ids,
user_id,
dept_ids,
)
existing_ids = {row.department_id for row in existing_rows}
to_add = [d for d in dept_ids if d not in existing_ids]
for dept_id in to_add:
await UserDepartmentDao.aadd_member(
user_id, dept_id, is_primary=0, source=row_source,
user_id,
dept_id,
is_primary=0,
source=row_source,
)
await cls._sync_department_member_tuples(user_id, dept_ids)
@@ -731,9 +817,11 @@ class LoginSyncService:
# Module-level helper: Redis SETNX-based per-user login lock.
# -----------------------------------------------------------------------
@asynccontextmanager
async def _acquire_user_lock(
lock_key: str, ttl: int = 30,
lock_key: str,
ttl: int = 30,
) -> AsyncIterator[bool]:
"""SETNX + TTL in a single Redis roundtrip (``SET key value NX EX ttl``).
@@ -750,12 +838,16 @@ async def _acquire_user_lock(
# Atomic SETNX + EX — avoids the two-step (setnx + expire) race
# where a crash between the two leaves a TTL-less lock.
result = await redis.async_connection.set(
lock_key, b'1', nx=True, ex=ttl,
lock_key,
b"1",
nx=True,
ex=ttl,
)
acquired = bool(result)
except Exception as e:
logger.warning(
'F014 Redis lock acquire failed (%s); proceeding without lock', e,
"F014 Redis lock acquire failed (%s); proceeding without lock",
e,
)
acquired = True
redis = None
@@ -766,4 +858,4 @@ async def _acquire_user_lock(
try:
await redis.adelete(lock_key)
except Exception as e: # pragma: no cover
logger.warning('F014 Redis lock release failed: %s', e)
logger.warning("F014 Redis lock release failed: %s", e)
@@ -14,6 +14,7 @@ The helpers must:
so callers see a deterministic "deleted" error rather than a leaked
cross-tenant row
"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -354,6 +355,46 @@ async def test_validate_accepts_string_model_ids_from_ws_model():
await avalidate_system_model_refs(['7', None, ''], target_tenant_id=ROOT_TENANT_ID)
@pytest.mark.asyncio
async def test_workbench_getter_sanitizes_inherited_stale_model_refs():
"""Inherited Root workbench config must not echo deleted or Child-owned
model ids back to the UI; otherwise the next POST repeats the stale
payload and fails write-side validation."""
from bisheng.llm.domain.schemas import WSModel
from bisheng.llm.domain.services.llm import LLMService
payload = {
"models": [
WSModel(id="10", name="root-model").model_dump(),
WSModel(id="20", name="child-model").model_dump(),
WSModel(id="30", name="deleted-model").model_dump(),
],
"linsight_default_model_id": "20",
"embedding_model": WSModel(id="10", name="root-embedding").model_dump(),
"asr_model": WSModel(id="30", name="deleted-asr").model_dump(),
"tts_model": WSModel(id="20", name="child-tts").model_dump(),
}
root_row = MagicMock(id=10, tenant_id=ROOT_TENANT_ID)
child_row = MagicMock(id=20, tenant_id=5)
with patch(
'bisheng.llm.domain.services.llm.TenantSystemModelConfigDao.aresolve',
new=AsyncMock(return_value=(json.dumps(payload), True, False)),
), patch(
'bisheng.llm.domain.services.llm.LLMDao.aget_model_by_ids',
new=AsyncMock(return_value=[root_row, child_row]),
):
config, inherited, blocked = await LLMService.aget_workbench_llm_with_meta(tenant_id=5)
assert inherited is True
assert blocked is False
assert [one.id for one in (config.models or [])] == ["10"]
assert config.linsight_default_model_id is None
assert config.embedding_model and config.embedding_model.id == "10"
assert config.asr_model is None
assert config.tts_model is None
# --- LLMService.update_*_llm setter integration -----------------------------
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -123,7 +123,7 @@ blue: { 50:'rgb(var(--brand-50) / <alpha-value>)', ... 900:'rgb(var(--brand-900)
| `ListWebLinkIllustration` | 列表网页链接 | |
| `CrawlingIllustration` | 爬取中 | mask uid 已唯一化 |
| `SuccessIllustration` | 成功态 | **跟随品牌主题**(用户已确认,非固定语义成功绿) |
| `SystemMaintenanceIllustration` | 系统维护 | 数据库+扳手;无 mask;2026-06-24 新增,组件已建未接槽位 |
| `SystemMaintenanceIllustration` | 系统维护 | 放大镜+小虫(2026-06 换过图);无 mask;用于 `SystemMaintenanceOverlay`(后端 500 全屏维护弹层)。含 6 档绿,按明度归到 illus-500/300/100 |
**通用空状态已替换(2026-06-24**10 处通用 `assets/channel/empty.png``<img>` 已换成 `<EmptyStateIllustration className="size-[120px] mb-X opacity-90" />`(去掉对内联 SVG 无意义的 `object-contain`):ChannelMemberManagementPanel、ChannelMemberDialog、KnowledgeSpaceMemberManagementPanel、KnowledgeSpaceMemberDialog、ChannelSquare、Subscription/index、knowledge/index、KnowledgeSquare、SpaceDetail/index、apps/AppEmptyState。
@@ -1,164 +0,0 @@
import request from "~/api/request";
import {
applyMenuAccessApi,
decideApprovalTaskApi,
getApprovalInstanceDetailApi,
getMyApprovalTaskDetailApi,
listApprovalRequestsApi,
listMyApprovalRequestsApi,
listMyApprovalTasksApi,
revokeMenuAccessGrantApi,
withdrawApprovalInstanceApi,
} from "./approval";
jest.mock("~/api/request", () => ({
__esModule: true,
default: {
get: jest.fn(),
post: jest.fn(),
paramsSerializer: jest.fn(),
},
}));
const mockGet = request.get as jest.Mock;
const mockPost = request.post as jest.Mock;
describe("approval api", () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
});
it("uses repeated query params for legacy approval request status arrays", async () => {
mockGet.mockResolvedValue({ data: [], total: 0 });
await listApprovalRequestsApi({
space_id: 1,
statuses: ["pending_review", "rejected", "finalize_failed"],
page: 1,
page_size: 100,
});
expect(mockGet).toHaveBeenCalledWith("/api/v1/approval/requests", {
params: {
space_id: 1,
statuses: ["pending_review", "rejected", "finalize_failed"],
page: 1,
page_size: 100,
},
paramsSerializer: request.paramsSerializer,
});
});
it("unwraps my-task list payloads from approval center", async () => {
mockGet.mockResolvedValue({
status_code: 200,
data: { data: [{ task_id: 11, business_name: "知识库订阅" }], total: 1 },
});
await expect(listMyApprovalTasksApi()).resolves.toEqual({
data: [{ task_id: 11, business_name: "知识库订阅" }],
total: 1,
});
});
it("loads task detail from approval center endpoint", async () => {
mockGet.mockResolvedValue({
status_code: 200,
data: { task_id: 11, status: "pending" },
});
await expect(getMyApprovalTaskDetailApi(11)).resolves.toEqual({
task_id: 11,
status: "pending",
});
});
it("submits task decisions to approval center endpoint", async () => {
mockPost.mockResolvedValue({
status_code: 200,
data: { task_id: 11, status: "approved" },
});
await expect(decideApprovalTaskApi(11, { action: "approve", comment: "ok" })).resolves.toEqual({
task_id: 11,
status: "approved",
});
expect(mockPost).toHaveBeenCalledWith("/api/v1/approval/tasks/11/decision", {
action: "approve",
comment: "ok",
});
});
it("unwraps my-request list payloads from approval center", async () => {
mockGet.mockResolvedValue({
status_code: 200,
data: { data: [{ instance_id: 21, business_name: "频道订阅" }], total: 1 },
});
await expect(listMyApprovalRequestsApi()).resolves.toEqual({
data: [{ instance_id: 21, business_name: "频道订阅" }],
total: 1,
});
});
it("loads approval instance detail", async () => {
mockGet.mockResolvedValue({
status_code: 200,
data: { instance_id: 21, status: "approved" },
});
await expect(getApprovalInstanceDetailApi(21)).resolves.toEqual({
instance_id: 21,
status: "approved",
});
});
it("submits withdraw and revoke grant actions", async () => {
mockPost
.mockResolvedValueOnce({
status_code: 200,
data: { instance_id: 21, status: "withdrawn" },
})
.mockResolvedValueOnce({
status_code: 200,
data: { instance_id: 21, revoked_keys: ["knowledge"] },
});
await expect(withdrawApprovalInstanceApi(21, { reason: "cancel" })).resolves.toEqual({
instance_id: 21,
status: "withdrawn",
});
await expect(revokeMenuAccessGrantApi(21, { reason: "cleanup" })).resolves.toEqual({
instance_id: 21,
revoked_keys: ["knowledge"],
});
expect(mockPost).toHaveBeenNthCalledWith(1, "/api/v1/approval/instances/21/withdraw", {
reason: "cancel",
});
expect(mockPost).toHaveBeenNthCalledWith(2, "/api/v1/approval/menu-access/21/revoke-grant", {
reason: "cleanup",
});
});
it("submits menu access applications", async () => {
mockPost.mockResolvedValue({
status_code: 200,
data: { decision: "pending", instance_id: 31 },
});
await expect(applyMenuAccessApi({
menu_key: "knowledge_space",
menu_name: "知识库",
})).resolves.toEqual({
decision: "pending",
instance_id: 31,
});
expect(mockPost).toHaveBeenCalledWith("/api/v1/approval/menu-access/apply", {
menu_key: "knowledge_space",
menu_name: "知识库",
});
});
});
@@ -1,162 +0,0 @@
import request from "~/api/request";
import {
authorizeChannelApi,
canEditChannelSettings,
canManageChannelPermissions,
ChannelRole,
getChannelsApi,
getChannelGrantSubjectsUsersApi,
getChannelPermissionsApi,
SortType,
} from "./channels";
jest.mock("~/api/request", () => ({
__esModule: true,
default: {
get: jest.fn(),
post: jest.fn(),
},
}));
const mockGet = request.get as jest.Mock;
const mockPost = request.post as jest.Mock;
describe("channel permission APIs", () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
});
it("uses channel manager permissions endpoint", async () => {
mockGet.mockResolvedValue({
status_code: 200,
data: {
data: [
{
subject_type: "user",
subject_id: 2,
subject_name: "Alice",
relation: "viewer",
},
],
},
});
await expect(getChannelPermissionsApi("channel-1")).resolves.toHaveLength(1);
expect(mockGet).toHaveBeenCalledWith(
"/api/v1/channel/manager/channel-1/permissions",
{ skip403Redirect: true },
);
});
it("uses channel manager authorize endpoint", async () => {
mockPost.mockResolvedValue({ status_code: 200, data: null });
await authorizeChannelApi("channel-1", {
grants: [{ subject_type: "user", subject_id: 2, relation: "viewer" }],
revokes: [],
});
expect(mockPost).toHaveBeenCalledWith(
"/api/v1/channel/manager/channel-1/authorize",
{
grants: [{ subject_type: "user", subject_id: 2, relation: "viewer" }],
revokes: [],
},
{ skip403Redirect: true },
);
});
it("uses channel manager grant subjects endpoint", async () => {
mockGet.mockResolvedValue({
status_code: 200,
data: { data: [{ user_id: 2, user_name: "Alice" }] },
});
await expect(
getChannelGrantSubjectsUsersApi(
"channel-1",
{ keyword: "ali", page: 2, page_size: 50 },
{ signal: undefined },
),
).resolves.toEqual([{ user_id: 2, user_name: "Alice" }]);
expect(mockGet).toHaveBeenCalledWith(
"/api/v1/channel/manager/channel-1/grant-subjects/users",
{
params: { keyword: "ali", page: 2, page_size: 50 },
skip403Redirect: true,
signal: undefined,
},
);
});
it("maps channel relation ahead of legacy user role", async () => {
mockGet.mockResolvedValue({
data: [
{
id: "channel-1",
name: "资讯频道",
source_list: [],
visibility: "public",
is_released: true,
user_role: "member",
relation: "editor",
permission_ids: ["view_channel", "edit_channel"],
is_pinned: false,
create_time: "2026-05-28T00:00:00Z",
latest_article_update_time: "2026-05-28T01:00:00Z",
unread_count: 0,
},
],
});
const channels = await getChannelsApi({
type: "subscribed",
sortBy: SortType.RECENT_UPDATE,
});
expect(channels[0].role).toBe("editor");
expect(channels[0].permissionIds).toEqual(["view_channel", "edit_channel"]);
expect(mockGet).toHaveBeenCalledWith(
"/api/v1/channel/manager/my_channels",
{
params: {
query_type: "followed",
sort_by: SortType.RECENT_UPDATE,
},
},
);
});
});
describe("channel relation helpers", () => {
it("allows editor to edit channel settings without managing permissions", () => {
expect(canEditChannelSettings("owner")).toBe(true);
expect(canEditChannelSettings("manager")).toBe(true);
expect(canEditChannelSettings("editor")).toBe(true);
expect(canEditChannelSettings(ChannelRole.CREATOR)).toBe(true);
expect(canEditChannelSettings(ChannelRole.ADMIN)).toBe(true);
expect(canEditChannelSettings("viewer")).toBe(false);
expect(canEditChannelSettings(ChannelRole.MEMBER)).toBe(false);
});
it("uses permission ids ahead of role for channel settings", () => {
expect(canEditChannelSettings("manager", ["view_channel"])).toBe(false);
expect(canEditChannelSettings("viewer", ["view_channel", "edit_channel"])).toBe(true);
});
it("allows new owner/manager and legacy creator/admin to manage permissions", () => {
expect(canManageChannelPermissions("owner")).toBe(true);
expect(canManageChannelPermissions("manager")).toBe(true);
expect(canManageChannelPermissions(ChannelRole.CREATOR)).toBe(true);
expect(canManageChannelPermissions(ChannelRole.ADMIN)).toBe(true);
expect(canManageChannelPermissions("editor")).toBe(false);
expect(canManageChannelPermissions("viewer")).toBe(false);
expect(canManageChannelPermissions(ChannelRole.MEMBER)).toBe(false);
});
it("uses permission ids ahead of role for member management", () => {
expect(canManageChannelPermissions("manager", ["view_channel", "edit_channel"])).toBe(false);
expect(canManageChannelPermissions("viewer", ["view_channel", "manage_channel_user"])).toBe(true);
});
});
+5
View File
@@ -133,6 +133,9 @@ export interface ChatMessage {
references?: ReferenceSource[];
citations?: ChatCitation[] | null;
files?: any[];
/** Persisted 点赞/点踩 verdict on this answer row: 0 none / 1 up / 2 down.
Seeds the feedback buttons' highlight on history reload. */
liked?: number;
// --- v2.5 Agent-mode native fields ---
/** One of question / agent_answer / agent_thinking / agent_tool_call / task / legacy answer. */
category?: string;
@@ -242,6 +245,7 @@ function mapAgentResponseItem(row: any): ChatMessage {
category,
files: Array.isArray(row.files) ? row.files : [],
citations: Array.isArray(row.citations) ? row.citations : null,
liked: row.liked,
};
if (category === "question" && raw && typeof raw === "object") {
@@ -490,6 +494,7 @@ export function parseStreamHistoryItem(raw: StreamHistoryItem): ChatMessage {
createdAt: raw.create_time,
error: false,
flow_name: raw.flow_name,
liked: raw.liked,
};
}
@@ -1,210 +0,0 @@
import request from "~/api/request";
import { batchDeleteApi, batchDownloadApi, createFolderApi, deleteFolderApi, getSquareSpacesApi, renameFolderApi, VisibilityType } from "./knowledge";
jest.mock("~/api/request", () => ({
__esModule: true,
default: {
get: jest.fn(),
post: jest.fn(),
postMultiPart: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
},
}));
const mockGet = request.get as jest.Mock;
const mockPost = request.post as jest.Mock;
const mockPostMultiPart = request.postMultiPart as jest.Mock;
const mockPut = request.put as jest.Mock;
const mockDelete = request.delete as jest.Mock;
describe("getSquareSpacesApi", () => {
it("maps pending square items from is_pending when subscription_status is absent", async () => {
mockGet.mockResolvedValue({
data: {
total: 1,
data: [
{
space: {
id: 101,
name: "Pending space",
auth_type: VisibilityType.APPROVAL,
user_id: 7,
user_name: "owner",
is_released: true,
},
is_pending: true,
file_num: 3,
follower_num: 2,
},
],
},
});
const result = await getSquareSpacesApi();
expect(result.data[0]).toMatchObject({
id: "101",
isPending: true,
isFollowed: false,
squareStatus: "pending",
});
});
});
describe("subscribeSpaceApi", () => {
beforeEach(() => {
mockPost.mockReset();
});
it("returns backend subscription status", async () => {
const { subscribeSpaceApi } = await import("./knowledge");
mockPost.mockResolvedValue({
status_code: 200,
data: {
status: "pending",
space_id: 101,
},
});
await expect(subscribeSpaceApi("101")).resolves.toEqual({
status: "pending",
spaceId: "101",
});
});
});
describe("unsubscribeSpaceApi", () => {
beforeEach(() => {
mockPost.mockReset();
});
it("returns backend response so callers can handle business status codes", async () => {
const { unsubscribeSpaceApi } = await import("./knowledge");
mockPost.mockResolvedValue({
status_code: 18071,
status_message: "本空间通过部门/用户组授权给你,暂无法退出",
data: null,
});
await expect(unsubscribeSpaceApi("101")).resolves.toEqual({
status_code: 18071,
status_message: "本空间通过部门/用户组授权给你,暂无法退出",
data: null,
});
});
});
describe("createFolderApi", () => {
beforeEach(() => {
mockPost.mockReset();
});
it("rejects backend business errors", async () => {
mockPost.mockResolvedValue({
status_code: 19000,
status_message: "Permission denied",
data: null,
});
await expect(createFolderApi("101", { name: "New folder" })).rejects.toThrow("Permission denied");
});
});
describe("renameFolderApi", () => {
beforeEach(() => {
mockPut.mockReset();
});
it("rejects backend business errors", async () => {
mockPut.mockResolvedValue({
status_code: 19000,
status_message: "Permission denied",
data: null,
});
await expect(renameFolderApi("101", "202", "Renamed")).rejects.toThrow("Permission denied");
});
});
describe("deleteFolderApi", () => {
beforeEach(() => {
mockDelete.mockReset();
});
it("rejects backend business errors", async () => {
mockDelete.mockResolvedValue({
status_code: 19000,
status_message: "Permission denied",
data: null,
});
await expect(deleteFolderApi("101", "202")).rejects.toThrow("Permission denied");
});
});
describe("batchDeleteApi", () => {
beforeEach(() => {
mockPost.mockReset();
});
it("rejects backend business errors", async () => {
mockPost.mockResolvedValue({
status_code: 19000,
status_message: "Permission denied",
data: null,
});
await expect(batchDeleteApi("101", { folder_ids: [202] })).rejects.toThrow("Permission denied");
});
});
describe("batchDownloadApi", () => {
beforeEach(() => {
mockPost.mockReset();
});
it("rejects backend business errors", async () => {
mockPost.mockResolvedValue({
status_code: 19000,
status_message: "Permission denied",
data: null,
});
await expect(batchDownloadApi("101", { folder_ids: [202] })).rejects.toThrow("Permission denied");
});
});
describe("uploadFileToServerApi", () => {
beforeEach(() => {
mockPostMultiPart.mockReset();
});
it("rejects backend business errors", async () => {
const { uploadFileToServerApi } = await import("./knowledge");
mockPostMultiPart.mockResolvedValue({
status_code: 19000,
status_message: "Permission denied",
data: null,
});
await expect(uploadFileToServerApi("101", new File(["x"], "doc.txt"))).rejects.toThrow("Permission denied");
});
});
describe("addFilesApi", () => {
beforeEach(() => {
mockPost.mockReset();
});
it("rejects backend business errors", async () => {
const { addFilesApi } = await import("./knowledge");
mockPost.mockResolvedValue({
status_code: 19000,
status_message: "Permission denied",
data: null,
});
await expect(addFilesApi("101", { file_path: ["/tmp/doc.txt"] })).rejects.toThrow("Permission denied");
});
});
+17
View File
@@ -48,6 +48,23 @@ export function startLinsight(versionId: string): Promise<any> {
});
}
// 灵思任务结果点赞/点踩(liked: 0 未评 / 1 赞 / 2 踩)。
// 落库到 linsight_session_version.liked,并汇总进 message_session(后端待接入,见 PRD)。
export function likeLinsightVersion(versionId: string, liked: number): Promise<any> {
return request.post('/api/v1/linsight/workbench/feedback', {
session_version_id: versionId,
liked
});
}
// 灵思点踩原因,落库到 linsight_session_version.execute_feedback(后端待接入)。
export function commentLinsightVersion(versionId: string, comment: string): Promise<any> {
return request.post('/api/v1/linsight/workbench/feedback', {
session_version_id: versionId,
comment
});
}
// F035 多轮对话:在已完成的同一会话里追加新一轮(复用同一 session_version + agent thread,保留上下文)
export function continueLinsight(session_version_id: string, question: string): Promise<any> {
return request.post('/api/v1/linsight/workbench/continue', {
@@ -1,35 +0,0 @@
import request from "~/api/request";
import { authorizeResource } from "./permission";
jest.mock("~/api/request", () => ({
__esModule: true,
default: {
get: jest.fn(),
post: jest.fn(),
},
}));
const mockPost = request.post as jest.Mock;
describe("permission API", () => {
beforeEach(() => {
mockPost.mockReset();
});
it("rejects business error envelopes from authorizeResource", async () => {
mockPost.mockResolvedValue({
status_code: 19000,
status_message: "Permission denied",
data: null,
});
await expect(
authorizeResource(
"knowledge_space",
"1",
[{ subject_type: "user", subject_id: 2, relation: "viewer" }],
[],
),
).rejects.toThrow("Permission denied");
});
});
@@ -27,6 +27,7 @@ import { ArrowDown } from "lucide-react";
import { SendIcon } from "~/components/svg";
import { Button, TextareaAutosize } from "~/components/ui";
import SpeechToTextComponent from "~/components/Voice/SpeechToText";
import { useContainerCompact, TOOLBAR_COMPACT_THRESHOLD } from "~/hooks";
import { useGetWorkbenchModelsQuery } from "~/hooks/queries/data-provider";
import InputFiles from "~/pages/appChat/components/InputFiles";
import { useFileDropAndPaste } from "~/pages/appChat/useFileDropAndPaste";
@@ -154,6 +155,10 @@ const AiChatInput = memo(
// so reading it from bsConfig would silently fall back to 50MB.
const envConfig = useRecoilValue(bishengConfState);
// Collapse toolbar labels to icons when the toolbar's own width (not the
// viewport's) runs short — e.g. once the sidebar opens on a mid-size screen.
const { ref: toolbarRef, compact: toolbarCompact } = useContainerCompact(TOOLBAR_COMPACT_THRESHOLD);
// F035 (PRD §4.1.3): daily "+ → 添加 Skill" picks a skill into the fresh
// task session ('new'), then enters task mode (/linsight/new) where the
// selection is refilled as a chip. Keyed 'new' to match the landing page.
@@ -432,7 +437,7 @@ const AiChatInput = memo(
<div className="flex h-7 min-h-7 w-full min-w-0 items-center justify-between gap-1 touch-mobile:gap-0.5">
{/* Toolbarflex-1 + overflow-hidden,避免与右侧语音/发送横向重叠 */}
<div className="input-bottom-left flex min-w-0 flex-1 items-center gap-1 touch-mobile:-ml-1 touch-mobile:gap-1 touch-mobile:pl-0 overflow-hidden">
<div ref={toolbarRef} className="input-bottom-left flex min-w-0 flex-1 items-center gap-1 touch-mobile:-ml-1 touch-mobile:gap-1 touch-mobile:pl-0 overflow-hidden">
{/* "+" menu — v2.5: combines file upload + knowledge space +
org knowledge base. Renders in place of ChatKnowledge when
agent mode is active (which is the v2.5 default). */}
@@ -489,6 +494,7 @@ const AiChatInput = memo(
variant="knowledge"
config={bsConfig}
disabled={!!disabled}
compact={toolbarCompact}
value={selectedOrgKbs}
onChange={(val) => {
onSelectedOrgKbsChange(val);
@@ -504,11 +510,13 @@ const AiChatInput = memo(
<AgentToolSelector
availableTools={bsConfig.tools}
disabled={toolsDisabled}
compact={toolbarCompact}
/>
)}
{tools && !agentMode && onSearchTypeChange && (
<ChatToolDown
config={bsConfig}
compact={toolbarCompact}
searchType={searchType}
setSearchType={(type) => {
onSearchTypeChange(type);
@@ -528,6 +536,7 @@ const AiChatInput = memo(
{taskMode && (
<TaskModeToggle
active
compact={toolbarCompact}
onClick={onToggleTaskMode ? onToggleTaskMode : () => navigate('/c/new')}
/>
)}
@@ -83,6 +83,7 @@ function MessageTreeNode({
onRegenerate,
knowledgeChatLayout,
allowExport,
allowFeedback,
onOpenCitationPanel,
activeCitationMessageId,
onPreviewFile,
@@ -95,6 +96,7 @@ function MessageTreeNode({
onPreviewFile?: (file: ArtifactFile) => void;
knowledgeChatLayout?: boolean;
allowExport?: boolean;
allowFeedback?: boolean;
onOpenCitationPanel?: (payload: CitationReferencesDesktopPayload) => void;
activeCitationMessageId?: string | null;
}) {
@@ -140,6 +142,7 @@ function MessageTreeNode({
setSiblingIdx={setSiblingIdx}
knowledgeChatLayout={knowledgeChatLayout}
allowExport={allowExport}
allowFeedback={allowFeedback}
onOpenCitationPanel={onOpenCitationPanel}
activeCitationMessageId={activeCitationMessageId}
onPreviewFile={onPreviewFile}
@@ -154,6 +157,7 @@ function MessageTreeNode({
onRegenerate={onRegenerate}
knowledgeChatLayout={knowledgeChatLayout}
allowExport={allowExport}
allowFeedback={allowFeedback}
onOpenCitationPanel={onOpenCitationPanel}
activeCitationMessageId={activeCitationMessageId}
onPreviewFile={onPreviewFile}
@@ -283,6 +287,10 @@ export default function AiChatMessages({
const hasMessages = messages.length > 0;
// 点赞/点踩 is offered on every real chat surface; the read-only anonymous
// share view (which carries a shareToken) opts out.
const allowFeedback = !shareToken;
// --- Empty state ---
if (!hasMessages && !isLoading && !hideEmptyState) {
return (
@@ -408,6 +416,7 @@ export default function AiChatMessages({
}
knowledgeChatLayout={knowledgeChatLayout}
allowExport={allowExport}
allowFeedback={allowFeedback}
onOpenCitationPanel={onOpenCitationPanel}
activeCitationMessageId={activeCitationMessageId}
onPreviewFile={onPreviewFile}
@@ -425,6 +434,7 @@ export default function AiChatMessages({
onRegenerate={onRegenerate}
knowledgeChatLayout={knowledgeChatLayout}
allowExport={allowExport}
allowFeedback={allowFeedback}
onOpenCitationPanel={onOpenCitationPanel}
activeCitationMessageId={activeCitationMessageId}
onPreviewFile={onPreviewFile}
@@ -21,6 +21,8 @@ import { TaskTurnPanel } from "~/components/Linsight/Execution/TaskTurnPanel";
import type { ArtifactFile } from "~/components/Linsight/Artifacts/artifactUtils";
import { Avatar, AvatarImage, AvatarName } from "~/components/ui/Avatar";
import { TextToSpeechButton } from "~/components/Voice/TextToSpeechButton";
import { MessageFeedbackButtons } from "~/components/Chat/MessageFeedbackButtons";
import { likeChatApi, disLikeCommentApi } from "~/api/apps";
import { useGetBsConfig } from "~/hooks/queries/data-provider";
import { useAuthContext } from "~/hooks";
import { useMessageSelection } from "~/hooks/useMessageSelection";
@@ -134,6 +136,9 @@ interface AiMessageBubbleProps {
homepage/task chat opts in; the lightweight knowledge/file/article docks
and the share view leave it off. */
allowExport?: boolean;
/** Show the 点赞/点踩 feedback buttons under assistant answers. Default true;
the read-only anonymous share view passes false. */
allowFeedback?: boolean;
onOpenCitationPanel?: (payload: CitationReferencesDesktopPayload) => void;
activeCitationMessageId?: string | null;
/** F035: preview a task-turn document in the inline workspace panel (ChatView
@@ -328,6 +333,7 @@ const AiMessageBubble = memo(
setSiblingIdx,
knowledgeChatLayout,
allowExport,
allowFeedback = true,
onOpenCitationPanel,
activeCitationMessageId,
onPreviewFile,
@@ -356,6 +362,7 @@ const AiMessageBubble = memo(
setSiblingIdx={setSiblingIdx}
knowledgeChatLayout={knowledgeChatLayout}
allowExport={allowExport}
allowFeedback={allowFeedback}
onOpenCitationPanel={onOpenCitationPanel}
activeCitationMessageId={activeCitationMessageId}
onPreviewFile={onPreviewFile}
@@ -483,6 +490,7 @@ function AssistantBubble({
setSiblingIdx,
knowledgeChatLayout,
allowExport,
allowFeedback = true,
onOpenCitationPanel,
activeCitationMessageId,
onPreviewFile,
@@ -496,6 +504,7 @@ function AssistantBubble({
setSiblingIdx?: (idx: number) => void;
knowledgeChatLayout?: boolean;
allowExport?: boolean;
allowFeedback?: boolean;
onOpenCitationPanel?: (payload: CitationReferencesDesktopPayload) => void;
activeCitationMessageId?: string | null;
onPreviewFile?: (file: ArtifactFile) => void;
@@ -736,6 +745,18 @@ function AssistantBubble({
messageId={message.messageId || ""}
text={regularContent}
/>
{/* 点赞/点踩 — the answer persists as a chatmessage row, so
reuse the existing /liked + /chat/comment endpoints keyed
by message_id. Hidden on the read-only share view. */}
{allowFeedback && message.messageId && (
<MessageFeedbackButtons
liked={message.liked}
onLike={(liked) => likeChatApi(message.messageId, liked)}
onDislikeComment={(comment) =>
disLikeCommentApi(message.messageId, comment)
}
/>
)}
</>
}
/>
@@ -71,15 +71,18 @@ const AiModelSelect = memo(
very long ones. `auto` (see SelectContent) keeps the popup
from being forced to the trigger's width. No flash on open:
the model list is already in memory via `options`. */}
<SelectContent auto className="bg-white w-auto min-w-[100px] max-w-[280px]">
<SelectContent auto className="bg-white w-auto min-w-[100px] max-w-[240px]">
{uniqueOptions.map((opt) => (
<SelectItem key={opt.id + ""} value={opt.id + ""} textValue={opt.displayName}>
<div className="flex min-w-0 flex-col py-0.5">
<span>{opt.displayName}</span>
<div className="flex min-w-0 items-center py-0.5">
<span className="shrink-0">{opt.displayName}</span>
{opt.description && (
<span className="mt-0.5 whitespace-normal break-words text-xs text-gray-400">
{opt.description}
</span>
<>
<span className="mx-1.5 h-3 w-px shrink-0 bg-[#E5E6EB]" />
<span className="min-w-0 truncate text-xs font-normal text-[#999999]">
{opt.description}
</span>
</>
)}
</div>
</SelectItem>
@@ -321,11 +321,11 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
landingResizeObserverRef.current = ro;
}, []);
// Mobile only: the landing parent is `h-full` (= the visible scroll-area
// height, header/banner already excluded). We center the welcome block at
// its 50% mark and place the apps `parentH/2 + blockH/2 + 40px` from the top
// — i.e. exactly 40px below the centered input — instead of `vh`, which on
// mobile resolves below the visual center (layout-viewport relative).
// H5 shell (≤767) only: the landing parent is a definite `h-full` box (= the
// visible scroll-area height; MobileNav + Banner already excluded). We can't
// use `vh` here, and a `%` paddingTop resolves against WIDTH, so the apps
// offset is computed in px from this measured height: apps sit 40px below the
// welcome block, whose center is pinned at 40% of the region.
const landingParentObserverRef = useRef<ResizeObserver | null>(null);
const [landingParentHeight, setLandingParentHeight] = useState(0);
const landingParentRef = useCallback((el: HTMLDivElement | null) => {
@@ -341,6 +341,7 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
landingParentObserverRef.current = ro;
}, []);
// F035: task mode is a ROLE permission. The backend folds each role's
// menu_ids into web_menu → client `user.plugins`; `linsight_task_mode` is the
// workbench-home sub-capability toggled per role in the admin console. When
@@ -586,11 +587,12 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
return (
<div className={cn(
'flex flex-col relative',
// Landing (non-messages) layout keeps auto height on desktop so the
// vh-positioned welcome block + apps flow naturally. On mobile it
// needs a definite height so the landing block's `min-h-full`
// centering resolves against the visible scroll-area height.
useMessagesLayout ? 'h-full' : 'touch-mobile:h-full'
// Landing (non-messages) layout keeps auto height on the desktop
// shell (≥768) so the vh-positioned welcome block + apps flow
// naturally. The H5 shell (≤767) needs a definite height so the
// landing block's `min-h-full` centering resolves against the
// visible scroll-area height.
useMessagesLayout ? 'h-full' : 'max-md:h-full'
)}>
{/* Content area: Split into Chat Main and Citation Sidebar */}
{isLoading && conversationId !== 'new' ? (
@@ -796,33 +798,26 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
{citationPanelElement}
</div>
) : (
/* Landing page branch — welcome + input are pinned to viewport
vertical center via absolute positioning (top:50% + translateY).
Recommended apps sit exactly 40px below the input by using
`paddingTop: calc(50vh + landingHalfHeight + 40px)`, where
`landingBlockHeight` is measured live with a ResizeObserver.
When total content exceeds viewport, the parent's
overflow-y-auto handles scrolling — the centered block scrolls
up with the document as expected.
/* Landing page branch — the welcome block is pinned to the
region's vertical center via absolute positioning, so its
position is INDEPENDENT of whether recommended apps exist
(apps just flow below it and scroll if they overflow).
Mobile (touch-mobile, ≤1023px) can NOT use `vh`: the MobileNav
header + Banner sit above this scroll container, so `45vh`
(layout-viewport relative) resolves below the visible center.
Instead the parent gets a definite `h-full` (= the visible
scroll-area height) so the welcome block sits at 45% of it
(`top-[45%]`, slightly above true center — looks more
balanced than dead-center), and the apps sit 40px below the
input via a JS-measured offset (see landingParentHeight). */
<div ref={landingParentRef} className="relative min-h-full touch-mobile:h-full">
{/* Centered: welcome message + input. `top: 50vh` (viewport
height), NOT `top: 50%` — the parent's effective height
gets stretched by the apps' paddingTop below, so a
percentage would resolve to a non-viewport midpoint.
On mobile the parent is a fixed `h-full` box, so `top-[45%]`
resolves against the visible height (45vh-equivalent). */}
≥768 (desktop): center at 45vh; apps sit 40px below via
`paddingTop: calc(45vh + halfBlock + 40px)`.
≤767 (H5 shell): can't use `vh` (MobileNav + Banner sit above
this scroll container). The parent is a definite `h-full` box,
so the block centers at 35% of it (`top-[35%]`) and the apps
offset is computed in px from the measured region height
(landingParentHeight) — a `%` paddingTop would resolve against
width, not height. */
<div ref={landingParentRef} className="relative min-h-full max-md:h-full">
{/* Welcome message + input, absolutely centered. ≥768 at 45vh,
≤767 at 40% of the definite-height region. */}
<div
ref={landingBlockRef}
className="absolute inset-x-0 top-[45vh] -translate-y-1/2 touch-mobile:top-[45%]"
className="absolute inset-x-0 top-[45vh] -translate-y-1/2 max-md:top-[35%]"
>
{/* F035 Track H (P5): daily/task mode switch removed —
task mode is reached via the sidebar "new task" entry
@@ -831,7 +826,7 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
{/* Input area for landing page */}
{!shareToken && (
<div className="w-full max-w-[800px] mx-auto px-4 mt-10 touch-mobile:mt-2 touch-mobile:max-w-full pb-3">
<div className="w-full max-w-[800px] mx-auto px-4 mt-10 max-md:max-w-full pb-3">
<AiChatInput
elevated
disabled={!bsConfig?.models?.length || !!shareToken}
@@ -867,16 +862,15 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index?
)}
</div>
{/* Recommended apps: 40px below the centered block. The
paddingTop pushes apps to (viewport midpoint) + (landing
half-height) + 40px = (landing block bottom) + 40px.
On mobile `vh` is replaced by the measured visible height
(landingParentHeight) so the gap stays exactly 40px below
the centered input rather than landing on the next screen. */}
{/* Recommended apps: 40px below the centered block, so the
block's own position never shifts when apps appear.
offset = (block center) + (half block height) + 40px.
≥768 block center = 45vh; ≤767 = 40% of the measured region
height (px, since `vh`/`%`-padding don't work here). */}
<div
style={{
paddingTop: isTouchLayout
? `${landingParentHeight * 0.45 + landingBlockHeight / 2 + 40}px`
paddingTop: isH5
? `${landingParentHeight * 0.35 + landingBlockHeight / 2 + 40}px`
: `calc(45vh + ${landingBlockHeight / 2 + 40}px)`,
}}
>
@@ -40,6 +40,8 @@ export interface AvailableToolGroup {
interface Props {
availableTools: AvailableToolGroup[];
disabled?: boolean;
/** Toolbar out of room (see useContainerCompact): collapse label to icon. */
compact?: boolean;
}
function iconForGroup(group: AvailableToolGroup) {
@@ -48,7 +50,7 @@ function iconForGroup(group: AvailableToolGroup) {
return <Outlined.Hammer className="size-4 text-[#999]" />;
}
export default function AgentToolSelector({ availableTools, disabled }: Props) {
export default function AgentToolSelector({ availableTools, disabled, compact = false }: Props) {
const localize = useLocalize();
const [selected, setSelected] = useRecoilState(store.selectedAgentTools);
const [initialized, setInitialized] = useRecoilState(store.agentToolsInitialized);
@@ -129,11 +131,12 @@ export default function AgentToolSelector({ availableTools, disabled }: Props) {
<div className="relative shrink-0">
<ApiAppIcon size="15" className={cn("shrink-0", isActive ? "text-blue-500" : "text-[#999999]")} strokeWidth={1.5} />
</div>
{/* Mobile: collapse to icon + chevron only to save horizontal space. */}
<span className="text-[14px] font-normal truncate min-w-0 max-w-[min(20vw,60px)] touch-mobile:hidden">
{localize("com_tools_title")}
{/* {isActive ? ` (${activeCount})` : ""} */}
</span>
{/* Compact: collapse to icon + chevron only to save horizontal space. */}
{!compact && (
<span className="text-[14px] font-normal truncate min-w-0 max-w-[min(20vw,60px)]">
{localize("com_tools_title")}
</span>
)}
</div>
</SelectTrigger>
<SelectContent className="bg-white rounded-[8px] w-[200px] max-h-[320px] overflow-y-auto">
@@ -28,11 +28,14 @@ export const ChatToolDown = ({
searchType,
setSearchType,
disabled,
compact = false,
}: {
config?: BsConfig;
searchType: string;
setSearchType: (type: string) => void;
disabled: boolean;
/** Toolbar out of room (see useContainerCompact): collapse label to icon. */
compact?: boolean;
}) => {
const localize = useLocalize();
@@ -42,21 +45,25 @@ export const ChatToolDown = ({
<Select disabled={disabled}>
<SelectTrigger
className={cn(
"h-7 rounded-full px-2 data-[state=open]:border-blue-500 touch-mobile:px-1.5",
"h-7 rounded-full px-2 data-[state=open]:border-blue-500",
compact && "px-1.5",
searchType === "netSearch" && "bg-blue-100"
)}
>
<div
className={cn(
"flex gap-2 touch-mobile:gap-1",
"flex items-center",
compact ? "gap-1" : "gap-2",
searchType === "netSearch" && "text-blue-600"
)}
>
<Settings2Icon size="16" />
{/* Mobile: collapse to icon + chevron only to save horizontal space. */}
<span className="text-xs font-normal truncate min-w-0 max-w-[min(36vw,140px)] touch-mobile:max-w-[min(18vw,56px)] touch-mobile:hidden">
{localize("com_tools_title")}
</span>
{/* Compact: collapse to icon + chevron only to save horizontal space. */}
{!compact && (
<span className="text-xs font-normal truncate min-w-0 max-w-[min(36vw,140px)]">
{localize("com_tools_title")}
</span>
)}
</div>
</SelectTrigger>
<SelectContent className="bg-white rounded-[8px] w-52">
@@ -124,10 +131,10 @@ export const LinsiTools = ({ tools, setTools }) => {
return (
<Select>
<SelectTrigger className="h-7 rounded-full px-2 bg-white dark:bg-transparent data-[state=open]:border-blue-500 touch-mobile:px-1.5">
<div className={cn("flex gap-2 touch-mobile:gap-1", active && "text-blue-600")}>
<SelectTrigger className="h-7 rounded-full px-2 bg-white dark:bg-transparent data-[state=open]:border-blue-500 max-md:px-1.5">
<div className={cn("flex gap-2 max-md:gap-1", active && "text-blue-600")}>
<Settings2Icon size="16" />
<span className="text-xs font-normal truncate min-w-0 max-w-[min(36vw,140px)] touch-mobile:max-w-[min(18vw,56px)]">
<span className="text-xs font-normal truncate min-w-0 max-w-[min(36vw,140px)] max-md:max-w-[min(18vw,56px)]">
{localize("com_tools_title")}
</span>
</div>
@@ -318,6 +318,7 @@ export const ChatKnowledge = ({
renderSkillSubmenu,
taskModeActive = false,
skillSelected = false,
compact = false,
}: {
/** Controls the trigger button and which menu sections render:
* - 'plus' → "+" trigger; file-upload + task-mode (+ optional add-skill) sections.
@@ -342,6 +343,8 @@ export const ChatKnowledge = ({
taskModeActive?: boolean;
/** F035: tint the "添加技能" icon brand-blue once at least one skill is picked. */
skillSelected?: boolean;
/** Toolbar out of room (see useContainerCompact): collapse label to icon. */
compact?: boolean;
}) => {
const localize = useLocalize();
const PAGE_SIZE = 20;
@@ -608,9 +611,9 @@ export const ChatKnowledge = ({
}}
/>
</div>
{/* Mobile: collapse to icon + chevron only to save horizontal
space in the input toolbar. */}
<span className="touch-mobile:hidden">{localize('com_ui_knowledge_space')}</span>
{/* Compact: collapse to icon + chevron only to save
horizontal space in the input toolbar. */}
{!compact && <span>{localize('com_ui_knowledge_space')}</span>}
<Outlined.Down size={16} className={cn("text-[#999] transition-transform duration-200", rootOpen && "rotate-180")} />
</button>
) : (
@@ -29,29 +29,29 @@ export default function Landing({ Header, isNew, hideSubtitle = false }: {
return (
<div className={`relative ${!isNew ? 'h-full' : ''}`}>
<div className="absolute left-0 right-0">{Header != null ? Header : null}</div>
<div className="flex h-full flex-col items-center justify-center touch-mobile:justify-start touch-mobile:pt-2 touch-mobile:pb-4 px-4">
{/* Hero: stack vertically on 576 稿 */}
<div className="flex flex-col touch-mobile:flex-col items-center gap-3 touch-mobile:gap-3 touch-desktop:flex-row touch-desktop:gap-4">
<div className="flex h-full flex-col items-center justify-center max-md:justify-start max-md:pt-2 max-md:pb-4 px-4">
{/* Hero: row on ≥768 (matches the desktop shell), stacked only on the H5 shell (≤767) */}
<div className="flex flex-col items-center gap-3 md:flex-row md:gap-4">
{bsConfig?.assistantIcon?.image && (
<img
className="overflow-hidden touch-mobile:w-14 touch-mobile:h-14 w-[52px] h-[52px] object-contain shrink-0"
className="overflow-hidden w-[52px] h-[52px] object-contain shrink-0"
src={__APP_ENV__.BASE_URL + bsConfig.assistantIcon.image}
alt=""
/>
)}
<h2 className="max-w-[75vh] touch-mobile:max-w-full text-center text-xl touch-mobile:font-semibold touch-mobile:text-[#1d2129] touch-mobile:leading-snug font-medium dark:text-white touch-desktop:text-2xl px-0">
<h2 className="max-w-full md:max-w-[75vw] text-center text-xl md:text-2xl font-semibold md:font-medium leading-snug md:leading-8 text-[#1d2129] dark:text-white px-0">
{bsConfig?.welcomeMessage}
</h2>
</div>
{!hideSubtitle && (
<div className="max-w-lg touch-mobile:max-w-full text-center mt-[26px] touch-mobile:mt-3 text-sm touch-mobile:text-[13px] font-normal text-gray-500 touch-mobile:text-[#4e5969] leading-5 touch-mobile:leading-relaxed">
<div className="max-w-lg text-center mt-[26px] text-sm font-normal text-gray-500 leading-5">
{bsConfig?.functionDescription}
</div>
)}
{/* Conversation starters */}
{conversation_starters.length > 0 && (
<div className="mt-6 touch-mobile:mt-5 w-full max-w-2xl flex flex-wrap justify-center gap-2 touch-mobile:gap-2 touch-mobile:px-0">
<div className="mt-5 md:mt-6 w-full max-w-2xl flex flex-wrap justify-center gap-2">
{conversation_starters
.slice(0, Constants.MAX_CONVO_STARTERS)
.map((text: string, index: number) => (
@@ -0,0 +1,138 @@
/**
* Shared 点赞/点踩 (thumbs up / down) feedback control.
*
* Reused by every AI answer surface (daily chat, knowledge-space 知源, channel
* subscription via AiMessageBubble; linsight task mode via ResultPanel). The
* button visuals match the appChat MessageButtons / AiMessageBubble action row
* (size-6 hit area, 14px bisheng-icons Outlined glyph, #818181 idle /
* brand-500 active) so the whole action row reads as one consistent set.
*
* State is optimistic-local: the parent injects the persistence via `onLike`
* (thumbs verdict) and `onDislikeComment` (reason text). `liked` seeds the
* initial highlight and re-syncs when history reload delivers the stored value.
*/
import { useEffect, useRef, useState } from "react";
import { Outlined } from "bisheng-icons";
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Textarea } from "~/components";
import { useToastContext } from "~/Providers";
import { useLocalize } from "~/hooks";
import { cn } from "~/utils";
// 0 = unrated / 1 = thumbs up / 2 = thumbs down (mirrors chatmessage.liked)
type ThumbsState = 0 | 1 | 2;
const ACTION_BTN =
"flex size-6 items-center justify-center rounded-[6px] transition-colors hover:bg-[#F7F7F7]";
interface MessageFeedbackButtonsProps {
/** Initial / persisted verdict: 0 none, 1 up, 2 down. */
liked?: number;
/** Persist the new verdict (0/1/2). Called on every toggle. */
onLike: (liked: number) => void;
/** Persist the free-text reason when the user submits a dislike comment. */
onDislikeComment?: (comment: string) => void;
className?: string;
}
export function MessageFeedbackButtons({
liked = 0,
onLike,
onDislikeComment,
className,
}: MessageFeedbackButtonsProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [state, setState] = useState<ThumbsState>(liked as ThumbsState);
const [commentOpen, setCommentOpen] = useState(false);
const [commentError, setCommentError] = useState(false);
const commentRef = useRef<HTMLTextAreaElement | null>(null);
// Re-sync when the persisted value arrives/changes (e.g. history reload).
useEffect(() => {
setState(liked as ThumbsState);
}, [liked]);
const handleClick = (type: ThumbsState) => {
setState((prev) => {
const next: ThumbsState = prev === type ? 0 : type;
onLike(next);
// Prompt for a reason only when newly disliking.
if (next === 2 && onDislikeComment) {
setCommentError(false);
setCommentOpen(true);
if (commentRef.current) commentRef.current.value = "";
}
return next;
});
};
const handleSubmitComment = () => {
const value = commentRef.current?.value?.trim();
if (!value) {
showToast?.({ message: localize("com_feedback_required"), status: "warning" });
setCommentError(true);
return;
}
onDislikeComment?.(value);
setCommentOpen(false);
setCommentError(false);
};
return (
<>
<div className={cn("flex gap-1", className)}>
<button
type="button"
className={ACTION_BTN}
onClick={() => handleClick(1)}
title="点赞"
aria-label="点赞"
aria-pressed={state === 1}
>
<Outlined.ThumbsUp
size={14}
className={cn(state === 1 ? "text-blue-500" : "text-[#818181]")}
/>
</button>
<button
type="button"
className={ACTION_BTN}
onClick={() => handleClick(2)}
title="点踩"
aria-label="点踩"
aria-pressed={state === 2}
>
<Outlined.ThumbsDown
size={14}
className={cn(state === 2 ? "text-blue-500" : "text-[#818181]")}
/>
</button>
</div>
{onDislikeComment && (
<Dialog open={commentOpen} onOpenChange={setCommentOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{localize("com_feedback_title")}</DialogTitle>
</DialogHeader>
<div>
<Textarea
ref={commentRef}
maxLength={9999}
className={cn("textarea", commentError && "border border-red-400")}
/>
<div className="flex justify-end gap-4 mt-4">
<Button className="px-11" variant="outline" onClick={() => setCommentOpen(false)}>
{localize("com_ui_cancel")}
</Button>
<Button className="px-11" onClick={handleSubmitComment}>
{localize("com_ui_submit")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
</>
);
}
@@ -1,140 +0,0 @@
/**
* DeepStepGroup — regression guard for the anti-flicker fix + the quiet-open fold.
*
* Root cause the anti-flicker fix locks down: the group's live-vs-done UI used to be
* driven by `group.running` ("any step mid-flight"), which toggles true↔false many
* times within ONE live episode — thinking frames ship as `status:'end'` (never
* running) and a tool step is running only between its start/end frames. Binding the
* 正在/已 label (and, before b1ff8967a, the fold) to it made the group flicker on
* every tool call ("内容上下反复跳跃").
*
* Current contract these tests assert:
* - the 正在/已 LABEL follows the stable `active` prop (the live tail episode, owned
* by ExecutionTimeline), NOT `group.running`;
* - the FOLD defaults COLLAPSED for every group — even the live tail — so task mode
* opens quiet (b1ff8967a); it is bound to neither `active` nor `group.running`.
*/
import { render } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { DeepStepGroup } from './DeepStepGroup';
import type { DeepStepGroup as DeepStepGroupData, MergedStep } from './stepUtils';
// useLocalize → identity so the rendered label IS the i18n key (assertable).
jest.mock('~/hooks', () => ({
__esModule: true,
useLocalize: () => (key: string) => key,
}));
// jsdom has no IntersectionObserver; the sticky-header pin detection news one up
// on mount. Stub it as an inert no-op so the group renders.
beforeAll(() => {
class MockIO {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
(window as any).IntersectionObserver = MockIO;
(global as any).IntersectionObserver = MockIO;
});
const RUNNING_LABEL = 'com_linsight_deep_thinking_running';
const DONE_LABEL = 'com_linsight_deep_thinking_done';
/** One thinking step. Thinking is the minimal render tree (no tool/knowledge child
* rows, so no extra hook deps) and always lands as a `status:'end'` frame, i.e.
* running=false — exactly the case the old code mis-collapsed. */
function thinkingStep(callId: string, output: string): MergedStep {
return {
callId,
taskId: 't',
name: 'thinking',
stepType: 'thinking',
running: false,
callReason: '',
params: null,
output,
namespace: null,
extraInfo: {},
// far-past second-level stamps so the live ticker measures elapsedMs > 0
// (the label keeps its 用时/已用 clause instead of the 0s compact form).
startedAt: 1000,
endedAt: 1200,
raw: {} as any,
};
}
function makeGroup(running: boolean, steps: MergedStep[]): DeepStepGroupData {
return { kind: 'deep_step_group', steps, startedAt: 1000, endedAt: 1200, running };
}
function renderGroup(group: DeepStepGroupData, active: boolean) {
return render(
<RecoilRoot>
<DeepStepGroup group={group} active={active} />
</RecoilRoot>,
);
}
/** The group's own fold container is the only element carrying an inline
* grid-template-rows (thinking-only group renders no nested collapsibles). */
function foldRows(container: HTMLElement): string {
const grid = container.querySelector('[style*="grid-template-rows"]') as HTMLElement;
return grid.style.gridTemplateRows;
}
describe('DeepStepGroup — label follows `active`; fold opens quiet, bound to neither', () => {
it('active=true shows the running label, yet opens collapsed (quiet) even as the live tail', () => {
const { container, getByText } = renderGroup(
makeGroup(false, [thinkingStep('c1', 'reasoning…')]),
true,
);
getByText(RUNNING_LABEL); // label follows active, not the (false) group.running
// Every group — even the live tail — defaults collapsed so task mode opens
// quiet (b1ff8967a); the collapsed header still streams via the NarrationTicker.
expect(foldRows(container)).toBe('0fr');
});
it('active=false collapses and shows the done label even while group.running=true (the regression guard)', () => {
// group.running=true would, under the old code, force-expand + "正在" —
// the exact per-tool-call flicker we removed.
const { container, getByText, queryByText } = renderGroup(
makeGroup(true, [thinkingStep('c1', 'reasoning…')]),
false,
);
getByText(DONE_LABEL);
expect(queryByText(RUNNING_LABEL)).toBeNull();
expect(foldRows(container)).toBe('0fr'); // collapsed
});
it('toggling group.running while active stays true changes neither the label nor the (collapsed) fold (anti-flicker)', () => {
const steps = [thinkingStep('c1', 'reasoning…')];
const { container, rerender, getByText } = render(
<RecoilRoot>
<DeepStepGroup group={makeGroup(false, steps)} active={true} />
</RecoilRoot>,
);
getByText(RUNNING_LABEL);
expect(foldRows(container)).toBe('0fr');
// a tool call starts → group.running flips true … (active unchanged)
rerender(
<RecoilRoot>
<DeepStepGroup group={makeGroup(true, steps)} active={true} />
</RecoilRoot>,
);
getByText(RUNNING_LABEL);
expect(foldRows(container)).toBe('0fr');
// … and ends → group.running flips back to false (active still unchanged)
rerender(
<RecoilRoot>
<DeepStepGroup group={makeGroup(false, steps)} active={true} />
</RecoilRoot>,
);
getByText(RUNNING_LABEL);
expect(foldRows(container)).toBe('0fr'); // fold never flips with group.running
});
});
@@ -230,7 +230,11 @@ export function ExecutionFlow({ versionId, conversationId, isSharePage = false,
{/* ── artifacts area (P4): report link / answer markdown / file
card — lifted into the terminal ResultPanel (peak-end). ── */}
{completed && (
<ResultPanel>
<ResultPanel
versionId={versionId}
liked={linsight?.liked ?? undefined}
allowFeedback={!readOnly && !isSharePage}
>
<ResultSection
answer={linsight?.output_result?.answer}
files={fileList}
@@ -11,14 +11,22 @@
import { Outlined } from 'bisheng-icons';
import type { ReactNode } from 'react';
import { useLocalize } from '~/hooks';
import { MessageFeedbackButtons } from '~/components/Chat/MessageFeedbackButtons';
import { likeLinsightVersion, commentLinsightVersion } from '~/api/linsight';
import { INK } from './execTokens';
interface ResultPanelProps {
/** the terminal deliverable (typically <ResultSection />) */
children: ReactNode;
/** linsight session_version id — the feedback target */
versionId?: string;
/** persisted 点赞/点踩 verdict: 0 none / 1 up / 2 down */
liked?: number;
/** show 点赞/点踩 (off for read-only / share view) */
allowFeedback?: boolean;
}
export function ResultPanel({ children }: ResultPanelProps) {
export function ResultPanel({ children, versionId, liked, allowFeedback }: ResultPanelProps) {
const localize = useLocalize();
// peak-end (§2.6): a DoubleCheck Ink "task completed" header marks the
// terminal state and lifts the deliverable out of the homogeneous flow; body
@@ -37,6 +45,18 @@ export function ResultPanel({ children }: ResultPanelProps) {
</span>
</div>
{children}
{/* 点赞/点踩 — the task result lives in linsight_session_version, so
persist via the linsight feedback endpoint keyed by version id
(best-effort: optimistic UI, backend wiring per PRD). */}
{allowFeedback && versionId && (
<div className="mt-3">
<MessageFeedbackButtons
liked={liked}
onLike={(l) => { void likeLinsightVersion(versionId, l).catch(() => {}); }}
onDislikeComment={(c) => { void commentLinsightVersion(versionId, c).catch(() => {}); }}
/>
</div>
)}
</div>
);
}
@@ -1,66 +0,0 @@
/**
* TaskErrorCard — friendly rate-limit copy (限流文案统一).
*
* Verifies the user-visible contract of the rate-limit copy change:
* - desc renders the unified "当前使用人数较多,请稍后再试。" in all three locales;
* - the suggestion (hint) line is removed — the empty i18n value is hidden by the
* component's `{hint && ...}` guard;
* - the title is retained;
* - quota_exhausted is left untouched (the throttling-vs-billing split) and still
* shows the "contact admin to top up" hint.
*
* Uses the REAL i18n resources (not the usual identity mock) so the key→copy
* wiring and the empty-string-hides-hint behaviour are actually exercised.
*/
import { render, screen } from '@testing-library/react';
import i18n from '~/locales/i18n';
import { TaskErrorCard } from './TaskErrorCard';
// useLocalize → real i18n.t so rendered text is the actual localized copy.
jest.mock('~/hooks', () => {
const realI18n = require('~/locales/i18n').default;
return {
__esModule: true,
useLocalize: () => (key: string, opts?: any) => realI18n.t(key, opts),
};
});
const RATE_LIMIT_DESC: Record<string, string> = {
'zh-Hans': '当前使用人数较多,请稍后再试。',
en: 'Too many users at the moment. Please try again later.',
ja: '現在ご利用が集中しています。しばらくしてからもう一度お試しください。',
};
describe('TaskErrorCard — rate-limit friendly copy', () => {
afterAll(async () => {
await i18n.changeLanguage('en');
});
it.each(Object.entries(RATE_LIMIT_DESC))('renders the unified rate-limit desc in %s', async (lang, expected) => {
await i18n.changeLanguage(lang);
render(<TaskErrorCard errorType="rate_limit" detail="raw provider 429 text" />);
expect(screen.getByText(expected)).toBeInTheDocument();
});
it('retains the rate-limit title', async () => {
await i18n.changeLanguage('zh-Hans');
render(<TaskErrorCard errorType="rate_limit" />);
expect(screen.getByText('模型服务繁忙')).toBeInTheDocument();
});
it('drops the suggestion line for rate_limit (empty hint hidden)', async () => {
await i18n.changeLanguage('zh-Hans');
render(<TaskErrorCard errorType="rate_limit" />);
// the old suggestion copy must be gone ...
expect(screen.queryByText(/稍等片刻后重新发起任务/)).not.toBeInTheDocument();
// ... and the empty hint key must not leak as raw text either
expect(screen.queryByText('com_linsight_error_hint_rate_limit')).not.toBeInTheDocument();
});
it('keeps quota_exhausted distinct (top-up hint intact, split not broken)', async () => {
await i18n.changeLanguage('zh-Hans');
render(<TaskErrorCard errorType="quota_exhausted" />);
expect(screen.getByText('模型服务额度已用尽')).toBeInTheDocument();
expect(screen.getByText(/联系管理员充值/)).toBeInTheDocument();
});
});
@@ -250,7 +250,7 @@ export function TaskTurnPanel({ versionId, conversationId, answer, readOnly = fa
document link opens it directly in ChatView's inline workspace
panel (preview), replacing the legacy right-side drawer. */}
{completed && (
<ResultPanel>
<ResultPanel versionId={versionId} liked={linsight?.liked ?? undefined} allowFeedback={!readOnly}>
<ResultSection
answer={linsight.output_result?.answer}
files={fileList}
@@ -1,160 +0,0 @@
/**
* timelineMemo — guards the React.memo comparators that keep the task-mode
* execution timeline from re-rendering frozen episodes on every WS frame (the fix
* for the "用时 N 秒" counter advancing unevenly / skipping seconds under a thinking
* token-delta storm). The critical contract: the WS pump rebuilds the whole node
* tree with FRESH objects each frame, so an unchanged (frozen) episode must compare
* EQUAL across rebuilds (skip re-render), while any real change in the active tail
* must compare UNEQUAL (re-render).
*/
import { deepStepGroupPropsEqual } from './DeepStepGroup';
import { toolRowLitePropsEqual } from './ToolRowLite';
import type { DeepStepGroup as DeepStepGroupData, MergedStep } from './stepUtils';
// useLocalize is only called inside the component, but importing DeepStepGroup
// pulls the module graph in — mirror the sibling test's hook stub so it resolves.
jest.mock('~/hooks', () => ({
__esModule: true,
useLocalize: () => (key: string) => key,
}));
const SHARED_PARAMS = { q: 'a' }; // a stable raw-frame reference (what mergeStepFrames reuses)
function step(overrides: Partial<MergedStep> = {}): MergedStep {
return {
callId: 'c1',
taskId: 't',
name: 'thinking',
stepType: 'thinking',
running: false,
callReason: '',
params: null,
output: 'abc',
namespace: null,
extraInfo: {},
startedAt: 1000,
endedAt: 1200,
raw: {} as any,
...overrides,
};
}
/** Clone a step into a NEW object with identical field values — the rebuild case. */
function rebuilt(s: MergedStep): MergedStep {
return { ...s, extraInfo: {} }; // fresh extraInfo, as mergeStepFrames spreads one each pass
}
function group(steps: MergedStep[], overrides: Partial<DeepStepGroupData> = {}): DeepStepGroupData {
return { kind: 'deep_step_group', steps, startedAt: 1000, endedAt: 1200, running: false, ...overrides };
}
describe('deepStepGroupPropsEqual', () => {
it('treats a rebuilt-but-unchanged frozen episode as EQUAL (skip re-render)', () => {
const s = step({ params: SHARED_PARAMS });
const a = { group: group([s]), active: false, compact: false };
const b = { group: group([rebuilt(s)]), active: false, compact: false };
expect(deepStepGroupPropsEqual(a, b)).toBe(true);
});
it('re-renders when `active` flips (live tail ⇄ done)', () => {
const s = step();
expect(
deepStepGroupPropsEqual(
{ group: group([s]), active: true, compact: false },
{ group: group([rebuilt(s)]), active: false, compact: false },
),
).toBe(false);
});
it('re-renders when a streaming step appends output (length grows)', () => {
const s = step({ output: 'abc' });
const grown = step({ output: 'abcdef' });
expect(
deepStepGroupPropsEqual(
{ group: group([s]), active: true, compact: false },
{ group: group([grown]), active: true, compact: false },
),
).toBe(false);
});
it('re-renders when a new step is appended to the episode', () => {
const s = step({ callId: 'c1' });
expect(
deepStepGroupPropsEqual(
{ group: group([s]), active: true, compact: false },
{ group: group([rebuilt(s), step({ callId: 'c2', name: 'web_search', stepType: 'tool' })]), active: true, compact: false },
),
).toBe(false);
});
it('re-renders when a MIDDLE step closes (running true→false) — the subtle case', () => {
// c2 is not the last step; a last-step-only signature would miss its close.
const mk = (c2Running: boolean) => [
step({ callId: 'c1', name: 'thinking' }),
step({ callId: 'c2', name: 'web_search', stepType: 'tool', running: c2Running }),
step({ callId: 'c3', name: 'thinking' }),
];
expect(
deepStepGroupPropsEqual(
{ group: group(mk(true)), active: true, compact: false },
{ group: group(mk(false)), active: true, compact: false },
),
).toBe(false);
});
it('re-renders when the group clock end stamp changes', () => {
const s = step();
expect(
deepStepGroupPropsEqual(
{ group: group([s], { endedAt: 1200 }), active: true, compact: false },
{ group: group([rebuilt(s)], { endedAt: 1300 }), active: true, compact: false },
),
).toBe(false);
});
it('re-renders when a subagent segment goal changes; equal when same', () => {
const s = step();
const base = { group: group([s]), active: false, compact: false, subagent: { goal: 'research X', idx: 1 } };
expect(
deepStepGroupPropsEqual(base, {
group: group([rebuilt(s)]),
active: false,
compact: false,
subagent: { goal: 'research Y', idx: 1 },
}),
).toBe(false);
expect(
deepStepGroupPropsEqual(base, {
group: group([rebuilt(s)]),
active: false,
compact: false,
subagent: { goal: 'research X', idx: 1 },
}),
).toBe(true);
});
});
describe('toolRowLitePropsEqual', () => {
it('treats a rebuilt-but-unchanged tool row as EQUAL', () => {
const s = step({ name: 'web_search', stepType: 'tool', params: SHARED_PARAMS, output: 'res' });
expect(toolRowLitePropsEqual({ step: s }, { step: rebuilt(s) })).toBe(true);
});
it('re-renders when the tool step closes (running flip)', () => {
const running = step({ name: 'web_search', stepType: 'tool', running: true });
const done = step({ name: 'web_search', stepType: 'tool', running: false });
expect(toolRowLitePropsEqual({ step: running }, { step: done })).toBe(false);
});
it('re-renders when output streams in (length grows)', () => {
const a = step({ name: 'web_search', stepType: 'tool', output: '' });
const b = step({ name: 'web_search', stepType: 'tool', output: 'hit list' });
expect(toolRowLitePropsEqual({ step: a }, { step: b })).toBe(false);
});
it('re-renders when params first arrive (reference changes)', () => {
const a = step({ name: 'web_search', stepType: 'tool', params: null });
const b = step({ name: 'web_search', stepType: 'tool', params: SHARED_PARAMS });
expect(toolRowLitePropsEqual({ step: a }, { step: b })).toBe(false);
});
});
@@ -28,9 +28,11 @@ interface KnowledgeSpaceSelectProps {
value: TaskModeKnowledgeItem[];
disabled?: boolean;
onChange: (items: TaskModeKnowledgeItem[]) => void;
/** Toolbar out of room (see useContainerCompact): collapse label to icon. */
compact?: boolean;
}
export function KnowledgeSpaceSelect({ value, disabled = false, onChange }: KnowledgeSpaceSelectProps) {
export function KnowledgeSpaceSelect({ value, disabled = false, onChange, compact = false }: KnowledgeSpaceSelectProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { data: bsConfig } = useGetBsConfig();
@@ -154,7 +156,7 @@ export function KnowledgeSpaceSelect({ value, disabled = false, onChange }: Know
WebkitMaskSize: 'contain', maskSize: 'contain',
}}
/>
<span className="truncate max-w-[min(30vw,120px)]">{localize('com_ui_knowledge_space')}</span>
{!compact && <span className="truncate">{localize('com_ui_knowledge_space')}</span>}
<ChevronDown size={14} className="text-slate-400" />
</button>
</DropdownMenuTrigger>
@@ -65,20 +65,23 @@ export function ModelSelector({ value, disabled = false, onChange }: ModelSelect
return (
<Select value={String(value)} disabled={disabled} onValueChange={onChange}>
<SelectTrigger className="h-8 w-auto min-w-0 max-w-[min(40vw,220px)] touch-mobile:max-w-[min(40vw,140px)] gap-1 overflow-hidden border-none bg-transparent px-2 text-[#4E5969] shadow-none outline-none hover:bg-black/5 focus:ring-0">
<SelectTrigger className="h-8 w-auto min-w-0 max-w-[min(40vw,220px)] max-md:max-w-[min(40vw,140px)] gap-1 overflow-hidden border-none bg-transparent px-2 text-[#4E5969] shadow-none outline-none hover:bg-black/5 focus:ring-0">
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] font-normal">
{label}
</span>
</SelectTrigger>
<SelectContent className="bg-white max-w-[280px]">
<SelectContent className="bg-white max-w-[240px]">
{options.map((opt: any) => (
<SelectItem key={String(opt.id)} value={String(opt.id)} textValue={opt.displayName ?? opt.name}>
<div className="flex min-w-0 flex-col py-0.5">
<span>{opt.displayName ?? opt.name}</span>
<div className="flex min-w-0 items-center py-0.5">
<span className="shrink-0">{opt.displayName ?? opt.name}</span>
{opt.description && (
<span className="mt-0.5 whitespace-normal break-words text-xs text-gray-400">
{opt.description}
</span>
<>
<span className="mx-1.5 h-3 w-px shrink-0 bg-[#E5E6EB]" />
<span className="min-w-0 truncate text-xs font-normal text-[#999999]">
{opt.description}
</span>
</>
)}
</div>
</SelectItem>
@@ -29,7 +29,7 @@ import {
AlertDialogTitle,
} from '~/components/ui/AlertDialog';
import { useGetBsConfig } from '~/hooks/queries/data-provider';
import { useLocalize } from '~/hooks';
import { useContainerCompact, useLocalize, TOOLBAR_COMPACT_THRESHOLD } from '~/hooks';
import { useLinsightSessionManager } from '~/hooks/useLinsightManager';
import InputFiles from '~/pages/appChat/components/InputFiles';
import { useFileDropAndPaste } from '~/pages/appChat/useFileDropAndPaste';
@@ -73,6 +73,9 @@ interface TaskModeInputProps {
export function TaskModeInput({ conversationId = 'new', disabled = false, onFollowUp, running = false, onStop }: TaskModeInputProps) {
const localize = useLocalize();
// Collapse toolbar labels to icons when the toolbar's own width (not the
// viewport's) runs short — e.g. once the sidebar opens on a mid-size screen.
const { ref: toolbarRef, compact: toolbarCompact } = useContainerCompact(TOOLBAR_COMPACT_THRESHOLD);
const navigate = useNavigate();
const location = useLocation();
const { showToast } = useToastContext();
@@ -339,7 +342,7 @@ export function TaskModeInput({ conversationId = 'new', disabled = false, onFoll
{/* Toolbar */}
<div className="flex h-8 min-h-8 w-full min-w-0 items-center justify-between gap-1">
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div ref={toolbarRef} className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<PlusMenu
disabled={disabled}
onUploadFile={() => inputFilesRef.current?.openPicker?.()}
@@ -353,14 +356,16 @@ export function TaskModeInput({ conversationId = 'new', disabled = false, onFoll
<KnowledgeSpaceSelect
value={context.knowledge}
disabled={disabled}
compact={toolbarCompact}
onChange={(knowledge) => setContext((prev) => ({ ...prev, knowledge }))}
/>
<ToolsSelect
tools={context.tools}
disabled={disabled}
compact={toolbarCompact}
onChange={(tools) => setContext((prev) => ({ ...prev, tools }))}
/>
<TaskModeToggle active disabled={disabled} onClick={handleExitTaskMode} />
<TaskModeToggle active disabled={disabled} compact={toolbarCompact} onClick={handleExitTaskMode} />
</div>
<div className="flex shrink-0 items-center gap-1.5">
@@ -8,25 +8,33 @@
import { X } from 'lucide-react';
import { Outlined } from 'bisheng-icons';
import { useState } from 'react';
import { useLocalize } from '~/hooks';
import useMediaQuery from '~/hooks/useMediaQuery';
import { useLocalize, useMediaQuery } from '~/hooks';
import { cn } from '~/utils';
interface TaskModeToggleProps {
active: boolean;
disabled?: boolean;
onClick: () => void;
/**
* Toolbar ran out of room (measured by the parent, see useContainerCompact):
* collapse to icon-only with a persistent exit "x". When compact, the hover
* icon-swap is disabled — otherwise it renders a SECOND x next to the
* persistent one. Roomy toolbars keep the hover binoculars→x affordance.
*/
compact?: boolean;
}
export function TaskModeToggle({ active, disabled = false, onClick }: TaskModeToggleProps) {
export function TaskModeToggle({ active, disabled = false, onClick, compact = false }: TaskModeToggleProps) {
const localize = useLocalize();
const [hovered, setHovered] = useState(false);
// Matches the CSS `touch-mobile` variant (≤1023px), where the label is
// hidden and a persistent exit "x" is shown instead. In that layout the
// hover icon-swap must be disabled — otherwise it renders a SECOND x next to
// the persistent one. Wide screens keep the hover binoculars→x affordance.
const isTouchLayout = useMediaQuery('(max-width: 1023px)');
const showExit = active && hovered && !isTouchLayout;
// Touch devices (iPad, foldables) can't hover, so the binoculars→x swap
// never fires there — fall back to a persistent exit "x" even when the label
// is shown. The swap stays only on hover-capable, roomy layouts.
const noHover = useMediaQuery('(hover: none)');
const showExit = active && hovered && !compact && !noHover;
// Standing exit "x": when there's no hover-swap to reveal it (compact layout
// or a non-hover device).
const showPersistentExit = active && (compact || noHover);
return (
<button
@@ -48,14 +56,15 @@ export function TaskModeToggle({ active, disabled = false, onClick }: TaskModeTo
) : (
<Outlined.Binoculars size={16} className={active ? 'text-blue-600' : 'text-[#4E5969]'} />
)}
{/* Mobile: collapse to icon only to save horizontal space in the
{/* Compact: collapse to icon only to save horizontal space in the
input toolbar, matching the knowledge/tools selectors. */}
<span className="touch-mobile:hidden">{localize('com_linsight_task_mode')}</span>
{/* Mobile + active: persistent exit "x" standing in for the other
selectors' chevron — same size/color/gap as their down icon
(size 16, #999). Desktop keeps the hover-swap affordance above. */}
{active && (
<X size={16} className="hidden shrink-0 text-[#999] touch-mobile:block" />
{!compact && <span>{localize('com_linsight_task_mode')}</span>}
{/* Persistent exit "x" standing in for the other selectors' chevron
— same size/color/gap as their down icon (size 16, #999). Shown
when no hover-swap will reveal one: compact layout, or a device
that can't hover. Hover-capable roomy layouts use the swap above. */}
{showPersistentExit && (
<X size={16} className="shrink-0 text-[#999]" />
)}
</button>
);
@@ -19,9 +19,11 @@ interface ToolsSelectProps {
tools: TaskModeToolItem[];
disabled?: boolean;
onChange: (tools: TaskModeToolItem[]) => void;
/** Toolbar out of room (see useContainerCompact): collapse label to icon. */
compact?: boolean;
}
export function ToolsSelect({ tools, disabled = false, onChange }: ToolsSelectProps) {
export function ToolsSelect({ tools, disabled = false, onChange, compact = false }: ToolsSelectProps) {
const localize = useLocalize();
const active = tools.some((tool) => tool.checked);
@@ -41,7 +43,7 @@ export function ToolsSelect({ tools, disabled = false, onChange }: ToolsSelectPr
)}
>
<Hammer size={16} />
<span className="truncate max-w-[min(30vw,120px)]">{localize('com_tools_title')}</span>
{!compact && <span className="truncate">{localize('com_tools_title')}</span>}
<ChevronDown size={14} className="text-slate-400" />
</button>
</DropdownMenuTrigger>
@@ -14,13 +14,11 @@ import {
Label,
} from '~/components/ui';
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/hooks/queries/data-provider';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, usePrefersMobileLayout } from '~/hooks';
import DataTable from '~/components/ui/DataTable';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { useToastContext, useConfirm } from '~/Providers';
import { formatDate } from '~/utils';
import { Spinner } from '~/components/svg';
const PAGE_SIZE = 25;
@@ -37,7 +35,6 @@ export default function SharedLinks() {
const { showToast } = useToastContext();
const isSmallScreen = usePrefersMobileLayout();
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
@@ -86,8 +83,6 @@ export default function SharedLinks() {
const deleteMutation = useDeleteSharedLinkMutation({
onSuccess: async () => {
setIsDeleteOpen(false);
setDeleteRow(null);
await refetch();
},
onError: (error) => {
@@ -144,14 +139,22 @@ export default function SharedLinks() {
await fetchNextPage();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
const confirmDelete = useCallback(() => {
if (deleteRow) {
handleDelete([deleteRow]);
}
setIsDeleteOpen(false);
}, [deleteRow, handleDelete]);
const confirm = useConfirm();
const handleDeleteClick = useCallback(
async (row: SharedLinkItem) => {
const ok = await confirm({
variant: 'destructive',
title: localize('com_ui_delete_shared_link'),
description: `${localize('com_ui_delete_confirm')} "${row.title}"`,
confirmText: localize('com_ui_delete'),
});
if (!ok) {
return;
}
handleDelete([row]);
},
[confirm, localize, handleDelete],
);
const columns = useMemo(
() => [
@@ -247,10 +250,7 @@ export default function SharedLinks() {
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
setDeleteRow(row.original);
setIsDeleteOpen(true);
}}
onClick={() => handleDeleteClick(row.original)}
title={localize('com_ui_delete')}
>
<TrashIcon className="size-4" />
@@ -261,7 +261,7 @@ export default function SharedLinks() {
),
},
],
[isSmallScreen, localize],
[isSmallScreen, localize, handleDeleteClick],
);
return (
@@ -294,31 +294,6 @@ export default function SharedLinks() {
/>
</OGDialogContent>
</OGDialog>
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_shared_link')}
className="max-w-[450px]"
main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
</Label>
</div>
</div>
</>
}
selection={{
selectHandler: confirmDelete,
selectClasses: `bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white ${
deleteMutation.isLoading ? 'cursor-not-allowed opacity-80' : ''
}`,
selectText: deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete'),
}}
/>
</OGDialog>
</div>
);
}
@@ -1,171 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
getMessageListApi,
markMessageReadApi,
} from "~/api/message";
import { NotificationsDialog } from "./NotificationsDialog";
jest.mock("react-i18next", () => ({
useTranslation: () => ({ i18n: { language: "zh-CN" } }),
}));
jest.mock("~/hooks/useLocalize", () => ({
__esModule: true,
default: () => (key: string, vars?: Record<string, string>) => {
const translations: Record<string, string> = {
com_notifications_action_request_menu_access: "申请访问菜单「{{target}}」",
com_notifications_action_approval_task_pending: "提交了「{{target}}」审批申请",
};
const template = translations[key];
if (!template) return key;
return template.replace("{{target}}", vars?.target ?? "");
},
}));
jest.mock("~/Providers", () => ({
useToastContext: () => ({ showToast: jest.fn() }),
}));
jest.mock("~/api/message", () => ({
getMessageListApi: jest.fn(),
markMessageReadApi: jest.fn(),
markAllMessageReadApi: jest.fn(),
deleteMessageApi: jest.fn(),
}));
jest.mock("~/components/ui/Dialog", () => ({
Dialog: ({ open, children }: { open?: boolean; children: React.ReactNode }) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
}));
jest.mock("~/components/ui/ExpandableSearchField", () => ({
ExpandableSearchField: () => null,
}));
jest.mock("~/components/ui/Tabs", () => ({
Tabs: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TabsList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TabsTrigger: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
TabsContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
jest.mock("~/components/ui/Button", () => ({
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" {...props}>{children}</button>
),
}));
jest.mock("~/components/ui/Avatar", () => ({
Avatar: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AvatarImage: () => null,
AvatarName: ({ name }: { name?: string }) => <span>{name}</span>,
}));
jest.mock("~/components/ui/Tooltip", () => ({
TooltipAnchor: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
describe("NotificationsDialog approval jump", () => {
beforeEach(() => {
jest.clearAllMocks();
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation(() => ({
matches: false,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
})),
});
class MockIntersectionObserver {
observe = jest.fn();
disconnect = jest.fn();
}
(window as any).IntersectionObserver = MockIntersectionObserver;
(global as any).IntersectionObserver = MockIntersectionObserver;
});
it("opens approval center instead of inline approving request messages", async () => {
jest.mocked(getMessageListApi).mockResolvedValue({
total: 1,
data: [{
id: 501,
sender: 7,
sender_name: "Alice",
message_type: "request",
action_code: "request_knowledge_space",
status: "pending",
is_read: false,
create_time: "2026-04-27T10:00:00Z",
update_time: "2026-04-27T10:00:00Z",
content: [{
type: "business_url",
content: "知识空间订阅申请",
metadata: {
business_type: "approval_instance_id",
data: { approval_instance_id: 99 },
},
}],
}],
});
jest.mocked(markMessageReadApi).mockResolvedValue({});
const openApprovalCenter = jest.fn();
render(<NotificationsDialog open onOpenApprovalCenter={openApprovalCenter} />);
expect(await screen.findByText("com_notifications_view_approval")).toBeInTheDocument();
expect(screen.queryByText("com_notifications_accept")).not.toBeInTheDocument();
expect(screen.queryByText("com_notifications_reject")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("com_notifications_view_approval"));
await waitFor(() => {
expect(openApprovalCenter).toHaveBeenCalledWith({
tab: "my_tasks",
taskId: null,
instanceId: 99,
});
});
});
it("uses scenario-specific PRD copy for later approval nodes", async () => {
jest.mocked(getMessageListApi).mockResolvedValue({
total: 1,
data: [{
id: 502,
sender: 7,
sender_name: "站内信",
message_type: "notify",
action_code: "approval_task_pending",
status: "pending",
is_read: false,
create_time: "2026-06-01T10:00:00Z",
update_time: "2026-06-01T10:00:00Z",
content: [
{ type: "system_text", content: "approval_task_pending" },
{
type: "business_url",
content: "--知识空间",
metadata: {
business_type: "approval_instance_id",
scenario_code: "menu_access_request",
data: {
approval_instance_id: "99",
business_name: "知识空间",
scenario_code: "menu_access_request",
},
},
},
],
}],
});
jest.mocked(markMessageReadApi).mockResolvedValue({});
render(<NotificationsDialog open onOpenApprovalCenter={jest.fn()} />);
expect(await screen.findByText(/申请访问菜单/)).toBeInTheDocument();
expect(screen.queryByText(/提交了/)).not.toBeInTheDocument();
});
});
@@ -1,104 +0,0 @@
import { render, screen, waitFor } from "@testing-library/react";
import { ApprovalCenterDialog } from "./ApprovalCenterDialog";
import {
getApprovalInstanceDetailApi,
getMyApprovalTaskDetailApi,
listMyApprovalRequestsApi,
listMyApprovalTasksApi,
} from "~/api/approval";
jest.mock("~/hooks/useLocalize", () => ({
__esModule: true,
default: () => (key: string) => key,
}));
const mockShowToast = jest.fn();
jest.mock("~/Providers", () => ({
useToastContext: () => ({ showToast: mockShowToast }),
}));
jest.mock("~/api/approval", () => ({
getApprovalInstanceDetailApi: jest.fn(),
getMyApprovalTaskDetailApi: jest.fn(),
listMyApprovalRequestsApi: jest.fn(),
listMyApprovalTasksApi: jest.fn(),
decideApprovalTaskApi: jest.fn(),
withdrawApprovalInstanceApi: jest.fn(),
revokeMenuAccessGrantApi: jest.fn(),
}));
jest.mock("~/components/ui/Dialog", () => ({
Dialog: ({ open, children }: { open?: boolean; children: React.ReactNode }) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
}));
describe("ApprovalCenterDialog", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("does not render a resubmit action for rejected requests", async () => {
jest.mocked(listMyApprovalRequestsApi).mockResolvedValue({
data: [
{
instance_id: 21,
business_name: "知识库申请",
status: "rejected",
},
],
total: 1,
});
jest.mocked(getApprovalInstanceDetailApi).mockResolvedValue({
instance_id: 21,
business_name: "知识库申请",
status: "rejected",
scenario_code: "knowledge_space_subscribe_request",
} as any);
render(
<ApprovalCenterDialog
open
onOpenChange={jest.fn()}
target={{ tab: "my_requests", instanceId: 21 }}
/>,
);
await waitFor(() => {
expect(getApprovalInstanceDetailApi).toHaveBeenCalled();
});
expect(screen.queryByText("com_approval_action_resubmit")).toBeNull();
});
it("selects the my-task matching the target instance id when no task id is provided", async () => {
// Channel/space subscribe approval notifications only carry instance_id (no task_id);
// the dialog must resolve the correct task from instance_id instead of picking the first.
jest.mocked(listMyApprovalTasksApi).mockResolvedValue({
data: [
{ task_id: 901, instance_id: 500, status: "pending", business_name: "频道A" },
{ task_id: 902, instance_id: 777, status: "pending", business_name: "频道B" },
],
total: 2,
});
jest.mocked(getMyApprovalTaskDetailApi).mockResolvedValue({
task_id: 902,
instance_id: 777,
status: "pending",
} as any);
render(
<ApprovalCenterDialog
open
onOpenChange={jest.fn()}
target={{ tab: "my_tasks", instanceId: 777 }}
/>,
);
await waitFor(() => {
expect(getMyApprovalTaskDetailApi).toHaveBeenCalledWith(902);
});
});
});
@@ -1,34 +1,52 @@
import React from 'react';
/**
* "System maintenance" empty-state illustration (database with a wrench).
* "System maintenance" empty-state illustration (magnifier over a bug).
*
* Brand greens re-point to the `--brand-*` palette so the illustration follows
* the blue ⇄ green theme switch. SVG presentation attributes ignore `var()`,
* so brand fills / strokes are applied via inline `style`
* (see BRAND-THEME-HANDOFF.md §3).
* Brand greens re-point to the `--illus-*` palette so the illustration follows
* the blue ⇄ green theme switch (and greyscale via the `grey` prop). SVG
* presentation attributes ignore `var()`, so brand fills / strokes are applied
* via inline `style` (see BRAND-THEME-HANDOFF.md §3 / §3.1 / §3.2).
*
* Colour mapping (§5):
* #19B476 (main green) → rgb(var(--illus-500))
* #BDE6D3 (light green) → rgb(var(--illus-100))
* white → kept as-is
* Colour mapping (by lightness → §5):
* #19B476 (main green) → rgb(var(--illus-500))
* #86DEB8 / #9BDDC1 (mid green) → rgb(var(--illus-300))
* #D3EFE3 / #DDF0E8 / #AAE9CE → rgb(var(--illus-100))
* white → kept as-is
*/
export const SystemMaintenanceIllustration = ({ className, grey, ...props }: React.SVGProps<SVGSVGElement> & { grey?: boolean }) => {
const fill100 = { fill: 'rgb(var(--illus-100))' } as React.CSSProperties;
const fill300 = { fill: 'rgb(var(--illus-300))' } as React.CSSProperties;
const fill500 = { fill: 'rgb(var(--illus-500))' } as React.CSSProperties;
const stroke100 = { stroke: 'rgb(var(--illus-100))' } as React.CSSProperties;
const stroke500 = { stroke: 'rgb(var(--illus-500))' } as React.CSSProperties;
const fill500stroke100 = { fill: 'rgb(var(--illus-500))', stroke: 'rgb(var(--illus-100))' } as React.CSSProperties;
const fill500stroke500 = { fill: 'rgb(var(--illus-500))', stroke: 'rgb(var(--illus-500))' } as React.CSSProperties;
return (
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg" className={['brand-illustration', grey && 'illus-grey', className].filter(Boolean).join(' ')} {...props}>
<ellipse cx="181.687" cy="155.079" rx="106.687" ry="23.8642" style={fill500} />
<rect x="74.9999" y="100.332" width="213.374" height="55.2151" style={fill500} />
<circle cx="99.7018" cy="143.766" r="10.266" fill="white" />
<circle opacity="0.6" cx="129.04" cy="149.766" r="10.266" fill="white" />
<ellipse cx="181.687" cy="99.8642" rx="106.687" ry="23.8642" style={fill500} stroke="white" strokeWidth="8" />
<path d="M288.374 223.864H288.352C287.237 236.828 239.909 247.26 181.686 247.26C123.464 247.26 76.1366 236.828 75.0224 223.864H74.9999V168.649H75.0224C76.1372 181.613 123.464 192.046 181.686 192.046C239.909 192.046 287.237 181.613 288.352 168.649H288.374V223.864Z" style={fill100} />
<path d="M288.374 292.181H288.352C287.238 305.145 239.91 315.577 181.686 315.577C123.464 315.577 76.1364 305.145 75.0224 292.181H74.9999V236.966H75.0224C76.137 249.93 123.464 260.363 181.686 260.363C239.909 260.363 287.237 249.93 288.352 236.966H288.374V292.181Z" style={fill100} />
<path d="M174.2 223.925C174.2 251.9 197.034 274.639 224.982 274.521C228.553 274.507 232.035 274.124 235.386 273.404C238.486 272.742 241.719 273.712 243.967 275.963L285.169 317.205C289.694 321.735 295.66 324 301.626 324C307.592 324 313.557 321.735 318.083 317.205C322.609 312.675 324.872 306.703 324.872 300.732C324.872 294.76 322.609 288.788 318.083 284.258L276.778 242.913C274.545 240.678 273.575 237.456 274.222 234.368C274.927 231.014 275.294 227.528 275.294 223.969C275.324 196.067 252.622 173.328 224.747 173.328C221.103 173.328 217.547 173.711 214.109 174.446C213.198 174.637 212.287 174.858 211.39 175.108C207.923 176.064 206.776 180.418 209.318 182.977L211.317 184.977L234.724 208.393C238.104 211.776 239.97 216.306 239.97 221.16C239.97 225.999 238.104 230.529 234.724 233.912C231.345 237.295 226.819 239.163 221.985 239.163C217.15 239.163 212.625 237.295 209.23 233.912L183.839 208.481C181.297 205.937 176.933 207.069 175.978 210.555C175.728 211.452 175.508 212.364 175.317 213.276C174.582 216.718 174.2 220.277 174.2 223.925Z" style={fill500} stroke="white" strokeWidth="8" />
<circle opacity="0.4" cx="99.7018" cy="210.766" r="10.266" fill="white" />
<circle opacity="0.4" cx="99.7018" cy="277.766" r="10.266" fill="white" />
<circle cx="199.564" cy="221.492" r="108.508" style={fill100} />
<path d="M243.805 97.0672C256.64 98.1625 269.476 111.673 269.476 111.673L255.838 135.406C255.838 135.406 232.173 126.643 226.959 118.245C221.745 109.847 230.97 95.972 243.805 97.0672Z" style={fill100} />
<ellipse cx="205.881" cy="320.8" rx="120.4" ry="8.4" style={fill100} />
<path d="M271.87 111.073C292.15 125.045 299.901 132.12 309.359 143.163L310.37 144.35L310.444 144.466C316.432 153.778 318.847 158.829 320.744 166.031L321.117 167.501L321.137 167.579L321.149 167.658C322.551 176.134 322.797 182.534 321.075 187.921C319.323 193.403 315.639 197.528 309.915 201.696L309.913 201.697C294.721 212.726 286.298 217.232 271.588 223.492C266.991 225.946 264.556 227.745 263.36 229.389C262.39 230.723 262.14 232.093 262.611 234.248L262.715 234.69L262.729 234.746C270.058 267.8 270.337 286.463 268.374 319.724L268.258 321.697H141.853L142.38 319.171C148.247 291.094 149.949 275.296 147.146 246.201C146.534 239.856 148.962 234.358 153.559 229.468C158.101 224.636 164.842 220.302 173.137 216.115C189.702 207.752 213.298 199.582 239.922 189.245L240.067 189.189L240.219 189.155C252.964 186.277 260.574 184.368 279.163 177.77C283.174 173.991 284.684 171.529 284.979 169.557C285.262 167.671 284.508 165.69 282.273 162.559C269.863 150.168 262.48 145.109 249.113 136.141L247.647 135.158L248.366 133.546C250.757 128.184 252.837 123.78 255.999 120.097C259.211 116.356 263.422 113.48 269.894 110.857L270.94 110.433L271.87 111.073Z" style={fill500stroke100} strokeWidth="4.19355" />
<ellipse cx="199.564" cy="209.96" rx="29.879" ry="25.6855" fill="white" />
<circle cx="176.681" cy="148.4" r="78.4" fill="white" style={stroke500} strokeWidth="6.29032" />
<path d="M124.08 138.608C124.629 141.427 122.79 144.158 119.971 144.707C117.152 145.257 114.422 143.417 113.872 140.599C113.322 137.78 115.807 129.625 116.985 129.396C118.163 129.166 123.53 135.789 124.08 138.608Z" style={fill300} />
<path d="M208.879 99.4077C209.429 102.226 207.59 104.957 204.771 105.507C201.952 106.056 199.221 104.217 198.672 101.398C198.122 98.5794 200.607 90.4248 201.785 90.1952C202.963 89.9655 208.33 96.5889 208.879 99.4077Z" style={fill300} />
<path d="M229.68 129.808C230.229 132.627 228.39 135.357 225.571 135.907C222.752 136.457 220.022 134.617 219.472 131.798C218.922 128.98 221.407 120.825 222.585 120.595C223.763 120.366 229.13 126.989 229.68 129.808Z" style={fill300} />
<path d="M126.171 134.625C126.635 137.006 125.081 139.313 122.699 139.778C120.318 140.242 118.01 138.688 117.546 136.306C117.082 133.925 119.181 127.035 120.177 126.841C121.172 126.647 125.706 132.243 126.171 134.625Z" fill="white" />
<path d="M211.002 95.5828C211.475 98.0081 209.892 100.358 207.467 100.83C205.041 101.303 202.692 99.7207 202.219 97.2954C201.746 94.8701 203.884 87.854 204.898 87.6564C205.911 87.4588 210.529 93.1575 211.002 95.5828Z" fill="white" />
<path d="M231.78 125.875C232.248 128.27 230.684 130.591 228.289 131.058C225.894 131.525 223.573 129.962 223.106 127.566C222.639 125.171 224.751 118.241 225.752 118.046C226.753 117.851 231.313 123.479 231.78 125.875Z" fill="white" />
<path d="M170.54 261.855C171.74 285.855 169.664 295.588 166.54 319.055" style={stroke100} strokeWidth="6.29032" strokeLinecap="round" />
<ellipse cx="141.207" cy="165.992" rx="3.77185" ry="7.33871" transform="rotate(73.507 141.207 165.992)" style={fill500} />
<ellipse cx="177.424" cy="159.413" rx="7.355" ry="3.63321" transform="rotate(-9.93375 177.424 159.413)" style={fill500} />
<path d="M179.573 179.108C167.9 185.591 161.258 186.446 149.325 185.179C149.325 185.179 155.13 202.809 169.684 198.952C184.239 195.094 179.573 179.108 179.573 179.108Z" style={fill500stroke500} strokeWidth="4.19355" strokeLinejoin="round" />
<path d="M134.701 185.323L133.501 190.923M127.501 186.123L126.701 189.723" style={stroke100} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
<path d="M205.991 168.548L204.791 174.148M198.791 169.348L197.991 174.148M212.391 169.748L211.591 172.948" style={stroke100} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="101.081" cy="255.2" r="13.2" style={fill500} />
<path d="M81.904 237.37C81.904 240.639 79.2589 243.288 75.9959 243.288C72.733 243.288 70.0879 240.639 70.0879 237.37C70.0879 234.101 72.733 231.452 75.9959 231.452C79.2589 231.452 81.904 234.101 81.904 237.37Z" style={fill300} />
<path d="M299.685 277.953C299.685 281.221 297.04 283.871 293.777 283.871C290.514 283.871 287.869 281.221 287.869 277.953C287.869 274.684 290.514 272.034 293.777 272.034C297.04 272.034 299.685 274.684 299.685 277.953Z" style={fill300} />
<circle cx="285.481" cy="89.1999" r="5.6" style={fill500} />
</svg>
);
};
@@ -1,36 +0,0 @@
import { render, screen } from "@testing-library/react";
import { PermissionDialog } from "./PermissionDialog";
jest.mock("~/pages/knowledge/SpaceDetail/KnowledgeSpaceShareDialog", () => ({
KnowledgeSpaceShareDialog: ({
resourceType,
resourceId,
resourceName,
showShareTab,
showMembersTab,
showPermissionTab,
}: any) => (
<div>
{`share-dialog:${resourceType}:${resourceId}:${resourceName}:${showShareTab}:${showMembersTab}:${showPermissionTab}`}
</div>
),
}));
describe("PermissionDialog", () => {
it("uses the shared subject-scoped permission dialog", () => {
render(
<PermissionDialog
open
onOpenChange={jest.fn()}
resourceType="channel"
resourceId="channel-1"
resourceName="Channel 1"
/>,
);
expect(
screen.getByText("share-dialog:channel:channel-1:Channel 1:false:false:true"),
).toBeInTheDocument();
});
});
@@ -1,382 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
authorizeResource,
getGrantableRelationModels,
getResourcePermissions,
getResourceGrantDepartmentChildren,
searchResourceGrantDepartments,
getResourceGrantUserGroups,
getResourceGrantUsers,
} from "~/api/permission";
import { PermissionGrantTab } from "./PermissionGrantTab";
const deptNode = (
id: number,
name: string,
parent_id: number | null,
path: string,
has_children: boolean,
) => ({
id,
dept_id: `dept-${id}`,
name,
parent_id,
path,
has_children,
matched: false,
children: [] as any[],
});
const emptyDeptSearch = { roots: [], total_matches: 0, truncated: false };
const mockLocalize = (key: string) => key;
jest.mock("~/hooks", () => ({
useLocalize: () => mockLocalize,
usePrefersMobileLayout: () => false,
}));
jest.mock("~/Providers", () => ({
useToastContext: () => ({ showToast: jest.fn() }),
}));
jest.mock("~/api/permission", () => ({
authorizeResource: jest.fn(),
getGrantableRelationModels: jest.fn(),
getResourcePermissions: jest.fn(),
getResourceGrantDepartmentChildren: jest.fn(),
searchResourceGrantDepartments: jest.fn(),
getResourceGrantUserGroups: jest.fn(),
getResourceGrantUsers: jest.fn(),
}));
const mockedAuthorizeResource = jest.mocked(authorizeResource);
const mockedGetGrantableRelationModels = jest.mocked(getGrantableRelationModels);
const mockedGetResourcePermissions = jest.mocked(getResourcePermissions);
const mockedDeptChildren = jest.mocked(getResourceGrantDepartmentChildren);
const mockedDeptSearch = jest.mocked(searchResourceGrantDepartments);
const mockedGetResourceGrantUserGroups = jest.mocked(getResourceGrantUserGroups);
const mockedGetResourceGrantUsers = jest.mocked(getResourceGrantUsers);
describe("PermissionGrantTab", () => {
beforeAll(() => {
class IntersectionObserverMock implements IntersectionObserver {
readonly root = null;
readonly rootMargin = "";
readonly thresholds = [];
disconnect = jest.fn();
observe = jest.fn();
takeRecords = jest.fn(() => []);
unobserve = jest.fn();
}
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
configurable: true,
value: IntersectionObserverMock,
});
class ResizeObserverMock implements ResizeObserver {
disconnect = jest.fn();
observe = jest.fn();
unobserve = jest.fn();
}
Object.defineProperty(globalThis, "ResizeObserver", {
writable: true,
configurable: true,
value: ResizeObserverMock,
});
if (!window.PointerEvent) {
Object.defineProperty(window, "PointerEvent", {
configurable: true,
value: MouseEvent,
});
}
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = jest.fn();
}
if (!Element.prototype.hasPointerCapture) {
Element.prototype.hasPointerCapture = jest.fn(() => false);
}
if (!Element.prototype.setPointerCapture) {
Element.prototype.setPointerCapture = jest.fn();
}
if (!Element.prototype.releasePointerCapture) {
Element.prototype.releasePointerCapture = jest.fn();
}
});
beforeEach(() => {
jest.clearAllMocks();
mockedAuthorizeResource.mockResolvedValue(null);
mockedGetResourcePermissions.mockResolvedValue([]);
mockedGetGrantableRelationModels.mockResolvedValue([
{
id: "viewer",
name: "Viewer",
relation: "viewer",
permissions: [],
is_system: true,
},
]);
mockedGetResourceGrantUsers.mockResolvedValue([]);
mockedDeptChildren.mockResolvedValue([deptNode(7, "测试部门", null, "/7/", false)] as any);
mockedDeptSearch.mockResolvedValue(emptyDeptSearch as any);
mockedGetResourceGrantUserGroups.mockResolvedValue([]);
});
const channelRelationModels = [
{
id: "owner",
name: "Owner",
relation: "owner",
permissions: [],
is_system: true,
},
{
id: "viewer",
name: "Viewer",
relation: "viewer",
permissions: [],
is_system: true,
},
{
id: "editor",
name: "Editor",
relation: "editor",
permissions: [],
is_system: true,
},
{
id: "manager",
name: "Manager",
relation: "manager",
permissions: [],
is_system: true,
},
] as const;
async function openRelationSelect() {
const trigger = screen.getByRole("combobox");
trigger.focus();
fireEvent.keyDown(trigger, {
key: "ArrowDown",
code: "ArrowDown",
keyCode: 40,
});
return await screen.findByRole("listbox");
}
it("keeps owner grant level available for channel user grants", async () => {
render(
<PermissionGrantTab
resourceType="channel"
resourceId="channel-1"
onSuccess={jest.fn()}
prefetchedGrantableModels={[...channelRelationModels]}
prefetchedGrantableModelsLoaded
skipGrantableModelsRequest
fixedSubjectType="user"
/>,
);
const listbox = await openRelationSelect();
expect(listbox).toHaveTextContent("com_permission.level_owner");
});
it.each([
["department", "部门"],
["user_group", "用户组"],
] as const)("hides owner grant level for channel %s grants", async (subjectType) => {
render(
<PermissionGrantTab
resourceType="channel"
resourceId="channel-1"
onSuccess={jest.fn()}
prefetchedGrantableModels={[...channelRelationModels]}
prefetchedGrantableModelsLoaded
skipGrantableModelsRequest
fixedSubjectType={subjectType}
/>,
);
const listbox = await openRelationSelect();
expect(listbox).not.toHaveTextContent("com_permission.level_owner");
expect(listbox).toHaveTextContent("com_permission.level_viewer");
});
it("submits the current include-children checkbox value for department grants", async () => {
render(
<PermissionGrantTab
resourceType="knowledge_space"
resourceId="space-1"
onSuccess={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "com_permission.subject_department" }));
fireEvent.click(await screen.findByText("测试部门"));
fireEvent.click(screen.getByRole("checkbox", { name: "com_permission.include_children" }));
fireEvent.click(screen.getByRole("button", { name: "com_permission.action_submit" }));
await waitFor(() => {
expect(mockedAuthorizeResource).toHaveBeenCalledWith(
"knowledge_space",
"space-1",
[
{
subject_type: "department",
subject_id: 7,
relation: "viewer",
model_id: "viewer",
include_children: false,
},
],
[],
);
});
expect(mockedDeptChildren).toHaveBeenCalledWith(
"knowledge_space",
"space-1",
null,
{ signal: expect.any(AbortSignal) },
);
});
it("summarizes only the explicitly picked department, not its descendants — decision 10", async () => {
// 测试部门 has children, but include-children coverage is conveyed by the flag;
// the summary must not enumerate descendants client-side.
mockedDeptChildren.mockResolvedValue([deptNode(7, "测试部门", null, "/7/", true)] as any);
render(
<PermissionGrantTab
resourceType="knowledge_space"
resourceId="space-1"
onSuccess={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "com_permission.subject_department" }));
fireEvent.click(await screen.findByText("测试部门"));
await waitFor(() => {
// The picked department appears both as a tree row and as a summary chip.
expect(screen.getAllByText("测试部门").length).toBeGreaterThan(1);
});
// No materialized descendant label is ever produced.
expect(screen.queryByText("测试部门/子部门")).not.toBeInTheDocument();
});
it("marks already granted departments as disabled without selecting them again", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "department",
subject_id: 7,
subject_name: "测试部门",
relation: "viewer",
include_children: false,
},
] as any);
render(
<PermissionGrantTab
resourceType="knowledge_space"
resourceId="space-1"
onSuccess={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "com_permission.subject_department" }));
const departmentLabel = await screen.findByText("测试部门");
const checkbox = departmentLabel.parentElement?.querySelector('[role="checkbox"]');
await waitFor(() => {
expect(checkbox).toHaveAttribute("data-state", "unchecked");
expect(checkbox).toBeDisabled();
});
expect(screen.getByText("com_permission.already_granted")).toBeInTheDocument();
fireEvent.click(departmentLabel);
fireEvent.click(screen.getByRole("button", { name: "com_permission.action_submit" }));
expect(mockedAuthorizeResource).not.toHaveBeenCalled();
});
it("marks already granted users as disabled without selecting them again", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user",
subject_id: 8,
subject_name: "Alice",
relation: "viewer",
},
] as any);
mockedGetResourceGrantUsers.mockResolvedValue([
{ user_id: 8, user_name: "Alice" },
]);
render(
<PermissionGrantTab
resourceType="knowledge_space"
resourceId="space-1"
onSuccess={jest.fn()}
/>,
);
const userLabel = await screen.findByText("Alice");
// The already-granted user row has a single checkbox; the name span is nested
// deeper than its sibling checkbox, so query the row's checkbox by role.
const checkbox = screen.getByRole("checkbox");
await waitFor(() => {
expect(checkbox).toHaveAttribute("data-state", "unchecked");
expect(checkbox).toBeDisabled();
});
expect(screen.getByText("com_permission.already_granted")).toBeInTheDocument();
fireEvent.click(userLabel);
fireEvent.click(screen.getByRole("button", { name: "com_permission.action_submit" }));
expect(mockedAuthorizeResource).not.toHaveBeenCalled();
});
it("marks already granted user groups as disabled without selecting them again", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user_group",
subject_id: 9,
subject_name: "测试用户组",
relation: "viewer",
},
] as any);
mockedGetResourceGrantUserGroups.mockResolvedValue([
{ id: 9, group_name: "测试用户组" },
]);
render(
<PermissionGrantTab
resourceType="knowledge_space"
resourceId="space-1"
onSuccess={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "com_permission.subject_user_group" }));
const userGroupLabel = await screen.findByText("测试用户组");
const checkbox = userGroupLabel.parentElement?.querySelector('[role="checkbox"]');
await waitFor(() => {
expect(checkbox).toHaveAttribute("data-state", "unchecked");
expect(checkbox).toBeDisabled();
});
expect(screen.getByText("com_permission.already_granted")).toBeInTheDocument();
fireEvent.click(userGroupLabel);
fireEvent.click(screen.getByRole("button", { name: "com_permission.action_submit" }));
expect(mockedAuthorizeResource).not.toHaveBeenCalled();
});
});
@@ -1,480 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
authorizeResource,
getGrantableRelationModels,
getResourcePermissions,
} from "~/api/permission";
import { PermissionListTab } from "./PermissionListTab";
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock("~/Providers", () => ({
useToastContext: () => ({ showToast: jest.fn() }),
useConfirm: () => jest.fn().mockResolvedValue(true),
}));
jest.mock("~/api/permission", () => ({
authorizeResource: jest.fn(),
getGrantableRelationModels: jest.fn(),
getResourcePermissions: jest.fn(),
}));
jest.mock("~/components/ui/Avatar", () => ({
Avatar: ({ children }: any) => <div>{children}</div>,
AvatarName: ({ name }: any) => <div>{name}</div>,
}));
jest.mock("~/components/ui/DropdownMenu", () => ({
DropdownMenu: ({ children }: any) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: any) => <div>{children}</div>,
DropdownMenuContent: ({ children }: any) => <div>{children}</div>,
DropdownMenuItem: ({ children, onSelect, ...props }: any) => (
<button type="button" onClick={onSelect} {...props}>{children}</button>
),
DropdownMenuSeparator: () => <div />,
}));
const mockedGetGrantableRelationModels = jest.mocked(getGrantableRelationModels);
const mockedGetResourcePermissions = jest.mocked(getResourcePermissions);
const mockedAuthorizeResource = jest.mocked(authorizeResource);
describe("Client PermissionListTab", () => {
beforeEach(() => {
jest.clearAllMocks();
mockedAuthorizeResource.mockResolvedValue(null);
mockedGetGrantableRelationModels.mockResolvedValue([
{
id: "owner",
name: "Owner",
relation: "owner",
permissions: [],
is_system: true,
},
{
id: "viewer",
name: "Viewer",
relation: "viewer",
permissions: [],
is_system: true,
},
{
id: "editor",
name: "Editor",
relation: "editor",
permissions: [],
is_system: true,
},
]);
});
it("keeps the last owner row read-only", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user",
subject_id: 2,
subject_name: "Alice",
relation: "owner",
model_id: "owner",
model_name: "Owner",
},
] as any);
render(
<PermissionListTab
resourceType="knowledge_space"
resourceId="space-1"
refreshKey={0}
fixedSubjectType="user"
/>,
);
await waitFor(() => {
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
});
await waitFor(() => {
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
it("shows owner actions when another owner remains", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user",
subject_id: 2,
subject_name: "Alice",
relation: "owner",
model_id: "owner",
model_name: "Owner",
},
{
subject_type: "user",
subject_id: 3,
subject_name: "Bob",
relation: "owner",
model_id: "owner",
model_name: "Owner",
},
] as any);
render(
<PermissionListTab
resourceType="knowledge_space"
resourceId="space-1"
refreshKey={0}
fixedSubjectType="user"
/>,
);
await waitFor(() => {
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
});
expect(screen.getAllByLabelText("com_permission.remove")).toHaveLength(2);
});
it("hides the owner option for a user group entry", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user_group",
subject_id: 9,
subject_name: "zz",
relation: "viewer",
model_id: "viewer",
model_name: "Viewer",
},
] as any);
render(
<PermissionListTab
resourceType="channel"
resourceId="channel-1"
refreshKey={0}
fixedSubjectType="user_group"
/>,
);
await waitFor(() => {
expect(screen.getAllByText("zz").length).toBeGreaterThan(0);
});
expect(screen.queryByText("com_permission.level_owner")).not.toBeInTheDocument();
expect(screen.getByText("com_permission.level_editor")).toBeInTheDocument();
});
it("keeps the owner option for a user entry", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user",
subject_id: 9,
subject_name: "Carol",
relation: "viewer",
model_id: "viewer",
model_name: "Viewer",
},
] as any);
render(
<PermissionListTab
resourceType="channel"
resourceId="channel-1"
refreshKey={0}
fixedSubjectType="user"
/>,
);
await waitFor(() => {
expect(screen.getAllByText("Carol").length).toBeGreaterThan(0);
});
expect(screen.getByText("com_permission.level_owner")).toBeInTheDocument();
});
it("deletes all relations for the selected subject", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user",
subject_id: 2,
subject_name: "Alice",
relation: "viewer",
model_id: "viewer",
model_name: "Viewer",
},
{
subject_type: "user",
subject_id: 2,
subject_name: "Alice",
relation: "editor",
model_id: "editor",
model_name: "Editor",
},
{
subject_type: "user",
subject_id: 3,
subject_name: "Bob",
relation: "viewer",
model_id: "viewer",
model_name: "Viewer",
},
] as any);
render(
<PermissionListTab
resourceType="knowledge_file"
resourceId="file-1"
refreshKey={0}
fixedSubjectType="user"
/>,
);
await waitFor(() => {
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
});
fireEvent.click(screen.getAllByLabelText("com_permission.remove")[0]);
await waitFor(() => {
expect(mockedAuthorizeResource).toHaveBeenCalledWith(
"knowledge_file",
"file-1",
[],
[
{
subject_type: "user",
subject_id: 2,
relation: "viewer",
},
{
subject_type: "user",
subject_id: 2,
relation: "editor",
},
],
);
});
});
it("updates the binding without a revoke when only the model changes (same relation)", async () => {
mockedGetGrantableRelationModels.mockResolvedValue([
{ id: "viewer", name: "Viewer", relation: "viewer", permissions: [], is_system: true },
{ id: "custom-viewer", name: "自定义查看", relation: "viewer", permissions: [], is_system: false },
] as any);
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user",
subject_id: 2,
subject_name: "Alice",
relation: "viewer",
model_id: "viewer",
model_name: "Viewer",
},
] as any);
render(
<PermissionListTab
resourceType="channel"
resourceId="channel-1"
refreshKey={0}
fixedSubjectType="user"
/>,
);
await waitFor(() => {
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
});
fireEvent.click(screen.getByText("自定义查看"));
await waitFor(() => {
expect(mockedAuthorizeResource).toHaveBeenCalledWith(
"channel",
"channel-1",
[
{
subject_type: "user",
subject_id: 2,
relation: "viewer",
model_id: "custom-viewer",
},
],
[],
);
});
});
it("revokes the old relation when the model change also changes the relation", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "user",
subject_id: 2,
subject_name: "Alice",
relation: "viewer",
model_id: "viewer",
model_name: "Viewer",
},
] as any);
render(
<PermissionListTab
resourceType="channel"
resourceId="channel-1"
refreshKey={0}
fixedSubjectType="user"
/>,
);
await waitFor(() => {
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
});
fireEvent.click(screen.getByText("com_permission.level_editor"));
await waitFor(() => {
expect(mockedAuthorizeResource).toHaveBeenCalledWith(
"channel",
"channel-1",
[
{
subject_type: "user",
subject_id: 2,
relation: "editor",
model_id: "editor",
},
],
[
{
subject_type: "user",
subject_id: 2,
relation: "viewer",
},
],
);
});
});
it("deletes department include-children grants across subtree and exact variants", async () => {
mockedGetResourcePermissions.mockResolvedValue([
{
subject_type: "department",
subject_id: 7,
subject_name: "研发部",
relation: "viewer",
model_id: "viewer",
model_name: "Viewer",
include_children: true,
},
] as any);
render(
<PermissionListTab
resourceType="knowledge_space"
resourceId="space-1"
refreshKey={0}
fixedSubjectType="department"
/>,
);
await waitFor(() => {
expect(screen.getByText("研发部")).toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText("com_permission.remove"));
await waitFor(() => {
expect(mockedAuthorizeResource).toHaveBeenCalledWith(
"knowledge_space",
"space-1",
[],
[
{
subject_type: "department",
subject_id: 7,
relation: "viewer",
include_children: true,
},
{
subject_type: "department",
subject_id: 7,
relation: "viewer",
include_children: false,
},
],
);
});
});
it("uses an injected permission API instead of generic resource endpoints", async () => {
const permissionApi = {
getPermissions: jest.fn().mockResolvedValue([
{
subject_type: "user",
subject_id: 2,
subject_name: "Alice",
relation: "viewer",
model_id: "viewer",
model_name: "Viewer",
},
]),
authorize: jest.fn(),
getGrantableRelationModels: jest.fn().mockResolvedValue([
{
id: "viewer",
name: "Viewer",
relation: "viewer",
permissions: [],
is_system: true,
},
]),
};
render(
<PermissionListTab
resourceType="channel"
resourceId="channel-1"
refreshKey={0}
fixedSubjectType="user"
permissionApi={permissionApi as any}
/>,
);
await waitFor(() => {
expect(screen.getAllByText("Alice").length).toBeGreaterThan(0);
});
expect(permissionApi.getPermissions).toHaveBeenCalledWith("channel", "channel-1");
expect(permissionApi.getGrantableRelationModels).toHaveBeenCalledWith("channel", "channel-1");
expect(mockedGetResourcePermissions).not.toHaveBeenCalled();
expect(mockedGetGrantableRelationModels).not.toHaveBeenCalled();
});
it("locks the current user's own row so they cannot change their own permission", async () => {
// Two user owners → both rows would normally expose action buttons.
mockedGetResourcePermissions.mockResolvedValue([
{ subject_type: "user", subject_id: 2, subject_name: "Alice", relation: "owner", model_id: "owner", model_name: "Owner" },
{ subject_type: "user", subject_id: 5, subject_name: "Me", relation: "owner", model_id: "owner", model_name: "Owner" },
] as any);
const { unmount } = render(
<PermissionListTab
resourceType="knowledge_space"
resourceId="s1"
refreshKey={0}
fixedSubjectType="user"
/>,
);
await waitFor(() => expect(screen.getAllByText("Alice").length).toBeGreaterThan(0));
const withoutSelfLock = screen.queryAllByRole("button").length;
unmount();
// With currentUserId=5 (Me): the current user's own row is locked, so it
// exposes no modify/remove buttons — fewer buttons than the unlocked render.
render(
<PermissionListTab
resourceType="knowledge_space"
resourceId="s1"
refreshKey={0}
fixedSubjectType="user"
currentUserId={5}
/>,
);
await waitFor(() => expect(screen.getAllByText("Me").length).toBeGreaterThan(0));
const withSelfLock = screen.queryAllByRole("button").length;
expect(withoutSelfLock).toBeGreaterThan(0);
expect(withSelfLock).toBeLessThan(withoutSelfLock);
});
});
@@ -1,166 +0,0 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import {
getResourceGrantDepartmentChildren,
searchResourceGrantDepartments,
} from "~/api/permission";
import type { SelectedSubject } from "~/api/permission";
import { SubjectSearchDepartment } from "./SubjectSearchDepartment";
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => key,
}));
// F038: the client picker is now a lazy tree fed by the resource-scoped grant
// endpoints (children / search). No full-tree load.
jest.mock("~/api/permission", () => ({
getResourceGrantDepartmentChildren: jest.fn(),
searchResourceGrantDepartments: jest.fn(),
}));
const mockedChildren = jest.mocked(getResourceGrantDepartmentChildren);
const mockedSearch = jest.mocked(searchResourceGrantDepartments);
const node = (
id: number,
name: string,
parent_id: number | null,
path: string,
has_children: boolean,
matched = false,
) => ({
id,
dept_id: `dept-${id}`,
name,
parent_id,
path,
has_children,
matched,
children: [] as any[],
});
const emptySearch = { roots: [], total_matches: 0, truncated: false };
describe("SubjectSearchDepartment (lazy, F038 decision 9/10)", () => {
beforeEach(() => {
jest.clearAllMocks();
// Root layer = 全集团 (has children); search returns its pruned subtree.
mockedChildren.mockResolvedValue([node(1, "全集团", null, "/1/", true)] as any);
mockedSearch.mockResolvedValue(emptySearch as any);
});
it("lazy-loads the grant root layer via the resource-scoped children endpoint", async () => {
render(
<SubjectSearchDepartment
value={[]}
onChange={jest.fn()}
resourceType="workflow"
resourceId="wf-1"
includeChildren
/>,
);
await waitFor(() => {
expect(mockedChildren).toHaveBeenCalledWith("workflow", "wf-1", null, {
signal: expect.any(AbortSignal),
});
});
expect(await screen.findByText("全集团")).toBeInTheDocument();
});
it("shows descendants as checked + disabled (implicit) when an ancestor grant includes children — decision 9 (path-based)", async () => {
mockedSearch.mockResolvedValue({
roots: [
{ ...node(1, "全集团", null, "/1/", true), children: [node(2, "子部门", 1, "/1/2/", false, true)] },
],
total_matches: 1,
truncated: false,
} as any);
const value: SelectedSubject[] = [
{ type: "department", id: 1, name: "全集团", include_children: true },
];
render(
<SubjectSearchDepartment
value={value}
onChange={jest.fn()}
resourceType="workflow"
resourceId="wf-1"
includeChildren
/>,
);
// Root load primes the ancestor's path so implicit selection resolves.
await screen.findByText("全集团");
fireEvent.change(screen.getByPlaceholderText("com_permission.search_department"), {
target: { value: "子部门" },
});
const childLabel = await screen.findByText("子部门");
const childCheckbox = within(childLabel.parentElement as HTMLElement).getByRole("checkbox");
expect(childCheckbox).toHaveAttribute("data-state", "checked");
expect(childCheckbox).toBeDisabled();
});
it("summarizes only the explicit picks, never the materialized subtree — decision 10", async () => {
const onSelectionSummaryChange = jest.fn();
render(
<SubjectSearchDepartment
value={[{ type: "department", id: 1, name: "全集团", include_children: true }]}
onChange={jest.fn()}
resourceType="workflow"
resourceId="wf-1"
includeChildren
onSelectionSummaryChange={onSelectionSummaryChange}
/>,
);
await waitFor(() => {
expect(onSelectionSummaryChange).toHaveBeenLastCalledWith([
{ type: "department", id: 1, name: "全集团", include_children: true },
]);
});
});
it("shows already granted departments as disabled and unchecked without selecting them", async () => {
render(
<SubjectSearchDepartment
value={[]}
onChange={jest.fn()}
resourceType="workflow"
resourceId="wf-1"
includeChildren
disabledIds={[1]}
/>,
);
const departmentLabel = await screen.findByText("全集团");
const checkbox = within(departmentLabel.parentElement as HTMLElement).getByRole("checkbox");
expect(checkbox).toHaveAttribute("data-state", "unchecked");
expect(checkbox).toBeDisabled();
expect(screen.getByText("com_permission.already_granted")).toBeInTheDocument();
});
it("adds a department carrying the current include-children flag when toggled on", async () => {
const onChange = jest.fn();
render(
<SubjectSearchDepartment
value={[]}
onChange={onChange}
resourceType="workflow"
resourceId="wf-1"
includeChildren
/>,
);
fireEvent.click(await screen.findByText("全集团"));
expect(onChange).toHaveBeenCalledWith([
{ type: "department", id: 1, name: "全集团", include_children: true },
]);
});
});
@@ -136,7 +136,13 @@ const SelectItem = React.forwardRef<
</SelectPrimitive.ItemIndicator>
</span>
)}
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{/* Radix ItemText silently drops className/style, so the shrink constraint
lives on this wrapper: as a flex child it must be allowed to go below
its content width, otherwise long content overflows past the pr-8
indicator area instead of truncating before the check mark. */}
<div className="min-w-0 flex-1">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</div>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
+1
View File
@@ -24,6 +24,7 @@ export { default as useNewConvo } from './useNewConvo';
export { default as useLocalize } from './useLocalize';
export type { TranslationKeys } from './useLocalize';
export { default as useMediaQuery } from './useMediaQuery';
export { useContainerCompact, TOOLBAR_COMPACT_THRESHOLD } from './useContainerCompact';
export { default as usePrefersMobileLayout } from './usePrefersMobileLayout';
export { default as useScrollToRef } from './useScrollToRef';
export { useScrollRevealRef } from './useScrollRevealRef';
@@ -0,0 +1,48 @@
import { useCallback, useRef, useState } from 'react';
/**
* Width (px) below which the input toolbars collapse their button labels to
* icons. Sized for the WIDEST locale (English labels — "Knowledge Space",
* "Task mode" — are far longer than the Chinese ones), so labels collapse as a
* group before any single one truncates, in every language. Tune here once;
* both toolbars (AiChatInput, TaskModeInput) read this same value.
*/
export const TOOLBAR_COMPACT_THRESHOLD = 440;
/**
* Report whether a container's inline width has dropped below `threshold`,
* measured live via a ResizeObserver.
*
* Input toolbars collapse their button labels to icon-only when space runs
* out. Viewport media queries are the wrong signal for that: the same viewport
* width leaves very different room once the sidebar opens. This hook measures
* the ACTUAL space available (the flex-1 toolbar column), so labels collapse
* exactly when the box that holds them can no longer fit them.
*
* The observed element must be layout-sized (e.g. `flex-1`), NOT content-sized:
* its width is then decided by the row, independent of whether labels are shown,
* so hiding labels can't feed back into the measurement and oscillate.
*
* Returns a callback ref (re-attaches the observer across conditional remounts,
* which a deps-based effect would miss) plus the current `compact` flag.
*/
export function useContainerCompact(threshold: number) {
const [compact, setCompact] = useState(false);
const observerRef = useRef<ResizeObserver | null>(null);
const ref = useCallback(
(el: HTMLElement | null) => {
observerRef.current?.disconnect();
observerRef.current = null;
if (!el) return;
const update = () => setCompact(el.clientWidth < threshold);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
observerRef.current = ro;
},
[threshold],
);
return { ref, compact };
}
@@ -1520,8 +1520,8 @@
"empty_go_explore": "No apps used yet. Head to the app marketplace",
"explore_more": "Explore more apps",
"recent_apps_hint": "Your recently used apps are all here",
"service_maintenance_title": "System under maintenance",
"service_maintenance": "We're performing emergency maintenance; service will be back soon.",
"service_maintenance_title": "Oops, we hit a small hiccup",
"service_maintenance": "Hang tight for a moment — we'll be back to normal shortly.",
"refresh": "Refresh to retry"
},
"com_knowledge": {
@@ -1444,8 +1444,8 @@
"empty_go_explore": "利用したアプリはまだありません。アプリ広場へどうぞ",
"explore_more": "さらにアプリを探す",
"recent_apps_hint": "最近使用したアプリはすべてここにあります~",
"service_maintenance_title": "システムメンテナンス中",
"service_maintenance": "緊急メンテナンスを実施中です。まもなく復旧します。",
"service_maintenance_title": "おっと、システムに少し問題が発生しました",
"service_maintenance": "少々お待ちください。まもなく通常どおりご利用いただけます。",
"refresh": "再読み込み"
},
"com_knowledge": {
@@ -1447,8 +1447,8 @@
"empty_go_explore": "暂无使用过的应用,可以前往应用广场",
"explore_more": "探索更多应用",
"recent_apps_hint": "最近使用过的应用都在这里~",
"service_maintenance_title": "系统维护中",
"service_maintenance": "我们正在进行紧急维护,服务将尽快恢复。",
"service_maintenance_title": "哎呀,系统出了点小状况",
"service_maintenance": "短暂停留一下,马上恢复正常使用",
"refresh": "刷新重试"
},
"com_knowledge": {
@@ -792,7 +792,7 @@ export function ArticleList({
<LoadingIcon className="size-20 text-primary" />
</div>
) : articles.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center py-60 text-center">
<div className="flex flex-1 flex-col items-center justify-center py-8 text-center">
{(searchQuery || selectedSources.length > 0 || onlyUnread) ? (
<>
<EmptyStateIllustration className="size-[120px] mb-4 opacity-90" />
@@ -1,160 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChannelRole } from "~/api/channels";
import type { Channel } from "~/api/channels";
import { ChannelActionsMenu } from "./ChannelActionsMenu";
// react-query is mocked so the component reads channel lists straight from the
// stubbed cache; queryKey[1] is "created" | "subscribed" (see component).
const mockLists: Record<string, Channel[]> = { created: [], subscribed: [] };
jest.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: unknown[] }) => ({
data: mockLists[queryKey[1] as string] ?? [],
}),
}));
const mockHandleDeleteChannel = jest.fn();
const mockHandleUnsubscribeChannel = jest.fn();
jest.mock("../hooks/useChannelActions", () => ({
useChannelActions: () => ({
handleDeleteChannel: mockHandleDeleteChannel,
handleUnsubscribeChannel: mockHandleUnsubscribeChannel,
}),
}));
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => {
const labels: Record<string, string> = {
"com_subscription.channel_settings": "频道设置",
"com_subscription.edit_channel": "编辑频道",
"com_subscription.permission_management": "权限管理",
"com_subscription.share": "分享",
"com_subscription.source_filter": "信息源筛选",
"com_subscription.dissolve_channel": "解散频道",
"com_subscription.delete_channel": "删除频道",
"com_subscription.unsubscribe": "取消订阅",
"com_subscription.prompt_tip": "提示",
"com_subscription.confirm_delete_channel_for_all": "删除频道",
"com_subscription.confirm_unsubscribe_channel_and_subs": "取消订阅",
"com_subscription.confirm": "确认",
"com_subscription.cancel": "取消",
};
return labels[key] ?? key;
},
}));
jest.mock("~/Providers", () => ({
useConfirm: () => jest.fn().mockResolvedValue(true),
useToastContext: () => ({ showToast: jest.fn() }),
}));
jest.mock("bisheng-icons", () => ({
Outlined: new Proxy(
{},
{ get: () => () => <span data-testid="icon" /> },
),
}));
const createChannel = (role: Channel["role"], permissionIds?: string[]): Channel => ({
id: "channel-1",
name: "资讯频道",
creator: "owner",
creatorId: "1",
subscriberCount: 3,
articleCount: 5,
unreadCount: 0,
role,
isPinned: false,
createdAt: "2026-05-28T00:00:00Z",
updatedAt: "2026-05-28T00:00:00Z",
subChannels: [],
permissionIds,
});
function renderMenu(
list: "created" | "subscribed",
role: Channel["role"],
permissionIds?: string[],
) {
const channel = createChannel(role, permissionIds);
mockLists.created = [];
mockLists.subscribed = [];
mockLists[list] = [channel];
const props = {
channel,
onChannelSelect: jest.fn(),
onManageMembers: jest.fn(),
onChannelSettings: jest.fn(),
};
const view = render(<ChannelActionsMenu {...props} />);
return { ...view, props };
}
async function openMenu(container: HTMLElement) {
const user = userEvent.setup();
const trigger = container.querySelector("button");
expect(trigger).not.toBeNull();
await user.click(trigger as HTMLButtonElement);
return user;
}
describe("ChannelActionsMenu permission gating", () => {
beforeEach(() => {
mockHandleDeleteChannel.mockClear();
mockHandleUnsubscribeChannel.mockClear();
});
it("shows channel settings to a granted owner whose channel sits in the followed list", async () => {
const { container } = renderMenu("subscribed", "owner", [
"view_channel",
"edit_channel",
"delete_channel",
"manage_channel_owner",
]);
await openMenu(container);
expect(await screen.findByText("频道设置")).toBeInTheDocument();
expect(screen.getByText("权限管理")).toBeInTheDocument();
});
it("shows both dissolve and unsubscribe to a granted owner in the followed list", async () => {
const { container } = renderMenu("subscribed", "owner", [
"view_channel",
"edit_channel",
"delete_channel",
"manage_channel_owner",
]);
await openMenu(container);
expect(await screen.findByText("解散频道")).toBeInTheDocument();
expect(screen.getByText("取消订阅")).toBeInTheDocument();
});
it("shows channel settings to an editor (edit permission, no delete) in the followed list", async () => {
const { container } = renderMenu("subscribed", "editor", ["view_channel", "edit_channel"]);
await openMenu(container);
expect(await screen.findByText("频道设置")).toBeInTheDocument();
// Editor cannot dissolve (no delete_channel) but can leave.
expect(screen.queryByText("解散频道")).not.toBeInTheDocument();
expect(screen.getByText("取消订阅")).toBeInTheDocument();
});
it("hides channel settings from a plain subscriber (viewer)", async () => {
const { container } = renderMenu("subscribed", "viewer", ["view_channel"]);
await openMenu(container);
expect(await screen.findByText("取消订阅")).toBeInTheDocument();
expect(screen.queryByText("频道设置")).not.toBeInTheDocument();
expect(screen.queryByText("解散频道")).not.toBeInTheDocument();
});
it("shows only dissolve (no unsubscribe) for the creator's own channel", async () => {
const { container } = renderMenu("created", ChannelRole.CREATOR);
await openMenu(container);
expect(await screen.findByText("频道设置")).toBeInTheDocument();
expect(screen.getByText("解散频道")).toBeInTheDocument();
expect(screen.queryByText("取消订阅")).not.toBeInTheDocument();
});
});
@@ -1,191 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChannelRole } from "~/api/channels";
import type { Channel } from "~/api/channels";
import ChannelItem from "./ChannelItem";
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => {
const labels: Record<string, string> = {
"com_subscription.channel_settings": "频道设置",
"com_subscription.member_management": "成员管理",
"com_subscription.unpin": "取消置顶",
"com_subscription.pin_channel": "置顶频道",
"com_subscription.prompt_tip": "提示",
"com_subscription.confirm_unsubscribe_channel_and_subs": "取消订阅",
"com_subscription.confirm_delete_channel_for_all": "删除频道",
"com_subscription.confirm": "确认",
"com_subscription.cancel": "取消",
"com_subscription.max_10_characters": "最多10个字符",
"com_subscription.dissolve_channel": "解散频道",
"com_subscription.unsubscribe": "取消订阅",
};
return labels[key] ?? key;
},
}));
jest.mock("~/Providers", () => ({
useConfirm: () => jest.fn().mockResolvedValue(true),
useToastContext: () => ({
showToast: jest.fn(),
}),
}));
jest.mock("~/components/ui/icon/ClosedIcon", () => ({
__esModule: true,
default: () => <span data-testid="closed-icon" />,
}));
jest.mock("~/components/icons/channels", () => ({
ChannelPinIcon: () => <span data-testid="pin-icon" />,
}));
jest.mock("~/components/icons/SpaceNotebookIcon", () => ({
SpaceNotebookIcon: () => <span data-testid="notebook-icon" />,
}));
const createChannel = (role: Channel["role"], permissionIds?: string[]): Channel => ({
id: "channel-1",
name: "资讯频道",
creator: "owner",
creatorId: "1",
subscriberCount: 3,
articleCount: 5,
unreadCount: 0,
role,
isPinned: false,
createdAt: "2026-05-28T00:00:00Z",
updatedAt: "2026-05-28T00:00:00Z",
subChannels: [],
permissionIds,
});
function renderChannelItem(
role: Channel["role"],
type: "created" | "subscribed" = "subscribed",
permissionIds?: string[],
) {
const props = {
channel: createChannel(role, permissionIds),
isActive: false,
type,
onSelect: jest.fn(),
onUpdate: jest.fn(),
onDelete: jest.fn(),
onUnsubscribe: jest.fn(),
onPin: jest.fn(),
onManageMembers: jest.fn(),
onChannelSettings: jest.fn(),
};
const view = render(<ChannelItem {...props} />);
return { ...view, props };
}
describe("ChannelItem relation actions", () => {
it("shows channel settings to editor without member management", async () => {
const user = userEvent.setup();
const { container } = renderChannelItem("editor");
const menuTrigger = container.querySelector("button");
expect(menuTrigger).not.toBeNull();
await user.click(menuTrigger as HTMLButtonElement);
expect(await screen.findByText("频道设置")).toBeInTheDocument();
expect(screen.queryByText("成员管理")).not.toBeInTheDocument();
});
it("does not show channel settings to viewer", async () => {
const user = userEvent.setup();
const { container } = renderChannelItem("viewer");
const menuTrigger = container.querySelector("button");
expect(menuTrigger).not.toBeNull();
await user.click(menuTrigger as HTMLButtonElement);
expect(screen.queryByText("频道设置")).not.toBeInTheDocument();
expect(screen.queryByText("成员管理")).not.toBeInTheDocument();
});
it("hides member management when manager model no longer grants it", async () => {
const user = userEvent.setup();
const { container } = renderChannelItem("manager", "subscribed", ["view_channel", "edit_channel"]);
const menuTrigger = container.querySelector("button");
expect(menuTrigger).not.toBeNull();
await user.click(menuTrigger as HTMLButtonElement);
expect(await screen.findByText("频道设置")).toBeInTheDocument();
expect(screen.queryByText("成员管理")).not.toBeInTheDocument();
});
it("keeps legacy creator able to open channel settings", async () => {
const user = userEvent.setup();
const { container } = renderChannelItem(ChannelRole.CREATOR, "created");
const menuTrigger = container.querySelector("button");
expect(menuTrigger).not.toBeNull();
await user.click(menuTrigger as HTMLButtonElement);
expect(await screen.findByText("频道设置")).toBeInTheDocument();
});
it("shows both dissolve and unsubscribe to a subscribed user granted delete_channel", async () => {
const user = userEvent.setup();
const { container } = renderChannelItem("manager", "subscribed", [
"view_channel",
"edit_channel",
"delete_channel",
]);
const menuTrigger = container.querySelector("button");
expect(menuTrigger).not.toBeNull();
await user.click(menuTrigger as HTMLButtonElement);
expect(await screen.findByText("解散频道")).toBeInTheDocument();
expect(screen.getByText("取消订阅")).toBeInTheDocument();
});
it("shows only dissolve (no unsubscribe) for a created channel", async () => {
const user = userEvent.setup();
const { container } = renderChannelItem(ChannelRole.CREATOR, "created", [
"view_channel",
"edit_channel",
"delete_channel",
]);
const menuTrigger = container.querySelector("button");
expect(menuTrigger).not.toBeNull();
await user.click(menuTrigger as HTMLButtonElement);
expect(await screen.findByText("解散频道")).toBeInTheDocument();
expect(screen.queryByText("取消订阅")).not.toBeInTheDocument();
});
it("triggers onDelete when a delete-permitted subscriber dissolves", async () => {
const user = userEvent.setup();
const { container, props } = renderChannelItem("manager", "subscribed", [
"view_channel",
"delete_channel",
]);
const menuTrigger = container.querySelector("button");
await user.click(menuTrigger as HTMLButtonElement);
await user.click(await screen.findByText("解散频道"));
expect(props.onDelete).toHaveBeenCalledWith("channel-1");
expect(props.onUnsubscribe).not.toHaveBeenCalled();
});
it("shows unsubscribe to a subscriber without delete permission", async () => {
const user = userEvent.setup();
const { container } = renderChannelItem("viewer", "subscribed", ["view_channel"]);
const menuTrigger = container.querySelector("button");
expect(menuTrigger).not.toBeNull();
await user.click(menuTrigger as HTMLButtonElement);
expect(await screen.findByText("取消订阅")).toBeInTheDocument();
expect(screen.queryByText("解散频道")).not.toBeInTheDocument();
});
});
@@ -1,118 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { NotificationSeverity } from "~/common";
import type { Channel } from "~/api/channels";
import { SortType } from "~/api/channels";
import { useChannelActions } from "./useChannelActions";
const mockShowToast = jest.fn();
const mockUnsubscribeChannelApi = jest.fn();
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => {
const labels: Record<string, string> = {
"com_subscription.unsubscribe_failed_retry": "取消订阅失败,请重试",
"com_subscription.unsubscribed": "已取消订阅",
"com_subscription.organization_grant_unsubscribe_blocked": ORGANIZATION_GRANT_MESSAGE,
};
return labels[key] ?? key;
},
}));
jest.mock("~/Providers", () => ({
useToastContext: () => ({
showToast: mockShowToast,
}),
}));
jest.mock("~/api/channels", () => ({
SortType: {
RECENT_UPDATE: "latest_update",
RECENT_ADDED: "latest_added",
NAME: "channel_name",
},
pinChannelApi: jest.fn(),
updateChannelApi: jest.fn(),
deleteChannelApi: jest.fn(),
unsubscribeChannelApi: (...args: unknown[]) => mockUnsubscribeChannelApi(...args),
}));
const ORGANIZATION_GRANT_MESSAGE = "本频道通过部门/用户组授权给你,暂无法取消订阅";
function createChannel(id = "channel-1"): Channel {
return {
id,
name: "资讯频道",
creator: "owner",
creatorId: "1",
subscriberCount: 3,
articleCount: 5,
unreadCount: 0,
role: "viewer",
isPinned: false,
createdAt: "2026-05-28T00:00:00Z",
updatedAt: "2026-05-28T00:00:00Z",
subChannels: [],
permissionIds: ["view_channel"],
};
}
describe("useChannelActions unsubscribe", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
mockShowToast.mockClear();
mockUnsubscribeChannelApi.mockReset();
});
function wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
it("shows organization grant message and restores optimistic state when unsubscribe is blocked", async () => {
const channel = createChannel();
const onChannelSelect = jest.fn();
queryClient.setQueryData(["channels", "subscribed", SortType.RECENT_UPDATE], [channel]);
const invalidateQueriesSpy = jest.spyOn(queryClient, "invalidateQueries");
mockUnsubscribeChannelApi.mockResolvedValue({
status_code: 19055,
});
const { result } = renderHook(() => useChannelActions({
activeChannelId: channel.id,
createdSortBy: SortType.RECENT_UPDATE,
subscribedSortBy: SortType.RECENT_UPDATE,
createdChannels: [],
subscribedChannels: [channel],
onChannelSelect,
}), { wrapper });
await act(async () => {
await result.current.handleUnsubscribeChannel(channel.id);
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: ORGANIZATION_GRANT_MESSAGE,
severity: NotificationSeverity.ERROR,
});
});
expect(mockShowToast).not.toHaveBeenCalledWith(expect.objectContaining({
message: "已取消订阅",
}));
expect(queryClient.getQueryData(["channels", "subscribed", SortType.RECENT_UPDATE])).toEqual([channel]);
expect(onChannelSelect).toHaveBeenLastCalledWith(channel);
expect(onChannelSelect).not.toHaveBeenCalledWith(null);
expect(mockUnsubscribeChannelApi).toHaveBeenCalledWith(channel.id);
expect(invalidateQueriesSpy).not.toHaveBeenCalledWith({
queryKey: ["channels", "subscribed"],
});
});
});
@@ -1,73 +0,0 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { NotificationSeverity } from "~/common";
import { useCrawlQueue } from "./useCrawlQueue";
const mockShowToast = jest.fn();
const mockCrawlTempSourceApi = jest.fn();
const mockAddWebsiteSourceApi = jest.fn();
// p-limit ships as ESM and is not transformed by jest; replace it with a
// pass-through limiter that runs the task immediately.
jest.mock("p-limit", () => ({
__esModule: true,
default: () => (fn: () => unknown) => fn(),
}));
jest.mock("~/utils", () => ({
generateUUID: () => "test-crawl-id",
}));
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock("~/Providers", () => ({
useToastContext: () => ({ showToast: mockShowToast }),
}));
jest.mock("~/api/channels", () => ({
crawlTempSourceApi: (...args: unknown[]) => mockCrawlTempSourceApi(...args),
addWebsiteSourceApi: (...args: unknown[]) => mockAddWebsiteSourceApi(...args),
}));
describe("useCrawlQueue API-key limit handling", () => {
beforeEach(() => {
mockShowToast.mockClear();
mockCrawlTempSourceApi.mockReset();
mockAddWebsiteSourceApi.mockReset();
});
it("shows an error popup when crawl is rejected with the 19006 API-key limit code", async () => {
mockCrawlTempSourceApi.mockResolvedValue({ status_code: 19006 });
const { result } = renderHook(() => useCrawlQueue({ onSourceAdded: jest.fn() }));
act(() => {
result.current.enqueue("https://example.com");
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: "api_errors.19006",
severity: NotificationSeverity.ERROR,
});
});
// The site is never added once the account quota is exhausted.
expect(mockAddWebsiteSourceApi).not.toHaveBeenCalled();
});
it("does not popup for per-site crawl failures (19003), which stay as queue tooltips", async () => {
mockCrawlTempSourceApi.mockResolvedValue({ status_code: 19003 });
const { result } = renderHook(() => useCrawlQueue({ onSourceAdded: jest.fn() }));
act(() => {
result.current.enqueue("https://example.com/article/123");
});
await waitFor(() => {
expect(result.current.queue[0]?.status).toBe("failed");
});
expect(mockShowToast).not.toHaveBeenCalled();
});
});
@@ -109,8 +109,9 @@ export function ConfirmDialogSection() {
subtitle={
<>
/ / {' '}
<code>OGDialogTemplate selection</code> 14 <code>useConfirm()</code>
24 8 <b></b>B C 9 selectClasses
<code>OGDialogTemplate selection</code> 13 7 UI + 6 {' '}
<code>useConfirm()</code>26 10
<b></b> UI SidePanel + Chat/Header Modal <b></b>B C 9 selectClasses
danger / primary
9 <code>AlertDialog</code> Modal
</>
@@ -126,7 +127,7 @@ export function ConfirmDialogSection() {
<>
<code>OGDialogTemplate</code> + <code>selection</code>
</>,
'14(原 21,迁移中',
'13(原 21;剩余全是死 UI 或表单',
'旧页面(会话/书签/Agent/设置/Prompt…LibreChat 血统)',
'差 · 确认按钮 9 种写法',
],
@@ -135,7 +136,7 @@ export function ConfirmDialogSection() {
<>
<code>useConfirm()</code>ConfirmContext + AlertDialog
</>,
'24(收敛目标,含已迁入 8 处)',
'26(收敛完成,含已迁入 10 处)',
'新页面(知识空间 / 订阅频道 / 权限)',
'好 · 样式集中在一个文件,destructive/default 两档',
],
@@ -151,20 +152,20 @@ export function ConfirmDialogSection() {
[
'1',
<code key="c">bg-red-700 dark:bg-red-600 hover:bg-red-800 </code>,
'删除(书签/工具/分享链接…)· 删会话 2 处已迁 C',
'6(原 8',
'可达的 4 处已迁 C;剩 4 处全是死 UI(书签/分享弹窗/两个工具移除)',
'4(原 8· 全死 UI',
],
[
'2',
<code key="c">bg-red-600 hover:bg-red-700 dark:hover:bg-red-800</code>,
'删除Agent / Assistant)· Prompt 组已迁 C',
'2(原 3',
'删除 Agent / Assistant —— 死 UISidePanel 被注释)',
'2(原 3· 全死 UI',
],
[
'3',
<code key="c">bg-red-600 hover:bg-red-700 dark:hover:bg-red-600</code>,
'清空预设',
'1',
'清空预设 —— 死 UIChat/Header 无人引用)',
'1 · 死 UI',
],
[
'4',
@@ -292,7 +293,7 @@ export function ConfirmDialogSection() {
/>
<ConfirmDemo
label="Loading 态(isLoading: true"
note="模板内置 Spinner · 自塞 Spinner 的只剩 SharedLinks 1 处(原 4 处,3 处已迁 C"
note="模板内置 Spinner · 各页自塞 Spinner 的写法已随迁移清零(原 4 处"
title="删除会话"
body="确认按钮处于加载中。"
selectText="删除"
@@ -1,10 +1,13 @@
/**
* Modal / Dialog gallery DEV-ONLY. See docs-ui-refactor/-Modal弹窗.md.
*
* Renders BiSheng's two parallel dialog families side by side so the difference
* (overlay darkness / blur / z-index) is visible by opening them.
* Fresh survey (2026-07-09): FIVE coexisting modal populations. The same demo
* content (title + description + input + cancel/confirm footer) is mounted into
* each shell so the shell differences (overlay / radius / padding / title /
* buttons) are the only variable when opening them side by side.
*/
import { Button } from '~/components/ui/Button';
import { Input } from '~/components/ui/Input';
import {
Dialog,
DialogTrigger,
@@ -23,16 +26,46 @@ import {
OGDialogTitle,
OGDialogDescription,
} from '~/components/ui/OriginalDialog';
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
} from '~/components/ui/AlertDialog';
import DialogTemplate from '~/components/ui/DialogTemplate';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useConfirm } from '~/Providers';
import { Section, Demo, DemoGrid, CompareTable } from '../components/kit';
const sampleBody = (
<p className="text-sm text-text-primary">
</p>
/** Identical body for every shell so only the shell itself differs. */
const demoBody = (
<div className="flex flex-col gap-3">
<p className="text-sm text-text-primary">
/
</p>
<Input placeholder="示例输入框" />
</div>
);
/** Small reference demo of the finalized confirm dialog, for shell comparison. */
function ConfirmReferenceDemo() {
const confirm = useConfirm();
return (
<Button
variant="outline"
onClick={() =>
confirm({
variant: 'destructive',
title: '确认删除',
description: '弹窗壳视觉参照:圆角16 / p-5 / 灰底毛玻璃遮罩。',
confirmText: '确认删除',
})
}
>
</Button>
);
}
export function ModalSection() {
return (
<Section
@@ -40,94 +73,263 @@ export function ModalSection() {
title="Modal 弹窗"
subtitle={
<>
<b></b>A <code>Dialog</code> z-100 B {' '}
<code>OriginalDialog / OG</code> z-50
2026-07-09 <b>5 64 </b>
B C A 22 + 3
AlertDialog7
</>
}
>
{/* Difference table */}
<div className="mb-6">
{/* ① Population overview */}
<h3 className="mb-3 mt-2 text-base font-semibold text-text-primary">
ui/
</h3>
<div className="mb-8">
<CompareTable
head={['维度', 'A 套 Dialog', 'B 套 OriginalDialog / OG']}
head={['体系', '实现', '业务文件数', '用在哪 / 备注']}
rows={[
['遮罩颜色', 'bg-black/40(浅)', 'bg-black/80(深)'],
['毛玻璃模糊', '有 backdrop-blur-md', '无'],
['层级 z-index', 'z-[100]', 'z-50'],
['便捷模板', 'DialogTemplate~4 处)', 'OGDialogTemplate~25 处 · 用得最多)'],
[
'A 套 · 原语直接拼',
'Dialog + DialogContent 手拼',
<b key="a1">22</b>,
'新页面为主:知识库 8、订阅 2、审批/通知/账号/分享/appChat…(含 MainLayout 全局弹窗)',
],
[
'A 套 · 模板',
'DialogTemplate',
'3',
'EditPresetDialog、PresetItems、ContextButton(后者在 SidePanel 死树)',
],
[
'B 套 · 模板',
'OGDialogTemplate',
'16(原 25,确认迁移后)',
'书签/导出/SetKey/归档/Agent 面板…(其中 SidePanel 死树约 6 处);壳已对齐 C 套',
],
[
'B 套 · 原语直接拼',
'OGDialog + OGDialogContent 手拼',
'16',
'设置(账号/数据)、Prompts、文件预览、ShareAgent…;壳同上(已对齐 C 套)',
],
[
'手拼 AlertDialog',
'AlertDialogContent + 自拼头尾',
'7',
'频道成员 2、爬取系 4、灵思 TaskModeInput(部分带确认性质,本期一并处理)',
],
[
'C 套 · useConfirm(参照)',
'ConfirmContextAlertDialog 底层)',
'26(已收敛 ✅)',
'二次确认已定稿的视觉基准:圆角16 / p-5 / 灰底毛玻璃 —— Modal 壳的天然候选',
],
]}
/>
</div>
<DemoGrid cols={4}>
{/* A-set raw primitives */}
<Demo label="A 套 · Dialog 原语" note="ui/Dialog.tsx · 毛玻璃遮罩">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline"></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Dialog </DialogTitle>
<DialogDescription> Header / Footer </DialogDescription>
</DialogHeader>
{sampleBody}
<DialogFooter>
<DialogClose asChild>
{/* ② Shell anatomy */}
<h3 className="mb-3 text-base font-semibold text-text-primary">
</h3>
<div className="mb-8">
<CompareTable
head={['维度', 'A 套 Dialog', 'B 套 OriginalDialog(已对齐 C 套)', 'AlertDialog(手拼底座)']}
rows={[
[
'遮罩',
'bg-black/40 + blur(浅黑毛玻璃)',
'bg-gray-500/90 + blur(灰白毛玻璃)',
'bg-gray-500/90 + blur(同 B',
],
['层级 z-index', 'z-[100]', 'z-50', 'z-[110]'],
['圆角', 'sm:rounded-lg8px,移动端直角)', 'rounded-2xl16px', 'sm:rounded-lg8px,移动端直角)'],
['内边距', 'p-520px', 'p-520px', 'p-624px'],
[
'边框 / 阴影',
'border + shadow-lg',
'border #ebebeb + 淡投影',
'无边框、无阴影',
],
[
'标题',
'text-base font-semibold',
'text-base font-medium',
'组件是 text-lg semibold,但各页多自拼标题行',
],
[
'关闭按钮 ×',
'内置右上(可关)',
'内置右上(可关)',
'无内置,各页自拼',
],
[
'深色模式底色',
'dark:bg-[#303134](写死)',
'bg-background(跟主题)',
'dark:bg-gray-900',
],
[
'移动端行为',
'居中缩放出现',
'居中缩放出现',
'从底部滑入、贴底',
],
[
'footer 按钮',
'各页自拼(多为 Button outline + default',
'模板:取消白底描边 + 确认 danger/primary 档;直接拼的各页自理',
'各页自拼(红 #F53F3F 等)',
],
]}
/>
</div>
{/* ③ Side-by-side demos — identical content, different shells */}
<h3 className="mb-3 text-base font-semibold text-text-primary">
</h3>
<div className="mb-8">
<DemoGrid cols={3}>
{/* A-set raw primitives — the largest population */}
<Demo label="A 套 · 原语直接拼(22 处)" note="ui/Dialog.tsx · 浅黑毛玻璃 · 圆角8 · p-5">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline"></Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{demoBody}
<DialogFooter>
<DialogClose asChild>
<Button variant="outline"></Button>
</DialogClose>
<Button></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Demo>
{/* A-set template — legacy default black confirm button */}
<Demo label="A 套 · DialogTemplate3 处)" note="旧模板 · 默认黑底确认钮 · 正文额外 px-6">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline"></Button>
</DialogTrigger>
<DialogTemplate
title="弹窗标题"
description="标题下的说明文字。"
main={demoBody}
selection={{ selectHandler: () => null, selectText: '确定' }}
/>
</Dialog>
</Demo>
{/* B-set template — shell already aligned with C-set */}
<Demo label="B 套 · OGDialogTemplate16 处)" note="壳已对齐 C 套 · 圆角16 · 取消/确认已统一">
<OGDialog>
<OGDialogTrigger asChild>
<Button variant="outline"></Button>
</OGDialogTrigger>
<OGDialogTemplate
title="弹窗标题"
description="标题下的说明文字。"
className="max-w-md"
main={demoBody}
selection={{
selectHandler: () => null,
selectVariant: 'primary',
selectText: '确定',
}}
/>
</OGDialog>
</Demo>
{/* B-set raw primitives — same shell, hand-rolled body/footer */}
<Demo label="B 套 · OG 原语直接拼(16 处)" note="壳同左 · 头尾各页自拼(设置/Prompts 老页面)">
<OGDialog>
<OGDialogTrigger asChild>
<Button variant="outline"></Button>
</OGDialogTrigger>
<OGDialogContent className="max-w-md">
<OGDialogHeader>
<OGDialogTitle></OGDialogTitle>
<OGDialogDescription></OGDialogDescription>
</OGDialogHeader>
{demoBody}
<div className="flex justify-end gap-2">
<Button variant="outline"></Button>
</DialogClose>
<Button></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Demo>
<Button></Button>
</div>
</OGDialogContent>
</OGDialog>
</Demo>
{/* A-set template */}
<Demo label="A 套 · DialogTemplate" note="ui/DialogTemplate.tsx">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline"></Button>
</DialogTrigger>
<DialogTemplate
title="DialogTemplate"
description="传 title / main / buttons 的便捷版。"
main={sampleBody}
buttons={<Button></Button>}
/>
</Dialog>
</Demo>
{/* Hand-rolled AlertDialog population */}
<Demo
label="手拼 AlertDialog7 处)"
note="p-6 · 圆角8 · 无边框阴影 · z-110 · 移动端贴底滑入"
>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline"></Button>
</AlertDialogTrigger>
<AlertDialogContent className="max-w-md">
{/* Business pages hand-roll header/footer like this (ChannelMemberDialog etc.) */}
<h3 className="text-base font-medium text-text-primary"></h3>
{demoBody}
<div className="flex justify-end gap-2">
<Button variant="outline"></Button>
<Button></Button>
</div>
</AlertDialogContent>
</AlertDialog>
</Demo>
{/* B-set raw primitives */}
<Demo label="B 套 · OG 原语" note="ui/OriginalDialog.tsx · 纯深色遮罩">
<OGDialog>
<OGDialogTrigger asChild>
<Button variant="outline"></Button>
</OGDialogTrigger>
<OGDialogContent className="w-11/12 max-w-lg bg-background text-foreground">
<OGDialogHeader>
<OGDialogTitle>OG </OGDialogTitle>
<OGDialogDescription>OriginalDialog </OGDialogDescription>
</OGDialogHeader>
<div className="py-2">{sampleBody}</div>
</OGDialogContent>
</OGDialog>
</Demo>
{/* C-set reference — the finalized confirm shell */}
<Demo
label="C 套 · useConfirm 参照(已定稿)"
note="二次确认基准壳:圆角16 / p-5 / 灰底毛玻璃 —— Modal 壳候选"
>
<ConfirmReferenceDemo />
</Demo>
</DemoGrid>
</div>
{/* B-set template — the most used one */}
<Demo label="B 套 · OGDialogTemplate" note="用得最多 · 基准候选">
<OGDialog>
<OGDialogTrigger asChild>
<Button></Button>
</OGDialogTrigger>
<OGDialogTemplate
title="OGDialogTemplate"
description="全站用得最多的便捷模板,收敛基准候选。"
className="max-w-lg"
main={sampleBody}
buttons={<Button></Button>}
/>
</OGDialog>
</Demo>
</DemoGrid>
{/* ④ Decision checklist */}
<h3 className="mb-3 text-base font-semibold text-text-primary"> </h3>
<div className="rounded-xl border border-border-light bg-muted/20 p-5 text-sm leading-7 text-text-primary">
<ol className="list-decimal space-y-1 pl-5">
<li>
<b></b>A black/40+blur vs B/C gray-500/90+blur
black/80
</li>
<li>
<b></b>8pxA / AlertDialog vs 16pxB/C /
</li>
<li>
<b></b>p-520pxA/B/C vs p-624pxAlertDialogheader/body/footer gap-4
</li>
<li>
<b></b>font-semiboldA vs font-mediumB/C
</li>
<li>
<b> ×</b> A/B ×AlertDialog
</li>
<li>
<b>footer </b>/ Button outline+default C
+ danger/primary gap-2 vs gap-3
</li>
<li>
<b></b>z-50 / z-[100] / z-[110] Drawer/Sheet/Popover
</li>
<li>
<b></b>A 22 A B
</li>
</ol>
</div>
</Section>
);
}
@@ -1,246 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { KnowledgeSpacePreviewDrawer } from "./KnowledgeSpacePreviewDrawer";
import { SpaceRole, VisibilityType, getJoinedSpacesApi, getSpaceChildrenApi, getSpaceInfoApi, subscribeSpaceApi } from "~/api/knowledge";
jest.mock("~/Providers", () => ({
useToastContext: () => ({
showToast: jest.fn(),
}),
}));
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => {
const dict: Record<string, string> = {
"com_knowledge.loading": "加载中",
"com_knowledge.join": "加入",
"com_knowledge.joined": "已加入",
"com_knowledge.space_invalid_or_deleted": "该知识空间已失效或被删除",
"com_knowledge.collapse_drawer": "收起",
"com_knowledge.close": "关闭",
"com_knowledge.articles_count": "篇内容",
"com_knowledge.users_count": "用户",
"com_knowledge.space_view_requires_join": "加入后可查看详情",
"com_knowledge.exit_space_short": "退出空间",
"com_knowledge.withdraw_application": "撤回申请",
"com_knowledge.reapply": "重新申请",
};
return dict[key] || key;
},
usePrefersMobileLayout: () => false,
}));
jest.mock("./SpaceDetail/FileCard", () => ({
FileCard: () => <div data-testid="file-card" />,
}));
jest.mock("~/components/ui/Sheet", () => ({
Sheet: ({ open, children }: any) => (open ? <div data-testid="sheet">{children}</div> : null),
SheetContent: ({ children }: any) => <div>{children}</div>,
SheetHeader: ({ children }: any) => <div>{children}</div>,
SheetTitle: ({ children }: any) => <div>{children}</div>,
}));
jest.mock("~/components/ui/Tooltip2", () => ({
Tooltip: ({ children }: any) => <>{children}</>,
TooltipTrigger: ({ children }: any) => <>{children}</>,
TooltipContent: ({ children }: any) => <div>{children}</div>,
}));
jest.mock("~/components/ui/Button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
jest.mock("~/api/knowledge", () => ({
SpaceRole: {
CREATOR: "creator",
ADMIN: "admin",
MEMBER: "member",
},
VisibilityType: {
PUBLIC: "public",
PRIVATE: "private",
APPROVAL: "approval",
},
SPACE_CHILDREN_STATUS_SUCCESS_ONLY: [2],
getJoinedSpacesApi: jest.fn(),
getSpaceChildrenApi: jest.fn(),
getSpaceInfoApi: jest.fn(),
subscribeSpaceApi: jest.fn(),
unsubscribeSpaceApi: jest.fn(),
}));
describe("KnowledgeSpacePreviewDrawer", () => {
test("keeps fallback detail visible for unjoined square spaces when info fetch is denied", async () => {
const mockedGetSpaceInfoApi = jest.mocked(getSpaceInfoApi);
const mockedGetSpaceChildrenApi = jest.mocked(getSpaceChildrenApi);
mockedGetSpaceChildrenApi.mockResolvedValue({ data: [], total: 0 });
mockedGetSpaceInfoApi.mockRejectedValue(new Error("permission denied"));
const baseSpace = {
id: "space-1",
name: "未加入空间",
description: "这是广场卡片上的摘要",
icon: "",
visibility: VisibilityType.PUBLIC,
creator: "Zhou",
creatorId: "u-1",
memberCount: 3,
fileCount: 8,
totalFileCount: 8,
role: SpaceRole.MEMBER,
isPinned: false,
createdAt: "",
updatedAt: "",
tags: [],
isReleased: true,
isFollowed: false,
isPending: false,
};
function Wrapper() {
const [statusMap, setStatusMap] = useState<Record<string, "join" | "joined" | "pending" | "rejected">>({});
return (
<KnowledgeSpacePreviewDrawer
spaceId={baseSpace.id}
initialSpace={{
...baseSpace,
squareStatus: statusMap[baseSpace.id],
}}
open
onOpenChange={() => undefined}
onSquareStatusChange={(id, status) => {
setStatusMap((prev) => ({
...prev,
[id]: status,
}));
}}
/>
);
}
render(<Wrapper />);
await waitFor(() => {
expect(screen.getByText("未加入空间")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getAllByText("这是广场卡片上的摘要").length).toBeGreaterThan(0);
});
await new Promise((resolve) => setTimeout(resolve, 30));
expect(mockedGetSpaceInfoApi).toHaveBeenCalledTimes(1);
});
test("loads files for unjoined public square spaces", async () => {
const mockedGetSpaceInfoApi = jest.mocked(getSpaceInfoApi);
const mockedGetSpaceChildrenApi = jest.mocked(getSpaceChildrenApi);
const publicSpace = {
id: "space-public",
name: "公开空间",
description: "公开可浏览",
icon: "",
visibility: VisibilityType.PUBLIC,
creator: "Zhou",
creatorId: "u-1",
memberCount: 3,
fileCount: 1,
totalFileCount: 1,
role: SpaceRole.MEMBER,
isPinned: false,
createdAt: "",
updatedAt: "",
tags: [],
isReleased: true,
isFollowed: false,
isPending: false,
};
mockedGetSpaceInfoApi.mockResolvedValue(publicSpace as any);
mockedGetSpaceChildrenApi.mockResolvedValue({
data: [
{
id: "file-1",
name: "公开文件.pdf",
type: "pdf",
tags: [],
path: "公开文件.pdf",
spaceId: "space-public",
createdAt: "",
updatedAt: "",
},
],
total: 1,
} as any);
render(
<KnowledgeSpacePreviewDrawer
spaceId={publicSpace.id}
initialSpace={publicSpace as any}
open
onOpenChange={() => undefined}
/>
);
await waitFor(() => {
expect(mockedGetSpaceChildrenApi).toHaveBeenCalledWith(
expect.objectContaining({
space_id: "space-public",
file_status: [2],
})
);
expect(screen.getByTestId("file-card")).toBeInTheDocument();
});
expect(screen.queryByText("加入后可查看详情")).not.toBeInTheDocument();
});
test("allows reapplying from rejected preview state", async () => {
const mockedGetSpaceInfoApi = jest.mocked(getSpaceInfoApi);
const mockedGetJoinedSpacesApi = jest.mocked(getJoinedSpacesApi);
const mockedSubscribeSpaceApi = jest.mocked(subscribeSpaceApi);
const rejectedSpace = {
id: "space-2",
name: "审批空间",
description: "需要审批",
icon: "",
visibility: VisibilityType.APPROVAL,
creator: "Zhou",
creatorId: "u-1",
memberCount: 3,
fileCount: 8,
totalFileCount: 8,
role: SpaceRole.MEMBER,
isPinned: false,
createdAt: "",
updatedAt: "",
tags: [],
isReleased: true,
isFollowed: false,
isPending: false,
subscriptionStatus: "rejected",
};
mockedGetSpaceInfoApi.mockResolvedValue(rejectedSpace as any);
mockedGetJoinedSpacesApi.mockResolvedValue([]);
mockedSubscribeSpaceApi.mockResolvedValue({ status: "pending", spaceId: "space-2" });
render(
<KnowledgeSpacePreviewDrawer
spaceId={rejectedSpace.id}
initialSpace={rejectedSpace as any}
open
onOpenChange={() => undefined}
/>
);
fireEvent.click(await screen.findByRole("button", { name: "重新申请" }));
await waitFor(() => {
expect(mockedSubscribeSpaceApi).toHaveBeenCalledWith("space-2");
});
});
});
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { NoPermissionIllustration } from "~/components/illustrations";
import { EmptyStateIllustration, NoPermissionIllustration } from "~/components/illustrations";
import { ChevronRight, X } from "lucide-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "~/components/ui/Sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/Tooltip2";
@@ -21,6 +21,7 @@ import {
} from "~/api/knowledge";
import { checkPermission } from "~/api/permission";
import { cn } from "~/utils";
import { LoadingIcon } from "~/components/ui/icon/Loading";
import { useLocalize, usePrefersMobileLayout, useScrollRevealRef } from "~/hooks";
import { useEffectiveQuota } from "~/hooks/useEffectiveQuota";
@@ -58,6 +59,12 @@ export function KnowledgeSpacePreviewDrawer({
const [childrenPage, setChildrenPage] = useState(1);
const [childrenTotal, setChildrenTotal] = useState(0);
const [loadingChildrenMore, setLoadingChildrenMore] = useState(false);
// True while the FIRST page of the file list is in flight, so the empty
// state is not flashed before data arrives.
const [loadingFiles, setLoadingFiles] = useState(false);
// Guards against out-of-order responses when the effect re-fires (space /
// folder switch) while a previous first-page request is still pending.
const filesRequestSeqRef = useRef(0);
// F027: cursor for the next page of `getSpaceChildrenApi`. null on first
// page (and after a parent/space switch). Backend `next_cursor` advances
// it as the user scrolls.
@@ -197,17 +204,21 @@ export function KnowledgeSpacePreviewDrawer({
// Load file preview list for spaces that are visible to the current user
useEffect(() => {
if (!space || !canViewFiles) {
filesRequestSeqRef.current += 1;
setFilesPreview([]);
setChildrenTotal(0);
setChildrenPage(1);
setLoadingFiles(false);
return;
}
// Reset + initial load
const requestSeq = ++filesRequestSeqRef.current;
setFilesPreview([]);
setChildrenPage(1);
setChildrenTotal(0);
setLoadingChildrenMore(false);
setLoadingFiles(true);
// F027: reset cursor so the first request after a parent/space switch
// fetches page 1 (cursor=null) instead of inheriting a stale token.
preview_next_cursor_ref.current = null;
@@ -221,6 +232,7 @@ export function KnowledgeSpacePreviewDrawer({
...(fileStatusFilter ? { file_status: fileStatusFilter } : {}),
})
.then(res => {
if (requestSeq !== filesRequestSeqRef.current) return;
setFilesPreview(res.data);
// F027: derive a count surrogate from `has_more` since `total`
// is gone (used only to decide "load more" visibility).
@@ -228,8 +240,13 @@ export function KnowledgeSpacePreviewDrawer({
preview_next_cursor_ref.current = res.next_cursor ?? null;
})
.catch(() => {
if (requestSeq !== filesRequestSeqRef.current) return;
setFilesPreview([]);
setChildrenTotal(0);
})
.finally(() => {
if (requestSeq !== filesRequestSeqRef.current) return;
setLoadingFiles(false);
});
// Include join/subscription signals so file list loads when async info maps to joined without subscription_status.
// canViewApprovalContent is resolved asynchronously (checkPermission) for APPROVAL spaces; without it as a
@@ -511,7 +528,7 @@ export function KnowledgeSpacePreviewDrawer({
}}
>
{canViewFiles ? (
<div className="space-y-2">
<div className="flex min-h-full flex-col space-y-2">
<div className="mb-1 text-sm text-[#4E5969] flex items-center gap-2 flex-wrap">
<button
type="button"
@@ -536,9 +553,17 @@ export function KnowledgeSpacePreviewDrawer({
);
})}
</div>
{filesPreview.length === 0 ? (
<div className="flex items-center justify-center h-64 text-[#86909c] text-sm">
{localize("com_knowledge.no_files")}</div>
{loadingFiles ? (
<div className="flex flex-1 items-center justify-center">
<LoadingIcon className="size-20 text-primary" />
</div>
) : filesPreview.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center text-center">
<EmptyStateIllustration className="size-[120px] mb-4 opacity-90" />
<p className="text-[14px] font-normal text-[#999999]">
{localize("com_knowledge.no_files")}
</p>
</div>
) : (
<div className="grid grid-cols-2 gap-3 min-[768px]:grid-cols-3">
{filesPreview.map((f) => (
@@ -595,7 +620,11 @@ export function KnowledgeSpacePreviewDrawer({
</>
) : (
<div className="flex flex-1 items-center justify-center px-6 py-4 text-sm text-[#86909c] touch-mobile:px-0">
{loadingSpace ? localize("com_knowledge.loading") : localize("com_knowledge.space_invalid_or_deleted")}
{loadingSpace ? (
<LoadingIcon className="size-20 text-primary" />
) : (
localize("com_knowledge.space_invalid_or_deleted")
)}
</div>
)}
</SheetContent>
@@ -1,98 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import KnowledgeSquare from "./KnowledgeSquare";
import { getJoinedSpacesApi, getSquareSpacesApi, SpaceRole, subscribeSpaceApi, VisibilityType } from "~/api/knowledge";
jest.mock("~/Providers", () => ({
useToastContext: () => ({
showToast: jest.fn(),
}),
}));
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => {
const dict: Record<string, string> = {
"com_knowledge.explore_square": "知识广场",
"com_knowledge.explore_more_spaces": "探索更多空间",
"com_knowledge.search_space_placeholder": "搜索空间",
"com_knowledge.no_matched_space": "暂无空间",
"com_knowledge.reapply": "重新申请",
"com_knowledge.join": "加入",
"com_knowledge.pending": "待审批",
"com_knowledge.joined": "已加入",
"com_knowledge.no_description": "暂无描述",
"com_knowledge.users_count": "用户",
"com_knowledge.applied_to_join_space": "申请已发送",
"com_subscription.articles": "篇内容",
};
return dict[key] || key;
},
}));
jest.mock("~/api/knowledge", () => ({
SpaceRole: {
CREATOR: "creator",
ADMIN: "admin",
MEMBER: "member",
},
VisibilityType: {
PUBLIC: "public",
PRIVATE: "private",
APPROVAL: "approval",
},
getJoinedSpacesApi: jest.fn(),
getSquareSpacesApi: jest.fn(),
subscribeSpaceApi: jest.fn(),
}));
describe("KnowledgeSquare", () => {
test("reapplies from rejected card state", async () => {
const mockedGetSquareSpacesApi = jest.mocked(getSquareSpacesApi);
const mockedGetJoinedSpacesApi = jest.mocked(getJoinedSpacesApi);
const mockedSubscribeSpaceApi = jest.mocked(subscribeSpaceApi);
const onSquareStatusChange = jest.fn();
mockedGetSquareSpacesApi.mockResolvedValue({
data: [
{
id: "space-1",
name: "审批空间",
description: "需要审批",
icon: "",
visibility: VisibilityType.APPROVAL,
creator: "Zhou",
creatorId: "u-1",
memberCount: 3,
fileCount: 8,
totalFileCount: 8,
role: SpaceRole.MEMBER,
isPinned: false,
createdAt: "",
updatedAt: "",
tags: [],
isReleased: true,
isFollowed: false,
isPending: false,
squareStatus: "rejected",
subscriptionStatus: "rejected",
},
],
total: 1,
} as any);
mockedGetJoinedSpacesApi.mockResolvedValue([]);
mockedSubscribeSpaceApi.mockResolvedValue({ status: "pending", spaceId: "space-1" });
render(
<KnowledgeSquare
statusOverride={{ "space-1": "rejected" }}
onSquareStatusChange={onSquareStatusChange}
/>
);
fireEvent.click(await screen.findByRole("button", { name: "重新申请" }));
await waitFor(() => {
expect(mockedSubscribeSpaceApi).toHaveBeenCalledWith("space-1");
expect(onSquareStatusChange).toHaveBeenCalledWith("space-1", "pending");
});
});
});
@@ -219,9 +219,9 @@ export function EditTagsModal({
<DialogContent
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
className="flex w-[600px] flex-col items-stretch gap-0 rounded-xl border-none bg-white p-0 shadow-[0px_5px_22px_0px_rgba(61,68,110,0.2)] outline-none touch-mobile:inset-0 touch-mobile:left-0 touch-mobile:top-0 touch-mobile:h-dvh touch-mobile:w-screen touch-mobile:max-w-none touch-mobile:translate-x-0 touch-mobile:translate-y-0 touch-mobile:rounded-none [&>button]:hidden"
className="flex w-[600px] max-w-[600px] flex-col items-stretch gap-0 border-none bg-white p-0 shadow-[0px_5px_22px_0px_rgba(61,68,110,0.2)] [outline:none] rounded-none sm:rounded-none md:rounded-xl max-md:inset-0 max-md:left-0 max-md:top-0 max-md:h-dvh max-md:w-screen max-md:max-w-none max-md:translate-x-0 max-md:translate-y-0 [&>button]:hidden"
>
<DialogHeader className="relative h-12 shrink-0 justify-center space-y-0 px-6 py-3 text-left touch-mobile:h-auto touch-mobile:px-4 touch-mobile:pt-6 touch-mobile:pb-4">
<DialogHeader className="relative h-12 shrink-0 justify-center space-y-0 px-5 py-3 text-left max-md:h-auto max-md:px-4 max-md:pt-6 max-md:pb-4">
<DialogTitle className="text-[16px] leading-6 font-medium text-[#212121]">
{isBatchMode ? localize("com_knowledge.batch_add_tags") : localize("com_knowledge.edit_tags")}
</DialogTitle>
@@ -235,10 +235,10 @@ export function EditTagsModal({
</button>
</DialogHeader>
<div className="flex flex-1 flex-col gap-3 px-6 py-6 touch-mobile:px-4 touch-mobile:py-4">
<div className="flex flex-1 flex-col gap-4 px-5 py-3 max-md:px-4 max-md:py-4">
{/* Tags Input Box */}
<div
className="relative flex min-h-8 cursor-text flex-wrap items-center gap-1 rounded-[8px] border border-[#EBECF0] bg-white px-3 py-[5px] pr-[40px] transition-colors focus-within:border-primary"
className="relative flex min-h-8 cursor-text flex-wrap items-center gap-1 rounded-[8px] border border-[#EBECF0] bg-white px-3 py-[5px] pr-[40px] transition-[border-color,box-shadow] focus-within:border-[#ddd] focus-within:shadow-[0_0_0_2px_#f1f5f9]"
onClick={() => document.getElementById("tag-input")?.focus()}
>
{selectedTags.map((tag) => (
@@ -261,6 +261,7 @@ export function EditTagsModal({
<input
id="tag-input"
type="text"
autoComplete="off"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
@@ -269,7 +270,7 @@ export function EditTagsModal({
? localize("com_knowledge.input_tags_placeholder")
: ""
}
className="flex-1 min-w-[120px] bg-transparent outline-none text-sm leading-[22px] text-[#212121] placeholder-[#86909c] min-h-[22px]"
className="flex-1 min-w-[120px] bg-transparent outline-none text-sm leading-[22px] text-[#212121] placeholder-[#999] min-h-[22px]"
// maxLength={8}
/>
<span className="absolute right-3 top-0 flex h-full items-center text-[14px] leading-[22px] text-[#999]">
@@ -284,7 +285,7 @@ export function EditTagsModal({
<div className="text-[14px] leading-[22px] font-medium text-[#212121]">{localize("com_knowledge.existing_tags")}</div>
<div className="flex flex-wrap gap-1">
{spaceTags.length === 0 && (
<span className="text-[12px] text-[#86909c]">{localize("com_knowledge.no_tags")}</span>
<span className="text-[14px] text-[#999]">{localize("com_knowledge.no_tags")}</span>
)}
{spaceTags.map((tag) => {
const isSelected = selectedTagIds.has(tag.id);
@@ -318,16 +319,16 @@ export function EditTagsModal({
</div>
</div>
<DialogFooter className="flex h-14 shrink-0 items-center justify-end gap-3 border-none px-6 py-3 touch-mobile:!mt-auto touch-mobile:!h-auto touch-mobile:!flex-row touch-mobile:!justify-stretch touch-mobile:border-t touch-mobile:border-[#ECECEC] touch-mobile:px-4 touch-mobile:py-3 sm:space-x-0">
<DialogFooter className="flex h-14 shrink-0 items-center justify-end gap-3 border-none px-5 py-3 max-md:!mt-auto max-md:!h-auto max-md:!flex-row max-md:!justify-stretch max-md:border-t max-md:border-[#ECECEC] max-md:px-4 max-md:py-3 sm:space-x-0">
<Button
variant="outline"
className="h-8 min-w-[60px] rounded-[6px] border-[#ebecf0] bg-white/50 px-4 font-normal text-[#070038] backdrop-blur-[8px] hover:bg-white/70 touch-mobile:flex-1"
className="h-8 min-w-[60px] rounded-[6px] border-[#ebecf0] bg-white/50 px-4 font-normal text-[#070038] backdrop-blur-[8px] hover:bg-white/70 max-md:flex-1"
onClick={handleClose}
>
{localize("com_knowledge.cancel")}</Button>
<Button
variant="default"
className="h-8 min-w-[60px] rounded-[6px] px-4 font-normal touch-mobile:flex-1"
className="h-8 min-w-[60px] rounded-[6px] px-4 font-normal max-md:flex-1"
onClick={handleSave}
disabled={loading}
>
@@ -1,165 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { getGrantableRelationModels } from "~/api/permission";
import { KnowledgeSpaceShareDialog } from "./KnowledgeSpaceShareDialog";
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock("~/Providers", () => ({
useToastContext: () => ({ showToast: jest.fn() }),
}));
jest.mock("~/utils", () => ({
copyText: jest.fn(),
}));
jest.mock("~/api/permission", () => ({
getGrantableRelationModels: jest.fn(),
}));
jest.mock("~/components/KnowledgeSpaceMemberManagementPanel", () => ({
KnowledgeSpaceMemberManagementPanel: () => <div>member-panel</div>,
}));
jest.mock("~/components/permission/PermissionListTab", () => ({
PermissionListTab: ({ resourceType, resourceId, fixedSubjectType }: any) => (
<div>{`list:${resourceType}:${resourceId}:${fixedSubjectType}`}</div>
),
}));
jest.mock("~/components/permission/PermissionGrantTab", () => ({
PermissionGrantTab: ({ resourceType, resourceId, fixedSubjectType, includeChildren }: any) => (
<div>{`grant:${resourceType}:${resourceId}:${fixedSubjectType}:${includeChildren ? "include" : "exclude"}`}</div>
),
}));
jest.mock("~/components/ui", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
Checkbox: ({ checked, onCheckedChange }: any) => (
<button
type="button"
role="checkbox"
aria-checked={checked ? "true" : "false"}
onClick={() => onCheckedChange?.(!checked)}
/>
),
Dialog: ({ children }: any) => <div>{children}</div>,
DialogContent: ({ children }: any) => <div>{children}</div>,
DialogHeader: ({ children }: any) => <div>{children}</div>,
DialogTitle: ({ children }: any) => <div>{children}</div>,
Input: (props: any) => <input {...props} />,
Tabs: ({ children }: any) => <div>{children}</div>,
TabsContent: ({ children }: any) => <div>{children}</div>,
TabsList: ({ children }: any) => <div>{children}</div>,
TabsTrigger: ({ children }: any) => <button type="button">{children}</button>,
}));
const mockedGetGrantableRelationModels = jest.mocked(getGrantableRelationModels);
describe("KnowledgeSpaceShareDialog", () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetGrantableRelationModels.mockResolvedValue([
{
id: "viewer",
name: "Viewer",
relation: "viewer",
permissions: [],
is_system: true,
},
]);
});
it("renders a single permission list tab instance for the active subject type", async () => {
render(
<KnowledgeSpaceShareDialog
open
onOpenChange={jest.fn()}
resourceId="space-59"
resourceName="Space 59"
showShareTab={false}
showMembersTab={false}
showPermissionTab
/>,
);
await waitFor(() => {
expect(mockedGetGrantableRelationModels).toHaveBeenCalledTimes(1);
});
expect(screen.getAllByText("list:knowledge_space:space-59:user")).toHaveLength(1);
expect(screen.queryByText("list:knowledge_space:space-59:department")).not.toBeInTheDocument();
expect(screen.queryByText("list:knowledge_space:space-59:user_group")).not.toBeInTheDocument();
});
it("passes the include-children toggle state into the grant form", async () => {
render(
<KnowledgeSpaceShareDialog
open
onOpenChange={jest.fn()}
resourceId="space-59"
resourceName="Space 59"
showShareTab={false}
showMembersTab={false}
showPermissionTab
/>,
);
await waitFor(() => {
expect(mockedGetGrantableRelationModels).toHaveBeenCalledTimes(1);
});
const grantDepartmentTab = screen.getAllByRole("button", {
name: "com_permission.subject_department",
}).at(-1);
expect(grantDepartmentTab).toBeTruthy();
fireEvent.click(grantDepartmentTab!);
expect(await screen.findByText("grant:knowledge_space:space-59:department:include")).toBeInTheDocument();
fireEvent.click(screen.getByRole("checkbox"));
expect(await screen.findByText("grant:knowledge_space:space-59:department:exclude")).toBeInTheDocument();
});
it("can manage a file resource with the same grant dialog", async () => {
render(
<KnowledgeSpaceShareDialog
open
onOpenChange={jest.fn()}
resourceType="knowledge_file"
resourceId="file-9"
resourceName="File 9"
showShareTab={false}
showMembersTab={false}
showPermissionTab
/>,
);
await waitFor(() => {
expect(mockedGetGrantableRelationModels).toHaveBeenCalledWith("knowledge_file", "file-9");
});
expect(screen.getByText("list:knowledge_file:file-9:user")).toBeInTheDocument();
expect(screen.getByText("grant:knowledge_file:file-9:user:include")).toBeInTheDocument();
});
it("can manage a folder resource with the same grant dialog", async () => {
render(
<KnowledgeSpaceShareDialog
open
onOpenChange={jest.fn()}
resourceType="folder"
resourceId="folder-9"
resourceName="Folder 9"
showShareTab={false}
showMembersTab={false}
showPermissionTab
/>,
);
await waitFor(() => {
expect(mockedGetGrantableRelationModels).toHaveBeenCalledWith("folder", "folder-9");
});
expect(screen.getByText("list:folder:folder-9:user")).toBeInTheDocument();
expect(screen.getByText("grant:folder:folder-9:user:include")).toBeInTheDocument();
});
});
@@ -1,186 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import {
FileType,
SpaceRole,
VisibilityType,
getDepartmentSpacesApi,
getJoinedSpacesApi,
getMineSpacesApi,
getSpaceChildrenApi,
type KnowledgeSpace,
} from "~/api/knowledge";
import { listUploadableSpacesApi } from "~/api/messageExport";
import { MoveToDialog } from "./MoveToDialog";
jest.mock("bisheng-icons", () => ({
Outlined: {
City: (props: any) => <span data-testid="city-icon" {...props} />,
Down: (props: any) => <span data-testid="down-icon" {...props} />,
File: (props: any) => <span data-testid="file-icon" {...props} />,
FileImage: (props: any) => <span data-testid="file-image-icon" {...props} />,
FolderClose: (props: any) => <span data-testid="folder-icon" {...props} />,
Notebook: (props: any) => <span data-testid="notebook-icon" {...props} />,
Right: (props: any) => <span data-testid="right-icon" {...props} />,
},
}));
jest.mock("lucide-react", () => ({
Loader2: (props: any) => <span data-testid="loader" {...props} />,
}));
jest.mock("~/components/ui/Button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
jest.mock("~/components/ui/Dialog", () => ({
Dialog: ({ open, children }: any) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: any) => <div>{children}</div>,
DialogFooter: ({ children }: any) => <div>{children}</div>,
DialogHeader: ({ children }: any) => <div>{children}</div>,
DialogTitle: ({ children }: any) => <h2>{children}</h2>,
}));
jest.mock("~/components/ui/ExpandableSearchField", () => ({
ExpandableSearchField: ({ value, onChange, placeholder }: any) => (
<input aria-label={placeholder} value={value} onChange={(event) => onChange(event.target.value)} />
),
}));
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock("../hooks/useDynamicEllipsis", () => ({
useDynamicEllipsis: jest.fn(),
}));
jest.mock("../sidebar/DynamicEllipsisName", () => ({
DynamicEllipsisName: ({ name, trailing }: any) => (
<span>
{name}
{trailing}
</span>
),
}));
jest.mock("./MoveToFolderTree", () => ({
MoveToFolderTree: () => <div data-testid="folder-tree" />,
}));
jest.mock("~/api/messageExport", () => ({
listUploadableSpacesApi: jest.fn(),
}));
jest.mock("~/api/knowledge", () => ({
FileType: {
FOLDER: "folder",
PDF: "pdf",
},
SpaceRole: {
CREATOR: "creator",
ADMIN: "admin",
MEMBER: "member",
},
VisibilityType: {
PUBLIC: "public",
PRIVATE: "private",
APPROVAL: "approval",
},
SPACE_CHILDREN_STATUS_NUMS_EXCLUDE_FAILED: [2],
getDepartmentSpacesApi: jest.fn(),
getJoinedSpacesApi: jest.fn(),
getMineSpacesApi: jest.fn(),
getSpaceChildrenApi: jest.fn(),
}));
function makeSpace(id: string, name: string): KnowledgeSpace {
return {
id,
name,
description: "",
icon: "",
visibility: VisibilityType.PRIVATE,
creator: "tester",
creatorId: "user-1",
memberCount: 1,
fileCount: 0,
totalFileCount: 0,
role: SpaceRole.MEMBER,
isPinned: false,
createdAt: "",
updatedAt: "",
tags: [],
isReleased: true,
};
}
function renderDialog() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<QueryClientProvider client={queryClient}>
<MoveToDialog
open
onOpenChange={() => undefined}
currentSpaceId="current-space"
currentSpaceName="Current Space"
onConfirm={() => undefined}
/>
</QueryClientProvider>,
);
}
describe("MoveToDialog", () => {
test("shows children for the first visible uploadable space when current space is not uploadable", async () => {
const targetSpace = makeSpace("target-space", "Target Space");
jest.mocked(listUploadableSpacesApi).mockResolvedValue([
{ id: targetSpace.id, name: targetSpace.name },
]);
jest.mocked(getDepartmentSpacesApi).mockResolvedValue([]);
jest.mocked(getMineSpacesApi).mockResolvedValue([makeSpace("current-space", "Current Space")]);
jest.mocked(getJoinedSpacesApi).mockResolvedValue([targetSpace]);
jest.mocked(getSpaceChildrenApi).mockResolvedValue({
data: [
{
id: "folder-1",
name: "Target Folder",
type: FileType.FOLDER,
tags: [],
path: "Target Folder",
spaceId: targetSpace.id,
createdAt: "",
updatedAt: "",
},
],
page_size: 200,
has_more: false,
next_cursor: null,
} as any);
renderDialog();
expect(screen.getByText("com_knowledge.move_empty_folder")).toBeInTheDocument();
await waitFor(() => {
expect(getSpaceChildrenApi).toHaveBeenCalledWith(
expect.objectContaining({
space_id: targetSpace.id,
file_status: [2],
}),
);
});
expect(getSpaceChildrenApi).not.toHaveBeenCalledWith(
expect.objectContaining({ space_id: "current-space" }),
);
expect(await screen.findByText("Target Folder")).toBeInTheDocument();
});
});
@@ -1,131 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { NotificationSeverity } from "~/common";
import type { KnowledgeSpace } from "~/api/knowledge";
import { SpaceRole, SpaceSortType, VisibilityType } from "~/api/knowledge";
import { useSpaceActions } from "./useSpaceActions";
const mockShowToast = jest.fn();
const mockUnsubscribeSpaceApi = jest.fn();
const ORGANIZATION_GRANT_MESSAGE = "本空间通过部门/用户组授权给你,暂无法退出";
jest.mock("~/hooks", () => ({
useLocalize: () => (key: string) => {
const labels: Record<string, string> = {
"com_knowledge.exit_space_failed": "退出空间失败",
"com_knowledge.exited_space": "已退出空间",
"com_knowledge.organization_grant_exit_blocked": ORGANIZATION_GRANT_MESSAGE,
};
return labels[key] ?? key;
},
}));
jest.mock("~/Providers", () => ({
useToastContext: () => ({
showToast: mockShowToast,
}),
}));
jest.mock("~/api/knowledge", () => ({
SpaceRole: {
CREATOR: "creator",
ADMIN: "admin",
MEMBER: "member",
},
SpaceSortType: {
NAME: "name",
UPDATE_TIME: "update_time",
},
VisibilityType: {
PUBLIC: "public",
PRIVATE: "private",
APPROVAL: "approval",
},
updateSpaceApi: jest.fn(),
deleteSpaceApi: jest.fn(),
unsubscribeSpaceApi: (...args: unknown[]) => mockUnsubscribeSpaceApi(...args),
pinSpaceApi: jest.fn(),
}));
function createSpace(id = "space-1"): KnowledgeSpace {
return {
id,
name: "知识空间",
description: "用于回归测试",
icon: "",
visibility: VisibilityType.PUBLIC,
creator: "owner",
creatorId: "1",
memberCount: 3,
fileCount: 5,
totalFileCount: 5,
role: SpaceRole.MEMBER,
isPinned: false,
createdAt: "2026-05-28T00:00:00Z",
updatedAt: "2026-05-28T00:00:00Z",
tags: [],
isReleased: true,
};
}
describe("useSpaceActions leave", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
mockShowToast.mockClear();
mockUnsubscribeSpaceApi.mockReset();
});
function wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
it("shows organization grant message and keeps joined state when leave is blocked", async () => {
const space = createSpace();
const onSpaceSelect = jest.fn();
queryClient.setQueryData(["knowledgeSpaces", "joined", SpaceSortType.UPDATE_TIME], [space]);
const invalidateQueriesSpy = jest.spyOn(queryClient, "invalidateQueries");
mockUnsubscribeSpaceApi.mockResolvedValue({
status_code: 18071,
});
const { result } = renderHook(() => useSpaceActions({
activeSpaceId: space.id,
createdSortBy: SpaceSortType.UPDATE_TIME,
joinedSortBy: SpaceSortType.UPDATE_TIME,
departmentSortBy: SpaceSortType.UPDATE_TIME,
createdSpaces: [],
joinedSpaces: [space],
departmentSpaces: [],
onSpaceSelect,
}), { wrapper });
await act(async () => {
await result.current.handleLeaveSpace(space.id);
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: ORGANIZATION_GRANT_MESSAGE,
severity: NotificationSeverity.ERROR,
});
});
expect(mockShowToast).not.toHaveBeenCalledWith(expect.objectContaining({
message: "已退出空间",
}));
expect(queryClient.getQueryData(["knowledgeSpaces", "joined", SpaceSortType.UPDATE_TIME])).toEqual([space]);
expect(onSpaceSelect).not.toHaveBeenCalledWith(null);
expect(mockUnsubscribeSpaceApi).toHaveBeenCalledWith(space.id);
expect(invalidateQueriesSpy).not.toHaveBeenCalledWith({
queryKey: ["knowledgeSpaces", "joined"],
});
});
});
@@ -60,6 +60,19 @@ function collectExpandedIds(nodes: TreeNode[], acc: Set<number>): Set<number> {
return acc;
}
/** Find a node anywhere in the tree by id, searching into loaded (but possibly
* collapsed) children too a node's `children` array persists after collapse. */
function findNode(nodes: TreeNode[], id: number): TreeNode | undefined {
for (const n of nodes) {
if (n.id === id) return n;
if (Array.isArray(n.children)) {
const found = findNode(n.children, id);
if (found) return found;
}
}
return undefined;
}
// ─── Single node row ──────────────────────────────────────────────────────────
interface TreeNodeRowProps {
@@ -166,6 +179,11 @@ export function KnowledgeFolderTree({
}: KnowledgeFolderTreeProps) {
const [roots, setRoots] = useState<TreeNode[]>([]);
const [rootLoading, setRootLoading] = useState(false);
// True once the root list for the current (knowledgeId, fileStatus) has
// finished loading. Gates the deep-link effect below — unlike rootLoading
// (whose initial `false` would let that effect run against an empty tree
// on mount), this only flips true after real data is in.
const [rootsReady, setRootsReady] = useState(false);
// Mirror the latest tree into a ref so refreshTree can read it without
// becoming a new function on every state change.
@@ -175,59 +193,31 @@ export function KnowledgeFolderTree({
}, [roots]);
// Load root folders on mount or when knowledgeId / fileStatus changes.
// If a folder is currently selected (currentFolderId set), also fetch its
// ancestor chain and pre-expand every ancestor so the selected folder is
// visible without the user having to re-expand the tree manually after
// collapse → expand of the parent space.
// Deliberately independent of currentFolderId: selecting a folder (click or
// route change) must never rebuild the tree. The deep-link effect below
// handles the one case where the selected folder isn't in the tree yet.
useEffect(() => {
if (!knowledgeId) return;
let cancelled = false;
setRootLoading(true);
setRootsReady(false);
(async () => {
try {
const { items } = await listKnowledgeFolders({
space_id: knowledgeId, parent_id: null, file_status: fileStatus,
});
if (cancelled) return;
let tree = mapToTree(items);
if (currentFolderId) {
try {
const parentPath = await getFolderParentPathApi(String(knowledgeId), currentFolderId);
if (!cancelled && parentPath?.length > 0) {
const ancestorIds = new Set(parentPath.map(p => Number(p.id)));
// Walk the tree; for each ancestor, fetch its children
// and recurse so deeper ancestors also get expanded.
const expandChain = async (nodes: TreeNode[]): Promise<TreeNode[]> => {
return Promise.all(nodes.map(async (n) => {
if (!ancestorIds.has(n.id)) return n;
try {
const { items: kids } = await listKnowledgeFolders({
space_id: knowledgeId, parent_id: n.id, file_status: fileStatus,
});
const children = await expandChain(mapToTree(kids));
return { ...n, expanded: true, loading: false, children };
} catch {
return { ...n, expanded: true, loading: false, children: [] };
}
}));
};
tree = await expandChain(tree);
}
} catch {
// ignore — fall through with collapsed tree
}
}
if (!cancelled) setRoots(tree);
if (!cancelled) setRoots(mapToTree(items));
} catch {
if (!cancelled) setRoots([]);
} finally {
if (!cancelled) setRootLoading(false);
if (!cancelled) {
setRootLoading(false);
setRootsReady(true);
}
}
})();
return () => { cancelled = true; };
}, [knowledgeId, fileStatus, currentFolderId]);
}, [knowledgeId, fileStatus]);
/** Immutably update a node anywhere in the tree by id. */
const updateNode = useCallback((
@@ -244,12 +234,14 @@ export function KnowledgeFolderTree({
});
}, []);
const handleExpand = useCallback((node: TreeNode) => {
// Toggle collapse if already expanded
if (node.expanded) {
setRoots((prev) => updateNode(prev, node.id, (n) => ({ ...n, expanded: false })));
return;
}
/**
* Expand a node without ever collapsing it. If its children were loaded
* before (even while collapsed) this is a pure state toggle no request;
* only a never-loaded node fetches its own children (one level, same as
* the expand arrow). Shared by the arrow and by folder-row clicks.
*/
const ensureExpanded = useCallback((node: TreeNode) => {
if (node.expanded) return;
// If children already loaded, just toggle open
if (Array.isArray(node.children)) {
@@ -282,10 +274,84 @@ export function KnowledgeFolderTree({
});
}, [knowledgeId, fileStatus, updateNode]);
const handleExpand = useCallback((node: TreeNode) => {
// Arrow keeps toggle semantics: collapse if already expanded.
if (node.expanded) {
setRoots((prev) => updateNode(prev, node.id, (n) => ({ ...n, expanded: false })));
return;
}
ensureExpanded(node);
}, [ensureExpanded, updateNode]);
// Clicking a folder row only selects it (route + highlight) — it never
// expands/collapses children; that is exclusively the arrow's job. No tree
// reload either: the row is already rendered, so its data is already local,
// and the highlight follows the currentFolderId prop on re-render.
const handleSelect = useCallback((node: TreeNode) => {
onSelectFolder({ id: String(node.id), name: node.name });
}, [onSelectFolder]);
// Deep-link catch-up: when currentFolderId points at a folder that is NOT in
// the local tree (direct URL visit, breadcrumb jump into a never-expanded
// branch), fetch its ancestor chain and expand just the missing levels.
// Folder-row clicks never enter here — a clickable row is already in the tree,
// so findNode succeeds and this effect exits with zero requests.
const handledDeepLinkRef = useRef<string | null>(null);
useEffect(() => {
// Wait for the root list to be genuinely loaded — rootLoading's initial
// `false` on mount would otherwise let this run against an empty tree.
if (!knowledgeId || !currentFolderId || !rootsReady) return;
if (findNode(rootsRef.current, Number(currentFolderId))) return;
// One attempt per folder id — if the chain fetch fails (or the id is
// stale/deleted), don't refetch on every roots change.
if (handledDeepLinkRef.current === currentFolderId) return;
handledDeepLinkRef.current = currentFolderId;
let cancelled = false;
let completed = false;
(async () => {
try {
const parentPath = await getFolderParentPathApi(String(knowledgeId), currentFolderId);
if (cancelled || !parentPath?.length) return;
const ancestorIds = new Set(parentPath.map((p) => Number(p.id)));
// Walk the current tree along the ancestor chain, reusing children
// that are already loaded and fetching only the missing levels.
const expandChain = async (nodes: TreeNode[]): Promise<TreeNode[]> => {
return Promise.all(nodes.map(async (n) => {
if (!ancestorIds.has(n.id)) return n;
try {
let children = n.children;
if (!Array.isArray(children)) {
const { items } = await listKnowledgeFolders({
space_id: knowledgeId, parent_id: n.id, file_status: fileStatus,
});
children = mapToTree(items);
}
return { ...n, expanded: true, loading: false, children: await expandChain(children) };
} catch {
return { ...n, expanded: true, loading: false, children: n.children ?? [] };
}
}));
};
const fresh = await expandChain(rootsRef.current);
if (!cancelled) setRoots(fresh);
} catch {
// ignore — leave the tree as is; the user can expand manually.
} finally {
completed = true;
}
})();
return () => {
cancelled = true;
// A run cancelled mid-flight (deps changed, StrictMode double-mount)
// didn't actually expand anything — release the once-per-id mark so
// the next run for this folder id can try again.
if (!completed && handledDeepLinkRef.current === currentFolderId) {
handledDeepLinkRef.current = null;
}
};
}, [knowledgeId, fileStatus, currentFolderId, rootsReady]);
// Re-fetch a freshly-loaded subtree, re-expanding nodes that were open before.
const rebuildWithExpansion = useCallback(async (
nodes: TreeNode[],
@@ -1,5 +1,5 @@
import { Outlined } from "bisheng-icons";
import { useEffect, useState, type MouseEvent } from "react";
import { useState, type MouseEvent } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { KnowledgeSpace, SpaceRole, SPACE_CHILDREN_STATUS_NUMS_EXCLUDE_FAILED } from "~/api/knowledge";
import {
@@ -63,6 +63,9 @@ export default function KnowledgeSpaceItem({
}: KnowledgeSpaceItemProps) {
const localize = useLocalize();
const [menuOpen, setMenuOpen] = useState(false);
// Initial expansion mirrors the mount-time active state (deep link / reload
// restores the tree). After mount, clicking the space row only selects it —
// expansion is exclusively the chevron's job (same rule as folder rows).
const [expanded, setExpanded] = useState(isActive);
// Right-click context menu mirrors the "..." action menu, positioned at the cursor.
const [contextMenuOpen, setContextMenuOpen] = useState(false);
@@ -75,11 +78,6 @@ export default function KnowledgeSpaceItem({
const treeEnabled =
bsConfig?.knowledge_space?.tree_structured_directory_display ?? true;
// Auto-expand when this space becomes active
useEffect(() => {
if (isActive) setExpanded(true);
}, [isActive]);
// Only highlight the space row when this space is active AND no folder
// inside it is selected — folders take over the active styling once chosen
// so only one row in the tree appears active at a time.
@@ -177,6 +177,12 @@ export function KnowledgeSpaceSidebar({
const departmentSpaceIds = new Set(departmentSpaces.map(s => s.id));
const filteredCreatedSpaces = createdSpaces.filter(s => !departmentSpaceIds.has(s.id));
const filteredJoinedSpaces = joinedSpaces.filter(s => !departmentSpaceIds.has(s.id));
// An empty created/joined section stretches over the remaining list height
// and centers its empty-state text (both empty → 50/50 split). Compact
// dropdown keeps natural heights — it is a popover sized to content.
const createdEmpty = !filteredCreatedSpaces.length;
const joinedEmpty = !filteredJoinedSpaces.length;
const stretchEmptySections = (createdEmpty || joinedEmpty) && !compactMode;
const permissionSpaceIds = useMemo(
() => Array.from(new Set([
...departmentSpaces.map(s => s.id),
@@ -416,7 +422,7 @@ export function KnowledgeSpaceSidebar({
scroll container's visible width that lets sticky-left/top
keep them pinned to the viewport edges even while items
horizontally overflow. */}
<div className="w-max min-w-full">
<div className={cn("w-max min-w-full", stretchEmptySections && "flex min-h-full flex-col")}>
{compactMode ? (
/* File-page title dropdown: same 3-section tree as the PC sidebar,
ordered . Per-row "..." menus stay hidden
@@ -458,7 +464,7 @@ export function KnowledgeSpaceSidebar({
<div className="space-y-1 px-3">
{filteredCreatedSpaces.map(s => renderCompactItem(s, "created"))}
{!filteredCreatedSpaces.length && (
<div className="py-6 text-center text-sm text-[#818181]">{localize("com_knowledge.no_data")}</div>
<div className="py-6 text-center text-sm text-[#999999]">{localize("com_knowledge.no_data")}</div>
)}
</div>
)}
@@ -480,7 +486,7 @@ export function KnowledgeSpaceSidebar({
<div className="space-y-1 px-3">
{filteredJoinedSpaces.map(s => renderCompactItem(s, "joined"))}
{!filteredJoinedSpaces.length && (
<div className="py-6 text-center text-sm text-[#818181]">{localize("com_knowledge.no_data")}</div>
<div className="py-6 text-center text-sm text-[#999999]">{localize("com_knowledge.no_data")}</div>
)}
</div>
)}
@@ -510,7 +516,7 @@ export function KnowledgeSpaceSidebar({
)}
{/* My created */}
<div className="pb-4">
<div className={cn("pb-4", stretchEmptySections && createdEmpty && !createdCollapsed && "flex min-h-0 flex-1 flex-col")}>
<SectionHeader
title={localize("com_knowledge.created_by_me")}
collapsed={createdCollapsed}
@@ -524,15 +530,20 @@ export function KnowledgeSpaceSidebar({
mobile={mobilePageMode}
/>
{!createdCollapsed && (
<div className={listRowClassName}>
<div className={cn(listRowClassName, stretchEmptySections && createdEmpty && "flex min-h-0 flex-1 flex-col")}>
{filteredCreatedSpaces.map(s => renderSpaceItem(s, "created"))}
{!filteredCreatedSpaces.length && <div className="py-6 text-center text-sm text-[#818181]">{localize("com_knowledge.no_data")}</div>}
{createdEmpty && (
<div className={cn(
"py-6 text-center text-sm text-[#999999]",
stretchEmptySections && "flex flex-1 items-center justify-center",
)}>{localize("com_knowledge.no_data")}</div>
)}
</div>
)}
</div>
{/* Joined */}
<div className="pb-4">
<div className={cn("pb-4", stretchEmptySections && joinedEmpty && !joinedCollapsed && "flex min-h-0 flex-1 flex-col")}>
<SectionHeader
title={localize("com_knowledge.joined_by_me")}
collapsed={joinedCollapsed}
@@ -544,9 +555,14 @@ export function KnowledgeSpaceSidebar({
mobile={mobilePageMode}
/>
{!joinedCollapsed && (
<div className={listRowClassName}>
<div className={cn(listRowClassName, stretchEmptySections && joinedEmpty && "flex min-h-0 flex-1 flex-col")}>
{filteredJoinedSpaces.map(s => renderSpaceItem(s, "joined"))}
{!filteredJoinedSpaces.length && <div className="py-6 text-center text-sm text-[#818181]">{localize("com_knowledge.no_data")}</div>}
{joinedEmpty && (
<div className={cn(
"py-6 text-center text-sm text-[#999999]",
stretchEmptySections && "flex flex-1 items-center justify-center",
)}>{localize("com_knowledge.no_data")}</div>
)}
</div>
)}
</div>
@@ -7,8 +7,8 @@ import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import type { AppConversation } from '~/@types/app';
import { DropdownPopup, OGDialog, Label } from '~/components';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { DropdownPopup } from '~/components';
import { useConfirm } from '~/Providers';
import TodayItemIcon from '~/components/ui/icon/TodayItem';
type GuestConvoItemProps = {
@@ -29,7 +29,7 @@ export function GuestConvoItem({ conv, isActive, onClick, onRename, onDelete }:
const [isPopoverActive, setIsPopoverActive] = useState(false);
const [renaming, setRenaming] = useState(false);
const [titleInput, setTitleInput] = useState(conv.title);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const confirm = useConfirm();
const inputRef = useRef<HTMLInputElement>(null);
const menuId = useId();
@@ -83,10 +83,18 @@ export function GuestConvoItem({ conv, isActive, onClick, onRename, onDelete }:
[conv.title],
);
const confirmDelete = useCallback(() => {
const handleDeleteClick = useCallback(async () => {
const ok = await confirm({
variant: 'destructive',
title: localize('com_ui_delete_conversation'),
description: `${localize('com_ui_delete_confirm')} "${conv.title}"`,
confirmText: localize('com_ui_delete'),
});
if (!ok) {
return;
}
onDelete(conv.id);
setShowDeleteDialog(false);
}, [conv.id, onDelete]);
}, [confirm, localize, conv.title, conv.id, onDelete]);
return (
<div
@@ -96,7 +104,7 @@ export function GuestConvoItem({ conv, isActive, onClick, onRename, onDelete }:
renaming ? 'bg-[#EEE]' : '',
)}
onClick={(e) => {
if (renaming || isPopoverActive || showDeleteDialog) return;
if (renaming || isPopoverActive) return;
onClick();
}}
>
@@ -174,7 +182,7 @@ export function GuestConvoItem({ conv, isActive, onClick, onRename, onDelete }:
label: localize('com_ui_delete'),
onClick: () => {
setIsPopoverActive(false);
setShowDeleteDialog(true);
handleDeleteClick();
},
icon: <Trash className="icon-sm mr-2 text-text-primary" />,
hideOnClick: false,
@@ -184,31 +192,6 @@ export function GuestConvoItem({ conv, isActive, onClick, onRename, onDelete }:
]}
menuId={menuId}
/>
{/* Delete confirmation dialog */}
{showDeleteDialog && (
<OGDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog} triggerRef={deleteButtonRef}>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_conversation')}
className="max-w-[450px]"
main={
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} <strong>{conv.title}</strong>
</Label>
</div>
</div>
}
selection={{
selectHandler: confirmDelete,
selectClasses: 'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
)}
</>
)}
</div>
@@ -52,6 +52,8 @@ export type LinsightInfo = {
}[];
output_result: null | any;
score: null | number;
// 点赞/点踩 verdict on the task result: 0 none / 1 up / 2 down.
liked?: null | number;
has_reexecute: boolean;
id: string;
update_time: string;
@@ -1207,7 +1207,7 @@
"model": "Model",
"displayName": "Display Name",
"modelDescription": "Description",
"modelDescriptionPlaceholder": "Optional, shown in the workspace model picker",
"modelDescriptionPlaceholder": "Shown in the model picker",
"vision": "Vision",
"visionText": "When enabled, the model will answer based on image content (supports PNG, JPEG, WEBP, non-animated GIF formats). Note that only multimodal models support this capability.",
"webSearchPrompt": "Web Search Prompt",
@@ -1190,7 +1190,7 @@
"model": "モデル",
"displayName": "表示名",
"modelDescription": "説明",
"modelDescriptionPlaceholder": "任意。ワークスペースのモデル選択に表示されます",
"modelDescriptionPlaceholder": "モデル選択に表示されます",
"vision": "画像",
"visionText": "有効にすると、モデルは画像コンテンツ(PNG、JPEG、WEBP、非アニメーションGIF形式をサポート)を組み合わせて回答します。この機能はマルチモーダルモデルのみでサポートされています。",
"webSearchPrompt": "Web 検索プロンプト",
@@ -1196,7 +1196,7 @@
"model": "模型",
"displayName": "显示名称",
"modelDescription": "描述信息",
"modelDescriptionPlaceholder": "选填,将展示在工作台模型选择器中",
"modelDescriptionPlaceholder": "将展示在模型选择器中",
"vision": "视觉",
"visionText": "开启后,模型将结合图像内容(支持PNG、JPEG、WEBP、非动画GIF格式)进行回答,注意仅多模态模型支持此能力。",
"webSearchPrompt": "联网搜索提示词",
@@ -90,7 +90,7 @@ export const ModelManagement = forwardRef<HTMLDivElement[], ModelManagementProps
return (
<div className="mt-2 border p-4 rounded-md bg-background">
<div className="grid mb-4 items-center" style={{ gridTemplateColumns: "1.2fr 0.85fr 1fr 72px 116px 36px" }}>
<div className="grid mb-4 items-center" style={{ gridTemplateColumns: "1.2fr 0.85fr 1.3fr 72px 116px 36px" }}>
<div className="">
<Label className="bisheng-label">{t('bench.model')}</Label>
</div>
@@ -117,7 +117,7 @@ export const ModelManagement = forwardRef<HTMLDivElement[], ModelManagementProps
key={model.key}
ref={(el) => setItemRef(el, index)}
className="grid items-center mb-4"
style={{ gridTemplateColumns: "1.2fr 0.85fr 1fr 72px 116px 36px" }}
style={{ gridTemplateColumns: "1.2fr 0.85fr 1.3fr 72px 116px 36px" }}
>
<div className="pr-2" id={model.id}>
{assistantLlmOptions.length > 0 ? (
@@ -211,7 +211,7 @@ export default function WorkbenchModel({ onBack }) {
const inheritedFromRoot = !!linsightConfig?.inherited_from_root;
const fallbackBlocked = !!linsightConfig?.fallback_blocked;
return (
<div className="max-w-[640px] mx-auto gap-y-4 flex flex-col mt-16 relative">
<div className="max-w-[720px] mx-auto gap-y-4 flex flex-col mt-16 relative">
<FallbackBlockedBanner visible={fallbackBlocked} />
{inheritedFromRoot && (
<div className="-mb-2 text-xs text-muted-foreground flex items-center">