Commit a6f8c3db by luoqi

fix(web): 生成中文案一闪而过 + 输入框/开场建议按角色分

走查两条:

1) loading 文案闪
   - 加最短停留 700ms(useStickyLabel)。get_current_user / get_agents 这类
     100ms 就返回,文案在屏幕上活不过两帧,主管只看到一片闪烁,反而以为卡了;
     慢工具的文案看得见,于是他以为"只有那一句"。
     用「最新覆盖待显示」而非排队 —— 排队会在活干完后继续播,5 个快工具能
     拖出 3 秒假忙碌。上限就是一个停留周期。
   - 修并行调用:原来只看最后一块,3 个工具并行时最后那块可能已 done 而前两个
     还在跑 → 退回「生成中」,前两句文案一次都不出现。改成找还在跑的那个。
   - 补齐 4 个漏掉的工具中文名(get_cohort_attributes / edit_assignment_sheet /
     revoke_assignment / render_artifact),此前兜底把英文工具名摆给主管看。

   实测(多工具一轮):5 个文案,最短 955ms,无一低于 700ms。

2) 输入框占位符 + 开场建议是客服口径
   主管打开助手是来分配和看批次的,占位符从不提这两件事 = 把一半能力藏起来。
   按 PLAN_DISPATCH 分两版(与召回池/确认单同一个闸)。
   顺带删掉「(Enter 发送,Shift+Enter 换行)」—— 输入框只有 300px,而原句 450px,
   那半句从来就没显示出来过,占着长度却看不见。

