diff --git a/.agents/skills/builtin-tool/references/ui.md b/.agents/skills/builtin-tool/references/ui.md index 2e08b87563..7f72b9e233 100644 --- a/.agents/skills/builtin-tool/references/ui.md +++ b/.agents/skills/builtin-tool/references/ui.md @@ -25,7 +25,7 @@ The two reference tools to read end-to-end: 1. **先保证折叠态可读。** 每个 API 都必须有 Inspector;用户不展开也应该能看懂 “正在做什么 / 对什么做 / 当前结果是什么”。Inspector 不应该只展示函数名和原始参数。 2. **Inspector 是一句话,不是详情页。** 优先表达动作、关键对象、数量、状态,例如 “分析图片 3 张”“搜索 12 个结果”“读取 config.json”。长文本、列表和结构化结果放到 Render 或 Portal。 3. **Inspector 要覆盖执行生命周期。** `args` 还在 streaming、工具执行中、执行完成、执行失败时都应该有稳定展示;必要时同时读取 `args`、`partialArgs` 和 `pluginState`,避免出现空白、跳变或只显示半截参数。 -4. **文案要随状态切换时态。** 同一个动作在 loading 与 completed 两个阶段必须用不同的措辞:执行中用现在进行时(“正在创建任务 / Creating task / 正在搜索”),执行完成后切到完成态(“已创建任务 / Task created / 已找到 N 条”)。Inspector chip 会一直留在聊天记录里——如果一直挂着 “正在 xxx”,几小时后回看历史时会读起来像还在跑。约定的 i18n 形式是 `.loading` / `.completed` 一对键(见 `lobe-agent.apiName.callSubAgent.{loading,completed}` 与 `lobe-claude-code.task.{create,list,update,get}.{loading,completed}`),渲染时按 `isArgumentsStreaming || isLoading` 决定取哪一个。只读 / 查询类(“查看任务”这种本来就是名词性的)可以共用一个键。 +4. **文案要随状态切换时态。** 同一个动作在 loading 与 completed 两个阶段必须用不同的措辞:执行中用现在进行时(“正在创建任务 / Creating task / 正在搜索”),执行完成后切到完成态(“已创建任务 / Task created / 已找到 N 条”)。Inspector chip 会一直留在聊天记录里 —— 如果一直挂着 “正在 xxx”,几小时后回看历史时会读起来像还在跑。约定的 i18n 形式是 `.loading` / `.completed` 一对键(见 `lobe-agent.apiName.callSubAgent.{loading,completed}` 与 `lobe-claude-code.task.{create,list,update,get}.{loading,completed}`),渲染时按 `isArgumentsStreaming || isLoading` 决定取哪一个。只读 / 查询类(“查看任务” 这种本来就是名词性的)可以共用一个键。 5. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。 6. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。 7. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容。 diff --git a/apps/cli/src/commands/hetero.test.ts b/apps/cli/src/commands/hetero.test.ts index 7eef0f67f2..f0eee9bdc7 100644 --- a/apps/cli/src/commands/hetero.test.ts +++ b/apps/cli/src/commands/hetero.test.ts @@ -346,25 +346,31 @@ describe('hetero exec command', () => { it('retries without --resume when the error stream event indicates the session is gone', async () => { // First spawn: exits non-zero, emits a resume-not-found error event const resumeNotFoundEvent = { - data: { error: 'No conversation found with session ID cc-stale', message: 'No conversation found with session ID cc-stale' }, + data: { + error: 'No conversation found with session ID cc-stale', + message: 'No conversation found with session ID cc-stale', + }, operationId: 'op-r1', stepIndex: 0, timestamp: 1, type: 'error', }; mockSpawnAgent - .mockReturnValueOnce( - createFakeHandle({ events: [resumeNotFoundEvent], exitCode: 1 }), - ) + .mockReturnValueOnce(createFakeHandle({ events: [resumeNotFoundEvent], exitCode: 1 })) // Second spawn: succeeds .mockReturnValueOnce(createFakeHandle({ exitCode: 0 })); await runCmd([ - 'hetero', 'exec', - '--type', 'claude-code', - '--prompt', 'do the thing', - '--resume', 'cc-stale', - '--operation-id', 'op-r1', + 'hetero', + 'exec', + '--type', + 'claude-code', + '--prompt', + 'do the thing', + '--resume', + 'cc-stale', + '--operation-id', + 'op-r1', ]); // Two spawns: first with --resume, retry without @@ -389,11 +395,16 @@ describe('hetero exec command', () => { .mockReturnValueOnce(createFakeHandle({ exitCode: 0 })); await runCmd([ - 'hetero', 'exec', - '--type', 'claude-code', - '--prompt', 'continue', - '--resume', 'xyz', - '--operation-id', 'op-r2', + 'hetero', + 'exec', + '--type', + 'claude-code', + '--prompt', + 'continue', + '--resume', + 'xyz', + '--operation-id', + 'op-r2', ]); expect(mockSpawnAgent).toHaveBeenCalledTimes(2); @@ -403,7 +414,10 @@ describe('hetero exec command', () => { it('retries without --resume when the error indicates context overflow', async () => { const contextOverflowEvent = { - data: { error: 'prompt is too long: 215168 tokens > 200000 maximum', message: 'prompt is too long: 215168 tokens > 200000 maximum' }, + data: { + error: 'prompt is too long: 215168 tokens > 200000 maximum', + message: 'prompt is too long: 215168 tokens > 200000 maximum', + }, operationId: 'op-ctx', stepIndex: 0, timestamp: 1, @@ -414,11 +428,16 @@ describe('hetero exec command', () => { .mockReturnValueOnce(createFakeHandle({ exitCode: 0 })); await runCmd([ - 'hetero', 'exec', - '--type', 'claude-code', - '--prompt', 'next question', - '--resume', 'cc-longctx', - '--operation-id', 'op-ctx', + 'hetero', + 'exec', + '--type', + 'claude-code', + '--prompt', + 'next question', + '--resume', + 'cc-longctx', + '--operation-id', + 'op-ctx', ]); expect(mockSpawnAgent).toHaveBeenCalledTimes(2); @@ -434,10 +453,14 @@ describe('hetero exec command', () => { ); await runCmd([ - 'hetero', 'exec', - '--type', 'claude-code', - '--prompt', 'hi', - '--resume', 'cc-valid', + 'hetero', + 'exec', + '--type', + 'claude-code', + '--prompt', + 'hi', + '--resume', + 'cc-valid', ]); expect(mockSpawnAgent).toHaveBeenCalledTimes(1); @@ -455,10 +478,14 @@ describe('hetero exec command', () => { mockSpawnAgent.mockReturnValueOnce(createFakeHandle({ events: [errorEvent], exitCode: 1 })); await runCmd([ - 'hetero', 'exec', - '--type', 'claude-code', - '--prompt', 'fresh run', - '--operation-id', 'op-nr', + 'hetero', + 'exec', + '--type', + 'claude-code', + '--prompt', + 'fresh run', + '--operation-id', + 'op-nr', ]); // No --resume → no interception → no retry @@ -468,7 +495,10 @@ describe('hetero exec command', () => { it('does NOT suppress the resume-error event from JSONL output', async () => { const resumeNotFoundEvent = { - data: { error: 'No conversation found with session ID old', message: 'No conversation found with session ID old' }, + data: { + error: 'No conversation found with session ID old', + message: 'No conversation found with session ID old', + }, operationId: 'op-jsonl', stepIndex: 0, timestamp: 1, @@ -479,11 +509,16 @@ describe('hetero exec command', () => { .mockReturnValueOnce(createFakeHandle({ exitCode: 0 })); await runCmd([ - 'hetero', 'exec', - '--type', 'claude-code', - '--prompt', 'do thing', - '--resume', 'old', - '--render', 'jsonl', + 'hetero', + 'exec', + '--type', + 'claude-code', + '--prompt', + 'do thing', + '--resume', + 'old', + '--render', + 'jsonl', ]); // The error event is still emitted to JSONL (for observability) even @@ -492,7 +527,11 @@ describe('hetero exec command', () => { .map((c) => c[0]) .filter((s): s is string => typeof s === 'string'); const errorLine = lines.find((l) => { - try { return JSON.parse(l).type === 'error'; } catch { return false; } + try { + return JSON.parse(l).type === 'error'; + } catch { + return false; + } }); expect(errorLine).toBeDefined(); }); diff --git a/apps/cli/src/commands/hetero.ts b/apps/cli/src/commands/hetero.ts index e8db508644..ed0b5cd6b6 100644 --- a/apps/cli/src/commands/hetero.ts +++ b/apps/cli/src/commands/hetero.ts @@ -341,7 +341,7 @@ const exec = async (options: ExecOptions): Promise => { // into the ingester. When intercepting resume errors, a matching // `error` event is withheld from the ingester and flags a retry instead. let resumeNotFound = false; - let ingestError = false; + const ingestError = false; try { for await (const event of handle.events) { if (interceptResumeErrors && event.type === 'error') { @@ -393,7 +393,14 @@ const exec = async (options: ExecOptions): Promise => { resumeNotFound = true; } - return { code, ingestError, resumeNotFound, sessionId: handle.sessionId, signal, stderrContent }; + return { + code, + ingestError, + resumeNotFound, + sessionId: handle.sessionId, + signal, + stderrContent, + }; }; // ─── First run (with --resume if provided) ─────────────────────────────── diff --git a/packages/builtin-tool-agent-management/src/executor.ts b/packages/builtin-tool-agent-management/src/executor.ts index 7add43b18e..fdb6855cdf 100644 --- a/packages/builtin-tool-agent-management/src/executor.ts +++ b/packages/builtin-tool-agent-management/src/executor.ts @@ -254,7 +254,12 @@ class AgentManagementExecutor extends BaseExecutor; } - diff --git a/packages/model-runtime/src/providers/githubCopilot/index.ts b/packages/model-runtime/src/providers/githubCopilot/index.ts index 810826103b..1ac15067d5 100644 --- a/packages/model-runtime/src/providers/githubCopilot/index.ts +++ b/packages/model-runtime/src/providers/githubCopilot/index.ts @@ -5,7 +5,6 @@ import OpenAI from 'openai'; import { responsesAPIModels } from '../../const/models'; import { buildDefaultAnthropicPayload } from '../../core/anthropicCompatibleFactory'; -import { assertToolLimits } from '../../utils/validateToolLimits'; import { type LobeRuntimeAI } from '../../core/BaseAI'; import { convertOpenAIMessages, @@ -20,6 +19,7 @@ import { AgentRuntimeError } from '../../utils/createError'; import { debugResponse, debugStream } from '../../utils/debugStream'; import { getModelPricing } from '../../utils/getModelPricing'; import { StreamingResponse } from '../../utils/response'; +import { assertToolLimits } from '../../utils/validateToolLimits'; const COPILOT_BASE_URL = 'https://api.githubcopilot.com'; const TOKEN_EXCHANGE_URL = 'https://api.github.com/copilot_internal/v2/token'; diff --git a/packages/types/src/agent/chatConfig.ts b/packages/types/src/agent/chatConfig.ts index 58d29408d8..f51150cd57 100644 --- a/packages/types/src/agent/chatConfig.ts +++ b/packages/types/src/agent/chatConfig.ts @@ -63,6 +63,7 @@ export interface LobeAgentChatConfig extends AgentMemoryChatConfig, AgentSelfIte * When enabled, old messages will be compressed into summaries when token threshold is reached */ enableContextCompression?: boolean; + enableFollowUpChips?: boolean; /** * Enable historical message count */ @@ -206,6 +207,7 @@ export const AgentChatConfigSchema = z enableAutoScrollOnStreaming: z.boolean().optional(), enableCompressHistory: z.boolean().optional(), enableContextCompression: z.boolean().optional(), + enableFollowUpChips: z.boolean().optional(), enableHistoryCount: z.boolean().optional(), enableMaxTokens: z.boolean().optional(), enableReasoning: z.boolean().optional(), diff --git a/packages/types/src/followUpAction.ts b/packages/types/src/followUpAction.ts index 228df81c0e..990e3bf861 100644 --- a/packages/types/src/followUpAction.ts +++ b/packages/types/src/followUpAction.ts @@ -19,6 +19,7 @@ export interface FollowUpModelConfig { export interface FollowUpExtractInput { hint?: FollowUpHint; modelConfig: FollowUpModelConfig; + threadId?: string; topicId: string; } @@ -46,5 +47,6 @@ export const FollowUpModelConfigSchema = z.object({ export const FollowUpExtractInputSchema = z.object({ hint: FollowUpHintSchema.optional(), modelConfig: FollowUpModelConfigSchema, + threadId: z.string().optional(), topicId: z.string().min(1), }); diff --git a/packages/types/src/user/settings/systemAgent.ts b/packages/types/src/user/settings/systemAgent.ts index 2e7b85d5e4..e652d04538 100644 --- a/packages/types/src/user/settings/systemAgent.ts +++ b/packages/types/src/user/settings/systemAgent.ts @@ -11,6 +11,7 @@ export interface PromptRewriteSystemAgent extends Omit ({ height: 1px; background: ${cssVar.colorSplit}; `, + hint: css` + font-size: 12px; + line-height: 18px; + color: ${cssVar.colorTextTertiary}; + `, form: css` margin: 0; `, @@ -533,6 +540,11 @@ const Controls = memo(({ setUpdating, updating, variant = 'popove 'enableAutoScrollOnStreaming', ]); const enableStreaming = form.getFieldValue(['chatConfig', 'enableStreaming']); + const enableFollowUpChips = form.getFieldValue(['chatConfig', 'enableFollowUpChips']); + const globalFollowUp = useUserStore(systemAgentSelectors.followUpAction, isEqual); + const globalFollowUpReady = + globalFollowUp.enabled === true && !!globalFollowUp.model && !!globalFollowUp.provider; + const showFollowUpHint = !globalFollowUpReady && Boolean(enableFollowUpChips); const enableReasoningEffort = form.getFieldValue(['chatConfig', 'enableReasoningEffort']); const reasoningEffortValue = form.getFieldValue(['params', 'reasoning_effort']); const disabledParams = useAiInfraStore( @@ -783,6 +795,26 @@ const Controls = memo(({ setUpdating, updating, variant = 'popove /> } /> + { + handleFieldChange(['chatConfig', 'enableFollowUpChips'], checked); + }} + /> + } + > + {showFollowUpHint && ( +
+ {t('settingChat.enableFollowUpChips.notConfiguredHint')} +
+ )} +
({ + mockChatState: { + operations: {} as Record, + operationsByMessage: {} as Record, + }, + mockState: { + displayMessages: [] as Array<{ id: string; role: string }>, + generatingIds: new Set(), + hooks: {} as Record any) | undefined>, + pendingInterventions: [] as Array<{ id: string }>, + }, +})); + +vi.mock('./store', () => ({ + contextSelectors: { + hook: (name: string) => (state: typeof mockState) => state.hooks[name], + }, + conversationSelectors: { + displayMessages: (state: typeof mockState) => state.displayMessages, + }, + dataSelectors: { + pendingInterventions: (state: typeof mockState) => state.pendingInterventions, + }, + messageStateSelectors: { + isAssistantGroupItemGenerating: (id: string) => (state: typeof mockState) => + state.generatingIds.has(id), + }, + useConversationStore: (selector: (state: typeof mockState) => unknown) => selector(mockState), +})); + +vi.mock('@/store/chat', () => ({ + useChatStore: { + getState: () => mockChatState, + }, +})); + +interface SeedOp { + cancelReason?: string; + endTime?: number; + parentOperationId?: string; + status: string; + type: string; +} + +const seedOperations = (messageId: string, ops: SeedOp[]) => { + mockChatState.operations = {}; + mockChatState.operationsByMessage = {}; + const ids: string[] = []; + ops.forEach((op, index) => { + const id = `op-${messageId}-${index}`; + ids.push(id); + mockChatState.operations[id] = { + id, + parentOperationId: op.parentOperationId, + status: op.status, + type: op.type, + metadata: { + cancelReason: op.cancelReason, + endTime: op.endTime ?? index + 1, + startTime: 0, + }, + }; + }); + mockChatState.operationsByMessage[messageId] = ids; +}; + +const armAndSettle = ( + rerender: (ui: React.ReactElement) => void, + hook: ReturnType, +) => { + mockState.hooks.onAssistantTurnSettled = hook; + mockState.displayMessages = [ + { id: 'user-1', role: 'user' }, + { id: 'assistant-1', role: 'assistant' }, + ]; + mockState.generatingIds = new Set(['assistant-1']); + rerender(); + + mockState.generatingIds = new Set(); + rerender(); +}; + +describe('AssistantTurnSettledWatcher', () => { + beforeEach(() => { + mockState.displayMessages = []; + mockState.generatingIds = new Set(); + mockState.pendingInterventions = []; + mockState.hooks = {}; + mockChatState.operations = {}; + mockChatState.operationsByMessage = {}; + }); + + it('fires with reason "completed" when latest terminal op is sendMessage/completed', async () => { + const hook = vi.fn(); + seedOperations('assistant-1', [{ status: 'completed', type: 'sendMessage' }]); + + const { rerender } = render(); + armAndSettle(rerender, hook); + + await waitFor(() => { + expect(hook).toHaveBeenCalledWith('assistant-1', { reason: 'completed' }); + }); + expect(hook).toHaveBeenCalledTimes(1); + }); + + it('fires with reason "regenerated" when latest terminal op type is regenerate', async () => { + const hook = vi.fn(); + seedOperations('assistant-1', [{ status: 'completed', type: 'regenerate' }]); + + const { rerender } = render(); + armAndSettle(rerender, hook); + + await waitFor(() => { + expect(hook).toHaveBeenCalledWith('assistant-1', { reason: 'regenerated' }); + }); + }); + + it('fires with reason "continued" when latest terminal op type is continue', async () => { + const hook = vi.fn(); + seedOperations('assistant-1', [{ status: 'completed', type: 'continue' }]); + + const { rerender } = render(); + armAndSettle(rerender, hook); + + await waitFor(() => { + expect(hook).toHaveBeenCalledWith('assistant-1', { reason: 'continued' }); + }); + }); + + it('fires with reason "stopped" when latest terminal op is cancelled, even on regenerate', async () => { + const hook = vi.fn(); + seedOperations('assistant-1', [{ status: 'cancelled', type: 'regenerate' }]); + + const { rerender } = render(); + armAndSettle(rerender, hook); + + await waitFor(() => { + expect(hook).toHaveBeenCalledWith('assistant-1', { reason: 'stopped' }); + }); + }); + + it('derives "regenerated" when parent regenerate completes after child callLLM also completes (parent/child ordering)', async () => { + const hook = vi.fn(); + seedOperations('assistant-1', [ + { endTime: 1000, status: 'completed', type: 'regenerate' }, + { + endTime: 1500, + parentOperationId: 'op-assistant-1-0', + status: 'completed', + type: 'callLLM', + }, + ]); + + const { rerender } = render(); + armAndSettle(rerender, hook); + + await waitFor(() => { + expect(hook).toHaveBeenCalledWith('assistant-1', { reason: 'regenerated' }); + }); + }); + + it('derives "continued" when parent continue completes after child callLLM also completes', async () => { + const hook = vi.fn(); + seedOperations('assistant-1', [ + { endTime: 1000, status: 'completed', type: 'continue' }, + { + endTime: 1500, + parentOperationId: 'op-assistant-1-0', + status: 'completed', + type: 'callLLM', + }, + ]); + + const { rerender } = render(); + armAndSettle(rerender, hook); + + await waitFor(() => { + expect(hook).toHaveBeenCalledWith('assistant-1', { reason: 'continued' }); + }); + }); + + it('derives "stopped" when parent regenerate is cancelled with cancelled child callLLM finishing later', async () => { + const hook = vi.fn(); + seedOperations('assistant-1', [ + { + cancelReason: 'User cancelled', + endTime: 1000, + status: 'cancelled', + type: 'regenerate', + }, + { + endTime: 1500, + parentOperationId: 'op-assistant-1-0', + status: 'cancelled', + type: 'callLLM', + }, + ]); + + const { rerender } = render(); + armAndSettle(rerender, hook); + + await waitFor(() => { + expect(hook).toHaveBeenCalledWith('assistant-1', { reason: 'stopped' }); + }); + }); + + it('defers settlement while pending intervention exists, fires once it clears', async () => { + const hook = vi.fn(); + seedOperations('assistant-1', [{ status: 'completed', type: 'sendMessage' }]); + + const { rerender } = render(); + + mockState.hooks.onAssistantTurnSettled = hook; + mockState.displayMessages = [ + { id: 'user-1', role: 'user' }, + { id: 'assistant-1', role: 'assistant' }, + ]; + mockState.generatingIds = new Set(['assistant-1']); + rerender(); + + mockState.generatingIds = new Set(); + mockState.pendingInterventions = [{ id: 'tool-1' }]; + rerender(); + expect(hook).not.toHaveBeenCalled(); + + mockState.pendingInterventions = []; + rerender(); + expect(hook).not.toHaveBeenCalled(); + + mockState.generatingIds = new Set(['assistant-1']); + rerender(); + expect(hook).not.toHaveBeenCalled(); + + mockState.generatingIds = new Set(); + rerender(); + + await waitFor(() => { + expect(hook).toHaveBeenCalledWith('assistant-1', { reason: 'completed' }); + }); + expect(hook).toHaveBeenCalledTimes(1); + }); + + it('does not double-fire for the same message id across rerenders', async () => { + const hook = vi.fn(); + seedOperations('assistant-1', [{ status: 'completed', type: 'sendMessage' }]); + + const { rerender } = render(); + armAndSettle(rerender, hook); + + await waitFor(() => { + expect(hook).toHaveBeenCalledTimes(1); + }); + + rerender(); + rerender(); + expect(hook).toHaveBeenCalledTimes(1); + }); + + it('falls back to reason "completed" and logs when no terminal op exists', async () => { + const hook = vi.fn(); + const logSpy = vi.spyOn(debug, 'log').mockImplementation(() => {}); + debug.enable('lobe-render:features:Conversation'); + + const { rerender } = render(); + armAndSettle(rerender, hook); + + await waitFor(() => { + expect(hook).toHaveBeenCalledWith('assistant-1', { reason: 'completed' }); + }); + expect(logSpy).toHaveBeenCalled(); + const logged = logSpy.mock.calls.flat().map(String).join(' '); + expect(logged).toContain('settlement fired without terminal op'); + + logSpy.mockRestore(); + debug.disable(); + }); +}); diff --git a/src/features/Conversation/AssistantTurnSettledWatcher.tsx b/src/features/Conversation/AssistantTurnSettledWatcher.tsx new file mode 100644 index 0000000000..3cd6da15dd --- /dev/null +++ b/src/features/Conversation/AssistantTurnSettledWatcher.tsx @@ -0,0 +1,108 @@ +'use client'; + +import debug from 'debug'; +import { useEffect, useMemo, useRef } from 'react'; + +import { useChatStore } from '@/store/chat'; +import { operationSelectors } from '@/store/chat/slices/operation/selectors'; +import { type Operation, type OperationType } from '@/store/chat/slices/operation/types'; + +import { + contextSelectors, + conversationSelectors, + dataSelectors, + messageStateSelectors, + useConversationStore, +} from './store'; + +const log = debug('lobe-render:features:Conversation'); + +const assistantLikeRoles = new Set(['assistant', 'assistantGroup', 'supervisor']); + +type SettlementReason = 'completed' | 'stopped' | 'regenerated' | 'continued'; + +// Restrict reason derivation to turn-level intents so child sub-ops (callLLM, executeToolCall, …) cannot shadow the parent type at settlement +const TURN_LEVEL_TYPES = new Set(['sendMessage', 'regenerate', 'continue']); + +const deriveReason = (op: Operation): SettlementReason => { + // cancelled wins over type: a stopped regenerate is still 'stopped' + if (op.status === 'cancelled') return 'stopped'; + if (op.type === 'regenerate') return 'regenerated'; + if (op.type === 'continue') return 'continued'; + return 'completed'; +}; + +const resolveReason = (messageId: string): SettlementReason => { + const operations = operationSelectors.getOperationsByMessage(messageId)(useChatStore.getState()); + const terminal = operations + .filter((op) => TURN_LEVEL_TYPES.has(op.type)) + .filter( + (op) => op.status === 'completed' || op.status === 'cancelled' || op.status === 'failed', + ) + .sort((a, b) => (b.metadata.endTime ?? 0) - (a.metadata.endTime ?? 0))[0]; + + if (!terminal) { + log('settlement fired without terminal op for messageId=%s', messageId); + return 'completed'; + } + + return deriveReason(terminal); +}; + +const AssistantTurnSettledWatcher = () => { + const displayMessages = useConversationStore(conversationSelectors.displayMessages); + const onAssistantTurnSettled = useConversationStore( + contextSelectors.hook('onAssistantTurnSettled'), + ); + + const latestAssistantMessageId = useMemo(() => { + const latest = displayMessages.at(-1); + if (!latest || !assistantLikeRoles.has(latest.role)) return undefined; + return latest.id; + }, [displayMessages]); + + const isLatestAssistantGenerating = useConversationStore((s) => + latestAssistantMessageId + ? messageStateSelectors.isAssistantGroupItemGenerating(latestAssistantMessageId)(s) + : false, + ); + + const pendingInterventionCount = useConversationStore( + (s) => dataSelectors.pendingInterventions(s).length, + ); + + const armedSettledMessageIdRef = useRef(undefined); + const firedSettledMessageIdRef = useRef(undefined); + + useEffect(() => { + if (!onAssistantTurnSettled || !latestAssistantMessageId) return; + + if (pendingInterventionCount > 0) { + armedSettledMessageIdRef.current = undefined; + return; + } + + if (isLatestAssistantGenerating) { + armedSettledMessageIdRef.current = latestAssistantMessageId; + return; + } + + if (armedSettledMessageIdRef.current !== latestAssistantMessageId) return; + if (firedSettledMessageIdRef.current === latestAssistantMessageId) return; + + firedSettledMessageIdRef.current = latestAssistantMessageId; + armedSettledMessageIdRef.current = undefined; + + const reason = resolveReason(latestAssistantMessageId); + void onAssistantTurnSettled(latestAssistantMessageId, { reason }); + }, [ + isLatestAssistantGenerating, + latestAssistantMessageId, + onAssistantTurnSettled, + pendingInterventionCount, + ]); + + return null; +}; + +export default AssistantTurnSettledWatcher; diff --git a/src/features/Conversation/ChatItem/ChatItem.tsx b/src/features/Conversation/ChatItem/ChatItem.tsx index 5600547bfc..812719e35a 100644 --- a/src/features/Conversation/ChatItem/ChatItem.tsx +++ b/src/features/Conversation/ChatItem/ChatItem.tsx @@ -44,7 +44,7 @@ const ChatItem = memo( ...rest }) => { const isUser = placement === 'right'; - const topicId = useConversationStore(contextSelectors.topicId); + const conversationKey = useConversationStore(contextSelectors.conversationKey); const isEmptyMessage = !message || String(message).trim() === '' || message === placeholderMessage; const errorContent = error && ( @@ -118,7 +118,9 @@ const ChatItem = memo( )} {belowMessage} - {id && topicId && } + {id && conversationKey && ( + + )} {actions && } ); diff --git a/src/features/Conversation/ChatList/components/VirtualizedList.tsx b/src/features/Conversation/ChatList/components/VirtualizedList.tsx index 17cacb90e9..9b36025936 100644 --- a/src/features/Conversation/ChatList/components/VirtualizedList.tsx +++ b/src/features/Conversation/ChatList/components/VirtualizedList.tsx @@ -48,321 +48,323 @@ interface VirtualizedListProps { */ const VirtualizedList = memo( ({ dataSource, footerSlot, headerSlot, itemContent }) => { - const virtuaRef = useRef(null); - const scrollEndTimerRef = useRef | null>(null); - const lastUserScrollIntentAtRef = useRef(0); + const virtuaRef = useRef(null); + const scrollEndTimerRef = useRef | null>(null); + const lastUserScrollIntentAtRef = useRef(0); - // Per-topic scroll restoration. Provider does not remount on topic switch, - // so we key the scroll snapshot by the message-map key derived from - // ConversationStore's `context`. - const contextKey = useConversationStore((s) => messageMapKey(s.context)); - const { recordScroll } = useTopicScrollPersist({ - contextKey, - dataSourceLength: dataSource.length, - virtuaRef, - }); + // Per-topic scroll restoration. Provider does not remount on topic switch, + // so we key the scroll snapshot by the message-map key derived from + // ConversationStore's `context`. + const contextKey = useConversationStore((s) => messageMapKey(s.context)); + const { recordScroll } = useTopicScrollPersist({ + contextKey, + dataSourceLength: dataSource.length, + virtuaRef, + }); - // Second-to-last message is the user turn when sending (user + assistant pair) - const isSecondLastMessageFromUser = useConversationStore( - dataSelectors.isSecondLastMessageFromUser, - ); + // Second-to-last message is the user turn when sending (user + assistant pair) + const isSecondLastMessageFromUser = useConversationStore( + dataSelectors.isSecondLastMessageFromUser, + ); - const { - isScrollShrinking, - isSpacerMessage, - listData, - onScrollOffset, - registerSpacerNode, - spacerActive, - spacerHeight, - } = useConversationScroll({ - dataSource, - isSecondLastMessageFromUser, - virtuaRef, - }); + const { + isScrollShrinking, + isSpacerMessage, + listData, + onScrollOffset, + registerSpacerNode, + spacerActive, + spacerHeight, + } = useConversationScroll({ + dataSource, + isSecondLastMessageFromUser, + virtuaRef, + }); - const isAutoScrollEnabled = useAutoScrollEnabled(); + const isAutoScrollEnabled = useAutoScrollEnabled(); - // Store actions - const registerVirtuaScrollMethods = useConversationStore((s) => s.registerVirtuaScrollMethods); - const setScrollState = useConversationStore((s) => s.setScrollState); - const resetVisibleItems = useConversationStore((s) => s.resetVisibleItems); - const setActiveIndex = useConversationStore((s) => s.setActiveIndex); - const activeIndex = useConversationStore(virtuaListSelectors.activeIndex); + // Store actions + const registerVirtuaScrollMethods = useConversationStore((s) => s.registerVirtuaScrollMethods); + const setScrollState = useConversationStore((s) => s.setScrollState); + const resetVisibleItems = useConversationStore((s) => s.resetVisibleItems); + const setActiveIndex = useConversationStore((s) => s.setActiveIndex); + const activeIndex = useConversationStore(virtuaListSelectors.activeIndex); - const markUserScrollIntent = useCallback(() => { - lastUserScrollIntentAtRef.current = Date.now(); - }, []); + const markUserScrollIntent = useCallback(() => { + lastUserScrollIntentAtRef.current = Date.now(); + }, []); - const handlePointerMove = useCallback( - (event: PointerEvent) => { - if (event.buttons > 0) { - markUserScrollIntent(); + const handlePointerMove = useCallback( + (event: PointerEvent) => { + if (event.buttons > 0) { + markUserScrollIntent(); + } + }, + [markUserScrollIntent], + ); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (SCROLL_KEYS.has(event.key)) { + markUserScrollIntent(); + } + }, + [markUserScrollIntent], + ); + + // Check if at bottom based on scroll position + const checkAtBottom = useCallback(() => { + const ref = virtuaRef.current; + if (!ref) return false; + + const scrollOffset = ref.scrollOffset; + const scrollSize = ref.scrollSize; + const viewportSize = ref.viewportSize; + + return scrollSize - scrollOffset - viewportSize <= AT_BOTTOM_THRESHOLD; + }, []); + + // Handle scroll events + const handleScroll = useCallback(() => { + const refForActive = virtuaRef.current; + const activeFromFindRaw = + refForActive && typeof refForActive.findItemIndex === 'function' + ? refForActive.findItemIndex(refForActive.scrollOffset + refForActive.viewportSize * 0.25) + : null; + const activeFromFind = + typeof activeFromFindRaw === 'number' && activeFromFindRaw >= 0 ? activeFromFindRaw : null; + + if (activeFromFind !== activeIndex) setActiveIndex(activeFromFind); + + setScrollState({ isScrolling: true }); + + // Shrink spacer on scroll up when not streaming + const ref = virtuaRef.current; + if (ref) { + const hasUserScrollIntent = + Date.now() - lastUserScrollIntentAtRef.current <= USER_SCROLL_INTENT_TTL_MS; + onScrollOffset(ref.scrollOffset, hasUserScrollIntent); } - }, - [markUserScrollIntent], - ); - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if (SCROLL_KEYS.has(event.key)) { - markUserScrollIntent(); + // Check if at bottom + const isAtBottom = checkAtBottom(); + setScrollState({ atBottom: isAtBottom }); + + if (ref) { + recordScroll(ref.scrollOffset, isAtBottom); } - }, - [markUserScrollIntent], - ); - // Check if at bottom based on scroll position - const checkAtBottom = useCallback(() => { - const ref = virtuaRef.current; - if (!ref) return false; - - const scrollOffset = ref.scrollOffset; - const scrollSize = ref.scrollSize; - const viewportSize = ref.viewportSize; - - return scrollSize - scrollOffset - viewportSize <= AT_BOTTOM_THRESHOLD; - }, []); - - // Handle scroll events - const handleScroll = useCallback(() => { - const refForActive = virtuaRef.current; - const activeFromFindRaw = - refForActive && typeof refForActive.findItemIndex === 'function' - ? refForActive.findItemIndex(refForActive.scrollOffset + refForActive.viewportSize * 0.25) - : null; - const activeFromFind = - typeof activeFromFindRaw === 'number' && activeFromFindRaw >= 0 ? activeFromFindRaw : null; - - if (activeFromFind !== activeIndex) setActiveIndex(activeFromFind); - - setScrollState({ isScrolling: true }); - - // Shrink spacer on scroll up when not streaming - const ref = virtuaRef.current; - if (ref) { - const hasUserScrollIntent = - Date.now() - lastUserScrollIntentAtRef.current <= USER_SCROLL_INTENT_TTL_MS; - onScrollOffset(ref.scrollOffset, hasUserScrollIntent); - } - - // Check if at bottom - const isAtBottom = checkAtBottom(); - setScrollState({ atBottom: isAtBottom }); - - if (ref) { - recordScroll(ref.scrollOffset, isAtBottom); - } - - // Clear existing timer - if (scrollEndTimerRef.current) { - clearTimeout(scrollEndTimerRef.current); - } - - // Set new timer for scroll end - scrollEndTimerRef.current = setTimeout(() => { - setScrollState({ isScrolling: false }); - }, 150); - }, [activeIndex, checkAtBottom, onScrollOffset, recordScroll, setActiveIndex, setScrollState]); - - const handleScrollEnd = useCallback(() => { - setScrollState({ isScrolling: false }); - }, [setScrollState]); - - // Register scroll methods to store on mount - useEffect(() => { - const ref = virtuaRef.current; - if (ref) { - registerVirtuaScrollMethods({ - getItemOffset: (index) => ref.getItemOffset(index), - getItemSize: (index) => ref.getItemSize(index), - getScrollOffset: () => ref.scrollOffset, - getScrollSize: () => ref.scrollSize, - getTotalCount: () => totalCountRef.current, - getViewportSize: () => ref.viewportSize, - scrollTo: (offset) => ref.scrollTo(offset), - scrollToIndex: (index, options) => ref.scrollToIndex(index, options), - }); - - // Seed active index once on mount (avoid requiring user scroll) - const initialActiveRaw = ref.findItemIndex(ref.scrollOffset + ref.viewportSize * 0.25); - const initialActive = - typeof initialActiveRaw === 'number' && initialActiveRaw >= 0 ? initialActiveRaw : null; - setActiveIndex(initialActive); - } - - return () => { - registerVirtuaScrollMethods(null); - }; - }, [registerVirtuaScrollMethods, setActiveIndex]); - - // Cleanup on unmount - useEffect(() => { - return () => { - resetVisibleItems(); + // Clear existing timer if (scrollEndTimerRef.current) { clearTimeout(scrollEndTimerRef.current); } - }; - }, [resetVisibleItems]); - // Keep currently-streaming items mounted so vlist recycling never triggers - // Markdown animation replay when the user scrolls them back into view. - const streamingIndices = useConversationStore( - useShallow((s) => { - const indices: number[] = []; + // Set new timer for scroll end + scrollEndTimerRef.current = setTimeout(() => { + setScrollState({ isScrolling: false }); + }, 150); + }, [activeIndex, checkAtBottom, onScrollOffset, recordScroll, setActiveIndex, setScrollState]); + + const handleScrollEnd = useCallback(() => { + setScrollState({ isScrolling: false }); + }, [setScrollState]); + + // Register scroll methods to store on mount + useEffect(() => { + const ref = virtuaRef.current; + if (ref) { + registerVirtuaScrollMethods({ + getItemOffset: (index) => ref.getItemOffset(index), + getItemSize: (index) => ref.getItemSize(index), + getScrollOffset: () => ref.scrollOffset, + getScrollSize: () => ref.scrollSize, + getTotalCount: () => totalCountRef.current, + getViewportSize: () => ref.viewportSize, + scrollTo: (offset) => ref.scrollTo(offset), + scrollToIndex: (index, options) => ref.scrollToIndex(index, options), + }); + + // Seed active index once on mount (avoid requiring user scroll) + const initialActiveRaw = ref.findItemIndex(ref.scrollOffset + ref.viewportSize * 0.25); + const initialActive = + typeof initialActiveRaw === 'number' && initialActiveRaw >= 0 ? initialActiveRaw : null; + setActiveIndex(initialActive); + } + + return () => { + registerVirtuaScrollMethods(null); + }; + }, [registerVirtuaScrollMethods, setActiveIndex]); + + // Cleanup on unmount + useEffect(() => { + return () => { + resetVisibleItems(); + if (scrollEndTimerRef.current) { + clearTimeout(scrollEndTimerRef.current); + } + }; + }, [resetVisibleItems]); + + // Keep currently-streaming items mounted so vlist recycling never triggers + // Markdown animation replay when the user scrolls them back into view. + const streamingIndices = useConversationStore( + useShallow((s) => { + const indices: number[] = []; + for (let i = 0; i < dataSource.length; i++) { + const id = dataSource[i]; + if (!id) continue; + if (messageStateSelectors.isMessageGenerating(id)(s)) indices.push(i); + } + return indices; + }), + ); + + // Also keep items that host the active text selection — unmounting a node + // containing a Selection endpoint would silently drop the user's highlight. + const selectionMessageIds = useSelectionMessageIds(); + + const keepMountedIndices = useMemo(() => { + if (selectionMessageIds.size === 0) return streamingIndices; + const merged = new Set(streamingIndices); for (let i = 0; i < dataSource.length; i++) { const id = dataSource[i]; - if (!id) continue; - if (messageStateSelectors.isMessageGenerating(id)(s)) indices.push(i); + if (id && selectionMessageIds.has(id)) merged.add(i); } - return indices; - }), - ); + if (merged.size === streamingIndices.length) return streamingIndices; + return [...merged].sort((a, b) => a - b); + }, [dataSource, streamingIndices, selectionMessageIds]); - // Also keep items that host the active text selection — unmounting a node - // containing a Selection endpoint would silently drop the user's highlight. - const selectionMessageIds = useSelectionMessageIds(); + const atBottom = useConversationStore(virtuaListSelectors.atBottom); + const scrollToBottom = useConversationStore((s) => s.scrollToBottom); - const keepMountedIndices = useMemo(() => { - if (selectionMessageIds.size === 0) return streamingIndices; - const merged = new Set(streamingIndices); - for (let i = 0; i < dataSource.length; i++) { - const id = dataSource[i]; - if (id && selectionMessageIds.has(id)) merged.add(i); - } - if (merged.size === streamingIndices.length) return streamingIndices; - return [...merged].sort((a, b) => a - b); - }, [dataSource, streamingIndices, selectionMessageIds]); + // The ChatInput's floating overlay (TodoProgress + QueueTray) covers the + // bottom of this scroll viewport like a layer. Extend VList's internal + // padding-bottom by the overlay height so the last message can still be + // scrolled into view *above* the overlay; the +12 compensates for the + // ChatInput's `marginTop: -12` (skipScrollMarginWithList) so the last + // message lands exactly on the overlay's top edge. + const overlayHeight = useConversationStore(inputSelectors.chatInputOverlayHeight); + const paddingBottom = Math.max(24, overlayHeight + 12); - const atBottom = useConversationStore(virtuaListSelectors.atBottom); - const scrollToBottom = useConversationStore((s) => s.scrollToBottom); + const dataWithSlots = useMemo( + () => [ + ...(headerSlot ? [CONVERSATION_HEADER_ID] : []), + ...listData, + ...(footerSlot ? [CONVERSATION_FOOTER_ID] : []), + ], + [footerSlot, headerSlot, listData], + ); - // The ChatInput's floating overlay (TodoProgress + QueueTray) covers the - // bottom of this scroll viewport like a layer. Extend VList's internal - // padding-bottom by the overlay height so the last message can still be - // scrolled into view *above* the overlay; the +12 compensates for the - // ChatInput's `marginTop: -12` (skipScrollMarginWithList) so the last - // message lands exactly on the overlay's top edge. - const overlayHeight = useConversationStore(inputSelectors.chatInputOverlayHeight); - const paddingBottom = Math.max(24, overlayHeight + 12); + const keepMountedIndicesWithSlots = useMemo( + () => (headerSlot ? keepMountedIndices.map((index) => index + 1) : keepMountedIndices), + [headerSlot, keepMountedIndices], + ); - const dataWithSlots = useMemo( - () => [ - ...(headerSlot ? [CONVERSATION_HEADER_ID] : []), - ...listData, - ...(footerSlot ? [CONVERSATION_FOOTER_ID] : []), - ], - [footerSlot, headerSlot, listData], - ); + // Mirror the latest data length into a ref so the scroll-methods registered + // once on mount can read the current total count (including spacer/footer) + // without re-registering on every render. + const totalCountRef = useRef(dataWithSlots.length); + totalCountRef.current = dataWithSlots.length; - const keepMountedIndicesWithSlots = useMemo( - () => (headerSlot ? keepMountedIndices.map((index) => index + 1) : keepMountedIndices), - [headerSlot, keepMountedIndices], - ); - - // Mirror the latest data length into a ref so the scroll-methods registered - // once on mount can read the current total count (including spacer/footer) - // without re-registering on every render. - const totalCountRef = useRef(dataWithSlots.length); - totalCountRef.current = dataWithSlots.length; - - return ( -
- {/* Debug Inspector - placed outside VList so it won't be recycled by the virtual list */} - {OPEN_DEV_INSPECTOR && } - - {(messageId, index): ReactElement => { - if (messageId === CONVERSATION_HEADER_ID) { - return ( - - {headerSlot} - - ); - } - if (messageId === CONVERSATION_FOOTER_ID) { - return ( - - {footerSlot} - - ); - } - if (isSpacerMessage(messageId)) { - // Only animate the collapse-to-zero (unmount). Any non-zero height - // change (initial mount, shrink as assistant grows) is applied - // instantly so virtua's scrollSize updates in a single frame and - // scrollToIndex can reach the user message without trailing behind - // a 200ms transition. - const shouldAnimate = !isScrollShrinking && spacerHeight === 0; - return ( - -
- - ); - } + {/* Debug Inspector - placed outside VList so it won't be recycled by the virtual list */} + {OPEN_DEV_INSPECTOR && } + + {(messageId, index): ReactElement => { + if (messageId === CONVERSATION_HEADER_ID) { + return ( + + {headerSlot} + + ); + } + if (messageId === CONVERSATION_FOOTER_ID) { + return ( + + {footerSlot} + + ); + } + if (isSpacerMessage(messageId)) { + // Only animate the collapse-to-zero (unmount). Any non-zero height + // change (initial mount, shrink as assistant grows) is applied + // instantly so virtua's scrollSize updates in a single frame and + // scrollToIndex can reach the user message without trailing behind + // a 200ms transition. + const shouldAnimate = !isScrollShrinking && spacerHeight === 0; + return ( + +
+ + ); + } - const isAgentCouncil = messageId.includes('agentCouncil'); - const messageIndex = headerSlot ? index - 1 : index; - const isLastItem = messageIndex === dataSource.length - 1; - const content = itemContent(messageIndex, messageId); + const isAgentCouncil = messageId.includes('agentCouncil'); + const messageIndex = headerSlot ? index - 1 : index; + const isLastItem = messageIndex === dataSource.length - 1; + const content = itemContent(messageIndex, messageId); + + if (isAgentCouncil) { + // AgentCouncil needs full width for horizontal scroll + return ( +
+ {content} + {/* AutoScroll is placed inside the last Item so it only triggers when the last Item is visible */} + {isLastItem && isAutoScrollEnabled && !spacerActive && } +
+ ); + } - if (isAgentCouncil) { - // AgentCouncil needs full width for horizontal scroll return ( -
+ {content} - {/* AutoScroll is placed inside the last Item so it only triggers when the last Item is visible */} {isLastItem && isAutoScrollEnabled && !spacerActive && } -
+ ); - } - - return ( - - {content} - {isLastItem && isAutoScrollEnabled && !spacerActive && } - - ); - }} - - {/* BackBottom is placed outside VList so it remains visible regardless of scroll position */} - - scrollToBottom(true)} - /> - -
- ); -}, isEqual); + }} +
+ {/* BackBottom is placed outside VList so it remains visible regardless of scroll position */} + + scrollToBottom(true)} + /> + +
+ ); + }, + isEqual, +); VirtualizedList.displayName = 'ConversationVirtualizedList'; diff --git a/src/features/Conversation/ConversationProvider.tsx b/src/features/Conversation/ConversationProvider.tsx index 808a0a2e18..e1ba0044d5 100644 --- a/src/features/Conversation/ConversationProvider.tsx +++ b/src/features/Conversation/ConversationProvider.tsx @@ -8,6 +8,7 @@ import { memo, useMemo } from 'react'; import { messageMapKey } from '@/store/chat/utils/messageMapKey'; +import AssistantTurnSettledWatcher from './AssistantTurnSettledWatcher'; import { createStore, Provider } from './store'; import StoreUpdater from './StoreUpdater'; import { @@ -103,6 +104,7 @@ export const ConversationProvider = memo( skipFetch={skipFetch} onMessagesChange={onMessagesChange} /> + {children} ); diff --git a/src/features/Conversation/FollowUp/FollowUpChips.test.tsx b/src/features/Conversation/FollowUp/FollowUpChips.test.tsx index 6e667b8103..07c60e2148 100644 --- a/src/features/Conversation/FollowUp/FollowUpChips.test.tsx +++ b/src/features/Conversation/FollowUp/FollowUpChips.test.tsx @@ -21,8 +21,8 @@ vi.hoisted(() => { const MSG = 'msg-1'; const OTHER_MSG = 'msg-2'; const CHILD_MSG = 'msg-1-child-answer'; -const TOPIC = 'topic-1'; -const OTHER_TOPIC = 'topic-2'; +const KEY = 'main_agent-a_topic-1'; +const OTHER_KEY = 'main_agent-a_topic-2'; const updateInputMessageMock = vi.fn(); const editorSetDocumentMock = vi.fn(); @@ -58,89 +58,109 @@ describe('', () => { it('renders nothing when status is not ready', () => { useFollowUpActionStore.setState({ - chips: [{ label: 'x', message: 'x' }], - messageId: MSG, - status: 'loading', - topicId: TOPIC, + slots: { + [KEY]: { + chips: [{ label: 'x', message: 'x' }], + messageId: MSG, + status: 'loading', + }, + }, }); - const { container } = render(); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); it('renders nothing when messageId mismatches and is not a child id', () => { useFollowUpActionStore.setState({ - chips: [{ label: 'x', message: 'x' }], - messageId: OTHER_MSG, - status: 'ready', - topicId: TOPIC, + slots: { + [KEY]: { + chips: [{ label: 'x', message: 'x' }], + messageId: OTHER_MSG, + status: 'ready', + }, + }, }); - const { container } = render(); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); - it('renders nothing when topicId mismatches', () => { + it('renders nothing when conversationKey mismatches', () => { useFollowUpActionStore.setState({ - chips: [{ label: 'a', message: 'a' }], - messageId: MSG, - status: 'ready', - topicId: TOPIC, + slots: { + [KEY]: { + chips: [{ label: 'a', message: 'a' }], + messageId: MSG, + status: 'ready', + }, + }, }); - const { container } = render(); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); it('renders nothing while the bound message is generating', () => { isGeneratingMock = true; useFollowUpActionStore.setState({ - chips: [{ label: 'a', message: 'a' }], - messageId: MSG, - status: 'ready', - topicId: TOPIC, + slots: { + [KEY]: { + chips: [{ label: 'a', message: 'a' }], + messageId: MSG, + status: 'ready', + }, + }, }); - const { container } = render(); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); it('renders one button per chip when both ids match and not generating', () => { useFollowUpActionStore.setState({ - chips: [ - { label: 'a', message: 'a' }, - { label: 'b', message: 'b' }, - { label: 'c', message: 'c' }, - ], - messageId: MSG, - status: 'ready', - topicId: TOPIC, + slots: { + [KEY]: { + chips: [ + { label: 'a', message: 'a' }, + { label: 'b', message: 'b' }, + { label: 'c', message: 'c' }, + ], + messageId: MSG, + status: 'ready', + }, + }, }); - render(); + render(); expect(screen.getAllByRole('button')).toHaveLength(3); }); it('renders chips when the stored messageId matches a child block id of the bound group', () => { displayMessagesMock = [{ children: [{ id: CHILD_MSG }], id: MSG }]; useFollowUpActionStore.setState({ - chips: [{ label: 'a', message: 'a' }], - messageId: CHILD_MSG, - status: 'ready', - topicId: TOPIC, + slots: { + [KEY]: { + chips: [{ label: 'a', message: 'a' }], + messageId: CHILD_MSG, + status: 'ready', + }, + }, }); - render(); + render(); expect(screen.getAllByRole('button')).toHaveLength(1); }); it('fills the input and consumes on click instead of sending', () => { useFollowUpActionStore.setState({ - chips: [{ label: 'go', message: 'go ahead' }], - messageId: MSG, - status: 'ready', - topicId: TOPIC, + slots: { + [KEY]: { + chips: [{ label: 'go', message: 'go ahead' }], + messageId: MSG, + status: 'ready', + }, + }, }); - render(); + render(); fireEvent.click(screen.getByRole('button', { name: 'go' })); expect(updateInputMessageMock).toHaveBeenCalledWith('go ahead'); expect(editorSetDocumentMock).toHaveBeenCalledWith('text', 'go ahead'); expect(editorFocusMock).toHaveBeenCalled(); - // The chip is not consumed on click — it stays ready until the user sends. - expect(useFollowUpActionStore.getState().status).toBe('ready'); + expect(useFollowUpActionStore.getState().slots[KEY]?.status).toBe('ready'); }); }); diff --git a/src/features/Conversation/FollowUp/FollowUpChips.tsx b/src/features/Conversation/FollowUp/FollowUpChips.tsx index 5e075f1bb9..e59913689e 100644 --- a/src/features/Conversation/FollowUp/FollowUpChips.tsx +++ b/src/features/Conversation/FollowUp/FollowUpChips.tsx @@ -11,27 +11,22 @@ import { messageStateSelectors } from '../store'; import { styles } from './style'; interface FollowUpChipsProps { + conversationKey: string; messageId: string; - topicId: string; } -const FollowUpChips = memo(({ messageId, topicId }) => { - // For assistantGroup, the server resolves the latest answer message id which - // lives inside `children`, not as the top-level group id. Collect children ids - // as a stable primitive so the followUpAction selector can match either. +const FollowUpChips = memo(({ conversationKey, messageId }) => { const childIdsKey = useConversationStore((s) => { const m = s.displayMessages.find((x) => x.id === messageId); return m?.children?.map((c) => c.id).join('|') ?? ''; }); const selector = useMemo( - () => followUpActionSelectors.chipsFor({ childIdsKey, messageId, topicId }), - [childIdsKey, messageId, topicId], + () => followUpActionSelectors.chipsFor({ childIdsKey, conversationKey, messageId }), + [childIdsKey, conversationKey, messageId], ); const chips = useFollowUpActionStore(selector); const updateInputMessage = useConversationStore((s) => s.updateInputMessage); const editor = useConversationStore((s) => s.editor); - // Hide chips while the bound group/message is still being generated — chips - // are only valid for a fully settled assistant turn. const isGenerating = useConversationStore( messageStateSelectors.isAssistantGroupItemGenerating(messageId), ); diff --git a/src/features/Conversation/hooks/useChatFollowUp.test.ts b/src/features/Conversation/hooks/useChatFollowUp.test.ts new file mode 100644 index 0000000000..f897868fa2 --- /dev/null +++ b/src/features/Conversation/hooks/useChatFollowUp.test.ts @@ -0,0 +1,233 @@ +import { type LobeAgentChatConfig } from '@lobechat/types'; +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useFollowUpActionStore } from '@/store/followUpAction'; +import { useUserStore } from '@/store/user'; + +import { useChatFollowUp } from './useChatFollowUp'; + +const VALID_GLOBAL = { + enabled: true, + model: 'global-model', + provider: 'global-provider', +}; + +const VALID_AGENT: LobeAgentChatConfig = { + enableFollowUpChips: true, +} as LobeAgentChatConfig; + +const CONVERSATION_KEY = 'main_agent-1_topic-1'; +const TOPIC_ID = 'topic-1'; + +type Mock = ReturnType; + +vi.mock('@/store/user', () => ({ + useUserStore: vi.fn(), +})); + +describe('useChatFollowUp', () => { + let fetchFor: ReturnType; + let clear: ReturnType; + + beforeEach(() => { + fetchFor = vi.fn().mockResolvedValue(undefined); + clear = vi.fn(); + + vi.spyOn(useFollowUpActionStore, 'getState').mockReturnValue({ + fetchFor, + clear, + } as any); + + (useUserStore as unknown as Mock).mockImplementation((selector: any) => + selector({ + settings: { systemAgent: { followUpAction: VALID_GLOBAL } }, + }), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + (useUserStore as unknown as Mock).mockReset(); + }); + + describe('disabled effective state — returns empty hooks', () => { + const renderWith = (config: { + agentChatConfig?: LobeAgentChatConfig; + conversationKey?: string; + topicId?: string; + }) => + renderHook(() => + useChatFollowUp({ + agentChatConfig: config.agentChatConfig, + conversationKey: config.conversationKey, + topicId: config.topicId, + }), + ); + + const assertEmpty = (hooks: ReturnType) => { + expect(hooks.onBeforeSendMessage).toBeUndefined(); + expect(hooks.onBeforeContinue).toBeUndefined(); + expect(hooks.onBeforeRegenerate).toBeUndefined(); + expect(hooks.onAssistantTurnSettled).toBeUndefined(); + }; + + it('when global enabled = false', () => { + (useUserStore as unknown as Mock).mockImplementation((selector: any) => + selector({ + settings: { + systemAgent: { followUpAction: { ...VALID_GLOBAL, enabled: false } }, + }, + }), + ); + const { result } = renderWith({ + agentChatConfig: VALID_AGENT, + conversationKey: CONVERSATION_KEY, + topicId: TOPIC_ID, + }); + assertEmpty(result.current); + }); + + it('when global model is empty', () => { + (useUserStore as unknown as Mock).mockImplementation((selector: any) => + selector({ + settings: { + systemAgent: { followUpAction: { ...VALID_GLOBAL, model: '' } }, + }, + }), + ); + const { result } = renderWith({ + agentChatConfig: VALID_AGENT, + conversationKey: CONVERSATION_KEY, + topicId: TOPIC_ID, + }); + assertEmpty(result.current); + }); + + it('when global provider is empty', () => { + (useUserStore as unknown as Mock).mockImplementation((selector: any) => + selector({ + settings: { + systemAgent: { followUpAction: { ...VALID_GLOBAL, provider: '' } }, + }, + }), + ); + const { result } = renderWith({ + agentChatConfig: VALID_AGENT, + conversationKey: CONVERSATION_KEY, + topicId: TOPIC_ID, + }); + assertEmpty(result.current); + }); + + it('when per-agent enableFollowUpChips is false', () => { + const { result } = renderWith({ + agentChatConfig: { enableFollowUpChips: false } as LobeAgentChatConfig, + conversationKey: CONVERSATION_KEY, + topicId: TOPIC_ID, + }); + assertEmpty(result.current); + }); + + it('when conversationKey is missing', () => { + const { result } = renderWith({ + agentChatConfig: VALID_AGENT, + conversationKey: undefined, + topicId: TOPIC_ID, + }); + assertEmpty(result.current); + }); + + it('when topicId is missing', () => { + const { result } = renderWith({ + agentChatConfig: VALID_AGENT, + conversationKey: CONVERSATION_KEY, + topicId: undefined, + }); + assertEmpty(result.current); + }); + + it('does not call clear or fetchFor when invoked through empty hooks', async () => { + const { result } = renderWith({ + agentChatConfig: { enableFollowUpChips: false } as LobeAgentChatConfig, + conversationKey: CONVERSATION_KEY, + topicId: TOPIC_ID, + }); + await result.current.onBeforeSendMessage?.({} as any); + await result.current.onAssistantTurnSettled?.('m', { reason: 'completed' }); + expect(clear).not.toHaveBeenCalled(); + expect(fetchFor).not.toHaveBeenCalled(); + }); + }); + + describe('enabled effective state', () => { + const renderEnabled = (overrides: Partial[0]> = {}) => + renderHook(() => + useChatFollowUp({ + agentChatConfig: VALID_AGENT, + conversationKey: CONVERSATION_KEY, + topicId: TOPIC_ID, + ...overrides, + }), + ); + + it('onBeforeSendMessage clears the slot', async () => { + const { result } = renderEnabled(); + await result.current.onBeforeSendMessage?.({} as any); + expect(clear).toHaveBeenCalledWith(CONVERSATION_KEY); + }); + + it('onBeforeContinue clears the slot', async () => { + const { result } = renderEnabled(); + await result.current.onBeforeContinue?.('m'); + expect(clear).toHaveBeenCalledWith(CONVERSATION_KEY); + }); + + it('onBeforeRegenerate clears the slot', async () => { + const { result } = renderEnabled(); + await result.current.onBeforeRegenerate?.('m'); + expect(clear).toHaveBeenCalledWith(CONVERSATION_KEY); + }); + + it('onAssistantTurnSettled with reason=stopped skips fetch', async () => { + const { result } = renderEnabled(); + await result.current.onAssistantTurnSettled?.('m', { reason: 'stopped' }); + expect(fetchFor).not.toHaveBeenCalled(); + }); + + it('onAssistantTurnSettled with reason=completed fires fetchFor with full params', async () => { + const { result } = renderEnabled({ threadId: 'thread-1' }); + await result.current.onAssistantTurnSettled?.('m', { reason: 'completed' }); + expect(fetchFor).toHaveBeenCalledWith(CONVERSATION_KEY, { + hint: { kind: 'chat' }, + modelConfig: { model: VALID_GLOBAL.model, provider: VALID_GLOBAL.provider }, + threadId: 'thread-1', + topicId: TOPIC_ID, + }); + }); + + it('onAssistantTurnSettled with reason=regenerated fires fetchFor', async () => { + const { result } = renderEnabled(); + await result.current.onAssistantTurnSettled?.('m', { reason: 'regenerated' }); + expect(fetchFor).toHaveBeenCalledTimes(1); + }); + + it('onAssistantTurnSettled with reason=continued fires fetchFor', async () => { + const { result } = renderEnabled(); + await result.current.onAssistantTurnSettled?.('m', { reason: 'continued' }); + expect(fetchFor).toHaveBeenCalledTimes(1); + }); + + it('clear is scoped to the passed conversationKey — different keys do not collide', async () => { + const { result: a } = renderEnabled({ conversationKey: 'key-a' }); + const { result: b } = renderEnabled({ conversationKey: 'key-b' }); + + await a.current.onBeforeSendMessage?.({} as any); + expect(clear).toHaveBeenCalledWith('key-a'); + expect(clear).not.toHaveBeenCalledWith('key-b'); + + await b.current.onBeforeSendMessage?.({} as any); + expect(clear).toHaveBeenCalledWith('key-b'); + }); + }); +}); diff --git a/src/features/Conversation/hooks/useChatFollowUp.ts b/src/features/Conversation/hooks/useChatFollowUp.ts new file mode 100644 index 0000000000..6bd0a70a36 --- /dev/null +++ b/src/features/Conversation/hooks/useChatFollowUp.ts @@ -0,0 +1,77 @@ +import { type LobeAgentChatConfig } from '@lobechat/types'; +import { useMemo } from 'react'; + +import { useFollowUpActionStore } from '@/store/followUpAction'; +import { useUserStore } from '@/store/user'; +import { systemAgentSelectors } from '@/store/user/slices/settings/selectors/systemAgent'; + +import { type ConversationHooks } from '../types'; + +interface UseChatFollowUpParams { + agentChatConfig: LobeAgentChatConfig | undefined; + conversationKey: string | undefined; + threadId?: string; + topicId: string | undefined; +} + +/** + * Wire the chat-side Follow-up Chips lifecycle. + * + * Effective enable = `systemAgent.followUpAction.enabled` AND a valid global + * model/provider AND per-agent `chatConfig.enableFollowUpChips` — otherwise + * returns an empty `ConversationHooks` object so the merge chain treats it as + * identity. + * + * Registration ordering note: callers MUST compose this hook LAST in a + * `mergeConversationHooks(...)` chain. The hook's + * `onBeforeSendMessage`/`onBeforeContinue`/`onBeforeRegenerate` clear the chip + * slot; if a preceding validator returns `false`, the chain short-circuits + * before the clear runs and chips persist for the blocked send. + */ +export const useChatFollowUp = ({ + agentChatConfig, + conversationKey, + threadId, + topicId, +}: UseChatFollowUpParams): ConversationHooks => { + const globalConfig = useUserStore(systemAgentSelectors.followUpAction); + + const effective = useMemo(() => { + const globalEnabled = globalConfig.enabled === true; + const hasValidModel = !!globalConfig.model && !!globalConfig.provider; + const perAgentEnabled = agentChatConfig?.enableFollowUpChips === true; + return globalEnabled && hasValidModel && perAgentEnabled; + }, [ + globalConfig.enabled, + globalConfig.model, + globalConfig.provider, + agentChatConfig?.enableFollowUpChips, + ]); + + return useMemo(() => { + if (!effective || !conversationKey || !topicId) return {}; + + const clearSlot = () => useFollowUpActionStore.getState().clear(conversationKey); + + return { + onAssistantTurnSettled: async (_messageId, { reason }) => { + if (reason === 'stopped') return; + await useFollowUpActionStore.getState().fetchFor(conversationKey, { + hint: { kind: 'chat' }, + modelConfig: { model: globalConfig.model, provider: globalConfig.provider }, + threadId, + topicId, + }); + }, + onBeforeContinue: async () => { + clearSlot(); + }, + onBeforeRegenerate: async () => { + clearSlot(); + }, + onBeforeSendMessage: async () => { + clearSlot(); + }, + }; + }, [effective, conversationKey, globalConfig.model, globalConfig.provider, threadId, topicId]); +}; diff --git a/src/features/Conversation/store/slices/context/selectors.ts b/src/features/Conversation/store/slices/context/selectors.ts index 5937a075cf..86dd25828f 100644 --- a/src/features/Conversation/store/slices/context/selectors.ts +++ b/src/features/Conversation/store/slices/context/selectors.ts @@ -1,3 +1,5 @@ +import { messageMapKey } from '@/store/chat/utils/messageMapKey'; + import { type State } from '../../initialState'; const context = (s: State) => s.context; @@ -7,12 +9,26 @@ const threadId = (s: State) => s.context.threadId; const isThread = (s: State) => !!s.context.threadId; const isTopic = (s: State) => !!s.context.topicId; +const conversationKey = (s: State): string => { + const { agentId, topicId, threadId, scope, isNew, groupId, subAgentId } = s.context; + return messageMapKey({ + agentId, + groupId, + isNew, + scope, + subAgentId, + threadId: threadId ?? undefined, + topicId: topicId ?? undefined, + }); +}; + const hooks = (s: State) => s.hooks; const hook = (hookName: keyof State['hooks']) => (s: State) => s.hooks[hookName]; export const contextSelectors = { agentId, context, + conversationKey, hook, hooks, isThread, diff --git a/src/features/Conversation/types/hooks.ts b/src/features/Conversation/types/hooks.ts index 4ffe9ec79f..a72842bcf7 100644 --- a/src/features/Conversation/types/hooks.ts +++ b/src/features/Conversation/types/hooks.ts @@ -69,6 +69,26 @@ export interface ConversationHooks { */ onAfterSendMessage?: () => Promise; + /** + * Fires once per assistant turn after streaming ends and any pending tool + * intervention has cleared. Use for post-turn side effects (e.g. extracting + * follow-up chips, syncing onboarding phase). + * + * Overlap: `onGenerationStop` also fires when the user stops generation; + * `onAssistantTurnSettled` will additionally fire in that case with + * `reason: 'stopped'`. Consumers tracking stop events should pick one and + * avoid double-counting. + * + * Registration order: consumers should be registered LAST in any hook-merge + * chain — the settlement handler may clear caches that depend on prior + * validators short-circuiting (e.g. a `false`-returning `onBeforeSendMessage` + * upstream must short-circuit before the chip slot is cleared). + */ + onAssistantTurnSettled?: ( + messageId: string, + meta: { reason: 'completed' | 'stopped' | 'regenerated' | 'continued' }, + ) => Promise | void; + /** * Called before continuing generation * @@ -103,6 +123,10 @@ export interface ConversationHooks { */ onBeforeSendMessage?: (params: SendMessageParams) => Promise; + // ======================================== + // Generation State Change Hooks + // ======================================== + /** * Called after continue generation completes * @@ -110,31 +134,6 @@ export interface ConversationHooks { */ onContinueComplete?: (messageId: string) => void; - // ======================================== - // Generation State Change Hooks - // ======================================== - - /** - * Called when AI generation is cancelled - * - * @param operationId - The operation ID for this generation - */ - onGenerationCancelled?: (operationId: string) => void; - - /** - * Called when AI generation completes successfully - * - * @param operationId - The operation ID for this generation - */ - onGenerationComplete?: (operationId: string) => void; - - /** - * Called when AI generation starts - * - * @param operationId - The operation ID for this generation - */ - onGenerationStart?: (operationId: string) => void; - /** * Called when generation is stopped by user */ diff --git a/src/features/Conversation/utils/mergeConversationHooks.test.ts b/src/features/Conversation/utils/mergeConversationHooks.test.ts new file mode 100644 index 0000000000..9e57e16097 --- /dev/null +++ b/src/features/Conversation/utils/mergeConversationHooks.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { type ConversationHooks } from '../types'; +import { mergeConversationHooks } from './mergeConversationHooks'; + +describe('mergeConversationHooks', () => { + it('returns empty object when no hooks supplied', () => { + expect(mergeConversationHooks()).toEqual({}); + expect(mergeConversationHooks(undefined)).toEqual({}); + expect(mergeConversationHooks(undefined, undefined)).toEqual({}); + }); + + it('returns the single hook object when only one is defined', () => { + const onMessageCopied = vi.fn(); + const merged = mergeConversationHooks({ onMessageCopied }); + expect(merged.onMessageCopied).toBe(onMessageCopied); + }); + + it('invokes hooks of disjoint members from both inputs', async () => { + const onMessageCopied = vi.fn(); + const onAfterSendMessage = vi.fn(); + + const merged = mergeConversationHooks({ onMessageCopied }, { onAfterSendMessage }); + + merged.onMessageCopied?.('m-1'); + await merged.onAfterSendMessage?.(); + + expect(onMessageCopied).toHaveBeenCalledWith('m-1'); + expect(onAfterSendMessage).toHaveBeenCalledTimes(1); + }); + + it('invokes both onAssistantTurnSettled handlers in order with same args', async () => { + const order: string[] = []; + const a = vi.fn(async () => { + order.push('a'); + }); + const b = vi.fn(async () => { + order.push('b'); + }); + + const merged = mergeConversationHooks( + { onAssistantTurnSettled: a }, + { onAssistantTurnSettled: b }, + ); + + await merged.onAssistantTurnSettled?.('msg-1', { reason: 'completed' }); + + expect(a).toHaveBeenCalledWith('msg-1', { reason: 'completed' }); + expect(b).toHaveBeenCalledWith('msg-1', { reason: 'completed' }); + expect(order).toEqual(['a', 'b']); + }); + + it('short-circuits onBeforeSendMessage chain on first false', async () => { + const first = vi.fn(async () => undefined); + const second = vi.fn(async () => false); + const third = vi.fn(async () => undefined); + + const merged = mergeConversationHooks( + { onBeforeSendMessage: first as ConversationHooks['onBeforeSendMessage'] }, + { onBeforeSendMessage: second as ConversationHooks['onBeforeSendMessage'] }, + { onBeforeSendMessage: third as ConversationHooks['onBeforeSendMessage'] }, + ); + + const result = await merged.onBeforeSendMessage?.({ message: 'hi' } as any); + + expect(result).toBe(false); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + expect(third).not.toHaveBeenCalled(); + }); + + it('continues the chain when before-hooks return undefined / true', async () => { + const first = vi.fn(async () => undefined); + const second = vi.fn(async () => true); + const third = vi.fn(async () => undefined); + + const merged = mergeConversationHooks( + { onBeforeRegenerate: first as ConversationHooks['onBeforeRegenerate'] }, + { onBeforeRegenerate: second as ConversationHooks['onBeforeRegenerate'] }, + { onBeforeRegenerate: third as ConversationHooks['onBeforeRegenerate'] }, + ); + + const result = await merged.onBeforeRegenerate?.('msg-1'); + + expect(result).toBeUndefined(); + expect(first).toHaveBeenCalled(); + expect(second).toHaveBeenCalled(); + expect(third).toHaveBeenCalled(); + }); + + it('skips undefined entries in the input array', async () => { + const onAfterSendMessage = vi.fn(); + const merged = mergeConversationHooks(undefined, { onAfterSendMessage }, undefined); + await merged.onAfterSendMessage?.(); + expect(onAfterSendMessage).toHaveBeenCalledTimes(1); + }); + + it('treats absent fields as no-ops without throwing', async () => { + const merged = mergeConversationHooks({}, {}); + expect(merged.onAssistantTurnSettled).toBeUndefined(); + expect(merged.onBeforeSendMessage).toBeUndefined(); + }); +}); diff --git a/src/features/Conversation/utils/mergeConversationHooks.ts b/src/features/Conversation/utils/mergeConversationHooks.ts new file mode 100644 index 0000000000..a3c095ab64 --- /dev/null +++ b/src/features/Conversation/utils/mergeConversationHooks.ts @@ -0,0 +1,52 @@ +import { type ConversationHooks } from '../types'; + +const BLOCKING_HOOK_KEYS = new Set([ + 'onBeforeContinue', + 'onBeforeRegenerate', + 'onBeforeSendMessage', +]); + +const collectHookNames = (hooks: ConversationHooks[]): Set => { + const names = new Set(); + for (const h of hooks) { + for (const key of Object.keys(h)) { + if (typeof h[key] === 'function') names.add(key); + } + } + return names; +}; + +export const mergeConversationHooks = ( + ...hooks: (ConversationHooks | undefined)[] +): ConversationHooks => { + const defined = hooks.filter((h): h is ConversationHooks => !!h); + if (defined.length === 0) return {}; + if (defined.length === 1) return defined[0]; + + const merged: ConversationHooks = {}; + const names = collectHookNames(defined); + + for (const name of names) { + if (BLOCKING_HOOK_KEYS.has(name)) { + merged[name] = async (...args: unknown[]) => { + for (const h of defined) { + const fn = h[name]; + if (typeof fn !== 'function') continue; + const result = await fn(...args); + if (result === false) return false; + } + return undefined; + }; + } else { + merged[name] = async (...args: unknown[]) => { + for (const h of defined) { + const fn = h[name]; + if (typeof fn !== 'function') continue; + await fn(...args); + } + }; + } + } + + return merged; +}; diff --git a/src/features/FloatingChatPanel/index.tsx b/src/features/FloatingChatPanel/index.tsx index f2674d390b..c8eed25448 100644 --- a/src/features/FloatingChatPanel/index.tsx +++ b/src/features/FloatingChatPanel/index.tsx @@ -11,9 +11,13 @@ import { type ConversationHooks, ConversationProvider, } from '@/features/Conversation'; +import { useChatFollowUp } from '@/features/Conversation/hooks/useChatFollowUp'; import { type ConversationContext } from '@/features/Conversation/types'; +import { mergeConversationHooks } from '@/features/Conversation/utils/mergeConversationHooks'; import { useOperationState } from '@/hooks/useOperationState'; import { useActionsBarConfig } from '@/routes/(main)/agent/features/Conversation/useActionsBarConfig'; +import { useAgentStore } from '@/store/agent'; +import { chatConfigByIdSelectors } from '@/store/agent/selectors'; import { useChatStore } from '@/store/chat'; import { messageMapKey } from '@/store/chat/utils/messageMapKey'; @@ -158,17 +162,28 @@ const FloatingChatPanel = memo( const [open, setOpen] = useState(true); const [activeSnapPoint, setActiveSnapPoint] = useState(REST_SNAP_POINT); + const agentChatConfig = useAgentStore(chatConfigByIdSelectors.getChatConfigById(agentId)); + const chatFollowUpHooks = useChatFollowUp({ + agentChatConfig, + conversationKey: chatKey, + threadId: threadId ?? undefined, + topicId: topicId ?? undefined, + }); + const mergedHooks = useMemo( - () => ({ - ...hooks, - // Expand the sheet the moment the user presses Send, so the chat grows - // into view before the AI response streams in — not after it finishes. - onBeforeSendMessage: async (params) => { - setActiveSnapPoint(MAX_SNAP_POINT); - return hooks?.onBeforeSendMessage?.(params); - }, - }), - [hooks], + () => + mergeConversationHooks( + hooks, + { + // Expand the sheet the moment the user presses Send, so the chat grows + // into view before the AI response streams in — not after it finishes. + onBeforeSendMessage: async () => { + setActiveSnapPoint(MAX_SNAP_POINT); + }, + }, + chatFollowUpHooks, + ), + [hooks, chatFollowUpHooks], ); const sheetProps: FloatingSheetProps = { diff --git a/src/features/Onboarding/Agent/Conversation.test.tsx b/src/features/Onboarding/Agent/Conversation.test.tsx index a6aa04cec4..0bf291c11d 100644 --- a/src/features/Onboarding/Agent/Conversation.test.tsx +++ b/src/features/Onboarding/Agent/Conversation.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import type { ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -17,7 +17,6 @@ const { chatInputSpy, messageItemSpy, mockState } = vi.hoisted(() => ({ messageItemSpy: vi.fn(), mockState: { displayMessages: [] as Array<{ content?: string; id: string; role: string }>, - generatingIds: new Set(), pendingInterventions: [] as Array<{ id: string }>, }, })); @@ -91,7 +90,6 @@ describe('AgentOnboardingConversation', () => { chatInputSpy.mockClear(); messageItemSpy.mockClear(); mockState.displayMessages = []; - mockState.generatingIds = new Set(); mockState.pendingInterventions = []; }); @@ -195,101 +193,6 @@ describe('AgentOnboardingConversation', () => { ); }); - it('fires the assistant-settled callback after the latest assistant stops generating', async () => { - const onAssistantTurnSettled = vi.fn(); - mockState.displayMessages = [ - { id: 'user-1', role: 'user' }, - { id: 'assistant-1', role: 'assistant' }, - ]; - mockState.generatingIds = new Set(['assistant-1']); - - const { rerender } = render( - , - ); - - expect(onAssistantTurnSettled).not.toHaveBeenCalled(); - - mockState.generatingIds = new Set(); - rerender( - , - ); - - await waitFor(() => { - expect(onAssistantTurnSettled).toHaveBeenCalledWith('assistant-1'); - }); - expect(onAssistantTurnSettled).toHaveBeenCalledTimes(1); - }); - - it('waits for resumed generation after a pending intervention clears', async () => { - const onAssistantTurnSettled = vi.fn(); - mockState.displayMessages = [ - { id: 'user-1', role: 'user' }, - { id: 'assistant-1', role: 'assistant' }, - ]; - mockState.generatingIds = new Set(['assistant-1']); - - const { rerender } = render( - , - ); - - mockState.generatingIds = new Set(); - mockState.pendingInterventions = [{ id: 'tool-1' }]; - rerender( - , - ); - expect(onAssistantTurnSettled).not.toHaveBeenCalled(); - - mockState.pendingInterventions = []; - rerender( - , - ); - expect(onAssistantTurnSettled).not.toHaveBeenCalled(); - - mockState.generatingIds = new Set(['assistant-1']); - rerender( - , - ); - expect(onAssistantTurnSettled).not.toHaveBeenCalled(); - - mockState.generatingIds = new Set(); - rerender( - , - ); - - await waitFor(() => { - expect(onAssistantTurnSettled).toHaveBeenCalledWith('assistant-1'); - }); - expect(onAssistantTurnSettled).toHaveBeenCalledTimes(1); - }); - it('renders normal message items outside the greeting state', () => { mockState.displayMessages = [ { id: 'assistant-1', role: 'assistant' }, @@ -323,8 +226,4 @@ vi.mock('@/features/Conversation/store', () => ({ dataSelectors: { pendingInterventions: (state: typeof mockState) => state.pendingInterventions, }, - messageStateSelectors: { - isAssistantGroupItemGenerating: (id: string) => (state: typeof mockState) => - state.generatingIds.has(id), - }, })); diff --git a/src/features/Onboarding/Agent/Conversation.tsx b/src/features/Onboarding/Agent/Conversation.tsx index 9669b4a205..d746394be5 100644 --- a/src/features/Onboarding/Agent/Conversation.tsx +++ b/src/features/Onboarding/Agent/Conversation.tsx @@ -16,7 +16,7 @@ import { MessageItem, useConversationStore, } from '@/features/Conversation'; -import { dataSelectors, messageStateSelectors } from '@/features/Conversation/store'; +import { dataSelectors } from '@/features/Conversation/store'; import WideScreenContainer from '@/features/WideScreenContainer'; import { useIsMobile } from '@/hooks/useIsMobile'; import type { OnboardingPhase } from '@/types/user'; @@ -29,8 +29,6 @@ import WelcomeMobile from './Welcome.mobile'; import WelcomeMessage from './WelcomeMessage'; import WrapUpHint from './WrapUpHint'; -const assistantLikeRoles = new Set(['assistant', 'assistantGroup', 'supervisor']); - interface AgentOnboardingConversationProps { discoveryUserMessageCount?: number; feedbackSubmitted?: boolean; @@ -46,7 +44,6 @@ interface AgentOnboardingConversationProps { // is ready to handle the first send. isInputReady?: boolean; onAfterWrapUp?: () => Promise | void; - onAssistantTurnSettled?: (messageId: string) => Promise | void; onboardingFinished?: boolean; phase?: OnboardingPhase; readOnly?: boolean; @@ -70,7 +67,6 @@ const AgentOnboardingConversation = memo( hasMessages, isInputReady = true, onAfterWrapUp, - onAssistantTurnSettled, onboardingFinished, phase, readOnly, @@ -79,9 +75,6 @@ const AgentOnboardingConversation = memo( }) => { const isMobile = useIsMobile(); const displayMessages = useConversationStore(conversationSelectors.displayMessages); - const pendingInterventionCount = useConversationStore( - (s) => dataSelectors.pendingInterventions(s).length, - ); // The agent-marketplace intervention renders as an absolute overlay anchored // to the chat input area, which would otherwise occlude the last message. // Reserve matching scroll headroom inside ChatList so the latest message can @@ -105,23 +98,8 @@ const AgentOnboardingConversation = memo( [hasMessages, displayMessages], ); - const latestAssistantMessageId = useMemo(() => { - const latest = displayMessages.at(-1); - if (!latest || !assistantLikeRoles.has(latest.role)) return undefined; - - return latest.id; - }, [displayMessages]); - - const isLatestAssistantGenerating = useConversationStore((s) => - latestAssistantMessageId - ? messageStateSelectors.isAssistantGroupItemGenerating(latestAssistantMessageId)(s) - : false, - ); - const [showGreeting, setShowGreeting] = useState(isGreetingState); const prevGreetingRef = useRef(isGreetingState); - const armedSettledMessageIdRef = useRef(undefined); - const firedSettledMessageIdRef = useRef(undefined); useEffect(() => { if (prevGreetingRef.current && !isGreetingState) { @@ -140,37 +118,9 @@ const AgentOnboardingConversation = memo( prevGreetingRef.current = isGreetingState; }, [isGreetingState]); - useEffect(() => { - if (!onAssistantTurnSettled || !latestAssistantMessageId) return; - - if (pendingInterventionCount > 0) { - armedSettledMessageIdRef.current = undefined; - return; - } - - if (isLatestAssistantGenerating) { - armedSettledMessageIdRef.current = latestAssistantMessageId; - return; - } - - if (armedSettledMessageIdRef.current !== latestAssistantMessageId) return; - if (firedSettledMessageIdRef.current === latestAssistantMessageId) return; - - firedSettledMessageIdRef.current = latestAssistantMessageId; - armedSettledMessageIdRef.current = undefined; - void onAssistantTurnSettled(latestAssistantMessageId); - }, [ - isLatestAssistantGenerating, - latestAssistantMessageId, - onAssistantTurnSettled, - pendingInterventionCount, - ]); - const hasPersistedAssistantOpener = displayMessages.at(0)?.role === 'assistant'; const shouldShowSyntheticWelcome = - !onboardingFinished && - !hasPersistedAssistantOpener && - displayMessages.length > 0; + !onboardingFinished && !hasPersistedAssistantOpener && displayMessages.length > 0; const shouldShowGreetingWelcome = showGreeting && !onboardingFinished; const shouldShowGreetingActions = showGreeting && !onboardingFinished; diff --git a/src/features/Onboarding/Agent/index.tsx b/src/features/Onboarding/Agent/index.tsx index b4fddfa2da..e71b0d6cc7 100644 --- a/src/features/Onboarding/Agent/index.tsx +++ b/src/features/Onboarding/Agent/index.tsx @@ -14,6 +14,8 @@ import { useNavigate } from 'react-router-dom'; import Loading from '@/components/Loading/BrandTextLoading'; import { ONBOARDING_PRODUCTION_DEFAULT_MODEL } from '@/const/onboarding'; +import { type ConversationHooks } from '@/features/Conversation/types'; +import { mergeConversationHooks } from '@/features/Conversation/utils/mergeConversationHooks'; import ModeSwitch from '@/features/Onboarding/components/ModeSwitch'; import { useOnboardingAgentTemplates } from '@/hooks/useOnboardingAgentTemplates'; import { useClientDataSWR, useOnlyFetchOnceSWR } from '@/libs/swr'; @@ -141,12 +143,14 @@ const AgentOnboardingPage = memo(() => { [onboardingAgentConfig?.model, onboardingAgentConfig?.provider], ); - const onboardingFollowUp = useOnboardingFollowUp({ + const onboardingFollowUpHooks = useOnboardingFollowUp({ enabled: !onboardingFinished && !viewingHistoricalTopic, isGreeting, modelConfig: onboardingFollowUpModelConfig, + onboardingAgentId, + phase: data?.context?.phase, + topicId: effectiveTopicId, }); - const { onBeforeSendMessage, triggerExtract } = onboardingFollowUp; // Re-entry latch for the fresh-state first-send orchestration. The combination // of advisory lock + this ref ensures rapid double-submit cannot create two @@ -157,7 +161,6 @@ const AgentOnboardingPage = memo(() => { const composedOnBeforeSendMessage = useCallback( async (params: SendMessageParams): Promise => { params.metadata = { ...params.metadata, trigger: RequestTrigger.Onboarding }; - await onBeforeSendMessage(); if (!onboardingAgentId) { // ChatInput is gated by `isInputReady`; this branch should be unreachable. @@ -212,7 +215,7 @@ const AgentOnboardingPage = memo(() => { await orchestration; return false; }, - [effectiveTopicId, mutate, onBeforeSendMessage, onboardingAgentId], + [effectiveTopicId, mutate, onboardingAgentId], ); const syncOnboardingContext = useCallback(async () => { @@ -223,43 +226,52 @@ const AgentOnboardingPage = memo(() => { return nextContext; }, [mutate, mutateHistoryTopics, onboardingAgentId]); - const handleAssistantTurnSettled = useCallback(async () => { - if (!effectiveTopicId) return; + const onboardingTurnSettledHook = useMemo(() => { + if (onboardingFinished || viewingHistoricalTopic) return {}; - const prevPhase = data?.context?.phase; - const prevFinishedAt = agentOnboarding?.finishedAt; + return { + onAssistantTurnSettled: async () => { + if (!effectiveTopicId) return; - const extractPromise = triggerExtract(effectiveTopicId, prevPhase); + const prevPhase = data?.context?.phase; + const prevFinishedAt = agentOnboarding?.finishedAt; - // Sync first to learn the next phase/finishedAt; only then decide whether - // the heavier user-store / builtin-agent refreshes are needed this turn. - const [nextContext] = await Promise.all([syncOnboardingContext(), extractPromise]); + const nextContext = await syncOnboardingContext(); + const newPhase = nextContext?.context?.phase; + const newFinishedAt = nextContext?.agentOnboarding?.finishedAt; - const newPhase = nextContext?.context?.phase; - const newFinishedAt = nextContext?.agentOnboarding?.finishedAt; - - const refreshes: Promise[] = []; - if (newFinishedAt !== prevFinishedAt) refreshes.push(refreshUserState()); - if (newPhase !== prevPhase) { - refreshes.push(refreshBuiltinAgent(BUILTIN_AGENT_SLUGS.webOnboarding)); - } - if (refreshes.length > 0) await Promise.all(refreshes); + const refreshes: Promise[] = []; + if (newFinishedAt !== prevFinishedAt) refreshes.push(refreshUserState()); + if (newPhase !== prevPhase) { + refreshes.push(refreshBuiltinAgent(BUILTIN_AGENT_SLUGS.webOnboarding)); + } + if (refreshes.length > 0) await Promise.all(refreshes); + }, + }; }, [ - agentOnboarding?.finishedAt, - data?.context?.phase, + onboardingFinished, + viewingHistoricalTopic, effectiveTopicId, + data?.context?.phase, + agentOnboarding?.finishedAt, refreshBuiltinAgent, refreshUserState, syncOnboardingContext, - triggerExtract, ]); - const assistantTurnSettledHandler = - onboardingFinished || viewingHistoricalTopic ? undefined : handleAssistantTurnSettled; - const conversationHooks = useMemo( - () => (onboardingFinished ? undefined : { onBeforeSendMessage: composedOnBeforeSendMessage }), - [onboardingFinished, composedOnBeforeSendMessage], - ); + const conversationHooks = useMemo(() => { + if (onboardingFinished) return undefined; + return mergeConversationHooks( + { onBeforeSendMessage: composedOnBeforeSendMessage }, + onboardingTurnSettledHook, + onboardingFollowUpHooks, + ); + }, [ + onboardingFinished, + composedOnBeforeSendMessage, + onboardingTurnSettledHook, + onboardingFollowUpHooks, + ]); if (error) { return ( @@ -316,7 +328,6 @@ const AgentOnboardingPage = memo(() => { showFeedback={!viewingHistoricalTopic} topicId={effectiveTopicId} onAfterWrapUp={syncOnboardingContext} - onAssistantTurnSettled={assistantTurnSettledHandler} /> diff --git a/src/features/Onboarding/Agent/useOnboardingFollowUp.test.ts b/src/features/Onboarding/Agent/useOnboardingFollowUp.test.ts index 6fa5c2a199..82a3dd5ef8 100644 --- a/src/features/Onboarding/Agent/useOnboardingFollowUp.test.ts +++ b/src/features/Onboarding/Agent/useOnboardingFollowUp.test.ts @@ -1,6 +1,7 @@ import { renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { messageMapKey } from '@/store/chat/utils/messageMapKey'; import { useFollowUpActionStore } from '@/store/followUpAction'; import { useOnboardingFollowUp } from './useOnboardingFollowUp'; @@ -9,6 +10,9 @@ const MODEL_CONFIG = { model: 'scene-model', provider: 'scene-provider', }; +const AGENT_ID = 'agent-onboarding'; +const TOPIC_ID = 'topic-1'; +const CONVERSATION_KEY = messageMapKey({ agentId: AGENT_ID, topicId: TOPIC_ID }); describe('useOnboardingFollowUp', () => { let fetchFor: ReturnType; @@ -27,65 +31,186 @@ describe('useOnboardingFollowUp', () => { vi.restoreAllMocks(); }); - it('triggerExtract skips when disabled', async () => { + it('returns no hooks when disabled', async () => { const { result } = renderHook(() => - useOnboardingFollowUp({ enabled: false, isGreeting: false, modelConfig: MODEL_CONFIG }), + useOnboardingFollowUp({ + enabled: false, + isGreeting: false, + modelConfig: MODEL_CONFIG, + onboardingAgentId: AGENT_ID, + phase: 'discovery', + topicId: TOPIC_ID, + }), ); - await result.current.triggerExtract('topic-1', 'discovery'); + expect(result.current.onAssistantTurnSettled).toBeUndefined(); + expect(result.current.onBeforeSendMessage).toBeUndefined(); + }); + + it('returns no hooks when onboardingAgentId is missing', async () => { + const { result } = renderHook(() => + useOnboardingFollowUp({ + enabled: true, + isGreeting: false, + modelConfig: MODEL_CONFIG, + onboardingAgentId: undefined, + phase: 'discovery', + topicId: TOPIC_ID, + }), + ); + expect(result.current.onAssistantTurnSettled).toBeUndefined(); + expect(result.current.onBeforeSendMessage).toBeUndefined(); + }); + + it('returns no hooks when topicId is missing', async () => { + const { result } = renderHook(() => + useOnboardingFollowUp({ + enabled: true, + isGreeting: false, + modelConfig: MODEL_CONFIG, + onboardingAgentId: AGENT_ID, + phase: 'discovery', + topicId: undefined, + }), + ); + expect(result.current.onAssistantTurnSettled).toBeUndefined(); + expect(result.current.onBeforeSendMessage).toBeUndefined(); + }); + + it('onAssistantTurnSettled skips when phase is undefined', async () => { + const { result } = renderHook(() => + useOnboardingFollowUp({ + enabled: true, + isGreeting: false, + modelConfig: MODEL_CONFIG, + onboardingAgentId: AGENT_ID, + phase: undefined, + topicId: TOPIC_ID, + }), + ); + await result.current.onAssistantTurnSettled?.('msg-1', { reason: 'completed' }); expect(fetchFor).not.toHaveBeenCalled(); }); - it('triggerExtract skips when phase is undefined', async () => { + it('onAssistantTurnSettled skips when phase is summary', async () => { const { result } = renderHook(() => - useOnboardingFollowUp({ enabled: true, isGreeting: false, modelConfig: MODEL_CONFIG }), + useOnboardingFollowUp({ + enabled: true, + isGreeting: false, + modelConfig: MODEL_CONFIG, + onboardingAgentId: AGENT_ID, + phase: 'summary', + topicId: TOPIC_ID, + }), ); - await result.current.triggerExtract('topic-1', undefined); + await result.current.onAssistantTurnSettled?.('msg-1', { reason: 'completed' }); expect(fetchFor).not.toHaveBeenCalled(); }); - it('triggerExtract skips when phase is summary', async () => { + it('onAssistantTurnSettled skips when isGreeting is true', async () => { const { result } = renderHook(() => - useOnboardingFollowUp({ enabled: true, isGreeting: false, modelConfig: MODEL_CONFIG }), + useOnboardingFollowUp({ + enabled: true, + isGreeting: true, + modelConfig: MODEL_CONFIG, + onboardingAgentId: AGENT_ID, + phase: 'agent_identity', + topicId: TOPIC_ID, + }), ); - await result.current.triggerExtract('topic-1', 'summary'); + await result.current.onAssistantTurnSettled?.('msg-1', { reason: 'completed' }); expect(fetchFor).not.toHaveBeenCalled(); }); - it('triggerExtract skips when isGreeting is true', async () => { + it('onAssistantTurnSettled skips when reason is stopped', async () => { const { result } = renderHook(() => - useOnboardingFollowUp({ enabled: true, isGreeting: true, modelConfig: MODEL_CONFIG }), + useOnboardingFollowUp({ + enabled: true, + isGreeting: false, + modelConfig: MODEL_CONFIG, + onboardingAgentId: AGENT_ID, + phase: 'discovery', + topicId: TOPIC_ID, + }), ); - await result.current.triggerExtract('topic-1', 'agent_identity'); + await result.current.onAssistantTurnSettled?.('msg-1', { reason: 'stopped' }); expect(fetchFor).not.toHaveBeenCalled(); }); - it('triggerExtract fires fetchFor with onboarding hint on a normal turn', async () => { + it('onAssistantTurnSettled fires fetchFor with onboarding hint on a normal turn', async () => { const { result } = renderHook(() => - useOnboardingFollowUp({ enabled: true, isGreeting: false, modelConfig: MODEL_CONFIG }), + useOnboardingFollowUp({ + enabled: true, + isGreeting: false, + modelConfig: MODEL_CONFIG, + onboardingAgentId: AGENT_ID, + phase: 'discovery', + topicId: TOPIC_ID, + }), ); - await result.current.triggerExtract('topic-1', 'discovery'); - expect(fetchFor).toHaveBeenCalledWith('topic-1', { + await result.current.onAssistantTurnSettled?.('msg-1', { reason: 'completed' }); + expect(fetchFor).toHaveBeenCalledWith(CONVERSATION_KEY, { hint: { kind: 'onboarding', phase: 'discovery', }, modelConfig: MODEL_CONFIG, + topicId: TOPIC_ID, + }); + }); + + it('onAssistantTurnSettled uses the phase snapshot captured at memoize time', async () => { + const { result, rerender } = renderHook( + (props: { phase: 'discovery' | 'agent_identity' }) => + useOnboardingFollowUp({ + enabled: true, + isGreeting: false, + modelConfig: MODEL_CONFIG, + onboardingAgentId: AGENT_ID, + phase: props.phase, + topicId: TOPIC_ID, + }), + { initialProps: { phase: 'discovery' } }, + ); + const fired = result.current.onAssistantTurnSettled; + rerender({ phase: 'agent_identity' }); + await fired?.('msg-1', { reason: 'completed' }); + expect(fetchFor).toHaveBeenCalledWith(CONVERSATION_KEY, { + hint: { + kind: 'onboarding', + phase: 'discovery', + }, + modelConfig: MODEL_CONFIG, + topicId: TOPIC_ID, }); }); it('onBeforeSendMessage clears when enabled', async () => { const { result } = renderHook(() => - useOnboardingFollowUp({ enabled: true, isGreeting: false, modelConfig: MODEL_CONFIG }), + useOnboardingFollowUp({ + enabled: true, + isGreeting: false, + modelConfig: MODEL_CONFIG, + onboardingAgentId: AGENT_ID, + phase: 'discovery', + topicId: TOPIC_ID, + }), ); - await result.current.onBeforeSendMessage(); - expect(clear).toHaveBeenCalledTimes(1); + await result.current.onBeforeSendMessage?.({} as any); + expect(clear).toHaveBeenCalledWith(CONVERSATION_KEY); }); - it('onBeforeSendMessage does nothing when disabled', async () => { + it('onBeforeSendMessage is absent when disabled', async () => { const { result } = renderHook(() => - useOnboardingFollowUp({ enabled: false, isGreeting: false, modelConfig: MODEL_CONFIG }), + useOnboardingFollowUp({ + enabled: false, + isGreeting: false, + modelConfig: MODEL_CONFIG, + onboardingAgentId: AGENT_ID, + phase: 'discovery', + topicId: TOPIC_ID, + }), ); - await result.current.onBeforeSendMessage(); + expect(result.current.onBeforeSendMessage).toBeUndefined(); expect(clear).not.toHaveBeenCalled(); }); }); diff --git a/src/features/Onboarding/Agent/useOnboardingFollowUp.ts b/src/features/Onboarding/Agent/useOnboardingFollowUp.ts index 0337bca1d1..66839f9de3 100644 --- a/src/features/Onboarding/Agent/useOnboardingFollowUp.ts +++ b/src/features/Onboarding/Agent/useOnboardingFollowUp.ts @@ -1,6 +1,8 @@ import type { FollowUpModelConfig } from '@lobechat/types'; -import { useCallback } from 'react'; +import { useMemo } from 'react'; +import { type ConversationHooks } from '@/features/Conversation/types'; +import { messageMapKey } from '@/store/chat/utils/messageMapKey'; import { useFollowUpActionStore } from '@/store/followUpAction'; import type { OnboardingPhase } from '@/types/user'; @@ -8,37 +10,48 @@ interface UseOnboardingFollowUpParams { enabled: boolean; isGreeting: boolean; modelConfig: FollowUpModelConfig; -} - -interface OnboardingFollowUpHandlers { - onBeforeSendMessage: () => Promise; - triggerExtract: (topicId: string, phase: OnboardingPhase | undefined) => Promise; + onboardingAgentId: string | undefined; + phase: OnboardingPhase | undefined; + topicId: string | undefined; } export const useOnboardingFollowUp = ({ enabled, isGreeting, modelConfig, -}: UseOnboardingFollowUpParams): OnboardingFollowUpHandlers => { - const triggerExtract = useCallback( - async (topicId: string, phase: OnboardingPhase | undefined) => { - if (!enabled) return; - if (!phase) return; - if (phase === 'summary') return; - if (isGreeting) return; + onboardingAgentId, + phase, + topicId, +}: UseOnboardingFollowUpParams): ConversationHooks => { + return useMemo(() => { + if (!enabled || !onboardingAgentId || !topicId) return {}; - await useFollowUpActionStore.getState().fetchFor(topicId, { - hint: { kind: 'onboarding', phase }, - modelConfig, - }); - }, - [enabled, isGreeting, modelConfig], - ); + const conversationKey = messageMapKey({ agentId: onboardingAgentId, topicId }); + const phaseSnapshot = phase; - const onBeforeSendMessage = useCallback(async () => { - if (!enabled) return; - useFollowUpActionStore.getState().clear(); - }, [enabled]); - - return { onBeforeSendMessage, triggerExtract }; + return { + onAssistantTurnSettled: async (_messageId, { reason }) => { + if (reason === 'stopped') return; + if (isGreeting) return; + if (!phaseSnapshot) return; + if (phaseSnapshot === 'summary') return; + await useFollowUpActionStore.getState().fetchFor(conversationKey, { + hint: { kind: 'onboarding', phase: phaseSnapshot }, + modelConfig, + topicId, + }); + }, + onBeforeSendMessage: async () => { + useFollowUpActionStore.getState().clear(conversationKey); + }, + }; + }, [ + enabled, + isGreeting, + modelConfig.model, + modelConfig.provider, + onboardingAgentId, + phase, + topicId, + ]); }; diff --git a/src/features/Portal/Thread/Chat/index.tsx b/src/features/Portal/Thread/Chat/index.tsx index 853e04b43d..669933d7a3 100644 --- a/src/features/Portal/Thread/Chat/index.tsx +++ b/src/features/Portal/Thread/Chat/index.tsx @@ -13,7 +13,11 @@ import { useConversationStore, } from '@/features/Conversation'; import SkeletonList from '@/features/Conversation/components/SkeletonList'; +import { useChatFollowUp } from '@/features/Conversation/hooks/useChatFollowUp'; +import { mergeConversationHooks } from '@/features/Conversation/utils/mergeConversationHooks'; import { useOperationState } from '@/hooks/useOperationState'; +import { useAgentStore } from '@/store/agent'; +import { chatConfigByIdSelectors } from '@/store/agent/selectors'; import { useChatStore } from '@/store/chat'; import { portalThreadSelectors, threadSelectors } from '@/store/chat/selectors'; import { type MessageMapKeyInput } from '@/store/chat/utils/messageMapKey'; @@ -187,30 +191,44 @@ const ThreadChat = memo(() => { // Get operation state for reactive updates const operationState = useOperationState(context); + const agentChatConfig = useAgentStore( + chatConfigByIdSelectors.getChatConfigById(activeAgentId || ''), + ); + const chatFollowUpHooks = useChatFollowUp({ + agentChatConfig, + conversationKey: chatKey, + threadId: portalThreadId ?? undefined, + topicId: activeTopicId ?? undefined, + }); + // Hooks to handle post-message-creation tasks for new thread const hooks: ConversationHooks = useMemo( - () => ({ - onAfterMessageCreate: async ({ createdThreadId }) => { - if (!createdThreadId) return; + () => + mergeConversationHooks( + { + onAfterMessageCreate: async ({ createdThreadId }) => { + if (!createdThreadId) return; - const state = useChatStore.getState(); + const state = useChatStore.getState(); - // Refresh threads list - await state.refreshThreads(); - // Refresh messages to include new thread messages - await state.refreshMessages(); - // Open the newly created thread in portal - state.openThreadInPortal(createdThreadId, threadStartMessageId); + // Refresh threads list + await state.refreshThreads(); + // Refresh messages to include new thread messages + await state.refreshMessages(); + // Open the newly created thread in portal + state.openThreadInPortal(createdThreadId, threadStartMessageId); - // Summarize thread title for new thread - const portalThread = threadSelectors.currentPortalThread(useChatStore.getState()); - if (portalThread) { - const chats = threadSelectors.portalAIChats(useChatStore.getState()); - await useChatStore.getState().summaryThreadTitle(portalThread.id, chats); - } - }, - }), - [threadStartMessageId], + // Summarize thread title for new thread + const portalThread = threadSelectors.currentPortalThread(useChatStore.getState()); + if (portalThread) { + const chats = threadSelectors.portalAIChats(useChatStore.getState()); + await useChatStore.getState().summaryThreadTitle(portalThread.id, chats); + } + }, + }, + chatFollowUpHooks, + ), + [chatFollowUpHooks, threadStartMessageId], ); return ( diff --git a/src/features/ServiceModel/ModelAssignmentsForm.tsx b/src/features/ServiceModel/ModelAssignmentsForm.tsx index 427bdad27f..9fa20ce063 100644 --- a/src/features/ServiceModel/ModelAssignmentsForm.tsx +++ b/src/features/ServiceModel/ModelAssignmentsForm.tsx @@ -29,6 +29,7 @@ const SYSTEM_AGENT_MODEL_ITEMS: SystemAgentModelItem[] = [ ]; const OPTIONAL_FEATURE_ITEMS: SystemAgentModelItem[] = [ + { key: 'followUpAction' }, { key: 'inputCompletion' }, { key: 'promptRewrite' }, ]; @@ -163,7 +164,9 @@ const ModelAssignmentsForm = memo(() => { }); const isOptionalFeatureLoading = - loadingKey === 'inputCompletion' || loadingKey === 'promptRewrite'; + loadingKey === 'followUpAction' || + loadingKey === 'inputCompletion' || + loadingKey === 'promptRewrite'; const isModelAssignmentLoading = loadingKey && !isOptionalFeatureLoading; const modelAssignments: FormGroupItemType = { diff --git a/src/locales/default/setting.ts b/src/locales/default/setting.ts index f21feb2392..405ae2c5ae 100644 --- a/src/locales/default/setting.ts +++ b/src/locales/default/setting.ts @@ -618,6 +618,11 @@ export default { 'settingChat.enableAutoScrollOnStreaming.desc': 'Override global setting for this assistant', 'settingChat.enableAutoScrollOnStreaming.title': 'Auto-scroll During AI Response', 'settingChat.enableCompressHistory.title': 'Enable Automatic Summary of Chat History', + 'settingChat.enableFollowUpChips.desc': + 'After each reply, show one-click follow-up reply chips below the message. Requires the global Follow-up model to be configured.', + 'settingChat.enableFollowUpChips.notConfiguredHint': + 'Configure the global Follow-up model first to enable this.', + 'settingChat.enableFollowUpChips.title': 'Follow-up Suggestions', 'settingChat.enableHistoryCount.alias': 'Unlimited', 'settingChat.enableHistoryCount.limited': 'Include only {{number}} conversation messages', 'settingChat.enableHistoryCount.setlimited': 'Set limited history messages', @@ -969,6 +974,10 @@ When I am ___, I need ___ 'Once filled out, the system agent will use the custom prompt when generating content', 'systemAgent.customPrompt.placeholder': 'Please enter custom prompt', 'systemAgent.customPrompt.title': 'Custom Prompt', + 'systemAgent.followUpAction.label': 'Follow-up Suggestions Model', + 'systemAgent.followUpAction.modelDesc': + 'Model used to suggest one-click follow-up replies under each assistant message', + 'systemAgent.followUpAction.title': 'Follow-up Suggestions', 'systemAgent.generationTopic.label': 'Model', 'systemAgent.generationTopic.modelDesc': 'Model used to name AI image topics', 'systemAgent.generationTopic.title': 'AI Image Topic Naming', diff --git a/src/routes/(main)/agent/features/Conversation/ConversationArea.tsx b/src/routes/(main)/agent/features/Conversation/ConversationArea.tsx index 5f52455af1..a0af016d51 100644 --- a/src/routes/(main)/agent/features/Conversation/ConversationArea.tsx +++ b/src/routes/(main)/agent/features/Conversation/ConversationArea.tsx @@ -9,11 +9,13 @@ import { useTranslation } from 'react-i18next'; import AgentHome from '@/features/AgentHome'; import ChatMiniMap from '@/features/ChatMiniMap'; import { ChatList, ConversationProvider } from '@/features/Conversation'; +import { useChatFollowUp } from '@/features/Conversation/hooks/useChatFollowUp'; +import { mergeConversationHooks } from '@/features/Conversation/utils/mergeConversationHooks'; import ZenModeToast from '@/features/ZenModeToast'; import { useGatewayReconnect } from '@/hooks/useGatewayReconnect'; import { useOperationState } from '@/hooks/useOperationState'; import { useAgentStore } from '@/store/agent'; -import { agentSelectors } from '@/store/agent/selectors'; +import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/selectors'; import { useChatStore } from '@/store/chat'; import { threadSelectors, topicSelectors } from '@/store/chat/selectors'; import { messageMapKey } from '@/store/chat/utils/messageMapKey'; @@ -71,11 +73,22 @@ const Conversation = memo(() => { ); useGatewayReconnect(context.topicId, runningOperation); + const agentChatConfig = useAgentStore(agentChatConfigSelectors.currentChatConfig); + const chatFollowUpHooks = useChatFollowUp({ + agentChatConfig, + conversationKey: chatKey, + threadId: context.threadId ?? undefined, + topicId: context.topicId ?? undefined, + }); + + const hooks = useMemo(() => mergeConversationHooks(chatFollowUpHooks), [chatFollowUpHooks]); + return ( { diff --git a/src/routes/(main)/settings/agent/index.tsx b/src/routes/(main)/settings/agent/index.tsx index 6943fc1a9a..3443bb7ccd 100644 --- a/src/routes/(main)/settings/agent/index.tsx +++ b/src/routes/(main)/settings/agent/index.tsx @@ -16,6 +16,7 @@ const Page = () => { + diff --git a/src/server/services/followUpAction/index.test.ts b/src/server/services/followUpAction/index.test.ts index a843ed6583..ee1ae00328 100644 --- a/src/server/services/followUpAction/index.test.ts +++ b/src/server/services/followUpAction/index.test.ts @@ -143,6 +143,48 @@ describe('FollowUpActionService.extract', () => { expect(result).toEqual({ chips: [], messageId: FOUND_MSG }); }); + const captureWhereOps = () => { + const arg = queryFindFirstSpy.mock.calls[0][0]; + const fakeTable = { + content: { col: 'content' }, + createdAt: { col: 'createdAt' }, + id: { col: 'id' }, + role: { col: 'role' }, + threadId: { col: 'threadId' }, + topicId: { col: 'topicId' }, + userId: { col: 'userId' }, + }; + const ops = { + and: (...parts: any[]) => ({ op: 'and', parts }), + eq: (col: any, value: any) => ({ col, op: 'eq', value }), + isNotNull: (col: any) => ({ col, op: 'isNotNull' }), + isNull: (col: any) => ({ col, op: 'isNull' }), + ne: (col: any, value: any) => ({ col, op: 'ne', value }), + }; + const result = arg.where(fakeTable, ops); + return { parts: result.parts as any[], table: fakeTable }; + }; + + it('filters by threadId when provided (thread isolation)', async () => { + queryFindFirstSpy.mockResolvedValue(undefined); + await svc.extract({ + modelConfig: MODEL_CONFIG, + threadId: 'thread-A', + topicId: TEST_TOPIC, + }); + const { parts, table } = captureWhereOps(); + expect(parts).toContainEqual({ col: table.threadId, op: 'eq', value: 'thread-A' }); + expect(parts.some((p) => p.op === 'isNull' && p.col === table.threadId)).toBe(false); + }); + + it('filters by isNull(threadId) when no threadId provided (main topic only)', async () => { + queryFindFirstSpy.mockResolvedValue(undefined); + await svc.extract({ modelConfig: MODEL_CONFIG, topicId: TEST_TOPIC }); + const { parts, table } = captureWhereOps(); + expect(parts).toContainEqual({ col: table.threadId, op: 'isNull' }); + expect(parts.some((p) => p.op === 'eq' && p.col === table.threadId)).toBe(false); + }); + it('appends onboarding addendum to system prompt when hint is onboarding', async () => { queryFindFirstSpy.mockResolvedValue({ id: FOUND_MSG, content: 'q?' }); runtimeMock.generateObject.mockResolvedValue({ chips: [] }); diff --git a/src/server/services/followUpAction/index.ts b/src/server/services/followUpAction/index.ts index b4636b38d3..af90b63ffb 100644 --- a/src/server/services/followUpAction/index.ts +++ b/src/server/services/followUpAction/index.ts @@ -22,6 +22,7 @@ export class FollowUpActionService { async extract({ topicId, + threadId, hint, modelConfig, }: FollowUpExtractInput): Promise { @@ -30,10 +31,13 @@ export class FollowUpActionService { const row = await this.db.query.messages.findFirst({ columns: { content: true, id: true }, orderBy: (m, { desc }) => desc(m.createdAt), - where: (m, { and, eq, isNotNull, ne }) => + where: (m, { and, eq, isNotNull, isNull, ne }) => and( eq(m.userId, this.userId), eq(m.topicId, topicId), + // Discriminate thread vs main topic: an absent threadId must NOT + // surface a thread reply that lives under the same topicId. + threadId ? eq(m.threadId, threadId) : isNull(m.threadId), eq(m.role, 'assistant'), isNotNull(m.content), ne(m.content, ''), diff --git a/src/server/services/heterogeneousAgent/cloudHeteroContext.ts b/src/server/services/heterogeneousAgent/cloudHeteroContext.ts index 845e6000d1..9b884d5019 100644 --- a/src/server/services/heterogeneousAgent/cloudHeteroContext.ts +++ b/src/server/services/heterogeneousAgent/cloudHeteroContext.ts @@ -158,9 +158,7 @@ export function buildCloudHeteroContext(params: { : entry.content; return `<${entry.role}>\n${body}\n`; }); - parts.push( - `\n${entries.join('\n')}\n`, - ); + parts.push(`\n${entries.join('\n')}\n`); } return parts.join('\n\n'); diff --git a/src/server/services/onboarding/index.test.ts b/src/server/services/onboarding/index.test.ts index 03aa7f632c..1b5e1ad058 100644 --- a/src/server/services/onboarding/index.test.ts +++ b/src/server/services/onboarding/index.test.ts @@ -266,7 +266,9 @@ describe('OnboardingService', () => { expect(result.success).toBe(true); expect(result.savedFields).toEqual(['fullName']); expect(result.ignoredFields).toEqual(['agentName', 'agentEmoji']); - expect(result.content).toContain('Skipped agent identity because agentName matches the user identity'); + expect(result.content).toContain( + 'Skipped agent identity because agentName matches the user identity', + ); expect(persistedUserState.fullName).toBe('anbex'); expect(mockAgentModel.update).not.toHaveBeenCalled(); }); diff --git a/src/store/followUpAction/action.ts b/src/store/followUpAction/action.ts index 3a3e5cb8a5..e131300f61 100644 --- a/src/store/followUpAction/action.ts +++ b/src/store/followUpAction/action.ts @@ -3,19 +3,55 @@ import type { FollowUpChip, FollowUpHint, FollowUpModelConfig } from '@lobechat/ import { followUpActionService } from '@/services/followUpAction'; import { type StoreSetter } from '@/store/types'; +import { type FollowUpActionSlot } from './initialState'; import { type FollowUpActionStore } from './store'; // LLM `generateObject` for chip extraction routinely takes 8-12s end-to-end. // Anything below ~20s aborts before the model can respond. const TIMEOUT_MS = 20_000; +const IDLE_SLOT: FollowUpActionSlot = { chips: [], status: 'idle' }; + type Setter = StoreSetter; interface FetchForParams { hint?: FollowUpHint; modelConfig: FollowUpModelConfig; + threadId?: string; + topicId: string; } +const writeSlot = ( + set: Setter, + conversationKey: string, + slot: FollowUpActionSlot, + action: string, +): void => { + set( + (state) => ({ + slots: { + ...state.slots, + [conversationKey]: slot, + }, + }), + false, + action, + ); +}; + +const removeSlot = (set: Setter, conversationKey: string, action: string): void => { + set( + (state) => { + if (!state.slots[conversationKey]) return state; + + const { [conversationKey]: _, ...rest } = state.slots; + return { slots: rest }; + }, + false, + action, + ); +}; + export const createFollowUpActionSlice = ( set: Setter, get: () => FollowUpActionStore, @@ -32,94 +68,76 @@ export class FollowUpActionImpl { this.#get = get; } - fetchFor = async (topicId: string, params: FetchForParams): Promise => { - const cur = this.#get(); - // Dedupe: skip if already loading/ready for the same topic - if (cur.pendingTopicId === topicId && cur.status !== 'idle') return; + fetchFor = async (conversationKey: string, params: FetchForParams): Promise => { + const existing = this.#get().slots[conversationKey]; + if (existing?.status === 'loading') return; - cur.abortController?.abort(); + existing?.abortController?.abort(); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); - this.#set( + writeSlot( + this.#set, + conversationKey, { abortController: controller, chips: [], - messageId: undefined, - pendingTopicId: topicId, status: 'loading', - topicId: undefined, }, - false, 'fetchFor:start', ); - const result = await followUpActionService.extract({ ...params, topicId }, controller.signal); + const result = await followUpActionService.extract( + { + hint: params.hint, + modelConfig: params.modelConfig, + threadId: params.threadId, + topicId: params.topicId, + }, + controller.signal, + ); clearTimeout(timeoutId); - // Discard stale results: if the active controller in state is no longer - // this one, our call has been superseded — either by clear()/abort() - // (e.g., user sent a new message) or by a newer fetchFor for the same - // topic (next turn). Identity beats topicId here because a same-topic - // follow-up turn would otherwise let an in-flight prior result overwrite - // the new turn's chips when the network abort race is lost. - if (this.#get().abortController !== controller) return; + // Identity guard: a same-key follow-up turn (next assistant settle) would + // otherwise let an in-flight prior result overwrite the new turn's chips + // when the network abort race is lost. + if (this.#get().slots[conversationKey]?.abortController !== controller) return; if (!result || !result.messageId || result.chips.length === 0) { - this.#set( - { - abortController: undefined, - chips: [], - messageId: undefined, - pendingTopicId: undefined, - status: 'idle', - topicId: undefined, - }, - false, - 'fetchFor:fail', - ); + writeSlot(this.#set, conversationKey, { ...IDLE_SLOT }, 'fetchFor:fail'); return; } - this.#set( + writeSlot( + this.#set, + conversationKey, { - abortController: undefined, chips: result.chips, messageId: result.messageId, - pendingTopicId: undefined, status: 'ready', - topicId, }, - false, 'fetchFor:ready', ); }; - abort = (): void => { - const cur = this.#get(); - cur.abortController?.abort(); - this.#set( - { - abortController: undefined, - chips: [], - messageId: undefined, - pendingTopicId: undefined, - status: 'idle', - topicId: undefined, - }, - false, - 'abort', - ); + abort = (conversationKey: string): void => { + const slot = this.#get().slots[conversationKey]; + if (!slot) return; + slot.abortController?.abort(); + writeSlot(this.#set, conversationKey, { ...IDLE_SLOT }, 'abort'); }; - clear = (): void => { - this.abort(); + clear = (conversationKey: string): void => { + const slot = this.#get().slots[conversationKey]; + if (!slot) return; + slot.abortController?.abort(); + removeSlot(this.#set, conversationKey, 'clear'); }; - consume = (chip: FollowUpChip): void => { + consume = (conversationKey: string, chip: FollowUpChip): void => { void chip; - this.clear(); + this.clear(conversationKey); }; } diff --git a/src/store/followUpAction/index.test.ts b/src/store/followUpAction/index.test.ts index db805cc47d..7621f3553c 100644 --- a/src/store/followUpAction/index.test.ts +++ b/src/store/followUpAction/index.test.ts @@ -2,13 +2,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { followUpActionService } from '@/services/followUpAction'; +import { followUpActionSelectors } from './selectors'; import { useFollowUpActionStore } from './store'; -const TOPIC = 'topic-1'; -const NEW_TOPIC = 'topic-2'; +const KEY_A = 'main_agent-a_topic-a'; +const KEY_B = 'main_agent-b_topic-b'; +const TOPIC_A = 'topic-a'; +const TOPIC_B = 'topic-b'; const MSG = 'msg-real'; const MODEL_CONFIG = { model: 'scene-model', provider: 'scene-provider' }; -const FETCH_PARAMS = { modelConfig: MODEL_CONFIG }; +const FETCH_PARAMS_A = { modelConfig: MODEL_CONFIG, topicId: TOPIC_A }; +const FETCH_PARAMS_B = { modelConfig: MODEL_CONFIG, topicId: TOPIC_B }; + +const slotA = () => useFollowUpActionStore.getState().slots[KEY_A]; +const slotB = () => useFollowUpActionStore.getState().slots[KEY_B]; describe('useFollowUpActionStore', () => { beforeEach(() => { @@ -26,84 +33,106 @@ describe('useFollowUpActionStore', () => { chips: [{ label: 'a', message: 'a' }], }); - const promise = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); - expect(useFollowUpActionStore.getState().status).toBe('loading'); + const promise = useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A); + expect(slotA().status).toBe('loading'); await promise; expect(spy).toHaveBeenCalledOnce(); - expect(useFollowUpActionStore.getState().status).toBe('ready'); - expect(useFollowUpActionStore.getState().chips).toHaveLength(1); - expect(useFollowUpActionStore.getState().messageId).toBe(MSG); - expect(useFollowUpActionStore.getState().topicId).toBe(TOPIC); + expect(slotA().status).toBe('ready'); + expect(slotA().chips).toHaveLength(1); + expect(slotA().messageId).toBe(MSG); }); - it('fetchFor forwards modelConfig to the service', async () => { + it('fetchFor forwards modelConfig, topicId, and threadId to the service', async () => { const spy = vi.spyOn(followUpActionService, 'extract').mockResolvedValue({ messageId: MSG, chips: [{ label: 'a', message: 'a' }], }); - await useFollowUpActionStore.getState().fetchFor(TOPIC, { + await useFollowUpActionStore.getState().fetchFor(KEY_A, { hint: { kind: 'onboarding', phase: 'discovery' }, modelConfig: MODEL_CONFIG, + threadId: 'thd-1', + topicId: TOPIC_A, }); expect(spy).toHaveBeenCalledWith( { hint: { kind: 'onboarding', phase: 'discovery' }, modelConfig: MODEL_CONFIG, - topicId: TOPIC, + threadId: 'thd-1', + topicId: TOPIC_A, }, expect.any(AbortSignal), ); }); - it('fetchFor returns idle when service returns null', async () => { + it('fetchFor leaves slot idle when service returns null', async () => { vi.spyOn(followUpActionService, 'extract').mockResolvedValue(null); - await useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); - expect(useFollowUpActionStore.getState().status).toBe('idle'); - expect(useFollowUpActionStore.getState().chips).toHaveLength(0); - expect(useFollowUpActionStore.getState().messageId).toBeUndefined(); + await useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A); + expect(slotA().status).toBe('idle'); + expect(slotA().chips).toHaveLength(0); + expect(slotA().messageId).toBeUndefined(); }); - it('fetchFor returns idle when service returns empty messageId', async () => { + it('fetchFor leaves slot idle when service returns empty messageId', async () => { vi.spyOn(followUpActionService, 'extract').mockResolvedValue({ chips: [], messageId: '' }); - await useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); - expect(useFollowUpActionStore.getState().status).toBe('idle'); - expect(useFollowUpActionStore.getState().messageId).toBeUndefined(); + await useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A); + expect(slotA().status).toBe('idle'); + expect(slotA().messageId).toBeUndefined(); }); - it('fetchFor dedupes same topicId while still loading', async () => { + it('fetchFor dedupes while the slot is loading', async () => { const spy = vi .spyOn(followUpActionService, 'extract') .mockImplementation(() => new Promise(() => {})); - const p1 = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); - const p2 = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); - void p1; - void p2; + void useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A); + void useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A); expect(spy).toHaveBeenCalledTimes(1); }); - it('fetchFor with new topicId aborts the old controller', async () => { - let firstSignal: AbortSignal | undefined; - vi.spyOn(followUpActionService, 'extract').mockImplementation(async (_, signal) => { - if (!firstSignal) firstSignal = signal; + it('fetchFor on a different key does not abort an in-flight fetch on another key', async () => { + let signalA: AbortSignal | undefined; + let signalB: AbortSignal | undefined; + vi.spyOn(followUpActionService, 'extract').mockImplementation(async (input, signal) => { + if (input.topicId === TOPIC_A) signalA = signal; + else signalB = signal; return new Promise(() => {}); }); - const p1 = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); - void p1; + void useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A); await Promise.resolve(); await Promise.resolve(); - void useFollowUpActionStore.getState().fetchFor(NEW_TOPIC, FETCH_PARAMS); - expect(firstSignal?.aborted).toBe(true); + void useFollowUpActionStore.getState().fetchFor(KEY_B, FETCH_PARAMS_B); + await Promise.resolve(); + expect(signalA?.aborted).toBe(false); + expect(signalB?.aborted).toBe(false); + expect(slotA().status).toBe('loading'); + expect(slotB().status).toBe('loading'); }); - it('clear() aborts and resets state', async () => { + it('clear(keyA) does not touch slots[keyB]', async () => { vi.spyOn(followUpActionService, 'extract').mockImplementation(() => new Promise(() => {})); - const p = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); - void p; - useFollowUpActionStore.getState().clear(); - expect(useFollowUpActionStore.getState().status).toBe('idle'); - expect(useFollowUpActionStore.getState().messageId).toBeUndefined(); - expect(useFollowUpActionStore.getState().pendingTopicId).toBeUndefined(); + void useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A); + void useFollowUpActionStore.getState().fetchFor(KEY_B, FETCH_PARAMS_B); + useFollowUpActionStore.getState().clear(KEY_A); + expect(slotA()).toBeUndefined(); + expect(slotB()?.status).toBe('loading'); + }); + + it('abort(keyA) does not affect keyB controller', async () => { + let signalA: AbortSignal | undefined; + let signalB: AbortSignal | undefined; + vi.spyOn(followUpActionService, 'extract').mockImplementation(async (input, signal) => { + if (input.topicId === TOPIC_A) signalA = signal; + else signalB = signal; + return new Promise(() => {}); + }); + void useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A); + void useFollowUpActionStore.getState().fetchFor(KEY_B, FETCH_PARAMS_B); + await Promise.resolve(); + useFollowUpActionStore.getState().abort(KEY_A); + expect(signalA?.aborted).toBe(true); + expect(signalB?.aborted).toBe(false); + expect(slotA().status).toBe('idle'); + expect(slotB()?.status).toBe('loading'); }); it('20s timeout aborts the in-flight call', async () => { @@ -112,23 +141,30 @@ describe('useFollowUpActionStore', () => { signal = s; return new Promise(() => {}); }); - const p = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); - void p; + void useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A); await Promise.resolve(); vi.advanceTimersByTime(20_000); expect(signal?.aborted).toBe(true); }); - it('consume(chip) clears state', () => { + it('consume(key, chip) clears the slot for that key only', () => { useFollowUpActionStore.setState({ - chips: [{ label: 'x', message: 'hello' }], - messageId: MSG, - status: 'ready', + slots: { + [KEY_A]: { + chips: [{ label: 'x', message: 'hello' }], + messageId: MSG, + status: 'ready', + }, + [KEY_B]: { + chips: [{ label: 'y', message: 'hello' }], + messageId: MSG, + status: 'ready', + }, + }, }); - useFollowUpActionStore.getState().consume({ label: 'x', message: 'hello' }); - expect(useFollowUpActionStore.getState().status).toBe('idle'); - expect(useFollowUpActionStore.getState().messageId).toBeUndefined(); - expect(useFollowUpActionStore.getState().chips).toHaveLength(0); + useFollowUpActionStore.getState().consume(KEY_A, { label: 'x', message: 'hello' }); + expect(slotA()).toBeUndefined(); + expect(slotB()?.status).toBe('ready'); }); it('discards stale results when controller is replaced (race protection)', async () => { @@ -145,46 +181,121 @@ describe('useFollowUpActionStore', () => { messageId: 'msg-new', }); - // First fetchFor is in flight (does not yet resolve). - const p1 = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); + const p1 = useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A); void p1; await Promise.resolve(); - // User sends a new message → clear() aborts and resets. - useFollowUpActionStore.getState().clear(); - expect(useFollowUpActionStore.getState().status).toBe('idle'); + useFollowUpActionStore.getState().clear(KEY_A); + expect(slotA()).toBeUndefined(); - // Next turn starts another fetchFor for the SAME topic. - const p2 = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); + const p2 = useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A); - // The first call now resolves with a stale result. It must be discarded - // because its controller is no longer the active one — even though the - // topicId still matches. resolveFirst!({ chips: [{ label: 'a', message: 'a' }], messageId: 'msg-old' }); await p1; - expect(useFollowUpActionStore.getState().messageId).not.toBe('msg-old'); + expect(slotA()?.messageId).not.toBe('msg-old'); - // Second call still writes through normally. await p2; expect(spy).toHaveBeenCalledTimes(2); - expect(useFollowUpActionStore.getState().status).toBe('ready'); - expect(useFollowUpActionStore.getState().messageId).toBe('msg-new'); + expect(slotA()?.status).toBe('ready'); + expect(slotA()?.messageId).toBe('msg-new'); }); - it('reset aborts in-flight request and resets state', async () => { - let signal: AbortSignal | undefined; + it('reset aborts all in-flight requests and clears every slot', async () => { + const signals: AbortSignal[] = []; vi.spyOn(followUpActionService, 'extract').mockImplementation(async (_, s) => { - signal = s; + if (s) signals.push(s); return new Promise(() => {}); }); - const p = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); - void p; + void useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A); + void useFollowUpActionStore.getState().fetchFor(KEY_B, FETCH_PARAMS_B); await Promise.resolve(); useFollowUpActionStore.getState().reset(); - expect(signal?.aborted).toBe(true); - expect(useFollowUpActionStore.getState().status).toBe('idle'); - expect(useFollowUpActionStore.getState().messageId).toBeUndefined(); - expect(useFollowUpActionStore.getState().pendingTopicId).toBeUndefined(); + expect(signals.every((s) => s.aborted)).toBe(true); + expect(useFollowUpActionStore.getState().slots).toEqual({}); + }); +}); + +describe('followUpActionSelectors.chipsFor', () => { + beforeEach(() => { + useFollowUpActionStore.getState().reset?.(); + }); + + it('returns chips when the slot matches messageId', () => { + useFollowUpActionStore.setState({ + slots: { + [KEY_A]: { + chips: [{ label: 'a', message: 'a' }], + messageId: MSG, + status: 'ready', + }, + }, + }); + const chips = followUpActionSelectors.chipsFor({ + conversationKey: KEY_A, + messageId: MSG, + })(useFollowUpActionStore.getState()); + expect(chips).toHaveLength(1); + }); + + it('returns empty when slot is missing', () => { + const chips = followUpActionSelectors.chipsFor({ + conversationKey: KEY_A, + messageId: MSG, + })(useFollowUpActionStore.getState()); + expect(chips).toHaveLength(0); + }); + + it('returns empty when slot is not ready', () => { + useFollowUpActionStore.setState({ + slots: { + [KEY_A]: { + chips: [{ label: 'a', message: 'a' }], + messageId: MSG, + status: 'loading', + }, + }, + }); + const chips = followUpActionSelectors.chipsFor({ + conversationKey: KEY_A, + messageId: MSG, + })(useFollowUpActionStore.getState()); + expect(chips).toHaveLength(0); + }); + + it('matches a child id via childIdsKey (assistantGroup case)', () => { + const CHILD = 'msg-child'; + useFollowUpActionStore.setState({ + slots: { + [KEY_A]: { + chips: [{ label: 'a', message: 'a' }], + messageId: CHILD, + status: 'ready', + }, + }, + }); + const chips = followUpActionSelectors.chipsFor({ + childIdsKey: `${CHILD}|other`, + conversationKey: KEY_A, + messageId: 'group-id', + })(useFollowUpActionStore.getState()); + expect(chips).toHaveLength(1); + }); + + it('does not leak across conversation keys', () => { + useFollowUpActionStore.setState({ + slots: { + [KEY_A]: { + chips: [{ label: 'a', message: 'a' }], + messageId: MSG, + status: 'ready', + }, + }, + }); + const chips = followUpActionSelectors.chipsFor({ + conversationKey: KEY_B, + messageId: MSG, + })(useFollowUpActionStore.getState()); + expect(chips).toHaveLength(0); }); }); diff --git a/src/store/followUpAction/initialState.ts b/src/store/followUpAction/initialState.ts index c82f884ffe..4e7952b209 100644 --- a/src/store/followUpAction/initialState.ts +++ b/src/store/followUpAction/initialState.ts @@ -2,16 +2,18 @@ import type { FollowUpChip } from '@lobechat/types'; export type FollowUpActionStatus = 'idle' | 'loading' | 'ready'; -export interface FollowUpActionState { +/** Per-conversation slot — concurrent surfaces (inbox, popup, thread) own their own slot. */ +export interface FollowUpActionSlot { abortController?: AbortController; chips: FollowUpChip[]; messageId?: string; - pendingTopicId?: string; status: FollowUpActionStatus; - topicId?: string; +} + +export interface FollowUpActionState { + slots: Record; } export const initialFollowUpActionState: FollowUpActionState = { - chips: [], - status: 'idle', + slots: {}, }; diff --git a/src/store/followUpAction/selectors.ts b/src/store/followUpAction/selectors.ts index f73e3db475..baa5eb67f0 100644 --- a/src/store/followUpAction/selectors.ts +++ b/src/store/followUpAction/selectors.ts @@ -1,43 +1,33 @@ import type { FollowUpChip } from '@lobechat/types'; -import { type FollowUpActionState } from './initialState'; +import { type FollowUpActionState, type FollowUpActionStatus } from './initialState'; const EMPTY_CHIPS: readonly FollowUpChip[] = []; interface ChipsForArgs { - /** - * Pipe-joined ids of the displayMessage's children blocks (for assistantGroup). - * Server-side resolves the latest answer message id, which inside an - * assistantGroup is a child block id rather than the group id, so we accept - * any child id as a valid match in addition to the top-level id. - */ + /** Pipe-joined ids of the assistantGroup's child blocks — the server resolves the latest answer to a child block id, not the group id. */ childIdsKey?: string; + conversationKey: string | undefined; messageId: string | undefined; - topicId: string | undefined; } -/** - * Chips render only when ALL hold: - * - status === 'ready' - * - the stored topicId matches - * - the stored messageId matches the bound message id OR one of its child block ids - * - * Topic-only matching would let stale chips from a previous turn render under - * a newly streaming assistant message in the same topic, so messageId membership - * is required. - */ const chipsFor = - ({ childIdsKey, messageId, topicId }: ChipsForArgs) => + ({ childIdsKey, conversationKey, messageId }: ChipsForArgs) => (s: FollowUpActionState): readonly FollowUpChip[] => { - if (s.status !== 'ready') return EMPTY_CHIPS; - if (!messageId || !topicId) return EMPTY_CHIPS; - if (s.topicId !== topicId) return EMPTY_CHIPS; - if (!s.messageId) return EMPTY_CHIPS; - if (s.messageId === messageId) return s.chips; - if (childIdsKey && childIdsKey.split('|').includes(s.messageId)) return s.chips; + if (!conversationKey || !messageId) return EMPTY_CHIPS; + const slot = s.slots[conversationKey]; + if (!slot || slot.status !== 'ready' || !slot.messageId) return EMPTY_CHIPS; + if (slot.messageId === messageId) return slot.chips; + if (childIdsKey && childIdsKey.split('|').includes(slot.messageId)) return slot.chips; return EMPTY_CHIPS; }; +const slotStatus = + (conversationKey: string | undefined) => + (s: FollowUpActionState): FollowUpActionStatus => + (conversationKey ? s.slots[conversationKey]?.status : undefined) ?? 'idle'; + export const followUpActionSelectors = { chipsFor, + slotStatus, }; diff --git a/src/store/followUpAction/store.ts b/src/store/followUpAction/store.ts index 2b0ce76d22..647c13a3b9 100644 --- a/src/store/followUpAction/store.ts +++ b/src/store/followUpAction/store.ts @@ -28,22 +28,11 @@ class FollowUpActionStoreResetAction implements ResetableStore { } reset = () => { - // Cancel any in-flight LLM call before wiping state, otherwise the AbortController is leaked. const current = this.#api.getState(); - current.abortController?.abort(); - // Explicitly include undefined fields so zustand's merge-mode setState clears them. - this.#set( - { - abortController: undefined, - chips: [], - messageId: undefined, - pendingTopicId: undefined, - status: 'idle', - topicId: undefined, - }, - false, - 'resetFollowUpActionStore', - ); + for (const slot of Object.values(current.slots)) { + slot.abortController?.abort(); + } + this.#set({ slots: {} }, false, 'resetFollowUpActionStore'); }; } diff --git a/src/store/user/slices/settings/selectors/__snapshots__/settings.test.ts.snap b/src/store/user/slices/settings/selectors/__snapshots__/settings.test.ts.snap index 8023d1c9bb..f5c83c828c 100644 --- a/src/store/user/slices/settings/selectors/__snapshots__/settings.test.ts.snap +++ b/src/store/user/slices/settings/selectors/__snapshots__/settings.test.ts.snap @@ -55,6 +55,11 @@ exports[`settingsSelectors > currentSystemAgent > should merge DEFAULT_SYSTEM_AG "provider": "deepseek", }, "enableAutoReply": true, + "followUpAction": { + "enabled": false, + "model": "gpt-5.4-mini", + "provider": "openai", + }, "generationTopic": { "model": "gpt-5.4-mini", "provider": "openai", @@ -107,6 +112,7 @@ exports[`settingsSelectors > defaultAgent > should merge DEFAULT_AGENT and s.set "enableAgentMode": true, "enableCompressHistory": true, "enableContextCompression": true, + "enableFollowUpChips": false, "enableHistoryCount": false, "enableStreaming": true, "historyCount": 20, @@ -150,6 +156,7 @@ exports[`settingsSelectors > defaultAgentConfig > should merge DEFAULT_AGENT_CON "enableAgentMode": true, "enableCompressHistory": true, "enableContextCompression": true, + "enableFollowUpChips": false, "enableHistoryCount": false, "enableStreaming": true, "historyCount": 20, diff --git a/src/store/user/slices/settings/selectors/systemAgent.ts b/src/store/user/slices/settings/selectors/systemAgent.ts index b6f6560922..a71ced0665 100644 --- a/src/store/user/slices/settings/selectors/systemAgent.ts +++ b/src/store/user/slices/settings/selectors/systemAgent.ts @@ -15,9 +15,11 @@ const promptRewrite = (s: UserStore) => currentSystemAgent(s).promptRewrite; const historyCompress = (s: UserStore) => currentSystemAgent(s).historyCompress; const generationTopic = (s: UserStore) => currentSystemAgent(s).generationTopic; const inputCompletion = (s: UserStore) => currentSystemAgent(s).inputCompletion; +const followUpAction = (s: UserStore) => currentSystemAgent(s).followUpAction; export const systemAgentSelectors = { agentMeta, + followUpAction, generationTopic, historyCompress, inputCompletion,