Commit 77bf6bd1 by luoqi

feat(web): 确认单 —— 待分配组、姓名可复制、重排按钮、同批指令不再互相覆盖

- 「待分配」单列一组(amber + 顶部粗边),可拖可删可收拢、默认展开,
  带医生列与已分配一致; 不处理就是不分,文案必须说清
- 顶栏「共 N 人 · 拟分 X(Y 位客服) · 待分配 Z 人」—— 先给总数再拆,
  光写「拟分 21 · 待分配 39」读不出 21 含不含 39
- 患者/客服姓名点击复制(draggable=false + stopPropagation,否则吃掉整行拖拽)
- 重排后卡片自己交代换了什么 —— 重排走 HTTP 只换卡片,助手不会重新说一遍,
  不交代就是"上面写着 34 人要您定、卡片显示 0 待分配"两份打架的口径
- 🔴 同批指令后一条读不到前一条:「李汝明给李闻,剩下的都给高瑞珍」——
  第二条读的还是旧 state,李汝明被连同其余 31 人一起铺给了高瑞珍,
  而两条都回报"已执行"。改成 moveDraft/dropDraft 草稿,最后一次性提交
- findPatients 要搜到「待分配」、findAgent 要搜到「本批未分到」——
  卡片上看得见的人,指令就必须能指到
