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