Browse Source

feat: refine conversational task flows

wangkangyjy 1 day ago
parent
commit
af58939911

BIN
artifacts/product-design/conversational-task-ui/01-registration-followup.png


BIN
artifacts/product-design/conversational-task-ui/02-registration-complete.png


BIN
artifacts/product-design/conversational-task-ui/03-report-conversational-task.png


BIN
artifacts/product-design/conversational-task-ui/04-tongue-conversational-task.png


+ 20 - 3
prototype/adjutant-mobile/design-qa.md

@@ -29,11 +29,12 @@ Focused region comparison was not required because the implementation reuses the
 
 Verified in the Codex in-app browser at `http://127.0.0.1:4173/`:
 
-1. Home “语音挂号” → voice transcript → missing doctor follow-up → complete slot summary → query schedule.
+1. Home “语音挂号” → voice transcript → persistent 5/6 slot summary → modify department → missing doctor follow-up → complete 6/6 slot summary → query schedule.
 2. Registration emergency demo → ordinary registration stops → emergency guidance and 120 action appear.
-3. New-report todo → report voice context → select hospital report → owner/source/final-status confirmation.
-4. Tongue service → voice context → consent → symptom question → qualified demo image → tongue MCP processing state.
+3. New-report todo → report voice context + known-information panel → select hospital report → owner/source/final-status confirmation.
+4. Tongue service → voice context + known-information panel → consent → symptom question → qualified demo image → tongue MCP processing state.
 5. Services, health records and root navigation remain accessible.
+6. The expandable “本次对话与操作记录” shows the voice transcription, parameter extraction, safety screening and completion events.
 
 Browser console: no errors; only Vite connection and React development information messages.
 
@@ -49,6 +50,22 @@ Browser console: no errors; only Vite connection and React development informati
 - Fix: added an explicit P0 emergency-door demo and urgent text routing.
 - Post-fix evidence: all three paths and the emergency branch were exercised in the in-app browser.
 
+### Iteration 2 — conversational task interface
+
+- Finding: the first version minimized dialogue too aggressively, so patients could not easily review what they said or how the agent understood it.
+- Fix: every agent task start now preserves the latest patient utterance and the assistant's interpretation.
+- Finding: registration slots were only shown after all information was complete.
+- Fix: the slot panel is persistent from the first follow-up, highlights the missing doctor, reports 5/6 or 6/6 completion and supports field modification.
+- Finding: report and tongue entry states looked like one-off navigation cards rather than the same agent interaction system.
+- Fix: both now use the shared pattern of recent dialogue, known context, current task and expandable operation history.
+- Finding: the previous dark transcript surface had weak text contrast in the rendered device frame.
+- Fix: the patient utterance uses a light brand-tint surface with explicit dark text.
+- Evidence:
+  - `artifacts/product-design/conversational-task-ui/01-registration-followup.png`
+  - `artifacts/product-design/conversational-task-ui/02-registration-complete.png`
+  - `artifacts/product-design/conversational-task-ui/03-report-conversational-task.png`
+  - `artifacts/product-design/conversational-task-ui/04-tongue-conversational-task.png`
+
 ## Follow-up polish
 
 - P3: add a real speech-to-text adapter when the P0 demo moves beyond scripted voice transcripts.

+ 150 - 29
prototype/adjutant-mobile/src/Prototype.tsx

