@k2b/cloud 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/package.json +7 -2
  2. package/src/_internal/app-access-request.ts +13 -0
  3. package/src/_internal/app-access-resources.ts +141 -0
  4. package/src/_internal/app-event-catalog.ts +40 -0
  5. package/src/_internal/app-event-schemas.ts +127 -0
  6. package/src/_internal/application-package.ts +40 -0
  7. package/src/_internal/built-in-app-ids.json +27 -0
  8. package/src/_internal/capabilities.ts +158 -12
  9. package/src/_internal/capability-origin.ts +4 -0
  10. package/src/_internal/capability-streams.ts +7 -5
  11. package/src/_internal/define-app.ts +57 -6
  12. package/src/_internal/object-store-area.ts +9 -0
  13. package/src/_internal/object-store-config.ts +101 -0
  14. package/src/_internal/object-store-key.ts +14 -0
  15. package/src/_internal/process-identity.ts +7 -2
  16. package/src/_internal/registry-validation.ts +11 -1
  17. package/src/_internal/registry.ts +8 -1
  18. package/src/_internal/runtime-context.ts +8 -1
  19. package/src/access/AppAccessEditor.tsx +2 -0
  20. package/src/access/PermissionEditor.tsx +9 -3
  21. package/src/access/PrincipalPicker.tsx +14 -4
  22. package/src/access/ResourceApiKeys.tsx +3 -4
  23. package/src/access/app-access-messages.ts +6 -0
  24. package/src/account/ProfilePictureDialog.tsx +699 -0
  25. package/src/account/profile-picture-messages.ts +269 -0
  26. package/src/account/ui.ts +7 -0
  27. package/src/ai/admin.ts +3 -0
  28. package/src/ai/app-frame.ts +12 -0
  29. package/src/ai/application-tasks.ts +101 -0
  30. package/src/ai/assistant-models.ts +126 -64
  31. package/src/ai/capabilities.ts +19 -4
  32. package/src/ai/chart-block.ts +63 -30
  33. package/src/ai/chat/chart-block.tsx +12 -3
  34. package/src/ai/chat/composer-adapter.ts +8 -7
  35. package/src/ai/chat/message-actions.tsx +1 -1
  36. package/src/ai/chat/message-utils.ts +2 -2
  37. package/src/ai/chat/presentation.tsx +22 -1
  38. package/src/ai/chat-quotas.ts +5 -4
  39. package/src/ai/chat-tasks.ts +2 -2
  40. package/src/ai/client/controller.ts +11 -10
  41. package/src/ai/client/projection.ts +3 -3
  42. package/src/ai/compaction.ts +3 -3
  43. package/src/ai/credentials.ts +13 -114
  44. package/src/ai/default-tool-contracts.ts +4 -3
  45. package/src/ai/dictation-runtime.ts +4 -4
  46. package/src/ai/enrich.ts +2 -2
  47. package/src/ai/executor.ts +31 -22
  48. package/src/ai/http.ts +17 -29
  49. package/src/ai/index.ts +11 -5
  50. package/src/ai/inference-calls.ts +5 -4
  51. package/src/ai/maintenance.ts +1 -1
  52. package/src/ai/memory-learning-runs.ts +5 -5
  53. package/src/ai/memory-learning.ts +3 -3
  54. package/src/ai/message-queue.ts +1 -2
  55. package/src/ai/migrate.ts +75 -42
  56. package/src/ai/model-access-migrate.ts +3 -17
  57. package/src/ai/model-access.ts +44 -85
  58. package/src/ai/model-card.tsx +134 -0
  59. package/src/ai/model-catalog.ts +359 -0
  60. package/src/ai/model-chooser-messages.ts +88 -0
  61. package/src/ai/model-chooser.tsx +577 -0
  62. package/src/ai/model-delete.ts +234 -0
  63. package/src/ai/model-fixture.ts +39 -0
  64. package/src/ai/model-icon.tsx +36 -0
  65. package/src/ai/model-overview.ts +53 -0
  66. package/src/ai/model-usage.ts +45 -0
  67. package/src/ai/openai-api-messages.ts +0 -4
  68. package/src/ai/openai-api.ts +26 -9
  69. package/src/ai/personal-agent.ts +1 -1
  70. package/src/ai/prefs.ts +12 -10
  71. package/src/ai/projects-routes.ts +2 -1
  72. package/src/ai/projects.ts +13 -12
  73. package/src/ai/protocol.ts +2 -2
  74. package/src/ai/provider-connections.ts +161 -0
  75. package/src/ai/provider-errors.ts +55 -6
  76. package/src/ai/provider-fetch.ts +4 -0
  77. package/src/ai/provider.ts +1 -1
  78. package/src/ai/quota-limits.ts +36 -0
  79. package/src/ai/quota-provider.ts +4 -2
  80. package/src/ai/quota-report.ts +7 -4
  81. package/src/ai/quotas.ts +19 -8
  82. package/src/ai/recent-resources.ts +6 -0
  83. package/src/ai/request-headers.ts +14 -87
  84. package/src/ai/resource-refs.ts +6 -2
  85. package/src/ai/routes.ts +17 -18
  86. package/src/ai/runtime.ts +50 -22
  87. package/src/ai/settings.ts +93 -580
  88. package/src/ai/store.ts +45 -23
  89. package/src/ai/stream.ts +1 -1
  90. package/src/ai/structured-runs.ts +2 -2
  91. package/src/ai/structured.ts +21 -44
  92. package/src/ai/task-contracts.ts +25 -5
  93. package/src/ai/task-execution.ts +15 -6
  94. package/src/ai/task-model.ts +9 -0
  95. package/src/ai/transcription.ts +27 -22
  96. package/src/ai/types.ts +50 -62
  97. package/src/ai/ui.tsx +13 -0
  98. package/src/ai/usage.ts +8 -8
  99. package/src/ai/validate.ts +8 -7
  100. package/src/ai/view-block.ts +2 -2
  101. package/src/api/accounts-entities.ts +16 -0
  102. package/src/api/admin-ai-quotas.ts +34 -56
  103. package/src/api/admin-ai-usage.ts +1 -1
  104. package/src/api/admin-ai.ts +173 -0
  105. package/src/api/admin-core-settings.ts +68 -244
  106. package/src/api/app-approval.ts +3 -1
  107. package/src/api/app-event-delivery.ts +51 -0
  108. package/src/api/app-events.ts +123 -0
  109. package/src/api/app-resource-access.ts +48 -0
  110. package/src/api/auth/account-mismatch.ts +24 -0
  111. package/src/api/auth.ts +26 -3
  112. package/src/api/capabilities.ts +28 -4
  113. package/src/api/index.ts +7 -1
  114. package/src/api/mcp.ts +175 -2
  115. package/src/api/me.ts +6 -4
  116. package/src/api/pwa-phone.ts +75 -26
  117. package/src/api/pwa.ts +34 -8
  118. package/src/api/resource-links.ts +83 -6
  119. package/src/api/resource-resolve.ts +50 -0
  120. package/src/api/search/schemas.ts +3 -1
  121. package/src/api/search.ts +9 -7
  122. package/src/app-pwa/contracts.ts +4 -0
  123. package/src/app-pwa/core-query-error.ts +20 -0
  124. package/src/app-pwa/index.ts +1 -0
  125. package/src/app-pwa/service.ts +91 -0
  126. package/src/app-pwa/transport.ts +22 -0
  127. package/src/approvals/messages.ts +2 -2
  128. package/src/browser/cloud-id.ts +86 -0
  129. package/src/browser/resource-clipboard.ts +4 -0
  130. package/src/browser/resource-links.ts +52 -0
  131. package/src/capabilities/server-invoke.ts +2 -11
  132. package/src/capabilities/testing.ts +5 -2
  133. package/src/cli/access.ts +6 -5
  134. package/src/cli/admin/ai-models.ts +199 -0
  135. package/src/cli/admin/ai-quotas.ts +6 -102
  136. package/src/cli/admin/ai-usage.ts +4 -4
  137. package/src/cli/admin/app-credentials.ts +5 -3
  138. package/src/cli/admin/index.ts +5 -2
  139. package/src/cli/apps.ts +89 -26
  140. package/src/cli/capabilities.ts +22 -1
  141. package/src/cli/events.ts +320 -0
  142. package/src/config/env.ts +40 -0
  143. package/src/contracts/app-access.ts +26 -2
  144. package/src/contracts/app-details.ts +89 -0
  145. package/src/contracts/app-events.ts +51 -0
  146. package/src/contracts/app.ts +21 -5
  147. package/src/contracts/capabilities.ts +143 -3
  148. package/src/contracts/index.ts +2 -0
  149. package/src/contracts/notification-types.ts +36 -9
  150. package/src/contracts/pwa.ts +17 -1
  151. package/src/contracts/registry.ts +6 -0
  152. package/src/contracts/resource-clipboard.ts +35 -0
  153. package/src/contracts/resource-pages.ts +140 -0
  154. package/src/contracts/resource-resolution.ts +13 -0
  155. package/src/contracts/shared.ts +4 -0
  156. package/src/contracts/user-notifications.ts +2 -0
  157. package/src/events/app-event-delivery.ts +100 -0
  158. package/src/events/app-events.testing.ts +65 -0
  159. package/src/events/app-events.ts +134 -0
  160. package/src/events/core-user-created.ts +24 -0
  161. package/src/events/index.ts +9 -0
  162. package/src/events/testing.ts +2 -0
  163. package/src/index.ts +1 -1
  164. package/src/server/api/respond.ts +8 -3
  165. package/src/server/index.ts +1 -0
  166. package/src/server/middleware/app-access.ts +1 -1
  167. package/src/server/middleware/auth.ts +21 -7
  168. package/src/server/middleware/index.ts +1 -0
  169. package/src/server/middleware/invocation.ts +2 -0
  170. package/src/server/public-storage.ts +106 -0
  171. package/src/server/services/access.ts +8 -0
  172. package/src/services/accounts/app.ts +6 -0
  173. package/src/services/accounts/entities.ts +14 -3
  174. package/src/services/accounts/groups.ts +3 -1
  175. package/src/services/accounts/identities.ts +62 -10
  176. package/src/services/accounts/users.ts +7 -6
  177. package/src/services/app-access.ts +25 -7
  178. package/src/services/app-details.ts +224 -0
  179. package/src/services/app-discovery.ts +2 -1
  180. package/src/services/app-event-shapes.ts +139 -0
  181. package/src/services/app-events/accept.ts +116 -0
  182. package/src/services/app-events/admin.ts +250 -0
  183. package/src/services/app-events/declarations.ts +187 -0
  184. package/src/services/app-events/deliver.ts +218 -0
  185. package/src/services/app-events.ts +92 -0
  186. package/src/services/config-conflict.ts +11 -0
  187. package/src/services/core-user-events.ts +1 -0
  188. package/src/services/freeipa-config.ts +9 -0
  189. package/src/services/geo/ledger.ts +28 -10
  190. package/src/services/geo/service.ts +43 -24
  191. package/src/services/identity/core-origin.ts +12 -0
  192. package/src/services/identity/invocation-actor.ts +20 -2
  193. package/src/services/identity/invocation-authority.ts +4 -0
  194. package/src/services/identity/invocation-token.ts +238 -56
  195. package/src/services/identity/issue-invocation.ts +6 -1
  196. package/src/services/image-metadata.ts +65 -1
  197. package/src/services/index.ts +13 -1
  198. package/src/services/ipa/sync.ts +6 -2
  199. package/src/services/ipa/users.ts +4 -1
  200. package/src/services/mandates/policy.ts +19 -12
  201. package/src/services/notifications/browser.ts +4 -0
  202. package/src/services/notifications/catalog.ts +5 -2
  203. package/src/services/notifications/platform.ts +7 -2
  204. package/src/services/notifications/user.ts +18 -7
  205. package/src/services/oauth-tokens.ts +28 -2
  206. package/src/services/object-store.ts +217 -0
  207. package/src/services/providers/local/users.ts +5 -0
  208. package/src/services/pwa-devices.ts +147 -37
  209. package/src/services/session/index.ts +14 -0
  210. package/src/services/settings/core-settings.ts +71 -43
  211. package/src/services/settings/index.ts +8 -0
  212. package/src/services/settings/versions.ts +26 -0
  213. package/src/shared/ai-models.ts +274 -0
  214. package/src/shared/ai-quotas.ts +2 -2
  215. package/src/shared/ai-usage.ts +2 -2
  216. package/src/shared/index.ts +2 -0
  217. package/src/shared-text/documents.ts +25 -8
  218. package/src/shared-text/log.ts +1 -1
  219. package/src/shared-text/text-diff.ts +75 -0
  220. package/src/ssr/AdminArea.tsx +131 -0
  221. package/src/ssr/AdminLayout.tsx +9 -3
  222. package/src/ssr/AdminSidebar.tsx +7 -7
  223. package/src/ssr/AppAccessDenied.tsx +2 -0
  224. package/src/ssr/AppBadge.tsx +14 -0
  225. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  226. package/src/ssr/CloudShell.island.tsx +25 -4
  227. package/src/ssr/Layout.tsx +8 -6
  228. package/src/ssr/PwaLayout.tsx +16 -1
  229. package/src/ssr/PwaPartCount.island.tsx +23 -0
  230. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  231. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  232. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  233. package/src/ssr/admin-active-link.ts +16 -0
  234. package/src/ssr/admin-navigation.ts +25 -20
  235. package/src/ssr/app-badges.ts +31 -7
  236. package/src/ssr/index.ts +8 -0
  237. package/src/ssr/page-trail-runtime.ts +3 -1
  238. package/src/ssr/platform-messages.ts +9 -1
  239. package/src/ssr/pwa-messages.ts +2 -2
  240. package/src/ssr/rail-navigation.ts +5 -1
  241. package/src/ssr/settings-draft.ts +153 -0
  242. package/src/ssr/shell-data.tsx +2 -0
  243. package/src/styles/global.css +1 -0
  244. package/src/styles/profile-picture.css +270 -0
  245. package/src/styles/utilities-navigation.css +6 -0
  246. package/src/workflows/ai/policy.ts +49 -0
  247. package/src/workflows/ai/runtime.ts +131 -21
  248. package/src/workflows/ai/store.ts +283 -35
  249. package/src/workflows/ai/types.ts +8 -2
  250. package/src/workflows/ai-actions.ts +148 -120
  251. package/src/workflows/authoring.ts +31 -0
  252. package/src/workflows/contracts.ts +1 -0
  253. package/src/workflows/definition.ts +2 -0
  254. package/src/workflows/editor.ts +15 -0
  255. package/src/workflows/index.ts +2 -1
  256. package/src/workflows/language/compiler.ts +34 -3
  257. package/src/workflows/runtime/executor.ts +132 -33
  258. package/src/workflows/runtime/index.ts +2 -0
  259. package/src/workflows/runtime/ports.ts +5 -1
  260. package/src/workflows/store/actions.ts +1 -1
  261. package/src/workflows/store/definitions.ts +5 -0
  262. package/src/workflows/store/runs.ts +5 -3
  263. package/src/workflows/store/worker.ts +7 -2
  264. package/src/ai/model-pricing.ts +0 -43
  265. package/src/ai/model-request-settings.ts +0 -98
  266. package/src/ai/settings-messages.ts +0 -129
  267. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -0,0 +1,577 @@
