dsh-plugin-subscriptions 0.4.2 → 0.5.1
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 +19 -6
- package/README.zh.md +18 -6
- package/lib/auth/device-flow.d.ts +64 -0
- package/lib/auth/device-flow.js +176 -0
- package/lib/auth/oauth-flow.js +1 -1
- package/lib/auth/rpc.d.ts +21 -2
- package/lib/auth/rpc.js +23 -3
- package/lib/auth/store.d.ts +20 -2
- package/lib/auth/store.js +45 -9
- package/lib/client/ImageGallery.d.ts +54 -0
- package/lib/client/ImageGallery.js +112 -0
- package/lib/client/ImageGenerateToolview.d.ts +1 -1
- package/lib/client/ImageGenerateToolview.js +2 -2
- package/lib/client/SpeedSelect.d.ts +48 -0
- package/lib/client/SpeedSelect.js +173 -0
- package/lib/client/SubscriptionsSection.d.ts +10 -1
- package/lib/client/SubscriptionsSection.js +48 -5
- package/lib/client/index.d.ts +1 -0
- package/lib/client/index.js +42 -0
- package/lib/client/locales.d.ts +22 -0
- package/lib/client/locales.js +22 -0
- package/lib/client.js +679 -77
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +3 -2
- package/lib/index.js +1868 -183
- package/lib/providers/catalog-store.js +19 -0
- package/lib/providers/claude.d.ts +20 -1
- package/lib/providers/claude.js +58 -31
- package/lib/providers/codex.d.ts +27 -0
- package/lib/providers/codex.js +117 -27
- package/lib/providers/common.d.ts +34 -1
- package/lib/providers/common.js +48 -1
- package/lib/providers/copilot.d.ts +315 -0
- package/lib/providers/copilot.js +786 -0
- package/lib/providers/grok.d.ts +7 -2
- package/lib/providers/grok.js +46 -18
- package/lib/tools/image-generate.js +3 -10
- package/lib/translate/anthropic.d.ts +47 -6
- package/lib/translate/anthropic.js +135 -20
- package/lib/translate/chat-completions.d.ts +120 -0
- package/lib/translate/chat-completions.js +363 -0
- package/lib/translate/responses.d.ts +49 -5
- package/lib/translate/responses.js +40 -7
- package/package.json +6 -3
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirror of ImageAttachmentRef (packages/attachment/attachment/src/types.ts);
|
|
3
|
+
* the brand on attachmentId is compile-time only, so string suffices here.
|
|
4
|
+
*/
|
|
5
|
+
export interface ImageAttachmentRef {
|
|
6
|
+
attachmentId: string;
|
|
7
|
+
mediaType: string;
|
|
8
|
+
bytes: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
name?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Loads a session-authorized durable image URL (resolves to a data/blob URL). */
|
|
14
|
+
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>;
|
|
15
|
+
/** Lightbox strings forwarded to the opened preview. */
|
|
16
|
+
export interface ImageLightboxLabels {
|
|
17
|
+
dialog: string;
|
|
18
|
+
close: string;
|
|
19
|
+
}
|
|
20
|
+
/** Message-image strings the owner resolves from its own locale namespace. */
|
|
21
|
+
export interface MessageImageLabels {
|
|
22
|
+
/** Fallback display name for an unnamed image. */
|
|
23
|
+
image: string;
|
|
24
|
+
/** Thumbnail tooltip inviting the original-image preview. */
|
|
25
|
+
open: string;
|
|
26
|
+
/** Accessible thumbnail label; receives the image's display name. */
|
|
27
|
+
openNamed: (label: string) => string;
|
|
28
|
+
/** Loading placeholder shown until bytes resolve. */
|
|
29
|
+
loading: string;
|
|
30
|
+
/** Retry-control label shown when the load fails. */
|
|
31
|
+
loadFailed: string;
|
|
32
|
+
/** Lightbox strings forwarded to the opened preview. */
|
|
33
|
+
lightbox: ImageLightboxLabels;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Compact history renderer with retryable loading and click-to-open original
|
|
37
|
+
* preview. A lone image renders at its `singleFit` size; an image among
|
|
38
|
+
* several renders as a fixed 64px square tile.
|
|
39
|
+
*/
|
|
40
|
+
export declare function MessageImage({ attachment, load, variant, labels }: {
|
|
41
|
+
attachment: ImageAttachmentRef;
|
|
42
|
+
load: ImageLoader;
|
|
43
|
+
variant: 'single' | 'tile';
|
|
44
|
+
labels: MessageImageLabels;
|
|
45
|
+
}): import("react").JSX.Element;
|
|
46
|
+
/** Wrapping image group: a lone image renders large, several render as 64px
|
|
47
|
+
* square tiles (same rule as the platform gallery). */
|
|
48
|
+
export declare function ImageGallery({ images, load, labels }: {
|
|
49
|
+
images: readonly {
|
|
50
|
+
attachment: ImageAttachmentRef;
|
|
51
|
+
}[];
|
|
52
|
+
load: ImageLoader;
|
|
53
|
+
labels: MessageImageLabels;
|
|
54
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Plugin-local image gallery for generated-image toolviews.
|
|
4
|
+
*
|
|
5
|
+
* Since web-app rc.8, `@deepseek-ai/dsh-client-ui-attachment`'s browser module
|
|
6
|
+
* exports only its cordis plugin surface (`apply`/`inject`) — the React
|
|
7
|
+
* components are no longer package values, so importing `ImageGallery` from it
|
|
8
|
+
* yields `undefined` at runtime and crashes the toolview inside the slot error
|
|
9
|
+
* boundary (the whole call row disappears). This module re-implements the
|
|
10
|
+
* gallery contract the toolview needs (loader-driven thumbnails, retry on
|
|
11
|
+
* failure, click-to-open lightbox) with the same sizing rules as the platform
|
|
12
|
+
* component, keeping this plugin independent of harness component exports.
|
|
13
|
+
*/
|
|
14
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
15
|
+
/** Display box for a lone image (platform rule): long edge 240px with the
|
|
16
|
+
* rendered aspect ratio clamped to [0.25, 4] — the overflow is cropped by
|
|
17
|
+
* `object-fit: cover` — and never upscaled past the image's natural size. The
|
|
18
|
+
* crop anchor keeps the top of very tall images and the left of very wide
|
|
19
|
+
* ones, where the informative content usually starts. */
|
|
20
|
+
function singleFit(attachment) {
|
|
21
|
+
const natural = attachment.width / attachment.height;
|
|
22
|
+
const ratio = Math.min(4, Math.max(0.25, natural));
|
|
23
|
+
const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 };
|
|
24
|
+
const scale = Math.min(1, attachment.width / box.width, attachment.height / box.height);
|
|
25
|
+
return {
|
|
26
|
+
width: Math.max(1, Math.round(box.width * scale)),
|
|
27
|
+
height: Math.max(1, Math.round(box.height * scale)),
|
|
28
|
+
objectPosition: natural < 0.25 ? 'center top' : natural > 4 ? 'left center' : 'center',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const styles = {
|
|
32
|
+
gallery: { display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-start' },
|
|
33
|
+
frame: {
|
|
34
|
+
display: 'grid', placeItems: 'center', overflow: 'hidden', padding: 0,
|
|
35
|
+
border: '1px solid var(--dsw-alias-border-l2-darkmode-thin)', borderRadius: 8,
|
|
36
|
+
background: 'var(--dsw-alias-interactive-bg-hover-solid)', cursor: 'zoom-in',
|
|
37
|
+
},
|
|
38
|
+
tile: { width: 64, height: 64 },
|
|
39
|
+
img: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' },
|
|
40
|
+
loading: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)', padding: '0 8px' },
|
|
41
|
+
error: {
|
|
42
|
+
fontSize: 12, color: 'var(--dsw-alias-state-error-primary)', cursor: 'pointer',
|
|
43
|
+
border: '1px solid var(--dsw-alias-border-l2-darkmode-thin)', borderRadius: 8,
|
|
44
|
+
background: 'transparent', padding: '6px 10px',
|
|
45
|
+
},
|
|
46
|
+
overlay: {
|
|
47
|
+
position: 'fixed', inset: 0, zIndex: 1000, display: 'grid', placeItems: 'center',
|
|
48
|
+
background: 'rgba(0, 0, 0, 0.72)', padding: 24,
|
|
49
|
+
},
|
|
50
|
+
overlayImg: { maxWidth: '92vw', maxHeight: '92vh', objectFit: 'contain', borderRadius: 4 },
|
|
51
|
+
close: {
|
|
52
|
+
position: 'absolute', top: 12, right: 12, width: 32, height: 32, display: 'grid',
|
|
53
|
+
placeItems: 'center', border: 'none', borderRadius: '50%', cursor: 'pointer',
|
|
54
|
+
background: 'rgba(255, 255, 255, 0.16)', color: '#fff', fontSize: 16, lineHeight: 1,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Full-viewport original-image preview: backdrop or close-control click and
|
|
59
|
+
* Escape all dismiss; the image itself is inert so a click on it does not
|
|
60
|
+
* fall through to the backdrop dismissal.
|
|
61
|
+
*/
|
|
62
|
+
function ImageLightbox({ src, alt, labels, onClose }) {
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const onKey = (event) => { if (event.key === 'Escape')
|
|
65
|
+
onClose(); };
|
|
66
|
+
window.addEventListener('keydown', onKey);
|
|
67
|
+
return () => { window.removeEventListener('keydown', onKey); };
|
|
68
|
+
}, [onClose]);
|
|
69
|
+
return (_jsxs("div", { role: "dialog", "aria-label": labels.dialog, style: styles.overlay, onClick: onClose, children: [_jsx("img", { src: src, alt: alt, style: styles.overlayImg, onClick: event => { event.stopPropagation(); } }), _jsx("button", { type: "button", "aria-label": labels.close, style: styles.close, onClick: onClose, children: "\u00D7" })] }));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Compact history renderer with retryable loading and click-to-open original
|
|
73
|
+
* preview. A lone image renders at its `singleFit` size; an image among
|
|
74
|
+
* several renders as a fixed 64px square tile.
|
|
75
|
+
*/
|
|
76
|
+
export function MessageImage({ attachment, load, variant, labels }) {
|
|
77
|
+
const [src, setSrc] = useState(null);
|
|
78
|
+
const [error, setError] = useState(false);
|
|
79
|
+
const [open, setOpen] = useState(false);
|
|
80
|
+
// Retry re-arms the one load effect below, so every attempt — first load or
|
|
81
|
+
// retry — runs under the same liveness guard and the same reset.
|
|
82
|
+
const [attempt, setAttempt] = useState(0);
|
|
83
|
+
const retry = useCallback(() => { setAttempt(a => a + 1); }, []);
|
|
84
|
+
const close = useCallback(() => { setOpen(false); }, []);
|
|
85
|
+
const fit = useMemo(() => (variant === 'single' ? singleFit(attachment) : undefined), [attachment, variant]);
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
let live = true;
|
|
88
|
+
setError(false);
|
|
89
|
+
setSrc(null);
|
|
90
|
+
void load(attachment).then((url) => { if (live)
|
|
91
|
+
setSrc(url); }).catch(() => { if (live)
|
|
92
|
+
setError(true); });
|
|
93
|
+
return () => { live = false; };
|
|
94
|
+
}, [attachment, load, attempt]);
|
|
95
|
+
const label = attachment.name ?? labels.image;
|
|
96
|
+
if (error) {
|
|
97
|
+
return _jsx("button", { type: "button", style: styles.error, onClick: retry, children: labels.loadFailed });
|
|
98
|
+
}
|
|
99
|
+
const box = fit === undefined ? styles.tile : { width: fit.width, height: fit.height };
|
|
100
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", style: { ...styles.frame, ...box }, title: labels.open, "aria-label": labels.openNamed(label), onClick: () => { if (src !== null)
|
|
101
|
+
setOpen(true); }, children: src === null
|
|
102
|
+
? _jsx("span", { style: styles.loading, children: labels.loading })
|
|
103
|
+
: _jsx("img", { src: src, alt: label, style: { ...styles.img, objectPosition: fit?.objectPosition } }) }), open && src !== null && _jsx(ImageLightbox, { src: src, alt: label, labels: labels.lightbox, onClose: close })] }));
|
|
104
|
+
}
|
|
105
|
+
/** Wrapping image group: a lone image renders large, several render as 64px
|
|
106
|
+
* square tiles (same rule as the platform gallery). */
|
|
107
|
+
export function ImageGallery({ images, load, labels }) {
|
|
108
|
+
if (images.length === 0)
|
|
109
|
+
return null;
|
|
110
|
+
const variant = images.length === 1 ? 'single' : 'tile';
|
|
111
|
+
return (_jsx("div", { style: styles.gallery, children: images.map((image, index) => (_jsx(MessageImage, { attachment: image.attachment, load: load, variant: variant, labels: labels }, `${image.attachment.attachmentId}:${index}`))) }));
|
|
112
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client';
|
|
2
2
|
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client';
|
|
3
|
-
import type { ImageLoader } from '
|
|
3
|
+
import type { ImageLoader } from './ImageGallery.js';
|
|
4
4
|
import type { SubscriptionsKey } from './locales.js';
|
|
5
5
|
/** Mirror of ui-tool's ToolCallOwnerProps (see the module header). */
|
|
6
6
|
interface ToolCallOwnerProps {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { IconSparkle16 } from '@deepseek-ai/dsh-client-ui-primitives';
|
|
3
|
-
import { ImageGallery } from '
|
|
3
|
+
import { ImageGallery } from './ImageGallery.js';
|
|
4
4
|
import { en } from './locales.js';
|
|
5
5
|
/** Logical RPC channel served by the node half of this plugin. */
|
|
6
6
|
const SUBSCRIPTIONS_AUTH_CHANNEL = '/subscriptions-auth';
|
|
@@ -133,5 +133,5 @@ export function ImageGenerateToolview(props) {
|
|
|
133
133
|
loadFailed: t('imageLoadFailed'),
|
|
134
134
|
lightbox: { dialog: t('imagePreview'), close: t('imageClose') },
|
|
135
135
|
};
|
|
136
|
-
return (_jsxs("div", { style: styles.container, children: [_jsxs("div", { style: styles.row, children: [_jsx("span", { style: styles.icon, children: _jsx(IconSparkle16, { size: 14 }) }), _jsx("span", { style: styles.title, children: title })] }), !settled && _jsx("p", { style: styles.subtle, children: t('generating') }), settled && block.isError && text !== '' && (_jsx("p", { style: styles.error, children: text.split('\n', 1)[0] })), settled && !block.isError && images.length > 0 && load !== undefined && (_jsx(ImageGallery, { images: images, load: load,
|
|
136
|
+
return (_jsxs("div", { style: styles.container, children: [_jsxs("div", { style: styles.row, children: [_jsx("span", { style: styles.icon, children: _jsx(IconSparkle16, { size: 14 }) }), _jsx("span", { style: styles.title, children: title })] }), !settled && _jsx("p", { style: styles.subtle, children: t('generating') }), settled && block.isError && text !== '' && (_jsx("p", { style: styles.error, children: text.split('\n', 1)[0] })), settled && !block.isError && images.length > 0 && load !== undefined && (_jsx(ImageGallery, { images: images, load: load, labels: labels })), settled && !block.isError && images.length === 0 && text !== '' && (_jsx("p", { style: styles.output, children: text }))] }));
|
|
137
137
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
2
|
+
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client';
|
|
3
|
+
/** One session's speed choice: standard routing or the fast (priority) tier. */
|
|
4
|
+
export type SpeedTier = 'standard' | 'fast';
|
|
5
|
+
/** `speed` endpoint value, mirrored from the node half. */
|
|
6
|
+
export interface SpeedState {
|
|
7
|
+
tier: SpeedTier;
|
|
8
|
+
fastModels: string[];
|
|
9
|
+
}
|
|
10
|
+
/** What {@link SpeedSelect} renders from: visibility plus the current tier. */
|
|
11
|
+
export interface SpeedSelectState {
|
|
12
|
+
visible: boolean;
|
|
13
|
+
tier: SpeedTier;
|
|
14
|
+
}
|
|
15
|
+
/** Injected dependencies of {@link SpeedSelect} (slot `inject`, session-bound). */
|
|
16
|
+
export interface SpeedSelectInjected {
|
|
17
|
+
/** Load the session's speed state; `visible` false keeps the control hidden. */
|
|
18
|
+
loadSpeed: () => Promise<SpeedSelectState>;
|
|
19
|
+
/** Set the session's speed tier; resolves false when the write failed. */
|
|
20
|
+
setSpeed: (tier: SpeedTier) => Promise<boolean>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Props delivered by the slot outlet: the framework session kit and InputZone
|
|
24
|
+
* owner share (unused — everything arrives session-bound through the inject
|
|
25
|
+
* face), the injected callbacks, and the locale seat.
|
|
26
|
+
*/
|
|
27
|
+
export type SpeedSelectProps = PropsRuntime<'conversation.input.right'> & Partial<SpeedSelectInjected> & Partial<PropsLocale<'settings.subscriptions'>>;
|
|
28
|
+
/**
|
|
29
|
+
* The `loadSpeed` half of the inject face: the plugin's own speed state plus
|
|
30
|
+
* the host's current model selection (the visibility gate). A model-RPC
|
|
31
|
+
* failure throws rather than answering "hidden" — the caller keeps its last
|
|
32
|
+
* known state, so a transient failure never locks the toggle away.
|
|
33
|
+
*
|
|
34
|
+
* `sessionId` is a plain string: slot and command contexts brand it through
|
|
35
|
+
* different dsh-session copies, and only the API-client boundary needs one.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createSpeedLoader(connection: ConnectionHandle, sessionId: string): SpeedSelectInjected['loadSpeed'];
|
|
38
|
+
/** The `setSpeed` half of the inject face: boolean outcome for the component's busy state. */
|
|
39
|
+
export declare function createSpeedSetter(connection: ConnectionHandle, sessionId: string): SpeedSelectInjected['setSpeed'];
|
|
40
|
+
/**
|
|
41
|
+
* The composer Speed control: a trigger reading `速度 · 快速`/`速度 · 标准`
|
|
42
|
+
* that opens a two-row menu (standard/fast with descriptions, check mark on
|
|
43
|
+
* the current tier). The host pushes nothing on a model switch, so the
|
|
44
|
+
* control re-reads on a slow poll with a single-flight guard; a failed read
|
|
45
|
+
* keeps the last known state, so a transient RPC failure can never lock the
|
|
46
|
+
* toggle away (the earlier mount-only load had no recovery path).
|
|
47
|
+
*/
|
|
48
|
+
export declare function SpeedSelect({ loadSpeed, setSpeed, t }: SpeedSelectProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Codex Speed toggle: one small control in the composer's right tool row
|
|
4
|
+
* (`conversation.input.right`), switching the session between standard routing
|
|
5
|
+
* and the fast (priority) service tier — the Codex desktop app's Speed menu.
|
|
6
|
+
* The choice is per session and lives in the node half (in-memory); this
|
|
7
|
+
* component holds only viewing state. The control renders nothing until the
|
|
8
|
+
* first load proves the session's current model is a codex model whose catalog
|
|
9
|
+
* advertises the fast tier.
|
|
10
|
+
*
|
|
11
|
+
* Every color resolves through a `--dsw-alias-*` design token and every
|
|
12
|
+
* user-visible string goes through the locale `t` of the
|
|
13
|
+
* 'settings.subscriptions' namespace, same as the settings section.
|
|
14
|
+
*/
|
|
15
|
+
import { useEffect, useRef, useState } from 'react';
|
|
16
|
+
import { callSubscriptionsAuth } from './SubscriptionsSection.js';
|
|
17
|
+
import { en } from './locales.js';
|
|
18
|
+
/**
|
|
19
|
+
* The `loadSpeed` half of the inject face: the plugin's own speed state plus
|
|
20
|
+
* the host's current model selection (the visibility gate). A model-RPC
|
|
21
|
+
* failure throws rather than answering "hidden" — the caller keeps its last
|
|
22
|
+
* known state, so a transient failure never locks the toggle away.
|
|
23
|
+
*
|
|
24
|
+
* `sessionId` is a plain string: slot and command contexts brand it through
|
|
25
|
+
* different dsh-session copies, and only the API-client boundary needs one.
|
|
26
|
+
*/
|
|
27
|
+
export function createSpeedLoader(connection, sessionId) {
|
|
28
|
+
return async () => {
|
|
29
|
+
const state = await callSubscriptionsAuth(connection.rpc, 'speed', { sessionId });
|
|
30
|
+
const { result } = await connection.api.sessions.models({ sessionId: sessionId });
|
|
31
|
+
if (!result.ok)
|
|
32
|
+
throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`);
|
|
33
|
+
const current = result.value.current;
|
|
34
|
+
const visible = current !== null && current.provider === 'codex'
|
|
35
|
+
&& state.fastModels.includes(current.model);
|
|
36
|
+
return { visible, tier: state.tier };
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** The `setSpeed` half of the inject face: boolean outcome for the component's busy state. */
|
|
40
|
+
export function createSpeedSetter(connection, sessionId) {
|
|
41
|
+
return tier => callSubscriptionsAuth(connection.rpc, 'setSpeed', { sessionId, tier })
|
|
42
|
+
.then(() => true, () => false);
|
|
43
|
+
}
|
|
44
|
+
/** English-dictionary fallback for a missing inject `t` (standalone renders). */
|
|
45
|
+
function fallbackTranslate(key) {
|
|
46
|
+
return en[key];
|
|
47
|
+
}
|
|
48
|
+
const TIERS = ['standard', 'fast'];
|
|
49
|
+
/**
|
|
50
|
+
* The composer Speed control: a trigger reading `速度 · 快速`/`速度 · 标准`
|
|
51
|
+
* that opens a two-row menu (standard/fast with descriptions, check mark on
|
|
52
|
+
* the current tier). Mount and every open reload the host state so a model
|
|
53
|
+
* switch made since the last open self-corrects.
|
|
54
|
+
*/
|
|
55
|
+
/** How often the control re-reads the host state (model switches arrive only by asking). */
|
|
56
|
+
const POLL_INTERVAL_MS = 3000;
|
|
57
|
+
/**
|
|
58
|
+
* The composer Speed control: a trigger reading `速度 · 快速`/`速度 · 标准`
|
|
59
|
+
* that opens a two-row menu (standard/fast with descriptions, check mark on
|
|
60
|
+
* the current tier). The host pushes nothing on a model switch, so the
|
|
61
|
+
* control re-reads on a slow poll with a single-flight guard; a failed read
|
|
62
|
+
* keeps the last known state, so a transient RPC failure can never lock the
|
|
63
|
+
* toggle away (the earlier mount-only load had no recovery path).
|
|
64
|
+
*/
|
|
65
|
+
export function SpeedSelect({ loadSpeed, setSpeed, t }) {
|
|
66
|
+
const translate = t ?? fallbackTranslate;
|
|
67
|
+
const [state, setState] = useState(null);
|
|
68
|
+
const [open, setOpen] = useState(false);
|
|
69
|
+
const [busy, setBusy] = useState(false);
|
|
70
|
+
const rootRef = useRef(null);
|
|
71
|
+
// The inject face may be re-evaluated (new callback identities) on re-render;
|
|
72
|
+
// the poll effect mounts once and reads through this ref, so identity churn
|
|
73
|
+
// neither resets the interval nor multiplies in-flight loads.
|
|
74
|
+
const loadRef = useRef(loadSpeed);
|
|
75
|
+
loadRef.current = loadSpeed;
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (loadRef.current === undefined)
|
|
78
|
+
return;
|
|
79
|
+
let cancelled = false;
|
|
80
|
+
let inflight = false;
|
|
81
|
+
const reload = () => {
|
|
82
|
+
const load = loadRef.current;
|
|
83
|
+
if (load === undefined || inflight)
|
|
84
|
+
return;
|
|
85
|
+
inflight = true;
|
|
86
|
+
void load().then((loaded) => { if (!cancelled)
|
|
87
|
+
setState(loaded); }, () => { }).finally(() => { inflight = false; });
|
|
88
|
+
};
|
|
89
|
+
reload();
|
|
90
|
+
const timer = setInterval(reload, POLL_INTERVAL_MS);
|
|
91
|
+
return () => {
|
|
92
|
+
cancelled = true;
|
|
93
|
+
clearInterval(timer);
|
|
94
|
+
};
|
|
95
|
+
}, []);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!open)
|
|
98
|
+
return;
|
|
99
|
+
const closeOutside = (event) => {
|
|
100
|
+
if (!rootRef.current?.contains(event.target))
|
|
101
|
+
setOpen(false);
|
|
102
|
+
};
|
|
103
|
+
document.addEventListener('mousedown', closeOutside);
|
|
104
|
+
return () => { document.removeEventListener('mousedown', closeOutside); };
|
|
105
|
+
}, [open]);
|
|
106
|
+
if (loadSpeed === undefined || setSpeed === undefined || state === null || !state.visible) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const choose = (tier) => {
|
|
110
|
+
if (busy)
|
|
111
|
+
return;
|
|
112
|
+
if (tier === state.tier) {
|
|
113
|
+
setOpen(false);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
setBusy(true);
|
|
117
|
+
void setSpeed(tier).then((ok) => {
|
|
118
|
+
setBusy(false);
|
|
119
|
+
if (ok) {
|
|
120
|
+
setState({ visible: true, tier });
|
|
121
|
+
setOpen(false);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
const show = () => {
|
|
126
|
+
setOpen(true);
|
|
127
|
+
const load = loadRef.current;
|
|
128
|
+
if (load === undefined)
|
|
129
|
+
return;
|
|
130
|
+
void load().then(setState, () => { });
|
|
131
|
+
};
|
|
132
|
+
const tierName = (tier) => translate(tier === 'fast' ? 'speedFast' : 'speedStandard');
|
|
133
|
+
const tierDescription = (tier) => translate(tier === 'fast' ? 'speedFastDescription' : 'speedStandardDescription');
|
|
134
|
+
const triggerLabel = `${translate('speed')} · ${tierName(state.tier)}`;
|
|
135
|
+
return (_jsxs("div", { ref: rootRef, style: styles.root, onKeyDown: (event) => {
|
|
136
|
+
if (event.key === 'Escape' && open) {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
setOpen(false);
|
|
139
|
+
}
|
|
140
|
+
}, children: [open && (_jsx("div", { style: styles.menu, role: "menu", "aria-label": translate('speed'), children: TIERS.map(tier => (_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": tier === state.tier, style: styles.item, disabled: busy, onClick: () => { choose(tier); }, children: [_jsx("span", { style: styles.itemCheck, children: tier === state.tier ? '✓' : '' }), _jsxs("span", { style: styles.itemText, children: [_jsx("span", { style: styles.itemName, children: tierName(tier) }), _jsx("span", { style: styles.itemDescription, children: tierDescription(tier) })] })] }, tier))) })), _jsx("button", { type: "button", style: styles.trigger, "aria-haspopup": "menu", "aria-expanded": open, title: triggerLabel, disabled: busy, onClick: () => {
|
|
141
|
+
if (open)
|
|
142
|
+
setOpen(false);
|
|
143
|
+
else
|
|
144
|
+
show();
|
|
145
|
+
}, children: triggerLabel })] }));
|
|
146
|
+
}
|
|
147
|
+
const styles = {
|
|
148
|
+
root: { position: 'relative', display: 'inline-flex' },
|
|
149
|
+
trigger: {
|
|
150
|
+
border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8,
|
|
151
|
+
background: 'transparent', color: 'var(--dsw-alias-label-secondary)',
|
|
152
|
+
font: 'inherit', fontSize: 12, lineHeight: '18px',
|
|
153
|
+
padding: '2px 8px', cursor: 'pointer', whiteSpace: 'nowrap',
|
|
154
|
+
},
|
|
155
|
+
menu: {
|
|
156
|
+
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
|
|
157
|
+
minWidth: 180, padding: 4, zIndex: 20,
|
|
158
|
+
background: 'var(--dsw-alias-bg-layer-1)', border: '1px solid var(--dsw-alias-border-l2)',
|
|
159
|
+
borderRadius: 8, display: 'flex', flexDirection: 'column', gap: 2,
|
|
160
|
+
},
|
|
161
|
+
item: {
|
|
162
|
+
display: 'flex', alignItems: 'flex-start', gap: 6, width: '100%',
|
|
163
|
+
border: 'none', borderRadius: 6, background: 'transparent',
|
|
164
|
+
padding: '6px 8px', cursor: 'pointer', font: 'inherit', textAlign: 'left',
|
|
165
|
+
},
|
|
166
|
+
itemCheck: {
|
|
167
|
+
width: 14, flexShrink: 0, fontSize: 12, lineHeight: '18px',
|
|
168
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
169
|
+
},
|
|
170
|
+
itemText: { display: 'flex', flexDirection: 'column' },
|
|
171
|
+
itemName: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-primary)' },
|
|
172
|
+
itemDescription: { fontSize: 11, lineHeight: '16px', color: 'var(--dsw-alias-label-tertiary)' },
|
|
173
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client';
|
|
2
2
|
import type { SubscriptionsKey } from './locales.js';
|
|
3
3
|
/** Subscription provider ids, fixed by the node half's OAuth adapters. */
|
|
4
|
-
export type SubscriptionProvider = 'codex' | 'claude' | 'grok';
|
|
4
|
+
export type SubscriptionProvider = 'codex' | 'claude' | 'grok' | 'copilot';
|
|
5
5
|
/** One provider's login state as answered by the `status` endpoint. */
|
|
6
6
|
export interface ProviderStatus {
|
|
7
7
|
loggedIn: boolean;
|
|
@@ -35,6 +35,15 @@ export interface SubscriptionsSectionInjected {
|
|
|
35
35
|
* renderer erases the share boundary at the render call).
|
|
36
36
|
*/
|
|
37
37
|
export type SubscriptionsSectionProps = Partial<SubscriptionsSectionInjected>;
|
|
38
|
+
/**
|
|
39
|
+
* Call one `/subscriptions-auth` endpoint and unwrap the business result.
|
|
40
|
+
* Shared by the settings section and the composer Speed toggle.
|
|
41
|
+
* @param rpc - Connection RPC caller.
|
|
42
|
+
* @param endpoint - channel-relative endpoint.
|
|
43
|
+
* @param payload - channel-owned request payload.
|
|
44
|
+
* @returns the success value, cast by the caller to the endpoint's shape.
|
|
45
|
+
*/
|
|
46
|
+
export declare function callSubscriptionsAuth<T>(rpc: ConnectionHandle['rpc'], endpoint: string, payload: unknown): Promise<T>;
|
|
38
47
|
/**
|
|
39
48
|
* The Subscriptions settings page component.
|
|
40
49
|
* @param props - the slot inject face ({@link SubscriptionsSectionInjected}).
|
|
@@ -24,18 +24,20 @@ const PROVIDERS = [
|
|
|
24
24
|
{ id: 'codex', name: 'Codex (ChatGPT)' },
|
|
25
25
|
{ id: 'claude', name: 'Claude' },
|
|
26
26
|
{ id: 'grok', name: 'Grok (X Premium)' },
|
|
27
|
+
{ id: 'copilot', name: 'GitHub Copilot' },
|
|
27
28
|
];
|
|
28
29
|
/** Business error returned by the `/subscriptions-auth` channel (error branch message). */
|
|
29
30
|
class SubscriptionsAuthError extends Error {
|
|
30
31
|
}
|
|
31
32
|
/**
|
|
32
33
|
* Call one `/subscriptions-auth` endpoint and unwrap the business result.
|
|
34
|
+
* Shared by the settings section and the composer Speed toggle.
|
|
33
35
|
* @param rpc - Connection RPC caller.
|
|
34
36
|
* @param endpoint - channel-relative endpoint.
|
|
35
37
|
* @param payload - channel-owned request payload.
|
|
36
38
|
* @returns the success value, cast by the caller to the endpoint's shape.
|
|
37
39
|
*/
|
|
38
|
-
async function callSubscriptionsAuth(rpc, endpoint, payload) {
|
|
40
|
+
export async function callSubscriptionsAuth(rpc, endpoint, payload) {
|
|
39
41
|
let result;
|
|
40
42
|
try {
|
|
41
43
|
result = await rpc.call(SUBSCRIPTIONS_AUTH_CHANNEL, endpoint, payload);
|
|
@@ -121,6 +123,15 @@ const styles = {
|
|
|
121
123
|
padding: '0 10px', font: 'inherit', fontSize: 14, lineHeight: '22px',
|
|
122
124
|
background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)',
|
|
123
125
|
},
|
|
126
|
+
deviceCode: {
|
|
127
|
+
marginTop: 4, display: 'flex', flexDirection: 'column', gap: 6,
|
|
128
|
+
border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8,
|
|
129
|
+
padding: '10px 12px', background: 'var(--dsw-alias-bg-layer-1)',
|
|
130
|
+
},
|
|
131
|
+
deviceCodeText: {
|
|
132
|
+
fontFamily: 'monospace', fontSize: 18, lineHeight: '24px', letterSpacing: 2,
|
|
133
|
+
color: 'var(--dsw-alias-label-primary)', userSelect: 'all',
|
|
134
|
+
},
|
|
124
135
|
};
|
|
125
136
|
/** Status dot color for one provider state. */
|
|
126
137
|
function dotColor(status) {
|
|
@@ -188,8 +199,11 @@ export function SubscriptionsSection(props) {
|
|
|
188
199
|
const [statuses, setStatuses] = useState({});
|
|
189
200
|
const [errors, setErrors] = useState({});
|
|
190
201
|
const [manualDrafts, setManualDrafts] = useState({
|
|
191
|
-
codex: '', claude: '', grok: '',
|
|
202
|
+
codex: '', claude: '', grok: '', copilot: '',
|
|
192
203
|
});
|
|
204
|
+
/** Pending device-flow codes (copilot), shown while the attempt polls. */
|
|
205
|
+
const [deviceCodes, setDeviceCodes] = useState({});
|
|
206
|
+
const [copiedCode, setCopiedCode] = useState(undefined);
|
|
193
207
|
const [usages, setUsages] = useState({});
|
|
194
208
|
const [usageErrors, setUsageErrors] = useState({});
|
|
195
209
|
const [usageLoading, setUsageLoading] = useState({});
|
|
@@ -234,8 +248,17 @@ export function SubscriptionsSection(props) {
|
|
|
234
248
|
setStatuses(response.providers);
|
|
235
249
|
for (const { id } of PROVIDERS) {
|
|
236
250
|
const status = response.providers[id];
|
|
237
|
-
if (status.loggedIn || !status.busy)
|
|
251
|
+
if (status.loggedIn || !status.busy) {
|
|
238
252
|
stopPolling(id);
|
|
253
|
+
// The attempt settled (success, timeout, or cancel): drop the code card.
|
|
254
|
+
setDeviceCodes((prev) => {
|
|
255
|
+
if (prev[id] === undefined)
|
|
256
|
+
return prev;
|
|
257
|
+
const next = { ...prev };
|
|
258
|
+
delete next[id];
|
|
259
|
+
return next;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
239
262
|
}
|
|
240
263
|
}, [rpc, stopPolling]);
|
|
241
264
|
const startPolling = useCallback((provider) => {
|
|
@@ -331,11 +354,18 @@ export function SubscriptionsSection(props) {
|
|
|
331
354
|
if (typeof response.authorizeUrl !== 'string') {
|
|
332
355
|
throw new SubscriptionsAuthError(t('loginMissingUrl'));
|
|
333
356
|
}
|
|
334
|
-
window.open(response.authorizeUrl, '_blank', 'noopener');
|
|
335
357
|
if (!mountedRef.current)
|
|
336
358
|
return;
|
|
337
359
|
// Optimistic busy so Cancel and the manual fallback appear before the first poll tick.
|
|
338
360
|
setStatuses(prev => ({ ...prev, [provider]: { ...prev[provider], busy: true, loggedIn: false } }));
|
|
361
|
+
if (typeof response.userCode === 'string' && response.userCode.length > 0) {
|
|
362
|
+
// Device flow: show the code card instead of opening the page blind —
|
|
363
|
+
// the user copies the code first, then opens the verification page.
|
|
364
|
+
setDeviceCodes(prev => ({ ...prev, [provider]: { userCode: response.userCode, verificationUrl: response.authorizeUrl } }));
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
window.open(response.authorizeUrl, '_blank', 'noopener');
|
|
368
|
+
}
|
|
339
369
|
startPolling(provider);
|
|
340
370
|
}
|
|
341
371
|
catch (error) {
|
|
@@ -385,12 +415,25 @@ export function SubscriptionsSection(props) {
|
|
|
385
415
|
}
|
|
386
416
|
await refresh();
|
|
387
417
|
}, [rpc, t, setProviderError, refresh]);
|
|
418
|
+
const copyDeviceCode = useCallback((provider, userCode) => {
|
|
419
|
+
void navigator.clipboard?.writeText(userCode).then(() => {
|
|
420
|
+
if (!mountedRef.current)
|
|
421
|
+
return;
|
|
422
|
+
setCopiedCode(provider);
|
|
423
|
+
setTimeout(() => {
|
|
424
|
+
if (mountedRef.current) {
|
|
425
|
+
setCopiedCode(current => current === provider ? undefined : current);
|
|
426
|
+
}
|
|
427
|
+
}, 1500);
|
|
428
|
+
}).catch(() => undefined);
|
|
429
|
+
}, []);
|
|
388
430
|
if (rpc === undefined) {
|
|
389
431
|
return _jsx("p", { style: styles.intro, children: t('unavailable') });
|
|
390
432
|
}
|
|
391
433
|
return (_jsxs("div", { style: styles.section, children: [_jsx("p", { style: styles.intro, children: t('intro') }), PROVIDERS.map(({ id, name }) => {
|
|
392
434
|
const status = statuses[id];
|
|
393
435
|
const busy = status?.busy === true;
|
|
436
|
+
const deviceCode = deviceCodes[id];
|
|
394
437
|
const usage = usages[id];
|
|
395
438
|
const usageError = usageErrors[id];
|
|
396
439
|
// Providers without a usage endpoint answer supported:false — no block.
|
|
@@ -400,6 +443,6 @@ export function SubscriptionsSection(props) {
|
|
|
400
443
|
const percent = Math.min(100, Math.max(0, window.usedPercent));
|
|
401
444
|
return (_jsxs("div", { style: styles.usageRow, children: [_jsxs("div", { style: styles.usageMeta, children: [_jsx("span", { children: usageWindowLabel(t, window) }), _jsxs("span", { children: [`${String(Math.round(percent))}%`, window.resetsAt !== undefined
|
|
402
445
|
&& ` · ${t('usageResets', { date: new Date(window.resetsAt).toLocaleString() })}`] })] }), _jsx("div", { style: styles.usageTrack, children: _jsx("div", { style: { ...styles.usageFill, width: `${String(percent)}%`, background: usageBarColor(percent) } }) })] }, index));
|
|
403
|
-
})] })), busy && (_jsxs("details", { style: styles.manual, children: [_jsx("summary", { children: t('manualSummary') }), _jsxs("div", { style: styles.manualRow, children: [_jsx("input", { style: styles.manualInput, value: manualDrafts[id], placeholder: t('manualPlaceholder'), onChange: event => setManualDrafts(prev => ({ ...prev, [id]: event.target.value })) }), _jsx("button", { type: "button", style: styles.button, onClick: () => { void submitManual(id); }, children: t('submit') })] })] }))] }, id));
|
|
446
|
+
})] })), busy && deviceCode !== undefined && (_jsxs("div", { style: styles.deviceCode, children: [_jsx("span", { style: styles.statusLine, children: t('deviceCodePrompt') }), _jsx("span", { style: styles.deviceCodeText, children: deviceCode.userCode }), _jsxs("div", { style: styles.actions, children: [_jsx("button", { type: "button", style: styles.button, onClick: () => { copyDeviceCode(id, deviceCode.userCode); }, children: copiedCode === id ? t('deviceCodeCopied') : t('deviceCodeCopy') }), _jsx("button", { type: "button", style: styles.button, onClick: () => { window.open(deviceCode.verificationUrl, '_blank', 'noopener'); }, children: t('deviceCodeOpenPage') })] })] })), busy && deviceCode === undefined && (_jsxs("details", { style: styles.manual, children: [_jsx("summary", { children: t('manualSummary') }), _jsxs("div", { style: styles.manualRow, children: [_jsx("input", { style: styles.manualInput, value: manualDrafts[id], placeholder: t('manualPlaceholder'), onChange: event => setManualDrafts(prev => ({ ...prev, [id]: event.target.value })) }), _jsx("button", { type: "button", style: styles.button, onClick: () => { void submitManual(id); }, children: t('submit') })] })] }))] }, id));
|
|
404
447
|
})] }));
|
|
405
448
|
}
|
package/lib/client/index.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { SubscriptionsKey } from './locales.js';
|
|
|
11
11
|
export type { SubscriptionsSectionInjected, SubscriptionsSectionProps } from './SubscriptionsSection.js';
|
|
12
12
|
export type { ImageGenerateToolviewInjected, ImageGenerateToolviewProps } from './ImageGenerateToolview.js';
|
|
13
13
|
export type { VideoGenerateToolviewInjected, VideoGenerateToolviewProps } from './VideoGenerateToolview.js';
|
|
14
|
+
export type { SpeedSelectInjected, SpeedSelectProps, SpeedState, SpeedTier } from './SpeedSelect.js';
|
|
14
15
|
export type { SubscriptionsKey } from './locales.js';
|
|
15
16
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
16
17
|
interface LocaleNamespaceMap {
|
package/lib/client/index.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { SubscriptionsSection } from './SubscriptionsSection.js';
|
|
5
5
|
import { ImageGenerateToolview, createImageLoader } from './ImageGenerateToolview.js';
|
|
6
6
|
import { VideoGenerateToolview, createVideoLoader } from './VideoGenerateToolview.js';
|
|
7
|
+
import { SpeedSelect, createSpeedLoader, createSpeedSetter } from './SpeedSelect.js';
|
|
7
8
|
import { en, zh } from './locales.js';
|
|
8
9
|
/** Dictionary namespace owned by this plugin. */
|
|
9
10
|
const NS = 'settings.subscriptions';
|
|
@@ -53,4 +54,45 @@ export function apply(ctx) {
|
|
|
53
54
|
locale: NS,
|
|
54
55
|
inject: videoToolviewInjected,
|
|
55
56
|
}, VideoGenerateToolview));
|
|
57
|
+
// The composer Speed toggle (codex fast tier) sits in the right tool row,
|
|
58
|
+
// just left of the model selector; the framework synthesizes its `t` seat
|
|
59
|
+
// from `locale: NS`, and the inject face binds each session's RPC calls.
|
|
60
|
+
ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
|
|
61
|
+
name: 'conversation.input.right',
|
|
62
|
+
id: 'codex-speed',
|
|
63
|
+
order: 0,
|
|
64
|
+
locale: NS,
|
|
65
|
+
inject: (sessionId) => ({
|
|
66
|
+
loadSpeed: createSpeedLoader(connection, sessionId),
|
|
67
|
+
setSpeed: createSpeedSetter(connection, sessionId),
|
|
68
|
+
}),
|
|
69
|
+
}, SpeedSelect));
|
|
70
|
+
// The /fast slash command offers the same Standard/Fast choice as a popup.
|
|
71
|
+
// `available` is synchronous and sees only the session id, so the command
|
|
72
|
+
// stays listed everywhere; `options` throws the friendly gate when the
|
|
73
|
+
// session's current model is not a fast-capable codex model (the same
|
|
74
|
+
// in-popup error posture the /model contribution uses for its guards).
|
|
75
|
+
ctx.inject(['commandUi'], (scope) => {
|
|
76
|
+
const command = scope.get('commandUi');
|
|
77
|
+
scope.effect(() => command.register({
|
|
78
|
+
name: 'fast',
|
|
79
|
+
description: t('commandFast'),
|
|
80
|
+
available: () => true,
|
|
81
|
+
ui: {
|
|
82
|
+
kind: 'popupSelect',
|
|
83
|
+
options: async (session) => {
|
|
84
|
+
const state = await createSpeedLoader(connection, session.sessionId)();
|
|
85
|
+
if (!state.visible)
|
|
86
|
+
throw new Error(t('commandFastUnavailable'));
|
|
87
|
+
return [
|
|
88
|
+
{ id: 'standard', label: t('speedStandard'), detail: t('speedStandardDescription') },
|
|
89
|
+
{ id: 'fast', label: t('speedFast'), detail: t('speedFastDescription') },
|
|
90
|
+
].map(option => ({ ...option, active: option.id === state.tier }));
|
|
91
|
+
},
|
|
92
|
+
onSelect: async (option, session) => {
|
|
93
|
+
await createSpeedSetter(connection, session.sessionId)(option.id);
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
}), 'dsh-plugin-subscriptions: /fast contribution');
|
|
97
|
+
});
|
|
56
98
|
}
|