feat(follow-up): extend follow-up chip suggestions to general chat (#15101)

*  feat(follow-up): add foundation types for chat follow-up chips

- FollowUpExtractInput.threadId for portal thread isolation
- UserSystemAgentConfig.followUpAction (global enable + model)
- LobeAgentChatConfig.enableFollowUpChips (per-agent opt-in)
- ConversationHooks.onAssistantTurnSettled first-class member
- Remove dead onGenerationStart/Complete/Cancelled hooks
- DEFAULT_SYSTEM_AGENT_CONFIG.followUpAction off by default
- DEFAULT_AGENT_CHAT_CONFIG.enableFollowUpChips false default

* ♻️ refactor(follow-up): key follow-up store by conversation for concurrency

- Convert useFollowUpActionStore from single-slot to slots map
- conversationKey = messageMapKey(agentId, topicId, threadId?) for parity with chat store
- contextSelectors.conversationKey exposes the key from ConversationProvider
- FollowUpChips and ChatItem consume conversationKey
- Onboarding hook adopts the new keyed API
- Pass threadId through to extract (server filter lands in T3)

* 🐛 fix(follow-up): address T2 code review feedback

- Restore design-intent comments for 20s timeout and race guard
- Remove dead pendingMessageId field from FollowUpActionSlot
- Remove unused slotFor selector
- Trim chipsFor / FollowUpActionSlot JSDoc to design intent only
- Gate useOnboardingFollowUp against missing onboardingAgentId
- removeSlot uses destructure; slotStatus uses ?? for falsy safety

*  feat(follow-up): filter extract by threadId for portal thread isolation

- FollowUpActionService.extract honours optional threadId
- threadId provided → eq(messages.threadId, threadId)
- threadId absent → isNull(messages.threadId) so main topic never surfaces thread replies
- Tests cover both branches

*  feat(conversation): emit onAssistantTurnSettled hook from provider

- AssistantTurnSettledWatcher fires hooks.onAssistantTurnSettled(messageId, { reason }) once per turn
- Reason derived from the most recent terminal Operation for the message id
- Reason mapping: cancelled → stopped, type=regenerate → regenerated, type=continue → continued, else → completed
- Settlement gated on idle + no pending tool intervention (mirrors Onboarding's logic)
- Tests cover all four reason branches + intervention gating + no double-fire + fallback log
- Onboarding bespoke prop untouched (migrates in T6)

* 🐛 fix(conversation): scope settlement reason to turn-level operations

- TURN_LEVEL_TYPES filter excludes child sub-ops (callLLM, executeToolCall, etc.) before sorting by endTime
- Prevents successful regenerate/continue being misreported as 'completed' when a child finishes after the parent
- Tests cover parent/child ordering for all reason branches

*  feat(follow-up): add useChatFollowUp hook and wire chat mount sites

- New mergeConversationHooks composes multiple hooks with boolean short-circuit
- useChatFollowUp computes effective enable (global × per-agent × valid model)
- Registers onBeforeSendMessage/Continue/Regenerate to clear slot and onAssistantTurnSettled to extract
- Mount sites: agent route ConversationArea, FloatingChatPanel, Portal Thread Chat (last in chain per §4.6)
- Skips on reason='stopped'; skips when effective is false
- Group chat intentionally not mounted

* ♻️ refactor(onboarding): migrate settlement to ConversationHooks first-class

- Drop bespoke onAssistantTurnSettled prop and duplicate useEffect from AgentOnboardingConversation
- useOnboardingFollowUp returns ConversationHooks { onBeforeSendMessage, onAssistantTurnSettled }
- Split settlement work: context-sync + builtin refresh runs first, chip extract runs after
- Phase snapshot captured at memoize time preserves original prevPhase semantics
- Settlement detection now lives solely in AssistantTurnSettledWatcher

*  feat(settings): add Follow-up suggestions controls (global + per-agent)

- Global System Agent page: new Follow-up Suggestions panel (model picker + enable toggle)
- Per-agent chat controls: enableFollowUpChips toggle with hint when global not configured
- i18n keys: setting.systemAgent.followUpAction.*, setting.settingChat.enableFollowUpChips.*
- Hint surfaces when user toggles per-agent ON but global is disabled/unmodeled

* 🔧 chore(follow-up): T8 — scoped lint cleanup and comment discipline pass

* 🐛 fix(follow-up): align conversationKey selector with callsite + wrap single hook

- contextSelectors.conversationKey forwards full context (scope/isNew/groupId/subAgentId) so portal-thread NEW state matches callsite-computed keys
- ConversationArea wraps chat-follow-up via mergeConversationHooks for spec §4.6 ordering robustness
- Both per final-review Important concerns

*  test(settings): update follow-up defaults snapshots

*  feat(follow-up): surface model in service-model page + default to mini

- Add followUpAction to /service-model OPTIONAL_FEATURE_ITEMS so model/provider and enable Switch render alongside inputCompletion and promptRewrite
- Seed DEFAULT_FOLLOW_UP_ACTION_SYSTEM_AGENT_ITEM with DEFAULT_MINI model/provider so out-of-box config has a valid model; users only need to flip enabled
- Sync settings selector snapshot
This commit is contained in:
Innei
2026-05-23 00:31:15 +08:00
committed by GitHub
parent 6770d8f321
commit de9f7e092a
48 changed files with 2067 additions and 851 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ The two reference tools to read end-to-end:
1. **先保证折叠态可读。** 每个 API 都必须有 Inspector;用户不展开也应该能看懂 “正在做什么 / 对什么做 / 当前结果是什么”。Inspector 不应该只展示函数名和原始参数。 1. **先保证折叠态可读。** 每个 API 都必须有 Inspector;用户不展开也应该能看懂 “正在做什么 / 对什么做 / 当前结果是什么”。Inspector 不应该只展示函数名和原始参数。
2. **Inspector 是一句话,不是详情页。** 优先表达动作、关键对象、数量、状态,例如 “分析图片 3 张”“搜索 12 个结果”“读取 config.json”。长文本、列表和结构化结果放到 Render 或 Portal。 2. **Inspector 是一句话,不是详情页。** 优先表达动作、关键对象、数量、状态,例如 “分析图片 3 张”“搜索 12 个结果”“读取 config.json”。长文本、列表和结构化结果放到 Render 或 Portal。
3. **Inspector 要覆盖执行生命周期。** `args` 还在 streaming、工具执行中、执行完成、执行失败时都应该有稳定展示;必要时同时读取 `args``partialArgs``pluginState`,避免出现空白、跳变或只显示半截参数。 3. **Inspector 要覆盖执行生命周期。** `args` 还在 streaming、工具执行中、执行完成、执行失败时都应该有稳定展示;必要时同时读取 `args``partialArgs``pluginState`,避免出现空白、跳变或只显示半截参数。
4. **文案要随状态切换时态。** 同一个动作在 loading 与 completed 两个阶段必须用不同的措辞:执行中用现在进行时(“正在创建任务 / Creating task / 正在搜索”),执行完成后切到完成态(“已创建任务 / Task created / 已找到 N 条”)。Inspector chip 会一直留在聊天记录里——如果一直挂着 “正在 xxx”,几小时后回看历史时会读起来像还在跑。约定的 i18n 形式是 `<api>.loading` / `<api>.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 形式是 `<api>.loading` / `<api>.completed` 一对键(见 `lobe-agent.apiName.callSubAgent.{loading,completed}``lobe-claude-code.task.{create,list,update,get}.{loading,completed}`),渲染时按 `isArgumentsStreaming || isLoading` 决定取哪一个。只读 / 查询类(“查看任务” 这种本来就是名词性的)可以共用一个键。
5. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。 5. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。
6. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。 6. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。
7. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容。 7. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容。
+74 -35
View File
@@ -346,25 +346,31 @@ describe('hetero exec command', () => {
it('retries without --resume when the error stream event indicates the session is gone', async () => { 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 // First spawn: exits non-zero, emits a resume-not-found error event
const resumeNotFoundEvent = { 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', operationId: 'op-r1',
stepIndex: 0, stepIndex: 0,
timestamp: 1, timestamp: 1,
type: 'error', type: 'error',
}; };
mockSpawnAgent mockSpawnAgent
.mockReturnValueOnce( .mockReturnValueOnce(createFakeHandle({ events: [resumeNotFoundEvent], exitCode: 1 }))
createFakeHandle({ events: [resumeNotFoundEvent], exitCode: 1 }),
)
// Second spawn: succeeds // Second spawn: succeeds
.mockReturnValueOnce(createFakeHandle({ exitCode: 0 })); .mockReturnValueOnce(createFakeHandle({ exitCode: 0 }));
await runCmd([ await runCmd([
'hetero', 'exec', 'hetero',
'--type', 'claude-code', 'exec',
'--prompt', 'do the thing', '--type',
'--resume', 'cc-stale', 'claude-code',
'--operation-id', 'op-r1', '--prompt',
'do the thing',
'--resume',
'cc-stale',
'--operation-id',
'op-r1',
]); ]);
// Two spawns: first with --resume, retry without // Two spawns: first with --resume, retry without
@@ -389,11 +395,16 @@ describe('hetero exec command', () => {
.mockReturnValueOnce(createFakeHandle({ exitCode: 0 })); .mockReturnValueOnce(createFakeHandle({ exitCode: 0 }));
await runCmd([ await runCmd([
'hetero', 'exec', 'hetero',
'--type', 'claude-code', 'exec',
'--prompt', 'continue', '--type',
'--resume', 'xyz', 'claude-code',
'--operation-id', 'op-r2', '--prompt',
'continue',
'--resume',
'xyz',
'--operation-id',
'op-r2',
]); ]);
expect(mockSpawnAgent).toHaveBeenCalledTimes(2); expect(mockSpawnAgent).toHaveBeenCalledTimes(2);
@@ -403,7 +414,10 @@ describe('hetero exec command', () => {
it('retries without --resume when the error indicates context overflow', async () => { it('retries without --resume when the error indicates context overflow', async () => {
const contextOverflowEvent = { 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', operationId: 'op-ctx',
stepIndex: 0, stepIndex: 0,
timestamp: 1, timestamp: 1,
@@ -414,11 +428,16 @@ describe('hetero exec command', () => {
.mockReturnValueOnce(createFakeHandle({ exitCode: 0 })); .mockReturnValueOnce(createFakeHandle({ exitCode: 0 }));
await runCmd([ await runCmd([
'hetero', 'exec', 'hetero',
'--type', 'claude-code', 'exec',
'--prompt', 'next question', '--type',
'--resume', 'cc-longctx', 'claude-code',
'--operation-id', 'op-ctx', '--prompt',
'next question',
'--resume',
'cc-longctx',
'--operation-id',
'op-ctx',
]); ]);
expect(mockSpawnAgent).toHaveBeenCalledTimes(2); expect(mockSpawnAgent).toHaveBeenCalledTimes(2);
@@ -434,10 +453,14 @@ describe('hetero exec command', () => {
); );
await runCmd([ await runCmd([
'hetero', 'exec', 'hetero',
'--type', 'claude-code', 'exec',
'--prompt', 'hi', '--type',
'--resume', 'cc-valid', 'claude-code',
'--prompt',
'hi',
'--resume',
'cc-valid',
]); ]);
expect(mockSpawnAgent).toHaveBeenCalledTimes(1); expect(mockSpawnAgent).toHaveBeenCalledTimes(1);
@@ -455,10 +478,14 @@ describe('hetero exec command', () => {
mockSpawnAgent.mockReturnValueOnce(createFakeHandle({ events: [errorEvent], exitCode: 1 })); mockSpawnAgent.mockReturnValueOnce(createFakeHandle({ events: [errorEvent], exitCode: 1 }));
await runCmd([ await runCmd([
'hetero', 'exec', 'hetero',
'--type', 'claude-code', 'exec',
'--prompt', 'fresh run', '--type',
'--operation-id', 'op-nr', 'claude-code',
'--prompt',
'fresh run',
'--operation-id',
'op-nr',
]); ]);
// No --resume → no interception → no retry // 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 () => { it('does NOT suppress the resume-error event from JSONL output', async () => {
const resumeNotFoundEvent = { 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', operationId: 'op-jsonl',
stepIndex: 0, stepIndex: 0,
timestamp: 1, timestamp: 1,
@@ -479,11 +509,16 @@ describe('hetero exec command', () => {
.mockReturnValueOnce(createFakeHandle({ exitCode: 0 })); .mockReturnValueOnce(createFakeHandle({ exitCode: 0 }));
await runCmd([ await runCmd([
'hetero', 'exec', 'hetero',
'--type', 'claude-code', 'exec',
'--prompt', 'do thing', '--type',
'--resume', 'old', 'claude-code',
'--render', 'jsonl', '--prompt',
'do thing',
'--resume',
'old',
'--render',
'jsonl',
]); ]);
// The error event is still emitted to JSONL (for observability) even // The error event is still emitted to JSONL (for observability) even
@@ -492,7 +527,11 @@ describe('hetero exec command', () => {
.map((c) => c[0]) .map((c) => c[0])
.filter((s): s is string => typeof s === 'string'); .filter((s): s is string => typeof s === 'string');
const errorLine = lines.find((l) => { 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(); expect(errorLine).toBeDefined();
}); });
+9 -2
View File
@@ -341,7 +341,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
// into the ingester. When intercepting resume errors, a matching // into the ingester. When intercepting resume errors, a matching
// `error` event is withheld from the ingester and flags a retry instead. // `error` event is withheld from the ingester and flags a retry instead.
let resumeNotFound = false; let resumeNotFound = false;
let ingestError = false; const ingestError = false;
try { try {
for await (const event of handle.events) { for await (const event of handle.events) {
if (interceptResumeErrors && event.type === 'error') { if (interceptResumeErrors && event.type === 'error') {
@@ -393,7 +393,14 @@ const exec = async (options: ExecOptions): Promise<void> => {
resumeNotFound = true; 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) ─────────────────────────────── // ─── First run (with --resume if provided) ───────────────────────────────
@@ -254,7 +254,12 @@ class AgentManagementExecutor extends BaseExecutor<typeof AgentManagementApiName
try { try {
await dispatchNonHeteroSubAgent( await dispatchNonHeteroSubAgent(
{ kind: 'callAgent', targetAgentId: agentId, instruction, parentMessageId: ctx.messageId }, {
kind: 'callAgent',
targetAgentId: agentId,
instruction,
parentMessageId: ctx.messageId,
},
{ {
conversationContext, conversationContext,
heterogeneousProvider: parentAgentConfig?.agencyConfig?.heterogeneousProvider, heterogeneousProvider: parentAgentConfig?.agencyConfig?.heterogeneousProvider,
+8 -4
View File
@@ -242,7 +242,8 @@ export const MemoryManifest: BuiltinToolManifest = {
type: 'string', type: 'string',
}, },
sourceIds: { sourceIds: {
description: 'Stable source message ids that support this memory. Use [] when unavailable.', description:
'Stable source message ids that support this memory. Use [] when unavailable.',
items: { type: 'string' }, items: { type: 'string' },
type: ['array', 'null'], type: ['array', 'null'],
}, },
@@ -398,7 +399,8 @@ export const MemoryManifest: BuiltinToolManifest = {
type: 'string', type: 'string',
}, },
sourceIds: { sourceIds: {
description: 'Stable source message ids that support this memory. Use [] when unavailable.', description:
'Stable source message ids that support this memory. Use [] when unavailable.',
items: { type: 'string' }, items: { type: 'string' },
type: ['array', 'null'], type: ['array', 'null'],
}, },
@@ -561,7 +563,8 @@ export const MemoryManifest: BuiltinToolManifest = {
type: 'string', type: 'string',
}, },
sourceIds: { sourceIds: {
description: 'Stable source message ids that support this memory. Use [] when unavailable.', description:
'Stable source message ids that support this memory. Use [] when unavailable.',
items: { type: 'string' }, items: { type: 'string' },
type: ['array', 'null'], type: ['array', 'null'],
}, },
@@ -770,7 +773,8 @@ export const MemoryManifest: BuiltinToolManifest = {
type: 'string', type: 'string',
}, },
sourceIds: { sourceIds: {
description: 'Stable source message ids that support this memory. Use [] when unavailable.', description:
'Stable source message ids that support this memory. Use [] when unavailable.',
items: { type: 'string' }, items: { type: 'string' },
type: ['array', 'null'], type: ['array', 'null'],
}, },
+1
View File
@@ -27,6 +27,7 @@ export const DEFAULT_AGENT_CHAT_CONFIG: LobeAgentChatConfig = {
enableAgentMode: true, enableAgentMode: true,
enableCompressHistory: true, enableCompressHistory: true,
enableContextCompression: true, enableContextCompression: true,
enableFollowUpChips: false,
enableHistoryCount: false, enableHistoryCount: false,
enableStreaming: true, enableStreaming: true,
historyCount: 20, historyCount: 20,
@@ -29,8 +29,15 @@ export const DEFAULT_INPUT_COMPLETION_SYSTEM_AGENT_ITEM: SystemAgentItem = {
provider: DEFAULT_MINI_SYSTEM_AGENT_ITEM.provider, provider: DEFAULT_MINI_SYSTEM_AGENT_ITEM.provider,
}; };
export const DEFAULT_FOLLOW_UP_ACTION_SYSTEM_AGENT_ITEM: SystemAgentItem = {
enabled: false,
model: DEFAULT_MINI_SYSTEM_AGENT_ITEM.model,
provider: DEFAULT_MINI_SYSTEM_AGENT_ITEM.provider,
};
export const DEFAULT_SYSTEM_AGENT_CONFIG: UserSystemAgentConfig = { export const DEFAULT_SYSTEM_AGENT_CONFIG: UserSystemAgentConfig = {
agentMeta: DEFAULT_SYSTEM_AGENT_ITEM, agentMeta: DEFAULT_SYSTEM_AGENT_ITEM,
followUpAction: DEFAULT_FOLLOW_UP_ACTION_SYSTEM_AGENT_ITEM,
generationTopic: DEFAULT_MINI_SYSTEM_AGENT_ITEM, generationTopic: DEFAULT_MINI_SYSTEM_AGENT_ITEM,
historyCompress: DEFAULT_SYSTEM_AGENT_ITEM, historyCompress: DEFAULT_SYSTEM_AGENT_ITEM,
inputCompletion: DEFAULT_INPUT_COMPLETION_SYSTEM_AGENT_ITEM, inputCompletion: DEFAULT_INPUT_COMPLETION_SYSTEM_AGENT_ITEM,
@@ -326,4 +326,3 @@ export interface AgentProcessConfig {
/** Environment variables */ /** Environment variables */
env?: Record<string, string>; env?: Record<string, string>;
} }
@@ -5,7 +5,6 @@ import OpenAI from 'openai';
import { responsesAPIModels } from '../../const/models'; import { responsesAPIModels } from '../../const/models';
import { buildDefaultAnthropicPayload } from '../../core/anthropicCompatibleFactory'; import { buildDefaultAnthropicPayload } from '../../core/anthropicCompatibleFactory';
import { assertToolLimits } from '../../utils/validateToolLimits';
import { type LobeRuntimeAI } from '../../core/BaseAI'; import { type LobeRuntimeAI } from '../../core/BaseAI';
import { import {
convertOpenAIMessages, convertOpenAIMessages,
@@ -20,6 +19,7 @@ import { AgentRuntimeError } from '../../utils/createError';
import { debugResponse, debugStream } from '../../utils/debugStream'; import { debugResponse, debugStream } from '../../utils/debugStream';
import { getModelPricing } from '../../utils/getModelPricing'; import { getModelPricing } from '../../utils/getModelPricing';
import { StreamingResponse } from '../../utils/response'; import { StreamingResponse } from '../../utils/response';
import { assertToolLimits } from '../../utils/validateToolLimits';
const COPILOT_BASE_URL = 'https://api.githubcopilot.com'; const COPILOT_BASE_URL = 'https://api.githubcopilot.com';
const TOKEN_EXCHANGE_URL = 'https://api.github.com/copilot_internal/v2/token'; const TOKEN_EXCHANGE_URL = 'https://api.github.com/copilot_internal/v2/token';
+2
View File
@@ -63,6 +63,7 @@ export interface LobeAgentChatConfig extends AgentMemoryChatConfig, AgentSelfIte
* When enabled, old messages will be compressed into summaries when token threshold is reached * When enabled, old messages will be compressed into summaries when token threshold is reached
*/ */
enableContextCompression?: boolean; enableContextCompression?: boolean;
enableFollowUpChips?: boolean;
/** /**
* Enable historical message count * Enable historical message count
*/ */
@@ -206,6 +207,7 @@ export const AgentChatConfigSchema = z
enableAutoScrollOnStreaming: z.boolean().optional(), enableAutoScrollOnStreaming: z.boolean().optional(),
enableCompressHistory: z.boolean().optional(), enableCompressHistory: z.boolean().optional(),
enableContextCompression: z.boolean().optional(), enableContextCompression: z.boolean().optional(),
enableFollowUpChips: z.boolean().optional(),
enableHistoryCount: z.boolean().optional(), enableHistoryCount: z.boolean().optional(),
enableMaxTokens: z.boolean().optional(), enableMaxTokens: z.boolean().optional(),
enableReasoning: z.boolean().optional(), enableReasoning: z.boolean().optional(),
+2
View File
@@ -19,6 +19,7 @@ export interface FollowUpModelConfig {
export interface FollowUpExtractInput { export interface FollowUpExtractInput {
hint?: FollowUpHint; hint?: FollowUpHint;
modelConfig: FollowUpModelConfig; modelConfig: FollowUpModelConfig;
threadId?: string;
topicId: string; topicId: string;
} }
@@ -46,5 +47,6 @@ export const FollowUpModelConfigSchema = z.object({
export const FollowUpExtractInputSchema = z.object({ export const FollowUpExtractInputSchema = z.object({
hint: FollowUpHintSchema.optional(), hint: FollowUpHintSchema.optional(),
modelConfig: FollowUpModelConfigSchema, modelConfig: FollowUpModelConfigSchema,
threadId: z.string().optional(),
topicId: z.string().min(1), topicId: z.string().min(1),
}); });
@@ -11,6 +11,7 @@ export interface PromptRewriteSystemAgent extends Omit<SystemAgentItem, 'enabled
export interface UserSystemAgentConfig { export interface UserSystemAgentConfig {
agentMeta: SystemAgentItem; agentMeta: SystemAgentItem;
followUpAction: SystemAgentItem;
generationTopic: SystemAgentItem; generationTopic: SystemAgentItem;
historyCompress: SystemAgentItem; historyCompress: SystemAgentItem;
inputCompletion: SystemAgentItem; inputCompletion: SystemAgentItem;
@@ -16,6 +16,8 @@ import ControlsForm from '@/features/ModelSwitchPanel/components/ControlsForm';
import { useAgentStore } from '@/store/agent'; import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors'; import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors';
import { aiModelSelectors, useAiInfraStore } from '@/store/aiInfra'; import { aiModelSelectors, useAiInfraStore } from '@/store/aiInfra';
import { useUserStore } from '@/store/user';
import { systemAgentSelectors } from '@/store/user/selectors';
import type { LobeAgentConfig } from '@/types/agent'; import type { LobeAgentConfig } from '@/types/agent';
import { useAgentId } from '../../hooks/useAgentId'; import { useAgentId } from '../../hooks/useAgentId';
@@ -114,6 +116,11 @@ const styles = createStaticStyles(({ css }) => ({
height: 1px; height: 1px;
background: ${cssVar.colorSplit}; background: ${cssVar.colorSplit};
`, `,
hint: css`
font-size: 12px;
line-height: 18px;
color: ${cssVar.colorTextTertiary};
`,
form: css` form: css`
margin: 0; margin: 0;
`, `,
@@ -533,6 +540,11 @@ const Controls = memo<ControlsProps>(({ setUpdating, updating, variant = 'popove
'enableAutoScrollOnStreaming', 'enableAutoScrollOnStreaming',
]); ]);
const enableStreaming = form.getFieldValue(['chatConfig', 'enableStreaming']); 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 enableReasoningEffort = form.getFieldValue(['chatConfig', 'enableReasoningEffort']);
const reasoningEffortValue = form.getFieldValue(['params', 'reasoning_effort']); const reasoningEffortValue = form.getFieldValue(['params', 'reasoning_effort']);
const disabledParams = useAiInfraStore( const disabledParams = useAiInfraStore(
@@ -783,6 +795,26 @@ const Controls = memo<ControlsProps>(({ setUpdating, updating, variant = 'popove
/> />
} }
/> />
<ControlRow
tag="followUpChips"
title={t('settingChat.enableFollowUpChips.title')}
tooltip={t('settingChat.enableFollowUpChips.desc')}
action={
<Switch
checked={Boolean(enableFollowUpChips)}
size={'small'}
onChange={(checked) => {
handleFieldChange(['chatConfig', 'enableFollowUpChips'], checked);
}}
/>
}
>
{showFollowUpHint && (
<div className={styles.hint}>
{t('settingChat.enableFollowUpChips.notConfiguredHint')}
</div>
)}
</ControlRow>
<ControlRow <ControlRow
tag="inputTemplate" tag="inputTemplate"
title={t('settingChat.inputTemplate.title')} title={t('settingChat.inputTemplate.title')}
@@ -0,0 +1,286 @@
/**
* @vitest-environment happy-dom
*/
import { render, waitFor } from '@testing-library/react';
import debug from 'debug';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AssistantTurnSettledWatcher from './AssistantTurnSettledWatcher';
const { mockState, mockChatState } = vi.hoisted(() => ({
mockChatState: {
operations: {} as Record<string, any>,
operationsByMessage: {} as Record<string, string[]>,
},
mockState: {
displayMessages: [] as Array<{ id: string; role: string }>,
generatingIds: new Set<string>(),
hooks: {} as Record<string, ((...args: any[]) => 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<typeof vi.fn>,
) => {
mockState.hooks.onAssistantTurnSettled = hook;
mockState.displayMessages = [
{ id: 'user-1', role: 'user' },
{ id: 'assistant-1', role: 'assistant' },
];
mockState.generatingIds = new Set(['assistant-1']);
rerender(<AssistantTurnSettledWatcher />);
mockState.generatingIds = new Set();
rerender(<AssistantTurnSettledWatcher />);
};
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(<AssistantTurnSettledWatcher />);
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(<AssistantTurnSettledWatcher />);
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(<AssistantTurnSettledWatcher />);
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(<AssistantTurnSettledWatcher />);
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(<AssistantTurnSettledWatcher />);
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(<AssistantTurnSettledWatcher />);
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(<AssistantTurnSettledWatcher />);
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(<AssistantTurnSettledWatcher />);
mockState.hooks.onAssistantTurnSettled = hook;
mockState.displayMessages = [
{ id: 'user-1', role: 'user' },
{ id: 'assistant-1', role: 'assistant' },
];
mockState.generatingIds = new Set(['assistant-1']);
rerender(<AssistantTurnSettledWatcher />);
mockState.generatingIds = new Set();
mockState.pendingInterventions = [{ id: 'tool-1' }];
rerender(<AssistantTurnSettledWatcher />);
expect(hook).not.toHaveBeenCalled();
mockState.pendingInterventions = [];
rerender(<AssistantTurnSettledWatcher />);
expect(hook).not.toHaveBeenCalled();
mockState.generatingIds = new Set(['assistant-1']);
rerender(<AssistantTurnSettledWatcher />);
expect(hook).not.toHaveBeenCalled();
mockState.generatingIds = new Set();
rerender(<AssistantTurnSettledWatcher />);
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(<AssistantTurnSettledWatcher />);
armAndSettle(rerender, hook);
await waitFor(() => {
expect(hook).toHaveBeenCalledTimes(1);
});
rerender(<AssistantTurnSettledWatcher />);
rerender(<AssistantTurnSettledWatcher />);
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(<AssistantTurnSettledWatcher />);
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();
});
});
@@ -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<OperationType>(['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<string>(undefined);
const firedSettledMessageIdRef = useRef<string>(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;
@@ -44,7 +44,7 @@ const ChatItem = memo<ChatItemProps>(
...rest ...rest
}) => { }) => {
const isUser = placement === 'right'; const isUser = placement === 'right';
const topicId = useConversationStore(contextSelectors.topicId); const conversationKey = useConversationStore(contextSelectors.conversationKey);
const isEmptyMessage = const isEmptyMessage =
!message || String(message).trim() === '' || message === placeholderMessage; !message || String(message).trim() === '' || message === placeholderMessage;
const errorContent = error && ( const errorContent = error && (
@@ -118,7 +118,9 @@ const ChatItem = memo<ChatItemProps>(
)} )}
{belowMessage} {belowMessage}
</Flexbox> </Flexbox>
{id && topicId && <FollowUpChips messageId={id} topicId={topicId} />} {id && conversationKey && (
<FollowUpChips conversationKey={conversationKey} messageId={id} />
)}
{actions && <Actions actions={actions} placement={placement} />} {actions && <Actions actions={actions} placement={placement} />}
</Flexbox> </Flexbox>
); );
@@ -48,321 +48,323 @@ interface VirtualizedListProps {
*/ */
const VirtualizedList = memo<VirtualizedListProps>( const VirtualizedList = memo<VirtualizedListProps>(
({ dataSource, footerSlot, headerSlot, itemContent }) => { ({ dataSource, footerSlot, headerSlot, itemContent }) => {
const virtuaRef = useRef<VListHandle>(null); const virtuaRef = useRef<VListHandle>(null);
const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastUserScrollIntentAtRef = useRef(0); const lastUserScrollIntentAtRef = useRef(0);
// Per-topic scroll restoration. Provider does not remount on topic switch, // Per-topic scroll restoration. Provider does not remount on topic switch,
// so we key the scroll snapshot by the message-map key derived from // so we key the scroll snapshot by the message-map key derived from
// ConversationStore's `context`. // ConversationStore's `context`.
const contextKey = useConversationStore((s) => messageMapKey(s.context)); const contextKey = useConversationStore((s) => messageMapKey(s.context));
const { recordScroll } = useTopicScrollPersist({ const { recordScroll } = useTopicScrollPersist({
contextKey, contextKey,
dataSourceLength: dataSource.length, dataSourceLength: dataSource.length,
virtuaRef, virtuaRef,
}); });
// Second-to-last message is the user turn when sending (user + assistant pair) // Second-to-last message is the user turn when sending (user + assistant pair)
const isSecondLastMessageFromUser = useConversationStore( const isSecondLastMessageFromUser = useConversationStore(
dataSelectors.isSecondLastMessageFromUser, dataSelectors.isSecondLastMessageFromUser,
); );
const { const {
isScrollShrinking, isScrollShrinking,
isSpacerMessage, isSpacerMessage,
listData, listData,
onScrollOffset, onScrollOffset,
registerSpacerNode, registerSpacerNode,
spacerActive, spacerActive,
spacerHeight, spacerHeight,
} = useConversationScroll({ } = useConversationScroll({
dataSource, dataSource,
isSecondLastMessageFromUser, isSecondLastMessageFromUser,
virtuaRef, virtuaRef,
}); });
const isAutoScrollEnabled = useAutoScrollEnabled(); const isAutoScrollEnabled = useAutoScrollEnabled();
// Store actions // Store actions
const registerVirtuaScrollMethods = useConversationStore((s) => s.registerVirtuaScrollMethods); const registerVirtuaScrollMethods = useConversationStore((s) => s.registerVirtuaScrollMethods);
const setScrollState = useConversationStore((s) => s.setScrollState); const setScrollState = useConversationStore((s) => s.setScrollState);
const resetVisibleItems = useConversationStore((s) => s.resetVisibleItems); const resetVisibleItems = useConversationStore((s) => s.resetVisibleItems);
const setActiveIndex = useConversationStore((s) => s.setActiveIndex); const setActiveIndex = useConversationStore((s) => s.setActiveIndex);
const activeIndex = useConversationStore(virtuaListSelectors.activeIndex); const activeIndex = useConversationStore(virtuaListSelectors.activeIndex);
const markUserScrollIntent = useCallback(() => { const markUserScrollIntent = useCallback(() => {
lastUserScrollIntentAtRef.current = Date.now(); lastUserScrollIntentAtRef.current = Date.now();
}, []); }, []);
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => { (event: PointerEvent<HTMLDivElement>) => {
if (event.buttons > 0) { if (event.buttons > 0) {
markUserScrollIntent(); markUserScrollIntent();
}
},
[markUserScrollIntent],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
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( // Check if at bottom
(event: KeyboardEvent<HTMLDivElement>) => { const isAtBottom = checkAtBottom();
if (SCROLL_KEYS.has(event.key)) { setScrollState({ atBottom: isAtBottom });
markUserScrollIntent();
if (ref) {
recordScroll(ref.scrollOffset, isAtBottom);
} }
},
[markUserScrollIntent],
);
// Check if at bottom based on scroll position // Clear existing timer
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();
if (scrollEndTimerRef.current) { if (scrollEndTimerRef.current) {
clearTimeout(scrollEndTimerRef.current); clearTimeout(scrollEndTimerRef.current);
} }
};
}, [resetVisibleItems]);
// Keep currently-streaming items mounted so vlist recycling never triggers // Set new timer for scroll end
// Markdown animation replay when the user scrolls them back into view. scrollEndTimerRef.current = setTimeout(() => {
const streamingIndices = useConversationStore( setScrollState({ isScrolling: false });
useShallow((s) => { }, 150);
const indices: number[] = []; }, [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<number>(streamingIndices);
for (let i = 0; i < dataSource.length; i++) { for (let i = 0; i < dataSource.length; i++) {
const id = dataSource[i]; const id = dataSource[i];
if (!id) continue; if (id && selectionMessageIds.has(id)) merged.add(i);
if (messageStateSelectors.isMessageGenerating(id)(s)) indices.push(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 const atBottom = useConversationStore(virtuaListSelectors.atBottom);
// containing a Selection endpoint would silently drop the user's highlight. const scrollToBottom = useConversationStore((s) => s.scrollToBottom);
const selectionMessageIds = useSelectionMessageIds();
const keepMountedIndices = useMemo(() => { // The ChatInput's floating overlay (TodoProgress + QueueTray) covers the
if (selectionMessageIds.size === 0) return streamingIndices; // bottom of this scroll viewport like a layer. Extend VList's internal
const merged = new Set<number>(streamingIndices); // padding-bottom by the overlay height so the last message can still be
for (let i = 0; i < dataSource.length; i++) { // scrolled into view *above* the overlay; the +12 compensates for the
const id = dataSource[i]; // ChatInput's `marginTop: -12` (skipScrollMarginWithList) so the last
if (id && selectionMessageIds.has(id)) merged.add(i); // message lands exactly on the overlay's top edge.
} const overlayHeight = useConversationStore(inputSelectors.chatInputOverlayHeight);
if (merged.size === streamingIndices.length) return streamingIndices; const paddingBottom = Math.max(24, overlayHeight + 12);
return [...merged].sort((a, b) => a - b);
}, [dataSource, streamingIndices, selectionMessageIds]);
const atBottom = useConversationStore(virtuaListSelectors.atBottom); const dataWithSlots = useMemo(
const scrollToBottom = useConversationStore((s) => s.scrollToBottom); () => [
...(headerSlot ? [CONVERSATION_HEADER_ID] : []),
...listData,
...(footerSlot ? [CONVERSATION_FOOTER_ID] : []),
],
[footerSlot, headerSlot, listData],
);
// The ChatInput's floating overlay (TodoProgress + QueueTray) covers the const keepMountedIndicesWithSlots = useMemo(
// bottom of this scroll viewport like a layer. Extend VList's internal () => (headerSlot ? keepMountedIndices.map((index) => index + 1) : keepMountedIndices),
// padding-bottom by the overlay height so the last message can still be [headerSlot, keepMountedIndices],
// 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 dataWithSlots = useMemo( // 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)
...(headerSlot ? [CONVERSATION_HEADER_ID] : []), // without re-registering on every render.
...listData, const totalCountRef = useRef(dataWithSlots.length);
...(footerSlot ? [CONVERSATION_FOOTER_ID] : []), totalCountRef.current = dataWithSlots.length;
],
[footerSlot, headerSlot, listData],
);
const keepMountedIndicesWithSlots = useMemo( return (
() => (headerSlot ? keepMountedIndices.map((index) => index + 1) : keepMountedIndices), <div
[headerSlot, keepMountedIndices], style={{ height: '100%', position: 'relative' }}
); onKeyDownCapture={handleKeyDown}
onPointerDownCapture={markUserScrollIntent}
// Mirror the latest data length into a ref so the scroll-methods registered onPointerMoveCapture={handlePointerMove}
// once on mount can read the current total count (including spacer/footer) onTouchMoveCapture={markUserScrollIntent}
// without re-registering on every render. onWheelCapture={markUserScrollIntent}
const totalCountRef = useRef(dataWithSlots.length);
totalCountRef.current = dataWithSlots.length;
return (
<div
style={{ height: '100%', position: 'relative' }}
onKeyDownCapture={handleKeyDown}
onPointerDownCapture={markUserScrollIntent}
onPointerMoveCapture={handlePointerMove}
onTouchMoveCapture={markUserScrollIntent}
onWheelCapture={markUserScrollIntent}
>
{/* Debug Inspector - placed outside VList so it won't be recycled by the virtual list */}
{OPEN_DEV_INSPECTOR && <DebugInspector />}
<VList
bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
data={dataWithSlots}
keepMounted={keepMountedIndicesWithSlots}
ref={virtuaRef}
style={{ height: '100%', overflowAnchor: 'none', paddingBottom }}
onScroll={handleScroll}
onScrollEnd={handleScrollEnd}
> >
{(messageId, index): ReactElement => { {/* Debug Inspector - placed outside VList so it won't be recycled by the virtual list */}
if (messageId === CONVERSATION_HEADER_ID) { {OPEN_DEV_INSPECTOR && <DebugInspector />}
return ( <VList
<WideScreenContainer key={messageId} style={{ position: 'relative' }}> bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
{headerSlot} data={dataWithSlots}
</WideScreenContainer> keepMounted={keepMountedIndicesWithSlots}
); ref={virtuaRef}
} style={{ height: '100%', overflowAnchor: 'none', paddingBottom }}
if (messageId === CONVERSATION_FOOTER_ID) { onScroll={handleScroll}
return ( onScrollEnd={handleScrollEnd}
<WideScreenContainer key={messageId} style={{ position: 'relative' }}> >
{footerSlot} {(messageId, index): ReactElement => {
</WideScreenContainer> if (messageId === CONVERSATION_HEADER_ID) {
); return (
} <WideScreenContainer key={messageId} style={{ position: 'relative' }}>
if (isSpacerMessage(messageId)) { {headerSlot}
// Only animate the collapse-to-zero (unmount). Any non-zero height </WideScreenContainer>
// 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 if (messageId === CONVERSATION_FOOTER_ID) {
// a 200ms transition. return (
const shouldAnimate = !isScrollShrinking && spacerHeight === 0; <WideScreenContainer key={messageId} style={{ position: 'relative' }}>
return ( {footerSlot}
<WideScreenContainer key={messageId} style={{ position: 'relative' }}> </WideScreenContainer>
<div );
aria-hidden }
ref={registerSpacerNode} if (isSpacerMessage(messageId)) {
style={{ // Only animate the collapse-to-zero (unmount). Any non-zero height
height: spacerHeight, // change (initial mount, shrink as assistant grows) is applied
pointerEvents: 'none', // instantly so virtua's scrollSize updates in a single frame and
transition: shouldAnimate // scrollToIndex can reach the user message without trailing behind
? `height ${CONVERSATION_SPACER_TRANSITION_MS}ms ease` // a 200ms transition.
: 'none', const shouldAnimate = !isScrollShrinking && spacerHeight === 0;
width: '100%', return (
}} <WideScreenContainer key={messageId} style={{ position: 'relative' }}>
/> <div
</WideScreenContainer> aria-hidden
); ref={registerSpacerNode}
} style={{
height: spacerHeight,
pointerEvents: 'none',
transition: shouldAnimate
? `height ${CONVERSATION_SPACER_TRANSITION_MS}ms ease`
: 'none',
width: '100%',
}}
/>
</WideScreenContainer>
);
}
const isAgentCouncil = messageId.includes('agentCouncil'); const isAgentCouncil = messageId.includes('agentCouncil');
const messageIndex = headerSlot ? index - 1 : index; const messageIndex = headerSlot ? index - 1 : index;
const isLastItem = messageIndex === dataSource.length - 1; const isLastItem = messageIndex === dataSource.length - 1;
const content = itemContent(messageIndex, messageId); const content = itemContent(messageIndex, messageId);
if (isAgentCouncil) {
// AgentCouncil needs full width for horizontal scroll
return (
<div key={messageId} style={{ position: 'relative', width: '100%' }}>
{content}
{/* AutoScroll is placed inside the last Item so it only triggers when the last Item is visible */}
{isLastItem && isAutoScrollEnabled && !spacerActive && <AutoScroll />}
</div>
);
}
if (isAgentCouncil) {
// AgentCouncil needs full width for horizontal scroll
return ( return (
<div key={messageId} style={{ position: 'relative', width: '100%' }}> <WideScreenContainer key={messageId} style={{ position: 'relative' }}>
{content} {content}
{/* AutoScroll is placed inside the last Item so it only triggers when the last Item is visible */}
{isLastItem && isAutoScrollEnabled && !spacerActive && <AutoScroll />} {isLastItem && isAutoScrollEnabled && !spacerActive && <AutoScroll />}
</div> </WideScreenContainer>
); );
} }}
</VList>
return ( {/* BackBottom is placed outside VList so it remains visible regardless of scroll position */}
<WideScreenContainer key={messageId} style={{ position: 'relative' }}> <WideScreenContainer style={{ position: 'relative' }}>
{content} <BackBottom
{isLastItem && isAutoScrollEnabled && !spacerActive && <AutoScroll />} atBottom={atBottom}
</WideScreenContainer> bottomOffset={overlayHeight}
); visible={!atBottom}
}} onScrollToBottom={() => scrollToBottom(true)}
</VList> />
{/* BackBottom is placed outside VList so it remains visible regardless of scroll position */} </WideScreenContainer>
<WideScreenContainer style={{ position: 'relative' }}> </div>
<BackBottom );
atBottom={atBottom} },
bottomOffset={overlayHeight} isEqual,
visible={!atBottom} );
onScrollToBottom={() => scrollToBottom(true)}
/>
</WideScreenContainer>
</div>
);
}, isEqual);
VirtualizedList.displayName = 'ConversationVirtualizedList'; VirtualizedList.displayName = 'ConversationVirtualizedList';
@@ -8,6 +8,7 @@ import { memo, useMemo } from 'react';
import { messageMapKey } from '@/store/chat/utils/messageMapKey'; import { messageMapKey } from '@/store/chat/utils/messageMapKey';
import AssistantTurnSettledWatcher from './AssistantTurnSettledWatcher';
import { createStore, Provider } from './store'; import { createStore, Provider } from './store';
import StoreUpdater from './StoreUpdater'; import StoreUpdater from './StoreUpdater';
import { import {
@@ -103,6 +104,7 @@ export const ConversationProvider = memo<ConversationProviderProps>(
skipFetch={skipFetch} skipFetch={skipFetch}
onMessagesChange={onMessagesChange} onMessagesChange={onMessagesChange}
/> />
<AssistantTurnSettledWatcher />
{children} {children}
</Provider> </Provider>
); );
@@ -21,8 +21,8 @@ vi.hoisted(() => {
const MSG = 'msg-1'; const MSG = 'msg-1';
const OTHER_MSG = 'msg-2'; const OTHER_MSG = 'msg-2';
const CHILD_MSG = 'msg-1-child-answer'; const CHILD_MSG = 'msg-1-child-answer';
const TOPIC = 'topic-1'; const KEY = 'main_agent-a_topic-1';
const OTHER_TOPIC = 'topic-2'; const OTHER_KEY = 'main_agent-a_topic-2';
const updateInputMessageMock = vi.fn(); const updateInputMessageMock = vi.fn();
const editorSetDocumentMock = vi.fn(); const editorSetDocumentMock = vi.fn();
@@ -58,89 +58,109 @@ describe('<FollowUpChips />', () => {
it('renders nothing when status is not ready', () => { it('renders nothing when status is not ready', () => {
useFollowUpActionStore.setState({ useFollowUpActionStore.setState({
chips: [{ label: 'x', message: 'x' }], slots: {
messageId: MSG, [KEY]: {
status: 'loading', chips: [{ label: 'x', message: 'x' }],
topicId: TOPIC, messageId: MSG,
status: 'loading',
},
},
}); });
const { container } = render(<FollowUpChips messageId={MSG} topicId={TOPIC} />); const { container } = render(<FollowUpChips conversationKey={KEY} messageId={MSG} />);
expect(container).toBeEmptyDOMElement(); expect(container).toBeEmptyDOMElement();
}); });
it('renders nothing when messageId mismatches and is not a child id', () => { it('renders nothing when messageId mismatches and is not a child id', () => {
useFollowUpActionStore.setState({ useFollowUpActionStore.setState({
chips: [{ label: 'x', message: 'x' }], slots: {
messageId: OTHER_MSG, [KEY]: {
status: 'ready', chips: [{ label: 'x', message: 'x' }],
topicId: TOPIC, messageId: OTHER_MSG,
status: 'ready',
},
},
}); });
const { container } = render(<FollowUpChips messageId={MSG} topicId={TOPIC} />); const { container } = render(<FollowUpChips conversationKey={KEY} messageId={MSG} />);
expect(container).toBeEmptyDOMElement(); expect(container).toBeEmptyDOMElement();
}); });
it('renders nothing when topicId mismatches', () => { it('renders nothing when conversationKey mismatches', () => {
useFollowUpActionStore.setState({ useFollowUpActionStore.setState({
chips: [{ label: 'a', message: 'a' }], slots: {
messageId: MSG, [KEY]: {
status: 'ready', chips: [{ label: 'a', message: 'a' }],
topicId: TOPIC, messageId: MSG,
status: 'ready',
},
},
}); });
const { container } = render(<FollowUpChips messageId={MSG} topicId={OTHER_TOPIC} />); const { container } = render(<FollowUpChips conversationKey={OTHER_KEY} messageId={MSG} />);
expect(container).toBeEmptyDOMElement(); expect(container).toBeEmptyDOMElement();
}); });
it('renders nothing while the bound message is generating', () => { it('renders nothing while the bound message is generating', () => {
isGeneratingMock = true; isGeneratingMock = true;
useFollowUpActionStore.setState({ useFollowUpActionStore.setState({
chips: [{ label: 'a', message: 'a' }], slots: {
messageId: MSG, [KEY]: {
status: 'ready', chips: [{ label: 'a', message: 'a' }],
topicId: TOPIC, messageId: MSG,
status: 'ready',
},
},
}); });
const { container } = render(<FollowUpChips messageId={MSG} topicId={TOPIC} />); const { container } = render(<FollowUpChips conversationKey={KEY} messageId={MSG} />);
expect(container).toBeEmptyDOMElement(); expect(container).toBeEmptyDOMElement();
}); });
it('renders one button per chip when both ids match and not generating', () => { it('renders one button per chip when both ids match and not generating', () => {
useFollowUpActionStore.setState({ useFollowUpActionStore.setState({
chips: [ slots: {
{ label: 'a', message: 'a' }, [KEY]: {
{ label: 'b', message: 'b' }, chips: [
{ label: 'c', message: 'c' }, { label: 'a', message: 'a' },
], { label: 'b', message: 'b' },
messageId: MSG, { label: 'c', message: 'c' },
status: 'ready', ],
topicId: TOPIC, messageId: MSG,
status: 'ready',
},
},
}); });
render(<FollowUpChips messageId={MSG} topicId={TOPIC} />); render(<FollowUpChips conversationKey={KEY} messageId={MSG} />);
expect(screen.getAllByRole('button')).toHaveLength(3); expect(screen.getAllByRole('button')).toHaveLength(3);
}); });
it('renders chips when the stored messageId matches a child block id of the bound group', () => { it('renders chips when the stored messageId matches a child block id of the bound group', () => {
displayMessagesMock = [{ children: [{ id: CHILD_MSG }], id: MSG }]; displayMessagesMock = [{ children: [{ id: CHILD_MSG }], id: MSG }];
useFollowUpActionStore.setState({ useFollowUpActionStore.setState({
chips: [{ label: 'a', message: 'a' }], slots: {
messageId: CHILD_MSG, [KEY]: {
status: 'ready', chips: [{ label: 'a', message: 'a' }],
topicId: TOPIC, messageId: CHILD_MSG,
status: 'ready',
},
},
}); });
render(<FollowUpChips messageId={MSG} topicId={TOPIC} />); render(<FollowUpChips conversationKey={KEY} messageId={MSG} />);
expect(screen.getAllByRole('button')).toHaveLength(1); expect(screen.getAllByRole('button')).toHaveLength(1);
}); });
it('fills the input and consumes on click instead of sending', () => { it('fills the input and consumes on click instead of sending', () => {
useFollowUpActionStore.setState({ useFollowUpActionStore.setState({
chips: [{ label: 'go', message: 'go ahead' }], slots: {
messageId: MSG, [KEY]: {
status: 'ready', chips: [{ label: 'go', message: 'go ahead' }],
topicId: TOPIC, messageId: MSG,
status: 'ready',
},
},
}); });
render(<FollowUpChips messageId={MSG} topicId={TOPIC} />); render(<FollowUpChips conversationKey={KEY} messageId={MSG} />);
fireEvent.click(screen.getByRole('button', { name: 'go' })); fireEvent.click(screen.getByRole('button', { name: 'go' }));
expect(updateInputMessageMock).toHaveBeenCalledWith('go ahead'); expect(updateInputMessageMock).toHaveBeenCalledWith('go ahead');
expect(editorSetDocumentMock).toHaveBeenCalledWith('text', 'go ahead'); expect(editorSetDocumentMock).toHaveBeenCalledWith('text', 'go ahead');
expect(editorFocusMock).toHaveBeenCalled(); expect(editorFocusMock).toHaveBeenCalled();
// The chip is not consumed on click — it stays ready until the user sends. expect(useFollowUpActionStore.getState().slots[KEY]?.status).toBe('ready');
expect(useFollowUpActionStore.getState().status).toBe('ready');
}); });
}); });
@@ -11,27 +11,22 @@ import { messageStateSelectors } from '../store';
import { styles } from './style'; import { styles } from './style';
interface FollowUpChipsProps { interface FollowUpChipsProps {
conversationKey: string;
messageId: string; messageId: string;
topicId: string;
} }
const FollowUpChips = memo<FollowUpChipsProps>(({ messageId, topicId }) => { const FollowUpChips = memo<FollowUpChipsProps>(({ conversationKey, messageId }) => {
// 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 childIdsKey = useConversationStore((s) => { const childIdsKey = useConversationStore((s) => {
const m = s.displayMessages.find((x) => x.id === messageId); const m = s.displayMessages.find((x) => x.id === messageId);
return m?.children?.map((c) => c.id).join('|') ?? ''; return m?.children?.map((c) => c.id).join('|') ?? '';
}); });
const selector = useMemo( const selector = useMemo(
() => followUpActionSelectors.chipsFor({ childIdsKey, messageId, topicId }), () => followUpActionSelectors.chipsFor({ childIdsKey, conversationKey, messageId }),
[childIdsKey, messageId, topicId], [childIdsKey, conversationKey, messageId],
); );
const chips = useFollowUpActionStore(selector); const chips = useFollowUpActionStore(selector);
const updateInputMessage = useConversationStore((s) => s.updateInputMessage); const updateInputMessage = useConversationStore((s) => s.updateInputMessage);
const editor = useConversationStore((s) => s.editor); 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( const isGenerating = useConversationStore(
messageStateSelectors.isAssistantGroupItemGenerating(messageId), messageStateSelectors.isAssistantGroupItemGenerating(messageId),
); );
@@ -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<typeof vi.fn>;
vi.mock('@/store/user', () => ({
useUserStore: vi.fn(),
}));
describe('useChatFollowUp', () => {
let fetchFor: ReturnType<typeof vi.fn>;
let clear: ReturnType<typeof vi.fn>;
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<typeof useChatFollowUp>) => {
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<Parameters<typeof useChatFollowUp>[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');
});
});
});
@@ -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<ConversationHooks>(() => {
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]);
};
@@ -1,3 +1,5 @@
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
import { type State } from '../../initialState'; import { type State } from '../../initialState';
const context = (s: State) => s.context; 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 isThread = (s: State) => !!s.context.threadId;
const isTopic = (s: State) => !!s.context.topicId; 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 hooks = (s: State) => s.hooks;
const hook = (hookName: keyof State['hooks']) => (s: State) => s.hooks[hookName]; const hook = (hookName: keyof State['hooks']) => (s: State) => s.hooks[hookName];
export const contextSelectors = { export const contextSelectors = {
agentId, agentId,
context, context,
conversationKey,
hook, hook,
hooks, hooks,
isThread, isThread,
+24 -25
View File
@@ -69,6 +69,26 @@ export interface ConversationHooks {
*/ */
onAfterSendMessage?: () => Promise<void>; onAfterSendMessage?: () => Promise<void>;
/**
* 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<unknown> | void;
/** /**
* Called before continuing generation * Called before continuing generation
* *
@@ -103,6 +123,10 @@ export interface ConversationHooks {
*/ */
onBeforeSendMessage?: (params: SendMessageParams) => Promise<boolean | void>; onBeforeSendMessage?: (params: SendMessageParams) => Promise<boolean | void>;
// ========================================
// Generation State Change Hooks
// ========================================
/** /**
* Called after continue generation completes * Called after continue generation completes
* *
@@ -110,31 +134,6 @@ export interface ConversationHooks {
*/ */
onContinueComplete?: (messageId: string) => void; 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 * Called when generation is stopped by user
*/ */
@@ -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();
});
});
@@ -0,0 +1,52 @@
import { type ConversationHooks } from '../types';
const BLOCKING_HOOK_KEYS = new Set([
'onBeforeContinue',
'onBeforeRegenerate',
'onBeforeSendMessage',
]);
const collectHookNames = (hooks: ConversationHooks[]): Set<string> => {
const names = new Set<string>();
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;
};
+25 -10
View File
@@ -11,9 +11,13 @@ import {
type ConversationHooks, type ConversationHooks,
ConversationProvider, ConversationProvider,
} from '@/features/Conversation'; } from '@/features/Conversation';
import { useChatFollowUp } from '@/features/Conversation/hooks/useChatFollowUp';
import { type ConversationContext } from '@/features/Conversation/types'; import { type ConversationContext } from '@/features/Conversation/types';
import { mergeConversationHooks } from '@/features/Conversation/utils/mergeConversationHooks';
import { useOperationState } from '@/hooks/useOperationState'; import { useOperationState } from '@/hooks/useOperationState';
import { useActionsBarConfig } from '@/routes/(main)/agent/features/Conversation/useActionsBarConfig'; 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 { useChatStore } from '@/store/chat';
import { messageMapKey } from '@/store/chat/utils/messageMapKey'; import { messageMapKey } from '@/store/chat/utils/messageMapKey';
@@ -158,17 +162,28 @@ const FloatingChatPanel = memo<FloatingChatPanelProps>(
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [activeSnapPoint, setActiveSnapPoint] = useState<number>(REST_SNAP_POINT); const [activeSnapPoint, setActiveSnapPoint] = useState<number>(REST_SNAP_POINT);
const agentChatConfig = useAgentStore(chatConfigByIdSelectors.getChatConfigById(agentId));
const chatFollowUpHooks = useChatFollowUp({
agentChatConfig,
conversationKey: chatKey,
threadId: threadId ?? undefined,
topicId: topicId ?? undefined,
});
const mergedHooks = useMemo<ConversationHooks>( const mergedHooks = useMemo<ConversationHooks>(
() => ({ () =>
...hooks, mergeConversationHooks(
// Expand the sheet the moment the user presses Send, so the chat grows hooks,
// into view before the AI response streams in — not after it finishes. {
onBeforeSendMessage: async (params) => { // Expand the sheet the moment the user presses Send, so the chat grows
setActiveSnapPoint(MAX_SNAP_POINT); // into view before the AI response streams in — not after it finishes.
return hooks?.onBeforeSendMessage?.(params); onBeforeSendMessage: async () => {
}, setActiveSnapPoint(MAX_SNAP_POINT);
}), },
[hooks], },
chatFollowUpHooks,
),
[hooks, chatFollowUpHooks],
); );
const sheetProps: FloatingSheetProps = { const sheetProps: FloatingSheetProps = {
@@ -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 type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -17,7 +17,6 @@ const { chatInputSpy, messageItemSpy, mockState } = vi.hoisted(() => ({
messageItemSpy: vi.fn(), messageItemSpy: vi.fn(),
mockState: { mockState: {
displayMessages: [] as Array<{ content?: string; id: string; role: string }>, displayMessages: [] as Array<{ content?: string; id: string; role: string }>,
generatingIds: new Set<string>(),
pendingInterventions: [] as Array<{ id: string }>, pendingInterventions: [] as Array<{ id: string }>,
}, },
})); }));
@@ -91,7 +90,6 @@ describe('AgentOnboardingConversation', () => {
chatInputSpy.mockClear(); chatInputSpy.mockClear();
messageItemSpy.mockClear(); messageItemSpy.mockClear();
mockState.displayMessages = []; mockState.displayMessages = [];
mockState.generatingIds = new Set();
mockState.pendingInterventions = []; 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(
<AgentOnboardingConversation
discoveryUserMessageCount={0}
topicId="topic-1"
onAssistantTurnSettled={onAssistantTurnSettled}
/>,
);
expect(onAssistantTurnSettled).not.toHaveBeenCalled();
mockState.generatingIds = new Set();
rerender(
<AgentOnboardingConversation
discoveryUserMessageCount={1}
topicId="topic-1"
onAssistantTurnSettled={onAssistantTurnSettled}
/>,
);
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(
<AgentOnboardingConversation
discoveryUserMessageCount={0}
topicId="topic-1"
onAssistantTurnSettled={onAssistantTurnSettled}
/>,
);
mockState.generatingIds = new Set();
mockState.pendingInterventions = [{ id: 'tool-1' }];
rerender(
<AgentOnboardingConversation
discoveryUserMessageCount={1}
topicId="topic-1"
onAssistantTurnSettled={onAssistantTurnSettled}
/>,
);
expect(onAssistantTurnSettled).not.toHaveBeenCalled();
mockState.pendingInterventions = [];
rerender(
<AgentOnboardingConversation
discoveryUserMessageCount={2}
topicId="topic-1"
onAssistantTurnSettled={onAssistantTurnSettled}
/>,
);
expect(onAssistantTurnSettled).not.toHaveBeenCalled();
mockState.generatingIds = new Set(['assistant-1']);
rerender(
<AgentOnboardingConversation
discoveryUserMessageCount={3}
topicId="topic-1"
onAssistantTurnSettled={onAssistantTurnSettled}
/>,
);
expect(onAssistantTurnSettled).not.toHaveBeenCalled();
mockState.generatingIds = new Set();
rerender(
<AgentOnboardingConversation
discoveryUserMessageCount={4}
topicId="topic-1"
onAssistantTurnSettled={onAssistantTurnSettled}
/>,
);
await waitFor(() => {
expect(onAssistantTurnSettled).toHaveBeenCalledWith('assistant-1');
});
expect(onAssistantTurnSettled).toHaveBeenCalledTimes(1);
});
it('renders normal message items outside the greeting state', () => { it('renders normal message items outside the greeting state', () => {
mockState.displayMessages = [ mockState.displayMessages = [
{ id: 'assistant-1', role: 'assistant' }, { id: 'assistant-1', role: 'assistant' },
@@ -323,8 +226,4 @@ vi.mock('@/features/Conversation/store', () => ({
dataSelectors: { dataSelectors: {
pendingInterventions: (state: typeof mockState) => state.pendingInterventions, pendingInterventions: (state: typeof mockState) => state.pendingInterventions,
}, },
messageStateSelectors: {
isAssistantGroupItemGenerating: (id: string) => (state: typeof mockState) =>
state.generatingIds.has(id),
},
})); }));
+2 -52
View File
@@ -16,7 +16,7 @@ import {
MessageItem, MessageItem,
useConversationStore, useConversationStore,
} from '@/features/Conversation'; } from '@/features/Conversation';
import { dataSelectors, messageStateSelectors } from '@/features/Conversation/store'; import { dataSelectors } from '@/features/Conversation/store';
import WideScreenContainer from '@/features/WideScreenContainer'; import WideScreenContainer from '@/features/WideScreenContainer';
import { useIsMobile } from '@/hooks/useIsMobile'; import { useIsMobile } from '@/hooks/useIsMobile';
import type { OnboardingPhase } from '@/types/user'; import type { OnboardingPhase } from '@/types/user';
@@ -29,8 +29,6 @@ import WelcomeMobile from './Welcome.mobile';
import WelcomeMessage from './WelcomeMessage'; import WelcomeMessage from './WelcomeMessage';
import WrapUpHint from './WrapUpHint'; import WrapUpHint from './WrapUpHint';
const assistantLikeRoles = new Set(['assistant', 'assistantGroup', 'supervisor']);
interface AgentOnboardingConversationProps { interface AgentOnboardingConversationProps {
discoveryUserMessageCount?: number; discoveryUserMessageCount?: number;
feedbackSubmitted?: boolean; feedbackSubmitted?: boolean;
@@ -46,7 +44,6 @@ interface AgentOnboardingConversationProps {
// is ready to handle the first send. // is ready to handle the first send.
isInputReady?: boolean; isInputReady?: boolean;
onAfterWrapUp?: () => Promise<unknown> | void; onAfterWrapUp?: () => Promise<unknown> | void;
onAssistantTurnSettled?: (messageId: string) => Promise<unknown> | void;
onboardingFinished?: boolean; onboardingFinished?: boolean;
phase?: OnboardingPhase; phase?: OnboardingPhase;
readOnly?: boolean; readOnly?: boolean;
@@ -70,7 +67,6 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
hasMessages, hasMessages,
isInputReady = true, isInputReady = true,
onAfterWrapUp, onAfterWrapUp,
onAssistantTurnSettled,
onboardingFinished, onboardingFinished,
phase, phase,
readOnly, readOnly,
@@ -79,9 +75,6 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
}) => { }) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const displayMessages = useConversationStore(conversationSelectors.displayMessages); const displayMessages = useConversationStore(conversationSelectors.displayMessages);
const pendingInterventionCount = useConversationStore(
(s) => dataSelectors.pendingInterventions(s).length,
);
// The agent-marketplace intervention renders as an absolute overlay anchored // The agent-marketplace intervention renders as an absolute overlay anchored
// to the chat input area, which would otherwise occlude the last message. // to the chat input area, which would otherwise occlude the last message.
// Reserve matching scroll headroom inside ChatList so the latest message can // Reserve matching scroll headroom inside ChatList so the latest message can
@@ -105,23 +98,8 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
[hasMessages, displayMessages], [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 [showGreeting, setShowGreeting] = useState(isGreetingState);
const prevGreetingRef = useRef(isGreetingState); const prevGreetingRef = useRef(isGreetingState);
const armedSettledMessageIdRef = useRef<string>(undefined);
const firedSettledMessageIdRef = useRef<string>(undefined);
useEffect(() => { useEffect(() => {
if (prevGreetingRef.current && !isGreetingState) { if (prevGreetingRef.current && !isGreetingState) {
@@ -140,37 +118,9 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
prevGreetingRef.current = isGreetingState; prevGreetingRef.current = isGreetingState;
}, [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 hasPersistedAssistantOpener = displayMessages.at(0)?.role === 'assistant';
const shouldShowSyntheticWelcome = const shouldShowSyntheticWelcome =
!onboardingFinished && !onboardingFinished && !hasPersistedAssistantOpener && displayMessages.length > 0;
!hasPersistedAssistantOpener &&
displayMessages.length > 0;
const shouldShowGreetingWelcome = showGreeting && !onboardingFinished; const shouldShowGreetingWelcome = showGreeting && !onboardingFinished;
const shouldShowGreetingActions = showGreeting && !onboardingFinished; const shouldShowGreetingActions = showGreeting && !onboardingFinished;
+42 -31
View File
@@ -14,6 +14,8 @@ import { useNavigate } from 'react-router-dom';
import Loading from '@/components/Loading/BrandTextLoading'; import Loading from '@/components/Loading/BrandTextLoading';
import { ONBOARDING_PRODUCTION_DEFAULT_MODEL } from '@/const/onboarding'; 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 ModeSwitch from '@/features/Onboarding/components/ModeSwitch';
import { useOnboardingAgentTemplates } from '@/hooks/useOnboardingAgentTemplates'; import { useOnboardingAgentTemplates } from '@/hooks/useOnboardingAgentTemplates';
import { useClientDataSWR, useOnlyFetchOnceSWR } from '@/libs/swr'; import { useClientDataSWR, useOnlyFetchOnceSWR } from '@/libs/swr';
@@ -141,12 +143,14 @@ const AgentOnboardingPage = memo(() => {
[onboardingAgentConfig?.model, onboardingAgentConfig?.provider], [onboardingAgentConfig?.model, onboardingAgentConfig?.provider],
); );
const onboardingFollowUp = useOnboardingFollowUp({ const onboardingFollowUpHooks = useOnboardingFollowUp({
enabled: !onboardingFinished && !viewingHistoricalTopic, enabled: !onboardingFinished && !viewingHistoricalTopic,
isGreeting, isGreeting,
modelConfig: onboardingFollowUpModelConfig, 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 // Re-entry latch for the fresh-state first-send orchestration. The combination
// of advisory lock + this ref ensures rapid double-submit cannot create two // of advisory lock + this ref ensures rapid double-submit cannot create two
@@ -157,7 +161,6 @@ const AgentOnboardingPage = memo(() => {
const composedOnBeforeSendMessage = useCallback( const composedOnBeforeSendMessage = useCallback(
async (params: SendMessageParams): Promise<boolean> => { async (params: SendMessageParams): Promise<boolean> => {
params.metadata = { ...params.metadata, trigger: RequestTrigger.Onboarding }; params.metadata = { ...params.metadata, trigger: RequestTrigger.Onboarding };
await onBeforeSendMessage();
if (!onboardingAgentId) { if (!onboardingAgentId) {
// ChatInput is gated by `isInputReady`; this branch should be unreachable. // ChatInput is gated by `isInputReady`; this branch should be unreachable.
@@ -212,7 +215,7 @@ const AgentOnboardingPage = memo(() => {
await orchestration; await orchestration;
return false; return false;
}, },
[effectiveTopicId, mutate, onBeforeSendMessage, onboardingAgentId], [effectiveTopicId, mutate, onboardingAgentId],
); );
const syncOnboardingContext = useCallback(async () => { const syncOnboardingContext = useCallback(async () => {
@@ -223,43 +226,52 @@ const AgentOnboardingPage = memo(() => {
return nextContext; return nextContext;
}, [mutate, mutateHistoryTopics, onboardingAgentId]); }, [mutate, mutateHistoryTopics, onboardingAgentId]);
const handleAssistantTurnSettled = useCallback(async () => { const onboardingTurnSettledHook = useMemo<ConversationHooks>(() => {
if (!effectiveTopicId) return; if (onboardingFinished || viewingHistoricalTopic) return {};
const prevPhase = data?.context?.phase; return {
const prevFinishedAt = agentOnboarding?.finishedAt; 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 const nextContext = await syncOnboardingContext();
// the heavier user-store / builtin-agent refreshes are needed this turn. const newPhase = nextContext?.context?.phase;
const [nextContext] = await Promise.all([syncOnboardingContext(), extractPromise]); const newFinishedAt = nextContext?.agentOnboarding?.finishedAt;
const newPhase = nextContext?.context?.phase; const refreshes: Promise<unknown>[] = [];
const newFinishedAt = nextContext?.agentOnboarding?.finishedAt; if (newFinishedAt !== prevFinishedAt) refreshes.push(refreshUserState());
if (newPhase !== prevPhase) {
const refreshes: Promise<unknown>[] = []; refreshes.push(refreshBuiltinAgent(BUILTIN_AGENT_SLUGS.webOnboarding));
if (newFinishedAt !== prevFinishedAt) refreshes.push(refreshUserState()); }
if (newPhase !== prevPhase) { if (refreshes.length > 0) await Promise.all(refreshes);
refreshes.push(refreshBuiltinAgent(BUILTIN_AGENT_SLUGS.webOnboarding)); },
} };
if (refreshes.length > 0) await Promise.all(refreshes);
}, [ }, [
agentOnboarding?.finishedAt, onboardingFinished,
data?.context?.phase, viewingHistoricalTopic,
effectiveTopicId, effectiveTopicId,
data?.context?.phase,
agentOnboarding?.finishedAt,
refreshBuiltinAgent, refreshBuiltinAgent,
refreshUserState, refreshUserState,
syncOnboardingContext, syncOnboardingContext,
triggerExtract,
]); ]);
const assistantTurnSettledHandler =
onboardingFinished || viewingHistoricalTopic ? undefined : handleAssistantTurnSettled;
const conversationHooks = useMemo( const conversationHooks = useMemo(() => {
() => (onboardingFinished ? undefined : { onBeforeSendMessage: composedOnBeforeSendMessage }), if (onboardingFinished) return undefined;
[onboardingFinished, composedOnBeforeSendMessage], return mergeConversationHooks(
); { onBeforeSendMessage: composedOnBeforeSendMessage },
onboardingTurnSettledHook,
onboardingFollowUpHooks,
);
}, [
onboardingFinished,
composedOnBeforeSendMessage,
onboardingTurnSettledHook,
onboardingFollowUpHooks,
]);
if (error) { if (error) {
return ( return (
@@ -316,7 +328,6 @@ const AgentOnboardingPage = memo(() => {
showFeedback={!viewingHistoricalTopic} showFeedback={!viewingHistoricalTopic}
topicId={effectiveTopicId} topicId={effectiveTopicId}
onAfterWrapUp={syncOnboardingContext} onAfterWrapUp={syncOnboardingContext}
onAssistantTurnSettled={assistantTurnSettledHandler}
/> />
</ErrorBoundary> </ErrorBoundary>
</OnboardingConversationProvider> </OnboardingConversationProvider>
@@ -1,6 +1,7 @@
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
import { useFollowUpActionStore } from '@/store/followUpAction'; import { useFollowUpActionStore } from '@/store/followUpAction';
import { useOnboardingFollowUp } from './useOnboardingFollowUp'; import { useOnboardingFollowUp } from './useOnboardingFollowUp';
@@ -9,6 +10,9 @@ const MODEL_CONFIG = {
model: 'scene-model', model: 'scene-model',
provider: 'scene-provider', 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', () => { describe('useOnboardingFollowUp', () => {
let fetchFor: ReturnType<typeof vi.fn>; let fetchFor: ReturnType<typeof vi.fn>;
@@ -27,65 +31,186 @@ describe('useOnboardingFollowUp', () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it('triggerExtract skips when disabled', async () => { it('returns no hooks when disabled', async () => {
const { result } = renderHook(() => 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(); expect(fetchFor).not.toHaveBeenCalled();
}); });
it('triggerExtract skips when phase is undefined', async () => { it('onAssistantTurnSettled skips when phase is summary', async () => {
const { result } = renderHook(() => 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(); expect(fetchFor).not.toHaveBeenCalled();
}); });
it('triggerExtract skips when phase is summary', async () => { it('onAssistantTurnSettled skips when isGreeting is true', async () => {
const { result } = renderHook(() => 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(); expect(fetchFor).not.toHaveBeenCalled();
}); });
it('triggerExtract skips when isGreeting is true', async () => { it('onAssistantTurnSettled skips when reason is stopped', async () => {
const { result } = renderHook(() => 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(); 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(() => 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'); await result.current.onAssistantTurnSettled?.('msg-1', { reason: 'completed' });
expect(fetchFor).toHaveBeenCalledWith('topic-1', { expect(fetchFor).toHaveBeenCalledWith(CONVERSATION_KEY, {
hint: { hint: {
kind: 'onboarding', kind: 'onboarding',
phase: 'discovery', phase: 'discovery',
}, },
modelConfig: MODEL_CONFIG, 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 () => { it('onBeforeSendMessage clears when enabled', async () => {
const { result } = renderHook(() => 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(); await result.current.onBeforeSendMessage?.({} as any);
expect(clear).toHaveBeenCalledTimes(1); expect(clear).toHaveBeenCalledWith(CONVERSATION_KEY);
}); });
it('onBeforeSendMessage does nothing when disabled', async () => { it('onBeforeSendMessage is absent when disabled', async () => {
const { result } = renderHook(() => 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(); expect(clear).not.toHaveBeenCalled();
}); });
}); });
@@ -1,6 +1,8 @@
import type { FollowUpModelConfig } from '@lobechat/types'; 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 { useFollowUpActionStore } from '@/store/followUpAction';
import type { OnboardingPhase } from '@/types/user'; import type { OnboardingPhase } from '@/types/user';
@@ -8,37 +10,48 @@ interface UseOnboardingFollowUpParams {
enabled: boolean; enabled: boolean;
isGreeting: boolean; isGreeting: boolean;
modelConfig: FollowUpModelConfig; modelConfig: FollowUpModelConfig;
} onboardingAgentId: string | undefined;
phase: OnboardingPhase | undefined;
interface OnboardingFollowUpHandlers { topicId: string | undefined;
onBeforeSendMessage: () => Promise<void>;
triggerExtract: (topicId: string, phase: OnboardingPhase | undefined) => Promise<void>;
} }
export const useOnboardingFollowUp = ({ export const useOnboardingFollowUp = ({
enabled, enabled,
isGreeting, isGreeting,
modelConfig, modelConfig,
}: UseOnboardingFollowUpParams): OnboardingFollowUpHandlers => { onboardingAgentId,
const triggerExtract = useCallback( phase,
async (topicId: string, phase: OnboardingPhase | undefined) => { topicId,
if (!enabled) return; }: UseOnboardingFollowUpParams): ConversationHooks => {
if (!phase) return; return useMemo<ConversationHooks>(() => {
if (phase === 'summary') return; if (!enabled || !onboardingAgentId || !topicId) return {};
if (isGreeting) return;
await useFollowUpActionStore.getState().fetchFor(topicId, { const conversationKey = messageMapKey({ agentId: onboardingAgentId, topicId });
hint: { kind: 'onboarding', phase }, const phaseSnapshot = phase;
modelConfig,
});
},
[enabled, isGreeting, modelConfig],
);
const onBeforeSendMessage = useCallback(async () => { return {
if (!enabled) return; onAssistantTurnSettled: async (_messageId, { reason }) => {
useFollowUpActionStore.getState().clear(); if (reason === 'stopped') return;
}, [enabled]); if (isGreeting) return;
if (!phaseSnapshot) return;
return { onBeforeSendMessage, triggerExtract }; 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,
]);
}; };
+37 -19
View File
@@ -13,7 +13,11 @@ import {
useConversationStore, useConversationStore,
} from '@/features/Conversation'; } from '@/features/Conversation';
import SkeletonList from '@/features/Conversation/components/SkeletonList'; 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 { useOperationState } from '@/hooks/useOperationState';
import { useAgentStore } from '@/store/agent';
import { chatConfigByIdSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat'; import { useChatStore } from '@/store/chat';
import { portalThreadSelectors, threadSelectors } from '@/store/chat/selectors'; import { portalThreadSelectors, threadSelectors } from '@/store/chat/selectors';
import { type MessageMapKeyInput } from '@/store/chat/utils/messageMapKey'; import { type MessageMapKeyInput } from '@/store/chat/utils/messageMapKey';
@@ -187,30 +191,44 @@ const ThreadChat = memo(() => {
// Get operation state for reactive updates // Get operation state for reactive updates
const operationState = useOperationState(context); 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 // Hooks to handle post-message-creation tasks for new thread
const hooks: ConversationHooks = useMemo( const hooks: ConversationHooks = useMemo(
() => ({ () =>
onAfterMessageCreate: async ({ createdThreadId }) => { mergeConversationHooks(
if (!createdThreadId) return; {
onAfterMessageCreate: async ({ createdThreadId }) => {
if (!createdThreadId) return;
const state = useChatStore.getState(); const state = useChatStore.getState();
// Refresh threads list // Refresh threads list
await state.refreshThreads(); await state.refreshThreads();
// Refresh messages to include new thread messages // Refresh messages to include new thread messages
await state.refreshMessages(); await state.refreshMessages();
// Open the newly created thread in portal // Open the newly created thread in portal
state.openThreadInPortal(createdThreadId, threadStartMessageId); state.openThreadInPortal(createdThreadId, threadStartMessageId);
// Summarize thread title for new thread // Summarize thread title for new thread
const portalThread = threadSelectors.currentPortalThread(useChatStore.getState()); const portalThread = threadSelectors.currentPortalThread(useChatStore.getState());
if (portalThread) { if (portalThread) {
const chats = threadSelectors.portalAIChats(useChatStore.getState()); const chats = threadSelectors.portalAIChats(useChatStore.getState());
await useChatStore.getState().summaryThreadTitle(portalThread.id, chats); await useChatStore.getState().summaryThreadTitle(portalThread.id, chats);
} }
}, },
}), },
[threadStartMessageId], chatFollowUpHooks,
),
[chatFollowUpHooks, threadStartMessageId],
); );
return ( return (
@@ -29,6 +29,7 @@ const SYSTEM_AGENT_MODEL_ITEMS: SystemAgentModelItem[] = [
]; ];
const OPTIONAL_FEATURE_ITEMS: SystemAgentModelItem[] = [ const OPTIONAL_FEATURE_ITEMS: SystemAgentModelItem[] = [
{ key: 'followUpAction' },
{ key: 'inputCompletion' }, { key: 'inputCompletion' },
{ key: 'promptRewrite' }, { key: 'promptRewrite' },
]; ];
@@ -163,7 +164,9 @@ const ModelAssignmentsForm = memo(() => {
}); });
const isOptionalFeatureLoading = const isOptionalFeatureLoading =
loadingKey === 'inputCompletion' || loadingKey === 'promptRewrite'; loadingKey === 'followUpAction' ||
loadingKey === 'inputCompletion' ||
loadingKey === 'promptRewrite';
const isModelAssignmentLoading = loadingKey && !isOptionalFeatureLoading; const isModelAssignmentLoading = loadingKey && !isOptionalFeatureLoading;
const modelAssignments: FormGroupItemType = { const modelAssignments: FormGroupItemType = {
+9
View File
@@ -618,6 +618,11 @@ export default {
'settingChat.enableAutoScrollOnStreaming.desc': 'Override global setting for this assistant', 'settingChat.enableAutoScrollOnStreaming.desc': 'Override global setting for this assistant',
'settingChat.enableAutoScrollOnStreaming.title': 'Auto-scroll During AI Response', 'settingChat.enableAutoScrollOnStreaming.title': 'Auto-scroll During AI Response',
'settingChat.enableCompressHistory.title': 'Enable Automatic Summary of Chat History', '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.alias': 'Unlimited',
'settingChat.enableHistoryCount.limited': 'Include only {{number}} conversation messages', 'settingChat.enableHistoryCount.limited': 'Include only {{number}} conversation messages',
'settingChat.enableHistoryCount.setlimited': 'Set limited history 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', 'Once filled out, the system agent will use the custom prompt when generating content',
'systemAgent.customPrompt.placeholder': 'Please enter custom prompt', 'systemAgent.customPrompt.placeholder': 'Please enter custom prompt',
'systemAgent.customPrompt.title': '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.label': 'Model',
'systemAgent.generationTopic.modelDesc': 'Model used to name AI image topics', 'systemAgent.generationTopic.modelDesc': 'Model used to name AI image topics',
'systemAgent.generationTopic.title': 'AI Image Topic Naming', 'systemAgent.generationTopic.title': 'AI Image Topic Naming',
@@ -9,11 +9,13 @@ import { useTranslation } from 'react-i18next';
import AgentHome from '@/features/AgentHome'; import AgentHome from '@/features/AgentHome';
import ChatMiniMap from '@/features/ChatMiniMap'; import ChatMiniMap from '@/features/ChatMiniMap';
import { ChatList, ConversationProvider } from '@/features/Conversation'; 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 ZenModeToast from '@/features/ZenModeToast';
import { useGatewayReconnect } from '@/hooks/useGatewayReconnect'; import { useGatewayReconnect } from '@/hooks/useGatewayReconnect';
import { useOperationState } from '@/hooks/useOperationState'; import { useOperationState } from '@/hooks/useOperationState';
import { useAgentStore } from '@/store/agent'; import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors'; import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat'; import { useChatStore } from '@/store/chat';
import { threadSelectors, topicSelectors } from '@/store/chat/selectors'; import { threadSelectors, topicSelectors } from '@/store/chat/selectors';
import { messageMapKey } from '@/store/chat/utils/messageMapKey'; import { messageMapKey } from '@/store/chat/utils/messageMapKey';
@@ -71,11 +73,22 @@ const Conversation = memo(() => {
); );
useGatewayReconnect(context.topicId, runningOperation); 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 ( return (
<ConversationProvider <ConversationProvider
actionsBar={actionsBarConfig} actionsBar={actionsBarConfig}
context={context} context={context}
hasInitMessages={!!messages} hasInitMessages={!!messages}
hooks={hooks}
messages={messages} messages={messages}
operationState={operationState} operationState={operationState}
onMessagesChange={(messages, ctx) => { onMessagesChange={(messages, ctx) => {
@@ -16,6 +16,7 @@ const Page = () => {
<SystemAgentForm systemAgentKey="translation" /> <SystemAgentForm systemAgentKey="translation" />
<SystemAgentForm systemAgentKey="historyCompress" /> <SystemAgentForm systemAgentKey="historyCompress" />
<SystemAgentForm systemAgentKey="agentMeta" /> <SystemAgentForm systemAgentKey="agentMeta" />
<SystemAgentForm allowDisable systemAgentKey="followUpAction" />
<SystemAgentForm allowDisable systemAgentKey="inputCompletion" /> <SystemAgentForm allowDisable systemAgentKey="inputCompletion" />
<SystemAgentForm allowDisable systemAgentKey="promptRewrite" /> <SystemAgentForm allowDisable systemAgentKey="promptRewrite" />
</> </>
@@ -143,6 +143,48 @@ describe('FollowUpActionService.extract', () => {
expect(result).toEqual({ chips: [], messageId: FOUND_MSG }); 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 () => { it('appends onboarding addendum to system prompt when hint is onboarding', async () => {
queryFindFirstSpy.mockResolvedValue({ id: FOUND_MSG, content: 'q?' }); queryFindFirstSpy.mockResolvedValue({ id: FOUND_MSG, content: 'q?' });
runtimeMock.generateObject.mockResolvedValue({ chips: [] }); runtimeMock.generateObject.mockResolvedValue({ chips: [] });
+5 -1
View File
@@ -22,6 +22,7 @@ export class FollowUpActionService {
async extract({ async extract({
topicId, topicId,
threadId,
hint, hint,
modelConfig, modelConfig,
}: FollowUpExtractInput): Promise<FollowUpExtractResult> { }: FollowUpExtractInput): Promise<FollowUpExtractResult> {
@@ -30,10 +31,13 @@ export class FollowUpActionService {
const row = await this.db.query.messages.findFirst({ const row = await this.db.query.messages.findFirst({
columns: { content: true, id: true }, columns: { content: true, id: true },
orderBy: (m, { desc }) => desc(m.createdAt), orderBy: (m, { desc }) => desc(m.createdAt),
where: (m, { and, eq, isNotNull, ne }) => where: (m, { and, eq, isNotNull, isNull, ne }) =>
and( and(
eq(m.userId, this.userId), eq(m.userId, this.userId),
eq(m.topicId, topicId), 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'), eq(m.role, 'assistant'),
isNotNull(m.content), isNotNull(m.content),
ne(m.content, ''), ne(m.content, ''),
@@ -158,9 +158,7 @@ export function buildCloudHeteroContext(params: {
: entry.content; : entry.content;
return `<${entry.role}>\n${body}\n</${entry.role}>`; return `<${entry.role}>\n${body}\n</${entry.role}>`;
}); });
parts.push( parts.push(`<previous_conversation>\n${entries.join('\n')}\n</previous_conversation>`);
`<previous_conversation>\n${entries.join('\n')}\n</previous_conversation>`,
);
} }
return parts.join('\n\n'); return parts.join('\n\n');
+3 -1
View File
@@ -266,7 +266,9 @@ describe('OnboardingService', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.savedFields).toEqual(['fullName']); expect(result.savedFields).toEqual(['fullName']);
expect(result.ignoredFields).toEqual(['agentName', 'agentEmoji']); 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(persistedUserState.fullName).toBe('anbex');
expect(mockAgentModel.update).not.toHaveBeenCalled(); expect(mockAgentModel.update).not.toHaveBeenCalled();
}); });
+72 -54
View File
@@ -3,19 +3,55 @@ import type { FollowUpChip, FollowUpHint, FollowUpModelConfig } from '@lobechat/
import { followUpActionService } from '@/services/followUpAction'; import { followUpActionService } from '@/services/followUpAction';
import { type StoreSetter } from '@/store/types'; import { type StoreSetter } from '@/store/types';
import { type FollowUpActionSlot } from './initialState';
import { type FollowUpActionStore } from './store'; import { type FollowUpActionStore } from './store';
// LLM `generateObject` for chip extraction routinely takes 8-12s end-to-end. // LLM `generateObject` for chip extraction routinely takes 8-12s end-to-end.
// Anything below ~20s aborts before the model can respond. // Anything below ~20s aborts before the model can respond.
const TIMEOUT_MS = 20_000; const TIMEOUT_MS = 20_000;
const IDLE_SLOT: FollowUpActionSlot = { chips: [], status: 'idle' };
type Setter = StoreSetter<FollowUpActionStore>; type Setter = StoreSetter<FollowUpActionStore>;
interface FetchForParams { interface FetchForParams {
hint?: FollowUpHint; hint?: FollowUpHint;
modelConfig: FollowUpModelConfig; 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 = ( export const createFollowUpActionSlice = (
set: Setter, set: Setter,
get: () => FollowUpActionStore, get: () => FollowUpActionStore,
@@ -32,94 +68,76 @@ export class FollowUpActionImpl {
this.#get = get; this.#get = get;
} }
fetchFor = async (topicId: string, params: FetchForParams): Promise<void> => { fetchFor = async (conversationKey: string, params: FetchForParams): Promise<void> => {
const cur = this.#get(); const existing = this.#get().slots[conversationKey];
// Dedupe: skip if already loading/ready for the same topic if (existing?.status === 'loading') return;
if (cur.pendingTopicId === topicId && cur.status !== 'idle') return;
cur.abortController?.abort(); existing?.abortController?.abort();
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
this.#set( writeSlot(
this.#set,
conversationKey,
{ {
abortController: controller, abortController: controller,
chips: [], chips: [],
messageId: undefined,
pendingTopicId: topicId,
status: 'loading', status: 'loading',
topicId: undefined,
}, },
false,
'fetchFor:start', '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); clearTimeout(timeoutId);
// Discard stale results: if the active controller in state is no longer // Identity guard: a same-key follow-up turn (next assistant settle) would
// this one, our call has been superseded — either by clear()/abort() // otherwise let an in-flight prior result overwrite the new turn's chips
// (e.g., user sent a new message) or by a newer fetchFor for the same // when the network abort race is lost.
// topic (next turn). Identity beats topicId here because a same-topic if (this.#get().slots[conversationKey]?.abortController !== controller) return;
// 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;
if (!result || !result.messageId || result.chips.length === 0) { if (!result || !result.messageId || result.chips.length === 0) {
this.#set( writeSlot(this.#set, conversationKey, { ...IDLE_SLOT }, 'fetchFor:fail');
{
abortController: undefined,
chips: [],
messageId: undefined,
pendingTopicId: undefined,
status: 'idle',
topicId: undefined,
},
false,
'fetchFor:fail',
);
return; return;
} }
this.#set( writeSlot(
this.#set,
conversationKey,
{ {
abortController: undefined,
chips: result.chips, chips: result.chips,
messageId: result.messageId, messageId: result.messageId,
pendingTopicId: undefined,
status: 'ready', status: 'ready',
topicId,
}, },
false,
'fetchFor:ready', 'fetchFor:ready',
); );
}; };
abort = (): void => { abort = (conversationKey: string): void => {
const cur = this.#get(); const slot = this.#get().slots[conversationKey];
cur.abortController?.abort(); if (!slot) return;
this.#set( slot.abortController?.abort();
{ writeSlot(this.#set, conversationKey, { ...IDLE_SLOT }, 'abort');
abortController: undefined,
chips: [],
messageId: undefined,
pendingTopicId: undefined,
status: 'idle',
topicId: undefined,
},
false,
'abort',
);
}; };
clear = (): void => { clear = (conversationKey: string): void => {
this.abort(); 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; void chip;
this.clear(); this.clear(conversationKey);
}; };
} }
+185 -74
View File
@@ -2,13 +2,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { followUpActionService } from '@/services/followUpAction'; import { followUpActionService } from '@/services/followUpAction';
import { followUpActionSelectors } from './selectors';
import { useFollowUpActionStore } from './store'; import { useFollowUpActionStore } from './store';
const TOPIC = 'topic-1'; const KEY_A = 'main_agent-a_topic-a';
const NEW_TOPIC = 'topic-2'; const KEY_B = 'main_agent-b_topic-b';
const TOPIC_A = 'topic-a';
const TOPIC_B = 'topic-b';
const MSG = 'msg-real'; const MSG = 'msg-real';
const MODEL_CONFIG = { model: 'scene-model', provider: 'scene-provider' }; 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', () => { describe('useFollowUpActionStore', () => {
beforeEach(() => { beforeEach(() => {
@@ -26,84 +33,106 @@ describe('useFollowUpActionStore', () => {
chips: [{ label: 'a', message: 'a' }], chips: [{ label: 'a', message: 'a' }],
}); });
const promise = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); const promise = useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A);
expect(useFollowUpActionStore.getState().status).toBe('loading'); expect(slotA().status).toBe('loading');
await promise; await promise;
expect(spy).toHaveBeenCalledOnce(); expect(spy).toHaveBeenCalledOnce();
expect(useFollowUpActionStore.getState().status).toBe('ready'); expect(slotA().status).toBe('ready');
expect(useFollowUpActionStore.getState().chips).toHaveLength(1); expect(slotA().chips).toHaveLength(1);
expect(useFollowUpActionStore.getState().messageId).toBe(MSG); expect(slotA().messageId).toBe(MSG);
expect(useFollowUpActionStore.getState().topicId).toBe(TOPIC);
}); });
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({ const spy = vi.spyOn(followUpActionService, 'extract').mockResolvedValue({
messageId: MSG, messageId: MSG,
chips: [{ label: 'a', message: 'a' }], chips: [{ label: 'a', message: 'a' }],
}); });
await useFollowUpActionStore.getState().fetchFor(TOPIC, { await useFollowUpActionStore.getState().fetchFor(KEY_A, {
hint: { kind: 'onboarding', phase: 'discovery' }, hint: { kind: 'onboarding', phase: 'discovery' },
modelConfig: MODEL_CONFIG, modelConfig: MODEL_CONFIG,
threadId: 'thd-1',
topicId: TOPIC_A,
}); });
expect(spy).toHaveBeenCalledWith( expect(spy).toHaveBeenCalledWith(
{ {
hint: { kind: 'onboarding', phase: 'discovery' }, hint: { kind: 'onboarding', phase: 'discovery' },
modelConfig: MODEL_CONFIG, modelConfig: MODEL_CONFIG,
topicId: TOPIC, threadId: 'thd-1',
topicId: TOPIC_A,
}, },
expect.any(AbortSignal), 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); vi.spyOn(followUpActionService, 'extract').mockResolvedValue(null);
await useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); await useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A);
expect(useFollowUpActionStore.getState().status).toBe('idle'); expect(slotA().status).toBe('idle');
expect(useFollowUpActionStore.getState().chips).toHaveLength(0); expect(slotA().chips).toHaveLength(0);
expect(useFollowUpActionStore.getState().messageId).toBeUndefined(); 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: '' }); vi.spyOn(followUpActionService, 'extract').mockResolvedValue({ chips: [], messageId: '' });
await useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); await useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A);
expect(useFollowUpActionStore.getState().status).toBe('idle'); expect(slotA().status).toBe('idle');
expect(useFollowUpActionStore.getState().messageId).toBeUndefined(); 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 const spy = vi
.spyOn(followUpActionService, 'extract') .spyOn(followUpActionService, 'extract')
.mockImplementation(() => new Promise(() => {})); .mockImplementation(() => new Promise(() => {}));
const p1 = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); void useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A);
const p2 = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); void useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A);
void p1;
void p2;
expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(1);
}); });
it('fetchFor with new topicId aborts the old controller', async () => { it('fetchFor on a different key does not abort an in-flight fetch on another key', async () => {
let firstSignal: AbortSignal | undefined; let signalA: AbortSignal | undefined;
vi.spyOn(followUpActionService, 'extract').mockImplementation(async (_, signal) => { let signalB: AbortSignal | undefined;
if (!firstSignal) firstSignal = signal; vi.spyOn(followUpActionService, 'extract').mockImplementation(async (input, signal) => {
if (input.topicId === TOPIC_A) signalA = signal;
else signalB = signal;
return new Promise(() => {}); return new Promise(() => {});
}); });
const p1 = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); void useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A);
void p1;
await Promise.resolve(); await Promise.resolve();
await Promise.resolve(); await Promise.resolve();
void useFollowUpActionStore.getState().fetchFor(NEW_TOPIC, FETCH_PARAMS); void useFollowUpActionStore.getState().fetchFor(KEY_B, FETCH_PARAMS_B);
expect(firstSignal?.aborted).toBe(true); 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(() => {})); vi.spyOn(followUpActionService, 'extract').mockImplementation(() => new Promise(() => {}));
const p = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); void useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A);
void p; void useFollowUpActionStore.getState().fetchFor(KEY_B, FETCH_PARAMS_B);
useFollowUpActionStore.getState().clear(); useFollowUpActionStore.getState().clear(KEY_A);
expect(useFollowUpActionStore.getState().status).toBe('idle'); expect(slotA()).toBeUndefined();
expect(useFollowUpActionStore.getState().messageId).toBeUndefined(); expect(slotB()?.status).toBe('loading');
expect(useFollowUpActionStore.getState().pendingTopicId).toBeUndefined(); });
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 () => { it('20s timeout aborts the in-flight call', async () => {
@@ -112,23 +141,30 @@ describe('useFollowUpActionStore', () => {
signal = s; signal = s;
return new Promise(() => {}); return new Promise(() => {});
}); });
const p = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); void useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A);
void p;
await Promise.resolve(); await Promise.resolve();
vi.advanceTimersByTime(20_000); vi.advanceTimersByTime(20_000);
expect(signal?.aborted).toBe(true); expect(signal?.aborted).toBe(true);
}); });
it('consume(chip) clears state', () => { it('consume(key, chip) clears the slot for that key only', () => {
useFollowUpActionStore.setState({ useFollowUpActionStore.setState({
chips: [{ label: 'x', message: 'hello' }], slots: {
messageId: MSG, [KEY_A]: {
status: 'ready', 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' }); useFollowUpActionStore.getState().consume(KEY_A, { label: 'x', message: 'hello' });
expect(useFollowUpActionStore.getState().status).toBe('idle'); expect(slotA()).toBeUndefined();
expect(useFollowUpActionStore.getState().messageId).toBeUndefined(); expect(slotB()?.status).toBe('ready');
expect(useFollowUpActionStore.getState().chips).toHaveLength(0);
}); });
it('discards stale results when controller is replaced (race protection)', async () => { it('discards stale results when controller is replaced (race protection)', async () => {
@@ -145,46 +181,121 @@ describe('useFollowUpActionStore', () => {
messageId: 'msg-new', messageId: 'msg-new',
}); });
// First fetchFor is in flight (does not yet resolve). const p1 = useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A);
const p1 = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS);
void p1; void p1;
await Promise.resolve(); await Promise.resolve();
// User sends a new message → clear() aborts and resets. useFollowUpActionStore.getState().clear(KEY_A);
useFollowUpActionStore.getState().clear(); expect(slotA()).toBeUndefined();
expect(useFollowUpActionStore.getState().status).toBe('idle');
// Next turn starts another fetchFor for the SAME topic. const p2 = useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A);
const p2 = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS);
// 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' }); resolveFirst!({ chips: [{ label: 'a', message: 'a' }], messageId: 'msg-old' });
await p1; 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; await p2;
expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledTimes(2);
expect(useFollowUpActionStore.getState().status).toBe('ready'); expect(slotA()?.status).toBe('ready');
expect(useFollowUpActionStore.getState().messageId).toBe('msg-new'); expect(slotA()?.messageId).toBe('msg-new');
}); });
it('reset aborts in-flight request and resets state', async () => { it('reset aborts all in-flight requests and clears every slot', async () => {
let signal: AbortSignal | undefined; const signals: AbortSignal[] = [];
vi.spyOn(followUpActionService, 'extract').mockImplementation(async (_, s) => { vi.spyOn(followUpActionService, 'extract').mockImplementation(async (_, s) => {
signal = s; if (s) signals.push(s);
return new Promise(() => {}); return new Promise(() => {});
}); });
const p = useFollowUpActionStore.getState().fetchFor(TOPIC, FETCH_PARAMS); void useFollowUpActionStore.getState().fetchFor(KEY_A, FETCH_PARAMS_A);
void p; void useFollowUpActionStore.getState().fetchFor(KEY_B, FETCH_PARAMS_B);
await Promise.resolve(); await Promise.resolve();
useFollowUpActionStore.getState().reset(); useFollowUpActionStore.getState().reset();
expect(signal?.aborted).toBe(true); expect(signals.every((s) => s.aborted)).toBe(true);
expect(useFollowUpActionStore.getState().status).toBe('idle'); expect(useFollowUpActionStore.getState().slots).toEqual({});
expect(useFollowUpActionStore.getState().messageId).toBeUndefined(); });
expect(useFollowUpActionStore.getState().pendingTopicId).toBeUndefined(); });
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);
}); });
}); });
+7 -5
View File
@@ -2,16 +2,18 @@ import type { FollowUpChip } from '@lobechat/types';
export type FollowUpActionStatus = 'idle' | 'loading' | 'ready'; 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; abortController?: AbortController;
chips: FollowUpChip[]; chips: FollowUpChip[];
messageId?: string; messageId?: string;
pendingTopicId?: string;
status: FollowUpActionStatus; status: FollowUpActionStatus;
topicId?: string; }
export interface FollowUpActionState {
slots: Record<string, FollowUpActionSlot>;
} }
export const initialFollowUpActionState: FollowUpActionState = { export const initialFollowUpActionState: FollowUpActionState = {
chips: [], slots: {},
status: 'idle',
}; };
+15 -25
View File
@@ -1,43 +1,33 @@
import type { FollowUpChip } from '@lobechat/types'; import type { FollowUpChip } from '@lobechat/types';
import { type FollowUpActionState } from './initialState'; import { type FollowUpActionState, type FollowUpActionStatus } from './initialState';
const EMPTY_CHIPS: readonly FollowUpChip[] = []; const EMPTY_CHIPS: readonly FollowUpChip[] = [];
interface ChipsForArgs { interface ChipsForArgs {
/** /** Pipe-joined ids of the assistantGroup's child blocks — the server resolves the latest answer to a child block id, not the group id. */
* 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.
*/
childIdsKey?: string; childIdsKey?: string;
conversationKey: string | undefined;
messageId: 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 = const chipsFor =
({ childIdsKey, messageId, topicId }: ChipsForArgs) => ({ childIdsKey, conversationKey, messageId }: ChipsForArgs) =>
(s: FollowUpActionState): readonly FollowUpChip[] => { (s: FollowUpActionState): readonly FollowUpChip[] => {
if (s.status !== 'ready') return EMPTY_CHIPS; if (!conversationKey || !messageId) return EMPTY_CHIPS;
if (!messageId || !topicId) return EMPTY_CHIPS; const slot = s.slots[conversationKey];
if (s.topicId !== topicId) return EMPTY_CHIPS; if (!slot || slot.status !== 'ready' || !slot.messageId) return EMPTY_CHIPS;
if (!s.messageId) return EMPTY_CHIPS; if (slot.messageId === messageId) return slot.chips;
if (s.messageId === messageId) return s.chips; if (childIdsKey && childIdsKey.split('|').includes(slot.messageId)) return slot.chips;
if (childIdsKey && childIdsKey.split('|').includes(s.messageId)) return s.chips;
return EMPTY_CHIPS; return EMPTY_CHIPS;
}; };
const slotStatus =
(conversationKey: string | undefined) =>
(s: FollowUpActionState): FollowUpActionStatus =>
(conversationKey ? s.slots[conversationKey]?.status : undefined) ?? 'idle';
export const followUpActionSelectors = { export const followUpActionSelectors = {
chipsFor, chipsFor,
slotStatus,
}; };
+4 -15
View File
@@ -28,22 +28,11 @@ class FollowUpActionStoreResetAction implements ResetableStore {
} }
reset = () => { reset = () => {
// Cancel any in-flight LLM call before wiping state, otherwise the AbortController is leaked.
const current = this.#api.getState(); const current = this.#api.getState();
current.abortController?.abort(); for (const slot of Object.values(current.slots)) {
// Explicitly include undefined fields so zustand's merge-mode setState clears them. slot.abortController?.abort();
this.#set( }
{ this.#set({ slots: {} }, false, 'resetFollowUpActionStore');
abortController: undefined,
chips: [],
messageId: undefined,
pendingTopicId: undefined,
status: 'idle',
topicId: undefined,
},
false,
'resetFollowUpActionStore',
);
}; };
} }
@@ -55,6 +55,11 @@ exports[`settingsSelectors > currentSystemAgent > should merge DEFAULT_SYSTEM_AG
"provider": "deepseek", "provider": "deepseek",
}, },
"enableAutoReply": true, "enableAutoReply": true,
"followUpAction": {
"enabled": false,
"model": "gpt-5.4-mini",
"provider": "openai",
},
"generationTopic": { "generationTopic": {
"model": "gpt-5.4-mini", "model": "gpt-5.4-mini",
"provider": "openai", "provider": "openai",
@@ -107,6 +112,7 @@ exports[`settingsSelectors > defaultAgent > should merge DEFAULT_AGENT and s.set
"enableAgentMode": true, "enableAgentMode": true,
"enableCompressHistory": true, "enableCompressHistory": true,
"enableContextCompression": true, "enableContextCompression": true,
"enableFollowUpChips": false,
"enableHistoryCount": false, "enableHistoryCount": false,
"enableStreaming": true, "enableStreaming": true,
"historyCount": 20, "historyCount": 20,
@@ -150,6 +156,7 @@ exports[`settingsSelectors > defaultAgentConfig > should merge DEFAULT_AGENT_CON
"enableAgentMode": true, "enableAgentMode": true,
"enableCompressHistory": true, "enableCompressHistory": true,
"enableContextCompression": true, "enableContextCompression": true,
"enableFollowUpChips": false,
"enableHistoryCount": false, "enableHistoryCount": false,
"enableStreaming": true, "enableStreaming": true,
"historyCount": 20, "historyCount": 20,
@@ -15,9 +15,11 @@ const promptRewrite = (s: UserStore) => currentSystemAgent(s).promptRewrite;
const historyCompress = (s: UserStore) => currentSystemAgent(s).historyCompress; const historyCompress = (s: UserStore) => currentSystemAgent(s).historyCompress;
const generationTopic = (s: UserStore) => currentSystemAgent(s).generationTopic; const generationTopic = (s: UserStore) => currentSystemAgent(s).generationTopic;
const inputCompletion = (s: UserStore) => currentSystemAgent(s).inputCompletion; const inputCompletion = (s: UserStore) => currentSystemAgent(s).inputCompletion;
const followUpAction = (s: UserStore) => currentSystemAgent(s).followUpAction;
export const systemAgentSelectors = { export const systemAgentSelectors = {
agentMeta, agentMeta,
followUpAction,
generationTopic, generationTopic,
historyCompress, historyCompress,
inputCompletion, inputCompletion,