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.
Files changed (112) hide show
  1. package/README.md +7 -14
  2. package/package.json +1 -3
  3. package/src/boot.ts +18 -0
  4. package/src/bot.ts +41 -6
  5. package/src/core/README.md +4 -6
  6. package/src/core/blobs.ts +23 -2
  7. package/src/core/config-schema.ts +1 -1
  8. package/src/core/config.ts +2 -16
  9. package/src/core/core.ts +15 -5
  10. package/src/core/loop.ts +55 -27
  11. package/src/core/session.ts +3 -2
  12. package/src/core/timers.ts +8 -6
  13. package/src/core/types.ts +19 -3
  14. package/src/core/util.ts +17 -1
  15. package/src/deploy.ts +4 -1
  16. package/src/extensions/README.md +6 -4
  17. package/src/extensions/dry-mount.ts +23 -2
  18. package/src/extensions/manifest.ts +21 -11
  19. package/src/extensions.ts +147 -23
  20. package/src/launcher.ts +23 -9
  21. package/src/protocol/open-responses/context-log.ts +37 -9
  22. package/src/providers/README.md +32 -8
  23. package/src/providers/base.ts +3 -1
  24. package/src/providers/configuration.ts +2 -1
  25. package/src/providers/console/hub.ts +270 -0
  26. package/src/providers/console/settings.ts +8 -18
  27. package/src/providers/console/types.ts +5 -0
  28. package/src/providers/hub-api.ts +3 -0
  29. package/src/providers/llamacpp/config.ts +8 -6
  30. package/src/providers/llamacpp/console/runtime-panel.ts +5 -1
  31. package/src/providers/llamacpp/console/server.ts +3 -1
  32. package/src/providers/llamacpp/index.ts +3 -1
  33. package/src/providers/llamacpp/native.ts +10 -5
  34. package/src/providers/llamacpp/strings.ts +6 -6
  35. package/src/providers/name.ts +8 -0
  36. package/src/providers/openai-responses-compat/config.ts +13 -0
  37. package/src/providers/openai-responses-compat/console/client.ts +5 -0
  38. package/src/providers/openai-responses-compat/console/reasoning-panel.ts +84 -0
  39. package/src/providers/openai-responses-compat/console/server.ts +127 -0
  40. package/src/providers/openai-responses-compat/index.ts +38 -13
  41. package/src/providers/openai-responses-compat/native.ts +9 -4
  42. package/src/providers/openai-responses-compat/strings.ts +62 -1
  43. package/src/providers/registry.ts +5 -0
  44. package/src/providers/transport/responses-input.ts +59 -10
  45. package/src/web/README.md +11 -6
  46. package/src/web/auth.ts +80 -0
  47. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +5 -4
  48. package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +22 -8
  49. package/src/web/client/console-pages/builtins/llm-settings/strings.ts +6 -8
  50. package/src/web/client/console-pages/host.ts +24 -7
  51. package/src/web/client/core/api.ts +5 -1
  52. package/src/web/client/core/router.ts +15 -0
  53. package/src/web/client/features/core/strings.ts +2 -2
  54. package/src/web/client/features/extensions/index.ts +276 -41
  55. package/src/web/client/features/extensions/strings.ts +84 -10
  56. package/src/web/client/features/feature.ts +2 -2
  57. package/src/web/client/features/live/diagnostics.ts +32 -0
  58. package/src/web/client/features/live/index.ts +34 -11
  59. package/src/web/client/features/live/onboarding.ts +4 -1
  60. package/src/web/client/features/live/protocol.ts +1 -0
  61. package/src/web/client/features/live/strings.ts +20 -14
  62. package/src/web/client/features/live/timeline.ts +2 -1
  63. package/src/web/client/features/providers/detail.ts +262 -0
  64. package/src/web/client/features/providers/drafts.ts +23 -0
  65. package/src/web/client/features/providers/index.ts +173 -87
  66. package/src/web/client/features/providers/strings.ts +44 -17
  67. package/src/web/client/features/providers/types.ts +15 -0
  68. package/src/web/client/features/settings/general.ts +13 -0
  69. package/src/web/client/features/settings/index.ts +1 -0
  70. package/src/web/client/features/settings/strings.ts +6 -0
  71. package/src/web/client/features/worlds/index.ts +1 -0
  72. package/src/web/client/main.ts +4 -0
  73. package/src/web/client/shell/index.ts +28 -48
  74. package/src/web/client/shell/strings.ts +0 -22
  75. package/src/web/client/ui/icons.ts +14 -1
  76. package/src/web/client/ui/prompt-input.tsx +17 -5
  77. package/src/web/client/ui/strings.ts +0 -2
  78. package/src/web/console-pages.ts +1 -1
  79. package/src/web/diagnostics.ts +133 -0
  80. package/src/web/public/login.html +67 -0
  81. package/src/web/public/styles.css +143 -26
  82. package/src/web/server.ts +233 -19
  83. package/src/web/shared/client-panel.ts +4 -1
  84. package/src/web/shared/console-protocol.ts +4 -1
  85. package/src/worlds/bilibili/README.md +1 -1
  86. package/src/worlds/bilibili/overlay/server.ts +4 -2
  87. package/src/worlds/minecraft/ADAPT.md +66 -0
  88. package/src/worlds/minecraft/README.md +69 -11
  89. package/src/worlds/minecraft/cell-facts.ts +226 -0
  90. package/src/worlds/minecraft/chests.ts +5 -0
  91. package/src/worlds/minecraft/containers.ts +325 -0
  92. package/src/worlds/minecraft/entity-facts.ts +31 -5
  93. package/src/worlds/minecraft/executor.ts +283 -10348
  94. package/src/worlds/minecraft/inventory.ts +268 -0
  95. package/src/worlds/minecraft/melee.ts +419 -0
  96. package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
  97. package/src/worlds/minecraft/placed-ledger.ts +152 -0
  98. package/src/worlds/minecraft/placement.ts +1038 -0
  99. package/src/worlds/minecraft/receipt.ts +340 -0
  100. package/src/worlds/minecraft/skill-context.ts +358 -0
  101. package/src/worlds/minecraft/skills-build.ts +1169 -0
  102. package/src/worlds/minecraft/skills-container.ts +1343 -0
  103. package/src/worlds/minecraft/skills-craft.ts +333 -0
  104. package/src/worlds/minecraft/skills-dig.ts +624 -0
  105. package/src/worlds/minecraft/skills-gather.ts +1230 -0
  106. package/src/worlds/minecraft/skills-interact.ts +1559 -0
  107. package/src/worlds/minecraft/tools.ts +331 -0
  108. package/src/worlds/minecraft/travel.ts +763 -0
  109. package/src/worlds/minecraft/until.ts +75 -0
  110. package/src/worlds/qq/normalize.ts +14 -0
  111. package/src/worlds/qq/world.ts +53 -7
  112. 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 { PROVIDERS_LAMP_ID, panelStreamRoute } from '../../../shared/console-protocol.ts';
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 { subscribeLamps } from '../../ui/lamp.ts';
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
- summary.append(status.el, ui.h('span', 'grow'), netEl);
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
- hint: S.composerHint,
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
- composerHint: 'Terminal · Enter 发送 · 可粘贴或拖入图片',
14
- composerNoProvider: '当前无可用 Provider,请前往「模型提供商」页设置',
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: '按需启用 World,也可以仅使用终端对话。',
119
- obWorldsState: (labels: readonly string[]) => `已启用 ${labels.length} 个 World:${labels.join('、')}`,
120
- obPrompts: '系统提示词定义人格描述、行为规范和语言风格,可按需修改。',
121
- obReady: '配置好后,可以邀请 bot 开口。',
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
- composerHint: 'Terminal · Enter to send · paste or drop images',
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: 'Configure a model provider to start chatting.',
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: 'Enable Worlds as needed, or use terminal chat alone.',
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: 'You can edit the system prompt to set personality, behavior and language style.',
246
- obReady: 'Once configured, you can invite the bot to speak.',
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
+ }