1
+ import { AnchoredPopover, Button, IconButton, NoticeCard, RichSelect, TextInput, useLocale } from "@k2b/ui";
2
+ import { createEffect, createMemo, createSignal, For, type JSX, Show, untrack } from "solid-js";
3
+ import {
4
+ AI_MODEL_CLASSES,
5
+ type AiModelClass,
6
+ type AiModelRequest,
7
+ type AiModelUnavailableReason,
8
+ type AiPeopleModels,
9
+ aiModelClassFallbackChain,
10
+ } from "../shared/ai-models";
11
+ import { AiModelCard, type AiModelCardStop, aiModelClassLabel, aiModelClassSubtitle } from "./model-card";
12
+ import { aiModelChooserMessages } from "./model-chooser-messages";
13
+ import { AiModelIcon } from "./model-icon";
14
+
15
+ type Option = AiPeopleModels["options"][number];
16
+ export type AiModelChooserState = { status: "ready"; models: AiPeopleModels } | { status: "loading" } | { status: "error" };
17
+
18
+ /** "Budget aufgebraucht · wieder Mo., 00:00": the reason that replaces a subtitle. */
19
+ export const aiModelUnavailableText = (locale: string, reason: AiModelUnavailableReason): string => {
20
+ const t = aiModelChooserMessages.resolve([locale]).t;
21
+ if (reason.code === "budget_exhausted")
22
+ return t.budget({
23
+ time: new Intl.DateTimeFormat(locale, { weekday: "short", hour: "2-digit", minute: "2-digit" }).format(new Date(reason.resetsAt)),
24
+ });
25
+ if (reason.code === "key_rejected") return t.keyRejected;
26
+ if (reason.code === "access_revoked") return t.revoked;
27
+ return t.unavailable;
28
+ };
29
+
30
+ const optionName = (option: Option) => (option.variantLabel ? `${option.label} · ${option.variantLabel}` : option.label);
31
+
32
+ /**
33
+ * What a request shows: the class that answers, the option behind it, the class it fell back from, and why it cannot
34
+ * answer. A model the person may no longer use is not among the options; only its class carries the reason.
35
+ */
36
+ export const resolveAiModelChoice = (
37
+ models: AiPeopleModels,
38
+ request: AiModelRequest | null,
39
+ ): {
40
+ option: Option | undefined;
41
+ class: AiModelClass | null;
42
+ requested: AiModelClass | null;
43
+ fellBack: boolean;
44
+ unavailable?: AiModelUnavailableReason;
45
+ } => {
46
+ const chosen = request ?? models.effectiveDefault;
47
+ if ("id" in chosen) {
48
+ const option = models.options.find((entry) => entry.id === chosen.id);
49
+ const modelClass = AI_MODEL_CLASSES.find(
50
+ (entry) => option?.classes.includes(entry) && models.classes.some((stop) => stop.class === entry),
51
+ );
52
+ return { option, class: modelClass ?? null, requested: modelClass ?? null, fellBack: false, unavailable: option?.unavailable };
53
+ }
54
+ for (const modelClass of [chosen.class, ...aiModelClassFallbackChain(chosen.class)]) {
55
+ const stop = models.classes.find((entry) => entry.class === modelClass);
56
+ if (stop) {
57
+ const option = models.options.find((entry) => entry.id === stop.variantId);
58
+ return {
59
+ option,
60
+ class: modelClass,
61
+ requested: chosen.class,
62
+ fellBack: modelClass !== chosen.class,
63
+ unavailable: stop.unavailable ?? option?.unavailable,
64
+ };
65
+ }
66
+ }
67
+ return { option: undefined, class: null, requested: chosen.class, fellBack: false };
68
+ };
69
+
70
+ /** A person's pick of one model: its class whenever it holds one, so the choice follows the administration. */
71
+ export const aiModelRequestFor = (models: AiPeopleModels, optionId: string): AiModelRequest => {
72
+ const option = models.options.find((entry) => entry.id === optionId);
73
+ const modelClass = AI_MODEL_CLASSES.find(
74
+ (entry) => option?.classes.includes(entry) && models.classes.some((stop) => stop.class === entry),
75
+ );
76
+ return modelClass ? { class: modelClass } : { id: optionId };
77
+ };
78
+
79
+ /**
80
+ * The model in a composer: a chip of fixed width with the model's icon and the class (or the model's short name), and
81
+ * a card with the slider over the assigned classes. The model's name in the card opens the list of every model in the
82
+ * same panel. A person's choice is a class whenever the model belongs to one, so it follows the administration.
83
+ */
84
+ export function AiModelChooser(props: {
85
+ state: AiModelChooserState;
86
+ value: AiModelRequest | null;
87
+ onChange: (request: AiModelRequest) => void;
88
+ /** An answer runs: the model stays as it is. */
89
+ locked?: boolean;
90
+ lockedReason?: string;
91
+ }): JSX.Element {
92
+ const locale = useLocale();
93
+ const t = () => aiModelChooserMessages.resolve([locale()]).t;
94
+ const [anchor, setAnchor] = createSignal<HTMLElement | null>(null);
95
+ const [step, setStep] = createSignal<"card" | "list">("card");
96
+ const [query, setQuery] = createSignal("");
97
+ const [active, setActive] = createSignal(0);
98
+ let chip: HTMLButtonElement | undefined;
99
+ const sliderId = `ai-model-slider-${Math.random().toString(36).slice(2, 8)}`;
100
+
101
+ const models = () => (props.state.status === "ready" ? props.state.models : null);
102
+ const choice = createMemo(() => (models() ? resolveAiModelChoice(models()!, props.value) : null));
103
+ const current = () => choice()?.option;
104
+ const issue = () => (choice()?.unavailable ? aiModelUnavailableText(locale(), choice()!.unavailable!) : undefined);
105
+ const stops = (): AiModelCardStop[] =>
106
+ (models()?.classes ?? []).flatMap((stop) => {
107
+ const option = models()!.options.find((entry) => entry.id === stop.variantId);
108
+ if (!option) return [];
109
+ const unavailable = stop.unavailable ?? option.unavailable;
110
+ return [
111
+ {
112
+ class: stop.class,
113
+ name: option.label,
114
+ icon: option.icon,
115
+ unavailable: unavailable ? aiModelUnavailableText(locale(), unavailable) : undefined,
116
+ },
117
+ ];
118
+ });
119
+ const single = () => (models()?.options.length ?? 0) <= 1;
120
+ /** Classes first in their order, then every other model alphabetically; each option once. */
121
+ const ordered = createMemo(() => {
122
+ const all = models()?.options ?? [];
123
+ const rank = (option: Option) => {
124
+ const index = AI_MODEL_CLASSES.findIndex((modelClass) => option.classes.includes(modelClass));
125
+ return index < 0 ? AI_MODEL_CLASSES.length : index;
126
+ };
127
+ return [...all].sort((left, right) => rank(left) - rank(right) || optionName(left).localeCompare(optionName(right), locale()));
128
+ });
129
+ const visible = createMemo(() => {
130
+ const words = query().trim().toLowerCase().split(/\s+/).filter(Boolean);
131
+ if (!words.length) return ordered();
132
+ return ordered().filter((option) => {
133
+ const hay = [optionName(option), option.description, ...option.classes.map((modelClass) => aiModelClassLabel(locale(), modelClass))]
134
+ .join(" ")
135
+ .toLowerCase();
136
+ return words.every((word) => hay.includes(word));
137
+ });
138
+ });
139
+ const subtitle = (option: Option) => {
140
+ if (option.unavailable) return aiModelUnavailableText(locale(), option.unavailable);
141
+ const modelClass = AI_MODEL_CLASSES.find((entry) => option.classes.includes(entry));
142
+ return modelClass ? aiModelClassSubtitle(locale(), modelClass) : option.description;
143
+ };
144
+
145
+ const close = () => {
146
+ setAnchor(null);
147
+ setStep("card");
148
+ setQuery("");
149
+ };
150
+ // A run that starts while the card is open closes it: the model stays as it is until the answer is done.
151
+ createEffect(() => {
152
+ if (props.locked) untrack(close);
153
+ });
154
+ const change = (request: AiModelRequest) => {
155
+ if (!props.locked) props.onChange(request);
156
+ };
157
+ const choose = (option: Option) => {
158
+ const current = models();
159
+ if (option.unavailable || !current) return;
160
+ change(aiModelRequestFor(current, option.id));
161
+ setStep("card");
162
+ setQuery("");
163
+ queueMicrotask(() => document.getElementById(sliderId)?.focus());
164
+ };
165
+ const listId = `${sliderId}-list`;
166
+ const searchId = `${sliderId}-search`;
167
+ const focusList = () => (document.getElementById(searchId) ?? document.getElementById(listId))?.focus();
168
+ const moveTo = (index: number) => {
169
+ setActive(index);
170
+ document.getElementById(`${sliderId}-o${index}`)?.scrollIntoView({ block: "nearest" });
171
+ };
172
+ const openList = () => {
173
+ const index = ordered().findIndex((option) => option.id === current()?.id);
174
+ setStep("list");
175
+ moveTo(Math.max(0, index));
176
+ // The name button that opened the list is gone; the search, or the list itself, takes the focus.
177
+ queueMicrotask(focusList);
178
+ };
179
+ const onListKey = (event: KeyboardEvent) => {
180
+ const list = visible();
181
+ if (event.key === "Escape") {
182
+ event.preventDefault();
183
+ event.stopPropagation();
184
+ setStep("card");
185
+ queueMicrotask(() => document.getElementById(sliderId)?.focus());
186
+ return;
187
+ }
188
+ // The back button keeps its own keys.
189
+ if (event.target instanceof HTMLButtonElement) return;
190
+ const moves: Record<string, number> = { ArrowDown: 1, ArrowUp: -1 };
191
+ if (event.key in moves) {
192
+ event.preventDefault();
193
+ moveTo(Math.max(0, Math.min(list.length - 1, active() + moves[event.key]!)));
194
+ } else if ((event.key === "Home" || event.key === "End") && !(event.target instanceof HTMLInputElement)) {
195
+ event.preventDefault();
196
+ moveTo(event.key === "Home" ? 0 : list.length - 1);
197
+ } else if (event.key === "Enter") {
198
+ const option = list[active()];
199
+ if (option) {
200
+ event.preventDefault();
201
+ choose(option);
202
+ }
203
+ }
204
+ };
205
+
206
+ const label = () => {
207
+ if (props.state.status === "loading") return "";
208
+ if (props.state.status === "error") return t().chipNotLoaded;
209
+ if (!single() && choice()?.class) return aiModelClassLabel(locale(), choice()!.class!);
210
+ return current()?.label ?? t().chipNone;
211
+ };
212
+ const ariaLabel = () =>
213
+ [
214
+ t().model,
215
+ choice()?.class ? aiModelClassLabel(locale(), choice()!.class!) : "",
216
+ current() ? optionName(current()!) : "",
217
+ issue() ?? "",
218
+ ]
219
+ .filter(Boolean)
220
+ .join(", ");
221
+ /** A class whose model is gone still opens the card, so the person can pick another. */
222
+ const disabled = () => props.state.status !== "ready" || (!current() && !choice()?.class) || props.locked;
223
+ /** The stop the card shows: the class that answers, or none for a model outside the classes ("Own choice"). */
224
+ const cardClass = (): AiModelClass | null => {
225
+ const modelClass = choice()?.class ?? null;
226
+ return modelClass && stops().some((stop) => stop.class === modelClass) ? modelClass : null;
227
+ };
228
+ const note = () => {
229
+ const resolved = choice();
230
+ if (!resolved?.fellBack || !resolved.requested || !resolved.class) return undefined;
231
+ return t().classMissing({
232
+ chosen: aiModelClassLabel(locale(), resolved.requested),
233
+ answers: aiModelClassLabel(locale(), resolved.class),
234
+ });
235
+ };
236
+
237
+ return (
238
+ <>
239
+ <button
240
+ ref={chip}
241
+ type="button"
242
+ class="ai-model-chip group inline-flex h-8 w-[148px] flex-none items-center justify-end border-0 bg-transparent p-0 outline-none"
243
+ aria-haspopup={single() ? undefined : "dialog"}
244
+ aria-expanded={single() ? undefined : anchor() !== null}
245
+ aria-label={ariaLabel() || t().chooseModel}
246
+ aria-busy={props.state.status === "loading" ? "true" : undefined}
247
+ title={props.locked ? props.lockedReason : current() ? optionName(current()!) : undefined}
248
+ disabled={disabled() || single()}
249
+ data-tone={issue() ? "warning" : undefined}
250
+ onClick={(event) => setAnchor(anchor() ? null : event.currentTarget)}
251
+ >
252
+ <span
253
+ class="inline-flex h-8 max-w-full min-w-0 items-center gap-1.5 rounded-[9px] px-1.5 text-[13px] text-(--k2b-text-secondary) group-hover:bg-(--k2b-surface-muted) group-hover:text-(--k2b-text) group-focus-visible:outline-2 group-focus-visible:outline-offset-1 group-focus-visible:outline-(--k2b-focus-ring) group-aria-expanded:bg-(--k2b-surface-muted) group-disabled:opacity-55 group-disabled:group-hover:bg-transparent"
254
+ classList={{ "opacity-100!": single() && Boolean(current()) }}
255
+ >
256
+ <Show
257
+ when={props.state.status !== "loading"}
258
+ fallback={
259
+ <>
260
+ <span class="size-5 animate-pulse rounded-[6px] bg-(--k2b-surface-muted)" />
261
+ <span class="h-3 w-16 animate-pulse rounded bg-(--k2b-surface-muted)" />
262
+ </>
263
+ }
264
+ >
265
+ <span class="relative inline-grid flex-none">
266
+ <Show when={current()} fallback={<i class="ti ti-sparkles text-sm" aria-hidden="true" />}>
267
+ {(option) => <AiModelIcon icon={option().icon} size="xs" />}
268
+ </Show>
269
+ <Show when={issue()}>
270
+ <span class="absolute -top-[3px] -right-[3px] size-[9px] rounded-full bg-(--k2b-warning-text) shadow-[0_0_0_2px_var(--k2b-surface)]" />
271
+ </Show>
272
+ </span>
273
+ <span class="min-w-0 truncate" classList={{ "text-(--k2b-warning-text)": Boolean(issue()) }}>
274
+ {label()}
275
+ </span>
276
+ <Show when={!single()}>
277
+ <i class="ti ti-chevron-down text-xs" aria-hidden="true" />
278
+ </Show>
279
+ </Show>
280
+ </span>
281
+ </button>
282
+ <AnchoredPopover
283
+ anchor={anchor()}
284
+ onClose={close}
285
+ label={t().chooseModel}
286
+ align="end"
287
+ plannedHeight={520}
288
+ width="21.25rem"
289
+ initialFocus={() => document.getElementById(sliderId)}
290
+ >
291
+ <Show
292
+ when={step() === "card"}
293
+ fallback={
294
+ <div class="grid" role="group" aria-label={t().allModels} onKeyDown={onListKey}>
295
+ <div class="flex items-center gap-1 px-2 pt-2">
296
+ <IconButton
297
+ type="button"
298
+ variant="ghost"
299
+ size="sm"
300
+ label={t().back}
301
+ onClick={() => {
302
+ setStep("card");
303
+ queueMicrotask(() => document.getElementById(sliderId)?.focus());
304
+ }}
305
+ >
306
+ <i class="ti ti-chevron-left" aria-hidden="true" />
307
+ </IconButton>
308
+ <span class="text-sm font-semibold">{t().allModels}</span>
309
+ </div>
310
+ <Show when={ordered().length >= 8 || query()}>
311
+ <div class="px-2.5 pt-2">
312
+ <TextInput
313
+ id={searchId}
314
+ aria-label={t().search}
315
+ aria-controls={listId}
316
+ aria-activedescendant={visible()[active()] ? `${sliderId}-o${active()}` : undefined}
317
+ type="search"
318
+ icon="ti ti-search"
319
+ value={query}
320
+ onValueChange={(value) => {
321
+ setQuery(value);
322
+ setActive(0);
323
+ }}
324
+ placeholder={t().search}
325
+ />
326
+ </div>
327
+ </Show>
328
+ <div
329
+ class="max-h-[356px] overflow-auto overscroll-contain px-1.5 pt-1 pb-2"
330
+ id={listId}
331
+ role="listbox"
332
+ aria-label={t().chooseModel}
333
+ tabIndex={ordered().length >= 8 || query() ? -1 : 0}
334
+ aria-activedescendant={visible()[active()] ? `${sliderId}-o${active()}` : undefined}
335
+ >
336
+ <Show
337
+ when={visible().length}
338
+ fallback={
339
+ <div class="flex items-center justify-between gap-2 px-2 py-3 text-sm text-(--k2b-text-muted)">
340
+ {t().noMatch}
341
+ <Button type="button" variant="ghost" size="sm" onClick={() => setQuery("")}>
342
+ {t().clearSearch}
343
+ </Button>
344
+ </div>
345
+ }
346
+ >
347
+ <For each={visible()}>
348
+ {(option, index) => (
349
+ <div
350
+ id={`${sliderId}-o${index()}`}
351
+ role="option"
352
+ aria-selected={option.id === current()?.id}
353
+ aria-disabled={option.unavailable ? "true" : undefined}
354
+ class="grid min-h-11 cursor-pointer grid-cols-[1.25rem_minmax(0,1fr)_1rem] items-center gap-2.5 rounded-lg px-2 py-1.5"
355
+ classList={{
356
+ "bg-(--k2b-surface-muted)": index() === active(),
357
+ "cursor-default opacity-60": Boolean(option.unavailable),
358
+ }}
359
+ onPointerMove={() => setActive(index())}
360
+ onClick={() => choose(option)}
361
+ onKeyDown={(event) => {
362
+ if (event.key === "Enter") choose(option);
363
+ }}
364
+ >
365
+ <AiModelIcon icon={option.icon} size="xs" />
366
+ <span class="grid min-w-0">
367
+ <span class="text-[13.5px] font-medium">{optionName(option)}</span>
368
+ <span
369
+ class="text-xs"
370
+ classList={{
371
+ "text-(--k2b-text-muted)": !option.unavailable,
372
+ "text-(--k2b-warning-text)": Boolean(option.unavailable),
373
+ }}
374
+ >
375
+ {subtitle(option)}
376
+ </span>
377
+ </span>
378
+ <Show when={option.id === current()?.id}>
379
+ <i class="ti ti-check text-sm text-(--k2b-action)" aria-hidden="true" />
380
+ </Show>
381
+ </div>
382
+ )}
383
+ </For>
384
+ </Show>
385
+ </div>
386
+ </div>
387
+ }
388
+ >
389
+ <div
390
+ class="px-3.5 pt-3 pb-4"
391
+ role="group"
392
+ aria-label={t().chooseModel}
393
+ onKeyDown={(event) => {
394
+ if (event.key === "Escape") {
395
+ event.preventDefault();
396
+ close();
397
+ chip?.focus();
398
+ }
399
+ }}
400
+ >
401
+ <AiModelCard
402
+ stops={stops()}
403
+ value={cardClass()}
404
+ own={current() ? { name: optionName(current()!), icon: current()!.icon, description: current()!.description } : undefined}
405
+ issue={issue()}
406
+ note={note()}
407
+ onChoose={(modelClass) => change({ class: modelClass })}
408
+ onOpenList={(models()?.options.length ?? 0) > 1 ? openList : undefined}
409
+ sliderId={sliderId}
410
+ size={typeof matchMedia === "function" && matchMedia("(max-width: 47.999rem)").matches ? "lg" : "md"}
411
+ />
412
+ </div>
413
+ </Show>
414
+ </AnchoredPopover>
415
+ </>
416
+ );
417
+ }
418
+
419
+ /**
420
+ * The note above a composer when the chosen model cannot answer, or no model is available: what is wrong and, where
421
+ * Balanced can answer, the one switch. It never moves the composer's controls; the host gives it a fixed place.
422
+ */
423
+ export function AiModelNote(props: {
424
+ state: AiModelChooserState;
425
+ value: AiModelRequest | null;
426
+ onChange: (request: AiModelRequest) => void;
427
+ }): JSX.Element {
428
+ const locale = useLocale();
429
+ const t = () => aiModelChooserMessages.resolve([locale()]).t;
430
+ const models = () => (props.state.status === "ready" ? props.state.models : null);
431
+ const choice = () => (models() ? resolveAiModelChoice(models()!, props.value) : null);
432
+ const balanced = () => models()?.classes.find((stop) => stop.class === "balanced" && !stop.unavailable);
433
+ const note = (): { tone: "info" | "warning" | "danger"; icon: string; text: string; switchable: boolean } | null => {
434
+ const current = models();
435
+ if (!current) return null;
436
+ if (current.options.length === 0) return { tone: "info", icon: "ti ti-info-circle", text: t().noteNoModel, switchable: false };
437
+ const resolved = choice();
438
+ const reason = resolved?.unavailable;
439
+ if (!resolved || !reason) return null;
440
+ const name = resolved.option ? optionName(resolved.option) : resolved.class ? aiModelClassLabel(locale(), resolved.class) : "";
441
+ const switchable = Boolean(balanced()) && choice()?.class !== "balanced";
442
+ if (reason.code === "budget_exhausted")
443
+ return {
444
+ tone: "warning",
445
+ icon: "ti ti-gauge",
446
+ text: t().noteBudget({
447
+ name,
448
+ time: new Intl.DateTimeFormat(locale(), { weekday: "long", hour: "2-digit", minute: "2-digit" }).format(
449
+ new Date(reason.resetsAt),
450
+ ),
451
+ }),
452
+ switchable,
453
+ };
454
+ if (reason.code === "access_revoked") return { tone: "danger", icon: "ti ti-lock", text: t().noteRevoked({ name }), switchable };
455
+ return { tone: "warning", icon: "ti ti-plug-x", text: t().noteUnavailable({ name }), switchable };
456
+ };
457
+ return (
458
+ <Show when={note()}>
459
+ {(current) => (
460
+ <NoticeCard tone={current().tone} icon={current().icon} title={current().text} role="status">
461
+ <Show when={current().switchable}>
462
+ <div>
463
+ <Button type="button" variant="secondary" size="sm" onClick={() => props.onChange({ class: "balanced" })}>
464
+ {t().switchTo({ name: aiModelClassLabel(locale(), "balanced") })}
465
+ </Button>
466
+ </div>
467
+ </Show>
468
+ </NoticeCard>
469
+ )}
470
+ </Show>
471
+ );
472
+ }
473
+
474
+ /** Whether the chosen model holds a draft back: no model at all, a used-up budget, or a model no longer allowed. */
475
+ export const aiModelBlocksSending = (state: AiModelChooserState, value: AiModelRequest | null): boolean => {
476
+ if (state.status !== "ready") return false;
477
+ if (state.models.options.length === 0) return true;
478
+ const reason = resolveAiModelChoice(state.models, value).unavailable;
479
+ return reason?.code === "budget_exhausted" || reason?.code === "access_revoked";
480
+ };
481
+
482
+ const NONE = "__none";
483
+
484
+ /**
485
+ * A model setting outside the composer, such as a Project's default: no default, one of the assigned classes, or a
486
+ * model outside them, in the same words as the card. A class follows the administration's assignment.
487
+ */
488
+ export function AiModelField(props: {
489
+ label: string;
490
+ description?: string;
491
+ noneLabel: string;
492
+ state: AiModelChooserState;
493
+ value: AiModelRequest | null;
494
+ onChange: (request: AiModelRequest | null) => void;
495
+ onRetry?: () => void;
496
+ disabled?: boolean;
497
+ }): JSX.Element {
498
+ const locale = useLocale();
499
+ const t = () => aiModelChooserMessages.resolve([locale()]).t;
500
+ const models = () => (props.state.status === "ready" ? props.state.models : null);
501
+ const items = () => {
502
+ const current = models();
503
+ if (!current) return [];
504
+ const classes = current.classes.map((stop) => {
505
+ const option = current.options.find((entry) => entry.id === stop.variantId);
506
+ const reason = stop.unavailable ?? option?.unavailable;
507
+ return {
508
+ value: `class:${stop.class}`,
509
+ label: aiModelClassLabel(locale(), stop.class),
510
+ description: reason ? aiModelUnavailableText(locale(), reason) : aiModelClassSubtitle(locale(), stop.class),
511
+ section: "classes",
512
+ };
513
+ });
514
+ const others = current.options
515
+ .filter((option) => !option.classes.some((modelClass) => current.classes.some((stop) => stop.class === modelClass)))
516
+ .map((option) => ({
517
+ value: `id:${option.id}`,
518
+ label: optionName(option),
519
+ description: option.unavailable ? aiModelUnavailableText(locale(), option.unavailable) : option.description,
520
+ section: "models",
521
+ }));
522
+ const listed = [{ value: NONE, label: props.noneLabel, section: "none" }, ...classes, ...others];
523
+ // A saved default stays visible as it is, also a model inside a class or a class that is not set up right now.
524
+ const saved = props.value;
525
+ if (!saved || listed.some((item) => item.value === value())) return listed;
526
+ if ("class" in saved) {
527
+ const answers = resolveAiModelChoice(current, saved).class;
528
+ return [
529
+ ...listed,
530
+ {
531
+ value: value(),
532
+ label: aiModelClassLabel(locale(), saved.class),
533
+ description: answers
534
+ ? t().classMissing({ chosen: aiModelClassLabel(locale(), saved.class), answers: aiModelClassLabel(locale(), answers) })
535
+ : t().unavailable,
536
+ section: "classes",
537
+ },
538
+ ];
539
+ }
540
+ const option = current.options.find((entry) => entry.id === saved.id);
541
+ return [
542
+ ...listed,
543
+ {
544
+ value: value(),
545
+ label: option ? optionName(option) : saved.id,
546
+ description: option
547
+ ? option.unavailable
548
+ ? aiModelUnavailableText(locale(), option.unavailable)
549
+ : option.description
550
+ : t().unavailable,
551
+ section: "models",
552
+ },
553
+ ];
554
+ };
555
+ const value = () => (props.value ? ("class" in props.value ? `class:${props.value.class}` : `id:${props.value.id}`) : NONE);
556
+ return (
557
+ <RichSelect
558
+ label={props.label}
559
+ description={props.description}
560
+ items={items()}
561
+ sections={[
562
+ { id: "none", label: props.noneLabel },
563
+ { id: "classes", label: t().classes },
564
+ { id: "models", label: t().allModels },
565
+ ]}
566
+ status={props.state.status === "loading" ? "loading" : props.state.status === "error" ? "error" : "ready"}
567
+ onRetry={props.onRetry}
568
+ value={value}
569
+ disabled={props.disabled}
570
+ onValueChange={(next) => {
571
+ if (!next || next === NONE) return props.onChange(null);
572
+ const modelClass = AI_MODEL_CLASSES.find((entry) => `class:${entry}` === next);
573
+ props.onChange(modelClass ? { class: modelClass } : { id: next.slice(3) });
574
+ }}
575
+ />
576
+ );
577
+ }