fix: complete the feat/2.6.0 merge and resolve its conflicts

The merge commit landed without the files mainline added wholesale — the pnpm
workspace and lockfile, the eslint baselines, the i18n check scripts — so the
branch declared bisheng-icons ^0.2.28 while carrying no lockfile to install it
from, and the client could not build. Those files come in here unchanged from
mainline, along with the removals mainline made (the npm lockfiles it replaced,
and the retired skill-editor components).

Conflict resolutions, all in files both lines had evolved:

- Popup sizing (tool/knowledge/skill selectors, PlusMenu): kept cofco's
  content-fit widths over mainline's fixed ones — mainline only restyled the
  radius, cofco had reworked the behaviour.
- redis_callback: kept cofco's override[one_node_id]; mainline still reads the
  enclosing loop variable, which only works because the caller happens to pass
  the same value. The chat-attachment promotion had to be re-applied here after
  taking cofco's side of the file.
- FileTable: both sides added something different, so both stay — mainline's
  per-type row icons, cofco's ghost action-button styling.
- KnowledgeSpaceSidebar: took mainline's knowledge-square entry.

Refs: features/v2.6.0/043-chat-file-permanent-storage
This commit is contained in:
dolphin
2026-07-29 11:33:54 +08:00
parent 71ff6a46c9
commit 28698552d0
797 changed files with 45021 additions and 57269 deletions
+61
View File
@@ -0,0 +1,61 @@
name: Frontend Quality
# Quality gate for the two frontend apps.
# Policy: legacy violations are frozen in each app's eslint-suppressions.json and
# via @ts-strict-ignore file annotations; suppressed counts may only shrink.
# Any NEW lint violation or strict-mode type error fails this workflow.
on:
pull_request:
paths:
- "src/frontend/**"
- ".github/workflows/frontend-quality.yml"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
name: Lint & Typecheck
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.9
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: src/frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Shared locale artifacts in sync (packages/locales)
run: pnpm --filter @bisheng/locales check
- name: i18n consistency (key parity + backend error-code coverage)
run: pnpm check-i18n
- name: Lint platform
run: pnpm --filter bisheng lint
- name: Lint client
run: pnpm --filter bishengchat lint
- name: Typecheck platform (strict, non-exempted files)
run: pnpm --filter bisheng typecheck
- name: Typecheck client (strict, non-exempted files)
run: pnpm --filter bishengchat typecheck
+3 -1
View File
@@ -50,6 +50,8 @@ Two React apps that **must not be mixed**. Per-app rules auto-load from each sub
- **Never** introduce new UI or state-management libraries.
- All code comments in English.
- 403 handled automatically by response interceptors — never add 403 branches in business code.
- **i18n**: no hardcoded Chinese in source (lint-enforced; legacy frozen). New keys ship all three languages (zh-Hans/en/ja) in the same PR. Error-code copy lives ONLY in `src/frontend/packages/locales` (`api_errors` domain — platform addresses `api_errors:<code>`, client `api_errors.<code>`); its generated artifacts (`platform/public/locales/*/api_errors.json`, `client/src/locales/*/api_errors.gen.json`) are never edited by hand (CI-checked). CI also runs `pnpm check-i18n` — key parity across languages + backend error-code coverage; legacy drift is frozen in `scripts/i18n-baseline.json` (shrink-only, `--update-baseline` after healing). Legacy hardcoded Chinese is paid down by whoever touches the file: when editing a file with frozen violations, extract its Chinese strings to i18n (`/i18n-localizer`) in the same change. See `packages/locales/README.md`.
- **Quality gate (CI-enforced, `frontend-quality.yml`)**: `pnpm lint` + `pnpm typecheck` (run from `src/frontend/`) must pass. Legacy violations are frozen — ESLint in each app's `eslint-suppressions.json`, TS strict via `// @ts-strict-ignore` file headers — and may only shrink: never hand-edit the suppressions file, never add `@ts-strict-ignore` to a new file. After fixing violations in a file, run `pnpm lint:prune` (per app) and delete its `@ts-strict-ignore` header if it now passes strict.
---
@@ -103,5 +105,5 @@ Backend runtime pitfalls (tenant-filter SELECT-only gap, ruff hook import trap,
- **Architecture docs** → `docs/architecture/` (overview, permission, gateway, multi-tenant, data-models, …)
- **Skills**: `/sdd-review`, `/task-review`, `/code-review`, `/e2e-test`, `/i18n-localizer`, `/react-component-refactor`
**Instruction files (AGENTS.md map).** Root = this file, loaded every session. Auto-loaded on top when editing the matching directory: `src/backend/`, `src/frontend/platform/`, `src/frontend/client/`, plus deep-dir specials `src/backend/bisheng/core/database/alembic/` (migrations) and `src/backend/scripts/` (one-off scripts). Every `CLAUDE.md` is a symlink to its sibling `AGENTS.md` — edit `AGENTS.md` only. Put a new rule in the deepest file covering its scope (cross-app / cross-module → this file; app- or dir-specific → the nearest file); never duplicate a rule across levels — it *will* drift.
**Instruction files (AGENTS.md map).** Root = this file, loaded every session. Auto-loaded on top when editing the matching directory: `src/backend/`, `src/frontend/platform/`, `src/frontend/client/`, `src/frontend/packages/ui/` (shared component library + design-token SSOT), plus deep-dir specials `src/backend/bisheng/core/database/alembic/` (migrations) and `src/backend/scripts/` (one-off scripts). Every `CLAUDE.md` is a symlink to its sibling `AGENTS.md` — edit `AGENTS.md` only. Put a new rule in the deepest file covering its scope (cross-app / cross-module → this file; app- or dir-specific → the nearest file); never duplicate a rule across levels — it *will* drift.
+5 -8
View File
@@ -21,13 +21,10 @@ uv sync --frozen --python $(which python)
### 前端环境
```bash
# Platform 前端(主应用)
cd src/frontend/platform
npm install
# Client 前端(客户端嵌入应用)
cd src/frontend/client
npm install
# 前端为 pnpm workspace(platform + client + packages/ui),在 workspace 根一次安装。
# 已禁用 npm(only-allow pnpm);pnpm 通过 corepack 提供:corepack enable
cd src/frontend
pnpm install
```
### 存储服务
@@ -77,7 +74,7 @@ cd src/backend
```bash
cd src/frontend/platform
npm start -- --host 0.0.0.0
pnpm start -- --host 0.0.0.0
```
Vite 开发服务器运行在 3001 端口,自动将 `/api/``/health` 请求代理到后端 `localhost:7860`。文件服务路由(`/bisheng``/tmp-dir`)代理到 MinIO。
@@ -0,0 +1,184 @@
# Design: 会话上传文件永久化 + 对话图片展示
> **本文档定位 — 现状快照(Why this How**
> `spec.md` 回答做什么;本文回答**为什么这么实现**;`tasks.md` 是流水账。
**关联**: [spec.md](./spec.md) · [tasks.md](./tasks.md)
**版本**: v2.6.0
**最后更新**: 2026-07-28
---
## 1. 目标与非目标
- **目标**:让会话里传过的文件在会话存续期间一直可用、且用户之间互不串扰;在此基础上把图片从"一个文件名标识"变成"可看、可放大的缩略图"。
- **非目标**:不找回存量已被清理的文件;不动知识库等非会话上传路径;不做会话保留期的定期清理;不渲染 AI 回复里的图片。
---
## 2. 关键约束
- 遵循 `docs/constitution.md` C1–C7。多租户(C3):附件归属随会话,跨租户不可见。
- 遵循 `src/frontend/client/AGENTS.md`Recoil / react-query v4 / `~/` / 命名导出 / 单文件 ≤600 行 / 三语 i18n)。
- **对象存储的临时桶配置了 3 天自动清理**(`minio_storage.py` 启动时设置 lifecycle`Expiration(days=3)`)。这是本特性要解决的根因,不是猜测。
- **会话删除是软删除**`MessageSession.is_delete=True`),且代码中无恢复入口 —— 因此删除即可视为终态,可安全清理附件。
- 换发链接的接口一旦设计不当就是任意文件读取漏洞,鉴权口径是本设计的安全底线(§3 决策 3)。
---
## 3. 方案对比与选定
### 决策 1:什么时候让文件变永久
- **现状**:会话上传走 `save_uploaded_file()`,其 `bucket_name` 参数**所有调用方都没传**,默认落 `put_object_tmp()` → 临时桶 → **3 天后被 lifecycle 规则物理删除**。日常模式的 `/workstation/files` 还把**原始文件名**当对象名,毫无唯一化。
- **备选**
- A. 上传就直接落主桶(改三个上传接口)。
- B. **上传保持不变(仍落临时桶),发消息时把附件 copy 到主桶「转正」**
- C. 取消临时桶的 lifecycle 规则。
- **选定****B**
- **原因**
- C 会让**所有**用临时桶的业务(报告导出等)一起永不清理,副作用远超本特性。
- A 要改三个上传接口,其中 `/knowledge/upload` 是知识库 / 数据集 / API 接入示例共用的(§5 坑 9),改它波及面大;绕开则要新增会话专用上传端点。
- B 的关键好处:**孤儿文件自动消失**。上传了但从未发送的文件留在临时桶,被原有的 3 天规则清掉——不需要对象打标记,也不需要巡检任务。A 方案下这些文件永远无人引用、必须额外造一套清理机制。
- MinIO 的 copy 是**服务端操作**(不经过后端下载再上传),且既有 `copy_object` 的默认参数正是「临时桶 → 主桶」,成本可忽略。
- 转正逻辑写成「**已经是永久对象就跳过**」,于是任务模式(本来就落主桶)走同一条代码路径而不会重复 copy。
- **配套**:日常模式前端改为直接调用共用上传接口 —— 它已是 uuid 命名,**同名覆盖导致的跨用户泄露随之消失**,后端上传接口一个都不用改(§5 坑 2)。
- **何时该重新考虑**:若将来上传后到发送前的间隔可能超过临时桶保留期(如草稿箱),转正时机需要提前或改为上传即永久 + 独立巡检。
### 决策 1b:永久对象怎么命名
- 永久位置 `chat/{user_id}/{uuid}.{ext}`。**不按会话归拢**:新会话的第一次上传发生在会话创建之前,命名时拿不到会话 ID(§5 坑 8)。
- 对象名**必须是纯 ASCII**:非 ASCII 的 key 会让部分 S3 兼容存储(华为 OBS 等)的预签名校验失败并返回 403(灵思侧已踩过,见其上传代码注释)。uuid 命名天然满足。
- 原始文件名只作为展示名留在消息里,不参与寻址。
### 决策 2:不新建附件表,用消息里的 files 结构承载
- **备选**
- A. 消息 `files` JSON 里补存对象名,换发链接时按 `chat_id + file_id` 从消息中查。
- B. 新建 `chat_attachment` 表(file_id / chat_id / object_name / owner …)。
- **选定**A
- **原因**:消息本来就持久化了附件数组,它**已经是**这份数据的事实存储;再建一张表就有了两个真相源,且要处理"消息删了表没删"的一致性问题。鉴权所需的归属信息(会话属于谁)在会话表里现成可查,不需要在附件上再存一份 owner。删除会话时也从这里取对象名(决策 1b 决定了无法按前缀清扫)。
- **代价**:换发链接需要按 `chat_id` 载入消息再匹配 `file_id`,比主键查表多一点开销;对单次点击的量级完全可以接受。
- **何时该重新考虑**:若出现"按文件维度"的需求(全局搜附件、配额统计、跨会话去重),再引入独立表。
### 决策 3:换发链接的鉴权口径(安全底线)
- **绝不能**让接口接受前端传来的对象名直接签发 —— 那等于开放整个对象存储的任意读取。
- **选定**:接口入参是 `chat_id` + `file_id`;后端流程固定为:① 按 `chat_id` 载入会话 → ② 校验请求者是该会话所属用户 → ③ 在该会话的消息里查 `file_id` 对应的对象名 → ④ 对该对象名签发短时效链接。
- **要点**:对象名**只从服务端数据里取**,从不信任入参;`file_id` 匹配不到就当作不存在,不回落到任何模糊匹配。
- **何时该重新考虑**:若引入会话分享(他人可见),鉴权条件要相应扩展为"会话所属者 ∪ 被分享者",但取对象名的方式不变。
### 决策 4:图片是否可用,以「实际加载结果」为准
- 前端渲染时先换发链接;**换发失败或图片加载失败**才显示「图片已失效,无法查看」。不做预先探测(多一次往返且探测通过不等于加载成功)。
- 存量文件已被清理的会话,会自然落到这个分支——这正是 spec 中"存量不可恢复"的表现形式。
### 决策 5:识别图片按文件名后缀
- 存量历史消息不保证带 MIME 字段,且不同上传入口写入的字段名不统一;文件名是所有入口、所有历史消息都必然存在的信息,且与现有文件图标的判断口径一致。
---
## 4. 系统现状(接手必读)
### 4.1 上传链路(改造前)
`选文件 → 上传接口 → save_uploaded_file() → 临时桶 → 返回预签名 URL → 随消息存库`
三个入口的现状**不一致**,这是排查时最容易被绕晕的地方:
| 场景 | 上传接口 | 存储位置 | 对象名 | 文件寿命 |
|---|---|---|---|---|
| 日常模式 / 工作流会话 | `/workstation/files``/knowledge/upload` | **临时桶** | 日常模式=**原始文件名**;工作流=uuid | **3 天** |
| 任务模式(灵思) | `/linsight/workbench/upload-file` | 主桶 | uuid | 永久 |
改造后三者统一:主桶 + `chat/{user_id}/{uuid}.{ext}`(会话上传另开专用入口,不复用共用的 `/knowledge/upload`,见 §5 坑 9)。
### 4.2 关键数据结构 / 字段约定
| 契约 | 形式 | 说明 | 谁会消费 |
|---|---|---|---|
| 消息附件项 | 现有字段(file_id / filename / type / filepath…)**新增对象名字段** | 对象名是换发链接的唯一依据;`filepath` 退化为"上传当时的链接",前端不再依赖它长期可用 | 换发接口、前端渲染 |
| 存储对象名 | `chat/{user_id}/{uuid}.{ext}` | 前缀即上传者;删除会话时从消息取对象名(上传时拿不到会话 ID,见 §5 坑 8) | 存储层、清理逻辑 |
| 换发链接接口 | 入参 `chat_id` + `file_id`,出参短时效 URL | 鉴权见 §3 决策 3 | client 端图片/附件渲染 |
| 图片识别口径 | 文件名后缀 ∈ png/jpg/jpeg/gif/webp/bmp/svg | 与文件图标同口径 | 前端 |
### 4.3 关键模块职责
| 模块 | 职责 | 不做什么 |
|---|---|---|
| 会话上传接口(三处) | 生成会话内唯一对象名、落主桶、回传对象名 | 不再依赖 `save_uploaded_file` 的默认临时桶行为 |
| 换发链接接口 | 鉴权 + 从服务端数据取对象名 + 签发 | **不接受**前端传入的对象名 |
| 会话删除逻辑 | 软删会话后,从该会话消息中取出对象名并逐个删除 | 清理失败不阻断删除主流程 |
| 共用「消息图片」组件(前端) | 换发链接、缩略图、全屏查看、失效占位 | 不判断"是不是图片"(调用方分流) |
| 两套消息渲染 | 各自按后缀分流到图片组件 | 不改非图片附件的现状 |
---
## 5. 已知坑 / 反直觉事实
| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 |
|---|---|---|---|
| 1 | 临时桶有 **3 天自动清理**的 lifecycle 规则,会话附件到期是**文件本身被删**,不只是链接过期 | 会把问题误判成"签名过期",去延长有效期——延长了照样打不开,因为对象已经没了 | 决策 1:改落主桶 |
| 2 | 日常模式的上传对象名就是**原始文件名**,无任何唯一化 | 永久化后同名文件永久互相覆盖,且 A 能看到 B 的文件——**数据泄露** | 决策 1:uuid 命名(本特性必做项) |
| 3 | `save_uploaded_file(bucket_name=...)` 有桶参数但**所有调用方都没传**,默认走临时桶 | 只看函数签名会以为"落主桶",实际相反 | 改造时显式传参,勿依赖默认值 |
| 4 | 三个上传入口的存储行为**本来就不一致**(任务模式已是永久桶) | 只改一处会以为统一了,实际另两处还在临时桶 | §4.1 对照表 |
| 5 | 会话删除是**软删除**且无恢复入口 | 会因担心"删了还能恢复"而不敢清理附件,或反过来以为硬删而漏掉清理 | 决策:软删即终态,可清理 |
| 6 | 换发链接接口若接受前端传对象名,等于任意文件读取漏洞 | 一个便利写法直接开一个安全洞 | 决策 3 的鉴权口径 |
| 7 | client 端有**两套互不相干的消息渲染**(日常/任务一套,工作流会话另一套) | 只改一处,另一场景毫无变化 | 两处各自接入同一组件 |
| 8 | **上传发生在会话创建之前**:新会话第一条消息的附件上传时,客户端的会话 ID 还是 `"new"`,真实 ID 由后端在发消息时分配 | 会设计出「对象名按会话 ID 归拢 / 删除时按会话前缀清扫」这类拿不到 ID 的方案(本特性初版设计正是如此,实现时才发现) | 决策 1 改为按上传者归拢,删除从消息里取对象名 |
| 9 | `POST /api/v1/knowledge/upload` **不是**会话专用接口——API 接入示例、知识库 QA 导入、数据集创建都在用 | 直接改它的存储行为会波及知识库等无关场景 | 决策 1 选 B:谁都不改,三个会话场景统一用它上传,永久化挪到发消息时做 |
| 10 | 对象名含非 ASCII 字符会让**部分 S3 兼容存储**(华为 OBS)的预签名校验失败返回 403 | 用原始中文文件名当对象名,本地 MinIO 一切正常、客户环境全线 403 | 永久对象一律 uuid 命名(决策 1b) |
| 11 | **任务模式是第三种架构**:上传先落**后端本地磁盘**(非对象存储),执行任务时才传 MinIO;其上传接口连文件路径都不返回 | 会以为三个场景只是"桶不同",按同一套改法处理任务模式必然落空 | 复用它已有的图片原件提升(`original_file_path`),把该对象名带进消息即可 |
---
## 6. 对外契约与依赖
### 6.1 我提供给别人的(Outgoing
| 契约 | 形式 | 谁在用 |
|---|---|---|
| 换发附件链接接口 | HTTP API(新增) | client 端消息渲染 |
| 消息附件项新增对象名字段 | 隐式数据契约(向后兼容:老消息无此字段,走失效分支) | 换发接口、前端 |
| 会话上传的存储位置与命名规则 | 存储约定 | 清理逻辑、运维排查 |
### 6.2 我依赖别人的(Incoming
| 依赖 | 形式 | 风险点 |
|---|---|---|
| 对象存储(MinIO)主桶 | 基础设施 | 主桶无自动清理策略——本特性正是依赖这一点;若将来给主桶加 lifecycle,会静默破坏本特性 |
| 会话归属关系(会话表) | 内部数据契约 | 鉴权依据;若引入会话分享需同步扩展 |
| 消息附件 JSON 结构 | 内部数据契约 | 各上传入口字段名本就不统一,取值需兼容 |
---
## 7. 测试与可观测
- **后端单测**:对象名生成(唯一性、扩展名保留、非法文件名);换发链接的鉴权(非所属用户拒绝、file_id 不存在拒绝、对象名不取自入参)。
- **手动验证**
1. 两个不同用户各传一个同名 `1.png` → 各自会话里看到的都是自己的图(AC-02,最关键的一条)
2. 三个场景各传一张图 → 缩略图 → 点开全屏 → 关闭
3. 传非图片附件 → 与现状一致
4. 删除一个含附件的会话 → 该会话消息中记录的对象应被删除
5. 打开一个存量老会话 → 图片显示「已失效」(预期行为,非缺陷)
- **可观测**:附件清理失败需留日志(会话删除仍应成功)。
---
## 8. 后续改进 / 不打算做的事
- **存量文件**:已被临时桶策略清理,不可恢复;不做迁移。
- **会话保留期定期清理**:本期只做"随会话删除",长期存储增长需要独立的容量策略。
- **附件跨会话复用 / 去重**:当前不支持引用计数(决策 1 的已知取舍)。
- **知识库等非会话上传路径**:仍走原有策略,未统一。
---
## 修订历史
| 日期 | 改动 | 触发原因 |
|---|---|---|
| 2026-07-28 | 初版:由原 v3.0.0-beta1 F045(纯前端图片展示)升级而来,纳入存储永久化与对象名唯一化 | 用户提出"会话文件需永久保存",排查发现临时桶 3 天清理 + 日常模式对象名未唯一化 |
| 2026-07-28 | 决策 1 由「按会话前缀」改为「按上传者前缀 + 删除时从消息取对象名」;新增 §5 坑 8/9 | 实现时发现上传发生在会话创建之前、拿不到会话 ID;且 `/knowledge/upload` 为多场景共用接口 |
| 2026-07-28 | 决策 1 改为「**上传不动,发消息时转正**」(原为上传即落主桶);日常模式前端改用共用上传接口;孤儿文件条目从 §8 移除(不再存在);新增坑 10(非 ASCII 对象名致 403) | 用户提出"为何要动共用上传接口",顺此推出转正方案:共用接口零改动,且孤儿文件被临时桶原有的 3 天规则自动清理 |
@@ -0,0 +1,92 @@
# Feature: 会话上传文件永久化 + 对话图片展示
> **本文档定位 — 纯 What(需求口径,不随代码漂移)**
> spec 只回答 **做什么 / 验收标准 / 不做什么**;所有 How(决策、数据流、字段、API、Service、前端、文件清单)一律在 [design.md](./design.md) 与 [tasks.md](./tasks.md)。
**关联 PRD**: [3.0.0-beta1 需求文档 §支持展示对话中上传的图片](https://dataelem.feishu.cn/wiki/Ifj6wOgSmiyClfkMQySc7ZmvnNb)
**优先级**: P0(含数据泄露风险修复)
**所属版本**: v2.6.0
**依赖**: 无
> **范围边界**
> - **本次纳入**
> - **会话上传文件改为永久保存**:不再落 3 天自动清空的临时桶,寿命跟随会话
> - **对象名唯一化**:会话上传的存储对象名不再使用原始文件名(现状会导致同名文件互相覆盖 + 跨用户串看,见 §3)
> - **按需换发访问链接**:前端渲染时现取有效链接,不再依赖上传当时那条会过期的链接
> - **会话删除时清理其附件**
> - **图片展示**:日常模式 / 任务模式 / 工作流会话中,用户上传的图片显示缩略图,点击全屏查看,右上角关闭
> - 图片确实不可用时(存量已被清理等)显示「图片已失效,无法查看」
> - **本次明确排除**
> - **存量文件的找回或迁移**——3 天前的会话附件在对象存储中已被自动清理,无法恢复(用户已确认接受);仅保证本次上线后新上传的文件永久可用
> - AI 回复内容中的图片渲染(PRD 范围仅用户上传)
> - 输入框内发送前的缩略图预览(已有能力,不动)
> - 知识库 / 知识空间等**非会话**上传路径的存储策略(不在本次射程)
> - 会话保留 N 天后的定期清理(本期只做「随会话删除而清理」)
---
## 1. 用户故事
作为 **对话用户**
我希望 **会话里传过的图片一直能看,并且能点开放大**
以便 **随时回看自己提供过的材料,而不是过几天回来只剩一个打不开的文件名**
作为 **平台使用方**
我希望 **不同用户上传的同名文件互不影响**
以便 **不会看到别人的文件,也不会发现自己的文件被顶掉**
---
## 2. 验收标准
> AC-ID 在本特性内唯一。
### 2.1 存储与安全
- **AC-01** — WHEN 用户在会话中上传文件, THE SYSTEM SHALL 将其保存到不会被自动清理的存储位置,使其寿命不短于所属会话。
- **AC-02** — WHEN 两个用户(或同一用户在不同会话)上传文件名相同的文件, THE SYSTEM SHALL 各自独立保存,任一方的内容不得被另一方覆盖,且任一方不得读到另一方的文件。
- **AC-03** — WHEN 用户删除一个会话, THE SYSTEM SHALL 清理该会话的上传附件。
- **AC-04** — IF 请求者不是某会话的所属用户, THEN THE SYSTEM SHALL 拒绝其获取该会话附件的访问链接。
### 2.2 图片展示
| ID | 角色 | 操作 | 预期结果 |
|----|------|------|---------|
| AC-05 | 用户 | 在日常模式 / 任务模式 / 工作流会话任一场景上传图片并发送 | 该条消息中显示图片缩略图(而非仅文件名标识) |
| AC-06 | 用户 | 点击消息中的图片缩略图 | 图片放大至全屏查看 |
| AC-07 | 用户 | 全屏模式下点击右上角关闭 | 退出全屏,回到会话 |
| AC-08 | 用户 | 打开一个较早的历史会话(超过原链接有效期) | 图片正常显示,不因链接过期而失效 |
| AC-09 | 用户 | 查看图片确实已不存在的历史消息 | 原位置显示「图片已失效,无法查看」,不出现浏览器裂图 |
| AC-10 | 用户 | 发送非图片附件 | 展示方式与现状一致,无回归 |
| AC-11 | 用户 | 三个场景中执行上述操作 | 行为与视觉表现一致 |
---
## 3. 边界情况
- **同名文件覆盖(现存缺陷)**:日常模式上传当前以原始文件名作为存储对象名,同名即互相覆盖。本次永久化会把这一缺陷从「3 天内偶发」放大为「永久数据损坏 + 跨用户可见」,因此对象名唯一化是本特性的**必做项**而非优化项。
- 一条消息包含多张图片时,逐张显示缩略图,各自可独立放大。
- 常见图片格式(png/jpg/jpeg/gif/webp/bmp/svg)均按图片处理;识别以文件名后缀为准(存量消息不保证带 MIME 字段)。
- 会话删除为软删除且无恢复入口,故删除时清理附件不会导致「恢复后附件丢失」。
- 附件清理失败(对象存储不可用等)不得阻断会话删除本身。
---
## 4. 设计与实现(指针,不复制)
| 你想知道 | 去哪看 |
|---|---|
| 为什么这么存、为什么不建新表(及备选与否决理由) | design.md §3 |
| 对象命名规则、换发链接的鉴权口径、清理时机 | design.md §4 |
| 代码里看不出的坑(临时桶 3 天策略、两条上传路径行为不一致) | design.md §5 |
| 对外契约与影响面 | design.md §6 |
---
## 相关文档
- 设计真相: [design.md](./design.md)
- 执行与落档: [tasks.md](./tasks.md)
- 版本契约: [../release-contract.md](../release-contract.md)
- PRD: https://dataelem.feishu.cn/wiki/Ifj6wOgSmiyClfkMQySc7ZmvnNb §支持展示对话中上传的图片
- 取代:`features/v3.0.0-beta1/045-chat-image-preview/`(原纯前端方案,因存储需一并改造而升级为本特性)
@@ -0,0 +1,145 @@
# Tasks: 会话上传文件永久化 + 对话图片展示
**关联规格**: [spec.md](./spec.md) · **设计真相**: [design.md](./design.md)
**版本**: v2.6.0
---
## 状态
| 步骤 | 状态 | 备注 |
|------|------|------|
| spec.md | ✅ 已评审 | 用户已确认(存量不可恢复 / 会话删除即清理 / 按需换发链接 / 三场景一并处理 / 对象名唯一化纳入范围) |
| design.md | ✅ 已评审 | 用户已确认;接手时第一入口 |
| tasks.md | ✅ 已拆解 | 方案调整后重排波次 |
| 实现 | 🟡 代码完成待验证 | 14 / 14 完成(需在有对象存储的环境人工验证)|
---
## 开发模式
- 后端 Test-First:对象名生成与换发鉴权都是纯逻辑,可单测覆盖;MinIO 交互 mock 掉。
- 前端手动验证(Playwright 🚧 未落地)。
- **顺序要求**Wave 1(存储基建)→ Wave 2(三个上传入口)→ Wave 3(换发 + 清理)→ Wave 4(前端)。Wave 2 的三个入口彼此独立,可并行。
---
## Tasks
### Wave 1 — 存储层基建
- [x] **T001**: 会话附件对象名生成 + 单元测试
**文件**: `src/backend/bisheng/core/storage/chat_attachment.py``test/core/test_chat_attachment_object_name.py`
**逻辑**: `build_chat_object_name(user_id, filename)``chat/{user_id}/{uuid}{ext}`。只从用户文件名取扩展名;路径分隔符与 `..` 一律丢弃;对象名必须纯 ASCII(design §5 坑 10
**覆盖 AC**: AC-02
**依赖**: 无
- [x] **T002**: 附件「转正」逻辑 + 单元测试
**文件**: `src/backend/bisheng/core/storage/chat_attachment.py``test/core/test_promote_chat_attachments.py`
**逻辑**: `promote_chat_attachments(files, user_id)` —— 从上传时签发的链接反解临时对象名 → 服务端 copy 到主桶 → 把永久对象名写回 files。**已有 object_name 的跳过**(任务模式本就落主桶);**单个失败不影响其他附件与消息本身**
**覆盖 AC**: AC-01, AC-02
**依赖**: T001
### Wave 2 — 在消息落库处接入转正(4 处)
> 上传接口**一个都不改**(design 决策 1 选 B)。日常模式前端改用共用上传接口即可获得 uuid 命名。
- [x] **T003**: 日常模式消息落库接入
**文件**: `src/backend/bisheng/workstation/domain/services/chat_service.py`(两处 `files=json.dumps(data.files)`
**逻辑**: 存库前调 `promote_chat_attachments(data.files, login_user.user_id)`
**覆盖 AC**: AC-01
**依赖**: T002
- [x] **T004**: 工作流会话消息落库接入
**文件**: `src/backend/bisheng/worker/workflow/redis_callback.py``files=json.dumps(chat_response.files…)`
**逻辑**: 同上;注意此处在 Celery worker 内,需确认用户上下文可取
**覆盖 AC**: AC-01
**依赖**: T002
- [x] **T005**: 任务模式消息落库确认
**文件**: `src/backend/bisheng/linsight/domain/utils.py``files=json.dumps(files)`
**逻辑**: 实测灵思是第三种架构——上传落**后端本地磁盘**,执行时才进对象存储。但其入库流程**已经**为图片预览把原始字节提升到了正式桶(`entry["original_file_path"]`),只需在 `_annotate_display_files` 里把它带进消息的 `object_name`;另对走共用上传接口的附件在持久化前统一调 `promote_chat_attachments`
**覆盖 AC**: AC-01, AC-11
**依赖**: T002
- [x] **T006**: 日常模式前端改用共用上传接口
**文件**: `src/frontend/client/src/api/apps.ts``uploadChatFile``urlMap`
**逻辑**: 日常模式不再指向 `/workstation/files`,改用 `/api/v1/knowledge/upload`(已 uuid 命名,**同名覆盖泄露随之消失**)。前端字段已有兜底(`file_id` 回落本地 id、`parsing_status` 有默认值),无需额外适配
**覆盖 AC**: AC-02
**依赖**: 无
### Wave 3 — 换发链接与清理
- [x] **T007**: 换发链接的鉴权逻辑 + 单元测试
**文件**: `src/backend/bisheng/chat_session/domain/chat.py`(或同模块 service
`src/backend/test/chat_session/test_attachment_link.py`(新建)
**逻辑**: `resolve_attachment_url(chat_id, file_id, login_user)` —— ①载入会话 ②校验请求者为会话所属用户 ③在该会话消息的 files 中查 `file_id` 取**对象名** ④签发短时效链接。**对象名只从服务端数据取,绝不使用入参**(design §3 决策 3、§5 坑 6
**测试**: 非所属用户 → 拒绝;file_id 不存在 → 拒绝;会话不存在 → 拒绝;正常 → 返回链接;**入参伪造对象名不影响结果**
**覆盖 AC**: AC-04, AC-08
**依赖**: 无
- [x] **T008**: 换发链接端点
**文件**: `src/backend/bisheng/chat_session/api/endpoints/chat.py`
**逻辑**: 新增端点,入参 `chat_id` + `file_id`,委托 T007;不新增错误码,复用既有未授权 / 未找到响应
**覆盖 AC**: AC-04, AC-08
**依赖**: T006
- [x] **T009**: 会话删除时清理附件
**文件**: `src/backend/bisheng/chat_session/domain/chat.py``delete_session`
**逻辑**: 软删会话后,从该会话的消息 files 中取出 `object_name` 并逐个删除(上传时拿不到会话 ID,无法按前缀清扫——design §5 坑 8)。**清理失败只记日志,不得让删除会话失败**(spec §3)
**覆盖 AC**: AC-03
**依赖**: T002
### Wave 4 — 前端(client
- [x] **T009**: 共用「消息图片」组件 + 换发链接接入
**文件**: `src/frontend/client/src/components/Chat/Messages/Content/MessageImage.tsx`(新建,基于既有 `Image.tsx` / `DialogImage.tsx` 提取)
`src/frontend/client/src/api/chatApi.ts`(新增换发链接请求方法)
**逻辑**: 渲染时调换发接口取链接 → 缩略图 → 点击全屏(右上角关闭);**换发失败或图片加载失败** → 渲染占位「图片已失效,无法查看」(design §3 决策 4);老消息无对象名字段时直接走失效分支(向后兼容)
**覆盖 AC**: AC-06, AC-07, AC-08, AC-09
**依赖**: T007
- [x] **T010**: 两套消息渲染各自接入
**文件**: `src/frontend/client/src/components/Chat/AiMessageBubble.tsx`(日常 / 任务模式)
`src/frontend/client/src/pages/appChat/components/MessageFile.tsx` / `ChatFile.tsx`(工作流会话)
**逻辑**: 按文件名后缀分流(png/jpg/jpeg/gif/webp/bmp/svg → 图片组件,其余保持现状)。**这是两套独立渲染,必须都改**(design §5 坑 7);文件名字段各入口键名不一,取值做兼容
**覆盖 AC**: AC-05, AC-10, AC-11
**依赖**: T009
- [x] **T012**: 失效占位组件(独立组件)
**文件**: `src/frontend/client/src/components/Chat/Messages/Content/InvalidImagePlaceholder.tsx`(新建)
**逻辑**: 浅灰圆角卡片,居中 `Outlined.FileImage`bisheng-icons,已确认存在)+ 下方文字「图片已失效,无法查看」。尺寸与图片缩略图一致,避免消息布局跳动
**覆盖 AC**: AC-09
**依赖**: 无
- [x] **T011**: 失效占位文案 i18n
**文件**: `src/frontend/client/src/locales/{en,zh-Hans,ja}/translation.json`
**逻辑**: 「图片已失效,无法查看」三语(嵌套命名空间)
**⚠️ 前车之鉴**: F044 曾把文案加错命名空间导致不生效——加完确认页面 `t()` 实际读的是哪个命名空间
**覆盖 AC**: AC-09
**依赖**: T009
---
## 手动验证清单(全部完成后按序跑)
1. **两个不同用户各上传同名 `1.png`** → 各自会话里看到的都是自己那张(AC-02,**最关键**,验证数据泄露已修)
2. 三个场景各传一张图 → 缩略图 → 点开全屏 → 右上角关闭(AC-05/06/07/11
3. 传一个非图片附件 → 展示与改动前一致(AC-10)
4. 删除一个含附件的会话 → 该会话消息中记录的对象在存储中应已消失(AC-03)
5. 用另一个账号调换发接口请求他人会话的附件 → 应被拒绝(AC-04)
6. 打开一个存量老会话 → 图片显示「图片已失效,无法查看」(预期行为,非缺陷)
7. 切 en / ja 检查新增文案(AC-09
---
## 实际偏差记录
> 只留一行指针,论证在 design.md。推翻已 ★ 确认的决策时先停下重新确认。
- T001 偏离:对象名由 `chat/{chat_id}/` 改为 `chat/{user_id}/` → 更新 design 决策 1 + 新增坑 8(上传发生在会话创建之前,命名时拿不到会话 ID)
- 方案整体调整:由「上传即落主桶」改为「上传不动 + 发消息时转正」→ 更新 design 决策 1(用户提出应避免改共用上传接口,顺此消除孤儿文件问题)
- 原 T002「前缀批量删除」已移除:新方案下删除按对象名进行,该能力无调用方,不留投机性死代码
- T005 偏离:任务模式为第三种架构(上传落本地磁盘、执行时才进对象存储),但其图片原件已被提升到正式桶,故只需把该对象名带进消息 → design §5 新增坑 11
- 转正逻辑补「存储不可达时不阻断发消息」:原实现会让 MinIO 故障直接导致消息发不出去,违背 design「附件问题不应拖垮消息」的原则(已补测试固化)
- T008 偏离:删除改为「从消息取对象名逐个删」,不再按前缀清扫(同坑 8)
-6
View File
@@ -1,6 +0,0 @@
{
"name": "bisheng",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
@@ -1,19 +1,13 @@
import asyncio
from typing import Optional, Union
from typing import Union
from fastapi import APIRouter, Body, Query, Request
from fastapi.params import Depends
from loguru import logger
from bisheng.api.services.workflow import WorkFlowService
from bisheng.api.v1.schema.base_schema import PageList
from bisheng.api.v1.schemas import AddChatMessages, ChatList, resp_200
from bisheng.api.v1.schemas import AddChatMessages, resp_200
from bisheng.chat_session.domain.chat import ChatSessionService
from bisheng.chat_session.domain.services.chat_message_service import ChatMessageService
from bisheng.common.dependencies.user_deps import UserPayload
from bisheng.common.errcode.http_error import UnAuthorizedError
from bisheng.database.models.flow import FlowStatus, FlowType
from bisheng.database.models.session import MessageSessionDao
from bisheng.share_link.api.dependencies import header_share_token_parser
from bisheng.share_link.domain.models.share_link import ShareLink
from bisheng.utils import get_request_ip
@@ -21,16 +15,18 @@ from bisheng.utils import get_request_ip
router = APIRouter()
@router.get('/chat/app/list')
def get_app_chat_list(*,
keyword: Optional[str] = None,
mark_user: Optional[str] = None,
mark_status: Optional[int] = None,
task_id: Optional[int] = Query(default=None, description='Callout TaskID'),
flow_type: Optional[int] = None,
page_num: Optional[int] = 1,
page_size: Optional[int] = 20,
login_user: UserPayload = Depends(UserPayload.get_login_user)):
@router.get("/chat/app/list")
def get_app_chat_list(
*,
keyword: str | None = None,
mark_user: str | None = None,
mark_status: int | None = None,
task_id: int | None = Query(default=None, description="Callout TaskID"),
flow_type: int | None = None,
page_num: int | None = 1,
page_size: int | None = 20,
login_user: UserPayload = Depends(UserPayload.get_login_user),
):
"""Get session list filtered by annotation task."""
result = ChatSessionService.get_app_chat_list(
login_user=login_user,
@@ -45,14 +41,16 @@ def get_app_chat_list(*,
return resp_200(result)
@router.get('/chat/history')
async def get_chat_message(*,
chat_id: str,
flow_id: str,
id: Optional[str] = None,
page_size: Optional[int] = 20,
login_user: UserPayload = Depends(UserPayload.get_login_user),
share_link: Union['ShareLink', None] = Depends(header_share_token_parser)):
@router.get("/chat/history")
async def get_chat_message(
*,
chat_id: str,
flow_id: str,
id: str | None = None,
page_size: int | None = 20,
login_user: UserPayload = Depends(UserPayload.get_login_user),
share_link: Union["ShareLink", None] = Depends(header_share_token_parser),
):
history = await ChatSessionService.get_chat_history(chat_id, flow_id, id, page_size)
if history and login_user.user_id != history[0].user_id:
@@ -61,61 +59,77 @@ async def get_chat_message(*,
return resp_200(history)
@router.get('/chat/info')
async def get_chat_info(chat_id: str = Query(..., description='Session Uniqueidchat_id')):
@router.get("/chat/info")
async def get_chat_info(chat_id: str = Query(..., description="Session Uniqueidchat_id")):
"""Get session details by chat_id."""
res = await ChatSessionService.get_session_info(chat_id)
return resp_200(res)
@router.post('/chat/conversation/rename')
async def rename(conversationId: str = Body(..., description='Session sid', embed=True),
name: str = Body(..., description='Session name', embed=True),
login_user: UserPayload = Depends(UserPayload.get_login_user)):
@router.post("/chat/conversation/rename")
async def rename(
conversationId: str = Body(..., description="Session sid", embed=True),
name: str = Body(..., description="Session name", embed=True),
login_user: UserPayload = Depends(UserPayload.get_login_user),
):
await ChatSessionService.rename_session(conversationId, name)
return resp_200()
@router.delete('/chat/{chat_id}', status_code=200)
async def del_chat_id(*,
request: Request,
chat_id: str,
login_user: UserPayload = Depends(UserPayload.get_login_user)):
@router.delete("/chat/{chat_id}", status_code=200)
async def del_chat_id(*, request: Request, chat_id: str, login_user: UserPayload = Depends(UserPayload.get_login_user)):
await ChatSessionService.delete_session(chat_id, login_user, get_request_ip(request))
return resp_200()
@router.post('/chat/message', status_code=200)
def add_chat_messages(*,
request: Request,
data: AddChatMessages,
login_user: UserPayload = Depends(UserPayload.get_login_user)):
@router.get("/chat/{chat_id}/files/{file_id}/url", status_code=200)
async def get_chat_attachment_url(
*, request: Request, chat_id: str, file_id: str, login_user: UserPayload = Depends(UserPayload.get_login_user)
):
"""Fresh link for an attachment of this conversation.
The link issued at upload time expires; the client asks for a new one when
it renders. Which object gets signed is decided from the conversation's own
messages, never from anything the caller sends.
"""
url = await ChatSessionService.resolve_attachment_url(chat_id, file_id, login_user)
return resp_200(data={"url": url})
@router.post("/chat/message", status_code=200)
def add_chat_messages(
*, request: Request, data: AddChatMessages, login_user: UserPayload = Depends(UserPayload.get_login_user)
):
"""Add a full Q&A record. Security check write usage."""
message_dbs = ChatMessageService.add_qa_messages(data, login_user, get_request_ip(request))
return resp_200(data=message_dbs)
@router.put('/chat/message/{message_id}', status_code=200)
def update_chat_message(*,
message_id: int,
message: str = Body(embed=True),
category: str = Body(default=None, embed=True),
login_user: UserPayload = Depends(UserPayload.get_login_user)):
@router.put("/chat/message/{message_id}", status_code=200)
def update_chat_message(
*,
message_id: int,
message: str = Body(embed=True),
category: str = Body(default=None, embed=True),
login_user: UserPayload = Depends(UserPayload.get_login_user),
):
"""Update the content of a message. Security check usage."""
ChatMessageService.update_message(message_id, message, category, login_user)
return resp_200()
@router.delete('/chat/message/{message_id}', status_code=200)
@router.delete("/chat/message/{message_id}", status_code=200)
def del_message_id(*, message_id: str, login_user: UserPayload = Depends(UserPayload.get_login_user)):
ChatMessageService.delete_message(login_user.user_id, message_id)
return resp_200()
@router.get('/chat/list')
def get_session_list(page: Optional[int] = Query(default=1, ge=1, le=1000),
limit: Optional[int] = Query(default=10, ge=1, le=100),
login_user: UserPayload = Depends(UserPayload.get_login_user)):
@router.get("/chat/list")
def get_session_list(
page: int | None = Query(default=1, ge=1, le=1000),
limit: int | None = Query(default=10, ge=1, le=100),
login_user: UserPayload = Depends(UserPayload.get_login_user),
):
"""Get session list sorted by update_time descending. Only shows daily chat and linsight sessions."""
chat_sessions = ChatSessionService.get_user_session_list(login_user.user_id, page, limit)
return resp_200(chat_sessions)
+127 -50
View File
@@ -1,5 +1,4 @@
from typing import List, Optional
import json
from loguru import logger
from bisheng.api.services.audit_log import AuditLogService
@@ -8,10 +7,12 @@ from bisheng.api.v1.schema.base_schema import PageList
from bisheng.api.v1.schema.chat_schema import AppChatList
from bisheng.api.v1.schema.workflow import WorkflowEventType
from bisheng.api.v1.schemas import ChatList
from bisheng.chat_session.domain.services.chat_message_service import _resolve_leaf_tenant_id
from bisheng.chat_session.utils import get_session_app_type
from bisheng.common.constants.enums.telemetry import BaseTelemetryTypeEnum
from bisheng.common.dependencies.user_deps import UserPayload
from bisheng.common.errcode.http_error import UnAuthorizedError
from bisheng.common.schemas.telemetry.event_data_schema import NewMessageSessionEventData, DeleteMessageSessionEventData
from bisheng.common.errcode.http_error import NotFoundError, UnAuthorizedError
from bisheng.common.schemas.telemetry.event_data_schema import DeleteMessageSessionEventData, NewMessageSessionEventData
from bisheng.common.services import telemetry_service
from bisheng.common.services.base import BaseService
from bisheng.core.logger import trace_id_var
@@ -19,21 +20,20 @@ from bisheng.database.models.assistant import AssistantDao
from bisheng.database.models.flow import FlowDao, FlowType
from bisheng.database.models.mark_record import MarkRecordDao, MarkRecordStatus
from bisheng.database.models.mark_task import MarkTaskDao
from bisheng.core.storage.minio.minio_manager import get_minio_storage
from bisheng.database.models.message import ChatMessageDao
from bisheng.database.models.session import MessageSession, MessageSessionDao, SensitiveStatus
from bisheng.database.models.user_group import UserGroupDao
from bisheng.chat_session.domain.services.chat_message_service import _resolve_leaf_tenant_id
from bisheng.chat_session.utils import get_session_app_type
from bisheng.user.domain.models.user import UserDao
class ChatSessionService:
"""Chat session lifecycle services."""
@staticmethod
async def get_chat_history(chat_id: str, flow_id: str, message_id: Optional[str] = None,
page_size: Optional[int] = 20) -> List['ChatMessageHistoryResponse']:
async def get_chat_history(
chat_id: str, flow_id: str, message_id: str | None = None, page_size: int | None = 20
) -> list["ChatMessageHistoryResponse"]:
"""Retrieve chat history for a user."""
from bisheng.api.v1.schema.chat_schema import ChatMessageHistoryResponse
@@ -43,19 +43,16 @@ class ChatSessionService:
if not session_info or session_info.flow_id != flow_id:
return []
history = await ChatMessageDao.afilter_message_by_chat_id(chat_id=chat_id, flow_id=flow_id,
message_id=message_id, page_size=page_size)
history = await ChatMessageDao.afilter_message_by_chat_id(
chat_id=chat_id, flow_id=flow_id, message_id=message_id, page_size=page_size
)
if history:
user_info = await UserDao.aget_user(user_id=session_info.user_id)
history = ChatMessageHistoryResponse.from_chat_message_objs(
history,
user_info,
session_info
)
history = ChatMessageHistoryResponse.from_chat_message_objs(history, user_info, session_info)
return history
@staticmethod
async def get_session_info(chat_id: str) -> Optional[MessageSession]:
async def get_session_info(chat_id: str) -> MessageSession | None:
"""Get session details with logo URL resolved."""
res = await MessageSessionDao.async_get_one(chat_id)
if res:
@@ -67,6 +64,81 @@ class ChatSessionService:
"""Rename a chat session."""
await MessageSessionDao.update_session_name(conversation_id, name)
@staticmethod
async def _own_conversation_or_raise(chat_id: str, login_user: UserPayload) -> MessageSession:
"""Load a conversation, refusing anyone who doesn't own it."""
session_chat = await MessageSessionDao.async_get_one(chat_id)
if not session_chat or session_chat.is_delete:
raise NotFoundError.http_exception()
if session_chat.user_id != login_user.user_id:
raise UnAuthorizedError.http_exception()
return session_chat
@staticmethod
def _attachments_of(messages: list) -> list[dict]:
"""Every attachment recorded across a conversation's messages."""
attachments = []
for message in messages:
if not message.files:
continue
try:
files = json.loads(message.files)
except (TypeError, ValueError):
logger.warning("unparsable files payload on message of chat {}", getattr(message, "chat_id", "?"))
continue
attachments.extend(f for f in files or [] if isinstance(f, dict))
return attachments
@staticmethod
async def _remove_attachments(chat_id: str) -> None:
"""Drop the files a deleted conversation was holding.
Deletion is a soft delete with no way back, so the files have no reader
left. Best-effort on purpose: failing to reach storage must not leave
the user staring at a conversation that refuses to disappear.
"""
try:
messages = await ChatMessageDao.aget_messages_by_chat_id(chat_id=chat_id, limit=1000)
object_names = {
f["object_name"] for f in ChatSessionService._attachments_of(messages) if f.get("object_name")
}
if not object_names:
return
minio_client = await get_minio_storage()
for object_name in object_names:
try:
await minio_client.remove_object(object_name=object_name)
except Exception:
logger.exception("failed to remove attachment {} of chat {}", object_name, chat_id)
except Exception:
logger.exception("failed to clean up attachments of chat {}", chat_id)
@staticmethod
async def resolve_attachment_url(chat_id: str, file_id: str, login_user: UserPayload) -> str:
"""Issue a fresh link for one attachment of one conversation.
The link handed out at upload time expires, so the client comes back
here when it renders. The object name is read from what we stored on the
conversation's messages and never taken from the caller -- signing a
caller-supplied name would open the whole bucket to anyone with an
account.
"""
await ChatSessionService._own_conversation_or_raise(chat_id, login_user)
messages = await ChatMessageDao.aget_messages_by_chat_id(chat_id=chat_id, limit=1000)
for attachment in ChatSessionService._attachments_of(messages):
if str(attachment.get("file_id")) != str(file_id):
continue
object_name = attachment.get("object_name")
if not object_name:
# Written before attachments were made permanent: the file is
# gone with the temp bucket. Say so rather than guess.
break
minio_client = await get_minio_storage()
return await minio_client.get_share_link(object_name)
raise NotFoundError.http_exception()
@staticmethod
async def delete_session(chat_id: str, login_user: UserPayload, request_ip: str) -> None:
"""Delete a session with audit logging and telemetry."""
@@ -87,6 +159,7 @@ class ChatSessionService:
await AuditLogService.delete_chat_workflow(login_user, request_ip, flow_info)
await MessageSessionDao.delete_session(chat_id)
await ChatSessionService._remove_attachments(chat_id)
await telemetry_service.log_event(
user_id=login_user.user_id,
@@ -99,11 +172,11 @@ class ChatSessionService:
def get_app_chat_list(
*,
login_user: UserPayload,
keyword: Optional[str] = None,
mark_user: Optional[str] = None,
mark_status: Optional[int] = None,
task_id: Optional[int] = None,
flow_type: Optional[int] = None,
keyword: str | None = None,
mark_user: str | None = None,
mark_status: int | None = None,
task_id: int | None = None,
flow_type: int | None = None,
page_num: int = 1,
page_size: int = 20,
) -> PageList:
@@ -117,20 +190,20 @@ class ChatSessionService:
if task_id:
if not login_user.is_admin():
task = MarkTaskDao.get_task_byid(task_id)
if str(login_user.user_id) not in task.process_users.split(','):
if str(login_user.user_id) not in task.process_users.split(","):
raise UnAuthorizedError()
if user_groups:
task = MarkTaskDao.get_task_byid(task_id)
group_flow_ids = task.app_id.split(',')
group_flow_ids = task.app_id.split(",")
if not group_flow_ids:
return PageList(list=[], total=0)
else:
task = MarkTaskDao.get_task_byid(task_id)
if str(login_user.user_id) not in task.process_users.split(','):
if str(login_user.user_id) not in task.process_users.split(","):
raise UnAuthorizedError()
group_flow_ids = MarkTaskDao.get_task_byid(task_id).app_id.split(',')
group_flow_ids = MarkTaskDao.get_task_byid(task_id).app_id.split(",")
else:
group_flow_ids = MarkTaskDao.get_task_byid(task_id).app_id.split(',')
group_flow_ids = MarkTaskDao.get_task_byid(task_id).app_id.split(",")
if keyword:
flows = FlowDao.get_flow_list_by_name(name=keyword)
@@ -182,16 +255,16 @@ class ChatSessionService:
if mark_status != tmp.mark_status:
continue
if mark_user:
users = [int(u) for u in mark_user.split(',')]
users = [int(u) for u in mark_user.split(",")]
if tmp.mark_id not in users:
continue
result.append(tmp)
result = result[(page_num - 1) * page_size: page_num * page_size]
result = result[(page_num - 1) * page_size : page_num * page_size]
return PageList(list=result, total=total)
@staticmethod
def get_user_session_list(user_id: int, page: int = 1, limit: int = 10) -> List[ChatList]:
def get_user_session_list(user_id: int, page: int = 1, limit: int = 10) -> list[ChatList]:
"""List daily chat and linsight sessions for a user, sorted by update_time descending."""
allowed_flow_types = [FlowType.WORKSTATION.value, FlowType.LINSIGHT.value]
@@ -221,7 +294,7 @@ class ChatSessionService:
flow_name=one.flow_name,
flow_type=one.flow_type,
name=one.name,
logo=BaseService.get_logo_share_link(one.flow_logo) if one.flow_logo else '',
logo=BaseService.get_logo_share_link(one.flow_logo) if one.flow_logo else "",
latest_message=latest_messages.get(one.chat_id, None),
create_time=one.create_time,
update_time=one.update_time,
@@ -235,7 +308,7 @@ class ChatSessionService:
flow_id: str,
login_user: UserPayload,
request_ip: str,
) -> Optional[MessageSession]:
) -> MessageSession | None:
"""Get existing session or create a new one with audit log and telemetry.
Used when adding messages to ensure a session exists.
@@ -254,29 +327,33 @@ class ChatSessionService:
flow_info = FlowDao.get_flow_by_id(flow_id)
if flow_info:
session_info = MessageSessionDao.insert_one(MessageSession(
chat_id=chat_id,
flow_id=flow_id,
flow_type=flow_info.flow_type,
flow_name=flow_info.name,
user_id=login_user.user_id,
sensitive_status=SensitiveStatus.VIOLATIONS.value,
tenant_id=leaf_tenant_id,
))
session_info = MessageSessionDao.insert_one(
MessageSession(
chat_id=chat_id,
flow_id=flow_id,
flow_type=flow_info.flow_type,
flow_name=flow_info.name,
user_id=login_user.user_id,
sensitive_status=SensitiveStatus.VIOLATIONS.value,
tenant_id=leaf_tenant_id,
)
)
if flow_info.flow_type == FlowType.WORKFLOW.value:
AuditLogService.create_chat_workflow(login_user, request_ip, flow_id, flow_info)
else:
assistant_info = AssistantDao.get_one_assistant(flow_id)
if assistant_info:
session_info = MessageSessionDao.insert_one(MessageSession(
chat_id=chat_id,
flow_id=flow_id,
flow_type=FlowType.ASSISTANT.value,
flow_name=assistant_info.name,
user_id=login_user.user_id,
sensitive_status=SensitiveStatus.VIOLATIONS.value,
tenant_id=leaf_tenant_id,
))
session_info = MessageSessionDao.insert_one(
MessageSession(
chat_id=chat_id,
flow_id=flow_id,
flow_type=FlowType.ASSISTANT.value,
flow_name=assistant_info.name,
user_id=login_user.user_id,
sensitive_status=SensitiveStatus.VIOLATIONS.value,
tenant_id=leaf_tenant_id,
)
)
AuditLogService.create_chat_assistant(login_user, request_ip, flow_id)
if session_info:
@@ -0,0 +1,156 @@
"""Object naming for files a user uploads inside a conversation.
Attachments live in the main bucket, grouped by uploader. They are NOT grouped
by conversation: the first upload of a new chat happens before the chat exists
(the client still holds "new" and the backend assigns the id when the message
is sent), so a conversation id simply isn't available at naming time. Deleting
a conversation therefore collects object names from its messages rather than
sweeping a prefix -- the message already carries them.
The stored name is a uuid: the original filename is display metadata on the
message, never an identity -- two users uploading "1.png" must not collide.
"""
import os
from urllib.parse import unquote, urlparse
from uuid import uuid4
from loguru import logger
from bisheng.core.storage.minio.minio_manager import get_minio_storage, get_minio_storage_sync
CHAT_OBJECT_PREFIX = "chat/"
# Long enough for real-world suffixes (".jpeg", ".docx"), short enough that a
# dotted filename can't smuggle half its name into the object key.
_MAX_EXT_LEN = 10
def chat_object_prefix(user_id: int | str) -> str:
"""Prefix holding one user's conversation attachments."""
return f"{CHAT_OBJECT_PREFIX}{user_id}/"
def _safe_extension(filename: str) -> str:
"""Extension of `filename`, or "" when it doesn't have a usable one.
The filename comes from the client, so only the suffix is ever read from
it -- never the stem, and never anything that could steer the object
elsewhere in the bucket.
"""
if not filename:
return ""
# Strip any directory part the client may have sent (POSIX and Windows).
base = filename.replace("\\", "/").rsplit("/", 1)[-1]
ext = os.path.splitext(base)[1].lower()
# splitext treats ".gitignore" as all-stem, but be explicit: a leading dot
# is a hidden file, not an extension.
if len(ext) > _MAX_EXT_LEN or not ext[1:].isalnum():
return ""
return ext
def build_chat_object_name(user_id: int | str, filename: str) -> str:
"""Storage object name for one conversation attachment."""
return f"{chat_object_prefix(user_id)}{uuid4().hex}{_safe_extension(filename)}"
def temp_object_name_from_url(file_url: str, tmp_bucket: str) -> str | None:
"""Object name inside the temp bucket, read back off the link we issued.
Uploads answer with a presigned link (`[host]/<bucket>/<object>?…`), and the
client hands that same link back on the message, so the object name is
recoverable without asking the upload endpoints to return anything new.
Returns None when the link doesn't point into the temp bucket -- the file is
then either already permanent or not ours to move.
"""
if not file_url:
return None
path = urlparse(file_url).path
marker = f"/{tmp_bucket}/"
idx = path.find(marker)
if idx == -1:
return None
return unquote(path[idx + len(marker) :]) or None
def _plan_promotions(files: list[dict], user_id: int | str, tmp_bucket: str) -> list[tuple[dict, str, str]]:
"""Work out which attachments still need moving: (file, source, dest)."""
plan = []
for file in files:
if not isinstance(file, dict) or file.get("object_name"):
# Already permanent — task-mode uploads land in the main bucket.
continue
source_object = temp_object_name_from_url(file.get("filepath") or file.get("file_path") or "", tmp_bucket)
if not source_object:
continue
dest_object = build_chat_object_name(user_id, file.get("filename") or file.get("file_name") or "")
plan.append((file, source_object, dest_object))
return plan
async def promote_chat_attachments(files: list[dict] | None, user_id: int | str) -> list[dict]:
"""Move a message's attachments out of the temp bucket, in place.
Called when the message is sent, not when the file is uploaded: whatever is
never sent stays in temp and is reclaimed by that bucket's 3-day rule, so
abandoned uploads cost nothing and need no separate sweeper.
Each promoted file gains an `object_name` pointing at the permanent copy —
that field is what later resolves a fresh link and what conversation
deletion removes.
One attachment failing must not cost the others or the message itself, so
failures are logged and that file is simply left without `object_name`.
"""
if not files:
return []
try:
minio_client = await get_minio_storage()
except Exception:
# Best-effort by design: an attachment that can't be made permanent is
# worth a broken thumbnail, not a message the user can't send.
logger.exception("cannot reach object storage to promote chat attachments for user {}", user_id)
return files
for file, source_object, dest_object in _plan_promotions(files, user_id, minio_client.tmp_bucket):
try:
await minio_client.copy_object(
source_bucket=minio_client.tmp_bucket,
source_object=source_object,
dest_bucket=minio_client.bucket,
dest_object=dest_object,
)
except Exception:
logger.exception("failed to promote chat attachment {} for user {}", source_object, user_id)
continue
file["object_name"] = dest_object
return files
def promote_chat_attachments_sync(files: list[dict] | None, user_id: int | str) -> list[dict]:
"""Sync twin of `promote_chat_attachments`, for the Celery worker path."""
if not files:
return []
try:
minio_client = get_minio_storage_sync()
except Exception:
# Same reasoning as the async twin: never block the message.
logger.exception("cannot reach object storage to promote chat attachments for user {}", user_id)
return files
for file, source_object, dest_object in _plan_promotions(files, user_id, minio_client.tmp_bucket):
try:
minio_client.copy_object_sync(
source_bucket=minio_client.tmp_bucket,
source_object=source_object,
dest_bucket=minio_client.bucket,
dest_object=dest_object,
)
except Exception:
logger.exception("failed to promote chat attachment {} for user {}", source_object, user_id)
continue
file["object_name"] = dest_object
return files
@@ -24,6 +24,7 @@ from bisheng.core.cache.redis_manager import get_redis_client
from bisheng.core.cache.utils import save_file_to_folder
from bisheng.core.logger import trace_id_var
from bisheng.core.prompts.manager import get_prompt_manager
from bisheng.core.storage.chat_attachment import promote_chat_attachments
from bisheng.core.storage.minio.minio_manager import get_minio_storage
from bisheng.database.models.flow import FlowType
from bisheng.database.models.session import MessageSession, MessageSessionDao
@@ -241,7 +242,12 @@ class LinsightWorkbenchImpl:
chat_id=chat_id,
user_id=login_user.user_id,
question=submit_obj.question,
files=cls._annotate_display_files(display_files, processed_files),
# Attachments that came in through the shared upload endpoint are
# still sitting in the temp bucket; the ones ingested by linsight
# already have an object_name and are skipped.
files=await promote_chat_attachments(
cls._annotate_display_files(display_files, processed_files), login_user.user_id
),
)
return message_session, linsight_session_version
@@ -276,6 +282,11 @@ class LinsightWorkbenchImpl:
item["parsing_status"] = p.get("parsing_status") or item.get("parsing_status")
if p.get("error_message"):
item["error_message"] = p["error_message"]
# Ingestion already persisted the original image bytes for the
# workspace preview; naming it here lets the conversation resolve
# a fresh link for it too, the same way the other chat modes do.
if p.get("original_file_path"):
item["object_name"] = p["original_file_path"]
annotated.append(item)
return annotated
@@ -3,22 +3,29 @@ import json
import os
import time
import uuid
from typing import AsyncIterator, Iterator, Dict, List
from collections.abc import AsyncIterator, Iterator
from langchain_core.documents import Document
from loguru import logger
from bisheng.api.v1.schema.workflow import WorkflowEventType
from bisheng.api.v1.schemas import ChatResponse
from bisheng.citation.domain.schemas.citation_schema import CitationRegistryItemSchema
from bisheng.citation.domain.services.citation_prompt_helper import (
collect_rag_citation_registry_items,
select_registry_items_for_persistence,
save_message_citations_sync,
select_registry_items_for_persistence,
)
from bisheng.common.chat.utils import sync_judge_source, sync_process_source_document
from bisheng.common.constants.enums.telemetry import BaseTelemetryTypeEnum, ApplicationTypeEnum
from bisheng.common.errcode.flow import WorkFlowNodeRunMaxTimesError, WorkFlowWaitUserTimeoutError, \
WorkFlowNodeUpdateError, WorkFlowVersionUpdateError, WorkFlowTaskBusyError, WorkFlowTaskOtherError
from bisheng.common.constants.enums.telemetry import ApplicationTypeEnum, BaseTelemetryTypeEnum
from bisheng.common.errcode.flow import (
WorkFlowNodeRunMaxTimesError,
WorkFlowNodeUpdateError,
WorkFlowTaskBusyError,
WorkFlowTaskOtherError,
WorkFlowVersionUpdateError,
WorkFlowWaitUserTimeoutError,
)
from bisheng.common.errcode.http_error import ServerError
from bisheng.common.schemas.telemetry.event_data_schema import NewMessageSessionEventData
from bisheng.common.services import telemetry_service
@@ -26,26 +33,36 @@ from bisheng.common.services.config_service import settings
from bisheng.common.utils.title_generator import generate_conversation_title_sync
from bisheng.core.cache.redis_manager import get_redis_client_sync
from bisheng.core.logger import trace_id_var
from bisheng.core.storage.chat_attachment import promote_chat_attachments_sync
from bisheng.database.models.flow import FlowDao, FlowType
from bisheng.database.models.message import ChatMessageDao, ChatMessage
from bisheng.database.models.session import MessageSessionDao, MessageSession
from bisheng.database.models.message import ChatMessage, ChatMessageDao
from bisheng.database.models.session import MessageSession, MessageSessionDao
from bisheng.llm.domain import LLMService
from bisheng.utils.threadpool import thread_pool
from bisheng.workflow.callback.base_callback import BaseCallback
from bisheng.workflow.callback.event import NodeStartData, NodeEndData, UserInputData, GuideWordData, GuideQuestionData, \
OutputMsgData, StreamMsgData, StreamMsgOverData, OutputMsgChooseData, OutputMsgInputData
from bisheng.workflow.callback.event import (
GuideQuestionData,
GuideWordData,
NodeEndData,
NodeStartData,
OutputMsgChooseData,
OutputMsgData,
OutputMsgInputData,
StreamMsgData,
StreamMsgOverData,
UserInputData,
)
from bisheng.workflow.common.workflow import WorkflowStatus
class RedisCallback(BaseCallback):
def __init__(self, unique_id: str, workflow_id: str, chat_id: str, user_id: int, **kwargs):
# F022 INV-T18: Owner tenant of the Flow (set by the celery task entry
# via tasks.py from FlowDao.get_flow_by_id) — used by sync helpers
# such as generate_session_title that resolve workbench config in a
# worker context with no admin-scope ContextVar.
self.tenant_id = kwargs.pop('tenant_id', None)
super(RedisCallback, self).__init__()
self.tenant_id = kwargs.pop("tenant_id", None)
super().__init__()
# Unique for asynchronous tasksID
self.unique_id = unique_id
self.workflow_id = workflow_id
@@ -53,43 +70,43 @@ class RedisCallback(BaseCallback):
self.user_id = user_id
self.workflow = None
self.create_session = False
self.source = kwargs.get('source', 'platform') # only platform or api
self.source = kwargs.get("source", "platform") # only platform or api
self.new_session = None
self.redis_client = get_redis_client_sync()
self.workflow_data_key = f'workflow:{unique_id}:data'
self.workflow_status_key = f'workflow:{unique_id}:status'
self.workflow_event_key = f'workflow:{unique_id}:event'
self.workflow_input_key = f'workflow:{unique_id}:input'
self.workflow_stop_key = f'workflow:{unique_id}:stop'
self.workflow_data_key = f"workflow:{unique_id}:data"
self.workflow_status_key = f"workflow:{unique_id}:status"
self.workflow_event_key = f"workflow:{unique_id}:event"
self.workflow_input_key = f"workflow:{unique_id}:input"
self.workflow_stop_key = f"workflow:{unique_id}:stop"
self.workflow_expire_time = settings.get_workflow_conf().timeout * 60 + 60
def set_workflow_data(self, data: Dict, override: Dict = None):
def set_workflow_data(self, data: dict, override: dict = None):
data = self.override_nodes_params(data, override)
self.redis_client.set(self.workflow_data_key, data, expiration=self.workflow_expire_time)
@staticmethod
def override_nodes_params(data: Dict, override: Dict = None) -> Dict:
def override_nodes_params(data: dict, override: dict = None) -> dict:
if not override:
return data
def replace_param(one_params: List[Dict], one_node_id: str):
def replace_param(one_params: list[dict], one_node_id: str):
for param in one_params:
param_key = param.get('key')
param_key = param.get("key")
if param_key not in override[one_node_id]:
continue
param['value'] = override[one_node_id][param_key]
param["value"] = override[one_node_id][param_key]
nodes = data.get('nodes', [])
nodes = data.get("nodes", [])
for node in nodes:
node_data = node.get('data', {})
node_id = node_data.get('id')
node_data = node.get("data", {})
node_id = node_data.get("id")
if node_id not in override:
continue
group_params = node_data.get('group_params', [])
group_params = node_data.get("group_params", [])
for group_param in group_params:
replace_param(group_param.get('params', []), node_id)
replace_param(group_param.get("params", []), node_id)
return data
async def async_set_workflow_data(self, data: dict):
@@ -99,18 +116,22 @@ class RedisCallback(BaseCallback):
return self.redis_client.get(self.workflow_data_key)
def set_workflow_status(self, status: str, reason: str = None):
self.redis_client.set(self.workflow_status_key,
{'status': status, 'reason': reason, 'time': time.time()},
expiration=3600 * 24 * 7)
self.redis_client.set(
self.workflow_status_key,
{"status": status, "reason": reason, "time": time.time()},
expiration=3600 * 24 * 7,
)
if status in [WorkflowStatus.FAILED.value, WorkflowStatus.SUCCESS.value]:
# Message Events and StatuskeyConsumption may also be required
self.redis_client.delete(self.workflow_data_key)
self.redis_client.delete(self.workflow_input_key)
async def async_set_workflow_status(self, status: str, reason: str = None):
await self.redis_client.aset(self.workflow_status_key,
{'status': status, 'reason': reason, 'time': time.time()},
expiration=3600 * 24 * 7)
await self.redis_client.aset(
self.workflow_status_key,
{"status": status, "reason": reason, "time": time.time()},
expiration=3600 * 24 * 7,
)
if status in [WorkflowStatus.FAILED.value, WorkflowStatus.SUCCESS.value]:
# Message Events and StatuskeyConsumption may also be required
await self.redis_client.adelete(self.workflow_data_key)
@@ -168,67 +189,81 @@ class RedisCallback(BaseCallback):
)
def parse_workflow_failed(self, status_info: dict) -> ChatResponse | None:
if status_info['reason'].find('-- has run more than the maximum number of times') != -1:
return self.build_chat_response(WorkflowEventType.Error.value, 'over',
message=WorkFlowNodeRunMaxTimesError(
exception=status_info['reason'].split('--')[0]).to_dict())
elif status_info['reason'].find('workflow wait user input timeout') != -1:
return self.build_chat_response(WorkflowEventType.Error.value, 'over',
message=WorkFlowWaitUserTimeoutError().to_dict())
elif status_info['reason'].find('-- node params is error') != -1:
return self.build_chat_response(WorkflowEventType.Error.value, 'over',
message=WorkFlowNodeUpdateError(
exception=status_info['reason'].split('--')[0]).to_dict())
elif status_info['reason'].find('-- workflow node is update') != -1:
return self.build_chat_response(WorkflowEventType.Error.value, 'over',
message=WorkFlowVersionUpdateError(
exception=status_info['reason'].split('--')[0]).to_dict())
elif status_info['reason'].find('stop by user') != -1:
if status_info["reason"].find("-- has run more than the maximum number of times") != -1:
return self.build_chat_response(
WorkflowEventType.Error.value,
"over",
message=WorkFlowNodeRunMaxTimesError(exception=status_info["reason"].split("--")[0]).to_dict(),
)
elif status_info["reason"].find("workflow wait user input timeout") != -1:
return self.build_chat_response(
WorkflowEventType.Error.value, "over", message=WorkFlowWaitUserTimeoutError().to_dict()
)
elif status_info["reason"].find("-- node params is error") != -1:
return self.build_chat_response(
WorkflowEventType.Error.value,
"over",
message=WorkFlowNodeUpdateError(exception=status_info["reason"].split("--")[0]).to_dict(),
)
elif status_info["reason"].find("-- workflow node is update") != -1:
return self.build_chat_response(
WorkflowEventType.Error.value,
"over",
message=WorkFlowVersionUpdateError(exception=status_info["reason"].split("--")[0]).to_dict(),
)
elif status_info["reason"].find("stop by user") != -1:
return None
else:
return self.build_chat_response(WorkflowEventType.Error.value, 'over',
WorkFlowTaskOtherError(exception=status_info['reason']).to_dict())
return self.build_chat_response(
WorkflowEventType.Error.value, "over", WorkFlowTaskOtherError(exception=status_info["reason"]).to_dict()
)
def sync_get_response_until_break(self) -> Iterator[ChatResponse]:
while True:
# get workflow status
status_info = self.get_workflow_status()
if not status_info:
yield self.build_chat_response(WorkflowEventType.Error.value, 'over',
message=WorkFlowTaskOtherError(
exception=Exception("workflow status not found")).to_dict())
yield self.build_chat_response(
WorkflowEventType.Error.value,
"over",
message=WorkFlowTaskOtherError(exception=Exception("workflow status not found")).to_dict(),
)
break
elif status_info['status'] in [WorkflowStatus.FAILED.value, WorkflowStatus.SUCCESS.value]:
elif status_info["status"] in [WorkflowStatus.FAILED.value, WorkflowStatus.SUCCESS.value]:
while True:
chat_response = self.get_workflow_response()
if not chat_response:
break
yield chat_response
if status_info['status'] == WorkflowStatus.FAILED.value:
if status_info["status"] == WorkflowStatus.FAILED.value:
error_resp = self.parse_workflow_failed(status_info)
if error_resp:
yield error_resp
break
elif status_info['status'] == WorkflowStatus.INPUT.value:
elif status_info["status"] == WorkflowStatus.INPUT.value:
while True:
chat_response = self.get_workflow_response()
if not chat_response:
break
yield chat_response
break
elif status_info['status'] in [WorkflowStatus.WAITING.value,
WorkflowStatus.INPUT_OVER.value] and time.time() - status_info['time'] > 10:
elif (
status_info["status"] in [WorkflowStatus.WAITING.value, WorkflowStatus.INPUT_OVER.value]
and time.time() - status_info["time"] > 10
):
# 10No status update received in seconds, descriptionworkflowNot started, could becelery workerThreads full
self.set_workflow_status(WorkflowStatus.FAILED.value, 'workflow task execute busy')
yield self.build_chat_response(WorkflowEventType.Error.value, 'over',
message=WorkFlowTaskBusyError().to_dict())
self.set_workflow_status(WorkflowStatus.FAILED.value, "workflow task execute busy")
yield self.build_chat_response(
WorkflowEventType.Error.value, "over", message=WorkFlowTaskBusyError().to_dict()
)
break
elif time.time() - status_info['time'] > 86400:
yield self.build_chat_response(WorkflowEventType.Error.value, 'over',
WorkFlowTaskOtherError(
exception=Exception(
"workflow status not update over 1 day")).to_dict())
self.set_workflow_status(WorkflowStatus.FAILED.value, 'workflow status not update over 1 day')
elif time.time() - status_info["time"] > 86400:
yield self.build_chat_response(
WorkflowEventType.Error.value,
"over",
WorkFlowTaskOtherError(exception=Exception("workflow status not update over 1 day")).to_dict(),
)
self.set_workflow_status(WorkflowStatus.FAILED.value, "workflow status not update over 1 day")
self.set_workflow_stop()
break
else:
@@ -239,46 +274,56 @@ class RedisCallback(BaseCallback):
yield chat_response
async def get_response_until_break(self) -> AsyncIterator[ChatResponse]:
""" Continuous accessworkflowright of privacyresponseuntil the end of the run is encountered or pending entry """
"""Continuous accessworkflowright of privacyresponseuntil the end of the run is encountered or pending entry"""
while True:
# get workflow status
status_info = await self.async_get_workflow_status()
if not status_info:
yield self.build_chat_response(WorkflowEventType.Error.value, 'over',
message=WorkFlowTaskOtherError(
exception=Exception("workflow status not found")).to_dict())
yield self.build_chat_response(
WorkflowEventType.Error.value,
"over",
message=WorkFlowTaskOtherError(exception=Exception("workflow status not found")).to_dict(),
)
break
elif status_info['status'] in [WorkflowStatus.FAILED.value, WorkflowStatus.SUCCESS.value]:
elif status_info["status"] in [WorkflowStatus.FAILED.value, WorkflowStatus.SUCCESS.value]:
while True:
chat_response = await self.async_get_workflow_response()
if not chat_response:
break
yield chat_response
if status_info['status'] == WorkflowStatus.FAILED.value:
if status_info["status"] == WorkflowStatus.FAILED.value:
error_resp = self.parse_workflow_failed(status_info)
if error_resp:
yield error_resp
break
elif status_info['status'] == WorkflowStatus.INPUT.value:
elif status_info["status"] == WorkflowStatus.INPUT.value:
while True:
chat_response = await self.async_get_workflow_response()
if not chat_response:
break
yield chat_response
break
elif status_info['status'] in [WorkflowStatus.WAITING.value,
WorkflowStatus.INPUT_OVER.value] and time.time() - status_info['time'] > 10:
elif (
status_info["status"] in [WorkflowStatus.WAITING.value, WorkflowStatus.INPUT_OVER.value]
and time.time() - status_info["time"] > 10
):
# 10No status update received in seconds, descriptionworkflowNot started, could becelery workerThreads full
await self.async_set_workflow_status(WorkflowStatus.FAILED.value, 'workflow task execute busy')
yield self.build_chat_response(WorkflowEventType.Error.value, 'over',
message=WorkFlowTaskBusyError().to_dict())
await self.async_set_workflow_status(WorkflowStatus.FAILED.value, "workflow task execute busy")
yield self.build_chat_response(
WorkflowEventType.Error.value, "over", message=WorkFlowTaskBusyError().to_dict()
)
break
elif time.time() - status_info['time'] > 86400:
yield self.build_chat_response(WorkflowEventType.Error.value, 'over',
message=WorkFlowTaskOtherError(exception=Exception(
"workflow status not update over 1 day")).to_dict())
await self.async_set_workflow_status(WorkflowStatus.FAILED.value,
'workflow status not update over 1 day')
elif time.time() - status_info["time"] > 86400:
yield self.build_chat_response(
WorkflowEventType.Error.value,
"over",
message=WorkFlowTaskOtherError(
exception=Exception("workflow status not update over 1 day")
).to_dict(),
)
await self.async_set_workflow_status(
WorkflowStatus.FAILED.value, "workflow status not update over 1 day"
)
await self.async_set_workflow_stop()
break
else:
@@ -288,8 +333,9 @@ class RedisCallback(BaseCallback):
continue
yield chat_response
def set_user_input(self, data: dict, message_id: int = None, message_content: str = None,
verify_input: bool = False):
def set_user_input(
self, data: dict, message_id: int = None, message_content: str = None, verify_input: bool = False
):
if self.chat_id and message_id:
message_db = ChatMessageDao.get_message_by_id(message_id)
self.update_old_message(data, message_db, message_content, verify_input)
@@ -297,8 +343,9 @@ class RedisCallback(BaseCallback):
self.redis_client.set(self.workflow_input_key, data, expiration=self.workflow_expire_time)
return
async def async_set_user_input(self, data: dict, message_id: int = None, message_content: str = None,
verify_input: bool = False):
async def async_set_user_input(
self, data: dict, message_id: int = None, message_content: str = None, verify_input: bool = False
):
if self.chat_id and message_id:
message_db = await ChatMessageDao.aget_message_by_id(message_id)
await self.async_update_old_message(data, message_db, message_content, verify_input)
@@ -307,17 +354,17 @@ class RedisCallback(BaseCallback):
return
@staticmethod
def _verify_input_schema(input_schema_message: Dict, user_input: Dict):
""" Verify that the user input matches the input schema """
node_id = input_schema_message['node_id']
def _verify_input_schema(input_schema_message: dict, user_input: dict):
"""Verify that the user input matches the input schema"""
node_id = input_schema_message["node_id"]
if node_id not in user_input:
raise ServerError(msg="node_id not found in user input")
user_input = user_input[node_id]
input_schema = input_schema_message['input_schema']
input_schema = input_schema_message["input_schema"]
if input_schema["tab"] == "form_input":
user_input_keys = {one: None for one in user_input.keys()}
for key_info in input_schema['value']:
key = key_info['key']
user_input_keys = dict.fromkeys(user_input.keys())
for key_info in input_schema["value"]:
key = key_info["key"]
if key not in user_input:
raise ServerError(msg=f"key {key} not found in user input")
user_input_keys.pop(key)
@@ -328,8 +375,9 @@ class RedisCallback(BaseCallback):
raise ServerError(msg=f"key {input_schema['key']} not found in user input")
@classmethod
def _update_old_message(cls, user_input: dict, message_db: ChatMessage, message_content: str,
verify_input: bool = False):
def _update_old_message(
cls, user_input: dict, message_db: ChatMessage, message_content: str, verify_input: bool = False
):
"""
if ChatResponse is not None: add new message
if ChatMessage is not None: update old message
@@ -342,37 +390,38 @@ class RedisCallback(BaseCallback):
# Update the input and selection of the user in the output to be entered message
old_message = json.loads(message_db.message)
if message_db.category == WorkflowEventType.OutputWithInput.value:
old_message['hisValue'] = user_input[old_message['node_id']][old_message['key']]
old_message["hisValue"] = user_input[old_message["node_id"]][old_message["key"]]
elif message_db.category == WorkflowEventType.OutputWithChoose.value:
old_message['hisValue'] = user_input[old_message['node_id']][old_message['key']]
old_message["hisValue"] = user_input[old_message["node_id"]][old_message["key"]]
elif message_db.category == WorkflowEventType.UserInput.value:
if verify_input:
cls._verify_input_schema(old_message, user_input)
user_input = user_input[old_message['node_id']]
user_input = user_input[old_message["node_id"]]
# If the front-end passes user input, the front-end content is used.
if message_content:
user_input_message = message_content
# Instructions are form inputs
elif old_message['input_schema']['tab'] == 'form_input':
user_input_message = ''
for key_info in old_message['input_schema']['value']:
elif old_message["input_schema"]["tab"] == "form_input":
user_input_message = ""
for key_info in old_message["input_schema"]["value"]:
user_input_message += f"{key_info['value']}:{user_input.get(key_info['key'], '')}\n"
else:
# Description Dialog Input, Uploaded file information needs to be added, It is related to the data structure of the input node.
user_input_message = user_input[old_message['input_schema']['key']]
dialog_files_content = user_input.get('dialog_files_content', [])
user_input_message = user_input[old_message["input_schema"]["key"]]
dialog_files_content = user_input.get("dialog_files_content", [])
for one in dialog_files_content:
user_input_message += f"\n{os.path.basename(one).split('?')[0]}"
return ChatResponse(
message=user_input_message,
category='question',
category="question",
), None
message_db.message = json.dumps(old_message, ensure_ascii=False)
return None, message_db
def update_old_message(self, user_input: dict, message_db: ChatMessage, message_content: str,
verify_input: bool = False):
def update_old_message(
self, user_input: dict, message_db: ChatMessage, message_content: str, verify_input: bool = False
):
chat_response, message = self._update_old_message(user_input, message_db, message_content, verify_input)
if chat_response:
self.save_chat_message(chat_response)
@@ -380,8 +429,9 @@ class RedisCallback(BaseCallback):
if message:
ChatMessageDao.update_message_model(message)
async def async_update_old_message(self, user_input: dict, message_db: ChatMessage, message_content: str,
verify_input: bool = False):
async def async_update_old_message(
self, user_input: dict, message_db: ChatMessage, message_content: str, verify_input: bool = False
):
chat_response, message = self._update_old_message(user_input, message_db, message_content, verify_input)
if chat_response:
self.save_chat_message(chat_response)
@@ -397,24 +447,26 @@ class RedisCallback(BaseCallback):
def set_workflow_stop(self):
from bisheng.worker.workflow.tasks import stop_workflow
self.redis_client.set(self.workflow_stop_key, 1, expiration=3600 * 24)
stop_workflow.delay(self.unique_id, self.workflow_id, self.chat_id, self.user_id)
async def async_set_workflow_stop(self):
from bisheng.worker.workflow.tasks import stop_workflow
await self.redis_client.aset(self.workflow_stop_key, 1, expiration=3600 * 24)
stop_workflow.delay(self.unique_id, self.workflow_id, self.chat_id, self.user_id)
def get_workflow_stop(self) -> bool | None:
""" In order to stop in timeworkflow, Do not cache memory """
"""In order to stop in timeworkflow, Do not cache memory"""
return self.redis_client.get(self.workflow_stop_key) == 1
async def async_get_workflow_stop(self) -> bool | None:
""" In order to stop in timeworkflow, Do not cache memory """
"""In order to stop in timeworkflow, Do not cache memory"""
return await self.redis_client.aget(self.workflow_stop_key) == 1
def send_chat_response(self, chat_response: ChatResponse):
""" Send a chat message """
"""Send a chat message"""
self.insert_workflow_response(chat_response.dict())
# Determine if it needs to be stoppedworkflow, Don't judge when streaming, queries are too frequent and can't be stoppedworkflow
@@ -424,12 +476,12 @@ class RedisCallback(BaseCallback):
self.workflow.stop()
def save_chat_message(
self,
chat_response: ChatResponse,
source_documents=None,
citation_registry_items: List[CitationRegistryItemSchema] | None = None,
self,
chat_response: ChatResponse,
source_documents=None,
citation_registry_items: list[CitationRegistryItemSchema] | None = None,
) -> int | str | None:
""" save chat message to database
"""save chat message to database
return message id
"""
if not self.chat_id:
@@ -458,20 +510,28 @@ class RedisCallback(BaseCallback):
chat_response.source = source
chat_response.extra = json.dumps(extra, ensure_ascii=False)
message = ChatMessageDao.insert_one(ChatMessage(
user_id=self.user_id,
chat_id=self.chat_id,
flow_id=self.workflow_id,
type=chat_response.type,
# Attachments the user sent become permanent here, not at upload time —
# uploads sit in the temp bucket, which clears itself every 3 days.
# Only the user's own files: what the workflow produced is out of scope.
if not chat_response.is_bot:
promote_chat_attachments_sync(chat_response.files, self.user_id)
is_bot=chat_response.is_bot,
source=chat_response.source,
message=chat_response.message if isinstance(chat_response.message, str) else json.dumps(
chat_response.message, ensure_ascii=False),
extra=chat_response.extra,
category=chat_response.category,
files=json.dumps(chat_response.files, ensure_ascii=False)
))
message = ChatMessageDao.insert_one(
ChatMessage(
user_id=self.user_id,
chat_id=self.chat_id,
flow_id=self.workflow_id,
type=chat_response.type,
is_bot=chat_response.is_bot,
source=chat_response.source,
message=chat_response.message
if isinstance(chat_response.message, str)
else json.dumps(chat_response.message, ensure_ascii=False),
extra=chat_response.extra,
category=chat_response.category,
files=json.dumps(chat_response.files, ensure_ascii=False),
)
)
answer_text = self._extract_message_text(chat_response.message)
items = self._resolve_citation_items(
@@ -488,37 +548,46 @@ class RedisCallback(BaseCallback):
# If the document is traceable, handle the recallchunk
if chat_response.source not in [0, 4]:
thread_pool.submit(f"workflow_source_document_{self.chat_id}",
sync_process_source_document,
source_documents, self.chat_id, message.id, chat_response.message.get('msg'))
thread_pool.submit(
f"workflow_source_document_{self.chat_id}",
sync_process_source_document,
source_documents,
self.chat_id,
message.id,
chat_response.message.get("msg"),
)
# Determine if a new session is needed
if not self.create_session and chat_response.category != WorkflowEventType.UserInput.value:
# Insert a new session without session data
if not MessageSessionDao.get_one(self.chat_id):
db_workflow = FlowDao.get_flow_by_id(self.workflow_id)
self.new_session = MessageSessionDao.insert_one(MessageSession(
chat_id=self.chat_id,
flow_id=self.workflow_id,
flow_name=db_workflow.name,
flow_type=FlowType.WORKFLOW.value,
user_id=self.user_id,
))
thread_pool.submit(f"workflow_generate_title_{self.chat_id}",
self.generate_session_title,
message.message)
self.new_session = MessageSessionDao.insert_one(
MessageSession(
chat_id=self.chat_id,
flow_id=self.workflow_id,
flow_name=db_workflow.name,
flow_type=FlowType.WORKFLOW.value,
user_id=self.user_id,
)
)
thread_pool.submit(
f"workflow_generate_title_{self.chat_id}", self.generate_session_title, message.message
)
# RecordTelemetryJournal
telemetry_service.log_event_sync(user_id=self.user_id,
event_type=BaseTelemetryTypeEnum.NEW_MESSAGE_SESSION,
trace_id=trace_id_var.get(),
event_data=NewMessageSessionEventData(
session_id=self.chat_id,
app_id=self.workflow_id,
source=self.source,
app_name=db_workflow.name,
app_type=ApplicationTypeEnum.WORKFLOW
))
telemetry_service.log_event_sync(
user_id=self.user_id,
event_type=BaseTelemetryTypeEnum.NEW_MESSAGE_SESSION,
trace_id=trace_id_var.get(),
event_data=NewMessageSessionEventData(
session_id=self.chat_id,
app_id=self.workflow_id,
source=self.source,
app_name=db_workflow.name,
app_type=ApplicationTypeEnum.WORKFLOW,
),
)
self.create_session = True
@@ -526,10 +595,10 @@ class RedisCallback(BaseCallback):
@staticmethod
def _resolve_citation_items(
answer_text: str,
source_documents=None,
citation_registry_items: List[CitationRegistryItemSchema] | None = None,
) -> List[CitationRegistryItemSchema]:
answer_text: str,
source_documents=None,
citation_registry_items: list[CitationRegistryItemSchema] | None = None,
) -> list[CitationRegistryItemSchema]:
items = list(citation_registry_items or [])
if not items and source_documents:
documents = source_documents if isinstance(source_documents, list) else [source_documents]
@@ -541,12 +610,12 @@ class RedisCallback(BaseCallback):
if isinstance(message, str):
return message
if isinstance(message, dict):
msg = message.get('msg')
msg = message.get("msg")
if isinstance(msg, str):
return msg
return json.dumps(message, ensure_ascii=False)
if message is None:
return ''
return ""
return json.dumps(message, ensure_ascii=False)
def generate_session_title(self, answer: str):
@@ -567,78 +636,94 @@ class RedisCallback(BaseCallback):
llm = LLMService.get_bisheng_llm_sync(
model_id=llm_conf.chat_title_llm.id,
app_id=ApplicationTypeEnum.DAILY_CHAT.value,
app_name='workflow_chat_title',
app_name="workflow_chat_title",
app_type=ApplicationTypeEnum.DAILY_CHAT,
user_id=self.user_id
user_id=self.user_id,
)
title = generate_conversation_title_sync(question=question, llm=llm, answer=answer)
MessageSessionDao.update_session_name_sync(self.new_session.chat_id, title)
self.new_session.name = title
def on_node_start(self, data: NodeStartData):
""" node start event """
logger.debug(f'node start: {data}')
"""node start event"""
logger.debug(f"node start: {data}")
self.send_chat_response(
ChatResponse(message=data.dict(),
category=WorkflowEventType.NodeRun.value,
type='start',
flow_id=self.workflow_id,
chat_id=self.chat_id))
ChatResponse(
message=data.dict(),
category=WorkflowEventType.NodeRun.value,
type="start",
flow_id=self.workflow_id,
chat_id=self.chat_id,
)
)
def on_node_end(self, data: NodeEndData):
""" node end event """
logger.debug(f'node end: {data}')
"""node end event"""
logger.debug(f"node end: {data}")
self.send_chat_response(
ChatResponse(message=data.dict(),
category=WorkflowEventType.NodeRun.value,
type='end',
flow_id=self.workflow_id,
chat_id=self.chat_id))
ChatResponse(
message=data.dict(),
category=WorkflowEventType.NodeRun.value,
type="end",
flow_id=self.workflow_id,
chat_id=self.chat_id,
)
)
def on_user_input(self, data: UserInputData):
""" user input event """
logger.debug(f'user input: {data}')
chat_response = ChatResponse(message=data.dict(),
category=WorkflowEventType.UserInput.value,
type='over',
flow_id=self.workflow_id,
chat_id=self.chat_id)
"""user input event"""
logger.debug(f"user input: {data}")
chat_response = ChatResponse(
message=data.dict(),
category=WorkflowEventType.UserInput.value,
type="over",
flow_id=self.workflow_id,
chat_id=self.chat_id,
)
msg_id = self.save_chat_message(chat_response)
if msg_id:
chat_response.message_id = msg_id
self.send_chat_response(chat_response)
def on_guide_word(self, data: GuideWordData):
""" guide word event """
logger.debug(f'guide word: {data}')
"""guide word event"""
logger.debug(f"guide word: {data}")
self.send_chat_response(
ChatResponse(message=data.dict(),
category=WorkflowEventType.GuideWord.value,
type='over',
flow_id=self.workflow_id,
chat_id=self.chat_id))
ChatResponse(
message=data.dict(),
category=WorkflowEventType.GuideWord.value,
type="over",
flow_id=self.workflow_id,
chat_id=self.chat_id,
)
)
def on_guide_question(self, data: GuideQuestionData):
""" guide question event """
logger.debug(f'guide question: {data}')
"""guide question event"""
logger.debug(f"guide question: {data}")
self.send_chat_response(
ChatResponse(message=data.dict(),
category=WorkflowEventType.GuideQuestion.value,
type='over',
flow_id=self.workflow_id,
chat_id=self.chat_id))
ChatResponse(
message=data.dict(),
category=WorkflowEventType.GuideQuestion.value,
type="over",
flow_id=self.workflow_id,
chat_id=self.chat_id,
)
)
def on_output_msg(self, data: OutputMsgData):
logger.debug(f'output msg: {data}')
chat_response = ChatResponse(message=data.dict(exclude={'source_documents', 'citation_registry_items'}),
category=WorkflowEventType.OutputMsg.value,
extra='',
type='over',
flow_id=self.workflow_id,
chat_id=self.chat_id,
files=data.files,
citations=data.citation_registry_items,
citation_registry_items=data.citation_registry_items)
logger.debug(f"output msg: {data}")
chat_response = ChatResponse(
message=data.dict(exclude={"source_documents", "citation_registry_items"}),
category=WorkflowEventType.OutputMsg.value,
extra="",
type="over",
flow_id=self.workflow_id,
chat_id=self.chat_id,
files=data.files,
citations=data.citation_registry_items,
citation_registry_items=data.citation_registry_items,
)
msg_id = self.save_chat_message(
chat_response,
source_documents=data.source_documents,
@@ -649,28 +734,33 @@ class RedisCallback(BaseCallback):
self.send_chat_response(chat_response)
def on_stream_msg(self, data: StreamMsgData):
logger.debug(f'stream msg: {data}')
logger.debug(f"stream msg: {data}")
self.send_chat_response(
ChatResponse(message=data.dict(),
category=WorkflowEventType.StreamMsg.value,
extra='',
type='stream',
flow_id=self.workflow_id,
chat_id=self.chat_id))
ChatResponse(
message=data.dict(),
category=WorkflowEventType.StreamMsg.value,
extra="",
type="stream",
flow_id=self.workflow_id,
chat_id=self.chat_id,
)
)
def on_stream_over(self, data: StreamMsgOverData):
logger.debug(f'stream over: {data}')
logger.debug(f"stream over: {data}")
# Replaceminioright of privacysharePrefix bynginxShare ugly solve
minio_share = settings.get_minio_conf().sharepoint
data.msg = data.msg.replace(f"http://{minio_share}", "")
chat_response = ChatResponse(message=data.dict(exclude={'source_documents', 'citation_registry_items'}),
category=WorkflowEventType.StreamMsg.value,
extra='',
type='end',
flow_id=self.workflow_id,
chat_id=self.chat_id,
citations=data.citation_registry_items,
citation_registry_items=data.citation_registry_items)
chat_response = ChatResponse(
message=data.dict(exclude={"source_documents", "citation_registry_items"}),
category=WorkflowEventType.StreamMsg.value,
extra="",
type="end",
flow_id=self.workflow_id,
chat_id=self.chat_id,
citations=data.citation_registry_items,
citation_registry_items=data.citation_registry_items,
)
msg_id = self.save_chat_message(
chat_response,
source_documents=data.source_documents,
@@ -681,16 +771,18 @@ class RedisCallback(BaseCallback):
self.send_chat_response(chat_response)
def on_output_choose(self, data: OutputMsgChooseData):
logger.debug(f'output choose: {data}')
chat_response = ChatResponse(message=data.dict(exclude={'source_documents', 'citation_registry_items'}),
category=WorkflowEventType.OutputWithChoose.value,
extra='',
type='over',
flow_id=self.workflow_id,
chat_id=self.chat_id,
files=data.files,
citations=data.citation_registry_items,
citation_registry_items=data.citation_registry_items)
logger.debug(f"output choose: {data}")
chat_response = ChatResponse(
message=data.dict(exclude={"source_documents", "citation_registry_items"}),
category=WorkflowEventType.OutputWithChoose.value,
extra="",
type="over",
flow_id=self.workflow_id,
chat_id=self.chat_id,
files=data.files,
citations=data.citation_registry_items,
citation_registry_items=data.citation_registry_items,
)
msg_id = self.save_chat_message(
chat_response,
source_documents=data.source_documents,
@@ -701,16 +793,18 @@ class RedisCallback(BaseCallback):
self.send_chat_response(chat_response)
def on_output_input(self, data: OutputMsgInputData):
logger.debug(f'output input: {data}')
chat_response = ChatResponse(message=data.dict(exclude={'source_documents', 'citation_registry_items'}),
category=WorkflowEventType.OutputWithInput.value,
extra='',
type='over',
flow_id=self.workflow_id,
chat_id=self.chat_id,
files=data.files,
citations=data.citation_registry_items,
citation_registry_items=data.citation_registry_items)
logger.debug(f"output input: {data}")
chat_response = ChatResponse(
message=data.dict(exclude={"source_documents", "citation_registry_items"}),
category=WorkflowEventType.OutputWithInput.value,
extra="",
type="over",
flow_id=self.workflow_id,
chat_id=self.chat_id,
files=data.files,
citations=data.citation_registry_items,
citation_registry_items=data.citation_registry_items,
)
msg_id = self.save_chat_message(
chat_response,
source_documents=data.source_documents,
@@ -31,6 +31,7 @@ from bisheng.common.services import telemetry_service
from bisheng.common.services.llm_error_classifier import ErrorType, label_error, unwrap
from bisheng.core.cache.utils import async_file_download
from bisheng.core.logger import trace_id_var
from bisheng.core.storage.chat_attachment import promote_chat_attachments
from bisheng.database.models.flow import FlowType
from bisheng.database.models.message import ChatMessage, ChatMessageDao
from bisheng.database.models.session import MessageSession, MessageSessionDao
@@ -119,6 +120,11 @@ async def initialize_chat(data: APIChatCompletion, login_user: UserPayload):
await MessageSessionDao.touch_session(conversation_id)
conversation = await MessageSessionDao.async_get_one(conversation_id)
# Uploads land in the temp bucket, which clears itself every 3 days. Sending
# is the point where the file becomes part of the conversation, so that's
# where it gets copied somewhere permanent.
await promote_chat_attachments(data.files, login_user.user_id)
if data.overrideParentMessageId:
message = await ChatMessageDao.aget_message_by_id(int(data.overrideParentMessageId))
else:
@@ -171,7 +177,6 @@ from pydantic import SkipValidation
from bisheng.citation.domain.schemas.citation_schema import CitationRegistryItemSchema
from bisheng.citation.domain.services.citation_prompt_helper import (
CITATION_PROMPT_RULES,
CitationRegistryCollector,
annotate_rag_documents_with_citations,
annotate_web_results_with_citations,
@@ -179,7 +184,6 @@ from bisheng.citation.domain.services.citation_prompt_helper import (
cache_citation_registry_items_sync,
collect_rag_citation_registry_items,
collect_web_citation_registry_items,
prompt_has_citation_rules,
save_message_citations,
select_registry_items_for_persistence,
)
@@ -1110,6 +1114,10 @@ async def _agent_initialize_chat(data: APIChatCompletion, login_user: UserPayloa
await MessageSessionDao.touch_session(conversation_id)
conversation = await MessageSessionDao.async_get_one(conversation_id)
# Same as the legacy flow: the attachment becomes permanent at send time,
# not at upload time (see promote_chat_attachments).
await promote_chat_attachments(data.files, login_user.user_id)
# Always insert a brand-new question row — Agent flow has no regenerate.
message = await ChatMessageDao.ainsert_one(
ChatMessage(
@@ -0,0 +1,110 @@
"""F043: handing out a fresh link for a conversation attachment.
Links issued at upload time expire, so the client asks for a new one when it
renders. The object name must only ever come from what the server has stored
for that conversation -- an endpoint that signed whatever object name the
caller passed would hand out the entire bucket.
See features/v2.6.0/043-chat-file-permanent-storage/design.md §3 decision 3.
"""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from bisheng.chat_session.domain.chat import ChatSessionService
OWNER_ID = 7
STRANGER_ID = 99
def _user(user_id):
user = MagicMock()
user.user_id = user_id
return user
def _message(files):
return SimpleNamespace(files=json.dumps(files))
@pytest.fixture
def storage():
client = MagicMock()
client.get_share_link = AsyncMock(return_value="/bisheng/chat/7/abc.png?sig=fresh")
with patch("bisheng.chat_session.domain.chat.get_minio_storage", AsyncMock(return_value=client)):
yield client
@pytest.fixture
def conversation():
"""A conversation owned by OWNER_ID holding one attachment."""
session = SimpleNamespace(chat_id="c1", user_id=OWNER_ID, is_delete=False)
files = [{"file_id": "f1", "filename": "a.png", "object_name": "chat/7/abc.png"}]
with (
patch(
"bisheng.chat_session.domain.chat.MessageSessionDao.async_get_one",
AsyncMock(return_value=session),
),
patch(
"bisheng.chat_session.domain.chat.ChatMessageDao.aget_messages_by_chat_id",
AsyncMock(return_value=[_message(files)]),
),
):
yield session
class TestResolveAttachmentUrl:
async def test_owner_gets_a_fresh_link(self, storage, conversation):
url = await ChatSessionService.resolve_attachment_url("c1", "f1", _user(OWNER_ID))
assert url == "/bisheng/chat/7/abc.png?sig=fresh"
# Signed for the object the server recorded, nothing else.
assert storage.get_share_link.await_args.args[0] == "chat/7/abc.png"
async def test_someone_else_is_refused(self, storage, conversation):
# AC-04 — conversation ownership is the whole authorization story.
with pytest.raises(Exception):
await ChatSessionService.resolve_attachment_url("c1", "f1", _user(STRANGER_ID))
storage.get_share_link.assert_not_awaited()
async def test_unknown_file_id_is_refused(self, storage, conversation):
with pytest.raises(Exception):
await ChatSessionService.resolve_attachment_url("c1", "nope", _user(OWNER_ID))
storage.get_share_link.assert_not_awaited()
async def test_attachment_without_object_name_is_refused(self, storage):
# Messages written before this feature carry no object name; they must
# read as "gone", not fall back to guessing at some other object.
session = SimpleNamespace(chat_id="c1", user_id=OWNER_ID, is_delete=False)
legacy = [{"file_id": "f1", "filename": "a.png", "filepath": "/bisheng-tmp/a.png"}]
with (
patch(
"bisheng.chat_session.domain.chat.MessageSessionDao.async_get_one",
AsyncMock(return_value=session),
),
patch(
"bisheng.chat_session.domain.chat.ChatMessageDao.aget_messages_by_chat_id",
AsyncMock(return_value=[_message(legacy)]),
),
pytest.raises(Exception),
):
await ChatSessionService.resolve_attachment_url("c1", "f1", _user(OWNER_ID))
storage.get_share_link.assert_not_awaited()
async def test_missing_conversation_is_refused(self, storage):
with (
patch(
"bisheng.chat_session.domain.chat.MessageSessionDao.async_get_one",
AsyncMock(return_value=None),
),
pytest.raises(Exception),
):
await ChatSessionService.resolve_attachment_url("nope", "f1", _user(OWNER_ID))
storage.get_share_link.assert_not_awaited()
@@ -0,0 +1,55 @@
"""F043: object names for files uploaded inside a conversation.
Daily-mode uploads used to be stored under the raw filename, so two users who
both uploaded "1.png" overwrote each other -- harmless while the bucket was
wiped every 3 days, a permanent data leak once the files stopped expiring.
Names are now uuid-based and prefixed by conversation.
See features/v2.6.0/043-chat-file-permanent-storage/design.md §3 decision 1.
"""
from bisheng.core.storage.chat_attachment import CHAT_OBJECT_PREFIX, build_chat_object_name
class TestBuildChatObjectName:
def test_same_filename_never_collides(self):
# AC-02 — the whole point: one user's upload must not clobber another's.
a = build_chat_object_name(1, "1.png")
b = build_chat_object_name(1, "1.png")
assert a != b
def test_scoped_to_the_uploader(self):
# Grouping by uploader keeps ops/quota work tractable later; deletion
# itself reads object names off the messages (see module docstring).
name = build_chat_object_name(42, "report.pdf")
assert name.startswith(f"{CHAT_OBJECT_PREFIX}42/")
def test_extension_preserved_and_lowercased(self):
assert build_chat_object_name(7, "Photo.PNG").endswith(".png")
def test_filename_without_extension(self):
name = build_chat_object_name(7, "README")
assert "." not in name.rsplit("/", 1)[-1]
def test_path_separators_in_filename_cannot_escape_the_prefix(self):
# The name is built from user-supplied content; a filename must never be
# able to steer the object somewhere else in the bucket.
name = build_chat_object_name(7, "../../etc/passwd")
assert name.startswith(f"{CHAT_OBJECT_PREFIX}7/")
assert ".." not in name
def test_windows_separators_are_not_taken_as_extension(self):
name = build_chat_object_name(7, r"C:\tmp\evil.exe")
assert name.startswith(f"{CHAT_OBJECT_PREFIX}7/")
assert "\\" not in name
assert name.endswith(".exe")
def test_absurdly_long_extension_is_dropped(self):
# A "." in a long name doesn't make everything after it an extension.
name = build_chat_object_name(7, "file." + "x" * 50)
assert name.startswith(f"{CHAT_OBJECT_PREFIX}7/")
assert len(name.rsplit("/", 1)[-1]) < 60
def test_hidden_file_has_no_extension(self):
name = build_chat_object_name(7, ".gitignore")
assert "." not in name.rsplit("/", 1)[-1]
@@ -0,0 +1,117 @@
"""F043: promoting a message's attachments out of the temp bucket.
Chat uploads land in the temp bucket, which wipes itself every 3 days. When the
message is actually sent we copy its attachments to the main bucket so they
live as long as the conversation. Files that are uploaded and never sent stay
in temp and expire on their own -- that is the point of promoting on send
rather than on upload.
See features/v2.6.0/043-chat-file-permanent-storage/design.md §3 decision 1.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from bisheng.core.storage.chat_attachment import (
CHAT_OBJECT_PREFIX,
promote_chat_attachments,
temp_object_name_from_url,
)
@pytest.fixture
def storage():
client = MagicMock()
client.bucket = "bisheng"
client.tmp_bucket = "bisheng-tmp"
client.copy_object = AsyncMock()
with patch("bisheng.core.storage.chat_attachment.get_minio_storage", AsyncMock(return_value=client)):
yield client
class TestPromoteChatAttachments:
async def test_copies_temp_object_and_records_permanent_name(self, storage):
# AC-01 — after this the file no longer depends on the temp bucket.
files = [{"file_id": "f1", "filename": "a.png", "filepath": "/bisheng-tmp/abc.png"}]
promoted = await promote_chat_attachments(files, user_id=7)
storage.copy_object.assert_awaited_once()
kwargs = storage.copy_object.await_args.kwargs
assert kwargs["source_bucket"] == "bisheng-tmp"
assert kwargs["source_object"] == "abc.png"
assert kwargs["dest_object"].startswith(f"{CHAT_OBJECT_PREFIX}7/")
assert promoted[0]["object_name"] == kwargs["dest_object"]
async def test_already_permanent_file_is_left_alone(self, storage):
# Task mode already uploads straight to the main bucket; it must flow
# through the same code path without being copied a second time.
files = [{"file_id": "f1", "filename": "a.png", "object_name": "linsight/session_files/9/f1.png"}]
promoted = await promote_chat_attachments(files, user_id=7)
storage.copy_object.assert_not_awaited()
assert promoted[0]["object_name"] == "linsight/session_files/9/f1.png"
async def test_one_failure_does_not_lose_the_other_attachments(self, storage):
# Sending the message matters more than any single attachment.
storage.copy_object.side_effect = [Exception("gone"), None]
files = [
{"file_id": "f1", "filename": "a.png", "filepath": "/bisheng-tmp/a.png"},
{"file_id": "f2", "filename": "b.png", "filepath": "/bisheng-tmp/b.png"},
]
promoted = await promote_chat_attachments(files, user_id=7)
assert "object_name" not in promoted[0] # stays unresolvable, flagged to the user later
assert promoted[1]["object_name"].startswith(f"{CHAT_OBJECT_PREFIX}7/")
async def test_extension_is_carried_over_from_the_display_name(self, storage):
files = [{"file_id": "f1", "filename": "报告.PDF", "filepath": "/bisheng-tmp/xyz.pdf"}]
promoted = await promote_chat_attachments(files, user_id=7)
assert promoted[0]["object_name"].endswith(".pdf")
async def test_no_files_is_a_no_op(self, storage):
assert await promote_chat_attachments([], user_id=7) == []
assert await promote_chat_attachments(None, user_id=7) == []
storage.copy_object.assert_not_awaited()
async def test_unreachable_storage_does_not_block_the_message(self):
# Sending must survive a storage outage: the worst outcome allowed here
# is an attachment that can't be viewed later, never a message the user
# cannot send at all.
files = [{"file_id": "f1", "filename": "a.png", "filepath": "/bisheng-tmp/a.png"}]
with patch(
"bisheng.core.storage.chat_attachment.get_minio_storage",
AsyncMock(side_effect=RuntimeError("minio down")),
):
assert await promote_chat_attachments(files, user_id=7) == files
class TestTempObjectNameFromUrl:
"""The link we issued at upload is what tells us the object to move."""
def test_presigned_link_with_query_string(self):
url = "/bisheng-tmp/abc.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=604800"
assert temp_object_name_from_url(url, "bisheng-tmp") == "abc.png"
def test_link_that_still_carries_the_host(self):
url = "http://minio:9000/bisheng-tmp/abc.png?X-Amz-Expires=1"
assert temp_object_name_from_url(url, "bisheng-tmp") == "abc.png"
def test_tenant_prefixed_object_keeps_its_full_key(self):
url = "/bisheng-tmp/tenant_acme/abc.png"
assert temp_object_name_from_url(url, "bisheng-tmp") == "tenant_acme/abc.png"
def test_percent_encoded_name_is_decoded(self):
url = "/bisheng-tmp/%E6%8A%A5%E5%91%8A.pdf"
assert temp_object_name_from_url(url, "bisheng-tmp") == "报告.pdf"
def test_link_outside_the_temp_bucket_is_not_ours_to_move(self):
assert temp_object_name_from_url("/bisheng/knowledge/abc.png", "bisheng-tmp") is None
def test_blank_link(self):
assert temp_object_name_from_url("", "bisheng-tmp") is None
+10
View File
@@ -24,3 +24,13 @@ yarn.lock
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# pnpm workspace
node_modules/
packages/ui/doc_build/
package-lock.json
yarn.lock
.obsidian/
# dead-key scan output (regenerable working artifact, not tracked)
scripts/i18n-dead-keys-report.json
+6
View File
@@ -0,0 +1,6 @@
registry=https://registry.npmmirror.com
# Workspace packages always link locally, never fetch from registry.
link-workspace-packages=true
prefer-workspace-packages=true
# Run pre/post lifecycle scripts (predev/prebuild regenerate locale artifacts).
enable-pre-post-scripts=true
+4 -2
View File
@@ -2,8 +2,10 @@ FROM node:20-alpine as frontend_build
ARG BACKEND
WORKDIR /app
COPY . /app
RUN cd /app/platform && npm install --registry=https://registry.npmmirror.com && npm run build
RUN cd /app/client && npm install --registry=https://registry.npmmirror.com && npm run build
# pnpm workspace build (registry comes from .npmrc; packageManager pins the version).
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
RUN cd /app && pnpm install --frozen-lockfile
RUN cd /app && pnpm --filter bisheng build && pnpm --filter bishengchat build
FROM nginx
COPY --from=frontend_build /app/platform/build/ /usr/share/nginx/html/platform
+11 -5
View File
@@ -6,8 +6,8 @@ Cross-app boundary + hard rules common to both apps: root `AGENTS.md §4` (singl
## Commands (cwd: `src/frontend/client/`)
```bash
npm install
npm run dev # dev server on :4001
pnpm install # run at src/frontend/ (pnpm workspace root; npm is disabled)
pnpm dev # dev server on :4001 (or `pnpm dev:client` from the workspace root)
```
## Tech Stack
@@ -16,10 +16,10 @@ Vite 6 + React 18 + TypeScript + TailwindCSS 3 + Radix UI (shadcn/ui) + **Recoil
## Mandatory Rules (client-specific — common hard rules in root §4)
- **Path Aliases**: `~/` (or `@/`) → `src/`.
- **HTTP Requests**: wrapper is `~/api/request.ts`.
- **State Management**: Recoil (`~/store/`). Context or other solutions prohibited for new state.
- **UI Components**: `~/components/ui/` (shadcn / Radix-based).
- **State Management**: **Recoil is FROZEN** (archived by Meta; ledger #5) — no new atoms/selectors/`recoil` imports (lint-enforced via `no-restricted-imports`; existing usage is suppressed). Server state → react-query v4; local state → `useState`/props. If a feature genuinely needs new cross-page client state, raise it with shanghang (Jotai migration decision pending) — do not work around with Context or new libraries.
- **UI Components**: shared library `@bisheng/ui` (`src/frontend/packages/ui/`) first — components migrated there (Button, …) keep re-export shims at `~/components/ui/<Name>` so both import paths work; everything else still lives in `~/components/ui/` (shadcn / Radix-based).
- **Icons**: prefer `bisheng-icons``import { Outlined } from 'bisheng-icons'``<Outlined.Delete />` (variants `Outlined` / `Filled` / `Colored`). Use `lucide-react` ONLY as a fallback when `bisheng-icons` has no matching-semantic icon.
- **⚠️ After upgrading `bisheng-icons`**, clear the Vite pre-bundle cache or new icons crash the page (`Element type is invalid`): `npm run dev -- --force` (or `rm -rf node_modules/.vite && npm run dev`). Its git-source `exports` field defeats Vite's dep-change detection, so the stale pre-bundled snapshot is served unless forced.
- **⚠️ After upgrading `bisheng-icons`**, clear the Vite pre-bundle cache or new icons crash the page (`Element type is invalid`): `pnpm dev -- --force` (or `rm -rf node_modules/.vite && pnpm dev`). Its git-source `exports` field defeats Vite's dep-change detection, so the stale pre-bundled snapshot is served unless forced.
- **Toast**: `const { showToast } = useToastContext(); showToast?.({ message, severity: 'error' | 'success' })`.
- **i18n**: `useLocalize()` from `~/hooks``localize()`. Locale files at `src/locales/{en,zh-Hans,ja}/translation.json` (single file). New keys use nested namespace format (see `/i18n-localizer` skill).
- **Brand theme (blue⇄green)**: brand-colored UI MUST follow the theme — **never hardcode brand hex** (`#165DFF`/`#024DE3`/`#19B476`/`#187C54`…).
@@ -29,3 +29,9 @@ Vite 6 + React 18 + TypeScript + TailwindCSS 3 + Radix UI (shadcn/ui) + **Recoil
- **Illustrations**: inline SVG, `fill`/`stroke` = `rgb(var(--illus-NNN))` (separate brighter palette, in `src/components/illustrations/`). SVG presentation attrs ignore `var()` → use inline `style`/className/CSS-mask, and `useId()` to dedupe gradient/clip ids.
- **Do NOT theme**: semantic colors (success `#00b42a` / danger `#f53f3f` / warning `#ff7d00`), type colors (skill-purple, assistant-orange), third-party logos. Need a muted-but-themed brand color → `rgb(var(--brand-muted))`.
- Full guide: `BRAND-THEME-HANDOFF.md`.
- **Design system (hard rules — full specs in `packages/ui/docs/`, site: `pnpm dev:ui`)**:
- Where a `@bisheng/ui` component exists, USE it — no hand-rolled equivalents. Buttons: `<Button>` dual-axis API (`color` × `variant` × `size`); never hand-write button heights/padding/radius; adjacent buttons same size; one primary-solid per action area.
- Button `loading` prop only — never inject your own Spinner. `iconOnly` requires `aria-label` + Tooltip.
- Typography (new code): semantic classes `text-caption/body-sm/body/h4…h1` (auto-remap ≤768px) — not raw `text-sm/base` (基础-字体规范.md).
- Neutral colors (new code): semantic tokens `text-text-1…4` / `bg-fill-1…4` / `border-border-base|-deep` / `success|warning|danger` — never `text-gray-*` or hex (基础-色彩规范.md).
- Hover/touch: plain `hover:` classes ONLY (`hoverOnlyWhenSupported` disables them on touch app-wide) — **never invent hover variant prefixes**; touch press via `coarse-pointer:active:`; hover/active shade stays within the base color's own ramp (no cross-palette graying).
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
// ESLint 9 flat config — quality gate for the client app.
// Policy: legacy violations are frozen in eslint-suppressions.json (may only shrink);
// new code must be clean. Run `npm run lint` locally, CI enforces on every PR.
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import reactHooks from 'eslint-plugin-react-hooks'
import globals from 'globals'
export default tseslint.config(
{
ignores: [
'dist/**',
'build/**',
'node_modules/**',
'public/**',
'coverage/**',
'doc_build/**',
'scripts/**',
'*.config.{js,ts,mjs,mts}',
'tailwind.config.js',
'postcss.config.js',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
globals: { ...globals.browser, ...globals.es2021, ...globals.node },
parserOptions: { ecmaFeatures: { jsx: true } },
},
plugins: { 'react-hooks': reactHooks },
rules: {
...reactHooks.configs.recommended.rules,
// All gate rules are "error" so they are frozen via suppressions and block new code.
'react-hooks/exhaustive-deps': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/ban-ts-comment': ['error', { 'ts-expect-error': 'allow-with-description' }],
'no-console': ['error', { allow: ['warn', 'error'] }],
// C7: HTTP must go through the wrapped request module.
'no-restricted-imports': [
'error',
{
paths: [
{ name: 'axios', message: 'Use ~/api/request (wrapped module) instead of raw axios. See constitution C7.' },
// Deprecated libs frozen at current usage (ledger #5, #8):
// existing violations live in eslint-suppressions.json, new imports are blocked.
{ name: 'recoil', message: 'Recoil is archived by Meta and frozen — no new atoms/selectors/imports (ledger #5). Server state goes to @tanstack/react-query; new client-global state awaits the Jotai migration decision.' },
{ name: 'react-beautiful-dnd', message: 'react-beautiful-dnd is deprecated upstream (no React 18 StrictMode support) and frozen; use @hello-pangea/dnd when migrating (ledger #8).' },
{ name: 'react-virtualized', message: 'react-virtualized is frozen — the app standardizes on react-window / @tanstack/react-virtual (ledger #8). Do not add new usage.' },
],
patterns: [
{ group: ['recoil/*'], message: 'Recoil is archived by Meta and frozen (ledger #5). Do not add new usage.' },
],
},
],
// Ledger #28: user-facing copy must go through i18n. Hardcoded Chinese in
// source is frozen in eslint-suppressions.json; new code uses localize().
'no-restricted-syntax': [
'error',
{ selector: 'Literal[value=/[\\u4e00-\\u9fff]/]', message: 'Hardcoded Chinese string — route copy through i18n (useLocalize → localize()). Ledger #28.' },
{ selector: 'TemplateElement[value.raw=/[\\u4e00-\\u9fff]/]', message: 'Hardcoded Chinese in template string — route copy through i18n (useLocalize → localize()). Ledger #28.' },
{ selector: 'JSXText[value=/[\\u4e00-\\u9fff]/]', message: 'Hardcoded Chinese JSX text — route copy through i18n (useLocalize → localize()). Ledger #28.' },
],
},
},
{
// The wrapper itself legitimately imports axios.
files: ['src/api/request.ts'],
rules: { 'no-restricted-imports': 'off' },
},
)
+3 -1
View File
@@ -38,7 +38,9 @@ module.exports = {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'jest-file-loader',
},
transformIgnorePatterns: ['node_modules/?!@zattoo/use-double-click'],
// @bisheng/ui is source-shipped TS from the workspace — jest must transform it
// (default ignore would skip everything under node_modules).
transformIgnorePatterns: ['node_modules/(?!(@bisheng/ui|@zattoo/use-double-click)/)'],
preset: 'ts-jest',
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '<rootDir>/test/setupTests.js'],
clearMocks: true,
-30835
View File
File diff suppressed because it is too large Load Diff
+19 -2
View File
@@ -5,6 +5,15 @@
"type": "module",
"scripts": {
"check-imports": "node scripts/check-case-sensitive-imports.mjs",
"lint": "eslint . --suppressions-location eslint-suppressions.json",
"lint:fix": "eslint . --suppressions-location eslint-suppressions.json --fix",
"lint:prune": "eslint . --suppressions-location eslint-suppressions.json --prune-suppressions",
"typecheck": "tsc-strict",
"gen:locales": "pnpm --filter @bisheng/locales build",
"predev": "pnpm gen:locales",
"prestart": "pnpm gen:locales",
"prebuild": "pnpm gen:locales",
"prebuild:vconsole": "pnpm gen:locales",
"dev": "cross-env NODE_ENV=development vite",
"dev:docs": "rspress dev",
"build:docs": "rspress build",
@@ -26,6 +35,8 @@
"dependencies": {
"@ariakit/react": "^0.4.15",
"@ariakit/react-core": "^0.4.15",
"@bisheng/file-viewers": "workspace:*",
"@bisheng/ui": "workspace:*",
"@codesandbox/sandpack-react": "^2.19.10",
"@dicebear/collection": "^7.0.4",
"@dicebear/core": "^7.0.4",
@@ -46,19 +57,23 @@
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.1",
"@radix-ui/react-slot": "catalog:",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.2.7",
"@rc-component/mini-decimal": "^1",
"@tanstack/react-query": "^4.28.0",
"@tanstack/react-table": "^8.11.7",
"@tanstack/react-virtual": "^3",
"axios": "^1.8.4",
"bisheng-icons": "^0.2.22",
"bisheng-icons": "^0.2.28",
"class-variance-authority": "^0.6.0",
"clsx": "^1.2.1",
"copy-to-clipboard": "^3.3.3",
"cross-env": "^7.0.3",
"date-fns": "^3.3.1",
"dedent": "^1",
"dompurify": "^3.4.11",
"downloadjs": "^1.4.7",
"echarts": "^6.0.0",
@@ -94,7 +109,7 @@
"react-flip-toolkit": "^7.1.0",
"react-gtm-module": "^2.0.11",
"react-hook-form": "^7.43.9",
"react-i18next": "^15.4.0",
"react-i18next": "^15.5.3",
"react-lazy-load-image-component": "^1.6.0",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^2.1.7",
@@ -117,6 +132,8 @@
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.5",
"tailwindcss-radix": "^2.8.0",
"unified": "^11",
"unist-util-visit": "^5",
"uuid": "^11.1.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-static-copy": "^3.1.1",
+69 -23
View File
@@ -7,7 +7,7 @@ import autoprefixer from 'autoprefixer';
/**
* Component-library docs site (rspress).
*
* - Docs root is the gitignored `docs-ui-refactor/` at the repo root (design specs).
* - Docs root is `packages/ui/docs/` (design specs, git-tracked with @bisheng/ui).
* - Real ui components are imported via the `~` / `@` alias (mirrors vite.config).
* - @rspress/plugin-preview renders live component demos inside markdown.
* - i18n and custom theme intentionally NOT enabled yet.
@@ -17,10 +17,14 @@ import autoprefixer from 'autoprefixer';
const clientSrc = path.join(__dirname, 'src');
export default defineConfig({
// req 2: docs root = bisheng/docs-ui-refactor
root: path.join(__dirname, '../../../docs-ui-refactor'),
title: 'BiSheng 组件库',
description: 'BiSheng client 设计规范 + 组件库',
// Docs live with the component library: src/frontend/packages/ui/docs
// (git-tracked; the site stays hosted in client until the app-coupled demos
// finish migrating onto @bisheng/ui).
root: path.join(__dirname, '../packages/ui/docs'),
// Build output next to the docs source (gitignored; CI artifact only).
outDir: path.join(__dirname, '../packages/ui/doc_build'),
title: 'BISHENG 组件库',
description: 'BISHENG client 设计规范 + 组件库',
lang: 'zh', // single language — i18n intentionally not enabled (req 5)
// No SSG: demos import real app components, whose dependency tree reaches
// browser/node-conditional packages (@dicebear/converter resolves its `node`
@@ -38,9 +42,10 @@ export default defineConfig({
plugins: [pluginPreview({ previewMode: 'internal' })],
route: {
// 00-总纲 is the Claude-window working charter, not reader material —
// keep it out of the site entirely (routes AND search index).
exclude: ['**/00-总纲.md'],
// Working/meta docs that are not reader material — kept out of the site
// entirely (routes AND search index): 00-总纲 (Claude-window charter) and
// 元-文档撰写规范 (how-to-author-these-docs guide, for authors only).
exclude: ['**/00-总纲.md', '**/元-文档撰写规范.md', '**/scripts/**'],
},
themeConfig: {
@@ -56,34 +61,59 @@ export default defineConfig({
sidebar: {
// 组件 section — component demos
'/components/': [
{ text: '组件总览', link: '/components/index' },
{ text: 'Typography 字体', link: '/components/typography' },
{ text: 'Color 色彩', link: '/components/color' },
{ text: 'Button 按钮', link: '/components/button' },
{ text: 'Modal 弹窗', link: '/components/modal' },
{ text: 'Confirm 二次确认', link: '/components/confirm' },
{ text: 'Feedback 点赞点踩', link: '/components/feedback' },
{ text: 'Icon 图标', link: '/components/icon' },
{ text: 'Illustration 插画', link: '/components/illustration' },
// antd-style categorized sidebar (mirrors the 文档 side): groups by kind.
// Foundations (typography/color/icon/illustration) live together; real
// components split by antd category.
{
text: '基础 Foundation',
items: [
{ text: '字体 Typography', link: '/components/typography' },
{ text: '色彩 Color', link: '/components/color' },
{ text: '图标 Icon', link: '/components/icon' },
{ text: '插画 Illustration', link: '/components/illustration' },
],
},
{
text: '通用 General',
items: [
{ text: '按钮 Button', link: '/components/button' },
],
},
{
text: '反馈 Feedback',
items: [
{ text: '弹窗 Modal', link: '/components/modal' },
{ text: '二次确认 Confirm', link: '/components/confirm' },
{ text: '点赞点踩 Feedback', link: '/components/feedback' },
],
},
],
// 文档 section — the existing design-spec markdown (kept flat, not moved)
'/': [
{
text: '设计模式',
items: [
{ text: '文案 Copywriting', link: '/基础-文案规范' },
{ text: '多端适配 Responsive', link: '/基础-多端适配原则' },
{ text: '滚动条 Scrollbar', link: '/基础-滚动条规范' },
],
},
{
text: '设计规范',
items: [
{ text: '设计变量 Design Token', link: '/design-token' },
{ text: '字体 Typography', link: '/基础-字体规范' },
{ text: '色彩 Color', link: '/基础-色彩规范' },
{ text: '多端适配', link: '/基础-多端适配原则' },
{ text: '图标 Icon', link: '/基础-图标规范' },
{ text: '插画 Illustration', link: '/基础-插画规范' },
{ text: '滚动条', link: '/基础-滚动条规范' },
{ text: '阴影与圆角 Elevation', link: '/基础-阴影与圆角规范' },
],
},
{
text: '组件规范',
items: [
{ text: 'Button 按钮', link: '/组件-Button按钮' },
{ text: 'Modal 弹窗', link: '/组件-Modal弹窗' },
{ text: '按钮 Button', link: '/组件-Button按钮' },
{ text: '弹窗 Modal', link: '/组件-Modal弹窗' },
],
},
],
@@ -120,6 +150,12 @@ export default defineConfig({
alias: {
'~': clientSrc,
'@': clientSrc,
// Spec pages (packages/ui/docs/*.mdx) live outside this project, so a
// bare `bisheng-icons` import resolves from the docs dir and misses the
// package installed here. Alias it to the local install so spec mdx can
// embed real icon components (plugin-preview fenced demos already
// resolve it via the client context; this covers page-body imports).
'bisheng-icons': path.join(__dirname, 'node_modules/bisheng-icons'),
$fonts: path.join(__dirname, 'public/fonts'),
// `import { URL } from 'url'` in api code must not resolve to the npm
// `url` package (no URL named export) — shim with the browser global.
@@ -146,7 +182,17 @@ export default defineConfig({
filter: (url: string) => !url.startsWith('/') && !url.startsWith('$fonts'),
},
},
rspack: (config) => {
rspack: (config, { rspack }) => {
// node:-scheme imports reached only on node-only paths (e.g.
// @dicebear/core toFile → import('node:fs/promises') via the ~/hooks
// barrel) — replace with an empty stub; rspack has no node: handling.
config.plugins = config.plugins || [];
config.plugins.push(
new rspack.NormalModuleReplacementPlugin(
/^node:fs\/promises$/,
path.join(__dirname, 'stubs/empty-module.ts'),
),
);
config.resolve = config.resolve || {};
// filenamify (ESM, imports node:path — an unbundlable scheme) comes
// in via the ~/hooks barrel (usePresets); no demo executes it. The
@@ -178,7 +224,7 @@ export default defineConfig({
// run, and MDX rejects the `<!-- site-hide -->` HTML comments this
// loader relies on. Spec .mdx pages are authored reader-clean instead.
test: /\.md$/,
include: [path.join(__dirname, '../../../docs-ui-refactor')],
include: [path.join(__dirname, '../packages/ui/docs')],
enforce: 'pre',
use: [path.join(__dirname, 'plugins/strip-internal-loader.cjs')],
});
+1 -1
View File
@@ -20,7 +20,7 @@ export interface FlowData {
//
export type ChatMessageType = {
message: string | Object;
message: string | object;
template?: string;
isSend: boolean;
thought?: string;
@@ -125,13 +125,13 @@ export const ConfirmProvider = ({ children }: { children: React.ReactNode }) =>
<AlertDialogFooter className="w-full flex-row gap-2 sm:space-x-0">
<AlertDialogCancel
onClick={handleCancel}
className="mt-0 h-auto flex-1 rounded-[6px] border-[#ebecf0] bg-white/50 px-4 py-[5px] text-sm font-normal text-[#070038] backdrop-blur-[4px] hover:bg-[#f7f8fa] focus:ring-0 focus:ring-offset-0 focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 sm:mt-0 sm:flex-none"
className="mt-0 h-auto flex-1 rounded-md border-[#ebecf0] bg-white/50 px-4 py-[5px] text-sm font-normal text-[#070038] backdrop-blur-[4px] hover:bg-[#f7f8fa] focus:ring-0 focus:ring-offset-0 focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 sm:mt-0 sm:flex-none"
>
{options.cancelText || defaultCancel}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
className={`h-auto flex-1 rounded-[6px] px-4 py-[5px] text-sm font-normal text-white focus:ring-0 focus:ring-offset-0 focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 sm:flex-none ${confirmColor}`}
className={`h-auto flex-1 rounded-md px-4 py-[5px] text-sm font-normal text-white focus:ring-0 focus:ring-offset-0 focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 sm:flex-none ${confirmColor}`}
>
{options.confirmText || defaultConfirm}
</AlertDialogAction>
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import React, { useEffect, useContext } from 'react';
import AnnouncerContext from '~/Providers/AnnouncerContext';
+1
View File
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import request from "./request";
import { FileStatus, FileType, type KnowledgeFile } from "./knowledge";
+5 -1
View File
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { generateUUID } from "~/utils";
import request from "./request";
@@ -242,7 +243,10 @@ export async function uploadChatFile(v, file: File, onProgress, uploadMode?: 'li
}
const urlMap = {
linsight: '/api/v1/linsight/workbench/upload-file',
workstation: '/api/v1/workstation/files',
// Daily chat used to have its own endpoint, which stored files under
// their raw filename — two users uploading "1.png" overwrote each other.
// The shared endpoint already names objects by uuid.
workstation: '/api/v1/knowledge/upload',
};
const url = uploadMode ? urlMap[uploadMode] : '/api/v1/knowledge/upload';
return await request.post(url, formData, {
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { z } from 'zod';
import _axios from 'axios';
import { URL } from 'url';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import type * as t from '~/types/chat/types';
import { EndpointURLs } from '~/types/chat/config';
import * as s from '~/types/chat/schemas';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import type { AxiosResponse } from 'axios';
import * as endpoints from './api-endpoints';
import * as config from '~/types/chat/config';
@@ -262,7 +263,7 @@ export const updateUserPlugins = (payload: t.TUpdateUserPlugins) => {
export const getStartupConfig = (): Promise<config.TStartupConfig> => {
// return request.get(endpoints.config());
return Promise.resolve({
"appTitle": "LibreChat",
"appTitle": "BISHENG",
"socialLogins": [
"github",
"google",
@@ -287,31 +288,20 @@ export const getStartupConfig = (): Promise<config.TStartupConfig> => {
"passwordResetEnabled": false,
"checkBalance": false,
"showBirthdayIcon": false,
"helpAndFaqURL": "https://librechat.ai",
"helpAndFaqURL": "",
"interface": {
"endpointsMenu": true,
"modelSelect": true,
"parameters": true,
"presets": true,
"sidePanel": true,
"privacyPolicy": {
"externalUrl": "https://librechat.ai/privacy-policy",
"openNewTab": true
},
"termsOfService": {
"externalUrl": "https://librechat.ai/tos",
"openNewTab": true,
"modalAcceptance": true,
"modalTitle": "Terms of Service for LibreChat",
"modalContent": "# Terms and Conditions for LibreChat\n\n*Effective Date: February 18, 2024*\n\nWelcome to LibreChat, the informational website for the open-source AI chat platform, available at https://librechat.ai. These Terms of Service (\"Terms\") govern your use of our website and the services we offer. By accessing or using the Website, you agree to be bound by these Terms and our Privacy Policy, accessible at https://librechat.ai//privacy.\n\n## 1. Ownership\n\nUpon purchasing a package from LibreChat, you are granted the right to download and use the code for accessing an admin panel for LibreChat. While you own the downloaded code, you are expressly prohibited from reselling, redistributing, or otherwise transferring the code to third parties without explicit permission from LibreChat.\n\n## 2. User Data\n\nWe collect personal data, such as your name, email address, and payment information, as described in our Privacy Policy. This information is collected to provide and improve our services, process transactions, and communicate with you.\n\n## 3. Non-Personal Data Collection\n\nThe Website uses cookies to enhance user experience, analyze site usage, and facilitate certain functionalities. By using the Website, you consent to the use of cookies in accordance with our Privacy Policy.\n\n## 4. Use of the Website\n\nYou agree to use the Website only for lawful purposes and in a manner that does not infringe the rights of, restrict, or inhibit anyone else's use and enjoyment of the Website. Prohibited behavior includes harassing or causing distress or inconvenience to any person, transmitting obscene or offensive content, or disrupting the normal flow of dialogue within the Website.\n\n## 5. Governing Law\n\nThese Terms shall be governed by and construed in accordance with the laws of the United States, without giving effect to any principles of conflicts of law.\n\n## 6. Changes to the Terms\n\nWe reserve the right to modify these Terms at any time. We will notify users of any changes by email. Your continued use of the Website after such changes have been notified will constitute your consent to such changes.\n\n## 7. Contact Information\n\nIf you have any questions about these Terms, please contact us at contact@librechat.ai.\n\nBy using the Website, you acknowledge that you have read these Terms of Service and agree to be bound by them.\n"
},
"bookmarks": true,
"prompts": true,
"multiConvo": true,
"agents": true,
"temporaryChat": true,
"runCode": true,
"customWelcome": "Welcome to LibreChat! Enjoy your experience."
"customWelcome": ""
},
"sharedLinksEnabled": true,
"publicSharedLinksEnabled": true,
@@ -1,3 +1,4 @@
// @ts-strict-ignore
export const envVarRegex = /^\${(.+)}$/;
/** Extracts the value of an environment variable from a string. */
+24
View File
@@ -1,3 +1,4 @@
// @ts-strict-ignore
/**
* Direct API calls for the AI chat system.
*/
@@ -12,6 +13,9 @@ const API = {
agentMessages: (conversationId: string) =>
`/api/v1/workstation/messages/${conversationId}/agent`,
sseChat: () => `/api/v1/workstation/chat/completions`,
// Links handed out at upload time expire; ask for a fresh one at render.
attachmentUrl: (conversationId: string, fileId: string) =>
`/api/v1/chat/${conversationId}/files/${fileId}/url`,
abortChat: () => `/api/v1/workstation/chat/completions/abort`,
deleteConversation: (id: string) => `/api/v1/chat/${id}`,
bsConfig: () => `/api/v1/workstation/config`,
@@ -640,3 +644,23 @@ export async function getFolderChatHistory(
// Backend returns newest-first; reverse for chronological order
return items.reverse().map(parseStreamHistoryItem);
}
/**
* Fresh link for one attachment of one conversation.
*
* The link stored on the message was signed at upload time and expires; the
* backend re-signs from the object it recorded for that conversation. Returns
* null when the file is no longer retrievable (cleared storage, or an upload
* from before attachments were kept), which the caller renders as such.
*/
export async function getAttachmentUrl(
conversationId: string,
fileId: string,
): Promise<string | null> {
try {
const res: any = await http.get(API.attachmentUrl(conversationId, fileId));
return (res?.data ?? res)?.url ?? null;
} catch {
return null;
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import request from "./request";
import { resolveKnowledgeParseFailureMessage } from "./knowledgeParseFailureMessage";
@@ -1171,7 +1172,7 @@ export async function deleteSpaceApi(space_id: string): Promise<void> {
export async function getFolderParentPathApi(
spaceId: string,
folderId: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Promise<Array<{ id: string; name: string }>> {
const res = await request.get<ApiResponse<any>>(
`/api/v1/knowledge/space/${spaceId}/folders/${folderId}/parent`
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import i18next from "i18next";
const ASR_ERROR_CODES = new Set([10014, 10015, 10016, 10017, 10018, 10019]);
+1
View File
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import {LinsightInfo} from "~/store/linsight";
import request from "./request";
+9 -1
View File
@@ -1,3 +1,4 @@
// @ts-strict-ignore
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import i18next from "i18next";
@@ -120,7 +121,14 @@ export const translateApiErrorMessage = (data: any) => {
if (statusMessageKey && i18next.exists(statusMessageKey)) {
return i18next.t(statusMessageKey, data?.data);
}
return statusMessage || (statusCodeKey ? i18next.t(statusCodeKey, data?.data) : "");
// Last resort keeps t(key, vars) so {{xxx}} templates still interpolate from
// data.data; defaultValue guards the truly-untranslated case — never the raw key.
return (
statusMessage ||
(statusCodeKey
? String(i18next.t(statusCodeKey, { ...(data?.data || {}), defaultValue: String(i18next.t("api_errors.fallback")) }))
: "")
);
};
// License degradation (gateway returns 11001): throttle the toast so a burst of
@@ -11,7 +11,7 @@ import { cn } from "~/utils";
* white surface, 8px corner radius, no hard border, soft shadow.
*/
export const actionMenuSurfaceClassName =
"rounded-[8px] border-0 bg-white shadow-[0_2px_16px_-2px_rgba(0,23,66,0.10)]";
"rounded-lg border-0 bg-white shadow-[0_2px_16px_-2px_rgba(0,23,66,0.10)]";
/** Default content frame: 160px wide, 8px padding, z-100 so it sits above
* any mobile drawer overlays. Width can be overridden via the `width` prop
@@ -22,7 +22,7 @@ export const actionMenuContentClassName = cn(
);
const itemBaseClassName =
"flex w-full cursor-pointer items-center gap-2 rounded-[6px] px-2 py-[5px] text-sm leading-[22px] outline-none transition-colors";
"flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-[5px] text-sm leading-[22px] outline-none transition-colors";
const itemRegularClassName = cn(
itemBaseClassName,
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import React, { useEffect, useRef, useState } from 'react';
import mermaid from 'mermaid';
import { TransformWrapper, TransformComponent, ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
// client/src/hooks/useDebounceCodeBlock.ts
import { useCallback, useEffect } from 'react';
import debounce from 'lodash/debounce';
@@ -65,7 +65,7 @@ function AuthLayout({
<img
src="/assets/sg-logo.png"
className="h-full w-full object-contain"
alt={localize('com_ui_logo', { 0: startupConfig?.appTitle ?? 'Deepseek' })}
alt={localize('com_ui_logo', { 0: startupConfig?.appTitle ?? 'BISHENG' })}
/>
</div>
</BlinkAnimation>
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { useMemo } from "react";
import { cn } from "~/utils"
import { AssistantIcon } from "~/components/ui/icon/AssistantIcon";
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { useEffect, useRef } from 'react';
import { XIcon } from 'lucide-react';
import { useRecoilState } from 'recoil';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { useCallback, useState } from 'react';
import type { FC } from 'react';
import { Label, OGDialog, OGDialogTrigger, TooltipAnchor } from '~/components/ui';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { useState } from 'react';
import type { FC } from 'react';
import type { TConversationTag } from '~/types/chat';
@@ -46,7 +46,7 @@ const ROLE_SELECT_WIDTH_CLASS = "h-8 w-24";
/** 角色下拉触发器:白底 + 浅灰描边(仅用于成员列表里「订阅用户」等可点下拉) */
const ROLE_SELECT_TRIGGER_CLASS = cn(
ROLE_SELECT_WIDTH_CLASS,
"box-border shrink-0 appearance-none rounded-[6px] border-[#EBECF0] bg-white shadow-none",
"box-border shrink-0 appearance-none rounded-md border-[#EBECF0] bg-white shadow-none",
"inline-flex items-center justify-end gap-1 px-2 text-[14px] text-[#818181]",
"hover:border-[#CED4E0] hover:text-blue-500",
);
@@ -222,7 +222,7 @@ export function ChannelMemberDialog({
<span
className={cn(
ROLE_SELECT_WIDTH_CLASS,
"inline-flex items-center justify-end rounded-[6px] px-2 text-[14px] text-[#818181]"
"inline-flex items-center justify-end rounded-md px-2 text-[14px] text-[#818181]"
)}
>
{getRoleLabel(m.role, localize)}
@@ -237,7 +237,7 @@ export function ChannelMemberDialog({
<span
className={cn(
ROLE_SELECT_WIDTH_CLASS,
"inline-flex items-center justify-end rounded-[6px] px-2 text-[14px] text-[#818181]"
"inline-flex items-center justify-end rounded-md px-2 text-[14px] text-[#818181]"
)}
>
{getRoleLabel(m.role, localize)}
@@ -255,7 +255,7 @@ export function ChannelMemberDialog({
<ChevronDown className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[120] w-28 rounded-[8px] border-[#EBECF0] p-1">
<DropdownMenuContent align="end" className="z-[120] w-28 rounded-lg border-[#EBECF0] p-1">
<DropdownMenuItem
className={cn(
"cursor-default",
@@ -290,7 +290,7 @@ export function ChannelMemberDialog({
<ChevronDown className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[120] w-28 rounded-[8px] border-[#EBECF0] p-1">
<DropdownMenuContent align="end" className="z-[120] w-28 rounded-lg border-[#EBECF0] p-1">
<DropdownMenuItem
className={cn(
m.role === "admin" &&
@@ -356,7 +356,7 @@ export function ChannelMemberDialog({
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={localize("com_subscription.search_user_placeholder") || "请输入用户名进行搜索"}
className="h-8 w-full rounded-[6px] border border-[#EBECF0] pl-9 pr-3 text-[14px] text-[#212121] placeholder:text-[#818181] focus:border-[#DDDDDD] focus:outline-none focus:ring-2 focus:ring-[#F1F5F9]"
className="h-8 w-full rounded-md border border-[#EBECF0] pl-9 pr-3 text-[14px] text-[#212121] placeholder:text-[#818181] focus:border-[#DDDDDD] focus:outline-none focus:ring-2 focus:ring-[#F1F5F9]"
/>
</div>
@@ -435,7 +435,7 @@ export function ChannelMemberDialog({
className={cn(
"flex h-6 min-w-6 items-center justify-center px-1.5 text-[14px] transition-colors",
p === page
? "rounded-[8px] border border-blue-500 text-blue-500"
? "rounded-lg border border-blue-500 text-blue-500"
: "rounded-[4px] border border-transparent text-[#4E5969] hover:text-blue-500"
)}
onClick={() => fetchMembers(p)}
@@ -37,7 +37,7 @@ const MAX_GROUP_LEN = 30;
const ROLE_SELECT_WIDTH_CLASS = "h-8 w-24";
const ROLE_SELECT_TRIGGER_CLASS = cn(
ROLE_SELECT_WIDTH_CLASS,
"box-border shrink-0 appearance-none rounded-[6px] border-[#EBECF0] bg-white shadow-none",
"box-border shrink-0 appearance-none rounded-md border-[#EBECF0] bg-white shadow-none",
"inline-flex items-center justify-end gap-1 px-2 text-[14px] text-[#818181]",
"hover:border-[#CED4E0] hover:text-blue-500",
);
@@ -209,7 +209,7 @@ export function ChannelMemberManagementPanel({
<span
className={cn(
ROLE_SELECT_WIDTH_CLASS,
"inline-flex items-center justify-end rounded-[6px] px-2 text-[14px] text-[#818181]",
"inline-flex items-center justify-end rounded-md px-2 text-[14px] text-[#818181]",
)}
>
{getRoleLabel(member.role, localize)}
@@ -223,7 +223,7 @@ export function ChannelMemberManagementPanel({
<span
className={cn(
ROLE_SELECT_WIDTH_CLASS,
"inline-flex items-center justify-end rounded-[6px] px-2 text-[14px] text-[#818181]",
"inline-flex items-center justify-end rounded-md px-2 text-[14px] text-[#818181]",
)}
>
{getRoleLabel(member.role, localize)}
@@ -238,7 +238,7 @@ export function ChannelMemberManagementPanel({
<ChevronDown className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[120] w-28 rounded-[8px] border-[#EBECF0] p-1">
<DropdownMenuContent align="end" className="z-[120] w-28 rounded-lg border-[#EBECF0] p-1">
<DropdownMenuItem
className={cn(
"cursor-default",
@@ -270,7 +270,7 @@ export function ChannelMemberManagementPanel({
<ChevronDown className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[120] w-28 rounded-[8px] border-[#EBECF0] p-1">
<DropdownMenuContent align="end" className="z-[120] w-28 rounded-lg border-[#EBECF0] p-1">
<DropdownMenuItem
className={cn(
member.role === "admin" &&
@@ -317,7 +317,7 @@ export function ChannelMemberManagementPanel({
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={localize("com_subscription.search_user_placeholder") || "请输入用户名进行搜索"}
className="h-8 w-full rounded-[6px] border border-[#EBECF0] pl-9 pr-3 text-[14px] text-[#212121] placeholder:text-[#818181] focus:border-[#DDDDDD] focus:outline-none focus:ring-2 focus:ring-[#F1F5F9]"
className="h-8 w-full rounded-md border border-[#EBECF0] pl-9 pr-3 text-[14px] text-[#212121] placeholder:text-[#818181] focus:border-[#DDDDDD] focus:outline-none focus:ring-2 focus:ring-[#F1F5F9]"
/>
</div>
@@ -398,7 +398,7 @@ export function ChannelMemberManagementPanel({
className={cn(
"flex h-6 min-w-6 items-center justify-center px-1.5 text-[14px] transition-colors",
pageNumber === page
? "rounded-[8px] border border-blue-500 text-blue-500"
? "rounded-lg border border-blue-500 text-blue-500"
: "rounded-[4px] border border-transparent text-[#4E5969] hover:text-blue-500",
)}
onClick={() => {
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { PlusCircle } from 'lucide-react';
import { isAssistantsEndpoint } from '~/types/chat';
import type { TConversation } from '~/types/chat';
@@ -452,7 +452,7 @@ export default function AiChatMessages({
<button
type="button"
onClick={scrollToBottom}
className="flex items-center h-8 justify-center gap-2 rounded-[6px] border border-[#EBECF0] bg-white/80 backdrop-blur-[4px] px-2.5 text-sm leading-5 text-neutral-800 hover:bg-white/90 transition-colors"
className="flex items-center h-8 justify-center gap-2 rounded-md border border-[#EBECF0] bg-white/80 backdrop-blur-[4px] px-2.5 text-sm leading-5 text-neutral-800 hover:bg-white/90 transition-colors"
>
<ArrowDownIcon size={16} />
<span className="text-sm"></span>
@@ -33,7 +33,8 @@ import {
} from "~/components/Chat/MessageSelection";
import { copyText, cn } from "~/utils";
import type { AgentEvent, ChatMessage } from "~/api/chatApi";
import { getFileTypebyFileName } from "~/components/ui/icon/File/FileIcon";
import { getFileTypebyFileName, isImageFileName } from "~/components/ui/icon/File/FileIcon";
import { MessageImage } from "~/components/Chat/Messages/Content/MessageImage";
// Transient/retryable backend error codes surfaced by daily-mode chat — LLM rate
// limit (12046), generic busy (429/503), thread-pool full (10540), dept concurrency
@@ -76,7 +77,7 @@ const FILE_TYPE_ICONS: Record<string, typeof Outlined.File> = {
* mask softly fades the top/bottom edge (instead of a hard clip) whenever there
* is more content to scroll in that direction same fade trick used elsewhere.
*/
function UploadedFileList({ files }: { files: any[] }) {
function UploadedFileList({ files, conversationId }: { files: any[]; conversationId?: string }) {
const scrollRef = useRef<HTMLDivElement>(null);
const [fade, setFade] = useState({ top: false, bottom: false });
@@ -102,29 +103,50 @@ function UploadedFileList({ files }: { files: any[] }) {
if (!files || files.length === 0) return null;
// Pictures are shown as pictures; everything else keeps the compact
// icon+name row it always had.
const images = files.filter((f) => isImageFileName(f.name || f.file_name));
const others = files.filter((f) => !isImageFileName(f.name || f.file_name));
return (
<div
ref={scrollRef}
onScroll={updateFade}
style={maskStyle}
className="scrollbar-os mb-2 mt-1 flex max-h-[120px] max-w-sm flex-col gap-3 overflow-y-auto"
>
{files.map((file, i) => {
const fileName = file.name || file.file_name || "File";
const fileType = getFileTypebyFileName(fileName);
const FileTypeIcon = FILE_TYPE_ICONS[fileType] ?? Outlined.File;
return (
<div key={i} className="flex shrink-0 items-center gap-1 text-[#999999]">
<FileTypeIcon size={12} className="shrink-0 text-[#CCCCCC]" />
<div className="min-w-0 flex-1 overflow-hidden">
<div className="truncate text-xs" title={fileName}>
{fileName}
<>
{images.length > 0 && (
<div className="mb-2 mt-1 flex flex-wrap justify-end gap-2">
{images.map((file, i) => (
<MessageImage
key={file.file_id ?? i}
conversationId={conversationId}
fileId={file.file_id}
altText={file.name || file.file_name}
/>
))}
</div>
)}
{others.length > 0 && (
<div
ref={scrollRef}
onScroll={updateFade}
style={maskStyle}
className="scrollbar-os mb-2 mt-1 flex max-h-[120px] max-w-sm flex-col gap-3 overflow-y-auto"
>
{others.map((file, i) => {
const fileName = file.name || file.file_name || "File";
const fileType = getFileTypebyFileName(fileName);
const FileTypeIcon = FILE_TYPE_ICONS[fileType] ?? Outlined.File;
return (
<div key={i} className="flex shrink-0 items-center gap-1 text-[#999999]">
<FileTypeIcon size={12} className="shrink-0 text-[#CCCCCC]" />
<div className="min-w-0 flex-1 overflow-hidden">
<div className="truncate text-xs" title={fileName}>
{fileName}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
);
})}
</div>
)}
</>
);
}
@@ -168,7 +190,7 @@ function CopyButton({ text }: { text: string }) {
<button
type="button"
onClick={handleCopy}
className="flex size-6 items-center justify-center rounded-[6px] backdrop-blur-[4px] transition-colors hover:bg-[#F7F7F7]"
className="flex size-6 items-center justify-center rounded-md backdrop-blur-[4px] transition-colors hover:bg-[#F7F7F7]"
title="复制"
aria-label="复制"
>
@@ -424,7 +446,7 @@ function UserBubble({
<div className={cn("flex min-w-0 flex-col items-end touch-mobile:max-w-[calc(100%-40px)]", knowledgeChatLayout ? "max-w-[min(92%,56rem)]" : "max-w-[80%]")}>
{/* Uploaded files: icon + filename only (no preview), with soft fade
edges while scrolling so the 120px-clipped list never hard-cuts. */}
<UploadedFileList files={message.files || []} />
<UploadedFileList files={message.files || []} conversationId={message.conversationId} />
{/* min-w-0: without it this flex row's `min-width: auto` floors at
the URL's (unbreakable) min-content width, defeating the bubble's
max-width and letting long content overflow off the left edge. */}
@@ -448,7 +470,7 @@ function UserBubble({
// sizing the box to a long unbreakable URL and max-width
// can't clamp it — `anywhere` reduces min-content so the
// box shrinks and the URL wraps inside max-w-full.
"w-fit max-w-full px-3 py-2 whitespace-pre-wrap [overflow-wrap:anywhere] rounded-[8px]",
"w-fit max-w-full px-3 py-2 whitespace-pre-wrap [overflow-wrap:anywhere] rounded-lg",
knowledgeChatLayout
? "bg-[#F2F3F5] text-[#4E5969] text-[14px] leading-[22px]"
: "rounded-[10px] bg-blue-500/[0.07] text-[#1d2129] text-sm"
@@ -769,7 +791,7 @@ function AssistantBubble({
/>
)}
<TextToSpeechButton
className="flex size-6 items-center justify-center rounded-[6px] backdrop-blur-[4px] transition-colors hover:bg-[#F7F7F7]"
className="flex size-6 items-center justify-center rounded-md backdrop-blur-[4px] transition-colors hover:bg-[#F7F7F7]"
messageId={message.messageId || ""}
text={regularContent}
/>
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { Rotate3DIcon } from "lucide-react";
import { memo, useEffect, useMemo } from "react";
import {
@@ -1009,7 +1009,7 @@ const DailyFeaturedApps = ({ t }: { t: (k: string) => string }) => {
{displayApps.map((appItem) => (
<Card
key={appItem.id}
className="group flex flex-col py-0 rounded-[8px] shadow-[0_2px_4px_rgba(0,0,0,0.02)] border border-[#E5E6EB] overflow-hidden cursor-pointer hover:border-blue-500 hover:shadow-[0_4px_14px_rgb(var(--brand-500)/0.12)] transition-all duration-300 h-[142px] hover:-translate-y-1"
className="group flex flex-col py-0 rounded-lg shadow-[0_2px_4px_rgba(0,0,0,0.02)] border border-[#E5E6EB] overflow-hidden cursor-pointer hover:border-blue-500 hover:shadow-[0_4px_14px_rgb(var(--brand-500)/0.12)] transition-all duration-300 h-[142px] hover:-translate-y-1"
style={{ background: 'linear-gradient(135deg, rgb(var(--brand-500)/0.04) 0%, #fff 50%, rgb(var(--brand-500)/0.04) 100%)' }}
onClick={() => handleCardClick(appItem)}
>
@@ -1019,7 +1019,7 @@ const DailyFeaturedApps = ({ t }: { t: (k: string) => string }) => {
id={appItem.name}
url={appItem.logo}
flowType={appItem.flow_type || appItem.type}
className={`size-[32px] min-w-[32px] !rounded-[8px]`}
className={`size-[32px] min-w-[32px] !rounded-lg`}
iconClassName="w-5 h-5"
/>
<div className="text-[15px] font-medium text-[#1D2129] line-clamp-1 break-all">{appItem.name}</div>
@@ -1037,7 +1037,7 @@ const DailyFeaturedApps = ({ t }: { t: (k: string) => string }) => {
</div>
))}
</div>
<div className="absolute inset-x-0 bottom-0 top-1 flex items-center justify-center bg-blue-500 rounded-[6px] text-white text-[13px] font-medium opacity-0 fine-pointer:group-hover:opacity-100 transform translate-y-2 fine-pointer:group-hover:translate-y-0 transition-all duration-300 coarse-pointer:opacity-100 coarse-pointer:translate-y-0">
<div className="absolute inset-x-0 bottom-0 top-1 flex items-center justify-center bg-blue-500 rounded-md text-white text-[13px] font-medium opacity-0 fine-pointer:group-hover:opacity-100 transform translate-y-2 fine-pointer:group-hover:translate-y-0 transition-all duration-300 coarse-pointer:opacity-100 coarse-pointer:translate-y-0">
</div>
</div>
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { useState, useId, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import * as Ariakit from '@ariakit/react';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { useCallback } from 'react';
import { useChatFormContext, useToastContext } from '~/Providers';
import { ListeningIcon, Spinner } from '~/components/svg';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import React from 'react';
import { CircleIcon, CircleDotsIcon } from '~/components/svg';
import { ECallState } from '~/types/chat';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import React from 'react';
import { Minimize2 } from 'lucide-react';
import { TooltipAnchor } from '~/components/ui';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import React, { useRef } from 'react';
import { FileUpload, TooltipAnchor } from '~/components/ui';
import { AttachmentIcon } from '~/components/svg';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import * as Ariakit from '@ariakit/react';
import React, { useRef, useState, useMemo } from 'react';
import { FileSearch, ImageUpIcon, TerminalSquareIcon } from 'lucide-react';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { memo, useEffect, useState } from "react";
import { useRecoilValue } from "recoil";
import { useChatContext } from "~/Providers";
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import type { ExtendedFile } from '~/common';
import { FileIcon, getFileTypebyFileName } from '~/components/ui/icon/File/FileIcon';
import LegacyFileIcon from '~/components/ui/icon/File';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import axios from 'axios';
import { useEffect, useState } from 'react';
import { NotificationSeverity } from '~/common';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import React, { useRef } from 'react';
import { Button, FileUpload, TooltipAnchor } from '~/components/ui';
import { AttachmentIcon } from '~/components/svg';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { ArrowUpDown, Database } from 'lucide-react';
import { FileSources, FileContext } from '~/types/chat';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import type {
ColumnDef,
ColumnFiltersState,
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { ArrowUpDown, Database, Download, TrashIcon } from 'lucide-react';
import { FileSources, FileContext, dataService } from '~/types/chat';
import type { ColumnDef } from '@tanstack/react-table';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { useRecoilState } from 'recoil';
import { Settings2 } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { useState, useRef, useEffect } from 'react';
import { AutoSizer, List } from 'react-virtualized';
import { EModelEndpoint } from '~/types/chat';
@@ -133,7 +133,7 @@ export default function PopoverButtons({
</Button>
))}
</div>
{/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */}
{ }
{disabled ? null : (
<div className="flex w-[150px] items-center justify-end">
{additionalButtons[settingsView].map((button, index) => (
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import React, { forwardRef } from 'react';
import { useWatch } from 'react-hook-form';
import type { Control } from 'react-hook-form';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { TooltipAnchor } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { useState, useId, useCallback, useMemo, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import * as Ariakit from '@ariakit/react';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import React, { memo } from 'react';
import type { TModelSpec, TEndpointsConfig } from '~/types/chat';
import type { IconMapProps } from '~/common';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { useRecoilState } from 'recoil';
import { useCallback, useEffect, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import type { FC } from 'react';
import { BookCopy } from 'lucide-react';
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
@@ -25,7 +25,7 @@ import { cn } from "~/utils";
type ThumbsState = 0 | 1 | 2;
const ACTION_BTN =
"flex size-6 items-center justify-center rounded-[6px] transition-colors hover:bg-[#F7F7F7]";
"flex size-6 items-center justify-center rounded-md transition-colors hover:bg-[#F7F7F7]";
interface MessageFeedbackButtonsProps {
/** Initial / persisted verdict: 0 none, 1 up, 2 down. */
@@ -1,3 +1,4 @@
// @ts-strict-ignore
/**
* F028 H5 bottom sheet that lets the user pick an export file format.
*
@@ -59,7 +59,7 @@ export function ExportSelectionButton({
type="button"
onClick={handleClick}
className={cn(
'flex size-6 items-center justify-center rounded-[6px] backdrop-blur-[4px] transition-colors hover:bg-[#F7F7F7]',
'flex size-6 items-center justify-center rounded-md backdrop-blur-[4px] transition-colors hover:bg-[#F7F7F7]',
active && 'bg-[#F0F0F0]',
className,
)}
@@ -1,3 +1,4 @@
// @ts-strict-ignore
/**
* F028 Bottom-fixed operation bar that appears when the conversation
* export selection mode is active.
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { Outlined } from 'bisheng-icons';
import { useEffect, useState } from 'react';
import { useSetRecoilState } from 'recoil';
@@ -279,7 +280,7 @@ export default function CitationDocumentPreviewDrawer({
'shrink-0 items-center justify-center text-[#86909C] hover:bg-[#F2F3F5] hover:text-blue-500 disabled:cursor-not-allowed disabled:text-[#C9CDD4]',
isFullBleedMobile
? 'inline-flex size-8 rounded-md'
: 'inline-flex size-6 rounded-[6px]',
: 'inline-flex size-6 rounded-md',
)}
aria-label={localize("com_knowledge.download_file")}
>
@@ -294,7 +295,7 @@ export default function CitationDocumentPreviewDrawer({
'items-center justify-center text-[#A9AEB8] hover:bg-[#F2F3F5] hover:text-[#4E5969]',
isFullBleedMobile
? 'inline-flex size-8 rounded-md'
: 'inline-flex size-6 rounded-[6px]',
: 'inline-flex size-6 rounded-md',
)}
aria-label="关闭文档预览"
>
@@ -67,7 +67,7 @@ function SourceTypeBadge({ preview, type }: { preview: CitationPreview | null; t
return (
<div
className={cn(
'inline-flex h-[18px] min-w-[16px] items-center justify-center rounded-[6px] px-1 text-[12px] font-normal leading-[18px]',
'inline-flex h-[18px] min-w-[16px] items-center justify-center rounded-md px-1 text-[12px] font-normal leading-[18px]',
isWeb ? 'bg-[#F7F3FF] text-[#7224D9]' : 'bg-blue-50 text-blue-600',
)}
>
@@ -120,7 +120,7 @@ function CitationReferenceCard({
'text-[14px] font-normal leading-[22px] text-[#1D2129]';
return (
<div className="flex min-h-[92px] flex-col gap-2 rounded-[6px] border border-[#ECECEC] bg-white p-2">
<div className="flex min-h-[92px] flex-col gap-2 rounded-md border border-[#ECECEC] bg-white p-2">
<div className="flex items-center">
<SourceTypeBadge preview={preview} type={item.data.type} />
</div>
@@ -709,7 +709,7 @@ export default function CitationReferencesDrawer({
data-citation-references-trigger="true"
onClick={handleOpenButtonClick}
className={cn(
'flex h-6 shrink-0 items-center justify-end gap-1 rounded-[6px] bg-transparent px-1 py-0.5 text-[#818181] transition-colors hover:bg-[#F7F7F7]',
'flex h-6 shrink-0 items-center justify-end gap-1 rounded-md bg-transparent px-1 py-0.5 text-[#818181] transition-colors hover:bg-[#F7F7F7]',
referenceButtonWidth,
)}
>
@@ -738,7 +738,7 @@ export default function CitationReferencesDrawer({
<aside
className={cn(
'fixed inset-y-0 right-0 z-[130] flex min-h-0 w-[min(520px,calc(100vw-24px))] min-w-0 flex-col overflow-hidden bg-white shadow-[0_8px_24px_rgba(0,0,0,0.12)] animate-in slide-in-from-right duration-300',
'rounded-tl-[8px]',
'rounded-tl-lg',
)}
aria-label="参考资料"
onClick={(event) => event.stopPropagation()}
@@ -1,3 +1,4 @@
// @ts-strict-ignore
"use client"
import { Copy } from "lucide-react"
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { useRef, useEffect, useCallback } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useForm } from 'react-hook-form';
@@ -0,0 +1,28 @@
import { Outlined } from 'bisheng-icons';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
/**
* Stands in for an attachment whose bytes are no longer retrievable an
* upload from before attachments were made permanent, or one the storage
* cleared. It keeps the message's shape instead of leaving a broken image, and
* says why so the user doesn't take it for a loading failure worth retrying.
*/
export function InvalidImagePlaceholder({ className }: { className?: string }) {
const localize = useLocalize();
return (
<div
className={cn(
'flex h-[120px] w-[160px] flex-col items-center justify-center gap-2 rounded-lg',
'border border-border-light bg-surface-secondary text-text-secondary',
className,
)}
>
<Outlined.FileImage className="size-7 opacity-60" />
<span className="px-2 text-center text-xs leading-tight">
{localize('com_chat_image_expired')}
</span>
</div>
);
}
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { memo } from 'react';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
"use client"
import type React from "react"
@@ -0,0 +1,72 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { getAttachmentUrl } from '~/api/chatApi';
import DialogImage from './DialogImage';
import { InvalidImagePlaceholder } from './InvalidImagePlaceholder';
/**
* An image a user attached to a message: thumbnail, click to view full screen.
*
* The link recorded on the message was signed when the file was uploaded and
* has long since expired on any older conversation, so the link is fetched
* fresh at render. When that isn't possible the storage no longer holds the
* file, or the message predates attachments being kept the placeholder says
* so instead of leaving a broken image behind.
*/
export function MessageImage({
conversationId,
fileId,
altText,
}: {
conversationId?: string;
fileId?: string;
altText?: string;
}) {
const [url, setUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
if (!conversationId || !fileId) {
setFailed(true);
return;
}
setFailed(false);
getAttachmentUrl(conversationId, fileId).then((fresh) => {
if (cancelled) {
return;
}
if (fresh) {
setUrl(fresh);
} else {
setFailed(true);
}
});
return () => {
cancelled = true;
};
}, [conversationId, fileId]);
if (failed) {
return <InvalidImagePlaceholder />;
}
if (!url) {
// Same footprint as the thumbnail so the message doesn't jump once it lands.
return <div className="h-[120px] w-[160px] animate-pulse rounded-lg bg-surface-secondary" />;
}
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<img
src={url}
alt={altText ?? ''}
onError={() => setFailed(true)}
className="h-[120px] w-[160px] cursor-pointer rounded-lg object-cover"
/>
</Dialog.Trigger>
<DialogImage src={url} />
</Dialog.Root>
);
}
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { memo, useMemo, ReactElement } from 'react';
import { useRecoilValue } from 'recoil';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { Suspense } from 'react';
import { useRecoilValue } from 'recoil';
import type { TMessage, TMessageContentParts } from '~/types/chat';
@@ -299,7 +299,7 @@ export function getCitationDocumentUrl(detail?: ChatCitation | null) {
return getCitationDocumentPreviewUrl(detail);
}
let inflightFileShareCache: Record<string, Promise<string>> = {};
const inflightFileShareCache: Record<string, Promise<string>> = {};
export async function resolveCitationDocumentUrl(detail?: ChatCitation | null) {
const fileId = getCitationKnowledgeFileId(detail);
@@ -105,7 +105,7 @@ export function useCitationReferencePanel({ hasMessages }: UseCitationReferenceP
handleCloseCitationPanel();
}
}}
panelClassName="h-full w-full overflow-hidden rounded-[8px] border border-[#ECECEC] bg-[#FBFBFB]"
panelClassName="h-full w-full overflow-hidden rounded-lg border border-[#ECECEC] bg-[#FBFBFB]"
messageId={citationPanelPayload.messageId}
content={citationPanelPayload.content}
webContent={citationPanelPayload.webContent}
@@ -147,7 +147,7 @@ export function useCitationReferencePanel({ hasMessages }: UseCitationReferenceP
data-citation-popover-surface
className={cn(
'fixed inset-y-0 right-0 z-[130] flex min-h-0 flex-col overflow-hidden border-l border-[#ECECEC] bg-white shadow-[-8px_0_28px_rgba(0,0,0,0.08)] animate-in slide-in-from-right duration-300',
'rounded-tl-[8px]',
'rounded-tl-lg',
'min-w-[260px] w-[min(520px,42vw)] max-[580px]:min-w-[240px] max-[580px]:w-[min(360px,calc(100vw-40px))]',
)}
onClick={(event) => event.stopPropagation()}
@@ -168,7 +168,7 @@ export function useCitationReferencePanel({ hasMessages }: UseCitationReferenceP
data-citation-popover-surface
className={cn(
'fixed inset-y-0 right-0 z-[150] flex min-h-0 flex-col overflow-hidden border-l border-[#ECECEC] bg-white shadow-[-8px_0_28px_rgba(0,0,0,0.1)] animate-in slide-in-from-right duration-300',
'rounded-tl-[8px]',
'rounded-tl-lg',
useExpandedCitationPanel ? 'w-[min(480px,100vw)]' : 'w-[min(360px,100vw)]',
)}
onClick={(event) => event.stopPropagation()}
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import React, { useMemo, memo } from 'react';
import type { Assistant, Agent } from '~/types/chat';
import type { TMessageIcon } from '~/common';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import type { TMessage } from '~/types/chat';
import { memo, useCallback, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { Outlined } from "bisheng-icons";
import { Check, X } from "lucide-react";
import type { FocusEvent, KeyboardEvent, MouseEvent } from "react";
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { useState, useCallback } from 'react';
import { QrCode, RotateCw, Trash2 } from 'lucide-react';
import type { TSharedLinkGetResponse } from '~/types/chat';
@@ -73,8 +73,8 @@ export function CopyShareLinkButton({
onClick={handleClick}
className={cn(
iconOnly
? "h-8 w-8 shrink-0 justify-center rounded-[6px] border border-[#EBECF0] bg-white p-0 transition-colors hover:bg-[#F7F8FA]"
: "h-8 gap-1 px-4 font-normal transition-colors hover:bg-[#F7F8FA] touch-mobile:rounded-[6px] touch-mobile:border touch-mobile:border-[#EBECF0] touch-mobile:bg-white touch-mobile:px-4 touch-mobile:text-[#212121]",
? "h-8 w-8 shrink-0 justify-center rounded-md border border-[#EBECF0] bg-white p-0 transition-colors hover:bg-[#F7F8FA]"
: "h-8 gap-1 px-4 font-normal transition-colors hover:bg-[#F7F8FA] touch-mobile:rounded-md touch-mobile:border touch-mobile:border-[#EBECF0] touch-mobile:bg-white touch-mobile:px-4 touch-mobile:text-[#212121]",
className,
)}
>
@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { memo, useMemo } from 'react';
import type { IconMapProps } from '~/common';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';

Some files were not shown because too many files have changed in this diff Show More