- 拖给「未分到」的客服后组标题不再退化成 #678 · 在手 0
- 铺平算水位时排除本次要改派的这批(否则算了两遍)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent a07b3691
...@@ -28,7 +28,7 @@ import { ...@@ -28,7 +28,7 @@ import {
downsampleTo16k, downsampleTo16k,
floatToPcm16, floatToPcm16,
} from '@/components/realtime-coach/audio-utils'; } from '@/components/realtime-coach/audio-utils';
import { Permission } from '@pac/types'; import { Permission, type AssignmentProposal } from '@pac/types';
import { useHasPermission } from '@/hooks/use-permission'; import { useHasPermission } from '@/hooks/use-permission';
import { useAssistantStore } from '@/stores/assistant-store'; import { useAssistantStore } from '@/stores/assistant-store';
import { AssignmentConfirmSheet } from './assignment-confirm-sheet'; import { AssignmentConfirmSheet } from './assignment-confirm-sheet';
...@@ -156,11 +156,14 @@ function BlockView({ ...@@ -156,11 +156,14 @@ function BlockView({
block, block,
onSheetConfirmed, onSheetConfirmed,
onSheetEdited, onSheetEdited,
onSheetReplaced,
}: { }: {
block: Block; block: Block;
onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string, modelSummary?: string) => void; onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string, modelSummary?: string) => void;
/** 助手改确认单后,界面回报的那一句(注入消息流) */ /** 助手改确认单后,界面回报的那一句(注入消息流) */
onSheetEdited?: (summary: string) => void; onSheetEdited?: (summary: string) => void;
/** 「重新排一版」回来的新提案 —— 整块换掉那张卡 */
onSheetReplaced?: (requestId: string, next: AssignmentProposal) => void;
}) { }) {
// 工具调用不再单独显示卡片 —— 其"正在做什么"通过下方 loading 指示体现(见 MessageView)。 // 工具调用不再单独显示卡片 —— 其"正在做什么"通过下方 loading 指示体现(见 MessageView)。
if (block.kind === 'tool') return null; if (block.kind === 'tool') return null;
...@@ -176,6 +179,7 @@ function BlockView({ ...@@ -176,6 +179,7 @@ function BlockView({
// 执行结果回一句进对话:既给主管看,也补进模型上下文 —— // 执行结果回一句进对话:既给主管看,也补进模型上下文 ——
// 否则它不知道到底成没成(可能有人没找到),下一句就会替系统撒谎 // 否则它不知道到底成没成(可能有人没找到),下一句就会替系统撒谎
onEditApplied={onSheetEdited} onEditApplied={onSheetEdited}
onSheetReplaced={(next) => onSheetReplaced?.(block.requestId, next)}
onConfirmed={(id, summary, modelSummary) => onConfirmed={(id, summary, modelSummary) =>
onSheetConfirmed?.(block.requestId, id, summary, modelSummary) onSheetConfirmed?.(block.requestId, id, summary, modelSummary)
} }
...@@ -431,12 +435,15 @@ function MessageView({ ...@@ -431,12 +435,15 @@ function MessageView({
streaming, streaming,
onSheetConfirmed, onSheetConfirmed,
onSheetEdited, onSheetEdited,
onSheetReplaced,
}: { }: {
message: ChatMessage; message: ChatMessage;
streaming?: boolean; streaming?: boolean;
onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string, modelSummary?: string) => void; onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string, modelSummary?: string) => void;
/** 助手改确认单后,界面回报的那一句(注入消息流) */ /** 助手改确认单后,界面回报的那一句(注入消息流) */
onSheetEdited?: (summary: string) => void; onSheetEdited?: (summary: string) => void;
/** 「重新排一版」回来的新提案 —— 整块换掉那张卡 */
onSheetReplaced?: (requestId: string, next: AssignmentProposal) => void;
}) { }) {
if (message.role === 'user') { if (message.role === 'user') {
return ( return (
...@@ -491,6 +498,7 @@ function MessageView({ ...@@ -491,6 +498,7 @@ function MessageView({
block={b} block={b}
onSheetConfirmed={onSheetConfirmed} onSheetConfirmed={onSheetConfirmed}
onSheetEdited={onSheetEdited} onSheetEdited={onSheetEdited}
onSheetReplaced={onSheetReplaced}
/> />
))} ))}
{showHint && <GeneratingHint label={hintLabel} />} {showHint && <GeneratingHint label={hintLabel} />}
...@@ -571,8 +579,17 @@ export function AssistantChat({ ...@@ -571,8 +579,17 @@ export function AssistantChat({
/** 空态开场建议(规则生成,调用方按场景定制);不传用页面版默认 */ /** 空态开场建议(规则生成,调用方按场景定制);不传用页面版默认 */
examples?: string[]; examples?: string[];
} = {}) { } = {}) {
const { messages, status, model, setModel, send, stop, appendAssistantNote, settleSheet } = const {
useAssistantChat(); messages,
status,
model,
setModel,
send,
stop,
appendAssistantNote,
settleSheet,
replaceSheet,
} = useAssistantChat();
/** /**
* 占位符按**角色**分。 * 占位符按**角色**分。
...@@ -608,7 +625,9 @@ export function AssistantChat({ ...@@ -608,7 +625,9 @@ export function AssistantChat({
) => { ) => {
settleSheet(requestId, assignmentId); settleSheet(requestId, assignmentId);
// ⭐ 第二个参数带完整批次 id —— 模型撤销时唯一的 id 来源(见 assignment-confirm-sheet) // ⭐ 第二个参数带完整批次 id —— 模型撤销时唯一的 id 来源(见 assignment-confirm-sheet)
appendAssistantNote(summary, modelSummary); // ⚠️ `asNewMessage`:主管点确认发生在模型发言之外,贴到上一条尾巴上会被当成上一段的续写,
// 他根本不觉得助手说了话(实测反馈:"agent 没有输出引导")。自成一条才读得出来。
appendAssistantNote(summary, modelSummary, { asNewMessage: true });
}; };
/** /**
* 助手改完确认单,界面把**实际结果**回一句进消息流。 * 助手改完确认单,界面把**实际结果**回一句进消息流。
...@@ -617,6 +636,9 @@ export function AssistantChat({ ...@@ -617,6 +636,9 @@ export function AssistantChat({
* 全靠这一句。不注入的话它下一轮就会自信地说"已经移除了",而那个人可能还在。 * 全靠这一句。不注入的话它下一轮就会自信地说"已经移除了",而那个人可能还在。
*/ */
const onSheetEdited = (summary: string) => appendAssistantNote(summary); const onSheetEdited = (summary: string) => appendAssistantNote(summary);
/** 「重新排一版」回来 → 整块换掉那张卡(人群没变、人换了) */
const onSheetReplaced = (requestId: string, next: AssignmentProposal) =>
replaceSheet(requestId, next);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const taRef = useRef<HTMLTextAreaElement>(null); const taRef = useRef<HTMLTextAreaElement>(null);
...@@ -850,6 +872,7 @@ export function AssistantChat({ ...@@ -850,6 +872,7 @@ export function AssistantChat({
streaming={status === 'streaming' && i === messages.length - 1 && m.role === 'assistant'} streaming={status === 'streaming' && i === messages.length - 1 && m.role === 'assistant'}
onSheetConfirmed={onSheetConfirmed} onSheetConfirmed={onSheetConfirmed}
onSheetEdited={onSheetEdited} onSheetEdited={onSheetEdited}
onSheetReplaced={onSheetReplaced}
/> />
)) ))
)} )}
......
...@@ -471,15 +471,49 @@ export function useAssistantChat() { ...@@ -471,15 +471,49 @@ export function useAssistantChat() {
* 不注入的话,下一轮主管说「刚才那批改成 5 天」,模型手里完全没有"那批"的痕迹, * 不注入的话,下一轮主管说「刚才那批改成 5 天」,模型手里完全没有"那批"的痕迹,
* 会当成新需求重新提议一次。改动很小但极易漏,漏了会被当成模型能力问题。 * 会当成新需求重新提议一次。改动很小但极易漏,漏了会被当成模型能力问题。
*/ */
const appendAssistantNote = useCallback((text: string, modelText?: string) => { const appendAssistantNote = useCallback(
(text: string, modelText?: string, opts?: { asNewMessage?: boolean }) => {
const block: Block = { kind: 'text', text, ...(modelText ? { modelText } : {}) };
setMessages((prev) => { setMessages((prev) => {
if (prev.length === 0) return prev; if (prev.length === 0) return prev;
/**
* 🔴 **确认单落库这一句必须自成一条消息**(2026-08-06 实测)。
*
* 默认行为是"贴到最后一条消息的末尾" —— 对「界面回报改单结果」是对的
* (那是模型那句话的续写)。但**主管点确认**发生在模型发言之外:
* 贴上去会和上一条的最后一行挤成一段,渲染出来是
* 「…确认无误就点卡片上的『确认分配』。已确认分配:修复 · 21 条 …这批还没带福利 ——」,
* 主管扫一眼只当是上一段的尾巴,根本不觉得助手"说了话"(实测反馈:"agent 没有输出引导")。
* ⇒ 点确认这一路走 `asNewMessage`,让它像助手新说的一句,自己一个气泡。
*/
if (opts?.asNewMessage) {
return [...prev, { id: nextId(), role: 'assistant', blocks: [block] }];
}
const last = prev[prev.length - 1]!; const last = prev[prev.length - 1]!;
return [ return [...prev.slice(0, -1), { ...last, blocks: [...last.blocks, block] }];
...prev.slice(0, -1),
{ ...last, blocks: [...last.blocks, { kind: 'text', text, ...(modelText ? { modelText } : {}) }] },
];
}); });
},
[],
);
/**
* 「重新排一版」回来的新提案 —— **整块替换**那张确认单。
*
* ⚠️ 换的是整张卡:人群没变、人换了。主管在旧卡上做的删/改派对新名单没有意义,
* 所以卡片自身的 state 也会随 key 变化重置(见下面按 requestId 换 key)。
* ⛔ 别只替换 items:byAgent / skippedAgents / 几段话术都得跟着换,漏一个就对不上数。
*/
const replaceSheet = useCallback((requestId: string, next: unknown) => {
setMessages((prev) =>
prev.map((m) => ({
...m,
blocks: m.blocks.map((b) =>
b.kind === 'assignment_sheet' && b.requestId === requestId
? { ...b, sheet: next as typeof b.sheet, edits: [] }
: b,
),
})),
);
}, []); }, []);
/** 确认单落库后把卡片切到终态(按钮禁用,显示批次号) */ /** 确认单落库后把卡片切到终态(按钮禁用,显示批次号) */
...@@ -496,5 +530,5 @@ export function useAssistantChat() { ...@@ -496,5 +530,5 @@ export function useAssistantChat() {
); );
}, []); }, []);
return { messages, status, model, setModel, send, stop, appendAssistantNote, settleSheet }; return { messages, status, model, setModel, send, stop, appendAssistantNote, settleSheet, replaceSheet };
} }
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