@xgspz/cli-ui 0.0.0-stage → 0.6.15
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/CHANGELOG.md +165 -0
- package/README.md +517 -2
- package/dist/cli-ui-embed-vIXVNs6k.js +50295 -0
- package/dist/cli-ui-index-R24x28OX.js +11209 -0
- package/dist/cli-ui-jszip.min-DUghzByz.js +2324 -0
- package/dist/cli-ui-jszip.min-DfH6rdQD.js +26 -0
- package/dist/cli-ui-xlsx-CP0lJhff.js +93 -0
- package/dist/cli-ui.css +32 -0
- package/dist/cli-ui.js +17 -0
- package/package.json +60 -4
- package/src/board/BrowserPane.tsx +969 -0
- package/src/board/ChangesPane.tsx +185 -0
- package/src/board/CliBoard.tsx +484 -0
- package/src/board/FileViewer.tsx +721 -0
- package/src/board/FilesPane.tsx +262 -0
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +40 -0
- package/src/board/blocks.tsx +97 -0
- package/src/board/context.ts +65 -0
- package/src/board/diffLines.ts +46 -0
- package/src/board/fileKind.ts +250 -0
- package/src/board/fileOps.ts +85 -0
- package/src/board/fileScope.ts +32 -0
- package/src/board/fileSync.ts +351 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +72 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +211 -0
- package/src/board/folderDownload.ts +211 -0
- package/src/board/frameQuote.ts +52 -0
- package/src/board/htmlReading.ts +148 -0
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +76 -0
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +200 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +960 -0
- package/src/chat/Composer.tsx +239 -0
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +196 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +49 -0
- package/src/chat/export.ts +34 -0
- package/src/chat/queueEdit.ts +27 -0
- package/src/chat/useExecutionConfig.ts +93 -0
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +205 -0
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +822 -0
- package/src/core/config.ts +252 -0
- package/src/core/conversation.ts +581 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/execution.ts +24 -0
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +21 -0
- package/src/core/provider.tsx +141 -0
- package/src/core/sha256.ts +21 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +1109 -0
- package/src/core/subagents.ts +78 -0
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +507 -0
- package/src/core/xlsx.ts +142 -0
- package/src/elements.tsx +376 -0
- package/src/embed.ts +56 -0
- package/src/index.ts +56 -0
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +251 -0
- package/src/msglist/CliMsgList.tsx +422 -0
- package/src/operator/branchFlow.ts +35 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/liveStream.ts +1 -0
- package/src/operator/node/AgentConnect.tsx +619 -0
- package/src/operator/node/AppearancePanel.tsx +35 -0
- package/src/operator/node/BranchHistory.tsx +67 -0
- package/src/operator/node/BranchNavigator.tsx +36 -0
- package/src/operator/node/BrowserPane.tsx +128 -0
- package/src/operator/node/BrowserPopup.tsx +22 -0
- package/src/operator/node/Chat.tsx +538 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +316 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +192 -0
- package/src/operator/node/Markdown.tsx +61 -0
- package/src/operator/node/Modal.tsx +179 -0
- package/src/operator/node/NeedsYouMenu.tsx +54 -0
- package/src/operator/node/OnboardingWizard.tsx +248 -0
- package/src/operator/node/Orchestrator.tsx +644 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +559 -0
- package/src/operator/node/RunControls.tsx +2 -0
- package/src/operator/node/RunSwitch.tsx +113 -0
- package/src/operator/node/Services.tsx +245 -0
- package/src/operator/node/SessionRail.tsx +299 -0
- package/src/operator/node/SessionView.tsx +292 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +526 -0
- package/src/operator/node/TaskHero.tsx +50 -0
- package/src/operator/node/TasksColumn.tsx +200 -0
- package/src/operator/node/Terminal.tsx +143 -0
- package/src/operator/node/Transcript.tsx +485 -0
- package/src/operator/node/ViewFile.tsx +79 -0
- package/src/operator/node/Welcome.tsx +101 -0
- package/src/operator/node/WorktreePrune.tsx +333 -0
- package/src/operator/node/agents.ts +58 -0
- package/src/operator/node/api.ts +57 -0
- package/src/operator/node/boardBackend.ts +63 -0
- package/src/operator/node/boardEvents.ts +40 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +74 -0
- package/src/operator/node/boardWorkspace.ts +15 -0
- package/src/operator/node/boundedPoll.ts +47 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +78 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +292 -0
- package/src/operator/node/github.tsx +232 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +323 -0
- package/src/operator/node/modals.tsx +766 -0
- package/src/operator/node/navHistory.ts +70 -0
- package/src/operator/node/persist.ts +26 -0
- package/src/operator/node/protocol/agents/optIn.ts +9 -0
- package/src/operator/node/protocol/agents/types.ts +394 -0
- package/src/operator/node/protocol/approvalFailure.ts +77 -0
- package/src/operator/node/protocol/authFailure.ts +66 -0
- package/src/operator/node/protocol/customModels.ts +23 -0
- package/src/operator/node/protocol/events.ts +13 -0
- package/src/operator/node/protocol/features.ts +70 -0
- package/src/operator/node/protocol/internalUsage.ts +1 -0
- package/src/operator/node/protocol/promptLimits.ts +52 -0
- package/src/operator/node/protocol/storageRetention.ts +13 -0
- package/src/operator/node/protocol/types.ts +335 -0
- package/src/operator/node/protocol/usageLimit.ts +72 -0
- package/src/operator/node/shared.tsx +89 -0
- package/src/operator/node/transcriptModel.ts +129 -0
- package/src/operator/node/types.ts +305 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +697 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +102 -0
- package/src/operator/node/useTaskStream.ts +286 -0
- package/src/operator.base.css +1952 -0
- package/src/operator.css +1952 -0
- package/src/styles.css +579 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/Markdown.tsx +60 -0
- package/src/ui/Popover.tsx +95 -0
- package/src/ui/QueuedMessages.tsx +110 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/SubagentChat.tsx +233 -0
- package/src/ui/TextBlock.tsx +51 -0
- package/src/ui/Transcript.tsx +435 -0
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/fileLinks.ts +52 -0
- package/src/ui/format.ts +103 -0
- package/src/ui/icons.tsx +92 -0
- package/src/ui/reference.ts +40 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +151 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { subagentFields } from "../../core/subagents";
|
|
3
|
+
import { SubagentEntry } from "../../ui/SubagentChat";
|
|
4
|
+
import { useContext } from "react";
|
|
5
|
+
import { OperatorBoardContext } from "./boardInteraction";
|
|
6
|
+
import { FileLinkContext } from "./fileLinks";
|
|
7
|
+
|
|
8
|
+
import { memo, useId, useState } from "react";
|
|
9
|
+
import type { ToolData, ToolPeek, AskQuestion, AskAnswers, InputRequest } from "./protocol/types";
|
|
10
|
+
import { Icon } from "./icons";
|
|
11
|
+
import { Markdown } from "./Markdown";
|
|
12
|
+
import { splitMessage } from "../../core/blocks";
|
|
13
|
+
import { withoutBoardContext } from "../../board/context";
|
|
14
|
+
import { clockTime, diffCls, splitAttachments, type MsgAttachment } from "./format";
|
|
15
|
+
import { CONTEXT_OVERFLOW_NOTICE } from "./protocol/promptLimits";
|
|
16
|
+
import { AUTH_EXPIRED_NOTICE } from "./protocol/authFailure";
|
|
17
|
+
import { USAGE_LIMIT_NOTICE } from "./protocol/usageLimit";
|
|
18
|
+
import { APPROVAL_BLOCKED_NOTICE } from "./protocol/approvalFailure";
|
|
19
|
+
import type { Msg } from "./types";
|
|
20
|
+
import { clientFeatures } from "./protocol/features";
|
|
21
|
+
import { Avatar } from "./shared";
|
|
22
|
+
import { toolDataOf } from "./transcriptModel";
|
|
23
|
+
import { ReferenceCard } from "../../ui/ReferenceCard";
|
|
24
|
+
import { splitQuotedMessage } from "../../ui/reference";
|
|
25
|
+
|
|
26
|
+
// The always-visible "peek" tier — Claude Code's `⎿` line. Counts show no
|
|
27
|
+
// content; diffs/snippets show a capped hunk with a clickable "+N more" that
|
|
28
|
+
// opens the full body. TodoWrite renders its checklist inline.
|
|
29
|
+
function PeekView({ peek, expandable, onExpand }: { peek: ToolPeek; expandable: boolean; onExpand: () => void }) {
|
|
30
|
+
const corner = <span className="tcorner">⎿</span>;
|
|
31
|
+
if (peek.kind === "count") {
|
|
32
|
+
return (
|
|
33
|
+
<button className="tpeek tpeek-count" style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && onExpand()}>
|
|
34
|
+
{corner}<span className="tpeek-txt">{peek.text}</span>
|
|
35
|
+
{expandable && <span className="tpeek-more">展开</span>}
|
|
36
|
+
</button>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (peek.kind === "todos") {
|
|
40
|
+
return (
|
|
41
|
+
<div className="tpeek tpeek-todos">
|
|
42
|
+
{peek.items.map((t, i) => (
|
|
43
|
+
<div className={`tdo ${t.status}`} key={i}>
|
|
44
|
+
<span className="tdo-box">{t.status === "completed" ? "✔" : t.status === "in_progress" ? "▣" : "▢"}</span>
|
|
45
|
+
<span className="tdo-txt">{t.text}</span>
|
|
46
|
+
</div>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (peek.kind === "diff") {
|
|
52
|
+
return (
|
|
53
|
+
<div className="tpeek tpeek-diff">
|
|
54
|
+
<div className="tpeek-sum">{corner}<span className="dstat add">+{peek.added}</span><span className="dstat del">−{peek.removed}</span>{peek.label && <span className="tpeek-txt">{peek.label}</span>}</div>
|
|
55
|
+
<pre className="tpeek-pre diff">{peek.lines.map((l, i) => <div className={`dl ${diffCls(l.sign)}`} key={i}>{l.sign} {l.text}</div>)}</pre>
|
|
56
|
+
{peek.truncated ? <button className="tpeek-more btn-link" onClick={onExpand}>还有 {peek.truncated} 行</button> : null}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
// lines (Bash output)
|
|
61
|
+
return (
|
|
62
|
+
<div className="tpeek tpeek-lines">
|
|
63
|
+
{peek.label && <div className="tpeek-sum">{corner}<span className="tpeek-txt">{peek.label}</span></div>}
|
|
64
|
+
<pre className="tpeek-pre">{peek.lines.join("\n") || "(无输出)"}</pre>
|
|
65
|
+
{peek.truncated ? <button className="tpeek-more btn-link" onClick={onExpand}>还有 {peek.truncated} 行</button> : null}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ToolView({ data }: { data: ToolData }) {
|
|
71
|
+
const [open, setOpen] = useState<boolean | null>(null);
|
|
72
|
+
const bodyId = useId();
|
|
73
|
+
const hasDiff = !!data.diff?.length;
|
|
74
|
+
const expandable = !!(data.detail || hasDiff || data.result !== undefined);
|
|
75
|
+
// Failures surface their output automatically, like Claude Code.
|
|
76
|
+
const showBody = open ?? (!!data.isError && data.result !== undefined);
|
|
77
|
+
return (
|
|
78
|
+
<div className="tool">
|
|
79
|
+
<button className="tool-h" title={data.title} aria-expanded={expandable ? showBody : undefined} aria-controls={expandable ? bodyId : undefined} style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && setOpen(!showBody)}>
|
|
80
|
+
{expandable && <span className={`tchev ${showBody ? "open" : ""}`}>{Icon.chevRight()}</span>}
|
|
81
|
+
<span className="tg">{data.title}</span>
|
|
82
|
+
{data.result !== undefined && <span className={data.isError ? "tx" : "tcheck"}>{data.isError ? Icon.x() : Icon.check()}</span>}
|
|
83
|
+
</button>
|
|
84
|
+
{data.peek && !showBody && <PeekView peek={data.peek} expandable={expandable} onExpand={() => setOpen(true)} />}
|
|
85
|
+
{showBody && (
|
|
86
|
+
<div className="tool-body" id={bodyId}>
|
|
87
|
+
{data.detail && <pre className="tool-pre">{data.detail}</pre>}
|
|
88
|
+
{hasDiff && (
|
|
89
|
+
<pre className="tool-pre diff">{data.diff!.map((l, i) => <div className={`dl ${diffCls(l.sign)}`} key={i}>{l.sign} {l.text}</div>)}</pre>
|
|
90
|
+
)}
|
|
91
|
+
{data.result !== undefined && (
|
|
92
|
+
<>
|
|
93
|
+
{(data.detail || hasDiff) && <div className="tool-divider">结果</div>}
|
|
94
|
+
<pre className={`tool-pre ${data.isError ? "err" : ""}`}>{data.result || "(无输出)"}</pre>
|
|
95
|
+
</>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Interactive AskUserQuestion card: option pickers (+ an "Other" free-text per
|
|
104
|
+
// question) while pending; a read-only summary once answered.
|
|
105
|
+
function AskView({ data, agentLabel, onAnswer }: { data: ToolData; agentLabel: string; onAnswer: (answers: AskAnswers) => void }) {
|
|
106
|
+
const questions = data.ask?.questions ?? [];
|
|
107
|
+
const existing = data.ask?.answers;
|
|
108
|
+
const [state, setState] = useState(() => questions.map(() => ({ picked: [] as string[], other: "" })));
|
|
109
|
+
const [submitted, setSubmitted] = useState(false);
|
|
110
|
+
|
|
111
|
+
if (existing) {
|
|
112
|
+
return (
|
|
113
|
+
<div className="ask answered">
|
|
114
|
+
<div className="ask-head">{Icon.spark()} 你的回答</div>
|
|
115
|
+
{questions.map((q, i) => (
|
|
116
|
+
<div className="ask-q" key={i}>
|
|
117
|
+
<div className="ask-qh"><span className="ask-chip">{q.header}</span>{q.question}</div>
|
|
118
|
+
<div className="ask-picked">{(existing[i] ?? []).join("、") || "—"}</div>
|
|
119
|
+
</div>
|
|
120
|
+
))}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const toggle = (qi: number, label: string, multi: boolean) =>
|
|
126
|
+
setState((s) => s.map((st, i) => {
|
|
127
|
+
if (i !== qi) return st;
|
|
128
|
+
if (multi) {
|
|
129
|
+
const has = st.picked.includes(label);
|
|
130
|
+
return { ...st, picked: has ? st.picked.filter((l) => l !== label) : [...st.picked, label] };
|
|
131
|
+
}
|
|
132
|
+
return { picked: [label], other: "" }; // single-select replaces, clears Other
|
|
133
|
+
}));
|
|
134
|
+
const setOther = (qi: number, v: string) =>
|
|
135
|
+
setState((s) => s.map((st, i) => (i === qi ? (questions[i].multiSelect ? { ...st, other: v } : { picked: [], other: v }) : st)));
|
|
136
|
+
|
|
137
|
+
const answers: AskAnswers = state.map((st) => [...st.picked, ...(st.other.trim() ? [st.other.trim()] : [])]);
|
|
138
|
+
const complete = answers.every((a) => a.length > 0);
|
|
139
|
+
const submit = () => { if (complete && !submitted) { setSubmitted(true); onAnswer(answers); } };
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className="ask">
|
|
143
|
+
<div className="ask-head">{Icon.spark()} {agentLabel} 需要你的输入</div>
|
|
144
|
+
{questions.map((q, i) => (
|
|
145
|
+
<div className="ask-q" key={i}>
|
|
146
|
+
<div className="ask-qh"><span className="ask-chip">{q.header}</span>{q.question}{q.multiSelect && <span className="ask-multi">可多选</span>}</div>
|
|
147
|
+
<div className="ask-opts">
|
|
148
|
+
{q.options.map((o) => (
|
|
149
|
+
<button key={o.label} className={`ask-opt ${state[i].picked.includes(o.label) ? "on" : ""}`} onClick={() => toggle(i, o.label, !!q.multiSelect)} disabled={submitted}>
|
|
150
|
+
<span className="ask-opt-l">{o.label}</span>
|
|
151
|
+
{o.description && <span className="ask-opt-d">{o.description}</span>}
|
|
152
|
+
</button>
|
|
153
|
+
))}
|
|
154
|
+
<input className="ask-other" placeholder="其他…" value={state[i].other} disabled={submitted} onChange={(e) => setOther(i, e.target.value)} />
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
))}
|
|
158
|
+
<div className="ask-foot">
|
|
159
|
+
<button className="btn btn-accent btn-sm" onClick={submit} disabled={!complete || submitted}>{submitted ? "发送中…" : "发送回答"}</button>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Ask the session rail to show this task's browser (SessionRail / SessionView listen). */
|
|
166
|
+
export function showTaskBrowser(taskId?: string): void {
|
|
167
|
+
window.dispatchEvent(new CustomEvent("orch:show-browser", {detail:{taskId}}));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// browser_ask_user card (lib/browserInput.ts): what the agent needs to carry on
|
|
171
|
+
// in the browser — codes, accounts, passwords, choices — typed or picked right
|
|
172
|
+
// here while the browser waits on the page. Passwords are masked and never kept.
|
|
173
|
+
function InputView({ input, agentLabel, onSubmit, onShowBrowser }: { onShowBrowser?:()=>void; input: InputRequest; agentLabel: string; onSubmit?: (id: string, values: string[]) => Promise<string | null> }) {
|
|
174
|
+
const [vals, setVals] = useState<string[]>(() => input.fields.map(() => ""));
|
|
175
|
+
const [shown, setShown] = useState<boolean[]>(() => input.fields.map(() => false));
|
|
176
|
+
const [busy, setBusy] = useState(false);
|
|
177
|
+
const [err, setErr] = useState<string | null>(null);
|
|
178
|
+
const browser = clientFeatures().browser;
|
|
179
|
+
|
|
180
|
+
if (input.status !== "pending") {
|
|
181
|
+
return (
|
|
182
|
+
<div className="ask answered">
|
|
183
|
+
<div className="ask-head">{Icon.lock()} {input.status === "answered" ? "你提供的信息" : "已取消 —— 这一轮被停止了"}</div>
|
|
184
|
+
<div className="ask-q"><div className="ask-qh">{input.message}</div></div>
|
|
185
|
+
{input.status === "answered" && input.fields.map((f, i) => (
|
|
186
|
+
<div className="ask-q" key={i}>
|
|
187
|
+
<div className="ask-qh"><span className="ask-chip">{f.label}</span></div>
|
|
188
|
+
<div className="ask-picked">{f.secret ? "••••••(只交给浏览器脚本,没有保存,也没有发给模型)" : input.values?.[i] ?? "—"}</div>
|
|
189
|
+
</div>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const set = (i: number, v: string) => setVals((a) => a.map((x, j) => (j === i ? v : x)));
|
|
196
|
+
const complete = vals.every((v) => v.trim());
|
|
197
|
+
const submit = async () => {
|
|
198
|
+
if (!complete || busy || !onSubmit) return;
|
|
199
|
+
setBusy(true);
|
|
200
|
+
setErr(null);
|
|
201
|
+
try {
|
|
202
|
+
const e = await onSubmit(input.id, vals.map((v, i) => input.fields[i].secret ? v : v.trim()));
|
|
203
|
+
if (e) { setErr(e); setBusy(false); }
|
|
204
|
+
} catch (error) {
|
|
205
|
+
setErr(error instanceof Error ? error.message : "提交失败,请重试");
|
|
206
|
+
setBusy(false);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
return (
|
|
210
|
+
<div className="ask">
|
|
211
|
+
<div className="ask-head">{Icon.lock()} {agentLabel} 需要你提供信息 · 浏览器正停在这一页等待</div>
|
|
212
|
+
<div className="ask-q"><div className="ask-qh" style={{ whiteSpace: "pre-wrap" }}>{input.message}</div></div>
|
|
213
|
+
{input.fields.map((f, i) => (
|
|
214
|
+
<div className="ask-q" key={i}>
|
|
215
|
+
<div className="ask-qh"><span className="ask-chip">{f.label}</span>{f.secret && <span className="ask-multi">保密</span>}</div>
|
|
216
|
+
<div className="ask-opts">
|
|
217
|
+
{f.options?.map((o) => (
|
|
218
|
+
<button key={o} className={`ask-opt ${vals[i] === o ? "on" : ""}`} onClick={() => set(i, o)} disabled={busy}>
|
|
219
|
+
<span className="ask-opt-l">{o}</span>
|
|
220
|
+
</button>
|
|
221
|
+
))}
|
|
222
|
+
<div className="input-row">
|
|
223
|
+
<input
|
|
224
|
+
className="ask-other"
|
|
225
|
+
type={f.secret && !shown[i] ? "password" : "text"}
|
|
226
|
+
autoComplete={f.secret ? "new-password" : "off"}
|
|
227
|
+
placeholder={f.options?.length ? "其他…" : f.placeholder || `输入${f.label}`}
|
|
228
|
+
value={f.options?.includes(vals[i]) ? "" : vals[i]}
|
|
229
|
+
disabled={busy}
|
|
230
|
+
autoFocus={i === 0 && !f.options?.length}
|
|
231
|
+
onChange={(e) => set(i, e.target.value)}
|
|
232
|
+
onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) void submit(); }}
|
|
233
|
+
/>
|
|
234
|
+
{f.secret && (
|
|
235
|
+
<button className="btn btn-ghost btn-sm" type="button" onClick={() => setShown((a) => a.map((x, j) => (j === i ? !x : x)))}>
|
|
236
|
+
{shown[i] ? "隐藏" : "显示"}
|
|
237
|
+
</button>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
))}
|
|
243
|
+
<div className="ask-foot" style={{ gap: 8, alignItems: "center" }}>
|
|
244
|
+
{err && <span className="input-err">{err}</span>}
|
|
245
|
+
{input.fields.some((f) => f.secret) && !err && <span className="input-note">密码只交给浏览器脚本,不保存、不发给模型</span>}
|
|
246
|
+
<span style={{ flex: 1 }} />
|
|
247
|
+
{browser && <button className="btn btn-line btn-sm" onClick={onShowBrowser ?? (()=>showTaskBrowser())}>查看浏览器</button>}
|
|
248
|
+
<button className="btn btn-accent btn-sm" onClick={() => void submit()} disabled={!complete || busy}>{busy ? "发送中…" : "提交"}</button>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// The transcript lines browser_handoff / a page prompt write (lib/browserHandoff.ts,
|
|
255
|
+
// lib/browser.ts — both start with 🖐): the user has to act in the page itself. Buttons to open the live browser and to hand
|
|
256
|
+
// it back without hunting for the rail.
|
|
257
|
+
export const HANDOFF_NOTICE_PREFIX = "🖐 ";
|
|
258
|
+
function HandoffLine({ text, onBrowser, onShowBrowser, readOnly }: { readOnly?:boolean; onShowBrowser?:()=>void; text: string; onBrowser?: (action: "handback") => Promise<string | null> }) {
|
|
259
|
+
const [state, setState] = useState<"idle" | "busy" | "done">("idle");
|
|
260
|
+
const [err, setErr] = useState<string | null>(null);
|
|
261
|
+
const browser = clientFeatures().browser;
|
|
262
|
+
return (
|
|
263
|
+
<div className="msg system info">
|
|
264
|
+
<div className="msg-body">
|
|
265
|
+
{text}
|
|
266
|
+
{browser && (
|
|
267
|
+
<div style={{ display: "flex", gap: 8, marginTop: 8, flexWrap: "wrap", alignItems: "center" }}>
|
|
268
|
+
{!readOnly&&<button className="btn btn-line btn-sm" onClick={onShowBrowser ?? (()=>showTaskBrowser())}>查看浏览器</button>}
|
|
269
|
+
{onBrowser && (
|
|
270
|
+
<button className="btn btn-accent btn-sm" disabled={state !== "idle"} onClick={async () => {
|
|
271
|
+
setState("busy");
|
|
272
|
+
const e = await onBrowser("handback");
|
|
273
|
+
if (e) { setErr(e); setState("idle"); } else setState("done");
|
|
274
|
+
}}>{state === "done" ? "已交还" : state === "busy" ? "交还中…" : "我已完成,交还给 AI"}</button>
|
|
275
|
+
)}
|
|
276
|
+
{err && <span className="input-err">{err}</span>}
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Attachment chips parsed out of a user message's markers: image thumbnails
|
|
285
|
+
// (click opens full size) and text-file chips (a big paste diverted to a file;
|
|
286
|
+
// click opens it). Both are served from the task's uploads dir.
|
|
287
|
+
function AttachmentStrip({ items }: { items: MsgAttachment[] }) {
|
|
288
|
+
const open = useContext(OperatorBoardContext), scope = useContext(FileLinkContext);
|
|
289
|
+
if (!items.length) return null;
|
|
290
|
+
return (
|
|
291
|
+
<div className="msg-attachments">
|
|
292
|
+
{items.map((a, i) =>
|
|
293
|
+
a.kind === "image" ? (
|
|
294
|
+
<a key={i} href={a.url} target="_blank" rel="noreferrer" title="查看原图">
|
|
295
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
296
|
+
<img src={a.url} alt="附件图片" loading="lazy" />
|
|
297
|
+
</a>
|
|
298
|
+
) : (
|
|
299
|
+
<a key={i} href={a.url} target="_blank" rel="noreferrer" className="file-chip" title={`打开 ${a.name}`} onClick={event => {
|
|
300
|
+
const taskId = /\/api\/tasks\/([^/]+)\/uploads\//.exec(a.url)?.[1] ?? scope?.taskId;
|
|
301
|
+
if (taskId && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey && open?.({ kind: "file", taskId: decodeURIComponent(taskId), path: a.path })) event.preventDefault();
|
|
302
|
+
}}>
|
|
303
|
+
{Icon.clip()} <span>{a.name}</span>
|
|
304
|
+
</a>
|
|
305
|
+
)
|
|
306
|
+
)}
|
|
307
|
+
</div>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Memoized: during a live turn every SSE event re-renders the transcript's
|
|
312
|
+
// parents, but message objects are append-only (replaced only when their content
|
|
313
|
+
// changes), so unchanged messages skip re-rendering — and re-parsing their
|
|
314
|
+
// markdown — entirely. Callers must pass identity-stable handlers or the memo
|
|
315
|
+
// is defeated (SessionView wraps its handlers for exactly this reason).
|
|
316
|
+
function AutomaticBoardBackground({content}:{content:string}) {
|
|
317
|
+
const blocks=splitMessage(content).filter(part=>part.kind==='block'&&part.block.kind==='viewing');
|
|
318
|
+
if(!blocks.length)return null;
|
|
319
|
+
return <details className="cli-auto-background"><summary>自动看板背景</summary>{blocks.map((part,index)=>part.kind==='block'?<pre key={index}>{part.block.text}</pre>:null)}</details>;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function QuotedUserText({ text }: { text: string }) {
|
|
323
|
+
const quoted = splitQuotedMessage(text);
|
|
324
|
+
return quoted ? <><ReferenceCard title={quoted.quote.author} text={quoted.quote.text} renderContent={value => <Markdown>{value}</Markdown>} />{quoted.text && <Markdown>{quoted.text}</Markdown>}</> : <Markdown>{text}</Markdown>;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export const MessageView = memo(function MessageView({ m, initial, hideWho, running, agent, agentLabel = "智能体", userLabel = "你", speakerLabel = "智能体", onAnswer, onInput, onBrowser, onCancelQueued, onClear, onReconnect, onRetry, onQuote, readOnly }: { readOnly?: boolean; userLabel?: string; speakerLabel?: string; onQuote?: (m: Msg, selected?: string) => void; m: Msg; initial: boolean; hideWho: boolean; running?: boolean; agent?: string | null; agentLabel?: string; onAnswer?: (askId: string, questions: AskQuestion[], answers: AskAnswers) => void; onInput?: (inputId: string, values: string[]) => Promise<string | null>; onBrowser?: (action: "handback") => Promise<string | null>; onCancelQueued?: (pendingId: string) => void; onClear?: () => void; onReconnect?: () => void; onRetry?: (msgId: string) => void }) {
|
|
328
|
+
const openBoard=useContext(OperatorBoardContext);
|
|
329
|
+
const fileScope=useContext(FileLinkContext);
|
|
330
|
+
const onShowBrowser=()=>{if(fileScope&&openBoard)openBoard({kind:'browser',taskId:fileScope.taskId});else showTaskBrowser(fileScope?.taskId);};
|
|
331
|
+
if (readOnly) { onAnswer=undefined; onInput=undefined; onBrowser=undefined; onRetry=undefined; onCancelQueued=undefined; onClear=undefined; }
|
|
332
|
+
if (m.role === "queued") {
|
|
333
|
+
// A follow-up the user typed mid-turn, waiting its turn. Reads like a user
|
|
334
|
+
// bubble but dimmed, tagged "Queued", with an × to drop it before it runs.
|
|
335
|
+
const { text, attachments } = splitAttachments(withoutBoardContext(m.content));
|
|
336
|
+
return (
|
|
337
|
+
<div className="msg user queued">
|
|
338
|
+
<div className="who"><Avatar who="user" /> 你<span className="badge queued-badge">排队中</span>{m.ts != null && <span className="msg-time">{clockTime(m.ts)}</span>}</div>
|
|
339
|
+
<div className="msg-body">
|
|
340
|
+
{text && <QuotedUserText text={text} />}
|
|
341
|
+
<AutomaticBoardBackground content={m.content}/>
|
|
342
|
+
<AttachmentStrip items={attachments} />
|
|
343
|
+
{onCancelQueued && <button className="queued-x" title="从队列中移除" aria-label="从队列中移除" onClick={() => onCancelQueued(m.id)}>{Icon.x()}</button>}
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
if (m.role === "tool") {
|
|
349
|
+
const data = toolDataOf(m);
|
|
350
|
+
const child = subagentFields(m);
|
|
351
|
+
if (child.subagentMessage) return null;
|
|
352
|
+
if (child.subagent) return <SubagentEntry info={child.subagent} generation={m.generation} />;
|
|
353
|
+
if (data.input) {
|
|
354
|
+
return <div className="msg msg-tool" inert={readOnly||undefined}><InputView input={data.input} agentLabel={agentLabel} onSubmit={onInput} onShowBrowser={onShowBrowser} /></div>;
|
|
355
|
+
}
|
|
356
|
+
if (data.ask) {
|
|
357
|
+
return <div className="msg msg-tool" inert={readOnly||undefined}><AskView data={data} agentLabel={agentLabel} onAnswer={(answers) => onAnswer?.(data.ask?.id || m.toolId || "", data.ask?.questions ?? [], answers)} /></div>;
|
|
358
|
+
}
|
|
359
|
+
return <div className="msg msg-tool"><ToolView data={data} /></div>;
|
|
360
|
+
}
|
|
361
|
+
if (m.role === "system" && m.content.startsWith(HANDOFF_NOTICE_PREFIX)) {
|
|
362
|
+
return <HandoffLine text={m.content} onBrowser={onBrowser} onShowBrowser={onShowBrowser} readOnly={readOnly} />;
|
|
363
|
+
}
|
|
364
|
+
if (m.role === "system") {
|
|
365
|
+
// A context-overflow failure: render the warning line plus a one-click path
|
|
366
|
+
// to /clear, which resets the poisoned session and starts a fresh window
|
|
367
|
+
// (carrying a summary over). The notice string is matched verbatim — it's
|
|
368
|
+
// the durable, reconnect-safe channel written by lib/runner.ts.
|
|
369
|
+
if (m.content.includes(CONTEXT_OVERFLOW_NOTICE)) {
|
|
370
|
+
return (
|
|
371
|
+
<div className="msg system overflow">
|
|
372
|
+
<div className="msg-body">
|
|
373
|
+
{m.content}
|
|
374
|
+
{onClear && (
|
|
375
|
+
<div className="overflow-actions">
|
|
376
|
+
<button className="btn btn-sm" onClick={onClear} disabled={running} title="保存摘要并开启全新的上下文窗口">
|
|
377
|
+
{Icon.clear()} 开启新上下文
|
|
378
|
+
</button>
|
|
379
|
+
</div>
|
|
380
|
+
)}
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
// The agent's login died: same shape as the overflow case — the warning line
|
|
386
|
+
// plus the one action that fixes it (Settings → Agents, where the connect
|
|
387
|
+
// flow lives). Instance-wide, so the titlebar banner says it too; this is
|
|
388
|
+
// the in-context copy for whoever is reading the failed task.
|
|
389
|
+
if (m.content.includes(AUTH_EXPIRED_NOTICE)) {
|
|
390
|
+
return (
|
|
391
|
+
<div className="msg system overflow">
|
|
392
|
+
<div className="msg-body">
|
|
393
|
+
{m.content}
|
|
394
|
+
{onReconnect && (
|
|
395
|
+
<div className="overflow-actions">
|
|
396
|
+
<button className="btn btn-sm" onClick={onReconnect} title={`重新登录 ${agentLabel}`}>
|
|
397
|
+
{Icon.bolt()} 重新连接 {agentLabel}
|
|
398
|
+
</button>
|
|
399
|
+
</div>
|
|
400
|
+
)}
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
// The agent's usage limit is spent: same shape as the two cases above, but
|
|
406
|
+
// the only recovery is waiting for the reset (the error line above the
|
|
407
|
+
// notice carries the reset time when the SDK reported one), so this renders
|
|
408
|
+
// the notice styled like the others with no action button.
|
|
409
|
+
if (m.content.includes(USAGE_LIMIT_NOTICE)) {
|
|
410
|
+
return (
|
|
411
|
+
<div className="msg system overflow">
|
|
412
|
+
<div className="msg-body">{m.content}</div>
|
|
413
|
+
</div>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
// The approval policy blocked the turn (enterprise-managed Codex downgraded
|
|
417
|
+
// the driver's "never" to an approval-requiring policy that exec mode can't
|
|
418
|
+
// service): same shape as the cases above, with a Retry button — the driver
|
|
419
|
+
// already switched future turns to the compatible "on-request" policy, so
|
|
420
|
+
// resending the failed message is the recovery (see lib/approvalFailure.ts).
|
|
421
|
+
if (m.content.includes(APPROVAL_BLOCKED_NOTICE)) {
|
|
422
|
+
return (
|
|
423
|
+
<div className="msg system overflow">
|
|
424
|
+
<div className="msg-body">
|
|
425
|
+
{m.content}
|
|
426
|
+
{onRetry && (
|
|
427
|
+
<div className="overflow-actions">
|
|
428
|
+
<button className="btn btn-sm" onClick={() => onRetry(m.id)} disabled={running} title="重新发送失败的消息">
|
|
429
|
+
{Icon.bolt()} 重试
|
|
430
|
+
</button>
|
|
431
|
+
</div>
|
|
432
|
+
)}
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
// Notes that already carry their own glyph (✓/ℹ/⇄ — e.g. the "caught up to main"
|
|
438
|
+
// sync note) render quietly; anything else is a warning. The runner's error
|
|
439
|
+
// lines arrive with their own ⚠ already, so only glyph-less content gets one
|
|
440
|
+
// (prepending unconditionally used to render "⚠ ⚠ …").
|
|
441
|
+
const info = /^[✓ℹ⇄]/.test(m.content);
|
|
442
|
+
const glyphed = info || m.content.startsWith("⚠");
|
|
443
|
+
return <div className={`msg system${info ? " info" : ""}`}><div className="msg-body">{glyphed ? m.content : `⚠ ${m.content}`}</div></div>;
|
|
444
|
+
}
|
|
445
|
+
const isUser = m.role === "user";
|
|
446
|
+
// Only user messages carry attachment markers; assistant text passes through.
|
|
447
|
+
const { text, attachments } = isUser ? splitAttachments(withoutBoardContext(m.content)) : { text: m.content, attachments: [] };
|
|
448
|
+
return (
|
|
449
|
+
<div className={`msg ${isUser ? "user" : "assistant"} ${initial ? "initial" : ""}`}>
|
|
450
|
+
{!hideWho && (
|
|
451
|
+
<div className="who">
|
|
452
|
+
<Avatar who={isUser ? "user" : "cc"} agent={agent} />
|
|
453
|
+
{isUser ? userLabel : speakerLabel}
|
|
454
|
+
{initial && <span className="badge">初始提示</span>}
|
|
455
|
+
{m.ts != null && <span className="msg-time">{clockTime(m.ts)}</span>}
|
|
456
|
+
</div>
|
|
457
|
+
)}
|
|
458
|
+
<div className="msg-body">
|
|
459
|
+
{initial && <div className="initial-tag">{Icon.spark()} 已随项目上下文发送</div>}
|
|
460
|
+
{text && (isUser ? <QuotedUserText text={text} /> : <Markdown>{text}</Markdown>)}
|
|
461
|
+
{isUser&&<AutomaticBoardBackground content={m.content}/>}
|
|
462
|
+
<AttachmentStrip items={attachments} />
|
|
463
|
+
</div>
|
|
464
|
+
{onQuote && text.trim() && <div className="msg-actions"><button title="引用这条消息(选中文字可仅引用选中部分)" aria-label="引用消息" onMouseDown={(e) => e.preventDefault()} onClick={(e) => {
|
|
465
|
+
const selection = window.getSelection();
|
|
466
|
+
const body = e.currentTarget.closest(".msg")?.querySelector(".msg-body");
|
|
467
|
+
const selected = selection && body?.contains(selection.anchorNode) && body.contains(selection.focusNode) ? selection.toString().trim() : "";
|
|
468
|
+
onQuote(isUser?{...m,content:withoutBoardContext(m.content)}:m, selected || undefined);
|
|
469
|
+
}}>{Icon.doc()} 引用</button></div>}
|
|
470
|
+
</div>
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
export function SessionBreak({ summary }: { summary: string }) {
|
|
475
|
+
return (
|
|
476
|
+
<div className="sbreak">
|
|
477
|
+
<span className="ln" />
|
|
478
|
+
<div className="card">
|
|
479
|
+
<div className="cl">{Icon.clear()} 上下文已清空 · 摘要已保存</div>
|
|
480
|
+
<div className="ct">{summary}</div>
|
|
481
|
+
</div>
|
|
482
|
+
<span className="ln" />
|
|
483
|
+
</div>
|
|
484
|
+
);
|
|
485
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { Markdown } from "./Markdown";
|
|
5
|
+
import { FileLinkContext, taskFileApiUrl } from "./fileLinks";
|
|
6
|
+
import { loadPersist } from "./persist";
|
|
7
|
+
|
|
8
|
+
// Markdown 预览页:/view?task=<任务 id>&path=<文件路径>
|
|
9
|
+
//
|
|
10
|
+
// 智能体在对话里给出的 .md 报告从这里打开 —— 渲染成和对话里一样的排版,
|
|
11
|
+
// 文中指向其他文件的相对链接 / 图片按这个 md 所在的目录解析。原文和下载
|
|
12
|
+
// 走同一个文件接口(app/api/tasks/[id]/files)。
|
|
13
|
+
export default function ViewFile() {
|
|
14
|
+
const [theme,setTheme]=useState("dark");
|
|
15
|
+
const [q, setQ] = useState<{ task: string; path: string } | null>(null);
|
|
16
|
+
// Where the server actually found the file (it may fall back to another
|
|
17
|
+
// folder): relative links and images in the document resolve from there.
|
|
18
|
+
const [real, setReal] = useState<string | null>(null);
|
|
19
|
+
const [text, setText] = useState<string | null>(null);
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const p = loadPersist();
|
|
24
|
+
setTheme(p.appearance?.theme??"dark");
|
|
25
|
+
if (p.appearance?.theme) document.documentElement.setAttribute("data-theme", p.appearance.theme);
|
|
26
|
+
const sp = new URLSearchParams(window.location.search);
|
|
27
|
+
const task = sp.get("task") ?? "";
|
|
28
|
+
const path = sp.get("path") ?? "";
|
|
29
|
+
if (!task || !path) {
|
|
30
|
+
setError("链接缺少任务或文件路径。");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
setQ({ task, path });
|
|
34
|
+
let alive = true;
|
|
35
|
+
fetch(taskFileApiUrl(task, path))
|
|
36
|
+
.then(async (res) => {
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
const j = await res.json().catch(() => null);
|
|
39
|
+
throw new Error(j?.error || `打开失败(${res.status})`);
|
|
40
|
+
}
|
|
41
|
+
const at = res.headers.get("x-orch-file-path");
|
|
42
|
+
if (at && alive) { try { setReal(decodeURIComponent(at)); } catch { /* keep the asked path */ } }
|
|
43
|
+
return res.text();
|
|
44
|
+
})
|
|
45
|
+
.then((t) => { if (alive) setText(t); })
|
|
46
|
+
.catch((e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); });
|
|
47
|
+
return () => { alive = false; };
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const scope = useMemo(() => {
|
|
51
|
+
if (!q) return null;
|
|
52
|
+
const p = real ?? q.path;
|
|
53
|
+
const cut = p.lastIndexOf("/");
|
|
54
|
+
return { taskId: q.task, baseDir: cut > 0 ? p.slice(0, cut) : cut === 0 ? "/" : "" };
|
|
55
|
+
}, [q, real]);
|
|
56
|
+
const name = q ? q.path.split("/").pop() || q.path : "";
|
|
57
|
+
const raw = q ? taskFileApiUrl(q.task, q.path) : "";
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="cli-ui" data-theme={theme} style={{ position: "fixed", inset: 0, overflow: "auto", background: "var(--bg)", color: "var(--ink)" }}>
|
|
61
|
+
<div style={{ position: "sticky", top: 0, zIndex: 1, display: "flex", alignItems: "center", gap: 10, padding: "10px 20px", borderBottom: "1px solid var(--line)", background: "var(--chrome)" }}>
|
|
62
|
+
<span style={{ fontFamily: "var(--mono)", fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1, minWidth: 0 }} title={q?.path}>{name}</span>
|
|
63
|
+
{q && <a className="btn btn-line btn-sm" href={raw} target="_blank" rel="noreferrer">原文</a>}
|
|
64
|
+
{q && <a className="btn btn-line btn-sm" href={`${raw}${raw.includes("?") ? "&" : "?"}download=1`}>下载</a>}
|
|
65
|
+
</div>
|
|
66
|
+
<div style={{ maxWidth: 880, margin: "0 auto", padding: "24px 20px 64px", fontSize: 14, lineHeight: 1.7 }}>
|
|
67
|
+
{error ? (
|
|
68
|
+
<div style={{ color: "var(--red)" }}>{error}</div>
|
|
69
|
+
) : text === null ? (
|
|
70
|
+
<div style={{ color: "var(--ink-3)" }}>加载中…</div>
|
|
71
|
+
) : (
|
|
72
|
+
<FileLinkContext.Provider value={scope}>
|
|
73
|
+
<Markdown>{text}</Markdown>
|
|
74
|
+
</FileLinkContext.Provider>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|