/** * 对话流的气泡与卡族 —— 自原型 `_components/TaskChat.tsx` 平移(气泡二元制,2026-07-26 定稿)。 * * **当前未挂载**:对话区正处在「一步一步描述、一步一步接」的阶段,暂为空占位。 * 这一份留着等接回 —— 描述到对话该长什么样时直接用,或按新口径改。 * * 「对话流是**一切回环输入的唯一入口**」(OS 4.1);输入侧不参与控制权转交(AM 07)。 * AI 的内容只有两种形态、不允许中间态: * · 纯文本气泡 —— 结论 / 说明 / 回执 * · 完整表单卡 —— 工单卡 / 验收单 / 决策单 / 补全单 / 录入单 / 评价单 * 六张卡共用一套外壳 `jc-fc`(头 = 图标 + 类型名 + 计数,身 = `jc-fc-b`)—— * 谁自造一套骨架,谁在对话流里就显得是外来的。 * 一条消息 = 署名行(AI · 时间 · 类型章)+ 一或两个**纯**块。 * ⛔ 补全 / 决策卡吐出期间 composer 锁定 —— 先办卡,再说话。 * * DOM 与类名逐字照搬原型:皮肤是原型的原样,自造类名等于没样式。 */ import { useEffect, useRef, useState } from 'react'; import type { FormField, AiTag, ChatCardReply, ChatMessage, WorkOrder, WorkOrderReply } from '@contracts'; import { EVENT_TYPE_LABEL, isRejection } from '@contracts'; import { Ico } from '@web-shared/Ico.tsx'; import { ChatAvatar } from '@web-shared/ChatAvatar.tsx'; import { atClock } from '@web-shared/at.ts'; /** * 类型章:结论 = auto,指导 = exe,判据 = acc(原型 TAG_UI 口径)。 * * **同一个签,带不带工单说的是两件事**: * 带工单(`ref`)的那条是「我派了一张卡给你」;不带的是 AI 在**交代进展**。 * 只按签取字的话,一句「三条已归一,缺 1 个交期」会被标成「派了工单」—— * 它一张卡也没派。界面不撒谎:没有卡就不写卡的字(实测踩过,满屏「派了工单」)。 */ const TAG_UI: Readonly> = { conclusion: { label: '阶段结论', cls: 'auto' }, guidance: { label: '派出工单', cls: 'exe' }, criterion: { label: '请你验收', cls: 'acc' }, }; /** * 交代进展的章 —— 同一批签,不带工单时的说法。 * * **一律四个字**(2026-07-31 用户定):这一列的每一格只说一件事, * 字数一致那一列才扫得动。从前这里是「进展」「判据」两个字, * 跟旁边的「阶段结论」一长一短,像两套词表混着用 —— 事实上它们就是一套。 * 工作日志用的也是这一套词(见 `task-log.ts` 的 `actionOf`)。 */ const SAY_UI: Readonly> = { conclusion: { label: '阶段结论', cls: 'auto' }, guidance: { label: '交代进展', cls: 'auto' }, criterion: { label: '验收判据', cls: 'acc' }, }; /** * 系统气泡的章 —— **对话区说人话,契约说类型**。 * * `EVENT_TYPE_LABEL` 是事件分类学(data / time / human / control),治理面按它分区没错; * 可直接摆进对话流就成了「数据事件」——「一封邮件触发了这条流程」被标成「数据事件」, * 人看不出这跟自己有什么关系。归侧词表(`SIDE_ST`)里这一类早就叫**触发事件**了, * 工作日志用的就是它;两屏说同一件事,得用同一个词。 */ const SYS_TAG: Readonly> = { data: '触发事件', time: '时间到点', // 系统气泡里 `human` 只有一个来源:人办结了执行工单(chat-projection 显式打的), // 裸「事件到达(human)」那条早在投影里就被滤掉了 —— 所以这里说得死一点 human: '员工办结', control: '控制事件', }; /** ISO → HH:MM(署名行只要时分) */ const hm = (iso: string): string => atClock(iso); export type TaskChatProps = { readonly chat: readonly ChatMessage[]; readonly orders: readonly WorkOrder[]; /** AI 正在回话 —— 请求在途即亮「输入中」 */ readonly thinking: boolean; /** * AI 正在办这单(Loop 在自己转)—— 与「在打字」是两种状态: * 打字是**在回你的话**(三点气泡);办事是**环在推进**(均衡条常驻在场)。 * 状态来自任务投影的 runtime,不是前端猜的。 */ readonly working?: boolean; onSay(text: string): void; onReplyCard(reply: ChatCardReply): void; onReplyOrder(reply: WorkOrderReply): void; /** 点卡上的「打开工单」→ 视图区定位到那张工单 */ onLocate(orderId: string): void; onOpenComms(): void; /** 人在工作区报告上改过的填报值 —— 验收裁决时一并带走(见 TaskChat 的注释) */ readonly orderValues?: Readonly>; /** 卡上填的值也进同一个缓冲 —— 执行单的表单如今长在对话区的卡上 */ onField(key: string, value: string): void; }; export function ChatStream({ chat, orders, thinking, working = false, onSay, onReplyCard, onReplyOrder, onLocate, onOpenComms, orderValues, onField, }: TaskChatProps) { const [text, setText] = useState(''); const boxRef = useRef(null); useEffect(() => { boxRef.current?.scrollTo({ top: 1e9, behavior: 'smooth' }); }, [chat.length, thinking, working]); // ⛔ 补全 / 决策卡未办结期间不能对话 —— 锁的是**卡**,不是工单 const blocked = chat.some( (m) => (m.k === 'elicitation' && m.done === undefined) || (m.k === 'decision' && m.chosen === undefined), ); const send = () => { const t = text.trim(); if (t === '' || blocked) return; setText(''); onSay(t); }; return (
任务助理 Agent 盯着这单推进 · 有事随时说
{chat.map((m) => ( ))} {thinking ? (
AI
) : working ? ( ) : null}
{blocked && (
有一张卡在等你(补全 / 决策)——处理完就能继续对话
)}