@k2b/cloud 0.42.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.
- package/package.json +13 -5
- package/scripts/app-favicon.ts +7 -6
- package/scripts/build.ts +3 -0
- package/scripts/installable-assets.ts +61 -0
- package/scripts/preload.ts +3 -0
- package/src/_internal/app-access-path.ts +12 -0
- package/src/_internal/app-access-request.ts +13 -0
- package/src/_internal/app-access-resources.ts +141 -0
- package/src/_internal/app-event-catalog.ts +40 -0
- package/src/_internal/app-event-schemas.ts +127 -0
- package/src/_internal/application-package.ts +40 -0
- package/src/_internal/built-in-app-ids.json +27 -0
- package/src/_internal/capabilities.ts +185 -12
- package/src/_internal/capability-origin.ts +4 -0
- package/src/_internal/capability-streams.ts +10 -5
- package/src/_internal/define-app.ts +159 -45
- package/src/_internal/installable-assets.ts +90 -0
- package/src/_internal/installable-head.ts +22 -0
- package/src/_internal/object-store-area.ts +9 -0
- package/src/_internal/object-store-config.ts +101 -0
- package/src/_internal/object-store-key.ts +14 -0
- package/src/_internal/page-responses.ts +34 -3
- package/src/_internal/process-identity.ts +7 -2
- package/src/_internal/registry-validation.ts +15 -1
- package/src/_internal/registry.ts +44 -1
- package/src/_internal/runtime-context.ts +9 -1
- package/src/_internal/runtime-watcher.ts +8 -0
- package/src/_internal/status-preserving-ssr.ts +12 -1
- package/src/access/AppAccessEditor.browser-fixture.ts +429 -0
- package/src/access/AppAccessEditor.browser-frame.tsx +68 -0
- package/src/access/AppAccessEditor.browser-harness.tsx +65 -0
- package/src/access/AppAccessEditor.tsx +380 -0
- package/src/access/PermissionEditor.browser-harness.tsx +22 -2
- package/src/access/PermissionEditor.tsx +386 -77
- package/src/access/PrincipalAvatar.tsx +63 -0
- package/src/access/PrincipalPicker.tsx +29 -8
- package/src/access/ResourceApiKeys.tsx +3 -4
- package/src/access/app-access-adapter.ts +51 -0
- package/src/access/app-access-messages.ts +172 -0
- package/src/access/messages.ts +14 -0
- package/src/access/ui.ts +4 -1
- package/src/account/Avatar.tsx +21 -3
- package/src/account/ProfilePictureDialog.tsx +699 -0
- package/src/account/profile-picture-messages.ts +269 -0
- package/src/account/ui.ts +8 -1
- package/src/ai/admin.ts +3 -0
- package/src/ai/app-frame.ts +12 -0
- package/src/ai/application-tasks.ts +101 -0
- package/src/ai/assistant-models.ts +126 -64
- package/src/ai/capabilities.ts +19 -4
- package/src/ai/capability-execution.ts +5 -2
- package/src/ai/chart-block.ts +63 -30
- package/src/ai/chat/chart-block.tsx +12 -3
- package/src/ai/chat/composer-adapter.ts +8 -7
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +2 -2
- package/src/ai/chat/presentation.tsx +22 -1
- package/src/ai/chat-quotas.ts +5 -4
- package/src/ai/chat-tasks.ts +2 -2
- package/src/ai/client/controller.ts +11 -10
- package/src/ai/client/projection.ts +3 -3
- package/src/ai/code-capability-routes.ts +1 -0
- package/src/ai/compaction.ts +3 -3
- package/src/ai/credentials.ts +13 -114
- package/src/ai/default-tool-contracts.ts +4 -3
- package/src/ai/dictation-runtime.ts +4 -4
- package/src/ai/enrich.ts +2 -2
- package/src/ai/executor.ts +38 -27
- package/src/ai/http.ts +17 -29
- package/src/ai/index.ts +11 -5
- package/src/ai/inference-calls.ts +6 -4
- package/src/ai/maintenance.ts +1 -1
- package/src/ai/memory-learning-runs.ts +5 -5
- package/src/ai/memory-learning.ts +3 -3
- package/src/ai/message-queue.ts +1 -2
- package/src/ai/migrate.ts +75 -42
- package/src/ai/model-access-migrate.ts +3 -17
- package/src/ai/model-access.ts +44 -85
- package/src/ai/model-card.tsx +134 -0
- package/src/ai/model-catalog.ts +359 -0
- package/src/ai/model-chooser-messages.ts +88 -0
- package/src/ai/model-chooser.tsx +577 -0
- package/src/ai/model-delete.ts +234 -0
- package/src/ai/model-fixture.ts +39 -0
- package/src/ai/model-icon.tsx +36 -0
- package/src/ai/model-overview.ts +53 -0
- package/src/ai/model-usage.ts +45 -0
- package/src/ai/openai-api-messages.ts +0 -4
- package/src/ai/openai-api-slots.ts +10 -2
- package/src/ai/openai-api.ts +83 -16
- package/src/ai/personal-agent.ts +1 -1
- package/src/ai/prefs.ts +12 -10
- package/src/ai/projects-routes.ts +2 -1
- package/src/ai/projects.ts +13 -12
- package/src/ai/protocol.ts +2 -2
- package/src/ai/provider-connections.ts +161 -0
- package/src/ai/provider-errors.ts +138 -0
- package/src/ai/provider-fetch.ts +4 -0
- package/src/ai/provider-retry.ts +1 -1
- package/src/ai/provider.ts +2 -1
- package/src/ai/quota-limits.ts +36 -0
- package/src/ai/quota-provider.ts +11 -2
- package/src/ai/quota-report.ts +7 -4
- package/src/ai/quotas-migrate.ts +1 -0
- package/src/ai/quotas.ts +19 -8
- package/src/ai/recent-resources.ts +6 -0
- package/src/ai/request-headers.ts +14 -87
- package/src/ai/resource-refs.ts +6 -2
- package/src/ai/routes.ts +17 -18
- package/src/ai/runtime.ts +50 -22
- package/src/ai/settings.ts +93 -580
- package/src/ai/store.ts +45 -23
- package/src/ai/stream.ts +1 -1
- package/src/ai/structured-runs.ts +2 -2
- package/src/ai/structured.ts +29 -46
- package/src/ai/task-contracts.ts +25 -5
- package/src/ai/task-execution.ts +15 -6
- package/src/ai/task-model.ts +9 -0
- package/src/ai/transcription.ts +37 -21
- package/src/ai/turn-failure.ts +24 -2
- package/src/ai/types.ts +50 -62
- package/src/ai/ui.tsx +13 -0
- package/src/ai/usage.ts +8 -8
- package/src/ai/validate.ts +8 -7
- package/src/ai/view-block.ts +2 -2
- package/src/api/accounts-entities.ts +40 -16
- package/src/api/admin-ai-quotas.ts +34 -56
- package/src/api/admin-ai-usage.ts +1 -1
- package/src/api/admin-ai.ts +173 -0
- package/src/api/admin-core-settings.ts +68 -244
- package/src/api/admin-directory-shares.ts +55 -0
- package/src/api/admin-identity.ts +81 -2
- package/src/api/app-access.ts +85 -0
- package/src/api/app-approval.ts +3 -1
- package/src/api/app-event-delivery.ts +51 -0
- package/src/api/app-events.ts +123 -0
- package/src/api/app-resource-access.ts +48 -0
- package/src/api/auth/account-mismatch.ts +24 -0
- package/src/api/auth.ts +26 -3
- package/src/api/capabilities.ts +127 -15
- package/src/api/capability-streams.ts +2 -5
- package/src/api/identity-child-invocation.ts +145 -0
- package/src/api/index.ts +11 -2
- package/src/api/mcp.ts +180 -5
- package/src/api/me.ts +38 -17
- package/src/api/pwa-phone.ts +87 -48
- package/src/api/pwa.ts +34 -8
- package/src/api/resource-links.ts +83 -6
- package/src/api/resource-resolve.ts +50 -0
- package/src/api/search/schemas.ts +3 -1
- package/src/api/search.ts +9 -7
- package/src/api/widgets.ts +1 -0
- package/src/app-pwa/authority.ts +75 -0
- package/src/app-pwa/contracts.ts +177 -0
- package/src/app-pwa/core-query-error.ts +20 -0
- package/src/app-pwa/index.ts +23 -0
- package/src/app-pwa/protocol.ts +27 -0
- package/src/app-pwa/schema.ts +59 -0
- package/src/app-pwa/service.ts +861 -0
- package/src/app-pwa/transaction.ts +338 -0
- package/src/app-pwa/transport-input.ts +57 -0
- package/src/app-pwa/transport.ts +235 -0
- package/src/approvals/messages.ts +2 -2
- package/src/browser/app-access-recovery.ts +44 -0
- package/src/browser/app-session.ts +14 -3
- package/src/browser/cloud-id.ts +86 -0
- package/src/browser/live-websocket.ts +5 -0
- package/src/browser/pwa-drafts.ts +57 -0
- package/src/browser/resource-clipboard.ts +4 -0
- package/src/browser/resource-links.ts +52 -0
- package/src/browser/resource-picker.tsx +3 -2
- package/src/capabilities/calendar-sources.ts +302 -0
- package/src/capabilities/executions.ts +10 -3
- package/src/capabilities/migrate.ts +4 -0
- package/src/capabilities/nested.ts +43 -0
- package/src/capabilities/server-invoke.ts +202 -0
- package/src/capabilities/server.ts +26 -187
- package/src/capabilities/testing.ts +5 -2
- package/src/cli/access.ts +6 -5
- package/src/cli/account.ts +57 -0
- package/src/cli/admin/agents.ts +66 -0
- package/src/cli/admin/ai-models.ts +199 -0
- package/src/cli/admin/ai-quotas.ts +6 -102
- package/src/cli/admin/ai-usage.ts +4 -4
- package/src/cli/admin/app-credentials.ts +5 -3
- package/src/cli/admin/index.ts +7 -3
- package/src/cli/app-access.ts +60 -0
- package/src/cli/apps.ts +89 -26
- package/src/cli/avatar.ts +64 -0
- package/src/cli/capabilities.ts +22 -1
- package/src/cli/events.ts +320 -0
- package/src/cli/index.ts +1 -1
- package/src/config/env.ts +40 -0
- package/src/contracts/app-access.ts +52 -5
- package/src/contracts/app-approval.ts +8 -1
- package/src/contracts/app-details.ts +89 -0
- package/src/contracts/app-events.ts +51 -0
- package/src/contracts/app.ts +24 -5
- package/src/contracts/calendar-source.ts +274 -0
- package/src/contracts/calendar-view.ts +35 -0
- package/src/contracts/capabilities.ts +164 -3
- package/src/contracts/geo-admin.ts +2 -0
- package/src/contracts/geo-route.ts +0 -11
- package/src/contracts/geo.ts +14 -2
- package/src/contracts/index.ts +6 -0
- package/src/contracts/installable.ts +40 -0
- package/src/contracts/notification-types.ts +36 -9
- package/src/contracts/profile.ts +32 -0
- package/src/contracts/pwa-builds.ts +14 -0
- package/src/contracts/pwa-location.ts +38 -0
- package/src/contracts/pwa.ts +24 -3
- package/src/contracts/registry.ts +8 -0
- package/src/contracts/resource-clipboard.ts +35 -0
- package/src/contracts/resource-pages.ts +140 -0
- package/src/contracts/resource-resolution.ts +13 -0
- package/src/contracts/shared.ts +4 -0
- package/src/contracts/user-notifications.ts +2 -0
- package/src/events/app-event-delivery.ts +100 -0
- package/src/events/app-events.testing.ts +65 -0
- package/src/events/app-events.ts +134 -0
- package/src/events/core-user-created.ts +24 -0
- package/src/events/index.ts +9 -0
- package/src/events/testing.ts +2 -0
- package/src/index.ts +2 -1
- package/src/server/api/respond.ts +8 -3
- package/src/server/index.ts +1 -0
- package/src/server/middleware/app-access.ts +7 -6
- package/src/server/middleware/auth.ts +168 -66
- package/src/server/middleware/index.ts +1 -0
- package/src/server/middleware/invocation.ts +4 -2
- package/src/server/middleware/workload.ts +3 -3
- package/src/server/oauth-scope.ts +10 -0
- package/src/server/public-storage.ts +106 -0
- package/src/server/services/access.ts +8 -0
- package/src/services/accounts/app.ts +35 -3
- package/src/services/accounts/avatar.ts +342 -35
- package/src/services/accounts/base-group.ts +4 -5
- package/src/services/accounts/directory-shares.ts +157 -0
- package/src/services/accounts/entities.ts +50 -6
- package/src/services/accounts/groups.ts +3 -1
- package/src/services/accounts/identities.ts +64 -12
- package/src/services/accounts/local-groups.ts +1 -1
- package/src/services/accounts/users.ts +23 -15
- package/src/services/app-access-management.ts +519 -0
- package/src/services/app-access.ts +25 -7
- package/src/services/app-approval.ts +15 -4
- package/src/services/app-details.ts +224 -0
- package/src/services/app-discovery.ts +2 -1
- package/src/services/app-event-shapes.ts +139 -0
- package/src/services/app-events/accept.ts +116 -0
- package/src/services/app-events/admin.ts +250 -0
- package/src/services/app-events/declarations.ts +187 -0
- package/src/services/app-events/deliver.ts +218 -0
- package/src/services/app-events.ts +92 -0
- package/src/services/config-conflict.ts +11 -0
- package/src/services/core-user-events.ts +1 -0
- package/src/services/freeipa-config.ts +9 -0
- package/src/services/geo/admin.ts +4 -1
- package/src/services/geo/ledger.ts +47 -14
- package/src/services/geo/route-chain.ts +40 -18
- package/src/services/geo/route-estimate.ts +19 -0
- package/src/services/geo/route-plan.ts +0 -2
- package/src/services/geo/route-service.ts +57 -39
- package/src/services/geo/service.ts +51 -25
- package/src/services/identity/core-origin.ts +12 -0
- package/src/services/identity/index.ts +2 -1
- package/src/services/identity/invocation-actor.ts +50 -2
- package/src/services/identity/invocation-authority.ts +38 -1
- package/src/services/identity/invocation-token.ts +255 -50
- package/src/services/identity/issue-invocation.ts +9 -1
- package/src/services/identity/models-only-auth.ts +13 -4
- package/src/services/identity/nested-invocation.ts +128 -0
- package/src/services/image-metadata.ts +65 -1
- package/src/services/index.ts +24 -1
- package/src/services/ipa/sync.ts +6 -2
- package/src/services/ipa/users.ts +4 -1
- package/src/services/mandates/policy.ts +19 -12
- package/src/services/notifications/browser.ts +4 -0
- package/src/services/notifications/catalog.ts +5 -2
- package/src/services/notifications/platform.ts +7 -2
- package/src/services/notifications/user.ts +18 -7
- package/src/services/oauth-tokens.ts +28 -2
- package/src/services/object-store.ts +217 -0
- package/src/services/providers/local/users.ts +5 -0
- package/src/services/pwa-devices.ts +147 -37
- package/src/services/service-account-credentials.ts +5 -2
- package/src/services/session/index.ts +14 -0
- package/src/services/settings/core-settings.ts +91 -47
- package/src/services/settings/index.ts +8 -0
- package/src/services/settings/versions.ts +26 -0
- package/src/shared/ai-models.ts +274 -0
- package/src/shared/ai-quotas.ts +2 -2
- package/src/shared/ai-usage.ts +2 -2
- package/src/shared/index.ts +2 -0
- package/src/shared-text/documents.ts +25 -8
- package/src/shared-text/log.ts +1 -1
- package/src/shared-text/text-diff.ts +75 -0
- package/src/ssr/AdminArea.tsx +131 -0
- package/src/ssr/AdminLayout.tsx +11 -3
- package/src/ssr/AdminSidebar.tsx +7 -7
- package/src/ssr/AppAccessDenied.tsx +114 -0
- package/src/ssr/AppBadge.tsx +14 -0
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/AppPwaRuntime.island.tsx +82 -0
- package/src/ssr/CloudShell.island.tsx +25 -4
- package/src/ssr/Layout.tsx +15 -7
- package/src/ssr/PwaLayout.tsx +73 -8
- package/src/ssr/PwaPartCount.island.tsx +23 -0
- package/src/ssr/PwaRuntime.island.tsx +85 -6
- package/src/ssr/SettingsSaveOutcome.tsx +58 -0
- package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
- package/src/ssr/ShellRuntime.island.tsx +5 -0
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +1 -1
- package/src/ssr/admin-active-link.ts +16 -0
- package/src/ssr/admin-navigation.ts +26 -20
- package/src/ssr/app-badges.ts +31 -7
- package/src/ssr/app-pwa-document.tsx +16 -0
- package/src/ssr/app-pwa-messages.ts +17 -0
- package/src/ssr/index.ts +8 -0
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +3 -1
- package/src/ssr/platform-messages.ts +9 -1
- package/src/ssr/pwa-messages.ts +10 -2
- package/src/ssr/rail-navigation.ts +5 -1
- package/src/ssr/settings-draft.ts +153 -0
- package/src/ssr/shell-data.tsx +2 -0
- package/src/styles/global.css +1 -0
- package/src/styles/profile-picture.css +270 -0
- package/src/styles/utilities-navigation.css +6 -0
- package/src/workflows/ai/policy.ts +49 -0
- package/src/workflows/ai/runtime.ts +131 -21
- package/src/workflows/ai/store.ts +283 -35
- package/src/workflows/ai/types.ts +8 -2
- package/src/workflows/ai-actions.ts +148 -120
- package/src/workflows/authoring.ts +31 -0
- package/src/workflows/contracts.ts +1 -0
- package/src/workflows/definition.ts +2 -0
- package/src/workflows/editor.ts +15 -0
- package/src/workflows/index.ts +2 -1
- package/src/workflows/language/compiler.ts +34 -3
- package/src/workflows/runtime/executor.ts +132 -33
- package/src/workflows/runtime/index.ts +2 -0
- package/src/workflows/runtime/ports.ts +5 -1
- package/src/workflows/store/actions.ts +1 -1
- package/src/workflows/store/definitions.ts +5 -0
- package/src/workflows/store/runs.ts +5 -3
- package/src/workflows/store/worker.ts +7 -2
- package/src/ai/model-pricing.ts +0 -43
- package/src/ai/model-request-settings.ts +0 -98
- package/src/ai/settings-messages.ts +0 -129
- 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
|
+
}
|