@spzxg/cli-ui 0.6.1 → 0.6.2

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.
package/dist/cli-ui.js CHANGED
@@ -1,4 +1,4 @@
1
- import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-Dk2QGHxn.js";
1
+ import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-6IJ_jRVh.js";
2
2
  export {
3
3
  a as CliBoard,
4
4
  i as CliBrowserView,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spzxg/cli-ui",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "description": "任务中心(cli-task-center)对话任务的前端积木:cli-msglist 消息对话列表、cli-chat 对话,数据全部来自 center 服务。",
6
6
  "main": "src/index.ts",
@@ -7,7 +7,7 @@ import type { CliEvent, CliStore, CliStoreOptions } from "../core/store.js";
7
7
  import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider.js";
8
8
  import { boardContextBlock, serializeBlock, viewingBlock } from "../core/blocks.js";
9
9
  import { convKey, emptyConv, isChatTask, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation.js";
10
- import type { AskAnswers, AskQuestion, Msg } from "../core/types.js";
10
+ import type { AskAnswers, AskQuestion, CenterTask, Msg } from "../core/types.js";
11
11
  import { Icon } from "../ui/icons.js";
12
12
  import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format.js";
13
13
  import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared.js";
@@ -174,17 +174,27 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
174
174
  })), [allMessages, cfg.transcript.tools]);
175
175
  const running = conv ? isRunning(conv) : false;
176
176
  const job = entry?.job;
177
- // 一次性任务(含续派,不是对话任务):只读查看其中一个子任务——没有轮次,不能发消息、结束、回答。
178
- // 例外是对话式子任务(续派时 mode=conversation):当对话用,随时能接着发(= 续派一条接着它),不显示子任务状态。
177
+ // 一次性任务没有轮次:补充消息通过 continueTask 接在当前子任务的对话后面。
179
178
  const batch = !!conv?.batch || (!!job && job.mode !== "conversation");
180
179
  const chat = !!taskId && isChatTask(conv);
181
- // 普通子任务结束后(完成 / 失败 / 取消)也能接着聊:发出去 = 续派一条接着它的对话式子任务(同一个 Agent 会话、同一个工作区)。
182
- // 还在跑的时候输入框照样显示、但不能发,提示结束后再接着聊(需要登录、验证码时在看板的浏览器里接管)。
183
180
  const batchTaskStatus = batch ? conv?.batchTask?.status ?? job?.tasks?.find((t) => t.id === (taskId ?? conv?.task?.id))?.status ?? null : null;
184
- const batchSettled = batch && !chat && !!batchTaskStatus && SETTLED_TASK.has(batchTaskStatus) && !!store.client.continueTask;
185
- const readOnly = !!readOnlyProp || !cfg.composer.show || (batch && !chat && !batchSettled);
186
- // 一次性任务的子任务没结束、不能发时,输入框也露出来(禁用 + 说明),别让人以为没有输入框。
187
- const showComposer = !readOnly || (batch && !readOnlyProp && cfg.composer.show);
181
+ const batchWritable = batch && !!(taskId ?? conv?.task?.id) && !!store.client.continueTask;
182
+ const readOnly = !!readOnlyProp || !cfg.composer.show || (batch && !batchWritable);
183
+ const showComposer = !readOnlyProp && cfg.composer.show;
184
+ // 最新消息可能还在排队:沿当前对话的接续链找正在执行 / 暂停的子任务,不能误停同 Job 的其他对话。
185
+ const controlTask = useMemo(() => {
186
+ if (!batch || !conv?.batchTask) return null;
187
+ const tasks = new Map<string, Pick<CenterTask, "id" | "status" | "continue_from_task_id">>((job?.tasks ?? []).map((t) => [t.id, t]));
188
+ tasks.set(conv.batchTask.id, { ...conv.batchTask, continue_from_task_id: conv.batchTask.continue_from_task_id ?? undefined });
189
+ const chain = [];
190
+ const seen = new Set<string>();
191
+ let t = tasks.get(conv.batchTask.id);
192
+ while (t && !seen.has(t.id)) { chain.push(t); seen.add(t.id); t = tasks.get(t.continue_from_task_id ?? ""); }
193
+ return chain.find((t) => ["dispatching", "running", "verifying"].includes(t.status))
194
+ ?? chain.find((t) => t.status === "paused")
195
+ ?? chain.reverse().find((t) => ["queued", "blocked"].includes(t.status)) ?? null;
196
+ }, [batch, conv?.batchTask, job?.tasks]);
197
+ const batchPending = batch && !!batchTaskStatus && !SETTLED_TASK.has(batchTaskStatus);
188
198
  // 这个对话对应的子任务:指定了就是它,否则对话任务的第一个(「新开」时借用第一个的智能体显示);
