dsh-compact-saviour 0.2.6
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/ACCEPTANCE.md +16 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/THIRD_PARTY_NOTICES.md +50 -0
- package/cordis.patch.yml +3 -0
- package/cordis.yml +2 -0
- package/docs/development.md +28 -0
- package/dshx.yml +16 -0
- package/lib/client.js +5933 -0
- package/lib/client.js.map +1 -0
- package/lib/dsh-compact-saviour.js +1206 -0
- package/lib/types/client/context-elements.d.ts +6 -0
- package/lib/types/client/context-elements.d.ts.map +1 -0
- package/lib/types/client/context-elements.js +41 -0
- package/lib/types/client/context-elements.js.map +1 -0
- package/lib/types/client/index.d.ts +10 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js +217 -0
- package/lib/types/client/index.js.map +1 -0
- package/lib/types/client/meter-bridge.d.ts +12 -0
- package/lib/types/client/meter-bridge.d.ts.map +1 -0
- package/lib/types/client/meter-bridge.js +89 -0
- package/lib/types/client/meter-bridge.js.map +1 -0
- package/lib/types/client/ring-control.d.ts +32 -0
- package/lib/types/client/ring-control.d.ts.map +1 -0
- package/lib/types/client/ring-control.js +191 -0
- package/lib/types/client/ring-control.js.map +1 -0
- package/lib/types/client/satellite.d.ts +32 -0
- package/lib/types/client/satellite.d.ts.map +1 -0
- package/lib/types/client/satellite.js +163 -0
- package/lib/types/client/satellite.js.map +1 -0
- package/lib/types/client/waiting-copy.d.ts +10 -0
- package/lib/types/client/waiting-copy.d.ts.map +1 -0
- package/lib/types/client/waiting-copy.js +31 -0
- package/lib/types/client/waiting-copy.js.map +1 -0
- package/lib/types/compress.d.ts +31 -0
- package/lib/types/compress.d.ts.map +1 -0
- package/lib/types/compress.js +265 -0
- package/lib/types/compress.js.map +1 -0
- package/lib/types/controller.d.ts +31 -0
- package/lib/types/controller.d.ts.map +1 -0
- package/lib/types/controller.js +323 -0
- package/lib/types/controller.js.map +1 -0
- package/lib/types/dsh-compact-saviour.d.ts +10 -0
- package/lib/types/dsh-compact-saviour.d.ts.map +1 -0
- package/lib/types/dsh-compact-saviour.js +104 -0
- package/lib/types/dsh-compact-saviour.js.map +1 -0
- package/lib/types/message-source.d.ts +9 -0
- package/lib/types/message-source.d.ts.map +1 -0
- package/lib/types/message-source.js +2 -0
- package/lib/types/message-source.js.map +1 -0
- package/lib/types/pressure.d.ts +17 -0
- package/lib/types/pressure.d.ts.map +1 -0
- package/lib/types/pressure.js +71 -0
- package/lib/types/pressure.js.map +1 -0
- package/lib/types/shared.d.ts +42 -0
- package/lib/types/shared.d.ts.map +1 -0
- package/lib/types/shared.js +2 -0
- package/lib/types/shared.js.map +1 -0
- package/lib/types/summary-types.d.ts +20 -0
- package/lib/types/summary-types.d.ts.map +1 -0
- package/lib/types/summary-types.js +7 -0
- package/lib/types/summary-types.js.map +1 -0
- package/lib/types/vendor/region.d.ts +65 -0
- package/lib/types/vendor/region.d.ts.map +1 -0
- package/lib/types/vendor/region.js +412 -0
- package/lib/types/vendor/region.js.map +1 -0
- package/package.json +101 -0
- package/scripts/link-harness-dependencies.mjs +41 -0
- package/scripts/normalize-artifacts.mjs +7 -0
- package/src/client/assets.d.ts +1 -0
- package/src/client/context-elements.ts +39 -0
- package/src/client/index.tsx +214 -0
- package/src/client/meter-bridge.ts +65 -0
- package/src/client/ring-control.ts +134 -0
- package/src/client/satellite.ts +121 -0
- package/src/client/style.css +54 -0
- package/src/client/vendor/LICENSE +21 -0
- package/src/client/vendor/open-bot-motion.cjs +1673 -0
- package/src/client/vendor/open-bot-motion.d.cts +9 -0
- package/src/client/waiting-copy.tsx +29 -0
- package/src/compress.ts +210 -0
- package/src/controller.ts +248 -0
- package/src/dsh-compact-saviour.ts +82 -0
- package/src/message-source.ts +4 -0
- package/src/pressure.ts +69 -0
- package/src/shared.ts +13 -0
- package/src/summary-types.ts +11 -0
- package/src/vendor/region.ts +583 -0
- package/tests/compression.test.ts +130 -0
- package/tests/controller.test.ts +279 -0
- package/tests/interaction-fixture.tsx +38 -0
- package/tests/interaction.browser.mjs +201 -0
- package/tests/meter-bridge.browser.mjs +83 -0
- package/tests/motion.test.ts +26 -0
- package/tests/peer-range.test.ts +23 -0
- package/tests/pressure.test.ts +11 -0
- package/tests/transactions.test.ts +56 -0
- package/tsconfig.json +10 -0
- package/tsdown.config.ts +13 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client';
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
3
|
+
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client';
|
|
4
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings/client';
|
|
5
|
+
import type {} from '@deepseek-ai/dsh-client-ui-slots';
|
|
6
|
+
import { useEffect, useRef, useState } from 'react';
|
|
7
|
+
import { createPortal } from 'react-dom';
|
|
8
|
+
import type { Config, ModelOption, State } from '../shared.js';
|
|
9
|
+
import { MeterBridge } from './meter-bridge.js';
|
|
10
|
+
import { RingControl } from './ring-control.js';
|
|
11
|
+
import { WaitingCopy } from './waiting-copy.js';
|
|
12
|
+
import { contextRoot, contextRing, contextPanel } from './context-elements.js';
|
|
13
|
+
import './style.css';
|
|
14
|
+
|
|
15
|
+
export const name = 'dsh-compact-saviour-client';
|
|
16
|
+
export const inject = ['slots'];
|
|
17
|
+
const ENDPOINT = '/api/dsh-compact-saviour/v1';
|
|
18
|
+
async function api<T>(params = '', data?: unknown, signal?: AbortSignal): Promise<T> {
|
|
19
|
+
const response = await fetch(ENDPOINT + params, { credentials: 'same-origin', signal,
|
|
20
|
+
...(data ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(data) } : {}),
|
|
21
|
+
});
|
|
22
|
+
const text = await response.text();
|
|
23
|
+
let value;
|
|
24
|
+
try { value = JSON.parse(text); }
|
|
25
|
+
catch { throw new Error(`压缩服务暂未就绪(HTTP ${response.status}),请稍后重试。`); }
|
|
26
|
+
if (!response.ok) throw new Error(value.error ?? `HTTP ${response.status}`);
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A zero-layout anchor scopes the native ring gesture, status animation and popup. */
|
|
31
|
+
export function PopupBridge({ sessionId }: { sessionId: string }) {
|
|
32
|
+
const anchor = useRef<HTMLSpanElement>(null);
|
|
33
|
+
const [footer, setFooter] = useState<HTMLDivElement | null>(null);
|
|
34
|
+
const [state, setState] = useState<State>();
|
|
35
|
+
const [error, setError] = useState('');
|
|
36
|
+
const [sending, setSending] = useState(false);
|
|
37
|
+
const posting = useRef(false);
|
|
38
|
+
const act = useRef<(action: string) => Promise<void>>(async () => {});
|
|
39
|
+
const latest = useRef<State>();
|
|
40
|
+
const syncMeter = useRef<() => void>(() => {});
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const abort = new AbortController(); let timer: ReturnType<typeof setTimeout>;
|
|
43
|
+
let revision = 0, connectionError = '';
|
|
44
|
+
setState(undefined); latest.current = undefined; setError(''); setSending(false); posting.current = false;
|
|
45
|
+
const update = (value: State) => { latest.current = value; setState(value); syncMeter.current(); };
|
|
46
|
+
act.current = async (action: string) => {
|
|
47
|
+
if (posting.current || abort.signal.aborted) return;
|
|
48
|
+
posting.current = true; revision++; setSending(true); setError(''); syncMeter.current();
|
|
49
|
+
try {
|
|
50
|
+
const value = await api<State>('', { action, sessionId }, abort.signal);
|
|
51
|
+
// POST acknowledges a job without remeasuring context. Keep the last
|
|
52
|
+
// same-session reading until GET replaces it, avoiding an 8M flash.
|
|
53
|
+
if (!abort.signal.aborted) update({ ...value, context: value.context ?? latest.current?.context });
|
|
54
|
+
} catch (e) {
|
|
55
|
+
if (!abort.signal.aborted) setError(e instanceof Error ? e.message : String(e));
|
|
56
|
+
} finally {
|
|
57
|
+
if (!abort.signal.aborted) { posting.current = false; revision++; setSending(false); syncMeter.current(); }
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const poll = async () => {
|
|
61
|
+
const started = revision;
|
|
62
|
+
try {
|
|
63
|
+
const value = await api<State>(`?sessionId=${encodeURIComponent(sessionId)}`, undefined, abort.signal);
|
|
64
|
+
if (!abort.signal.aborted && started === revision && !posting.current) {
|
|
65
|
+
update(value); const previous = connectionError;
|
|
66
|
+
setError(current => current === previous ? '' : current); connectionError = '';
|
|
67
|
+
}
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (!abort.signal.aborted) { connectionError = e instanceof Error ? e.message : String(e); setError(connectionError); }
|
|
70
|
+
}
|
|
71
|
+
if (!abort.signal.aborted) timer = setTimeout(poll, 1000);
|
|
72
|
+
};
|
|
73
|
+
void poll(); return () => { abort.abort(); clearTimeout(timer); act.current = async () => {}; };
|
|
74
|
+
}, [sessionId]);
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const root = anchor.current && contextRoot(anchor.current);
|
|
77
|
+
if (!root) return;
|
|
78
|
+
let owned: HTMLDivElement | null = null;
|
|
79
|
+
let observedPanel: HTMLElement | undefined;
|
|
80
|
+
const meter = new MeterBridge();
|
|
81
|
+
let control: RingControl | undefined;
|
|
82
|
+
const sync = () => {
|
|
83
|
+
const ring = contextRing(root);
|
|
84
|
+
const panel = contextPanel(ring);
|
|
85
|
+
if (panel !== observedPanel) {
|
|
86
|
+
panelObserver.disconnect(); observedPanel = panel;
|
|
87
|
+
if (panel) panelObserver.observe(panel, observeOptions);
|
|
88
|
+
}
|
|
89
|
+
meter.apply(ring, latest.current?.context);
|
|
90
|
+
if (control?.button !== ring) {
|
|
91
|
+
control?.destroy(); control = ring ? new RingControl(ring, () => { void act.current('configured'); }) : undefined;
|
|
92
|
+
}
|
|
93
|
+
control?.update(posting.current ? 'sending' : latest.current?.job.phase ?? 'idle', Boolean(latest.current?.available));
|
|
94
|
+
if (owned && owned.parentElement === panel) {
|
|
95
|
+
// The optional breakdown can arrive after the portal footer. React
|
|
96
|
+
// appends its new rows last; keep our action beneath those native rows.
|
|
97
|
+
if (panel && panel.lastElementChild !== owned) panel.append(owned);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
owned?.remove(); owned = null;
|
|
101
|
+
if (panel && !panel.querySelector('[data-compact-saviour]')) {
|
|
102
|
+
owned = document.createElement('div'); owned.dataset.compactSaviour = 'footer'; owned.className = 'dsh-cs-footer'; panel.append(owned);
|
|
103
|
+
}
|
|
104
|
+
setFooter(owned);
|
|
105
|
+
};
|
|
106
|
+
const observeOptions = { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['aria-expanded', 'aria-label', 'aria-describedby', 'stroke-dasharray', 'style'] };
|
|
107
|
+
const observer = new MutationObserver(sync);
|
|
108
|
+
const panelObserver = new MutationObserver(sync);
|
|
109
|
+
const portals = new MutationObserver(sync);
|
|
110
|
+
observer.observe(root, observeOptions);
|
|
111
|
+
// Only direct body children, not the streaming transcript subtree.
|
|
112
|
+
portals.observe(root.ownerDocument.body, { childList: true });
|
|
113
|
+
syncMeter.current = sync; sync();
|
|
114
|
+
return () => { syncMeter.current = () => {}; observer.disconnect(); portals.disconnect(); panelObserver.disconnect(); control?.destroy(); meter.restore(); owned?.remove(); };
|
|
115
|
+
}, [sessionId]);
|
|
116
|
+
return <><span ref={anchor} style={{ display: 'none' }} aria-hidden="true" />{footer && createPortal(<CompactFooter state={state} sending={sending} error={error} act={action => act.current(action)} />, footer)}</>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function CompactFooter({ state, sending, error, act }: {
|
|
120
|
+
state?: State; sending: boolean; error: string; act(action: string): Promise<void>;
|
|
121
|
+
}) {
|
|
122
|
+
const job = state?.job, active = job?.phase === 'queued' || job?.phase === 'running';
|
|
123
|
+
const current = job?.current;
|
|
124
|
+
const busy = sending || active;
|
|
125
|
+
const running = !sending && job?.phase === 'running';
|
|
126
|
+
const stage = job?.message.includes('合并摘要') ? 'merge' : 'compress';
|
|
127
|
+
const label = sending ? '正在提交' : job?.phase === 'queued' ? '等待当前步骤结束' : job?.phase === 'running'
|
|
128
|
+
? stage === 'merge' ? '正在合并摘要' : '正在压缩' : '手动压缩';
|
|
129
|
+
const progress = !sending && job?.phase === 'running' && Number.isInteger(job.completed) && Number.isInteger(job.total)
|
|
130
|
+
&& job.total! > 1 && job.completed! >= 0 && job.completed! <= job.total! ? `${job.completed}/${job.total}` : '';
|
|
131
|
+
return <div className="dsh-cs-content" data-phase={sending ? 'sending' : job?.phase ?? 'idle'}>
|
|
132
|
+
<button className="dsh-cs-button dsh-cs-primary dsh-cs-compact" type="button" aria-label={progress ? `${label} ${progress}` : label} disabled={sending || !state?.available || active} onClick={() => void act('configured')}>
|
|
133
|
+
<span className="dsh-cs-button-status" aria-hidden="true">
|
|
134
|
+
{running ? <WaitingCopy key={stage} stage={stage} /> : <span className="dsh-cs-label">{label}{busy ? '…' : ''}</span>}
|
|
135
|
+
{progress && <span className="dsh-cs-progress"> {progress}</span>}
|
|
136
|
+
</span>
|
|
137
|
+
<span className="dsh-cs-sr-status" role="status" aria-live="polite" aria-atomic="true">{label}{progress ? ` ${progress}` : ''}</span>
|
|
138
|
+
</button>
|
|
139
|
+
{!active && !sending && <div className="dsh-cs-hint">也可以长按圆环直接压缩</div>}
|
|
140
|
+
{!busy && job?.message && <div className="dsh-cs-status dsh-cs-job-status" role="status"><span>{job.message}{job.phase === 'done' && job.beforeTokens !== undefined && job.afterTokens !== undefined ? ` ${Math.round(job.beforeTokens / 1000)}K → ${Math.round(job.afterTokens / 1000)}K` : ''}</span></div>}
|
|
141
|
+
{state?.context?.corrected && <div className="dsh-cs-status">{state.context.reason === 'invalid-usage' ? '已排除异常历史用量,当前按消息和工具估算。' : '已按新模型的上下文容量重新估算。'}</div>}
|
|
142
|
+
{error && <div className="dsh-cs-error" role="alert">{error}</div>}
|
|
143
|
+
{state && !state.config.model && !error && <div className="dsh-cs-status">先在设置 → Compact Saviour 中选择压缩模型。</div>}
|
|
144
|
+
{state && !state.available && <div className="dsh-cs-status">当前会话的 Compact 后端尚未就绪。</div>}
|
|
145
|
+
{job?.phase === 'failed' && <div className="dsh-cs-actions">
|
|
146
|
+
<button className="dsh-cs-button" disabled={sending} onClick={() => void act('configured')}>重试压缩模型</button>
|
|
147
|
+
{current && <button className="dsh-cs-button" disabled={sending} onClick={() => void act('current')}>本次使用 {current.model} · {current.reasoningEffort || '默认推理'}</button>}
|
|
148
|
+
</div>}
|
|
149
|
+
{(active || job?.phase === 'failed') && <button className="dsh-cs-button" disabled={sending} onClick={() => void act('cancel')}>取消</button>}
|
|
150
|
+
</div>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function SettingsPanel() {
|
|
154
|
+
const [config, setConfig] = useState<Config>();
|
|
155
|
+
const [models, setModels] = useState<ModelOption[]>([]);
|
|
156
|
+
const [search, setSearch] = useState('');
|
|
157
|
+
const [efforts, setEfforts] = useState<{ id: string; name: string }[]>([]);
|
|
158
|
+
const [message, setMessage] = useState('');
|
|
159
|
+
const [saving, setSaving] = useState(false);
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
const abort = new AbortController();
|
|
162
|
+
void Promise.all([api<State>('', undefined, abort.signal), api<{ models: ModelOption[] }>('?models=1', undefined, abort.signal)])
|
|
163
|
+
.then(([s, m]) => { setConfig(s.config); setModels(m.models); })
|
|
164
|
+
.catch(e => { if (!abort.signal.aborted) setMessage(e.message); });
|
|
165
|
+
return () => abort.abort();
|
|
166
|
+
}, []);
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
const abort = new AbortController(); setEfforts([]);
|
|
169
|
+
if (config?.provider && config.model) void api<{ efforts: { id: string; name: string }[] }>(`?provider=${encodeURIComponent(config.provider)}&model=${encodeURIComponent(config.model)}`, undefined, abort.signal)
|
|
170
|
+
.then(v => setEfforts(v.efforts)).catch(e => { if (!abort.signal.aborted) setMessage(e.message); });
|
|
171
|
+
return () => abort.abort();
|
|
172
|
+
}, [config?.provider, config?.model]);
|
|
173
|
+
const save = async () => {
|
|
174
|
+
setSaving(true); setMessage('');
|
|
175
|
+
try { const s = await api<State>('', { action: 'settings', config }); setConfig(s.config); setMessage('已保存。'); }
|
|
176
|
+
catch (e) { setMessage(e instanceof Error ? e.message : String(e)); }
|
|
177
|
+
finally { setSaving(false); }
|
|
178
|
+
};
|
|
179
|
+
const selected = config?.model ? JSON.stringify([config.provider, config.model]) : '';
|
|
180
|
+
const filtered = models.filter(m => JSON.stringify([m.provider, m.id]) === selected || `${m.name} ${m.id} ${m.provider}`.toLowerCase().includes(search.toLowerCase()));
|
|
181
|
+
return <section className="dsh-cs-settings">
|
|
182
|
+
<h2>Compact Saviour</h2>
|
|
183
|
+
<p>独立模型负责上下文压缩。点击圆环查看用量,长按圆环或点击弹层内的按钮开始压缩。</p>
|
|
184
|
+
{config ? <>
|
|
185
|
+
<label className="dsh-cs-toggle"><input type="checkbox" checked={config.enabled} onChange={e => setConfig({ ...config, enabled: e.target.checked })} /> 启用自动压缩辅助</label>
|
|
186
|
+
<label>自动压缩方式<select value={config.mode ?? 'rescue'} disabled={!config.enabled} onChange={e => setConfig({ ...config, mode: e.target.value as 'direct' | 'rescue' })}>
|
|
187
|
+
<option value="rescue">官方连续失败两次后救援(默认)</option>
|
|
188
|
+
<option value="direct">直接使用 Saviour 模型</option>
|
|
189
|
+
</select></label>
|
|
190
|
+
<p>{!config.enabled ? '自动压缩由官方处理。手动压缩仍直接使用下方模型。' : config.mode === 'direct' ? '自动和手动压缩都直接使用下方模型。' : '自动压缩先由官方处理,连续失败两次后静默救援。手动压缩直接使用下方模型。'}</p>
|
|
191
|
+
<label>Saviour 压缩模型<input placeholder="搜索已配置的模型" value={search} onChange={e => setSearch(e.target.value)} /></label>
|
|
192
|
+
<select aria-label="压缩模型" value={selected} onChange={e => { const [provider, model] = e.target.value ? JSON.parse(e.target.value) : ['', '']; setConfig({ ...config, provider, model, reasoningEffort: '' }); }}>
|
|
193
|
+
<option value="">请选择模型</option>
|
|
194
|
+
{filtered.map(m => <option key={JSON.stringify([m.provider, m.id])} value={JSON.stringify([m.provider, m.id])}>{m.name} · {m.provider}</option>)}
|
|
195
|
+
</select>
|
|
196
|
+
<label>Reasoning level<select value={config.reasoningEffort} onChange={e => setConfig({ ...config, reasoningEffort: e.target.value })}>
|
|
197
|
+
<option value="">模型默认</option>{efforts.map(e => <option key={e.id} value={e.id}>{e.name}</option>)}
|
|
198
|
+
</select></label>
|
|
199
|
+
<p>超长历史自动分块,保留近期消息和原始记录。失败后由你选择重试、仅本次使用当前对话模型,或取消。</p>
|
|
200
|
+
<button className="dsh-cs-button dsh-cs-primary" disabled={saving} onClick={() => void save()}>{saving ? '保存中…' : '保存'}</button>
|
|
201
|
+
</> : <p>正在加载模型配置…</p>}
|
|
202
|
+
{message && <p role="status">{message}</p>}
|
|
203
|
+
</section>;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function apply(ctx: Context): void {
|
|
207
|
+
ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
|
|
208
|
+
name: 'conversation.input.right', id: 'compact-saviour-popup-anchor', order: 100,
|
|
209
|
+
inject: (sessionId: string) => ({ sessionId }),
|
|
210
|
+
}, PopupBridge));
|
|
211
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
212
|
+
name: 'settings.section', id: 'compact-saviour', order: 38, label: () => 'Compact Saviour',
|
|
213
|
+
}, SettingsPanel));
|
|
214
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ContextView } from '../shared.js';
|
|
2
|
+
import { contextPanel } from './context-elements.js';
|
|
3
|
+
|
|
4
|
+
function tokens(value: number): string {
|
|
5
|
+
const unit = value >= 1_000_000 ? 1_000_000 : value >= 1_000 ? 1_000 : 1;
|
|
6
|
+
const scaled = value / unit;
|
|
7
|
+
return `${scaled >= 100 || unit === 1 ? Math.round(scaled) : Math.round(scaled * 10) / 10}${unit === 1_000_000 ? 'M' : unit === 1_000 ? 'K' : ''}`;
|
|
8
|
+
}
|
|
9
|
+
type Owned = { read(): string | null; write(value: string | null): void; before: string | null; last: string | null; desired?: string };
|
|
10
|
+
|
|
11
|
+
/** Narrow compatibility bridge until DSH exposes a context-meter reading slot.
|
|
12
|
+
* Preserve the original React nodes, styles, handlers and any later owner update. */
|
|
13
|
+
export class MeterBridge {
|
|
14
|
+
private edits = new Map<Node, Map<string, Owned>>();
|
|
15
|
+
private write(node: Node, key: string, value: string, read: Owned['read'], write: Owned['write']) {
|
|
16
|
+
let fields = this.edits.get(node);
|
|
17
|
+
if (!fields) { fields = new Map(); this.edits.set(node, fields); }
|
|
18
|
+
let field = fields.get(key);
|
|
19
|
+
if (!field) { field = { read, write, before: read(), last: null }; fields.set(key, field); }
|
|
20
|
+
else if (read() !== field.last) field.before = read();
|
|
21
|
+
if (read() !== value && !(field.desired === value && read() === field.last)) write(value);
|
|
22
|
+
field.last = read(); field.desired = value;
|
|
23
|
+
}
|
|
24
|
+
private text(element: Element | null | undefined, value: string) {
|
|
25
|
+
const node = element?.firstChild;
|
|
26
|
+
if (element?.childNodes.length !== 1 || node?.nodeType !== Node.TEXT_NODE) return;
|
|
27
|
+
this.write(node, 'text', value, () => node.nodeValue, v => { node.nodeValue = v; });
|
|
28
|
+
}
|
|
29
|
+
private attr(element: Element, key: string, value: string) {
|
|
30
|
+
this.write(element, key, value, () => element.getAttribute(key), v => { if (v === null) element.removeAttribute(key); else element.setAttribute(key, v); });
|
|
31
|
+
}
|
|
32
|
+
restore() {
|
|
33
|
+
for (const fields of this.edits.values()) for (const edit of fields.values()) if (edit.read() === edit.last) edit.write(edit.before);
|
|
34
|
+
this.edits.clear();
|
|
35
|
+
}
|
|
36
|
+
apply(ring: HTMLButtonElement | undefined, context?: ContextView) {
|
|
37
|
+
if (!ring || !context?.corrected) { this.restore(); return; }
|
|
38
|
+
for (const node of this.edits.keys()) if (!node.isConnected) this.edits.delete(node);
|
|
39
|
+
const percent = Math.max(0, Math.min(100, Math.round(context.usedTokens / context.contextWindow * 100)));
|
|
40
|
+
const reading = `${percent}%`, circumference = 2 * Math.PI * 5.5;
|
|
41
|
+
// RC2 displays a percentage alongside the SVG in the dock.
|
|
42
|
+
const label = [...ring.children].find(child => child.tagName === 'SPAN' && /^\d+%$/.test(child.textContent ?? ''));
|
|
43
|
+
this.text(label, reading);
|
|
44
|
+
const circles = ring.querySelectorAll('svg[viewBox="0 0 14 14"] circle');
|
|
45
|
+
if (circles.length !== 2) { this.restore(); return; }
|
|
46
|
+
this.attr(circles[1]!, 'stroke-dasharray', `${circumference * percent / 100} ${circumference}`);
|
|
47
|
+
const aria = ring.getAttribute('aria-label');
|
|
48
|
+
if (aria && /\d+%/.test(aria)) this.attr(ring, 'aria-label', aria.replace(/\d+%/, reading));
|
|
49
|
+
const tooltipId = ring.getAttribute('aria-describedby');
|
|
50
|
+
const tooltip = tooltipId ? document.getElementById(tooltipId) : null;
|
|
51
|
+
if (tooltip?.getAttribute('role') === 'tooltip') this.text(tooltip, (tooltip.textContent ?? '').replace(/\d+%/, reading));
|
|
52
|
+
const panel = contextPanel(ring);
|
|
53
|
+
const header = panel?.firstElementChild, bar = header?.nextElementSibling;
|
|
54
|
+
if (!header || header.children.length !== 4 || !bar || !/^\d+%$/.test(header.children[1]?.textContent ?? '')) return;
|
|
55
|
+
this.text(header.children[1], reading);
|
|
56
|
+
this.text(header.children[3], `~${tokens(context.usedTokens)} / ${tokens(context.contextWindow)}`);
|
|
57
|
+
const parts = context.parts?.filter(value => value > 0), total = parts?.reduce((a, b) => a + b, 0);
|
|
58
|
+
if (parts && total && parts.length === bar.children.length) {
|
|
59
|
+
[...bar.children].forEach((segment, index) => {
|
|
60
|
+
const el = segment as HTMLElement;
|
|
61
|
+
this.write(el, 'width', `${percent * parts[index]! / total}%`, () => el.style.width, value => { el.style.width = value ?? ''; });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Satellite } from './satellite.js';
|
|
2
|
+
|
|
3
|
+
export const HOLD_MS = 650;
|
|
4
|
+
export type RingPhase = 'idle' | 'sending' | 'queued' | 'running' | 'done' | 'failed';
|
|
5
|
+
/** Enhance only the existing context button; native short click and keys survive. */
|
|
6
|
+
export class RingControl {
|
|
7
|
+
private host = document.createElement('span');
|
|
8
|
+
private bot: Satellite;
|
|
9
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
10
|
+
private pointer?: { id: number; x: number; y: number };
|
|
11
|
+
private suppressedUntil = 0;
|
|
12
|
+
private phase: RingPhase = 'idle';
|
|
13
|
+
private available = false;
|
|
14
|
+
private disposed = false;
|
|
15
|
+
private previousTitle: string | null;
|
|
16
|
+
private lastTitle = '';
|
|
17
|
+
private native?: { element: SVGElement; fields: Map<string, { before: string; priority: string; last: string; lastPriority: string }> };
|
|
18
|
+
constructor(readonly button: HTMLButtonElement, private activate: () => void) {
|
|
19
|
+
this.previousTitle = button.getAttribute('title');
|
|
20
|
+
this.host.className = 'dsh-cs-ring-art'; this.host.setAttribute('aria-hidden', 'true');
|
|
21
|
+
this.host.innerHTML = '<svg class="dsh-cs-hold" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" pathLength="1"/></svg>';
|
|
22
|
+
this.bot = new Satellite(this.host); button.append(this.host); button.classList.add('dsh-cs-ring');
|
|
23
|
+
button.addEventListener('pointerdown', this.down);
|
|
24
|
+
button.addEventListener('pointerleave', this.cancel);
|
|
25
|
+
button.addEventListener('pointercancel', this.cancel);
|
|
26
|
+
button.addEventListener('contextmenu', this.context);
|
|
27
|
+
button.addEventListener('click', this.click, true);
|
|
28
|
+
window.addEventListener('pointerup', this.up, true);
|
|
29
|
+
window.addEventListener('pointermove', this.move, true);
|
|
30
|
+
window.addEventListener('pointerdown', this.anotherPointer, true);
|
|
31
|
+
window.addEventListener('blur', this.cancel);
|
|
32
|
+
document.addEventListener('visibilitychange', this.cancel);
|
|
33
|
+
}
|
|
34
|
+
update(phase: RingPhase, available: boolean) {
|
|
35
|
+
this.available = available;
|
|
36
|
+
// Restore the native meter from job state, never from an animation callback.
|
|
37
|
+
// React may also replace the trigger's class while updating its usage label.
|
|
38
|
+
this.button.classList.add('dsh-cs-ring');
|
|
39
|
+
if (this.host.parentElement !== this.button) this.button.append(this.host);
|
|
40
|
+
if (this.button.dataset.csPhase !== phase) this.button.dataset.csPhase = phase;
|
|
41
|
+
this.showMeter(!['sending', 'queued', 'running'].includes(phase));
|
|
42
|
+
if (this.phase !== phase) {
|
|
43
|
+
this.phase = phase;
|
|
44
|
+
if (phase === 'running') { this.button.dataset.csMotion = 'running'; this.bot.setRunning(true); }
|
|
45
|
+
else if (phase === 'queued' || phase === 'sending') { this.button.dataset.csMotion = 'queued'; this.bot.setRunning(false); }
|
|
46
|
+
else {
|
|
47
|
+
this.button.dataset.csMotion = 'settling';
|
|
48
|
+
this.bot.setRunning(false, () => { if (!this.disposed) delete this.button.dataset.csMotion; });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const title = phase === 'running' ? '正在压缩上下文 · 点击查看进度'
|
|
52
|
+
: phase === 'queued' || phase === 'sending' ? '压缩已排队 · 点击查看进度'
|
|
53
|
+
: '点击查看上下文 · 长按压缩';
|
|
54
|
+
if (this.button.title !== title) this.button.title = title;
|
|
55
|
+
this.lastTitle = title;
|
|
56
|
+
if (!this.canHold()) this.cancel();
|
|
57
|
+
}
|
|
58
|
+
private restoreMeter() {
|
|
59
|
+
if (!this.native) return;
|
|
60
|
+
for (const [key, field] of this.native.fields) {
|
|
61
|
+
if (this.native.element.style.getPropertyValue(key) !== field.last || this.native.element.style.getPropertyPriority(key) !== field.lastPriority) continue;
|
|
62
|
+
if (field.before) this.native.element.style.setProperty(key, field.before, field.priority);
|
|
63
|
+
else this.native.element.style.removeProperty(key);
|
|
64
|
+
}
|
|
65
|
+
this.native = undefined;
|
|
66
|
+
}
|
|
67
|
+
private showMeter(visible: boolean) {
|
|
68
|
+
const element = this.button.querySelector<SVGElement>('svg[viewBox="0 0 14 14"]');
|
|
69
|
+
if (this.native?.element !== element) {
|
|
70
|
+
this.restoreMeter();
|
|
71
|
+
if (element) this.native = { element, fields: new Map() };
|
|
72
|
+
}
|
|
73
|
+
if (!this.native) return;
|
|
74
|
+
// The RC2 trigger includes percentage text. Keep the animation over the
|
|
75
|
+
// actual icon instead of stretching it across the whole button.
|
|
76
|
+
const iconBox = this.native.element.getBoundingClientRect(), buttonBox = this.button.getBoundingClientRect();
|
|
77
|
+
const position = { left: `${iconBox.left - buttonBox.left - 3}px`, top: `${iconBox.top - buttonBox.top - 3}px`, width: `${iconBox.width + 6}px`, height: `${iconBox.height + 6}px` };
|
|
78
|
+
for (const [key, value] of Object.entries(position)) if (this.host.style.getPropertyValue(key) !== value) this.host.style.setProperty(key, value);
|
|
79
|
+
// Set the SVG itself: ancestor-attribute selector invalidation can leave an
|
|
80
|
+
// SVG's computed visibility/opacity stale even when the job is already idle.
|
|
81
|
+
for (const [key, value] of [['visibility', 'visible'], ['opacity', visible ? '1' : '0']] as const) {
|
|
82
|
+
const style = this.native.element.style;
|
|
83
|
+
let field = this.native.fields.get(key);
|
|
84
|
+
if (!field || style.getPropertyValue(key) !== field.last || style.getPropertyPriority(key) !== field.lastPriority) {
|
|
85
|
+
field = { before: style.getPropertyValue(key), priority: style.getPropertyPriority(key), last: value, lastPriority: '' };
|
|
86
|
+
this.native.fields.set(key, field);
|
|
87
|
+
}
|
|
88
|
+
if (style.getPropertyValue(key) !== value) style.setProperty(key, value);
|
|
89
|
+
field.last = value; field.lastPriority = style.getPropertyPriority(key);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
private canHold() { return this.available && !['sending', 'queued', 'running'].includes(this.phase); }
|
|
93
|
+
private down = (event: PointerEvent) => {
|
|
94
|
+
this.suppressedUntil = 0;
|
|
95
|
+
if (!event.isPrimary || event.button !== 0 || !this.canHold()) return;
|
|
96
|
+
this.cancel(); this.pointer = { id: event.pointerId, x: event.clientX, y: event.clientY };
|
|
97
|
+
this.button.dataset.csHolding = 'true';
|
|
98
|
+
this.timer = setTimeout(() => {
|
|
99
|
+
if (!this.pointer || !this.canHold() || !this.button.isConnected) { this.cancel(); return; }
|
|
100
|
+
this.cancel(); this.suppressedUntil = Infinity;
|
|
101
|
+
// Synthetic keyboard-style click is deliberately allowed; open the native
|
|
102
|
+
// popup once so model/configuration errors always have a visible home.
|
|
103
|
+
if (this.button.getAttribute('aria-expanded') !== 'true') this.button.click();
|
|
104
|
+
this.activate();
|
|
105
|
+
}, HOLD_MS);
|
|
106
|
+
};
|
|
107
|
+
private anotherPointer = (event: PointerEvent) => { if (this.pointer && this.pointer.id !== event.pointerId) this.cancel(); };
|
|
108
|
+
private move = (event: PointerEvent) => { if (this.pointer?.id === event.pointerId && Math.hypot(event.clientX - this.pointer.x, event.clientY - this.pointer.y) > 10) this.cancel(); };
|
|
109
|
+
private up = (event: PointerEvent) => {
|
|
110
|
+
if (this.pointer?.id === event.pointerId) this.cancel();
|
|
111
|
+
if (this.suppressedUntil === Infinity) this.suppressedUntil = performance.now() + 700;
|
|
112
|
+
};
|
|
113
|
+
private context = (event: Event) => { if (this.pointer || performance.now() < this.suppressedUntil) event.preventDefault(); };
|
|
114
|
+
private click = (event: MouseEvent) => {
|
|
115
|
+
if (event.detail !== 0 && performance.now() < this.suppressedUntil) { event.preventDefault(); event.stopImmediatePropagation(); }
|
|
116
|
+
};
|
|
117
|
+
private cancel = () => {
|
|
118
|
+
clearTimeout(this.timer); this.timer = undefined; this.pointer = undefined; delete this.button.dataset.csHolding;
|
|
119
|
+
};
|
|
120
|
+
destroy() {
|
|
121
|
+
this.disposed = true; this.cancel(); this.bot.destroy(); this.host.remove();
|
|
122
|
+
this.button.classList.remove('dsh-cs-ring'); delete this.button.dataset.csMotion; delete this.button.dataset.csPhase;
|
|
123
|
+
this.restoreMeter();
|
|
124
|
+
if (this.button.title === this.lastTitle) {
|
|
125
|
+
if (this.previousTitle === null) this.button.removeAttribute('title'); else this.button.title = this.previousTitle;
|
|
126
|
+
}
|
|
127
|
+
this.button.removeEventListener('pointerdown', this.down); this.button.removeEventListener('pointerleave', this.cancel);
|
|
128
|
+
this.button.removeEventListener('pointercancel', this.cancel); this.button.removeEventListener('contextmenu', this.context);
|
|
129
|
+
this.button.removeEventListener('click', this.click, true);
|
|
130
|
+
window.removeEventListener('pointerup', this.up, true); window.removeEventListener('pointermove', this.move, true);
|
|
131
|
+
window.removeEventListener('pointerdown', this.anotherPointer, true); window.removeEventListener('blur', this.cancel);
|
|
132
|
+
document.removeEventListener('visibilitychange', this.cancel);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import motion from './vendor/open-bot-motion.cjs';
|
|
2
|
+
|
|
3
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
4
|
+
let nextClip = 0;
|
|
5
|
+
const clipPrefix = Math.random().toString(36).slice(2);
|
|
6
|
+
const element = (tag: string) => document.createElementNS(NS, tag);
|
|
7
|
+
type Frame = ReturnType<typeof motion.getBot7State>;
|
|
8
|
+
// The authored orbit has a 3.6 s period. Loop just that span, not the complete
|
|
9
|
+
// demo (whose final satellite frame does not match its initial resting pose).
|
|
10
|
+
export const orbitTime = (seconds: number) => seconds < .8 ? 2 + seconds : 2.8 + ((seconds - .8) % 3.6);
|
|
11
|
+
export function settleFrame(from: Frame, amount: number): Frame {
|
|
12
|
+
const idle = motion.getBot7State(0), bot = { ...idle.bot };
|
|
13
|
+
const t = 1 - (1 - amount) ** 3;
|
|
14
|
+
for (const key of Object.keys(bot)) if (typeof bot[key] === 'number' && typeof from.bot[key] === 'number') {
|
|
15
|
+
bot[key] = (from.bot[key] as number) * (1 - t) + (bot[key] as number) * t;
|
|
16
|
+
}
|
|
17
|
+
return { bot, dots: from.dots.map(dot => ({ ...dot, x: dot.x * (1 - t), y: dot.y * (1 - t), r: dot.r * (1 - t) })) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Owns one SVG and one clock. Hidden/reduced-motion pages consume no frames. */
|
|
21
|
+
export class Satellite {
|
|
22
|
+
readonly svg = element('svg');
|
|
23
|
+
private projector = new motion.SvgProjector({ viewportSize: 280 });
|
|
24
|
+
private body = element('path');
|
|
25
|
+
private clipBody = element('path');
|
|
26
|
+
private eyes = element('g');
|
|
27
|
+
private dots = Array.from({ length: 8 }, () => element('circle'));
|
|
28
|
+
private frame?: number;
|
|
29
|
+
private elapsed = 0;
|
|
30
|
+
private lastTime = 0;
|
|
31
|
+
private settling = 0;
|
|
32
|
+
private mode: 'rest' | 'run' | 'settle' = 'rest';
|
|
33
|
+
private current = motion.getBot7State(0);
|
|
34
|
+
private exitFrom = this.current;
|
|
35
|
+
private reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
|
36
|
+
private disposed = false;
|
|
37
|
+
private done?: () => void;
|
|
38
|
+
constructor(host: HTMLElement) {
|
|
39
|
+
this.svg.setAttribute('viewBox', '-76 -76 152 152');
|
|
40
|
+
this.svg.setAttribute('aria-hidden', 'true');
|
|
41
|
+
this.svg.classList.add('dsh-cs-robot');
|
|
42
|
+
const defs = element('defs'), clip = element('clipPath');
|
|
43
|
+
// SVG clip identity is not a credential; also work on non-secure HTTP pages.
|
|
44
|
+
const id = `dsh-cs-bot-${clipPrefix}-${nextClip++}`;
|
|
45
|
+
clip.id = id; clip.append(this.clipBody); defs.append(clip);
|
|
46
|
+
this.eyes.setAttribute('clip-path', `url(#${id})`);
|
|
47
|
+
this.body.classList.add('dsh-cs-body'); this.eyes.classList.add('dsh-cs-eyes');
|
|
48
|
+
this.dots.forEach(dot => dot.classList.add('dsh-cs-dot'));
|
|
49
|
+
this.svg.append(defs, this.body, this.eyes, ...this.dots); host.append(this.svg);
|
|
50
|
+
this.render(this.current);
|
|
51
|
+
document.addEventListener('visibilitychange', this.wake);
|
|
52
|
+
this.reduced.addEventListener('change', this.wake);
|
|
53
|
+
}
|
|
54
|
+
setRunning(running: boolean, done?: () => void) {
|
|
55
|
+
if (running) {
|
|
56
|
+
if (this.mode === 'run') return;
|
|
57
|
+
this.mode = 'run'; this.elapsed = 0; this.done = undefined;
|
|
58
|
+
} else if (this.mode === 'run') {
|
|
59
|
+
this.exitFrom = this.current; this.mode = 'settle'; this.settling = 0; this.done = done;
|
|
60
|
+
} else if (this.mode === 'settle') { this.done = done; return; }
|
|
61
|
+
else { done?.(); }
|
|
62
|
+
this.wake();
|
|
63
|
+
}
|
|
64
|
+
private wake = () => {
|
|
65
|
+
if (this.frame !== undefined) cancelAnimationFrame(this.frame);
|
|
66
|
+
this.frame = undefined; this.lastTime = 0;
|
|
67
|
+
if (this.disposed) return;
|
|
68
|
+
if (this.reduced.matches || document.hidden) {
|
|
69
|
+
if (this.mode === 'settle') this.finish();
|
|
70
|
+
else if (this.reduced.matches) this.render(motion.getBot7State(0));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (this.mode !== 'rest') this.frame = requestAnimationFrame(this.tick);
|
|
74
|
+
};
|
|
75
|
+
private finish() {
|
|
76
|
+
this.mode = 'rest'; this.render(motion.getBot7State(0));
|
|
77
|
+
const done = this.done; this.done = undefined; done?.();
|
|
78
|
+
}
|
|
79
|
+
private tick = (now: number) => {
|
|
80
|
+
this.frame = undefined;
|
|
81
|
+
if (this.disposed) return;
|
|
82
|
+
// Cap work at 30 fps; retain elapsed time across visibility pauses.
|
|
83
|
+
if (!this.lastTime) this.lastTime = now;
|
|
84
|
+
const delta = (now - this.lastTime) / 1000;
|
|
85
|
+
if (delta >= 1 / 30) {
|
|
86
|
+
this.lastTime = now;
|
|
87
|
+
if (this.mode === 'run') { this.elapsed += delta; this.render(motion.getBot7State(orbitTime(this.elapsed))); }
|
|
88
|
+
if (this.mode === 'settle') {
|
|
89
|
+
this.settling += delta;
|
|
90
|
+
if (this.settling >= .32) this.finish();
|
|
91
|
+
else this.render(settleFrame(this.exitFrom, this.settling / .32));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (this.mode !== 'rest') this.frame = requestAnimationFrame(this.tick);
|
|
95
|
+
};
|
|
96
|
+
private render(state: Frame) {
|
|
97
|
+
this.current = state;
|
|
98
|
+
const projected = this.projector.projectRoundedCube(state.bot);
|
|
99
|
+
this.body.setAttribute('d', projected.bodyPath); this.clipBody.setAttribute('d', projected.bodyPath);
|
|
100
|
+
const eyes = state.bot.showEyes === false ? [] : projected.eyes;
|
|
101
|
+
while (this.eyes.children.length > eyes.length) this.eyes.lastChild!.remove();
|
|
102
|
+
while (this.eyes.children.length < eyes.length) this.eyes.append(element('rect'));
|
|
103
|
+
eyes.forEach((eye, index) => {
|
|
104
|
+
const node = this.eyes.children[index]!;
|
|
105
|
+
for (const [key, value] of Object.entries({ x: -eye.w / 2, y: -eye.h / 2, width: eye.w, height: eye.h, rx: eye.rx, ry: eye.ry, opacity: eye.opacity ?? 1 })) node.setAttribute(key, value.toFixed(2));
|
|
106
|
+
node.setAttribute('transform', `translate(${eye.cx.toFixed(2)} ${eye.cy.toFixed(2)}) rotate(${eye.angle.toFixed(2)})`);
|
|
107
|
+
});
|
|
108
|
+
this.dots.forEach((node, index) => {
|
|
109
|
+
const dot = state.dots[index];
|
|
110
|
+
node.setAttribute('r', dot?.visible ? dot.r.toFixed(2) : '0');
|
|
111
|
+
if (dot) { node.setAttribute('cx', dot.x.toFixed(2)); node.setAttribute('cy', dot.y.toFixed(2)); }
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
destroy() {
|
|
115
|
+
this.disposed = true;
|
|
116
|
+
if (this.frame !== undefined) cancelAnimationFrame(this.frame);
|
|
117
|
+
document.removeEventListener('visibilitychange', this.wake);
|
|
118
|
+
this.reduced.removeEventListener('change', this.wake);
|
|
119
|
+
this.svg.remove(); this.done = undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
.dsh-cs-footer { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--dsw-alias-separator, #e7e7e9); }
|
|
2
|
+
.dsh-cs-content { display: flex; flex-direction: column; gap: 7px; }
|
|
3
|
+
.dsh-cs-button { font: inherit; font-size: 12px; line-height: 1.5; border: 0; border-radius: 7px; padding: 6px 10px; color: var(--dsw-alias-label-primary, #30343b); background: var(--dsw-alias-interactive-bg-hover, #f3f4f6); cursor: pointer; white-space: normal; overflow-wrap: anywhere; }
|
|
4
|
+
.dsh-cs-button:hover { filter: brightness(.96); }
|
|
5
|
+
.dsh-cs-button:focus-visible { outline: 2px solid var(--dsw-static-blue-450, #4d88f5); outline-offset: 2px; }
|
|
6
|
+
.dsh-cs-button:disabled { opacity: .5; cursor: default; }
|
|
7
|
+
.dsh-cs-primary { color: var(--dsw-static-blue-550, #3476e3); }
|
|
8
|
+
.dsh-cs-compact { position: relative; isolation: isolate; overflow: hidden; }
|
|
9
|
+
.dsh-cs-compact > .dsh-cs-button-status { position: relative; z-index: 1; display: inline-flex; align-items: center; justify-content: center; max-width: 100%; }
|
|
10
|
+
.dsh-cs-sr-status { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
|
11
|
+
.dsh-cs-compact::before { content: ''; pointer-events: none; position: absolute; inset: 0; background: linear-gradient(105deg, transparent 25%, color-mix(in srgb, currentColor 14%, transparent) 48%, transparent 72%); transform: translateX(-110%); opacity: 0; }
|
|
12
|
+
.dsh-cs-content:is([data-phase="sending"], [data-phase="queued"], [data-phase="running"]) .dsh-cs-compact { opacity: 1; }
|
|
13
|
+
.dsh-cs-content:is([data-phase="sending"], [data-phase="queued"], [data-phase="running"]) .dsh-cs-compact::before { opacity: 1; animation: dsh-cs-shimmer 2.6s ease-in-out infinite; }
|
|
14
|
+
.dsh-cs-content[data-phase="done"] .dsh-cs-compact::before { opacity: 1; animation: dsh-cs-shimmer 900ms ease-out 1 both; }
|
|
15
|
+
.dsh-cs-progress { font-variant-numeric: tabular-nums; white-space: nowrap; margin-left: 6px; flex-shrink: 0; }
|
|
16
|
+
.dsh-cs-waiting-copy { display: inline-grid; min-width: 0; }
|
|
17
|
+
.dsh-cs-waiting-copy > span { grid-area: 1 / 1; opacity: 0; transition: opacity 450ms ease; }
|
|
18
|
+
.dsh-cs-waiting-copy > span[data-active="true"] { opacity: 1; }
|
|
19
|
+
@keyframes dsh-cs-shimmer { 0% { transform: translateX(-110%); } 70%, 100% { transform: translateX(110%); } }
|
|
20
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
21
|
+
.dsh-cs-content:is([data-phase="idle"], [data-phase="done"]) .dsh-cs-compact:not(:disabled):is(:hover, :focus-visible)::before { opacity: 1; animation: dsh-cs-shimmer 900ms ease-out 1 both; }
|
|
22
|
+
}
|
|
23
|
+
.dsh-cs-status, .dsh-cs-error { font-size: 11px; line-height: 1.5; overflow-wrap: anywhere; color: var(--dsw-alias-label-secondary, #747982); }
|
|
24
|
+
.dsh-cs-error { color: var(--dsw-static-red-500, #c54848); }
|
|
25
|
+
.dsh-cs-hint { font-size: 10px; line-height: 1.5; text-align: center; color: var(--dsw-alias-label-tertiary, #8a9099); }
|
|
26
|
+
.dsh-cs-ring { position: relative; touch-action: manipulation; -webkit-touch-callout: none; user-select: none; }
|
|
27
|
+
.dsh-cs-ring-art { position: absolute; inset: auto; display: grid; place-items: center; pointer-events: none; color: var(--dsw-static-blue-550, #3476e3); }
|
|
28
|
+
.dsh-cs-ring-art svg { position: absolute; width: 100%; height: 100%; overflow: visible; }
|
|
29
|
+
.dsh-cs-hold { opacity: 0; transform: rotate(-90deg); }
|
|
30
|
+
.dsh-cs-hold circle { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-dasharray: 1; stroke-dashoffset: 1; }
|
|
31
|
+
[data-cs-holding] .dsh-cs-hold { opacity: 1; }
|
|
32
|
+
[data-cs-holding] .dsh-cs-hold circle { animation: dsh-cs-charge 650ms linear forwards; }
|
|
33
|
+
@keyframes dsh-cs-charge { to { stroke-dashoffset: 0; } }
|
|
34
|
+
.dsh-cs-robot { opacity: 0; transition: opacity 180ms ease, transform 320ms cubic-bezier(.2,.8,.2,1); }
|
|
35
|
+
.dsh-cs-body { fill: currentColor; }
|
|
36
|
+
.dsh-cs-eyes { fill: #fff; }
|
|
37
|
+
.dsh-cs-dot { fill: currentColor; }
|
|
38
|
+
.dsh-cs-ring > svg[viewBox="0 0 14 14"] { transition: opacity 180ms ease; }
|
|
39
|
+
.dsh-cs-ring[data-cs-phase="running"] .dsh-cs-robot { opacity: 1; transform: scale(1); }
|
|
40
|
+
.dsh-cs-ring:is([data-cs-phase="queued"], [data-cs-phase="sending"]) .dsh-cs-robot { opacity: .55; transform: scale(1); }
|
|
41
|
+
.dsh-cs-ring[data-cs-motion="settling"] .dsh-cs-robot { opacity: 0; transform: scale(.42); }
|
|
42
|
+
@media (prefers-reduced-motion: reduce) {
|
|
43
|
+
[data-cs-holding] .dsh-cs-hold circle { animation: none; stroke-dashoffset: .25; }
|
|
44
|
+
.dsh-cs-content[data-phase] .dsh-cs-compact::before { animation: none; opacity: 0; }
|
|
45
|
+
.dsh-cs-waiting-copy > span { transition: none; }
|
|
46
|
+
.dsh-cs-ring > svg[viewBox="0 0 14 14"], .dsh-cs-robot { transition: none; }
|
|
47
|
+
}
|
|
48
|
+
.dsh-cs-actions { display: flex; flex-direction: column; gap: 6px; }
|
|
49
|
+
.dsh-cs-settings { max-width: 640px; display: flex; flex-direction: column; gap: 14px; padding: 24px; color: var(--dsw-alias-label-primary); }
|
|
50
|
+
.dsh-cs-settings h2, .dsh-cs-settings p { margin: 0; }
|
|
51
|
+
.dsh-cs-settings p { font-size: 13px; line-height: 1.7; color: var(--dsw-alias-label-secondary); }
|
|
52
|
+
.dsh-cs-settings label { display: flex; flex-direction: column; gap: 7px; font-size: 13px; }
|
|
53
|
+
.dsh-cs-settings .dsh-cs-toggle { flex-direction: row; align-items: center; }
|
|
54
|
+
.dsh-cs-settings input:not([type=checkbox]), .dsh-cs-settings select { width: 100%; box-sizing: border-box; padding: 9px 10px; font: inherit; color: inherit; background: var(--dsw-alias-interactive-bg-hover, #f3f4f6); border: 1px solid var(--dsw-alias-separator, #e7e7e9); border-radius: 8px; }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Open Bot Motion Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|