diff --git a/README.md b/README.md index 9013103f5b..e78415d446 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Fully modular pipeline from document parsing, vectorization, and retrieval to LL | Knowledge Base Types | FAQ / Document / Wiki with folder import, URL import, multi-tag management, and online entry | | Per-Upload Process Config | Override parser, chunking, multimodal (VLM / ASR), graph extraction, and question generation per upload batch via upload-confirm dialog or `process_config` API; reparse with new settings | | Batch Reparse | Re-queue parsing for multiple documents at once with optional per-batch `process_config` | -| Data Source Import | Auto-sync from Feishu / Notion / Yuque / RSS feeds (more data sources coming soon); incremental and full sync | +| Data Source Import | Auto-sync from Feishu / DingTalk / Notion / Yuque / RSS feeds (more data sources coming soon); incremental and full sync | | Document Formats | PDF / Word / Txt / Markdown / HTML / EPUB / MHTML / Images / CSV / Excel / PPT / JSON | | Retrieval Strategies | BM25 sparse / Dense retrieval / GraphRAG / parent-child chunking / HNSW-accelerated pgvector (1024-dim) / multi-dimensional indexing | | Batch Selection | Marquee drag-select multiple documents in the KB list for batch operations | diff --git a/README_CN.md b/README_CN.md index 0002873976..11f90cc94f 100644 --- a/README_CN.md +++ b/README_CN.md @@ -123,7 +123,7 @@ | 知识库类型 | FAQ / 文档 / Wiki,支持文件夹导入、URL 导入、多标签管理、在线录入 | | 按批次解析配置 | 上传确认对话框或 `process_config` API 覆盖解析引擎、分块、多模态(VLM / ASR)、图谱抽取与问题生成;支持 reparse 时调整配置 | | 批量重新解析 | 一次为多篇文档重新排队解析,可携带批次级 `process_config` | -| 数据源导入 | 飞书 / Notion / 语雀 / RSS 订阅自动同步(更多数据源开发中),支持增量与全量同步 | +| 数据源导入 | 飞书 / 钉钉 / Notion / 语雀 / RSS 订阅自动同步(更多数据源开发中),支持增量与全量同步 | | 文档格式 | PDF / Word / Txt / Markdown / HTML / EPUB / MHTML / 图片 / CSV / Excel / PPT / JSON | | 检索策略 | BM25 稀疏召回 / Dense 稠密召回 / GraphRAG 图谱增强 / 父子分块 / pgvector HNSW 加速(1024 维)/ 多维度索引 | | 批量选择 | 知识库文档列表支持框选(marquee)多选,便于批量操作 | diff --git a/README_JA.md b/README_JA.md index 9a33afe6a2..eb547ea731 100644 --- a/README_JA.md +++ b/README_JA.md @@ -124,7 +124,7 @@ Feishu、Notion、Yuqueなどの外部プラットフォームからのナレッ | ナレッジベースタイプ | FAQ / ドキュメント / Wiki、フォルダーインポート・URL インポート・複数タグ管理・オンライン入力 | | アップロード単位の解析設定 | アップロード確認ダイアログまたは `process_config` API でパーサー・チャンキング・マルチモーダル(VLM / ASR)・グラフ抽出・質問生成をバッチ単位で上書き;reparse 時も設定変更可能 | | 一括 reparse | 複数ドキュメントの解析を一度に再キュー、バッチ単位の `process_config` 対応 | -| データソースインポート | Feishu / Notion / Yuque / RSS フィードの自動同期(他のデータソースも開発中)、増分・全量同期対応 | +| データソースインポート | Feishu / DingTalk / Notion / Yuque / RSS フィードの自動同期(他のデータソースも開発中)、増分・全量同期対応 | | 文書フォーマット | PDF / Word / Txt / Markdown / HTML / EPUB / MHTML / 画像 / CSV / Excel / PPT / JSON | | 検索戦略 | BM25 疎検索 / Dense 密検索 / GraphRAG グラフ強化 / 親子チャンキング / pgvector HNSW 加速(1024 次元)/ 多次元インデックス | | 一括選択 | KB リストでマーキー(ドラッグ)複数選択によるバッチ操作 | diff --git a/README_KO.md b/README_KO.md index fc58785c8d..9f4b68554d 100644 --- a/README_KO.md +++ b/README_KO.md @@ -133,7 +133,7 @@ Feishu, Notion, Yuque 등 외부 플랫폼에서 지식 자동 동기화를 지 | 지식베이스 타입 | FAQ / 문서 / Wiki, 폴더 임포트·URL 임포트·다중 태그 관리·온라인 입력 | | 업로드 단위 파싱 설정 | 업로드 확인 대화상자 또는 `process_config` API로 파서·청킹·멀티모달(VLM / ASR)·그래프 추출·질문 생성을 배치 단위로 덮어쓰기; reparse 시 설정 변경 지원 | | 일괄 reparse | 여러 문서의 파싱을 한 번에 재큐잉, 배치 단위 `process_config` 지원 | -| 데이터 소스 임포트 | Feishu / Notion / Yuque / RSS 피드 자동 동기화(추가 데이터 소스 개발 중), 증분·전체 동기화 지원 | +| 데이터 소스 임포트 | Feishu / DingTalk / Notion / Yuque / RSS 피드 자동 동기화(추가 데이터 소스 개발 중), 증분·전체 동기화 지원 | | 문서 포맷 | PDF / Word / Txt / Markdown / HTML / EPUB / MHTML / 이미지 / CSV / Excel / PPT / JSON | | 검색 전략 | BM25 희소 / Dense 밀집 / GraphRAG 그래프 강화 / 부모-자식 청킹 / pgvector HNSW 가속(1024차원) / 다차원 인덱싱 | | 일괄 선택 | KB 목록에서 마키(드래그) 다중 선택으로 일괄 작업 | diff --git "a/docs/wiki/\351\233\206\346\210\220\346\211\251\345\261\225/\346\225\260\346\215\256\346\272\220\345\257\274\345\205\245\345\274\200\345\217\221.md" "b/docs/wiki/\351\233\206\346\210\220\346\211\251\345\261\225/\346\225\260\346\215\256\346\272\220\345\257\274\345\205\245\345\274\200\345\217\221.md" index 171faedf56..4662d68fdb 100644 --- "a/docs/wiki/\351\233\206\346\210\220\346\211\251\345\261\225/\346\225\260\346\215\256\346\272\220\345\257\274\345\205\245\345\274\200\345\217\221.md" +++ "b/docs/wiki/\351\233\206\346\210\220\346\211\251\345\261\225/\346\225\260\346\215\256\346\272\220\345\257\274\345\205\245\345\274\200\345\217\221.md" @@ -1,13 +1,13 @@ --- title: 数据源导入开发 -tags: [集成扩展, 数据源, 飞书, 同步, 连接器] +tags: [集成扩展, 数据源, 飞书, 钉钉, 同步, 连接器] aliases: [数据源导入, DataSource, 数据同步] source: 数据源导入开发文档.md --- # 数据源导入开发 -WeKnora 的数据源导入模块支持从外部平台(飞书、企业微信、Notion、Confluence 等)自动导入和同步内容到知识库。用户可配置数据源连接,选择需要同步的资源,并通过手动触发或定时调度自动完成内容的增量/全量同步。 +WeKnora 的数据源导入模块支持从外部平台(飞书、钉钉、Notion、语雀、RSS 等)自动导入和同步内容到知识库。用户可配置数据源连接,选择需要同步的资源,并通过手动触发或定时调度自动完成内容的增量/全量同步。 数据源绑定到知识库,一个知识库可接入多个数据源。凭证使用 AES-256-GCM 加密存储。 @@ -18,6 +18,7 @@ WeKnora 的数据源导入模块支持从外部平台(飞书、企业微信、 | 连接器 | 认证方式 | 增量同步 | 删除同步 | |--------|---------|:-:|:-:| | 飞书 (Feishu) | OAuth2 (Tenant Access Token) | ✅ | ✅ | +| 钉钉文档 (DingTalk Docs) | OAuth2 App Access Token | ✅ | ✅ | ## 快速接入:飞书知识库 @@ -29,6 +30,17 @@ WeKnora 的数据源导入模块支持从外部平台(飞书、企业微信、 > 注意:飞书国际版(Lark)同样支持,自动适配 `https://open.larksuite.com` 的 API 地址 +## 快速接入:钉钉文档 + +1. 创建钉钉企业内部应用并获取 Client ID / Client Secret +2. 开通知识库读、知识库节点读、企业存储文件读权限并发布应用版本 +3. 准备一个能够读取目标知识库的用户 UnionID +4. 在知识库设置页添加“钉钉文档”,测试连接并选择空间、文件夹或单篇文档 +5. 配置全量或增量同步并触发首次同步 + +连接器使用官方知识库节点和文档块 API,当前仅同步 `FILE + ALIDOC + adoc` +在线文档。目录遍历不完整时不会判定删除,以避免临时权限或网络异常造成误删除。 + ## 架构设计 ``` diff --git "a/docs/\346\225\260\346\215\256\346\272\220\345\257\274\345\205\245\345\274\200\345\217\221\346\226\207\346\241\243.md" "b/docs/\346\225\260\346\215\256\346\272\220\345\257\274\345\205\245\345\274\200\345\217\221\346\226\207\346\241\243.md" index 875880e0f1..fd3073eebf 100644 --- "a/docs/\346\225\260\346\215\256\346\272\220\345\257\274\345\205\245\345\274\200\345\217\221\346\226\207\346\241\243.md" +++ "b/docs/\346\225\260\346\215\256\346\272\220\345\257\274\345\205\245\345\274\200\345\217\221\346\226\207\346\241\243.md" @@ -1,6 +1,6 @@ # 数据源导入开发文档 -WeKnora 的数据源导入模块支持从外部平台(飞书、企业微信、Notion、Confluence 等)自动导入和同步内容到知识库。用户可配置数据源连接,选择需要同步的资源,并通过手动触发或定时调度自动完成内容的增量/全量同步。 +WeKnora 的数据源导入模块支持从外部平台(飞书、钉钉、Notion、语雀、RSS 等)自动导入和同步内容到知识库。用户可配置数据源连接,选择需要同步的资源,并通过手动触发或定时调度自动完成内容的增量/全量同步。 数据源绑定到知识库,一个知识库可接入多个数据源。所有配置通过前端知识库设置页面管理,凭证使用 AES-256-GCM 加密存储在数据库中。 @@ -8,6 +8,7 @@ WeKnora 的数据源导入模块支持从外部平台(飞书、企业微信、 - [快速接入指南](#快速接入指南) - [飞书知识库接入](#飞书知识库接入) + - [钉钉文档接入](#钉钉文档接入) - [前端管理](#前端管理) - [架构总览](#架构总览) - [数据模型](#数据模型) @@ -128,6 +129,39 @@ Lark 是飞书的国际版。Wiki / docx / drive 接口与飞书一致,**共 若你此前用「飞书」连接器加 `base_url=https://open.larksuite.com` 的方式接入过 Lark,该配置 仍然有效(`base_url` 保留为显式覆盖项),但新建数据源请直接选「Lark」类型。 +### 钉钉文档接入 + +钉钉连接器通过官方服务端 API 同步知识库中的在线文档。当前支持选择整个知识库、 +文件夹子树或单篇在线文档,支持全量和基于节点 `modifiedTime` 的增量同步。 + +#### 第一步:创建企业内部应用 + +1. 登录 [钉钉开放平台](https://open-dev.dingtalk.com/) 并创建企业内部应用 +2. 获取 **Client ID(AppKey)** 与 **Client Secret(AppSecret)** +3. 创建并发布应用版本 + +#### 第二步:开通只读权限 + +按照钉钉官方 API 的权限要求开通: + +- 知识库读权限 +- 知识库节点读权限 +- 企业存储文件读权限 + +#### 第三步:准备操作人 UnionID + +知识库与文档接口要求传入 `operatorId`。该 UnionID 对应的用户必须能够读取目标知识库; +应用权限和操作人资源权限缺一不可。 + +#### 第四步:添加数据源 + +进入 **知识库设置 → 数据源 → 添加数据源 → 钉钉文档**,填写凭证并测试连接, +然后选择同步范围和同步策略。 + +> 当前仅同步节点类型为 `FILE`、类别为 `ALIDOC`、扩展名为 `adoc` 的钉钉在线文档。 +> 在线表格、多维表及普通上传文件不会被错误地当作文档块读取。只有目录遍历完整时才会 +> 生成删除标记;分支读取失败会保留旧游标,避免临时权限或网络问题造成误删除。 + --- ## 前端管理 diff --git a/frontend/src/i18n/datasourceConnectorLocale.test.ts b/frontend/src/i18n/datasourceConnectorLocale.test.ts new file mode 100644 index 0000000000..0b9c8e0158 --- /dev/null +++ b/frontend/src/i18n/datasourceConnectorLocale.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +import { LOCALE_BUNDLES, getLocaleValueAtPath, type LocaleName } from './localeKeyAudit.ts' + +const DIALOG_PATH = join( + dirname(fileURLToPath(import.meta.url)), + '../views/knowledge/settings/DataSourceEditorDialog.vue', +) + +// The connector picker resolves its label and description through computed keys +// (`datasource.connector.${def.type}`), which the static usage audit cannot +// see. A type declared in connectorDefs but absent from a locale therefore +// renders the raw key in the UI while every other check stays green, so assert +// the two bags directly against the connector list that drives the picker. +function declaredConnectorTypes(): string[] { + const source = readFileSync(DIALOG_PATH, 'utf8') + const start = source.indexOf('const connectorDefs') + assert.notEqual(start, -1, 'connectorDefs not found in DataSourceEditorDialog.vue') + const end = source.indexOf('const currentDef', start) + assert.notEqual(end, -1, 'end of connectorDefs not found') + const types = [...source.slice(start, end).matchAll(/^\s*type:\s*'([^']+)'/gm)].map((m) => m[1]) + assert.ok(types.length > 0, 'no connector types parsed from connectorDefs') + return types +} + +test('every connector type has a name and description in every locale', () => { + const types = declaredConnectorTypes() + const failures: string[] = [] + + for (const type of types) { + for (const bag of ['connector', 'connectorDesc'] as const) { + for (const [localeName, bundle] of Object.entries(LOCALE_BUNDLES) as Array< + [LocaleName, unknown] + >) { + const path = `datasource.${bag}.${type}` + const label = getLocaleValueAtPath(bundle, path) + if (typeof label !== 'string' || !label) failures.push(`${localeName}: missing ${path}`) + } + } + } + + assert.deepEqual(failures, [], failures.join('\n')) +}) diff --git a/frontend/src/i18n/embed.ts b/frontend/src/i18n/embed.ts index d644d74319..dd9c1b881f 100644 --- a/frontend/src/i18n/embed.ts +++ b/frontend/src/i18n/embed.ts @@ -106,6 +106,9 @@ const messages = { "unableToGetKnowledgeBaseId": "无法获取知识库ID", "summaryInProgress": "正在总结答案……", "thinkingAlt": "正在思考", + "preparingAnswer": "正在准备回答…", + "connectingModelAndGeneratingAnswer": "正在连接模型并生成回答…", + "modelStillResponding": "模型响应较慢,仍在等待…", "deepThoughtCompleted": "已深度思考", "deepThoughtAlt": "深度思考完成", "referencesTitle": "参考了{count}个相关内容", @@ -596,6 +599,9 @@ const messages = { "unableToGetKnowledgeBaseId": "Unable to get knowledge base ID", "summaryInProgress": "Summarizing answer…", "thinkingAlt": "Thinking in progress", + "preparingAnswer": "Preparing an answer…", + "connectingModelAndGeneratingAnswer": "Connecting to the model and generating an answer…", + "modelStillResponding": "The model is taking longer than usual, still waiting…", "deepThoughtCompleted": "Deep thinking completed", "deepThoughtAlt": "Deep thinking finished", "referencesTitle": "Referenced {count} related item(s)", @@ -1048,6 +1054,9 @@ const koEmbedPublish = { followUpQuestions: '이어서 질문', followUpQuestionsLoading: '추천 질문 로딩 중', thinkingAlt: '생각 중', + preparingAnswer: '답변을 준비하고 있습니다…', + connectingModelAndGeneratingAnswer: '모델에 연결하여 답변을 생성하고 있습니다…', + modelStillResponding: '모델 응답이 평소보다 오래 걸리고 있습니다. 계속 기다리는 중…', refreshSuggestedQuestions: '다른 질문', imageTooMany: '이미지는 최대 5장까지 업로드할 수 있습니다', imageTypeSizeError: 'JPG/PNG/GIF/WEBP만 지원하며, 각 파일은 10MB 이하여야 합니다', @@ -1136,6 +1145,9 @@ const ruEmbedPublish = { followUpQuestions: 'Спрашивайте дальше', followUpQuestionsLoading: 'Загрузка рекомендуемых вопросов', thinkingAlt: 'Обдумывание...', + preparingAnswer: 'Подготовка ответа…', + connectingModelAndGeneratingAnswer: 'Подключение к модели и создание ответа…', + modelStillResponding: 'Модель отвечает дольше обычного, продолжаем ждать…', refreshSuggestedQuestions: 'Ещё', imageTooMany: 'Можно загрузить не более 5 изображений', imageTypeSizeError: 'Поддерживаются только JPG/PNG/GIF/WEBP, каждый файл до 10 МБ', diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 2e26df7584..3756935cb6 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -2558,6 +2558,9 @@ export default { refreshSuggestedQuestions: 'More', thinking: 'Thinking...', thinkingAlt: 'Thinking in progress', + preparingAnswer: 'Preparing an answer…', + connectingModelAndGeneratingAnswer: 'Connecting to the model and generating an answer…', + modelStillResponding: 'The model is taking longer than usual, still waiting…', deepThoughtCompleted: 'Deep thinking completed', deepThoughtAlt: 'Deep thinking finished', referencesTitle: 'Referenced {count} related item(s)', @@ -5074,6 +5077,7 @@ export default { noResources: 'No wiki spaces found', noResourcesDesc: 'The app needs wiki access via a group chat to fetch content', noResourcesDesc_notion: 'The app needs Notion page access permissions to fetch content', + noResourcesDesc_dingtalk: 'Check the app wiki/file read permissions and ensure the operator can access the target knowledge base.', retryLoadResources: 'Retry', guideStep1: 'Create a group chat in Feishu, then add your app as a bot in the group settings', guideStep2: 'Open wiki "Settings" > "Member Settings" > "Add Member", search for the group chat and add it', @@ -5081,7 +5085,11 @@ export default { guideStep1_notion: 'Open the page or database you want to sync in Notion', guideStep2_notion: 'Click the "···" menu at the top right, select "Connect to" or "Add connections"', guideStep3_notion: 'Search and select your Integration app, then come back and click Retry', + guideStep1_dingtalk: 'Create an enterprise internal app in DingTalk Open Platform', + guideStep2_dingtalk: 'Grant knowledge-base, node, and enterprise-file read permissions, then publish an app version', + guideStep3_dingtalk: 'Ensure the operator can access the target knowledge base, then reload resources', permissionDocLink: 'View Feishu wiki permission docs', + permissionDocLink_dingtalk: 'View DingTalk knowledge-base API docs', syncScheduleLabel: 'Sync schedule', conflictLabel: 'Conflict strategy', conflict: { @@ -5133,18 +5141,24 @@ export default { lark: 'Lark', notion: 'Notion', yuque: 'Yuque', - rss: 'RSS / Atom Feed' + rss: 'RSS / Atom Feed', + dingtalk: 'DingTalk Docs' }, connectorDesc: { feishu: 'Sync documents, spreadsheets and files from Feishu Wiki', lark: 'Sync documents, spreadsheets and files from Lark Wiki (Feishu international)', notion: 'Sync pages and databases from Notion', yuque: 'Sync documents from Yuque knowledge bases', - rss: 'Sync articles from RSS / Atom feeds' + rss: 'Sync articles from RSS / Atom feeds', + dingtalk: 'Sync online documents from DingTalk knowledge bases' }, field: { appId: 'App ID', appSecret: 'App Secret', + clientId: 'Client ID (AppKey)', + clientSecret: 'Client Secret (AppSecret)', + operatorId: 'Operator UnionID', + operatorIdHint: 'Use the unionId of a user who can read the target knowledge base.', integrationToken: 'Integration Token', apiToken: 'API Token', baseUrl: 'Base URL (optional)', @@ -5166,10 +5180,18 @@ export default { prereqStep3Brief_yuque: '(Optional) Enter Base URL for enterprise deployments', prereqStep3Desc_yuque: 'Leave empty for public cloud; for Yuque Enterprise or self-hosted, enter your company domain.', prereqOpenConsole_yuque: 'Open Yuque Token settings', + prereqBarText_dingtalk: 'First time? Open the DingTalk app setup guide', + prereqStep1Brief_dingtalk: 'Create an enterprise internal app', + prereqStep1Desc_dingtalk: 'Create an app in DingTalk Open Platform and obtain its Client ID and Client Secret.', + prereqStep2Brief_dingtalk: 'Grant three read permissions', + prereqStep2Desc_dingtalk: 'Grant knowledge-base read, node read, and enterprise-file read permissions, then publish the app version.', + prereqStep3Brief_dingtalk: 'Prepare an operator UnionID', + prereqStep3Desc_dingtalk: 'Use the unionId of a user who can read the target knowledge base and documents.', prereqBotBrief: 'Add "Bot" capability to your app', prereqBotDesc: 'Open Platform > Add App Capability > Bot > create version and publish', prereqPermBrief: 'Grant API permissions', prereqOpenConsole: 'Open Feishu Developer Console', + prereqOpenConsole_dingtalk: 'Open DingTalk Developer Console', prereqMemberBrief: 'Add app to wiki via group chat', prereqMemberDesc: 'Create group chat > add app as bot > add group chat as wiki member', back: 'Back', @@ -5187,10 +5209,20 @@ export default { '12h': 'Every 12 hours', '24h': 'Daily' }, + syncError: { + dingtalk_auth_or_permission: 'DingTalk authentication or permission error; check credentials, app permissions, and operator access', + dingtalk_rate_limited: 'DingTalk API rate limited; will retry on the next sync', + dingtalk_timeout: 'DingTalk request timed out; will retry on the next sync', + dingtalk_unavailable: 'DingTalk is temporarily unavailable; will retry on the next sync', + dingtalk_api_error: 'DingTalk API error (code={code}); will retry on the next sync', + dingtalk_api_error_generic: 'DingTalk API error; will retry on the next sync' + }, resourceType: { wikiSpace: 'Wiki Space', docCategory: 'Document Tag', - book: 'Yuque Book' + book: 'Yuque Book', + folder: 'Folder', + document: 'Online Document' }, neverSynced: 'Never synced', justNow: 'Just now', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index bd77d2e226..a983cb7617 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -565,6 +565,7 @@ export default { noResources: '동기화 가능한 위키 공간을 찾을 수 없습니다', noResourcesDesc: '앱이 콘텐츠를 가져오려면 그룹 채팅을 통해 위키 접근 권한을 얻어야 합니다', noResourcesDesc_notion: '앱이 콘텐츠를 가져오려면 Notion 페이지 접근 권한이 필요합니다', + noResourcesDesc_dingtalk: "앱의 지식베이스/파일 읽기 권한과 작업자의 대상 지식베이스 접근 권한을 확인하세요.", retryLoadResources: '다시 시도', guideStep1: 'Feishu에서 그룹 채팅을 만들고 그룹 설정의 \'그룹 봇\'에 앱을 추가하세요', guideStep2: '위키 \'설정\' > \'멤버 설정\' > \'멤버 추가\'를 열고 해당 그룹 채팅을 검색하여 추가하세요', @@ -572,7 +573,11 @@ export default { guideStep1_notion: 'Notion에서 동기화하려는 페이지나 데이터베이스를 엽니다', guideStep2_notion: '오른쪽 상단의 \'···\' 메뉴를 클릭하고 \'Connect to\' 또는 \'Add connections\'를 선택합니다', guideStep3_notion: 'Integration 앱을 검색하여 선택한 후, 돌아와서 다시 시도를 클릭하세요', + guideStep1_dingtalk: "DingTalk Open Platform에서 기업 내부 앱을 만듭니다", + guideStep2_dingtalk: "지식베이스, 노드, 기업 파일 읽기 권한을 부여하고 앱 버전을 게시합니다", + guideStep3_dingtalk: "작업자가 대상 지식베이스에 접근할 수 있는지 확인한 후 다시 불러옵니다", permissionDocLink: '페이슈 위키 권한 설정 문서 보기', + permissionDocLink_dingtalk: "DingTalk 지식베이스 API 문서 보기", syncScheduleLabel: '동기화 주기', conflictLabel: '충돌 전략', syncDeletions: '삭제 동기화 (소스에서 삭제 시 지식베이스에서도 삭제)', @@ -596,10 +601,18 @@ export default { prereqStep3Brief_yuque: '(선택) Enterprise 사용 시 Base URL 입력', prereqStep3Desc_yuque: '퍼블릭 클라우드 사용자는 입력하지 않아도 됩니다. Yuque Enterprise 또는 사설 배포 시 기업 도메인을 입력하세요', prereqOpenConsole_yuque: 'Yuque Token 설정으로 이동', + prereqBarText_dingtalk: "처음 사용하시나요? DingTalk 앱 설정 가이드를 확인하세요", + prereqStep1Brief_dingtalk: "기업 내부 앱 만들기", + prereqStep1Desc_dingtalk: "DingTalk Open Platform에서 앱을 만들고 Client ID와 Client Secret을 확인합니다.", + prereqStep2Brief_dingtalk: "세 가지 읽기 권한 부여", + prereqStep2Desc_dingtalk: "지식베이스 읽기, 노드 읽기, 기업 파일 읽기 권한을 부여하고 앱 버전을 게시합니다.", + prereqStep3Brief_dingtalk: "작업자 UnionID 준비", + prereqStep3Desc_dingtalk: "대상 지식베이스와 문서를 읽을 수 있는 사용자의 unionId를 사용합니다.", prereqBotBrief: '앱에 \'봇\' 기능 추가', prereqBotDesc: '오픈 플랫폼 → 앱 기능 추가 → 봇 → 버전 생성 후 게시', prereqPermBrief: 'API 권한 활성화', prereqOpenConsole: 'Feishu 오픈 플랫폼 설정으로 이동', + prereqOpenConsole_dingtalk: "DingTalk 개발자 콘솔 열기", prereqMemberBrief: '그룹 채팅을 통해 지식베이스 멤버로 추가', prereqMemberDesc: '그룹 채팅 생성 → 앱을 그룹 봇으로 추가 → 그룹 채팅을 지식베이스 멤버로 추가', back: '이전', @@ -615,10 +628,20 @@ export default { minutesAgo: '{n}분 전', hoursAgo: '{n}시간 전', daysAgo: '{n}일 전', + syncError: { + dingtalk_auth_or_permission: "DingTalk 인증 또는 권한 오류입니다. 자격 증명, 앱 권한, 작업자 접근 권한을 확인하세요", + dingtalk_rate_limited: "DingTalk API 요청 제한, 다음 동기화 시 다시 시도합니다", + dingtalk_timeout: "DingTalk 요청 시간 초과, 다음 동기화 시 다시 시도합니다", + dingtalk_unavailable: "DingTalk 서비스를 일시적으로 사용할 수 없습니다. 다음 동기화 시 다시 시도합니다", + dingtalk_api_error: "DingTalk API 오류(code={code}), 다음 동기화 시 다시 시도합니다", + dingtalk_api_error_generic: "DingTalk API 오류, 다음 동기화 시 다시 시도합니다" + }, resourceType: { wikiSpace: '위키 공간', docCategory: '문서 태그', - book: 'Yuque 지식베이스' + book: 'Yuque 지식베이스', + folder: "폴더", + document: "온라인 문서" }, scheduleHuman: { '30min': '30분마다', @@ -630,6 +653,10 @@ export default { field: { appId: 'App ID', appSecret: 'App Secret', + clientId: "Client ID (AppKey)", + clientSecret: "Client Secret (AppSecret)", + operatorId: "작업자 UnionID", + operatorIdHint: "대상 지식베이스를 읽을 수 있는 사용자의 unionId를 입력하세요.", integrationToken: 'Integration Token', apiToken: 'API Token', baseUrl: 'Base URL', @@ -644,14 +671,16 @@ export default { lark: 'Lark 위키에서 문서, 스프레드시트, 파일 동기화', notion: 'Notion에서 페이지 및 데이터베이스 동기화', yuque: '위큐 지식베이스에서 문서 동기화', - rss: 'RSS / Atom 피드에서 글 동기화' + rss: 'RSS / Atom 피드에서 글 동기화', + dingtalk: "DingTalk 지식베이스의 온라인 문서 동기화" }, connector: { feishu: '페이슈 (Feishu)', lark: 'Lark (Feishu 글로벌)', notion: 'Notion', yuque: '위큐 (Yuque)', - rss: 'RSS / Atom 피드' + rss: 'RSS / Atom 피드', + dingtalk: "DingTalk 문서" }, logDetail: { startTime: '시작 시간', @@ -3046,6 +3075,9 @@ export default { refreshSuggestedQuestions: '다른 질문', thinking: '생각 중...', thinkingAlt: '생각 중', + preparingAnswer: '답변을 준비하고 있습니다…', + connectingModelAndGeneratingAnswer: '모델에 연결하여 답변을 생성하고 있습니다…', + modelStillResponding: '모델 응답이 평소보다 오래 걸리고 있습니다. 계속 기다리는 중…', deepThoughtCompleted: '심층 분석 완료', deepThoughtAlt: '심층 분석 완료', referencesTitle: '{count}개의 관련 내용 참조', diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index 7930731c49..e6c4e1a5bc 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -565,6 +565,7 @@ export default { noResources: 'Пространства вики не найдены', noResourcesDesc: 'Приложению требуется доступ к вики через групповой чат для получения контента', noResourcesDesc_notion: 'Приложению требуются права доступа к странице Notion для получения контента', + noResourcesDesc_dingtalk: 'Проверьте права приложения на чтение базы знаний и файлов, а также доступ оператора к целевой базе.', retryLoadResources: 'Повторить', guideStep1: 'Создайте групповой чат в Feishu, затем добавьте ваше приложение как бота в настройках группы', guideStep2: 'Откройте вики "Настройки" > "Управление участниками" > "Добавить участника", найдите групповой чат и добавьте его', @@ -572,7 +573,11 @@ export default { guideStep1_notion: 'Откройте страницу или базу данных, которую хотите синхронизировать в Notion', guideStep2_notion: 'Нажмите меню «···» в правом верхнем углу, выберите «Connect to» или «Add connections»', guideStep3_notion: 'Найдите и выберите ваше интеграционное приложение, затем вернитесь и нажмите Повторить', + guideStep1_dingtalk: 'Создайте внутреннее корпоративное приложение в DingTalk Open Platform', + guideStep2_dingtalk: 'Выдайте права чтения базы знаний, узлов и корпоративных файлов, затем опубликуйте версию приложения', + guideStep3_dingtalk: 'Убедитесь, что оператор имеет доступ к целевой базе знаний, и повторите загрузку', permissionDocLink: 'Документация по настройке прав доступа', + permissionDocLink_dingtalk: 'Документация API базы знаний DingTalk', syncScheduleLabel: 'Расписание синхронизации', conflictLabel: 'Стратегия конфликтов', syncDeletions: 'Синхронизировать удаления (удалять знания при удалении в источнике)', @@ -596,10 +601,18 @@ export default { prereqStep3Brief_yuque: '(Опционально) Для Enterprise укажите Base URL', prereqStep3Desc_yuque: 'Пользователям публичного облака указывать не нужно. Для Yuque Enterprise или приватного развёртывания укажите корпоративный домен', prereqOpenConsole_yuque: 'Перейти к настройкам Yuque Token', + prereqBarText_dingtalk: 'Первое подключение? Откройте руководство по настройке DingTalk', + prereqStep1Brief_dingtalk: 'Создайте внутреннее корпоративное приложение', + prereqStep1Desc_dingtalk: 'Создайте приложение в DingTalk Open Platform и получите Client ID и Client Secret.', + prereqStep2Brief_dingtalk: 'Выдайте три права на чтение', + prereqStep2Desc_dingtalk: 'Выдайте права чтения базы знаний, узлов и корпоративных файлов, затем опубликуйте версию приложения.', + prereqStep3Brief_dingtalk: 'Подготовьте UnionID оператора', + prereqStep3Desc_dingtalk: 'Используйте unionId пользователя с доступом на чтение целевой базы знаний и документов.', prereqBotBrief: 'Добавьте приложению возможность «Бот»', prereqBotDesc: 'Открытая платформа → Добавить возможность приложения → Бот → Создать версию и опубликовать', prereqPermBrief: 'Включите права API', prereqOpenConsole: 'Открыть настройки Feishu Open Platform', + prereqOpenConsole_dingtalk: 'Открыть консоль разработчика DingTalk', prereqMemberBrief: 'Добавьте через групповой чат как участника базы знаний', prereqMemberDesc: 'Создайте групповой чат → добавьте приложение как группового бота → добавьте групповой чат как участника базы знаний', back: 'Назад', @@ -615,10 +628,20 @@ export default { minutesAgo: '{n} мин назад', hoursAgo: '{n} ч назад', daysAgo: '{n} д назад', + syncError: { + dingtalk_auth_or_permission: 'Ошибка аутентификации или прав DingTalk; проверьте учётные данные, права приложения и доступ оператора', + dingtalk_rate_limited: 'Превышен лимит API DingTalk; повтор при следующей синхронизации', + dingtalk_timeout: 'Тайм-аут запроса DingTalk; повтор при следующей синхронизации', + dingtalk_unavailable: 'DingTalk временно недоступен; повтор при следующей синхронизации', + dingtalk_api_error: 'Ошибка API DingTalk (code={code}); повтор при следующей синхронизации', + dingtalk_api_error_generic: 'Ошибка API DingTalk; повтор при следующей синхронизации' + }, resourceType: { wikiSpace: 'Пространство вики', docCategory: 'Тег документа', - book: 'База знаний Yuque' + book: 'База знаний Yuque', + folder: 'Папка', + document: 'Онлайн-документ' }, scheduleHuman: { '30min': 'Каждые 30 мин', @@ -630,6 +653,10 @@ export default { field: { appId: 'App ID', appSecret: 'App Secret', + clientId: 'Client ID (AppKey)', + clientSecret: 'Client Secret (AppSecret)', + operatorId: 'UnionID оператора', + operatorIdHint: 'Укажите unionId пользователя с доступом на чтение целевой базы знаний.', integrationToken: 'Integration Token', apiToken: 'API Token', baseUrl: 'Base URL', @@ -644,14 +671,16 @@ export default { lark: 'Синхронизация документов, таблиц и файлов из Lark Wiki', notion: 'Синхронизация страниц и баз данных из Notion', yuque: 'Синхронизация документов из баз знаний Yuque', - rss: 'Синхронизация статей из лент RSS / Atom' + rss: 'Синхронизация статей из лент RSS / Atom', + dingtalk: 'Синхронизация онлайн-документов из баз знаний DingTalk' }, connector: { feishu: 'Feishu (Фэйшу)', lark: 'Lark', notion: 'Notion', yuque: 'Yuque (Юйцюэ)', - rss: 'RSS / Atom лента' + rss: 'RSS / Atom лента', + dingtalk: 'Документы DingTalk' }, logDetail: { startTime: 'Время начала', @@ -3046,6 +3075,9 @@ export default { refreshSuggestedQuestions: 'Ещё', thinking: 'Думаю...', thinkingAlt: 'Обдумывание...', + preparingAnswer: 'Подготовка ответа…', + connectingModelAndGeneratingAnswer: 'Подключение к модели и создание ответа…', + modelStillResponding: 'Модель отвечает дольше обычного, продолжаем ждать…', deepThoughtCompleted: 'Глубокий анализ завершён', deepThoughtAlt: 'Глубокий анализ', referencesTitle: 'Использовано {count} связанного материала', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index c80b842ff5..c67a6c8824 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -565,6 +565,7 @@ export default { noResources: '未找到可同步的知识库空间', noResourcesDesc: '应用需要通过群聊获得知识库访问权限才能拉取内容', noResourcesDesc_notion: '应用需要获得 Notion 页面的访问权限才能拉取内容', + noResourcesDesc_dingtalk: "请确认应用已开通知识库和文件读取权限,且操作人可以访问目标知识库。", retryLoadResources: '重新加载', guideStep1: '在飞书中创建一个群聊,在群设置「群机器人」中添加你的应用', guideStep2: '打开知识库「设置」→「成员设置」→ 添加成员,搜索该群聊名称并添加', @@ -572,7 +573,11 @@ export default { guideStep1_notion: '在 Notion 中打开你想要同步的页面或数据库', guideStep2_notion: '点击右上角的「···」菜单,选择「Connect to」或「Add connections」', guideStep3_notion: '搜索并选择你的集成应用(Integration),然后回到这里点重新加载', + guideStep1_dingtalk: "在钉钉开放平台创建企业内部应用", + guideStep2_dingtalk: "开通知识库读、知识库节点读和企业存储文件读权限,并发布应用版本", + guideStep3_dingtalk: "确认操作人可以访问目标知识库,然后回到这里重新加载", permissionDocLink: '查看飞书知识库权限配置文档', + permissionDocLink_dingtalk: "查看钉钉知识库 API 文档", syncScheduleLabel: '同步频率', conflictLabel: '冲突策略', syncDeletions: '同步删除(源端删除时同步删除知识库中的条目)', @@ -596,10 +601,18 @@ export default { prereqStep3Brief_yuque: '(可选)企业版填写 Base URL', prereqStep3Desc_yuque: '公有云用户无需填写;语雀企业版或私有部署请填写企业域名', prereqOpenConsole_yuque: '前往语雀 Token 设置', + prereqBarText_dingtalk: "首次使用?点击查看钉钉应用配置指引", + prereqStep1Brief_dingtalk: "创建企业内部应用", + prereqStep1Desc_dingtalk: "在钉钉开放平台创建应用,并获取 Client ID 和 Client Secret。", + prereqStep2Brief_dingtalk: "开通三个只读权限", + prereqStep2Desc_dingtalk: "开通知识库读、知识库节点读、企业存储文件读权限,并发布应用版本。", + prereqStep3Brief_dingtalk: "准备操作人 UnionID", + prereqStep3Desc_dingtalk: "使用一个对目标知识库和文档具有读取权限的用户 unionId。", prereqBotBrief: '为应用添加「机器人」能力', prereqBotDesc: '开放平台 → 添加应用能力 → 机器人 → 创建版本并发布', prereqPermBrief: '开通 API 权限', prereqOpenConsole: '前往飞书开放平台配置', + prereqOpenConsole_dingtalk: "前往钉钉开放平台配置", prereqMemberBrief: '通过群聊添加为知识库成员', prereqMemberDesc: '创建群聊 → 添加应用为群机器人 → 将群聊添加为知识库成员', back: '上一步', @@ -615,10 +628,20 @@ export default { minutesAgo: '{n} 分钟前', hoursAgo: '{n} 小时前', daysAgo: '{n} 天前', + syncError: { + dingtalk_auth_or_permission: "钉钉鉴权或权限不足,请检查凭证、应用权限及操作人访问权限", + dingtalk_rate_limited: "钉钉接口限流,下次同步时将重试", + dingtalk_timeout: "钉钉接口请求超时,下次同步时将重试", + dingtalk_unavailable: "钉钉服务暂时不可用,下次同步时将重试", + dingtalk_api_error: "钉钉接口错误(code={code}),下次同步时将重试", + dingtalk_api_error_generic: "钉钉接口错误,下次同步时将重试" + }, resourceType: { wikiSpace: '知识库空间', docCategory: '文档标签', - book: '语雀知识库' + book: '语雀知识库', + folder: "文件夹", + document: "在线文档" }, scheduleHuman: { '30min': '每 30 分钟', @@ -630,6 +653,10 @@ export default { field: { appId: 'App ID', appSecret: 'App Secret', + clientId: "Client ID(AppKey)", + clientSecret: "Client Secret(AppSecret)", + operatorId: "操作人 UnionID", + operatorIdHint: "填写一个对目标知识库具有读取权限的用户 unionId。", integrationToken: 'Integration Token', apiToken: 'API Token', baseUrl: 'Base URL(可选)', @@ -644,14 +671,16 @@ export default { lark: '同步 Lark 知识库中的文档、表格、文件(飞书国际版)', notion: '同步 Notion 中的页面和数据库', yuque: '同步语雀知识库中的文档', - rss: '同步 RSS / Atom 订阅源中的文章' + rss: '同步 RSS / Atom 订阅源中的文章', + dingtalk: "同步钉钉知识库中的在线文档" }, connector: { feishu: '飞书', lark: 'Lark(飞书国际版)', notion: 'Notion', yuque: '语雀', - rss: 'RSS / Atom 订阅' + rss: 'RSS / Atom 订阅', + dingtalk: "钉钉文档" }, logDetail: { startTime: '开始时间', @@ -3046,6 +3075,9 @@ export default { refreshSuggestedQuestions: '换一批', thinking: '思考中...', thinkingAlt: '正在思考', + preparingAnswer: '正在准备回答…', + connectingModelAndGeneratingAnswer: '正在连接模型并生成回答…', + modelStillResponding: '模型响应较慢,仍在等待…', deepThoughtCompleted: '已深度思考', deepThoughtAlt: '深度思考完成', referencesTitle: '参考了{count}个相关内容', diff --git a/frontend/src/utils/rag-pipeline-history.ts b/frontend/src/utils/rag-pipeline-history.ts index 70f841594d..b8bd2a1860 100644 --- a/frontend/src/utils/rag-pipeline-history.ts +++ b/frontend/src/utils/rag-pipeline-history.ts @@ -1,5 +1,8 @@ export const RAG_PIPELINE_TOOL_NAMES = new Set(['query_understand', 'knowledge_search']) +/** Retrieval tools that can produce citations. `search_knowledge` is the legacy alias. */ +export const RAG_RETRIEVAL_TOOL_NAMES = new Set(['knowledge_search', 'search_knowledge']) + /** Tools rendered on the quick-answer timeline (includes pre-RAG attachment prep). */ export const RAG_TIMELINE_TOOL_NAMES = new Set([ ...RAG_PIPELINE_TOOL_NAMES, diff --git a/frontend/src/utils/rag-pipeline-state.test.ts b/frontend/src/utils/rag-pipeline-state.test.ts new file mode 100644 index 0000000000..6705c09cea --- /dev/null +++ b/frontend/src/utils/rag-pipeline-state.test.ts @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + RAG_WAIT_REVEAL_DELAY_MS, + RAG_WAIT_STALL_DELAY_MS, + createRagWaitController, + getRagPipelineWaitKind, + type RagWaitScheduler, + type RagWaitView, +} from './rag-pipeline-state.ts' + +const completedRetrieval = { + isCompleted: false, + hasAnswer: false, + hasThinkingEvent: false, + stepCount: 2, + allStepsDone: true, + hasCompletedRetrievalStep: true, +} + +test('waits on the model once retrieval finished', () => { + assert.equal(getRagPipelineWaitKind(completedRetrieval), 'model') +}) + +test('waits while a pipeline step is still pending', () => { + assert.equal(getRagPipelineWaitKind({ + ...completedRetrieval, + allStepsDone: false, + }), 'none') +}) + +test('waits for nothing before pipeline events arrive', () => { + assert.equal(getRagPipelineWaitKind({ + ...completedRetrieval, + stepCount: 0, + }), 'none') +}) + +test('falls back to the neutral preparing state when no retrieval step ran', () => { + assert.equal(getRagPipelineWaitKind({ + ...completedRetrieval, + hasCompletedRetrievalStep: false, + }), 'preparing') +}) + +test('stops waiting when thinking, answer, or completion arrives', () => { + assert.equal(getRagPipelineWaitKind({ ...completedRetrieval, hasThinkingEvent: true }), 'none') + assert.equal(getRagPipelineWaitKind({ ...completedRetrieval, hasAnswer: true }), 'none') + assert.equal(getRagPipelineWaitKind({ ...completedRetrieval, isCompleted: true }), 'none') +}) + +function createFakeScheduler() { + let now = 0 + let nextHandle = 1 + const jobs = new Map void }>() + + const scheduler: RagWaitScheduler = { + setTimeout: (callback, ms) => { + const handle = nextHandle++ + jobs.set(handle, { runAt: now + ms, callback }) + return handle + }, + clearTimeout: (handle) => { + jobs.delete(handle as number) + }, + } + + const advance = (ms: number) => { + now += ms + for (const [handle, job] of [...jobs].sort((a, b) => a[1].runAt - b[1].runAt)) { + if (job.runAt > now) continue + jobs.delete(handle) + job.callback() + } + } + + return { scheduler, advance, pendingCount: () => jobs.size } +} + +function createHarness() { + const views: RagWaitView[] = [] + const { scheduler, advance, pendingCount } = createFakeScheduler() + const controller = createRagWaitController((view) => views.push(view), scheduler) + return { controller, views, advance, pendingCount } +} + +test('keeps the wait row hidden while the model answers quickly', () => { + const { controller, views, advance } = createHarness() + + controller.update('model') + advance(RAG_WAIT_REVEAL_DELAY_MS - 1) + controller.update('none') + advance(RAG_WAIT_STALL_DELAY_MS) + + assert.deepEqual(views, []) +}) + +test('reveals the wait row once the model stays quiet past the delay', () => { + const { controller, views, advance } = createHarness() + + controller.update('model') + advance(RAG_WAIT_REVEAL_DELAY_MS) + + assert.deepEqual(views, [{ kind: 'model', stalled: false }]) + + controller.update('none') + assert.deepEqual(views.at(-1), { kind: 'none', stalled: false }) +}) + +test('marks the wait row stalled when no answer ever arrives', () => { + const { controller, views, advance } = createHarness() + + controller.update('model') + advance(RAG_WAIT_REVEAL_DELAY_MS) + advance(RAG_WAIT_STALL_DELAY_MS - 1) + + assert.deepEqual(views, [{ kind: 'model', stalled: false }]) + + advance(1) + + assert.deepEqual(views.at(-1), { kind: 'model', stalled: true }) +}) + +test('swaps the label in place instead of re-running the reveal delay', () => { + const { controller, views, advance } = createHarness() + + controller.update('preparing') + advance(RAG_WAIT_REVEAL_DELAY_MS) + controller.update('model') + + assert.deepEqual(views, [ + { kind: 'preparing', stalled: false }, + { kind: 'model', stalled: false }, + ]) +}) + +test('gives each phase a fresh stall budget', () => { + const { controller, views, advance } = createHarness() + + controller.update('preparing') + advance(RAG_WAIT_REVEAL_DELAY_MS) + advance(RAG_WAIT_STALL_DELAY_MS - 1) + controller.update('model') + advance(RAG_WAIT_STALL_DELAY_MS - 1) + + assert.deepEqual(views.at(-1), { kind: 'model', stalled: false }) + + advance(1) + + assert.deepEqual(views.at(-1), { kind: 'model', stalled: true }) +}) + +test('drops pending timers on dispose', () => { + const { controller, views, advance, pendingCount } = createHarness() + + controller.update('model') + controller.dispose() + + assert.equal(pendingCount(), 0) + + advance(RAG_WAIT_STALL_DELAY_MS) + assert.deepEqual(views, []) +}) diff --git a/frontend/src/utils/rag-pipeline-state.ts b/frontend/src/utils/rag-pipeline-state.ts new file mode 100644 index 0000000000..3a530aef0b --- /dev/null +++ b/frontend/src/utils/rag-pipeline-state.ts @@ -0,0 +1,121 @@ +/** How long the wait row stays hidden so a fast model answer never flashes it. */ +export const RAG_WAIT_REVEAL_DELAY_MS = 250 + +/** + * How long the wait row keeps claiming progress. A dropped SSE connection never + * sets `is_completed` (the stream layer only raises a toast), so without this cap + * the row would promise an answer forever. + */ +export const RAG_WAIT_STALL_DELAY_MS = 60_000 + +export type RagWaitKind = 'none' | 'preparing' | 'model' + +export interface RagPipelineWaitInput { + isCompleted: boolean + hasAnswer: boolean + hasThinkingEvent: boolean + stepCount: number + allStepsDone: boolean + hasCompletedRetrievalStep: boolean +} + +/** + * Describe the quiet gap after every visible RAG pipeline step has finished and + * before the model emits thinking or answer text. + * + * `model` is only claimed once retrieval actually finished; turns that never run + * a retrieval step (attachment-only Q&A) still get the neutral `preparing` row + * rather than no feedback at all. + */ +export function getRagPipelineWaitKind(state: RagPipelineWaitInput): RagWaitKind { + if (state.isCompleted || state.hasAnswer || state.hasThinkingEvent) return 'none' + if (state.stepCount === 0 || !state.allStepsDone) return 'none' + return state.hasCompletedRetrievalStep ? 'model' : 'preparing' +} + +export interface RagWaitView { + kind: RagWaitKind + stalled: boolean +} + +export interface RagWaitScheduler { + setTimeout: (callback: () => void, ms: number) => unknown + clearTimeout: (handle: unknown) => void +} + +export interface RagWaitController { + update: (kind: RagWaitKind) => void + dispose: () => void +} + +const defaultScheduler: RagWaitScheduler = { + setTimeout: (callback, ms) => setTimeout(callback, ms), + clearTimeout: (handle) => clearTimeout(handle as ReturnType), +} + +/** + * Turn the raw wait kind into what the timeline renders: delayed reveal, in-place + * label swaps once visible, and a stalled state when the answer never arrives. + */ +export function createRagWaitController( + onChange: (view: RagWaitView) => void, + scheduler: RagWaitScheduler = defaultScheduler, +): RagWaitController { + let target: RagWaitKind = 'none' + let view: RagWaitView = { kind: 'none', stalled: false } + let revealHandle: unknown + let stallHandle: unknown + + const cancelTimers = () => { + if (revealHandle !== undefined) { + scheduler.clearTimeout(revealHandle) + revealHandle = undefined + } + if (stallHandle !== undefined) { + scheduler.clearTimeout(stallHandle) + stallHandle = undefined + } + } + + const emit = (next: RagWaitView) => { + if (next.kind === view.kind && next.stalled === view.stalled) return + view = next + onChange(view) + } + + const armStall = () => { + stallHandle = scheduler.setTimeout(() => { + stallHandle = undefined + emit({ kind: target, stalled: true }) + }, RAG_WAIT_STALL_DELAY_MS) + } + + return { + update(kind) { + if (kind === target) return + target = kind + cancelTimers() + + if (kind === 'none') { + emit({ kind: 'none', stalled: false }) + return + } + + if (view.kind !== 'none') { + emit({ kind, stalled: false }) + armStall() + return + } + + revealHandle = scheduler.setTimeout(() => { + revealHandle = undefined + emit({ kind: target, stalled: false }) + armStall() + }, RAG_WAIT_REVEAL_DELAY_MS) + }, + dispose() { + cancelTimers() + target = 'none' + }, + } +} diff --git a/frontend/src/views/chat/components/RagPipelineProgress.style.test.mjs b/frontend/src/views/chat/components/RagPipelineProgress.style.test.mjs index 75594e2d2a..b29b837eea 100644 --- a/frontend/src/views/chat/components/RagPipelineProgress.style.test.mjs +++ b/frontend/src/views/chat/components/RagPipelineProgress.style.test.mjs @@ -50,7 +50,7 @@ test('rag pipeline opens references from search steps and the drawer composable' test('rag pipeline uses a native pending step and lets the thinking title shimmer while pending', () => { assert.match(source, /showPrePipelineWait/) assert.match(source, /class="action-card action-pending"/) - assert.match(source, /t\('chat\.thinkingAlt'\)/) + assert.match(source, /t\('chat\.preparingAnswer'\)/) assert.match(source, /showThinkingStep/) assert.match(source, /'action-pending': thinkingPending/) assert.match(source, /hasThinkingEvent/) @@ -58,6 +58,25 @@ test('rag pipeline uses a native pending step and lets the thinking title shimme assert.doesNotMatch(source, /showActivityIndicator/) }) +test('rag pipeline shows a pending model-answer step after retrieval completes', () => { + assert.match(source, /showWaitStep/) + assert.match(source, /getRagPipelineWaitKind/) + assert.match(source, /createRagWaitController/) + assert.match(source, /t\('chat\.connectingModelAndGeneratingAnswer'\)/) + assert.match(source, /t\('chat\.modelStillResponding'\)/) + assert.match(source, /rag-model-wait-step/) + assert.match(source, /'action-pending': !waitStepStalled/) + assert.match(source, /waitController\.dispose\(\)/) +}) + +test('rag pipeline announces wait status from a region that outlives each row', () => { + const template = source.split(' { assert.match(source, /const showDoneRow = computed\(\(\) => \{[\s\S]*hasAnswer\.value/) }) diff --git a/frontend/src/views/chat/components/RagPipelineProgress.vue b/frontend/src/views/chat/components/RagPipelineProgress.vue index e642459a28..7e75ef5242 100644 --- a/frontend/src/views/chat/components/RagPipelineProgress.vue +++ b/frontend/src/views/chat/components/RagPipelineProgress.vue @@ -1,5 +1,9 @@