189
199
  // 一次性任务没指定时是实时流挑的那个(最近一个派发过的)。
190
200
  const shownTaskId = taskId ?? (batch ? conv?.task?.id : undefined);
@@ -194,10 +204,10 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
194
204
  const agentText = labelOf(agent);
195
205
  const jobStatus = conv?.job?.status ?? job?.status ?? null;
196
206
  // 对话式子任务没有「结束」:大任务结束了,再发一条会重新打开它。
197
- // 普通子任务结束后能接着聊的(batchSettled)同理:大任务结束不挡输入框。
198
- const jobEnded = !chat && !batchSettled && !!jobStatus && TERMINAL_JOB.has(jobStatus);
207
+ // 支持续派的普通子任务同理:大任务结束不挡输入框。
208
+ const jobEnded = !chat && !batchWritable && !!jobStatus && TERMINAL_JOB.has(jobStatus);
199
209
  // 只关掉了这一个对话(对话任务还开着):按「这个对话已结束」显示,不能再发消息。
200
- const convClosed = !jobEnded && !batchSettled && !!(conv?.task?.closed ?? info?.closed);
210
+ const convClosed = !jobEnded && !batchWritable && !!(conv?.task?.closed ?? info?.closed);
201
211
  const status = convClosed ? "succeeded" : jobStatus;
202
212
  const closedReason = convClosed ? "conversation_closed" : conv?.job?.closed_reason ?? job?.conversation?.closed_reason ?? null;
203
213
  const ended = jobEnded || convClosed;
@@ -385,7 +395,8 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
385
395
  const [loadingOlder, setLoadingOlder] = useState(false);
386
396
  const olderRef = useRef(older); olderRef.current = older;
387
397
  const keyRef2 = useRef(key); keyRef2.current = key;
388
- const items = useMemo(() => buildTranscriptItems(committed, older > 0), [committed, older]);
398
+ const items = useMemo(() => buildTranscriptItems(committed, older > 0)
399
+ .filter((it) => cfg.transcript.initialPrompt || it.kind !== "msg" || !it.initial), [committed, older, cfg.transcript.initialPrompt]);
389
400
  // 展开了的「续等」折叠行(按折叠行的 key)。
390
401
  const [openFolds, setOpenFolds] = useState<Set<string>>(() => new Set());
391
402
  const [win, setWin] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
@@ -541,9 +552,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
541
552
  const composerDisabled = !!readOnly || finalEnd || conv?.stream === "error";
542
553
  const disabledHint = readOnly
543
554
  ? batch && !readOnlyProp && cfg.composer.show
544
- ? (batchTaskStatus && SETTLED_TASK.has(batchTaskStatus) ? "只读(当前中枢不支持接着子任务聊)"
545
- : batchTaskStatus === "blocked" || batchTaskStatus === "queued" ? "子任务还没开始,开始并结束后可以接着它聊"
546
- : "子任务运行中,结束后可以接着它继续聊;需要登录等操作请在右侧浏览器里接管")
555
+ ? "当前任务暂不支持发送消息"
547
556
  : "只读"
548
557
  : finalEnd ? "对话已结束,不能再发消息" : conv?.streamError ?? undefined;
549
558
 
@@ -573,15 +582,10 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
573
582
  <div className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
574
583
  <div className="session">