996 tests,web tsc + next build 干净。
parent 2e75bef9
......@@ -28,6 +28,8 @@ import {
downsampleTo16k,
floatToPcm16,
} from '@/components/realtime-coach/audio-utils';
import { Permission } from '@pac/types';
import { useHasPermission } from '@/hooks/use-permission';
import { useAssistantStore } from '@/stores/assistant-store';
import { AssignmentConfirmSheet } from './assignment-confirm-sheet';
import {
......@@ -66,6 +68,12 @@ const TOOL_META: Record<string, { label: string; running: string }> = {
propose_assignment: { label: '出分配方案', running: '正在挑人、排客服,出分配方案' },
list_assignment_batches: { label: '查历史批次', running: '正在查你分过的批次' },
get_assignment_detail: { label: '查批次详情', running: '正在查这批的执行情况' },
// ⚠️ 这四个此前漏了 —— 兜底文案是「正在调用 get_cohort_attributes」,
// 把内部英文工具名摆给主管看。凡是注册进 MCP 的工具都要在这里有中文说法。
get_cohort_attributes: { label: '看这批人构成', running: '正在看这批人里各类有多少' },
edit_assignment_sheet: { label: '改确认单', running: '正在按你说的改确认单' },
revoke_assignment: { label: '撤销批次', running: '正在撤销这批' },
render_artifact: { label: '出图表', running: '正在画图' },
};
function toolMeta(tool: string) {
return TOOL_META[tool] ?? { label: tool, running: `正在调用 ${tool}` };
......@@ -355,13 +363,49 @@ function ArtifactView({ artifact }: { artifact: Artifact }) {
);
}
/**
* 状态文案的**最短停留**。
*
* 🔴 2026-08-04 走查:文案一闪而过,根本看不清它在做什么。
* `get_current_user`、`get_agents` 这类工具 100ms 就返回,
* 于是「正在确认你的身份与权限」这句在屏幕上活不过两帧 —— 主管只看到一片闪烁,
* 反而以为界面卡了。慢工具(propose_assignment)的文案倒是看得见,
* 所以他会以为"只有那一句文案"。
* ⚠️ 用「最新覆盖待显示」而不是排队:排队会让文案在活干完之后还继续播,
* 5 个快工具能拖出 3 秒假忙碌 —— 那是另一种撒谎。上限就是一个停留周期。
*/
const HINT_MIN_MS = 700;
function useStickyLabel(target: string): string {
const [shown, setShown] = useState(target);
const freeAt = useRef(0);
useEffect(() => {
if (target === shown) return;
const wait = freeAt.current - Date.now();
if (wait <= 0) {
setShown(target);
freeAt.current = Date.now() + HINT_MIN_MS;
return;
}
const t = setTimeout(() => {
setShown(target);
freeAt.current = Date.now() + HINT_MIN_MS;
}, wait);
return () => clearTimeout(t);
}, [target, shown]);
return shown;
}
// 生成中指示:覆盖"工具运行 / 工具已返回但下一段还在生成"的空档。
// 有工具在跑 → 显示"正在xxx…"(动作);否则默认"生成中…"。
function GeneratingHint({ label = '生成中' }: { label?: string }) {
// ⭐ 组件在整个"调工具"阶段是常驻的(只有 label 变),所以粘滞状态放这里就够,
// ⛔ 不必提到 MessageView —— 那里有 user 分支的提前 return,加 hook 会条件调用。
const shown = useStickyLabel(label);
return (
<div className="flex items-center gap-1.5 text-[12px] text-slate-400">
<Loader2 className="h-3.5 w-3.5 animate-spin text-brand-500" />
<span>{label}</span>
<span className="animate-in fade-in duration-200">{shown}</span>
</div>
);
}
......@@ -391,8 +435,13 @@ function MessageView({
// 若最后一块是正在跑的工具 → 显示"正在xxx…"(动作);否则默认"生成中…"。
const last = message.blocks[message.blocks.length - 1];
const showHint = streaming && (!last || last.kind !== 'text');
const hintLabel =
last && last.kind === 'tool' && last.step.status === 'running' ? toolMeta(last.step.tool).running : '生成中';
// ⚠️ 找**还在跑的**工具,⛔ 不能只看最后一块:模型并行调三个工具时,
// 最后那块可能已经 done 而前两个还在跑 —— 只看最后一块就退回「生成中」,
// 前面两句文案一次都不会出现。
const runningTool = [...message.blocks]
.reverse()
.find((b): b is Extract<Block, { kind: 'tool' }> => b.kind === 'tool' && b.step.status === 'running');
const hintLabel = runningTool ? toolMeta(runningTool.step.tool).running : '生成中';
// ⛔ 助手消息**不带左侧头像**(2026-08-03 走查):助手窗只有 400px,
// 每条都挂一个 28px 的圆头像 + 间距 = 白白吃掉一成宽度,而"这段是助手说的"
// 靠气泡形态(用户消息右对齐深底、助手左对齐无底)已经分得清了。
......@@ -510,6 +559,25 @@ export function AssistantChat({
useAssistantChat();
/**
* 占位符按**角色**分。
*
* ⚠️ 原来只有一句「问关于患者画像 / 事实 / 召回计划的问题」—— 那是**客服**的活。
* 主管打开助手是来分配和看批次跑得怎么样的,占位符从不提这两件事,
* 等于把他能用的一半能力藏起来了(T16:主管本质也是客服,所以两件事都要留)。
* ⚠️ 判据用 `PLAN_DISPATCH` —— 与召回池 / 确认单同一个闸,⛔ 别另立标准。
*/
const canDispatch = useHasPermission(Permission.PLAN_DISPATCH);
/**
* ⚠️ 两句都**去掉了「(Enter 发送,Shift+Enter 换行)」** —— 助手窗的输入框只有 300px,
* 而原来那句连提示带正文 450px,**那半句从来就没显示出来过**(实测量的)。
* 占着长度却看不见,还挤掉了真正该说的"你能让我干什么"。
* 换行键这种约定靠试一次就知道,⛔ 不值得用唯一一行提示位去讲。
*/
const placeholder = canDispatch
? '让我出一批分配方案,或问某批分得怎么样…'
: '问患者画像 / 事实 / 召回计划…';
/**
* 确认单落库成功。两件事一个都不能少:
* ① 卡片切终态(按钮禁用 + 显示批次号)
* ② ⭐ **往消息流注入一条文本块** —— `toApiMessage` 只回传文本块,
......@@ -807,7 +875,7 @@ export function AssistantChat({
submit();
}
}}
placeholder="问关于患者画像 / 事实 / 召回计划的问题… (Enter 发送,Shift+Enter 换行)"
placeholder={placeholder}
className="max-h-[140px] flex-1 resize-none bg-transparent py-1 text-[13.5px] leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
/>
{VOICE_INPUT_ENABLED && (
......
......@@ -48,14 +48,29 @@ export function AssistantWidget() {
};
// 场景化开场建议(规则,非 AI):有当前患者 → 围绕该患者;否则兜底通用
const current = usePlanSyncStore((s) => s.current);
/**
* ⚠️ 主管多两条 —— 原来四条全是**客服**的活(捋事实、想开场白),
* 主管看不出"我还能在这儿分配和看批次"。占位符同理(见 assistant-chat)。
* ⚠️ 仍然保留患者类的两条:T16 主管本质也是客服,他一样会点开某个患者看。
* ⛔ 别写「转化率怎么样」这类 —— 系统只报**处理率**,例句先许愿会逼着模型去圆。
*/
const canDispatch = useHasPermission(Permission.PLAN_DISPATCH);
const leaderExamples = [
'帮我给「种植 · 窗口内」这批患者出一份分配方案',
'我分过的批次,哪批出问题了?',
];
const examples = current?.patientName
? [
`${current.patientName}为什么被召回?帮我捋一下关键事实`,
`查一下${current.patientName}的画像和潜在治疗`,
`这通电话怎么开口比较好?给我两句开场`,
...(canDispatch ? leaderExamples : ['这通电话怎么开口比较好?给我两句开场']),
'今日推荐:挑几个该优先跟进的患者,并说明理由',
]
: ['今日推荐:挑几个该优先跟进的患者,并说明理由', '现在召回池里优先级最高的是谁?'];
: [
...(canDispatch ? leaderExamples : []),
'今日推荐:挑几个该优先跟进的患者,并说明理由',
'现在召回池里优先级最高的是谁?',
];
if (!allowed) return null;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment