@springbrand/message-panel 0.1.3-alpha.10 → 0.1.3-alpha.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -57,8 +57,8 @@ pnpm dev:cloud-os # http://localhost:9992
57
57
 
58
58
  - `update_plan` 是**整份快照覆盖**,不是增量事件。整条会话只保留最后一份计划快照
59
59
  (`dropSupersededPlans`),否则同一份计划会被画上四遍。
60
- - `ask_user` 的「已回答」取的是**这条消息之后的第一条用户直答**,不是工具 output ——
61
- 后者往往只是 `{ asked: true }` 之类的回执。
60
+ - `ask_user` 的「已回答」只取该 Tool Part 的 `output.answers[]`;
61
+ 不扫描后续用户消息,问题与答案按数组下标一一对应。
62
62
  - `approval-requested` 的工具是否渲染成原位授权卡,由宿主的 `approvalIdOf` 决定;
63
63
  返回 `undefined` 就退回普通工具行(本仓的持久化审批走输入框上方那条队列)。
64
64
 
@@ -150,23 +150,31 @@ function UserBubble({
150
150
  </div>
151
151
  )}
152
152
  {entry.attachments.length > 0 && (
153
- <div className="mb-1.5 flex max-w-[min(680px,78%)] flex-wrap justify-end gap-2">
154
- {entry.attachments.map((attachment, index) => (
155
- <Fragment key={`${attachment.url}:${index}`}>
156
- {renderFileView(renderFile, {
157
- file: attachment,
158
- placement: "user-message",
159
- })}
160
- </Fragment>
161
- ))}
153
+ <div className="mb-1.5 flex max-w-[min(680px,78%)] flex-wrap items-start justify-end gap-2">
154
+ {entry.attachments.map((attachment, index) => {
155
+ const view = renderFileView(renderFile, {
156
+ file: {
157
+ ...attachment,
158
+ url: resolveUrl?.(attachment.url) ?? attachment.url,
159
+ },
160
+ placement: "user-message",
161
+ });
162
+ return attachment.mediaType?.startsWith("image/") ? (
163
+ <Fragment key={`${attachment.url}:${index}`}>{view}</Fragment>
164
+ ) : (
165
+ <span
166
+ key={`${attachment.url}:${index}`}
167
+ className="flex basis-full justify-end"
168
+ >
169
+ {view}
170
+ </span>
171
+ );
172
+ })}
162
173
  </div>
163
174
  )}
164
175
  {entry.text && (
165
- <div className="themed-user-bubble-shadow cos-markdown w-fit max-w-[min(680px,78%)] rounded-[24px] rounded-br-lg border border-transparent bg-kumo-bubble-user px-4 py-2.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default">
166
- {/* pre-wrap 让用户消息里的单个换行渲染成硬换行 */}
167
- <div className="whitespace-pre-wrap">
168
- <MarkdownMessage message={entry.text} resolveUrl={resolveUrl} />
169
- </div>
176
+ <div className="themed-user-bubble-shadow cos-markdown cos-user-markdown w-fit max-w-[min(680px,78%)] rounded-[24px] rounded-br-lg border border-transparent bg-kumo-bubble-user px-4 py-2.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default">
177
+ <MarkdownMessage message={entry.text} resolveUrl={resolveUrl} />
170
178
  </div>
171
179
  )}
172
180
  <div className="mt-0.5 flex items-center justify-end gap-2 pr-1 text-[11px] leading-4 text-kumo-inactive opacity-0 transition-opacity duration-150 ease-out group-hover/message:opacity-100 group-focus-within/message:opacity-100">