575
584
  {h.show && (h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
576
- {(h.crumb || h.export || h.title) && <div className="sh-main">
577
- {(h.crumb || h.export) && <div className="crumb">
578
- {h.crumb && <>
585
+ {(h.crumb || h.title) && <div className="sh-main">
586
+ {h.crumb && <div className="crumb">
579
587
  <span className="pic" style={{ width: 16, height: 16, borderRadius: 5, background: color, display: "grid", placeItems: "center", color: "#fff", fontSize: 9, fontWeight: 700 }}>{title[0]}</span>
580
588
  {title} <span className="sep">/</span> 对话
581
- </>}
582
- {h.export && key && allMessages.length > 0 && (
583
- <button className="crumb-export" onClick={() => exportMarkdown(convTitle, taskId ? `${jobId}-${taskId}` : jobId!, allMessages, conv?.turns ?? [])} title="导出这个对话的完整过程(Markdown)">{Icon.download()} 导出执行过程</button>
584
- )}
585
589
  </div>}
586
590
  {h.title && <div className="sh-title"><span className="sht-text" title={convTitle}>{convTitle}</span></div>}
587
591
  </div>}
@@ -603,6 +607,12 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
603
607
  <StatusDot cls={dotCls} running={running} awaiting={awaiting} title={statusText} />
604
608
  <span className="cv">{statusText}</span>
605
609
  </span>}
610
+ {h.export && key && <button type="button" className="btn btn-line btn-sm cli-export" disabled={!allMessages.length}
611
+ aria-label="导出执行过程(Markdown)"
612
+ title={older > 0 ? "下载已加载的执行过程(Markdown);更早的消息尚未加载" : "下载执行过程(Markdown),包含初始上下文和工具记录"}
613
+ onClick={() => exportMarkdown(convTitle, taskId ? `${jobId}-${taskId}` : jobId!, allMessages, conv?.turns ?? [])}>
614
+ {Icon.download()}<span>{older > 0 ? "导出已加载" : "导出"}</span>
615
+ </button>}
606
616
  {h.close && key && !isNew && allowClose && !readOnly && !ended && !batch && (
607
617
  <button className="btn btn-line btn-sm" onClick={() => setClosing("confirm")} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
608
618
  )}
@@ -734,9 +744,16 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
734
744
  placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
735
745
  disabled={composerDisabled}
736
746
  disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
737
- running={running}
747
+ running={running || batchPending}
738
748
  onSend={send}
739
749
  onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
750
+ taskControl={cfg.composer.stop && !readOnlyProp && controlTask && jobId && store.client.pauseTask && store.client.resumeTask ? {
751
+ paused: controlTask.status === "paused",
752
+ onToggle: async () => {
753
+ const action = controlTask.status === "paused" ? store.client.resumeTask! : store.client.pauseTask!;
754
+ await report(action(controlTask.id).then(() => store.refreshJob(jobId, true)), controlTask.status === "paused" ? "继续任务失败" : "暂停任务失败");
755
+ },
756
+ } : undefined}
740
757
  options={composerOptions}
741
758
  />
742
759
  )}
@@ -30,7 +30,7 @@ export interface ComposerOptions {
30
30
  queue: boolean;
31
31
  }
32
32
 
33
- export function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, options }: {
33
+ export function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, taskControl, options }: {
34
34
  /** 草稿按这个键存在 localStorage(一般是对话 id)。 */
35
35
  draft: string;
36
36
  placeholder: string;
@@ -39,6 +39,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
39
39
  running: boolean;
40
40
  onSend: (text: string) => Promise<void>;
41
41
  onStop?: () => Promise<void> | void;
42
+ taskControl?: { paused: boolean; onToggle: () => Promise<void> };
42
43
  options: ComposerOptions;
43
44
  }) {
44
45
  const [val, setVal] = useState(() => loadDraft(draft));
@@ -47,6 +48,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
47
48
  const [error, setError] = useState<string | null>(null);
48
49
  const [sending, setSending] = useState(false);
49
50
  const [stopping, setStopping] = useState(false);
51
+ const [controlling, setControlling] = useState(false);
50
52
  const [dragging, setDragging] = useState(false);
51
53
  const dragDepth = useRef(0);
52
54
  const seq = useRef(0);
@@ -56,7 +58,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
56
58
  useEffect(() => { if (!running) setStopping(false); }, [running]);
57
59
  useEffect(() => { saveDraft(draft, val); }, [draft, val]);
58
60
  const autosize = (el: HTMLTextAreaElement) => { el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 160) + "px"; };
