接口修改
This commit is contained in:
1 parent
f791210e70
commit
6ab6695be7
6 files changed
+373
-128
No files matched your search
@@ -449,7 +449,9 @@ export function RecommendAgentsDialog({
|
||||
让 UI 焦点转到下方推荐卡片;用户手动切换后不再自动收起(尊重偏好)。
|
||||
============================================================ */}
|
||||
<CollapsibleModelOutput
|
||||
streaming={state.phase === "recommending"}
|
||||
isLoading={
|
||||
state.phase === "loading_agents" || state.phase === "recommending"
|
||||
}
|
||||
phase={state.phase}
|
||||
>
|
||||
<RecommenderOutput state={state} />
|
||||
@@ -576,22 +578,25 @@ export function RecommendAgentsDialog({
|
||||
* 「模型生成内容」整段的可折叠外壳。
|
||||
*
|
||||
* 默认行为:
|
||||
* - 流式期间(`streaming=true`)默认展开,让用户看到实时生成
|
||||
* - 流式结束(切到 `ready`/`fallback`/`error`)后延迟 1s 自动收起一次,
|
||||
* - 「正在加载候选」+「正在生成推荐」期间(`isLoading=true`)始终保持展开,
|
||||
* 让用户看到内部 spinner / 实时生成内容,不会在中途被自动收起。
|
||||
* - 推荐完成(切到 `ready`/`fallback`/`error`)后延迟 1s 自动收起一次,
|
||||
* 把焦点让给下方推荐卡片
|
||||
* - 用户**手动**点击 toggle 后,`userTouchedRef` 置 true,后续不再自动改变
|
||||
*
|
||||
* 收起态 header 上显示状态徽标(「正在生成 / 已生成 / 已使用兜底 / 推荐失败」),
|
||||
* 让用户不用展开就知道当前阶段。
|
||||
* 收起态 header 上显示:
|
||||
* - 加载中/生成中 → 旋转 spinner 图标(替代静态 ✨),即便折叠也能看到在加载
|
||||
* - 完成态 → 静态 ✨ + 状态徽标(已生成 / 已使用兜底 / 推荐失败)
|
||||
*/
|
||||
const MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS = 1000;
|
||||
|
||||
function CollapsibleModelOutput({
|
||||
streaming,
|
||||
isLoading,
|
||||
phase,
|
||||
children,
|
||||
}: {
|
||||
streaming: boolean;
|
||||
/** True 时面板保持展开 + header 显示 spinner;涵盖加载候选 + 流式生成。 */
|
||||
isLoading: boolean;
|
||||
phase: DialogPhase;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
@@ -599,16 +604,19 @@ function CollapsibleModelOutput({
|
||||
const userTouchedRef = useRef<boolean>(false);
|
||||
const hasAutoClosedRef = useRef<boolean>(false);
|
||||
|
||||
// 流式开始 / 进行中 → 默认展开(只在用户没动过时生效)。
|
||||
// 流式结束 → 延迟自动收起一次(仅一次)。
|
||||
// 加载中 → 保持展开(用户没手动动过的前提下)。
|
||||
// 加载完成 → 延迟自动收起一次(仅一次),让出焦点给下方推荐卡片。
|
||||
// 关键区分:idle / loading_agents 早期阶段以前会被 1s 计时器误关——
|
||||
// 现在只有真正进入「已完成/兜底/失败」三种终态才触发自动收起。
|
||||
useEffect(() => {
|
||||
if (userTouchedRef.current) return;
|
||||
if (streaming) {
|
||||
if (isLoading) {
|
||||
setOpen(true);
|
||||
hasAutoClosedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!hasAutoClosedRef.current && open) {
|
||||
const isCompleted = phase === "ready" || phase === "fallback" || phase === "error";
|
||||
if (isCompleted && !hasAutoClosedRef.current && open) {
|
||||
const timer = setTimeout(() => {
|
||||
setOpen(false);
|
||||
hasAutoClosedRef.current = true;
|
||||
@@ -616,12 +624,12 @@ function CollapsibleModelOutput({
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [streaming, open]);
|
||||
}, [isLoading, phase, open]);
|
||||
|
||||
const statusBadge = (() => {
|
||||
switch (phase) {
|
||||
case "loading_agents":
|
||||
return { label: "加载中", className: "bg-slate-100 text-slate-600" };
|
||||
return { label: "加载候选", className: "bg-slate-100 text-slate-600" };
|
||||
case "recommending":
|
||||
return { label: "正在生成", className: "bg-purple-50 text-purple-700 border border-purple-200" };
|
||||
case "ready":
|
||||
@@ -645,12 +653,11 @@ function CollapsibleModelOutput({
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer"
|
||||
>
|
||||
<SparklesIcon
|
||||
className={cn(
|
||||
"size-3 text-purple-500 shrink-0",
|
||||
streaming && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="size-3 text-purple-500 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-3 text-purple-500 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 text-left">模型生成内容</span>
|
||||
{statusBadge && (
|
||||
<span
|
||||
|
||||
@@ -36,11 +36,17 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
// 这是修「鼠标拖滚动条没出现按钮」的关键:之前依赖 wheel/touchmove,
|
||||
// 但鼠标拖滚动条**只触发 scroll 事件**,wheel/touchmove 完全不会触发。
|
||||
const programmaticScrollRef = useRef(false);
|
||||
// 自动跟随 effect 和 onScroll 共享的"上次观察到的 scrollTop"。
|
||||
// 用来在 effect 里区分两种 distance 暴涨的情况:
|
||||
// - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随
|
||||
// - currentScrollTop 不变,但 scrollHeight 暴涨(如澄清卡片渲染) → 继续跟随
|
||||
const lastObservedScrollTopRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
let prevScrollTop = el.scrollTop;
|
||||
lastObservedScrollTopRef.current = prevScrollTop;
|
||||
const onScroll = () => {
|
||||
const currentScrollTop = el.scrollTop;
|
||||
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
|
||||
@@ -50,6 +56,7 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
// 这是我们刚 scrollTop=scrollHeight 触发的回调,跳过,但仍刷新 prev 用于下次比较。
|
||||
programmaticScrollRef.current = false;
|
||||
prevScrollTop = currentScrollTop;
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
// 程序滚动后通常落在底部,把 atBottom 维持 true。
|
||||
if (!atBottomRef.current && isAtBottom) {
|
||||
atBottomRef.current = true;
|
||||
@@ -67,12 +74,12 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
// 用户自己又滚回底部 → 恢复自动跟随
|
||||
atBottomRef.current = true;
|
||||
setAtBottom(true);
|
||||
} else if (!isAtBottom && atBottomRef.current) {
|
||||
// 兜底:scrollTop 没明显减少但距底部已超阈值(如内容增长而用户没动)
|
||||
atBottomRef.current = false;
|
||||
setAtBottom(false);
|
||||
}
|
||||
// **不再**因为「scrollTop 没明显减少但 distance > threshold」就退出自动跟随。
|
||||
// 这种情况只可能由内容突然增长触发(如澄清卡片一帧渲染 200+ px),
|
||||
// 此时用户并没有滚走,应当继续跟随,由下面的 effect 把视口拉到新底部。
|
||||
prevScrollTop = currentScrollTop;
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
// 初次测量
|
||||
@@ -83,24 +90,34 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
}, [ref, threshold]);
|
||||
|
||||
// 自动跟随 effect。用 instant 同步赋值,避免 smooth 动画与用户拖动叠加打架。
|
||||
// 在赋值前 **实时检查** scrollTop 位置(不依赖 atBottomRef),防止 ref 滞后于
|
||||
// 用户刚刚的拖动事件 —— 这是修「拖动期间又被自动滚拽回」的关键。
|
||||
// 关键:区分「用户向上滚」(scrollTop 变小) 与「内容增长」(scrollTop 不变、
|
||||
// scrollHeight 变大)。仅前者算作用户离开,后者继续自动跟随 ——
|
||||
// 否则像澄清卡片这种一帧渲染 200+ px 的场景会被误判成"用户滚走了"。
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
// 实时位置检测:即便 ref 还没翻成 false,只要当前 scrollTop 离底部超过阈值,
|
||||
// 说明用户已经离开,绝不自动跟随。
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
if (distanceFromBottom > threshold) {
|
||||
// 顺手把 ref/state 同步到位,防止下次 onScroll 还没跑就被 effect 再次拽
|
||||
const currentScrollTop = el.scrollTop;
|
||||
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
|
||||
// 1px 容忍滚动条像素抖动 / 浏览器自身的次像素回弹。
|
||||
const userScrolledUp = currentScrollTop < lastObservedScrollTopRef.current - 1;
|
||||
if (userScrolledUp && distanceFromBottom > threshold) {
|
||||
// 用户主动向上滚 → 退出自动跟随 (兜底 ref 滞后于刚刚的拖动事件)。
|
||||
if (atBottomRef.current) {
|
||||
atBottomRef.current = false;
|
||||
setAtBottom(false);
|
||||
}
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
return;
|
||||
}
|
||||
if (!atBottomRef.current) {
|
||||
// 之前已退出自动跟随,不要因为内容增长又把用户拽回去。
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
return;
|
||||
}
|
||||
// 在底部 (或仅是内容刚增长) → 同步拉到新底部。
|
||||
programmaticScrollRef.current = true;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
lastObservedScrollTopRef.current = el.scrollTop;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
|
||||
@@ -257,10 +257,17 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候");
|
||||
return;
|
||||
}
|
||||
if (isIntentSending) {
|
||||
// 重入保护用 ref 而不是 state:isIntentSending 是 React state,
|
||||
// 同一 event-loop tick 内闭包还看到旧值。极速双击 / 输入法回车连击
|
||||
// 会两次都通过 state 检查,结果发两次答案、跑两路 stream,
|
||||
// 回来两张几乎一样的澄清卡——这正是用户报告的"同样选项又出现一次"。
|
||||
// intentAborterRef 在 stream 完成 / abort 后会被清空,正好用来标记"在途"。
|
||||
if (intentAborterRef.current) {
|
||||
triggerToast("⏳ 智能体正在回复,请稍候");
|
||||
return;
|
||||
}
|
||||
const aborter = new AbortController();
|
||||
intentAborterRef.current = aborter;
|
||||
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
|
||||
@@ -300,8 +307,6 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
]);
|
||||
|
||||
setIsIntentSending(true);
|
||||
const aborter = new AbortController();
|
||||
intentAborterRef.current = aborter;
|
||||
try {
|
||||
const res = await streamIntent({
|
||||
threadId: intentThreadId,
|
||||
@@ -440,7 +445,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
setIsIntentSending(false);
|
||||
}
|
||||
},
|
||||
[intentThreadId, isIntentSending, triggerToast],
|
||||
[intentThreadId, triggerToast],
|
||||
);
|
||||
|
||||
const submitIntentClarification = useCallback(
|
||||
|
||||
Reference in new issue
Block a user