接口修改

This commit is contained in:
fengxiangdebaozi committed 2026-05-27 11:37:07 +08:00
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(