59
- useEffect(() => { if (ref.current) autosize(ref.current); }, [draft]);
61
+ useEffect(() => { if (ref.current) autosize(ref.current); }, [draft, disabled]);
60
62
 
61
63
  const nextKey = () => `blk-${++seq.current}`;
62
64
  const addBlock = (block: TextBlock) => setBlocks((prev) => [...prev, { ...block, key: nextKey() }]);
@@ -108,6 +110,10 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
108
110
 
109
111
  const isFileDrag = (e: React.DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files");
110
112
  const hasFileDrag = (e: React.DragEvent) => options.fileUpload && isFileDrag(e);
113
+ const stopButton = running && onStop && (
114
+ <button type="button" className="send stop" onClick={() => { setStopping(true); void Promise.resolve(onStop()).catch(() => setStopping(false)); }}
115
+ disabled={stopping} aria-label={stopping ? "正在停止" : "停止当前轮次"} title={stopping ? "正在停止…" : "停止当前轮次"}>{Icon.stop()}</button>
116
+ );
111
117
 
112
118
  return (
113
119
  <div className="composer">
@@ -145,8 +151,8 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
145
151
  )}
146
152
  <div className="comp-area">
147
153
  <textarea
148
- ref={ref} rows={1} value={val} disabled={disabled}
149
- placeholder={disabled ? disabledHint ?? "暂时不能发送" : running && options.queue ? options.placeholderRunning : placeholder}
154
+ ref={ref} rows={2} value={val} disabled={disabled} aria-label="消息内容"
155
+ placeholder={disabled ? disabledHint ?? "暂时不能发送" : taskControl?.paused ? "任务已暂停,可补充消息,继续后依次执行…" : running && options.queue ? options.placeholderRunning : placeholder}
150
156
  onChange={(e) => { setVal(e.target.value); autosize(e.target); }}
151
157
  onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); void submit(); } }}
152
158
  onPaste={(e) => {
@@ -166,27 +172,32 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
166
172
  }
167
173
  }}
168
174
  />
169
- {running ? (
170
- <div className="send-group">
171
- {canSend && <button className="send queue" onClick={() => void submit()} title="把这条后续消息排队 —— 当前轮次结束后自动发送" disabled={disabled}>{Icon.send()}</button>}
172
- {onStop && <button className="send stop" onClick={() => { setStopping(true); void Promise.resolve(onStop()).catch(() => setStopping(false)); }} disabled={stopping} title={stopping ? "正在停止…" : "停止当前轮次"}>{Icon.stop()}</button>}
173
- </div>
174
- ) : (
175
- <button className="send" disabled={!canSend || disabled} onClick={() => void submit()}>{Icon.send()}</button>
176
- )}
177
175
  </div>
