@spzxg/cli-ui 0.0.0-stage → 0.3.0
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 +163 -2
- package/dist/cli-ui-embed-hutDQhkT.js +37841 -0
- package/dist/cli-ui-index-C8TOk7M3.js +13516 -0
- package/dist/cli-ui.css +1 -0
- package/dist/cli-ui.js +17 -0
- package/package.json +49 -4
- package/src/board/BrowserPane.tsx +418 -0
- package/src/board/ChangesPane.tsx +142 -0
- package/src/board/CliBoard.tsx +246 -0
- package/src/board/FileViewer.tsx +257 -0
- package/src/board/FilesPane.tsx +109 -0
- package/src/board/blocks.tsx +96 -0
- package/src/board/fileKind.ts +81 -0
- package/src/chat/CliChat.tsx +639 -0
- package/src/chat/Composer.tsx +194 -0
- package/src/chat/export.ts +34 -0
- package/src/core/blocks.ts +132 -0
- package/src/core/client.ts +441 -0
- package/src/core/config.ts +222 -0
- package/src/core/conversation.ts +439 -0
- package/src/core/provider.tsx +103 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +575 -0
- package/src/core/types.ts +385 -0
- package/src/elements.tsx +166 -0
- package/src/embed.ts +58 -0
- package/src/index.ts +40 -0
- package/src/msglist/CliMsgList.tsx +342 -0
- package/src/operator.css +1628 -0
- package/src/styles.css +174 -0
- package/src/ui/Markdown.tsx +55 -0
- package/src/ui/TextBlock.tsx +49 -0
- package/src/ui/Transcript.tsx +408 -0
- package/src/ui/fileLinks.ts +46 -0
- package/src/ui/format.ts +101 -0
- package/src/ui/icons.tsx +89 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +94 -0
package/src/styles.css
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/* @spzxg/cli-ui 样式入口:Operator 原样式(作用域在 .cli-ui 内)+ 组件自己的补充。
|
|
2
|
+
用法:import "@spzxg/cli-ui/styles.css";
|
|
3
|
+
字体:沿用 Operator 的 Space Grotesk / JetBrains Mono,宿主页面没加载时退回系统字体。 */
|
|
4
|
+
@import "./operator.css";
|
|
5
|
+
|
|
6
|
+
/* ── 组件根容器:填满宿主给的空间 ── */
|
|
7
|
+
.cli-ui.cli-msglist,
|
|
8
|
+
.cli-ui.cli-chat{display:flex;flex-direction:column;min-height:0;min-width:0;height:100%;overflow:hidden;}
|
|
9
|
+
.cli-ui.cli-msglist .col-tasks{flex:1 1 auto;min-height:0;border-right:none;}
|
|
10
|
+
.cli-ui.cli-chat .session{height:100%;}
|
|
11
|
+
.cli-ui .task .tdesc{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
|
|
12
|
+
|
|
13
|
+
/* 新对话的空白页:放在转录区里,不撑满高度 */
|
|
14
|
+
.cli-ui .cli-hero{flex:none;padding:60px 20px 20px;}
|
|
15
|
+
|
|
16
|
+
/* ── 文本块(文件 / 长粘贴):输入框和转录共用 ── */
|
|
17
|
+
.cli-ui .cli-blocks{display:flex;flex-direction:column;gap:8px;padding:10px 12px 0;max-height:46vh;overflow-y:auto;}
|
|
18
|
+
.cli-ui .cli-blocks .attach-chip.file{max-width:100%;align-self:flex-start;}
|
|
19
|
+
.cli-ui .cli-blocks .attach-chip.file .attach-badge{position:static;margin-left:8px;}
|
|
20
|
+
.cli-ui .cli-block{border:1px solid var(--line-strong);border-radius:var(--r);background:var(--surface-2);overflow:hidden;}
|
|
21
|
+
.cli-ui .msg-body .cli-block{margin:6px 0;background:var(--surface);}
|
|
22
|
+
.cli-ui .cli-block-h{display:flex;align-items:center;gap:8px;padding:7px 8px 7px 10px;font-size:12.5px;}
|
|
23
|
+
.cli-ui .cli-block-ic{display:inline-flex;color:var(--ink-3);}
|
|
24
|
+
.cli-ui .cli-block-ic svg{width:14px;height:14px;}
|
|
25
|
+
.cli-ui .cli-block-name{font-weight:600;color:var(--ink);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
26
|
+
.cli-ui .cli-block-meta{font-family:var(--mono);font-size:11px;color:var(--ink-4);white-space:nowrap;}
|
|
27
|
+
.cli-ui .cli-block-sp{flex:1;}
|
|
28
|
+
.cli-ui .cli-block-btn{font-size:12px;color:var(--accent);padding:2px 8px;border-radius:6px;}
|
|
29
|
+
.cli-ui .cli-block-btn:hover{background:var(--accent-soft);}
|
|
30
|
+
.cli-ui .cli-block-x{display:inline-grid;place-items:center;width:22px;height:22px;border-radius:6px;color:var(--ink-3);}
|
|
31
|
+
.cli-ui .cli-block-x:hover{background:var(--row-sel);color:var(--ink);}
|
|
32
|
+
.cli-ui .cli-block-x svg{width:13px;height:13px;}
|
|
33
|
+
.cli-ui .cli-block-body{margin:0;padding:8px 12px 10px;border-top:1px solid var(--line-2);font-family:var(--mono);font-size:12px;line-height:1.55;color:var(--ink-2);white-space:pre-wrap;word-break:break-word;max-height:calc(4.65em + 18px);overflow:hidden;}
|
|
34
|
+
.cli-ui .cli-block.open .cli-block-body{max-height:360px;overflow-y:auto;}
|
|
35
|
+
.cli-ui .cli-block-more{display:block;width:100%;padding:6px;font-size:12px;color:var(--accent);border-top:1px solid var(--line-2);}
|
|
36
|
+
.cli-ui .cli-block-more:hover{background:var(--accent-soft);}
|
|
37
|
+
|
|
38
|
+
/* ── 很长的消息正文:折叠显示开头 ── */
|
|
39
|
+
.cli-ui .cli-long-pre{white-space:pre-wrap;word-break:break-word;font-size:inherit;font-family:inherit;margin:0;}
|
|
40
|
+
.cli-ui .cli-long-pre.clamp{max-height:10.5em;overflow:hidden;-webkit-mask-image:linear-gradient(#000 60%,transparent);mask-image:linear-gradient(#000 60%,transparent);}
|
|
41
|
+
.cli-ui .cli-long-btn{margin-top:6px;font-size:12px;color:var(--accent);padding:2px 0;}
|
|
42
|
+
.cli-ui .cli-long-btn:hover{text-decoration:underline;}
|
|
43
|
+
|
|
44
|
+
/* ── 输入框补充 ── */
|
|
45
|
+
.cli-ui .cli-comp-err{margin:0 0 8px;padding:7px 11px;border-radius:var(--r-sm);font-size:12.5px;color:var(--red);background:var(--red-soft);}
|
|
46
|
+
.cli-ui .cli-count{font-family:var(--mono);}
|
|
47
|
+
.cli-ui .cli-count.over{color:var(--red);}
|
|
48
|
+
|
|
49
|
+
/* 排队气泡下的等待原因(中枢给的:节点连不上、没有可用节点……) */
|
|
50
|
+
.cli-ui .cli-queued-note{margin-top:6px;font-size:12px;color:var(--amber);}
|
|
51
|
+
|
|
52
|
+
/* Agent 消息里打不开的本地文件链接:显示成文字(路径在悬停提示里) */
|
|
53
|
+
.cli-ui .md .cli-local-link{color:var(--ink-2);border-bottom:1px dashed var(--line-strong);cursor:help;}
|
|
54
|
+
|
|
55
|
+
/* ── cli-board:右侧看板(Operator 右栏的样子 + 文件浏览 / 预览) ── */
|
|
56
|
+
.cli-ui.cli-file-preview,
|
|
57
|
+
.cli-ui.cli-browser-view{display:flex;flex-direction:column;min-height:0;min-width:0;height:100%;overflow:hidden;background:var(--panel);}
|
|
58
|
+
.cli-ui.cli-browser-view .rail-pad{flex:1;}
|
|
59
|
+
.cli-ui.cli-board{display:flex;flex-direction:column;min-height:0;min-width:0;height:100%;overflow:hidden;border-left:1px solid var(--line);}
|
|
60
|
+
.cli-ui.cli-board .rail-scroll{overflow:hidden;}
|
|
61
|
+
.cli-ui.cli-board.collapsed{background:var(--bg-2);}
|
|
62
|
+
.cli-ui .cli-board-strip{display:flex;flex-direction:column;align-items:center;gap:12px;width:100%;height:100%;padding:14px 0;color:var(--ink-3);}
|
|
63
|
+
.cli-ui .cli-board-strip:hover{background:var(--row-sel);color:var(--ink);}
|
|
64
|
+
.cli-ui .cli-board-strip svg{width:14px;height:14px;transform:rotate(180deg);}
|
|
65
|
+
.cli-ui .cli-board-strip-t{writing-mode:vertical-rl;font-family:var(--mono);font-size:11px;letter-spacing:.1em;}
|
|
66
|
+
.cli-ui.cli-board .tc-note{padding:18px 16px;font-size:13px;color:var(--ink-3);line-height:1.6;}
|
|
67
|
+
.cli-ui .cli-sync-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--ink-4);margin-right:5px;vertical-align:middle;}
|
|
68
|
+
.cli-ui .cli-sync-dot.on{background:var(--green);animation:cli-pulse 1.6s ease-in-out infinite;}
|
|
69
|
+
@keyframes cli-pulse{50%{opacity:.35;}}
|
|
70
|
+
.cli-ui .cli-tc-open{font-size:11px;color:var(--accent);padding:1px 6px;border-radius:5px;margin-left:6px;cursor:pointer;}
|
|
71
|
+
.cli-ui .cli-tc-open:hover{background:var(--accent-soft);}
|
|
72
|
+
|
|
73
|
+
.cli-ui .cli-files{display:flex;flex-direction:column;min-height:0;flex:1;}
|
|
74
|
+
.cli-ui .cli-crumbs{display:flex;align-items:center;flex-wrap:wrap;gap:2px;padding:10px 12px;border-bottom:1px solid var(--line-2);font-size:12.5px;}
|
|
75
|
+
.cli-ui .cli-crumb{display:inline-flex;align-items:center;gap:5px;padding:3px 6px;border-radius:6px;color:var(--ink-3);}
|
|
76
|
+
.cli-ui .cli-crumb svg{width:13px;height:13px;}
|
|
77
|
+
.cli-ui .cli-crumb.on{color:var(--ink);font-weight:600;}
|
|
78
|
+
.cli-ui .cli-crumb:hover{background:var(--row-sel);}
|
|
79
|
+
.cli-ui .cli-crumb-sep{color:var(--ink-4);}
|
|
80
|
+
.cli-ui .cli-files-sync{font-family:var(--mono);font-size:10.5px;color:var(--ink-4);}
|
|
81
|
+
.cli-ui .cli-files-list{flex:1;min-height:0;overflow-y:auto;padding:4px 0;}
|
|
82
|
+
.cli-ui .cli-frow{display:flex;align-items:center;width:100%;}
|
|
83
|
+
.cli-ui .cli-frow:hover{background:var(--row-sel);}
|
|
84
|
+
.cli-ui .cli-frow.fresh .cli-frow-name{color:var(--accent-ink);}
|
|
85
|
+
.cli-ui button.cli-frow,.cli-ui .cli-frow-main{display:flex;align-items:center;gap:8px;flex:1;min-width:0;padding:7px 12px;font-size:13px;text-align:left;color:var(--ink);}
|
|
86
|
+
.cli-ui .cli-frow-ic{display:inline-flex;color:var(--ink-3);}
|
|
87
|
+
.cli-ui .cli-frow.dir .cli-frow-ic{color:var(--accent);}
|
|
88
|
+
.cli-ui .cli-frow-ic svg{width:14px;height:14px;}
|
|
89
|
+
.cli-ui .cli-frow-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
90
|
+
.cli-ui .cli-frow-new{font-size:10.5px;color:var(--accent);background:var(--accent-soft);padding:1px 6px;border-radius:9px;}
|
|
91
|
+
.cli-ui .cli-frow-meta{font-family:var(--mono);font-size:10.5px;color:var(--ink-4);white-space:nowrap;}
|
|
92
|
+
.cli-ui .cli-frow-dl{display:inline-grid;place-items:center;width:28px;height:28px;margin-right:6px;border-radius:6px;color:var(--ink-3);opacity:0;}
|
|
93
|
+
.cli-ui .cli-frow:hover .cli-frow-dl,.cli-ui .cli-frow-dl:focus-visible{opacity:1;}
|
|
94
|
+
.cli-ui .cli-frow-dl:hover{background:var(--surface-2);color:var(--ink);}
|
|
95
|
+
.cli-ui .cli-frow-dl svg{width:14px;height:14px;}
|
|
96
|
+
@media (hover:none){.cli-ui .cli-frow-dl{opacity:1;}}
|
|
97
|
+
|
|
98
|
+
.cli-ui .cli-fv{display:flex;flex-direction:column;min-height:0;flex:1;background:var(--panel);}
|
|
99
|
+
.cli-ui .cli-fv-h{display:flex;align-items:center;gap:6px;padding:8px 10px;border-bottom:1px solid var(--line-2);font-size:12.5px;min-width:0;}
|
|
100
|
+
.cli-ui .cli-fv-ic{display:inline-flex;color:var(--ink-3);}
|
|
101
|
+
.cli-ui .cli-fv-ic svg{width:14px;height:14px;}
|
|
102
|
+
.cli-ui .cli-fv-name{font-weight:600;color:var(--ink);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
103
|
+
.cli-ui .cli-fv-meta{font-family:var(--mono);font-size:10.5px;color:var(--ink-4);white-space:nowrap;}
|
|
104
|
+
.cli-ui .cli-fv-sp{flex:1;}
|
|
105
|
+
.cli-ui .cli-fv-back{font-size:18px;line-height:1;width:26px;height:26px;border-radius:6px;color:var(--ink-3);}
|
|
106
|
+
.cli-ui .cli-fv-back:hover{background:var(--row-sel);color:var(--ink);}
|
|
107
|
+
.cli-ui .cli-fv-btn{font-size:12px;color:var(--accent);padding:3px 8px;border-radius:6px;white-space:nowrap;}
|
|
108
|
+
.cli-ui .cli-fv-btn:hover{background:var(--accent-soft);}
|
|
109
|
+
.cli-ui .cli-fv-icon{display:inline-grid;place-items:center;width:26px;height:26px;border-radius:6px;color:var(--ink-3);flex:none;}
|
|
110
|
+
.cli-ui .cli-fv-icon:hover:not(:disabled){background:var(--surface-2);color:var(--ink);}
|
|
111
|
+
.cli-ui .cli-fv-icon:disabled{opacity:.4;}
|
|
112
|
+
.cli-ui .cli-fv-icon svg{width:14px;height:14px;}
|
|
113
|
+
.cli-ui .cli-fv-b{flex:1;min-height:0;overflow:auto;}
|
|
114
|
+
.cli-ui .cli-fv-b.k-pdf,.cli-ui .cli-fv-b.k-html,.cli-ui .cli-fv-b.k-docx{overflow:hidden;display:flex;}
|
|
115
|
+
.cli-ui .cli-fv-frame{flex:1;width:100%;height:100%;min-height:360px;border:0;background:#fff;}
|
|
116
|
+
.cli-ui .cli-fv-md{padding:16px 18px;}
|
|
117
|
+
.cli-ui .cli-fv-code .md pre{margin:0;border:0;border-radius:0;min-height:100%;}
|
|
118
|
+
.cli-ui .cli-fv-pre{margin:0;padding:12px 14px;font-family:var(--mono);font-size:12px;line-height:1.55;color:var(--ink);white-space:pre-wrap;word-break:break-word;}
|
|
119
|
+
.cli-ui .cli-fv-img{display:grid;place-items:center;padding:16px;min-height:100%;background:repeating-conic-gradient(var(--surface-2) 0 25%,transparent 0 50%) 0 0/16px 16px;}
|
|
120
|
+
.cli-ui .cli-fv-img img{max-width:100%;height:auto;box-shadow:var(--sh-1);}
|
|
121
|
+
.cli-ui .cli-fv-csv{overflow:auto;}
|
|
122
|
+
.cli-ui .cli-fv-csv table{border-collapse:collapse;font-size:12px;font-family:var(--mono);}
|
|
123
|
+
.cli-ui .cli-fv-csv th,.cli-ui .cli-fv-csv td{border:1px solid var(--line-2);padding:4px 8px;white-space:nowrap;text-align:left;}
|
|
124
|
+
.cli-ui .cli-fv-csv th{position:sticky;top:0;background:var(--surface-2);font-weight:600;}
|
|
125
|
+
.cli-ui .cli-fv-note{padding:18px 16px;font-size:13px;color:var(--ink-3);line-height:1.7;}
|
|
126
|
+
.cli-ui .cli-fv-note.err{color:var(--red);}
|
|
127
|
+
.cli-ui .cli-fv-trunc{padding:6px 14px;font-size:12px;color:var(--amber);background:var(--surface-2);border-bottom:1px solid var(--line-2);}
|
|
128
|
+
|
|
129
|
+
/* 对话里弹窗打开的文件 / 浏览器 */
|
|
130
|
+
.cli-ui .modal.cli-popup{width:min(960px,calc(100vw - 40px));height:min(82vh,900px);display:flex;flex-direction:column;overflow:hidden;}
|
|
131
|
+
.cli-ui .modal.cli-popup.browser .rail-pad{flex:1;}
|
|
132
|
+
.cli-ui .md a[href="#"]{cursor:pointer;}
|
|
133
|
+
|
|
134
|
+
/* ── 浏览器实时画面(canvas):按比例适应容器;接管时高亮 ── */
|
|
135
|
+
.cli-ui .bp-screen{display:flex;align-items:center;justify-content:center;}
|
|
136
|
+
/* 宽高都用 auto + max-*:替换元素按自身比例缩小,点击坐标和显示区域一致。 */
|
|
137
|
+
.cli-ui .bp-frame{display:block;max-width:100%;max-height:var(--cli-browser-max-h,70vh);width:auto;height:auto;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;}
|
|
138
|
+
.cli-ui .bp-frame.live{cursor:pointer;outline:2px solid var(--coral);outline-offset:-2px;}
|
|
139
|
+
/* 键盘 / 输入法接收框:不放到屏幕外(focus 时会把宿主的滚动容器拖过去),透明盖在左上角,不挡点击。 */
|
|
140
|
+
.cli-ui .bp-keys{position:absolute;left:0;top:0;width:1px;height:1px;opacity:0;pointer-events:none;resize:none;border:0;padding:0;}
|
|
141
|
+
|
|
142
|
+
/* ── 窄容器(手机、很窄的侧栏):预览工具栏换行,次要信息收起 ── */
|
|
143
|
+
.cli-ui .cli-fv{container-type:inline-size;}
|
|
144
|
+
.cli-ui .cli-fv-h{flex-wrap:wrap;row-gap:4px;}
|
|
145
|
+
@container (max-width:380px){
|
|
146
|
+
.cli-ui .cli-fv-meta{display:none;}
|
|
147
|
+
.cli-ui .cli-fv-btn{padding:3px 6px;}
|
|
148
|
+
}
|
|
149
|
+
.cli-ui.cli-board{container-type:inline-size;}
|
|
150
|
+
@container (max-width:340px){
|
|
151
|
+
.cli-ui .cli-frow-meta{display:none;}
|
|
152
|
+
.cli-ui .rail-tab{padding:12px 9px 10px;}
|
|
153
|
+
}
|
|
154
|
+
.cli-ui .scrim{z-index:var(--cli-popup-z,200);}
|
|
155
|
+
|
|
156
|
+
/* ── 对话列表:一个任务里的几个对话 ── */
|
|
157
|
+
.cli-ui .cli-job-card{position:relative;margin-bottom:8px;}
|
|
158
|
+
.cli-ui .cli-job-card .task{width:100%;margin-bottom:0;}
|
|
159
|
+
.cli-ui .cli-job-card .task .task-foot{padding-right:26px;}
|
|
160
|
+
.cli-ui .cli-new-in-job{position:absolute;right:8px;bottom:8px;width:22px;height:22px;opacity:0;transition:opacity .12s;}
|
|
161
|
+
.cli-ui .cli-job-card:hover .cli-new-in-job,
|
|
162
|
+
.cli-ui .cli-new-in-job:focus-visible{opacity:1;}
|
|
163
|
+
@media (hover:none){.cli-ui .cli-new-in-job{opacity:1;}}
|
|
164
|
+
.cli-ui .cli-job-group{margin-bottom:8px;}
|
|
165
|
+
.cli-ui .cli-job-group .cli-job-card,
|
|
166
|
+
.cli-ui .cli-job-group .task{margin-bottom:0;}
|
|
167
|
+
.cli-ui .cli-conv-rows{display:flex;flex-direction:column;gap:2px;margin:4px 0 0 14px;padding-left:8px;border-left:1px solid var(--line);}
|
|
168
|
+
.cli-ui .cli-conv-row{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:1px solid transparent;border-radius:var(--r);background:none;color:inherit;text-align:left;cursor:pointer;font-size:12.5px;}
|
|
169
|
+
.cli-ui div.cli-conv-row{cursor:default;color:var(--ink-3);}
|
|
170
|
+
.cli-ui .cli-conv-row:hover{background:var(--surface-2);}
|
|
171
|
+
.cli-ui .cli-conv-row.sel{border-color:var(--accent);background:var(--surface);}
|
|
172
|
+
.cli-ui .cli-conv-row.awaiting{box-shadow:inset 2px 0 0 var(--blue);}
|
|
173
|
+
.cli-ui .cli-conv-title{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
174
|
+
.cli-ui .cli-conv-act{font-family:var(--mono);font-size:10px;color:var(--ink-3);white-space:nowrap;}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { memo, useContext, type ComponentProps, type MouseEvent } from "react";
|
|
2
|
+
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
import rehypeHighlight from "rehype-highlight";
|
|
5
|
+
import { FileLinkContext, FileOpenContext, localFilePath } from "./fileLinks.js";
|
|
6
|
+
|
|
7
|
+
// 渲染 Agent 的 markdown 输出:标题、列表、表格、带语言的代码块(高亮)、行内代码、链接。
|
|
8
|
+
// 与 Operator 的 Markdown.tsx 一致:只高亮写明语言的代码块(自动识别在流式输出时很耗),
|
|
9
|
+
// 并 memo,文字没变的消息重渲染时跳过整个解析。
|
|
10
|
+
// 本地文件链接按 Operator 的规则识别:有地址(FileLinkContext)就改写过去,没有就显示成不可点的文字。
|
|
11
|
+
function MdLink({ href, children, ...props }: ComponentProps<"a">) {
|
|
12
|
+
const resolve = useContext(FileLinkContext);
|
|
13
|
+
const open = useContext(FileOpenContext);
|
|
14
|
+
const local = localFilePath(href);
|
|
15
|
+
if (local) {
|
|
16
|
+
const url = resolve?.(local, "link") ?? null;
|
|
17
|
+
if (!url && !open) return <span className="cli-local-link" title={`本地文件:${local}`}>{children}</span>;
|
|
18
|
+
const onClick = (e: MouseEvent<HTMLAnchorElement>) => {
|
|
19
|
+
if (!open || (url && (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0))) return;
|
|
20
|
+
if (open(local) || !url) e.preventDefault();
|
|
21
|
+
};
|
|
22
|
+
return <a {...props} href={url ?? "#"} target="_blank" rel="noreferrer" title={`打开文件:${local}`} onClick={onClick}>{children}</a>;
|
|
23
|
+
}
|
|
24
|
+
return <a {...props} href={href} target="_blank" rel="noreferrer">{children}</a>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function MdImg({ src, alt, ...props }: ComponentProps<"img">) {
|
|
28
|
+
const resolve = useContext(FileLinkContext);
|
|
29
|
+
const local = typeof src === "string" ? localFilePath(src) : null;
|
|
30
|
+
if (local) {
|
|
31
|
+
const url = resolve?.(local, "image") ?? null;
|
|
32
|
+
if (!url) return <span className="cli-local-link" title={`本地文件:${local}`}>{alt || local}</span>;
|
|
33
|
+
// eslint-disable-next-line jsx-a11y/alt-text
|
|
34
|
+
return <img {...props} alt={alt ?? ""} src={url} />;
|
|
35
|
+
}
|
|
36
|
+
return <img {...props} alt={alt ?? ""} src={src} />;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 缺省的地址处理会把 file: 等非网页协议清空;放行 file: 和 Windows 路径,交给上面按本地文件处理(与 Operator 一致)。
|
|
40
|
+
const urlTransform = (url: string) => (/^file:/i.test(url) || /^[a-z]:[\\/]/i.test(url) ? url : defaultUrlTransform(url));
|
|
41
|
+
|
|
42
|
+
export const Markdown = memo(function Markdown({ children }: { children: string }) {
|
|
43
|
+
return (
|
|
44
|
+
<div className="md">
|
|
45
|
+
<ReactMarkdown
|
|
46
|
+
remarkPlugins={[remarkGfm]}
|
|
47
|
+
rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
|
|
48
|
+
components={{ a: MdLink, img: MdImg }}
|
|
49
|
+
urlTransform={urlTransform}
|
|
50
|
+
>
|
|
51
|
+
{children}
|
|
52
|
+
</ReactMarkdown>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { memo, useState } from "react";
|
|
2
|
+
import { charCount, type TextBlock } from "../core/blocks.js";
|
|
3
|
+
import { fmtCount } from "./format.js";
|
|
4
|
+
import { Icon } from "./icons.js";
|
|
5
|
+
|
|
6
|
+
/** 展开后一次最多渲染这么多字;更长的再点「显示全部」,避免一次塞几十万字进 DOM。 */
|
|
7
|
+
const EXPANDED_STEP = 20_000;
|
|
8
|
+
const PREVIEW_LINES = 3;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 文本块卡片:输入框里待发送的文件 / 长粘贴,和转录里已发出的附件,用同一个样子。
|
|
12
|
+
* 默认只显示开头几行,点开才显示正文(分段渲染),长内容不会把页面卡住。
|
|
13
|
+
*/
|
|
14
|
+
export const TextBlockCard = memo(function TextBlockCard({ block, onRemove }: { block: TextBlock; onRemove?: () => void }) {
|
|
15
|
+
const [open, setOpen] = useState(false);
|
|
16
|
+
const [limit, setLimit] = useState(EXPANDED_STEP);
|
|
17
|
+
const chars = charCount(block.text);
|
|
18
|
+
const lines = block.text ? block.text.split("\n").length : 0;
|
|
19
|
+
const preview = block.text.split("\n", PREVIEW_LINES + 1).slice(0, PREVIEW_LINES).join("\n").slice(0, 400);
|
|
20
|
+
const shown = open ? block.text.slice(0, limit) : preview;
|
|
21
|
+
const rest = block.text.length - limit;
|
|
22
|
+
return (
|
|
23
|
+
<div className={`cli-block ${open ? "open" : ""}`}>
|
|
24
|
+
<div className="cli-block-h">
|
|
25
|
+
<span className="cli-block-ic">{block.kind === "file" ? Icon.doc() : Icon.clip()}</span>
|
|
26
|
+
<span className="cli-block-name" title={block.name}>{block.name}</span>
|
|
27
|
+
<span className="cli-block-meta">{fmtCount(chars)} 字 · {fmtCount(lines)} 行</span>
|
|
28
|
+
<span className="cli-block-sp" />
|
|
29
|
+
<button type="button" className="cli-block-btn" onClick={() => { setOpen((o) => !o); setLimit(EXPANDED_STEP); }}>
|
|
30
|
+
{open ? "收起" : "展开"}
|
|
31
|
+
</button>
|
|
32
|
+
{onRemove && <button type="button" className="cli-block-x" title="移除" aria-label={`移除 ${block.name}`} onClick={onRemove}>{Icon.x()}</button>}
|
|
33
|
+
</div>
|
|
34
|
+
<pre className="cli-block-body">{shown}{!open && block.text.length > preview.length ? "…" : ""}</pre>
|
|
35
|
+
{open && rest > 0 && (
|
|
36
|
+
<button type="button" className="cli-block-more" onClick={() => setLimit((n) => n + EXPANDED_STEP * 5)}>
|
|
37
|
+
还有 {fmtCount(rest)} 字,继续显示
|
|
38
|
+
</button>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** 很长的普通文字(没走附件格式、直接打进来的):折叠显示开头,点开看全部。 */
|
|
45
|
+
export const LONG_MESSAGE_CHARS = 3_000;
|
|
46
|
+
export const LONG_MESSAGE_LINES = 40;
|
|
47
|
+
export function isLongMessage(text: string) {
|
|
48
|
+
return text.length > LONG_MESSAGE_CHARS || text.split("\n", LONG_MESSAGE_LINES + 1).length > LONG_MESSAGE_LINES;
|
|
49
|
+
}
|