ompchamber 1.1.0 → 1.2.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/dist/client/index.html +1 -1
- package/dist/client/static/css/index.b938f4a693.css +2 -0
- package/dist/client/static/js/77.8022a800b5.js +139 -0
- package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
- package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
- package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
- package/dist/client/static/js/async/66.86544ad741.js +93 -0
- package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
- package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
- package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
- package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
- package/dist/client/static/js/async/e.6443e739a2.js +79 -0
- package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
- package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
- package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
- package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
- package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
- package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
- package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
- package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
- package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
- package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
- package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
- package/dist/client/static/js/async/{5b.a5ccbc19dc.js → q.b453193538.js} +10 -10
- package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
- package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
- package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
- package/dist/client/static/js/async/vt.20406a5604.js +1 -0
- package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
- package/dist/client/static/js/async/yx.af2936c368.js +10 -0
- package/dist/client/static/js/index.57b39ffa7f.js +34 -0
- package/package.json +1 -1
- package/src/client/components/common/file-icon/index.tsx +4 -1
- package/src/client/components/common/image-viewer/index.tsx +224 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
- package/src/client/components/settings/Modal.tsx +1 -0
- package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
- package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
- package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
- package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
- package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
- package/src/client/components/workspace/btw-panel/index.tsx +83 -0
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +37 -21
- package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +44 -40
- package/src/client/components/workspace/chat-timeline/index.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
- package/src/client/components/workspace/editor/index.tsx +45 -36
- package/src/client/hooks/chat/btw/index.ts +180 -0
- package/src/client/hooks/chat/btw/intercept.ts +43 -0
- package/src/client/hooks/chat/btw/mode.ts +179 -0
- package/src/client/hooks/chat/composer/attachments.ts +198 -0
- package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
- package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
- package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
- package/src/client/hooks/chat/composer/drop-references.ts +106 -0
- package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
- package/src/client/hooks/chat/composer/file-drop.ts +226 -0
- package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
- package/src/client/hooks/chat/composer/file-reads.ts +238 -0
- package/src/client/hooks/chat/composer/index.ts +7 -1
- package/src/client/hooks/chat/composer/pipeline.ts +181 -0
- package/src/client/hooks/chat/omp/index.ts +3 -3
- package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
- package/src/client/hooks/chat/timeline/actions.ts +16 -1
- package/src/client/hooks/chat/timeline/index.ts +16 -8
- package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
- package/src/client/hooks/chat/timeline/send.ts +29 -65
- package/src/client/hooks/chat/timeline/session-load.ts +2 -1
- package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
- package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
- package/src/client/hooks/editor/file-actions.ts +49 -0
- package/src/client/hooks/editor/use-file-editor.ts +38 -27
- package/src/client/hooks/editor/use-file-transfer.ts +78 -0
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.ts +21 -287
- package/src/server/lib/btw/frames.test.ts +68 -0
- package/src/server/lib/btw/frames.ts +56 -0
- package/src/server/lib/btw/prompt.ts +28 -0
- package/src/server/lib/btw/registry.server.ts +141 -0
- package/src/server/lib/btw/runtime.server.ts +324 -0
- package/src/server/lib/btw/schema.server.ts +44 -0
- package/src/server/lib/btw/service.server.ts +171 -0
- package/src/server/lib/btw/session-copy.server.ts +213 -0
- package/src/server/lib/btw/session-copy.test.ts +166 -0
- package/src/server/lib/btw/store.server.ts +240 -0
- package/src/server/lib/db/schema.ts +110 -0
- package/src/server/lib/db/seed.ts +160 -0
- package/src/server/lib/db/workspace-sync.ts +70 -0
- package/src/server/lib/fs/root.ts +37 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
- package/src/server/lib/omp/rpc/manager.ts +27 -123
- package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
- package/src/server/lib/omp/session/auto-title.server.ts +152 -0
- package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
- package/src/server/lib/omp/session/blobs.server.ts +76 -0
- package/src/server/lib/omp/session/file-scan.ts +174 -0
- package/src/server/lib/omp/session/files.test.ts +147 -0
- package/src/server/lib/omp/session/files.ts +52 -35
- package/src/server/lib/omp/session/locator.ts +2 -2
- package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
- package/src/server/lib/omp/session/merge-stored.ts +171 -0
- package/src/server/lib/omp/session/messages.ts +34 -0
- package/src/server/lib/omp/session/reader.ts +2 -2
- package/src/server/lib/omp/session/subagent-presence.ts +9 -6
- package/src/server/lib/queue/delivery.server.test.ts +57 -0
- package/src/server/lib/queue/delivery.server.ts +25 -3
- package/src/server/plugins/ssr.ts +29 -2
- package/src/server/routes/btw/events.ts +70 -0
- package/src/server/routes/btw/index.ts +8 -0
- package/src/server/routes/btw/topics.ts +87 -0
- package/src/server/routes/btw/ws.ts +136 -0
- package/src/server/routes/chat/index.ts +2 -0
- package/src/server/routes/chat/session.ts +1 -115
- package/src/server/routes/chat/turns.ts +87 -0
- package/src/server/routes/fs/index.ts +4 -1
- package/src/server/routes/fs/raw.ts +85 -0
- package/src/server/routes/fs/read.ts +58 -3
- package/src/server/routes/index.ts +7 -1
- package/src/shared/lib/chat/attachments.test.ts +208 -0
- package/src/shared/lib/chat/attachments.ts +200 -15
- package/src/shared/lib/chat/btw/client.ts +94 -0
- package/src/shared/lib/chat/composer/client.test.ts +48 -0
- package/src/shared/lib/chat/composer/client.ts +6 -1
- package/src/shared/lib/chat/composer/composer.test.ts +39 -1
- package/src/shared/lib/chat/composer/trigger.ts +45 -1
- package/src/shared/lib/chat/omp/agent-events.ts +33 -80
- package/src/shared/lib/chat/omp/socket.ts +11 -6
- package/src/shared/lib/chat/omp/sse.ts +11 -6
- package/src/shared/lib/chat/omp/tool-results.ts +102 -0
- package/src/shared/lib/chat/omp/transport.ts +19 -3
- package/src/shared/lib/chat/text-file.ts +148 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
- package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
- package/src/shared/lib/chat/timeline/turns.ts +7 -0
- package/src/shared/lib/fs/file-kind.ts +50 -0
- package/src/shared/lib/fs/paths.ts +19 -1
- package/src/shared/lib/markdown/math.test.ts +20 -3
- package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
- package/src/shared/lib/omp/session/mapper.ts +24 -4
- package/src/shared/lib/omp/session/messages-map.ts +16 -3
- package/src/shared/lib/omp/session/messages-parse.ts +2 -2
- package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
- package/src/shared/types/btw.ts +65 -0
- package/src/shared/types/chat.ts +39 -1
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/omp/agent.ts +14 -1
- package/src/shared/types/omp/session.ts +4 -1
- package/src/shared/types/settings/state.ts +5 -0
- package/dist/client/static/css/index.b859cc1cc5.css +0 -2
- package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
- package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
- package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
- package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
- package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
- package/dist/client/static/js/async/e.93214d622b.js +0 -79
- package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
- package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
- package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
- package/dist/client/static/js/async/jl.874d352278.js +0 -1
- package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
- package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
- package/dist/client/static/js/async/q.23af2a358f.js +0 -21
- package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
- package/dist/client/static/js/async/s.222ee974f6.js +0 -2
- package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
- package/dist/client/static/js/async/vt.42416b707d.js +0 -1
- package/dist/client/static/js/async/x1.da8687159b.js +0 -90
- package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
- package/dist/client/static/js/index.6e065017eb.js +0 -33
- package/dist/client/static/js/jp.50817896f3.js +0 -139
- /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{5b.a5ccbc19dc.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.6e065017eb.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The ask card — the top card of the side-question form.
|
|
8
|
+
*
|
|
9
|
+
* It is the form's own entry, not the chat's: the question typed here is always
|
|
10
|
+
* a question of its own (a new topic), which is why it carries its own `⟳`
|
|
11
|
+
* (start over) and `✕` (leave the form). The topic history and promotion ride
|
|
12
|
+
* along here because the form replaced the composer that used to hold them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
16
|
+
import type { TargetedKeyboardEvent } from 'preact';
|
|
17
|
+
import { ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-preact';
|
|
18
|
+
import type { BtwTopic } from '@/shared/types';
|
|
19
|
+
import { BtwTopicMenu } from '@/client/components/workspace/btw-panel/TopicMenu';
|
|
20
|
+
|
|
21
|
+
export interface BtwAskCardProps {
|
|
22
|
+
value: string;
|
|
23
|
+
onChange: (value: string) => void;
|
|
24
|
+
onSubmit: () => void;
|
|
25
|
+
/** `⟳` — clear the question and fall back to the newest topic. */
|
|
26
|
+
onNewQuestion: () => void;
|
|
27
|
+
/** `✕` — leave the side-question form. */
|
|
28
|
+
onClose: () => void;
|
|
29
|
+
/** A side answer is streaming: starting over waits for it. */
|
|
30
|
+
running: boolean;
|
|
31
|
+
topics: BtwTopic[];
|
|
32
|
+
activeTopicId: string | null;
|
|
33
|
+
onSelectTopic: (id: string) => void;
|
|
34
|
+
onDeleteTopic: (id: string) => void;
|
|
35
|
+
canPromote: boolean;
|
|
36
|
+
onPromote: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const GHOST_BUTTON =
|
|
40
|
+
'p-1.5 rounded text-ink/45 hover:text-ink hover:bg-ink/5 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-ink/45';
|
|
41
|
+
|
|
42
|
+
export function BtwAskCard({
|
|
43
|
+
value,
|
|
44
|
+
onChange,
|
|
45
|
+
onSubmit,
|
|
46
|
+
onNewQuestion,
|
|
47
|
+
onClose,
|
|
48
|
+
running,
|
|
49
|
+
topics,
|
|
50
|
+
activeTopicId,
|
|
51
|
+
onSelectTopic,
|
|
52
|
+
onDeleteTopic,
|
|
53
|
+
canPromote,
|
|
54
|
+
onPromote,
|
|
55
|
+
}: BtwAskCardProps) {
|
|
56
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
57
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
58
|
+
|
|
59
|
+
// Grow with the text and collapse again once it is submitted or cleared.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const el = textareaRef.current;
|
|
62
|
+
if (!el) return;
|
|
63
|
+
el.style.height = 'auto';
|
|
64
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
65
|
+
}, [value]);
|
|
66
|
+
|
|
67
|
+
const handleKeyDown = (event: TargetedKeyboardEvent<HTMLTextAreaElement>) => {
|
|
68
|
+
if (event.key === 'Escape') {
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
onClose();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (event.key !== 'Enter' || event.shiftKey) return;
|
|
74
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
onSubmit();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="bg-paper border border-ink/20 rounded-2xl shadow-sm flex items-center gap-1 pl-1.5 pr-2 py-1 focus-within:border-ink transition-colors">
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={() => {
|
|
84
|
+
onNewQuestion();
|
|
85
|
+
// The button took the focus; asking again means typing again.
|
|
86
|
+
textareaRef.current?.focus();
|
|
87
|
+
}}
|
|
88
|
+
disabled={running}
|
|
89
|
+
className={GHOST_BUTTON}
|
|
90
|
+
aria-label="New side question"
|
|
91
|
+
title="New side question"
|
|
92
|
+
>
|
|
93
|
+
<RefreshCw size={14} strokeWidth={2} />
|
|
94
|
+
</button>
|
|
95
|
+
|
|
96
|
+
<textarea
|
|
97
|
+
ref={textareaRef}
|
|
98
|
+
rows={1}
|
|
99
|
+
value={value}
|
|
100
|
+
onInput={(event) => onChange(event.currentTarget.value)}
|
|
101
|
+
onKeyDown={handleKeyDown}
|
|
102
|
+
placeholder="Ask your question"
|
|
103
|
+
aria-label="Ask your question"
|
|
104
|
+
className="flex-1 min-w-0 bg-transparent text-[13px] text-ink placeholder-ink/40 resize-none outline-none py-1.5 max-h-32 leading-6"
|
|
105
|
+
/>
|
|
106
|
+
|
|
107
|
+
{topics.length > 0 && (
|
|
108
|
+
<div className="relative">
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
onClick={() => setMenuOpen((open) => !open)}
|
|
112
|
+
className={GHOST_BUTTON}
|
|
113
|
+
aria-label="Side question history"
|
|
114
|
+
title="Side question history"
|
|
115
|
+
aria-expanded={menuOpen}
|
|
116
|
+
data-btw-menu-anchor=""
|
|
117
|
+
>
|
|
118
|
+
<ChevronDown size={14} strokeWidth={2} />
|
|
119
|
+
</button>
|
|
120
|
+
{menuOpen && (
|
|
121
|
+
<BtwTopicMenu
|
|
122
|
+
topics={topics}
|
|
123
|
+
activeTopicId={activeTopicId}
|
|
124
|
+
onSelectTopic={onSelectTopic}
|
|
125
|
+
onDeleteTopic={onDeleteTopic}
|
|
126
|
+
onCloseMenu={() => setMenuOpen(false)}
|
|
127
|
+
/>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{canPromote && (
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
onClick={onPromote}
|
|
136
|
+
className={GHOST_BUTTON}
|
|
137
|
+
aria-label="Promote answer into chat"
|
|
138
|
+
title="Promote answer into chat"
|
|
139
|
+
>
|
|
140
|
+
<ExternalLink size={14} strokeWidth={2} />
|
|
141
|
+
</button>
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
onClick={onClose}
|
|
147
|
+
className={GHOST_BUTTON}
|
|
148
|
+
aria-label="Close side questions"
|
|
149
|
+
title="Close side questions"
|
|
150
|
+
>
|
|
151
|
+
<X size={14} strokeWidth={2} />
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BTW panel composer card — the side question's own input.
|
|
3
|
+
*
|
|
4
|
+
* Pure presentation: text, attachments and the running turn are owned by the
|
|
5
|
+
* parent; this file only autosizes the textarea, reads keys and renders.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useRef } from 'preact/hooks';
|
|
9
|
+
import type { TargetedEvent, TargetedKeyboardEvent } from 'preact';
|
|
10
|
+
import { File as FileIcon, Paperclip, Send, Shield, Square, X } from 'lucide-preact';
|
|
11
|
+
import { formatBytes } from '@/shared/lib/format/number';
|
|
12
|
+
import { providerLabel } from '@/shared/lib/models/provider-label';
|
|
13
|
+
|
|
14
|
+
export interface BtwComposerProps {
|
|
15
|
+
value: string;
|
|
16
|
+
onChange: (value: string) => void;
|
|
17
|
+
onSubmit: () => void;
|
|
18
|
+
onStop: () => void;
|
|
19
|
+
running: boolean;
|
|
20
|
+
modelLabel?: string;
|
|
21
|
+
provider?: string;
|
|
22
|
+
attachments: { id: string; name: string; size: number }[];
|
|
23
|
+
onFilesSelected: (files: File[]) => void;
|
|
24
|
+
onRemoveAttachment: (id: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ICON_BUTTON = 'p-1.5 rounded text-ink/60 hover:text-ink hover:bg-ink/5 transition-colors cursor-pointer';
|
|
28
|
+
|
|
29
|
+
export function BtwComposer({
|
|
30
|
+
value,
|
|
31
|
+
onChange,
|
|
32
|
+
onSubmit,
|
|
33
|
+
onStop,
|
|
34
|
+
running,
|
|
35
|
+
modelLabel,
|
|
36
|
+
provider,
|
|
37
|
+
attachments,
|
|
38
|
+
onFilesSelected,
|
|
39
|
+
onRemoveAttachment,
|
|
40
|
+
}: BtwComposerProps) {
|
|
41
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
42
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
43
|
+
|
|
44
|
+
const canSend = value.trim().length > 0 || attachments.length > 0;
|
|
45
|
+
const providerText = provider ? providerLabel(provider) : '';
|
|
46
|
+
|
|
47
|
+
// The form replaces the chat composer that had focus, so the caret is put
|
|
48
|
+
// here — the field the form is for, and the one a restored draft is waiting
|
|
49
|
+
// in — instead of being left on the document body.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const textarea = textareaRef.current;
|
|
52
|
+
if (!textarea) return;
|
|
53
|
+
textarea.focus();
|
|
54
|
+
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
// Grow with the content and collapse again when the parent clears `value`
|
|
58
|
+
// after a submit — `height: auto` first, otherwise the box can only expand.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const textarea = textareaRef.current;
|
|
61
|
+
if (!textarea) return;
|
|
62
|
+
textarea.style.height = 'auto';
|
|
63
|
+
textarea.style.height = `${textarea.scrollHeight}px`;
|
|
64
|
+
}, [value]);
|
|
65
|
+
|
|
66
|
+
const handleKeyDown = (event: TargetedKeyboardEvent<HTMLTextAreaElement>) => {
|
|
67
|
+
if (event.key === 'Escape') {
|
|
68
|
+
event.currentTarget.blur();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (event.key !== 'Enter' || event.shiftKey) return;
|
|
72
|
+
// IME candidate selection must not submit the question.
|
|
73
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
if (canSend) onSubmit();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const handleFileSelect = (event: TargetedEvent<HTMLInputElement>) => {
|
|
79
|
+
const files = event.currentTarget.files;
|
|
80
|
+
if (files && files.length > 0) onFilesSelected(Array.from(files));
|
|
81
|
+
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="bg-paper border border-ink/20 rounded-2xl shadow-lg flex flex-col overflow-hidden">
|
|
86
|
+
<textarea
|
|
87
|
+
ref={textareaRef}
|
|
88
|
+
rows={1}
|
|
89
|
+
value={value}
|
|
90
|
+
onInput={(event) => onChange(event.currentTarget.value)}
|
|
91
|
+
onKeyDown={handleKeyDown}
|
|
92
|
+
placeholder="Ask in this btw session..."
|
|
93
|
+
className="w-full bg-transparent text-[13px] text-ink placeholder-ink/40 resize-none outline-none px-3.5 pt-3 pb-1 max-h-32"
|
|
94
|
+
/>
|
|
95
|
+
|
|
96
|
+
{attachments.length > 0 && (
|
|
97
|
+
<div className="flex items-center flex-wrap gap-1.5 px-3 pt-2">
|
|
98
|
+
{attachments.map((attachment) => (
|
|
99
|
+
<div
|
|
100
|
+
key={attachment.id}
|
|
101
|
+
className="relative flex items-center bg-canvas border border-ink/10 rounded-md p-0.5 pr-6 text-xs"
|
|
102
|
+
>
|
|
103
|
+
<div className="w-6 h-6 flex items-center justify-center bg-ink/5 rounded-sm mr-1.5 text-ink/60">
|
|
104
|
+
<FileIcon size={12} strokeWidth={2} />
|
|
105
|
+
</div>
|
|
106
|
+
<span className="truncate max-w-[120px] font-mono text-[10px] text-ink/80">{attachment.name}</span>
|
|
107
|
+
{formatBytes(attachment.size) && (
|
|
108
|
+
<span className="ml-1.5 font-mono text-[10px] text-ink/40">{formatBytes(attachment.size)}</span>
|
|
109
|
+
)}
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
onClick={() => onRemoveAttachment(attachment.id)}
|
|
113
|
+
className="absolute right-0.5 top-1/2 -translate-y-1/2 p-0.5 text-ink/40 hover:text-error hover:bg-error/10 rounded transition-colors cursor-pointer"
|
|
114
|
+
aria-label={`Remove ${attachment.name}`}
|
|
115
|
+
title="Remove attachment"
|
|
116
|
+
>
|
|
117
|
+
<X size={10} strokeWidth={2} />
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
))}
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
<div className="flex items-center justify-between px-2 pb-2">
|
|
125
|
+
<div className="flex items-center gap-0.5">
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
onClick={() => fileInputRef.current?.click()}
|
|
129
|
+
className={ICON_BUTTON}
|
|
130
|
+
aria-label="Attach files"
|
|
131
|
+
title="Attach files"
|
|
132
|
+
>
|
|
133
|
+
<Paperclip size={14} strokeWidth={2} />
|
|
134
|
+
</button>
|
|
135
|
+
|
|
136
|
+
<span
|
|
137
|
+
className="p-1.5 text-ink/40"
|
|
138
|
+
aria-label="Side answers never use tools"
|
|
139
|
+
title="Side answers never use tools"
|
|
140
|
+
>
|
|
141
|
+
<Shield size={14} strokeWidth={2} />
|
|
142
|
+
</span>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
146
|
+
{(providerText || modelLabel) && (
|
|
147
|
+
<div className="flex items-baseline gap-1.5 min-w-0">
|
|
148
|
+
{providerText && <span className="text-[10px] text-ink/40 shrink-0">{providerText}</span>}
|
|
149
|
+
{modelLabel && <span className="text-[11px] text-ink/70 truncate">{modelLabel}</span>}
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{running ? (
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
onClick={onStop}
|
|
157
|
+
className="bg-ink text-canvas rounded p-1.5 cursor-pointer"
|
|
158
|
+
aria-label="Stop side answer"
|
|
159
|
+
title="Stop side answer"
|
|
160
|
+
>
|
|
161
|
+
<Square size={14} strokeWidth={2} />
|
|
162
|
+
</button>
|
|
163
|
+
) : (
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
onClick={onSubmit}
|
|
167
|
+
disabled={!canSend}
|
|
168
|
+
className="bg-ink text-canvas hover:bg-ink/80 rounded p-1.5 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
|
169
|
+
aria-label="Send side question"
|
|
170
|
+
title="Send side question"
|
|
171
|
+
>
|
|
172
|
+
<Send size={14} strokeWidth={2} />
|
|
173
|
+
</button>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<input type="file" accept="image/*" multiple className="hidden" ref={fileInputRef} onChange={handleFileSelect} />
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BTW panel message body — the scrollable turn list of the active topic.
|
|
3
|
+
*
|
|
4
|
+
* Pure presentation: the parent streams `liveAnswer` in and this file only
|
|
5
|
+
* decides which text is current. No fetch, no session state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useMemo, useRef } from 'preact/hooks';
|
|
9
|
+
import type { BtwTopic, BtwTurnStatus } from '@/shared/types/btw';
|
|
10
|
+
import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
|
|
11
|
+
|
|
12
|
+
export interface BtwMessagesProps {
|
|
13
|
+
topic: BtwTopic | null;
|
|
14
|
+
/** Partial text of the running turn ('' when none). */
|
|
15
|
+
liveAnswer: string;
|
|
16
|
+
/** Panel-level error line, surfaced above the turns. */
|
|
17
|
+
error: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Status line under an answer that is not `complete`. */
|
|
21
|
+
function AnswerStatus({ status }: { status: BtwTurnStatus }) {
|
|
22
|
+
if (status === 'complete') return null;
|
|
23
|
+
|
|
24
|
+
if (status === 'running') {
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex items-center gap-1.5 text-[11px] text-ink/50">
|
|
27
|
+
<span className="w-1.5 h-1.5 rounded-full bg-ink/40 animate-pulse" />
|
|
28
|
+
<span>Thinking…</span>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (status === 'cancelled') {
|
|
34
|
+
return <div className="text-[11px] text-ink/50">Cancelled</div>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return <div className="text-[11px] text-error">{status === 'failed' ? 'Failed' : 'Interrupted'}</div>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function BtwMessages({ topic, liveAnswer, error }: BtwMessagesProps) {
|
|
41
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
const turns = topic?.turns ?? [];
|
|
43
|
+
|
|
44
|
+
// Cheap scroll signal: any visible text growth (stream delta, new question,
|
|
45
|
+
// topic switch) changes this number.
|
|
46
|
+
const renderedLength = useMemo(
|
|
47
|
+
() => turns.reduce((total, turn) => total + turn.question.length + turn.answer.length, 0) + liveAnswer.length,
|
|
48
|
+
[turns, liveAnswer],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const body = scrollRef.current;
|
|
53
|
+
if (body) body.scrollTop = body.scrollHeight;
|
|
54
|
+
}, [renderedLength]);
|
|
55
|
+
|
|
56
|
+
const lastIndex = turns.length - 1;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
ref={scrollRef}
|
|
61
|
+
className="flex-1 min-h-0 overflow-y-auto scrollbar-overlay-container scrollbar-overlay-static px-4 py-3 space-y-4"
|
|
62
|
+
>
|
|
63
|
+
{error && (
|
|
64
|
+
<div className="bg-error/10 border border-error/30 text-error rounded-lg px-3 py-2 text-[12px]">{error}</div>
|
|
65
|
+
)}
|
|
66
|
+
|
|
67
|
+
{topic === null ? (
|
|
68
|
+
<div className="flex h-full items-center justify-center px-4 text-center text-[12px] text-ink/50">
|
|
69
|
+
Start a side question — the answer stays out of this chat's transcript.
|
|
70
|
+
</div>
|
|
71
|
+
) : (
|
|
72
|
+
turns.map((turn, index) => {
|
|
73
|
+
// The streamed text wins while the newest turn is live: either it is
|
|
74
|
+
// still running, or it already outgrew the last flushed answer.
|
|
75
|
+
const streaming =
|
|
76
|
+
index === lastIndex &&
|
|
77
|
+
liveAnswer !== '' &&
|
|
78
|
+
(turn.status === 'running' || liveAnswer.length > turn.answer.length);
|
|
79
|
+
const answer = streaming ? liveAnswer : turn.answer;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div key={turn.index} className="space-y-2">
|
|
83
|
+
<div className="ml-auto max-w-[85%] w-fit bg-ink/[0.06] border border-ink/10 rounded-xl px-3.5 py-2.5 text-[13px] text-ink break-words">
|
|
84
|
+
<MarkdownRenderer content={turn.question} />
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{answer !== '' && (
|
|
88
|
+
<div className="px-1 text-[13px] text-ink leading-relaxed">
|
|
89
|
+
<MarkdownRenderer content={answer} />
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
<AnswerStatus status={turn.status} />
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
})
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BTW topic history dropdown — the ⌄ menu of the panel header.
|
|
3
|
+
*
|
|
4
|
+
* Pure presentation: it renders the topic list and reports dismissal; the open
|
|
5
|
+
* state lives in `BtwHeader`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useRef } from 'preact/hooks';
|
|
9
|
+
import { Trash2 } from 'lucide-preact';
|
|
10
|
+
import type { BtwTopic, BtwTurnStatus } from '@/shared/types/btw';
|
|
11
|
+
|
|
12
|
+
export interface BtwTopicMenuProps {
|
|
13
|
+
topics: BtwTopic[];
|
|
14
|
+
activeTopicId: string | null;
|
|
15
|
+
onSelectTopic: (id: string) => void;
|
|
16
|
+
/** Drop a topic and its transcript — a topic copies the parent session, so
|
|
17
|
+
* history nobody will reopen is real disk. */
|
|
18
|
+
onDeleteTopic: (id: string) => void;
|
|
19
|
+
/** Called after a selection and on outside click / Escape. */
|
|
20
|
+
onCloseMenu: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const STATUS_LABEL: Record<BtwTurnStatus, string> = {
|
|
24
|
+
running: 'thinking…',
|
|
25
|
+
complete: 'complete',
|
|
26
|
+
cancelled: 'cancelled',
|
|
27
|
+
failed: 'failed',
|
|
28
|
+
interrupted: 'interrupted',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** `<turn count> · <status of the newest turn>` — the one-line topic summary. */
|
|
32
|
+
export function BtwTopicMenu({ topics, activeTopicId, onSelectTopic, onDeleteTopic, onCloseMenu }: BtwTopicMenuProps) {
|
|
33
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
34
|
+
|
|
35
|
+
// Outside dismissal. Pointerdown (not click) so the menu is gone before the
|
|
36
|
+
// underlying target reacts; the header toggle is exempt — it owns its own
|
|
37
|
+
// open state and would otherwise re-open what this handler just closed.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const onPointerDown = (event: globalThis.PointerEvent) => {
|
|
40
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
41
|
+
if (menuRef.current?.contains(target)) return;
|
|
42
|
+
if (target?.closest('[data-btw-menu-anchor]')) return;
|
|
43
|
+
onCloseMenu();
|
|
44
|
+
};
|
|
45
|
+
document.addEventListener('pointerdown', onPointerDown);
|
|
46
|
+
return () => document.removeEventListener('pointerdown', onPointerDown);
|
|
47
|
+
}, [onCloseMenu]);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const onKeyDown = (event: globalThis.KeyboardEvent) => {
|
|
51
|
+
if (event.key === 'Escape') onCloseMenu();
|
|
52
|
+
};
|
|
53
|
+
document.addEventListener('keydown', onKeyDown);
|
|
54
|
+
return () => document.removeEventListener('keydown', onKeyDown);
|
|
55
|
+
}, [onCloseMenu]);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
ref={menuRef}
|
|
60
|
+
role="menu"
|
|
61
|
+
aria-label="Side question history"
|
|
62
|
+
className="absolute left-0 top-full mt-1 z-50 bg-paper border border-ink/15 rounded-lg shadow-xl min-w-[280px] max-h-72 overflow-y-auto scrollbar-overlay-container scrollbar-overlay-static p-1"
|
|
63
|
+
>
|
|
64
|
+
{topics.length === 0 && (
|
|
65
|
+
<div className="px-2.5 py-2 text-[12px] text-ink/50">No side questions yet.</div>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
{topics.map((topic) => {
|
|
69
|
+
const count = topic.turns.length;
|
|
70
|
+
const newest = topic.turns[count - 1];
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
key={topic.id}
|
|
75
|
+
className={`group flex items-center rounded ${topic.id === activeTopicId ? 'bg-ink/5' : 'hover:bg-ink/5'}`}
|
|
76
|
+
>
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
role="menuitem"
|
|
80
|
+
onClick={() => {
|
|
81
|
+
onSelectTopic(topic.id);
|
|
82
|
+
onCloseMenu();
|
|
83
|
+
}}
|
|
84
|
+
className="flex-1 min-w-0 text-left px-2.5 py-2 rounded cursor-pointer"
|
|
85
|
+
>
|
|
86
|
+
<div className="truncate text-[12px] text-ink">{topic.title}</div>
|
|
87
|
+
<div className="text-[11px] text-ink/50">
|
|
88
|
+
{count} turn{count === 1 ? '' : 's'}
|
|
89
|
+
{newest ? ` · ${STATUS_LABEL[newest.status]}` : ''}
|
|
90
|
+
</div>
|
|
91
|
+
</button>
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={() => onDeleteTopic(topic.id)}
|
|
95
|
+
className="p-1.5 mr-1 rounded text-ink/40 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 hover:text-error hover:bg-error/10 transition-colors cursor-pointer"
|
|
96
|
+
aria-label={`Delete side question: ${topic.title}`}
|
|
97
|
+
title="Delete this side question"
|
|
98
|
+
>
|
|
99
|
+
<Trash2 size={12} strokeWidth={2} />
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
})}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Attachment handling for the side composer.
|
|
8
|
+
*
|
|
9
|
+
* A side question reaches the model with images only — no text files and no
|
|
10
|
+
* tools (the prompt says so, and the child runs `--no-tools`), so an
|
|
11
|
+
* attachment that cannot be an image payload is not offered in the first
|
|
12
|
+
* place. The file picker is `accept="image/*"`; this reads what it returns.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AgentImage } from '@/shared/types';
|
|
16
|
+
|
|
17
|
+
/** An attachment held by the side composer until it is sent. */
|
|
18
|
+
export interface BtwAttachment {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
size: number;
|
|
22
|
+
dataBase64?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readImage(file: File): Promise<string | undefined> {
|
|
26
|
+
const { promise, resolve } = Promise.withResolvers<string | undefined>();
|
|
27
|
+
const reader = new FileReader();
|
|
28
|
+
reader.onload = () => {
|
|
29
|
+
const result = typeof reader.result === 'string' ? reader.result : '';
|
|
30
|
+
const comma = result.indexOf(',');
|
|
31
|
+
resolve(comma >= 0 ? result.slice(comma + 1) : undefined);
|
|
32
|
+
};
|
|
33
|
+
reader.onerror = () => resolve(undefined);
|
|
34
|
+
reader.readAsDataURL(file);
|
|
35
|
+
return promise;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Read picked files into attachments, keeping only images. */
|
|
39
|
+
export async function readBtwAttachments(files: File[]): Promise<BtwAttachment[]> {
|
|
40
|
+
const images = files.filter((file) => file.type.startsWith('image/'));
|
|
41
|
+
return Promise.all(
|
|
42
|
+
images.map(async (file) => {
|
|
43
|
+
const dataBase64 = await readImage(file);
|
|
44
|
+
return {
|
|
45
|
+
id: `btw-${file.name}-${file.size}-${file.lastModified}`,
|
|
46
|
+
name: file.name,
|
|
47
|
+
size: file.size,
|
|
48
|
+
...(dataBase64 ? { dataBase64 } : {}),
|
|
49
|
+
};
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The model payload for an ask — images only, base64 already read. */
|
|
55
|
+
export function btwImagesFrom(attachments: BtwAttachment[]): AgentImage[] {
|
|
56
|
+
return attachments
|
|
57
|
+
.filter((attachment): attachment is BtwAttachment & { dataBase64: string } => typeof attachment.dataBase64 === 'string')
|
|
58
|
+
.map((attachment) => ({
|
|
59
|
+
type: 'image' as const,
|
|
60
|
+
data: attachment.dataBase64,
|
|
61
|
+
mimeType: mimeTypeOf(attachment.name),
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const MIME_BY_EXTENSION: Record<string, string> = {
|
|
66
|
+
png: 'image/png',
|
|
67
|
+
jpg: 'image/jpeg',
|
|
68
|
+
jpeg: 'image/jpeg',
|
|
69
|
+
gif: 'image/gif',
|
|
70
|
+
webp: 'image/webp',
|
|
71
|
+
bmp: 'image/bmp',
|
|
72
|
+
svg: 'image/svg+xml',
|
|
73
|
+
avif: 'image/avif',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function mimeTypeOf(name: string): string {
|
|
77
|
+
const dot = name.lastIndexOf('.');
|
|
78
|
+
const extension = dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
|
|
79
|
+
return MIME_BY_EXTENSION[extension] ?? 'image/png';
|
|
80
|
+
}
|