178
- {/* 底部栏的内容都关掉时整行不占位。 */}
179
- {(options.hints || options.charCount || options.fileUpload || tooLong) && <div className="comp-foot">
180
- {options.hints && <span className="hint"><span className="kbd">⏎</span> 发送</span>}
181
- {options.hints && <span className="hint comp-hint-extra"><span className="kbd">⇧⏎</span> 换行</span>}
176
+ <div className="comp-foot">
177
+ {options.fileUpload && !disabled && <>
178
+ <input ref={fileRef} type="file" accept={ACCEPT_ATTR} multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />
179
+ <button type="button" className="cli-attach" title={`添加文档:${ACCEPTED_LABEL}(也可拖拽进来),内容会转成文字发送`}
180
+ onClick={() => fileRef.current?.click()}>{Icon.clip()} {options.fileButtonText}</button>
181
+ </>}
182
+ {options.hints && !disabled && <span className="hint comp-hint-extra">Shift + Enter 换行</span>}
183
+ <span className="spacer" />
182
184
  {options.charCount && total > 0 && <span className={`hint cli-count ${tooLong ? "over" : ""}`}>{fmtCount(total)} 字</span>}
183
185
  {!options.charCount && tooLong && <span className="hint cli-count over">{fmtCount(total)} 字</span>}
184
- <span className="spacer" />
185
- {options.fileUpload && <input ref={fileRef} type="file" accept={ACCEPT_ATTR} multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />}
186
- {options.fileUpload && !disabled && (
187
- <button className="hint" style={{ cursor: "pointer" }} title={`添加文档:${ACCEPTED_LABEL}(也可拖拽进来),内容会转成文字发送`} onMouseDown={(e) => { e.preventDefault(); fileRef.current?.click(); }}>{Icon.clip()} {options.fileButtonText}</button>
188
- )}
189
- </div>}
186
+ <div className="send-group">
187
+ {taskControl && <button type="button" className="btn btn-line cli-task-control" disabled={controlling}
188
+ aria-label={taskControl.paused ? "继续任务" : "暂停任务"}
189
+ title={taskControl.paused ? "重新派发当前子任务,排队消息随后执行" : "暂停当前子任务,继续时重新派发"}
190
+ onClick={async () => { setControlling(true); try { await taskControl.onToggle(); } catch { /* 错误由调用方显示,保留草稿和当前状态 */ } finally { setControlling(false); } }}>
191
+ {taskControl.paused ? Icon.play() : Icon.pause()}<span>{controlling ? "处理中…" : taskControl.paused ? "继续" : "暂停"}</span>
192
+ </button>}
193
+ {(!running || options.queue) && <button type="button" className={`send cli-send${running ? " queue" : ""}`} disabled={disabled || !canSend}
194
+ onClick={() => void submit()} aria-label={sending ? "正在发送" : running ? "排队发送" : "发送消息"}
195
+ title={taskControl?.paused ? "排队发送,继续任务后依次执行" : running ? "排队发送,当前轮次结束后执行" : "发送消息(Enter)"}>
196
+ {Icon.send()}<span>{sending ? "发送中…" : running ? "排队发送" : "发送"}</span>
197
+ </button>}
198
+ {stopButton}
199
+ </div>
200
+ </div>
190
201
  </div>
191
202
  </div>
192
203
  </div>
@@ -75,6 +75,9 @@ const ERROR_TEXT: Record<string, string> = {
75
75
  viewer_not_ready: "画面连接还没就绪,正在重连",
76
76
  link_expired: "文件链接已失效,请刷新",
77
77
  task_not_found: "这个子任务不在对应的任务里",
78
+ task_already_finished: "当前子任务已经结束,请刷新状态后继续",
79
+ task_not_paused: "当前子任务没有暂停,请刷新状态后重试",
80
+ job_already_paused: "整个任务已暂停,请先恢复整个任务",
78
81
  };
79
82
 
80
83
  /**
@@ -102,6 +105,9 @@ export interface ConversationBackend {
102
105
  * 返回新子任务的 id。
103
106
  */
104
107
  continueTask?(jobId: string, taskId: string, text: string, opts?: { title?: string; businessId?: string }): Promise<{ task_id: string }>;
108
+ /** 暂停 / 继续一次性任务中的单个子任务。继续会重新派发该子任务。 */
109
+ pauseTask?(taskId: string): Promise<CenterTask>;
110
+ resumeTask?(taskId: string): Promise<CenterTask>;
105
111
  /** 在对话任务里新开一个对话(同一节点、同一工作区),text 是它的第一条消息。 */
106
112
  openConversation?(jobId: string, text: string, opts?: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number }): Promise<SendResult>;
107
113
  cancelTurn(jobId: string, turnId: string): Promise<CenterTurn>;
@@ -336,6 +342,8 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
336
342
  });
337
343
  return { task_id: result.task_ids[0] };
338
344
  },
345
+ pauseTask: (taskId: string) => post<CenterTask>(`/api/tasks/${id(taskId)}/pause`, {}),
346
+ resumeTask: (taskId: string) => post<CenterTask>(`/api/tasks/${id(taskId)}/resume`, {}),
339
347
  /** 同一个 business_id 重发只开一个对话。 */
340
348
  openConversation: (jobId: string, text: string, opts: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number } = {}) =>
