@spzxg/cli-ui 0.6.0 → 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/README.md +17 -5
- package/dist/{cli-ui-embed-CXdmrwo1.js → cli-ui-embed-6IJ_jRVh.js} +11479 -11152
- package/dist/cli-ui-index-BY5i3wId.js +11209 -0
- package/dist/cli-ui-jszip.min-BZsUwI-s.js +2324 -0
- package/dist/cli-ui-jszip.min-m01fexHW.js +26 -0
- package/dist/cli-ui-xlsx-CpWicJLT.js +93 -0
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +2 -1
- package/src/board/CliBoard.tsx +2 -2
- package/src/board/FileViewer.tsx +168 -35
- package/src/board/fileKind.ts +31 -0
- package/src/board/frameQuote.ts +50 -0
- package/src/board/textLoad.ts +99 -0
- package/src/chat/CliChat.tsx +56 -33
- package/src/chat/Composer.tsx +36 -25
- package/src/core/blocks.ts +50 -11
- package/src/core/client.ts +55 -20
- package/src/core/config.ts +26 -17
- package/src/core/conversation.ts +1 -1
- package/src/core/xlsx.ts +142 -0
- package/src/styles.css +31 -0
- package/src/ui/TextBlock.tsx +1 -1
- package/src/ui/Transcript.tsx +10 -5
- package/src/ui/icons.tsx +1 -0
- package/dist/cli-ui-index-BZppwn-v.js +0 -13528
package/src/chat/CliChat.tsx
CHANGED
|
@@ -5,16 +5,16 @@ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useSta
|
|
|
5
5
|
import { CliErrorBoundary } from "../ui/ErrorBoundary.js";
|
|
6
6
|
import type { CliEvent, CliStore, CliStoreOptions } from "../core/store.js";
|
|
7
7
|
import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider.js";
|
|
8
|
-
import { boardContextBlock, serializeBlock } from "../core/blocks.js";
|
|
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";
|
|
14
14
|
import { HANDOFF_NOTICE_PREFIX, MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript.js";
|
|
15
15
|
import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel.js";
|
|
16
16
|
import { Composer, type ComposerOptions } from "./Composer.js";
|
|
17
|
-
import { fillTemplate, resolveChatConfig, type CliChatConfig } from "../core/config.js";
|
|
17
|
+
import { fillTemplate, refShown, resolveChatConfig, type CliChatConfig } from "../core/config.js";
|
|
18
18
|
import { exportMarkdown } from "./export.js";
|
|
19
19
|
import { FileLinkContext, FileOpenContext } from "../ui/fileLinks.js";
|
|
20
20
|
import { FileViewer } from "../board/FileViewer.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
|
|
185
|
-
const readOnly = !!readOnlyProp || !cfg.composer.show || (batch && !
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
//
|
|
198
|
-
const jobEnded = !chat && !
|
|
207
|
+
// 支持续派的普通子任务同理:大任务结束不挡输入框。
|
|
208
|
+
const jobEnded = !chat && !batchWritable && !!jobStatus && TERMINAL_JOB.has(jobStatus);
|
|
199
209
|
// 只关掉了这一个对话(对话任务还开着):按「这个对话已结束」显示,不能再发消息。
|
|
200
|
-
const convClosed = !jobEnded && !
|
|
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;
|
|
@@ -263,12 +273,15 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
263
273
|
// 一次性任务:发给正在看的那个子任务(接着它聊)。
|
|
264
274
|
const id = latest.current.conv?.batch ? latest.current.runKey : latest.current.key;
|
|
265
275
|
// 看板里引用到这个对话的东西:合成一个「看板上下文」块跟着这条消息发出去;发失败就放回去。
|
|
266
|
-
//
|
|
276
|
+
// 看板上正在看的文件缺省也带上一次(已经明确引用了的不重复),单独一块「看板正在看」:转录里按 config.refs.viewing 显示或不显示。
|
|
267
277
|
const refKey = latest.current.key;
|
|
268
278
|
const refs = refKey ? store.takeRefs(refKey) : [];
|
|
269
279
|
const viewing = refKey ? store.takeViewing(refKey) : null;
|
|
270
|
-
const
|
|
271
|
-
|
|
280
|
+
const blocks = [
|
|
281
|
+
...(viewing && !refs.some((r) => r.id === viewing.id) ? [viewingBlock(viewing.text)] : []),
|
|
282
|
+
...(refs.length ? [boardContextBlock(refs.map((r) => r.text))] : []),
|
|
283
|
+
];
|
|
284
|
+
const full = blocks.length ? [text, ...blocks.map(serializeBlock)].join("\n\n") : text;
|
|
272
285
|
let target: string;
|
|
273
286
|
try {
|
|
274
287
|
target = await store.send(id ?? null, full, sendOpts.current);
|
|
@@ -280,7 +293,10 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
280
293
|
}, [store, go]);
|
|
281
294
|
const refs = useCliState((s) => (key ? s.refs[key] : undefined));
|
|
282
295
|
const viewingState = useCliState((s) => (key ? s.viewing[key] : undefined));
|
|
283
|
-
const
|
|
296
|
+
const viewingRef = viewingState && !viewingState.off && !refs?.some((r) => r.id === viewingState.ref.id) ? viewingState.ref : null;
|
|
297
|
+
// 输入框上方显示哪些(config.refs):不显示的照样随消息带上。正在看的缺省不显示。
|
|
298
|
+
const viewing = viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
|
|
299
|
+
const shownRefs = refs?.filter((r) => refShown(cfg, r.kind));
|
|
284
300
|
// 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
|
|
285
301
|
const contextItems = useCliState((s) => s.context[key ?? ""]);
|
|
286
302
|
// AI 把浏览器交给用户(实时新来的「🖐 …」行):同一 Provider 里有看板在看这个对话,就把看板切到浏览器(收起的会请宿主展开)。
|
|
@@ -379,7 +395,8 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
379
395
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
380
396
|
const olderRef = useRef(older); olderRef.current = older;
|
|
381
397
|
const keyRef2 = useRef(key); keyRef2.current = key;
|
|
382
|
-
const items = useMemo(() => buildTranscriptItems(committed, older > 0)
|
|
398
|
+
const items = useMemo(() => buildTranscriptItems(committed, older > 0)
|
|
399
|
+
.filter((it) => cfg.transcript.initialPrompt || it.kind !== "msg" || !it.initial), [committed, older, cfg.transcript.initialPrompt]);
|
|
383
400
|
// 展开了的「续等」折叠行(按折叠行的 key)。
|
|
384
401
|
const [openFolds, setOpenFolds] = useState<Set<string>>(() => new Set());
|
|
385
402
|
const [win, setWin] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
|
|
@@ -527,7 +544,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
527
544
|
|
|
528
545
|
const contextReset = !!lastTurn?.context_reset && conv?.turns.some((turn) => turn.context_reset);
|
|
529
546
|
const h = cfg.header;
|
|
530
|
-
const view = useMemo(() => ({ timestamps: cfg.transcript.timestamps, initialBadge: cfg.transcript.initialBadge, userLabel: cfg.labels.user, agentName: cfg.labels.agent }), [cfg]);
|
|
547
|
+
const view = useMemo(() => ({ timestamps: cfg.transcript.timestamps, initialBadge: cfg.transcript.initialBadge, userLabel: cfg.labels.user, agentName: cfg.labels.agent, viewingBlock: refShown(cfg, "viewing") }), [cfg]);
|
|
531
548
|
const composerOptions = useMemo<ComposerOptions>(() => ({
|
|
532
549
|
placeholderRunning: cfg.composer.placeholderRunning, hints: cfg.composer.hints, charCount: cfg.composer.charCount,
|
|
533
550
|
fileUpload: cfg.composer.fileUpload, fileButtonText: cfg.composer.fileButtonText, queue: cfg.composer.queue,
|
|
@@ -535,9 +552,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
535
552
|
const composerDisabled = !!readOnly || finalEnd || conv?.stream === "error";
|
|
536
553
|
const disabledHint = readOnly
|
|
537
554
|
? batch && !readOnlyProp && cfg.composer.show
|
|
538
|
-
?
|
|
539
|
-
: batchTaskStatus === "blocked" || batchTaskStatus === "queued" ? "子任务还没开始,开始并结束后可以接着它聊"
|
|
540
|
-
: "子任务运行中,结束后可以接着它继续聊;需要登录等操作请在右侧浏览器里接管")
|
|
555
|
+
? "当前任务暂不支持发送消息"
|
|
541
556
|
: "只读"
|
|
542
557
|
: finalEnd ? "对话已结束,不能再发消息" : conv?.streamError ?? undefined;
|
|
543
558
|
|
|
@@ -567,15 +582,10 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
567
582
|
<div className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
|
|
568
583
|
<div className="session">
|
|
569
584
|
{h.show && (h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
|
|
570
|
-
{(h.crumb || h.
|
|
571
|
-
{
|
|
572
|
-
{h.crumb && <>
|
|
585
|
+
{(h.crumb || h.title) && <div className="sh-main">
|
|
586
|
+
{h.crumb && <div className="crumb">
|
|
573
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>
|
|
574
588
|
{title} <span className="sep">/</span> 对话
|
|
575
|
-
</>}
|
|
576
|
-
{h.export && key && allMessages.length > 0 && (
|
|
577
|
-
<button className="crumb-export" onClick={() => exportMarkdown(convTitle, taskId ? `${jobId}-${taskId}` : jobId!, allMessages, conv?.turns ?? [])} title="导出这个对话的完整过程(Markdown)">{Icon.download()} 导出执行过程</button>
|
|
578
|
-
)}
|
|
579
589
|
</div>}
|
|
580
590
|
{h.title && <div className="sh-title"><span className="sht-text" title={convTitle}>{convTitle}</span></div>}
|
|
581
591
|
</div>}
|
|
@@ -597,6 +607,12 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
597
607
|
<StatusDot cls={dotCls} running={running} awaiting={awaiting} title={statusText} />
|
|
598
608
|
<span className="cv">{statusText}</span>
|
|
599
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>}
|
|
600
616
|
{h.close && key && !isNew && allowClose && !readOnly && !ended && !batch && (
|
|
601
617
|
<button className="btn btn-line btn-sm" onClick={() => setClosing("confirm")} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
|
|
602
618
|
)}
|
|
@@ -703,7 +719,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
703
719
|
))}
|
|
704
720
|
</div>
|
|
705
721
|
)}
|
|
706
|
-
{showComposer && (!!
|
|
722
|
+
{showComposer && (!!shownRefs?.length || !!viewing) && (
|
|
707
723
|
<div className="cli-refs" aria-label="引用自看板,下一条消息会带上">
|
|
708
724
|
<span className="cli-refs-t">{Icon.board()} 引用自看板</span>
|
|
709
725
|
{viewing && (
|
|
@@ -713,7 +729,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
713
729
|
<button className="cli-ref-x" onClick={() => key && store.dismissViewing(key)} title="这次不带上" aria-label={`不带上 ${viewing.label}`}>{Icon.x()}</button>
|
|
714
730
|
</span>
|
|
715
731
|
)}
|
|
716
|
-
{(
|
|
732
|
+
{(shownRefs ?? []).map((r) => (
|
|
717
733
|
<span key={r.id} className={`cli-ref ${r.kind === "selection" ? "sel" : ""}`} title={r.text}>
|
|
718
734
|
<span className="cli-ref-l">{r.label}</span>
|
|
719
735
|
<button className="cli-ref-x" onClick={() => key && store.removeRef(key, r.id)} title="不带上" aria-label={`不带上 ${r.label}`}>{Icon.x()}</button>
|
|
@@ -728,9 +744,16 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
728
744
|
placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
|
|
729
745
|
disabled={composerDisabled}
|
|
730
746
|
disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
|
|
731
|
-
running={running}
|
|
747
|
+
running={running || batchPending}
|
|
732
748
|
onSend={send}
|
|
733
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}
|
|
734
757
|
options={composerOptions}
|
|
735
758
|
/>
|
|
736
759
|
)}
|
package/src/chat/Composer.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// 输入框。结构与 Operator 的 Composer.tsx 一致(comp-box / comp-area / send-group / comp-foot),
|
|
2
|
-
//
|
|
2
|
+
// 但只发文本:不收图片和其他附件。文件只收文档(Word、Excel、Markdown、TXT、HTML、CSV),在前端解析成纯文本,
|
|
3
3
|
// 和长粘贴一样变成可折叠的文本块,发送时按统一格式拼进正文(见 core/blocks.ts)。
|
|
4
4
|
import { useEffect, useRef, useState } from "react";
|
|
5
5
|
import {
|
|
6
|
-
ACCEPT_ATTR, MAX_MESSAGE_CHARS, PASTE_NAME, charCount, composeMessage, isLongText, readFileText, type TextBlock,
|
|
6
|
+
ACCEPT_ATTR, ACCEPTED_LABEL, MAX_MESSAGE_CHARS, PASTE_NAME, charCount, composeMessage, isLongText, readFileText, type TextBlock,
|
|
7
7
|
} from "../core/blocks.js";
|
|
8
8
|
import { Icon } from "../ui/icons.js";
|
|
9
9
|
import { TextBlockCard } from "../ui/TextBlock.js";
|
|
@@ -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={
|
|
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) => {
|
|
@@ -154,7 +160,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
154
160
|
if (files.length) {
|
|
155
161
|
e.preventDefault();
|
|
156
162
|
if (!options.fileUpload) { setError("这里只能发送文字"); return; }
|
|
157
|
-
if (files.some((f) => f.type.startsWith("image/"))) setError(
|
|
163
|
+
if (files.some((f) => f.type.startsWith("image/"))) setError(`不支持发送图片,只能发送文字,或上传文档:${ACCEPTED_LABEL}`);
|
|
158
164
|
addFiles(files.filter((f) => !f.type.startsWith("image/")));
|
|
159
165
|
return;
|
|
160
166
|
}
|
|
@@ -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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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>
|
package/src/core/blocks.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 「文本块」:上传的文档(Word / Excel / Markdown / TXT / HTML / CSV)和粘贴的大段文字。
|
|
3
3
|
*
|
|
4
4
|
* 前端先把文件解析成纯文本,再按统一格式拼进消息正文发给 center——center 只收纯文本。
|
|
5
5
|
* 同一个格式两头用:输入框里是可折叠的卡片,发出去之后转录里也解析回同样的卡片,
|
|
@@ -12,11 +12,13 @@
|
|
|
12
12
|
* 对 Agent 来说这也是清楚的分隔:哪段是附件、叫什么、在哪结束。
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { decodeText } from "./textDecode.js";
|
|
16
|
+
|
|
15
17
|
export interface TextBlock {
|
|
16
18
|
/** 显示名:文件名,粘贴的文字为「粘贴的文本」。 */
|
|
17
19
|
name: string;
|
|
18
20
|
text: string;
|
|
19
|
-
kind: "file" | "paste" | "context" | "background";
|
|
21
|
+
kind: "file" | "paste" | "context" | "background" | "viewing";
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/** 看板引用合成的块的名字(转录里按它认出来,显示成「看板上下文」卡片)。 */
|
|
@@ -27,6 +29,14 @@ export function boardContextBlock(lines: string[]): TextBlock {
|
|
|
27
29
|
return { name: BOARD_CONTEXT_NAME, kind: "context", text: ["用户在右侧看板里引用了下面这些,供这条消息参考:", ...lines.map((l) => `- ${l}`)].join("\n") };
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/** 看板上正在看的东西(缺省随消息带上、不显示)合成的块的名字:和明确引用的分开,转录里可以单独不显示。 */
|
|
33
|
+
export const VIEWING_NAME = "看板正在看";
|
|
34
|
+
|
|
35
|
+
/** 看板上正在看的东西 → 发给智能体的块。 */
|
|
36
|
+
export function viewingBlock(line: string): TextBlock {
|
|
37
|
+
return { name: VIEWING_NAME, kind: "viewing", text: `${line}。用户没有另外说明时,消息里说的「这个」「这里」「改一下」等就是指它。` };
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
/** 补充背景合成的块的名字(转录里显示成「补充背景」卡片)。 */
|
|
31
41
|
export const BACKGROUND_NAME = "补充背景";
|
|
32
42
|
|
|
@@ -38,9 +48,25 @@ export function backgroundBlock(items: Array<{ title?: string; text: string }>):
|
|
|
38
48
|
return { name: BACKGROUND_NAME, kind: "background", text: `以下是补充的项目背景信息,请在这次及之后的工作中一并参考:\n\n${body}` };
|
|
39
49
|
}
|
|
40
50
|
|
|
41
|
-
/**
|
|
42
|
-
export const ACCEPTED_EXTENSIONS = [".
|
|
43
|
-
export const ACCEPT_ATTR =
|
|
51
|
+
/** 支持的文件:只收文档——Word(.docx)、Excel(.xlsx / .xlsm)、Markdown、TXT、HTML、CSV / TSV。都在浏览器里转成文字。 */
|
|
52
|
+
export const ACCEPTED_EXTENSIONS = [".docx", ".xlsx", ".xlsm", ".md", ".markdown", ".txt", ".html", ".htm", ".csv", ".tsv"] as const;
|
|
53
|
+
export const ACCEPT_ATTR = [
|
|
54
|
+
...ACCEPTED_EXTENSIONS,
|
|
55
|
+
"text/plain", "text/markdown", "text/html", "text/csv", "text/tab-separated-values",
|
|
56
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
57
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-excel.sheet.macroEnabled.12",
|
|
58
|
+
].join(",");
|
|
59
|
+
/** 不支持时提示里列的格式。 */
|
|
60
|
+
export const ACCEPTED_LABEL = "Word(.docx)、Excel(.xlsx)、Markdown、TXT、HTML、CSV";
|
|
61
|
+
/** 一些常见但读不了的文档:给出另存为什么。 */
|
|
62
|
+
const SAVE_AS: Record<string, string> = {
|
|
63
|
+
".doc": "老版 Word(.doc)请另存为 .docx 后再上传",
|
|
64
|
+
".xls": "老版 Excel(.xls)请另存为 .xlsx 或 CSV 后再上传",
|
|
65
|
+
".wps": "WPS 文字请另存为 .docx 后再上传",
|
|
66
|
+
".et": "WPS 表格请另存为 .xlsx 后再上传",
|
|
67
|
+
};
|
|
68
|
+
/** HTML 里内嵌的大段 data: 数据(图片、字体):换成占位,免得一张图就把消息撑爆。 */
|
|
69
|
+
const INLINE_DATA = /data:[\w.+-]+\/[\w.+-]+(?:;[\w=.+-]+)*,[^"')\s]{200,}/g;
|
|
44
70
|
/** 粘贴超过这个长度(或行数)就转成文本块,不放进输入框。 */
|
|
45
71
|
export const PASTE_BLOCK_CHARS = 2_000;
|
|
46
72
|
export const PASTE_BLOCK_LINES = 40;
|
|
@@ -82,7 +108,7 @@ export function splitMessage(content: string): MessagePart[] {
|
|
|
82
108
|
if (!open || end === -1) { text.push(lines[i]); continue; }
|
|
83
109
|
flush();
|
|
84
110
|
const name = open[1];
|
|
85
|
-
parts.push({ kind: "block", block: { name, text: lines.slice(i + 1, end).join("\n"), kind: name === PASTE_NAME ? "paste" : name === BOARD_CONTEXT_NAME ? "context" : name === BACKGROUND_NAME ? "background" : "file" } });
|
|
111
|
+
parts.push({ kind: "block", block: { name, text: lines.slice(i + 1, end).join("\n"), kind: name === PASTE_NAME ? "paste" : name === BOARD_CONTEXT_NAME ? "context" : name === VIEWING_NAME ? "viewing" : name === BACKGROUND_NAME ? "background" : "file" } });
|
|
86
112
|
i = end;
|
|
87
113
|
}
|
|
88
114
|
flush();
|
|
@@ -105,13 +131,24 @@ export function extensionOf(name: string) {
|
|
|
105
131
|
export class FileReadError extends Error {}
|
|
106
132
|
|
|
107
133
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
134
|
+
* 读出文件的纯文本:
|
|
135
|
+
* · TXT / Markdown / CSV / TSV / HTML —— 按编码解(UTF-8,导出的 GBK 文件按 GB18030),HTML 保留源码、内嵌的大段 data: 数据换成占位;
|
|
136
|
+
* · Word —— mammoth 取正文文字(不带格式、图片);
|
|
137
|
+
* · Excel —— 每个工作表一段 CSV(单元格的值,日期写成 2026-08-10),见 xlsx.ts。
|
|
138
|
+
* 其他文件(图片、PDF、压缩包……)不收;老版 .doc / .xls 浏览器里读不了,提示另存为。
|
|
110
139
|
*/
|
|
111
140
|
export async function readFileText(file: File): Promise<string> {
|
|
112
141
|
const ext = extensionOf(file.name);
|
|
113
|
-
if (ext
|
|
114
|
-
if (!(ACCEPTED_EXTENSIONS as readonly string[]).includes(ext)) throw new FileReadError(
|
|
142
|
+
if (SAVE_AS[ext]) throw new FileReadError(`不支持这种格式:${SAVE_AS[ext]}`);
|
|
143
|
+
if (!(ACCEPTED_EXTENSIONS as readonly string[]).includes(ext)) throw new FileReadError(`「${file.name}」不是支持的文档,只能上传 ${ACCEPTED_LABEL}`);
|
|
144
|
+
if (ext === ".xlsx" || ext === ".xlsm") {
|
|
145
|
+
const { readXlsx, sheetsToText } = await import("./xlsx.js");
|
|
146
|
+
try {
|
|
147
|
+
return normalizeText(sheetsToText(await readXlsx(await readAs(file, "buffer"))));
|
|
148
|
+
} catch {
|
|
149
|
+
throw new FileReadError("Excel 文件解析失败,文件可能已损坏、加了密码,或不是 .xlsx 格式");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
115
152
|
if (ext === ".docx") {
|
|
116
153
|
// 按需加载:mammoth 不小,只有真的传 Word 时才下载。
|
|
117
154
|
const mammoth = await import("mammoth");
|
|
@@ -127,7 +164,9 @@ export async function readFileText(file: File): Promise<string> {
|
|
|
127
164
|
throw new FileReadError("Word 文件解析失败,文件可能已损坏或不是 .docx 格式");
|
|
128
165
|
}
|
|
129
166
|
}
|
|
130
|
-
|
|
167
|
+
const { text } = decodeText(new Uint8Array(await readAs(file, "buffer")));
|
|
168
|
+
if (ext === ".html" || ext === ".htm") return normalizeText(text.replace(INLINE_DATA, (m) => `${m.slice(0, m.indexOf(",") + 1)}…(内嵌数据已省略)`));
|
|
169
|
+
return normalizeText(text);
|
|
131
170
|
}
|
|
132
171
|
|
|
133
172
|
/** Blob.text() / arrayBuffer() 老浏览器(Safari 14 以前)没有,退回 FileReader。 */
|
package/src/core/client.ts
CHANGED
|
@@ -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>;
|
|
@@ -150,7 +156,12 @@ export interface BoardBackend {
|
|
|
150
156
|
* tail = 读末尾那一段(超大日志);节点不支持只读一段时退回开头(返回的 tail 为 false)。
|
|
151
157
|
* 不是 UTF-8 的(GBK 导出的 CSV 之类)按 GB18030 解,encoding 说明按什么解的。
|
|
152
158
|
*/
|
|
153
|
-
readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean }): Promise<TextChunk>;
|
|
159
|
+
readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean; onBytes?: (bytes: number) => void }): Promise<TextChunk>;
|
|
160
|
+
/**
|
|
161
|
+
* 读一段原始字节:从 start 开始(缺省 0)最多 maxBytes;tail = 末尾 maxBytes。返回这段在文件里的起点和文件总大小。
|
|
162
|
+
* 看板用它在文件变长时只取新增的部分(日志、还在写的报告),不整个重读。没有这个方法的后端每次整个重读。
|
|
163
|
+
*/
|
|
164
|
+
readBytes?(jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; onBytes?: (bytes: number) => void }): Promise<ByteChunk>;
|
|
154
165
|
/**
|
|
155
166
|
* 读整个文件。signal 取消(打包下载点了取消);onBytes 报已收到的字节数(大文件显示进度)。
|
|
156
167
|
* 弱网:FILE_HEADER_TIMEOUT_MS 内没回响应头、或传输中 FILE_STALL_MS 没收到数据,按网络错误(NETWORK)失败。
|
|
@@ -171,6 +182,15 @@ export interface BoardBackend {
|
|
|
171
182
|
browserLive?(jobId: string, handlers: BrowserLiveHandlers): () => void;
|
|
172
183
|
}
|
|
173
184
|
|
|
185
|
+
/** readBytes 读到的一段。 */
|
|
186
|
+
export interface ByteChunk {
|
|
187
|
+
bytes: Uint8Array<ArrayBuffer>;
|
|
188
|
+
/** 这段在文件里从第几个字节开始。 */
|
|
189
|
+
start: number;
|
|
190
|
+
/** 文件总大小。 */
|
|
191
|
+
size: number;
|
|
192
|
+
}
|
|
193
|
+
|
|
174
194
|
export interface TextChunk {
|
|
175
195
|
text: string;
|
|
176
196
|
/** 只读到了一部分。 */
|
|
@@ -322,6 +342,8 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
322
342
|
});
|
|
323
343
|
return { task_id: result.task_ids[0] };
|
|
324
344
|
},
|
|
345
|
+
pauseTask: (taskId: string) => post<CenterTask>(`/api/tasks/${id(taskId)}/pause`, {}),
|
|
346
|
+
resumeTask: (taskId: string) => post<CenterTask>(`/api/tasks/${id(taskId)}/resume`, {}),
|
|
325
347
|
/** 同一个 business_id 重发只开一个对话。 */
|
|
326
348
|
openConversation: (jobId: string, text: string, opts: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number } = {}) =>
|
|
327
349
|
post<SendResult>(`/api/jobs/${id(jobId)}/conversations`, {
|
|
@@ -590,6 +612,30 @@ function createBoardClient(
|
|
|
590
612
|
};
|
|
591
613
|
return { response, body };
|
|
592
614
|
};
|
|
615
|
+
/**
|
|
616
|
+
* 读一段:开头 / 从 start 起 maxBytes,或者末尾 maxBytes(超大日志)。只向中枢要这一段(Range);
|
|
617
|
+
* 不支持 Range 的节点回整个文件(200),这里边读边截,大文件不整个拉进浏览器。
|
|
618
|
+
*/
|
|
619
|
+
const readRange = async (jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; onBytes?: (bytes: number) => void }) => {
|
|
620
|
+
const from = Math.max(0, opts.start ?? 0);
|
|
621
|
+
const { maxBytes } = opts;
|
|
622
|
+
const { response, body } = await fileFetch(jobId, path, { Range: opts.tail ? `bytes=-${maxBytes}` : `bytes=${from}-${from + maxBytes - 1}` });
|
|
623
|
+
const contentType = response.headers.get("content-type") ?? "text/plain";
|
|
624
|
+
const total = /\/(\d+)\s*$/.exec(response.headers.get("content-range") ?? "")?.[1];
|
|
625
|
+
// 416:要的起点不在文件里(空文件,或文件变短了)。
|
|
626
|
+
if (response.status === 416) { await body(); return { bytes: new Uint8Array(0), start: from, size: total ? Number(total) : 0, contentType }; }
|
|
627
|
+
const length = Number(response.headers.get("content-length") ?? 0);
|
|
628
|
+
if (response.status === 206) {
|
|
629
|
+
// 节点给的一段可能比要的长(不严格按 Range):也只留 maxBytes。
|
|
630
|
+
const bytes = await body({ limit: maxBytes, onBytes: opts.onBytes });
|
|
631
|
+
const start = Number(/bytes (\d+)-/.exec(response.headers.get("content-range") ?? "")?.[1] ?? 0);
|
|
632
|
+
return { bytes, start, size: total ? Number(total) : start + bytes.length, contentType };
|
|
633
|
+
}
|
|
634
|
+
// 回了整个文件:跳过 from 之前的,留 maxBytes。
|
|
635
|
+
const all = await body({ limit: from + maxBytes, onBytes: opts.onBytes });
|
|
636
|
+
const size = length || all.length;
|
|
637
|
+
return { bytes: all.slice(Math.min(from, all.length)), start: Math.min(from, all.length), size, contentType };
|
|
638
|
+
};
|
|
593
639
|
// 文件链接令牌:按对话键缓存,离过期不到 5 分钟就换新的;同一时刻只换一次。
|
|
594
640
|
const links = new Map<string, { base: string; until: number }>();
|
|
595
641
|
const minting = new Map<string, Promise<string | null>>();
|
|
@@ -621,25 +667,14 @@ function createBoardClient(
|
|
|
621
667
|
return work;
|
|
622
668
|
},
|
|
623
669
|
async readText(jobId, path, maxBytes = 512 * 1024, opts = {}) {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
const bytes = await body({ limit: maxBytes });
|
|
633
|
-
const size = total ? Number(total) : bytes.length;
|
|
634
|
-
const start = Number(/bytes (\d+)-/.exec(response.headers.get("content-range") ?? "")?.[1] ?? 0);
|
|
635
|
-
const { text, encoding } = decodeText(bytes, { fromMiddle: start > 0 });
|
|
636
|
-
return { text, truncated: bytes.length < size, size, contentType, tail: start > 0, encoding };
|
|
637
|
-
}
|
|
638
|
-
// 不支持 Range(回 200 整个文件):只读前 maxBytes,大文件不整个拉进浏览器。
|
|
639
|
-
const all = await body({ limit: maxBytes });
|
|
640
|
-
const truncated = all.length >= maxBytes && (!length || length > maxBytes);
|
|
641
|
-
const { text, encoding } = decodeText(all);
|
|
642
|
-
return { text, truncated, size: length || all.length, contentType, encoding };
|
|
670
|
+
const { bytes, start, size, contentType } = await readRange(jobId, path, { maxBytes, tail: opts.tail, onBytes: opts.onBytes });
|
|
671
|
+
if (!size && !bytes.length) return { text: "", truncated: false, size: 0, contentType };
|
|
672
|
+
const { text, encoding } = decodeText(bytes, { fromMiddle: start > 0 });
|
|
673
|
+
return { text, truncated: bytes.length < size, size, contentType, tail: start > 0, encoding };
|
|
674
|
+
},
|
|
675
|
+
async readBytes(jobId, path, opts) {
|
|
676
|
+
const { bytes, start, size } = await readRange(jobId, path, opts);
|
|
677
|
+
return { bytes, start, size };
|
|
643
678
|
},
|
|
644
679
|
async readBuffer(jobId, path, opts = {}) {
|
|
645
680
|
const { body } = await fileFetch(jobId, path, {}, opts.signal);
|