From 7d39a5f9c1d1ca3c0ad020855aeb189a7a0a70ad Mon Sep 17 00:00:00 2001 From: Ross and Zober Date: Thu, 28 May 2026 18:58:59 +0700 Subject: [PATCH 1/2] refactor: migrate chat message rendering to new ChainOfThought UI with assistant trace logic --- .../src/features/agent/ui/AgentRenderers.tsx | 39 ++- .../agent/ui/chat/ChatMessageItem.tsx | 172 ++++++------ .../features/agent/ui/chat/assistant-trace.ts | 160 +++++++++++ .../ui/ai-elements/chain-of-thought.tsx | 259 ++++++++++++++++++ frontend/src/shared/ui/ai-elements/tool.tsx | 4 +- .../tests/CompanionChatArea.stop.test.tsx | 37 +++ frontend/tests/assistant-trace.test.ts | 84 ++++++ frontend/tests/chat-message-trace.test.tsx | 82 ++++++ 8 files changed, 742 insertions(+), 95 deletions(-) create mode 100644 frontend/src/features/agent/ui/chat/assistant-trace.ts create mode 100644 frontend/src/shared/ui/ai-elements/chain-of-thought.tsx create mode 100644 frontend/tests/assistant-trace.test.ts create mode 100644 frontend/tests/chat-message-trace.test.tsx diff --git a/frontend/src/features/agent/ui/AgentRenderers.tsx b/frontend/src/features/agent/ui/AgentRenderers.tsx index d9d36a83..677d6b47 100644 --- a/frontend/src/features/agent/ui/AgentRenderers.tsx +++ b/frontend/src/features/agent/ui/AgentRenderers.tsx @@ -43,9 +43,10 @@ export function ToolCallCard({ toolName={toolName} title={title} state={effectiveState} + className="rounded-xl bg-input/60 px-3 py-2" /> {hasContent && ( - + {input != null && } {(output != null || (isError && errorMessage)) && ( + {input != null && } + {(output != null || (isError && errorMessage)) && ( + + )} + + ) +} + // -------------------------------------------------------------------------- // // VocabCardRenderer — compact inline vocab card // -------------------------------------------------------------------------- // diff --git a/frontend/src/features/agent/ui/chat/ChatMessageItem.tsx b/frontend/src/features/agent/ui/chat/ChatMessageItem.tsx index 294416cf..01c7252a 100644 --- a/frontend/src/features/agent/ui/chat/ChatMessageItem.tsx +++ b/frontend/src/features/agent/ui/chat/ChatMessageItem.tsx @@ -2,14 +2,13 @@ import type { UIMessage } from '@ai-sdk/react' import type { MessageAction } from './MessageActions' import type { ExerciseRenderResult } from '@/features/study/ui/ExerciseRenderer' -import { Brain, ChevronDown, FileText } from 'lucide-react' +import { FileText } from 'lucide-react' import { motion } from 'motion/react' -import { memo, useCallback, useDeferredValue, useMemo, useState } from 'react' +import { memo, useEffect, useMemo, useState } from 'react' import { useI18n } from '@/app/providers/I18nContext' import { getToolName, isToolPart, - isWidePart, } from '@/features/agent/lib/companion-utils' import { EXERCISE_TOOLS, @@ -18,12 +17,18 @@ import { import { ProgressChartRenderer, ToolCallCard, + ToolTraceCard, VocabCardRenderer, } from '@/features/agent/ui/AgentRenderers' import { ExerciseRenderer } from '@/features/study/ui/ExerciseRenderer' import { isSessionCompletePayload } from '@/shared/lib/study-utils' -import { cn } from '@/shared/lib/utils' -import { TextShimmer } from '@/shared/ui/text-shimmer' +import { + ChainOfThought, + ChainOfThoughtContent, + ChainOfThoughtHeader, + ChainOfThoughtStep, +} from '@/shared/ui/ai-elements/chain-of-thought' +import { buildAssistantTrace } from './assistant-trace' import { MessageActions } from './MessageActions' import { MessageMarkdown } from './MessageMarkdown' import { SessionResultsCard } from './SessionResultsCard' @@ -52,51 +57,6 @@ export function StreamingDots() { ) } -// Extracted so memo can skip re-renders when only the parent streaming/open state changes. -const ThinkingExpandedContent = memo(({ text }: { text: string }) => ( -
- {text} -
-)) - -// Extracted so memo skips header re-renders on every reasoning delta (streaming=true doesn't change per chunk). -const ThinkingHeader = memo(({ streaming, open, onToggle }: { streaming: boolean, open: boolean, onToggle: () => void }) => { - const { t } = useI18n() - return ( - - ) -}) - -function ThinkingBlock({ text, streaming }: { text: string, streaming: boolean }) { - const [open, setOpen] = useState(false) - // Defer layout-heavy text renders so rapid reasoning deltas don't block the UI. - const deferredText = useDeferredValue(text) - const handleToggle = useCallback(() => setOpen(o => !o), []) - return ( -
- - {open && } -
- ) -} - /** * Render all parts of a message using AI SDK v5 part types. * activeWideIds: set of toolCallIds that should render as full widgets. @@ -198,18 +158,8 @@ export function renderMessageParts( return } - if (part.type === 'reasoning') { - const rp = part as { type: 'reasoning', text: string, state?: 'streaming' | 'done' } - if (!rp.text) - return null - return ( - - ) - } + if (part.type === 'reasoning') + return null if (part.type === 'text') { const partKey = `text-${i}` @@ -262,6 +212,11 @@ interface MessageItemProps { export const MessageItem = memo( ({ msg, sendMessage, activeWideIds, isLast, isStreaming, onTimestampClick, actions, onRegenerate }: MessageItemProps) => { + const { t } = useI18n() + const [traceOpen, setTraceOpen] = useState(isStreaming) + useEffect(() => { + setTraceOpen(isStreaming) + }, [isStreaming]) const assistantText = useMemo( () => msg.role === 'assistant' @@ -269,6 +224,10 @@ export const MessageItem = memo( : '', [msg.role, msg.parts], ) + const trace = useMemo( + () => (msg.role === 'assistant' ? buildAssistantTrace(msg, isStreaming) : { steps: [], hasTextAnswer: false }), + [isStreaming, msg], + ) if (msg.role !== 'assistant') { // Parse context chips from user messages for visual rendering @@ -310,30 +269,51 @@ export const MessageItem = memo( ) } - // Split assistant parts: text + tool indicator cards in bubble; wide parts - // (exercises, charts, vocab cards) render full-width below. - const parts = msg.parts - // Tool parts before text parts so indicators show above the response text - const bubbleParts = parts.filter(p => !isWidePart(p)).toSorted((a, b) => { - const aIsTool = isToolPart(a) ? 0 : 1 - const bIsTool = isToolPart(b) ? 0 : 1 - return aIsTool - bIsTool - }) - const fullWidthParts = parts.filter(isWidePart) + const showActions = !isStreaming && actions && actions.length > 0 && assistantText.trim().length > 0 - const bubbleContent = renderMessageParts({ ...msg, parts: bubbleParts } as UIMessage, sendMessage, activeWideIds, onTimestampClick) - const fullWidthContent = renderMessageParts({ ...msg, parts: fullWidthParts } as UIMessage, sendMessage, activeWideIds, onTimestampClick) - const hasBubble = bubbleParts.some((p) => { - if (p.type === 'text' && 'text' in p) - return (p.text as string)?.trim() - if (p.type === 'reasoning' && 'text' in p) - return (p.text as string)?.trim() - if (isToolPart(p)) - return true - return false - }) + function renderTraceStep(step: ReturnType['steps'][number], index: number) { + if (step.kind === 'reasoning') { + return ( + + ) + } - const showActions = !isStreaming && actions && actions.length > 0 && assistantText.trim().length > 0 + const isWide = activeWideIds.has(step.toolCallId) || activeWideIds.has(step.toolName) + const toolKey = `tool.${step.toolName}` + const translatedTool = t(toolKey as Parameters[0]) + const toolLabel = translatedTool === toolKey ? step.toolName : translatedTool + + return ( + + + + ) + } return ( - {hasBubble && ( + {(trace.steps.length > 0 || assistantText.trim().length > 0) && (
-
- {bubbleContent} +
+ {trace.steps.length > 0 && ( + + + + {trace.steps.map((step, index) => renderTraceStep(step, index))} + + + )} + {assistantText.trim().length > 0 && ( + + )}
{showActions && ( @@ -360,11 +353,6 @@ export const MessageItem = memo( )}
)} - {fullWidthParts.length > 0 && ( -
- {fullWidthContent} -
- )} ) }, diff --git a/frontend/src/features/agent/ui/chat/assistant-trace.ts b/frontend/src/features/agent/ui/chat/assistant-trace.ts new file mode 100644 index 00000000..63c628bd --- /dev/null +++ b/frontend/src/features/agent/ui/chat/assistant-trace.ts @@ -0,0 +1,160 @@ +import type { UIMessage } from '@ai-sdk/react' + +type MessagePart + = NonNullable[number] + | { type: 'step-start' } + +interface ToolMessagePart { + type: string + toolName?: string + toolCallId?: string + state: string + input?: unknown + output?: unknown + errorText?: string +} + +export type AssistantTraceStep + = | { + kind: 'reasoning' + text: string + status: 'active' | 'complete' | 'pending' + } + | { + kind: 'tool' + toolName: string + toolCallId: string + status: 'pending' | 'active' | 'complete' | 'error' + input?: unknown + output?: unknown + errorText?: string + } + +export interface AssistantTrace { + steps: AssistantTraceStep[] + hasTextAnswer: boolean +} + +function isReasoningPart( + part: MessagePart, +): part is Extract[number], { type: 'reasoning' }> { + return part.type === 'reasoning' +} + +function isTextPart( + part: MessagePart, +): part is Extract[number], { type: 'text' }> { + return part.type === 'text' +} + +function isToolPart(part: MessagePart): part is MessagePart & ToolMessagePart { + return typeof part.type === 'string' && part.type.startsWith('tool-') +} + +function mapToolStatus(state: string): AssistantTraceStep['status'] { + if (state === 'input-streaming') + return 'pending' + if (state === 'input-available') + return 'active' + if (state === 'output-error') + return 'error' + return 'complete' +} + +function isErrorOutput(output: unknown): boolean { + if (typeof output === 'object' && output != null && 'error' in output) { + const err = (output as Record).error + return typeof err === 'string' && err.length > 0 + } + return false +} + +export function buildAssistantTrace(message: UIMessage, isStreaming: boolean): AssistantTrace { + const steps: AssistantTraceStep[] = [] + const toolIndexById = new Map() + let reasoningBuffer: string[] = [] + let reasoningStreaming = false + + const flushReasoning = () => { + if (reasoningBuffer.length === 0) + return + steps.push({ + kind: 'reasoning', + text: reasoningBuffer.join('\n\n'), + status: reasoningStreaming ? 'active' : 'complete', + }) + reasoningBuffer = [] + reasoningStreaming = false + } + + for (const part of message.parts ?? []) { + if (part.type === 'step-start') { + flushReasoning() + continue + } + + if (isReasoningPart(part)) { + if (part.text?.trim()) { + reasoningBuffer.push(part.text) + if (part.state === 'streaming' || (isStreaming && message.role === 'assistant')) + reasoningStreaming = true + } + continue + } + + if (isTextPart(part)) { + flushReasoning() + continue + } + + if (isToolPart(part)) { + flushReasoning() + + const toolName = part.toolName || part.type.replace('tool-', '') + const toolCallId = part.toolCallId || `${toolName}-${steps.length}` + const mappedStatus = mapToolStatus(part.state) + const finalStatus + = mappedStatus === 'complete' && isErrorOutput(part.output) ? 'error' : mappedStatus + const nextStep: AssistantTraceStep = { + kind: 'tool', + toolName, + toolCallId, + status: finalStatus, + input: part.input, + output: part.output, + errorText: part.errorText, + } + + const existingIndex = toolIndexById.get(toolCallId) + if (existingIndex == null) { + toolIndexById.set(toolCallId, steps.length) + steps.push(nextStep) + } + else { + const prev = steps[existingIndex] + if (prev.kind === 'tool') { + steps[existingIndex] = { + ...prev, + ...nextStep, + input: nextStep.input ?? prev.input, + output: nextStep.output ?? prev.output, + errorText: nextStep.errorText ?? prev.errorText, + status: + nextStep.status === 'error' + ? 'error' + : nextStep.status === 'complete' && prev.status === 'error' + ? 'error' + : nextStep.status, + } + } + } + } + } + + flushReasoning() + + return { + steps, + hasTextAnswer: message.parts?.some(part => isTextPart(part) && part.text.trim().length > 0) ?? false, + } +} diff --git a/frontend/src/shared/ui/ai-elements/chain-of-thought.tsx b/frontend/src/shared/ui/ai-elements/chain-of-thought.tsx new file mode 100644 index 00000000..06c19a3a --- /dev/null +++ b/frontend/src/shared/ui/ai-elements/chain-of-thought.tsx @@ -0,0 +1,259 @@ +import type { LucideIcon } from 'lucide-react' +import type { ComponentProps, ReactNode } from 'react' +import { Brain, ChevronDown, Circle } from 'lucide-react' +import { useEffect, useState } from 'react' +import { cn } from '@/shared/lib/utils' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/shared/ui/collapsible' +import { TextShimmer } from '@/shared/ui/text-shimmer' + +// -------------------------------------------------------------------------- // +// ChainOfThoughtItem +// -------------------------------------------------------------------------- // + +export type ChainOfThoughtItemProps = ComponentProps<'div'> + +export function ChainOfThoughtItem({ + children, + className, + ...props +}: ChainOfThoughtItemProps) { + return ( +
+ {children} +
+ ) +} + +// -------------------------------------------------------------------------- // +// ChainOfThought — outer collapsible panel +// -------------------------------------------------------------------------- // + +export type ChainOfThoughtProps = ComponentProps + +export function ChainOfThought({ className, ...props }: ChainOfThoughtProps) { + return ( + + ) +} + +// -------------------------------------------------------------------------- // +// ChainOfThoughtHeader — collapsible trigger for the outer panel +// -------------------------------------------------------------------------- // + +export type ChainOfThoughtHeaderProps = ComponentProps & { + isStreaming?: boolean + label?: string +} + +export function ChainOfThoughtHeader({ + className, + children, + isStreaming = false, + label, + ...props +}: ChainOfThoughtHeaderProps) { + return ( + +
+ + {isStreaming + ? ( + + ) + : ( + <> + + + + )} + + {label + ? ( + isStreaming + ? ( + + {label} + + ) + : ( + + {label} + + ) + ) + : ( + children + )} +
+
+ ) +} + +// -------------------------------------------------------------------------- // +// ChainOfThoughtContent — the list of steps inside the panel +// -------------------------------------------------------------------------- // + +export type ChainOfThoughtContentProps = ComponentProps + +export function ChainOfThoughtContent({ + className, + children, + ...props +}: ChainOfThoughtContentProps) { + return ( + +
+ {children} +
+
+ ) +} + +// -------------------------------------------------------------------------- // +// ChainOfThoughtStep — individual step, itself collapsible when it has content +// -------------------------------------------------------------------------- // + +export interface ChainOfThoughtStepProps { + icon?: LucideIcon + label: string + description?: string + status: 'complete' | 'active' | 'pending' | 'error' + children?: ReactNode + className?: string + isLast?: boolean +} + +function StatusDot({ status }: { status: ChainOfThoughtStepProps['status'] }) { + return ( + + ) +} + +export function ChainOfThoughtStep({ + icon: Icon, + label, + description, + status, + children, + className, + isLast = false, +}: ChainOfThoughtStepProps) { + const hasContent = !!(description || children) + + const [open, setOpen] = useState(status === 'active' || status === 'error') + + useEffect(() => { + if (status === 'active' || status === 'error') { + setOpen(true) + } + else if (status === 'complete') { + setOpen(false) + } + }, [status]) + + const labelClass = cn( + 'min-w-0 flex-1 text-xs leading-tight', + status === 'complete' && 'text-muted-foreground', + status === 'active' && 'text-foreground font-medium', + status === 'pending' && 'text-muted-foreground/60', + status === 'error' && 'text-destructive', + ) + + const iconEl = Icon + ? ( + + ) + : ( + + ) + + // No content — render a plain non-interactive row + if (!hasContent) { + return ( +
+
+ + {iconEl} + + {label} +
+
+
+
+
+ ) + } + + // Has content — render as collapsible step + return ( + + +
+ + {iconEl} + + {label} +
+ +
+ +
+
+
+ {description && ( +
+ {description} +
+ )} + {children} +
+
+ +
+
+
+ + ) +} diff --git a/frontend/src/shared/ui/ai-elements/tool.tsx b/frontend/src/shared/ui/ai-elements/tool.tsx index 1cb23218..645daa5b 100644 --- a/frontend/src/shared/ui/ai-elements/tool.tsx +++ b/frontend/src/shared/ui/ai-elements/tool.tsx @@ -163,13 +163,13 @@ export function ToolOutput({
- {errorText &&
{errorText}
} + {errorText &&
{errorText}
} {Output}
diff --git a/frontend/tests/CompanionChatArea.stop.test.tsx b/frontend/tests/CompanionChatArea.stop.test.tsx index bb77e6f1..50f1f012 100644 --- a/frontend/tests/CompanionChatArea.stop.test.tsx +++ b/frontend/tests/CompanionChatArea.stop.test.tsx @@ -1,3 +1,4 @@ +import type { UIMessage } from '@ai-sdk/react' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeAll, describe, expect, it, vi } from 'vitest' @@ -42,4 +43,40 @@ describe('companionChatArea — streaming stop', () => { expect(onStop).toHaveBeenCalledOnce() expect(onSend).not.toHaveBeenCalled() }) + + it('renders the shared unified trace path with the final assistant answer still after the trace', () => { + const assistantWithTrace = { + id: 'a-1', + role: 'assistant', + content: '', + parts: [ + { type: 'reasoning', text: 'I should inspect the input.', state: 'done' }, + { + type: 'tool-render_vocab_card', + toolName: 'render_vocab_card', + toolCallId: 'call-1', + state: 'output-available', + output: { entry: { id: '1', word: 'test', meaning: 'test' } }, + }, + { type: 'text', text: 'Done.' }, + ], + } as UIMessage + + render( + , + ) + + expect(screen.getAllByRole('button', { name: /thinking|reasoning|trace|chain of thought/i })).toHaveLength(1) + expect(screen.getByText(/I should inspect the input\./i)).toBeTruthy() + expect(screen.getByText(/vocab|render.*card/i)).toBeTruthy() + expect(screen.getByText(/Done\./i)).toBeTruthy() + }) }) diff --git a/frontend/tests/assistant-trace.test.ts b/frontend/tests/assistant-trace.test.ts new file mode 100644 index 00000000..e0e4bbc7 --- /dev/null +++ b/frontend/tests/assistant-trace.test.ts @@ -0,0 +1,84 @@ +import type { UIMessage } from '@ai-sdk/react' +import { describe, expect, it } from 'vitest' +import { buildAssistantTrace } from '@/features/agent/ui/chat/assistant-trace' + +function makeAssistantMessage(parts: UIMessage['parts']): UIMessage { + return { + id: 'msg-1', + role: 'assistant', + content: '', + parts, + } as UIMessage +} + +describe('buildAssistantTrace', () => { + it('groups consecutive reasoning parts into one active reasoning step while streaming', () => { + const msg = makeAssistantMessage([ + { type: 'reasoning', text: 'First thought.', state: 'streaming' }, + { type: 'reasoning', text: 'Second thought.', state: 'done' }, + { type: 'text', text: 'Final answer.' }, + ] as UIMessage['parts']) + + const trace = buildAssistantTrace(msg, true) + + expect(trace.steps).toHaveLength(1) + expect(trace.steps[0]).toMatchObject({ + kind: 'reasoning', + text: 'First thought.\n\nSecond thought.', + status: 'active', + }) + expect(trace.hasTextAnswer).toBe(true) + }) + + it('merges a tool call in place as later parts arrive', () => { + const pendingMsg = makeAssistantMessage([ + { type: 'reasoning', text: 'I should search.', state: 'done' }, + { + type: 'tool-search_profiles', + toolName: 'search_profiles', + toolCallId: 'call-1', + state: 'input-streaming', + input: { query: 'Hayden Bleasel' }, + }, + ] as UIMessage['parts']) + + const pendingTrace = buildAssistantTrace(pendingMsg, true) + expect(pendingTrace.steps).toHaveLength(2) + expect(pendingTrace.steps[1]).toMatchObject({ + kind: 'tool', + toolName: 'search_profiles', + status: 'pending', + }) + + const completeMsg = makeAssistantMessage([ + { type: 'reasoning', text: 'I should search.', state: 'done' }, + { + type: 'tool-search_profiles', + toolName: 'search_profiles', + toolCallId: 'call-1', + state: 'input-available', + input: { query: 'Hayden Bleasel' }, + }, + { + type: 'tool-search_profiles', + toolName: 'search_profiles', + toolCallId: 'call-1', + state: 'output-available', + input: { query: 'Hayden Bleasel' }, + output: { results: ['x.com', 'github.com'] }, + }, + { type: 'text', text: 'Here is the answer.' }, + ] as UIMessage['parts']) + + const trace = buildAssistantTrace(completeMsg, false) + + expect(trace.steps.map(step => step.kind)).toEqual(['reasoning', 'tool']) + expect(trace.steps[1]).toMatchObject({ + kind: 'tool', + toolName: 'search_profiles', + status: 'complete', + output: { results: ['x.com', 'github.com'] }, + }) + expect(trace.hasTextAnswer).toBe(true) + }) +}) diff --git a/frontend/tests/chat-message-trace.test.tsx b/frontend/tests/chat-message-trace.test.tsx new file mode 100644 index 00000000..0ae39f9d --- /dev/null +++ b/frontend/tests/chat-message-trace.test.tsx @@ -0,0 +1,82 @@ +import type { UIMessage } from '@ai-sdk/react' +import { render, screen } from '@testing-library/react' +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { MessageItem } from '@/features/agent/ui/chat/ChatMessageItem' + +vi.mock('@/app/providers/I18nContext', async () => { + const { getTranslation } = await import('@/shared/lib/i18n') + return { + useI18n: () => ({ locale: 'en', setLocale: vi.fn(), t: getTranslation('en') }), + } +}) + +beforeAll(() => { + class MockIntersectionObserver { + observe() {} + unobserve() {} + disconnect() {} + } + globalThis.IntersectionObserver = MockIntersectionObserver as any +}) + +function makeAssistantMessage(parts: UIMessage['parts']): UIMessage { + return { + id: 'msg-1', + role: 'assistant', + content: '', + parts, + } as UIMessage +} + +describe('messageItem unified trace', () => { + it('renders one open trace with grouped reasoning, ordered tool steps, and the final answer after the trace while streaming', () => { + const msg = makeAssistantMessage([ + { type: 'reasoning', text: 'First thought.', state: 'streaming' }, + { type: 'reasoning', text: 'Second thought.', state: 'done' }, + { + type: 'tool-search_profiles', + toolName: 'search_profiles', + toolCallId: 'call-1', + state: 'input-available', + input: { query: 'Hayden Bleasel' }, + }, + { + type: 'tool-search_profiles', + toolName: 'search_profiles', + toolCallId: 'call-1', + state: 'output-available', + input: { query: 'Hayden Bleasel' }, + output: { results: ['x.com', 'github.com'] }, + }, + { type: 'text', text: 'Found it.' }, + ] as UIMessage['parts']) + + const { container } = render( + , + ) + + const traceTriggers = screen.getAllByRole('button', { name: /thinking|reasoning|trace|chain of thought/i }) + expect(traceTriggers).toHaveLength(1) + expect(traceTriggers[0]).toHaveAttribute('aria-expanded', 'true') + + expect(screen.getByText(/First thought\./i)).toBeTruthy() + expect(screen.getByText(/Second thought\./i)).toBeTruthy() + expect(screen.getAllByText(/search_profiles/i).length).toBeGreaterThan(0) + expect(screen.getByText(/Found it\./i)).toBeTruthy() + + const text = container.textContent ?? '' + const reasoningIndex = text.indexOf('First thought.') + const toolIndex = text.indexOf('search_profiles') + const answerIndex = text.indexOf('Found it.') + + expect(reasoningIndex).toBeGreaterThanOrEqual(0) + expect(toolIndex).toBeGreaterThan(reasoningIndex) + expect(answerIndex).toBeGreaterThan(toolIndex) + }) +}) From 74fba29b195d42b544431a2c4653310c01684cac Mon Sep 17 00:00:00 2001 From: Ross and Zober Date: Thu, 28 May 2026 22:00:39 +0700 Subject: [PATCH 2/2] feat: implement granular document retrieval tool and add thread-aware prompt caching for agent interactions --- backend/app/agent/router.py | 4 + backend/app/document_search/router.py | 28 +- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 356 +++++++++--------- .../agent/application/useZoberChat.ts | 1 + .../src/features/agent/lib/prompt/sections.ts | 28 +- .../agent/lib/tools/data/getPageContent.ts | 38 ++ .../agent/lib/tools/data/searchDocument.ts | 27 +- .../src/features/agent/lib/tools/index.ts | 4 + frontend/src/shared/lib/i18n.ts | 2 + page-index/api/models/schemas.py | 9 +- page-index/api/routers/documents.py | 29 +- page-index/api/services/search_service.py | 45 +-- 13 files changed, 334 insertions(+), 239 deletions(-) create mode 100644 frontend/src/features/agent/lib/tools/data/getPageContent.ts diff --git a/backend/app/agent/router.py b/backend/app/agent/router.py index 55b986b1..f6d9f087 100644 --- a/backend/app/agent/router.py +++ b/backend/app/agent/router.py @@ -55,6 +55,7 @@ class AgentRequest(BaseModel): model: str | None = None trigger: str | None = None stitch_message_id: str | None = None + thread_id: str | None = None # --------------------------------------------------------------------------- # @@ -548,6 +549,9 @@ async def agent_chat(request: AgentRequest) -> StreamingResponse: # hit rate (cached_tokens) — see _stream_agent logging. "stream_options": {"include_usage": True}, "extra_body": { + # Prompt cache key for OpenRouter sticky routing + prefix caching. + # Same thread_id across turns = same cache shard. + "prompt_cache_key": request.thread_id or "default", # OpenRouter-native usage accounting — includes cached_tokens / cost. "usage": {"include": True}, "reasoning": {"budget_tokens": 256}, diff --git a/backend/app/document_search/router.py b/backend/app/document_search/router.py index 459145ab..61f5c2c6 100644 --- a/backend/app/document_search/router.py +++ b/backend/app/document_search/router.py @@ -9,6 +9,8 @@ import httpx from fastapi import APIRouter, HTTPException +from typing import Any + from pydantic import BaseModel from app.settings import settings @@ -23,15 +25,16 @@ class DocumentSearchRequest(BaseModel): query: str -class Passage(BaseModel): +class SearchDocumentResult(BaseModel): doc_id: str doc_name: str - title: str - content: str + doc_description: str | None = None + page_count: int = 0 + structure: list[Any] class DocumentSearchResponse(BaseModel): - passages: list[Passage] + documents: list[SearchDocumentResult] routed_doc_ids: list[str] @@ -51,3 +54,20 @@ async def document_search(req: DocumentSearchRequest) -> DocumentSearchResponse: raise HTTPException(status_code=502, detail=f"Document search upstream error: {e}") return DocumentSearchResponse(**resp.json()) + + +@router.get("/documents/{doc_id}/pages") +async def get_document_pages(doc_id: str, pages: str): + """Proxy: get page content from PageIndex.""" + key = _resolve_key(None, settings.pageindex_api_key, "PageIndex API key") + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{settings.pageindex_base_url}/doc/{doc_id}/pages", + params={"pages": pages}, + headers={"X-API-Key": key}, + ) + resp.raise_for_status() + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail=f"Page content upstream error: {e}") + return resp.json() diff --git a/frontend/package.json b/frontend/package.json index 60b5cbc3..497e3366 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -96,7 +96,7 @@ "jsdom": "^28.1.0", "tailwindcss": "^4.2.1", "tsx": "^4.21.0", - "typescript": "~5.9.3", + "typescript": "~6.0.3", "typescript-eslint": "^8.56.1", "unzipper": "^0.12.3", "vite": "^8.0.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 729f7373..0f2a2e42 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -124,7 +124,7 @@ importers: version: 4.0.1 shadcn: specifier: ^4.0.6 - version: 4.1.1(@types/node@24.12.0)(typescript@5.9.3) + version: 4.1.1(@types/node@24.12.0)(typescript@6.0.3) shiki: specifier: ^4.0.2 version: 4.0.2 @@ -149,10 +149,10 @@ importers: devDependencies: '@antfu/eslint-config': specifier: ^7.7.2 - version: 7.7.3(@eslint-react/eslint-plugin@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/rule-tester@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3))(@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.31)(eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)))(eslint-plugin-react-refresh@0.5.2(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 7.7.3(@eslint-react/eslint-plugin@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(@typescript-eslint/rule-tester@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.57.2(typescript@6.0.3))(@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(@vue/compiler-sfc@3.5.31)(eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)))(eslint-plugin-react-refresh@0.5.2(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@24.12.0)(typescript@6.0.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) '@eslint-react/eslint-plugin': specifier: ^2.13.0 - version: 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@eslint/js': specifier: ^9.39.4 version: 9.39.4 @@ -203,7 +203,7 @@ importers: version: 4.4.4(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-boundaries: specifier: ^6.0.2 - version: 6.0.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + version: 6.0.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@4.4.4(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: specifier: ^7.0.1 version: 7.0.1(eslint@9.39.4(jiti@2.6.1)) @@ -226,11 +226,11 @@ importers: specifier: ^4.21.0 version: 4.21.0 typescript: - specifier: ~5.9.3 - version: 5.9.3 + specifier: ~6.0.3 + version: 6.0.3 typescript-eslint: specifier: ^8.56.1 - version: 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) unzipper: specifier: ^0.12.3 version: 0.12.3 @@ -239,7 +239,7 @@ importers: version: 8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: ^4.1.0 - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@24.12.0)(typescript@6.0.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages: @@ -5435,8 +5435,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true @@ -5862,7 +5862,7 @@ snapshots: transitivePeerDependencies: - zod - '@antfu/eslint-config@7.7.3(@eslint-react/eslint-plugin@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/rule-tester@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3))(@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.31)(eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)))(eslint-plugin-react-refresh@0.5.2(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))': + '@antfu/eslint-config@7.7.3(@eslint-react/eslint-plugin@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(@typescript-eslint/rule-tester@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.57.2(typescript@6.0.3))(@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(@vue/compiler-sfc@3.5.31)(eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)))(eslint-plugin-react-refresh@0.5.2(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@24.12.0)(typescript@6.0.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 1.1.0 @@ -5870,9 +5870,9 @@ snapshots: '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@9.39.4(jiti@2.6.1)) '@eslint/markdown': 7.5.1 '@stylistic/eslint-plugin': 5.10.0(eslint@9.39.4(jiti@2.6.1)) - '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@vitest/eslint-plugin': 1.6.13(@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) + '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@vitest/eslint-plugin': 1.6.13(@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@24.12.0)(typescript@6.0.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) ansis: 4.2.0 cac: 7.0.0 eslint: 9.39.4(jiti@2.6.1) @@ -5880,19 +5880,19 @@ snapshots: eslint-flat-config-utils: 3.0.2 eslint-merge-processors: 2.0.0(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-antfu: 3.2.2(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-command: 3.5.2(@typescript-eslint/rule-tester@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3))(@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-command: 3.5.2(@typescript-eslint/rule-tester@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.57.2(typescript@6.0.3))(@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-import-lite: 0.5.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsdoc: 62.8.1(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsonc: 3.1.2(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-n: 17.24.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-n: 17.24.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint-plugin-no-only-tests: 3.3.0 - eslint-plugin-perfectionist: 5.7.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-perfectionist: 5.7.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint-plugin-pnpm: 1.6.0(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-regexp: 3.1.0(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-toml: 1.3.1(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-unicorn: 63.0.0(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4(jiti@2.6.1)))(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) + eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4(jiti@2.6.1)))(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) eslint-plugin-yml: 3.3.1(eslint@9.39.4(jiti@2.6.1)) eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.31)(eslint@9.39.4(jiti@2.6.1)) globals: 17.4.0 @@ -5902,7 +5902,7 @@ snapshots: vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1)) yaml-eslint-parser: 2.0.0 optionalDependencies: - '@eslint-react/eslint-plugin': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/eslint-plugin': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-refresh: 0.5.2(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: @@ -6151,10 +6151,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@boundaries/elements@2.0.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))': + '@boundaries/elements@2.0.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@4.4.4(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))': dependencies: eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) handlebars: 4.7.9 is-core-module: 2.16.1 micromatch: 4.0.8 @@ -6376,77 +6376,77 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} - '@eslint-react/ast@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@eslint-react/ast@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-react/eff': 2.13.0 '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.2(typescript@6.0.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint: 9.39.4(jiti@2.6.1) string-ts: 2.3.1 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint-react/core@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@eslint-react/core@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.57.2 '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint: 9.39.4(jiti@2.6.1) ts-pattern: 5.9.0 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color '@eslint-react/eff@2.13.0': {} - '@eslint-react/eslint-plugin@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@eslint-react/eslint-plugin@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint: 9.39.4(jiti@2.6.1) - eslint-plugin-react-dom: 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-react-hooks-extra: 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-react-naming-convention: 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-react-rsc: 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-react-web-api: 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-react-x: 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + eslint-plugin-react-dom: 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + eslint-plugin-react-hooks-extra: 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + eslint-plugin-react-naming-convention: 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + eslint-plugin-react-rsc: 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + eslint-plugin-react-web-api: 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + eslint-plugin-react-x: 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint-react/shared@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@eslint-react/shared@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-react/eff': 2.13.0 - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint: 9.39.4(jiti@2.6.1) ts-pattern: 5.9.0 - typescript: 5.9.3 + typescript: 6.0.3 zod: 4.3.6 transitivePeerDependencies: - supports-color - '@eslint-react/var@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@eslint-react/var@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.57.2 '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint: 9.39.4(jiti@2.6.1) ts-pattern: 5.9.0 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -7665,48 +7665,48 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.57.2 eslint: 9.39.4(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.57.2 '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.2(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.57.2 debug: 4.4.3 eslint: 9.39.4(jiti@2.6.1) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.57.2(typescript@5.9.3)': + '@typescript-eslint/project-service@8.57.2(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@6.0.3) '@typescript-eslint/types': 8.57.2 debug: 4.4.3 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/rule-tester@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/rule-tester@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.57.2(typescript@6.0.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) ajv: 6.14.0 eslint: 9.39.4(jiti@2.6.1) json-stable-stringify-without-jsonify: 1.0.1 @@ -7721,47 +7721,47 @@ snapshots: '@typescript-eslint/types': 8.57.2 '@typescript-eslint/visitor-keys': 8.57.2 - '@typescript-eslint/tsconfig-utils@8.57.2(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.57.2(typescript@6.0.3)': dependencies: - typescript: 5.9.3 + typescript: 6.0.3 - '@typescript-eslint/type-utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.2(typescript@6.0.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) debug: 4.4.3 eslint: 9.39.4(jiti@2.6.1) - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color '@typescript-eslint/types@8.57.2': {} - '@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.57.2(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.57.2(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/project-service': 8.57.2(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@6.0.3) '@typescript-eslint/types': 8.57.2 '@typescript-eslint/visitor-keys': 8.57.2 debug: 4.4.3 minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.57.2 '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.2(typescript@6.0.3) eslint: 9.39.4(jiti@2.6.1) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -7858,15 +7858,15 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/eslint-plugin@1.6.13(@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))': + '@vitest/eslint-plugin@1.6.13(@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@24.12.0)(typescript@6.0.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))': dependencies: '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint: 9.39.4(jiti@2.6.1) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - typescript: 5.9.3 - vitest: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + typescript: 6.0.3 + vitest: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@24.12.0)(typescript@6.0.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - supports-color @@ -7879,13 +7879,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.2(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.2(msw@2.12.14(@types/node@24.12.0)(typescript@6.0.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.2 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.12.14(@types/node@24.12.0)(typescript@5.9.3) + msw: 2.12.14(@types/node@24.12.0)(typescript@6.0.3) vite: 8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) '@vitest/pretty-format@4.1.2': @@ -8250,14 +8250,14 @@ snapshots: dependencies: layout-base: 2.0.1 - cosmiconfig@9.0.1(typescript@5.9.3): + cosmiconfig@9.0.1(typescript@6.0.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 js-yaml: 4.1.1 parse-json: 5.2.0 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 cross-spawn@7.0.6: dependencies: @@ -8692,11 +8692,11 @@ snapshots: dependencies: eslint: 9.39.4(jiti@2.6.1) - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 4.4.4(eslint@9.39.4(jiti@2.6.1)) @@ -8707,13 +8707,13 @@ snapshots: dependencies: eslint: 9.39.4(jiti@2.6.1) - eslint-plugin-boundaries@6.0.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-boundaries@6.0.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@4.4.4(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: - '@boundaries/elements': 2.0.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + '@boundaries/elements': 2.0.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@4.4.4(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) chalk: 4.1.2 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) handlebars: 4.7.9 micromatch: 4.0.8 transitivePeerDependencies: @@ -8722,12 +8722,12 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-command@3.5.2(@typescript-eslint/rule-tester@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3))(@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-command@3.5.2(@typescript-eslint/rule-tester@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.57.2(typescript@6.0.3))(@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@es-joy/jsdoccomment': 0.84.0 - '@typescript-eslint/rule-tester': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/rule-tester': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.57.2(typescript@6.0.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint: 9.39.4(jiti@2.6.1) eslint-plugin-depend@1.5.0(eslint@9.39.4(jiti@2.6.1)): @@ -8783,7 +8783,7 @@ snapshots: transitivePeerDependencies: - '@eslint/json' - eslint-plugin-n@17.24.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-n@17.24.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) enhanced-resolve: 5.20.1 @@ -8794,15 +8794,15 @@ snapshots: globrex: 0.1.2 ignore: 5.3.2 semver: 7.7.4 - ts-declaration-location: 1.0.7(typescript@5.9.3) + ts-declaration-location: 1.0.7(typescript@6.0.3) transitivePeerDependencies: - typescript eslint-plugin-no-only-tests@3.3.0: {} - eslint-plugin-perfectionist@5.7.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-perfectionist@5.7.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint: 9.39.4(jiti@2.6.1) natural-orderby: 5.0.0 transitivePeerDependencies: @@ -8820,37 +8820,37 @@ snapshots: yaml: 2.8.3 yaml-eslint-parser: 2.0.0 - eslint-plugin-react-dom@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-dom@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.57.2 '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) compare-versions: 6.1.1 eslint: 9.39.4(jiti@2.6.1) ts-pattern: 5.9.0 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-hooks-extra@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-hooks-extra@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint: 9.39.4(jiti@2.6.1) ts-pattern: 5.9.0 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -8865,22 +8865,22 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-react-naming-convention@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-naming-convention@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) compare-versions: 6.1.1 eslint: 9.39.4(jiti@2.6.1) string-ts: 2.3.1 ts-pattern: 5.9.0 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -8888,53 +8888,53 @@ snapshots: dependencies: eslint: 9.39.4(jiti@2.6.1) - eslint-plugin-react-rsc@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-rsc@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint: 9.39.4(jiti@2.6.1) ts-pattern: 5.9.0 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-web-api@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-web-api@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.57.2 '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) birecord: 0.1.1 eslint: 9.39.4(jiti@2.6.1) ts-pattern: 5.9.0 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-x@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-x@2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/core': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@eslint-react/eff': 2.13.0 - '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/shared': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@eslint-react/var': 2.13.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) compare-versions: 6.1.1 eslint: 9.39.4(jiti@2.6.1) - is-immutable-type: 5.0.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - ts-api-utils: 2.5.0(typescript@5.9.3) + is-immutable-type: 5.0.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + ts-api-utils: 2.5.0(typescript@6.0.3) ts-pattern: 5.9.0 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -8980,13 +8980,13 @@ snapshots: semver: 7.7.4 strip-indent: 4.1.1 - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) - eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4(jiti@2.6.1)))(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))): + eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4(jiti@2.6.1)))(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) eslint: 9.39.4(jiti@2.6.1) @@ -8998,7 +8998,7 @@ snapshots: xml-name-validator: 4.0.0 optionalDependencies: '@stylistic/eslint-plugin': 5.10.0(eslint@9.39.4(jiti@2.6.1)) - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint-plugin-yml@3.3.1(eslint@9.39.4(jiti@2.6.1)): dependencies: @@ -9658,13 +9658,13 @@ snapshots: is-hexadecimal@2.0.1: {} - is-immutable-type@5.0.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + is-immutable-type@5.0.1(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint: 9.39.4(jiti@2.6.1) - ts-api-utils: 2.5.0(typescript@5.9.3) - ts-declaration-location: 1.0.7(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + ts-declaration-location: 1.0.7(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -10432,7 +10432,7 @@ snapshots: ms@2.1.3: {} - msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3): + msw@2.12.14(@types/node@24.12.0)(typescript@6.0.3): dependencies: '@inquirer/confirm': 5.1.21(@types/node@24.12.0) '@mswjs/interceptors': 0.41.3 @@ -10453,7 +10453,7 @@ snapshots: until-async: 3.0.2 yargs: 17.7.2 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - '@types/node' @@ -11226,7 +11226,7 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@4.1.1(@types/node@24.12.0)(typescript@5.9.3): + shadcn@4.1.1(@types/node@24.12.0)(typescript@6.0.3): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 @@ -11237,7 +11237,7 @@ snapshots: '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.1 commander: 14.0.3 - cosmiconfig: 9.0.1(typescript@5.9.3) + cosmiconfig: 9.0.1(typescript@6.0.3) dedent: 1.7.2 deepmerge: 4.3.1 diff: 8.0.4 @@ -11247,7 +11247,7 @@ snapshots: fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 - msw: 2.12.14(@types/node@24.12.0)(typescript@5.9.3) + msw: 2.12.14(@types/node@24.12.0)(typescript@6.0.3) node-fetch: 3.3.2 open: 11.0.0 ora: 8.2.0 @@ -11525,14 +11525,14 @@ snapshots: trough@2.2.0: {} - ts-api-utils@2.5.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: - typescript: 5.9.3 + typescript: 6.0.3 - ts-declaration-location@1.0.7(typescript@5.9.3): + ts-declaration-location@1.0.7(typescript@6.0.3): dependencies: picomatch: 4.0.4 - typescript: 5.9.3 + typescript: 6.0.3 ts-dedent@2.2.0: {} @@ -11578,18 +11578,18 @@ snapshots: optionalDependencies: rxjs: 7.8.2 - typescript-eslint@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.57.2(typescript@6.0.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint: 9.39.4(jiti@2.6.1) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - typescript@5.9.3: {} + typescript@6.0.3: {} ufo@1.6.3: {} @@ -11779,10 +11779,10 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@24.12.0)(typescript@6.0.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.2 - '@vitest/mocker': 4.1.2(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 4.1.2(msw@2.12.14(@types/node@24.12.0)(typescript@6.0.3))(vite@8.0.3(@types/node@24.12.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.2 '@vitest/runner': 4.1.2 '@vitest/snapshot': 4.1.2 diff --git a/frontend/src/features/agent/application/useZoberChat.ts b/frontend/src/features/agent/application/useZoberChat.ts index fee84b6d..4a9019e6 100644 --- a/frontend/src/features/agent/application/useZoberChat.ts +++ b/frontend/src/features/agent/application/useZoberChat.ts @@ -338,6 +338,7 @@ export function useZoberChat(args: ZoberChatArgs) { // into a single growing assistant message instead of N copies. trigger, stitch_message_id: messageId ?? null, + thread_id: threadId, }, } }, diff --git a/frontend/src/features/agent/lib/prompt/sections.ts b/frontend/src/features/agent/lib/prompt/sections.ts index ffd7296f..0068221c 100644 --- a/frontend/src/features/agent/lib/prompt/sections.ts +++ b/frontend/src/features/agent/lib/prompt/sections.ts @@ -40,11 +40,11 @@ export function toDateOnly(iso?: string): string { export function learningResourceProtocolBlock(): string { return tag('learning_resource_protocol', [ - 'Learning tips and vocabulary methods = call `search_document` FIRST, then ground the answer in the returned passages.', + 'Learning tips and vocabulary methods = call `search_document` FIRST, then fetch relevant pages via `get_page_content`, then ground the answer in the fetched content.', '- **TRIGGER:** any question about HOW to learn Chinese — memorizing words, building vocabulary, planning a study schedule, retention techniques, effective daily habits, or any request for a recommended video/lesson on a specific learning topic (e.g. "how do I memorize tones", "recommend a video about spaced repetition", "how should I plan my study day").', - '- **DO NOT answer from training data alone.** The knowledge base contains curated content from language-learning experts; prefer retrieved passages over generic advice.', - '- **NO REDUNDANT CALLS:** if a prior `search_document` result in this conversation already covers the topic, reuse those passages.', - '- **SHOW THE SOURCE URL** when one appears in the returned passages — link the learner directly to the original video.', + '- **DO NOT answer from training data alone.** The knowledge base contains curated content from language-learning experts; prefer retrieved content over generic advice.', + '- **NO REDUNDANT CALLS:** NEVER call `search_document` again for a `doc_id` already returned in this conversation. Reuse the structures in context — go straight to `get_page_content`. Only re-query `search_document` for a genuinely new topic not covered by any document already in context.', + '- **SHOW THE SOURCE URL** when one appears in the fetched page content — link the learner directly to the original video.', ]) } @@ -74,18 +74,18 @@ export function roleBlock(surface: PromptSurface): string { /** * Grammar handling rule. Both surfaces ship `search_document`, so both answer - * grammar the same way — from retrieved passages, never from training data. + * grammar the same way — from retrieved content, never from training data. */ export function grammarProtocolBlock(): string { return tag('grammar_protocol', [ - 'Grammar = the single source of truth is `search_document`, NEVER your own training knowledge.', + 'Grammar = the single source of truth is `search_document` + `get_page_content`, NEVER your own training knowledge.', '- **SCOPE:** grammar means rules, particles, sentence patterns, word order. A bare single-word meaning ("what does 不 mean?") is vocabulary — answer it normally, no `search_document` needed. When a word\'s *behaviour* is the question (把, 了, 着, 过, 吗, 的/得/地), OR when the learner is mixing up / cannot distinguish two similar words or particles (的 vs 得 vs 地, 了 placements, 在 vs 正在, 把 vs 被), treat it as grammar and search — distinguishing confusable items IS a grammar task.', - '- **TRIGGER — explicit OR implicit.** Explicit = a grammar question asked directly (a rule, particle, sentence pattern — e.g. 把 construction, 了/着/过, complements, word order). Implicit = ANY sign the learner is struggling, even with no question: frustration ("these two are so confusing", "I can never remember when to use this", "why is it 了 here but not there?"), repeated misuse of a pattern, or confusion between similar forms. In BOTH cases call `search_document` FIRST with a concise natural-language query, THEN address the struggle from the passages. Do not wait for a formal "explain X" request.', - '- **GROUND:** answer ONLY from the returned passages. Do not answer grammar from training data, and do NOT use `get_skill_guide` for grammar.', - '- **NO REDUNDANT CALLS:** if an earlier `search_document` result in this conversation already contains the passages needed to answer, reuse it — do NOT call again for the same point. Only re-query for a genuinely new grammar point or when prior passages are insufficient.', - '- **ALWAYS SHOW THE SOURCE URL:** the passages include the YouTube link(s) the content was derived from. Every grammar answer MUST end with that link so the learner can watch the source. Use links found verbatim in the passages only — never invent or guess a URL; if a link is split across lines, rejoin into a single `https://youtu.be/` URL.', - '- **EXAMPLE (explicit) — the rule holds even for "easy" questions.** Learner: "does 吗 make a sentence a question?" → call `search_document("吗 question particle")` FIRST, ground the answer in the passage, end with its URL. Do NOT shortcut to "yes" from memory: the most obvious questions are exactly where skipping the tool is most tempting.', - '- **EXAMPLE (implicit) — detect struggle with no question asked.** Learner: "ugh, 的 得 地 are so confusing, I can never tell them apart." This is not phrased as a question, but it is a clear grammar struggle → call `search_document("difference between 的 得 地 usage")` FIRST, then explain the distinction grounded in the passages and end with the URL. Treat venting/confusion as a trigger, not small talk.', + '- **TRIGGER — explicit OR implicit.** Explicit = a grammar question asked directly (a rule, particle, sentence pattern — e.g. 把 construction, 了/着/过, complements, word order). Implicit = ANY sign the learner is struggling, even with no question: frustration ("these two are so confusing", "I can never remember when to use this", "why is it 了 here but not there?"), repeated misuse of a pattern, or confusion between similar forms. In BOTH cases call `search_document` FIRST with a concise natural-language query, THEN call `get_page_content` to fetch the relevant pages, and address the struggle from the fetched content. Do not wait for a formal "explain X" request.', + '- **GROUND:** call `get_page_content(doc_id, "5-7")` to fetch the relevant pages, then answer ONLY from the fetched content. Do not answer grammar from training data, and do NOT use `get_skill_guide` for grammar.', + '- **NO REDUNDANT CALLS:** NEVER call `search_document` again for a `doc_id` already returned in this conversation. The structures from that document are already in context — go straight to `get_page_content` to fetch the relevant pages. Only re-query `search_document` for a genuinely new grammar point not covered by any document `doc_id` already returned.', + '- **ALWAYS SHOW THE SOURCE URL:** the fetched page content includes the YouTube link(s) the content was derived from. Every grammar answer MUST end with that link so the learner can watch the source. Use links found verbatim in the fetched content only — never invent or guess a URL; if a link is split across lines, rejoin into a single `https://youtu.be/` URL.', + '- **EXAMPLE (explicit) — the rule holds even for "easy" questions.** Learner: "does 吗 make a sentence a question?" → call `search_document("吗 question particle")` FIRST, then call `get_page_content` to fetch the relevant pages, end with the URL. Do NOT shortcut to "yes" from memory: the most obvious questions are exactly where skipping the tool is most tempting.', + '- **EXAMPLE (implicit) — detect struggle with no question asked.** Learner: "ugh, 的 得 地 are so confusing, I can never tell them apart." This is not phrased as a question, but it is a clear grammar struggle → call `search_document("difference between 的 得 地 usage")` FIRST, then call `get_page_content` to fetch the relevant pages, explain the distinction grounded in the fetched content, and end with the URL. Treat venting/confusion as a trigger, not small talk.', ]) } @@ -99,7 +99,7 @@ export function instructionsBlock(surface: PromptSurface): string { '- **Call `recall_memory()` proactively when the user asks about their goals, preferences, history, or learning context** — do not rely solely on the Memory Summary above.', '- Answer general learning questions, including grammar (see ``) and learning strategies (see ``). Only DECLINE questions about a specific YouTube lesson\'s content or lesson-specific actions (exercises, shadowing) — point the user into the lesson for those. Boundary: "what does 把 mean / how does 了 work" = general grammar, ANSWER it; "what does the 3rd sentence of my Daily Conversation lesson mean / start a drill" = lesson-specific, REDIRECT into the lesson.', '- For grammar, follow `` — never `get_skill_guide`.', - '- For learning tips, study planning, vocabulary methods, or video recommendations, follow `` — call `search_document` first.', + '- For learning tips, study planning, vocabulary methods, or video recommendations, follow `` — call `search_document` first, then `get_page_content` to read relevant pages.', '- If asked for tips, advice, or a topic covered in core guidelines or skill guides, ALWAYS use get_core_guidelines() or get_skill_guide() to provide accurate info.', '- Do not re-call `get_core_guidelines` or `get_skill_guide` if already loaded this session.', ]), @@ -114,7 +114,7 @@ export function instructionsBlock(surface: PromptSurface): string { '- **Call `get_core_guidelines()` at session start — loads SLA principles, feedback templates, and session protocols.**', '- **ALWAYS call `get_skill_guide({ skill })` BEFORE giving advice, tips, or answering "how-to" questions about specific skills (tones, pronunciation, vocabulary, listening, speaking, characters).**', '- For grammar, follow `` — never `get_skill_guide`.', - '- For learning tips, study planning, vocabulary methods, or video recommendations, follow `` — call `search_document` first.', + '- For learning tips, study planning, vocabulary methods, or video recommendations, follow `` — call `search_document` first, then `get_page_content` to read relevant pages.', '- Chain tools when needed, but always end with a user-visible response.', '- Use get_study_context (composite) before suggesting exercises — it covers all data in one call.', '- Save important user observations with save_memory().', diff --git a/frontend/src/features/agent/lib/tools/data/getPageContent.ts b/frontend/src/features/agent/lib/tools/data/getPageContent.ts new file mode 100644 index 00000000..cd3db4ff --- /dev/null +++ b/frontend/src/features/agent/lib/tools/data/getPageContent.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' +import { buildTool } from '@/features/agent/lib/tools/types' +import { API_BASE } from '@/shared/lib/config' + +export const GetPageContentSchema = z.object({ + doc_id: z.string().describe('The document ID to get page content from'), + pages: z.string().describe('Page range in format "5-7", "3,8", or "12". Use tight ranges; never fetch the whole document.'), +}) + +export type GetPageContentArgs = z.infer + +interface PageContent { + page: number + content: string +} + +export async function executeGetPageContent( + args: GetPageContentArgs, +): Promise { + const resp = await fetch( + `${API_BASE}/api/documents/${args.doc_id}/pages?pages=${encodeURIComponent(args.pages)}`, + ) + if (!resp.ok) + return { error: `Failed to get page content (${resp.status})` } + return await resp.json() as PageContent[] +} + +export const getPageContentTool = buildTool({ + name: 'get_page_content', + description: 'Get the text content of specific pages by doc_id and page range. Call this AFTER search_document returns a doc_id you want to deep-dive into. Use tight ranges: e.g. "5-7" for pages 5 to 7, "3,8" for pages 3 and 8, "12" for page 12. NEVER fetch large ranges or the whole document — that wastes tokens and time. If you need more context, make multiple tight requests.', + inputSchema: GetPageContentSchema, + isConcurrencySafe: () => true, + isReadOnly: () => true, + // A single page of Chinese text can be 3000-5000 chars; a 3-page range can exceed 15000. + maxResultSizeChars: 100_000, + searchHint: 'page content, text by page number, document section text', + execute: async input => executeGetPageContent(input as GetPageContentArgs), +}) diff --git a/frontend/src/features/agent/lib/tools/data/searchDocument.ts b/frontend/src/features/agent/lib/tools/data/searchDocument.ts index b2441c09..7e2656fe 100644 --- a/frontend/src/features/agent/lib/tools/data/searchDocument.ts +++ b/frontend/src/features/agent/lib/tools/data/searchDocument.ts @@ -8,11 +8,17 @@ export const SearchDocumentSchema = z.object({ export type SearchDocumentArgs = z.infer -interface Passage { doc_id: string, doc_name: string, title: string, content: string } +interface SearchDocumentResult { + doc_id: string + doc_name: string + doc_description?: string + page_count: number + structure: any[] +} export async function executeSearchDocument( args: SearchDocumentArgs, -): Promise<{ passages: Passage[] } | { error: string }> { +): Promise<{ documents: SearchDocumentResult[] } | { error: string }> { const resp = await fetch(`${API_BASE}/api/document-search`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -20,22 +26,21 @@ export async function executeSearchDocument( }) if (!resp.ok) return { error: `Document search failed (${resp.status})` } - const data = await resp.json() as { passages: Passage[] } - if (!data.passages?.length) - return { error: 'No relevant content found in the indexed documents.' } - return { passages: data.passages } + const data = await resp.json() as { documents: SearchDocumentResult[] } + if (!data.documents?.length) + return { error: 'No relevant documents found for this query.' } + return { documents: data.documents } } export const searchDocumentTool = buildTool({ name: 'search_document', - description: 'Search the knowledge base and return relevant verbatim passages to ground your answer. The knowledge base holds three kinds of documents: (1) the ShadowLearn app user manual — how to use features of the app; (2) a grammar-point reference compiled from well-known language-learning YouTube channels; (3) learning strategy content covering vocabulary acquisition methods, memorization techniques, study scheduling, and effective learning habits. Call this for: "how do I use shadowing mode?" (manual), "explain the 把 construction" (grammar), or "how do I memorize Chinese words / plan my study day / learn vocabulary effectively?" (learning strategies). Also call this when the user asks for a recommended video or lesson on any of these topics. Pass a natural-language question, not keywords. Ground your answer in the returned passages and cite the source; do not add facts beyond them.', + description: 'Search the knowledge base and return document structures (tree index with titles, page ranges, summaries) for matching documents. Each result includes a doc_id — use get_page_content(doc_id, "5-7") to read specific sections. The knowledge base holds three kinds of documents: (1) the ShadowLearn app user manual — how to use features of the app; (2) a grammar-point reference compiled from well-known language-learning YouTube channels; (3) learning strategy content covering vocabulary acquisition methods, memorization techniques, study scheduling, and effective learning habits. Call this for: "how do I use shadowing mode?" (manual), "explain the 把 construction" (grammar), or "how do I memorize Chinese words / plan my study day?" (learning strategies). Pass a natural-language question, not keywords. Ground your answer in content you fetch via get_page_content; do not add facts beyond the retrieved sections.', inputSchema: SearchDocumentSchema, isConcurrencySafe: () => true, isReadOnly: () => true, - // RAG passages are the source of truth the agent fetched to answer; never chop - // them at produce-time. Context is managed downstream by compaction (stale - // passages are pruned only after being summarized away, never the active one). + // Structure can be large (50-100 nodes per doc, 30-150KB for 3 docs). Never + // truncate at produce-time — context management prunes stale results downstream. maxResultSizeChars: Number.MAX_SAFE_INTEGER, - searchHint: 'search app manual, how to use feature, grammar point explanation, construction pattern language reference, vocabulary memorization methods, study planning, learning tips, effective Chinese learning strategies', + searchHint: 'knowledge base, document search, grammar reference, user manual, learning guide, shadowing guide, resource lookup', execute: async (input, _context) => executeSearchDocument(input as SearchDocumentArgs), }) diff --git a/frontend/src/features/agent/lib/tools/index.ts b/frontend/src/features/agent/lib/tools/index.ts index 6b96a54f..7a28e6a3 100644 --- a/frontend/src/features/agent/lib/tools/index.ts +++ b/frontend/src/features/agent/lib/tools/index.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import { navigateToSegmentTool } from '@/features/agent/lib/tools/action/navigateToSegment' import { playSegmentAudioTool } from '@/features/agent/lib/tools/action/playSegmentAudio' import { startShadowingTool } from '@/features/agent/lib/tools/action/startShadowing' +import { getPageContentTool } from '@/features/agent/lib/tools/data/getPageContent' import { getProgressSummaryTool } from '@/features/agent/lib/tools/data/getProgressSummary' import { getStudyContextTool } from '@/features/agent/lib/tools/data/getStudyContext' import { getVocabularyTool } from '@/features/agent/lib/tools/data/getVocabulary' @@ -28,6 +29,7 @@ export function getAllBaseTools(openrouterApiKey: string, uiLanguage: string = ' toolSearchTool, // ALWAYS first - never deferred getStudyContextTool, getVocabularyTool, + getPageContentTool, searchDocumentTool, getProgressSummaryTool, recallMemoryTool, @@ -72,6 +74,7 @@ const GLOBAL_TOOL_NAMES = new Set([ 'recall_memory', 'save_memory', 'get_vocabulary', + 'get_page_content', 'search_document', 'get_study_context', 'get_progress_summary', @@ -137,6 +140,7 @@ export function findTool(pool: AgentTool[], name: string): AgentTool | undefined export const SILENT_TOOLS = new Set([ 'get_study_context', 'get_vocabulary', + 'get_page_content', 'search_document', 'get_progress_summary', 'recall_memory', diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 0b29fc04..60eaa199 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -61,6 +61,7 @@ export const TRANSLATIONS = { 'tool.log_mistake': 'Log Mistake', 'tool.update_learner_profile': 'Learner Profile', 'tool.get_core_guidelines': 'Teaching Guidelines', + 'tool.get_page_content': 'Page Content', 'tool.get_skill_guide': 'Skill Guide', 'tool.search_document': 'Search Documents', 'tool.get_user_manual': 'User Manual', @@ -1487,6 +1488,7 @@ export const TRANSLATIONS = { 'tool.log_mistake': 'Ghi sổ Nam Tào', 'tool.update_learner_profile': 'Sửa sổ hộ tịch', 'tool.get_core_guidelines': 'Tầm sư học đạo', + 'tool.get_page_content': 'Đọc Kinh', 'tool.get_skill_guide': 'Bí kíp Luyện công', 'tool.search_document': 'Cửu âm chân kinh', 'tool.get_user_manual': 'Cẩm nang Xuyên không', diff --git a/page-index/api/models/schemas.py b/page-index/api/models/schemas.py index bef90561..6742b3f0 100644 --- a/page-index/api/models/schemas.py +++ b/page-index/api/models/schemas.py @@ -55,8 +55,15 @@ class SearchPassage(BaseModel): title: str content: str +class SearchDocument(BaseModel): + doc_id: str + doc_name: str + doc_description: Optional[str] = None + page_count: int = 0 + structure: List[Any] + class SearchResponse(BaseModel): - passages: List[SearchPassage] + documents: List[SearchDocument] routed_doc_ids: List[str] # === Retrieval === diff --git a/page-index/api/routers/documents.py b/page-index/api/routers/documents.py index 667ab6e8..19a8d3b1 100644 --- a/page-index/api/routers/documents.py +++ b/page-index/api/routers/documents.py @@ -1,4 +1,11 @@ +import json +import os +import uuid + from fastapi import APIRouter, UploadFile, File, HTTPException, Depends +from starlette.concurrency import run_in_threadpool + +from api.config import settings from api.models.schemas import ( DocumentUploadResponse, DocumentResultResponse, @@ -9,7 +16,7 @@ from api.services.document_service import DocumentService from api.utils.storage import enforce_size_limit from api.dependencies import get_db -import uuid +from pageindex.retrieve import get_page_content as _get_page_content router = APIRouter() @@ -74,6 +81,26 @@ async def get_document_page( content=content ) +@router.get("/{doc_id}/pages") +async def get_document_pages(doc_id: str, pages: str): + """Get text of specific pages. Use tight ranges: '5-7', '3,8', or '12'.""" + result_path = os.path.join(settings.RESULTS_DIR, f"{doc_id}.json") + if not os.path.exists(result_path): + raise HTTPException(status_code=404, detail="Document not found or not yet processed") + with open(result_path) as f: + doc_info = json.load(f) + doc_info["id"] = doc_id + doc_info.setdefault("type", "pdf") + doc_info["path"] = os.path.join(settings.UPLOAD_DIR, f"{doc_id}.pdf") + result = await run_in_threadpool( + _get_page_content, {doc_id: doc_info}, doc_id, pages + ) + result_data = json.loads(result) + if isinstance(result_data, dict) and 'error' in result_data: + raise HTTPException(status_code=400, detail=result_data['error']) + return result_data + + @router.delete("/{doc_id}/") async def delete_document(doc_id: str, db = Depends(get_db)): """Delete a document and all associated data.""" diff --git a/page-index/api/services/search_service.py b/page-index/api/services/search_service.py index 2d67e21c..83b26bda 100644 --- a/page-index/api/services/search_service.py +++ b/page-index/api/services/search_service.py @@ -25,7 +25,7 @@ from api.config import settings from api.models.database import ProcessingStatus from api.services.document_service import DocumentService -from api.services.retrieval_core import retrieve_from_document + logger = logging.getLogger(__name__) @@ -60,32 +60,19 @@ def _route(query: str, catalogue: list[dict], model: str, max_docs: int, timeout return [d for d in chosen if d in valid][:max_docs] -def _retrieve_doc_sync(doc, query: str, model: str, timeout: int) -> list[dict]: - """Load a doc's tree and run single-doc retrieval (blocking; runs in a threadpool).""" +def _retrieve_doc_sync(doc, query: str, model: str, timeout: int) -> dict | None: + """Load a doc's tree structure (no LLM call).""" if not doc.result_path or not os.path.exists(doc.result_path): - return [] + return None with open(doc.result_path, "r", encoding="utf-8") as f: doc_info = json.load(f) - # Normalize into a core doc_info: ensure id/type, and force a valid local PDF - # path so the cached-pages-or-PDF fallback works for pre-refactor docs too. - doc_info["id"] = doc.doc_id - doc_info.setdefault("type", "pdf") - doc_info["path"] = os.path.join(settings.UPLOAD_DIR, f"{doc.doc_id}.pdf") - tree_structure = doc_info.get("structure", []) - result = retrieve_from_document(tree_structure, query, model, doc_info, timeout) - passages = [] - for node in result["retrieved_nodes"]: - text = " ".join( - rc.get("relevant_content", "") for rc in node.get("relevant_contents", []) - ).strip() - if text: - passages.append({ - "doc_id": doc.doc_id, - "doc_name": doc.original_filename, - "title": node.get("title", ""), - "content": text, - }) - return passages + return { + "doc_id": doc.doc_id, + "doc_name": doc.original_filename, + "doc_description": doc_info.get("doc_description", ""), + "page_count": doc_info.get("page_count", 0), + "structure": doc_info.get("structure", []), + } async def search_documents(db: AsyncSession, query: str, max_docs: int = 3) -> dict: @@ -93,7 +80,7 @@ async def search_documents(db: AsyncSession, query: str, max_docs: int = 3) -> d all_docs = await service.list_documents() ready = [d for d in all_docs if d.status == ProcessingStatus.COMPLETED and d.retrieval_ready] if not ready: - return {"passages": [], "routed_doc_ids": []} + return {"documents": [], "routed_doc_ids": []} model = settings.RETRIEVAL_MODEL or settings.OPENAI_MODEL timeout = settings.LLM_TIMEOUT_SECONDS @@ -111,10 +98,10 @@ async def search_documents(db: AsyncSession, query: str, max_docs: int = 3) -> d _t1 = time.perf_counter() if not doc_ids: logger.info("[timing] route=%.3fs catalogue=%d routed=0", _t1 - _t0, len(catalogue)) - return {"passages": [], "routed_doc_ids": []} + return {"documents": [], "routed_doc_ids": []} by_id = {d.doc_id: d for d in ready} - groups = await asyncio.gather(*[ + results = await asyncio.gather(*[ run_in_threadpool(_retrieve_doc_sync, by_id[doc_id], query, model, timeout) for doc_id in doc_ids ]) @@ -123,5 +110,5 @@ async def search_documents(db: AsyncSession, query: str, max_docs: int = 3) -> d "[timing] route=%.3fs retrieve_all=%.3fs total=%.3fs catalogue=%d routed=%d", _t1 - _t0, _t2 - _t1, _t2 - _t0, len(catalogue), len(doc_ids), ) - passages = [p for group in groups for p in group] - return {"passages": passages, "routed_doc_ids": doc_ids} + documents = [r for r in results if r is not None] + return {"documents": documents, "routed_doc_ids": doc_ids}