@@ -362,6 +362,13 @@ function AssistantExperience({ flow, mode }: { flow: FlowControls; mode: Assista
   const [text, setText] = useState("");
   const [stage, setStage] = useState<"ready" | "followup" | "complete">("ready");
   const [doctor, setDoctor] = useState("");
+  const [editingSlot, setEditingSlot] = useState("");
+  const [slots, setSlots] = useState({
+    department: "神经内科",
+    date: "明天 07-27",
+    time: "上午 09:00",
+    category: "专家号",
+  });
   const modeCopy = {
     general: {
       title: "请说出您的需求",
@@ -413,10 +420,31 @@ function AssistantExperience({ flow, mode }: { flow: FlowControls; mode: Assista
     else flow.push(scheduleScreen);
   };
 
+  const updateSlot = (key: keyof typeof slots, value: string) => {
+    setSlots((current) => ({ ...current, [key]: value }));
+    setEditingSlot("");
+  };
+
+  const registrationSlots = [
+    { key: "patient", label: "就诊人", value: "王*明", editable: false },
+    { key: "department", label: "科室", value: slots.department, editable: true },
+    { key: "date", label: "日期", value: slots.date, editable: true },
+    { key: "time", label: "时间", value: slots.time, editable: true },
+    { key: "category", label: "号别", value: slots.category, editable: true },
+    { key: "doctor", label: "医生", value: doctor || "待补充", editable: Boolean(doctor) },
+  ];
+
+  const slotOptions: Record<string, string[]> = {
+    department: ["神经内科", "普通内科"],
+    date: ["明天 07-27", "后天 07-28"],
+    time: ["上午 09:00", "上午 09:30"],
+    category: ["专家号", "普通号"],
+  };
+
   return (
     <MobileScroll className="task-scroll">
       <main className="task-page assistant-page">
-        <section className="assistant-welcome">
+        <section className={`assistant-welcome ${stage !== "ready" ? "compact" : ""}`}>
           <span className={`assistant-orb ${stage !== "ready" ? "is-active" : ""}`}><SpeakerLoudIcon /></span>
           <h2>{modeCopy.title}</h2>
           <p>{modeCopy.description}</p>
@@ -459,58 +487,151 @@ function AssistantExperience({ flow, mode }: { flow: FlowControls; mode: Assista
           </>
         ) : (
           <section className="slot-dialogue" aria-live="polite">
-            <article className="voice-transcript">
-              <span>您说</span>
-              <p>{text}</p>
-            </article>
+            <div className="dialogue-context">
+              <span className="dialogue-label">最近对话</span>
+              <article className="voice-transcript">
+                <span>您说</span>
+                <p>{text}</p>
+              </article>
 
-            {mode === "report" ? (
               <article className="assistant-reply">
-                <strong>已理解您的需求</strong>
-                <p>请上传报告,或从本院已审核报告中选择一份。</p>
-                <button className="primary-button inline-action" type="button" onClick={nextTask}>选择报告 <ChevronRightIcon /></button>
+                <strong>
+                  {mode === "report"
+                    ? "我会帮您解读血常规报告"
+                    : mode === "tongue"
+                      ? "我会先了解近期身体感受"
+                      : stage === "followup"
+                        ? "大部分挂号信息已经记下"
+                        : "挂号信息已经收集完整"}
+                </strong>
+                <p>
+                  {mode === "report"
+                    ? "下一步请选择本院报告,或上传清晰图片与 PDF。"
+                    : mode === "tongue"
+                      ? "完成体质症候问答后,再引导您拍摄舌象。"
+                      : stage === "followup"
+                        ? "还差医生信息,确认后再查询实时号源。"
+                        : "接下来只查询号源,不会直接执行挂号。"}
+                </p>
               </article>
+            </div>
+
+            {mode === "report" ? (
+              <>
+                <section className="collected-panel">
+                  <div className="panel-heading"><span>已确定的信息</span><em>2 项</em></div>
+                  <dl className="compact-context-list">
+                    <div><dt>当前患者</dt><dd>王*明</dd></div>
+                    <div><dt>解读目标</dt><dd>血常规报告</dd></div>
+                    <div className="pending"><dt>报告来源</dt><dd>待选择</dd></div>
+                  </dl>
+                </section>
+                <section className="current-task-card">
+                  <span className="step-kicker">当前任务</span>
+                  <h3>选择要解读的报告</h3>
+                  <p>可以从本院报告中选择,也可以上传图片或 PDF。</p>
+                  <button className="primary-button inline-action" type="button" onClick={nextTask}>选择报告 <ChevronRightIcon /></button>
+                </section>
+              </>
             ) : mode === "tongue" ? (
-              <article className="assistant-reply">
-                <strong>可以为您开始中医舌诊</strong>
-                <p>接下来会通过语音和选项补充体质症候,再拍摄舌象。</p>
-                <button className="primary-button inline-action" type="button" onClick={nextTask}>开始评估 <ChevronRightIcon /></button>
-              </article>
+              <>
+                <section className="collected-panel">
+                  <div className="panel-heading"><span>已确定的信息</span><em>2 项</em></div>
+                  <dl className="compact-context-list">
+                    <div><dt>当前患者</dt><dd>王*明</dd></div>
+                    <div><dt>健康诉求</dt><dd>了解疲倦与体质</dd></div>
+                    <div className="pending"><dt>体质症候</dt><dd>待问答</dd></div>
+                  </dl>
+                </section>
+                <section className="current-task-card">
+                  <span className="step-kicker">当前任务</span>
+                  <h3>开始体质与症候问答</h3>
+                  <p>约 2 分钟,完成后再进行舌象拍摄。</p>
+                  <button className="primary-button inline-action" type="button" onClick={nextTask}>开始评估 <ChevronRightIcon /></button>
+                </section>
+              </>
             ) : (
               <>
-                <article className="assistant-reply">
-                  <strong>{stage === "followup" ? "还需要确认一项信息" : "挂号信息已收集完整"}</strong>
-                  <p>{stage === "followup" ? "已记录神经内科、明天上午 9 点和专家号。请问您想挂哪位医生?" : "我会按以下条件查询实时号源,查询前不会执行挂号。"}</p>
-                </article>
+                <section className="collected-panel">
+                  <div className="panel-heading">
+                    <span>已收集挂号信息</span>
+                    <em>{doctor ? "6/6 完整" : "5/6 已获取"}</em>
+                  </div>
+                  <dl className="slot-summary conversational">
+                    {registrationSlots.map((slot) => (
+                      <div className={!slot.value || slot.value === "待补充" ? "pending" : ""} key={slot.key}>
+                        <dt>{slot.label}</dt>
+                        <dd>
+                          <span>{slot.value}</span>
+                          {slot.editable ? (
+                            <button type="button" onClick={() => setEditingSlot(slot.key)}>修改</button>
+                          ) : (
+                            <em>{slot.value === "待补充" ? "待补充" : "已确认"}</em>
+                          )}
+                        </dd>
+                      </div>
+                    ))}
+                  </dl>
+                  {editingSlot && slotOptions[editingSlot] ? (
+                    <div className="slot-edit-options">
+                      <span>修改{registrationSlots.find((slot) => slot.key === editingSlot)?.label}</span>
+                      <div>
+                        {slotOptions[editingSlot].map((option) => (
+                          <button
+                            type="button"
+                            key={option}
+                            onClick={() => updateSlot(editingSlot as keyof typeof slots, option)}
+                          >
+                            {option}
+                          </button>
+                        ))}
+                      </div>
+                    </div>
+                  ) : null}
+                </section>
 
                 {stage === "followup" ? (
-                  <>
+                  <section className="current-task-card">
+                    <span className="step-kicker">还差 1 项信息</span>
+                    <h3>您希望挂哪位医生?</h3>
+                    <p>可以说医生姓名,也可以选择“不限医生”。</p>
                     <button className="voice-followup" type="button" onClick={() => chooseDoctor("李明 主任医师")}>
                       <SpeakerLoudIcon />
-                      <span><strong>语音回答</strong><small>例如:“李明主任”</small></span>
+                      <span><strong>按住说话</strong><small>例如:“李明主任”</small></span>
                     </button>
                     <div className="doctor-quick-replies">
                       <button type="button" onClick={() => chooseDoctor("李明 主任医师")}>李明 主任医师</button>
                       <button type="button" onClick={() => chooseDoctor("王佳 副主任医师")}>王佳 副主任医师</button>
                       <button type="button" onClick={() => chooseDoctor("不限医生")}>不限医生</button>
                     </div>
-                  </>
+                    <label className="task-text-fallback">
+                      <input
+                        value={doctor}
+                        onChange={(event) => setDoctor(event.target.value)}
+                        placeholder="也可以输入医生姓名"
+                        aria-label="输入医生姓名"
+                      />
+                      <button type="button" onClick={() => chooseDoctor(doctor || "不限医生")}>确认</button>
+                    </label>
+                  </section>
                 ) : (
                   <>
-                    <dl className="slot-summary">
-                      <div><dt>就诊人</dt><dd>王*明 <em>已获取</em></dd></div>
-                      <div><dt>科室</dt><dd>神经内科 <em>已获取</em></dd></div>
-                      <div><dt>日期</dt><dd>明天 07-27 <em>已获取</em></dd></div>
-                      <div><dt>时间</dt><dd>上午 09:00 <em>已获取</em></dd></div>
-                      <div><dt>号别</dt><dd>专家号 <em>已获取</em></dd></div>
-                      <div><dt>医生</dt><dd>{doctor} <em>已获取</em></dd></div>
-                    </dl>
                     <section className="risk-check-line"><CheckCircledIcon /> 未识别到急诊红旗,可继续普通挂号</section>
                     <button className="primary-button inline-action" type="button" onClick={nextTask}>信息已齐全,查询号源 <ChevronRightIcon /></button>
                   </>
                 )}
               </>
             )}
+
+            <details className="task-history">
+              <summary>本次对话与操作记录</summary>
+              <ol>
+                <li>10:12 接收患者语音并转写</li>
+                <li>10:12 提取服务对象与业务参数</li>
+                <li>10:12 完成医疗安全前置筛查</li>
+                {stage === "complete" ? <li>10:13 挂号必填信息收集完成</li> : null}
+              </ol>
+            </details>
           </section>
         )}
       </main>

+ 272 - 3
prototype/adjutant-mobile/src/prototype.css

@@ -673,6 +673,42 @@ button {
   line-height: 1.6;
 }
 
+.assistant-welcome.compact {
+  display: grid;
+  grid-template-columns: 46px 1fr;
+  column-gap: 12px;
+  align-items: center;
+  text-align: left;
+}
+
+.assistant-welcome.compact .assistant-orb {
+  width: 46px;
+  height: 46px;
+  grid-row: 1 / 3;
+  margin: 0;
+  border-radius: 15px;
+}
+
+.assistant-welcome.compact .assistant-orb svg {
+  width: 22px;
+  height: 22px;
+}
+
+.assistant-welcome.compact h2,
+.assistant-welcome.compact p {
+  margin: 0;
+}
+
+.assistant-welcome.compact h2 {
+  font-size: 18px;
+}
+
+.assistant-welcome.compact p {
+  margin-top: 3px;
+  font-size: 12px;
+  line-height: 1.4;
+}
+
 .assistant-composer {
   display: grid;
   grid-template-columns: 1fr auto;
@@ -779,6 +815,17 @@ button {
   margin-top: 22px;
 }
 
+.dialogue-context {
+  display: grid;
+  gap: 9px;
+}
+
+.dialogue-label {
+  color: var(--muted);
+  font-size: 11px;
+  font-weight: 750;
+}
+
 .voice-transcript,
 .assistant-reply {
   padding: 15px 16px;
@@ -787,13 +834,15 @@ button {
 
 .voice-transcript {
   margin-left: 34px;
-  background: var(--brand);
-  color: white;
+  border: 1px solid #d8d8ff;
+  background: #eeedff;
+  color: var(--text);
 }
 
 .voice-transcript span {
+  color: var(--brand);
   font-size: 11px;
-  opacity: 0.72;
+  font-weight: 750;
 }
 
 .voice-transcript p,
@@ -803,6 +852,10 @@ button {
   line-height: 1.55;
 }
 
+.voice-transcript p {
+  color: var(--text);
+}
+
 .assistant-reply {
   margin-right: 22px;
   border: 1px solid var(--border);
@@ -900,6 +953,222 @@ button {
   font-style: normal;
 }
 
+.collected-panel {
+  overflow: hidden;
+  border: 1px solid var(--border);
+  border-radius: 18px;
+  background: var(--surface);
+  box-shadow: 0 8px 24px rgba(38, 41, 90, 0.035);
+}
+
+.panel-heading {
+  display: flex;
+  min-height: 48px;
+  padding: 0 14px;
+  align-items: center;
+  justify-content: space-between;
+  background: #f5f7ff;
+}
+
+.panel-heading span {
+  font-size: 13px;
+  font-weight: 750;
+}
+
+.panel-heading em {
+  color: var(--brand);
+  font-size: 11px;
+  font-style: normal;
+  font-weight: 750;
+}
+
+.slot-summary.conversational {
+  border: 0;
+  border-radius: 0;
+}
+
+.slot-summary.conversational > div {
+  min-height: 44px;
+}
+
+.slot-summary.conversational > div.pending {
+  background: #fff9ec;
+}
+
+.slot-summary.conversational dd {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.slot-summary.conversational dd button {
+  min-height: 28px;
+  padding: 0 8px;
+  border: 0;
+  border-radius: 999px;
+  background: var(--brand-soft);
+  color: var(--brand);
+  font-size: 10px;
+  font-weight: 700;
+}
+
+.slot-summary.conversational .pending dd span {
+  color: #9b6813;
+}
+
+.slot-summary.conversational .pending em {
+  color: #9b6813;
+}
+
+.slot-edit-options {
+  padding: 12px 14px 14px;
+  border-top: 1px solid var(--border);
+  background: #fafbff;
+}
+
+.slot-edit-options > span {
+  color: var(--muted);
+  font-size: 11px;
+  font-weight: 700;
+}
+
+.slot-edit-options > div {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 7px;
+  margin-top: 9px;
+}
+
+.slot-edit-options button,
+.doctor-quick-replies button {
+  min-height: 38px;
+  padding: 0 11px;
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  background: var(--surface);
+  font-size: 11px;
+}
+
+.compact-context-list {
+  margin: 0;
+}
+
+.compact-context-list > div {
+  display: flex;
+  min-height: 44px;
+  padding: 0 14px;
+  align-items: center;
+  justify-content: space-between;
+  border-top: 1px solid var(--border);
+}
+
+.compact-context-list dt {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.compact-context-list dd {
+  margin: 0;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.compact-context-list .pending {
+  background: #fff9ec;
+}
+
+.compact-context-list .pending dd {
+  color: #9b6813;
+}
+
+.current-task-card {
+  padding: 16px;
+  border: 1px solid #d5d6f7;
+  border-radius: 18px;
+  background: #f9f9ff;
+}
+
+.current-task-card .step-kicker {
+  margin-bottom: 6px;
+}
+
+.current-task-card h3 {
+  font-size: 18px;
+}
+
+.current-task-card > p {
+  margin-top: 6px;
+  color: var(--muted);
+  font-size: 12px;
+  line-height: 1.55;
+}
+
+.current-task-card .voice-followup {
+  margin-top: 14px;
+}
+
+.current-task-card .doctor-quick-replies {
+  margin-top: 10px;
+}
+
+.task-text-fallback {
+  display: grid;
+  grid-template-columns: 1fr auto;
+  margin-top: 10px;
+  overflow: hidden;
+  border: 1px solid var(--border);
+  border-radius: 13px;
+  background: var(--surface);
+}
+
+.task-text-fallback input {
+  min-width: 0;
+  min-height: 44px;
+  padding: 0 12px;
+  border: 0;
+  outline: 0;
+  background: transparent;
+  color: var(--text);
+  font: inherit;
+  font-size: 12px;
+}
+
+.task-text-fallback button {
+  min-width: 58px;
+  margin: 5px;
+  border: 0;
+  border-radius: 10px;
+  background: var(--brand);
+  color: white;
+  font-size: 11px;
+  font-weight: 750;
+}
+
+.task-history {
+  overflow: hidden;
+  border: 1px solid var(--border);
+  border-radius: 15px;
+  background: var(--surface);
+}
+
+.task-history summary {
+  min-height: 46px;
+  padding: 0 14px;
+  align-content: center;
+  color: var(--muted);
+  cursor: pointer;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.task-history ol {
+  margin: 0;
+  padding: 0 18px 14px 34px;
+  color: var(--muted);
+  font-size: 11px;
+  line-height: 1.9;
+}
+
 .risk-check-line {
   display: flex;
   padding: 12px 14px;