341
349
  post<SendResult>(`/api/jobs/${id(jobId)}/conversations`, {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 组件的界面配置:哪些模块显示、文案怎么写。全部可选,不给就是缺省(默认文案;cli-chat 的顶部栏缺省隐藏,其余全部显示)。
2
+ * 组件的界面配置:哪些模块显示、文案怎么写。全部可选,缺省值见下方 DEFAULT_*_CONFIG。
3
3
  *
4
4
  * <CliChat config={{ header: { title: true, status: true }, composer: { placeholder: "问点什么…", fileUpload: false } }} />
5
5
  * <CliMsgList config={{ header: { refresh: false }, card: { turns: false } }} />
@@ -39,7 +39,7 @@ export interface CliChatConfig {
39
39
  placeholderRunning?: string;
40
40
  /** 不能发送时(对话已结束、只读)的占位文案。 */
41
41
  placeholderDisabled?: string;
42
- /** 底部「⏎ 发送 / ⇧⏎ 换行」提示。 */
42
+ /** 底部「Shift + Enter 换行」提示;发送快捷键仍为 Enter。 */
43
43
  hints?: boolean;
44
44
  /** 底部字数统计。 */
45
45
  charCount?: boolean;
@@ -47,7 +47,7 @@ export interface CliChatConfig {
47
47
  fileUpload?: boolean;
48
48
  /** 「文件」按钮的文字。 */
49
49
  fileButtonText?: string;
50
- /** 运行中的停止按钮。 */
50
+ /** 执行控制:普通对话停止当前轮次,一次性子任务暂停 / 继续。 */
51
51
  stop?: boolean;
52
52
  /** 运行中允许发消息排队;false = 运行中不能发。 */
53
53
  queue?: boolean;
@@ -60,6 +60,8 @@ export interface CliChatConfig {
60
60
  sessionLabel?: boolean;
61
61
  /** 消息时间。 */
62
62
  timestamps?: boolean;
63
+ /** 已随项目上下文发送的初始提示。缺省不渲染;true 显示原文。 */
64
+ initialPrompt?: boolean;
63
65
  /** 首条消息上的「初始提示」「已随项目上下文发送」标记。 */
64
66
  initialBadge?: boolean;
65
67
  /** 工具调用卡片(命令、读写文件……);false 只留文字往来(提问卡片照常显示)。 */
@@ -87,7 +89,7 @@ export interface ResolvedChatConfig {
87
89
  show: boolean; placeholder: string; placeholderNew: string; placeholderRunning: string; placeholderDisabled: string | null;
88
90
  hints: boolean; charCount: boolean; fileUpload: boolean; fileButtonText: string; stop: boolean; queue: boolean;
89
91
  };
90
- transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean; status: boolean };
92
+ transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialPrompt: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean; status: boolean };
91
93
  labels: { user: string; agent: string };
92
94
  empty: { show: boolean; title: string; description: string };
93
95
  /** 按种类显示 / 不显示;"*" 是没列出的种类。用 refShown 判断。 */
@@ -102,13 +104,13 @@ export const DEFAULT_CHAT_CONFIG: ResolvedChatConfig = {
102
104
  header: { show: true, crumb: false, export: false, title: false, agent: false, usage: false, model: false, status: false, close: false },
103
105
  composer: {
104
106
  show: true,
105
- placeholder: "在“{title}”中回复 {agent}…(可拖入 Word / Excel / Markdown 等文档)",
106
- placeholderNew: "输入第一条消息,开始新对话…(可拖入 Word / Excel / Markdown 等文档)",
107
+ placeholder: "回复 {agent}…",
108
+ placeholderNew: "输入消息,开始新对话…",
107
109
  placeholderRunning: "排队一条后续消息…(本轮结束后发送)",
108
110
  placeholderDisabled: null,
109
111
  hints: true, charCount: true, fileUpload: true, fileButtonText: "文件", stop: true, queue: true,
110
112
  },
111
- transcript: { nav: true, sessionLabel: true, timestamps: true, initialBadge: true, tools: true, runningIndicator: true, status: false },
113
+ transcript: { nav: true, sessionLabel: true, timestamps: true, initialPrompt: false, initialBadge: true, tools: true, runningIndicator: true, status: false },
112
114
  labels: { user: "你", agent: "智能体" },
113
115
  empty: { show: true, title: "开始一个新对话", description: "在下面输入第一条消息。可以附上 Word、Excel、Markdown、TXT、HTML、CSV 文档,内容会转成文字一起发送。" },
114
116
  refs: { "*": true, viewing: false },
@@ -119,12 +121,6 @@ export function resolveChatConfig(config: CliChatConfig = {}): ResolvedChatConfi
119
121
  const header = config.header === true ? allOn(d.header) : toggle(config.header, d.header);
120
122
  const composer = toggle(config.composer, d.composer);
121
123
  const empty = toggle(config.empty, d.empty);
122
- // 没有文件上传时,缺省文案里的「可拖入文件」提示也去掉。
123
- if (!composer.fileUpload) {
124
- const strip = (text: string) => text.replace(/(可拖入 Word \/ Excel \/ Markdown 等文档)$/, "");
125
- if (!isObj(config.composer) || config.composer.placeholder === undefined) composer.placeholder = strip(composer.placeholder);
126
- if (!isObj(config.composer) || config.composer.placeholderNew === undefined) composer.placeholderNew = strip(composer.placeholderNew);
127
- }
128
124
  return {
129
125
  header,
130
126
  composer,
@@ -146,7 +142,7 @@ export interface CliMsgListConfig {
146
142
  newButton?: boolean;
147
143
  /** 「+ 对话」按钮的文字。 */
148
144
  newButtonText?: string;
149
- /** 刷新按钮。 */
145
+ /** 手动刷新按钮,缺省隐藏;列表仍按 pollMs 自动更新。 */
150
146
  refresh?: boolean;
151
147
  /** 上下文说明框(需同时给 context 或 onEditContext)。 */
152
148
  context?: boolean;
@@ -192,7 +188,7 @@ export interface ResolvedMsgListConfig {
192
188
  }
193
189
 
194
190
  export const DEFAULT_MSGLIST_CONFIG: ResolvedMsgListConfig = {
195
- header: { show: true, title: true, newButton: true, newButtonText: "对话", refresh: true, context: true },
191
+ header: { show: true, title: true, newButton: true, newButtonText: "对话", refresh: false, context: true },
196
192
  search: "auto",
197
193
  searchPlaceholder: "搜索对话…",
198
194
  grouped: false,
@@ -403,10 +403,10 @@ function batchViewMessages(conv: ConvState): Msg[] {
403
403
  }
404
404
  const generation = out.length ? out[out.length - 1].generation : 1;
405
405
  const note = (id: string, content: string) => out.push({ id: `batch-${id}`, role: "system", content, generation });
406
+ for (const q of task?.queued ?? []) out.push({ id: `batch-q-${q.id}`, role: "queued", content: q.prompt, generation, ...(q.created_at ? { ts: new Date(q.created_at).getTime() } : {}) });
406
407
  if (task?.conversation) {
407
408
  // 对话式:像对话一样显示——还没派发的消息是「排队中」气泡,没有「子任务」字样。
408
409
  if (CHAT_TASK_ERROR[task.status]) note(`err-${task.id}-${task.attempt}`, `⚠ ${CHAT_TASK_ERROR[task.status]}${task.last_error ? `:${describeTurnError(task.last_error)}` : ""}`);
409
- for (const q of task.queued ?? []) out.push({ id: `batch-q-${q.id}`, role: "queued", content: q.prompt, generation, ...(q.created_at ? { ts: new Date(q.created_at).getTime() } : {}) });
410
410
  return out;
411
411
  }
412
412
  if (!task) {
package/src/styles.css CHANGED
@@ -46,6 +46,37 @@
46
46
  .cli-ui .cli-count{font-family:var(--mono);}
47
47
  .cli-ui .cli-count.over{color:var(--red);}
48
48
 
49
+ /* 组件在侧栏中也保持清晰的标题、操作和输入区。 */
50
+ .cli-ui.cli-chat{container:cli-chat / inline-size;}
51
+ .cli-ui .sess-head .sh-main{min-width:0;flex:1 1 180px;}
52
+ .cli-ui .sess-head .sh-tools{min-width:0;max-width:100%;flex-wrap:wrap;justify-content:flex-end;margin-left:auto;}
53
+ .cli-ui .sess-head .crumb{overflow-wrap:anywhere;}
54
+ .cli-ui .cli-export{white-space:nowrap;}
55
+ .cli-ui .comp-area textarea{min-width:0;min-height:48px;}
56
+ .cli-ui .comp-foot{gap:8px;padding:8px 10px;background:transparent;border-top:0;flex-wrap:wrap;}
57
+ .cli-ui .cli-attach{display:inline-flex;align-items:center;gap:5px;min-height:32px;padding:0 8px;border-radius:7px;color:var(--ink-3);font-size:12px;}
58
+ .cli-ui .cli-attach:hover{background:var(--surface-2);color:var(--ink);}
59
+ .cli-ui .cli-attach svg{width:15px;height:15px;}
60
+ .cli-ui .send.cli-send{display:inline-flex;justify-content:center;gap:5px;width:auto;min-width:76px;padding:0 12px;font-size:12.5px;font-weight:600;}
61
+ .cli-ui .send.cli-send:disabled{background:var(--surface-2);color:var(--ink-4);box-shadow:none;opacity:1;}
62
+ .cli-ui .cli-export:focus-visible,.cli-ui .cli-attach:focus-visible,.cli-ui .send:focus-visible{outline:2px solid var(--accent);outline-offset:3px;}
63
+ .cli-ui .cli-task-control{height:34px;padding:0 10px;white-space:nowrap;}
64
+ .cli-ui .cli-task-control:focus-visible{outline:2px solid var(--accent);outline-offset:3px;}
65
+ @container cli-chat (max-width:560px){
66
+ .cli-ui .sess-head{gap:8px;padding:12px 14px;}
67
+ .cli-ui .sess-head .sh-main{flex-basis:100%;}
68
+ .cli-ui .sess-head .sh-tools{flex:1;justify-content:flex-start;margin-left:0;}
69
+ .cli-ui .sess-head .cli-export{margin-left:auto;}
70
+ .cli-ui .tw{padding:0 16px;}
71
+ .cli-ui .composer{padding:10px 12px;}
72
+ }
73
+
74
+ /* 普通文本和 URL 换行;代码、宽表格只在各自区域内横向滚动。聊天和看板预览共用。 */
75
+ .cli-ui .transcript-wrap,.cli-ui .transcript,.cli-ui .tw,.cli-ui .msg,.cli-ui .msg-body,.cli-ui .md{min-width:0;}
76
+ .cli-ui .msg-body,.cli-ui .md{overflow-wrap:anywhere;}
77
+ .cli-ui .md pre{max-width:100%;overflow-wrap:normal;}
78
+ .cli-ui .md table{display:block;max-width:100%;overflow-x:auto;}
79
+
49
80
  /* 排队气泡下的等待原因(中枢给的:节点连不上、没有可用节点……) */
50
81
  .cli-ui .cli-queued-note{margin-top:6px;font-size:12px;color:var(--amber);}
51
82
 
package/src/ui/icons.tsx CHANGED
@@ -54,6 +54,7 @@ export const Icon = {
54
54
  external: (p?: P) => S(<><path d="M14 4h6v6" /><path d="M20 4l-9 9" /><path d="M19 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5" /></>, p),
55
55
  copy: (p?: P) => S(<><rect x={9} y={9} width={12} height={12} rx={2} /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></>, p),
56
56
  play: (p?: P) => S(<path d="M7 5l11 7-11 7z" />, p),
57
+ pause: (p?: P) => S(<><path d="M9 5v14" /><path d="M15 5v14" /></>, p),
57
58
  stop: (p?: P) => S(<rect x={6} y={6} width={12} height={12} rx={2} />, p),
58
59
  sliders: (p?: P) => S(<><line x1={4} y1={21} x2={4} y2={14} /><line x1={4} y1={10} x2={4} y2={3} /><line x1={12} y1={21} x2={12} y2={12} /><line x1={12} y1={8} x2={12} y2={3} /><line x1={20} y1={21} x2={20} y2={16} /><line x1={20} y1={12} x2={20} y2={3} /><line x1={1} y1={14} x2={7} y2={14} /><line x1={9} y1={8} x2={15} y2={8} /><line x1={17} y1={16} x2={23} y2={16} /></>, p),
59
60
  search: (p?: P) => S(<><circle cx={11} cy={11} r={7} /><line x1={21} y1={21} x2={16.65} y2={16.65} /></>, p),