cortico 0.1.0 → 0.1.2
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 +7 -14
- package/package.json +1 -3
- package/src/boot.ts +18 -0
- package/src/bot.ts +41 -6
- package/src/core/README.md +4 -6
- package/src/core/blobs.ts +23 -2
- package/src/core/config-schema.ts +1 -1
- package/src/core/config.ts +2 -16
- package/src/core/core.ts +15 -5
- package/src/core/loop.ts +55 -27
- package/src/core/session.ts +3 -2
- package/src/core/timers.ts +8 -6
- package/src/core/types.ts +19 -3
- package/src/core/util.ts +17 -1
- package/src/deploy.ts +4 -1
- package/src/extensions/README.md +6 -4
- package/src/extensions/dry-mount.ts +23 -2
- package/src/extensions/manifest.ts +21 -11
- package/src/extensions.ts +147 -23
- package/src/launcher.ts +23 -9
- package/src/protocol/open-responses/context-log.ts +37 -9
- package/src/providers/README.md +32 -8
- package/src/providers/base.ts +3 -1
- package/src/providers/configuration.ts +2 -1
- package/src/providers/console/hub.ts +270 -0
- package/src/providers/console/settings.ts +8 -18
- package/src/providers/console/types.ts +5 -0
- package/src/providers/hub-api.ts +3 -0
- package/src/providers/llamacpp/config.ts +8 -6
- package/src/providers/llamacpp/console/runtime-panel.ts +5 -1
- package/src/providers/llamacpp/console/server.ts +3 -1
- package/src/providers/llamacpp/index.ts +3 -1
- package/src/providers/llamacpp/native.ts +10 -5
- package/src/providers/llamacpp/strings.ts +6 -6
- package/src/providers/name.ts +8 -0
- package/src/providers/openai-responses-compat/config.ts +13 -0
- package/src/providers/openai-responses-compat/console/client.ts +5 -0
- package/src/providers/openai-responses-compat/console/reasoning-panel.ts +84 -0
- package/src/providers/openai-responses-compat/console/server.ts +127 -0
- package/src/providers/openai-responses-compat/index.ts +38 -13
- package/src/providers/openai-responses-compat/native.ts +9 -4
- package/src/providers/openai-responses-compat/strings.ts +62 -1
- package/src/providers/registry.ts +5 -0
- package/src/providers/transport/responses-input.ts +59 -10
- package/src/web/README.md +11 -6
- package/src/web/auth.ts +80 -0
- package/src/web/client/console-pages/builtins/llm-settings/panel.ts +5 -4
- package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +22 -8
- package/src/web/client/console-pages/builtins/llm-settings/strings.ts +6 -8
- package/src/web/client/console-pages/host.ts +24 -7
- package/src/web/client/core/api.ts +5 -1
- package/src/web/client/core/router.ts +15 -0
- package/src/web/client/features/core/strings.ts +2 -2
- package/src/web/client/features/extensions/index.ts +276 -41
- package/src/web/client/features/extensions/strings.ts +84 -10
- package/src/web/client/features/feature.ts +2 -2
- package/src/web/client/features/live/diagnostics.ts +32 -0
- package/src/web/client/features/live/index.ts +34 -11
- package/src/web/client/features/live/onboarding.ts +4 -1
- package/src/web/client/features/live/protocol.ts +1 -0
- package/src/web/client/features/live/strings.ts +20 -14
- package/src/web/client/features/live/timeline.ts +2 -1
- package/src/web/client/features/providers/detail.ts +262 -0
- package/src/web/client/features/providers/drafts.ts +23 -0
- package/src/web/client/features/providers/index.ts +173 -87
- package/src/web/client/features/providers/strings.ts +44 -17
- package/src/web/client/features/providers/types.ts +15 -0
- package/src/web/client/features/settings/general.ts +13 -0
- package/src/web/client/features/settings/index.ts +1 -0
- package/src/web/client/features/settings/strings.ts +6 -0
- package/src/web/client/features/worlds/index.ts +1 -0
- package/src/web/client/main.ts +4 -0
- package/src/web/client/shell/index.ts +28 -48
- package/src/web/client/shell/strings.ts +0 -22
- package/src/web/client/ui/icons.ts +14 -1
- package/src/web/client/ui/prompt-input.tsx +17 -5
- package/src/web/client/ui/strings.ts +0 -2
- package/src/web/console-pages.ts +1 -1
- package/src/web/diagnostics.ts +133 -0
- package/src/web/public/login.html +67 -0
- package/src/web/public/styles.css +143 -26
- package/src/web/server.ts +233 -19
- package/src/web/shared/client-panel.ts +4 -1
- package/src/web/shared/console-protocol.ts +4 -1
- package/src/worlds/bilibili/README.md +1 -1
- package/src/worlds/bilibili/overlay/server.ts +4 -2
- package/src/worlds/minecraft/ADAPT.md +66 -0
- package/src/worlds/minecraft/README.md +69 -11
- package/src/worlds/minecraft/cell-facts.ts +226 -0
- package/src/worlds/minecraft/chests.ts +5 -0
- package/src/worlds/minecraft/containers.ts +325 -0
- package/src/worlds/minecraft/entity-facts.ts +31 -5
- package/src/worlds/minecraft/executor.ts +283 -10348
- package/src/worlds/minecraft/inventory.ts +268 -0
- package/src/worlds/minecraft/melee.ts +419 -0
- package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
- package/src/worlds/minecraft/placed-ledger.ts +152 -0
- package/src/worlds/minecraft/placement.ts +1038 -0
- package/src/worlds/minecraft/receipt.ts +340 -0
- package/src/worlds/minecraft/skill-context.ts +358 -0
- package/src/worlds/minecraft/skills-build.ts +1169 -0
- package/src/worlds/minecraft/skills-container.ts +1343 -0
- package/src/worlds/minecraft/skills-craft.ts +333 -0
- package/src/worlds/minecraft/skills-dig.ts +624 -0
- package/src/worlds/minecraft/skills-gather.ts +1230 -0
- package/src/worlds/minecraft/skills-interact.ts +1559 -0
- package/src/worlds/minecraft/tools.ts +331 -0
- package/src/worlds/minecraft/travel.ts +763 -0
- package/src/worlds/minecraft/until.ts +75 -0
- package/src/worlds/qq/normalize.ts +14 -0
- package/src/worlds/qq/world.ts +53 -7
- package/src/worlds/terminal/world.ts +5 -3
|
@@ -8,12 +8,13 @@ import type {
|
|
|
8
8
|
ConsoleStreamHandle,
|
|
9
9
|
Disposable,
|
|
10
10
|
} from '../../../shared/client-panel.ts';
|
|
11
|
-
import {
|
|
11
|
+
import { panelStreamRoute } from '../../../shared/console-protocol.ts';
|
|
12
12
|
import type { FeatureContext, FrameworkFeature } from '../feature.ts';
|
|
13
13
|
import { get, post } from '../../core/api.ts';
|
|
14
14
|
import { openStream } from '../../core/stream.ts';
|
|
15
15
|
import { browserSocketEnv, openFrameworkSocket, type SocketEnv } from '../../core/websocket.ts';
|
|
16
16
|
import { buildCtxPanel, computeCtx, type ContextBreakdown } from './context.ts';
|
|
17
|
+
import { exportDiagnostics } from './diagnostics.ts';
|
|
17
18
|
import { createForkView, MAIN_ID, MAIN_LABEL } from './fork.ts';
|
|
18
19
|
import {
|
|
19
20
|
arr,
|
|
@@ -22,7 +23,7 @@ import {
|
|
|
22
23
|
type StatusSnapshot,
|
|
23
24
|
type ToolSchemaDoc,
|
|
24
25
|
} from './protocol.ts';
|
|
25
|
-
import {
|
|
26
|
+
import { icon } from '../../ui/icons.ts';
|
|
26
27
|
import { createOnboarding, type OnboardingView } from './onboarding.ts';
|
|
27
28
|
import { createSessionBand } from './sessions.ts';
|
|
28
29
|
import { applyDisplayName, createStatusBand } from './status.ts';
|
|
@@ -150,13 +151,29 @@ function mountLive(ctx: FeatureContext, env: SocketEnv): Disposable | void {
|
|
|
150
151
|
};
|
|
151
152
|
|
|
152
153
|
const summary = ui.h('div', 'live-summary');
|
|
153
|
-
|
|
154
|
+
const connection = ui.button('', { onClick: () => ctx.router.navigate(state.status?.modelConnection ? ['providers', state.status.modelConnection.name] : ['providers']) });
|
|
155
|
+
connection.className = 'live-connection';
|
|
156
|
+
const connectionValue = ui.h('span', 'live-connection-value');
|
|
157
|
+
connection.append(ui.h('span', 'live-connection-label', S.currentProvider), connectionValue);
|
|
158
|
+
summary.append(status.el, ui.h('span', 'grow'), connection, netEl);
|
|
154
159
|
band.append(summary, sessionBand.el);
|
|
155
160
|
|
|
161
|
+
const exportButton = ui.button('', {
|
|
162
|
+
size: 'sm',
|
|
163
|
+
onClick: () => {
|
|
164
|
+
void exportDiagnostics({ doc, signal: ctx.signal }).catch((err: unknown) => {
|
|
165
|
+
ui.toast(S.exportFailed, 'bad');
|
|
166
|
+
ctx.onError(err);
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
exportButton.className += ' btn-ico';
|
|
171
|
+
exportButton.append(icon(doc, 'download'), ui.h('span', null, S.exportDiagnostics));
|
|
172
|
+
|
|
156
173
|
const composer = ui.promptInput({
|
|
157
174
|
label: S.composerLabel,
|
|
158
175
|
placeholder: S.composerPlaceholder,
|
|
159
|
-
|
|
176
|
+
leading: exportButton,
|
|
160
177
|
tools: ctxAnchor,
|
|
161
178
|
images: { max: 8 },
|
|
162
179
|
onSubmit: (text, images) => {
|
|
@@ -175,21 +192,20 @@ function mountLive(ctx: FeatureContext, env: SocketEnv): Disposable | void {
|
|
|
175
192
|
timeline.rebuild([], { empty: S.emptyConnecting });
|
|
176
193
|
|
|
177
194
|
let providerReady = false;
|
|
178
|
-
ctx.lifecycle.own(subscribeLamps(ctx.root.ownerDocument, (lamps) => {
|
|
179
|
-
const lamp = lamps[PROVIDERS_LAMP_ID]?.[0];
|
|
180
|
-
composer.setPlaceholder(lamp && lamp.state !== 'online' ? S.composerNoProvider : null);
|
|
181
|
-
providerReady = lamp?.state === 'online';
|
|
182
|
-
onboarding?.setProvider(providerReady);
|
|
183
|
-
}));
|
|
184
195
|
|
|
185
196
|
const resumeRun = (): Promise<unknown> => post('/api/run/resume', {}, { signal: ctx.signal });
|
|
186
197
|
|
|
198
|
+
/**
|
|
199
|
+
* 开场引导认部署目录里那个一次性标记(`.onboarding`,自建部署时写入)。session 与事件游标
|
|
200
|
+
* 都当不了判据:全新部署起来就有一条系统前缀和一条 session 开场事件,而上下文交接后 session
|
|
201
|
+
* 反倒是空的。
|
|
202
|
+
*/
|
|
187
203
|
let onboarding: OnboardingView | null = null;
|
|
188
204
|
/** 本次挂载里已经收过一次;销标记的请求在路上时状态帧还会报 pending。 */
|
|
189
205
|
let dismissed = false;
|
|
190
206
|
const dismissOnboarding = (): void => {
|
|
191
207
|
dismissed = true;
|
|
192
|
-
void post('/api/onboarding/dismiss', {}, { signal: ctx.signal }).catch(() => { /*
|
|
208
|
+
void post('/api/onboarding/dismiss', {}, { signal: ctx.signal }).catch(() => { /* 下次打开再收 */ });
|
|
193
209
|
syncOnboarding();
|
|
194
210
|
};
|
|
195
211
|
const syncOnboarding = (): void => {
|
|
@@ -212,6 +228,7 @@ function mountLive(ctx: FeatureContext, env: SocketEnv): Disposable | void {
|
|
|
212
228
|
});
|
|
213
229
|
onboarding.setProvider(providerReady);
|
|
214
230
|
timeline.setHeader(onboarding.el);
|
|
231
|
+
// 引导期间系统前缀那张卡先收起来:这一页此刻要说的是怎么把 bot 配起来。
|
|
215
232
|
timeline.setHideSystem(true);
|
|
216
233
|
timeline.rebuild(state.messages, { head: state.head });
|
|
217
234
|
return;
|
|
@@ -261,6 +278,12 @@ function mountLive(ctx: FeatureContext, env: SocketEnv): Disposable | void {
|
|
|
261
278
|
const setStatus = (st: StatusSnapshot | null): void => {
|
|
262
279
|
state.status = st;
|
|
263
280
|
status.render(st);
|
|
281
|
+
const current = st?.modelConnection;
|
|
282
|
+
connectionValue.textContent = current ? `${current.name} · ${current.model ?? '—'}` : S.noProvider;
|
|
283
|
+
connection.title = current ? `${current.moduleTitle} (${current.module})\n${current.baseUrl}` : S.noProvider;
|
|
284
|
+
providerReady = current?.ready === true;
|
|
285
|
+
composer.setPlaceholder(providerReady ? null : S.composerNoProvider);
|
|
286
|
+
onboarding?.setProvider(providerReady);
|
|
264
287
|
if (st && applyDisplayName(doc, st.displayName, state.displayName)) {
|
|
265
288
|
state.displayName = str(st.displayName);
|
|
266
289
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 新部署的开场引导:四条 Cortico 署名的气泡,用 assistant 直接输出那套气泡样式。
|
|
3
3
|
* 端点状态由终端页从 providers 灯推进来,已启用的 World 在挂载时读一次接口。
|
|
4
4
|
* 是否显示由终端页判定,这里只画。
|
|
5
5
|
*/
|
|
@@ -60,6 +60,7 @@ export function createOnboarding(deps: OnboardingDeps): OnboardingView {
|
|
|
60
60
|
const box = ui.h('div', 'monolog');
|
|
61
61
|
box.appendChild(ui.h('div', 'monolog-body', line));
|
|
62
62
|
const state = ui.h('div', 'ob-state hidden');
|
|
63
|
+
// 外观取子页签那颗按钮(`.seg`),`ob-btn` 只挂本页的微调。
|
|
63
64
|
const button = ui.h('button', action?.accent ? 'seg active ob-btn ob-go' : 'seg active ob-btn');
|
|
64
65
|
button.type = 'button';
|
|
65
66
|
if (action?.icon) button.appendChild(icon(doc, action.icon));
|
|
@@ -87,6 +88,7 @@ export function createOnboarding(deps: OnboardingDeps): OnboardingView {
|
|
|
87
88
|
bubble(S.obPrompts, { label: S.obGoEdit, onClick: () => deps.go(['prompts']) });
|
|
88
89
|
|
|
89
90
|
const startLabel = S.obStart;
|
|
91
|
+
// 最后那颗按的是「开始跑」,图标与左下角运行控制里的继续是同一个。
|
|
90
92
|
const ready = bubble(S.obReady, {
|
|
91
93
|
label: startLabel,
|
|
92
94
|
icon: 'play',
|
|
@@ -106,6 +108,7 @@ export function createOnboarding(deps: OnboardingDeps): OnboardingView {
|
|
|
106
108
|
el,
|
|
107
109
|
setProvider(available) {
|
|
108
110
|
provider.setState(available ? S.obProviderReady : S.obProviderNone, available ? 'ok' : 'bad');
|
|
111
|
+
// 最后一条只在还缺端点时说话:配好了就只剩那颗按钮。
|
|
109
112
|
ready.setState(available ? null : S.obProviderNone, 'bad');
|
|
110
113
|
ready.button.disabled = !available;
|
|
111
114
|
},
|
|
@@ -71,6 +71,7 @@ export interface StatusChip {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
export interface StatusSnapshot {
|
|
74
|
+
modelConnection?: { name: string; model: string | null; module: string; moduleTitle: string; baseUrl: string; ready: boolean } | null;
|
|
74
75
|
displayName?: string;
|
|
75
76
|
loop?: LoopStatus | null;
|
|
76
77
|
/**
|
|
@@ -3,6 +3,8 @@ import { pick } from '../../core/language.ts';
|
|
|
3
3
|
const zh = {
|
|
4
4
|
// index.ts
|
|
5
5
|
navLabel: '终端',
|
|
6
|
+
currentProvider: '当前模型供应商',
|
|
7
|
+
noProvider: '未选择模型供应商',
|
|
6
8
|
ctxOpenAria: '查看 token 分类',
|
|
7
9
|
ctxPanelAria: 'Token 分类',
|
|
8
10
|
netConnecting: '连接中…',
|
|
@@ -10,8 +12,9 @@ const zh = {
|
|
|
10
12
|
netOffline: '接口不可达',
|
|
11
13
|
composerLabel: '终端消息输入',
|
|
12
14
|
composerPlaceholder: "输入消息…",
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
exportDiagnostics: '导出诊断',
|
|
16
|
+
exportFailed: '诊断包取不回来',
|
|
17
|
+
composerNoProvider: '当前模型供应商不可用,请前往「模型供应商」页设置',
|
|
15
18
|
composerQueued: '终端通道正在重连,消息已排队',
|
|
16
19
|
emptyConnecting: '连接调试通道中…',
|
|
17
20
|
ctxTitle: (total: string, max: string | null) =>
|
|
@@ -111,14 +114,14 @@ const zh = {
|
|
|
111
114
|
|
|
112
115
|
// onboarding.ts
|
|
113
116
|
obWho: 'Cortico',
|
|
114
|
-
obWelcome: '欢迎使用 Cortico。',
|
|
115
|
-
obProvider: '
|
|
117
|
+
obWelcome: '欢迎使用 Cortico!现在,让我们开始部署你的第一个 Cortico Bot。',
|
|
118
|
+
obProvider: '首先,请配置模型提供商:',
|
|
116
119
|
obProviderNone: '尚未配置可用的模型提供商',
|
|
117
120
|
obProviderReady: '模型提供商已配置',
|
|
118
|
-
obWorlds: '
|
|
119
|
-
obWorldsState: (labels: readonly string[]) =>
|
|
120
|
-
obPrompts: '
|
|
121
|
-
obReady: '
|
|
121
|
+
obWorlds: '接下来,请启用并配置你的 Bot 接入的外部环境模组(Cortico World)。也可以先仅启用终端对话。',
|
|
122
|
+
obWorldsState: (labels: readonly string[]) => `当前已经启用了 ${labels.length} 个外部环境:${labels.join('、')}`,
|
|
123
|
+
obPrompts: '最后,你可以在这里方便地编辑系统提示词,来提供人格描述、行为规范、语言风格等定制化内容!',
|
|
124
|
+
obReady: '准备就绪!',
|
|
122
125
|
obGoConfigure: '前往配置',
|
|
123
126
|
obGoEdit: '开始编辑',
|
|
124
127
|
obStart: '打个招呼?',
|
|
@@ -127,6 +130,8 @@ const zh = {
|
|
|
127
130
|
const en: typeof zh = {
|
|
128
131
|
// index.ts
|
|
129
132
|
navLabel: 'Terminal',
|
|
133
|
+
currentProvider: 'Current model provider',
|
|
134
|
+
noProvider: 'No provider selected',
|
|
130
135
|
ctxOpenAria: 'View token breakdown',
|
|
131
136
|
ctxPanelAria: 'Token breakdown',
|
|
132
137
|
netConnecting: 'Connecting…',
|
|
@@ -134,7 +139,8 @@ const en: typeof zh = {
|
|
|
134
139
|
netOffline: 'API unreachable',
|
|
135
140
|
composerLabel: 'Terminal message input',
|
|
136
141
|
composerPlaceholder: "Enter a message…",
|
|
137
|
-
|
|
142
|
+
exportDiagnostics: 'Export diagnostics',
|
|
143
|
+
exportFailed: 'The diagnostics bundle could not be fetched',
|
|
138
144
|
composerNoProvider: 'No usable provider. Set one up on the LLM Provider page.',
|
|
139
145
|
composerQueued: 'Terminal channel is reconnecting; message queued',
|
|
140
146
|
emptyConnecting: 'Connecting to the debug channel…',
|
|
@@ -235,15 +241,15 @@ const en: typeof zh = {
|
|
|
235
241
|
|
|
236
242
|
// onboarding.ts
|
|
237
243
|
obWho: 'Cortico',
|
|
238
|
-
obWelcome: "Welcome to Cortico.",
|
|
239
|
-
obProvider: '
|
|
244
|
+
obWelcome: "Welcome to Cortico! Let's set up your first Cortico Bot.",
|
|
245
|
+
obProvider: 'First, configure a model provider:',
|
|
240
246
|
obProviderNone: 'No usable model provider yet',
|
|
241
247
|
obProviderReady: 'Model provider configured',
|
|
242
|
-
obWorlds: '
|
|
248
|
+
obWorlds: 'Next, enable and configure the external environments your bot reaches (Cortico Worlds). Terminal chat alone is a fine start.',
|
|
243
249
|
obWorldsState: (labels: readonly string[]) =>
|
|
244
250
|
`Currently ${labels.length} external environment${labels.length === 1 ? '' : 's'} enabled: ${labels.join(', ')}`,
|
|
245
|
-
obPrompts: '
|
|
246
|
-
obReady: '
|
|
251
|
+
obPrompts: 'Finally, the system prompt is edited here: who it is, how it behaves, how it talks.',
|
|
252
|
+
obReady: 'Ready to go!',
|
|
247
253
|
obGoConfigure: 'Configure',
|
|
248
254
|
obGoEdit: 'Edit',
|
|
249
255
|
obStart: 'Say hello?',
|
|
@@ -100,7 +100,7 @@ export interface TimelineView {
|
|
|
100
100
|
append(m: ContextRecord, index: number, live: boolean): void;
|
|
101
101
|
/** 说话人展示名。头像图片加载不到时,ASSISTANT 组左栏的占位圆里印它的首字;下一次画到组时生效。 */
|
|
102
102
|
setSpeaker(name: string): void;
|
|
103
|
-
/**
|
|
103
|
+
/** 开场引导期间把系统前缀那张卡收起来,下一次重画生效。 */
|
|
104
104
|
setHideSystem(hide: boolean): void;
|
|
105
105
|
/** 在滚动区顶部挂一块外部内容,随时间线一起滚;传 null 取下。重画不动它。 */
|
|
106
106
|
setHeader(node: HTMLElement | null): void;
|
|
@@ -131,6 +131,7 @@ export function createTimeline(deps: TimelineDeps): TimelineView {
|
|
|
131
131
|
let turn: Turn | null = null;
|
|
132
132
|
/** 头像占位圆里的字。 */
|
|
133
133
|
let speakerInitial = 'B';
|
|
134
|
+
/** 收起系统前缀卡。 */
|
|
134
135
|
let hideSystem = false;
|
|
135
136
|
/** 挂在滚动区顶部的外部内容(开场引导)。 */
|
|
136
137
|
let header: HTMLElement | null = null;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { FeatureContext } from '../feature.ts';
|
|
2
|
+
import { get, post } from '../../core/api.ts';
|
|
3
|
+
import { Lifecycle } from '../../core/lifecycle.ts';
|
|
4
|
+
import { configField, type ConfigGroup } from '../config/view.ts';
|
|
5
|
+
import { validateProviderName } from '../../../../providers/name.ts';
|
|
6
|
+
import { connectionPath, type Detail, type Editing, type Module } from './types.ts';
|
|
7
|
+
import { LANGUAGE } from '../../core/language.ts';
|
|
8
|
+
import { pricingEditor } from '../../console-pages/builtins/llm-settings/pricing-panel.ts';
|
|
9
|
+
import { S } from './strings.ts';
|
|
10
|
+
|
|
11
|
+
export interface DetailController { dispose(): void; dirty(): boolean; leave(): Promise<boolean>; }
|
|
12
|
+
interface Options {
|
|
13
|
+
ctx: FeatureContext; root: HTMLElement; modules: Module[]; saved: Detail | null; draft: Editing | null;
|
|
14
|
+
changed(editing: Editing, invalid: boolean): void; saveDraft(editing: Editing): void; onSaved(name: string, select?: boolean): Promise<void>; cancelled(): Promise<void>;
|
|
15
|
+
discarded(): void; deleted(): Promise<void>; duplicate(editing: Editing): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export async function mountDetail(options: Options): Promise<DetailController> {
|
|
18
|
+
const { ctx, root, saved, modules } = options;
|
|
19
|
+
const { ui } = ctx;
|
|
20
|
+
const lifecycle = new Lifecycle(ctx.onError);
|
|
21
|
+
const opts = { signal: lifecycle.signal };
|
|
22
|
+
const editing: Editing = structuredClone(options.draft ?? { original: saved!.name, name: saved!.name, entry: saved!.entry, revision: saved!.revision, secretValue: '', raw: {} });
|
|
23
|
+
editing.entry.spec ??= { model: '', thinking: false };
|
|
24
|
+
let baseline = JSON.stringify(editing);
|
|
25
|
+
const dirty = () => JSON.stringify(editing) !== baseline;
|
|
26
|
+
const report = ui.msgline();
|
|
27
|
+
const form = ui.h('div');
|
|
28
|
+
root.append(form, report);
|
|
29
|
+
const errors = new Map<string, () => boolean>();
|
|
30
|
+
let rendering = 0;
|
|
31
|
+
let saving = false;
|
|
32
|
+
let panelHandle: { dispose(): void } | null = null;
|
|
33
|
+
let panelHost: ReturnType<NonNullable<FeatureContext['consolePageHost']>> | null = null;
|
|
34
|
+
const change = () => options.changed(editing, !!form.querySelector('[aria-invalid="true"]'));
|
|
35
|
+
const run = (work: () => Promise<unknown>) => { void work().catch(error => { if (!lifecycle.disposed) report.textContent = String(error); }); };
|
|
36
|
+
function field(body: HTMLElement, key: string, label: string, value: string, set: (value: string) => void, validate?: (value: string) => string | null, type = 'text') {
|
|
37
|
+
const input = ui.input({ value, type: type as 'text' }); input.setAttribute('aria-label', label);
|
|
38
|
+
const note = ui.h('div', 'field-error');
|
|
39
|
+
const row = ui.h('div', 'connection-field'); row.append(ui.field(label + (['name', 'baseUrl', 'model'].includes(key) ? ' *' : ''), input), note); body.append(row);
|
|
40
|
+
const check = () => { const error = validate?.(input.value); note.textContent = error ?? ''; input.setAttribute('aria-invalid', String(!!error)); return !error; };
|
|
41
|
+
errors.set(key, check);
|
|
42
|
+
input.addEventListener('input', () => { set(input.value); check(); change(); }, opts);
|
|
43
|
+
return input;
|
|
44
|
+
}
|
|
45
|
+
function jsonField(body: HTMLElement, key: string, label: string, value: unknown, array: boolean, set: (value: unknown) => void) {
|
|
46
|
+
const input = ui.textarea({ rows: 4, value: editing.raw[key] ?? (value === undefined ? '' : JSON.stringify(value, null, 2)), cls: 'mono' });
|
|
47
|
+
input.setAttribute('aria-label', label);
|
|
48
|
+
const error = ui.h('div', 'field-error'); body.append(ui.field(label, input), error);
|
|
49
|
+
const check = () => {
|
|
50
|
+
try {
|
|
51
|
+
const parsed: unknown = input.value.trim() ? JSON.parse(input.value) : undefined;
|
|
52
|
+
if (parsed !== undefined && (array ? !Array.isArray(parsed) : !parsed || typeof parsed !== 'object' || Array.isArray(parsed))) throw new Error();
|
|
53
|
+
set(parsed); error.textContent = ''; input.setAttribute('aria-invalid', 'false'); return true;
|
|
54
|
+
} catch { error.textContent = array ? S.jsonArray : S.jsonObject; input.setAttribute('aria-invalid', 'true'); return false; }
|
|
55
|
+
};
|
|
56
|
+
errors.set(key, check);
|
|
57
|
+
if (editing.raw[key] !== undefined) check();
|
|
58
|
+
input.addEventListener('input', () => { editing.raw[key] = input.value; check(); change(); }, opts);
|
|
59
|
+
}
|
|
60
|
+
function section(title: string, id?: string, open = false) {
|
|
61
|
+
const card = id ? ui.foldSheet('connection-' + id, { title, defaultOpen: open }) : ui.sheet({ title });
|
|
62
|
+
form.append(card.el); return card.body;
|
|
63
|
+
}
|
|
64
|
+
async function render() {
|
|
65
|
+
const gen = ++rendering;
|
|
66
|
+
panelHandle?.dispose(); panelHandle = null;
|
|
67
|
+
errors.clear(); form.replaceChildren();
|
|
68
|
+
const basic = section(S.basic);
|
|
69
|
+
field(basic, 'name', S.name, editing.name, value => { editing.name = value; }, value => value === saved?.name ? null : validateProviderName(value) ? S.nameHint : null);
|
|
70
|
+
basic.append(ui.msgline(S.nameHint));
|
|
71
|
+
const selectedModule = modules.find(module => module.id === editing.entry.kind);
|
|
72
|
+
if (saved) {
|
|
73
|
+
const module = ui.select({ value: editing.entry.kind, options: [{ value: editing.entry.kind, label: selectedModule?.title ?? editing.entry.kind }], disabled: true });
|
|
74
|
+
module.setAttribute('aria-label', S.module); module.classList.add('connection-module-readonly');
|
|
75
|
+
basic.append(ui.field(S.module, module), ui.msgline(S.fixedModule));
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const select = ui.select({ value: editing.entry.kind, options: [{ value: '', label: '—' }, ...modules.map(module => ({ value: module.id, label: module.title }))],
|
|
79
|
+
onChange: kind => {
|
|
80
|
+
const module = modules.find(module => module.id === kind);
|
|
81
|
+
editing.entry = { kind, baseUrl: module?.defaultBaseUrl ?? '', spec: { model: '', thinking: module?.reasoningTiers[0]?.thinking ?? true, ...(module?.reasoningTiers[0]?.effort ? { reasoningEffort: module.reasoningTiers[0].effort } : {}) } };
|
|
82
|
+
editing.raw = {}; change(); run(render);
|
|
83
|
+
} });
|
|
84
|
+
select.setAttribute('aria-label', S.module); basic.append(ui.field(S.module + ' *', select));
|
|
85
|
+
const required = ui.h('div', 'field-error'); basic.append(required);
|
|
86
|
+
errors.set('module', () => { required.textContent = editing.entry.kind ? '' : S.required; select.setAttribute('aria-invalid', String(!editing.entry.kind)); return !!editing.entry.kind; });
|
|
87
|
+
}
|
|
88
|
+
if (selectedModule) basic.append(ui.h('p', 'field-note', S.moduleNote(selectedModule.description, selectedModule.id)));
|
|
89
|
+
const connection = section(S.connection);
|
|
90
|
+
field(connection, 'baseUrl', S.url, editing.entry.baseUrl, value => { editing.entry.baseUrl = value; }, value => {
|
|
91
|
+
try { const url = new URL(value); return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password ? null : S.required; } catch { return S.required; }
|
|
92
|
+
});
|
|
93
|
+
const key = field(connection, 'key', S.key, editing.secretValue, value => { editing.secretValue = value; }, undefined, 'password');
|
|
94
|
+
key.placeholder = saved?.secretConfigured !== 'none' && saved ? S.keySet : S.keyEmpty;
|
|
95
|
+
const test = ui.button(S.test, { onClick: () => run(async () => {
|
|
96
|
+
if (!saved || dirty()) { report.textContent = S.savedFirst; return; }
|
|
97
|
+
test.disabled = true;
|
|
98
|
+
try {
|
|
99
|
+
const result = await post<{ ok: boolean; status: number | null; elapsedMs: number; model?: string; error?: string; hint?: string }>(connectionPath(saved.name) + '/test', {}, opts);
|
|
100
|
+
report.textContent = result.ok ? `${S.testOk} · HTTP ${result.status ?? '—'} · ${(result.elapsedMs / 1000).toFixed(1)}s · ${result.model ?? ''}` : `${S.testFailed}: ${result.hint ?? result.error ?? ''}`;
|
|
101
|
+
} finally { test.disabled = false; }
|
|
102
|
+
}) }); connection.append(test);
|
|
103
|
+
if (saved?.readiness.reason) connection.append(ui.msgline(saved.readiness.reason, true));
|
|
104
|
+
const model = section(S.modelSection, 'model', true);
|
|
105
|
+
const spec = editing.entry.spec ??= { model: '', thinking: false };
|
|
106
|
+
const modelInput = field(model, 'model', S.model, spec.model, value => { spec.model = value; }, value => value.trim() ? null : S.required);
|
|
107
|
+
const catalog = ui.h('datalist'); catalog.id = 'connection-models-' + Math.random().toString(36).slice(2); modelInput.setAttribute('list', catalog.id); model.append(catalog);
|
|
108
|
+
let listedModels: Array<{ id: string; contextWindow?: number }> = [];
|
|
109
|
+
let contextInput: HTMLInputElement | null = null;
|
|
110
|
+
modelInput.addEventListener('change', () => {
|
|
111
|
+
const known = listedModels.find(item => item.id === modelInput.value)?.contextWindow;
|
|
112
|
+
if (known && contextInput) { spec.contextWindow = known; contextInput.value = String(known); delete editing.raw.contextWindow; change(); }
|
|
113
|
+
}, opts);
|
|
114
|
+
const fetch = ui.button(S.fetchModels, { onClick: () => run(async () => {
|
|
115
|
+
if (!saved || dirty()) { report.textContent = S.savedFirst; return; }
|
|
116
|
+
fetch.disabled = true;
|
|
117
|
+
try { const result = await post<{ models: Array<{ id: string; contextWindow?: number }> }>(connectionPath(saved.name) + '/models', {}, opts);
|
|
118
|
+
catalog.replaceChildren(...result.models.map(item => { const option = ui.h('option'); option.value = item.id; return option; }));
|
|
119
|
+
listedModels = result.models;
|
|
120
|
+
} finally { fetch.disabled = false; }
|
|
121
|
+
}) }); model.append(fetch);
|
|
122
|
+
const tiers = selectedModule?.reasoningTiers ?? [];
|
|
123
|
+
if (tiers.length) {
|
|
124
|
+
const select = ui.select({ value: tiers.find(tier => tier.thinking === spec.thinking && tier.effort === spec.reasoningEffort)?.id ?? '', options: tiers.map(tier => ({ value: tier.id, label: tier.label })), onChange: value => {
|
|
125
|
+
const tier = tiers.find(tier => tier.id === value)!; spec.thinking = tier.thinking;
|
|
126
|
+
if (tier.effort) spec.reasoningEffort = tier.effort; else delete spec.reasoningEffort; change();
|
|
127
|
+
} }); select.setAttribute('aria-label', S.reasoning); model.append(ui.field(S.reasoning, select));
|
|
128
|
+
} else field(model, 'reasoning', S.reasoning, !spec.thinking ? 'none' : spec.reasoningEffort ?? '', value => {
|
|
129
|
+
spec.thinking = value !== 'none'; if (value && value !== 'none') spec.reasoningEffort = value; else delete spec.reasoningEffort;
|
|
130
|
+
});
|
|
131
|
+
for (const [name, label] of [['temperature', S.temperature], ['maxTokens', S.maxTokens], ['contextWindow', S.context]] as const) {
|
|
132
|
+
const input = field(model, name, label, editing.raw[name] ?? String(spec[name] ?? ''), value => {
|
|
133
|
+
editing.raw[name] = value; if (!value) delete spec[name]; else spec[name] = Number(value);
|
|
134
|
+
}, value => !value || Number.isFinite(Number(value)) && (name === 'temperature' ? Number(value) >= 0 && Number(value) <= 2 : Number.isInteger(Number(value)) && Number(value) > 0) ? null : S.invalidNumber, 'number');
|
|
135
|
+
if (name === 'contextWindow') contextInput = input;
|
|
136
|
+
}
|
|
137
|
+
if (selectedModule?.serviceTiers.length) {
|
|
138
|
+
const select = ui.select({ value: editing.entry.serviceTier ?? '', options: [{ value: '', label: '—' }, ...selectedModule.serviceTiers.map(tier => ({ value: tier.id, label: tier.label }))], onChange: value => { editing.entry.serviceTier = value; change(); } });
|
|
139
|
+
select.setAttribute('aria-label', S.tier); model.append(ui.field(S.tier, select));
|
|
140
|
+
}
|
|
141
|
+
const images = ui.h('input'); images.type = 'checkbox'; images.checked = editing.entry.multimodal === true; images.setAttribute('aria-label', S.images);
|
|
142
|
+
images.addEventListener('change', () => { editing.entry.multimodal = images.checked; change(); }, opts); model.append(ui.field(S.images, images));
|
|
143
|
+
const moduleBody = section(S.moduleSection, 'module', true);
|
|
144
|
+
const pricing = section(S.pricing, 'pricing');
|
|
145
|
+
const prices = pricingEditor(ui, editing.entry.pricing ?? [], saved?.quotes ?? [], value => {
|
|
146
|
+
editing.entry.pricing = value as Detail['entry']['pricing']; change();
|
|
147
|
+
}, LANGUAGE, { raw: editing.raw.pricing, onRaw: value => { editing.raw.pricing = value; change(); } });
|
|
148
|
+
pricing.append(prices.body);
|
|
149
|
+
errors.set('pricing', prices.validate);
|
|
150
|
+
const advanced = section(S.advanced, 'advanced'); advanced.append(ui.msgline(S.advancedHint));
|
|
151
|
+
field(advanced, 'secret', S.secret, editing.entry.secret ?? '', value => { if (value) editing.entry.secret = value; else delete editing.entry.secret; });
|
|
152
|
+
form.append(ui.h('div', 'connection-shared', S.shared));
|
|
153
|
+
const actions = ui.h('div', 'connection-actions');
|
|
154
|
+
if (saved) {
|
|
155
|
+
actions.append(ui.button(S.remove, { variant: 'danger', onClick: () => run(async () => {
|
|
156
|
+
const current = await get<Detail>(connectionPath(saved.name), opts);
|
|
157
|
+
if (current.references.length) { await ui.confirm({ title: S.remove, body: S.referenced + current.references.join(', ') }); return; }
|
|
158
|
+
if (!(await ui.confirm({ title: S.remove, body: S.deleteConfirm, danger: true }))) return;
|
|
159
|
+
await post(connectionPath(saved.name) + '/delete', { expectedRevision: saved.revision }, opts); baseline = JSON.stringify(editing); await options.deleted();
|
|
160
|
+
}) }), ui.button(S.duplicate, { onClick: () => run(async () => {
|
|
161
|
+
if (!(await leave())) return;
|
|
162
|
+
await options.duplicate({ original: null, copyFrom: { name: saved.name, revision: saved.revision }, name: (validateProviderName(saved.name) ? 'Connection' : saved.name) + '-Copy', entry: structuredClone(saved.entry), secretValue: '', raw: {} });
|
|
163
|
+
}) }));
|
|
164
|
+
}
|
|
165
|
+
actions.append(ui.h('span', 'grow'), ui.button(S.cancel, { onClick: () => run(async () => { baseline = JSON.stringify(editing); await options.cancelled(); }) }), ui.button(S.draft, { onClick: () => { try { options.saveDraft(editing); baseline = JSON.stringify(editing); report.textContent = S.drafted; } catch (error) { report.textContent = String(error); } } }), ui.button(S.save, { variant: 'primary', onClick: () => run(() => save()) }));
|
|
166
|
+
form.append(actions);
|
|
167
|
+
if (!selectedModule) { moduleBody.append(ui.msgline(saved ? S.readiness['module-missing'] : S.chooseModule)); return; }
|
|
168
|
+
const identity = saved?.name ?? 'draft';
|
|
169
|
+
const groups = await post<ConfigGroup[]>('/api/provider-modules/config', { name: identity, entry: editing.entry }, opts);
|
|
170
|
+
if (gen !== rendering || lifecycle.disposed) return;
|
|
171
|
+
if (ctx.consolePageHost) {
|
|
172
|
+
panelHost ??= ctx.consolePageHost({ root: moduleBody, route: () => ['providers', saved?.name ?? ''] });
|
|
173
|
+
await panelHost.load();
|
|
174
|
+
}
|
|
175
|
+
const hasPanels = panelHost?.find(`llm:${editing.entry.kind}`)?.panels?.some(panel => panel.id !== 'settings');
|
|
176
|
+
for (const group of groups.filter(group => !group.id.endsWith('.connection'))) {
|
|
177
|
+
for (const [path, property] of Object.entries(group.schema.properties ?? {})) {
|
|
178
|
+
const suffix = path.slice(`providers.${identity}.`.length);
|
|
179
|
+
if (['baseUrl', 'secret', 'multimodal'].includes(suffix) || property.type === 'object') continue;
|
|
180
|
+
const parts = suffix.split('.');
|
|
181
|
+
const getValue = () => parts.reduce<unknown>((value, part) => (value as Record<string, unknown> | undefined)?.[part], editing.entry);
|
|
182
|
+
const setValue = (value: unknown) => {
|
|
183
|
+
let target = editing.entry as unknown as Record<string, unknown>;
|
|
184
|
+
for (const part of parts.slice(0, -1)) target = (target[part] ??= {}) as Record<string, unknown>;
|
|
185
|
+
target[parts.at(-1)!] = value;
|
|
186
|
+
};
|
|
187
|
+
const body = suffix.includes('endpointPath') || suffix.includes('extraHeaders') || suffix.includes('extraBody') ? advanced : moduleBody;
|
|
188
|
+
if (body === moduleBody && hasPanels) continue;
|
|
189
|
+
const control = configField(ui, property, getValue(), () => { if (control.read) { setValue(control.read()); change(); } }, lifecycle.signal);
|
|
190
|
+
control.node.setAttribute('aria-label', property.title ?? suffix);
|
|
191
|
+
body.append(ui.field(property.title ?? suffix, control.node));
|
|
192
|
+
if (property.description) body.append(ui.h('p', 'tdesc', property.description));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (ctx.consolePageHost) {
|
|
196
|
+
const slot = ui.h('div'); moduleBody.append(slot);
|
|
197
|
+
panelHost ??= ctx.consolePageHost({ root: slot, route: () => ['providers', saved?.name ?? ''] });
|
|
198
|
+
await panelHost.load();
|
|
199
|
+
if (gen !== rendering || lifecycle.disposed) return;
|
|
200
|
+
panelHandle = await panelHost.mountConnection(`llm:${editing.entry.kind}`, slot, { instance: identity }, context => ({
|
|
201
|
+
...context,
|
|
202
|
+
setConfig: async (_id, values) => {
|
|
203
|
+
for (const [path, value] of Object.entries(values)) {
|
|
204
|
+
const prefix = `providers.${identity}.`;
|
|
205
|
+
if (!path.startsWith(prefix)) throw new Error('Foreign connection field');
|
|
206
|
+
const parts = path.slice(prefix.length).split('.');
|
|
207
|
+
let target = editing.entry as unknown as Record<string, unknown>;
|
|
208
|
+
for (const part of parts.slice(0, -1)) target = (target[part] ??= {}) as Record<string, unknown>;
|
|
209
|
+
target[parts.at(-1)!] = value;
|
|
210
|
+
}
|
|
211
|
+
change(); return S.unsaved;
|
|
212
|
+
},
|
|
213
|
+
invoke: async <T>(method: string, args?: unknown[]): Promise<T> => {
|
|
214
|
+
const before = JSON.stringify(editing.entry);
|
|
215
|
+
const reply = await post<{ result: T; entry: Detail['entry'] }>('/api/provider-modules/preview', { name: identity, entry: editing.entry, panel: context.panelId, method, args }, opts);
|
|
216
|
+
if (before === JSON.stringify(editing.entry) && JSON.stringify(reply.entry) !== before) { if (reply.entry.spec) Object.assign(spec, reply.entry.spec); editing.entry = { ...reply.entry, spec }; change(); }
|
|
217
|
+
return reply.result;
|
|
218
|
+
},
|
|
219
|
+
refresh: async () => {},
|
|
220
|
+
}));
|
|
221
|
+
if (gen !== rendering || lifecycle.disposed) panelHandle.dispose();
|
|
222
|
+
}
|
|
223
|
+
// Object-valued protocol fields remain JSON editors; their presence is declared by the module schema.
|
|
224
|
+
for (const group of groups) for (const [path, property] of Object.entries(group.schema.properties ?? {})) {
|
|
225
|
+
if (property.type !== 'object') continue;
|
|
226
|
+
const parts = path.slice(`providers.${identity}.`.length).split('.');
|
|
227
|
+
const value = parts.reduce<unknown>((object, key) => (object as Record<string, unknown> | undefined)?.[key], editing.entry);
|
|
228
|
+
jsonField(advanced, path, property.title ?? parts.at(-1)!, value, false, value => {
|
|
229
|
+
let object = editing.entry as unknown as Record<string, unknown>;
|
|
230
|
+
for (const key of parts.slice(0, -1)) object = (object[key] ??= {}) as Record<string, unknown>;
|
|
231
|
+
if (value === undefined) delete object[parts.at(-1)!]; else object[parts.at(-1)!] = value;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async function save(select = true): Promise<boolean> {
|
|
236
|
+
if (saving) return false;
|
|
237
|
+
const valid = [...errors.values()].map(check => check()).every(Boolean); change();
|
|
238
|
+
if (!valid) { report.textContent = S.readiness.invalid; return false; }
|
|
239
|
+
saving = true;
|
|
240
|
+
try {
|
|
241
|
+
const result = await post<Detail>(saved ? connectionPath(saved.name) + '/save' : '/api/providers', { name: editing.name, entry: editing.entry, expectedRevision: editing.revision, copyFrom: editing.copyFrom, ...(editing.secretValue ? { secretValue: editing.secretValue } : {}) }, opts);
|
|
242
|
+
baseline = JSON.stringify(editing); await options.onSaved(result.name, select); return true;
|
|
243
|
+
} catch (error) { report.textContent = String(error); return false; }
|
|
244
|
+
finally { saving = false; }
|
|
245
|
+
}
|
|
246
|
+
async function leave(): Promise<boolean> {
|
|
247
|
+
if (!dirty()) return true;
|
|
248
|
+
return new Promise(resolve => {
|
|
249
|
+
const dialog = ui.h('dialog', 'connection-guard');
|
|
250
|
+
const buttons = ui.rowbar();
|
|
251
|
+
const finish = (answer: boolean) => { dialog.remove(); resolve(answer); };
|
|
252
|
+
dialog.append(ui.h('h3', '', S.unsaved), buttons);
|
|
253
|
+
buttons.append(ui.button(S.stay, { onClick: () => finish(false) }), ui.button(S.discard, { onClick: () => { baseline = JSON.stringify(editing); options.discarded(); finish(true); } }), ui.button(S.save, { variant: 'primary', onClick: () => { void save(false).then(ok => finish(ok)); } }));
|
|
254
|
+
dialog.addEventListener('cancel', event => { event.preventDefault(); finish(false); }, opts);
|
|
255
|
+
lifecycle.signal.addEventListener('abort', () => finish(false), { once: true });
|
|
256
|
+
root.ownerDocument.body.append(dialog);
|
|
257
|
+
if (dialog.showModal) dialog.showModal(); else dialog.setAttribute('open', '');
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
await render(); change();
|
|
261
|
+
return { dispose: () => { lifecycle.dispose(); panelHandle?.dispose(); panelHost?.unmount(); }, dirty, leave };
|
|
262
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Editing } from './types.ts';
|
|
2
|
+
|
|
3
|
+
export const NEW_DRAFT_ID = '/new';
|
|
4
|
+
|
|
5
|
+
/** Drafts are scoped to the deployment and never retain API Key input. */
|
|
6
|
+
export function providerDrafts(storage: Storage, scope: string) {
|
|
7
|
+
const key = `cortico:provider-drafts:v2:${scope}`;
|
|
8
|
+
let values: Record<string, Editing> = Object.create(null);
|
|
9
|
+
try { values = JSON.parse(storage.getItem(key) ?? '{}'); } catch { /* A corrupt draft does not block saved connections. */ }
|
|
10
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)) values = Object.create(null);
|
|
11
|
+
else values = Object.assign(Object.create(null), values);
|
|
12
|
+
const persist = () => storage.setItem(key, JSON.stringify(values));
|
|
13
|
+
return {
|
|
14
|
+
get(name: string): Editing | null {
|
|
15
|
+
const value = values[name];
|
|
16
|
+
return value && typeof value.name === 'string' && value.entry && typeof value.entry.kind === 'string' && value.raw
|
|
17
|
+
? structuredClone({ ...value, secretValue: '' }) : null;
|
|
18
|
+
},
|
|
19
|
+
set(value: Editing): void { values[value.original ?? NEW_DRAFT_ID] = structuredClone({ ...value, secretValue: '' }); persist(); },
|
|
20
|
+
remove(name: string): void { delete values[name]; persist(); },
|
|
21
|
+
has(name: string): boolean { return !!values[name]; },
|
|
22
|
+
};
|
|
23
|
+
}
|