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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/src/i18n/embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ const messages = {
"thinkingAlt": "正在思考",
"preparingAnswer": "正在准备回答…",
"connectingModelAndGeneratingAnswer": "正在连接模型并生成回答…",
"modelStillResponding": "模型响应较慢,仍在等待…",
"deepThoughtCompleted": "已深度思考",
"deepThoughtAlt": "深度思考完成",
"referencesTitle": "参考了{count}个相关内容",
Expand Down Expand Up @@ -600,6 +601,7 @@ const messages = {
"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)",
Expand Down Expand Up @@ -1054,6 +1056,7 @@ const koEmbedPublish = {
thinkingAlt: '생각 중',
preparingAnswer: '답변을 준비하고 있습니다…',
connectingModelAndGeneratingAnswer: '모델에 연결하여 답변을 생성하고 있습니다…',
modelStillResponding: '모델 응답이 평소보다 오래 걸리고 있습니다. 계속 기다리는 중…',
refreshSuggestedQuestions: '다른 질문',
imageTooMany: '이미지는 최대 5장까지 업로드할 수 있습니다',
imageTypeSizeError: 'JPG/PNG/GIF/WEBP만 지원하며, 각 파일은 10MB 이하여야 합니다',
Expand Down Expand Up @@ -1144,6 +1147,7 @@ const ruEmbedPublish = {
thinkingAlt: 'Обдумывание...',
preparingAnswer: 'Подготовка ответа…',
connectingModelAndGeneratingAnswer: 'Подключение к модели и создание ответа…',
modelStillResponding: 'Модель отвечает дольше обычного, продолжаем ждать…',
refreshSuggestedQuestions: 'Ещё',
imageTooMany: 'Можно загрузить не более 5 изображений',
imageTypeSizeError: 'Поддерживаются только JPG/PNG/GIF/WEBP, каждый файл до 10 МБ',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2560,6 +2560,7 @@ export default {
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)',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/ko-KR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3048,6 +3048,7 @@ export default {
thinkingAlt: '생각 중',
preparingAnswer: '답변을 준비하고 있습니다…',
connectingModelAndGeneratingAnswer: '모델에 연결하여 답변을 생성하고 있습니다…',
modelStillResponding: '모델 응답이 평소보다 오래 걸리고 있습니다. 계속 기다리는 중…',
deepThoughtCompleted: '심층 분석 완료',
deepThoughtAlt: '심층 분석 완료',
referencesTitle: '{count}개의 관련 내용 참조',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/ru-RU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3048,6 +3048,7 @@ export default {
thinkingAlt: 'Обдумывание...',
preparingAnswer: 'Подготовка ответа…',
connectingModelAndGeneratingAnswer: 'Подключение к модели и создание ответа…',
modelStillResponding: 'Модель отвечает дольше обычного, продолжаем ждать…',
deepThoughtCompleted: 'Глубокий анализ завершён',
deepThoughtAlt: 'Глубокий анализ',
referencesTitle: 'Использовано {count} связанного материала',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3048,6 +3048,7 @@ export default {
thinkingAlt: '正在思考',
preparingAnswer: '正在准备回答…',
connectingModelAndGeneratingAnswer: '正在连接模型并生成回答…',
modelStillResponding: '模型响应较慢,仍在等待…',
deepThoughtCompleted: '已深度思考',
deepThoughtAlt: '深度思考完成',
referencesTitle: '参考了{count}个相关内容',
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/utils/rag-pipeline-history.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
161 changes: 136 additions & 25 deletions frontend/src/utils/rag-pipeline-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { shouldShowRagModelAnswerWait } from './rag-pipeline-state.ts'
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,
Expand All @@ -11,42 +18,146 @@ const completedRetrieval = {
hasCompletedRetrievalStep: true,
}

test('shows the model-answer wait state after the RAG pipeline finishes', () => {
assert.equal(shouldShowRagModelAnswerWait(completedRetrieval), true)
test('waits on the model once retrieval finished', () => {
assert.equal(getRagPipelineWaitKind(completedRetrieval), 'model')
})

test('does not show the model-answer wait state while a pipeline step is pending', () => {
assert.equal(shouldShowRagModelAnswerWait({
test('waits while a pipeline step is still pending', () => {
assert.equal(getRagPipelineWaitKind({
...completedRetrieval,
allStepsDone: false,
}), false)
}), 'none')
})

test('does not show the model-answer wait state before pipeline events arrive', () => {
assert.equal(shouldShowRagModelAnswerWait({
test('waits for nothing before pipeline events arrive', () => {
assert.equal(getRagPipelineWaitKind({
...completedRetrieval,
stepCount: 0,
}), false)
}), 'none')
})

test('does not mistake a completed pre-retrieval step for the model-answer wait', () => {
assert.equal(shouldShowRagModelAnswerWait({
test('falls back to the neutral preparing state when no retrieval step ran', () => {
assert.equal(getRagPipelineWaitKind({
...completedRetrieval,
hasCompletedRetrievalStep: false,
}), false)
}), 'preparing')
})

test('hides the model-answer wait state when thinking, answer, or completion arrives', () => {
assert.equal(shouldShowRagModelAnswerWait({
...completedRetrieval,
hasThinkingEvent: true,
}), false)
assert.equal(shouldShowRagModelAnswerWait({
...completedRetrieval,
hasAnswer: true,
}), false)
assert.equal(shouldShowRagModelAnswerWait({
...completedRetrieval,
isCompleted: true,
}), false)
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<number, { runAt: number; callback: () => 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, [])
})
122 changes: 110 additions & 12 deletions frontend/src/utils/rag-pipeline-state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
export interface RagModelAnswerWaitState {
/** 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
Expand All @@ -8,16 +20,102 @@ export interface RagModelAnswerWaitState {
}

/**
* Show a model-answer wait state only in the quiet gap after every visible RAG
* pipeline step has finished and before the model emits thinking or answer text.
* 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<typeof setTimeout>),
}

/**
* 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 shouldShowRagModelAnswerWait(state: RagModelAnswerWaitState): boolean {
return (
!state.isCompleted &&
!state.hasAnswer &&
!state.hasThinkingEvent &&
state.stepCount > 0 &&
state.allStepsDone &&
state.hasCompletedRetrievalStep
)
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'
},
}
}
Loading