diff --git a/config/config.yaml b/config/config.yaml index c6b69f3c1d..b5c62b4601 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -3,6 +3,22 @@ server: port: 8080 host: "0.0.0.0" +# Reusable ingestion artifacts. Environment overrides use +# ARTIFACT_CACHE_READ_ENABLED and ARTIFACT_CACHE_WRITE_ENABLED. +artifact_cache: + read_enabled: true + write_enabled: true + stages: + parse: true + embedding: true + summary: true + question: true + vlm_ocr: true + vlm_caption: true + wiki_map: true + graph_extract.entities: true + graph_extract.relationships: true + # Conversation service configuration # NOTE: Prompt content is resolved from prompt_templates/ YAML files via xxx_id fields. # Set the _id to the template ID you want; the system will load its content at startup. diff --git a/docs/dev/artifact-dag-reconciliation.md b/docs/dev/artifact-dag-reconciliation.md new file mode 100644 index 0000000000..d4a9b6ae52 --- /dev/null +++ b/docs/dev/artifact-dag-reconciliation.md @@ -0,0 +1,176 @@ +# Versioned Artifact DAG and Knowledge Reconciliation + +This document describes the implementation added for +[issue #1679](https://github.com/Tencent/WeKnora/issues/1679). The goal is to +reuse expensive deterministic processing outputs without allowing cached data +to own mutable knowledge, chunk, or attempt state. + +## Invariants + +- Artifact identity is tenant scoped and immutable. +- An artifact key contains the exact direct-input digests, processor identity, + rendered request, effective options, canonicalizer version, and output schema + version. +- Downstream keys depend on upstream `output_digest` values, not only on the + upstream input key. +- Stored payloads contain reusable provider output only. Tenant, knowledge, + chunk, and attempt ownership is rebound when the desired state is built. +- Cache failures are fail-open. Provider failures and invalid provider output + remain visible to the caller. +- Publication is add/update first and stale deletion last. A stale attempt must + not publish final knowledge state. + +## Data flow + +```text +source bytes + -> DocReader artifact + -> normalized source chunks + -> chat/VLM/wiki artifacts + -> stable desired chunk IDs + -> embedding and graph artifacts + -> desired-state diff + -> add/update/index + -> attempt fence + -> conditional knowledge publication + storage accounting + -> delete stale vector, graph, and chunk state +``` + +`internal/artifact` owns canonical keys, codecs, payload validation, immutable +freeze semantics, cache lookup, corruption eviction, singleflight, Redis +leases, stable UUIDv5 identities, desired-state diffs, and attempt fences. +Model and service adapters keep provider-specific request and response details +outside the reconciliation layer. + +## Persistence + +`processing_artifacts` is keyed by: + +```text +(tenant_id, stage, key_version, artifact_key) +``` + +The row records processor and output digests, schema, codec, checksum, size, and +hit metadata. `object_ref` is reserved for a future object-store implementation; +the current implementation stores bounded inline payloads and bypasses +DocReader caching above 16 MiB. + +`knowledge_attempt_counters` allocates monotonically increasing attempts +independently of span history. This prevents attempt reuse after spans are +cleaned up. + +Migration locations: + +- PostgreSQL: `migrations/versioned/000079_processing_artifacts.{up,down}.sql` +- SQLite: `migrations/sqlite/000002_processing_artifacts.{up,down}.sql` +- MySQL bootstrap: `migrations/mysql/00-init-db.sql` +- ParadeDB bootstrap: `migrations/paradedb/00-init-db.sql` + +## Concurrency and publication + +The runtime suppresses duplicate work in three layers: + +1. in-process `singleflight` for an artifact key; +2. a Redis lease for workers in different processes; +3. database `put-if-absent` uniqueness as the final correctness boundary. + +Batch embedding selects a deterministic missing artifact as the batch leader. +The leader lease covers the provider batch, while the remaining results are +frozen together. This keeps one provider call for concurrent identical batches +without changing caller output order. + +Knowledge processing allocates an attempt before work begins. Final publication +and destructive stale cleanup recheck that attempt. A per-knowledge mutation +lock spans chunk/vector/graph binding through exact stale cleanup (local gate in +Lite mode, ownership-token Redis lease in standard mode), preventing a newer +generation from being deleted between a fence check and an external-store +delete. Knowledge publication and tenant storage accounting share one database +transaction; retries calculate the delta from the already-published row and +therefore cannot double-charge. Graph storage uses per-chunk contributions so +stale chunks can be removed exactly without deleting unchanged contributions. + +## Compatibility and rollback + +The schema addition is non-destructive to existing knowledge and chunk tables. +Artifact misses rebuild data through the existing providers, so an empty +artifact table is valid. + +`artifact_cache` supports independent read/write controls and exact stage +overrides. Missing configuration defaults to read/write enabled. + +| Mode | `read_enabled` | `write_enabled` | +| --- | --- | --- | +| Disabled | `false` | `false` | +| Shadow write | `false` | `true` | +| Read fallback | `true` | `true` | +| Read only | `true` | `false` | + +Environment overrides use `ARTIFACT_CACHE_READ_ENABLED` and +`ARTIFACT_CACHE_WRITE_ENABLED`. Individual stages can be disabled in +`artifact_cache.stages`; omitted stages remain enabled. + +To roll back: + +1. disable artifact reads, then writes, and restart or drain workers; +2. deploy the previous application version; +3. verify no process reads or writes artifact/attempt tables; +4. optionally run migration `000079` down. + +Dropping the new tables discards only reusable artifacts and attempt counters; +it does not delete knowledge, chunks, vectors, or graph data. Do not run the +down migration while the new worker version is active. + +## Observability and log safety + +The runtime observer emits structured fields for stage, outcome, reason, key +version, output schema, provider calls, singleflight wait time, and embedding +batch totals/hits/misses/deduplication. Outcomes use `hit`, `miss`, `computed`, +`wait`, `bypass`, `corrupt`, and `error_fallback`. + +The artifact observer never receives request or payload bytes. New DAG logs and +span fields omit complete artifact keys, bodies, prompts, file/image references, +and provider error details; they retain IDs, booleans, lengths, counts, and +concrete error classes. + +## Validation + +The implementation has focused tests for: + +- canonical keys, tenant isolation, credential exclusion, and schema versions; +- checksum validation, corrupt-row eviction, fail-open storage, and immutable + first-writer-wins behavior; +- local and Redis-backed concurrent provider suppression; +- exact input bytes, duplicate input ordering, partial batch hits, and invalid + provider response rejection; +- stable chunk/generated IDs, desired-state diffs, and stale-attempt fencing; +- all eight crash boundaries, with final DB/vector/Wiki/Graph snapshots equal + to a clean run after retry; +- local and Redis per-knowledge mutation-lock ownership and wait behavior; +- atomic, idempotent knowledge publication plus tenant storage accounting; +- SQLite migration up/down and uniqueness behavior; +- duplicate migration-version rejection for PostgreSQL and SQLite directories; +- DocReader, chat, embedding, VLM, wiki, multimodal, and graph stage adapters. + +Run the focused suite with: + +```bash +go test -count=1 \ + ./internal/artifact \ + ./internal/application/repository \ + ./internal/application/service \ + ./internal/database \ + ./internal/models/chat \ + ./internal/models/embedding \ + ./internal/models/vlm + +go test -race -count=1 \ + ./internal/artifact \ + ./internal/application/repository \ + ./internal/application/service \ + ./internal/models/embedding +``` + +Live PostgreSQL/MySQL/Redis/Neo4j/vector integration should also be exercised in +the deployment environment before a broad rollout. URL-based DocReader inputs +currently bypass artifacts because their remote content cannot be proven stable +from the URL alone. 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..05f5796e8e 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)', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index bd77d2e226..e7e6414ca1 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -3046,6 +3046,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..533caa264e 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -3046,6 +3046,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..aab24a9d5f 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -3046,6 +3046,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 @@