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