@@ -1,4 +1,6 @@
1
1
  import {
2
+ ArrowLeft,
3
+ ArrowRight,
2
4
  Bell,
3
5
  CaretRight,
4
6
  Check,
@@ -9,7 +11,7 @@ import {
9
11
  UsersThree,
10
12
  WarningCircle,
11
13
  } from "@phosphor-icons/react";
12
- import { useState } from "react";
14
+ import { useRef, useState } from "react";
13
15
  import { Tooltip } from "../primitives/tooltip";
14
16
  import { WorkshopButton } from "../primitives/workshop-controls";
15
17
  import { MarkdownMessage } from "./markdown-message";
@@ -124,33 +126,32 @@ interface AskQuestion {
124
126
  prompt: string;
125
127
  options: string[];
126
128
  multi: boolean;
129
+ allowCustom: boolean;
127
130
  }
128
131
 
129
132
  function questionsOf(input: Record<string, unknown>): AskQuestion[] {
130
- if (Array.isArray(input.questions)) {
131
- return input.questions.map((item) => {
132
- const question = (item ?? {}) as Record<string, unknown>;
133
- return {
134
- prompt: String(question.prompt ?? question.question ?? "Choose an option"),
135
- options: Array.isArray(question.options)
136
- ? question.options.filter((o): o is string => typeof o === "string")
137
- : [],
138
- multi: question.kind === "multi" || question.multiSelect === true,
139
- };
140
- });
141
- }
142
- if (typeof input.question === "string") {
143
- return [
144
- {
145
- prompt: input.question,
146
- options: Array.isArray(input.options)
147
- ? input.options.filter((o): o is string => typeof o === "string")
148
- : [],
149
- multi: input.multiSelect === true,
150
- },
151
- ];
152
- }
153
- return [];
133
+ if (!Array.isArray(input.questions)) return [];
134
+ return input.questions.map((item) => {
135
+ const question = (item ?? {}) as Record<string, unknown>;
136
+ const options = Array.isArray(question.options)
137
+ ? question.options.filter((option): option is string =>
138
+ typeof option === "string"
139
+ )
140
+ : [];
141
+ return {
142
+ prompt: typeof question.question === "string"
143
+ ? question.question
144
+ : "Choose an option",
145
+ options,
146
+ multi: question.multiSelect === true,
147
+ allowCustom: question.allowCustom === true || options.length === 0,
148
+ };
149
+ });
150
+ }
151
+
152
+ interface DraftAnswer {
153
+ selections: string[];
154
+ text: string;
154
155
  }
155
156
 
156
157
  export function AskUserBlock({
@@ -162,37 +163,54 @@ export function AskUserBlock({
162
163
  onRespond?: (toolCallId: string, response: unknown) => Promise<boolean>;
163
164
  }) {
164
165
  const questions = questionsOf(call.input);
165
- // 不能用 `useState(() => questions.map(() => []))` 初始化:工具入参是**流式**到达的,
166
- // 首帧 questions 往往还是空数组,那之后每次 setSelections 都在空数组上 map,
166
+ // 不能用 `useState(() => questions.map(...))` 初始化:工具入参是**流式**到达的,
167
+ // 首帧 questions 往往还是空数组,那之后每次 setAnswers 都在空数组上 map,
167
168
  // 选择永远存不进去 → Submit 永久禁用。改成稀疏存 + 按当前 questions 补齐。
168
- const [selections, setSelections] = useState<string[][]>([]);
169
- const selectionAt = (index: number) => selections[index] ?? [];
169
+ const [answers, setAnswers] = useState<DraftAnswer[]>([]);
170
+ const [current, setCurrent] = useState(0);
171
+ const direction = useRef<"back" | "forward">("forward");
172
+ const answerAt = (index: number): DraftAnswer =>
173
+ answers[index] ?? { selections: [], text: "" };
170
174
  const toggle = (questionIndex: number, option: string, multi: boolean) =>
171
- setSelections((current) => {
172
- const next = questions.map((_, index) => current[index] ?? []);
173
- const values = next[questionIndex] ?? [];
174
- next[questionIndex] = !multi
175
- ? [option]
176
- : values.includes(option)
177
- ? values.filter((value) => value !== option)
178
- : [...values, option];
175
+ setAnswers((current) => {
176
+ const next = questions.map((_, index) =>
177
+ current[index] ?? { selections: [], text: "" }
178
+ );
179
+ const answer = next[questionIndex]!;
180
+ next[questionIndex] = {
181
+ ...answer,
182
+ selections: !multi
183
+ ? [option]
184
+ : answer.selections.includes(option)
185
+ ? answer.selections.filter((value) => value !== option)
186
+ : [...answer.selections, option],
187
+ };
188
+ return next;
189
+ });
190
+ const setText = (questionIndex: number, text: string) =>
191
+ setAnswers((current) => {
192
+ const next = questions.map((_, index) =>
193
+ current[index] ?? { selections: [], text: "" }
194
+ );
195
+ next[questionIndex] = { ...next[questionIndex]!, text };
179
196
  return next;
180
197
  });
181
198
  // 在途只用于按钮转圈;权威状态永远是 part 的 state。
182
199
  const [inFlight, setInFlight] = useState(false);
183
200
  const outcome = askUserOutcome(call);
184
- const answer = outcome.kind === "answered" ? outcome : null;
185
- const answeredSelections = new Set(answer?.selections ?? []);
186
- const answeredText = [
187
- ...(answer?.selections ?? []),
188
- ...(answer?.text.trim() ? [answer.text.trim()] : []),
189
- ].join(" / ");
201
+ const settledAnswers = outcome.kind === "answered" ? outcome.answers : [];
190
202
  // 服务端已经结算 = 这张卡永久不可操作,不管结算成什么。
191
203
  const resolved = outcome.kind !== "pending" || inFlight;
192
204
  const valid = questions.length > 0 && questions.every(
193
- (question, index) =>
194
- question.options.length === 0 || selectionAt(index).length > 0,
205
+ (_question, index) =>
206
+ answerAt(index).selections.length > 0 || answerAt(index).text.trim(),
195
207
  );
208
+ const currentIndex = Math.min(current, Math.max(questions.length - 1, 0));
209
+ const currentQuestion = questions[currentIndex];
210
+ const currentAnswer = answerAt(currentIndex);
211
+ const currentValid = currentAnswer.selections.length > 0 ||
212
+ Boolean(currentAnswer.text.trim());
213
+ const isLast = currentIndex === questions.length - 1;
196
214
  const buttonLabel = outcome.kind === "answered"
197
215
  ? "Submitted"
198
216
  : outcome.kind === "closed"
@@ -200,32 +218,30 @@ export function AskUserBlock({
200
218
  : inFlight
201
219
  ? "Submitting…"
202
220
  : "Submit";
203
- const footnote = outcome.kind === "answered"
204
- ? answeredText && `Answered: ${answeredText}`
205
- : outcome.kind === "closed"
221
+ const footnote = outcome.kind === "closed"
206
222
  ? outcome.reason === "user_replied_freeform"
207
223
  ? "Answered in the chat instead."
208
224
  : "Closed without an answer."
209
225
  : "";
210
226
 
211
- // 提交按钮固定在卡片右下角,和脚注同一行 —— 脚注不再单独占一行,多问题时
212
- // 按钮也不会卡在某一题的选项中间。选项没到(流式首帧 questions 为空)时这行
213
- // 照样渲染,否则用户看到的是一张空卡。
214
- const submit = (
227
+ const submit = () => {
228
+ if (!onRespond) return;
229
+ setInFlight(true);
230
+ // 失败就把按钮放回可点:权威状态由 part 决定,这里只管别把用户锁死。
231
+ void onRespond(call.toolCallId, {
232
+ answers: questions.map((_, index) => ({
233
+ selections: answerAt(index).selections,
234
+ text: answerAt(index).text.trim(),
235
+ })),
236
+ }).then((ok) => {
237
+ if (!ok) setInFlight(false);
238
+ }, () => setInFlight(false));
239
+ };
240
+
241
+ const settledButton = (
215
242
  <WorkshopButton
216
243
  tone="primary"
217
- disabled={resolved || !valid || !onRespond}
218
- onClick={() => {
219
- if (!onRespond) return;
220
- setInFlight(true);
221
- // 失败就把按钮放回可点:权威状态由 part 决定,这里只管别把用户锁死。
222
- void onRespond(call.toolCallId, {
223
- selections: questions.flatMap((_, index) => selectionAt(index)),
224
- text: "",
225
- }).then((ok) => {
226
- if (!ok) setInFlight(false);
227
- });
228
- }}
244
+ disabled
229
245
  className="ml-auto !h-7 flex-shrink-0 gap-1 !rounded-lg px-2.5 text-[12px]"
230
246
  >
231
247
  {buttonLabel}
@@ -234,52 +250,193 @@ export function AskUserBlock({
234
250
 
235
251
  return (
236
252
  // 收成 w-fit:短问题不该撑成一条横贯全栏的长条,长问题到 560px 再换行。
237
- <div className="w-fit min-w-[15rem] max-w-[560px]">
238
- {/* 组内(问题↔选项)10px、组间(题与题、题与按钮行)12px —— 组间必须比组内
239
- 松,否则多问题时上一题的选项会看起来像下一题的。
240
- flex+gap 而不是 space-y-*:Tailwind v4 space-y 选择器裹在
241
- `:where()` 里,特异性被抹平,会被子元素自己的 `m-0` 压掉 → 不生效。 */}
253
+ <div
254
+ className="w-fit min-w-[15rem] max-w-[560px]"
255
+ data-part-type="ask_user"
256
+ data-state={outcome.kind === "answered" ? "submitted" : outcome.kind}
257
+ >
242
258
  <div className="flex flex-col gap-3 rounded-xl border border-kumo-line bg-kumo-base px-3 py-2.5">
243
- {questions.map((question, questionIndex) => (
244
- <div
245
- key={`${question.prompt}:${questionIndex}`}
246
- className="flex flex-col gap-2.5"
247
- >
248
- <p className="m-0 text-[13px] leading-[18px] font-medium tracking-[-0.25px] text-kumo-default">
249
- {question.prompt}
250
- </p>
251
- <div className="flex flex-wrap items-center gap-1.5">
252
- {question.options.map((option) => {
253
- const selected = answer
254
- ? answeredSelections.has(option)
255
- : selectionAt(questionIndex).includes(option);
256
- return (
257
- <button
258
- key={option}
259
- type="button"
260
- disabled={resolved || !onRespond}
261
- onClick={() => toggle(questionIndex, option, question.multi)}
262
- className={`inline-flex h-7 cursor-pointer items-center rounded-lg border px-2.5 text-[13px] leading-none tracking-[-0.25px] transition-colors duration-150 ease-out disabled:cursor-default ${
263
- selected
264
- ? "border-kumo-brand/45 bg-kumo-brand/10 text-kumo-default"
265
- : "border-kumo-line bg-kumo-base text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default"
266
- }`}
267
- >
268
- {option}
269
- </button>
270
- );
271
- })}
259
+ {outcome.kind === "pending" ? (
260
+ <>
261
+ {questions.length > 1 && (
262
+ <div className="flex flex-col gap-2">
263
+ <div className="flex items-center justify-between text-[11px] font-medium leading-4 text-kumo-inactive">
264
+ <span>Clarifying questions</span>
265
+ <span className="tabular-nums">
266
+ {currentIndex + 1}/{questions.length}
267
+ </span>
268
+ </div>
269
+ <div className="flex gap-1" aria-hidden="true">
270
+ {questions.map((question, index) => (
271
+ <span
272
+ key={`${question.prompt}:${index}`}
273
+ className={`h-1 flex-1 rounded-full ${
274
+ index < currentIndex
275
+ ? "bg-kumo-brand"
276
+ : index === currentIndex
277
+ ? "bg-kumo-brand/60"
278
+ : "bg-kumo-fill"
279
+ }`}
280
+ />
281
+ ))}
282
+ </div>
283
+ </div>
284
+ )}
285
+ {currentQuestion && (
286
+ <div className="overflow-hidden">
287
+ <div
288
+ key={currentIndex}
289
+ className="cos-ask-user-question flex flex-col gap-2.5"
290
+ data-direction={direction.current}
291
+ >
292
+ <p className="m-0 text-[13px] leading-[18px] font-medium tracking-[-0.25px] text-kumo-default">
293
+ {currentQuestion.prompt}
294
+ </p>
295
+ {currentQuestion.options.length > 0 && (
296
+ <ul className="m-0 flex list-none flex-col gap-1.5 p-0">
297
+ {currentQuestion.options.map((option) => {
298
+ const selected = currentAnswer.selections.includes(
299
+ option,
300
+ );
301
+ return (
302
+ <li key={option} className="m-0 p-0">
303
+ <label
304
+ className={`flex min-h-8 items-center gap-2 rounded-lg border px-2.5 py-1.5 text-[13px] leading-[18px] tracking-[-0.25px] transition-colors duration-150 ease-out ${
305
+ resolved || !onRespond
306
+ ? "cursor-default text-kumo-inactive"
307
+ : "cursor-pointer text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default"
308
+ } ${
309
+ selected
310
+ ? "border-kumo-brand/45 bg-kumo-brand/10 text-kumo-default"
311
+ : "border-kumo-line bg-kumo-base"
312
+ }`}
313
+ >
314
+ <input
315
+ type={currentQuestion.multi
316
+ ? "checkbox"
317
+ : "radio"}
318
+ name={`${call.toolCallId}-${currentIndex}`}
319
+ checked={selected}
320
+ disabled={resolved || !onRespond}
321
+ onChange={() =>
322
+ toggle(
323
+ currentIndex,
324
+ option,
325
+ currentQuestion.multi,
326
+ )}
327
+ className="h-4 w-4 shrink-0 accent-[var(--color-kumo-brand)]"
328
+ />
329
+ <span>{option}</span>
330
+ </label>
331
+ </li>
332
+ );
333
+ })}
334
+ </ul>
335
+ )}
336
+ {currentQuestion.allowCustom && (
337
+ <label className="flex flex-col gap-1 text-[12px] leading-4 text-kumo-subtle">
338
+ <span>
339
+ {currentQuestion.options.length > 0 ? "Other" : "Answer"}
340
+ </span>
341
+ <input
342
+ type="text"
343
+ value={currentAnswer.text}
344
+ maxLength={2_000}
345
+ disabled={resolved || !onRespond}
346
+ aria-label={`自定义回答:${currentQuestion.prompt}`}
347
+ placeholder={currentQuestion.options.length > 0
348
+ ? "Add a custom answer"
349
+ : "Type your answer"}
350
+ onChange={(event) =>
351
+ setText(currentIndex, event.currentTarget.value)}
352
+ className="h-8 rounded-lg border border-kumo-line bg-kumo-base px-2.5 text-[13px] text-kumo-default outline-none placeholder:text-kumo-inactive focus:border-kumo-brand focus:ring-2 focus:ring-kumo-brand/20 disabled:cursor-default"
353
+ />
354
+ </label>
355
+ )}
356
+ </div>
357
+ </div>
358
+ )}
359
+ <div className="flex items-center gap-2">
360
+ {questions.length > 1 && (
361
+ <WorkshopButton
362
+ disabled={currentIndex === 0 || resolved}
363
+ onClick={() => {
364
+ direction.current = "back";
365
+ setCurrent((index) => Math.max(0, index - 1));
366
+ }}
367
+ className="!h-7 gap-1 !rounded-lg px-2.5 text-[12px]"
368
+ >
369
+ <ArrowLeft size={13} weight="bold" />
370
+ Back
371
+ </WorkshopButton>
372
+ )}
373
+ <WorkshopButton
374
+ tone="primary"
375
+ disabled={
376
+ resolved || !onRespond ||
377
+ (questions.length > 1 && !isLast ? !currentValid : !valid)
378
+ }
379
+ onClick={() => {
380
+ if (questions.length > 1 && !isLast) {
381
+ direction.current = "forward";
382
+ setCurrent((index) =>
383
+ Math.min(questions.length - 1, index + 1)
384
+ );
385
+ return;
386
+ }
387
+ submit();
388
+ }}
389
+ className="ml-auto !h-7 gap-1 !rounded-lg px-2.5 text-[12px]"
390
+ >
391
+ {questions.length > 1 && !isLast ? (
392
+ <>
393
+ Next
394
+ <ArrowRight size={13} weight="bold" />
395
+ </>
396
+ ) : buttonLabel}
397
+ </WorkshopButton>
272
398
  </div>
273
- </div>
274
- ))}
275
- <div className="flex items-center gap-3">
276
- {footnote && (
277
- <p className="m-0 min-w-0 text-[12px] leading-4 text-kumo-inactive">
278
- {footnote}
279
- </p>
280
- )}
281
- {submit}
282
- </div>
399
+ </>
400
+ ) : (
401
+ <>
402
+ {outcome.kind === "answered" &&
403
+ questions.map((question, questionIndex) => (
404
+ <div
405
+ key={`${question.prompt}:${questionIndex}`}
406
+ className="flex flex-col gap-1.5"
407
+ >
408
+ <p className="m-0 text-[13px] leading-[18px] font-medium tracking-[-0.25px] text-kumo-default">
409
+ {question.prompt}
410
+ </p>
411
+ {settledAnswers[questionIndex] && (
412
+ <div className="flex flex-col gap-1 text-[12px] leading-4 text-kumo-inactive">
413
+ {settledAnswers[questionIndex]!.selections.length > 0 && (
414
+ <p className="m-0">
415
+ Answered: {settledAnswers[questionIndex]!.selections
416
+ .join(" / ")}
417
+ </p>
418
+ )}
419
+ {settledAnswers[questionIndex]!.text.trim() && (
420
+ <p className="m-0">
421
+ {settledAnswers[questionIndex]!.selections.length > 0
422
+ ? `补充:${settledAnswers[questionIndex]!.text.trim()}`
423
+ : `Answered: ${settledAnswers[questionIndex]!.text.trim()}`}
424
+ </p>
425
+ )}
426
+ </div>
427
+ )}
428
+ </div>
429
+ ))}
430
+ <div className="flex items-center gap-3">
431
+ {footnote && (
432
+ <p className="m-0 min-w-0 text-[12px] leading-4 text-kumo-inactive">
433
+ {footnote}
434
+ </p>
435
+ )}
436
+ {settledButton}
437
+ </div>
438
+ </>
439
+ )}
283
440
  </div>
284
441
  </div>
285
442
  );
@@ -498,7 +498,7 @@ export function toCloudOsToolCall(
498
498
  *
499
499
  * - `pending`:还等着用户操作,选项可点。**只有 input 态才算 pending** ——
500
500
  * 服务端一旦结算,这次 interaction 就不再接受响应。
501
- * - `answered`:`ask_user` 的 settle 写了 `{ selections, text }`。
501
+ * - `answered`:`ask_user` 的 settle 写了按问题对应的 `{ answers }`。
502
502
  * - `closed`:被结算掉了但不是一个答案 —— 用户改成直接发言(`{ cancelled: true }`)、
503
503
  * Turn 提前结束、或工具出错。必须和 pending 分开,否则卡片会留一个点了拿
504
504
  * `{ ok: false }` 的假 Submit,读起来就是「点了没反应」。
@@ -508,7 +508,14 @@ export function toCloudOsToolCall(
508
508
  */
509
509
  export type AskUserOutcome =
510
510
  | { kind: "pending" }
511
- | { kind: "answered"; selections: string[]; text: string }
511
+ | {
512
+ kind: "answered";
513
+ answers: Array<{
514
+ question: string;
515
+ selections: string[];
516
+ text: string;
517
+ }>;
518
+ }
512
519
  | { kind: "closed"; reason: "user_replied_freeform" | "other" };
513
520
 
514
521
  export function askUserOutcome(call: CloudOsToolCall): AskUserOutcome {
@@ -516,14 +523,23 @@ export function askUserOutcome(call: CloudOsToolCall): AskUserOutcome {
516
523
  return { kind: "pending" };
517
524
  }
518
525
  const record = recordOf(call.output);
519
- if (Array.isArray(record.selections)) {
520
- return {
521
- kind: "answered",
522
- selections: record.selections.filter(
523
- (value): value is string => typeof value === "string",
524
- ),
525
- text: typeof record.text === "string" ? record.text : "",
526
- };
526
+ if (Array.isArray(record.answers) && record.answers.length > 0) {
527
+ const answers = record.answers.map(recordOf);
528
+ if (answers.every((answer) =>
529
+ typeof answer.question === "string" &&
530
+ Array.isArray(answer.selections) &&
531
+ answer.selections.every((value) => typeof value === "string") &&
532
+ (answer.text === undefined || typeof answer.text === "string")
533
+ )) {
534
+ return {
535
+ kind: "answered",
536
+ answers: answers.map((answer) => ({
537
+ question: answer.question as string,
538
+ selections: answer.selections as string[],
539
+ text: typeof answer.text === "string" ? answer.text : "",
540
+ })),
541
+ };
542
+ }
527
543
  }
528
544
  return {
529
545
  kind: "closed",
@@ -365,6 +365,9 @@
365
365
  line-height: 1.6;
366
366
  overflow-wrap: anywhere;
367
367
  }
368
+ .cos-user-markdown :where(p, li) {
369
+ white-space: pre-wrap;
370
+ }
368
371
  .cos-markdown p {
369
372
  margin: 0.4em 0;
370
373
  }
@@ -535,6 +538,35 @@
535
538
  }
536
539
  }
537
540
 
541
+ /* ── 原位提问:逐题横向切换 ─────────────────────────────────────────────── */
542
+ .cos-ask-user-question[data-direction="forward"] {
543
+ animation: cos-ask-user-forward 180ms ease-out;
544
+ }
545
+
546
+ .cos-ask-user-question[data-direction="back"] {
547
+ animation: cos-ask-user-back 180ms ease-out;
548
+ }
549
+
550
+ @keyframes cos-ask-user-forward {
551
+ from {
552
+ opacity: 0;
553
+ transform: translateX(24px);
554
+ }
555
+ }
556
+
557
+ @keyframes cos-ask-user-back {
558
+ from {
559
+ opacity: 0;
560
+ transform: translateX(-24px);
561
+ }
562
+ }
563
+
564
+ @media (prefers-reduced-motion: reduce) {
565
+ .cos-ask-user-question {
566
+ animation: none !important;
567
+ }
568
+ }
569
+
538
570
  /* ── 观察类 markdown(行内、紧凑)────────────────────────────────────────── */
539
571
  .cos-observation-markdown {
540
572
  line-height: 1.5;
@@ -15,11 +15,13 @@ import {
15
15
  import {
16
16
  commands as sharedCommands,
17
17
  defaultPrompt,
18
+ demoAskUserResponse,
18
19
  demoModels,
19
20
  finalToolNames,
20
21
  flowSteps,
21
22
  messagesForScenario,
22
23
  scenarios,
24
+ type DemoAskUserResponse,
23
25
  type DemoScenario,
24
26
  type DemoScenarioMeta,
25
27
  } from "./chat-scenarios";
@@ -42,7 +44,7 @@ export function CamelChatShowcase() {
42
44
  const [theme, setTheme] = useState<"light" | "dark">("dark");
43
45
  const [scenario, setScenario] = useState<DemoScenario>(initialScenario);
44
46
  const [prompt, setPrompt] = useState(defaultPrompt);
45
- const [humanAnswer, setHumanAnswer] = useState("管理层");
47
+ const [humanAnswer, setHumanAnswer] = useState(demoAskUserResponse);
46
48
  const [model, setModel] = useState<string>(demoModels[0].value);
47
49
  const [value, setValue] = useState("");
48
50
  const [attachments, setAttachments] =
@@ -274,20 +276,17 @@ export function CamelChatShowcase() {
274
276
  onAnswer={(payload) => {
275
277
  const data =
276
278
  payload && typeof payload === "object"
277
- ? (payload as {
278
- selections?: string[][];
279
- text?: string;
280
- })
281
- : {};
282
- const answer = [
283
- ...(data.selections?.flat() ?? []),
284
- data.text?.trim() ?? "",
285
- ]
279
+ ? (payload as DemoAskUserResponse)
280
+ : { answers: [] };
281
+ const answer = data.answers.flatMap((item) => [
282
+ ...item.selections,
283
+ item.text?.trim() ?? "",
284
+ ])
286
285
  .filter(Boolean)
287
286
  .join("、");
288
- if (answer) setHumanAnswer(answer);
287
+ if (data.answers.length > 0) setHumanAnswer(data);
289
288
  selectScenario("planning");
290
- setLastEvent(`已回答并继续:${answer || humanAnswer}`);
289
+ setLastEvent(`已回答并继续:${answer}`);
291
290
  }}
292
291
  getToolApproval={approvalView}
293
292
  />